끝을 보는 용기

Day 123 - 게임 아이템 거래소 프로젝트 90%, 테스트 코드를 원 없이 작성한 날

writingforever162 2025. 2. 6. 23:47

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

🥇 분산 락 구현하기 (완료 - 팀원이 진행)
🥈 분산 락 구현 이후 테스트 코드 실행하기 (완료 - 팀원이 진행)

🥉 동시성 제어 관련 공부하기 (진행 중, 25.03.17 완료 목표)

 

2. 고민

Q1. 분산 락을 거래소와 경매장 둘 다 적용해야 할까? 

A1. 이 문제를 해결하려면, 거래소와 경매장의 차이를 고려해야 했다. 경매는 아이템 가격이 어디까지 오를지 예측할 수 없어서 진입 장벽이 꽤 높았다. 아이템이 경매장에 올라올 정도면 매우 희귀하단 뜻일 테고, 그만큼 보유한 골드가 부족한 사용자는 애초에 입찰할 생각을 하기가 어려웠다.

 

반대로 거래는 가격에만 만족하면 누구든지 시도할 수 있으므로, 경매장보다 거래소에 훨씬 많은 사용자가 몰릴 수 있었다. 이렇게 예측되는 높은 거래량을 고려하여 분산 락은 거래소에만 적용했다. 분산 락은 메모리 사용을 요구하므로 불필요하게 자원을 낭비할 수 있었기 때문이다.

 

3. 테스트 코드를 원 없이 작성했다.

더보기
package no.gunbang.market.unit.domain.auction.service;

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 java.util.concurrent.atomic.AtomicInteger;
import lombok.extern.slf4j.Slf4j;
import no.gunbang.market.common.entity.Item;
import no.gunbang.market.common.entity.ItemRepository;
import no.gunbang.market.common.exception.CustomException;
import no.gunbang.market.domain.auction.dto.request.BidAuctionRequestDto;
import no.gunbang.market.domain.auction.entity.Auction;
import no.gunbang.market.domain.auction.entity.Bid;
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<>(); // 입찰자 목록
    Bid bid;

    // 각 테스트 전에 실행되는 설정
    @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; // 경매 시작 가격 설정
        long initialBidPrice = 15L; // 생성된 입찰 가격 설정

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

        final User FIRST_BIDDER = bidderList.get(0);

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

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

        bid = Bid.of(
            FIRST_BIDDER,
            auction,
            initialBidPrice
        );

        auctionRepository.save(auction); // 경매 저장
        bidRepository.save(bid); // 입찰 저장
    }

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

    @WithMockUser
    @Test
    @DisplayName("비관적 락 적용 후 입찰 테스트")
    void testBidAuctionWithPessimisticLock() throws InterruptedException {
        // given
        // 초기 입찰자 수
        int initialBidderCount = auction.getBidderCount();

        AtomicInteger totalOccurredExceptions = new AtomicInteger(0);
        // 동시성 문제 때문에 순서대로 계산되지 않을 거라서

        int totalRequests = 100; // 총 요청 수
        int totalThreads = 20; // 총 스레드 수
        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 {
                        log.info("현재 입찰 금액: {}",
                            bidRepository.findById(bid.getId()).get().getBidPrice());

                        // 입찰자 목록에서 입찰 요청에 해당하는 인덱스
                        // 예시) 입찰자가 총 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
                        );

                        log.info("새로운 입찰 금액: {}", bidIncrement);
                    } catch (CustomException e) {
                        log.info("에러 발생 시점의 현재 입찰 금액: {}",
                            bidRepository.findById(bid.getId()).get().getBidPrice());
                        log.info("에러 발생 시점의 새로운 입찰 금액: {}", bidIncrement);

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

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

        // 스레드 풀 종료
        executorService.shutdown();

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

        log.info("초기 요청 수: {}", totalRequests);
        log.info("발생한 예외 총 개수: {}", totalOccurredExceptions);
        log.info("성공한 요청 총 개수: {}", updatedAuction.getBidderCount());

        // then
        // '(초기 입찰자 수 + 총 요청 수) - 발생한 예외 총 개수'가
        // '최종 입찰자 수'와 같은지 확인 
        assertThat(updatedAuction.getBidderCount())
            .isEqualTo((initialBidderCount + totalRequests) - totalOccurredExceptions.get());
    }
}
더보기
package no.gunbang.market.util;

