트랜잭션 전파 격리
같은 물리 트랜잭션 참여와 독립 커밋의 차이를 H2에서 실행하고, suspended 외부 연결·풀 sizing·잠금 가시성·타임아웃을 포함해 REQUIRES_NEW의 운영 비용을 계산합니다.
REQUIRES_NEW는 내부 실패를 예외 처리하기 위한 일반 도구가 아닙니다.
기존 트랜잭션을 멈춘 채 별도 연결로 새 물리 트랜잭션을 만들고, 내부 결과를 외부와 독립적으로 완료해야 할 때 사용합니다.
이 장에서는 회원 게시판의 명령과 감사 행을 이용해 무엇이 남는지 직접 관찰하고, 동시에 요청이 몰릴 때 필요한 풀 여유를 계산합니다.
REQUIRED 커밋 운명
외부가 게시글을 삽입하고 내부 필수가 감사를 삽입하면 두 논리 스코프는 같은 연결을 사용합니다. 어느 한쪽이 롤백 전용 상태가 되면 둘 다 사라집니다. 핵심 변경과 아웃박스처럼 함께 영속화해야 하는 데이터에는 이 속성이 장점입니다.
내부가 정상 반환했다고 감사가 먼저 커밋된 것은 아닙니다. 외부가 이후 낙관적 충돌을 내면 감사도 롤백됩니다. 업무 이벤트를 아웃박스로 남길 때 이벤트 행이 애그리거트와 함께 사라져야 “없는 변경을 발행”하지 않으므로 필수가 맞습니다.
반대로 실패 시도 자체를 보안 감사로 남겨야 한다면 커밋 결과를 공유하는 방식이 요구와 맞지 않습니다. 이때 별도 트랜잭션이나 데이터베이스 외부의 영속적 수집 대상이 후보가 됩니다. 먼저 감사 종류를 업무 이벤트와 보안 관찰로 나눕니다.
REQUIRES_NEW 독립 커밋
다음 프로그램은 메인 삽입을 외부 트랜잭션에 두고 감사 작성기를 독립 빈으로 분리합니다. 감사가 커밋된 뒤 외부가 실패하므로 최종 행 수가 달라집니다.
package board.tx;
import javax.sql.DataSource;
import org.h2.jdbcx.JdbcDataSource;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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;
public final class RequiresNewProbe {
static class AuditWriter {
private final JdbcTemplate jdbc;
AuditWriter(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(long id, String outcome) {
jdbc.update("insert into audit_log(id, outcome) values (?, ?)",
id, outcome);
}
}
static class Registration {
private final JdbcTemplate jdbc;
private final AuditWriter audit;
Registration(JdbcTemplate jdbc, AuditWriter audit) {
this.jdbc = jdbc;
this.audit = audit;
}
@Transactional
public void registerThenFail(long id) {
jdbc.update("insert into posts(id) values (?)", id);
audit.record(id, "ATTEMPTED");
throw new IllegalStateException("reject main transaction");
}
}
@Configuration
@EnableTransactionManagement(proxyTargetClass = true)
static class Config {
@Bean
DataSource dataSource() {
var source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:requires_new;DB_CLOSE_DELAY=-1");
return source;
}
@Bean
PlatformTransactionManager transactionManager(DataSource source) {
return new DataSourceTransactionManager(source);
}
@Bean
JdbcTemplate jdbcTemplate(DataSource source) {
return new JdbcTemplate(source);
}
@Bean
AuditWriter auditWriter(JdbcTemplate jdbc) {
return new AuditWriter(jdbc);
}
@Bean
Registration registration(JdbcTemplate jdbc, AuditWriter audit) {
return new Registration(jdbc, audit);
}
}
public static void main(String[] args) {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var jdbc = context.getBean(JdbcTemplate.class);
jdbc.execute("create table posts(id bigint primary key)");
jdbc.execute("create table audit_log(id bigint primary key, outcome varchar(30))");
try {
context.getBean(Registration.class).registerThenFail(41L);
} catch (IllegalStateException expected) {
int posts = jdbc.queryForObject(
"select count(*) from posts", Integer.class);
int audits = jdbc.queryForObject(
"select count(*) from audit_log", Integer.class);
System.out.printf("posts=%d audits=%d%n", posts, audits);
}
}
}
}posts=0 audits=1이 결과가 원하는 업무 상태인지 검토해야 합니다. 감사 기록이 본문 행의 외래 키를 참조하면 내부 트랜잭션은 아직 커밋되지 않은 외부 행을 보지 못해 실패할 수 있습니다. 독립 레코드에는 요청 ID나 구성원 ID처럼 이미 영속화된 식별자를 사용합니다.
복수 연결 점유
외부 트랜잭션이 연결 하나를 반환하지 않은 채 내부가 풀에서 또 하나를 빌립니다. 동시 요청이 T개이고 모두 같은 순간 REQUIRES_NEW에 들어가면 외부 T개와 내부 T개가 필요할 수 있습니다. 풀 크기가 T뿐이면 모든 스레드가 두 번째 연결을 기다리는 교착성 고갈이 생깁니다.
단순 공식은 시작점일 뿐입니다. 요청당 중첩 깊이, 다른 엔드포인트의 사용량, 스케줄러, 상태 확인, DB 최대 연결을 함께 모델링합니다. 무한 대기 대신 짧은 획득 타임아웃으로 과부하를 실패 신호로 드러냅니다.
package board.tx;
public record PoolBudget(
int concurrentOuterTransactions,
int maximumRequiresNewDepth,
int reservedConnections
) {
public PoolBudget {
if (concurrentOuterTransactions < 1
|| maximumRequiresNewDepth < 0
|| reservedConnections < 0) {
throw new IllegalArgumentException("invalid pool inputs");
}
}
public int pessimisticMinimum() {
return concurrentOuterTransactions
* (1 + maximumRequiresNewDepth)
+ reservedConnections;
}
public static void main(String[] args) {
var budget = new PoolBudget(24, 1, 4);
System.out.printf("minimum=%d dbLimitCheck=%s%n",
budget.pessimisticMinimum(),
budget.pessimisticMinimum() <= 60 ? "PASS" : "FAIL");
}
}예상 출력은 minimum=52 dbLimitCheck=PASS입니다.
실제 작업 부하에서 모든 외부가 동시에 내부에 도달하지 않을 수 있지만, 풀을 작게 유지하려면 REQUIRES_NEW 호출을 요청 트랜잭션 밖으로 옮기거나 비동기 영속적 대기열을 사용합니다.
독립 트랜잭션 격리
내부 연결은 외부가 아직 커밋하지 않은 행을 일반적으로 볼 수 없습니다. 외부가 어떤 키를 갱신하며 잠금을 잡은 상태에서 내부가 같은 행을 읽거나 수정하면 기다리다 타임아웃 또는 교착 상태의 희생 트랜잭션이 될 수 있습니다. 서로 다른 물리 트랜잭션이므로 Java 호출이 순차라는 사실이 DB 잠금 순서를 안전하게 만들지 않습니다.
REQUIRES_NEW 작업은 외부가 변경 중인 애그리거트를 다시 조회하지 않게 설계합니다.
감사 페이로드를 불변 값으로 넘기고 독립 테이블에 추가합니다.
내부가 반드시 같은 행을 갱신해야 한다면 전파 선택 자체를 재검토합니다.
격리도 각각 따로 적용됩니다. 외부의 반복 가능 읽기 스냅샷과 내부의 커밋된 읽기 결과가 다를 수 있습니다. 테스트는 단일 스레드 행 개수뿐 아니라 두 연결의 블로킹과 시간 초과를 실제 운영 환경 데이터베이스에서 확인해야 합니다. H2 결과만으로 PostgreSQL이나 MySQL 잠금 의미를 단정하지 않습니다.
내부·외부 실패 조합
전파 테스트 행렬에는 다음 결과가 필요합니다.
| 외부 | 내부 | 필수 결과 | REQUIRES_NEW 결과 |
|---|---|---|---|
| 커밋 | 커밋 | 둘 다 커밋 | 둘 다 커밋 |
| 롤백 | 커밋 | 둘 다 롤백 | 내부만 커밋 |
| 커밋 | 롤백 | 전체 롤백 또는 UnexpectedRollback | 외부가 예외 처리하면 외부 커밋 가능 |
| 롤백 | 롤백 | 둘 다 롤백 | 둘 다 각자 롤백 |
REQUIRES_NEW 내부 예외를 외부가 예외 처리하지 않으면 예외 전파 때문에 외부도 롤백합니다. 독립 트랜잭션이라는 말은 예외 전파까지 차단한다는 뜻이 아닙니다. 외부가 실패를 허용할 경우에만 예외 처리하고 대체 경로를 명시합니다.
독립 커밋 관찰
메트릭에는 작업별 REQUIRES_NEW 개수, 획득 시간, 활성·대기 연결, 내부 지속 시간, 실패 타입을 둡니다.
트랜잭션 ID를 고카디널리티 태그로 넣지 않고 트레이스 이벤트에만 기록합니다.
풀 대기가 증가하면 DB가 느린지, 외부 잠금과 내부 쿼리가 충돌하는지, 단순 크기 부족인지 분리합니다.
보안 감사처럼 반드시 남겨야 하는 데이터가 같은 DB 장애로 내부까지 실패할 수 있음을 인정합니다. 별도 트랜잭션은 외부 롤백과의 격리이지 데이터베이스 장애와의 격리가 아닙니다. 규제 수준의 감사라면 별도 추가 전용 서비스, 영속적 로그, 접근 제어와 보존 정책이 필요합니다.
연습 문제
동시 요청 40개, 요청당 외부 트랜잭션 하나, 최대 REQUIRES_NEW 깊이 1, 스케줄러가 동시에 6개 연결을 쓰고 DB 계정 제한이 70인 환경을 분석하세요. 안전한 풀 크기와 획득 타임아웃을 제안하고, 제한 안에서 불가능하면 구조 변경을 설명하세요.
해설 보기
비관적 수요는 40 × 2 + 6 = 86이라 계정 제한 70을 넘습니다.
풀 숫자만 조정해 모든 외부와 내부 동시 진행을 보장할 수 없습니다.
내부 감사를 외부 종료 후 영속적 대기열에서 처리하거나 요청 동시성을 제한해야 합니다.
package board.tx;
public final class CapacityDecision {
public static String decide(
int requests,
int nestedDepth,
int background,
int databaseLimit
) {
int demand = requests * (1 + nestedDepth) + background;
return demand <= databaseLimit
? "pool may fit: " + demand
: "redesign required: demand=" + demand
+ " limit=" + databaseLimit;
}
public static void main(String[] args) {
System.out.println(decide(40, 1, 6, 70));
}
}타임아웃은 수요를 없애지 않지만 무한 정지를 방지합니다. HTTP 기한보다 짧게 설정하고 실패를 503과 재시도-이후 정책으로 연결합니다.