자바 개발자가 알아야 할 DB 트랜잭션 격리 수준과 @Transactional 원리

자바 백엔드 개발을 진행하다 보면 데이터의 정밀함과 안전성을 지키기 위해 스프링(Spring)이 제공하는 @Transactional 어노테이션을 밥먹듯이 사용하게 됩니다. 이 한 줄의 코드는 데이터베이스(DB)의 핵심 속성인 ACID(원자성, 일관성, 격리성, 지속성)를 유지해 주는 강력한 무기입니다.

하지만 실무에서 대량의 트래픽이 발생하거나 여러 사용자가 동시에 동일한 데이터를 수정하려고 하면, 데이터가 꼬이거나 엉뚱한 값이 조회되는 부작용이 발생하곤 합니다. 이는 트랜잭션의 ‘격리 수준(Isolation Level)’에 대한 이해가 부족하기 때문입니다.

이번 글에서는 동시성 제어의 끝판왕인 DB 트랜잭션 격리 수준 4단계와, 스프링이 제공하는 @Transactional이 내부적으로 어떻게 작동하는지 그 원리를 완벽하게 파헤쳐 보겠습니다.

1. 트랜잭션 격리 수준(Isolation Level)이란?

트랜잭션 격리 수준이란 “동시에 여러 트랜잭션이 진행될 때, 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할지 말지 결정하는 등급”을 뜻합니다.

격리 수준을 높여서 트랜잭션끼리 서로 완벽하게 차단하면 데이터의 정확성(정점)은 올라가지만, 동시 처리 속도가 대폭 떨어져 성능 저하가 발생합니다. 반대로 격리 수준을 너무 낮추면 성능은 좋아지지만 데이터의 일관성이 깨지는 트레이드오프(Trade-off) 관계를 가집니다. ANSI/ISO SQL 표준은 이를 4단계로 정의합니다.

2. 트랜잭션 격리 수준 4단계와 발생할 수 있는 문제점

격리 수준이 낮을 때 발생하는 3가지 치명적인 데이터 부정합 현상(Dirty Read, Non-Repeatable Read, Phantom Read)과 함께 각 단계를 알아보겠습니다.

2-1. READ UNCOMMITTED (레벨 0)

  • 특징: 어떤 트랜잭션의 변경 내용이 아직 커밋(Commit)되거나 롤백(Rollback)되지 않았음에도 다른 트랜잭션에서 조회가 가능한 수준입니다.
  • 문제점 (Dirty Read): 트랜잭션 A가 데이터를 수정 중이고 아직 커밋하지 않았는데, 트랜잭션 B가 이 수정 중인 데이터를 읽어갈 수 있습니다. 만약 트랜잭션 A에서 에러가 발생해 롤백되면, 트랜잭션 B는 존재한 적도 없는 거짓 데이터를 가지고 로직을 수행하게 됩니다. 이를 더티 리드(Dirty Read)라고 하며, 실무에서는 거의 사용하지 않습니다.

2-2. READ COMMITTED (레벨 1)

  • 특징: 트랜잭션이 커밋이 완료된 데이터만 다른 트랜잭션이 조회할 수 있도록 보장합니다. 오라클(Oracle) 등 대다수 DB의 기본 격리 수준입니다.
  • 문제점 (Non-Repeatable Read): 더티 리드는 방지하지만, 한 트랜잭션 내에서 똑같은 조회 쿼리를 두 번 실행했을 때 그 사이에 다른 트랜잭션이 데이터를 수정하고 커밋해 버리면 두 조회의 결과 값이 다르게 나타나는 현상이 발생합니다. 이를 반복 불가능한 조회(Non-Repeatable Read)라고 합니다.

2-3. REPEATABLE READ (레벨 2)

  • 특징: 한 트랜잭션이 시작되기 전에 커밋된 내용에 대해서만 조회할 수 있도록 보장합니다. 자신의 트랜잭션 번호보다 오래된 데이터만 읽게 하여, 한 트랜잭션 내에서는 똑같은 조회를 몇 번을 해도 항상 동일한 데이터 결과를 보장합니다. MySQL(InnoDB)의 기본 격리 수준입니다.
  • 문제점 (Phantom Read): 데이터의 수정은 방지하지만, 데이터의 ‘삽입(Insert)’은 막지 못합니다. 트랜잭션 A가 조건으로 데이터를 조회한 후, 그 사이에 트랜잭션 B가 새로운 데이터를 삽입하고 커밋하면, 트랜잭션 A가 다시 조회했을 때 기존에 없던 유령 레코드가 나타나는 현상이 발생합니다. 이를 팬텀 리드(Phantom Read)라고 합니다.

