Spring Boot 실무 가이드 · Part 7

계층 구조와 트랜잭션

어디에 무엇을 두고 트랜잭션 경계를 어디에 그을지 판단하기

작성 기준2026년 7월버전과 지원 현황은 이후 달라질 수 있으니 공식 문서를 함께 확인하세요.

이 파트에서 다루는 내용

계층별 책임@Transactional 동작 원리롤백 규칙트랜잭션 범위 관리
01

계층은 나누는 것보다 지키는 것이 어렵습니다

Controller, Service, Repository로 폴더를 나누는 것은 쉽습니다. 문제는 시간이 지나면서 책임이 섞이는 것입니다. 컨트롤러에 업무 규칙이 들어가고, 서비스가 HTTP를 알게 되면 계층은 이름만 남습니다.

Controller
입출력 변환
  • 요청을 DTO로 받고 형식을 검증합니다
  • 서비스를 호출하고 결과를 응답으로 바꿉니다
  • 상태 코드를 결정합니다
  • 업무 규칙 판단은 하지 않습니다
Service
업무 규칙과 트랜잭션
  • 여러 저장소 작업을 하나의 업무 단위로 묶습니다
  • 트랜잭션 경계를 여기에 둡니다
  • HttpServletRequest 같은 웹 타입을 알지 않습니다
  • 다른 서비스를 호출할 수 있지만 순환하지 않게 설계합니다
Repository
데이터 접근
  • 저장, 조회, 삭제만 담당합니다
  • 업무 규칙에 따른 분기를 넣지 않습니다
  • 반환 타입은 엔티티나 조회 전용 객체입니다
판단 기준

이 코드를 웹이 아닌 배치나 메시지 수신에서도 그대로 호출할 수 있는가를 물어보면 됩니다. 서비스가 HTTP 타입을 참조하고 있다면 계층이 새고 있는 것입니다.

02

@Transactional은 프록시가 대신 처리합니다

@Transactional을 붙이면 Spring이 해당 빈을 감싼 프록시 객체를 만듭니다. 외부에서 메서드를 호출하면 프록시가 먼저 트랜잭션을 시작하고, 실제 메서드를 실행한 뒤, 결과에 따라 커밋하거나 롤백합니다.

이 구조에서 나오는 제약이 실무 사고의 대부분을 차지합니다. 프록시를 거치지 않는 호출에는 트랜잭션이 적용되지 않습니다.

self-invocation
가장 흔한 함정

같은 클래스 안에서 메서드를 직접 호출하면 프록시를 거치지 않습니다. @Transactional을 붙였어도 트랜잭션이 시작되지 않습니다.

private 메서드
적용 안 됨

프록시가 오버라이드할 수 없으므로 애노테이션이 무시됩니다. 오류도 나지 않아 발견이 늦습니다.

해결 방법
구조 분리

트랜잭션이 필요한 메서드를 별도 빈으로 분리해 주입받아 호출합니다. 자기 자신을 주입하는 우회는 가독성을 해칩니다.

동작하지 않는 코드와 해결java
// 동작하지 않습니다: 내부 호출은 프록시를 거치지 않습니다
@Service
public class OrderService {

    public void processAll(List<Long> ids) {
        for (Long id : ids) {
            processOne(id);   // 프록시 미경유 -> 트랜잭션 없음
        }
    }

    @Transactional
    public void processOne(Long id) { }
}

// 해결: 트랜잭션 단위를 별도 빈으로 분리합니다
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderProcessor orderProcessor;

    public void processAll(List<Long> ids) {
        for (Long id : ids) {
            orderProcessor.processOne(id);   // 프록시 경유 -> 트랜잭션 적용
        }
    }
}

@Service
public class OrderProcessor {

    @Transactional
    public void processOne(Long id) { }
}

건별로 트랜잭션을 나누면 한 건이 실패해도 나머지가 처리됩니다. 반대로 전부 하나로 묶고 싶다면 processAll에 @Transactional을 붙입니다. 어느 쪽이 맞는지는 업무 요구에 따라 다릅니다.

03

롤백 규칙을 모르면 데이터가 조용히 남습니다

