온라인 경매 시스템 설계: 동시 입찰과 세션 종료
1. 개요
경매 시스템은 간단해 보입니다. 사용자가 가격을 제시하고 최고가가 낙찰됩니다. 어려움은 두 지점에서 나타납니다. 첫째, 수백 개의 입찰이 몇 초 안에 같은 경매 세션으로 몰려들 수 있습니다. 둘째, “세션이 종료됨”이라는 경계가 절대적으로 명확해야 합니다. 입찰 손실이나 낙찰자 오판은 돈과 관련된 오류입니다.
이 글은 전체 설계를 다룹니다: 요구사항, 데이터 모델, API, PostgreSQL과 Redis를 이용한 동시 입찰 처리, “마지막 순간 입찰” 방지(anti-sniping) 메커니즘, 그리고 여러 인스턴스를 실행할 때 안전한 세션 종료. 이를 읽고 나면 Spring Boot 3으로 올바른 핵심 경매 시스템을 구축할 수 있을 것입니다.
2. 문제 요구사항
2.1. 기능 요구사항
- 판매자가 초기 가격, 최소 가격 상승폭, 종료 시간으로 경매 세션을 생성합니다.
- 구매자가 입찰을 제시합니다. 입찰이 유효하려면 세션이 열려있어야 하고 가격이
현재 가격 + 최소 상승폭이상이어야 합니다. - 마지막 2분 안에 유효한 입찰이 있으면 세션이 추가로 2분 연장됩니다.
- 시간이 끝나면 시스템이 세션을 종료하고 낙찰자를 결정한 후 결제 및 알림 단계를 위한 이벤트를 발생시킵니다.
- 시청자는 실시간으로 새로운 가격을 받습니다.
2.2. 규모 및 제약
- 약 100,000개의 경매 세션이 동시에 열려있습니다. 대부분의 세션은 매우 “차갑고”, 하루에 겨우 몇 개의 입찰만 받습니다.
- 일부 “뜨거운” 세션(고가 제품, 플래시 세일)은 마지막 분에 초당 최대 2,000개의 입찰을 받습니다.
- 강한 일관성: 각 세션별로 유효한 입찰을 잃지 않고, 종료 후 입찰을 받지 않으며, 오직 한 명의 낙찰자만 있어야 합니다.
- 입찰이 멱등성이어야 합니다. 타임아웃으로 인한 클라이언트 재시도는 두 개의 입찰을 만들지 않습니다.
- 입찰 배치 p99 지연 시간은 200ms 미만입니다.
요약하면, 각 세션별로 일관성이 필요합니다. 다른 세션들 간에는 필요하지 않습니다. 이것이 확장의 핵심입니다. 각 세션은 독립적인 경합 단위입니다.
3. 데이터 모델
PostgreSQL을 진실의 공급처로 사용합니다. auction 테이블은 현재 상태를 유지합니다. bid 테이블은 추가 전용(append-only) 로그입니다.
CREATE TABLE auction (
id BIGSERIAL PRIMARY KEY,
seller_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
current_price NUMERIC(14,2) NOT NULL,
min_increment NUMERIC(14,2) NOT NULL,
leader_id BIGINT,
status VARCHAR(10) NOT NULL DEFAULT 'OPEN', -- OPEN | CLOSED
end_time TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_auction_open_end ON auction (end_time) WHERE status = 'OPEN';
CREATE TABLE bid (
id BIGSERIAL PRIMARY KEY,
auction_id BIGINT NOT NULL REFERENCES auction(id),
bidder_id BIGINT NOT NULL,
amount NUMERIC(14,2) NOT NULL,
request_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
UNIQUE (auction_id, request_id)
);
몇 가지 주목할 점:
current_price와leader_id는auction에 비정규화되어 있습니다. 덕분에 입찰 검증은 한 줄만 읽으면 되고SELECT MAX(amount)를bid테이블에서 실행할 필요가 없습니다.- 부분 인덱스
idx_auction_open_end는 열려있는 세션만 포함합니다. 세션 종료 작업은 이 인덱스를 매우 빠르게 스캔할 수 있습니다. UNIQUE (auction_id, request_id)는 데이터베이스 레벨의 멱등성 메커니즘입니다.
4. API
POST /auctions 경매 세션 생성
GET /auctions/{id} 경매 세션 상태 보기
POST /auctions/{id}/bids 입찰 제시 (header Idempotency-Key: <uuid>)
WS /ws/auctions/{id} 실시간으로 새 가격 받기
입찰 요청:
{ "bidderId": 42, "amount": 1250000 }
응답은 세 가지 형태입니다. 입찰이 수락되면 201 Created. 가격이 너무 낮거나 세션이 종료되면 409 Conflict. 요청이 같은 Idempotency-Key와 일치하면 기존 결과와 함께 200 OK.
5. 동시 입찰
5.1. 고전적 오류: 읽은 후 쓰기
가장 자연스러운 방법이 가장 잘못된 방법입니다:
@Transactional
public void placeBidNaive(long auctionId, long bidderId, BigDecimal amount) {
Auction a = auctionRepository.findById(auctionId).orElseThrow();
if (amount.compareTo(a.getCurrentPrice().add(a.getMinIncrement())) < 0) {
throw new BidRejectedException("가격이 너무 낮습니다");
}
a.setCurrentPrice(amount);
a.setLeaderId(bidderId);
}
현재 가격이 1,000이라고 가정합니다. 두 개의 스레드가 모두 1,000의 가격을 읽습니다. 스레드 A가 1,100을 제시하고 스레드 B가 1,050을 제시합니다. 둘 다 검증을 통과합니다. B가 마지막에 커밋하면 최종 가격은 1,050이고 리더는 B입니다. A의 더 높은 입찰이 덮어씌워집니다. 이것이 손실된 업데이트 오류입니다.
5.2. 원자적 조건부 업데이트
가장 깔끔한 해결책은 검증과 쓰기를 단일 UPDATE 문으로 병합하는 것입니다. 조건은 WHERE 절에 있습니다:
public interface AuctionRepository extends JpaRepository<Auction, Long> {
@Modifying
@Query(value = """
UPDATE auction
SET current_price = :amount,
leader_id = :bidderId,
end_time = CASE
WHEN end_time - clock_timestamp() < INTERVAL '2 minutes'
THEN clock_timestamp() + INTERVAL '2 minutes'
ELSE end_time END
WHERE id = :id
AND status = 'OPEN'
AND end_time > clock_timestamp()
AND :amount >= current_price + min_increment
""", nativeQuery = true)
int tryPlaceBid(@Param("id") long id,
@Param("bidderId") long bidderId,
@Param("amount") BigDecimal amount);
}
작동 메커니즘은 다음과 같습니다:
- PostgreSQL은 UPDATE 시
auction행을 잠금니다. 두 번째 스레드는 첫 번째 스레드가 커밋할 때까지 기다려야 합니다. READ COMMITTED레벨에서는 잠금이 해제된 후 PostgreSQL이 최신 행 버전에 대해WHERE를 재평가합니다. 1,050 입찰은 새로운 가격 1,100을 만나고 조건이 거짓이므로 문 자체가0을 반환합니다.- anti-sniping 로직 (CASE 블록)이 이 명령문 안에 있습니다. 따라서 연장은 결코 입찰과 벗어나지 않습니다.
- 시간은 데이터베이스의
clock_timestamp()에서 가져오며, 각 애플리케이션 인스턴스의 시계에서 가져오지 않습니다. 데이터베이스가 유일한 시간 소스입니다.
주의: 여기서
now()를 사용하지 마세요. PostgreSQL에서now()는 트랜잭션 시작 시점을 반환합니다. 종료 시간 전에 시작된 트랜잭션이 느리게 실행되어도 여전히 종료 후 입찰을 “통과”시킬 수 있습니다.
서비스는 같은 트랜잭션에서 입찰 로그를 기록합니다:
@Service
@RequiredArgsConstructor
public class BidService {
private final AuctionRepository auctionRepository;
private final BidRepository bidRepository;
@Transactional
public Bid placeBid(long auctionId, long bidderId, BigDecimal amount, UUID requestId) {
var existing = bidRepository.findByAuctionIdAndRequestId(auctionId, requestId);
if (existing.isPresent()) {
return existing.get(); // 클라이언트 재시도: 기존 결과 반환
}
int updated = auctionRepository.tryPlaceBid(auctionId, bidderId, amount);
if (updated == 0) {
throw new BidRejectedException("세션이 종료되었거나 가격이 충분히 높지 않습니다");
}
return bidRepository.save(new Bid(auctionId, bidderId, amount, requestId));
}
}
같은 requestId인 두 개의 재시도가 병렬로 실행되면 둘 다 findBy... 단계를 통과할 수 있습니다. 그러면 UNIQUE 제약이 두 번째 트랜잭션에 DataIntegrityViolationException을 던집니다. 이 트랜잭션은 완전히 롤백되며, 가격 UPDATE도 포함됩니다. 컨트롤러는 이 예외를 잡고 이미 존재하는 입찰을 반환합니다.
5.3. 동시 입찰 테스트
아래 테스트는 virtual thread를 사용하여 200개의 입찰을 같은 세션에 병렬로 보낸 후 불변성을 확인합니다. 필요한 의존성:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class BidServiceConcurrencyTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired BidService bidService;
@Autowired AuctionRepository auctionRepository;
@Autowired BidRepository bidRepository;
@Test
void concurrentBids_keepHighestAcceptedBidAsLeader() throws Exception {
long id = auctionRepository.save(Auction.open("빈티지 시계",
new BigDecimal("1000"), new BigDecimal("10"),
Instant.now().plus(Duration.ofHours(1)))).getId();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 200; i++) {
long bidder = i;
var amount = new BigDecimal(1000 + i * 10);
executor.submit(() -> {
try {
bidService.placeBid(id, bidder, amount, UUID.randomUUID());
} catch (BidRejectedException ignored) { }
});
}
}
Auction result = auctionRepository.findById(id).orElseThrow();
BigDecimal maxAccepted = bidRepository.findMaxAmount(id);
assertThat(result.getCurrentPrice()).isEqualByComparingTo("3000");
assertThat(result.getLeaderId()).isEqualTo(200L);
assertThat(maxAccepted).isEqualByComparingTo(result.getCurrentPrice());
}
}
3,000 입찰 (입찰자 200)은 항상 유효합니다 (더 높은 입찰이 없기 때문). 따라서 실행 순서가 무엇이든 그들이 리더여야 합니다. 5.1의 naive 버전으로 tryPlaceBid를 바꾸면 이 테스트가 불규칙하게 실패할 것입니다. 이것이 race condition의 표시입니다.
6. “뜨거운” 세션: Redis로 경합 이동
조건부 업데이트는 단일 행의 모든 입찰을 순차화합니다. 한 세션에서 PostgreSQL은 보통 초당 수백 개에서 약 천 개의 UPDATE를 처리할 수 있습니다. 초당 2,000개의 입찰을 받는 세션은 긴 잠금 큐를 만들고 p99를 한계 이상으로 밀어냅니다.
“뜨거운” 것으로 표시된 세션의 경우 결정 부분을 Redis로 이동합니다. Redis는 Lua 스크립트를 단일 스레드 및 원자적으로 실행합니다. 필요한 의존성:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
@Service
@RequiredArgsConstructor
public class HotBidService {
private static final RedisScript<Long> PLACE_BID = RedisScript.of("""
local a = redis.call('HMGET', KEYS[1], 'price', 'inc', 'end', 'status')
local now = tonumber(ARGV[3])
if a[4] ~= 'OPEN' or now >= tonumber(a[3]) then return -1 end
if tonumber(ARGV[1]) < tonumber(a[1]) + tonumber(a[2]) then return 0 end
local newEnd = tonumber(a[3])
if newEnd - now < 120000 then newEnd = now + 120000 end
redis.call('HSET', KEYS[1], 'price', ARGV[1], 'leader', ARGV[2], 'end', newEnd)
return 1
""", Long.class);
private final StringRedisTemplate redis;
private final KafkaTemplate<String, BidAccepted> kafka;
public boolean placeBid(long auctionId, long bidderId, long amount, UUID requestId) {
long now = Long.parseLong(redis.execute(
(RedisCallback<Long>) c -> c.serverCommands().time()).toString());
Long r = redis.execute(PLACE_BID, List.of("auction:" + auctionId),
String.valueOf(amount), String.valueOf(bidderId), String.valueOf(now));
if (r == null || r != 1L) return false;
kafka.send("bid-accepted", String.valueOf(auctionId),
new BidAccepted(auctionId, bidderId, amount, requestId, now));
return true;
}
}
간단한 설명:
- 스크립트는 가격, 최소 상승폭, 종료 시간 및 상태를 읽고, 검증하고, 기록하고, 모두 한 번에 원자적으로. 반환 값:
1은 수락,0은 가격 부족,-1은 세션 종료. - 시간은 Redis의
TIME명령에서 가져와서 모든 인스턴스가 공유 시계를 사용합니다. 실제로redis.call('TIME')을 스크립트 내에서 직접 호출할 수 있습니다. 이를 위해서는 스크립트 효과 복제 모드를 활성화해야 하며, 이는 Redis 5부터 기본값입니다. - 수락된 입찰은 key가
auctionId인 Kafka로 전송됩니다. 한 세션의 모든 입찰은 같은 파티션에 떨어지고 순서를 유지합니다. 소비자는 배치로 PostgreSQL에 기록합니다.
주의: 이제 Redis는 세션 전체에서 진실의 공급처가 됩니다. AOF를
appendfsync everysec또는always로 활성화하고, 복제본을 실행하며,kafka.send실패 후 Redis가 이미 썼을 경우를 처리해야 합니다 (재시도 또는 Redis와 PostgreSQL 간의 주기적 대조). 이 모델을 모든 세션에 적용하지 마세요. 대부분의 차가운 세션은 조건부 업데이트로 잘 실행되고 훨씬 간단합니다.
7. 세션 종료
7.1. 원자적 문장 하나로 세션 종료
각 세션마다 밀리초 수준으로 정확한 스케줄러가 필요하지 않습니다. 진정한 경계는 이미 입찰 배치 UPDATE의 end_time > clock_timestamp() 조건에 있습니다. 종료 시간 이후에는 상태가 여전히 OPEN이더라도 어떤 입찰도 통과하지 못합니다. 세션 종료 작업은 몇 초 안에 “장부를 닫으면” 됩니다:
@Component
@RequiredArgsConstructor
public class AuctionCloser {
private final JdbcTemplate jdbc;
@Scheduled(fixedDelay = 1000)
@Transactional
public void closeExpired() {
var closed = jdbc.query("""
UPDATE auction SET status = 'CLOSED'
WHERE id IN (SELECT id FROM auction
WHERE status = 'OPEN' AND end_time <= clock_timestamp()
ORDER BY end_time LIMIT 500
FOR UPDATE SKIP LOCKED)
RETURNING id, leader_id, current_price
""", (rs, i) -> new AuctionClosed(rs.getLong("id"),
(Long) rs.getObject("leader_id"), rs.getBigDecimal("current_price")));
for (var e : closed) {
jdbc.update("INSERT INTO outbox(topic, aggregate_id, payload) VALUES (?, ?, ?::jsonb)",
"auction-closed", e.auctionId(), e.toJson());
}
}
}
이 명령문은 여러 인스턴스에서 실행해도 안전합니다:
FOR UPDATE SKIP LOCKED를 통해 인스턴스들이 세션을 나눌 수 있습니다. 두 인스턴스는 같은 세션을 절대 종료하지 않습니다.- 입찰이 행 잠금을 유지하고 있으면
SKIP LOCKED가 그 행을 건너뜁니다. 다음 실행에서 다시 확인되며, 이때end_time이 연장되었을 수 있습니다. OPEN → CLOSED상태 전환은 한 번만 발생합니다. 작업을 다시 실행해도 부작용이 없으므로 작업은 멱등적입니다.
7.2. Outbox로 이벤트 발행
auction-closed 이벤트는 결제를 시작하고 낙찰자에게 이메일을 보냅니다. 트랜잭션 내에서 kafka.send를 직접 호출하면 두 가지 나쁜 상황을 마주칠 수 있습니다. 하나는 이벤트를 이미 보냈지만 트랜잭션이 롤백됩니다. 다른 하나는 이미 커밋했지만 전송에 실패합니다. outbox 테이블은 세션 종료와 같은 트랜잭션에 기록됩니다. 나중에 릴레이 (폴링 또는 Debezium CDC)가 outbox 행을 Kafka로 푸시합니다. 컨슈머 쪽은 auctionId 기준으로 멱등성이 있어야 합니다. 릴레이가 최소 한 번을 보장하기 때문입니다.
8. 트래픽 급증과 실시간 팬아웃
뜨거운 세션의 마지막 분은 많은 입찰뿐만 아니라 수만 명의 시청자를 가집니다. 몇 가지 보조 기술:
- 사용자 당 속도 제한, 예를 들어 Redis의 토큰 버킷으로 초당 최대 5개 입찰. 이 기술은 스팸 봇을 경합 레이어에 도달하기 전에 차단합니다.
- 조기 거부: 메모리에 현재 가격을 캐시 (수백 밀리초)하고 명백히 너무 낮은 입찰을 DB나 Redis에 보내기 전에 즉시 거부합니다. 캐시는 거부하기 위해서만 사용하며, 수락하기 위해서는 절대 사용하지 않습니다.
- Pub/Sub을 통한 팬아웃: 입찰이 수락되면 Redis 채널
auction:{id}에 발행합니다. 각 WebSocket 노드는 구독하고 클라이언트로 푸시합니다. 입찰이 너무 빠르면 업데이트를 100–200ms마다 병합합니다. 시청자는 최신 가격만 보면 되고, 모든 개별 입찰을 볼 필요는 없습니다. - 읽기와 쓰기 분리:
GET /auctions/{id}는 복제본이나 캐시에서 읽을 수 있습니다. 오직 새 입찰 경로만 프라이머리로 가야 합니다.
9. 일반적인 오류와 사용하지 말아야 할 경우
- 애플리케이션 시계를 사용하여
end_time과 비교합니다. 인스턴스들이 몇 초씩 벗어나는 것은 흔합니다. 항상 데이터베이스나 Redis에 시간을 결정하게 하세요. - 뜨거운 세션에
@Version(낙관적 잠금)을 사용합니다. 이것도 맞지만, 초당 2,000개 입찰은 셀 수 없는OptimisticLockException과 재시도를 생성합니다. 조건부 업데이트는 재시도가 필요 없습니다 왜냐하면WHERE이 자동으로 재평가되기 때문입니다. SELECT ... FOR UPDATE를 한 다음 Java에서 검증합니다. 이 방법도 맞지만 애플리케이션을 통한 한 바퀴 왕복을 기다려야 하므로 잠금을 더 오래 유지합니다. 검증 로직이 SQL로 작성하기에 너무 복잡할 때만 사용합니다.- Distributed lock (Redisson)을 전체 입찰 함수 주위에 사용합니다. 이 방법은 또 다른 장애 지점을 추가하고 잠금이 도중에 만료될 위험이 있으며, 데이터베이스에는 이미 행 잠금이 있습니다.
- 멱등성을 무시합니다. 모바일 클라이언트가 네트워크가 끊어졌을 때 재시도하는 것은 흔합니다.
request_id가 없으면 같은 사람이 자신과 “경합”할 수 있습니다.
10. 결론
경매 시스템의 핵심은 각 세션을 독립적인 일관성 단위로 취급하는 것입니다. 이 글은 다음과 같이 각 어려운 지점을 다루었습니다:
- 동시 입찰: 검증과 쓰기를 원자적
UPDATE ... WHERE문으로 병합하고, anti-sniping 로직을 같은 명령문에 포함합니다. - 종료 시간: 애플리케이션이 아닌 데이터베이스에서 시간을 가져옵니다 (
clock_timestamp()). - 뜨거운 세션: 경합을 Redis Lua로 이동하고 Kafka를 통해 내구성을 제공하며, 운영 복잡도는 증가합니다.
- 세션 종료:
FOR UPDATE SKIP LOCKED를 사용하고, 상태 전환을 멱등적으로 유지하며, Outbox로 이벤트를 발행합니다.
PostgreSQL 순수 버전으로 시작해야 합니다. 대부분의 경우에 충분하고 5.3 같은 동시성 테스트로 검증하기 쉽습니다. 실제 측정치가 단일 auction 행이 병목이 되었음을 보여줄 때만 그 세션들을 Redis로 이동하세요.