본문 바로가기

REQUIRES_NEW의 독립 자원

REQUIRES_NEW가 별도 연결과 물리 트랜잭션을 요구하는 이유를 확인하고 풀 용량·미커밋 가시성·잠금 비용을 계산합니다.

REQUIRES_NEW가 따로 끝난다는 것은 데이터베이스의 격리 수준(isolation level)을 높인다는 뜻이 아닙니다. 바깥과 다른 물리 트랜잭션과 연결을 써서 커밋의 운명을 나눈다는 뜻입니다. 바깥의 연결은 돌려주지 않은 채 멈춰 있고, 안쪽 호출은 풀에서 연결을 하나 더 빌립니다. 그래서 이 전파를 고를 때는 따로 하는 커밋뿐 아니라 동시 연결 수와 커밋되지 않은 행의 가시성, 잠금과 타임아웃까지 한 묶음으로 검토해야 합니다.

REQUIRES_NEW의 핵심은 격리 수준이 아니라 독립 연결이다

외부 연결 A 보류·내부 B 획득·B 완료와 반환·A 재개를 같은 연결 위치에서 따라갈 수 있다.

REQUIRES_NEW · TWO CONNECTIONS + RESOURCE BUDGET

외부 연결을 보류한 동안 내부 연결을 더 빌린다

REQUIRES_NEW는 독립 물리 완료를 선택합니다. 연결 A는 풀에 반환되지 않고 B를 추가 획득하므로 동시 자원 수와 가시성을 함께 계산합니다.

외부 연결 A 보류, 내부 연결 B 완료, 외부 A 재개외부 A가 연결을 보유한 채 보류되고 내부는 새 연결 B를 빌린다. B 커밋 또는 롤백이 끝나면 B를 반환하고 A를 재개한다. 한순간 두 연결이 동시에 필요하다.연결 A 보류외부가 계속 보유연결 B 사용내부 commit / rollback연결 A 재개B 반환 뒤 외부 계속동시에 필요한 자원: 보류된 A + 현재 사용 중인 BB 커밋은 A 롤백으로 취소되지 않음 · Java 예외는 전파될 수 있음
보류된 A와 현재 B의 동시 보유원desktop3노드2간선과같이A보류→B사용→B반환후A재개를그린다. B사용노드에A계속보유를명시해한순간두연결임을보인다. Java의순차실행은동시자원보유를없애지않고독립commit은예외전파를자동차단하지않는다.
연결 A 보류외부가 계속 보유
B 추가B 추가연결 A 보류 · 외부가 계속 보유 → 연결 B 사용 · A도 계속 보유 · B commit / rollback
연결 B 사용A도 계속 보유B commit / rollback
B 반환B 반환연결 B 사용 · A도 계속 보유 · B commit / rollback → B 반환 · A 재개 · 외부 계속 실행
B 반환 · A 재개외부 계속 실행
  1. A 보류 → B 획득

    외부 A는 풀에 반환되지 않습니다. 내부 B와 한순간 두 연결을 점유합니다.

  2. B 완료·반환 → A 재개

    B 커밋은 이후 A 롤백으로 취소되지 않습니다. 내부 Java 예외를 처리하지 않으면 외부도 롤백할 수 있습니다.

풀 수요 · 비관적 상한

T × (1 + D) + R

24 × (1 + 1) + 4 = 52

T는 외부 동시 요청, D는 최대 중첩 깊이, R은 예약 연결입니다. 자동 안전 보장이 아니라 모든 요청이 함께 중첩될 때의 출발 모델입니다.

가시성 · READ_COMMITTED

B에서는 A의 미커밋 행이 보이지 않습니다. 같은 키나 외래 키 확인은 DB 구현에 따라 잠금을 기다릴 수 있습니다. 불변 requestId·actorId·outcome을 전달하고 운영 DB에서 두 연결로 검증합니다.

