성장일기

내가 보려고 정리하는 공부기록

백엔드/스프링

[Spring] 요청을 보내면 어떤 일이 벌어질까? : 트랜잭션과 DB 커넥션

와나나나 2026. 7. 20. 17:00
반응형

프로젝트를 진행하다보면 당연하게 transaction 어노테이션을 사용한다. 트랜잭션을 쓰는 이유라고 한다면 성공하면 전체 성공, 실패하면 전체 롤백을 하는 방식으로 원자성을 확보하기 위함인게 가장 크다고 생각한다. 이렇게 얄팍하게 알고있던 트랜잭션을 정리해보려고 한다. 목차는 다음과 같다.

 

  • 트랜잭션을 사용하는 이유
  • 트랜잭션의 전파 레벨과 DB커넥션
  • 요청이 들어온다면 어떤일이 벌어질까?

 


1. 트랜잭션을 사용하는 이유

트랜잭션을 사용하는 이유는 앞서 말했던 원자성 확보도 있지만, 다른 이유들이 있다. 트랜잭션을 사용함으로써 보장되는 4가지가 있는데 이를 ACID라고 한다. 

 

ACID

 

  • 원자성 (Atomicity): 트랜잭션 안의 모든 작업은 전부 성공하거나 전부 실패한다. 하나라도 실패하면 나머지도 전부 되돌린다.
  • 일관성 (Consistency): 트랜잭션이 끝나면 데이터는 항상 DB가 정한 규칙(제약조건, 무결성 등)을 만족하는 상태여야 한다.
  • 격리성 (Isolation): 동시에 실행되는 트랜잭션들은 서로의 중간 상태에 영향을 주지 않아야 한다.
  • 지속성 (Durability): 커밋이 끝난 데이터는 이후 시스템에 장애가 나도 사라지지 않아야 한다.

 

✔️ @Transactional은 실제로 어떻게 동작할까

 

@Transactional이 붙은 빈은 스프링 컨테이너에 원본 그대로 등록되지 않는다. 대신 그 빈을 감싸는 프록시 객체가 대신 등록된다. @Transactional 뿐만 아니라 @Async, @Cacheable, @PreAuthorize 등 프록시로 받아야하는 어노테이션들이 있다.

다른 빈이 이 서비스를 주입받아 메서드를 호출하면, 실제로는 원본이 아닌 프록시를 먼저 거친다.

class OrderServiceProxy extends OrderService {
    @Override
    public void placeOrder() {
        beginTransaction();      // 커넥션 획득, autocommit=false
        try {
            super.placeOrder();  // 진짜 로직은 원본에만 있음
            commit();
        } catch (RuntimeException e) {
            rollback();
            throw e;
        }
    }
}

 

 

이 트랜잭션 처리 코드는 범용적으로 작성이 되어있고, 실행시점에 지금 호출된 메서드가 뭔지만 판단하여 실행시킨다. 이렇게 트랜잭션 처리가 구현되어 있고, 알아서 실행시키기 때문에 트랜잭션 시작, 커밋, 롤백 코드를 매번 추가하지 않아도 된다.

 

트랜잭션 처리가 프록시 객체로 일어나는 만큼, 자기참조를 하지 않도록 주의해야 한다.

 

self-invocation

프록시는 외부에서 컨테이너에게 빈을 요청하는 경로에만 개입한다. 즉 this를 이용해 클래스 내부에서 자신의 메서드를 호출할 때는 프록시가 개입하지 않는다는 의미이다. 클래스 내부에서 메서드를 호출하면, 이미 원본객체 내부에 들어와있는 상태이고, 원본객체는 프록시에 감싸져 있다는 걸 인지하지 않기 때문에 개입하지 않는 것이다.
따라서 자신의 메서드를 호출하는 경우, 트랜잭션이 호출되지 않을 수 있어 주의해야한다.

 

 

트랜잭션을 어떻게 사용하는지 더 알아보자.

 


2. 트랜잭션의 전파 레벨과 DB 커넥션

개발을 하면 대부분의 비즈니스 로직에는 @Transactional 가 붙는다. 따라서 @Transactional 메서드가 실행되는 중에 @Transactional이 붙은 또다른 메서드를 호출하는 경우가 많다.

@Transactional
public void placeOrder() {
    validateStock();      // 이것도 @Transactional
    paymentService.pay(); // 이것도 @Transactional
}

 

이때 안쪽 메서드가 바깥쪽 트랜잭션에 합류해야할지, 분리해야할지 생각해보아야 하는데, 이 개념이 바로 트랜잭션의 전파(propagation) 속성이다.

 

