목록으로
개발/Spring

Spring @Transactional과 PostgreSQL 트랜잭션 abort

PostgreSQL에서 트랜잭션 내 SQL 에러가 발생하면 이후 모든 쿼리가 실패하는 abort 상태를 분석합니다. try-catch가 무력화되는 원인, MySQL과의 차이, REQUIRES_NEW를 활용한 해결법을 정리합니다.

guideintermediate
18 min read
Spring BootSpring Boot

회사에서 PostgreSQL DB를 사용하는 서비스에서 @Transactional을 다룰 때 있었던 일입니다. @Transactional을 선언한 메서드 안에서 핵심 데이터를 INSERT한 뒤, 부가 로직으로 메일 발송 쿼리를 실행하는 구조였습니다. 메일 발송은 어디까지나 부가적인 요소이므로, 실패하더라도 메인 INSERT에는 영향을 주지 않아야 했습니다. 당연히 try-catch로 감싸서 처리했습니다.

테스트 중 메일 수신자 조회 쿼리에서 SQL 에러가 발생했지만, try-catch로 잡고 있으니 INSERT는 안전할 것이라 판단했습니다. 하지만 결과는 INSERT 데이터까지 모두 사라지는 silent rollback이었습니다.

원인은 PostgreSQL의 트랜잭션 abort 메커니즘이었습니다. MySQL에서는 발생하지 않는, PostgreSQL 고유의 동작입니다. 이 글에서는 이 현상의 원인, MySQL과의 차이, 그리고 REQUIRES_NEW를 사용한 해결 방법을 정리합니다.


문제: try-catch로 잡았는데 다음 쿼리가 실패한다

아래는 문제가 발생한 코드의 구조를 단순화한 것입니다.

java
@Transactional
public void processOrder(OrderDTO order) {
    // 1. 핵심 데이터 INSERT (성공)
    orderMapper.insert(order);
 
    // 2. 메일 수신자 조회 (실패 가능)
    try {
        mailMapper.selectRecipients(order.getId());
    } catch (Exception e) {
        log.warn("메일 수신자 조회 실패, 건너뜀: {}", e.getMessage());
    }
 
    // 3. 상태 업데이트
    orderMapper.updateStatus(order.getId(), "COMPLETED");
}

기대 동작: 2번이 실패해도 1번 INSERT와 3번 UPDATE는 커밋됩니다.

실제 동작: PostgreSQL에서는 1번, 3번 모두 롤백됩니다.

핵심 문제

Java의 try-catch는 JVM 프로세스 내의 예외 전파를 제어할 뿐입니다. PostgreSQL 서버의 트랜잭션 상태는 네트워크 너머에 있는 별개의 상태 머신이므로, Java 코드로 직접 복구할 수 없습니다.


원인: PostgreSQL의 트랜잭션 에러 처리 방식

MySQL vs PostgreSQL — 에러 후 동작 차이

MySQL vs PostgreSQL 트랜잭션 에러 처리 비교MySQL vs PostgreSQL 트랜잭션 에러 처리 비교
MySQL은 에러 쿼리만 실패하고 트랜잭션이 계속되지만, PostgreSQL은 트랜잭션 전체가 abort 상태로 전환된다
동작MySQL (InnoDB)PostgreSQL
트랜잭션 내 SQL 에러 발생해당 쿼리만 실패, 트랜잭션 유지트랜잭션 전체가 abort 상태로 전환
abort 후 다음 쿼리 실행정상 실행 가능거부됨 (current transaction is aborted)
abort 후 COMMIT 시도정상 커밋 (에러 쿼리 제외)자동 ROLLBACK

MySQL에 익숙한 상태에서 PostgreSQL로 전환하면 이 차이에 의해 예상치 못한 롤백이 발생할 수 있습니다.

abort 상태란

PostgreSQL은 트랜잭션 내에서 SQL 에러가 발생하면 해당 트랜잭션을 abort 상태로 전환합니다. abort 상태에서는 ROLLBACK 명령 외의 모든 SQL 실행이 거부됩니다.

