트랜잭션은 현대 소프트웨어 개발에서 핵심적인 개념으로써, 결제 시스템, 주문 처리 등 다양한 분야에서 사용되는 중요한 기술입니다. 트랜잭션을 정확하게 이해하지 못한 상태에서 프로그램을 개발하게 되면 데이터 무결성이 깨지거나, 동시성 문제가 발생하여 서비스에 치명적인 장애가 발생할 수 있습니다. 트랜잭션을 제대로 이해하고 활용하는 것은 안정적이고 일관된 서비스 제공을 위해 반드시 필요합니다. 이 글을 통해 트랜잭션의 기본 개념부터 스프링 프레임워크에서의 트랜잭션 관리 및 속성에 대해 이해하기를 기대합니다.
트랜잭션은 더 이상 쪼갤 수 없는 최소 단위의 작업을 뜻합니다. 모든 연산이 성공적으로 완료된다면 모든 데이터가 커밋되어 저장돼야하고, 중간에 실패가 일어난다면 모든 데이터가 롤백되어야 합니다. 보통 트랜잭션이라고 하면 데이터베이스 트랜잭션만을 생각하기 쉬운데, 그 외에도 파일이나 네트워크 등 트랜잭션은 다양한 곳에 적용되는 용어입니다. 그리고 트랜잭션은 ACID(Atomicity, Consistency, Isolation, Durability)라고 하는 4가지 특성을 가지고 있습니다.
트랜잭션 전파 속성이란 트랜잭션이 진행중일 때 추가 트랜잭션을 어떻게 할지 결정하는 것입니다. 위 그림과 같은 상황에서, A 트랜잭션이 진행중인 상태에서 B 트랜잭션을 A와 같은 트랜잭션에 참여시킬지, 새로운 트랜잭션으로 처리할지를 결정하는 속성입니다.
PROPAGATION_REQUIRED: (기본값) 현재 트랜잭션이 존재하면 해당 트랜잭션에 합류하고, 없으면 새로운 트랜잭션을 생성합니다.PROPAGATION_REQUIRES_NEW: 항상 새로운 트랜잭션을 시작합니다. 기존 트랜잭션은 일시 중단됩니다.PROPAGATION_NESTED: 현재 트랜잭션 내에서 중첩된 트랜잭션을 시작합니다.PROPAGATION_SUPPORTS: 현재 트랜잭션이 존재하면 해당 트랜잭션에 참여하지만, 없으면 트랜잭션 없이 실행합니다.PROPAGATION_MANDATORY: 반드시 기존 트랜잭션이 있어야 하며, 없으면 예외를 발생시킵니다.PROPAGATION_NOT_SUPPORTED: 트랜잭션 없이 실행하며, 현재 트랜잭션은 일시 중단됩니다.PROPAGATION_NEVER: 트랜잭션 없이 실행하며, 현재 트랜잭션이 존재하면 예외를 발생시킵니다.위 속성 중에서 주로 PROPAGATION_REQUIRED, PROPAGATION_REQUIRES_NEW가 가장 많이 사용됩니다.
스프링에서는 트랜잭션 전파 속성을 관리하는 TransactionDefinition 인터페이스와 이를 구현한 DefaultTransactionDefinition을 통해 기본 속성이 PROPAGATION_REQUIRED 인 것을 확인할 수 있습니다.
[그림4] 같은 경우에 PROPAGATION_REQUIRES_NEW를 효과적으로 사용할 수 있습니다. 한 트랜잭션 내에서 일부 작업이 실패할 경우 모든 작업을 롤백해야된다는 특성을 떠올려보세요. 로그추가 작업이 실패했다고 해서 고객의 행동인 메뉴 선택과 결제까지 롤백된다면, 사용자 경험에 부정적인 영향을 미칠 것입니다. 이런 경우에는 트랜잭션 경계를 나눠서 고객의 행동과 시스템 내부의 동작이 독립적으로 처리되도록 하는 것이 효과적일 것입니다.
트랜잭션 격리 수준은 동시에 실행되는 트랜잭션들 사이의 데이터 가시성을 제어합니다. 쉽게 말해 다른 트랜잭션들이 처리중인 데이터의 중간 결과를 얼마나 볼 수 있도록 할 것인가를 설정하는 것입니다. 이 설정은 여러 트랜잭션이 동시에 실행될 때 데이터의 일관성 보장과 동시성 성능 사이의 균형을 맞추는데 중요한 역할을 수행합니다.
READ UNCOMMITTED 설정은 가장 낮은 격리 수준으로써 하나의 트랜잭션이 다른 트랜잭션의 아직 커밋하지 않은 데이터를 읽을 수 있습니다.
정상적인 흐름에 대한 예시는 다음과 같습니다.
-- 초기 상태: balance = 100
-- 트랜잭션 A: 데이터를 업데이트하고 커밋하지 않음
-- START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
-- (트랜잭션 A는 아직 커밋되지 않은 상태)
-- 트랜잭션 B: 다른 트랜잭션의 커밋되지 않은 데이터를 읽음 (READ UNCOMMITTED)
-- START TRANSACTION;
SELECT balance FROM accounts WHERE account_id = 1;
-- 트랜잭션 B 결과: balance = 0
-- Dirty Read 예시
-- 초기 상태: balance = 100
-- 트랜잭션 A: 데이터를 업데이트하고 커밋하지 않음
-- START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
-- (트랜잭션 A는 아직 커밋되지 않은 상태)
-- 트랜잭션 B: 다른 트랜잭션의 커밋되지 않은 데이터를 읽음 (READ UNCOMMITTED)
-- START TRANSACTION;
SELECT balance FROM accounts WHERE account_id = 1;
-- 트랜잭션 B 결과: balance = 0
-- 트랜잭션 A 롤백
-- balance = 100, 트랜잭션 B가 조회한 balance = 0 (Dirty Read 발생)
READ COMMITTED 설정은 하나의 트랜잭션이 다른 트랜잭션의 커밋한 데이터만 읽을 수 있습니다.
정상적인 흐름에 대한 예시는 다음과 같습니다.
-- 초기 상태: balance = 100
-- 트랜잭션 A: 데이터를 업데이트하고 커밋
-- START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
-- COMMIT;
-- (트랜잭션 A는 balance = 0으로 커밋됨)
-- 트랜잭션 B: 다른 트랜잭션이 커밋한 데이터만 읽음 (READ COMMITTED)
-- START TRANSACTION;
SELECT balance FROM accounts WHERE account_id = 1;
-- 트랜잭션 B 결과: balance = 0
발생할 수 있는 문제로는 Non-Repeatable Read가 있습니다.
-- Non-Repeatable Read 예시
-- 초기 상태: balance = 100
-- 트랜잭션 A: 데이터를 읽음
-- START TRANSACTION;
SELECT balance FROM accounts WHERE account_id = 1;
-- 트랜잭션 A balance = 100
-- 트랜잭션 B: 데이터를 변경하고 커밋함
-- START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
-- COMMIT;
-- 트랜잭션 B 결과: balance = 0
-- 트랜잭션 A: 아까 읽었던 데이터를 다시 읽음
-- START TRANSACTION;
SELECT balance FROM accounts WHERE account_id = 1;
-- 트랜잭션 A balance = 0
-- 트랜잭션 A는 한 트랜잭션 내에서 조회한 데이터가 변경되었음 (Non-Repeatable Read 발생)
REPEATABLE READ 설정은 MySQL의 기본 격리 수준입니다. 하나의 트랜잭션이 시작된 시점 이후에 커밋된 데이터만 읽을 수 있으며, 같은 데이터를 반복해서 읽을 때 항상 동일한 값을 반환합니다. 일종의 버전관리 형식으로 생각할 수 있고, 트랜잭션이 시작되고 나면 커밋된 값들을 모두 같은 버전으로 취급하고 다른 트랜잭션들의 커밋은 다른 버전으로 취급하기 때문에 조회하지 않습니다.
정상적인 흐름 예시는 다음과 같습니다.
-- 초기 상태: balance = 100
-- 트랜잭션 A: 커밋된 데이터를 읽음 (한 번 읽은 record는 저장해두고 다음에도 같은 값으로 읽음)
-- START TRANSACTION;
SELECT balance FROM accounts WHERE account_id = 1;
-- 트랜잭션 A 결과: balance = 100
-- 트랜잭션 B: 데이터를 업데이트하고 커밋
-- START TRANSACTION;
UPDATE accounts SET balance = balance - 50 WHERE account_id = 1;
-- COMMIT;
-- 트랜잭션 B 결과: balance = 50
-- 트랜잭션 A: 동일한 데이터를 다시 읽음 (Repeatable Read)
SELECT balance FROM accounts WHERE account_id = 1;
-- 트랜잭션 A 결과: balance = 100
발생할 수 있는 문제로는 Phantom Read가 있습니다.
-- Phantom Read 예시
-- 초기 상태: accounts 테이블에 balance record가 2개 있음 (100, 200)
-- 트랜잭션 A: 커밋된 데이터를 읽음 (한 번 읽은 record는 저장해두고 다음에도 같은 값으로 읽음)
-- START TRANSACTION;
SELECT balance FROM accounts WHERE balance > 100;
-- 트랜잭션 A 결과: 100, 200 (2개)
-- 트랜잭션 B: 데이터를 추가하고 커밋
-- START TRANSACTION;
INSERT INTO accounts(balance) VALUES(300);
-- COMMIT;
-- 트랜잭션 A: 동일한 데이터를 다시 읽음
SELECT balance FROM accounts WHERE balance > 100;
-- 트랜잭션 A 결과: 100, 200, 300 (3개)
-- 처음 수행한 SELECT에 없던 새로운 record가 추가돼서 결과가 달라졌음 (Phantom Read) // record 삭제 시에도 발생할 수 있음
SERIALIZABLE 설정은 가장 높은 격리 수준으로, 트랜잭션들이 순차적으로 실행되는 것처럼 동작하므로 완벽한 데이터 일관성을 보장합니다.
정상적인 흐름 예시는 다음과 같습니다.
-- 초기 상태: balance = 100
-- 트랜잭션 A: 데이터를 읽음 (SERIALIZABLE)
-- START TRANSACTION;
SELECT balance FROM accounts WHERE account_id = 1;
-- 트랜잭션 A 결과: balance = 100
-- 트랜잭션 B: 데이터 읽기 시도 (SERIALIZABLE, 트랜잭션 A가 완료되지 않았기 때문에 대기상태)
-- START TRANSACTION;
-- SELECT balance FROM accounts WHERE account_id = 1;
-- 트랜잭션 B 결과: 작업을 아직 수행하지 않고 트랜잭션 A가 완료될 때까지 대기중
-- 트랜잭션 A: 커밋
-- COMMIT;
-- 트랜잭션 B: 데이터를 읽음 (대기상태 해제)
SELECT balance FROM accounts WHERE account_id = 1;
-- 트랜잭션 B 결과: balance = 100
발생할 수 있는 문제로는 성능 저하가 있습니다. 여러 트랜잭션이 동시에 실행되지 못하고 순차적으로 실행되기 때문에, 병목현상이 발생할 수 있기 때문입니다.
각 격리 수준의 특성과 발생 가능한 문제점을 이해하고, 어플리케이션 요구사항에 맞는 적절한 격리 수준을 설정하는 것이 중요합니다.
스프링에서 TransactionDefinition, DefaultTransactionDefinition을 확인해보면, 트랜잭션 격리 수준은 DEFAULT로 설정되어 있는 것을 볼 수 있습니다. 기본 설정값은 DB에 따라 다르고, JDBC 드라이버나 DataSource에서 재설정할 수 있습니다. 참고로 MySQL의 기본 트랜잭션 격리 수준은 REPEATABLE READ입니다.
스프링의 트랜잭션 추상화는 JDBC, JPA, Hibernate 처럼 트랜잭션을 관리하는 구체적인 방식은 다르지만, 트랜잭션을 가져와서 commit, rollback을 한다는 동작은 동일한 행위를 PlatformTransactionManager interface를 통해 편리하게 사용할 수 있도록 추상화 해둔 것입니다.
PlatformTransactionManager 인터페이스를 구현하는 구체 클래스에는 다음과 같은 것들이 있습니다.
DataSourceTransactionManager: JDBC를 사용하여 트랜잭션을 관리한다.JpaTransactionManager: JPA를 사용하여 트랜잭션을 관리한다.HibernateTransactionManager: Hibernate를 사용하여 트랜잭션을 관리한다.JtaTransactionManager: 분산 트랜잭션을 관리하는 데 사용된다.이 트랜잭션 매니저의 getTransaction() 메서드를 통해 트랜잭션을 시작, 참여 등을 제어할 수 있습니다. 만약 선언적 트랜잭션을 사용한다면, 트랜잭션 매니저 구현체를 직접 사용하지 않고 트랜잭션을 편리하게 제어할 수도 있습니다.
스프링의 선언적 트랜잭션 관리는 @Transactional 어노테이션을 통해 구현됩니다. 이 방식은 프록시를 이용한 AOP를 통해 트랜잭션 로직을 비즈니스 로직과 분리하여 동작하게 합니다. 이 때 @Transactional에 설정된 속성(트랜잭션 전파 속성, 트랜잭션 격리 수준 등)을 내부적으로 트랜잭션 매니저를 사용하여 트랜잭션 속성을 설정합니다. @Transactional 어노테이션은 메서드나 클래스에 사용할 수 있으며, 해당 범위 내의 작업을 트랜잭션 경계 범위로 설정합니다. 복잡한 트랜잭션 관리 코드를 작성할 필요 없이 간단하게 트랜잭션을 설정하고 사용할 수 있습니다. 사용 예시는 다음과 같습니다.
// 기본 설정값을 사용해서 메서드에 트랜잭션 설정
@Transactional
public void someMethod() {
// 트랜잭션 내에서 실행되는 코드
}
// 또는
// 직접 설정값을 주입해서 메서드에 트랜잭션 설정
@Service
public class UserService {
@Transactional(
propagation = Propagation.REQUIRED,
isolation = Isolation.READ_COMMITTED,
readOnly = true,
timeout = 30,
rollbackFor = {DataAccessException.class},
noRollbackFor = {NotFoundException.class}
)
public User getUser(Long id) {
// 사용자 조회 로직
}
}
트랜잭션 동기화는 트랜잭션 컨텍스트를 일관되게 유지하는 메커니즘입니다. 같은 트랜잭션 경계 범위로 묶여야되는 한 트랜잭션의 작업을 위해 원래는 DAO 메서드가 매개변수로 Connection을 주고 받아야 합니다. 하지만 트랜잭션 동기화는 이러한 작업을 편하게 수행할 수 있도록 비즈니스 로직을 수행하는 동안 생성된 Connection 객체를 어떤 공간에 보관하고, 이후에 호출되는 DAO 메서드가 해당 Connection 객체를 재사용할 수 있도록 해줍니다. 이러한 트랜잭션 동기화 사용의 장점은 다음과 같습니다.
스프링에서 트랜잭션 동기화를 사용하기 위해서는 TransactionSynchronizationManager 클래스를 통해 트랜잭션 동기화 매니저라는 기술을 사용합니다.
트랜잭션 동기화 매니저는 TransactionSynchronizationManager 클래스를 통해 트랜잭션 동기화의 구체적인 구현을 담당합니다. 트랜잭션 경계 내의 Connection을 ThreadLocal에 저장하고 필요한 순간에 재사용 하도록 도와줍니다. ThreadLocal을 사용하기 때문에 멀티스레드 환경에서도 안전하게 Connection을 동기화할 수 있습니다. 다음은 트랜잭션 동기화 매니저를 통해 트랜잭션 동기화를 수행하는 예시입니다.
public void logic() {
// 트랜잭션 동기화 시작
TransactionSynchronizationManager.initSynchronization();
Connection conn = null;
try {
// 첫 번째 DB 커넥션 획득
conn = DataSourceUtils.getConnection(dataSource);
System.out.println("First connection hash: " + conn.hashCode());
// 데이터베이스 작업 수행
// 두 번째 DB 커넥션 획득
Connection secondConn = DataSourceUtils.getConnection(dataSource);
System.out.println("Second connection hash: " + secondConn.hashCode());
// 데이터베이스 작업 수행
// 두 연결이 동일한지 확인
System.out.println("두 연결이 같은가?: " + (conn == secondConn));
} finally {
// 연결 해제 및 트랜잭션 동기화 정리
DataSourceUtils.releaseConnection(conn, dataSource);
TransactionSynchronizationManager.clearSynchronization();
}
}
DataSourceUtils.getConnection()가 내부적으로 트랜잭션 동기화를 확인하고 처리하기 때문에 TransactionSynchronizationManager를 사용하지 않았을 때와 결과가 같을 수 있지만, 명시적으로 트랜잭션 동기화 컨텍스트를 초기화하고 경계를 명확히하여 코드의 가독성을 높일 수 있습니다.
트랜잭션 관리는 엔터프라이즈 뿐만 아니라 모든 규모의 서비스에서 핵심 요소입니다. 스프링의 트랜잭션 추상화와 선언적 트랜잭션은 복잡한 트랜잭션 관리 로직을 간소화하고, 다양한 트랜잭션 API를 일관되게 사용할 수 있게 해줍니다. 개발할 때 이러한 개념들을 깊이 있게 이해하고 적절히 활용함으로써, 안정적이고 높은 코드 품질을 유지해야 합니다. 트랜잭션을 적용한 로직에 스스로 확신을 가질 수 없다면 꼭 다시 분석해서 개선하는 것이 필요할 것이다.