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

허정석·2026년 2월 24일

WIL

목록 보기
11/14
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
저장 위치브라우저 HttpOnly 쿠키 (TASKFLOW_SESSION)서버 DB (OAuthGoogleToken, 토큰 암호화 저장)
갱신 방식재로그인Refresh Token
사용 대상TaskFlow APIGoogle Calendar API

JWT는 TaskFlow 로그인 정보를 전달하고 OAuth Token은 Google API 호출에 사용합니다. 현재 TaskFlow는 JWT 검증 후 DB에서 사용자와 세션 버전도 확인합니다. 로그아웃·계정 만료를 반영하기 위한 구조이며 완전한 Stateless 인증은 아닙니다. 브라우저에는 HttpOnly 쿠키로 JWT를 전달하고 쿠키 기반 변경 요청에는 CSRF 검증을 적용합니다.

OAuthGoogleToken 엔티티 설계

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

첫 번째는 낙관적 락(@Version) 적용입니다. 조회한 버전과 저장 시점의 버전을 비교해 같은 토큰 행의 동시 수정 충돌을 감지합니다. Google의 토큰 갱신 API 호출 자체를 직렬화하는 기능은 아니므로 여러 Worker의 외부 갱신 요청까지 한 번으로 제한하지는 못합니다.

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

    @Version
    private Long version;

    @Convert(converter = EncryptedStringConverter.class)
    private String accessToken;

    @Convert(converter = EncryptedStringConverter.class)
    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;
    }
}

토큰 필드에는 EncryptedStringConverter를 적용해 DB에 암호화하여 저장합니다. 위 코드는 주요 필드만 발췌한 예시입니다.

OAuth Callback과 AnonymousAuthenticationToken

OAuth Callback 엔드포인트는 Google에서 리다이렉트되는 경로이므로 JWT 인증 없이 호출됩니다. Spring Security는 인증되지 않은 요청에 AnonymousAuthenticationToken을 설정하는데, isAuthenticated()가 true를 반환하기 때문에 기존 인증 체크 로직이 그대로 통과됩니다. 이를 처리하기 위해 SecurityContextHelper에 익명 사용자 체크를 추가했습니다. Callback에서는 state를 검증하고 Authorization Code로 받은 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()을 사용하는데, CalendarEventDto는 startAt과 endAt 두 필드를 가지며 모두 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,
                                      boolean missingIsSuccess) {
    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) {
        if (missingIsSuccess) {
            log.info("Resource already deleted (status={}), treat as success", statusCode);
            return;
        }
        throw new NonRetryableIntegrationException("Calendar event not found", statusCode, e);
    }
    throw new NonRetryableIntegrationException("Bad request: " + statusCode, statusCode);
}
상태 코드예외 타입이유
401, 403NonRetryable401은 토큰 갱신 후 1회 재시도하며 이후에도 실패하면 인증 오류로 분류
429RetryableRate Limit (일시적)
5xxRetryable서버 일시 장애
404, 410 (DELETE)return이미 삭제된 이벤트의 삭제를 성공 처리
404, 410 (UPDATE/CREATE)NonRetryableClient에서 예외 전달. UPDATE는 상위 Service에서 이벤트 재생성
400, 기타 4xxNonRetryable요청 구조 문제
IOExceptionRetryable네트워크 오류

DELETE 멱등성 보장

이미 삭제된 이벤트를 다시 삭제할 때 반환되는 404·410은 DELETE에서만 "이미 삭제됨"으로 처리합니다. deleteEvent()는 공통 예외 처리기에 missingIsSuccess=true를 전달하고 CREATE·UPDATE는 false를 전달합니다.

UPDATE의 404·410까지 삼키면 실제 캘린더는 바뀌지 않았는데 Outbox가 SUCCESS로 남을 수 있습니다. 따라서 Client는 예외를 전달하고 상위 GoogleCalendarServiceImpl이 이벤트가 사라진 경우를 구분해 새 이벤트를 만든 뒤 Task의 eventId를 교체합니다. CREATE 실패는 성공으로 처리하지 않습니다.


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()은 별도 트랜잭션에서 토큰을 조회하고 Google Refresh Token API를 호출한 뒤 결과를 저장합니다. 같은 버전을 읽은 두 트랜잭션이 저장하려 하면 @Version이 충돌을 감지해 낙관적 락 예외가 발생할 수 있습니다. 다만 외부 API 호출은 DB 저장보다 먼저 실행되므로 이 방식만으로 중복 Refresh 요청을 막지는 못합니다.