import java.time.LocalDateTime;
import no.gunbang.market.common.entity.Item;
import no.gunbang.market.common.entity.Status;
import no.gunbang.market.domain.auction.entity.Auction;
import no.gunbang.market.domain.auction.entity.Bid;
import no.gunbang.market.domain.user.entity.User;
import org.springframework.test.util.ReflectionTestUtils;

public class TestData {

    public static final User USER_ONE;
    public static final User USER_TWO;
    public static final User USER_THREE;
    public static final Item ITEM;
    public static final Auction AUCTION_BY_USER_ONE;
    public static final Bid BID_BY_USER_TWO;
    public static final long STARTING_PRICE = 9L;
    public static final long BID_PRICE = 10L;
    public static final int MIN_AUCTION_DAYS = 3;
    public static final int MAX_AUCTION_DAYS = 7;

    static {
        USER_ONE = new User();
        ReflectionTestUtils.setField(USER_ONE, "nickname", "one");
        ReflectionTestUtils.setField(USER_ONE, "server", "lostark");
        ReflectionTestUtils.setField(USER_ONE, "level", (short) 100);
        ReflectionTestUtils.setField(USER_ONE, "job", "warrior");
        ReflectionTestUtils.setField(USER_ONE, "gold", 1000000L);
        ReflectionTestUtils.setField(USER_ONE, "email", "one@example.com");
        ReflectionTestUtils.setField(USER_ONE, "password", "1234");
        ReflectionTestUtils.setField(USER_ONE, "id", 1L);

        USER_TWO = new User();
        ReflectionTestUtils.setField(USER_TWO, "nickname", "two");
        ReflectionTestUtils.setField(USER_TWO, "server", "lostark");
        ReflectionTestUtils.setField(USER_TWO, "level", (short) 200);
        ReflectionTestUtils.setField(USER_TWO, "job", "warrior");
        ReflectionTestUtils.setField(USER_TWO, "gold", 2000000L);
        ReflectionTestUtils.setField(USER_TWO, "email", "two@example.com");
        ReflectionTestUtils.setField(USER_TWO, "password", "1234");
        ReflectionTestUtils.setField(USER_TWO, "id", 2L);

        USER_THREE = new User();
        ReflectionTestUtils.setField(USER_THREE, "nickname", "three");
        ReflectionTestUtils.setField(USER_THREE, "server", "lostark");
        ReflectionTestUtils.setField(USER_THREE, "level", (short) 300);
        ReflectionTestUtils.setField(USER_THREE, "job", "warrior");
        ReflectionTestUtils.setField(USER_THREE, "gold", 3000000L);
        ReflectionTestUtils.setField(USER_THREE, "email", "three@example.com");
        ReflectionTestUtils.setField(USER_THREE, "password", "1234");
        ReflectionTestUtils.setField(USER_THREE, "id", 3L);

        ITEM = new Item();
        ReflectionTestUtils.setField(ITEM, "name", "지옥의 황천길에서 온 스크램블");
        ReflectionTestUtils.setField(ITEM, "id", 1L);

        AUCTION_BY_USER_ONE = new Auction();
        ReflectionTestUtils.setField(AUCTION_BY_USER_ONE, "user", USER_ONE);
        ReflectionTestUtils.setField(AUCTION_BY_USER_ONE, "item", ITEM);
        ReflectionTestUtils.setField(AUCTION_BY_USER_ONE, "startingPrice", STARTING_PRICE);
        ReflectionTestUtils.setField(AUCTION_BY_USER_ONE, "status", Status.ON_SALE);
        ReflectionTestUtils.setField(AUCTION_BY_USER_ONE, "id", 1L);

        LocalDateTime dueDate = LocalDateTime.now().plusDays(MAX_AUCTION_DAYS);

        ReflectionTestUtils.setField(AUCTION_BY_USER_ONE, "dueDate", dueDate);

        BID_BY_USER_TWO = new Bid();
        ReflectionTestUtils.setField(BID_BY_USER_TWO, "user", USER_TWO);
        ReflectionTestUtils.setField(BID_BY_USER_TWO, "auction", AUCTION_BY_USER_ONE);
        ReflectionTestUtils.setField(BID_BY_USER_TWO, "bidPrice", BID_PRICE);
    }
}
더보기
package no.gunbang.market.unit.domain.auction.service;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.anyLong;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.util.Optional;
import no.gunbang.market.util.TestData;
import no.gunbang.market.common.entity.Item;
import no.gunbang.market.common.entity.ItemRepository;
import no.gunbang.market.common.entity.Status;
import no.gunbang.market.domain.auction.service.AuctionScheduler;
import no.gunbang.market.domain.auction.dto.request.AuctionRegistrationRequestDto;
import no.gunbang.market.domain.auction.dto.request.BidAuctionRequestDto;
import no.gunbang.market.domain.auction.dto.response.AuctionRegistrationResponseDto;
import no.gunbang.market.domain.auction.dto.response.BidAuctionResponseDto;
import no.gunbang.market.domain.auction.entity.Auction;
import no.gunbang.market.domain.auction.entity.Bid;
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.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.test.context.support.WithMockUser;