같은 DB·계정·풀의 장애는 공유합니다. 수요가 DB 상한을 넘으면 동시성 제한이나 외부 완료 뒤 영속 전달로 구조를 바꿉니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 외부 A가 연결을 보유한 채 보류되고 내부는 새 연결 B를 빌린다. B 커밋 또는 롤백이 끝나면 B를 반환하고 A를 재개한다. 한순간 두 연결이 동시에 필요하다.
  • 원래 가로 도표의 3노드와 2간선과 같이 A 보류→B 사용→B 반환 후 A 재개를 그린다. B 사용 노드에 A 계속 보유를 명시해 한순간 두 연결을 보유함을 보인다. Java의 순차 실행은 동시 자원 보유를 없애지 않고 독립 commit은 예외 전파를 자동 차단하지 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1REQUIRES_NEW의 핵심은 격리 수준이 아니라 독립 연결이다
16원래 가로 도표의 3노드와 2간선과 같이 A 보류→B 사용→B 반환 후 A 재개를 그린다. B 사용 노드에 A 계속 보유를 명시해 한순간 두 연결을 보유함을 보인다. Java의 순차 실행은 동시 자원 보유를 없애지 않고 독립 commit은 예외 전파를 자동 차단하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
외부 A 보류

외부 transaction이 연결 A를 가진 채 보류됩니다.

A: 보유·보류 · B: 아직 없음외부 연결을 반환하지 않고 안쪽으로 갑니다.
내부 B 실행

독립 내부 transaction이 새 연결 B를 빌립니다.

A: 계속 보유 · B: 새 연결 · 동시 필요: 두 연결내부 업무가 다른 물리 연결을 사용합니다.
B 반환과 A 재개

B의 commit·rollback 뒤 B를 반환하고 A를 재개합니다.

B: 완료·반환 · A: 재개내부 연결 소유가 끝나 외부 실행이 돌아옵니다.

REQUIRES_NEW의 핵심은 격리 수준이 아니라 독립 연결이다

외부 연결 A 보류·내부 B 획득·B 완료와 반환·A 재개를 같은 연결 위치에서 따라갈 수 있다.

REQUIRES_NEW · TWO CONNECTIONS + RESOURCE BUDGET

외부 연결을 보류한 동안 내부 연결을 더 빌린다

REQUIRES_NEW는 독립 물리 완료를 선택합니다. 연결 A는 풀에 반환되지 않고 B를 추가 획득하므로 동시 자원 수와 가시성을 함께 계산합니다.

외부 연결 A 보류, 내부 연결 B 완료, 외부 A 재개외부 A가 연결을 보유한 채 보류되고 내부는 새 연결 B를 빌린다. B 커밋 또는 롤백이 끝나면 B를 반환하고 A를 재개한다. 한순간 두 연결이 동시에 필요하다.연결 A 보류외부가 계속 보유연결 B 사용내부 commit / rollback연결 A 재개B 반환 뒤 외부 계속동시에 필요한 자원: 보류된 A + 현재 사용 중인 BB 커밋은 A 롤백으로 취소되지 않음 · Java 예외는 전파될 수 있음
보류된 A와 현재 B의 동시 보유원desktop3노드2간선과같이A보류→B사용→B반환후A재개를그린다. B사용노드에A계속보유를명시해한순간두연결임을보인다. Java의순차실행은동시자원보유를없애지않고독립commit은예외전파를자동차단하지않는다.
연결 A 보류외부가 계속 보유
B 추가B 추가연결 A 보류 · 외부가 계속 보유 → 연결 B 사용 · A도 계속 보유 · B commit / rollback
연결 B 사용A도 계속 보유B commit / rollback
B 반환B 반환연결 B 사용 · A도 계속 보유 · B commit / rollback → B 반환 · A 재개 · 외부 계속 실행
B 반환 · A 재개외부 계속 실행
  1. A 보류 → B 획득

    외부 A는 풀에 반환되지 않습니다. 내부 B와 한순간 두 연결을 점유합니다.

  2. B 완료·반환 → A 재개

    B 커밋은 이후 A 롤백으로 취소되지 않습니다. 내부 Java 예외를 처리하지 않으면 외부도 롤백할 수 있습니다.

풀 수요 · 비관적 상한

T × (1 + D) + R

24 × (1 + 1) + 4 = 52

T는 외부 동시 요청, D는 최대 중첩 깊이, R은 예약 연결입니다. 자동 안전 보장이 아니라 모든 요청이 함께 중첩될 때의 출발 모델입니다.

가시성 · READ_COMMITTED

