엔터프라이즈 애플리케이션 개발에서 데이터의 일관성과 무결성을 보장하는 것은 시스템의 신뢰도를 결정하는 가장 중요한 요소 중 하나입니다. 여러 데이터베이스 연산이 하나의 논리적인 작업 단위로 묶여 모두 성공하거나 모두 실패해야 하는 상황은 비일비재하며, 이를 '트랜잭션(Transaction)'이라 부릅니다. 스프링 프레임워크는 이러한 트랜잭션 관리를 매우 효율적이고 유연하게 처리할 수 있는 강력한 기능을 제공하여 개발자가 비즈니스 로직에 더 집중할 수 있도록 돕습니다. 하지만 이 편리함 이면에는 반드시 이해해야 할 정교한 동작 원리가 숨어있습니다.
특히 여러 서비스 메소드가 연달아 호출되는 복잡한 시나리오에서, 각 메소드가 기존 트랜잭션에 어떻게 참여할지를 결정하는 '트랜잭션 전파(Transaction Propagation)'는 시스템의 동작 방식을 완전히 바꿀 수 있는 핵심 개념입니다. 단순히 @Transactional
어노테이션을 붙이는 것만으로는 충분하지 않습니다. 이 글에서는 스프링 트랜잭션의 내부 동작 원리부터 시작하여, 7가지 트랜잭션 전파 속성을 실제 사용 사례와 함께 깊이 있게 분석하고, 자주 발생하는 문제점과 해결 방안까지 포괄적으로 다룰 것입니다.
트랜잭션, 왜 필요한가? - ACID 원칙의 중요성
스프링의 트랜잭션 관리 기능을 논하기 전에, 데이터베이스 트랜잭션의 근간을 이루는 ACID 원칙을 되짚어볼 필요가 있습니다. ACID는 신뢰할 수 있는 트랜잭션 시스템이 갖추어야 할 4가지 핵심 속성을 의미합니다.
- 원자성 (Atomicity): 트랜잭션에 포함된 모든 작업은 전부 성공적으로 실행되거나, 혹은 단 하나라도 실패할 경우 전부 실행되지 않은 상태로 되돌아가야 합니다. 'All or Nothing'의 원칙입니다. 예를 들어, A 계좌에서 B 계좌로 돈을 이체하는 작업은 'A 계좌 출금'과 'B 계좌 입금'이라는 두 가지 연산으로 구성됩니다. 출금은 성공했지만 입금이 실패했다면, 출금 작업까지 모두 취소(롤백)되어야 데이터의 정합성이 유지됩니다.
- 일관성 (Consistency): 트랜잭션이 성공적으로 완료되면, 데이터베이스는 항상 일관된 상태를 유지해야 합니다. 즉, 트랜잭션 실행 전과 후에 데이터베이스의 제약 조건이나 규칙(예: 기본 키, 외래 키, 도메인 제약 등)이 위반되지 않아야 합니다. 계좌 이체 예에서 총액은 이체 전후로 동일해야 한다는 규칙이 여기에 해당합니다.
- 격리성 (Isolation): 여러 트랜잭션이 동시에 실행될 때, 각 트랜잭션은 다른 트랜잭션의 작업에 영향을 받지 않고 독립적으로 실행되는 것처럼 보여야 합니다. 이를 통해 동시성 문제를 방지할 수 있습니다. 격리 수준(Isolation Level)에 따라 그 정도가 달라지며, 이는 성능과 데이터 일관성 사이의 트레이드오프 관계를 가집니다.
- 지속성 (Durability): 성공적으로 완료된 트랜잭션의 결과는 시스템에 영구적으로 저장되어야 합니다. 즉, 트랜잭션이 커밋(commit)된 후에는 시스템 장애(예: 정전, 서버 다운)가 발생하더라도 그 결과는 손실되지 않아야 합니다.
스프링은 이러한 ACID 원칙을 개발자가 쉽게 적용할 수 있도록 추상화된 트랜잭션 관리 기능을 제공합니다. 이를 통해 개발자는 복잡한 트랜잭션 처리 코드를 직접 작성하는 대신, 비즈니스 요구사항에 맞는 트랜잭션 속성을 선언적으로 정의할 수 있습니다.
스프링이 트랜잭션을 다루는 방식: AOP와 프록시
스프링의 선언적 트랜잭션 관리의 핵심에는 관점 지향 프로그래밍(Aspect-Oriented Programming, AOP)이 있습니다. 개발자가 서비스 객체의 메소드에 @Transactional
어노테이션을 추가하면, 스프링은 해당 객체에 대한 프록시(Proxy) 객체를 생성합니다. 실제 실행 시에는 원본 객체 대신 이 프록시 객체가 호출됩니다.
프록시 객체는 @Transactional
이 붙은 메소드가 호출될 때, 메소드 실행 전후로 다음과 같은 트랜잭션 관련 부가 기능을 수행합니다.
- 메소드 호출 가로채기: 클라이언트가 서비스 메소드를 호출하면, 프록시 객체가 이 호출을 먼저 받습니다.
- 트랜잭션 시작: 프록시는
@Transactional
어노테이션의 속성(전파, 격리 수준 등)을 확인하고, 필요하다면 데이터베이스 커넥션을 얻어 트랜잭션을 시작합니다. (auto-commit = false
) - 실제 메소드 호출: 트랜잭션이 시작된 후, 원본 객체의 실제 비즈니스 로직 메소드를 호출합니다.
- 트랜잭션 결정: 메소드 실행이 성공적으로 완료되면, 프록시는 트랜잭션을 커밋(commit)합니다. 만약 메소드 실행 중 예외(기본적으로
RuntimeException
또는Error
)가 발생하면, 트랜잭션을 롤백(rollback)합니다. - 결과 반환: 트랜잭션 처리가 완료된 후, 결과를 클라이언트에게 반환합니다.
이러한 프록시 방식 때문에 발생하는 중요한 제약사항이 바로 'Self-Invocation(자기 호출)' 문제입니다. 같은 클래스 내에서 @Transactional
이 붙지 않은 메소드가 @Transactional
이 붙은 다른 메소드를 호출하는 경우, 프록시를 거치지 않고 원본 객체의 메소드가 직접 호출(this.anotherMethod()
)됩니다. 그 결과, 트랜잭션 기능이 전혀 동작하지 않게 됩니다. 이는 스프링 AOP의 고질적인 한계이므로, 트랜잭션이 필요한 메소드는 별도의 서비스 빈(Bean)으로 분리하여 의존성 주입을 통해 호출하는 것이 일반적인 해결책입니다.
@Transactional
어노테이션 상세 분석
@Transactional
어노테이션은 단순히 트랜잭션을 적용하는 것 이상의 세밀한 제어를 위한 여러 속성을 제공합니다. 이들을 올바르게 이해하고 사용하는 것이 중요합니다.
isolation
(격리 수준): 트랜잭션의 격리성을 어느 수준으로 유지할지 설정합니다. 스프링은 데이터베이스 표준 격리 수준인READ_UNCOMMITTED
,READ_COMMITTED
,REPEATABLE_READ
,SERIALIZABLE
을 지원합니다. 격리 수준이 높아질수록 데이터 일관성은 향상되지만 동시 처리 성능은 저하될 수 있으므로, 애플리케이션의 특성에 맞는 적절한 수준을 선택해야 합니다.readOnly
: 트랜잭션을 읽기 전용으로 설정합니다. 이를true
로 설정하면 스프링과 하부 ORM(JPA 등)은 해당 트랜잭션 내에서 데이터 변경 작업이 없을 것으로 간주하고 다양한 최적화를 수행합니다(예: 더티 체킹(dirty checking) 생략, 플러시 모드 변경). 성능 향상을 위해 조회 기능에는 적극적으로 사용하는 것이 좋습니다.rollbackFor
/noRollbackFor
: 스프링의 기본 롤백 정책을 재정의합니다. 기본적으로 스프링은RuntimeException
과Error
에 대해서만 롤백을 수행하며, 체크 예외(Checked Exception)가 발생하면 커밋을 시도합니다. 특정 체크 예외 발생 시에도 롤백을 원한다면rollbackFor = BusinessException.class
와 같이 지정할 수 있고, 반대로 특정 런타임 예외에 대해 롤백을 원치 않는다면noRollbackFor = SpecificRuntimeException.class
와 같이 설정할 수 있습니다.timeout
: 지정된 시간(초 단위) 내에 트랜잭션이 완료되지 않으면 롤백을 수행합니다. 장시간 실행될 수 있는 작업이 시스템 전체에 영향을 주는 것을 방지하기 위해 사용됩니다.
그리고 가장 중요하고 복잡한 속성이 바로 다음에 다룰 propagation
(전파)입니다.
7가지 트랜잭션 전파(Propagation) 속성 심층 탐구
트랜잭션 전파 속성은 하나의 트랜잭션 컨텍스트 내에서 다른 트랜잭션 메소드가 호출될 때, 새로 호출된 메소드가 기존 트랜잭션에 어떻게 참여할지를 결정하는 규칙입니다. 스프링은 7가지의 전파 옵션을 제공하며, 각 옵션은 뚜렷한 목적과 사용 사례를 가집니다.
이해를 돕기 위해, 외부 서비스를 호출하는 `OuterService`와 그에 의해 호출되는 `InnerService`의 두 가지 컴포넌트가 있다고 가정하고 각 속성을 살펴보겠습니다.
1. PROPAGATION_REQUIRED
(기본값)
가장 널리 사용되는 기본 전파 속성입니다.
- 동작 방식:
- 기존 트랜잭션이 있는 경우: 해당 트랜잭션에 참여(join)합니다. 즉, 동일한 물리적 트랜잭션의 일부가 되어 같은 커넥션을 공유하고, 하나의 작업 단위로 묶입니다. 내부 메소드에서 발생한 예외는 외부 메소드의 트랜잭션에도 영향을 주어 전체 롤백을 유발할 수 있습니다.
- 기존 트랜잭션이 없는 경우: 새로운 트랜잭션을 생성합니다.
- 사용 사례: 대부분의 비즈니스 로직에 적합합니다. 하나의 비즈니스 요구사항을 처리하기 위해 여러 메소드가 연계되어 동작할 때, 이들을 단일 트랜잭션으로 묶어 원자성을 보장하고자 할 때 사용합니다. 예를 들어 '주문 생성' 서비스가 '재고 감소'와 '결제 기록' 서비스를 순차적으로 호출하는 경우가 이에 해당합니다.
// OuterService
@Service
@RequiredArgsConstructor
public class OuterService {
private final InnerService innerService;
@Transactional // (propagation = Propagation.REQUIRED)
public void processOrder() {
// ... 주문 관련 데이터 처리 ...
innerService.updateStock(); // 기존 트랜잭션에 참여
}
}
// InnerService
@Service
public class InnerService {
@Transactional // (propagation = Propagation.REQUIRED)
public void updateStock() {
// ... 재고 관련 데이터 처리 ...
// 여기서 예외 발생 시 processOrder()의 작업까지 모두 롤백됨
}
}
2. PROPAGATION_REQUIRES_NEW
이름에서 알 수 있듯이, 항상 새로운 트랜잭션을 생성하는 강력한 옵션입니다.
- 동작 방식:
- 기존 트랜잭션이 있는 경우: 기존 트랜잭션을 일시 중단(suspend)시키고, 완전히 새로운 물리적 트랜잭션을 시작합니다. 내부 메소드의 트랜잭션은 외부 트랜잭션과 독립적으로 커밋 또는 롤백됩니다. 내부 트랜잭션이 완료된 후, 중단되었던 외부 트랜잭션이 다시 재개(resume)됩니다.
- 기존 트랜잭션이 없는 경우: 새로운 트랜잭션을 생성합니다. (
REQUIRED
와 동일)
- 사용 사례: 외부 트랜잭션의 성공 여부와 관계없이, 특정 작업의 결과를 반드시 독립적으로 처리해야 할 때 사용합니다. 대표적인 예로, 시스템 작업의 성공/실패 여부를 기록하는 '감사 로그(Audit Log)'가 있습니다. 비즈니스 로직(외부 트랜잭션)이 실패하여 롤백되더라도, "실패했다"는 사실 자체는 로그 테이블에 반드시 기록되어야 하기 때문입니다.
// OuterService
@Service
@RequiredArgsConstructor
public class OuterService {
private final AuditService auditService;
@Transactional
public void importantBusinessLogic() {
try {
// ... 중요한 비즈니스 로직 수행 ...
if (someConditionIsBad) {
throw new BusinessException("비즈니스 로직 실패");
}
auditService.logSuccess("작업 성공");
} catch (BusinessException e) {
auditService.logFailure("작업 실패: " + e.getMessage());
throw e; // 예외를 다시 던져 외부 트랜잭션 롤백
}
}
}
// AuditService
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logSuccess(String message) {
// 로그 저장 로직. 이 트랜잭션은 항상 커밋된다.
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logFailure(String message) {
// 로그 저장 로직. importantBusinessLogic이 롤백되더라도
// 이 로그는 DB에 저장된다.
}
}
3. PROPAGATION_NESTED
REQUIRES_NEW
와 혼동하기 쉽지만, 동작 방식에 중요한 차이가 있는 고급 옵션입니다.
- 동작 방식:
- 기존 트랜잭션이 있는 경우: 중첩된 트랜잭션(nested transaction)을 시작합니다. 이는 물리적으로는 외부 트랜잭션과 동일한 커넥션을 사용하지만, 논리적으로는 독립된 트랜잭션처럼 동작합니다. JDBC의 세이브포인트(Savepoint) 기능을 사용하여 구현됩니다. 내부 트랜잭션에서 롤백이 발생하면, 외부 트랜잭션 전체가 롤백되는 것이 아니라 해당 트랜잭션이 시작되기 직전의 세이브포인트까지만 롤백됩니다. 외부 트랜잭션은 이 롤백 사실을 인지하고 다른 로직을 수행하거나 계속 진행할 수 있습니다.
- 기존 트랜잭션이 없는 경우: 새로운 트랜잭션을 생성합니다. (
REQUIRED
와 동일)
REQUIRES_NEW
와의 핵심 차이점:- 물리적 트랜잭션:
REQUIRES_NEW
는 별개의 커넥션을 사용하는 두 개의 물리적 트랜잭션을 만들지만,NESTED
는 하나의 물리적 트랜잭션 내에서 동작합니다. - 롤백의 영향:
NESTED
의 내부 트랜잭션은 외부 트랜잭션에 종속적입니다. 따라서 외부 트랜잭션이 롤백되면, 내부 트랜잭션의 결과도 함께 롤백됩니다. 반면REQUIRES_NEW
는 완전히 독립적이므로 외부 트랜잭션의 롤백이 내부 트랜잭션에 영향을 주지 않습니다.
- 물리적 트랜잭션:
- 사용 사례: 복잡한 비즈니스 로직 내에서 특정 부분의 작업만 독립적으로 롤백하고 싶을 때 유용합니다. 예를 들어, 여러 상품을 장바구니에 담는 과정에서 특정 상품의 재고가 부족하여 해당 상품 추가만 실패 처리하고 나머지 상품은 정상적으로 처리하고 싶을 때 사용할 수 있습니다.
- 주의사항: 모든 데이터베이스(JDBC 드라이버)가 세이브포인트 기능을 지원하는 것은 아니므로, 사용 전 호환성 확인이 필수적입니다.
4. PROPAGATION_SUPPORTS
트랜잭션을 강제하지 않고, 유연하게 지원하는 모드입니다.
- 동작 방식:
- 기존 트랜잭션이 있는 경우: 해당 트랜잭션에 참여합니다. (
REQUIRED
와 동일) - 기존 트랜잭션이 없는 경우: 트랜잭션 없이 메소드를 실행합니다.
- 기존 트랜잭션이 있는 경우: 해당 트랜잭션에 참여합니다. (
- 사용 사례: 해당 메소드가 반드시 트랜잭션을 필요로 하지는 않지만, 트랜잭션 컨텍스트 내에서 호출될 경우 원자성을 보장받고 싶을 때 사용합니다. 주로 조회(read) 관련 로직이나, 데이터 변경이 일어나지 않는 유틸리티성 메소드에 적합합니다.
5. PROPAGATION_MANDATORY
반드시 기존 트랜잭션 내에서 실행되어야 함을 강제하는 속성입니다.
- 동작 방식:
- 기존 트랜잭션이 있는 경우: 해당 트랜잭션에 참여합니다.
- 기존 트랜잭션이 없는 경우: 예외(
IllegalTransactionStateException
)를 발생시킵니다.
- 사용 사례: 해당 메소드가 독립적으로 호출되어서는 안 되고, 반드시 더 큰 서비스의 트랜잭션 흐름의 일부로서만 실행되어야 함을 명확히 하고 싶을 때 사용됩니다. 아키텍처적으로 특정 메소드의 역할을 강제하는 데 도움이 됩니다.
6. PROPAGATION_NOT_SUPPORTED
트랜잭션 컨텍스트 밖에서 실행되도록 보장합니다.
- 동작 방식:
- 기존 트랜잭션이 있는 경우: 기존 트랜잭션을 일시 중단하고, 트랜잭션 없이 메소드를 실행합니다. 메소드 실행이 완료되면 중단되었던 트랜잭션을 재개합니다.
- 기존 트랜잭션이 없는 경우: 트랜잭션 없이 메소드를 실행합니다.
- 사용 사례: 트랜잭션과 무관한 작업을 수행할 때 유용합니다. 예를 들어, 트랜잭션 내에서 오랜 시간이 걸리는 외부 API를 호출하거나, 대용량 파일 I/O 작업을 수행하는 경우, 해당 작업 시간 동안 데이터베이스 커넥션을 불필요하게 점유하는 것을 방지할 수 있습니다.
7. PROPAGATION_NEVER
가장 엄격한 비-트랜잭션 속성입니다.
- 동작 방식:
- 기존 트랜잭션이 있는 경우: 예외(
IllegalTransactionStateException
)를 발생시킵니다. - 기존 트랜잭션이 없는 경우: 트랜잭션 없이 메소드를 실행합니다.
- 기존 트랜잭션이 있는 경우: 예외(
- 사용 사례: 특정 메소드가 절대로 트랜잭션 내에서 호출되어서는 안 된다는 것을 시스템적으로 보장하고 싶을 때 사용합니다. 예를 들어, 데이터베이스의 현재 상태를 모니터링하는 등의 특수한 목적을 가진 메소드에 적용할 수 있습니다.
트랜잭션 관리 시 흔히 겪는 문제와 해결책
스프링 트랜잭션을 사용하면서 개발자들이 자주 겪는 몇 가지 함정이 있습니다.
@Transactional
의 적용 범위: 앞서 언급했듯, 프록시 방식으로 동작하기 때문에public
메소드에만 적용됩니다.private
,protected
, 또는package-private
메소드에 어노테이션을 붙여도 트랜잭션은 적용되지 않으며, 스프링은 이에 대한 어떠한 경고나 오류도 발생시키지 않으므로 각별한 주의가 필요합니다.- 체크 예외(Checked Exception)와 롤백: 스프링의 기본 롤백 정책은 언체크 예외(
RuntimeException
의 하위 클래스)와Error
에 대해서만 작동합니다. 만약 서비스 로직에서 체크 예외(예:IOException
,SQLException
)를 잡아서 던질 경우, 트랜잭션은 롤백되지 않고 커밋됩니다. 이를 방지하려면@Transactional(rollbackFor = Exception.class)
와 같이 롤백 정책을 명시적으로 지정해야 합니다. try-catch
블록의 오용:@Transactional
메소드 내부에서 예외를try-catch
로 잡고 아무런 처리를 하지 않으면, 스프링 AOP 프록시는 예외 발생을 감지할 수 없습니다. 따라서 프록시는 메소드가 정상 종료된 것으로 판단하고 트랜잭션을 커밋하게 됩니다. 예외를 잡았다면, 반드시 다시 던지거나(throw e;
)TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
를 호출하여 프로그래밍 방식으로 롤백을 명시해야 합니다.
결론: 올바른 트랜잭션 전략의 중요성
스프링의 @Transactional
은 매우 강력하고 편리한 도구이지만, 그 내부 동작 원리와 다양한 속성을 정확히 이해하지 못하면 예기치 않은 데이터 부정합 문제를 야기할 수 있습니다. 특히 트랜잭션 전파는 여러 서비스가 상호작용하는 복잡한 애플리케이션의 동작을 정의하는 핵심적인 요소입니다.
단순히 기본값인 REQUIRED
만 사용하는 것을 넘어, 각 비즈니스 시나리오의 요구사항을 분석하고 그에 가장 적합한 전파 속성(REQUIRES_NEW
, NESTED
, NOT_SUPPORTED
등)을 선택하는 것이 견고하고 신뢰성 높은 시스템을 구축하는 지름길입니다. 이 글에서 다룬 내용들을 바탕으로 자신의 애플리케이션에 적용된 트랜잭션 전략을 다시 한번 점검하고, 더 정교하게 다듬어 나가는 계기가 되기를 바랍니다.
0 개의 댓글:
Post a Comment