@ExtendWith(MockitoExtension.class)
class AuctionServiceTest {

    @Mock
    UserRepository userRepository;

    @Mock
    AuctionRepository auctionRepository;

    @Mock
    ItemRepository itemRepository;

    @Mock
    BidRepository bidRepository;

    @Mock
    AuctionScheduler auctionScheduler;

    @InjectMocks
    AuctionService auctionService;

    User auctionRegistrant;
    User bidder;
    Item item;
    Auction auction;

    @BeforeEach
    void setUp() {
        auctionRegistrant = TestData.USER_ONE;
        item = TestData.ITEM;
        bidder = TestData.USER_TWO;

        when(userRepository.findById(anyLong()))
            .thenReturn(Optional.of(auctionRegistrant));
    }

    @WithMockUser
    @Test
    @DisplayName("성공: 경매 등록")
    void succeedsToRegisterNewAuction() {
        // given
        long startingPrice = TestData.STARTING_PRICE;
        int auctionDays = TestData.MIN_AUCTION_DAYS;
        Long itemId = item.getId();
        Long userId = auctionRegistrant.getId();

        AuctionRegistrationRequestDto requestDto = new AuctionRegistrationRequestDto(
            itemId,
            startingPrice,
            auctionDays
        );

        Auction newAuction = Auction.of(
            auctionRegistrant,
            item,
            startingPrice,
            auctionDays
        );

        when(itemRepository.findById(anyLong()))
            .thenReturn(Optional.of(item));

        when(auctionRepository.save(any()))
            .thenReturn(newAuction);

        // when
        AuctionRegistrationResponseDto responseDto = auctionService.registerAuction(
            userId,
            requestDto
        );

        // then
        long expectedStartingPrice = requestDto.getStartingPrice();
        long actualStartingPrice = responseDto.getStartingPrice();

        assertEquals(expectedStartingPrice, actualStartingPrice);
        verify(auctionRepository).save(any());
    }