B에서는 A의 미커밋 행이 보이지 않습니다. 같은 키나 외래 키 확인은 DB 구현에 따라 잠금을 기다릴 수 있습니다. 불변 requestId·actorId·outcome을 전달하고 운영 DB에서 두 연결로 검증합니다.

같은 DB·계정·풀의 장애는 공유합니다. 수요가 DB 상한을 넘으면 동시성 제한이나 외부 완료 뒤 영속 전달로 구조를 바꿉니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 외부 A가 연결을 보유한 채 보류되고 내부는 새 연결 B를 빌린다. B 커밋 또는 롤백이 끝나면 B를 반환하고 A를 재개한다. 한순간 두 연결이 동시에 필요하다.
  • 원래 가로 도표의 3노드와 2간선과 같이 A 보류→B 사용→B 반환 후 A 재개를 그린다. B 사용 노드에 A 계속 보유를 명시해 한순간 두 연결을 보유함을 보인다. Java의 순차 실행은 동시 자원 보유를 없애지 않고 독립 commit은 예외 전파를 자동 차단하지 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1REQUIRES_NEW의 핵심은 격리 수준이 아니라 독립 연결이다
16원래 가로 도표의 3노드와 2간선과 같이 A 보류→B 사용→B 반환 후 A 재개를 그린다. B 사용 노드에 A 계속 보유를 명시해 한순간 두 연결을 보유함을 보인다. Java의 순차 실행은 동시 자원 보유를 없애지 않고 독립 commit은 예외 전파를 자동 차단하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
외부 A 보류

외부 transaction이 연결 A를 가진 채 보류됩니다.

A: 보유·보류 · B: 아직 없음외부 연결을 반환하지 않고 안쪽으로 갑니다.
내부 B 실행

독립 내부 transaction이 새 연결 B를 빌립니다.

A: 계속 보유 · B: 새 연결 · 동시 필요: 두 연결내부 업무가 다른 물리 연결을 사용합니다.
B 반환과 A 재개

B의 commit·rollback 뒤 B를 반환하고 A를 재개합니다.

B: 완료·반환 · A: 재개내부 연결 소유가 끝나 외부 실행이 돌아옵니다.

그림은 바깥이 연결 A를 쥔 채 멈추고, 안쪽이 새 연결 B를 빌려 커밋이나 롤백을 마친 뒤 B를 돌려주고 A를 다시 이어 가는 흐름을 보여 줍니다. B를 쓰는 동안에도 A는 계속 쥐고 있으므로, 한순간에 연결 두 개가 함께 필요합니다. Java의 순차 실행이 자원을 동시에 쥐는 것을 없애 주지 않으며, 따로 하는 커밋이 Java 예외의 전파를 저절로 막지도 않습니다. 아래 칸들은 풀 수요의 계산식과, READ_COMMITTED에서 B가 A의 커밋되지 않은 행을 보지 못한다는 점을 설명합니다.


한 호출 스레드가 연결 두 개를 쥡니다

바깥의 REQUIRED가 시작되면 연결 A가 현재 스레드에 묶입니다. 안쪽 REQUIRES_NEW에 들어갈 때 Spring은 A와 바깥 트랜잭션의 상태를 잠시 멈추고, 연결 B로 새 물리 트랜잭션을 시작합니다. 안쪽이 끝나면 B를 커밋하거나 롤백하고 A를 되돌려 놓습니다. Java 호출은 순차적이지만, 한순간에는 멈춰 두었지만 아직 돌려주지 않은 바깥 연결 A와, 안쪽이 지금 쓰는 연결 B가 함께 필요합니다.

안쪽이 커밋된 뒤 바깥이 롤백되면 B의 행은 남고 A의 행은 사라집니다. 이것은 따로 커밋하려고 한 의도된 결과이며, 안쪽 커밋이 바깥 롤백과 함께 취소된다고 설명하면 안 됩니다.

반대로 안쪽 예외가 호출한 쪽으로 전파되면, 바깥 인터셉터도 런타임 예외를 보고 롤백할 수 있습니다. 물리 트랜잭션이 독립이라고 Java 예외의 전파가 저절로 막히지는 않습니다. 바깥이 안쪽의 실패를 허용할 때만 구체적인 실패 타입을 좁게 처리하고, 대신할 경로를 명시합니다.


