TaskFlow 아키텍처 설계 ② — Google OAuth 연동과 운영 가시성 확보

허정석·2026년 2월 24일

WIL

목록 보기
11/11
post-thumbnail

Spring Boot 기반 일정 서비스 설계 기록

개요

①편에서 Outbox 패턴을 설계하고 Worker가 이벤트를 안정적으로 처리하는 구조를 구축했습니다. 이번 글에서는 그 Worker가 실제로 Google Calendar API를 호출하도록 연결하는 과정을 다룹니다. Google OAuth 2.0 인증 흐름 구현부터 Access Token 자동 갱신 전략, 외부 API 예외 분류 원칙, 그리고 Outbox 처리 흐름을 눈으로 확인할 수 있는 모니터링 화면 구축까지 전 과정을 정리합니다. 구현 중 마주친 JPA Persistence Context 초기화 문제와 Worker 루프 버그처럼 통합 테스트에서만 드러나는 트러블슈팅도 함께 기록합니다.


목차

  • Google OAuth 2.0 인증 흐름 구현
  • GoogleCalendarClient 설계와 예외 분류 전략
  • Access Token 자동 갱신 전략
  • 트러블슈팅
  • 프론트엔드 구현과 운영 가시성 확보
  • Outbox 패턴 아키텍처 다이어그램
  • FAQ

Google OAuth 2.0 인증 흐름 구현

Authorization Code Flow

TaskFlow는 Google Calendar API를 호출하기 위해 사용자로부터 캘린더 접근 권한을 위임받아야 합니다. OAuth 2.0 Authorization Code Flow는 이 권한 위임을 처리하는 표준 프로토콜입니다. 전체 흐름은 세 단계로 진행됩니다.

  1. 사용자가 TaskFlow에서 Google 연동을 요청하면 서버가 Google Authorization Server로 리다이렉트할 URL을 생성합니다. 이 URL에는 요청 범위(scope), redirect_uri, 그리고 CSRF 방지용 state 파라미터가 포함됩니다. state는 UUID로 생성해 OAuthStateStore에 저장하며 userId는 포함하지 않습니다.
  2. 사용자가 Google 동의 화면에서 권한을 승인하면 Google은 Authorization Code와 state를 redirect_uri로 전달합니다.
  3. 서버가 Authorization Code를 Google Token Endpoint에 제출해 Access Token과 Refresh Token을 발급받고, Google ID Token을 파싱해 이메일을 추출한 뒤 DB 조회로 userId를 확정합니다.

JWT 인증과 OAuth 인증의 차이

TaskFlow 내부 인증(JWT)과 Google 권한 위임(OAuth)은 목적이 다릅니다.

항목JWT (TaskFlow)OAuth (Google)
목적사용자 인증 (Authentication)API 접근 권한 위임 (Authorization)
발급자TaskFlow 서버Google
저장 위치클라이언트 (localStorage)서버 DB (OAuthGoogleToken)
갱신 방식재로그인Refresh Token
사용 대상TaskFlow APIGoogle Calendar API

JWT는 stateless하게 사용자를 식별하고, OAuth Token은 DB에 저장해 Google API 호출마다 사용합니다.

OAuthGoogleToken 엔티티 설계

Google에서 발급받은 토큰은 OAuthGoogleToken 엔티티로 관리합니다. 설계에서 두 가지 결정이 중요합니다.

첫 번째는 낙관적 락(@Version) 적용입니다. 여러 Outbox Worker가 동시에 토큰 갱신을 시도할 경우 중복 갱신을 방지해야 합니다. 비관적 락(Pessimistic Lock)은 DB 락을 점유하는 반면, 낙관적 락은 충돌이 발생할 때만 예외를 던지므로 충돌 빈도가 낮은 토큰 갱신에 적합합니다.

@Entity
@Table(name = "oauth_google_tokens")
public class OAuthGoogleToken {
    @Id
    private Long userId;

