티스토리 뷰

반응형

지난번에 작성했던 글에 이어서 추가로 작성해본다.

https://pooreumjung.tistory.com/602

 

[Spring Boot / 스프링 부트] - Spring @Transactional과 Proxy

✏️ 배경현재 한국대학생IT경영학회(KUSITMS, 큐시즘)에서 교육기획팀장으로 활동하고 있다. 최근 운영진과 학회원 모집을 진행하면서 많은 지원자들의 서류를 읽게 되었는데, 생각보다 많은 분

pooreumjung.tistory.com

 

✏️ 1. 트랜잭션이란, 그리고 Spring은 어떻게 관리하는가

트랜잭션은 "여러 개의 DB 작업을 하나의 단위로 묶어서, 전부 성공하거나 전부 실패하게 만드는 것"이다. ACID 네 가지 속성을 다시 정리하면:

  • 원자성(Atomicity): 중간에 하나라도 실패하면 전부 되돌아간다. 실무에서 가장 자주 체감하는 속성.
  • 일관성(Consistency): 트랜잭션 전후로 DB가 제약조건(FK, unique 등)을 항상 만족하는 상태를 유지한다.
  • 격리성(Isolation): 동시에 실행되는 트랜잭션끼리 서로의 중간 상태를 어디까지 볼 수 있는지 결정한다.
  • 지속성(Durability): 커밋된 결과는 서버가 죽어도 사라지지 않는다. (WAL, redo log 등 DB 엔진 레벨의 책임)

Spring에서는 이걸 직접 connection.commit() / connection.rollback()을 호출하지 않고 @Transactional 어노테이션으로 선언적으로 처리한다. 이게 가능한 이유는 AOP(관점 지향 프로그래밍) 프록시 덕분이다. @Transactional이 붙은 메서드를 호출하면, 실제로는 AuthService 원본이 아니라 Spring이 만든 프록시 객체가 먼저 호출된다. 프록시가 트랜잭션을 시작하고, 진짜 메서드를 실행하고, 예외 없이 끝나면 커밋, (기본적으로는) 런타임 예외가 터지면 롤백한다. 이 프록시를 거치지 않으면 @Transactional은 그냥 무시되는 어노테이션일 뿐이다.

@Service
public class AuthService {

    @Transactional
    public void withdraw(Long userId, String accessToken) {
        User user = userReader.findActiveUserById(userId);
        userService.withdrawUser(user);
        eventPublisher.publishEvent(new UserWithdrawCleanupRequestedEvent(...));
    }
}

1-1. 프록시는 어떤 방식으로 만들어지는가 (JDK vs CGLIB)

Spring AOP 프록시는 두 가지 방식으로 만들어진다.

  • JDK 동적 프록시: 대상 클래스가 인터페이스를 구현하고 있으면, 그 인터페이스를 기반으로 프록시를 만든다.
  • CGLIB 프록시: 인터페이스가 없으면 대상 클래스를 상속받아 프록시를 만든다. Spring Boot 2.0부터는 기본 전략이 CGLIB로 바뀌었다(spring.aop.proxy-target-class=true가 기본값).

CGLIB는 상속 기반이라 final 클래스나 final 메서드에는 적용할 수 없다. @Transactional이 붙은 서비스 클래스나 메서드를 실수로 final로 선언하면 프록시가 아예 생성되지 않거나 트랜잭션이 무시될 수 있다는 뜻이다.

1-2. 트랜잭션 매니저의 실체

@Transactional이 실제로 위임하는 대상은 PlatformTransactionManager 인터페이스다. JPA를 쓰면 Spring Boot가 자동으로 JpaTransactionManager를 빈으로 등록해준다. 이 매니저가 하는 일은 크게 세 가지다.

  1. getTransaction() - 새 트랜잭션을 시작하거나 기존 트랜잭션에 참여
  2. commit() - 정상 종료 시 커밋
  3. rollback() - 예외 발생 시 롤백

프록시 내부적으로는 TransactionInterceptor가 이 매니저를 호출하는 구조로 되어 있다. 이 구조를 알면 "왜 트랜잭션 경계가 메서드 단위인가"가 자연스럽게 이해된다 - 인터셉터가 메서드 호출을 감싸는 형태이기 때문이다.

 