풀의 용량은 동시성과 중첩 깊이로 계산합니다

바깥 트랜잭션의 동시 요청을 T, 요청 하나가 동시에 쥘 수 있는 REQUIRES_NEW의 최대 중첩 깊이를 D, 스케줄러나 상태 확인 등을 위해 남겨 둘 연결을 R이라고 하면, 보수적인 출발점은 다음과 같습니다.

필요한 연결 수 = T × (1 + D) + R

예를 들어 바깥 요청 24개, 깊이 1, 예약 4개라면 24 × 2 + 4 = 52입니다. 이 값은 안전을 자동으로 보장하는 정답이 아니라, 모든 바깥 요청이 연결을 쥐고 같은 순간 안쪽 연결을 기다리는 상황을 막기 위한 상한 모델입니다.

풀의 크기가 T뿐이면, 모든 요청이 A를 쥔 채 B를 기다리는 교착 형태의 고갈이 생길 수 있습니다. DB 계정의 최대 연결 수와 다른 애플리케이션 인스턴스, 배치 작업과 장애 때의 재시도도 함께 넣어야 합니다.

수요가 DB의 상한을 넘으면 타임아웃 숫자만 바꿔서는 해결되지 않습니다. REQUIRES_NEW 작업을 바깥이 끝난 뒤의 영속적인 대기열로 넘기거나, 요청의 동시성을 제한하거나, 데이터 흐름 자체를 다시 설계합니다. 연결 획득 타임아웃은 HTTP 기한보다 짧게 두어 무한 대기를 실패 신호로 바꿔 줄 뿐, 수요를 없애지는 않습니다.

더 깊이: 교착을 피하는 최소 크기와 이 글의 상한 모델

HikariCP 위키의 About Pool Sizing은 이런 풀 잠김(pool-locking)을 피하는 최소 크기를 자원 할당 공식으로 제시합니다. 스레드의 최대 수를 Tn, 스레드 하나가 동시에 쥐는 연결의 최대 수를 Cm이라고 하면 Tn × (Cm - 1) + 1입니다. 모든 스레드가 연결을 하나씩 덜 쥔 최악의 상태에서도 한 스레드는 마지막 연결을 얻어 끝낼 수 있고, 그 스레드가 돌려준 연결로 다른 스레드가 이어서 끝낼 수 있기 때문입니다. 이 글의 기호로 바꾸면 Cm은 1 + D이므로, 바깥 요청 24개와 깊이 1에서는 24 × 1 + 1 = 25개가 교착을 막는 최소입니다.

같은 위키는 이 값이 최적의 크기가 아니라 교착을 피하기 위한 최소일 뿐이라고 밝힙니다. 25개라면 대부분의 요청은 안쪽 연결을 얻으려고 줄을 서고, 최악의 경우에는 한 번에 하나씩만 진행되는 것과 비슷해집니다. 이 글의 T × (1 + D) + R은 모든 요청이 기다리지 않고 동시에 안쪽까지 들어갈 수 있게 하는 상한이고, HikariCP의 공식은 그 아래에서 멈추지 않을 하한이라고 보면 두 값 사이에서 실제 크기를 고를 수 있습니다. 위키는 또 풀을 키우기 전에 한 작업이 연결을 여럿 쥐는 애플리케이션의 구조부터 살펴보라고 권합니다.


따로 연 연결은 바깥의 커밋되지 않은 상태를 보지 못합니다

연결 B는 일반적인 READ_COMMITTED에서 연결 A가 아직 커밋하지 않은 행을 볼 수 없습니다. 안쪽 감사가 바깥에서 막 삽입한 게시글을 외래 키로 참조하면, 행을 찾지 못하거나 제약 확인과 잠금의 구현에 따라 기다리다 타임아웃될 수 있습니다.

바깥이 어떤 키를 고치며 잠금을 쥐고 있고 안쪽이 같은 키를 읽거나 고치면, Java 코드가 순차적이어도 DB에서는 서로 다른 트랜잭션의 잠금 경쟁입니다. 안쪽 작업에는 요청 ID와 행위자 ID, 작업 코드처럼 이미 확정되었거나 호출할 때 복사한 불변 값을 넘기고, 바깥이 고치고 있는 관리 엔티티를 다시 조회하게 만들지 않습니다.