    @Version
    private Long version;

    private String accessToken;
    private String refreshToken;
    private LocalDateTime expiryAt;
    private String scope;
}

두 번째는 Null-safe 토큰 업데이트입니다. Google Token Refresh API는 Access Token만 재발급하고 Refresh Token을 반환하지 않는 경우가 있습니다. 새 Refresh Token이 없을 때 기존 값을 null로 덮어쓰면 이후 갱신이 불가능해지므로 조건부로 업데이트합니다.

public void updateTokens(String newAccessToken, String newRefreshToken,
                         LocalDateTime newExpiryAt, String newScope) {
    this.accessToken = newAccessToken;
    this.expiryAt = newExpiryAt;

    if (newRefreshToken != null && !newRefreshToken.isBlank()) {
        this.refreshToken = newRefreshToken;
    }
    if (newScope != null && !newScope.isBlank()) {
        this.scope = newScope;
    }
}

OAuth Callback과 AnonymousAuthenticationToken

OAuth Callback 엔드포인트는 Google에서 리다이렉트되는 경로이므로 JWT 인증 없이 호출됩니다. Spring Security는 인증되지 않은 요청에 AnonymousAuthenticationToken을 설정하는데, isAuthenticated()true를 반환하기 때문에 기존 인증 체크 로직이 그대로 통과됩니다. 이를 처리하기 위해 SecurityContextHelper에 익명 사용자 체크를 추가했습니다. Callback에서는 state로 CSRF 검증만 수행하고, Authorization Code로 Google ID Token을 파싱해 이메일을 추출한 뒤 DB 조회로 userId를 확정합니다.

public static Long getCurrentUserId() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    if (authentication == null) {
        throw new UnauthorizedException("User is not authenticated");
    }
    if (authentication instanceof AnonymousAuthenticationToken) {
        throw new UnauthorizedException("Anonymous user cannot access this resource");
    }
    if (!authentication.isAuthenticated()) {
        throw new UnauthorizedException("User is not authenticated");
    }

    Object principal = authentication.getPrincipal();

    if (principal instanceof Long) {
        return (Long) principal;
    }
    if (principal instanceof String) {
        return Long.parseLong((String) principal);
    }

    throw new UnauthorizedException("Invalid authentication principal type");
}

GoogleCalendarClient 설계와 예외 분류 전략

인터페이스 설계

GoogleCalendarClient는 세 가지 메서드로 Google Calendar 이벤트를 관리합니다. 인터페이스로 분리한 이유는 테스트 환경에서 Mock으로 교체할 수 있도록 하기 위해서입니다.

public interface GoogleCalendarClient {
    String createEvent(Long userId, CalendarEventDto event);
    void updateEvent(Long userId, String eventId, CalendarEventDto event);
    void deleteEvent(Long userId, String eventId);
}

CalendarEventDto는 시간 지정 이벤트 전용으로 설계합니다. Google Calendar API는 종일 이벤트와 시간 지정 이벤트에 각각 setDate()setDateTime()을 사용하는데, CalendarEventDtostartAtendAt 두 필드를 가지며 모두 setDateTime()으로 매핑합니다.

구현 시 setDate() 대신 setDateTime()을 사용해야 한다는 점을 놓쳐 400 Bad Request를 받았습니다. 메서드 이름이 유사해도 용도가 전혀 다르므로, 새 외부 API를 사용할 때는 구현 전에 공식 문서에서 각 메서드의 역할을 확인해야 합니다.

Calendar Service 생성

Google Calendar API를 호출하려면 사용자의 Access Token으로 Calendar 서비스 객체를 생성해야 합니다. getCalendarService()는 토큰 조회와 Proactive Refresh를 함께 처리합니다. 토큰 만료가 5분 이내로 임박하면 갱신을 먼저 실행하고 재조회한 토큰으로 Credential을 생성합니다. 자세한 갱신 흐름은 섹션 3에서 다룹니다.