트랜잭션 전파 속성

전파 속성은 다음과 같이 어노테이션에 설정하면 된다. 설정하지 않으면 기본값은 REQIRED로 들어간다.

 

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logPayment() {
}

 

여러 옵션들이 있지만 가장 많이 쓰이는 속성만 소개하면 다음과 같다.

 

  • REQUIRED (기본값): 기존 트랜잭션이 있으면 참여하고, 없으면 새로 시작한다.
  • REQUIRES_NEW: 기존 트랜잭션을 일시 중단하고, 완전히 독립된 새 트랜잭션을 시작한다.

로직이 요구하는 것에 따라 그때그때 설정해 사용하면 된다. REQUIRES_NEW에 대해좀 더 자세히 풀어보려 한다.

 

 

REQUIRES_NEW에 대해

REQUIRES_NEW의 "일시 중단"이라는 표현이 추상적으로 느껴질 수 있는데, 커넥션 풀 관점에서 생각하면 좀 더 이해가 편하다.

 

  • 기존 트랜잭션이 물고 있던 커넥션 A는 반납되지 않고 그대로 대기 상태가 된다.
  • 새 트랜잭션을 위해 커넥션 풀에서 커넥션 B를 추가로 대출한다.
  • 새 트랜잭션이 끝나면 커넥션 B를 반납하고, 그제야 커넥션 A로 돌아가 원래 로직을 이어간다.

즉 REQUIRES_NEW를 쓰는 순간, 그 구간 동안은 커넥션 두 개를 동시에 점유하게 된다. 격리가 되니 안전해 보일 수는 있지만, 실제로는 자원을 두 배 쓰고있는 셈이다. 특히 반복문 안에서 REQUIRES_NEW 메서드를 호출하는 패턴은, 반복할 때마다 커넥션을 새로 대출하고 반납하는 비용이 누적되고, 풀 사이즈가 작을수록 대기가 빠르게 쌓인다.

 

🔗 DB 커넥션풀

DB 커넥션을 모아둔 커넥션 풀이 있고, 스프링은 HikariCP를 사용한다. 커넥션풀에는 정해진 사이즈 만큼의 커넥션이 들어있으며, 트랜잭션이 걸린 로직을 실행할 때마다 커넥션을 빌려서 쓴다. 트랜잭션이 커밋되거나 롤백되는 시점에 커넥션이 반납되기 떄문에 트랜잭션 구간의 길이가 곧 커넥션 점유 시간인 것이다.

별도의 설정을 하지 않으면 커넥션풀의 크기는 보통 10이다. 커넥션 10개를 스레드가 나눠써야하니, 커넥션은 필요할때 최대한 짧게 갖고있는게 좋다. 풀에 있는 커넥션을 모두 사용중이라면, 나머지 요청은 pending 상태가 되고 타임아웃을 넘기면 예외로 터진다.

 

 

동시 요청이 발생하는 경우 - DB 격리수준

커넥션을 붙잡고 있는 그 짧은 시간 동안, 다른 요청이 같은 데이터를 동시에 건드리면 어떤 일이 벌어질까. 이 질문에 답을 정해두는 게 격리 수준(Isolation Level)이다.

 

동시 접근이 만들어내는 문제는 크게 세 가지로 정리할 수 있다.

  • Dirty Read
    • 다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽어버리는 것
    • 트랜잭션이 나중에 롤백되면, 나는 애초에 존재한 적 없는 데이터를 읽은 셈이 된다.
  • Non-Repeatable Read
    • 같은 트랜잭션 안에서 같은 행을 두 번 조회했는데 값이 달라져 있는 것
    • 그 사이에 다른 트랜잭션이 값을 바꾸고 커밋해버린 경우다.
  • Phantom Read
    • 같은 조건으로 두 번 조회했는데 조회되는 행의 개수 자체가 달라지는 것
    • 그 사이에 다른 트랜잭션이 행을 추가하거나 지운 경우다.

 