PostgreSQL 트랜잭션 abort 흐름PostgreSQL 트랜잭션 abort 흐름
SQL 에러 발생 후 try-catch로 잡아도 PostgreSQL 트랜잭션은 abort 상태를 유지한다
plaintext
Java 레벨                        DB 서버 레벨
─────────────                    ──────────────
try {
  mailMapper.select(...)  ──────→ SQL 실행 → 에러 → 트랜잭션 abort
} catch (Exception e) {
  log.warn("실패");              // DB는 여전히 abort 상태
}
 
orderMapper.update(...)  ───────→ "current transaction is aborted" 거부
 
// Spring COMMIT 시도    ───────→ PostgreSQL: ROLLBACK 처리

Java에서 예외를 catch해도 PostgreSQL 서버의 트랜잭션 상태는 변하지 않습니다. abort된 트랜잭션은 ROLLBACK이나 SAVEPOINT 롤백으로만 복구할 수 있습니다.


해결 방법

아래 방법들은 "실패해도 메인 비즈니스에 영향 없는 부가 로직"을 격리할 때 사용합니다. 메인 로직과 원자성이 필요한 경우(함께 롤백되어야 하는 로직)에는 적합하지 않습니다. PostgreSQL abort 방어뿐 아니라, DB 종류와 무관하게 관심사 분리 관점에서 부가 로직을 격리하는 설계 패턴으로 권장됩니다.

방법 1: REQUIRES_NEW로 실패 가능 쿼리 격리 (권장)

실무에서 이 문제를 해결할 때 사용한 방법입니다. 실패 가능한 로직을 별도 Bean의 별도 트랜잭션으로 분리합니다.

java
// 메일 서비스 (별도 Bean)
@Service
@RequiredArgsConstructor
public class MailServiceImpl implements MailService {
 
    private final MailMapper mailMapper;
 
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @Override
    public List<RecipientDTO> getRecipients(Long orderId) {
        return mailMapper.selectRecipients(orderId);
    }
}
java
// 주문 서비스 (호출부)
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
 
    private final OrderMapper orderMapper;
    private final MailService mailService;  // 별도 Bean 주입
 
    @Transactional
    @Override
    public void processOrder(OrderDTO order) {
        orderMapper.insert(order);
 
        try {
            mailService.getRecipients(order.getId());  // 별도 트랜잭션
        } catch (Exception e) {
            log.warn("메일 수신자 조회 실패, 주문 처리는 계속: {}", e.getMessage());
        }
 
        orderMapper.updateStatus(order.getId(), "COMPLETED");
    }
}
REQUIRES_NEW 해결법 시퀀스REQUIRES_NEW 해결법 시퀀스
REQUIRES_NEW는 별도 트랜잭션(Tx2)을 생성하여, Tx2가 실패해도 외부 트랜잭션(Tx1)은 영향받지 않는다

REQUIRES_NEW는 외부 트랜잭션(Tx1)을 일시 중단하고, 내부 로직을 독립적인 새 트랜잭션(Tx2)에서 실행합니다. Tx2에서 SQL 에러가 발생해도 abort 상태는 Tx2에만 적용되므로, Tx1은 정상적으로 계속 진행할 수 있습니다.

REQUIRES_NEW 사용 시 주의사항

1. 별도 Bean 필수 — 같은 클래스 내 private 메서드에 붙이면 AOP 프록시를 타지 않아 무효입니다.
2. 커넥션 2개 사용 — 메인 + 신규 트랜잭션이 각각 DB 커넥션을 점유하므로 풀 고갈에 주의해야 합니다.
3. 데드락 가능성 — Tx1이 잡은 row를 Tx2가 접근하면 서로 대기 상태에 빠질 수 있습니다.
4. 롤백 범위 비대칭 — Tx2 실패 시 Tx1은 유지되지만, Tx1 실패 시 Tx2는 이미 커밋되어 롤백이 불가능합니다.

방법 2: SAVEPOINT로 부분 롤백