바깥과 안쪽은 각자의 격리 수준과 스냅샷을 가질 수 있습니다. 하지만 REQUIRES_NEW를 고른 이유를 더 강한 격리가 필요해서라고 설명하면 전파와 격리를 섞게 됩니다. 필요한 것은 따로 끝나는 것이고, 다른 가시성은 그 결과로 따라오는 제약입니다. H2에서 한 번 테스트한 결과로 PostgreSQL이나 MySQL의 잠금 대기와 교착 상태의 희생자 선택을 단정하지 않으며, 운영 데이터베이스에서 실제로 연결 두 개를 열어 시간 초과와 잠금 순서를 검증합니다.


실행으로 자원의 범위를 확인합니다

다음 Java 25 테스트는 Spring Boot 4.1.2와 Spring Framework 7.0.10, JUnit 6.0.3을 기준으로 합니다. 바깥과 안쪽에서 실제 JDBC 연결 객체의 식별자를 기록하고, 안쪽이 바깥의 커밋되지 않은 게시글을 보지 못하지만 감사 행은 따로 커밋되는지 검증합니다. JdbcTemplate.execute(ConnectionCallback)는 호출할 때마다 close()를 막는 새 프록시로 연결을 감싸 콜백에 넘기므로, currentConnection은 ConnectionProxy.getTargetConnection()으로 그 안의 실제 연결을 꺼내 비교합니다.

두 번째 테스트는 풀 예산의 계산식을 실행할 수 있는 값 객체로 고정합니다. 앞에서 쓴 Spring JDBC와 H2, JUnit 의존성이 준비된 프로젝트에서 이 테스트 클래스와 설정 빈을 함께 실행합니다.

src/test/java/board/tx/propagation/requiresnew/RequiresNewResourceTest.java
package board.tx.propagation.requiresnew;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.sql.Connection;
import javax.sql.DataSource;

import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.ConnectionProxy;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

class RequiresNewResourceTest {
    private AnnotationConfigApplicationContext context;
    private JdbcTemplate jdbc;

    @BeforeEach
    void openContext() {
        context = new AnnotationConfigApplicationContext(Config.class);
        jdbc = context.getBean(JdbcTemplate.class);
        jdbc.execute("drop table if exists audit_log");
        jdbc.execute("drop table if exists posts");
        jdbc.execute("create table posts(id bigint primary key)");
        jdbc.execute("create table audit_log(id bigint primary key, outcome varchar(24))");
    }

    @AfterEach
    void closeContext() {
        context.close();
    }

    @Test
    void requiresNewUsesAnotherConnectionAndCommitsIndependently() {
        var observation = context.getBean(ConnectionObservation.class);

        assertThrows(
                ExpectedOuterFailure.class,
                () -> context.getBean(OuterWork.class).writeAuditThenFail(41L));

        assertNotSame(observation.outerConnection, observation.innerConnection);
        assertEquals(0, observation.outerRowsVisibleToInner);
        assertEquals(0, jdbc.queryForObject(
                "select count(*) from posts", Integer.class));
        assertEquals(1, jdbc.queryForObject(
                "select count(*) from audit_log", Integer.class));
    }

    @Test
    void poolBudgetIncludesEveryHeldOuterAndInnerConnection() {
        assertEquals(52, new PoolBudget(24, 1, 4).pessimisticMinimum());

        int demand = new PoolBudget(40, 1, 6).pessimisticMinimum();
        assertEquals(86, demand);
        assertTrue(demand > 70, "the database limit requires a redesign");
    }

    record PoolBudget(
            int concurrentOuterTransactions,
            int maximumRequiresNewDepth,
            int reservedConnections
    ) {
        PoolBudget {
            if (concurrentOuterTransactions < 1
                    || maximumRequiresNewDepth < 0
                    || reservedConnections < 0) {
                throw new IllegalArgumentException("invalid pool inputs");
            }
        }

        int pessimisticMinimum() {
            return Math.addExact(
                    Math.multiplyExact(
                            concurrentOuterTransactions,
                            1 + maximumRequiresNewDepth),
                    reservedConnections);
        }
    }

    public static class ConnectionObservation {
        private Connection outerConnection;
        private Connection innerConnection;
        private int outerRowsVisibleToInner;
    }