private Calendar getCalendarService(Long userId) throws IOException {
    OAuthGoogleToken token = repository.findByUserId(userId)
        .orElseThrow(() -> new NonRetryableIntegrationException("Token not found", 0));

    if (token.isExpiringSoon(5)) {
        googleOAuthService.refreshAccessToken(userId);
        token = repository.findById(userId).orElseThrow();
    }

    GoogleCredential credential = new GoogleCredential()
        .setAccessToken(token.getAccessToken());

    return new Calendar.Builder(
        new NetHttpTransport(),
        JacksonFactory.getDefaultInstance(),
        credential
    ).setApplicationName("TaskFlow Calendar").build();
}

예외 분류 전략

Google API 호출에서 발생하는 예외는 재시도 가능 여부에 따라 분류합니다. Outbox Worker는 예외 타입만 보고 재시도 여부를 결정하기 때문에, 서비스 계층에서 정확히 분류하는 것이 중요합니다.

private void handleGoogleApiException(GoogleJsonResponseException e,
                                      String operation, Long userId) {
    int statusCode = e.getStatusCode();

    if (statusCode == 401 || statusCode == 403) {
        throw new NonRetryableIntegrationException("Auth failed", statusCode);
    }
    if (statusCode == 429) {
        throw new RetryableIntegrationException("Rate limit exceeded", e);
    }
    if (statusCode >= 500) {
        throw new RetryableIntegrationException("Server error: " + statusCode, e);
    }
    if (statusCode == 404 || statusCode == 410) {
        log.info("Resource already deleted (status={}), treat as success", statusCode);
        return;
    }
    throw new NonRetryableIntegrationException("Bad request: " + statusCode, statusCode);
}
상태 코드예외 타입이유
401, 403NonRetryable인증·권한 문제
429RetryableRate Limit (일시적)
5xxRetryable서버 일시 장애
404, 410return (swallow)멱등 DELETE 처리
400, 기타 4xxNonRetryable요청 구조 문제
IOExceptionRetryable네트워크 오류

DELETE 멱등성 보장

Google Calendar API는 첫 번째 DELETE 요청에 200 OK를 반환하지만, 이미 삭제된 리소스를 다시 삭제하면 410 Gone을 반환합니다. Outbox Worker는 재시도 과정에서 동일한 삭제 요청을 여러 번 보낼 수 있으므로, Client 레벨에서 404와 410을 모두 "이미 삭제됨"으로 처리해 멱등성을 보장합니다. handleGoogleApiException()에서 통합 처리하기 때문에 deleteEvent() 메서드에는 별도 분기가 없습니다.


Access Token 자동 갱신 전략

Access Token은 만료 시한이 있으므로 토큰이 만료되면 Google API 호출이 실패합니다. 401 에러를 수동으로 처리하거나 사용자가 재인증하도록 하는 대신, 두 가지 자동 갱신 전략을 조합했습니다.

Proactive Refresh(예방적 갱신) 는 API 호출 전에 토큰 만료 시각을 확인해 5분 이내에 만료될 경우 미리 갱신합니다. getCalendarService() 내부에서 isExpiringSoon(5) 체크를 수행하고, 만료 임박 시 googleOAuthService.refreshAccessToken()을 직접 호출합니다. 갱신 후에는 DB에서 토큰을 재조회해 갱신된 Access Token으로 Credential을 생성합니다.

// GoogleCalendarClientImpl.getCalendarService()
OAuthGoogleToken token = repository.findByUserId(userId)
    .orElseThrow(() -> new NonRetryableIntegrationException("Token not found", 0));

if (token.isExpiringSoon(5)) {
    log.info("Access token expiring soon. Refreshing. userId={}", userId);
    googleOAuthService.refreshAccessToken(userId);
    token = repository.findById(userId).orElseThrow(); // 갱신 후 재조회
}

