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)도 락(樂)이다.
'끝을 보는 용기' 카테고리의 다른 글
Day 123 - 프로젝트 마감 중 (02.08 작성한 테스트 코드 업로드 예정) (0) | 2025.02.06 |
---|---|
Day 122 - 게임 아이템 거래소 프로젝트 70%, 삽질로 하루를 허비해 버렸다 (0) | 2025.02.05 |
Day 120 - 게임 아이템 거래소 프로젝트 45%, 자물쇠를 걸기도 전에 데드락(Deadlock)이 발생했을 때 심정이란 (0) | 2025.02.03 |
Day 119 - 게임 아이템 거래소 프로젝트 35%, 낙낙(Knock Knock) 대신 락락(Lock Lock), 제대로 골머리 앓는 중 (0) | 2025.02.02 |
Day 118 - 게임 아이템 거래소 프로젝트 25%, 코드 한 줄 쓸 때마다 '지금이 최선일까?' 고민하다 (0) | 2025.02.01 |