SQL 표준은 이 세 문제를 얼마나 허용할지에 따라 격리 수준을 4단계로 나누며, 다음과 같다.

 

  • READ UNCOMMITTED
    • 가장 낮은 단계이며 다른 트랜잭션이 아직 커밋하지 않은 데이터까지 그대로 읽을 수 있다.
    • 세 가지 이상 현상(Dirty Read, Non-Repeatable Read, Phantom Read)이 전부 발생할 수 있는, 사실상 정합성을 거의 포기한 수준이라 실무에서 쓰이는 경우는 드물다.
  • READ COMMITTED
    • 커밋된 데이터만 읽도록 보장한다. 그래서 Dirty Read는 막을 수 있지만, 같은 트랜잭션 안에서 같은 행을 두 번 읽는 사이에 다른 트랜잭션이 커밋해버리면 값이 달라져 있을 수 있다(Non-Repeatable Read).
    • Phantom Read도 마찬가지로 막지 못한다. Oracle, PostgreSQL 등에서 기본값으로 쓰인다.
  • REPEATABLE READ
    • 트랜잭션이 시작된 시점의 데이터를 트랜잭션이 끝날 때까지 일관되게 읽도록 보장한다. 그래서 Dirty Read와 Non-Repeatable Read는 막지만, 표준 정의상으로는 Phantom Read까지 막지는 못한다
      • 같은 조건으로 조회했을 때 새로 추가된 행(Phantom)이 보일 수 있다는 뜻이다.
    •  MySQL(InnoDB)의 기본값이 이 단계이다.
  • SERIALIZABLE
    • 가장 엄격한 단계. 모든 트랜잭션이 마치 순서대로(직렬로) 실행된 것과 동일한 결과를 보장한다.
    • 세 이상 현상을 전부 막지만, 그만큼 락을 가장 많이, 가장 오래 잡기 때문에 동시 처리량이 크게 떨어진다.
    • 정합성이 무엇보다 중요한 극히 일부 로직에만 제한적으로 쓰인다.

 

 

단계가 올라갈수록 정합성이 강해지고, 그만큼 락을 오래 잡아야해서 동시 처리량이 떨어진다. 따라서 서비스의 특성을 파악해 설정해야한다.

 

참고로 MySQL (InnoDB) 의 기본 격리 수준은 REPEATABLE READ 인데 표준 REPEATABLE READ 과 달리  Phantom Read까지 상당 부분 막을 수 있다. 이는 InnoDB의 MVCC 덕분이다.

MVCC (다중 버전 동시성 제어)

MVCC는 간단히 말하면, 데이터를 수정할 때 기존 행을 바로 덮어쓰지 않고 버전을 여러 개 유지하는 방식이다. 어떤 행이 수정되면 이전 버전은 바로 사라지는 게 아니라 그대로 남아있고, 새 버전이 하나 더 생긴다. 그래서 트랜잭션 시작 당시 버전을 계속 읽게 해 부딪히지 않도록 한다.

 


3. 서버에 트랜잭션이 걸린 요청이 들어온다면

지금까지 프록시, 전파, 커넥션 점유, 격리 수준을 따로따로 뜯어봤다. 이들이 실제로는 어떻게 한 흐름 안에서 동작하는지 처음부터 끝까지 따라가 보면서 확인해보려 한다. 클라이언트가 주문 생성 API를 호출했고, 서버는 @Transactional이 붙은 placeOrder()를 통해 이 요청을 처리한다고 가정하려한다.

 

1) 요청이 프록시에 도착한다

컨트롤러가 orderService.placeOrder()를 호출하는 순간, 실제로 먼저 응답하는 건 원본 객체가 아니라 프록시다. 여기서 트랜잭션의 시작과 끝을 감싸는 코드가 실행될 준비를 한다.

 

2) 트랜잭션이 시작되고, 커넥션이 대출된다

프록시는 beginTransaction()을 호출한다. 이 시점에 커넥션 풀(HikariCP)에서 커넥션 하나를 대출하고, autocommit을 false로 바꾼다. 별다른 전파 속성을 지정하지 않았다면 기본값인 REQUIRED가 적용되어, 이 요청을 위한 새 트랜잭션이 시작된다.

 

3) 로직이 실행되는 동안

placeOrder() 내부에서 재고를 확인하고 차감하는 로직이 실행된다. 만약 이 로직 안에서 결제 이력처럼 바깥 트랜잭션이 롤백돼도 반드시 남아야 하는 처리가 필요하다면, 여기서 REQUIRES_NEW로 별도 트랜잭션을 열게 된다. 그렇게 되면 커넥션을 동시에 2개 쥐게 된다.

 

4) flush와 commit (실제로 DB에 반영)

로직이 끝나면 영속성 컨텍스트가 변경된 엔티티를 감지해서(dirty checking) DB에 반영할 쿼리를 만들고(flush), 트랜잭션을 커밋한다. 만약 이 트랜잭션이 조회 전용이라 readOnly = true로 선언되어 있었다면, 애초에 이 dirty checking 자체를 생략하도록 최적화가 시도된다

 

5) 커넥션이 반납된다

커밋(혹은 예외가 발생했다면 롤백)이 끝나면 커넥션은 그제야 풀로 돌아간다.