본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
11장 : 예외와 트랜잭션 심화

Repository 예외 변환

리포지토리에서 SQLException을 숨기되 중복·조회 실패·낙관적 충돌·일시 장애를 사용 사례가 판단할 의미로 구분합니다.

런타임 예외로 바꾸는 목적은 throws 글자를 줄이는 데 있지 않습니다.

애플리케이션이 JDBC, JPA, HTTP 클라이언트 가운데 어떤 어댑터를 쓰더라도 같은 업무 의미로 판단하게 만드는 것이 핵심입니다.

게시글 등록 사용 사례에는 “같은 멱등성 키”, “이미 수정된 버전”, “저장소를 사용할 수 없음”이 중요하고 SQLState 숫자는 중요하지 않습니다.


포트의 실패 규칙

리포지토리 인터페이스에 기술 예외를 쓰면 구현 세부가 애플리케이션으로 새어 나옵니다.

반대로 RuntimeException 하나만 던진다고 쓰면 사용자에게 409를 줄지 503을 줄지 판단할 수 없습니다.

계약은 성공값과 함께 안정적인 실패 분류 체계를 정의해야 합니다.

src/main/java/board/application/PostStore.java
package board.application;

import java.time.LocalDate;
import java.util.Optional;

public interface PostStore {
    StoredPost create(NewPost command);

    Optional<StoredPost> find(long postId, long authorId);

    StoredPost rename(
            long postId,
            long authorId,
            long expectedVersion,
            String newTitle);

    record NewPost(
            long authorId,
            String title,
            String content,
            LocalDate createdOn,
            String idempotencyKey
    ) { }

    record StoredPost(
            long id,
            long authorId,
            String title,
            String content,
            LocalDate createdOn,
            long version
    ) { }

    sealed class StoreFailure extends RuntimeException
            permits DuplicateRequest, ConcurrentChange, StoreUnavailable {
        protected StoreFailure(String message, Throwable cause) {
            super(message, cause);
        }
    }

    final class DuplicateRequest extends StoreFailure {
        public DuplicateRequest(String key, Throwable cause) {
            super("duplicate idempotency key: " + key, cause);
        }
    }

    final class ConcurrentChange extends StoreFailure {
        public ConcurrentChange(long id) {
            super("stale post: " + id, null);
        }
    }

    final class StoreUnavailable extends StoreFailure {
        public StoreUnavailable(Throwable cause) {
            super("post store unavailable", cause);
        }
    }
}

find의 없음은 정상적인 카디널리티라 Optional로 표현했습니다.

반면 rename의 버전 불일치는 호출자가 최신 값을 다시 읽어야 하는 충돌이므로 예외로 드러냅니다.

팀이 봉인 계층을 원하지 않으면 독립 클래스로 둘 수 있지만, 애플리케이션 패키지가 기술 라이브러리 타입을 가져오기하지 않는 원칙은 같습니다.


어댑터 예외 변환

제약 조건 위반 전체를 중복으로 바꾸면 빈 본문이나 5,000자를 넘는 본문 위반도 멱등 요청처럼 보입니다.

어댑터는 제약 조건 이름, SQLState, Spring DataAccessException 하위 타입을 조합해 자신이 만든 스키마 의미만 번역합니다.

모르는 오류를 성공이나 409로 꾸미지 않습니다.

아래 예제는 외부 의존성 없이 번역 분기를 실행하도록 만든 작은 어댑터입니다.

실제 JDBC 구현에서는 SQLException의 상태와 공급자 코드를 변환기에 전달합니다.

src/main/java/board/adapter/FailureMappingDemo.java
package board.adapter;

import java.sql.SQLException;

public final class FailureMappingDemo {
    static final class DuplicateRequest extends RuntimeException {
        DuplicateRequest(String key, Throwable cause) {
            super("duplicate request: " + key, cause);
        }
    }

    static final class StoreUnavailable extends RuntimeException {
        StoreUnavailable(Throwable cause) {
            super("store unavailable", cause);
        }
    }

    static RuntimeException translate(
            String idempotencyKey,
            SQLException failure
    ) {
        return switch (failure.getSQLState()) {
            case "23505" -> new DuplicateRequest(idempotencyKey, failure);
            case "08001", "08003", "08006" ->
                    new StoreUnavailable(failure);
            default -> new IllegalStateException(
                    "unclassified persistence failure", failure);
        };
    }

    public static void main(String[] args) {
        SQLException unique = new SQLException(
                "constraint uk_post_request", "23505", 23505);
        RuntimeException mapped = translate("req-20260714-41", unique);
        System.out.printf("contract=%s cause=%s%n",
                mapped.getClass().getSimpleName(),
                mapped.getCause().getClass().getSimpleName());
    }
}
FailureMappingDemo 실행
contract=DuplicateRequest cause=SQLException

메시지에서 제약 조건 이름을 정규식으로 긁는 방법은 드라이버별 형식 변화에 약합니다.

가능하면 데이터베이스가 제공하는 구조화 필드나 Spring 변환기를 사용하고, 스키마 마이그레이션에서 제약 조건 이름을 안정적인 계약으로 관리합니다.


실패별 사용자 행동

애플리케이션 서비스는 리포지토리 예외를 다시 “DB 오류”로 포장하지 않습니다.

DuplicateRequest라면 같은 키로 저장된 결과를 조회해 멱등 응답을 만들 수 있고, ConcurrentChange라면 최신 버전과 함께 충돌을 알립니다.

StoreUnavailable은 재시도 정책 또는 503 매핑으로 넘깁니다.