✏️ 2. 전파(Propagation) - 트랜잭션 안에서 트랜잭션을 또 시작하면?

메서드 A가 트랜잭션 안에서 메서드 B를 호출하는데, B에도 @Transactional이 붙어있다면 어떻게 될까? 이걸 결정하는 게 전파 속성이다. Spring이 지원하는 전파 속성은 총 7가지이다.

전파 속성 동작
REQUIRED (기본값) 이미 트랜잭션이 있으면 거기 합류, 없으면 새로 시작
REQUIRES_NEW 무조건 새 트랜잭션을 시작 (기존 트랜잭션은 잠깐 보류)
SUPPORTS 이미 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행
NOT_SUPPORTED 트랜잭션 없이 실행. 기존 트랜잭션은 보류
MANDATORY 반드시 기존 트랜잭션이 있어야 함. 없으면 예외 발생
NEVER 트랜잭션이 있으면 안 됨. 있으면 예외 발생
NESTED 기존 트랜잭션 안에 savepoint를 만들어 중첩 실행. 중첩 부분만 롤백 가능 (JDBC savepoint 지원 필요)


세모산 코드에서 쓰는 건 REQUIREDREQUIRES_NEW다. 관리자 로그인 실패 로그를 남기는 메서드다. 만약 이게 REQUIRED였다면, 로그인 실패로 인해 바깥 트랜잭션이 예외를 던지고 롤백될 때 로그도 같이 사라진다. "로그인 실패했다"는 기록 자체가 실패 트랜잭션에 종속되면 안 되니까, 독립된 트랜잭션(REQUIRES_NEW)으로 무조건 커밋되게 만든 것이다. 감사 로그(audit log)의 전형적인 패턴이다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveFailLog(String username, String ipAddress, String userAgent, String reason) {
    adminLoginLogRepository.save(AdminLoginLog.fail(username, ipAddress, userAgent, reason));
}

2-1. REQUIRES_NEW의 숨은 비용

REQUIRES_NEW는 기존 트랜잭션을 잠깐 "보류(suspend)"하고 완전히 새로운 물리 트랜잭션을 시작한다. 즉 커넥션 풀에서 커넥션을 하나 더 빌린다는 뜻이다. 바깥 트랜잭션의 커넥션 하나 + 안쪽 REQUIRES_NEW의 커넥션 하나, 총 두 개를 동시에 물고 있게 된다. 트랜잭션 격리 수준에 따라 안쪽 트랜잭션이 바깥 트랜잭션이 아직 커밋하지 않은 데이터를 못 보는 경우도 생길 수 있으므로, 안쪽 트랜잭션에서 바깥 트랜잭션이 다루는 것과 같은 row를 다시 조회해야 한다면 주의가 필요하다.

2-2. 전파와 예외 전파의 관계

전파 속성은 "어느 트랜잭션에 합류하느냐"만 결정할 뿐, 예외가 발생했을 때 실제로 롤백되는 범위는 별개의 이야기다. REQUIRED로 합류한 내부 메서드에서 런타임 예외가 발생하면, Spring은 해당 트랜잭션 전체를 rollback-only로 마킹한다. 이 상태에서는 내부 메서드가 예외를 잡아서 삼키더라도, 바깥 트랜잭션이 커밋 시점에 UnexpectedRollbackException을 던지며 실패한다. "예외를 잡았는데 왜 롤백되지"라는 흔한 삽질의 원인이다.

 

✏️ 3. 격리 수준(Isolation Level) 

동시성 이슈를 이해하려면 먼저 세 가지 이상 현상을 알아야 한다.

  • Dirty Read: 다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽는 것
  • Non-Repeatable Read: 같은 트랜잭션 안에서 같은 row를 두 번 읽었는데 값이 다른 것 (그 사이에 다른 트랜잭션이 커밋함)
  • Phantom Read: 같은 조건으로 두 번 조회했는데 없던 row가 생기거나 사라지는 것

격리 수준은 이 현상들을 어디까지 허용할지를 정한다.

