1. 트랜잭션(Transaction)의 본질 (All or Nothing)
트랜잭션은 데이터베이스의 상태를 변화시키는 '쪼갤 수 없는 논리적인 작업의 최소 단위'를 의미한다. 이는 금융 IT 시스템이나 전자상거래 결제 로직처럼 데이터의 정합성이 생명인 환경에서 절대적으로 지켜져야 하는 개념이다.
가장 고전적이고 확실한 '은행 계좌 이체'를 예로 들어보자. A가 B에게 1만 원을 송금하는 작업은 DB 내부에서 2개의 상태 변화 쿼리로 나뉜다.
- A의 계좌 잔액을 1만 원 감소시킨다. (Update)
- B의 계좌 잔액을 1만 원 증가시킨다. (Update)
만약 1번 쿼리까지 정상적으로 실행된 직후, 네트워크 장애나 서버 메모리 부족으로 시스템이 다운된다면 어떻게 될까? A의 돈은 증발하고 B는 돈을 받지 못하는 최악의 금융 사고가 발생한다.
따라서 이 두 쿼리는 반드시 둘 다 성공(Commit)하거나, 하나라도 실패하면 둘 다 시작 전 상태로 취소(Rollback)되어야 한다. 중간만 성공하는 애매한 상태를 허용하지 않는 것, 이것이 트랜잭션의 All or Nothing 원칙이다.
2. 데이터베이스를 지탱하는 4가지 기둥 (ACID 특성)
데이터베이스는 이 트랜잭션을 안전하게 처리하기 위해 다음 4가지 절대 규칙(ACID)을 엄격하게 준수한다.
- 원자성 (Atomicity): 트랜잭션 내의 작업들은 하나의 원자처럼 동작하여, DB에 모두 반영되거나 아예 반영되지 않아야 한다.
- 일관성 (Consistency): 트랜잭션이 성공적으로 완료된 후에도 DB의 기본 제약 조건(예: '계좌 잔액은 음수가 될 수 없다', '이름은 null일 수 없다')은 항상 모순 없이 유지되어야 한다.
- 고립성 (Isolation): 수천 명의 유저가 동시에 접근하더라도, 각각의 트랜잭션은 서로의 작업에 끼어들거나 간섭할 수 없다. 철저히 독립적인 실행을 보장한다. (단, 성능 이슈로 인해 실무에서는 유연하게 조절한다. 아래에서 후술)
- 지속성 (Durability): 성공적으로 완료(Commit)된 트랜잭션의 결과는 시스템 장애나 DB 센터에 정전이 발생하더라도 디스크에 영구적으로 보존되어야 한다.
3. 스프링의 선언적 트랜잭션 (@Transactional)과 AOP 프록시
과거 순수 JDBC 환경에서는 이러한 트랜잭션을 자바 코드로 직접 제어해야 했다. conn.setAutoCommit(false)로 트랜잭션을 열고, 로직이 끝나면 conn.commit(), 에러가 발생하면 catch 블록에서 conn.rollback()을 호출하는 코드를 모든 비즈니스 로직에 도배해야 했다.
스프링 프레임워크는 이 지독한 보일러플레이트 코드를 @Transactional이라는 어노테이션 단 하나로 해결한다. 이를 선언적 트랜잭션(Declarative Transaction)이라 부른다.
import org.springframework.transaction.annotation.Transactional
@Transactional
public class MemberService {
public void join(Member member) {
// DB 저장 로직 (개발자는 핵심 비즈니스 로직만 집중!)
}
}
💡 스프링은 어떻게 이 마법을 부릴까? (Proxy AOP)
스프링은 @Transactional을 만나면 AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)를 활용하여 동작한다. 진짜 MemberService 객체를 실행하기 전에, 스프링 컨테이너가 '프록시(Proxy) 가짜 객체'를 몰래 하나 만들어 낸다.
- 클라이언트가 join()을 호출하면, 진짜 객체가 아닌 프록시 객체가 요청을 가로챈다.
- 프록시 객체가 트랜잭션을 시작한다. (Connection을 가져오고 setAutoCommit(false) 실행)
- 프록시 객체가 진짜 MemberService의 join()을 호출하여 비즈니스 로직을 실행시킨다.
- 로직이 정상적으로 끝나면 프록시가 Commit을, 예외가 터지면 Rollback을 수행하고 커넥션을 닫는다.
이러한 프록시 기술 덕분에 개발자의 핵심 비즈니스 로직 코드에는 트랜잭션 관련 코드가 단 한 줄도 섞이지 않게 된다.
4. 트랜잭션 격리 수준 (Isolation Level)
앞서 ACID 특성 중 '고립성(Isolation)'은 완벽하게 지키려면 성능이 심각하게 저하되는 문제가 있다. (모든 요청을 줄 세워서 하나씩 처리해야 하기 때문). 따라서 스프링과 DB는 데이터의 안전성과 성능 사이에서 타협할 수 있도록 4단계의 격리 수준을 제공한다.
(스프링에서는 @Transactional(isolation = Isolation.READ_COMMITTED) 처럼 설정할 수 있으며, 보통은 DB의 기본 설정을 따른다.)
- READ UNCOMMITTED (커밋되지 않은 읽기)
- A 트랜잭션이 데이터를 수정 중이고 아직 커밋하지 않았는데, B 트랜잭션이 그 수정 중인 데이터를 읽어갈 수 있다.
- 만약 A가 롤백해버리면 B는 세상에 존재하지 않는 잘못된 데이터를 바탕으로 로직을 수행하게 된다. (이를 Dirty Read라 한다). 실무에서는 절대 사용하지 않는다.
- READ COMMITTED (커밋된 읽기)
- 반드시 커밋이 완료된 데이터만 읽을 수 있다. 오라클(Oracle), PostgreSQL의 기본 설정이다. Dirty Read는 막을 수 있다.
- REPEATABLE READ (반복 가능한 읽기)
- 한 트랜잭션 안에서 같은 데이터를 여러 번 조회할 때, 중간에 다른 트랜잭션이 데이터를 수정하더라도 처음 조회했던 그 값을 그대로 보장해 준다. MySQL(InnoDB)의 기본 설정이다.
- SERIALIZABLE (직렬화 가능)
- 가장 엄격한 수준. 완벽한 고립성을 보장하지만, 테이블 전체에 락(Lock)을 걸어버리기 때문에 동시 처리 성능이 극단적으로 떨어진다.
5. 예외 롤백 규칙 (체크 예외 vs 언체크 예외)
스프링 트랜잭션을 실무에 적용할 때 주니어 개발자들이 가장 많이 하는 실수 중 하나가 바로 '롤백 규칙'을 모르는 것이다. 스프링의 @Transactional은 에러가 난다고 해서 무조건 롤백해주지 않는다.
- 언체크 예외 (Unchecked Exception): RuntimeException, Error 계열.
- 스프링은 이를 '복구 불가능한 치명적 시스템 예외'로 간주하고 자동으로 롤백한다. (예: NullPointerException, IllegalArgumentException)
- 체크 예외 (Checked Exception): Exception 계열 (단, RuntimeException 제외).
- 스프링은 이를 '비즈니스적인 의미가 있거나 복구 가능한 예외'로 간주하여 롤백하지 않고 커밋해버린다. (예: IOException, 잔액 부족 예외 등)
만약 체크 예외가 발생했을 때도 강제로 롤백시키고 싶다면, 명시적으로 롤백 규칙을 지정해주어야 한다.
// Exception이 터져도 롤백시켜라!
@Transactional(rollbackFor = Exception.class)
public void transferMoney() throws Exception { ... }
6. 트랜잭션 전파 속성 (Propagation)
실무 환경에서는 A 트랜잭션 로직 안에서 B 트랜잭션 로직을 호출하는 등 여러 트랜잭션이 얽히는 상황이 빈번하다. 이때 스프링은 전파 속성을 통해 트랜잭션의 경계를 어떻게 지을지 결정한다.
- REQUIRED (기본값): 가장 많이 쓰인다. 이미 진행 중인 트랜잭션이 있다면 거기에 슬쩍 합류한다. (즉, 두 로직이 하나의 트랜잭션으로 묶여서, 둘 중 하나라도 실패하면 전체가 다 같이 롤백된다). 진행 중인 트랜잭션이 없다면 새로 하나를 만든다.
- REQUIRES_NEW: 기존에 진행 중인 트랜잭션이 있든 없든, 무조건 자신만의 새로운 독립적인 트랜잭션을 생성한다. 기존 트랜잭션은 잠시 보류된다. (예를 들어, 핵심 비즈니스 로직이 실패해서 롤백되더라도, '접속 로그 남기기' 로직만큼은 롤백되지 않고 무조건 DB에 커밋되어야 할 때 사용한다.)
마무리
결론적으로 기술이 발전하면서 우리가 직접 쿼리를 짜거나 트랜잭션을 코드로 관리할 일은 획기적으로 줄어들었다. 단순히 어노테이션 하나 붙이는 것만으로도 거대한 백엔드 시스템이 굴러간다.
하지만 프레임워크가 알아서 해준다고 해서 그 내부를 모르면, 운영 환경에서 동시성 제어가 안 되거나(Isolation 문제), 롤백이 되어야 할 시점에 커밋이 되어버리는(예외 규칙 문제) 등 치명적인 재앙을 맞이할 수 있다. 스프링이 어떻게 AOP 프록시로 트랜잭션을 감싸는지, 데이터 무결성이 어떻게 지켜지는지 그 근본적인 원리를 이해하는 것이야말로 탄탄한 백엔드 엔지니어로 성장하는 필수 조건일 것이다.
'백엔드' 카테고리의 다른 글
| Spring 입문 #8 - 스프링 DB 접근 기술 (0) | 2026.06.01 |
|---|---|
| Spring 입문 #7 - mySQL (2) (0) | 2026.05.09 |
| Spring 입문 #6 - mySQL (1) (0) | 2026.05.09 |
| Spring 입문 #5 - 회원 관리 예제: 웹 MVC 개발 (0) | 2026.05.09 |
| Spring 입문 #4 - 스프링 빈과 의존관계 (0) | 2026.05.04 |