    public static class IndependentAudit {
        private final JdbcTemplate jdbc;
        private final ConnectionObservation observation;

        IndependentAudit(JdbcTemplate jdbc, ConnectionObservation observation) {
            this.jdbc = jdbc;
            this.observation = observation;
        }

        @Transactional(propagation = Propagation.REQUIRES_NEW)
        public void record(long id) {
            observation.innerConnection = currentConnection(jdbc);
            observation.outerRowsVisibleToInner = jdbc.queryForObject(
                    "select count(*) from posts where id = ?",
                    Integer.class,
                    id);
            jdbc.update(
                    "insert into audit_log(id, outcome) values (?, 'ATTEMPTED')",
                    id);
        }
    }

    public static class OuterWork {
        private final JdbcTemplate jdbc;
        private final IndependentAudit audit;
        private final ConnectionObservation observation;

        OuterWork(
                JdbcTemplate jdbc,
                IndependentAudit audit,
                ConnectionObservation observation
        ) {
            this.jdbc = jdbc;
            this.audit = audit;
            this.observation = observation;
        }

        @Transactional
        public void writeAuditThenFail(long id) {
            observation.outerConnection = currentConnection(jdbc);
            jdbc.update("insert into posts(id) values (?)", id);
            audit.record(id);
            throw new ExpectedOuterFailure();
        }
    }

    static Connection currentConnection(JdbcTemplate jdbc) {
        Connection connection = jdbc.execute(
                (ConnectionCallback<Connection>) current ->
                        ((ConnectionProxy) current).getTargetConnection());
        if (connection == null) {
            throw new IllegalStateException("connection callback returned null");
        }
        return connection;
    }

    static final class ExpectedOuterFailure extends RuntimeException {
        private static final long serialVersionUID = 1L;
    }

    @Configuration
    @EnableTransactionManagement(proxyTargetClass = true)
    static class Config {
        @Bean
        DataSource dataSource() {
            var source = new JdbcDataSource();
            source.setURL("jdbc:h2:mem:b92_requires_new;DB_CLOSE_DELAY=-1");
            return source;
        }

        @Bean
        PlatformTransactionManager transactionManager(DataSource dataSource) {
            return new DataSourceTransactionManager(dataSource);
        }

        @Bean
        JdbcTemplate jdbcTemplate(DataSource dataSource) {
            return new JdbcTemplate(dataSource);
        }

        @Bean
        ConnectionObservation connectionObservation() {
            return new ConnectionObservation();
        }

        @Bean
        IndependentAudit independentAudit(
                JdbcTemplate jdbc,
                ConnectionObservation observation
        ) {
            return new IndependentAudit(jdbc, observation);
        }

        @Bean
        OuterWork outerWork(
                JdbcTemplate jdbc,
                IndependentAudit audit,
                ConnectionObservation observation
        ) {
            return new OuterWork(jdbc, audit, observation);
        }
    }
}

PoolBudget 테스트는 깊이 1의 평범한 입력만 확인하며, 모든 int 입력의 넘침까지 검증한 것은 아닙니다. 특히 1 + maximumRequiresNewDepth는 일반 int 덧셈이어서, 최대 정수 깊이를 허용하면 먼저 음수로 넘친 뒤 바깥의 정확한 연산이 그것을 놓칠 수 있습니다. 바깥 설정 값을 받는 구현에서는 현실적인 깊이의 상한을 검증하거나, 이 안쪽 덧셈도 Math.addExact(1, maximumRequiresNewDepth)로 계산해야 합니다. 곱셈에만 정확한 연산을 썼다고 식 전체가 안전해지지는 않습니다.


완료 표에는 예외의 전파도 넣습니다

바깥 결과안쪽 결과최종 상태호출한 쪽이 확인할 조건
커밋커밋본 데이터와 감사정상 경로
롤백커밋감사만 남음따로 남는 기록이 시도라는 의미인가
커밋롤백본 데이터만 남을 수 있음안쪽 예외를 명시적으로 허용하고 처리했는가
롤백롤백둘 다 없음각 물리 트랜잭션의 실패 원인

REQUIRES_NEW를 붙였다는 것만으로 세 번째 행이 만들어지지는 않습니다. 안쪽의 런타임 예외를 바깥이 처리하지 않으면, 바깥도 롤백될 가능성이 큽니다.