PostgreSQL의 SAVEPOINT를 활용하면 하나의 트랜잭션 안에서 부분 롤백이 가능합니다. Spring의 NESTED 전파 옵션이 이에 해당합니다.

java
@Transactional(propagation = Propagation.NESTED)
public List<RecipientDTO> getRecipients(Long orderId) {
    return mailMapper.selectRecipients(orderId);
}
plaintext
트랜잭션 흐름:
BEGIN
INSERT orders ✅
SAVEPOINT sp1
  SELECT recipients ❌
ROLLBACK TO SAVEPOINT sp1    ← 여기까지만 롤백
UPDATE status ✅             ← 정상 실행 가능
COMMIT ✅

중첩 트랜잭션은 커넥션을 1개만 사용한다는 장점이 있지만, MyBatis 환경에서는 지원이 제한적입니다. JPA/Hibernate 환경에서 더 적합한 방법입니다.

방법 3: 트랜잭션 밖에서 실행

실패해도 무방한 로직(알림, 메일 발송 등)은 트랜잭션 커밋 후 실행하는 것도 방법입니다.

java
@Transactional
public void processOrder(OrderDTO order) {
    orderMapper.insert(order);
    orderMapper.updateStatus(order.getId(), "COMPLETED");
}
java
// 호출부에서 분리
public void handleOrder(OrderDTO order) {
    orderService.processOrder(order);  // 트랜잭션 커밋 완료
 
    try {
        mailService.dispatch(order.toParam());  // 트랜잭션 밖에서 실행
    } catch (Exception e) {
        log.warn("메일 발송 실패: {}", e.getMessage());
    }
}

@TransactionalEventListener를 사용하면 커밋 후 실행을 더 깔끔하게 구현할 수 있습니다.

java
// 이벤트 발행
@Transactional
public void processOrder(OrderDTO order) {
    orderMapper.insert(order);
    orderMapper.updateStatus(order.getId(), "COMPLETED");
    applicationEventPublisher.publishEvent(new OrderCompletedEvent(order));
}
 
// 커밋 후 이벤트 처리
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCompleted(OrderCompletedEvent event) {
    mailService.dispatch(event.getOrder().toParam());
}

어떤 방법을 선택할 것인가

비교REQUIRES_NEWNESTED (SAVEPOINT)트랜잭션 밖 실행
트랜잭션 수2개1개1개
DB 커넥션2개1개1개
메인 롤백 시 서브커밋 유지함께 롤백커밋 유지
MyBatis 지원완전 지원제한적완전 지원
서브 로직에서 DB 필요가능가능가능
적용 난이도별도 Bean 분리 필요간단호출 구조 변경 필요
적합한 상황실패 가능 쿼리 격리JPA 환경 부분 롤백알림, 로깅 등 후처리

실무에서 REQUIRES_NEW를 선택한 이유는 소거법이었습니다.

NESTED(SAVEPOINT)를 배제한 이유 — 프로젝트가 MyBatis 기반이라 NESTED 전파 옵션의 지원이 제한적이었습니다. JPA/Hibernate 환경이 아니었기 때문에 SAVEPOINT 자동 관리를 기대할 수 없었습니다.

트랜잭션 밖 실행을 배제한 이유 — 메일 발송 로직 내부에서 수신자 SELECT 쿼리가 필요했습니다. 트랜잭션 밖으로 빼면 커넥션 관리가 복잡해지고, 기존 Controller/Service 호출 흐름을 재설계해야 하는 범위가 REQUIRES_NEW보다 컸습니다. @TransactionalEventListener는 이벤트 발행/구독 구조를 새로 도입해야 해서 변경 범위가 과대했습니다.

REQUIRES_NEW를 선택한 이유 — 별도 Bean 분리만 하면 기존 Service 메서드의 호출 구조를 크게 바꾸지 않고 적용할 수 있었습니다. 메일 발송 서비스가 이미 독립적인 책임을 가지고 있어서 Bean 분리가 자연스러운 설계였고, 메일 발송이 빈번하지 않아 커넥션 2개 사용에 따른 풀 고갈 위험도 낮았습니다.


