Back-end

[Java] Spring 트랜잭션 처리 방식 ㅡ @Transactional vs AOP

somuxsomu 2026. 4. 7. 10:24
반응형

Spring 트랜잭션 처리 방식 정리 ㅡ @Transactional vs AOP


 

프로젝트 코드를 보는데 @Transactional이 하나도 없는데 트랜잭션은 잘 돌아가고 있었다.

"이거 어디서 걸리는 거지?" 싶어서 Config파일을 확인해보기 AOP 방식으로 패키지 전체에 자동 적용하고 있었다.

궁금한 김에 @Transactional 방식과 함께 정리해본다! 


1. 트랜잭션이란?

트랜잭션은 여러 DB 작업을 하나의 논리적 단위로 묶는 것이다.

묶인 작업들은 전부 성공하면 commit, 하나라도 실패하면 전부 rollback된다.

 

트랜잭션은 왜 필요할까?

예를 들어 쇼핑몰에서 주문을 처리한다고 해보자.

주문 처리 예시:
  1) 주문 상태 UPDATE  ─┐
  2) 결제 정보 INSERT   ├─ 하나의 트랜잭션
  3) 재고 수량 UPDATE  ─┘

  → 3번에서 실패하면 1, 2번도 모두 취소

 

 

만약 트랜잭션 없이 이 작업을 처리하면 어떻게 될까?

3번(재고 차감)에서 에러가 나도 1번(주문 상태 변경)과 2번(결제 정보 저장)은 이미 DB에 반영되어 버린다.

결제는 됐는데 재고는 안 빠진 상태, 또는 주문은 "완료"인데 결제가 실패한 상태 같은 데이터 불일치가 생긴다.

트랜잭션은 이런 상황을 방지하기 위해 "전부 되거나, 전부 안 되거나" 를 보장해주는 장치이다.


2. Spring Prjoect에서 트랜잭션을 적용하는 두 가지 방식

Spring에서는 트랜잭션을 적용하는 방법이 크게 두 가지가 있다.

- @Transactional 어노테이션 방식

- AOP 방식

 

구분  @Transactional (어노테이션) AOP 방식
적용 방법 메서드/클래스에 직접 어노테이션 부착 Config 파일에서 포인트컷으로 일괄 적용
적용 범위 개발자가 명시한 메서드만 지정한 패키지 하위 전체 메서드에 자동 적용
장점 메서드별로 세밀한 제어 가능 누락 없이 일괄 적용, 서비스 코드가 깔끔
단점 빼먹으면 트랜잭션 없이 실행됨 불필요한 곳에도 트랜잭션이 걸릴 수 있음
readOnly 분리 메서드별로 자유롭게 지정 가능 설정에 따라 다름 (패턴 매칭 필요)

 

- @Transactional은 필요한 곳에 직접 달아주는 방식

- AOP는 특정 패키지 전체에 자동으로 걸어버리는 방식

 


3. @Transactional 어노테이션 방식

가장 일반적이고 많이 사용되는 방식이다.

사용방법은 매우 간단하다!

트랜잭션이 필요한 메서드나 클래스에 @Transactional을 직접 붙여주면 된다.

 

기본 사용법

@Service
public class MemberService {

    // 기본 — write 트랜잭션
    @Transactional
    public void registerMember(MemberDto member) {
        memberDao.insertMember(member);
        memberDao.insertMemberRole(member);
        // 두 번의 INSERT가 하나의 트랜잭션으로 묶임
        // 둘 중 하나라도 예외 발생 시 둘 다 rollback
    }

    // 조회 전용 — readOnly 트랜잭션
    @Transactional(readOnly = true)
    public MemberDto getMemberInfo(String memberId) {
        return memberDao.selectMemberById(memberId);
    }
}

 

@Transactional만 붙이면 기본 설정(write 가능, REQUIRED 전파, 런타임 예외 시 롤백)으로 트랜잭션이 걸린다.

조회 전용 메서드에는 readOnly = true를 넣어주면 DB가 내부적으로 읽기 최적화를 해준다.

 

주요 속성

@Transactional에는 여러 속성을 설정할 수 있다.