격리 수준 Dirty Read Non-Repeatable Read  Phantom Read
READ_UNCOMMITTED 발생 발생 발생
READ_COMMITTED 방지 발생 발생
REPEATABLE_READ 방지 방지 발생(엔진별 차이 있음)
SERIALIZABLE 방지 방지 방지

 

Spring @Transactional의 기본값은 Isolation.DEFAULT, 즉 DB 드라이버가 설정한 기본 격리 수준을 그대로 따른다. MySQL(InnoDB)은 기본이 REPEATABLE_READ, PostgreSQLOracle은 기본이 READ_COMMITTED다. 같은 코드라도 어떤 DB를 쓰느냐에 따라 동시성 동작이 달라질 수 있다는 뜻이므로, 로컬은 H2/PostgreSQL이고 운영은 MySQL인 것처럼 DB가 다르면 이 부분을 꼭 확인해야 한다.

격리 수준을 올릴수록 정합성은 좋아지지만 락 경합이 늘어나서 처리량이 떨어진다. 실무에서 SERIALIZABLE을 애플리케이션 레벨에서 명시적으로 쓰는 경우는 드물고, 대부분은 비관적 락(@Lock(LockModeType.PESSIMISTIC_WRITE))이나 낙관적 락(@Version)으로 필요한 구간만 좁혀서 처리한다.

 

✏️ 4. readOnly = true -  조회 전용 트랜잭션

UserReader의 조회 메서드들은 전부 readOnly = true가 붙어있다. 이건 단순 표시가 아니라

  • Hibernateflush를 생략하고 dirty checking(변경 감지) 스냅샷을 안 만들어서 성능이 좋아지고
  • 실수로 조회 메서드 안에서 엔티티를 수정해도 DB에 반영되지 않도록 막아주는 안전장치 역할도 한다
  • DB 드라이버가 지원하면 커넥션에 Connection.setReadOnly(true)를 걸어줘서, 일부 DB(예: 읽기 전용 replica로 라우팅하는 구성)에서는 읽기 전용 복제본으로 쿼리를 분산시키는 데도 활용된다

한 가지 자주 하는 실수는, 클래스 레벨에 @Transactional(readOnly = true)를 걸어놓고 그 안의 쓰기 메서드에 @Transactional만 다시 붙이면서 readOnly 속성을 깜빡하는 경우다. 메서드 레벨 어노테이션이 클래스 레벨을 덮어쓰기 때문에, 쓰기 메서드는 명시적으로 readOnly = false(기본값)로 다시 선언해야 한다. 클래스 전체를 readOnly = true로 걸고, 쓰기 메서드에만 개별로 @Transactional을 다시 붙이는 패턴을 많이 쓴다.

@Transactional(readOnly = true)
public User findActiveUserById(Long userId) {
    return userRepository.findByIdAndDeletedFalse(userId)
            .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));
}

 

✏️ 5. self-invocation - 프록시를 우회하면 트랜잭션이 사라진다

1번에서 말했듯 @Transactional프록시를 거쳐야만 동작한다. 그런데 같은 클래스 안에서 this.otherMethod()처럼 자기 자신의 메서드를 호출하면, 이건 프록시를 거치지 않고 그냥 자바 메서드 호출이 되어버린다 - @Transactional이 조용히 무시된다.

TrackingPointFlushService는 이 문제를 피하려고 아예 별도 빈으로 분리된 케이스다. 주석에 이유가 그대로 적혀있다:


OAuthLoginProcessor도 같은 이유로 분리됐다 - "유저 생성 + 토큰 발급"을 하나의 원자적 트랜잭션으로 묶어야 하는데, 이걸 OAuthService 내부 메서드로 두면 self-invocation 문제에 걸릴 수 있어서 별도 빈으로 뺐다.

/**
 * 별도 서비스로 분리한 이유:
 *  - TrackingStreamConsumer 내부에서 @Transactional 메서드를 self-invocation 으로 호출하면
 *    Spring AOP proxy 가 적용되지 않아 트랜잭션이 무효화된다.
 *  - 외부 빈으로 분리하면 호출 시 proxy 를 거치므로 @Transactional 이 정상 동작한다.
 */
