[StyleHub#7]Transactional이 동작하지 않는다? — Spring Self-Invocation 버그 발견과 해결

2026. 3. 17. 10:29·stylehub 프로젝트
@Transactional을 붙였는데 DB에 반영이 안 됐다.
원인은 같은 클래스 내부에서 메서드를 호출하면 Spring 프록시를 거치지 않아 트랜잭션이 무시되는 것이었다.
별도 클래스 분리나 AopContext 방식을 검토했지만, 프록시에 의존하지 않는 TransactionTemplate으로 해결했다.

들어가며

Google OAuth 로그인을 구현한 이후 Postman을 통해 기존 유저 재로그인을 테스트하던 중 예상과 다른 동작을 발견했다.
StyleHub 프로젝트에는 일일 로그인시 10포인트 지급이라는 정책이 있다.

따라서 기존 유저가 다시 로그인하면 포인트가 적립되고 lastLoginDate 또한 갱신되어야 한다.

 

하지만 테스트 결과

  • 로그인은 정상적으로 성공했지만
  • 포인트가 적립되지 않았고
  • lastLoginDate 또한 갱신되지 않았다
public OAuthLoginResponse googleLogin(String code) {
    // Google API 호출 ...
    return findOrCreateUser(userInfo);
}

@Transactional
protected OAuthLoginResponse findOrCreateUser(GoogleUserInfoResponse userInfo) {
    // DB 조회/저장 ...
}

분명 서비스 계층의 메서드에는 @Transactional이 선언되어 있었고 로직상 컴파일 에러나 런타임 예외도 발생하지 않는 상황이었다

 

이 글에서는트랜잭션이 기대와 다르게 동작한 원인과 그 해결 과정을 정리해본다.


1. 증상 : 신규 유저는 되고, 기존 유저는 안 된다

@Transactional
protected OAuthLoginResponse findOrCreateUser(GoogleUserInfoResponse userInfo) {
    Optional<User> existingUser = userRepository.findByEmail(userInfo.email());

    if (existingUser.isPresent()) {
        // 기존 유저: 더티 체킹으로 UPDATE 기대
        User user = existingUser.get();
        user.rewardLoginPoint(LocalDate.now());
        return OAuthLoginResponse.from(user, false);
    }

    // 신규 유저: save() 명시 호출
    User newUser = User.createOAuth(...);
    newUser.rewardLoginPoint(LocalDate.now());
    userRepository.save(newUser);  // INSERT 실행
    return OAuthLoginResponse.from(savedUser, true);
}

테스트 결과는 다음과 같았다.

 

  • 신규 유저 → save() 호출 → INSERT 실행 →  정상 동작
  • 기존 유저 → 필드 변경 (더티 체킹 기대) →  DB 반영 실패

여기서 이상했던 점은 동일한 트랜잭션 내부 로직임에도 불구하고 기존 유저만 업데이트가 반영되지 않았다는 것이다.

 

일반적으로 JPA에서는 트랜잭션이 활성화된 상태라면 엔티티의 변경을 감지(Dirty Checking)하여 자동으로 UPDATE 쿼리를 실행한다.

 

특히 신규 유저는 save()를 통해 명시적으로 INSERT가 발생하기 때문에 문제가 드러나지 않았고 더티 체킹에 의존하는 기존 유저 로직에서만 문제가 발생한 것으로 보였다.

또한 이 버그는 신규 가입 테스트만으로는 발견되지 않고 기존 유저 재로그인 + DB 확인을 해야만 발견되는 문제였다


2. 원인  분석 — 너, 프록시(Proxy)를 거쳤니?

@Transactional을 붙였는데 트랜잭션이 없다. 이게 어떻게 가능할까?

 

멘토링 중에 멘토님이 @Transactional 내부 동작을 여러 번 물어보셨는데 당시에는  답을 제대로 못했지만 그래도 말로 몇 번 뱉었던 경험이  있었기에 이 버그와 연관이 있을 것 같다는 생각이 들었고, 트랜잭션 동작 원리를 다시 들여다봤다.

 

@Transactional은 Spring AOP 기반 프록시를 통해 동작한다.

즉, 외부에서 메서드를 호출할 때만 트랜잭션이 적용된다.

[외부에서 호출]
Controller → OAuthService$$Proxy.findOrCreateUser()
                 │
                 ├─ 프록시가 트랜잭션 시작
                 ├─ 실제 메서드 실행
                 └─ 프록시가 트랜잭션 커밋 

 

하지만 같은 클래스 내부에서 호출하면 프록시를 거치지 않는다.

[같은 클래스 내부 호출 — 현재 코드]
googleLogin() → this.findOrCreateUser()
                 │
                 └─ 프록시를 거치지 않고 직접 호출
                    → @Transactional 무시 
                    → 트랜잭션 없음
                    → 더티 체킹 불가

googleLogin()이 findOrCreateUser()를 호출할 때 Java 내부적으로 this.findOrCreateUser()가 실행된다. this는 프록시가 아닌 실제 객체를 가리키므로, @Transactional 어노테이션이 완전히 무시된다.

 

JPA의  save() 메서드의 내부를 보면:

// SimpleJpaRepository (Spring Data JPA 내부)
@Transactional
public <S extends T> S save(S entity) {
    // ...
}

save()에는 자체적으로 @Transactional이 붙어있다.

그리고 이 호출은 외부 호출(OAuthService → JpaRepository)이므로 프록시가 정상 동작한다.

시나리오 트랜잭션 이유
신규 유저 → save()  동작 save() 자체에 @Transactional, 외부 호출
기존 유저 → 더티 체킹  미동작 findOrCreateUser()의 @Transactional이 self-invocation으로 무시

3. 해결 방안:  TransactionTemplate 도입

Self-invocation 문제를 해결하는 방법은 여러 가지가 있었다.


가장 일반적인 방법은 별도 클래스로 분리하는 것이다.

findOrCreateUser()를 다른 빈으로 옮기면 외부 호출이 되어 프록시가 정상 동작한다.

하지만 이 방법은 책임 분리가 아닌 트랜잭션 경계를 위한 클래스 분리라는 점이 마음에 걸렸다. 로직 하나를 위해 클래스를 늘리는 건 과하다고 판단했다.


AopContext.currentProxy()를 사용하는 방법도 있었다.

현재 빈의 프록시 객체를 직접 꺼내 호출하는 방식인데, exposeProxy = true 설정도 따로 필요하고 코드만 봐서는 의도를 파악하기 어렵다. Spring 내부 구현에 강하게 의존한다는 것도 부담이었다.


결국 TransactionTemplate을 선택했다.

프록시를 거치지 않고 .execute() 블록 자체가 트랜잭션 범위가 되기 때문에 self-invocation 문제가 구조적으로 발생하지 않는다. 이미 BCrypt 커넥션 최적화를 위해 같은 패턴을 사용하고 있었던 것도 선택을 굳힌 이유였다.


4. 수정 결과 - 요약 코드

전체 코드는 https://github.com/ccommit/stylehub/pull/9 pr 에서 확인할 수 있다.
public OAuthLoginResponse googleLogin(String code) {

    // 1. Google API 호출 — 트랜잭션 밖 (외부 HTTP 통신, 수백ms)
    GoogleTokenResponse tokenResponse = googleOAuthClient.exchangeCodeForToken(code);
    Objects.requireNonNull(tokenResponse, "Google 토큰 응답이 null입니다");

    GoogleUserInfoResponse userInfo = googleOAuthClient.getUserInfo(tokenResponse.accessToken());
    Objects.requireNonNull(userInfo, "Google 유저 정보 응답이 null입니다");

    // 2. DB 조회/저장 — 트랜잭션 안 (TransactionTemplate으로 직접 관리)
    return Objects.requireNonNull(
            transactionTemplate.execute(status -> {
                Optional<User> existingUser = userRepository.findByEmail(userInfo.email());

                if (existingUser.isPresent()) {
                    User user = existingUser.get();

                    if (user.getProvider() == null) {
                        throw new IllegalArgumentException("이미 일반 회원가입으로 등록된 이메일입니다");
                    }

                    user.rewardLoginPoint(LocalDate.now());  // 더티 체킹 정상 동작 ✅
                    return OAuthLoginResponse.from(user, false);
                }

                User newUser = User.createOAuth(
                        userInfo.name(), userInfo.email(),
                        Provider.GOOGLE, userInfo.sub()
                );
                newUser.rewardLoginPoint(LocalDate.now());
                User savedUser = userRepository.save(newUser);
                return OAuthLoginResponse.from(savedUser, true);
            })
    );
}

 

별도 메서드 분리 없이 googleLogin() 하나로 통합했다.
transactionTemplate.execute() 블록 안이 곧 트랜잭션 범위이므로 self-invocation 문제가 원천적으로 발생하지 않는다.


변경 전/후 비교

항목 변경 전 변경 후
트랜잭션 방식 @Transactional (미동작) TransactionTemplate
기존 유저 포인트 DB 반영 안 됨 정상 반영
기존 유저 lastLoginDate 갱신 안 됨 정상 갱신
신규 유저 생성 정상 정상
Google API 호출 트랜잭션 밖 트랜잭션 밖 (동일)

변경전 dB

 

변경 후 DB

DB에도 잘 반영이 되는걸 확인할 수 있다.


마치며 : @Transactional 은 마법이 아니다

이 버그의 까다로운 점은 세 가지였다:

  1. 컴파일 에러가 없다. @Transactional을 붙이면 IDE도 경고를 주지 않는다.
  2. 일부 시나리오에서는 정상 동작한다. 신규 유저 생성은 save() 자체 트랜잭션으로 문제없이 동작했다.
  3. 기능 테스트로 발견하기 어렵다. 기존 유저 재로그인 + DB 값 확인까지 해야 포인트 미반영을 알 수 있다.

결국 @Transactional을 단순히 선언하는 것만으로는 트랜잭션이 항상 보장되는 것이 아니라는 점을 배우게 되었다.

프록시가 어떻게 동작하는지, self-invocation이 발생하는 구조인지 이해하지 못하면  함정에 빠진다.

 

테스트 케이스의 중요성도 다시 한번 느꼈다. 신규 가입 테스트만 했다면 이 버그는 운영 상황에서 발생했을 것이다.

"기존 유저가 재로그인하면?" 같은 시나리오를 떠올리는 습관이 왜 중요한지 몸으로 배웠다.

 

그리고 멘토링 중에 말로 몇 번 뱉어봤던 경험이 생각보다 큰 도움이 됐다.

완벽히 이해하지 못한 채로 뱉은 말이었지만 그 덕에 원인을 향한 실마리를 잡을 수 있었다.

머릿속에 희미하게 남아있던 개념이 결정적인 순간에 실마리가 됐다. 공부한 게 헛되지 않았다.

 

잘 돌아가는 코드와 올바르게 동작하는 코드는 다르다.

그 간격을 좁히는 게 앞으로의 목표다.

 

 

 

 

 

저작자표시 비영리 (새창열림)

'stylehub 프로젝트' 카테고리의 다른 글

[stylehub#8]100만건 상품 목록에서 Offset 대신 커서 페이징을 선택한 이유  (0) 2026.03.27
[StyleHub#6]@Transactional 범위 최소화로 커넥션 점유 시간을 줄인 과정 (feat. BCrypt)  (1) 2026.03.15
[StyleHub #5] 왜 QueryDSL을 도입했는가 — JPQL의 한계를 설계 단계에서 미리 마주하다  (0) 2026.03.09
[StyleHub #3]Spring이라서 JPA를 선택하지 않았습니다 — StyleHub에서 JDBC, MyBatis, JPA를 비교한 이유  (0) 2026.03.09
[StyleHub #2]커머스 DB 설계: ERD 설계하면서 가장 많이 고민했던 9가지  (0) 2026.03.08
'stylehub 프로젝트' 카테고리의 다른 글
  • [stylehub#8]100만건 상품 목록에서 Offset 대신 커서 페이징을 선택한 이유
  • [StyleHub#6]@Transactional 범위 최소화로 커넥션 점유 시간을 줄인 과정 (feat. BCrypt)
  • [StyleHub #5] 왜 QueryDSL을 도입했는가 — JPQL의 한계를 설계 단계에서 미리 마주하다
  • [StyleHub #3]Spring이라서 JPA를 선택하지 않았습니다 — StyleHub에서 JDBC, MyBatis, JPA를 비교한 이유
깊은바다속꼬북이
깊은바다속꼬북이
  • 깊은바다속꼬북이
    CodeBlossom
    깊은바다속꼬북이
  • 전체
    오늘
    어제
    • 분류 전체보기 (70)
      • 라이징 캠프 (4)
      • 객채지향 개발론 (2)
      • 스프링 (12)
      • 네트워크 (2)
      • 자바 (17)
      • 자료구조 (3)
      • 운영체제 (1)
      • 데이터베이스 (5)
      • 디자인패턴 (8)
      • JSP (1)
      • 개발 알쓸신잡 (3)
      • 일반 교양 (0)
      • 환경세팅 (1)
        • ai (1)
      • stylehub 프로젝트 (7)
      • 일상 (1)
      • JPA (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    백엔드 데이터베이스 설계
    porintcut
    GC
    spring
    디자인패턴
    Spring 공통 관심사
    백엔드
    토큰 절약
    객체지향
    디자인 패턴
    MyBatis vs ORM차이
    backend
    cloude code설치
    1차2차 캐시
    기술선택이유
    자료구조
    MyBatis vs ORM
    토큰 절약 방법
    스프링
    claudecode
    SQLMapper
    AOP
    mybatis
    JPA
    데이터베이스
    커서기반
    개발자 성장기
    java
    JVM
    MyBatis vs ORM비교
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
깊은바다속꼬북이
[StyleHub#7]Transactional이 동작하지 않는다? — Spring Self-Invocation 버그 발견과 해결
상단으로

티스토리툴바