@Transactional(
    readOnly = false,                          // 기본값. true면 조회 최적화
    propagation = Propagation.REQUIRED,        // 기본값. 트랜잭션 없으면 새로 생성
    isolation = Isolation.DEFAULT,             // DB 기본 격리 수준 사용
    timeout = 30,                              // 30초 초과 시 롤백
    rollbackFor = Exception.class              // 어떤 예외에서 롤백할지
)

 

 

  • readOnly: true로 설정하면 해당 트랜잭션 안에서는 조회만 가능하다. DB가 내부적으로 더티 체킹을 스킵하거나 스냅샷 읽기를 사용하는 등의 최적화를 해준다. 실수로 INSERT/UPDATE를 실행하면 예외가 발생하므로 안전장치 역할도 한다.
  • propagation: 이미 트랜잭션이 진행 중일 때 새로운 트랜잭션을 어떻게 처리할지 결정한다. 가장 자주 쓰이는 것은 기본값인 REQUIRED다.
  • isolation: 동시에 여러 트랜잭션이 실행될 때 서로 어떻게 격리할지 결정한다. 보통 DB 기본값을 따른다.
  • timeout: 트랜잭션이 지정된 시간(초) 안에 끝나지 않으면 자동으로 롤백된다.
  • rollbackFor: 어떤 예외가 발생했을 때 롤백할지 지정한다. 기본적으로 RuntimeException과 Error만 롤백하는데, Exception.class를 넣으면 체크 예외까지 롤백 대상에 포함된다.

 

Propagation (전파 옵션)

트랜잭션 전파는

"이미 트랜잭션이 걸려있는 상태에서 또 다른 트랜잭션 메서드를 호출하면 어떻게 할 것인가?"

를 결정하는 옵션이다.

 

옵션  동작  사용 예시
REQUIRED (기본) 기존 트랜잭션 있으면 참여, 없으면 새로 생성 대부분의 서비스 메서드
REQUIRES_NEW 항상 새 트랜잭션 생성 (기존 것은 일시 중단) 로그 저장, 알림 발송
NOT_SUPPORTED 트랜잭션 없이 실행 (기존 것 일시 중단) 외부 API 호출, 대용량 조회
MANDATORY 기존 트랜잭션 필수, 없으면 예외 발생 반드시 트랜잭션 안에서만 실행되어야 하는 메서드

 

 

실무에서 제일 자주 사용하는 REQUIRES_NEW로 예시를 들어보자!

// 예: 로그는 메인 트랜잭션과 별도로 저장하고 싶을 때
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(String message) {
    logDao.insertLog(message);
    // 메인 트랜잭션이 롤백되어도 이 로그는 커밋됨
}

 

예를들면, 주문 처리 중에 에러가 나서 롤백되더라도, "주문 실패" 로그는 남아있어야 한다.

이런 경우 로그 저장 메서드에 REQUIRES_NEW를 붙이면 메인 트랜잭션과 독립적으로 동작한다.

 

클래스 레벨 적용

메서드마다 일일이 붙이기 번거로울 때는 클래스 레벨에 @Transactional을 달 수 있다.

이렇게 하면 해당 클래스의 모든 public 메서드에 기본으로 적용되고, 개별 메서드에서 오버라이드할 수 있다.

@Service
@Transactional(readOnly = true)  // 클래스 전체 기본값: 조회 전용
public class BoardService {

    // readOnly = true 상속받음 — 별도 어노테이션 불필요
    public List<Board> getBoardList() {
        return boardDao.selectBoardList();
    }

    // 메서드 레벨 어노테이션이 클래스 레벨을 오버라이드
    @Transactional  // readOnly = false (기본값)
    public void createBoard(Board board) {
        boardDao.insertBoard(board);
    }
}

 

이러한 패턴은 "조회 메서드가 대부분이고 쓰기 메서드가 몇 개 없는" 서비스에서 주로 유용하다.

클래스에 readOnly = true를 걸어두고, 쓰기가 필요한 메서드에만 @Transactional을 별도로 붙이면 된다.


 

4. AOP 방식 

AOP(Aspect-Oriented Programming)방식은

Config파일에서 포인트컷(어떤 메서드에 적용할지)과 트랜잭션 규칙을 한번에 설정해두면,

