목요일

Spring Boot UnexpectedRollbackException 완전 해결 가이드

🔍 검색 키워드: Spring Boot UnexpectedRollbackException, 트랜잭션 롤백 에러, Spring @Transactional 에러, rollback-only 에러, Spring 트랜잭션 전파

이 에러, 왜 뜨는 건가

운영 중에 갑자기 이런 에러를 마주하는 경우가 있다.

org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only

겉으로 보면 "조용히 롤백됐다"는 건데, 왜 롤백됐는지 이유가 안 보인다. 내 코드엔 예외처리도 했고, try-catch도 했는데 왜?

원인: Spring 트랜잭션 전파(Propagation) 구조 이해

핵심 개념

Spring의 기본 트랜잭션 전파 방식은 REQUIRED다. 즉, 이미 트랜잭션이 있으면 그 트랜잭션에 참여(join)한다.

문제는 여기서 발생한다.

[외부 트랜잭션 시작]
  └── 내부 서비스 호출 (REQUIRED → 외부 트랜잭션에 참여)
        └── 내부에서 예외 발생 → 트랜잭션에 rollback-only 마킹
  내부 예외를 try-catch로 잡음
  외부 트랜잭션 커밋 시도
      → BOOM: UnexpectedRollbackException

내부 메서드에서 예외가 발생해서 rollback-only로 마킹됐는데, 외부에서 예외를 잡아버리면 Spring은 "아 괜찮은 거구나"하고 커밋을 시도한다. 하지만 이미 롤백 마킹이 됐기 때문에 UnexpectedRollbackException이 터진다.

레벨별 해결 방법

Level 1 — 기초 (원인 파악부터)

상황 재현 코드:

@Service
@RequiredArgsConstructor
public class OrderService {
    private final PaymentService paymentService;

    @Transactional
    public void placeOrder(OrderRequest request) {
        // 주문 저장 로직...

        try {
            paymentService.processPayment(request.getPaymentInfo()); // 내부 트랜잭션 참여
        } catch (Exception e) {
            log.error("결제 실패: {}", e.getMessage()); // 예외 잡음
            // → 이 시점에 트랜잭션은 이미 rollback-only
        }

        // 커밋 시도 → UnexpectedRollbackException 발생!
    }
}

@Service
public class PaymentService {
    @Transactional // REQUIRED (기본값) → 외부 트랜잭션에 참여
    public void processPayment(PaymentInfo info) {
        // 내부 예외 발생
        throw new PaymentException("카드 한도 초과");
    }
}

트랜잭션 상태 디버깅:

@Transactional
public void placeOrder(OrderRequest request) {
    log.info("트랜잭션 활성: {}", TransactionSynchronizationManager.isActualTransactionActive());
    log.info("롤백 마킹: {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly());
}

Level 2 — 실무 해결책 (전파 방식 변경)

방법 1: REQUIRES_NEW로 별도 트랜잭션 분리

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    // ↑ 기존 트랜잭션과 완전히 분리된 새 트랜잭션 시작
    public void processPayment(PaymentInfo info) {
        // 이 트랜잭션이 롤백돼도 외부 트랜잭션에 영향 없음
        paymentRepository.save(/* ... */);
    }
}
⚠️ 주의: REQUIRES_NEW는 별도 DB 커넥션을 사용한다. 커넥션 풀 고갈 위험이 있으니 남발하면 안 된다.

방법 2: noRollbackFor 설정

@Transactional(noRollbackFor = PaymentException.class)
public void processPayment(PaymentInfo info) {
    // PaymentException이 발생해도 롤백하지 않음
}

방법 3: 예외를 잡지 말고 던지기

@Transactional
public void placeOrder(OrderRequest request) {
    try {
        paymentService.processPayment(request.getPaymentInfo());
    } catch (PaymentException e) {
        // 잡지 말고 그냥 던진다
        throw e; // 혹은 새 예외로 래핑
        // → 외부 호출자가 트랜잭션 롤백 처리
    }
}

Level 3 — 고급 (아키텍처 관점에서 설계)

이벤트 기반 분리 (트랜잭션 완료 후 처리)

@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void placeOrder(OrderRequest request) {
        Order order = orderRepository.save(Order.of(request));

        // 트랜잭션 커밋 후 이벤트 발행
        eventPublisher.publishEvent(new OrderPlacedEvent(order.getId()));
    }
}