GoogleCredential credential = new GoogleCredential()
    .setAccessToken(token.getAccessToken());

Reactive Refresh(반응적 갱신) 는 예방적 갱신을 통과했더라도 실제 API 호출 시점에 401이 발생하는 경우를 처리합니다. GoogleCalendarClientImpl 내부의 executeWithRetry()가 이 책임을 담당합니다. 401을 감지하면 즉시 토큰을 갱신하고 동일한 호출을 1회 재시도합니다. createEvent, updateEvent, deleteEvent 세 메서드 모두 이 메서드를 통해 실행되므로 별도 분기 없이 일관되게 처리됩니다. 5xx 에러는 서버 일시 장애이므로 backoff가 필요하지만, 401은 클라이언트가 직접 해결할 수 있어 구분했습니다.

// GoogleCalendarClientImpl.executeWithRetry()
private <T> T executeWithRetry(Long userId, GoogleApiCall<T> call) throws IOException {
    try {
        return call.execute();
    } catch (GoogleJsonResponseException e) {
        if (e.getStatusCode() == 401) {
            log.info("401 Unauthorized. Refreshing token and retrying. userId={}", userId);
            googleOAuthService.refreshAccessToken(userId);
            return call.execute(); // 재시도 1회
        }
        throw e;
    }
}

refreshAccessToken()OAuthGoogleToken을 조회해 Google Refresh Token API를 호출하고 @Version 낙관적 락으로 동시 갱신을 방지합니다. 여러 Worker가 동시에 401을 받아 갱신을 시도하더라도 먼저 커밋한 트랜잭션만 성공하고 나머지는 OptimisticLockingFailureException이 발생합니다.

아래는 실제 동작을 확인한 로그입니다.

Proactive Refresh 동작 로그
이미지 1

GoogleCalendarClientImpl.getCalendarService() — 토큰 만료 5분 전 자동 갱신이 실행된 로그

Reactive Refresh 동작 로그 (401 감지)
이미지 2

GoogleCalendarClientImpl.executeWithRetry() — 401 Unauthorized 감지 후 토큰 갱신 트리거 로그

Reactive Refresh 동작 로그 (재시도 성공)
이미지 3

토큰 갱신 완료 후 즉시 재시도해 성공한 로그


트러블슈팅

@Modifying(clearAutomatically=true)로 인한 엔티티 Detach

E2E 통합 테스트에서 task.update()로 필드를 변경했는데 트랜잭션 커밋 시 UPDATE SQL이 실행되지 않는 현상이 발생했습니다.

원인은 Outbox 적재 과정에서 실행되는 벌크 DELETE 쿼리였습니다. 중복 Outbox를 제거하는 coalescing 로직에서 @Modifying(clearAutomatically=true)로 선언된 JPQL DELETE가 실행되면, 벌크 연산 이후 EntityManager.clear()가 자동 호출되어 Persistence Context 전체가 초기화됩니다. 그 결과 같은 트랜잭션에서 관리 중이던 task도 Detached 상태로 바뀌고, Dirty Checking 대상에서 제외됩니다.

아래 디버깅 로그로 원인을 확정했습니다.

task managed before outbox? true
... delete from calendar_outbox ...
managed after outbox? false

해결책은 clearAutomatically 옵션을 제거하는 것입니다. 벌크 DELETE는 그대로 실행되되 Persistence Context를 초기화하지 않으므로, task는 Managed 상태를 유지하고 커밋 시 Dirty Checking이 정상 동작합니다.

// 수정 전
@Modifying(clearAutomatically = true)
@Query("DELETE FROM CalendarOutbox o WHERE o.taskId = :taskId ...")
int deleteByTaskIdAndStatusAndOpType(...);

// 수정 후
@Modifying
@Query("DELETE FROM CalendarOutbox o WHERE o.taskId = :taskId ...")
int deleteByTaskIdAndStatusAndOpType(...);

