TIL(Today I Learned) 플랫폼에 AI 기반 태그 추출 기능을 도입하면서 외부 API 장애로 인한 데이터 정합성 불일치가 발생했습니다.
이 글에서는 기능의 데이터 정합성 문제를 해결하기 위한 여러 패턴들을 점진적으로 도입하며 가용성까지 고려한 과정을 소개합니다.
과거 사용자가 TIL을 작성하면 OpenAI나 Claude 같은 AI 서비스가 적절한 태그를 자동으로 추천하는 기능을 구현했습니다. 그러나 시간이 지나며 다음의 문제가 발생했습니다.
조사 결과, 간헐적 실패의 원인은 두 가지였습니다.

특정 외부 API는 예상보다 자주 장애가 발생했고, 이로 인해 서비스를 이용하던 사용자는 관련 기능을 사용할 수 없었습니다.
이에 기능의 가용성을 확보할 방법이 필요합니다.
위 요구사항을 요약해보겠습니다.
가용성은 다음을 말합니다.
서버와 네트워크, 프로그램 등의 정보 시스템이 정상적으로 사용 가능한 정도
이제 요구사항에 대해 정리해보았으니 서비스 요구사항에 적용해봅시다!

출처 : https://microservices.io/
microservices.io에 따르면 다음을 트랜잭션 아웃박스 패턴이라고 합니다.
The solution is for the service that sends the message to first store the message in the database as part of the transaction that updates the business entities. A separate process then sends the messages to the message broker.
메시지를 전송하는 서비스가 비즈니스 엔티티를 업데이트하는 트랜잭션의 일부로서, 먼저 메시지를 데이터베이스에 저장합니다.
그 후, 별도의 프로세스가 데이터베이스에 저장된 메시지를 메시지 브로커로 전송합니다.
간단하게는, 메시지 전송(외부 api 호출)시에 메시지 그 자체를 데이터베이스에 저장하고 추후에 전송하는 패턴이라고 설명할 수 있습니다.
이제 본격적으로 트랜잭션 아웃박스 패턴을 구현해보겠습니다.
Transactional Outbox 패턴을 위해 재시도에 필요한 정보와, 요청 상태가 필요합니다.
다음 엔티티로 자세히 알아보겠습니다.
@Entity
@Table(name = "tag_creation_outbox_events")
public class TagCreationOutboxEvent extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "til_id", nullable = false)
private Long tilId;
@Column(name = "til_content", columnDefinition = "TEXT", nullable = false)
private String tilContent;
@Column(name = "user_id", nullable = false)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private OutboxEventStatus status;
private LocalDateTime scheduledAt;
}
TagCreationOutboxEvent 엔티티의 tilContent 필드는 재시도에 필요합니다.
status 필드로 재요청이 실패했는지를 판단합니다.
상세 필드 설명:
@Scheduled(fixedDelay = 30_MIN)
@Transactional
public void processPendingEvents() {
List<TagCreationOutboxEvent> pendingEvents =
outboxRepository.findPendingEvents(LocalDateTime.now());
for (TagCreationOutboxEvent event : pendingEvents) {
try {
processEvent(event.getId());
} catch (Exception e) {
log.error("Failed to process pending event {}", event.getId(), e);
throw e;
}
}
}
30초마다 대기 중인 이벤트를 처리하게 합니다.
태그 생성 이벤트를 Outbox에 저장해줍니다.
이 이벤트는 추후 이전에 구현했던 processPendingEvents에서 실행될 것입니다.
/**
* 태그 생성 이벤트를 Outbox에 저장 (트랜잭션 안전)
*/
@Transactional
public void scheduleTagCreation(TilCreatedEvent tilCreatedEvent) {
TagCreationOutboxEvent outboxEvent = TagCreationOutboxEvent.builder()
.tilId(tilCreatedEvent.getTilId())
.tilContent(tilCreatedEvent.getTilContent())
.userId(tilCreatedEvent.getUserId())
.status(OutboxEventStatus.PENDING)
.scheduledAt(LocalDateTime.now())
.build();
outboxRepository.save(outboxEvent);
log.info("Tag creation scheduled for TIL {}", tilCreatedEvent.getTilId());
}
지금까지 구현으로는 장기간 API 서버 자체가 다운된 경우 성공까지 많은 시간이 걸릴 것입니다.
이런 단일 병목,장애 지점을 없애기 위하여 여러 AI API를 순차적으로 시도하도록 외부 API 시스템에 적용해봅시다.