운영에서 볼 것과 구조를 바꿀 기준

메트릭에는 작업별 안쪽 트랜잭션의 수와 연결 획득 시간, 풀의 활성·유휴·대기 수, 안쪽의 지속 시간, 타임아웃과 실패 타입을 둡니다. 요청 ID와 트랜잭션 ID는 고카디널리티 메트릭 태그가 아니라 추적 이벤트에 기록합니다.

따로 연 연결은 바깥의 롤백과 커밋의 운명을 나눌 뿐, 같은 데이터베이스의 장애와 같은 계정의 제한, 같은 풀의 장애를 나누지는 않습니다. 규제 감사처럼 DB 전체가 장애를 겪어도 남아야 하는 기록에는, 별도의 추가 전용 수집 대상과 보존·접근 제어가 필요합니다.


정리

  • REQUIRES_NEW는 격리 수준을 높이는 것이 아니라, 바깥 연결을 쥔 채 새 연결로 따로 끝나는 물리 트랜잭션을 여는 것입니다.
  • 안쪽 커밋은 바깥 롤백으로 취소되지 않지만, 안쪽 예외가 전파되면 바깥도 롤백될 수 있으므로 허용할 실패를 명시적으로 처리합니다.
  • 풀의 수요는 T × (1 + D) + R로 계산하며, 풀이 T뿐이면 모든 요청이 서로를 기다리는 고갈이 생길 수 있습니다.
  • 안쪽 연결은 바깥의 커밋되지 않은 행을 보지 못하고 같은 키에서 잠금을 기다릴 수 있으므로, 안쪽에는 확정된 불변 값만 넘깁니다.
  • 수요가 DB의 상한을 넘으면 타임아웃 조정이 아니라 동시성 제한이나 바깥이 끝난 뒤의 영속적 전달로 구조를 바꿉니다.

연습 문제

1. 중첩 깊이를 늘린 예산 계산

바깥 동시 요청이 24개, 최대 깊이가 2, 예약이 4개이고, 이 애플리케이션이 쓸 수 있는 연결이 70개라면 수요는 몇 개인가요? 깊이와 예약을 그대로 둔 채 상한 모델을 넘지 않는 최대 정수의 요청 동시성도 계산하세요.

해설 보기

깊이 2에서는 바깥 A와 멈춰 둔 첫 안쪽 B, 지금의 두 번째 안쪽 C까지 요청마다 연결 세 개를 쥘 수 있습니다. 수요는 24 × (1 + 2) + 4 = 76으로 70을 넘습니다. 동시성 T를 정수로 제한하면 3T + 4 ≤ 70에서 T는 최대 22입니다. 이 값은 최대 중첩이 동시에 일어난다는 출발 모델일 뿐 측정된 처리량이나 최적의 풀 크기가 아니며, 다른 인스턴스가 같은 DB 연결 한도를 나눠 쓰는지도 따로 반영해야 합니다.

2. 계정 상한을 넘는 수요

동시 요청이 40개, 깊이가 1, 예약 연결이 6개인데, DB 계정의 상한이 70이라고 합시다. 수요는 몇 개이고, 풀 크기를 조정하는 것만으로 해결할 수 있나요? 어떤 방향으로 구조를 바꿔야 하나요?

해설 보기

수요는 40 × (1 + 1) + 6 = 86으로 계정 상한 70을 넘으므로, 풀 숫자를 바꾸는 것만으로는 해결할 수 없습니다. 풀을 70보다 크게 잡아도 DB가 연결을 거부하고, 작게 잡으면 요청이 서로를 기다리며 고갈됩니다. 안쪽 REQUIRES_NEW 작업을 바깥 트랜잭션 밖의 영속적인 전달, 예를 들어 같은 트랜잭션에 기록하는 아웃박스와 비동기 처리로 옮겨 요청당 연결을 하나로 줄이거나, 요청의 동시성을 32개 이하로 제한하는 방향으로 구조를 바꿉니다.

다음 글 감사 로그와 독립 커밋에서는 실패한 시도를 남기는 감사 로그를 REQUIRES_NEW와 아웃박스로 각각 설계하고, 두 방식이 남기는 결과를 비교합니다.