[TIL] 스케줄러와 상태 머신으로 경매 종료 흐름 설계하기
For the English version of this post, see here.
스케줄러와 상태 머신의 개념을 학습하고, 경매 종료 시나리오에 적용하면서 fixedRate와 fixedDelay의 차이, 상태 전이 규칙, RESULT_PENDING 상태의 필요성을 정리했다.
오늘 학습한 내용
스케줄러가 필요한 이유
@Scheduled사용법fixedRate,fixedDelay,cron차이상태값 enum 설계
상태 전이 규칙 작성
잘못된 상태 변경을 막는 방법
경매 종료 시나리오에 스케줄러와 상태 머신 적용
fixedRate와 fixedDelay 중 어떤 방식을 선택할지에 대한 고민
스케줄러가 필요한 이유
백엔드 API는 보통 사용자의 요청이 들어왔을 때 실행된다.
예를 들어 사용자가 경매를 생성하거나 입찰을 하면 해당 API가 호출된다.
하지만 경매 종료는 사용자가 버튼을 누른다고 발생하는 일이 아니고, 시간이 지나면 서버가 자동으로 해당 경매를 종료 처리해야 한다.
예를 들어 다음과 같은 경매 데이터가 있다고 가정한다.
1
2
3
auctionId: 1
status: ONGOING
endAt: 2026-06-14 23:00:00
현재 시간이 23:01이 되면 이 경매는 더 이상 ONGOING 상태이면 안 된다.
하지만 사용자가 해당 경매를 조회하거나 별도의 요청을 보내지 않는다면 서버 입장에서는 이 경매를 처리할 계기가 없다.
이때 필요한 것이 스케줄러이다.
스케줄러는 정해진 시간마다 자동으로 특정 로직을 실행하는 기능이다.
경매 서비스에서는 다음과 같은 역할을 할 수 있다.
- 매 60초마다 실행
종료 시간이 지난 ONGOIN 경매 조회
RESULT_PENDING 상태로 변경
즉, 스케줄러는 시간 기반으로 자동 실행되어야 하는 작업을 처리하기 위해 필요하다.
@Scheduled 사용법
Spring Boot에서는 @Scheduled를 사용해 스케줄러를 구현할 수 있다.
먼저 스케줄러 기능을 활성화하기 위해 메인 클래스 또는 설정 클래스에 @EnableScheduling을 추가한다.
1
2
3
4
5
6
7
8
@EnableScheduling
@SpringBootApplication
public class AuctionApplication {
public static void main(String[] args) {
SpringApplication.run(AuctionApplication.class, args);
}
}
그 후 스케줄러 클래스를 작성한다.
1
2
3
4
5
6
7
8
9
10
11
@Component
@RequiredArgsConstructor
public class AuctionScheduler {
private final AuctionService auctionService;
@Scheduled(fixedRate = 60000)
public void markExpiredAuctionsAsPending() {
auctionService.markExpiredAuctionsAsPending();
}
}
위 코드는 60초마다 markExpiredAuctionsAsPending() 메서드를 실행한다는 의미이다.
여기서 스케줄러 클래스는 반드시 Spring Bean으로 등록되어야 하므로 @Component를 붙여야 한다.
fixedRate / fixedDelay / cron 차이
@Scheduled에는 여러 실행 방식이 있다.
fixedRate
1
@Scheduled(fixedRate = 60000)
fixedRate는 이전 작업의 시작 시간을 기준으로 일정 간격마다 실행된다.
즉, 작업이 언제 끝났는지보다 작업이 언제 시작했는지가 기준이다.
매 60초마다 최대한 주기적으로 실행되어야 하는 작업에 적합하다.
fixedDelay
1
@Scheduled(fixedDelay = 60000)
fixedDelay는 이전 작업이 끝난 후 일정 시간이 지난 뒤 다음 작업을 실행한다.
이전 작업이 완전히 끝난 뒤 다음 작업이 실행되어야 하는 경우에 적합하다.
예를 들어 외부 API 호출, 대량 데이터 처리, 재처리 작업처럼 작업 시간이 길어질 수 있는 경우에는 fixedDelay가 더 안전하다.
cron
1
@Scheduled(cron = "0 0 6 * * *")
cron은 일정 간격이 아니라 특정 시각에 실행하고 싶을 때 사용한다.
예를 들어 위 cron 표현식은 매일 오전 6시에 실행된다는 의미이다.
Spring cron 표현식은 일반적으로 다음 순서를 가진다.
1
초 분 시 일 월 요일
따라서 매일 자정, 매주 월요일 오전 9시처럼 정해진 시각에 실행되어야 하는 작업에 적합하다.
상태값 enum 설계
경매 상태를 문자열로 직접 관리하면 오타나 대소문자 문제로 인해 오류가 발생할 수 있다.
따라서 상태값은 enum으로 관리하는 것이 안전하다.
경매 서비스에서는 다음과 같은 상태값을 사용할 수 있다.
1
2
3
4
5
6
public enum AuctionStatus {
ONGOING,
RESULT_PENDING,
WON,
FAIL
}
각 상태의 의미는 다음과 같다.
1
2
3
4
ONGOING: 경매 진행 중
RESULT_PENDING: 경매는 종료되었지만 결과 처리 대기 중
WON: 낙찰 성공
FAIL: 유찰 또는 낙찰 실패
여기서 중요한 점은 FAIL의 의미이다.
FAIL은 시스템 오류가 아니라 비즈니스 결과 실패를 의미해야 한다.
예를 들어 입찰자가 없거나 낙찰 조건을 만족하지 못한 경우에는 FAIL로 처리할 수 있다.
하지만 최고가 조회 실패, 경매 엔진 응답 실패, 네트워크 오류 등은 낙찰 실패가 아니라 시스템 처리 실패에 가깝다.
따라서 이런 경우에는 FAIL로 변경하지 않고 RESULT_PENDING 상태를 유지하는 것이 더 안전하다.
상태 전이 규칙 작성
상태 머신은 현재 상태에서 어떤 상태로 이동할 수 있는지를 관리하는 규칙이다.
경매 상태는 아무렇게나 변경되면 안 된다.
예를 들어 정상적인 흐름은 다음과 같다.
1
2
ONGOING → RESULT_PENDING → WON
ONGOING → RESULT_PENDING → FAIL
반면 다음과 같은 흐름은 이상하다.
1
2
3
4
WON → ONGOING
FAIL → WON
ONGOING → WON
ONGOING → FAIL
WON은 이미 낙찰이 완료된 상태인데 다시 진행 중으로 돌아가면 안 된다.
또한 ONGOING에서 바로 WON이나 FAIL로 변경되면 결과 처리 대기 단계를 건너뛰게 되어 상태 흐름이 명확하지 않다.
따라서 다음과 같이 상태 전이 규칙을 정리할 수 있다.
| 현재 상태 | 이벤트 | 다음 상태 | 설명 |
|---|---|---|---|
ONGOING | 경매 종료 시간 도달 | RESULT_PENDING | 경매 종료 처리 시작 |
RESULT_PENDING | 낙찰자 있음 | WON | 낙찰 성공 |
RESULT_PENDING | 입찰자 없음 | FAIL | 유찰 |
RESULT_PENDING | 결과 처리 실패 | RESULT_PENDING | 상태 유지 |
이렇게 상태 전이 규칙을 작성하면 잘못된 상태 변경을 막을 수 있다.
잘못된 상태 변경 막기
상태값을 enum으로 관리하더라도, 외부에서 setStatus()를 직접 호출할 수 있으면 상태가 잘못 변경될 수 있다.
1
auction.setStatus(AuctionStatus.WON);
이렇게 작성하면 현재 상태가 무엇인지 확인하지 않고 바로 WON으로 바뀔 수 있다.
따라서 엔티티에 상태 변경 메서드를 만들고, 그 안에서 현재 상태를 검증하는 방식이 더 안전하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Auction {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDateTime endAt;
@Enumerated(EnumType.STRING)
private AuctionStatus status;
public void markResultPending() {
if (this.status != AuctionStatus.ONGOING) {
throw new IllegalStateException("진행 중인 경매만 결과 대기 상태로 변경할 수 있습니다.");
}
this.status = AuctionStatus.RESULT_PENDING;
}
public void markWon() {
if (this.status != AuctionStatus.RESULT_PENDING) {
throw new IllegalStateException("결과 대기 상태에서만 낙찰 처리할 수 있습니다.");
}
this.status = AuctionStatus.WON;
}
public void markFail() {
if (this.status != AuctionStatus.RESULT_PENDING) {
throw new IllegalStateException("결과 대기 상태에서만 유찰 처리할 수 있습니다.");
}
this.status = AuctionStatus.FAIL;
}
}
이렇게 하면 경매 상태는 반드시 정해진 메서드를 통해서만 변경된다.
즉, 단순히 상태값을 바꾸는 것이 아니라 markResultPending(), markWon(), markFail()처럼 의미 있는 행위로 상태를 변경하게 된다.
이 방식은 상태 변경 규칙을 엔티티 내부에 모아둘 수 있다는 장점이 있다.
경매 종료 시나리오에 적용
경매 종료 시나리오에서는 스케줄러와 상태 머신을 함께 사용할 수 있다.
우선 스케줄러는 매 60초마다 종료 시간이 지난 경매를 확인한다.
1
2
3
4
5
6
7
8
9
10
11
@Component
@RequiredArgsConstructor
public class AuctionScheduler {
private final AuctionService auctionService;
@Scheduled(fixedRate = 60000)
public void markExpiredAuctionsAsPending() {
auctionService.markExpiredAuctionsAsPending();
}
}
Repository에서는 종료 시간이 지난 ONGOING 경매만 조회한다.
1
2
3
4
5
6
7
public interface AuctionRepository extends JpaRepository<Auction, Long> {
List<Auction> findAllByStatusAndEndAtBefore(
AuctionStatus status,
LocalDateTime now
);
}
Service에서는 조회된 경매를 RESULT_PENDING 상태로 변경한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
@RequiredArgsConstructor
public class AuctionService {
private final AuctionRepository auctionRepository;
@Transactional
public void markExpiredAuctionsAsPending() {
List<Auction> expiredAuctions =
auctionRepository.findAllByStatusAndEndAtBefore(
AuctionStatus.ONGOING,
LocalDateTime.now()
);
for (Auction auction : expiredAuctions) {
auction.markResultPending();
}
}
}
이 흐름은 다음과 같이 정의할 수 있다.
1
2
3
매 60초마다 스케줄러 실행
→ status = ONGOING, endAt <= now인 경매 조회
→ 조회된 경매를 RESULT_PENDING으로 변경
이후 경매 엔진에서 최고가 또는 낙찰 결과가 전달되면 결과를 반영한다.
1
2
3
4
5
6
7
8
9
10
11
@Transactional
public void applyAuctionResult(Long auctionId, boolean hasWinner) {
Auction auction = auctionRepository.findById(auctionId)
.orElseThrow(() -> new IllegalArgumentException("경매를 찾을 수 없습니다."));
if (hasWinner) {
auction.markWon();
} else {
auction.markFail();
}
}
이때도 markWon()과 markFail() 내부에서 현재 상태가 RESULT_PENDING인지 검증하기 때문에 잘못된 상태 변경을 막을 수 있다.
학습 중 생긴 의문점과 해결 과정
처음에는 경매 종료 스케줄러를 구현할 때 fixedDelay를 사용하는 것이 더 안전하다고 생각했다.
fixedDelay는 이전 작업이 끝난 뒤 일정 시간이 지난 후 다음 작업을 실행하는 방식이다.
1
@Scheduled(fixedDelay = 60000)
따라서 이전 스케줄러 작업이 아직 끝나지 않았는데 다음 스케줄러가 다시 실행되는 상황을 피할 수 있다.
예를 들어 종료된 경매가 많아서 1차 스케줄러 작업이 오래 걸리는 경우를 생각했다.
1
2
3
4
5
10:00:00 1차 스케줄러 실행
→ 종료된 경매 처리 중
10:01:00 2차 스케줄러 실행
→ 1차 작업이 아직 끝나지 않았는데 같은 경매를 다시 처리할 수 있음
이 경우 같은 경매에 대해 마감 처리가 중복으로 일어나거나, 낙찰 확정 및 이벤트 발행이 중복될 수 있다고 생각했다.
그래서 처음에는 이전 작업이 끝난 후 다음 작업이 실행되는 fixedDelay가 더 안전하다고 판단했다.
하지만 이후 경매 종료 처리의 목적을 다시 생각해보니 의문이 생겼다.
경매방은 여러 개가 존재하고, 각 경매는 종료 시간이 되면 가능한 빠르게 종료 대상으로 확인되어야 한다.
그런데 fixedDelay를 사용하면 이전 작업이 오래 걸릴 경우 다음 스케줄러 실행 자체가 늦어진다.
예를 들어 다음과 같은 상황이 생길 수 있다.
1
2
3
4
5
10:00 스케줄러 실행
→ 종료된 경매방 5,000개 처리
→ 10:05 작업 종료
→ fixedDelay 60초 대기
→ 10:06 다음 스케줄러 실행
이 경우 10:01, 10:02, 10:03에 새로 종료된 경매가 있더라도 10:06까지 확인되지 않을 수 있다.
즉, fixedDelay는 중복 실행을 피하는 데에는 안전하지만, 작업 시간이 길어질 경우 새로 종료된 경매 확인이 밀릴 수 있다는 단점이 있다.
이 문제를 해결하기 위해 스케줄러의 책임을 다시 분리해서 생각했다.
처음에는 스케줄러가 경매 종료 이후의 여러 작업을 모두 처리한다고 생각했다.
1
2
3
4
5
6
종료된 경매 조회
→ 최고가 판단
→ 낙찰자 결정
→ 이벤트 발행
→ 알림 발송
→ 상태 변경
하지만 이렇게 하면 스케줄러 작업이 무거워지고, 실행 시간이 길어질 가능성이 커진다.
따라서 MVP 단계에서는 스케줄러의 역할을 최소화하는 것이 더 적절하다고 판단했다.
스케줄러는 낙찰자 판단이나 이벤트 발행까지 담당하지 않고, 종료 시간이 지난 ONGOING 경매를 RESULT_PENDING으로 변경하는 역할만 맡는다.
1
2
스케줄러 역할:
ONGOING → RESULT_PENDING
그리고 이후 경매 엔진에서 최고가 및 낙찰 결과가 전달되면 별도 흐름에서 WON 또는 FAIL로 변경한다.
1
2
경매 엔진 결과 전달
→ RESULT_PENDING → WON / FAIL
이렇게 역할을 나누면 스케줄러 작업은 단순한 DB 상태 변경이 되므로 비교적 가벼워진다.
따라서 매 60초마다 종료 대상 경매를 확인하는 것이 더 중요하다고 판단했고, 최종적으로 fixedDelay보다 fixedRate를 사용하는 방향으로 의사결정이 바뀌었다.
1
@Scheduled(fixedRate = 60000)
fixedRate는 이전 작업의 시작 시간을 기준으로 일정 간격마다 실행된다.
즉, 매 60초마다 종료 시간이 지난 경매를 주기적으로 확인할 수 있다.
1
2
3
10:00:00 스케줄러 실행
10:01:00 스케줄러 실행
10:02:00 스케줄러 실행
정리하면 다음과 같다.
1
2
3
4
5
6
7
8
처음 판단:
중복 실행을 피하기 위해 fixedDelay가 더 안전하다고 생각함
다시 고민한 점:
fixedDelay는 이전 작업이 오래 걸리면 다음 경매 종료 확인 자체가 밀릴 수 있음
최종 판단:
스케줄러의 역할을 가볍게 만들고, 매 60초마다 확인하기 위해 fixedRate를 사용하기로 함
하지만 fixedRate를 사용하면 다시 중복 처리 가능성에 대한 의문이 생긴다.
스케줄러가 매 60초마다 실행될 때, 이전 스케줄러가 어떤 경매를 RESULT_PENDING으로 변경하는 중인데 아직 트랜잭션이 커밋되지 않았다면 어떻게 될까?
예를 들어 다음과 같은 상황이다.
1
2
3
4
5
1차 스케줄러 실행
→ auctionId = 10 조회
→ status = ONGOING 확인
→ 엔티티 상태를 RESULT_PENDING으로 변경
→ 아직 트랜잭션 커밋 전
이때 1차 스케줄러의 트랜잭션 안에서는 엔티티 상태가 RESULT_PENDING으로 바뀌었지만, 아직 커밋되지 않았기 때문에 다른 트랜잭션에서는 DB 변경 사항을 볼 수 없다.
따라서 2차 스케줄러가 같은 경매를 조회하면 DB에는 여전히 다음과 같이 보일 수 있다.
1
2
auctionId = 10
status = ONGOING
즉, 2차 스케줄러 입장에서도 해당 경매는 여전히 status = ONGOING, endAt <= now 조건을 만족하는 경매로 조회될 수 있다.
처음에는 조회 조건을 다음과 같이 두면 충분하다고 생각했다.
1
2
status = ONGOING
endAt <= now
하지만 트랜잭션이 아직 커밋되지 않은 상황에서는 이 조건만으로 중복 조회를 완전히 막을 수 없다는 것을 알게 되었다.
또한 엔티티 내부에서 상태 변경 메서드로 검증하더라도 완전히 막을 수 없다.
1
2
3
4
5
6
7
public void markResultPending() {
if (this.status != AuctionStatus.ONGOING) {
throw new IllegalStateException("진행 중인 경매만 결과 대기 상태로 변경할 수 있습니다.");
}
this.status = AuctionStatus.RESULT_PENDING;
}
왜냐하면 2차 스케줄러가 조회한 엔티티도 아직 ONGOING 상태로 보이기 때문이다.
따라서 엔티티 메서드에서 현재 상태를 검증하는 것은 도메인 규칙을 지키는 데에는 도움이 되지만, 트랜잭션 커밋 전 중복 조회 문제까지 완전히 해결해주지는 못한다.
이 문제를 더 안전하게 해결하려면 단순히 조회 조건을 제한하는 것보다, 상태 변경을 DB의 조건부 UPDATE로 처리하는 것이 좋다.
조회 후 Java 코드에서 상태를 변경하는 방식은 다음과 같은 흐름이다.
1
2
3
SELECT로 종료 대상 경매 조회
→ Java에서 엔티티 상태 변경
→ 트랜잭션 커밋 시 UPDATE 반영
이 방식에서는 커밋 전까지 다른 트랜잭션이 같은 row를 ONGOING 상태로 조회할 수 있다.
반면 조건부 UPDATE는 DB에 다음과 같이 직접 요청하는 방식이다.
1
2
3
4
5
UPDATE auction
SET status = 'RESULT_PENDING'
WHERE id = 10
AND status = 'ONGOING'
AND end_at <= NOW();
이 쿼리의 의미는 다음과 같다.
1
이 경매가 아직 ONGOING이고 종료 시간이 지났다면 RESULT_PENDING으로 변경한다.
조건부 UPDATE의 장점은 UPDATE가 실행될 때 DB가 해당 row에 락을 건다는 점이다.
만약 1차 트랜잭션이 이미 같은 row를 업데이트 중이라면, 2차 트랜잭션의 UPDATE는 보통 1차 트랜잭션이 커밋 또는 롤백될 때까지 기다리게 된다.
그 후 1차 트랜잭션이 커밋되어 상태가 RESULT_PENDING으로 바뀌면, 2차 트랜잭션은 다시 조건을 확인했을 때 status = ONGOING 조건을 만족하지 못한다.
결과적으로 2차 UPDATE는 아무 row도 변경하지 않는다.
1
2
3
4
5
6
7
8
9
10
11
1차 트랜잭션
→ status = ONGOING인 row를 RESULT_PENDING으로 UPDATE
→ row lock 획득
→ 커밋 후 status = RESULT_PENDING
2차 트랜잭션
→ 같은 row UPDATE 시도
→ 1차 트랜잭션 종료까지 대기
→ 커밋 후 조건 재확인
→ status = ONGOING 조건 불만족
→ update count = 0
여기서 또 하나의 의문이 생겼다.
처음에 fixedDelay를 사용하지 않으려고 했던 이유는 이전 작업이 끝날 때까지 다음 스케줄러가 기다리는 것이 싫었기 때문이다.
그런데 조건부 UPDATE에서도 2차 트랜잭션이 1차 트랜잭션이 끝날 때까지 기다릴 수 있다면, 결국 똑같이 기다리는 것이 아닌가 하는 의문이 들었다.
결론적으로 둘은 기다림의 범위와 목적이 다르다.
fixedDelay의 기다림은 스케줄러 실행 전체가 밀리는 것이다.
1
2
3
10:00 스케줄러 시작
10:05 스케줄러 종료
10:06 다음 스케줄러 시작
이 경우 10:01, 10:02, 10:03에 새로 종료된 경매가 있어도 다음 스케줄러 실행 자체가 늦어지기 때문에 확인이 밀릴 수 있다.
즉, fixedDelay의 기다림은 전체 스케줄러 주기가 밀리는 기다림이다.
반면 조건부 UPDATE에서의 기다림은 같은 row를 동시에 수정하려는 경우에만 발생하는 DB 레벨의 대기이다.
예를 들어 두 트랜잭션이 동시에 auctionId = 10을 RESULT_PENDING으로 변경하려고 할 때만 DB가 해당 row에 대해 순서를 정리한다.
1
2
3
4
5
6
1차 트랜잭션이 auctionId = 10 UPDATE 중
→ 2차 트랜잭션도 auctionId = 10 UPDATE 시도
→ 같은 row이므로 2차 트랜잭션은 잠시 대기
→ 1차 트랜잭션 커밋
→ 2차 트랜잭션이 조건 재확인
→ 이미 RESULT_PENDING이므로 update count = 0
즉, 조건부 UPDATE의 기다림은 전체 스케줄러 실행을 늦추는 것이 아니라, 같은 데이터를 동시에 변경하려는 순간에만 발생하는 동시성 제어이다.
따라서 두 방식의 차이는 다음과 같이 정리할 수 있다.
1
2
3
4
5
6
7
fixedDelay의 기다림:
이전 스케줄러 작업 전체가 끝나야 다음 스케줄러가 실행된다.
새로 종료된 경매 확인 자체가 늦어질 수 있다.
조건부 UPDATE의 기다림:
같은 row를 동시에 수정하려는 경우에만 DB가 잠깐 기다리게 한다.
전체 스케줄러 주기를 늦추기 위한 것이 아니라 중복 상태 변경을 막기 위한 장치이다.
결국 fixedRate와 조건부 UPDATE는 서로 다른 역할을 한다.
1
2
3
4
5
fixedRate:
매 60초마다 스케줄러를 실행하여 종료 대상 경매 확인이 너무 밀리지 않도록 한다.
조건부 UPDATE:
동시에 같은 경매를 처리하려는 경우에도 이미 처리된 경매가 다시 상태 변경되지 않도록 막는다.
최종적으로 MVP에서는 스케줄러가 매 60초마다 실행되며, 종료 시간이 지난 경매를 RESULT_PENDING으로 변경하는 역할만 담당하도록 한다.
이때 더 안전한 처리를 위해 다음과 같은 조건부 UPDATE 방식을 사용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
@Modifying(clearAutomatically = true)
@Query("""
UPDATE Auction a
SET a.status = :pendingStatus
WHERE a.status = :ongoingStatus
AND a.endAt <= :now
""")
int bulkMarkExpiredAuctionsAsPending(
@Param("ongoingStatus") AuctionStatus ongoingStatus,
@Param("pendingStatus") AuctionStatus pendingStatus,
@Param("now") LocalDateTime now
);
서비스에서는 다음과 같이 호출할 수 있다.
1
2
3
4
5
6
7
8
@Transactional
public int markExpiredAuctionsAsPending() {
return auctionRepository.bulkMarkExpiredAuctionsAsPending(
AuctionStatus.ONGOING,
AuctionStatus.RESULT_PENDING,
LocalDateTime.now()
);
}
이 방식은 종료 대상 경매를 조회한 뒤 하나씩 엔티티 상태를 변경하는 방식보다 동시성 상황에서 더 안전하다.
이번 의문을 통해 스케줄러 방식 선택은 단순히 “겹치지 않게 할 것인가”만으로 결정하면 안 된다는 것을 알게 되었다.
처음에는 중복 실행을 피하기 위해 fixedDelay를 고려했지만, 경매 종료 확인이 지연될 수 있다는 단점이 있었다.
이후 스케줄러의 책임을 ONGOING → RESULT_PENDING 상태 변경으로 최소화하면 작업이 가벼워지므로, 매 60초마다 주기적으로 확인하는 fixedRate가 더 적절하다고 판단했다.
다만 fixedRate를 사용할 경우 중복 처리 가능성을 고려해야 하므로, 조회 후 엔티티 변경 방식이 아니라 DB 레벨의 조건부 UPDATE로 상태를 변경하는 방식이 더 안전하다.
결론적으로 경매 종료 처리에서는 다음과 같은 방식이 적절하다고 정리했다.
1
2
3
4
fixedRate = 60000
→ 매 60초마다 종료 대상 경매 확인
→ 조건부 UPDATE로 status = ONGOING AND endAt <= now인 경매만 RESULT_PENDING으로 변경
→ 경매 엔진 결과 수신 후 RESULT_PENDING 상태에서 WON 또는 FAIL 처리
즉, fixedRate는 “언제 확인할지”를 담당하고, 조건부 UPDATE는 “같은 경매를 중복 처리하지 않게” 담당한다.
최종 선택
경매 종료 처리 방식은 최종적으로 다음과 같이 정리했다.
1
2
3
4
fixedRate = 60000
→ 매 60초마다 종료 대상 경매 확인
→ 조건부 UPDATE로 status = ONGOING AND endAt <= now인 경매만 RESULT_PENDING으로 변경
→ 경매 엔진 결과 수신 후 RESULT_PENDING 상태에서 WON 또는 FAIL 처리
처음에는 스케줄러 중복 실행을 피하기 위해 fixedDelay를 고려했다.
fixedDelay는 이전 작업이 끝난 뒤 일정 시간이 지나야 다음 작업이 실행되므로, 같은 작업이 겹쳐 실행되는 상황을 줄일 수 있다는 장점이 있다.
하지만 경매 서비스에서는 종료 시간이 지난 경매를 가능한 주기적으로 확인하는 것이 중요하다.
만약 이전 스케줄러 작업이 오래 걸리면 다음 스케줄러 실행 자체가 밀리고, 그 사이에 새로 종료된 경매도 늦게 처리될 수 있다.
따라서 스케줄러의 책임을 무겁게 가져가기보다는, MVP 단계에서는 스케줄러가 종료 시간이 지난 경매를 RESULT_PENDING 상태로 변경하는 역할만 담당하도록 정리했다.
낙찰자 판단, 낙찰 확정, 이벤트 발행, 알림 발송 등은 스케줄러가 직접 처리하지 않고 이후 경매 엔진 결과를 받아 별도 흐름에서 처리한다.
이렇게 역할을 분리하면 스케줄러 작업은 단순한 상태 변경 작업이 되므로 비교적 가볍다.
따라서 매 60초마다 종료 대상 경매를 확인할 수 있는 fixedRate가 더 적절하다고 판단했다.
다만 fixedRate를 사용할 경우 이전 작업과 다음 작업이 겹치거나, 트랜잭션 커밋 전 같은 경매가 다시 조회될 수 있는 가능성을 고려해야 한다.
이를 막기 위해 단순히 조회 조건만 두는 것이 아니라, DB 레벨에서 조건부 UPDATE를 사용하기로 했다.
1
2
3
4
UPDATE auction
SET status = 'RESULT_PENDING'
WHERE status = 'ONGOING'
AND end_at <= NOW();
이 방식은 아직 ONGOING 상태이고 종료 시간이 지난 경매만 RESULT_PENDING으로 변경한다.
이미 다른 트랜잭션에서 처리되어 RESULT_PENDING이 된 경매는 조건을 만족하지 않기 때문에 다시 변경되지 않는다.
즉, fixedRate는 주기적인 확인을 담당하고, 조건부 UPDATE는 동일 경매에 대한 중복 상태 변경을 막는 역할을 한다.
회고
이번 학습을 하면서 단순히 @Scheduled를 사용하는 방법보다, 스케줄러가 어떤 책임을 가져야 하는지가 더 중요하다는 것을 알게 되었다.
이번 내용을 통해 백엔드 설계에서는 단순히 기능이 동작하는 것뿐만 아니라, 시간 흐름, 상태 변화, 트랜잭션, 동시성까지 함께 고려해야 한다는 것을 느꼈다.
특히 경매처럼 시간이 지나면 자동으로 상태가 바뀌는 도메인에서는 스케줄러와 상태 머신을 함께 생각해야 흐름이 명확해진다.
결론적으로 이번 학습의 핵심은 다음과 같다.
1
2
3
스케줄러는 언제 실행할지를 담당하고,
상태 머신은 어떤 상태로 변경 가능한지를 담당하며,
조건부 UPDATE는 동시에 같은 경매가 중복 처리되지 않도록 막는 역할을 한다.
처음에는 각각의 개념을 따로 이해하려고 했지만, 경매 종료 시나리오에 직접 적용해보면서 세 개념이 서로 연결된다는 것을 알게 되었다.
댓글
궁금한 점, 피드백, 오류 제보를 남겨 주세요.