TaskFlow는 Spring Boot 기반의 일정 관리 애플리케이션을 직접 설계·구현한 프로젝트입니다. 초기 3주간 프로젝트 셋업부터 Outbox 패턴까지 단계적으로 구현하며 API 13개 엔드포인트를 구축했습니다. TaskHistory 조회에는 @EntityGraph를 적용해 연관 사용자 정보를 함께 가져오도록 했습니다. Google Calendar 동기화를 Outbox + Worker 폴링 구조로 분리해, 내부 트랜잭션 안정성을 유지하는 비동기 아키텍처를 설계했습니다.
이 글에서는 각 주차별 설계 결정과 구현 과정을 정리하고 특히 프로젝트의 핵심인 Outbox 패턴의 설계와 트러블슈팅을 집중적으로 다룹니다.
기술 스택 Java 17, Spring Boot, Spring Data JPA, PostgreSQL, Spring Security, JWT
공통 컴포넌트부터 구현하여 이후 개발의 일관된 패턴과 코드 품질을 확립합니다.
모든 API 응답을 success, data, error 세 필드로 통일했습니다. 클라이언트는 success로 성공/실패를 즉시 판단하고 실패 시 error.code를 기준으로 분기 처리할 수 있습니다.
@Getter
public class ApiResponse<T> {
private final boolean success;
private final T data;
private final ApiError error;
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, null);
}
public static <T> ApiResponse<T> error(ErrorCode errorCode, String message) {
return new ApiResponse<>(false, null, new ApiError(errorCode.getCode(), message));
}
}
에러 코드는 ErrorCode Enum으로 중앙 관리하며 사람이 읽기 쉬운 문자열 코드(TASK_NOT_FOUND, VALIDATION_ERROR)로 표준화해 에러 원인 파악과 클라이언트 분기 로직을 단순화했습니다. GlobalExceptionHandler에서는 @RestControllerAdvice로 예외를 전역 처리하고 HTTP 상태 코드를 400/404/409/500으로 분리해, 컨트롤러에 try-catch를 두지 않아도 일관된 에러 계약을 유지했습니다.
@MappedSuperclass로 BaseEntity 추상 클래스를 만들어 createdAt, updatedAt 필드를 공통화했습니다. @CreatedDate와 @LastModifiedDate로 공통 타임스탬프 필드를 상속으로 통일해, 엔티티별 중복 코드를 제거했습니다.
Entity 생성에는 User.createGoogleUser()와 User.createDemoUser()처럼 생성 목적을 이름으로 드러내는 정적 팩터리 메서드를 적용했습니다. 아래는 Google 사용자 생성 경로입니다.
public static User createGoogleUser(String email, String name) {
User user = new User();
user.email = email;
user.name = name != null && !name.isBlank() ? name : extractNameFromEmail(email);
user.provider = Provider.GOOGLE;
user.password = null;
return user;
}
메서드 이름으로 생성 의도를 드러내고 이름과 인증 제공자 같은 초기값을 한곳에서 설정합니다. Google 사용자와 만료 시각이 있는 데모 사용자의 생성 경로도 구분했습니다.
API 응답에는 Entity를 직접 노출하지 않고 DTO로 분리했습니다. Entity 변경이 API 계약에 영향을 주지 않고, 필요한 데이터만 노출하여 보안성을 높이며, LazyInitializationException 같은 JSON 직렬화 문제도 회피할 수 있습니다.
Task 도메인의 핵심 비즈니스 로직과 인증 체계를 구축하여 포트폴리오 품질의 CRUD API를 완성합니다.
Task 삭제 시 레코드를 실제로 제거하지 않고 deleted 플래그와 deletedAt 타임스탬프를 기록합니다.
public void markAsDeleted() {
this.deleted = true;
this.deletedAt = LocalDateTime.now();
}
삭제는 deleted/deletedAt만 변경하고 Dirty Checking으로 트랜잭션 커밋 시 UPDATE가 자동 반영되도록 했습니다. 이 방식으로 실수 삭제 복구, 외래 키 참조 데이터 보전, 감사 추적이 가능해집니다.
TaskStatusPolicy를 Utility Class로 만들어 상태 전이 규칙을 한 곳에서 관리합니다. final 클래스와 private 생성자로 인스턴스 생성을 방지하고 static 메서드와 불변 데이터로 구성했습니다. 상태 변경은 일반 수정과 분리해 POST /api/tasks/{taskId}/status로 고정했고 전이 규칙을 한 곳에서 검증하도록 했습니다.
TaskHistory를 조회한 뒤 연관 User를 개별 조회하는 N+1 문제를 다뤘습니다. @EntityGraph로 changedByUser를 함께 가져오도록 지정했습니다. 쿼리 횟수는 이력 수와 연관 사용자 중복 여부, 영속성 컨텍스트 상태에 따라 달라지므로 고정된 감소 수치로 표현하지 않았습니다.
@EntityGraph(attributePaths = {"changedByUser"})
List<TaskHistory> findByTask_IdOrderByCreatedAtDesc(Long taskId);
JWT로 로그인 정보를 전달하고 Spring Security 필터에서 검증하도록 구성했습니다. 현재 브라우저는 JWT를 HttpOnly 쿠키인 TASKFLOW_SESSION으로 전달합니다. HttpOnly는 JavaScript에서 쿠키를 읽지 못하게 하는 속성으로 토큰이 스크립트에 직접 노출되는 범위를 줄입니다. 쿠키 기반 요청에는 CSRF 검증도 적용합니다.
JwtAuthenticationFilter는 토큰 검증 후 DB에서 사용자를 조회하고 세션 버전과 데모 계정 만료 여부를 확인합니다. 따라서 토큰만 검증하는 완전한 Stateless 인증은 아닙니다. 로그아웃 등으로 세션 버전을 변경하면 기존 토큰을 거부할 수 있도록 했습니다. 일반 사용자 인증은 Google OAuth를 사용하며 로컬 비밀번호 로그인은 제공하지 않습니다.
외부 API는 항상 실패할 수 있다고 전제하고 Outbox로 핵심 데이터 저장과 외부 연동을 트랜잭션 경계에서 분리했습니다.
Task를 저장하는 트랜잭션 안에서 Google Calendar API를 직접 호출하면, API 장애 시 Task 저장까지 함께 실패합니다. Outbox 패턴은 동기화 요청을 같은 트랜잭션의 별도 테이블에 기록하고 Worker가 비동기로 처리하여 핵심 데이터와 외부 연동을 분리합니다.
Task 저장 + Outbox 적재 (같은 트랜잭션)
↓
Worker가 Outbox 폴링 → Google Calendar API 호출
↓
성공 시 SUCCESS / 실패 시 재시도
Task 저장 트랜잭션은 외부 장애와 무관하게 완주하고 동기화는 Worker가 독립적으로 처리하도록 분리했습니다. 외부 API가 일시적으로 장애를 겪더라도 Outbox에 기록이 남아 있으므로 동기화 요청이 유실되지 않습니다.
CalendarOutbox Entity에 Static Factory Method를 적용해 PENDING과 retryCount=0 같은 초기값을 한곳에서 설정했습니다. 다만 CalendarOutbox.builder()도 공개되어 있으므로 모든 생성 경로에서 잘못된 조합을 차단하는 구조는 아닙니다. 서비스에서는 팩터리 메서드를 사용해 초기값을 통일합니다.
public static CalendarOutbox forUpsert(Long taskId, String payload) {
return CalendarOutbox.builder()
.taskId(taskId)
.opType(OutboxOpType.UPSERT)
.payload(payload)
.status(OutboxStatus.PENDING)
.retryCount(0)
.build();
}
상태 변경은 markAsSuccess(), markForRetry() 같은 Rich Domain Model 메서드로 캡슐화했습니다.
public void markAsSuccess() {
this.status = OutboxStatus.SUCCESS;
this.lastError = null;
this.nextRetryAt = null;
}
public void markForRetry(String errorMessage, LocalDateTime nextRetry) {
this.status = OutboxStatus.FAILED;
this.retryCount++;
this.lastError = errorMessage;
this.nextRetryAt = nextRetry;
}
상태 변경을 메서드로 캡슐화해(lastError/nextRetryAt 초기화 포함) 부분 업데이트로 인한 불일치를 구조적으로 차단했습니다.
Backoff 계산은 외부 API 연동 정책이므로 Entity가 아닌 Service에 배치하여 책임을 분리했습니다. Entity는 자신의 상태 변경만 담당하고 재시도 간격 결정 같은 비즈니스 규칙은 Service가 관리합니다.
private LocalDateTime calculateNextRetry(int retryCount) {
LocalDateTime now = LocalDateTime.now();
switch (retryCount) {
case 0: return now.plusMinutes(1);
case 1: return now.plusMinutes(5);
case 2: return now.plusMinutes(15);
case 3: return now.plusHours(1);
case 4: return now.plusHours(6);
case 5: return now.plusHours(24);
default: return null;
}
}
재시도 간격은 정책 기반 단계형 Backoff(1m → 5m → 15m → 1h → 6h → 24h)로 설계해, 외부 API 부하를 점진적으로 낮췄습니다. 정책이 변경되더라도 Service만 수정하면 됩니다.
Task를 여러 번 수정할 때 중간 상태까지 모두 외부로 전파할 필요는 없다고 판단했습니다. UPSERT 적재 시 기존 PENDING 상태의 UPSERT를 삭제하고 최신 1개만 유지합니다.
@Transactional
public void enqueueUpsert(Task task) {
int deleted = outboxRepository.deleteByTaskIdAndStatusAndOpType(
task.getId(), OutboxStatus.PENDING, OutboxOpType.UPSERT);
if (deleted > 0) {
log.debug("Task {} - Coalescing: {}개 PENDING UPSERT 제거",
task.getId(), deleted);
}
String payload = buildUpsertPayload(task);
CalendarOutbox outbox = CalendarOutbox.forUpsert(task.getId(), payload);
outboxRepository.save(outbox);
}
기존 PENDING UPSERT를 삭제한 뒤 최신 상태를 반영한 새 레코드를 적재합니다. 2026년 8월 26일 격리된 PostgreSQL에서 Worker를 끄고 Task를 순차적으로 10회 수정했을 때 PENDING UPSERT 1건과 최종 payload 일치를 확인했습니다. 이는 대기 레코드 병합 결과이며 실제 Google API 호출량 90% 감소나 동시 요청에서의 1건 유지를 검증한 결과는 아닙니다. DELETE 적재 시에도 기존 PENDING UPSERT를 삭제합니다.
Worker가 Outbox를 선점하면 PROCESSING 상태로 변경합니다. 만약 Worker가 처리 도중 장애가 발생하면 해당 레코드가 PROCESSING 상태로 고착될 수 있습니다. PROCESSING이 5분 이상 지속되면 Lease 만료로 간주하고 다시 대상에 포함해, Worker 장애 시에도 자동 회복 가능성을 확보했습니다.
전체 상태 흐름을 정리하면 다음과 같습니다.
Docker 네트워크 문제. 컨테이너 내부에서 localhost는 컨테이너 자신을 가리킨다는 점을 몰라 PostgreSQL 연결에 실패했습니다. 로컬과 컨테이너 환경의 네트워크 차이를 이해한 뒤 해결했습니다.
JPA Auditing 미작동. @EnableJpaAuditing 설정 누락으로 createdAt, updatedAt이 null로 저장되었습니다. Auto Configuration에 의존하지 않고 필수 설정을 명시적으로 확인하는 습관을 갖게 되었습니다.
Repository 명명 규칙. 연관 엔티티의 필드를 참조하는 경로를 명확히 구분하지 못해 에러가 발생했습니다. findByTask_Id처럼 밑줄로 task.id 경계를 명시해 메서드 이름의 해석을 분명하게 했습니다.
Boolean null-safe. Boolean Wrapper 타입의 Auto-unboxing으로 NPE가 발생할 수 있다는 점을 발견하고 Boolean.TRUE.equals() 패턴을 적용했습니다.
Task 생성 후 15초 이내에 삭제하면 어떤 일이 벌어질까요?
T=0초 Task 생성 → UPSERT Outbox 적재 (eventId=null)
T=5초 사용자가 Task 삭제 → DELETE Outbox 적재 시도
T=15초 Worker 실행 → UPSERT 처리, eventId 발급
→ DELETE 처리 시도 → eventId가 없어서 실패?
초기 설계에서는 task.getCalendarEventId() == null이면 DELETE를 skip했습니다. DELETE를 "즉시 실행"으로 해석한 것이 문제였고, 비동기 구조에서는 UPSERT가 아직 처리되지 않은 중간 상태가 항상 존재합니다.
DELETE를 "즉시 삭제 실행"이 아니라 "삭제 의도 기록"으로 정의해, eventId 유무와 무관하게 항상 적재하도록 바꿨습니다. 기존 PENDING UPSERT는 모두 삭제합니다.
public void enqueueDelete(Task task) {
outboxRepository.deleteByTaskIdAndStatusAndOpType(
task.getId(), OutboxStatus.PENDING, OutboxOpType.UPSERT);
boolean exists = outboxRepository.existsByTaskIdAndStatusAndOpType(
task.getId(), OutboxStatus.PENDING, OutboxOpType.DELETE);
if (exists) return;
CalendarOutbox outbox = CalendarOutbox.forDelete(task.getId(), buildDeletePayload(task));
outboxRepository.save(outbox);
}
Worker가 DELETE를 처리할 때 eventId가 없으면 외부에 생성된 이벤트가 없다는 의미이므로, DELETE는 멱등한 no-op 성공으로 처리했습니다. UPSERT가 아직 처리되지 않았다면 이미 PENDING UPSERT를 삭제했으므로 Google Calendar에 이벤트가 생성되지 않고, DELETE도 할 필요가 없기 때문입니다.
private void handleDelete(OutboxPayload payload) {
String eventId = payload.getEvent().getEventId();
if (eventId == null) {
log.info("[MOCK] DELETE no-op - Task {} has no eventId",
payload.getTaskId());
return;
}
log.info("[MOCK] DELETE Event {}", eventId);
}
비동기 설계에서는 타임라인을 먼저 그리고, 중간 상태(UPSERT 미처리, DELETE 의도만 존재)를 케이스로 고정해 검증해야 한다는 교훈을 얻었습니다.
재시도 테스트 중 FAILED 상태의 Outbox가 다시 처리되지 않는 현상을 발견했습니다.
T=15초 Worker 실행 → Retryable 예외 → status=FAILED, nextRetryAt 설정
T=30초 Worker 실행 → Found 0 processable outboxes
T=45초 Worker 실행 → Found 0 processable outboxes
원인을 추적해보니 조회 조건이 PENDING만 포함되어, FAILED(임시 실패)가 재시도 큐에서 누락되고 있었습니다.
// 수정 전
"WHERE o.status = 'PENDING' AND ..."
// 수정 후
"WHERE o.status IN ('PENDING', 'FAILED') AND ..."
markForRetry() 메서드는 status를 FAILED로 변경하는데, 조회 쿼리에서 FAILED를 포함하지 않아 재시도 대상에서 빠진 것입니다. 명세에는 FAILED 상태도 재시도 대상으로 명시되어 있었지만, 구현 시 "FAILED = 영구 실패"라고 잘못 가정했습니다.
FAILED 상태라도 nextRetryAt이 있고 해당 시각이 지났으며 retryCount < 6인 경우에만 재처리 대상입니다. 재시도 불가 예외는 nextRetryAt을 비우고 횟수가 6에 도달한 레코드는 조회에서 제외해 자동 재시도를 중단합니다.
이 문제를 통해 명세의 상태/조건(PENDING·FAILED·nextRetryAt·leaseTimeout)을 체크리스트/테스트 케이스로 전환하는 습관이 필요하다는 것을 체감했습니다.
Worker가 UPSERT를 처리한 뒤 로그에는 eventId가 생성되었다고 나오지만, DB의 Task에는 eventId가 null인 현상이 있었습니다.
Mock이라도 결과를 "반환"하는 것만으로는 부족했고 Task의 상태(eventId)가 실제로 변경되는 부작용까지 동일하게 구현해야 했습니다.
// 수정 전: eventId를 반환만 함
return eventId;
// 수정 후: Task에 eventId를 저장
task.updateCalendarEventId(eventId);
taskRepository.save(task);
return eventId;
로그는 참고일 뿐이고, 최종 진실은 DB 상태입니다. 처리 후 Task.calendarEventId와 Outbox.status를 기준으로 검증해야 합니다.
Envelope 패턴과 전역 예외 처리.
ApiResponse로 응답 형식을 통일하고 ErrorCode Enum과 GlobalExceptionHandler로 에러 처리를 중앙화했습니다. 컨트롤러마다 예외 처리를 반복하지 않아도 되는 구조를 확립했습니다.
JPA 활용.
BaseEntity로 공통 필드를 상속하고 JPA Auditing으로 타임스탬프를 자동 관리했습니다. Dirty Checking으로 명시적 save 없이 상태 변경을 반영하고 @EntityGraph로 N+1 문제를 해결했습니다.
도메인 설계 패턴.
Static Factory Method로 생성 의도와 초기값을 정리하고 Rich Domain Model로 상태 변경 로직을 Entity 내부에 캡슐화했습니다. Setter 대신 의도를 드러내는 메서드(markAsDeleted, markAsSuccess)를 사용하여 비즈니스 규칙을 코드로 표현했습니다.
Outbox 패턴.
Task와 Outbox를 같은 트랜잭션에 저장하고 외부 API 호출은 Worker로 분리했습니다. 정적 Coalescing으로 대기 중인 변경을 최신 상태로 병합하고 Lease Timeout으로 장시간 PROCESSING 상태인 작업을 다시 처리할 수 있게 했습니다. 단계형 Backoff와 최대 재시도 횟수로 재시도 간격과 종료 조건을 관리합니다.
공통 컴포넌트를 먼저 구현합니다.
Week 1에서 ApiResponse, ErrorCode, BaseEntity를 먼저 만들어 둔 덕분에 Week 2~3에서 일관된 패턴으로 빠르게 개발할 수 있었습니다.
명세는 구현의 지도입니다.
명세를 Source of Truth로 활용하여 구현 방향을 잡고, 불일치 발견 시 명세 기준으로 수정했습니다. Week 3에서 FAILED 재시도 누락 문제는 명세를 꼼꼼히 읽지 않아 발생한 것으로, 명세의 상태/조건(PENDING·FAILED·nextRetryAt·leaseTimeout)을 체크리스트/테스트 케이스로 전환하는 습관이 필요합니다.
비동기 설계에서는 중간 상태를 항상 고려합니다.
동기적 사고방식으로 DELETE를 "즉시 실행"으로 해석했다가 레이스 컨디션이 발생했습니다. 의도와 실행을 분리하고 타임라인을 그려서 모든 케이스를 검증하는 접근이 중요합니다.
검증은 로그가 아닌 DB로 합니다.
Mock 구현에서 로그 출력만 보고 정상 동작이라 판단했다가 eventId 미반영 문제를 놓쳤습니다. 최종 진실은 DB 상태이며, 처리 후 실제 데이터를 기준으로 검증해야 합니다.
API 응답 형식을 통일하기 위해 도입했습니다. success 필드로 성공/실패를 판단하고 error 필드의 코드 값으로 클라이언트가 프로그래밍 방식으로 에러를 처리할 수 있습니다.
Entity 변경이 API 계약에 영향을 주지 않도록 DTO로 분리했습니다. JSON 직렬화 시 LazyInitializationException을 피할 수 있고, 필요한 데이터만 노출하여 보안성을 높입니다.
User.createGoogleUser()처럼 메서드 이름으로 생성 의도를 드러내고 초기값을 통일할 수 있습니다. 다만 CalendarOutbox처럼 공개 Builder가 함께 있으면 팩터리 메서드만으로 모든 생성 경로를 제한하지는 못합니다.
Hard Delete는 실수 삭제 시 복구가 불가능하고 외래 키 참조 데이터도 손실됩니다. Soft Delete로
deleted플래그만 변경하여 감사 추적과 복구 가능성을 확보했습니다.
상태 전이는 단순 필드 변경이 아니라 비즈니스 규칙이 적용되는 액션입니다. 별도 API로 분리하면 규칙 검증이 명확하고 DONE 전이 시 제목에
[DONE]prefix를 추가하는 등 후속 로직 확장도 용이합니다.
@EntityGraph로
changedByUser를 함께 조회하도록 지정했습니다. 이력마다 연관 User를 추가 조회하는 문제를 다뤘으며 정확한 쿼리 감소 횟수는 데이터와 조회 조건에 따라 구분해야 합니다.
SPA에서 로그인 정보를 전달하고 서버 필터에서 검증하기 위해 사용했습니다. 현재는 HttpOnly 쿠키로 전달하며 DB의 사용자·세션 버전도 확인하므로 토큰만으로 인증을 끝내는 구조는 아닙니다.
Task 저장과 Google Calendar 동기화를 분리하여, 외부 API 장애가 핵심 데이터 저장에 영향을 주지 않도록 했습니다. 같은 트랜잭션에 Outbox 레코드를 저장하여 동기화 요청 유실을 방지합니다.
정적 Coalescing으로 기존 PENDING UPSERT를 지우고 최신 상태를 적재합니다. Worker를 끈 격리 환경의 순차 수정 10회에서 PENDING 1건을 확인했습니다. 처리 중인 작업과 동시 요청까지 항상 1건으로 유지한다는 의미는 아닙니다.
Lease Timeout을 적용했습니다. PROCESSING이 5분을 넘고 재시도 횟수 조건을 만족하면 다시 선점할 수 있습니다. 실제 처리는 다음 Worker 폴링에서 시도하며 5분 이내 복구 완료를 보장하지는 않습니다.
Tell, Don't Ask 원칙을 따르기 위해서입니다.
outbox.markAsSuccess()처럼 Entity에게 명령하면, 내부 로직(lastError 초기화 등)은 Entity가 알아서 처리합니다. 부분 업데이트로 인한 불일치를 구조적으로 차단합니다.