@Service
public class TrackingPointFlushService {

    @Transactional
    public int flush(Long sessionId, List<PendingPoint> pendings) {
        ...
    }
}

5-1. 별도 빈 분리 말고 다른 대안은 없을까?

있긴 하다. 대표적으로 두 가지가 더 있지만, 둘 다 별도 빈 분리보다 권장되지 않는다.

  • AopContext.currentProxy(): <aop:aspectj-autoproxy expose-proxy="true"/> 설정 후 ((MyService) AopContext.currentProxy()).method() 형태로 자기 자신의 프록시를 얻어와 호출. 코드가 AOP 구현 세부사항에 강하게 의존하게 되어 지저분해진다.
  • 자기 자신을 주입받기(self-injection): 생성자나 필드에 @Lazy private final MyService self;처럼 프록시를 주입받아 self.method()로 호출. 순환 참조처럼 보여서 가독성이 떨어진다.

우리 코드처럼 책임 단위로 별도 서비스/빈을 분리하는 게 가장 명확하고, 부수적으로 단일 책임 원칙(SRP)에도 맞아떨어진다.

정리: @Transactional을 특정 메서드에만 걸고 싶은데 그 메서드가 같은 클래스의 다른 메서드에서 호출된다면, 반드시 별도 빈으로 분리해야 한다.

 

✏️ 6. 롤백 규칙 - 모든 예외가 롤백을 유발하지는 않는다

의외로 놓치는 사람이 많은 부분이다. @Transactional의 기본 롤백 정책은 다음과 같다.

  • RuntimeException과 Error → 롤백
  • 체크 예외(checked exception) → 롤백하지 않고 그대로 커밋

IOException 같은 체크 예외를 던지는 메서드에 아무 설정 없이 @Transactional만 붙이면, 예외가 발생해도 그때까지 실행된 DB 작업은 그대로 커밋돼버린다. 이건 EJB 시절부터 내려온 관례를 Spring이 그대로 따른 것이라 처음 보면 직관에 안 맞는다고 느끼기 쉽다.

이 기본 정책을 바꾸고 싶으면 명시적으로 지정한다.

@Transactional(rollbackFor = Exception.class)
public void processPayment() throws IOException {
    ...
}

반대로 특정 런타임 예외는 롤백시키고 싶지 않을 때는 noRollbackFor를 쓴다. 예를 들어 "재고 부족은 비즈니스적으로는 실패지만, 이미 처리한 포인트 적립까지 되돌릴 필요는 없다" 같은 케이스가 있다면 검토해볼 만하다. 다만 이런 예외적인 처리는 트랜잭션 경계를 쪼개는 쪽(즉 로직을 나누는 쪽)이 더 명확할 때가 많다.

 

커스텀 예외 클래스를 만들어 쓰는 프로젝트라면, 그 예외가 RuntimeException을 상속하는지, 그리고 롤백이 필요한 상황에서 실제로 롤백되는지를 한 번은 테스트 코드로 확인해두는 게 안전하다.

 

✏️ 7. @TransactionalEventListener - DB가 커밋된 뒤에만 실행하기

탈퇴, 좋아요 알림처럼 "DB 작업은 성공했는데 그 이후에 외부 시스템(Redis, FCM, 외부 API)까지 건드려야 하는" 상황에서 골치 아픈 문제가 하나 있다. 트랜잭션 도중에 외부 호출을 해버리면, 그 트랜잭션이 나중에 롤백될 때 이미 나간 외부 호출은 되돌릴 수 없다.

 

UserWithdrawCleanupEventListener는 회원 탈퇴 트랜잭션이 실제로 커밋된 뒤에만 실행된다. @TransactionalEventListener(phase = AFTER_COMMIT)가 이걸 보장한다 -  만약 탈퇴 처리 도중 예외로 롤백되면, 이 리스너 자체가 아예 호출되지 않는다. "DB는 롤백됐는데 Redis 토큰은 이미 삭제됨" 같은 불일치를 원천 차단하는 방식이다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserWithdrawCleanupRequested(UserWithdrawCleanupRequestedEvent event) {
    invalidateTokens(event);   // JWT 블랙리스트, refresh token 삭제 (Redis)
    dispatcher.dispatch(event); // FCM 토큰 삭제, 카카오 unlink (외부 API)
}

