Spring Boot

Spring Boot에서 @Transactional 제대로 쓰기(전파/읽기전용) — 프록시부터 실수까지

IT Lab 2026. 3. 11. 20:00

Spring Boot 3.x에서 @Transactional이 프록시로 동작하는 방식, readOnly의 실제 효과, 전파 옵션(Propagation) 선택 기준과 현업에서 자주 하는 실수를 코드로 정리합니다.

도입 (문제 상황)

서비스 메서드에 @Transactional을 붙였는데도 “왜 롤백이 안 되지?” 혹은 “readOnly로 했는데도 업데이트 쿼리가 나가네?” 같은 상황을 한 번쯤 겪으셨을 거예요. 특히 계층형 구조(Controller/Service/Repository)를 잘 나눠도, 트랜잭션 경계가 어긋나면 데이터 정합성이 쉽게 깨집니다.
이번 글에서는 @Transactional이 **어떻게 동작하는지(프록시)**부터 readOnly의 진짜 의미, 전파 옵션 선택, 자주 하는 실수를 실무 관점으로 정리해 봅니다.

핵심 개념: Spring Boot @Transactional이 “왜” 중요한가

@Transactional은 단순히 “DB 작업을 묶는 애노테이션”이 아니라, **데이터 변경의 원자성(Atomicity)**과 **일관성(Consistency)**을 지키는 경계선입니다. 이 경계가 애매하면 다음 문제가 자주 생깁니다.

  • 일부만 커밋되고 일부는 실패(부분 성공) → 장애 대응이 매우 어려움
  • 읽기 API가 의도치 않게 쓰기 락/플러시를 유발 → 성능 저하
  • 트랜잭션이 중첩되면서 롤백 규칙이 꼬임 → “왜 롤백 안 됨?” 이슈

1) 프록시 기반 동작: “호출 경로”가 핵심입니다

Spring의 선언적 트랜잭션은 기본적으로 AOP 프록시로 구현됩니다. 즉, 빈을 감싼 프록시가 메서드 호출 전후로 트랜잭션을 시작/커밋/롤백합니다.

sequenceDiagram
  participant C as "Controller"
  participant P as "Service Proxy"
  participant S as "Service Target"
  participant TM as "Transaction Manager"
  participant R as "Repository"

  C->>P: "call service()"
  P->>TM: "begin"
  P->>S: "invoke"
  S->>R: "DB access"
  P->>TM: "commit/rollback"

Spring 트랜잭션 프록시가 요청을 가로채 트랜잭션을 시작/커밋하는 흐름

프록시가 “가로채서” 트랜잭션을 열고 닫는 흐름입니다.

여기서 중요한 포인트는 프록시를 거쳐야만 @Transactional이 적용된다는 점입니다. 그래서 아래가 대표적인 함정입니다.

  • 같은 클래스 내부에서 this.someTxMethod()로 호출(= self-invocation)하면 프록시를 우회 → 트랜잭션이 안 걸릴 수 있음
  • private 메서드에 붙여도 보통 의미 없음(프록시가 가로채기 어려움)
  • Bean으로 관리되지 않는 객체에 붙여도 적용되지 않음

2) readOnly=true: “쓰기 금지”가 아니라 “쓰기 최적화 힌트”에 가깝습니다

@Transactional(readOnly = true)는 DB에 “절대 쓰지 마”를 강제하는 만능 스위치가 아닙니다. 보통 다음 효과를 기대할 수 있습니다.

  • (JPA/Hibernate) Flush 모드 조정 등으로 불필요한 변경 감지를 줄여 성능에 도움
  • DB 드라이버/DB 설정에 따라 read-only 트랜잭션 최적화가 적용될 수도 있음(항상 보장되진 않음)

하지만 다음은 오해입니다.

  • readOnly를 붙이면 UPDATE/INSERT가 무조건 막힌다 → 아닙니다. (환경/구현체에 따라 다르고, 애플리케이션 레벨에서 완전 차단을 보장하지 않습니다)
  • readOnly면 트랜잭션이 없는 것과 같다 → 아닙니다. 트랜잭션은 열립니다. (일관된 읽기, Lazy 로딩 등에서 의미가 큼)

정리하면 readOnly는 “안전장치”라기보다 “성능과 의도를 표현하는 설정”에 가깝습니다. 정말 쓰기를 막고 싶다면 DB 권한/분리(읽기 전용 계정, 리드 레플리카) 같은 운영 설계가 필요합니다.

3) 전파(Propagation): “이미 트랜잭션이 있을 때” 어떻게 할지 결정합니다

전파 옵션은 “현재 호출이 기존 트랜잭션 안에서 실행되는가?”를 다룹니다. 실무에서 자주 쓰는 옵션만 비교하면 다음과 같습니다.

