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) 내부에 구현하여 테스트 코드 작성을 빠르게 마칠 수 있었다. 최종 프로젝트에서는 과제 제출 직전이 아니라, 새로운 기능을 구현할 때마다 바로 테스트 코드를 작성해서 좀 더 의미 있게 활용해야겠다.
'끝을 보는 용기' 카테고리의 다른 글
Day 126 - 휴식 및 최종 프로젝트 도메인 고민 2 (수정 중) (0) | 2025.02.09 |
---|---|
Day 122 - 게임 아이템 거래소 프로젝트 70%, 삽질로 하루를 허비해 버렸다 (0) | 2025.02.05 |
Day 121 - 게임 아이템 거래소 프로젝트 55%, 한 사용자가 연속으로 입찰하지 못하도록 막아야 할까? (0) | 2025.02.04 |
Day 120 - 게임 아이템 거래소 프로젝트 45%, 자물쇠를 걸기도 전에 데드락(Deadlock)이 발생했을 때 심정이란 (0) | 2025.02.03 |
Day 119 - 게임 아이템 거래소 프로젝트 35%, 낙낙(Knock Knock) 대신 락락(Lock Lock), 제대로 골머리 앓는 중 (0) | 2025.02.02 |