TaskFlow는 Spring Boot 기반의 일정 관리 애플리케이션을 직접 설계·구현한 프로젝트입니다. 3주간 프로젝트 셋업부터 Outbox 패턴까지 단계적으로 구현하며, API 13개 엔드포인트를 구축했고, TaskHistory 조회에서 N+1을 제거해 쿼리 101회(이력 100개 + 사용자 조회) → 1회(조인 1회)로 줄였습니다. Google Calendar 동기화를 Outbox + Worker 폴링 구조로 분리해, 내부 트랜잭션 안정성을 유지하는 비동기 아키텍처를 설계했습니다.
이 글에서는 각 주차별 설계 결정과 구현 과정을 정리하고, 특히 프로젝트의 핵심인 Outbox 패턴의 설계와 트러블슈팅을 집중적으로 다룹니다.
기술 스택 Java 11, 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.create()처럼 Static Factory Method를 적용했습니다.
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String name;
@Builder
private User(String email, String name) {
this.email = email;
this.name = name;
}
public static User create(String email, String name) {
return User.builder()
.email(email)
.name(name)
.build();
}
}
생성자 대신 메서드 이름으로 생성 의도와 규칙을 고정할 수 있고, 이후 createGuest() 같은 변형 생성도 안전하게 확장할 수 있습니다. Builder를 private으로 감춰, 생성 경로를 Factory Method로 단일화했습니다.
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 정보를 가져오기 위해 이력마다 SELECT가 발생하는 N+1 문제가 있었습니다. @EntityGraph로 changedByUser를 fetch하도록 지정해, 이력 100개 기준 N+1(101회) → 1회로 정리했습니다.
@EntityGraph(attributePaths = {"changedByUser"})
List<TaskHistory> findByTask_IdOrderByCreatedAtDesc(Long taskId);
Session 대신 JWT를 선택했습니다. SPA(React)와의 호환성과 Stateless 확장성을 우선했고, 향후 서비스 분리(MSA) 시에도 인증 구조를 재설계하지 않도록 했습니다.
| 항목 | Session | JWT |
|---|---|---|
| 상태 관리 | Stateful (서버 세션 저장소) | Stateless (토큰에 정보 포함) |
| 확장성 | Scale-out 시 세션 공유 필요 | 어떤 서버든 토큰만으로 인증 |
| 보안 | 서버에서 즉시 무효화 가능 | 만료 전 무효화 어려움 |
| 사용 사례 | 전통적 웹 앱 | SPA, Mobile, MSA |
JwtAuthenticationFilter를 Security Filter Chain에 등록하여 요청마다 토큰을 검증하고 인증 정보를 설정합니다. MVP 단계에서는 구현 속도를 위해 평문 저장으로 시작했지만, 운영 관점에서는 취약하므로 BCrypt 적용을 명확한 후속 과제로 남겼습니다.
외부 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를 적용하여 생성 규칙을 강제했습니다. Factory Method로 초기 상태를 강제해, 잘못된 status/retryCount 조합이 생성 단계에서 차단되도록 했습니다.
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를 삭제한 뒤 최신 상태를 반영한 새 레코드를 적재합니다. Task를 10회 수정해도 PENDING UPSERT를 1건으로 유지하도록 해, 최대 90%까지 호출량이 줄어드는 구조를 만들었습니다. 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 형식을 사용해야 한다는 Spring Data JPA 명명 규칙을 몰라 에러가 발생했습니다.
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이 지나면 재처리 대상입니다. "영구 실패"는 maxRetry 초과 또는 NonRetryable 예외로 분리해 재시도 루프를 명확히 끊었습니다.
이 문제를 통해 명세의 상태/조건(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 패턴.
핵심 데이터 저장과 외부 API 연동을 분리하여 트랜잭션 안정성을 보장했습니다. 정적 Coalescing으로 중복 호출을 최대 90% 절감하고, Lease Timeout으로 Worker 장애를 자동 회복하며, 정책 기반 단계형 Backoff로 재시도 간격을 늘려 외부 API 부하를 제어했습니다.
공통 컴포넌트를 먼저 구현합니다.
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.create()는 의도가 명확하고, 이후createGuest()같은 변형 생성도 안전하게 확장할 수 있습니다. Builder를 private으로 감춰 생성 경로를 Factory Method로 단일화할 수도 있습니다.
Hard Delete는 실수 삭제 시 복구가 불가능하고, 외래 키 참조 데이터도 손실됩니다. Soft Delete로
deleted플래그만 변경하여 감사 추적과 복구 가능성을 확보했습니다.
상태 전이는 단순 필드 변경이 아니라 비즈니스 규칙이 적용되는 액션입니다. 별도 API로 분리하면 규칙 검증이 명확하고, DONE 전이 시 제목에
[DONE]prefix를 추가하는 등 후속 로직 확장도 용이합니다.
TaskHistory 조회 시 각 이력마다 User SELECT가 발생했습니다. @EntityGraph로
changedByUser를 fetch하도록 지정해, 이력 100개 기준 N+1(101회) → 1회로 정리했습니다.
SPA(React)와의 호환성과 Stateless 확장성을 우선했습니다. 토큰만 있으면 어떤 서버든 인증이 가능해서 향후 서비스 분리(MSA) 시에도 인증 구조를 재설계하지 않아도 됩니다.
Task 저장과 Google Calendar 동기화를 분리하여, 외부 API 장애가 핵심 데이터 저장에 영향을 주지 않도록 했습니다. 같은 트랜잭션에 Outbox 레코드를 저장하여 동기화 요청 유실을 방지합니다.
정적 Coalescing을 적용했습니다. Task를 10번 수정해도 PENDING UPSERT를 1건으로 유지해, 최대 90%까지 호출량이 줄어드는 구조입니다.
Lease Timeout을 적용했습니다. PROCESSING이 5분 이상 지속되면 Lease 만료로 간주하고 다시 대상에 포함해 자동 회복됩니다.
Tell, Don't Ask 원칙을 따르기 위해서입니다.
outbox.markAsSuccess()처럼 Entity에게 명령하면, 내부 로직(lastError 초기화 등)은 Entity가 알아서 처리합니다. 부분 업데이트로 인한 불일치를 구조적으로 차단합니다.