2-4. SERIALIZABLE (레벨 3)

  • 특징: 가장 엄격한 격리 수준입니다. 한 트랜잭션이 레코드를 읽으면 다른 트랜잭션은 해당 레코드를 수정하거나 삽입할 수 없도록 완벽한 락(Lock)을 겁니다.
  • 단점: 앞서 언급한 모든 데이터 부정합 문제가 해결되지만, 동시 처리 성능이 극도로 떨어지므로 극단적인 금융 금융 거래 분야 외에는 실무에서 거의 쓰이지 않습니다.

3. 스프링 @Transactional의 마법과 내부 작동 원리

자바 스프링 환경에서는 DB의 이러한 복잡한 트랜잭션을 소스 코드 레벨에서 편리하게 제어할 수 있도록 선언적 트랜잭션인 @Transactional 어노테이션을 제공합니다. 개발자는 비즈니스 로직 위에 이 어노테이션 한 줄만 붙이면 끝이지만, 내부적으로는 놀라운 기술이 숨어있습니다.

3-1. 스프링 AOP와 프록시(Proxy) 패턴 기반 동작

스프링은 이전 스레드 풀 글에서 다루었던 비동기(@Async) 원리와 마찬가지로 AOP와 프록시 기술을 이용해 트랜잭션을 처리합니다.

  1. 클라이언트(컨트롤러 등)가 @Transactional이 적용된 서비스 메서드를 호출하면, 실제 서비스 객체가 아닌 스프링이 임의로 만들어둔 프록시(가짜) 객체가 요청을 가로챕니다.
  2. 프록시 객체는 비즈니스 로직이 시작되기 전, 내부적으로 TransactionManager를 통해 DB 커넥션을 획득하고 connection.setAutoCommit(false);를 호출하여 트랜잭션을 시작합니다.
  3. 비즈니스 로직(실제 서비스 메서드)을 대리 실행합니다.
  4. 예외 없이 로직이 정상적으로 종료되면 프록시 객체가 connection.commit();을 호출하여 최종 반영하고, 만약 런타임 예외(RuntimeException)가 발생하면 connection.rollback();을 처리한 뒤 커넥션을 반환합니다.

3-2. @Transactional 사용 시 주의해야 할 치명적인 실무 법칙

프록시 패턴으로 동작한다는 한계 때문에 개발 시 반드시 아래 2가지 제약 사항을 지켜야 장애를 막을 수 있습니다.

  • 자가 호출(Self-Invocation) 현상 금지: 클래스 내부의 일반 메서드에서 동일한 클래스의 @Transactional 메서드를 직접 호출하면 트랜잭션이 켜지지 않습니다. 프록시 객체를 거치지 않고 내 몸통의 메서드를 바로 호출하기 때문입니다.
  • 접근 제어자는 무조건 public: 스프링 AOP 프록시 메커니즘 특성상 외부에서 접근 가능한 public 메서드에만 트랜잭션이 정상적으로 적용됩니다.

4. 핵심 요약: 격리 수준과 문제점 한눈에 보기

격리 수준 (Isolation Level)Dirty ReadNon-Repeatable ReadPhantom Read동시 처리 성능
READ UNCOMMITTED발생 가능발생 가능발생 가능최고 (매우 빠름)
READ COMMITTED방지 가능발생 가능발생 가능우수 (보통 수준)
REPEATABLE READ방지 가능방지 가능발생 가능 (MySQL은 일부 방지)보통
SERIALIZABLE방지 가능방지 가능방지 가능최악 (매우 느림)

5. 결론

자바 백엔드 엔지니어에게 트랜잭션을 제어하는 능력은 데이터의 생명과 직결됩니다. 내가 사용하는 데이터베이스의 기본 격리 수준(Oracle은 READ COMMITTED, MySQL은 REPEATABLE READ)이 무엇인지 인지하고, 데이터 동시 수정이 빈번한 비즈니스 로직에는 스프링의 @Transactional 옵션을 섬세하게 제어하거나 비관적/낙관적 락(Lock)을 적절히 결합해야만 안전한 고성능 아키텍처를 완성할 수 있습니다.