전파 옵션 기존 트랜잭션이 있으면 없으면 주 사용처
REQUIRED(기본) 기존에 참여 새로 생성 대부분의 서비스 메서드
REQUIRES_NEW 기존을 잠시 중단하고 새로 생성 새로 생성 “로그는 반드시 남겨야 함” 같은 독립 커밋
SUPPORTS 있으면 참여 트랜잭션 없이 실행 읽기 API에서 선택적으로
MANDATORY 반드시 참여 예외 발생 “무조건 트랜잭션 안”을 강제할 때
NEVER 트랜잭션이 있으면 예외 트랜잭션 없이 실행 트랜잭션을 절대 열면 안 되는 작업(드묾)

특히 REQUIRES_NEW는 강력하지만 비용이 있습니다. 커넥션을 새로 확보하고(혹은 별도 트랜잭션을 열고) 기존 트랜잭션과 운명을 분리하므로, 남용하면 성능/데드락/일관성 측면에서 복잡해집니다.

4) 롤백 규칙: “어떤 예외에서 롤백되는가”

Spring의 기본 규칙은 다음입니다.

  • RuntimeException / Error → 롤백
  • Checked Exception → 커밋(기본)

Checked Exception에서도 롤백하려면 rollbackFor를 명시해야 합니다. 이 때문에 “예외는 났는데 커밋돼 버림” 이슈가 종종 발생합니다.


 

코드 예제: 전파/읽기전용/프록시 함정까지 한 번에 재현하기 (Spring Boot 3.x)

아래 예제는 H2 + Spring Data JPA로 구성했고, 실행 후 간단한 엔드포인트 호출로 다음을 확인할 수 있습니다.

  • readOnly=true에서 쓰기를 시도했을 때의 동작(환경에 따라 “막힘”이 보장되지 않음을 체감)
  • REQUIRES_NEW로 감사 로그를 “독립 커밋”하는 패턴
  • 같은 클래스 내부 호출(self-invocation)로 @Transactional이 적용되지 않는 함정

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') { useJUnitPlatform() }

application.yml

spring:
  datasource:
    url: jdbc:h2:mem:txdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
  h2:
    console:
      enabled: true

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace

도메인/리포지토리

package com.example.tx;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
public class Member {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    protected Member() {}

    public Member(String email) {
        this.email = email;
    }

    public Long getId() { return id; }
    public String getEmail() { return email; }
    public void changeEmail(String email) { this.email = email; }
}

@Entity
public class AuditLog {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String message;

    @Column(nullable = false)
    private Instant createdAt = Instant.now();

    protected AuditLog() {}

    public AuditLog(String message) {
        this.message = message;
    }

    public Long getId() { return id; }
    public String getMessage() { return message; }
    public Instant getCreatedAt() { return createdAt; }
}
package com.example.tx;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email);
}

public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
}

서비스: 전파/읽기전용/프록시 함정

REQUIRED와 REQUIRES_NEW 전파로 트랜잭션이 중단/분리되는 개념도