@Component
public class PaymentEventHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    // ↑ 외부 트랜잭션 커밋 후에 실행 → 트랜잭션 오염 없음
    public void handleOrderPlaced(OrderPlacedEvent event) {
        paymentService.processPayment(event.getOrderId());
    }
}

Facade 패턴으로 트랜잭션 경계 명확히

@Service
public class OrderFacade {
    // @Transactional 없음 → 트랜잭션 경계 없음

    public void placeOrder(OrderRequest request) {
        orderService.saveOrder(request);        // 각각 독립 트랜잭션
        paymentService.processPayment(request); // 각각 독립 트랜잭션
    }
}

@Service
public class OrderService {
    @Transactional // 이 메서드 범위만 트랜잭션
    public void saveOrder(OrderRequest request) { /* ... */ }
}

상황별 해결 체크리스트

상황권장 방법주의사항
내부 예외가 외부와 독립적으로 처리돼야 할 때REQUIRES_NEW커넥션 풀 사용량 증가
특정 예외는 롤백 안 해도 될 때noRollbackFor데이터 정합성 검토 필요
결제 같은 외부 I/O가 있을 때@TransactionalEventListenerAFTER_COMMIT 타이밍 주의
서비스 레이어 설계를 바꿀 수 있을 때Facade 패턴트랜잭션 경계 재설계 필요
빠르게 임시 수정이 필요할 때예외 재던지기호출부에서 처리 필요

자주 하는 실수

실수 1: try-catch에서 예외를 먹어버리기

// ❌ 잘못된 코드
try {
    innerService.doSomething();
} catch (Exception e) {
    log.error("에러 발생", e);
    // 예외를 삼켜버림 → UnexpectedRollbackException 확정
}

// ✅ 올바른 코드
try {
    innerService.doSomething();
} catch (Exception e) {
    log.error("에러 발생", e);
    throw new BusinessException("처리 실패", e); // 반드시 다시 던지기
}

실수 2: Self-invocation (같은 클래스 내 메서드 호출)

@Service
public class MyService {
    @Transactional
    public void outer() {
        inner(); // 프록시를 거치지 않음 → @Transactional 무시됨
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { /* ... */ }
    // → REQUIRES_NEW가 적용되지 않아서 여전히 같은 트랜잭션 사용
}

Self-invocation í•´ê²°:

@Service
@RequiredArgsConstructor
public class MyService {
    private final ApplicationContext context;

    public void outer() {
        MyService proxy = context.getBean(MyService.class); // 프록시 직접 가져오기
        proxy.inner();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { /* ... */ }
}

정리

UnexpectedRollbackException은 트랜잭션 전파를 모르면 반드시 한 번은 만나는 에러다. 핵심은 하나다.

💡 내부 트랜잭션이 롤백 마킹되면, 같은 트랜잭션에 참여한 외부 트랜잭션도 롤백될 수밖에 없다.

해결은 ‫글 두 방향이다.