기본 동작은 RuntimeException과 Error가 발생했을 때만 롤백입니다. 체크 예외(Exception을 상속한 일반 예외)는 발생해도 커밋됩니다.

자바 표준 예외 중 IOException처럼 체크 예외를 던지는 코드를 트랜잭션 안에서 쓰면, 실패했는데 앞선 저장 작업은 그대로 반영되는 상황이 생깁니다.

기본 롤백 대상
RuntimeException, Error

직접 만든 업무 예외를 RuntimeException 계열로 만들면 별도 설정 없이 롤백됩니다. 대부분의 프로젝트가 이 방식을 씁니다.

체크 예외
롤백되지 않음

rollbackFor로 명시해야 롤백됩니다. 이 사실을 모르면 부분 반영된 데이터가 남습니다.

예외를 잡으면 롤백 안 됨
주의

메서드 안에서 try-catch로 예외를 삼키면 프록시가 예외를 보지 못해 커밋됩니다. 처리 후 다시 던지거나 롤백을 명시해야 합니다.

롤백 지정java
// 체크 예외도 롤백하려면 명시합니다
@Transactional(rollbackFor = Exception.class)
public void importFile(Path path) throws IOException {
    orderRepository.saveAll(parse(path));
    archive(path);   // IOException 발생 시에도 롤백
}

// 조회 전용은 readOnly를 지정합니다
@Transactional(readOnly = true)
public List<OrderResponse> findAll() {
    return orderRepository.findAll().stream()
            .map(OrderResponse::from)
            .toList();
}

readOnly는 변경 감지를 하지 않아 불필요한 비교 작업이 줄고, 읽기 전용 의도가 코드에 드러납니다. DB에 따라 읽기 전용 커넥션으로 라우팅되기도 합니다.

04

트랜잭션은 짧게 유지합니다

트랜잭션이 열려 있는 동안 DB 커넥션이 점유됩니다. 이 시간이 길어지면 커넥션 풀이 고갈되고, 전혀 관계없는 요청까지 대기하다 타임아웃됩니다.

가장 흔한 원인은 트랜잭션 안에서 외부 API를 호출하는 것입니다. 외부 서버가 느려지면 우리 서비스 전체가 함께 멈춥니다.

트랜잭션 안에 넣지 말 것
사고 원인
  • 외부 API 호출과 결제 요청
  • 파일 업로드·다운로드
  • 메일·알림 발송
  • 오래 걸리는 계산이나 대량 반복문
구성 방법
권장
  • 외부 호출은 트랜잭션 밖에서 먼저 수행합니다
  • DB 변경만 짧은 트랜잭션으로 묶습니다
  • 발송·알림은 커밋 이후 처리로 분리합니다
확인 방법
운영

커넥션 풀 대기 시간과 느린 쿼리 로그를 함께 봅니다. 특정 API 호출 시점에 대기가 몰리면 트랜잭션 범위를 의심합니다.

설계 원칙

트랜잭션은 '되돌릴 수 있어야 하는 DB 작업'만 감쌉니다. 외부로 나간 결제 요청이나 발송된 메일은 롤백해도 되돌아오지 않습니다. 롤백할 수 없는 작업은 트랜잭션 안에 둘 이유가 없습니다.

버전

3.x와 4.x 차이

본문은 현장에서 가장 많이 쓰는 3.x 기준입니다. 4.x에서 달라진 부분만 아래에 정리합니다.

  • @Transactional의 동작 방식과 롤백 규칙은 3.x와 4.x가 같습니다. Spring Framework 5 시절과도 동일한 규칙입니다.
  • Java 21 이상에서 가상 스레드를 사용하면 커넥션 점유 패턴이 달라질 수 있습니다. 4.0은 가상 스레드 지원이 강화됐지만, 트랜잭션을 짧게 유지해야 한다는 원칙 자체는 그대로입니다.
  • 가상 스레드 사용 시 커넥션 풀 크기를 기존 감각대로 잡으면 오히려 병목이 생길 수 있습니다. 적용 전 부하 테스트로 확인합니다.
체크

이 파트 완료 기준