트랜잭션 abort를 유발하는 다른 시나리오

위에서 다룬 SQL 에러 외에도 트랜잭션이 abort되거나 롤백되는 시나리오는 여러 가지가 있습니다.

트랜잭션 abort 유발 시나리오 분류트랜잭션 abort 유발 시나리오 분류
트랜잭션 abort/rollback은 PostgreSQL 레벨과 Spring 레벨 양쪽에서 발생할 수 있다

PostgreSQL 레벨

제약 조건 위반 — UNIQUE, FK, CHECK, NOT NULL 제약 조건 위반은 SQL 에러와 동일하게 트랜잭션을 abort시킵니다.

java
@Transactional
public void createUser(UserDTO user) {
    userMapper.insert(user);        // UNIQUE 제약 위반 → abort
    auditMapper.insertLog(user);    // 실행 불가
}

직렬화 실패SERIALIZABLE 격리 수준에서 동시에 같은 데이터를 수정하면 PostgreSQL이 한쪽 트랜잭션을 abort합니다.

데드락 감지 — 두 트랜잭션이 교차로 lock을 잡으면 PostgreSQL이 데드락을 감지하고 한쪽을 강제 abort합니다.

타임아웃statement_timeout 설정에 의해 쿼리가 시간 초과되면 해당 쿼리 취소와 함께 트랜잭션이 abort됩니다.

Spring 레벨

rollback-only 마킹 — PostgreSQL abort와는 별개로, Spring 프레임워크 레벨에서 발생하는 롤백입니다.

java
@Service
public class OuterService {
    @Autowired private InnerService innerService;
 
    @Transactional
    public void outerMethod() {
        try {
            innerService.innerMethod();  // RuntimeException → rollback-only 마킹
        } catch (Exception e) {
            log.warn("잡았으니 계속...");
        }
        // COMMIT 시도 → UnexpectedRollbackException 발생
    }
}
 
@Service
public class InnerService {
    @Transactional  // 기본 REQUIRED → 같은 트랜잭션 참여
    public void innerMethod() {
        throw new RuntimeException("실패!");
    }
}

innerMethod()RuntimeException을 던지면 Spring의 TransactionInterceptor가 현재 트랜잭션에 rollback-only 플래그를 설정합니다. 외부에서 catch해도 이미 마킹된 플래그는 되돌릴 수 없으며, 커밋 시점에 UnexpectedRollbackException이 발생합니다.

PostgreSQL abort vs Spring rollback-only

PostgreSQL abort는 DB 서버가 트랜잭션을 무효화하는 것이고, rollback-only는 Spring 프레임워크가 트랜잭션을 무효화하는 것입니다. 원인은 다르지만 결과는 동일합니다 — try-catch로 복구할 수 없는 롤백이 발생합니다.

기본 롤백 규칙 — Spring @Transactional은 기본적으로 RuntimeException(unchecked)에 대해 롤백하고, Exception(checked)에 대해서는 커밋합니다. 이 규칙을 인지하지 못하면 의도치 않은 롤백이 발생할 수 있습니다.

java
// RuntimeException → 자동 롤백
@Transactional
public void process() {
    throw new CustomBusinessException("비즈니스 규칙 위반");
}
 
// 특정 예외에 대해 롤백하지 않으려면
@Transactional(noRollbackFor = CustomBusinessException.class)
public void process() {
    throw new CustomBusinessException("비즈니스 규칙 위반");
}

자가 진단 체크리스트

@Transactional 메서드를 작성할 때 다음 항목을 확인하면 silent rollback을 예방할 수 있습니다.

@Transactional 안전 체크리스트

1. try-catch 안에 DB 쿼리가 있는가?
→ PostgreSQL에서는 catch해도 트랜잭션이 abort될 수 있습니다.

2. 메인 로직과 부가 로직이 같은 트랜잭션인가?
→ 부가 로직(메일, 알림, 로깅) 실패가 메인 로직을 롤백시킬 수 있습니다.