7-1. AFTER_COMMIT 말고 다른 phase도 있다

TransactionPhase는 네 가지를 지원한다. 주변에서 많이 쓰는 건 AFTER_COMMIT인 것 같다. AFTER_ROLLBACK은 "실패 시 보상 처리(compensating action)"가 필요할 때, 예를 들어 결제 실패 시 재시도 큐에 넣는다든가 하는 상황에 쓸 수 있다.

phase 실행 시점
BEFORE_COMMIT 커밋 직전 (아직 트랜잭션 안, 여기서 예외 나면 롤백됨)
AFTER_COMMIT (기본값) 커밋이 완전히 끝난 뒤
AFTER_ROLLBACK 롤백된 뒤
AFTER_COMPLETION 커밋이든 롤백이든 트랜잭션이 끝나면 무조건

 

7-2. 이벤트를 발행한 트랜잭션이 애초에 존재하지 않는 경우

@TransactionalEventListener는 이벤트를 발행하는 시점에 활성 트랜잭션이 존재해야 동작한다. 만약 트랜잭션 없이(예: 배치 잡의 트랜잭션 경계 밖에서, 혹은 @Transactional이 안 걸린 메서드에서) 이벤트를 발행하면, 기본 설정으로는 리스너가 아예 호출되지 않는다. fallbackExecution = true 옵션을 주면 트랜잭션이 없을 때도 즉시 실행하게 만들 수 있다.

게시글 좋아요 알림(PostLikedEventListener)은 여기에 REQUIRES_NEW까지 결합한 케이스다:

 

좋아요 저장 커밋 → 알림 발송이 완전히 별개의 트랜잭션으로 분리되어 있다. 알림 발송 중 뭔가 실패해도(2번의 REQUIRES_NEW 패턴과 동일한 이유로) 원래 좋아요 트랜잭션에는 영향을 주지 않고, 예외도 여기서 잡아서 로그만 남기고 삼킨다 - "알림 실패했다고 좋아요 자체가 취소되면 안 되니까"다.

 

@TransactionalEventListener는 이미 AFTER_COMMIT 시점(원래 트랜잭션이 끝난 뒤)에 호출되므로, 여기 붙은 @Transactional(REQUIRES_NEW)는 "원래 트랜잭션을 잠깐 보류"하는 의미보다는, 알림 발송용 DB 작업(postRepository.findById, 알림 저장 등)을 위한 새 트랜잭션을 만든다는 의미로 보는 게 좋을 것 같다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onPostLiked(PostLikedEvent event) {
    try {
        Post post = postRepository.findByIdAndDeletedFalse(event.postId()).orElse(null);
        ...
        communityNotificationService.sendPostLikeNotification(post, actor);
    } catch (Exception e) {
        log.warn("게시글 좋아요 알림 생성 실패: ...", e);
    }
}

 

✏️ 8. 트랜잭션 범위 - 외부 API 호출을 트랜잭션 안에 넣으면 안 되는 이유

@Transactional이 걸린 메서드는 시작할 때 DB 커넥션 풀에서 커넥션을 하나 빌려와서, 메서드가 끝날 때까지 붙들고 있는다. 그 안에 카카오 로그인 API 호출처럼 네트워크 왕복이 있는 느린 작업이 섞여 있으면, 그 시간 동안 커넥션 하나가 통째로 묶여버린다. 동시 요청이 몰리면 커넥션 풀이 금방 고갈된다.


HikariCP 기본 풀 사이즈가 10인 상태에서 외부 API 응답이 평소보다 2~3초씩 느려지기만 해도, 커넥션 대기 큐가 쌓이면서 트래픽이 몰릴 때 서비스 전체가 도미노처럼 느려질 수 있다. 원래 OAuthService.kakaoLogin()은 카카오 API 호출과 유저 저장을 한 트랜잭션 안에서 처리했는데, 이렇게 바꿨다.

 