먼저 AI 클라이언트를 나타낼 인터페이스를 구현합시다.
public interface AIClient {
String callAI(List<Map<String, Object>> messages,
Map<String, Object> functionDefinition);
String getClientName();
boolean isAvailable();
}
AIClient 인터페이스를 구현한 구현체들을 차례대로 호출하도록 구현합니다.
public String callAIWithSimpleFallback(
List<Map<String, Object>> messages,
Map<String, Object> functionDefinition
) {
for (AIClient client : aiClients) {
try {
return client.callAI(messages, functionDefinition);
} catch (Exception e) {
log.warn("Failed to call {} API: {}",
client.getClientName(), e.getMessage());
}
}
throw new RuntimeException("No available AI services");
}
지금까지 구현한 방식으로 가용성을 확보했습니다.
하지만 장기간 장애가 발생한 경우나, 왜 실패했는지 사유 등은 알지 못하는 상태입니다.
Dead Letter Queue를 통해 실패한 이벤트를 격리하여 모니터링을 용이하도록 해보겠습니다.
Dead Letter Queue(이하 DLQ)는 처리하지 못한 메시지를 별도로 저장하는 특수 큐입니다. 정상 처리되지 않은 메시지를 격리하여 재처리나 분석을 가능하게 합니다.
| 항목 | Transactional Outbox | DLQ |
|---|---|---|
| 목적 | 메시지 처리 일관성 보장 | 실패 메시지 격리 및 분석 |
| 메시지 저장 시점 | 정상 흐름 | 예외 발생 시 |
| 재처리 | 자동 재시도 | 수동 개입 필요 |
| 알람 | X | O |
즉 DLQ는 실패한 이벤트를 잘 관리하기 위한 패턴입니다.
최종적 일관성을 보장해주는 트랜잭션 아웃박스 패턴과는 목적이 다르다고 볼 수 있습니다.
DLQ 엔티티를 설계할때는, 개발자가 왜 실패했는지를 알게 해주기 위해 에러 정보들을 잘 담는것이 필요합니다.
다음 예제코드에서는 이벤트 타입과 에러 메시지, 스택 트레이스 등을 디버깅용으로 저장하고 있습니다.
@Entity
@Table(name = "dlq_events")
public class DLQEvent extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "original_event_type", nullable = false)
private String originalEventType;
@Column(name = "original_event_id")
private Long originalEventId;
@Column(name = "payload", columnDefinition = "TEXT", nullable = false)
private String payload;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "stack_trace", columnDefinition = "TEXT")
private String stackTrace;
@Column(name = "alarm_sent", nullable = false)
@Builder.Default
private Boolean alarmSent = false;
public boolean shouldSendAlarm() {
return !this.alarmSent &&
this.status == DLQEventStatus.PERMANENTLY_FAILED;
}
}
이제 DLQ를 저장했다면, 실패한 상황을 개발자에게 알람을 보내야합니다.
@Transactional
public void sendToDLQ(String originalEventType, Long originalEventId,
String payload, String errorMessage, String stackTrace) {
DLQEvent dlqEvent = DLQEvent.builder()
.originalEventType(originalEventType)
.originalEventId(originalEventId)
.payload(payload)
.errorMessage(errorMessage)
.stackTrace(stackTrace)
.status(DLQEventStatus.PERMANENTLY_FAILED)
.build();
dlqEventRepository.save(dlqEvent);
log.warn("Event sent to DLQ: {} (ID: {})", originalEventType, dlqEvent.getId());
sendAlarmIfNeeded(dlqEvent.getId());
}
@Async
public void sendAlarmIfNeeded(Long dlqEventId) {
try {
DLQEvent dlqEvent = dlqEventRepository.findById(dlqEventId)
.orElseThrow(() -> new IllegalArgumentException("DLQ event not found"));
if (dlqEvent.shouldSendAlarm()) {
dlqAlarmService.sendDLQAlarm(dlqEvent);
dlqEvent.markAlarmSent();
dlqEventRepository.save(dlqEvent);
}
} catch (Exception e) {
log.error("Failed to send alarm for DLQ event {}", dlqEventId, e);
}
}
🚨 *DLQ Alert - PROD*
*Event Details:*
• ID: `12345`
• Type: `TAG_CREATION_OUTBOX`
• Status: `PERMANENTLY_FAILED`
• Created: `2024-01-15 14:30:25`
*Error Message:* Connection timeout after 3 retries
*Stack Trace:* ...
알람 메시지에는 로그를 확인하지 않고도 디버깅할 수 있도록 stackTrace, 에러 메시지, 이벤트 정보를 포함합니다.
지금까지, 실패한 api를 Transactional Outbox Pattern과, Failover로 최종적 일관성을 보장했습니다.
추가적으로 DLQ를 통해 실패하는 이벤트들을 개발자가 모니터링할 수 있게 하였습니다.
3겹 방어(Transactional Outbox + Failover + DLQ)를 구축했지만, Slack API마저 실패하면 알람을 받지 못합니다. 이는 다음의 딜레마로 귀결됩니다.
실패를 막기 위한 방어선도 실패할 수 있다.
해당 기능의 안정성을 위해 얼마나 자원을 투자할 수 있는가?
아무리 방어선을 많이 두어도,가용성 100%는 불가능에 가깝습니다.
대신, 우리 서비스의 SLA 기준을 세우고 이를 충족하도록 설계하는 것은 가능합니다.

예로, AWS CloudFront는 월별 가용성에 따라 요금을 환불하는 SLA를 제공합니다.
해당 고찰로 무작정 기술로 해결할 수 있다고 생각하기보다
현실적인 목표를 설정하고, 이를 달성하기 위한 적절한 전략을 생각하는것이 개발자가 가야할 방향이 아닐까 생각할 수 있었습니다.
독자분들도 우리 서비스의 가용성 목표는 어느정도인지 다시 한번 생각해보면 어떨까요? 🤓