한번에 수행되어야 할 DB 명령어의 논리적 작업 단위(LUW, Logical Units of Work)를 말한다.
하나의 트랜잭션으로 이루어진 작업들은 반드시 한꺼번에 완료가 되어야 하며, 그렇지 않은 경우에는 한번에 취소 되어야 한다. 데이터의 무결성과 신뢰성을 유지를 위해 활용된다.
TCL은 트랜잭션을 관리 하기 위한 언어로 아래 4개의 명령어를 갖는다.
START TRANSACTION(트랜잭션 시작, 생략 가능), COMMIT(트랜잭션 종료 및 저장), ROLLBACK(트랜잭션 취소), SAVEPOINT(임시 저장)
데이터베이스는 실시간으로 다수의 사용자가 접근이 가능함으로 병행성의 문제가 발생 가능하다.
병행성 문제란? 특정 데이터를 여러 사용자가 동시에 조회/수정할 경우, 데이터가 의도와 다르게 처리될 수 있는 상황
예를 들어, 은행 계좌에서 여러 프로세스가 동시에 출금을 시도하는 상황에서 병행성 문제가 해결되지 않은 상태라면, 잔고가 충분하지 않더라도 두 개 이상의 출금 요청이 동시에 성공할 수 있으며, 그 결과 잔액이 2번 출금 되고 계좌 잔고가 음수로 떨어지거나 데이터 무결성이 깨질 수 있다.
트랜잭션은 보통 2가지 방법을 위해 활용되는데, 첫번째 활용은 업무 단위를 만들어 업무를 실패하는 경우 이를 모두 Rollback(되돌리기)하는 시점으로 활용 될 수 있으며, 두번째 활용은 처리 시간 단위로 Lock을 걸고, 다른 요청이 해당 업무(데이터)의 접근을 불가능 하도록 보호 할 수 있다. (트랜잭션의 원자성 유지)
ACID(원자성, 일관성, 고립성, 지속성)는 데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질을 가리키는 약어이다. 짐 그레이는 1970년대 말에 신뢰할 수 있는 트랜잭션 시스템의 이러한 특성들을 정의 하였으며 이는 점차 연구되어 DB의 핵심 기능으로 유지되고 있다.
모든 작업을 정상적으로 처리하겠다고 확정하는 명령으로 트랜잭션의 처리 과정을 물리 데이터베이스에 반영하여, 변경된 내용을 모두 영구 저장한다.
TRANSACTION은 INSERT, UPDATE, DELETE의 복합 명령으로 구성될 수 있다.
작업 중 문제가 발생했을 때, 트랜잭션의 처리 과정에서 발생한 변경 사항을 취소하고, 트랜잭션 과정을 종료, 트랜잭션 인한 하나의 묶음 처리가 시작되기 이전의 상태로 되돌리는 명령, 마지막 COMMIT 까지로 되돌려 진다.
트랜잭션은 아래와 같이 5개의 상태를 가지며, COMMIT 명령과, ROLLBACK 명령을 통해 완료 또는 철회 상태로 변경된다.