중복 키가 항상 같은 요청을 의미하는 것도 아닙니다.

키와 요청 지문을 같이 저장해 페이로드가 다르면 409로 거절해야 합니다.

기존 결과를 반환하는 행동은 트랜잭션에서 커밋된 행이 실제로 존재한다는 확인 뒤에만 수행합니다.

예외 클래스가 HTTP 상태를 직접 가지면 애플리케이션이 웹 프로토콜에 묶입니다.

ProblemDetail 변환은 웹 어댑터가 맡고, 메시지 사용자는 같은 실패를 재시도 또는 배달 실패로 표현합니다.

하나의 업무 분류 체계가 여러 전달 방식에서 서로 다른 정책으로 사용됩니다.


런타임 전파 경계

비검사 예외는 선언이 없기 때문에 검토자가 실패 경로를 놓치기 쉽습니다.

포트 문서와 계약 테스트, 예외 핸들러, 메트릭 이름 지정을 같이 둡니다.

예외 계층이 너무 잘게 쪼개져 호출자가 아무 차이도 두지 않는다면 다시 합칩니다.

분류 체계 크기는 공급자 오류 수가 아니라 선택 가능한 행동 수에 맞춥니다.

요청 경계는 예상한 규칙 실패와 프로그래밍 결함을 구별합니다.

전자는 안정적인 오류 코드를 내고 스택 트레이스를 낮은 수준으로 기록할 수 있습니다.

NullPointerException이나 불변식 위반을 StoreUnavailable로 바꾸면 장애 원인을 숨기므로 예상하지 못한 런타임은 500과 장애 신호로 남깁니다.

재시도는 예외 이름만 보고 시작하지 않습니다.

작업의 멱등성, 이미 커밋되었을 가능성, 남은 기한, 최대 횟수를 함께 검사합니다.

DB 소켓이 응답을 잃은 경우 서버가 커밋했는지 모를 수 있으므로 멱등성 키가 없는 쓰기를 즉시 반복하면 중복 행이 생깁니다.


어댑터 규칙 테스트

메모리 기반 가짜, JDBC, JPA 어댑터가 같은 인터페이스를 구현해도 동작은 쉽게 달라집니다.

가짜가 중복 키를 덮어쓰고 JDBC가 예외를 내면 테스트가 어댑터 교체 가능성을 증명하지 못합니다.

공통 픽스처로 다음 행동을 반복합니다.

  • 생성 뒤 반환 동일성과 버전이 같은가
  • 같은 멱등성 키·같은 페이로드가 같은 결과를 주는가
  • 같은 키·다른 페이로드는 충돌인가
  • 오래된 이름 변경이 기존 행을 바꾸지 않는가
  • 찾을 수 없음을 빈 결과와 예외 중 계약대로 표현하는가
  • 원인을 응답에 노출하지 않으면서 로그에서 찾을 수 있는가

가짜를 빠른 단위 테스트용으로 남기더라도 데이터베이스 제약 조건의 의미를 흉내 내야 합니다.

더 안전한 선택은 같은 리포지토리 계약 테스트를 H2와 운영 데이터베이스 컨테이너에 모두 적용하고, 가짜는 서비스의 특정 분기만 검증하는 것입니다.


기술 교체와 예외 매핑

JDBC에서 JPA로 바꾸면 DuplicateKeyException 대신 플러시 시점의 DataIntegrityViolationException이나 낙관적 잠금 예외가 나타날 수 있습니다.

새 어댑터가 같은 애플리케이션 실패를 내도록 매핑하고, 커밋 시점 예외까지 계약 테스트가 실행되게 명시적으로 플러시합니다.

운영 대시보드도 공급자 타입 대신 post.store.duplicate, post.store.concurrent, post.store.unavailable처럼 안정적인 차원을 사용합니다.

다만 원인 클래스와 SQLState는 진단 이벤트에 별도로 남겨 새로운 실패가 미분류 버킷에 몰리는지 확인합니다.


연습 문제

회원 게시판의 삭제 포트를 설계하세요.

이미 삭제된 게시글을 멱등 성공으로 볼지 찾을 수 없음으로 볼지 결정하고, 다른 회원의 행, 오래된 버전, 데이터베이스 사용 불가를 서로 다른 외부 행동으로 연결하세요.

JDBC와 메모리 기반 구현에 같은 계약 테스트를 적용할 수 있어야 합니다.

해설 보기

삭제의 멱등성은 결과 상태가 같다는 뜻이지 모든 응답이 같아야 한다는 뜻은 아닙니다.

이 API에서는 첫 삭제와 반복 삭제를 모두 성공으로 정하고, 소유자가 다른 행은 정보 노출을 막기 위해 없음처럼 처리할 수 있습니다.

package board.application;

public interface PostDeletion {
    DeleteResult delete(
            long postId,
            long authorId,
            long expectedVersion);

    enum DeleteResult {
        DELETED,
        ALREADY_ABSENT
    }

    final class StaleDeletion extends RuntimeException {
        public StaleDeletion(long postId) {
            super("post changed before deletion: " + postId);
        }
    }

    final class DeletionUnavailable extends RuntimeException {
        public DeletionUnavailable(Throwable cause) {
            super("post deletion unavailable", cause);
        }
    }
}

어댑터의 SQL은 DELETE ... WHERE id=? AND author_id=? AND version=?처럼 한 구문으로 경쟁을 판정합니다.

영향 행이 0이면 존재 여부와 버전을 추가 조회할지, 보안과 비용을 고려해 규칙에서 미리 정합니다.