3. 같은 클래스 내 메서드에 @Transactional을 붙였는가?
→ AOP 프록시를 타지 않으므로 전파 옵션이 무효입니다. 별도 Bean으로 분리해야 합니다.

4. REQUIRES_NEW 사용 시 같은 테이블에 접근하는가?
→ 외부 트랜잭션이 잡은 row에 접근하면 데드락 위험이 있습니다.

5. 내부 @Transactional 메서드의 예외를 외부에서 catch하고 있는가?
→ rollback-only 마킹으로 인해 커밋 시점에 UnexpectedRollbackException이 발생할 수 있습니다.


정리

구분내용
PostgreSQL 특성트랜잭션 내 SQL 에러 → 전체 abort → COMMIT 시 자동 ROLLBACK
MySQL과의 차이MySQL은 에러 쿼리만 실패하고 트랜잭션 유지, PostgreSQL은 전체 abort
try-catch 한계Java 예외만 처리, DB 트랜잭션 상태는 복구 불가
REQUIRES_NEW별도 Bean + 별도 트랜잭션으로 격리 (MyBatis 호환, 커넥션 2개)
NESTEDSAVEPOINT 기반 부분 롤백 (커넥션 1개, MyBatis 제한적)
트랜잭션 후 실행@TransactionalEventListener(AFTER_COMMIT) 활용
Spring rollback-only내부 @Transactional 예외 시 마킹, catch해도 커밋 불가
트랜잭션 abort
PostgreSQL은 트랜잭션 안에서 하나의 SQL이라도 에러가 발생하면 해당 트랜잭션을 abort 상태로 전환한다. abort 상태에서는 ROLLBACK이나 SAVEPOINT 롤백 외의 모든 SQL 실행이 거부되며, 'current transaction is aborted, commands ignored until end of transaction block' 에러가 반환된다.
REQUIRES_NEW
Spring @Transactional의 propagation 속성 중 하나로, 호출 시점에 이미 진행 중인 트랜잭션이 있으면 이를 일시 중단(suspend)하고 새로운 독립 트랜잭션을 생성한다. 내부 트랜잭션의 성공/실패가 외부 트랜잭션에 영향을 주지 않으므로, 실패 가능성이 있는 쿼리를 격리할 때 사용한다.
SAVEPOINT
PostgreSQL에서 SAVEPOINT를 설정하면 에러 발생 시 해당 지점까지만 롤백(ROLLBACK TO SAVEPOINT)하고 나머지 트랜잭션은 계속 진행할 수 있다. Spring에서는 NESTED 전파 옵션이 이에 해당한다.
트랜잭션 전파(Propagation)
Spring @Transactional의 propagation 속성으로 설정한다. REQUIRED(기본값, 기존 참여), REQUIRES_NEW(새 트랜잭션), NESTED(중첩 트랜잭션), NOT_SUPPORTED(트랜잭션 없이 실행) 등이 있다.
rollback-only
Spring에서 @Transactional 메서드 내부에서 RuntimeException이 발생하면 트랜잭션에 rollback-only 마크가 설정된다. 이후 외부 메서드에서 예외를 catch하더라도 트랜잭션 커밋 시점에 UnexpectedRollbackException이 발생한다.
중첩 트랜잭션(Nested Transaction)
PROPAGATION_NESTED를 사용하면 물리적으로는 하나의 트랜잭션이지만 SAVEPOINT로 구간을 나누어, 내부 구간만 롤백하고 외부 트랜잭션은 계속 진행할 수 있다. 단, 물리 트랜잭션이 같으므로 외부가 롤백되면 내부도 함께 롤백된다.
원자성(Atomicity)
ACID의 A에 해당하며, 트랜잭션 내 일부만 적용되는 부분 커밋을 허용하지 않는다. PostgreSQL은 이 원칙을 엄격하게 적용하여 트랜잭션 내 하나의 SQL이라도 실패하면 전체를 abort 상태로 전환한다. MySQL(InnoDB)은 상대적으로 유연하게 해석하여 실패한 statement만 무효화하고 트랜잭션을 유지한다.