본문으로 건너뛰기

안동민 개발노트

본문 시작

REQUIRES_NEW의 독립 자원

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

이전 제목에서 말한 “트랜잭션 전파 격리”는 데이터베이스 isolation level을 높여야 한다는 뜻이 아닙니다. 핵심은 REQUIRES_NEW가 외부와 다른 물리 트랜잭션과 연결을 사용해 커밋 운명을 분리한다는 점입니다.

외부 연결은 반납되지 않은 채 보류되고, 내부 호출은 풀에서 연결 하나를 더 빌립니다. 그러므로 이 전파를 선택할 때는 독립 커밋뿐 아니라 동시 연결 수, 미커밋 행의 가시성, 잠금과 타임아웃까지 한 묶음으로 검토해야 합니다.

REQUIRES_NEW가 외부 연결 A를 보류하고 내부 연결 B를 동시에 사용하며 풀 용량과 미커밋 데이터 가시성에 미치는 영향을 설명하는 다이어그램

REQUIRES_NEW · RESOURCE BOUNDARY

격리 수준이 아니라 연결과 완료 운명을 분리한다

외부 연결 A는 보류 상태로 남고 내부는 연결 B를 추가로 빌립니다. 독립 커밋에는 풀 여유와 미커밋 가시성 제약이 따라옵니다.

REQUIRES_NEW의 두 연결, 풀 예산과 데이터 가시성 호출 스레드는 외부 트랜잭션의 연결 A를 보류한 채 풀에서 연결 B를 빌려 내부 감사 트랜잭션을 독립 커밋한다. B는 A의 미커밋 게시글을 볼 수 없고 같은 키 접근은 잠금 대기를 만들 수 있다. 풀 수요는 외부 동시성 곱하기 하나 더하기 중첩 깊이에 예약 연결을 더해 계산한다. ONE CALL THREAD · TWO PHYSICAL RESOURCES Outer method Tx A 시작 Connection A 외부가 계속 보유 suspend A 풀에 반환 안 됨 Connection B Inner Tx B · commit resume A B의 commit은 이후 A rollback으로 취소되지 않는다 VISIBILITY · LOCKING A의 uncommitted post 외부 연결에만 보임 B에서 다시 조회 0 rows 또는 lock wait 독립 audit payload requestId · actorId · outcome 외부가 변경 중인 aggregate를 내부에서 다시 읽지 않는다 POOL CAPACITY · PESSIMISTIC START T = 24 outer 동시성 1 + D = 2 outer + inner R = 4 예약 연결 T × (1 + D) + R 24 × 2 + 4 = 52 DB 상한보다 크면 timeout 조정이 아니라 구조 변경
resource isolation level과 propagation을 구분하기
  1. 외부는 연결 A를 보유

    내부 호출 동안 A는 보류되지만 풀에 반환되지 않습니다.

  2. 내부는 연결 B를 추가 획득

    B에서 별도 물리 트랜잭션을 커밋하며 그 결과는 이후 A의 롤백으로 취소되지 않습니다.

  3. 풀 수요를 상한으로 계산

    T × (1 + D) + R로 외부, 중첩 깊이, 예약 연결을 함께 잡습니다.

  4. B는 A의 미커밋 행을 볼 수 없음

    불변 감사 payload를 넘기고 외부가 잠근 aggregate를 내부에서 다시 조회하지 않습니다.

  5. 독립 커밋은 장애 격리가 아님

    같은 DB와 풀의 장애는 공유합니다. 상한을 넘으면 비동기 영속 전달이나 동시성 제한으로 구조를 바꿉니다.

  • 새 물리 자원과 독립 커밋
  • 보류·미커밋 비가시성
  • 잠금 또는 용량 위험

핵심: REQUIRES_NEW는 “더 강한 격리”가 아니라 두 연결과 두 완료 운명을 선택하는 전파 규칙입니다.


한 호출 스레드가 두 연결을 점유한다

외부 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 기한보다 짧게 두어 무한 대기를 실패 신호로 바꿀 뿐 수요를 없애지는 않습니다.


독립 연결은 외부 미커밋 상태를 보지 못한다

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

외부가 어떤 키를 갱신하며 잠금을 보유하고 내부가 같은 키를 읽거나 수정하면 Java 코드가 순차여도 DB에서는 서로 다른 트랜잭션의 잠금 경쟁입니다. 내부 작업에는 요청 ID, 행위자 ID, 작업 코드처럼 이미 확정됐거나 호출 시점에 복사한 불변 값을 전달합니다. 외부가 변경 중인 관리 엔티티를 다시 조회하게 만들지 않습니다.

외부와 내부는 각자의 isolation level과 스냅샷을 가질 수 있습니다. 하지만 REQUIRES_NEW의 목적을 “더 강한 격리가 필요해서”라고 설명하면 전파와 격리를 섞게 됩니다. 필요한 것은 독립 완료이고, 다른 가시성은 그 결과로 따라오는 제약입니다.

H2 단일 테스트 결과로 PostgreSQL이나 MySQL의 잠금 대기와 deadlock victim 선택을 단정하지 않습니다. 운영 데이터베이스에서 두 연결을 실제로 열어 시간 초과와 잠금 순서를 검증합니다.


실행으로 자원 경계를 관찰한다

다음 Java 25 테스트는 Spring Boot 4.1.1, Spring Framework 7.0.9, JUnit 6.0.3 기준입니다. 외부와 내부에서 실제 JDBC 연결 객체의 식별자를 기록하고, 내부가 외부 미커밋 게시글을 보지 못하지만 감사 행은 독립 커밋함을 검증합니다.

두 번째 테스트는 풀 예산 공식을 실행 가능한 값 객체로 고정합니다. 공통 빌드 파일이나 루트 설정 없이 배정된 패키지 안에서 완결됩니다.

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.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 -> current);
        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);
        }
    }
}

완료 행렬은 예외 전파까지 포함한다

외부 결과내부 결과최종 상태호출자가 확인할 조건
커밋커밋main + audit정상 경로
롤백커밋audit만 남음독립 기록의 의미가 “시도”인가
커밋롤백main만 남을 수 있음내부 예외를 명시적으로 허용·처리했는가
롤백롤백둘 다 없음각 물리 트랜잭션의 실패 원인

REQUIRES_NEW를 붙였다는 사실만으로 세 번째 행이 만들어지지는 않습니다. 내부 런타임 예외를 외부가 처리하지 않으면 외부도 롤백될 가능성이 큽니다.


운영 관찰과 구조 변경 기준

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

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

동시 요청 40개, 깊이 1, 예약 연결 6개인데 DB 계정 상한이 70이라면 수요는 86입니다. 풀 숫자 조정만으로는 불가능합니다. 외부 트랜잭션 밖의 영속적 전달 또는 동시성 제한으로 구조를 바꾸는 것이 답입니다.