끝을 보는 용기

Day 121 - 게임 아이템 거래소 프로젝트 55%, 한 사용자가 연속으로 입찰하지 못하도록 막아야 할까?

writingforever162 2025. 2. 4. 23:39

1. 프로젝트 진행 상황 및 계획

🥇 동시성 문제를 검증할 수 있는 테스트 코드 작성하기 - 락(Lock)이 없을 때 (완료)
🥈 낙관적 락 사용하기 (진행 중, 25.02.05 완료 목표)

🥉 비관적 락 사용하기 (진행 중, 25.02.05 완료 목표)
4️⃣ 동시성 문제를 검증할 수 있는 테스트 코드 작성하기 - 낙관적 락 적용 후 (완료)

5️⃣ 동시성 문제를 검증할 수 있는 테스트 코드 작성하기 - 비관적 락 적용 후 (완료)

6️⃣ 동시성 제어 관련 공부하기 (진행 중, 25.02.06 완료 목표)

 

2. 한 사용자가 연속으로 입찰하지 못하도록 막아야 할까?

고민 끝에 우리 팀의 게임 아이템 거래소에서는 한 사용자가 연속으로 입찰하지 못하도록 했다. '사용자 A-B-A' 순으로는 가능해도 'A-A-B' 순으로는 입찰할 수 없도록 예외 처리했다. 

private void checkIfBidderIsSameAsPrevious(long newBidderId) {
    if (this.user.getId().equals(newBidderId)) {
        throw new CustomException(ErrorCode.CONSECUTIVE_BID_NOT_ALLOWED);
    }
}

연속 입찰을 막은 이유를 정리할 때는, 한 사용자가 '아이템 판매' 대신 '아이템 경매'를 선택했을 때 그 이유를 생각해 보았다. 아이템 판매는 원하는 금액만 맞추면 거래가 이루어지니까 빠른 자금 흐름을 원할 때 아이템을 판다고 생각했다. 반대로 경매는 '많은 사람이 참여해서 아이템 값이 예상보다 훨씬 올라가면 좋겠다'라는 생각으로 참여한다고봤다. 이러한 심리를 고려했을 때, 만약 한 사용자가 연속으로 입찰한다면, 입찰 기회가 몇 명에게만 몰려서 경매의 본래 목적을 해칠 수 있었다. 다양한 사용자가 참여할 수 있도록 연속 입찰에 예외 처리가 필요했다. 

더보기
package no.gunbang.market.domain.auction.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import no.gunbang.market.common.BaseEntity;
import no.gunbang.market.common.exception.CustomException;
import no.gunbang.market.common.exception.ErrorCode;
import no.gunbang.market.domain.user.entity.User;
import org.hibernate.annotations.Comment;

@Entity
@Getter
@Table(name = "bid")
@NoArgsConstructor
public class Bid extends BaseEntity {

    @Comment("입찰 식별자")
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(columnDefinition = "BIGINT")
    private Long id;

    @Comment("입찰 가격")
    private long bidPrice;

    @Comment("마지막 입찰 성공 시간")
    private LocalDateTime updatedAt;

    @Comment("경매 외래키")
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "auction_id")
    private Auction auction;

    @Comment("사용자 외래키")
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    public static Bid of(
        User user,
        Auction auction,
        long bidPrice
    ) {
        validateAuctionNotExpired(auction);
        validateNewBid(auction, bidPrice);
        validateUserGold(user, bidPrice);

        Bid bid = new Bid();
        bid.user = user;
        bid.auction = auction;
        bid.bidPrice = bidPrice;
        return bid;
    }

    public void updateBid(
        long bidPrice,
        User user
    ) {
        validateAuctionNotExpired(this.auction);
        validateBidUpdate(bidPrice);
        validateUserGold(user, bidPrice);
        checkIfBidderIsSameAsPrevious(user.getId());

        this.bidPrice = bidPrice;
        this.user = user;
        this.updatedAt = LocalDateTime.now();
    }

    // 새로운 입찰 생성 시 최소 입찰 가격 검증
    private static void validateNewBid(
        Auction auction,
        long bidPrice
    ) {
        if (bidPrice < auction.getStartingPrice()) {
            throw new CustomException(ErrorCode.LACK_OF_GOLD);
        }
    }

    // 기존 입찰이 있을 때, 기존의 입찰 가격보다 같거나 낮은지 검증
    private void validateBidUpdate(long newBidPrice) {
        if (newBidPrice <= this.bidPrice) {
            throw new CustomException(ErrorCode.BID_TOO_LOW);
        }
    }

    // 동일한 사용자가 연속으로 입찰하는지 검증
    private void checkIfBidderIsSameAsPrevious(long newBidderId) {
        if (this.user.getId().equals(newBidderId)) {
            throw new CustomException(ErrorCode.CONSECUTIVE_BID_NOT_ALLOWED);
        }
    }

    // 입찰 가격이 보유한 골드보다 많은지 검증
    private static void validateUserGold(
        User user,
        long bidPrice
    ) {
        if (bidPrice > user.getGold()) {
            throw new CustomException(ErrorCode.EXCESSIVE_BID);
        }
    }

    // 경매 마감 시간이 지났는지 검증
    private static void validateAuctionNotExpired(Auction auction) {
        if (LocalDateTime.now().isAfter(auction.getDueDate())) {
            throw new CustomException(ErrorCode.AUCTION_EXPIRED);
        }
    }
}

연속 입찰 검증 또한 입찰할 때 무조건 이루어져야 했으므로 서비스 레이어(Service Layer)에서 검증 메서드(method)를 호출하지 않고 엔티티(Entity) 내부에서 처리했다.

 

3. 테스트 코드 작성이 생각보다 순식간에 끝났다. [깃허브 링크]