카카오 API 호출은 트랜잭션 시작 전에 끝내놓고, @Transactional이 걸린 OAuthLoginProcessor.login()은 순수하게 "유저 조회/생성 + 토큰 발급"이라는 DB 작업만 담당하도록 분리했다. 커넥션을 붙잡는 시간이 네트워크 왕복 시간만큼 줄어든다.

 

같은 원리로, 트랜잭션 메서드 안에서 Redis 호출이나 파일 I/O처럼 DB와 무관한 작업을 하는 것도 가급적 피해야 한다. "이 작업이 실패하면 DB 작업도 같이 롤백돼야 하는가?"를 기준으로 트랜잭션 경계 안에 넣을지 판단하면 된다. 롤백과 무관한 작업이면 트랜잭션 밖으로 빼거나, 7번에서 다룬 AFTER_COMMIT 이벤트로 분리하는 게 맞다.

public OAuthLoginResponse kakaoLogin(OAuthKakaoLoginRequest request) {
    // 1. 외부 API 호출 — 트랜잭션 밖
    KakaoUserInfoResponse userInfo = oAuthKakaoClient.getKakaoUserInfo(request.accessToken());
    OAuthUserProfile profile = toKakaoOAuthUserProfile(userInfo);

    // 2. DB 작업만 트랜잭션 안에서 (OAuthLoginProcessor, 별도 빈)
    return oAuthLoginProcessor.login(profile, OAuthProvider.KAKAO, request.deviceType());
}

 

✏️ 9. 테스트에서의 @Transactional

통합 테스트에서 @Transactional을 쓰면 스프링이 테스트 메서드가 끝날 때 자동으로 롤백해준다(기본값). 각 테스트가 서로 데이터에 영향을 주지 않게 하는 데 유용하다. 다만 여기엔 함정이 있다. 테스트 메서드에 @Transactional이 걸려있으면 테스트 자체가 하나의 트랜잭션이 되기 때문에, 검증하려는 서비스 로직 내부의 REQUIRES_NEW@TransactionalEventListener(AFTER_COMMIT)가 테스트 환경에서는 실제 운영과 다르게 동작할 수 있다.

 

AFTER_COMMIT 리스너는 테스트 트랜잭션이 끝까지 롤백되고 커밋되지 않으므로 아예 호출되지 않는다. 이런 로직을 검증하려면 @Transactional을 빼고 @Sql이나 @AfterEach로 직접 데이터를 정리하거나, TestTransaction API로 커밋 시점을 강제로 만들어야 한다.

@SpringBootTest
class UserServiceTest {

    @Test
    @Transactional
    void 유저_탈퇴_시_상태가_변경된다() {
        ...
        // 테스트 종료 후 자동 롤백 — DB에 흔적이 안 남는다
    }
}

 

✏️ 10. 트랜잭션과 자주 엮이는 함정들

  • LazyInitializationException: 지연 로딩된 연관 엔티티는 영속성 컨텍스트가 살아있는(=트랜잭션이 열려있는) 동안에만 접근할 수 있다. 트랜잭션이 끝난 뷰 레이어나 DTO 변환 시점에서 지연 로딩 필드를 건드리면 이 예외가 난다. 필요한 데이터는 트랜잭션 안에서 DTO로 미리 변환해서 꺼내는 게 정석이다.
  • N+1 문제와 readOnly: readOnly = true는 N+1 문제 자체를 막아주지 않는다. 별개의 문제이므로 fetch join이나 @EntityGraph, batch fetch size 설정으로 따로 대응해야 한다.
  • 긴 트랜잭션의 락 경합: 트랜잭션이 오래 열려있을수록 그 안에서 건드린 row에 대한 락도 오래 붙잡고 있게 된다. 트랜잭션 메서드는 "짧고 굵게" 끝내는 게 기본 원칙이다.

이 글의 코드 예시는 모두 SEMOSAN 백엔드에서 가져온 것이며, 새로 추가된 개념 설명(격리 수준, 롤백 규칙, 테스트 관련 항목 등)은 Spring 프레임워크의 일반적인 동작을 기준으로 작성했다.

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함