PeakTime 서비스에서는 미션 완료 시 Admin 서버로 완료 기록을 전송한다. Admin은 이 기록으로 미션 수행 랭킹을 집계하고, 상위권 사용자에게 보상을 제공해야 한다. Admin과 API 서버가 별도의 DB를 사용하는 구조이기 때문에, API 서버가 데이터를 직접 전송해야 한다.
PeakTime의 주요 기능은 미션을 수행하고 그 기록을 남기는 것이다. 아래 코드는 미션을 수행할 때마다 호출되는데, 미션이 수행될 때마다 @Transactional 내부에서 Admin 서버로 미션 완료 기록을 요청한다.
@Transactional
public UserMissionCompletionResponse completeDailyMission(Long userId, Long missionId, UserMissionCompletionRequest request) {
User user = findUser(userId);
LocalDate today = LocalDate.now();
DailyMission dailyMission = getDailyMission(missionId, today);
Mission mission = dailyMission.getMission();
validateSameMission(missionId, user);
SolarTerm solarTerm = getCurrentSolarTerm(today);
UserMissionCompletion completion = userMissionCompletionRepository.save(
UserMissionCompletion.create(user, mission, solarTerm, MissionType.DAILY,
request.objectKey(), request.memo())
);
dailyMissionRepository.incrementParticipantCount(dailyMission.getId());
// 문제: @Transactional 내부에서 HTTP 호출
// → DB 커넥션을 계속 점유한 채로 외부 API 응답 대기
// → 커넥션 풀 고갈 위험
sendMissionLogToAdmin(user, missionId, solarTerm, completion);
return UserMissionCompletionResponse.from(completion);
}
그렇다면 트랜잭션 안에서 Admin으로 API를 호출하면 어떤 문제가 발생할까? 크게 두 가지다. 하나는 외부 API 호출에는 성공했지만 이후 트랜잭션이 롤백되는 경우이고, 다른 하나는 외부 API 응답이 지연될 때 커넥션이 고갈되는 경우다.
PeakTime은 사용자마다 미션 기록 횟수를 Admin 페이지에서 순위에 맞게 보여줘야 한다. 그런데 사용자가 미션 기록을 수행해 Admin 서버 요청에는 성공했지만 이후 트랜잭션 내에서 롤백이 일어나면, Admin에 보이는 횟수와 실제 사용자 기록 횟수의 정합성이 깨진다.
또한 미션 기록은 PeakTime의 핵심 기능이다. 기록이 많아져 트랜잭션 호출이 잦아지는데 그때마다 @Transactional 내부에서 커넥션을 장기간 점유한다면, 커넥션이 고갈될 수 있다.
문제 상황을 다시 정리하면 두 가지다. API 호출과 트랜잭션 안의 도메인 로직이 원자적으로 묶여 있지 않다는 것, 그리고 Admin 서버 상황에 따라 DB 커넥션을 장기간 점유한다는 것이다.
그렇다면 어떻게 원자적으로 묶을 수 있을까? 여기서 Transactional Outbox 패턴이 등장한다.
핵심 아이디어는 데이터베이스의 원자성을 활용하는 것이다.
하나의 트랜잭션은 원자성을 보장하기 때문에 반드시 전부 롤백되거나 전부 커밋된다. 따라서 핵심 비즈니스 로직과 외부로 보낼 데이터(이벤트)를 하나의 트랜잭션으로 묶어 처리하면 된다.
구체적으로는, 외부로 보내야 할 데이터나 이벤트를 메인 데이터베이스의 별도 테이블(Outbox)에 함께 저장하고, 별도의 워커(Worker)가 그 테이블의 레코드를 읽어 외부로 요청을 보내는 방식이다.
Before (트랜잭션 내 HTTP 호출):
completeMission() → DB 저장 + HTTP 호출 (Admin 서버) ← DB 커넥션 점유한 채 대기
After (이벤트 + Outbox):
completeMission() → DB 저장 + Outbox 저장 + 이벤트 발행 (같은 트랜잭션)
API를 보내는 방법은 두 가지가 있다. 하나는 커밋 이후 비동기로 해당 이벤트를 조회해 API를 요청하는 방법이고, 다른 하나는 주기적으로 Outbox 테이블을 조회해 API를 요청하는 방법이다.
커밋 직후 비동기로 호출하면, 주기적으로 폴링해서 보내는 것보다 결과를 더 빨리 알 수 있다. 그래서 비동기 호출을 마다할 이유는 없다고 판단했다. 그리고 여기서 실패한 경우는 스케줄링을 통해 Outbox를 조회한 뒤 다시 처리하면 되지 않을까? 우선 비동기 요청을 처리하기 위한 로직을 구현해보았다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void handle(MissionCompletedEvent event) {
UserMissionCompletion completion = findCompletion(event.getCompletionId());
MissionLogPayload payload = createPayload(completion);
SendResult result = adminClient.sendMissionLog(payload, event.getOutboxId());
handleSendResult(event, result, completion);
}
이 메서드에는 의도적으로 @Transactional을 붙이지 않았다. 이벤트를 조회해 페이로드를 만들고, Admin으로 전송한 뒤 그 결과(SendResult)에 따라 Outbox를 삭제하거나 재시도를 예약하는 것이 전부라, 별도의 트랜잭션이 없어도 괜찮다고 판단했다. 만약 트랜잭션이 있다면, 앞서 본 "메서드 내부에서 API를 호출하는" 것과 똑같은 문제가 발생한다. 이 이벤트 리스너는 커밋 이후에 실행되므로 요청 수와 동일한 횟수로 호출되는데, 요청이 많아지면 결국 트랜잭션 내부에서 외부 API를 호출하는 꼴이 되어 커넥션 고갈로 이어질 수 있다.
전송 결과 분기는 앞서 정의한 실패 케이스(영구/일시)에 따라 handleSendResult에서 처리한다. 성공이나 영구적 실패라면 더 이상 재시도가 무의미하므로 Outbox에서 삭제하고, 일시적 실패라면 레코드를 남겨 폴링 스케줄러가 나중에 다시 시도하도록 둔다.
위 코드에서는 @Async로 별도의 스레드 풀에서 비동기로 처리된다. 만약 여기에 트랜잭션이 걸려 있다면, 작업 중인 워커 스레드는 커넥션과 1대1로 묶일 수밖에 없다. 그러면 스레드 풀이 고갈되기 전에 커넥션 풀이 먼저 고갈될 가능성이 생긴다. 문득 든 생각은, 그렇다면 스레드 풀의 개수로 커넥션 소모량을 제어할 수 있지 않을까 하는 것이었다. 다만 지금 구조에서는 트랜잭션이 필요하지 않으니 이 고민은 넘어가기로 했다.
그런데 이 비동기 구조에서는 다음과 같은 상황들이 발생할 수 있다.
sendLogToAdmin에서 외부 API 요청은 성공했지만, Outbox 테이블에서 delete에 실패한 경우Admin 서버에 요청을 보낸 뒤, API 서버 입장에서는 그 요청이 실제로 성공했는지 알 수 없을 때가 있다. 외부 서버는 정상적으로 처리했더라도 응답이 돌아오는 과정에서 네트워크 문제로 예외가 날 수 있고, 타임아웃으로도 예외가 발생할 수 있다. 흔히 이런 경우를 "알 수 없는 예외 상황"이라 부른다.
알 수 없는 예외 상황을 어떻게 다룰지는 정책에 따라 다르겠지만, 정합성이 중요한 상황에서는 재시도가 필수적이다. PeakTime은 Admin 서버로 보낸 미션 기록을 집계해 순위를 매기고 그것을 기반으로 보상을 제공하기 때문에, 재시도가 반드시 필요하다고 판단했다. 그래서 실패 상황을 두 가지로 정의했다.
먼저 영구적 실패(Permanent Failure)는 비즈니스 로직이나 검증에 의한 실패다. Admin 서버의 도메인 규칙을 위반했거나 요청 페이로드가 잘못되어 4xx 에러(Client Error)를 반환하는 경우가 여기에 해당한다. 이 케이스는 같은 요청을 아무리 재시도해도 계속 같은 실패 응답을 받기 때문에, 재시도할 이유가 없다.
다음으로 일시적 실패(Transient Failure)는 일시적인 네트워크 오류나 타임아웃에 의한 실패다. 네트워크 단절, 커넥션 타임아웃, 혹은 Admin 서버 자체의 일시적인 문제로 인한 5xx 에러(Server Error)가 이에 해당한다. 시스템 환경이 정상화되면 재시도를 통해 성공할 가능성이 있는 케이스다.
이 분류에 맞게 RestClient로 예외 처리를 구현했다. 아래는 Admin 서버로 요청을 보낸 뒤, 예외의 성격에 따라 실패 케이스를 분류하는 메서드다. 기본적으로 4xx 에러는 재시도가 무의미한 영구적 실패로 분류하지만, 충돌 등으로 일시적으로 발생할 수 있는 409 Conflict만큼은 예외적으로 일시적 실패로 간주해 재시도 대상에 포함했다.
public SendResult sendMissionLogWithResult(MissionLogPayload payload, Long eventId) {
try {
AdminApiResponse response = adminRestClient.post()
.uri("/api/stats/mission-log")
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(AdminApiResponse.class);
log.info("미션 로그 전송 성공: missionId={}", payload.missionId());
return new SendResult.Success(eventId);
} catch (HttpClientErrorException.Conflict e) {
// 409 Conflict: 동시에 같은 요청이 처리 중 → 일시 실패 (재시도 필요)
log.info("미션 로그 처리 중 (409 Conflict): missionId={}", payload.missionId());
return new SendResult.TransientFailure(eventId, "409 Conflict: 처리 중");
} catch (HttpClientErrorException e) {
// 4xx (409 제외): 클라이언트 에러 → 영구 실패 (재시도 무의미)
log.warn("미션 로그 클라이언트 에러 (영구 실패): missionId={}, status={}",
payload.missionId(), e.getStatusCode());
return new SendResult.PermanentFailure(eventId, "4xx: " + e.getStatusCode());
} catch (HttpServerErrorException e) {
// 5xx: 서버 에러 → 일시 실패 (재시도 가능)
log.warn("미션 로그 서버 에러 (일시 실패): missionId={}, status={}",
payload.missionId(), e.getStatusCode());
return new SendResult.TransientFailure(eventId, "5xx: " + e.getStatusCode());
} catch (Exception e) {
// 네트워크 오류, 타임아웃 등 → 일시 실패 (재시도 가능)
log.error("미션 로그 전송 실패 (일시 실패): {}", e.getMessage());
return new SendResult.TransientFailure(eventId, e.getMessage());
}
}
각 예외의 성격에 맞게 SendResult 내부 클래스에 상태와 이벤트 ID를 담아 반환하고, 이후 스케줄러나 배치 영역에서 이 반환값을 기반으로 Outbox 테이블의 데이터를 삭제하거나 다음 재시도를 예약한다.
정리하면, 커밋 이후 비동기로 API 요청을 보내고(즉시 발송), 여기서 실패해 Outbox 테이블에 남은 레코드는 30초마다 폴링 재시도로 다시 요청을 보낸다. 동작 흐름은 아래와 같다.