    @WithMockUser
    @Test
    @DisplayName("성공: 경매에 새로운 입찰")
    void succeedsToRegisterNewBid() {
        // given
        Long bidderId = bidder.getId();
        auction = TestData.AUCTION_BY_USER_ONE;
        Long auctionId = auction.getId();
        long bidPrice = auction.getStartingPrice() + 1;

        when(auctionRepository.findByIdAndStatus(
                anyLong(),
                any(Status.class)
            )
        ).thenReturn(Optional.of(auction));

        doNothing().when(auctionScheduler)
            .makeExpiredAuctionCompleted(any());

        BidAuctionRequestDto requestDto = new BidAuctionRequestDto(
            auctionId,
            bidPrice
        );

        Bid newBid = Bid.of(
            bidder,
            auction,
            bidPrice
        );

        when(bidRepository.save(any()))
            .thenReturn(newBid);

        // when
        BidAuctionResponseDto responseDto = auctionService.bidAuction(
            bidderId,
            requestDto
        );

        // then
        assertEquals(bidPrice, responseDto.getBidPrice());
        verify(bidRepository).save(any());
    }
}
더보기
package no.gunbang.market.unit.domain.auction.entity;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import no.gunbang.market.util.TestData;
import no.gunbang.market.common.entity.Item;
import no.gunbang.market.common.exception.CustomException;
import no.gunbang.market.common.exception.ErrorCode;
import no.gunbang.market.domain.auction.entity.Auction;
import no.gunbang.market.domain.user.entity.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class AuctionTest {

    User auctionRegistrant;
    Item item;
    long startingPrice;

    @BeforeEach
    void setUp() {
        auctionRegistrant = TestData.USER_ONE;
        item = TestData.ITEM;
        startingPrice = TestData.STARTING_PRICE;
    }

    @Test
    @DisplayName("실패: 입력된 경매 희망 기간이 최소 기한보다 적으면 예외 발생")
    void failsToRegisterAuctionIfAuctionDaysAreLessThanMinimum() {
        // given
        int auctionDays = TestData.MIN_AUCTION_DAYS - 1;

        // when
        CustomException exception = assertThrows(
            CustomException.class,
            () -> Auction.of(
                auctionRegistrant,
                item,
                startingPrice,
                auctionDays
            )
        );

        // then
        String expectedMessage = ErrorCode.AUCTION_DAYS_OUT_OF_RANGE.getMessage();
        String actualMessage = exception.getMessage();

        assertEquals(expectedMessage, actualMessage);
    }

    @Test
    @DisplayName("실패: 입력된 경매 희망 기간이 최대 기한을 넘으면 예외 발생")
    void failsToRegisterAuctionIfAuctionDaysAreGreaterThanMaximum() {
        // given
        int auctionDays = TestData.MAX_AUCTION_DAYS + 1;

        // when
        CustomException exception = assertThrows(
            CustomException.class,
            () -> Auction.of(
                auctionRegistrant,
                item,
                startingPrice,
                auctionDays
            )
        );

        // then
        String expectedMessage = ErrorCode.AUCTION_DAYS_OUT_OF_RANGE.getMessage();
        String actualMessage = exception.getMessage();

        assertEquals(expectedMessage, actualMessage);
    }
}
더보기
package no.gunbang.market.unit.domain.auction.entity;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import no.gunbang.market.util.TestData;
import no.gunbang.market.common.exception.CustomException;
import no.gunbang.market.common.exception.ErrorCode;
import no.gunbang.market.domain.auction.entity.Auction;
import no.gunbang.market.domain.auction.entity.Bid;
import no.gunbang.market.domain.user.entity.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class BidTest {

    User bidder;
    Auction auction;
    Bid bid;

    @BeforeEach
    void setUp() {
        bidder = TestData.USER_THREE;
        auction = TestData.AUCTION_BY_USER_ONE;
        bid = TestData.BID_BY_USER_TWO;
    }

    @Test
    @DisplayName("실패: 입찰가가 기존 금액과 동일하면 예외 발생")
    void failsToBidIfNewBidPriceIsSameAsCurrentBidPrice(){
        // given
        long bidPrice = TestData.BID_PRICE;

        // when
        CustomException thrownException = assertThrows(
            CustomException.class,
            () -> bid.updateBid(
                bidPrice,
                bidder
            )
        );

        // then
        String expectedMessage = ErrorCode.BID_TOO_LOW.getMessage();
        String actualMessage = thrownException.getMessage();

        assertEquals(expectedMessage, actualMessage);
    }

    @Test
    @DisplayName("실패: 같은 사용자가 연속으로 입찰하면 예외 발생")
    void failsToBidIfConsecutiveBiddingOccurs() {
        // given
        long firstBidPrice = TestData.BID_PRICE + 1;
        long secondBidPrice = firstBidPrice + 1;

        bid.updateBid(firstBidPrice, bidder);

        // when
        CustomException thrownException = assertThrows(
            CustomException.class,
            () -> bid.updateBid(
                secondBidPrice,
                bidder
            )
        );

        // then
        String expectedMessage = ErrorCode.CONSECUTIVE_BID_NOT_ALLOWED.getMessage();
        String actualMessage = thrownException.getMessage();

        assertEquals(expectedMessage, actualMessage);
    }

    @Test
    @DisplayName("실패: 사용자가 보유한 골드보다 입찰 금액이 높으면 예외 발생")
    void failsToBidIfBidPriceIsHigherThanHoldingGoldAmount() {
        // given
        long bidPrice = bidder.getGold() + 1;

        // when
        CustomException thrownException = assertThrows(
            CustomException.class,
            () -> bid.updateBid(
                bidPrice,
                bidder
            )
        );

        // then
        String expectedMessage = ErrorCode.EXCESSIVE_BID.getMessage();
        String actualMessage = thrownException.getMessage();

        assertEquals(expectedMessage, actualMessage);
    }
}

테스트 코드를 작성하고 실행하는 데 겪은 시행착오가 적을수록 비즈니스 로직(business logic)이 깔끔하게 작성되었다는 생각이 들었다. 특히 경매에 입찰할 때 필요한 여러 검증 로직을 엔티티(entity) 내부에 구현하여 테스트 코드 작성을 빠르게 마칠 수 있었다. 최종 프로젝트에서는 과제 제출 직전이 아니라, 새로운 기능을 구현할 때마다 바로 테스트 코드를 작성해서 좀 더 의미 있게 활용해야겠다.