아래는 연동 구현 당시 Proactive·Reactive Refresh 동작을 확인한 로그입니다.

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 초기화)을 직접 제거하는 것이 가장 단순하고 부작용이 적습니다. 같은 패턴을 무조건 제거할 것이 아니라 벌크 쿼리 전후에 같은 영속성 컨텍스트의 엔티티를 계속 사용하는지 점검해야 합니다. 벌크 쿼리는 영속성 컨텍스트를 거치지 않으므로 이미 조회된 대상 엔티티가 있다면 메모리 상태가 DB와 달라질 수 있습니다.

Outbox 조회·선점 조건 불일치

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

@Modifying
@Query("UPDATE CalendarOutbox o " +
       "SET o.status = 'PROCESSING', o.updatedAt = CURRENT_TIMESTAMP " +
       "WHERE o.id = :id " +
       "AND (o.status = 'PENDING' " +
       "  OR (o.status = 'FAILED' AND o.nextRetryAt IS NOT NULL AND o.nextRetryAt <= :now) " +
       "  OR (o.status = 'PROCESSING' AND o.updatedAt < :leaseTimeout))")
int claimForProcessing(@Param("id") Long id,
                       @Param("now") LocalDateTime now,
                       @Param("leaseTimeout") LocalDateTime leaseTimeout);

조회 조건과 선점 조건에 동일한 lease timeout 기준을 적용했습니다. FAILED는 nextRetryAt이 있고 해당 시각이 지난 경우에만 선점합니다. 처리 후보를 조회하는 findProcessable()에는 별도로 retryCount < maxRetry 조건도 적용되어 있습니다.

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 펼치기를 갖춘 모니터링 화면을 구현했습니다. 현재 조회는 로그인한 사용자의 Outbox를 기준으로 제한합니다.

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

디자인 시스템 토큰

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

초기 화면에서는 커스텀 색상과 cx.ts의 정적 클래스 선언을 사용했습니다. 현재 화면은 공통 CSS 변수도 사용하므로 디자인 변경 범위에 따라 공통 토큰과 개별 컴포넌트 스타일을 함께 확인합니다.


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

Outbox Pattern Architecture

Task 변경과 Outbox 적재는 같은 DB 트랜잭션에서 처리하고 실제 Google Calendar API 호출은 Worker가 비동기로 실행합니다. CREATE·UPDATE 처리 시에는 최신 Task를 다시 조회하며 요청자 토큰은 Payload의 meta.requestedByUserId로 찾습니다. 연동되지 않은 사용자의 항목은 SKIPPED로 종료합니다.

Worker가 활성화된 경우 기본 60초 간격으로 처리 후보를 조회합니다. PENDING 외에 재시도 시각이 지난 FAILED와 처리 중 상태가 5분을 넘긴 PROCESSING도 후보가 되지만, 모두 retryCount < 6 조건을 만족해야 합니다. 실패 후 대기 간격은 1분 → 5분 → 15분 → 1시간 → 6시간이며 횟수가 6에 도달하면 자동 처리 대상에서 제외됩니다. 5분이 지났다는 사실만으로 복구가 완료되는 것은 아니며 다음 폴링에서 조건을 확인해 재처리를 시도합니다.

같은 Task의 대기 요청을 정리하는 효과는 Worker를 멈추고 변경 10건을 순차 적재한 통제 실험에서 PENDING 1건과 최신 Payload 유지로 확인했습니다. 이 결과는 동시 요청 전체에 대한 1건 보장이나 외부 API 호출 감소율을 뜻하지 않습니다.


FAQ

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

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

Outbox 처리 시 어떤 사용자의 Google 토큰을 사용하나요?

Outbox Payload의 meta.requestedByUserId에 요청자 ID를 기록합니다. Worker가 호출하는 GoogleCalendarServiceImpl은 이 값을 읽어 해당 사용자의 Google 토큰을 사용합니다. Task의 담당자나 Project owner를 역으로 조회해 추정하지 않습니다.

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

Outbox는 상태와 시각 조건이 있는 UPDATE로 원자적으로 선점하고 영향받은 행 수로 성공 여부를 판단합니다. JPA의 비관적 락 API를 사용하는 방식과는 구분합니다. 토큰 행에는 @Version 낙관적 락을 적용해 저장 충돌을 감지합니다. 두 장치 모두 담당 범위가 다르며 토큰의 외부 Refresh 요청 중복까지 막아 주는 것은 아닙니다.

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

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

0개의 댓글