먼저 재시도를 담당하는 스케줄러다. 스케줄러에서 커넥션 점유 시간을 측정하기 위해, 실제 처리는 별도의 빈으로 분리해 호출하는 형태로 구현했다.
@Scheduled(fixedDelay = 30000) // 30초마다
public void pollAndProcess() {
long startTime = System.currentTimeMillis();
log.info("[Task Start] 시작 시각: {}", LocalDateTime.now());
// @Tx 시작
processer.process();
long endTime = System.currentTimeMillis();
log.info("[Task End] 끝 시각: {}", LocalDateTime.now());
// 소요 시간 계산 (끝 시각 - 시작 시각)
long duration = endTime - startTime;
log.info("[Performance Result] 작업 완료! 소요 시간: {} ms (약 {} 초)", duration, duration / 1000.0);
}
그리고 실제 외부 요청을 처리하는 부분이다.
@Transactional
public void process() {
LocalDateTime threshold = LocalDateTime.now().minusSeconds(3);
List<OutboxEvent> events = outboxRepository.findByCreatedAtBeforeWithSkipLocked(threshold);
if (events.isEmpty()) {
log.info("기록해야 하는 미션이 존재하지 않습니다.");
return;
}
log.info("Outbox 폴링: {}건 처리 시작", events.size());
ProcessingResult result = processAllEvents(events);
// TX: 성공 시 Outbox 테이블에서 삭제
deleteProcessedEvents(result.toDelete());
logUnknownEvents(result.unknownCount());
}
앞서 말했듯이 클라이언트 입장에서는 서버 쪽에서 네트워크 에러나 타임아웃이 발생하면 재시도를 해야 한다. 그런데 재시도를 하다 보면 한 번만 수행되어야 할 작업이 여러 번 수행될 수 있다. 현재 구조에서도 이런 중복이 발생할 여지가 있었다.
|
|
위에서 살펴본 상황들 외에도, 즉시 발송하는 비동기 이벤트와 폴링 스케줄러가 동시에 실행되어 같은 요청이 두 번 나가는 경우도 가능하다. 근본적으로는 Admin 서버에서 멱등성 키로 중복 요청을 막아야 하지만, 불필요한 요청 자체를 줄이기 위해 Outbox 테이블에서 생성된 지 3초 이상 지난 레코드만 조회하도록 했다. 즉시 발송이 처리 중인 따끈따끈한 레코드를 폴링이 곧바로 집어가지 않도록 한 것이다.
LocalDateTime threshold = LocalDateTime.now().minusSeconds(3);
List<OutboxEvent> events = outboxRepository.findByCreatedAtBeforeWithSkipLocked(threshold);
서버를 2대 이상으로 스케일 아웃한 다중 인스턴스 환경에서는, 각 서버의 @Scheduled 메서드가 동시에 실행되면서 동일한 Outbox 이벤트를 중복으로 조회하고 API를 중복 요청하는 문제가 생길 수 있다.
이런 스케줄러 중복 실행을 막기 위해, 흔히 한 인스턴스만 작업을 수행하도록 고정하는 분산 락을 떠올리곤 한다. 하지만 분산 락을 쓰면 특정 시점에 오직 한 대의 서버만 스케줄러를 실행할 수 있어서, 다중 인스턴스 구조가 가진 병렬 처리의 이점을 살리지 못하고 나머지 서버의 리소스가 놀게 된다는 아쉬움이 있었다.
고민 끝에 선택한 해결책은 MySQL의 SELECT ... FOR UPDATE SKIP LOCKED 구문이다.
@Query(value = "SELECT * FROM outbox_event WHERE created_at < :threshold ORDER BY created_at LIMIT 5 FOR UPDATE SKIP LOCKED",
nativeQuery = true)
List<OutboxEvent> findByCreatedAtBeforeWithSkipLocked(@Param("threshold") LocalDateTime threshold);
이 쿼리는 다른 트랜잭션이 이미 락을 획득한 행이 있으면, 그 행을 얻으려 대기하지 않고 그냥 건너뛴 뒤 락이 걸리지 않은 다음 행을 즉시 가져온다.
이를 Outbox 이벤트 조회에 적용하면 세 가지 이점을 얻는다. 첫째, 각 서버 인스턴스가 서로 겹치지 않는 레코드를 나눠 가져가므로 동일한 이벤트에 대한 중복 API 요청이 원천적으로 차단된다. 둘째, 모든 서버 인스턴스가 대기 없이 동시에 자기 할당량을 가져와 처리하므로 병렬 스케줄러를 제대로 구현할 수 있다. 셋째, 행 단위 락 경쟁으로 인한 스케줄러 간 대기가 없고, 분산 시스템의 고질적인 문제인 데드락 위험에서도 자유로워진다.
조회 건수는 트래픽을 기준으로 정했다. 미션 기록 요청이 1초에 1번 온다고 휴리스틱하게 가정하면, 스케줄러 주기인 30초 동안 최악의 경우 Outbox 테이블에 30건의 레코드가 쌓인다. 서버가 2대라면 이 30건을 병렬로 나눠 처리해야 하므로, 한 인스턴스당 BATCH_SIZE를 15건씩 조회하도록 설정했다.
이번 작업을 통해 Transactional Outbox 패턴과 실패 케이스 분류 등, 서버 간 API 통신에서 발생할 수 있는 문제들을 처음으로 깊이 고민해볼 수 있었다.
다만 아쉬운 점도 남았다. 다중 서버 환경에서 병렬로 처리하기 위해 TPS와 서버 개수를 기준으로 배치 사이즈를 정했는데, 이 방식은 서버 인스턴스를 증설할 때마다 배치 사이즈를 다시 손봐야 한다. 또한 TPS가 올라가 최악의 상황에 Outbox 테이블에 쌓이는 레코드가 많아질 때도 마찬가지로 배치 사이즈를 조정해야 한다.
그리고 현재 스케줄러는 30초마다 호출되는데, 단일 스레드로 동작하긴 하지만 이 역시 트랜잭션 내부에서 API를 호출한다. 이 두 가지 문제점은 다음 글에서 이어서 고민해보려 한다.
한 수 배우고 갑니다. 👍🏻