@Transactional는 Spring에서 선언적 트랜잭션 관리를 지원하는 대표 어노테이션이다.
AOP 기반으로 메서드 실행 전후에 트랜잭션을 시작하고, 정상 종료 시 commit, 예외 발생 시 rollback 처리
어노테이션 속성 값을 통해 읽기 전용이나 트랜잭션 전파 수준을 관리 할 수 있다.
@Transactional(
propagation = Propagation.REQUIRES_NEW, // 항상 새로운 트랜잭션
isolation = Isolation.READ_COMMITTED , // 커밋된 데이터만 읽음
timeout = 10, // 10초 초과 시 롤백
readOnly = true, // 읽기 전용
rollbackFor = {IOException.class, Exception.class}, // 지정 예외 롤백
noRollbackFor = {BusinessWarningException.class} // 지정 예외는 커밋
)
public void createUser() {}
| 속성 | 기본값 | 설명 |
|---|---|---|
| propagation | Propagation.REQUIRED | 트랜잭션 전파 방식 설정 옵션: REQUIRED, REQUIRES_NEW, MANDATORY, SUPPORTS, NOT_SUPPORTED, NEVER, NESTED |
| isolation | Isolation.DEFAULT | 트랜잭션 격리 수준 (DB 기본값 따름, 보통 READ_COMMITTED) 옵션: DEFAULT, READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE |
| timeout | -1 (제한 없음) | 트랜잭션 제한 시간(초). 설정한 시간 초과 시 롤백 발생 |
| readOnly | false | 읽기 전용 여부. 조회 전용 트랜잭션일 때 더티 체킹·플러시 최소화로 최적화 가능 |
| rollbackFor | 없음 (기본: RuntimeException, Error) | 지정한 예외 발생 시 롤백 수행. Checked 예외도 롤백하려면 직접 지정 필요 |
| rollbackForClassName | 없음 | 클래스 이름(String)으로 롤백 예외 지정 |
| noRollbackFor | 없음 | 지정한 예외 발생 시 롤백하지 않음 |
| noRollbackForClassName | 없음 | 클래스 이름(String)으로 롤백 제외 예외 지정 |
| transactionManager | 기본 트랜잭션 매니저 | 여러 개의 트랜잭션 매니저가 등록된 경우 특정 매니저를 지정할 때 사용 |
| 구분 | 설정 예시 | 설명 |
|---|---|---|
| 롤백 옵션 (rollbackFor) | @Transactional(rollbackFor = Exception.class) | 기본적으로 Unchecked Exception(RuntimeException, Error)만 롤백.일반 Checked Exception은 롤백하지 않음.DB 예외를 포함한 대부분의 예외는 Checked이므로 rollbackFor 옵션 지정 필요. |
| 격리 수준 (isolation) | @Transactional(isolation = Isolation.DEFAULT) → DB 기본값@Transactional(isolation = Isolation.READ_COMMITTED) → Level1, Dirty Read 방지@Transactional(isolation = Isolation.REPEATABLE_READ) → Level2, Non-Repeatable Read 방지@Transactional(isolation = Isolation.SERIALIZABLE) → Level3, Phantom Read 방지 (가장 엄격, 성능 저하) | DB 트랜잭션 격리 수준 설정. 격리 수준이 높아질수록 동시성 이슈 감소, 대신 성능 저하. 실무에서는 보통 READ_COMMITTED ~ REPEATABLE_READ 수준 권장. |
| 전파 수준 (propagation) | @Transactional(propagation = Propagation.REQUIRED) → 기본값, 부모 TX 재사용, 없으면 새로 생성@Transactional(propagation = Propagation.REQUIRES_NEW) → 항상 새 트랜잭션 생성@Transactional(propagation = Propagation.SUPPORTS) → 부모 TX 있으면 참여, 없으면 비트랜잭션@Transactional(propagation = Propagation.NESTED) → 항상 트랜잭션 실행, 내부에 Savepoint 설정 | 트랜잭션을 어떻게 전파할지 정의. 일반적으로 REQUIRED 사용. |
| 읽기 전용 (readOnly) | @Transactional(readOnly = true) | 읽기 전용 트랜잭션. Hibernate는 flush 최소화, DB는 select 최적화 가능. 격리 수준이 설정되어 있어도 락을 잡지 않음 → SELECT 성능 향상. |
| 타임아웃 (timeout) | @Transactional(timeout = 30) | 트랜잭션 최대 수행 시간을 초 단위로 설정. 설정 시간 초과 시 롤백 발생. 지나치게 오래 걸리는 서비스 요청을 방지 가능. |
Spring에서 제공하는 프로그래밍 방식 트랜잭션 관리 유틸리티 클래스
내부적으로 트랜잭션 매니저를 사용하며, 템플릿 메서드 패턴으로 트랜잭션 경계(시작/커밋/롤백)를 보장
사용자가 직접 세밀하게 트랜잭션을 관리 할 때 사용되는 방법이나 자주 활용되진 않는다.
@Service
public class UserService {
private final PlatformTransactionManager transactionManager;
public void createUser(User user) {
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
userRepository.save(user);
emailService.sendWelcomeMail(user);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
}
| 구분 | 선언적 트랜잭션 (Declarative) | 프로그래밍 방식 트랜잭션 (Programmatic) |
|---|---|---|
| 관리 방식 | @Transactional 애노테이션 또는 XML 설정 기반스프링이 자동 관리 | TransactionTemplate, PlatformTransactionManager 등을 코드에서 직접 호출 |
| 구현 방식 | AOP 프록시가 메서드 실행 전/후에 트랜잭션 시작 → 커밋/롤백 자동 처리 | 개발자가 begin → commit/rollback 흐름을 직접 작성 |
| 코드 복잡도 | 비즈니스 로직에만 집중 가능 → 코드 간결 | 트랜잭션 처리 코드가 섞여 복잡하고 중복 증가 |
| 제어 수준 | 단순/일괄 트랜잭션 제어에 적합 (주로 Service 계층에서 사용) | 조건부 롤백, 부분 커밋 등 세밀한 제어 가능 |
| 주요 장점 | 표준화, 유지보수 용이 애노테이션 선언만으로 적용 | 복잡한 트랜잭션 시나리오를 유연하게 처리 가능 |
| 주요 단점 | 특수 조건·부분 제어에는 한계 | 코드 중복·가독성 저하 |
| 대표 기술 | @Transactional(readOnly, rollbackFor, propagation, isolation) | TransactionTemplate.execute(), PlatformTransactionManager |
| 권장 사용 | 일반적인 CRUD, 서비스 계층 비즈니스 로직 | 예외적 상황 제어, 조건부 롤백, 부분 커밋 필요 시 |
프록시 기반 트랜잭션 처리는 스프링에서 AOP를 활용하여 트랜잭션 경계를 자동으로 관리하는 방식이다.
대상 객체의 메서드 호출 시 프록시가 개입해 트랜잭션 시작, 커밋, 롤백을 제어한다.
개발자는 비즈니스 로직에만 집중할 수 있고 트랜잭션 관리 로직은 프록시에 의해 투명하게 처리된다.
AOP는 핵심 로직과 부가 기능(로깅, 보안, 트랜잭션 등)을 분리하여 모듈화하는 기법이다. 프록시 객체가 메서드 호출 전·후 또는 예외 발생 시에 지정된 공통 기능을 삽입한다. 이를 통해 중복 코드를 제거하고 관심사의 분리를 달성한다.
트랜잭션 프록시는 실제 객체 앞단에서 동작하며 메서드 호출 시 트랜잭션 경계를 설정한다.
호출이 정상 완료되면 커밋을 수행하고, 예외 발생 시 롤백을 처리한다.이를 통해 개발자는 트랜잭션 관리
코드를 작성하지 않고도 일관성 있는 데이터 처리를 보장받는다.
트랜잭션 매니저(Transaction Manager)는 데이터베이스와 애플리케이션 사이에서 트랜잭션을 제어하는 핵심 컴포넌트이다. 트랜잭션 시작, 커밋, 롤백 같은 동작을 표준화된 방식으로 제공한다.
DB 종류나 기술(JDBC, JPA 등)에 따라 구현체가 달라지지만 동일한 인터페이스로 관리된다.
Spring은 데이터 접근 기술에 맞춰 다양한 트랜잭션 매니저 구현체를 제공한다.
대표적으로 DataSourceTransactionManager(JDBC), JpaTransactionManager(JPA/Hibernate), HibernateTransactionManager(Hibernate), JtaTransactionManager(분산 트랜잭션) 등이 있다.
JpaTransactionManager는 JPA 표준을 따르는 트랜잭션 매니저로, Spring에서 JPA 기반 데이터 접근 시 사용된다.
EntityManager를 통해 영속성 컨텍스트와 DB 트랜잭션을 일관되게 관리한다.
트랜잭션 시작, 커밋, 롤백을 자동으로 처리하여 개발자가 비즈니스 로직에만 집중할 수 있게 한다.
트랜잭션 전파는 메서드 호출 시 기존 트랜잭션을 이어받을지, 새로운 트랜잭션을 생성할지 결정하는 동작 방식이다. 스프링은 REQUIRED, REQUIRES_NEW, NESTED 등 다양한 전파 옵션을 제공한다. 이를 통해 복잡한 계층 구조에서도 트랜잭션 경계를 유연하게 제어할 수 있다.
@Transactional(propagation = Propagation.REQUIRES_NEW )

Lock은 트랜잭션(또는 세션)이 사용하는 자원에 대해 상호배제(Mutual Exclusion)를 보장하는 기능이다.
이는 하나의 트랜잭션이 특정 데이터 항목에 대해 잠금(Lock)을 설정하면, 해당 잠금이 해제되기 전까지 다른 트랜잭션은 해당 자원에 접근할 수 없도록 막는다. MySQL에서는 주로 세션 단위로 Lock을 설정하며, 세션이 비정상적으로 종료될 경우 해당 잠금이 해제되지 않으면 다른 트랜잭션은 해당 자원에 접근하지 못하는 문제가 발생할 수 있다.
Deadlock은 두 개 이상의 트랜잭션이 서로가 보유한 자원을 기다리며 무한 대기 상태에 빠지는 현상이다.
각 트랜잭션이 잠금(Lock)을 해제하지 않고 상대의 자원을 요청할 때 발생한다.
DBMS는 이를 감지하면 일반적으로 한 쪽 트랜잭션을 강제 종료(Rollback)시켜 교착 상태를 해소한다.
공유락(Shared Lock) : 데이터를 읽을 수는 있지만, 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 하는 읽기 전용 잠금이다. (SELECT ... LOCK IN SHARE MODE)
배타락(Exclusive Lock) : 데이터를 읽고 수정할 수 있으며, 다른 트랜잭션의 읽기와 쓰기 모두 차단하는 쓰기 전용 잠금이다. INSERT문과 UPDATE문은 자동으로 배타락을 걸어준다. (SELECT ... FOR UPDATE)
Isolation Level은 트랜잭션 간 데이터 접근을 어느 정도까지 격리할지를 정의하여, 동시성 문제를 방지하는 트랜잭션 속성이다. DB의 기본 격리 수준은 REPEATABLE READ이며, 이를 포함해 총 4단계(READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE)를 지원한다.
Isolation Level의 강도에 따른 정합성과 성능은 상충 관계임으로 적절한 구문으로 Level을 설정해야 한다.
→ LOCK과 Isolation은 상호 보완적으로 함께 사용되어야 데이터 무결성과 동시성을 유지할 수 있다.

| 격리 수준 | 설명 | 발생 가능한 문제 | 특징/주의사항 |
|---|---|---|---|
| READ UNCOMMITTED (읽기 미보장) | 아직 커밋되지 않은 데이터도 다른 트랜잭션에서 읽을 수 있음 | - Dirty Read - Non-Repeatable Read - Phantom Read | 가장 낮은 격리 수준. 성능은 가장 좋지만 무결성 보장이 거의 없음. |
| READ COMMITTED (커밋된 읽기) | 커밋된 데이터만 읽음 (미커밋 데이터는 읽지 않음) | - Non-Repeatable Read - Phantom Read | 대부분 DBMS 기본값 (Oracle, PostgreSQL 등). 성능과 일관성의 절충. |
| REPEATABLE READ (반복 가능 읽기) | 동일 트랜잭션 내에서 같은 쿼리 결과는 항상 동일 | - Phantom Read (새로운 행 삽입은 막지 못함) | MySQL InnoDB 기본값. Dirty Read, Non-Repeatable Read 방지. |
| SERIALIZABLE (직렬화) | 트랜잭션을 직렬 실행한 것과 같은 효과 → 완전한 일관성 보장 | 없음 (모든 문제 방지) | 가장 높은 격리 수준. 무결성 보장은 확실하지만 동시성 성능 저하 심함. |
다른 트랜잭션에서 아직 커밋하지 않은 데이터를 읽는 현상
-- Session A
BEGIN;
UPDATE account SET balance = 200 WHERE id = 1;
-- 아직 COMMIT 안 함
-- Session B
BEGIN;
SELECT balance FROM account WHERE id = 1;
-- 결과: 200원 (커밋되지 않은 값 읽음)
COMMIT;
-- Session A
ROLLBACK;
-- Session B가 읽은 값은 존재하지 않음 (데이터 무결성 깨짐)
👉 발생 가능: READ UNCOMMITTED
👉 방지: READ COMMITTED 이상
같은 트랜잭션 내에서 같은 조건으로 두 번 조회했는데 값이 달라지는 현상
-- Session A
BEGIN;
SELECT balance FROM account WHERE id = 1;
-- 결과: 100원
-- Session B
BEGIN;
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT;
-- 다시 Session A
SELECT balance FROM account WHERE id = 1;
-- 결과: 200원 (처음과 다름)
COMMIT;
👉 발생 가능: READ COMMITTED
👉 방지: REPEATABLE READ 이상
같은 트랜잭션 내에서 같은 조건으로 조회했는데 행(row) 수 자체가 달라지는 현상
-- Session A
BEGIN;
SELECT * FROM orders WHERE price > 100;
-- 결과: 2건
-- Session B
BEGIN;
INSERT INTO orders (id, price) VALUES (3, 200);
COMMIT;
-- 다시 Session A
SELECT * FROM orders WHERE price > 100;
-- 결과: 3건 (새로운 행이 "팬텀"처럼 나타남)
COMMIT;
👉 발생 가능: REPEATABLE READ
👉 방지: SERIALIZABLE
| 현상 | 설명 | 발생 격리 수준 | 방지 격리 수준 |
|---|---|---|---|
| Dirty Read | 커밋 전 데이터를 다른 트랜잭션이 읽음 | READ UNCOMMITTED | READ COMMITTED 이상 |
| Non-Repeatable Read | 같은 조건의 조회 결과가 달라짐 | READ COMMITTED | REPEATABLE READ 이상 |
| Phantom Read | 같은 조건의 조회에서 행 수가 달라짐 | REPEATABLE READ | SERIALIZABLE |
스프링의 @Transactional은 기본적으로 런타임 예외(언체크 예외, RuntimeException 및 그 하위 클래스)와 에러(Error) 발생 시 롤백한다. 반면 체크 예외(Exception) 발생 시에는 기본적으로 롤백하지 않고 커밋한다.
rollbackFor: 지정한 예외가 발생하면 롤백을 강제로 수행한다. (체크 예외도 롤백 가능)
@Transactional(rollbackFor = Exception.class)
public void saveData() { ... }
noRollbackFor: 지정한 예외는 발생하더라도 롤백하지 않고 커밋한다.
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void updateData() { ... }
@Transactional이 붙은 스프링 빈의 public 메서드를 외부에서 호출할 때 AOP 프록시가 트랜잭션을 시작한다.@Service
public class PaymentService {
// 트랜잭션 시작: 컨트롤러 등 외부에서 호출될 때 프록시가 경계를 연다
@Transactional
public void pay(PaymentCommand cmd) {
// 비즈니스 로직
// 내부에서 helper()를 호출해도 프록시를 거치지 않아 새 전파 옵션이 적용되지 않는다
helper(cmd);
}
// self-invocation: 프록시 미적용
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void helper(PaymentCommand cmd) { /* ... */ }
}
@Service
public class OrderService {
// 조회 전용 경계
@Transactional(readOnly = true)
public OrderView getOrder(Long id) {
return /* 조회 로직 */;
}
// 쓰기 경계
@Transactional(timeout = 5, isolation = Isolation.READ_COMMITTED )
public void placeOrder(OrderCommand cmd) {
inventoryService.reserve(cmd.getProductId(), cmd.getQty()); // REQUIRED
outboxService.saveOutbox(cmd); // REQUIRES_NEW로 분리 가능 시나리오도 존재
// 여기서 호출하면 DB 락 유지 시간이 늘어난다 → 분리 권장
}
}
@Service
public class RefundService {
// 기본 규칙: RuntimeException/Errors → 롤백, Checked Exception → 커밋
// 체크 예외에도 롤백이 필요하면 rollbackFor로 명시한다
@Transactional(rollbackFor = {IOException.class, Exception.class})
public void refund(RefundCommand cmd) throws IOException {
// 로직...
if (/* 외부 IO 실패 */) {
throw new IOException("외부 환불 API 실패"); // 체크 예외지만 롤백된다
}
}
// 특정 예외는 커밋 강제
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void adjust(AdjustCommand cmd) {
// 유효성 문제는 롤백하지 않고 보정 처리 후 커밋
if (/* 경미한 파라미터 오류 */) throw new IllegalArgumentException("보정 가능 오류");
}
}
@Transactional(readOnly = true)는 영속성 컨텍스트의 쓰기 관련 작업과 더티체킹을 최소화하고, 드라이버/DB에 읽기 전용 힌트를 전달하여 불필요한 플러시를 억제하는 최적화이다.@Service
public class PostQueryService {
// 조회 전용: 더티체킹/플러시 최소화
@Transactional(readOnly = true)
public PostView findOne(Long id) {
// 순수 조회 로직만 유지
return /* 조회 */;
}
}
@Service
@RequiredArgsConstructor
public class OrderOrchestrator {
private final OrderService orderService;
private final PaymentGateway paymentGateway;
public void place(OrderCommand cmd) {
// 트랜잭션 밖: 외부 결제 준비, 서명 생성 등 장기 연산
String token = paymentGateway.prepare(cmd);
// 트랜잭션 안: 최소 변경만 수행
orderService.place(cmd, token);
// 트랜잭션 밖: 후속 알림/웹훅 대기 등
paymentGateway.confirm(cmd);
}
}
@Service
public class OrderService {
@Transactional(timeout = 5, isolation = Isolation.READ_COMMITTED)
public void place(OrderCommand cmd, String token) {
// 재고 차감, 주문 레코드 저장 등 최소 변경
}
}
@Service
public class CatalogService {
// 상위 트랜잭션이 있더라도 참여하지 않음 → 커넥션/락 점유 최소화
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public List<ProductView> hotItems() {
// 캐시 우선 조회 또는 단순 읽기
return /* 조회 */;
}
}
// 잘못된 예: 리포지토리에 트랜잭션을 붙이면 경계가 흩어져 전파/락 추적이 어려워진다
@Repository
public class BadRepository {
// @Transactional <-- 제거 권장
public void save(...) {}
}
@Service
@RequiredArgsConstructor
public class OrderFacade {
private final OrderService orderService; // 비즈 로직
private final AuditService auditService; // 분리된 빈
@Transactional
public void place(OrderCommand cmd) {
orderService.core(cmd); // REQUIRED
auditService.writeLog(cmd); // REQUIRES_NEW 정상 적용
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeLog(OrderCommand cmd) { /* ... */ }
}
@Service
public class OutboxService {
// 반드시 남겨야 하는 이벤트 기록에만 REQUIRES_NEW 사용
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void save(OutboxEvent e) { /* insert ... */ }
}
@Service
@RequiredArgsConstructor
public class CheckoutService {
private final OrderRepository orders;
private final OutboxService outbox;
@Transactional // REQUIRED
public void checkout(Cart c) {
orders.save(...); // 본거래
outbox.save(...); // 부분 커밋 허용 의도적 사용
}
}
@Service
@RequiredArgsConstructor
public class OrderOrchestrator {
private final OrderService orderService;
private final PaymentGateway paymentGateway;
public void place(OrderCommand cmd) {
String token = paymentGateway.prepare(cmd); // 트랜잭션 밖
orderService.place(cmd, token); // 최소 변경만 트랜잭션 안
paymentGateway.confirm(cmd); // 트랜잭션 밖
}
}
@Service
public class OrderService {
@Transactional(timeout = 5, isolation = Isolation.READ_COMMITTED)
public void place(OrderCommand cmd, String token) {
// 재고 차감·주문 저장 등 DB 변경 최소화
}
}
@Service
public class RefundService {
// 체크 예외도 롤백되게 명시
@Transactional(rollbackFor = {IOException.class, Exception.class})
public void refund(RefundCommand cmd) throws IOException {
// 외부 환불 API 호출 실패 시 IOException 발생 → 롤백
callExternal();
}
// 예외를 삼키지 말고 런타임으로 래핑
@Transactional
public void adjust(AdjustCommand cmd) {
try {
callPartner();
} catch (PartnerCheckedException e) {
throw new BusinessException("조정 실패", e); // 언체크 → 롤백
}
}
}