더보기
package no.gunbang.market;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.extern.slf4j.Slf4j;
import no.gunbang.market.common.Item;
import no.gunbang.market.common.ItemRepository;
import no.gunbang.market.domain.auction.dto.request.BidAuctionRequestDto;
import no.gunbang.market.domain.auction.entity.Auction;
import no.gunbang.market.domain.auction.repository.AuctionRepository;
import no.gunbang.market.domain.auction.repository.BidRepository;
import no.gunbang.market.domain.auction.service.AuctionService;
import no.gunbang.market.domain.user.entity.User;
import no.gunbang.market.domain.user.repository.UserRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;

@Slf4j
@SpringBootTest
class AuctionServiceConcurrencyTest {

    @Autowired
    private AuctionService auctionService;

    @Autowired
    private AuctionRepository auctionRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private ItemRepository itemRepository;

    @Autowired
    private BidRepository bidRepository;

    Auction auction; // 경매
    List<User> userList = new ArrayList<>(); // 사용자 목록
    List<User> bidderList = new ArrayList<>(); // 입찰자 목록

    // 각 테스트 전에 실행되는 설정
    @BeforeEach
    public void beforeEach() {
        // 모든 사용자 목록을 불러옴
        userList = userRepository.findAll();

        // 첫 번째 사용자로 경매 생성자 지정
        final User AUCTION_CREATOR = userList.get(0);
        final int ID_OF_AUCTION_CREATOR = 1;

        int auctionDays = 7; // 경매 기간 설정
        long startingPrice = 10L; // 경매 시작 가격 설정

        // 경매에 입찰할 사용자 목록
        // 경매 생성자는 제외함
        bidderList = userList.subList(
            ID_OF_AUCTION_CREATOR,
            userList.size()
        );

        // 1번 아이템 조회
        Item item = itemRepository.findById(1L).orElseThrow();

        // 경매 객체 생성
        auction = Auction.of(
            AUCTION_CREATOR,
            item,
            startingPrice,
            auctionDays
        );

        auctionRepository.save(auction); // 경매 저장
    }

    // 각 테스트 후 실행되는 설정
    @AfterEach
    public void afterEach() {
        // 경매에 등록된 입찰 전체 삭제
        bidRepository.deleteAllByAuction(auction);
        // 생성된 경매 삭제
        auctionRepository.delete(auction);
    }

    @WithMockUser
    @Test
    @DisplayName("락 없이 입찰 테스트")
    void testBidAuctionWithoutLock() throws InterruptedException {
        // given
        // 초기 입찰자 수
        int initialBidderCount = auction.getBidderCount();

        int totalRequests = 7; // 총 요청 수
        int totalThreads = 3; // 총 스레드 수
        long bidPrice = 20L; // 초기 입찰 금액
        Long auctionId = auction.getId();

        // CountDownLatch를 이용하여 스레드 동기화
        // 모든 스레드가 완료될 때까지 대기
        CountDownLatch latch = new CountDownLatch(totalRequests);

        // 스레드 풀 생성
        ExecutorService executorService = Executors.newFixedThreadPool(totalThreads);

        // when
        // 총 요청 수만큼 스레드 실행
        for (int i = 0; i < totalRequests; i++) {
            int bidRequestIndex = i; // 요청 인덱스

            // 입찰 증가 금액
            // 요청마다 증가해야 하므로, 기본 금액에 요청 인덱스를 더함
            long bidIncrement = bidPrice + bidRequestIndex;

            // 각 스레드에서 입찰 요청 실행
            executorService.execute(
                () -> {
                    try {
                        // 입찰자 목록에서 입찰 요청에 해당하는 인덱스
                        // 예시) 입찰자가 총 9명이고 요청이 4번이라면,
                        //      4 % 9 = 4가 되는데,
                        //      인덱스는 0부터 세므로,
                        //      4 → 5번째 입찰자가 선택됨
                        int bidderIndex = bidRequestIndex % bidderList.size();

                        // 입찰자 목록에서 해당 인덱스로 조회한 입찰자
                        User bidder = bidderList.get(bidderIndex);
                        Long bidderId = bidder.getId();

                        // 입찰 요청 DTO 생성
                        BidAuctionRequestDto requestDto = new BidAuctionRequestDto(
                            auctionId,
                            bidIncrement
                        );

                        // 입찰 요청 서비스 호출
                        auctionService.bidAuction(
                            bidderId,
                            requestDto
                        );

                    } finally {
                        // 작업이 끝나면 countDown 호출
                        // 기다리는 스레드 수를 하나씩 차감
                        latch.countDown();
                    }
                }
            );
        }

        // 모든 스레드가 완료될 때까지 대기
        latch.await();

        // 입찰이 완료된 후 경매 정보 다시 조회
        Auction updatedAuction = auctionRepository.findById(auctionId).orElseThrow();

        // then
        // 최종 입찰자 수가 '초기 입찰자 수 + 총 요청 수'와 같은지 확인
        assertThat(updatedAuction.getBidderCount())
            .isEqualTo(initialBidderCount + totalRequests);
    }
}

비즈니스 로직(business logic)을 수정하지 않는 한, 비관적 락(pessimistic lock)과 낙관적 락(optimistic lock)을 각각 사용한 뒤 테스트 코드를 실행하면 동시성 제어 여부를 쉽게 확인할 수 있었다. 처음에는 상황에 맞게 테스트 코드를 매번 작성해야 하나 부담되었는데, 비즈니스 로직이 그대로라면 테스트할 때마다 @DisplayName 어노테이션(annotation)과 메서드 이름만 수정하면 되었다. 내일은 꼭 락 정복에 성공해야겠다.

 

락(Lock)도 락(樂)이다.