해당 범위의 모든 메서드에 트랜잭션이 자동으로 걸리는 방식이다.

 

현재 근무하고 있는 회사 프로젝트 중 하나가 이 방식으로 트랜잭션을 관리하고 있다.

 

DataSourceConfig.java
  ├── dataSource         → HikariCP로 MySQL 연결
  ├── transactionManager → DataSourceTransactionManager
  ├── txAdvice           → 트랜잭션 규칙 (timeout 120초, 모든 예외 롤백)
  ├── advisor            → task 패키지 전체에 txAdvice 적용
  ├── txAdviceManual     → txManual* 메서드는 트랜잭션 제외
  └── advisorManual      → txAdviceManual 적용 포인트컷

 

  • dataSource: HikariCP 커넥션 풀을 사용해서 MySQL에 연결한다. 애플리케이션이 DB와 통신할 때 매번 새로운 커넥션을 만드는 게 아니라, 미리 만들어둔 커넥션 풀에서 꺼내 쓰고 반납하는 구조다.
  • transactionManager: DataSourceTransactionManager가 이 DataSource의 트랜잭션을 관리한다. 트랜잭션이 시작되면 커넥션 하나를 잡고 autocommit을 false로 바꾸고, 끝나면 commit 또는 rollback 후 커넥션을 반납한다.
  • txAdvice: 트랜잭션의 구체적인 규칙(타임아웃, 롤백 조건 등)을 정의한다.
  • advisor: "어떤 메서드에 txAdvice를 적용할지"를 포인트컷 표현식으로 지정한다.
  • txAdviceManual / advisorManual: 트랜잭션을 의도적으로 빼야 하는 메서드를 위한 예외 규칙이다.

 

적용 범위

// 이 포인트컷에 해당하는 모든 메서드에 트랜잭션 자동 적용
"execution(* com.company.task..*.*(..))"

 

부분  의미
* 리턴 타입 상관없이
com.company.task 이 패키지의
.. 하위 패키지 전부 포함
* 모든 클래스의
.*(..) 모든 메서드, 파라미터 상관없이

 

저 코드의 뜻은 "task 패키지 아래에 있는 모든 클래스의 모든 public 메서드에 트랜잭션이 자동으로 걸린다." 이다.

 

즉, MemberServiceImpl, BoardServiceImpl 등 전부 포함된다.

개발자가 직접 @Transactional을 안 달아도 자동으로 걸리는 것이다.

 

현재 적용되는 트랜잭션 설정

항목  설명
Propagation REQUIRED 이미 트랜잭션이 있으면 참여, 없으면 새로 생성
Timeout 120초 이 시간 안에 끝나지 않으면 강제 롤백
Rollback 조건 모든 Exception 예외가 발생하면 무조건 롤백
ReadOnly false (전부 write) 조회 메서드도 write 트랜잭션으로 동작

 

참고로 readOnly가 전부 false인 이유는 설정 코드에서 같은 키(*)에 readOnly 속성과 write 속성을 순서대로 넣었는데,

뒤에 넣은 write 속성이 앞의 readOnly를 덮어써버렸기 때문이다.

의도한 동작은 아니고 설정상의 실수로 보인다. 🤔🤔

 

트랜잭션 제외 (txManual)

모든 메서드에 트랜잭션을 거는 건 좋지만, 트랜잭션이 오히려 방해가 되는 경우도 있다.

그런 경우에는 txManual이라는 예외 규칙을 사용하면 된다.

// 메서드 이름이 txManual로 시작하면 트랜잭션 없이 실행
"execution(* com.company.task..*.txManual*(..))"

 

PROPAGATION_NOT_SUPPORTED가 적용되어 기존 트랜잭션이 있어도 일시 중단하고, 트랜잭션 없이 실행한다.

 

 

아래와 같은 경우일때 사용한다.

  • 외부 API 호출처럼 DB 트랜잭션과 무관한 작업
  • 트랜잭션이 길어지면 안 되는 대용량 조회
  • 로그 저장처럼 메인 트랜잭션의 롤백과 무관하게 실행해야 하는 작업

 

실제 동작 흐름