package com.example.tx;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class MemberService {

    private final MemberRepository memberRepository;
    private final AuditService auditService;

    public MemberService(MemberRepository memberRepository, AuditService auditService) {
        this.memberRepository = memberRepository;
        this.auditService = auditService;
    }

    // 기본 전파(REQUIRED): 대부분의 쓰기 로직은 이 패턴이 기본값입니다.
    @Transactional
    public Long register(String email) {
        Member saved = memberRepository.save(new Member(email));
        return saved.getId();
    }

    // readOnly는 "쓰기 금지"가 아니라 "읽기 최적화 힌트"에 가깝습니다.
    @Transactional(readOnly = true)
    public String findEmailAndTryToWrite(Long memberId, String newEmail) {
        Member m = memberRepository.findById(memberId)
                .orElseThrow(() -> new IllegalArgumentException("not found"));

        // 의도적으로 수정 시도: 환경에 따라 flush 시점에 예외가 나거나,
        // 또는 특정 설정에서는 UPDATE가 나갈 수도 있습니다(= readOnly가 강제 차단이 아님).
        m.changeEmail(newEmail);

        return m.getEmail();
    }

    // 전파 예시: 메인 로직은 실패해도 감사 로그는 남기고 싶은 경우
    @Transactional
    public void changeEmailWithAudit(Long memberId, String newEmail) {
        Member m = memberRepository.findById(memberId)
                .orElseThrow(() -> new IllegalArgumentException("not found"));

        m.changeEmail(newEmail);

        // 감사 로그는 독립 트랜잭션으로 커밋
        auditService.writeRequiresNew("email change requested: memberId=" + memberId);

        // 일부러 실패시켜 롤백 유도
        throw new RuntimeException("boom - main tx rollback");
    }

    // 프록시 함정: 같은 클래스 내부 호출은 프록시를 우회할 수 있습니다.
    public void outerCallsInnerDirectly(Long memberId, String newEmail) {
        // 아래 호출은 this.innerTransactional(...)과 동일한 경로가 될 수 있어
        // @Transactional이 기대대로 적용되지 않는 대표 케이스입니다.
        innerTransactional(memberId, newEmail);
    }

    @Transactional
    public void innerTransactional(Long memberId, String newEmail) {
        Member m = memberRepository.findById(memberId)
                .orElseThrow(() -> new IllegalArgumentException("not found"));
        m.changeEmail(newEmail);
    }
}
package com.example.tx;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AuditService {

    private final AuditLogRepository auditLogRepository;

    public AuditService(AuditLogRepository auditLogRepository) {
        this.auditLogRepository = auditLogRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void writeRequiresNew(String message) {
        auditLogRepository.save(new AuditLog(message));
    }
}

컨트롤러: 호출용 엔드포인트

package com.example.tx;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/tx")
public class TxController {

    private final MemberService memberService;
    private final MemberRepository memberRepository;
    private final AuditLogRepository auditLogRepository;

    public TxController(MemberService memberService,
                        MemberRepository memberRepository,
                        AuditLogRepository auditLogRepository) {
        this.memberService = memberService;
        this.memberRepository = memberRepository;
        this.auditLogRepository = auditLogRepository;
    }

    @PostMapping("/members")
    public Long register(@RequestParam String email) {
        return memberService.register(email);
    }

    @PostMapping("/readonly-write")
    public String readOnlyWrite(@RequestParam Long memberId, @RequestParam String newEmail) {
        return memberService.findEmailAndTryToWrite(memberId, newEmail);
    }

    @PostMapping("/requires-new")
    public String requiresNew(@RequestParam Long memberId, @RequestParam String newEmail) {
        try {
            memberService.changeEmailWithAudit(memberId, newEmail);
            return "ok";
        } catch (Exception e) {
            long auditCount = auditLogRepository.count();
            String currentEmail = memberRepository.findById(memberId).map(Member::getEmail).orElse("n/a");
            return "main rolled back, auditCount=" + auditCount + ", emailNow=" + currentEmail;
        }
    }

    @PostMapping("/self-invocation")
    public String selfInvocation(@RequestParam Long memberId, @RequestParam String newEmail) {
        memberService.outerCallsInnerDirectly(memberId, newEmail);
        return "called";
    }
}

실행/확인 방법(간단)

  1. 회원 생성
    POST /tx/members?email=a@a.com
  2. REQUIRES_NEW 확인
    POST /tx/requires-new?memberId=1&newEmail=b@b.com
    → 메인 변경은 롤백되어 이메일이 그대로일 수 있지만, 감사 로그는 auditCount=1처럼 남는 것을 확인할 수 있습니다.
  3. self-invocation 함정은 로그/디버깅으로 확인해 보세요.
    /tx/self-invocation 호출 시 innerTransactional이 프록시를 거치지 않으면 트랜잭션 경계가 기대와 달라질 수 있습니다. (이 케이스는 “왜 트랜잭션이 안 열렸지?”를 재현할 때 유용합니다.)

 

실무 팁

💡 실무에서는
트랜잭션은 Service 계층에만 두는 규칙을 먼저 세우는 게 좋습니다. Controller에 @Transactional을 붙이면 웹 요청 범위 전체가 트랜잭션이 되어, 불필요하게 길어지고(외부 API 호출/파일 처리까지 포함), 락 경합이나 커넥션 고갈로 이어지기 쉽습니다. Repository에는 보통 붙이지 않아도 됩니다(호출자가 트랜잭션을 열어주는 구조가 더 예측 가능해요).

💡 실무에서는
REQUIRES_NEW는 “감사 로그/알림 기록” 같은 독립 커밋이 반드시 필요한 곳에만 제한적으로 쓰는 게 안전합니다. 남용하면 트랜잭션이 쪼개져서 “메인 데이터는 롤백됐는데 부가 데이터는 남는” 상태가 늘어나고, 운영 중 정합성 이슈로 되돌아오는 경우가 많습니다. 정말 필요한지 먼저 질문해 보시고, 필요하다면 “왜 분리 커밋이 맞는가”를 코드 주석/문서로 남겨두는 편이 좋습니다.


핵심 요약

  • @Transactional은 프록시 기반이라 프록시를 거치는 호출 경로가 아니면 적용되지 않을 수 있습니다.
  • readOnly=true는 대개 최적화 힌트이며 “쓰기 완전 차단”을 보장하지 않습니다.
  • 전파 옵션은 트랜잭션 중첩의 규칙이며, REQUIRES_NEW는 강력하지만 정합성/운영 비용을 동반합니다.

다음 글: #15 Spring Data JPA 시작(Repository 인터페이스)