saveAndFlush()로 UPDATE를 먼저 확정하거나 Outbox 서비스에서 엔티티를 재조회하는 방법도 있지만, 근본 원인(Persistence Context 초기화)을 직접 제거하는 것이 가장 단순하고 부작용이 적습니다. 같은 패턴의 @Modifying(clearAutomatically=true)가 다른 Outbox 쿼리에도 존재한다면 전수 점검이 필요합니다.

Outbox 조회·선점 조건 불일치

findProcessable() 쿼리는 PROCESSING 상태이면서 lease timeout(5분)이 지난 항목도 조회 대상에 포함했지만, claimForProcessing() 쿼리는 PENDING과 FAILED 상태만 선점 대상으로 지정했습니다. 조회는 됐지만 선점이 안 되는 상황이 발생해 Worker 장애 후 고착된 PROCESSING 항목이 복구되지 않는 문제가 있었습니다.

// 수정 후 claimForProcessing
@Query("UPDATE CalendarOutbox o SET o.status = 'PROCESSING', o.updatedAt = CURRENT_TIMESTAMP " +
       "WHERE o.id = :id " +
       "AND (o.status IN ('PENDING', 'FAILED') " +
       "     OR (o.status = 'PROCESSING' AND o.updatedAt < :leaseTimeout))")
int claimForProcessing(@Param("id") Long id, @Param("leaseTimeout") LocalDateTime leaseTimeout);

조회 조건과 선점 조건에 동일한 lease timeout 기준을 적용해 일관성을 확보했습니다.

Worker 루프에서 return vs continue 버그

선점 실패 시 return을 사용하면 Worker 메서드 전체가 종료되어 나머지 processable 항목들이 처리되지 않는 버그가 있었습니다. 선점 실패는 "이 항목을 건너뛰라"는 신호이지 "모두 멈추라"는 신호가 아닙니다.

// 수정 전: return → Worker 전체 종료
if (!claimed) { return; }

// 수정 후: continue → 해당 항목만 skip
if (!claimed) { continue; }

단위 테스트만으로는 발견하기 어렵고, 여러 Outbox가 동시에 PENDING인 시나리오를 E2E 테스트로 검증하다 발견했습니다.


프론트엔드 구현과 운영 가시성 확보

비즈니스 규칙의 프론트엔드 적용

백엔드 도메인 규칙과 동일한 상태 전이 매트릭스를 프론트엔드에도 명시적으로 선언했습니다. 허용되지 않는 전이 버튼을 비활성화해 사용자가 잘못된 요청을 보내기 전에 UI 레벨에서 차단합니다.

const ALLOWED_TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
  REQUESTED: ['IN_PROGRESS', 'BLOCKED'],
  IN_PROGRESS: ['DONE', 'BLOCKED'],
  BLOCKED: ['IN_PROGRESS'],
  DONE: [],
};

캘린더 동기화 옵션이 활성화된 상태에서 dueAt이 없으면 백엔드가 400을 반환합니다. 이 조건도 프론트엔드 폼 검증으로 사전에 차단합니다. 필드별로 setTitleErr / setDueErr를 분리해 복수 오류를 동시에 표시하고, ok = false 플래그로 모든 조건을 한 번에 수집한 뒤 반환합니다.

스펙 변경 시 백엔드와 프론트엔드 두 곳을 동시에 수정해야 하는 trade-off가 있지만, 사용자 경험의 일관성을 위해 수용했습니다.

Outbox 모니터링 화면

Outbox 패턴은 처리 흐름이 비동기이므로 눈에 보이지 않으면 디버깅이 어렵습니다. 상태별 카운트 배지, taskId 검색과 상태 필터 조합, payload JSON 펼치기, Worker 수동 트리거 기능을 갖춘 모니터링 화면을 구현했습니다.

개발 중에는 Outbox 적재 → Worker 처리 → Google API 호출 전 흐름을 실시간으로 확인해 디버깅 속도를 높였고, FAILED 항목의 lastError를 즉시 확인할 수 있어 운영 관측 가능성(Observability)도 확보했습니다.