클라이언트 요청
  → Controller
    → MemberServiceImpl.registerMember() 호출
      → AOP 프록시가 가로챔
        → "task 패키지 메서드네? txAdvice 적용!"
          → 트랜잭션 시작 (autocommit = false)
            → memberDao.insertMember()        ─┐ 같은 트랜잭션
            → memberDao.insertMemberRole()    ─┘
          → 정상 완료: commit / 예외 발생: rollback

 

핵심은 하나의 Service 메서드 안에서 실행되는 모든 DB 작업이 하나의 트랜잭션으로 묶인다는 것이다.

 

insertMember()는 성공했는데 insertMemberRole()에서 예외가 터지면, insertMember()도 함께 롤백된다.

Controller → Service로 호출이 넘어가는 순간, AOP 프록시가 중간에서 가로채서 트랜잭션을 시작하고,

메서드가 끝나면 결과에 따라 commit 또는 rollback을 수행한다.

 

개발자 입장에서는 별도로 신경 쓸 필요 없이 Service 메서드만 작성하면 된다.


5. 트랜잭션 사용시 주의사항

@Transactional이든 AOP든, Spring 트랜잭션을 사용할 때 반드시 알아야 할 주의사항들이 있다.

 

같은 클래스 내부 호출은 AOP가 안 걸림

바로 이 문제는 Spring AOP 프록시 방식의 근본적인 한계다.

 

@Transactional이 붙은 메서드라도, 같은 클래스 안에서 this.메서드()로 호출하면 AOP가 적용되지 않는다.

프록시를 거치지 않고 직접 호출하기 때문이다.

@Service
public class OrderService {

    @Transactional
    public void methodA() {
        this.methodB();  // ← AOP 프록시를 안 거침, 별도 트랜잭션 안 걸림!
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void methodB() {
        // 외부에서 호출하면 새 트랜잭션으로 동작하지만,
        // methodA 내부에서 this.methodB()로 호출하면
        // REQUIRES_NEW가 무시되고 methodA의 트랜잭션에 그대로 포함됨
    }
}

 

왜 이런 일이 생길까?

Spring AOP는 프록시 객체를 통해 동작한다.

외부에서 orderService.methodA()를 호출하면 실제로는 프록시 객체의 메서드가 호출되고,

프록시가 트랜잭션을 시작한 뒤 실제 객체의 메서드를 호출한다.

하지만 같은 클래스 안에서 this.methodB()를 호출하면 프록시를 거치지 않고

실제 객체가 직접 자신의 메서드를 호출하기 때문에 AOP가 개입할 수 없다.

 

해결 방법 👉🏻 별도 트랜잭션이 필요하면 해당 메서드를 다른 클래스(Bean)로 분리해야 한다.

 

예외를 던지지 않으면 롤백 안 됨

트랜잭션 롤백은 예외(Exception)가 발생했을 때만 동작한다.

메서드가 정상적으로 리턴하면 무조건 commit이다.

// ❌ 롤백 안 됨 — 메서드가 정상 리턴하므로 commit으로 간주
if (검증실패) {
    rtnMap.put("success", false);
    return rtnMap;  // → commit 됨!
}

// ✅ 롤백 됨 — 예외를 던졌으므로 rollback
if (검증실패) {
    throw new RuntimeException("검증 실패");  // → rollback 됨
}

 

현재 내가 진행하고있는 프로젝트에서는

에러가 나도 예외를 던지지 않고 rtnMap.put("success", false) 후 return하는 방식으로 처리하는 경우가 종종 있다.

이 경우 메서드가 정상 종료한 것으로 간주되어 앞쪽에서 실행된 DB 변경이 그대로 커밋되어 버린다.

 

예를 들어,

회원 등록 로직에서 insertMember()는 성공하고 이후 권한 검증에서 실패한 경우,

예외 대신 Map 리턴으로 처리하면 회원 정보는 이미 저장된 채로 남게 된다.

이 부분이 트랜잭션을 사용할 때 가장 주의해야 할 포인트다.

 

 

트랜잭션 안에서 외부 API 호출 주의

트랜잭션이 걸린 상태에서 외부 API를 호출하면, API 응답이 올 때까지 DB 커넥션을 계속 점유하게 된다.

예를 들어 하나의 트랜잭션 안에서 쿠폰 API → 마일리지 API → 예약 API를 순차로 호출한다고 하자.

각 API가 30초씩 걸리면 90초 동안 커넥션 하나가 묶여있는 것이다.

 

이런 요청이 동시에 여러 개 들어오면 커넥션 풀이 고갈되어 다른 요청들도 전부 대기 상태에 빠질 수 있다.

가능하면 외부 API 호출은 트랜잭션 바깥에서 처리하는 것이 좋다!

 

AOP 방식의 프로젝트에서는 txManual을 활용해서 외부 API 호출 부분을 트랜잭션 밖으로 뺄 수 있다.

다만 이렇게 하면 [API 호출은 성공했는데 이후 DB 업데이트에서 실패]하는 경우에 대한

보상 처리가 필요해져서 구조가 복잡해진다.

 

readOnly 활용

// readOnly = true로 하면:
// 1) DB가 내부적으로 읽기 최적화 (스냅샷 읽기, 더티 체킹 스킵 등)
// 2) 실수로 INSERT/UPDATE 실행 시 예외 발생 → 안전장치
// 3) 설정에 따라 리플리카(읽기 전용) DB로 라우팅 가능

@Transactional(readOnly = true)
public List<Order> selectOrderList() { ... }

 

단순 조회 메서드에는 readOnly = true를 붙여주는 것이 좋다.

 

성능상 큰 차이가 없을 수도 있지만, 의도를 명확히 표현하고 실수를 방지하는 안전장치 역할을 한다!

 

현재 회사에서 진행하는 프로젝트는 AOP 설정상의 문제로 readOnly 설정이 무효화되어 있어서,

조회 메서드도 전부 write 트랜잭션으로 동작 중이다.


6. 만일 AOP 방식 프로젝트에서 @Transactional을 추가로 쓴다면

AOP 방식과 @Transactional 어노테이션은 공존할 수 있다.

 

현재 AOP 방식을 유지하면서,

특정 메서드에 @Transactional을 추가로 붙이면 AOP 설정보다 어노테이션이 우선 적용된다.

 

@Service
public class MemberServiceImpl {