  • 트랜잭션을 분리한다 (REQUIRES_NEW, Facade 패턴, 이벤트 기반)
  • 예외를 제대로 다룬다 (먹지 말고 던지기, noRollbackFor)

실무에서는 REQUIRES_NEW를 무분별하게 쓰기보다 @TransactionalEventListener나 Facade 패턴으로 설계 단계에서 트랜잭션 경계를 명확히 하는 게 장기적으로 낫다.

화요일

GitHub Actions OOM 에러 해결: JavaScript heap out of memory 완전 정복

TypeScript "Type is not assignable" 에러 완전 정복

🔍 검색 키워드: TypeScript 타입 에러, Type is not assignable to type, TS2322, TS2345, TypeScript 타입 오류 해결, typescript type error 해결

TypeScript 쓰다 보면 한 번쯤은 이 에러를 만난다.

Type 'string' is not assignable to type 'number'.  ts(2322)

처음엔 당황스럽지만, 패턴을 이해하면 금방 잡힌다. 이 글은 자주 나오는 케이스별로 원인과 해결을 정리했다.


에러가 생기는 이유

TypeScript는 정적 타입 언어다. 컴파일 타임에 타입을 검사하기 때문에, 선언된 타입과 실제 할당값이 다르면 에러를 낸다. Java의 컴파일 에러와 같은 맥락이다.


레벨 1 (초보자) — 기본 타입 불일치

증상

let count: number = "5";  // ❌ TS2322

원인

number 타입으로 선언된 변수에 string을 대입했다.

해결

// 방법 1: 타입에 맞는 값으로 수정
let count: number = 5;

// 방법 2: 타입 변환
let count: number = parseInt("5");

// 방법 3: 타입 선언 수정
let count: string = "5";

레벨 2 (중급자) — 함수 파라미터 타입 불일치

증상

function greet(name: string): string {
  return `Hello, ${name}`;
}
greet(123);  // ❌ TS2345

해결

// 방법 1: 올바른 타입으로 전달
greet("홍길동");

// 방법 2: Union 타입 사용
function greet(name: string | number): string {
  return `Hello, ${String(name)}`;
}

레벨 3 (중급자) — Object 타입 구조 불일치

증상

interface User {
  id: number;
  name: string;
  email: string;
}
const user: User = {
  id: 1,
  name: "홍길동"
  // email 누락 → ❌ TS2322
};

해결

// 방법 1: 빠진 프로퍼티 추가
const user: User = { id: 1, name: "홍길동", email: "hong@example.com" };

// 방법 2: 선택적 프로퍼티로 변경
interface User { id: number; name: string; email?: string; }

// 방법 3: Partial 사용
const partialUser: Partial<User> = { id: 1, name: "홍길동" };

레벨 4 (중급자) — 배열/제네릭 타입 불일치

증상

const ids: number[] = [1, 2, "3", 4];  // ❌ TS2322

function first<T>(arr: T[]): T { return arr[0]; }
const result: number = first(["a", "b"]);  // ❌ TS2322

해결

const ids: (number | string)[] = [1, 2, "3", 4];
const result: string = first(["a", "b"]);

레벨 5 (실무자) — null/undefined 처리

증상

function getUser(id: number): User | null {
  return id === 1 ? { id: 1, name: "홍길동", email: "hong@example.com" } : null;
}
const user: User = getUser(999);  // ❌ TS2322: 'User | null'

해결

// 방법 1: 타입에 null 포함
const user: User | null = getUser(999);

// 방법 2: null 체크 후 사용 (Type narrowing)
const maybeUser = getUser(999);
if (maybeUser !== null) {
  const user: User = maybeUser;
}

// 방법 3: Non-null assertion
const user: User = getUser(1)!;

// 방법 4: Nullish coalescing으로 기본값
const user: User = getUser(999) ?? { id: 0, name: "Guest", email: "" };

레벨 6 (고급자) — 타입 추론 문제

증상

const config = { mode: "development" };  // TypeScript가 string으로 추론
function setup(mode: "development" | "production") {}
setup(config.mode);  // ❌ TS2345

해결

// 방법 1: as const로 리터럴 타입 고정
const config = { mode: "development" } as const;

// 방법 2: 명시적 타입 선언
const config: { mode: "development" | "production" } = { mode: "development" };

// 방법 3: 타입 단언
setup(config.mode as "development" | "production");

상황별 체크리스트

상황확인 포인트
변수 할당 에러선언 타입과 값 타입 일치 여부
함수 인자 에러함수 시그니처와 전달값 타입 비교
객체 에러인터페이스 필수 프로퍼티 누락 여부
null 에러strictNullChecks 활성화 여부 확인
리터럴 타입 에러as const 또는 명시적 타입 선언 필요
any 남용any 대신 unknown + 타입 가드 사용

자주 하는 실수 — any로 도배

// ❌ TypeScript 쓰는 의미 없음
const data: any = fetchData();

// ✅ unknown 쓰고 타입 가드로 좁혀라
const data: unknown = fetchData();
if (typeof data === "string") {
  console.log(data.toUpperCase());
}

마무리

TypeScript 타입 에러는 대부분 세 가지다.

  1. 타입을 잘못 선언했거나
  2. 값이 여러 타입이 될 수 있는데 하나만 선언했거나
  3. null/undefined 처리를 안 했거나

에러 메시지를 읽으면 어느 쪽인지 대부분 나온다. ts(숫자) 에러코드로 TypeScript 공식 문서에서 정확한 설명도 찾을 수 있다.