디자인 시스템 토큰

Tailwind의 동적 클래스 조합(bg-${color}-900 형태)은 빌드 시 정적 분석으로 추출되지 않아 프로덕션 빌드에서 스타일이 사라지는 문제가 있습니다. 이를 해결하기 위해 모든 스타일을 cx.ts에 완전한 클래스명으로 정적 선언했습니다. 런타임에 결정되는 색상은 인라인 스타일로 처리합니다.

색상 전략으로는 Tailwind 기본 팔레트 대신 hex 커스텀 색상(#0a0a0f, #3b5bff)을 전면 사용해 다크 전용 디자인 시스템을 구축했습니다. 스타일 변경 시 cx.ts 하나만 수정하면 모든 컴포넌트에 반영됩니다.


Outbox 패턴 아키텍처 다이어그램

Outbox Pattern Architecture

💡 Task 변경과 외부 API 호출을 분리해, DB 트랜잭션은 Outbox 적재까지만 책임지고 실제 Google 연동은 Worker가 비동기로 처리하도록 설계했습니다.
Worker는 상태 기반(PENDING/FAILED)으로 레코드를 점유(lease)해 처리하고, 실패 시 retryCount, nextRetryAt, lastError를 기록해 재시도 및 관측이 가능하도록 했습니다.
이를 통해 외부 장애로부터 핵심 트랜잭션을 보호하고, 재처리·디버깅이 가능한 안정적인 비동기 구조를 구현했습니다.


FAQ

OAuth Callback 처리 시 userId를 어떻게 확정하나요?

OAuth Callback은 Google에서 리다이렉트되는 경로이므로 JWT 없이 호출됩니다. state 파라미터는 CSRF 방지 목적으로만 사용하며 userId를 포함하지 않습니다. Callback에서는 Authorization Code로 Google Token API를 호출해 ID Token을 파싱하고, ID Token에 포함된 이메일로 DB를 조회해 userId를 확정합니다.

Outbox Payload에 userId가 없는데 토큰 갱신 시 어떻게 userId를 찾나요?

현재 설계에서는 Outbox Payload에 userId가 포함되어 있지 않아 Task → Project → Owner 관계를 역으로 조회해 userId를 얻습니다. MVP 수준에서 Project owner가 곧 토큰 owner라는 가정이 성립하므로 동작하지만, 실서비스에서는 Payload에 userId를 직접 포함(denormalization)하거나 Task에 createdBy 필드를 추가하는 것이 더 적합합니다.

낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)을 어떻게 구분해서 사용하나요?

Outbox Worker의 선점 처리는 비관적 락 방식(UPDATE 기반 선점)을 사용합니다. 여러 Worker가 동시에 같은 Outbox를 처리하려 할 때 먼저 UPDATE에 성공한 Worker만 처리권을 가져야 하기 때문입니다. 반면 토큰 갱신에는 낙관적 락(@Version) 을 사용합니다. 토큰 갱신 충돌은 빈도가 낮고, DB 락 없이 처리하는 편이 성능상 유리합니다. 충돌이 빈번한 곳에는 비관적 락, 드문 곳에는 낙관적 락이 적합합니다.

@Modifying(clearAutomatically=true)는 언제 사용하는 게 안전한가요?

clearAutomatically=true는 벌크 DML 실행 후 Persistence Context를 자동으로 초기화합니다. 벌크 연산 결과와 1차 캐시 사이의 불일치를 방지하기 위한 옵션이지만, 같은 트랜잭션에서 관리 중인 다른 엔티티까지 Detach시키는 부작용이 있습니다. 벌크 DML 이후 같은 트랜잭션에서 다른 엔티티를 수정하거나 Dirty Checking에 의존하는 로직이 없을 때만 안전하게 사용할 수 있습니다.

0개의 댓글