    // AOP에 의해 기본 트랜잭션이 걸리지만,
    // 어노테이션으로 readOnly를 명시적으로 지정 → 이것이 우선 적용됨
    @Transactional(readOnly = true)
    public List<MemberDto> selectMemberList() { ... }

    // 기본 AOP 트랜잭션 그대로 사용 (어노테이션 없어도 됨)
    public void registerMember(MemberDto member) { ... }
}

 

 

또 다른 방법으로는 AOP 설정 자체를 수정하는 것이다.

메서드 이름 패턴별로 트랜잭션 속성을 다르게 줄 수 있다.

 

// DataSourceConfig.java — 메서드 이름 패턴별로 분리
txAttributes.setProperty("select*", readOnlyTransactionAttributesDefinition);
txAttributes.setProperty("get*",    readOnlyTransactionAttributesDefinition);
txAttributes.setProperty("find*",   readOnlyTransactionAttributesDefinition);
txAttributes.setProperty("count*",  readOnlyTransactionAttributesDefinition);
txAttributes.setProperty("insert*", writeTransactionAttributesDefinition);
txAttributes.setProperty("update*", writeTransactionAttributesDefinition);
txAttributes.setProperty("delete*", writeTransactionAttributesDefinition);
txAttributes.setProperty("save*",   writeTransactionAttributesDefinition);
txAttributes.setProperty("*",       writeTransactionAttributesDefinition);  // 나머지는 write

 

이렇게 하면 select, get, find, count로 시작하는 메서드는

자동으로 readOnly 트랜잭션이,

그 외에는 write 트랜잭션이 적용된다.

 

단, 메서드 네이밍 컨벤션을 팀 전체가 지켜야 한다는 전제가 필요하다.


 

트랜잭션은 개념 자체는 어렵지 않지만, 막상 프로젝트에 적용하려고 하면 쉽지 않게 느껴진다.

로직의 사이즈가 점점 커질수록 어떤 방식을 써야 문제가 없을지 더 고민이 되는데,

그래서 동작 원리를 확실히 이해해두는 게 중요하다고 느꼈다..!! 

반응형