예외 처리 표준에서 ErrorCode와 ErrorCodeException으로 실패를 다루는 방법을 정했다. 그런데 그 글이 다룬 것은 우리가 직접 던지는 예외뿐이다.
실제 서비스에서는 우리가 던지지 않은 예외가 훨씬 많이 나온다. 그리고 그중 일부는 우리가 만든 응답 형식을 통째로 무시하고 나간다. 이 글은 실패의 종류를 전부 늘어놓고, 각각을 어디서 잡을지 정한다.
분류 기준: 누구 잘못이고, 어디서 터지는가
Advice 못 잡음"| X1["EntryPoint
AccessDeniedHandler"] D -.->|"400/405/415"| X2["@RestControllerAdvice"] SVC -.->|"비즈니스 위반"| X2 REPO -.->|"제약 위반"| X3["Repository에서 번역"] EXT -.->|"타임아웃/5xx"| X4["Client에서 번역"] style F fill:#2d3748,stroke:#f56565,stroke-width:3px,color:#e2e8f0 style X1 fill:#1a202c,stroke:#f56565,stroke-width:3px,color:#e2e8f0 style X2 fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style X3 fill:#2d3748,stroke:#ed8936,stroke-width:2px,color:#e2e8f0 style X4 fill:#2d3748,stroke:#ed8936,stroke-width:2px,color:#e2e8f0
그림을 읽는 법은 이렇다. 요청은 먼저 Security 필터 — 로그인·권한을 확인하려고 줄줄이 세워 둔 검문소들 — 를 통과해야 한다. 통과하면 DispatcherServlet이 어느 Controller가 처리할지 정해 넘기고, Controller는 실제 업무를 Service에 맡긴다. Service는 DB를 다루는 Repository를 쓰거나, 다른 서버에 요청을 보내는 외부 API를 부른다. 점선 화살표는 “이 지점에서 실패하면 누가 응답을 만드는가”를 보여준다 — Security 필터에서 막히면 401·403 응답을 EntryPoint·AccessDeniedHandler가 직접 만들고(원칙 1), DispatcherServlet 이후에 터지면 @RestControllerAdvice가 받는다(원칙 2 이후).
핵심은 @RestControllerAdvice가 만능이 아니라는 것이다. 그 앞뒤로 새는 곳이 있고, 그 구멍마다 처리 지점을 따로 둬야 응답 형식이 유지된다.
원칙 1. 인증·인가 실패는 Advice 밖에서 처리한다
인증은 “이 요청을 보낸 사람이 누구인지” 확인하는 일이고, 인가는 “이 사람이 이 작업을 해도 되는지” 확인하는 일이다. 이 둘을 포함해 로그인·권한 처리를 맡아 주는 것이 Spring Security다. 가장 놓치기 쉬운 지점이다. Spring Security 필터는 DispatcherServlet보다 앞에서 돈다. 그런데 @RestControllerAdvice는 DispatcherServlet의 예외 처리 단계에 있다.
[Security 필터] ← 토큰 만료가 여기서 터지면
↓ @RestControllerAdvice까지 도달하지 못한다
[DispatcherServlet] → [Controller] → ...
그대로 두면 인증 실패에서만 우리 형식이 아닌 응답이 나간다. 클라이언트는 401만 다르게 파싱해야 한다.
응답을 직접 써주는 컴포넌트를 하나 두고, AuthenticationEntryPoint와 AccessDeniedHandler가 공유한다.
@Component
@RequiredArgsConstructor
public class SecurityResponseWriter {
private final ObjectMapper objectMapper;
public void write(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setStatus(errorCode.getStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
objectMapper.writeValue(
response.getWriter(),
HttpApiResponse.error(errorCode, errorCode.getDefaultMessage()));
}
}
한 줄씩 보면, @Component와 @RequiredArgsConstructor는 이 클래스를 스프링이 관리하는 빈으로 만들고, final 필드(ObjectMapper, 자바 객체와 JSON을 서로 바꿔 주는 도구)를 받는 생성자를 자동으로 만들어 준다. write 메서드는 지금 응답을 만들고 있는 HttpServletResponse 객체 하나를 받아 상태 코드·형식·본문을 직접 채워 넣는다. throws IOException은 “응답을 쓰다가 네트워크 쪽 오류가 나면 이 메서드도 실패를 알린다”는 뜻으로, 자바가 그 가능성을 메서드 선언에 적어 두라고 강제하는 자리다.
@Component
@RequiredArgsConstructor
public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final SecurityResponseWriter writer;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException e) throws IOException {
writer.write(response, ErrorCode.UNAUTHENTICATED);
}
}
@Component
@RequiredArgsConstructor
public class ApiAccessDeniedHandler implements AccessDeniedHandler {
private final SecurityResponseWriter writer;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException e) throws IOException {
writer.write(response, ErrorCode.ACCESS_DENIED);
}
}
http.exceptionHandling(handler -> handler
.authenticationEntryPoint(apiAuthenticationEntryPoint)
.accessDeniedHandler(apiAccessDeniedHandler));
handler -> handler...는 람다다 — handler라는 이름을 하나 받아서 그 뒤에 적은 설정을 적용하라는 뜻이다. 이 코드는 “인증 실패는 apiAuthenticationEntryPoint가, 인가 실패는 apiAccessDeniedHandler가 처리한다”고 Spring Security에 등록하는 설정이다.
메서드 레벨 거부는 Advice가 잡을 수 있다
@PreAuthorize가 막는 경우는 DispatcherServlet 안에서 일어나므로 Advice가 잡을 수 있다. 그래서 AccessDeniedException 핸들러를 Advice에도 두게 되는데, 같은 ErrorCode를 쓰면 어느 쪽에서 잡히든 응답이 같다. 그게 목적이므로 중복이라고 볼 필요는 없다.
원칙 2. 영속성 예외는 Repository 구현체에서 번역한다
반증: 중복 키 저장을 Service 의 catch(DataIntegrityViolationException) 가 잡는가 — Boot 3.5.16 (가)saveAndFlush Service catch 가 잡음/밖으로=UnexpectedRollbackException (나)save+커밋 Service catch 못 잡음/밖으로=DataIntegrityViolationException<-PSQLException · Boot 4.1.1 (가)saveAndFlush Service catch 가 잡음/밖으로=UnexpectedRollbackException (나)save+커밋 Service catch 못 잡음/밖으로=DataIntegrityViolationException<-PSQLException — 증명
인용줄 읽는 법. (가)·(나)(필요하면 (다)·(라)로 이어간다)는 비교한 상황에 붙인 이름표다. 그 뒤에 “무엇을 했을 때”(예: saveAndFlush), “Service의 catch가 잡았는지”, 안 잡았다면 “밖으로 어떤 예외가 나갔는지”를 슬래시(/)로 이어 적는다. Boot 버전이 여럿이면 가운데 점(·)으로 묶어 그 버전의 결과를 나란히 보여준다. 뒤에 나오는 다른 원칙의 인용줄도 같은 방식으로 읽는다.
인용줄에 나온 말을 풀면, saveAndFlush는 변경 내용을 그 자리에서 바로 DB로 내보내는 메서드다. PSQLException은 PostgreSQL 드라이버가 던지는 에러이고, DataIntegrityViolationException은 스프링이 그걸 감싸 돌려주는 예외다. UnexpectedRollbackException은 이미 되돌리기로 정해진 트랜잭션을 또 커밋하려 할 때 나는 예외다. 이 넷이 어떻게 엮이는지는 패키지 구조 표준에서 더 자세히 설명한다.
영속성(프로그램이 꺼져도 데이터가 사라지지 않고 DB에 남는 성질)과 관련된 예외, 즉 DB에 저장하다가 나는 예외를 이 원칙에서는 “영속성 예외”라 부른다. 이메일 unique 제약을 어기면 Spring이 DataIntegrityViolationException을 던진다. 이걸 그냥 두면 Exception 핸들러로 떨어져 500 “서버 내부 오류”가 나간다. 실제로는 사용자 잘못(중복 가입)이므로 409가 맞다.
번역 위치는 Repository 구현체다. DTO 네이밍 표준에서 Entity와 MapperResult가 패키지 밖으로 안 나가게 했듯, 영속성 예외 타입도 Service로 새면 안 된다.
@Repository
@RequiredArgsConstructor
public class UserJpaRepository implements UserRepository {
private final UserJpaEntityRepository jpaEntityRepository;
private final UserEntityMapper mapper;
@Override
public UserDomain save(UserDomain user) {
try {
if (user.getId() == null) {
return mapper.toDomain(jpaEntityRepository.save(mapper.toEntity(user)));
}
UserEntity entity = jpaEntityRepository.findById(user.getId())
.orElseThrow(() -> ErrorCodeException.of(ErrorCode.USER_NOT_FOUND));
mapper.applyTo(entity, user);
jpaEntityRepository.flush(); // 커밋을 기다리지 않고 try 안에서 UPDATE를 보낸다 — 원칙 2-2
return mapper.toDomain(entity);
} catch (DataIntegrityViolationException e) {
throw ErrorCodeException.of(ErrorCode.DUPLICATE_EMAIL, e);
} catch (OptimisticLockingFailureException e) {
throw ErrorCodeException.of(ErrorCode.USER_CONCURRENT_UPDATE, e);
}
}
}
한 줄씩 보면, user.getId() == null이면 아직 DB에 없는 새 데이터라는 뜻이라 save로 새로 만든다. 이미 있으면 findById로 기존 행을 가져오는데, Optional(값이 있을 수도 없을 수도 있다는 걸 타입으로 드러낸 상자)이 비어 있으면 orElseThrow가 그 자리에서 예외를 던진다. mapper.applyTo(entity, user)는 DB에서 가져온 entity의 값을 user 내용으로 덮어쓰는 것이고, 그 변경은 일단 영속성 컨텍스트(바뀐 내용을 모아 두는 자리)에만 쌓인다. flush()는 그렇게 모아 둔 변경을 지금 바로 DB로 내보내는 일이다. 보통은 트랜잭션을 커밋할 때 한꺼번에 나가는데, 여기서는 커밋을 기다리지 않고 미리 내보내 바로 뒤의 catch가 그 결과를 받을 수 있게 한다(원칙 2-2에서 왜 필요한지 다룬다). 두 번째 catch가 잡는 OptimisticLockingFailureException은 낙관적 락이 충돌했을 때 나는 예외다.
Service는 DataIntegrityViolationException이라는 타입 자체를 모른다. JPA에서 MyBatis로 바꿔도 Service 코드는 그대로다.
OptimisticLockingFailureException을 잡으려면 그게 실제로 발생해야 한다. 이 글을 쓸 당시에는save(mapper.toEntity(user))한 줄이었는데, 그러면merge가 되어Domain이 나르지 않는@Version이 비어 넘어간다 — 잡기로 한 예외가 의도대로 발생하지 않는 상태였다. 위처럼 영속 Entity를 직접 고치면 JPA가 version을 검사한다. Domain 영속화 표준에서 정리했다. 다만 그 검사는 커밋 시점에 일어나서,flush()없이는 이catch에 닿지 않는다(원칙 2-2). 그리고 격리 수준을 REPEATABLE READ로 올리면flush()를 불러도 충돌이 이 타입으로 오지 않는다. 그래서 격리 수준을 READ COMMITTED에 두고 올리지 않기로 했다(원칙 2-3).
번역할 예외가 없는 충돌도 같은 409로 낸다(2026-10-21). 이 원칙은 예외를 번역하는 이야기지만, 같은 실패가 예외 없이 오는 경우가 하나 있어 여기 함께 적는다. 표준 경로(findById로 꺼내 applyTo로 고친다)에서 OptimisticLockingFailureException은 두 저장이 같은 순간에 겹칠 때만 난다. 수정 화면을 열어 둔 사이에 남이 저장한 경우는 저장 요청 때 서버가 최신 행을 읽어 고치므로 이 예외가 나지 않는다. 그 경우는 수정 요청이 조회 때 받은 version을 돌려보내고, 위 save가 아니라 그 값을 따로 받는 update(domain, expectedVersion)에서 구현체가 applyTo 전에 영속 Entity의 version과 비교해 잡는다(Domain 영속화 표준 원칙 5-1). 다르면 번역할 예외가 없으니 구현체가 직접, 위 catch와 같은 USER_CONCURRENT_UPDATE(409)를 던진다. 둘 다 「내가 읽은 뒤에 남이 고쳤다」는 같은 실패라서 코드를 가르지 않았다. If-Match 헤더로 받아 412로 거절하는 안은 쓰지 않는다 — 버린 이유는 그 원칙에 있다.
한계는 인정하고 간다
한 테이블에 unique 제약이 여러 개면, DataIntegrityViolationException만으로는 어느 제약이 깨졌는지 알 수 없다. 예외 메시지를 파싱하는 방법이 있지만 DB 벤더에 종속되므로 쓰지 않는다.
이런 경우는 저장 전에 조회해서 구체적인 ErrorCode로 막고, 제약 위반은 동시 요청에 대비한 최후의 방어선으로 남긴다.
// 평소에는 조회로 구체적인 실패를 알려주고
if (userRepository.existsByEmail(command.getEmail())) {
throw ErrorCodeException.of(ErrorCode.DUPLICATE_EMAIL);
}
// 동시에 들어온 요청은 Repository의 제약 위반이 막는다
원칙 2-1. DB 쿼리 타임아웃도 영속성 예외와 같은 자리에서 503으로 번역한다
스키마 마이그레이션 표준이 슬로우 쿼리 기준(300ms)을 정하면서, 그 기준을 넘겨 실제로 끊긴 쿼리가 무엇으로 응답해야 하는지가 드러났다. 지금까지 이 표준의 5xx는 전부 외부 API 쪽이었다. QueryTimeoutException이 잡히지 않으면 Exception 핸들러로 떨어져 500 “서버 내부 오류”가 나간다.
번역 위치는 원칙 2와 같다 — Repository 구현체. DB 타임아웃도 영속성 예외의 한 종류이고, DataIntegrityViolationException을 거기서 잡는 것과 같은 이유다.
정해야 했던 건 이거였다 — 상태 코드를 외부 연동처럼 502·503·504로 나눌 것인가, 하나로 통일할 것인가.
안 A — 외부 연동과 같은 삼분법을 쓴다. 얻는 것은 기존 표를 그대로 재사용한다는 점이다. 버린 이유는 원인을 나눌 축이 없다는 것이다. 외부 연동의 삼분법은 “상대가 오류를 줬는가·연결이 안 됐는가·응답이 없는가”처럼 상대의 상태로 갈린다. DB 타임아웃은 상대가 없다 — 원인은 “이쪽이 정한 시간 안에 못 끝냈다” 하나뿐이다. 없는 축을 나누면 ORDER_DB_ERROR(502에 대응)처럼 의미 없는 자리만 하나 늘어난다.
안 B — 503 하나로 통일한다. 얻는 것은 코드가 실제로 있는 원인의 개수와 맞는다는 점이다. 503은 “재시도하면 될 수도 있는, 클라이언트 잘못이 아닌 실패”라는 의미도 외부 연동의 503(연결 불가)과 같은 결이다.
골랐다 — 안 B. 안 A가 못 지켜주는 것은 코드 개수가 원인 개수와 맞는 것이다. 없는 축을 나누면 아무도 안 던지는 상수가 하나 생기고, 그건 다음 사람에게 「여기 들어갈 경우가 있나 보다」로 읽힌다.
대신 DB가 느린 것과 DB가 죽은 것이 같은 코드로 나간다. 지금은 원인이 하나뿐이라 맞지만, 나중에 둘을 갈라야 할 일이 생기면 그때는 이미 부르는 쪽이 503 하나에 맞춰 동작하고 있다. 코드를 쪼개는 값이 그때 더 비싸다.
// order/infra/OrderJpaRepository.java
@Override
public List<OrderDomain> findExpiredPendingOrders() {
try {
return jpaEntityRepository.findByStatusAndCreatedAtBefore(...)
.stream().map(mapper::toDomain).toList();
} catch (QueryTimeoutException e) {
throw ErrorCodeException.of(ErrorCode.ORDER_QUERY_TIMEOUT, e);
}
}
한 줄씩 보면, findByStatusAndCreatedAtBefore(...)가 돌려준 Entity 목록을 스트림으로 받아 map(mapper::toDomain)으로 하나씩 Domain으로 바꾸고(mapper::toDomain은 mapper.toDomain(x)를 줄여 쓰는 메서드 참조다), toList()로 다시 리스트를 만든다. 쿼리가 오래 걸려 끊기면 QueryTimeoutException이 나고, catch가 그것을 503 ErrorCodeException으로 바꾼다.
ErrorCode는 원칙 9의 도메인 대역 규칙대로 도메인별로 발급한다. ORDER_QUERY_TIMEOUT처럼 처리 대상 도메인을 남기고, COMMON으로 뭉치지 않는다.
원칙 2-2. 수정 경로는 구현체의 try 안에서 flush()까지 부른다
실측(supported): 표준 모양 Repository 구현체의 catch 가 수정 경로 예외를 번역하는가 — Boot 3.5.16 (가)수정·낙관락 catch=none/밖으로=CannotAcquireLockException<-SQLException/번역=no/롤백=yes (나)수정·유니크 catch=none/밖으로=DataIntegrityViolationException<-SQLIntegrityConstraintViolationException/번역=no/롤백=yes (다)신규·유니크 catch=DIVE/밖으로=ErrorCodeException<-SQLIntegrityConstraintViolationException/번역=yes/롤백=yes (라)flush·낙관락 catch=none/밖으로=CannotAcquireLockException<-SQLException/번역=no/롤백=yes (라)flush·유니크 catch=DIVE/밖으로=ErrorCodeException<-SQLIntegrityConstraintViolationException/번역=yes/롤백=yes · Boot 4.1.1 (가)수정·낙관락 catch=none/밖으로=JpaSystemException<-SQLException/번역=no/롤백=yes (나)수정·유니크 catch=none/밖으로=DataIntegrityViolationException<-SQLIntegrityConstraintViolationException/번역=no/롤백=yes (다)신규·유니크 catch=DIVE/밖으로=ErrorCodeException<-SQLIntegrityConstraintViolationException/번역=yes/롤백=yes (라)flush·낙관락 catch=none/밖으로=JpaSystemException<-SQLException/번역=no/롤백=yes (라)flush·유니크 catch=DIVE/밖으로=ErrorCodeException<-SQLIntegrityConstraintViolationException/번역=yes/롤백=yes — 증명
위 인용줄의 catch=DIVE에 나오는 DIVE는 원칙 2에서 다룬 DataIntegrityViolationException의 앞 글자를 딴 줄임말이다 — 매번 긴 이름을 전부 적으면 한 줄에 여러 상황을 비교하는 표가 너무 길어져서, 이 인용줄 안에서만 줄여 쓴 것이다.
원칙 2의 번역은 예외가 구현체의 try 안에서 터진다는 전제 위에 서 있다. 신규 저장은 그렇다. 기본 키 값을 DB가 행을 넣는 순간 자동으로 채워 주는 방식을 IDENTITY라고 한다. Entity의 id를 @GeneratedValue(strategy = GenerationType.IDENTITY)로 매핑하면 JPA는 그 값을 받아 와야 해서 save하는 순간 INSERT를 곧바로 보낸다. DB 제품이 아니라 이 매핑이 정하는 것이고, 우리 표준의 Entity 예시가 이 매핑이다. 수정 경로는 아니다. Domain 영속화 표준대로 영속 Entity를 고치고 더티체킹에 맡기면 UPDATE는 트랜잭션 커밋 때 나간다. 커밋은 Service의 @Transactional이 끝날 때다. 그때 구현체는 이미 반환했다.
실측이 그대로였다. 원칙 2 예시 모양 그대로 수정 경로에서 유니크 위반과 낙관락 충돌을 일으키자, 두 Boot 버전 모두 구현체의 catch가 한 번도 실행되지 않았고 호출자는 번역 안 된 예외를 받았다. 신규 경로 대조군은 번역됐다. 번역 코드가 아니라 시점이 문제였다.
정해야 했던 건 이거였다 — 커밋 때 터지는 영속성 예외를 어디서 번역하는가.
안 A — 이유만 고치고 예시는 둔다. 얻는 것은 저장 비용이 그대로라는 점이다. 버린 이유는 수정 경로의 제약 위반이 계속 500으로 나간다는 것이다. 글이 「번역한다」고 적고 코드는 번역하지 못하는 상태가 남는다.
안 C — 커밋 시점 예외는 Advice가 번역한다. 얻는 것은 flush 없이 모든 시점을 한 곳에서 받는다는 점이다. 버린 이유는 번역 자리가 구현체와 Advice 두 곳이 된다는 것이다. Advice는 어느 도메인의 충돌인지 모르므로 URI 같은 간접 단서로 도메인을 추측해야 하고, ErrorCode 계약 표준이 전제한 「구현체가 도메인 코드를 붙인다」가 흔들린다.
골랐다 — 안 B. 수정 경로는 applyTo 뒤에 flush()를 try 안에서 부른다. 실측에서 이렇게 바꾸자 유니크 위반이 catch에 걸려 ErrorCodeException으로 번역됐다. 안 A와 달리 번역이 실제로 일어나고, 안 C와 달리 번역 자리가 구현체 하나로 남는다.
대가는 두 가지다. 저장마다 flush가 한 번 늘어, 같은 트랜잭션에서 뒤에 바뀔 것까지 모아 한 번에 보내던 이득을 버린다. 그리고 낙관락은 이것으로 해결되지 않았다. 위 실측은 MariaDB 11.8 기본값에서 돌렸는데, 충돌이 OptimisticLockingFailureException이 아니라 Boot 3은 CannotAcquireLockException, Boot 4는 JpaSystemException으로 왔다. DB가 JPA보다 먼저 거절했기 때문이다. 표준 DB인 PostgreSQL에서도 격리 수준을 REPEATABLE READ로 올리면 같은 일이 난다. flush로 시점을 당겨도 DB 쪽 설정에 따라 catch 타입이 맞지 않을 수 있어서, 격리 수준은 원칙 2-3에서 따로 정했다.
원칙 2-3. 트랜잭션 격리 수준은 READ COMMITTED에 두고 올리지 않는다
실측(supported): PostgreSQL 17 기본 READ COMMITTED 에서 표준 모양 구현체의 catch(OptimisticLockingFailureException) 가 수정 경로 낙관락 충돌을 번역하는가 — Boot 3.5.16 iso=rc (가)flush·낙관락 catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes (가)noflush·낙관락 catch=none/밖으로=ObjectOptimisticLockingFailureException<-StaleObjectStateException/sql=-/번역=no/롤백=yes (마)version없음 catch=none/밖으로=none/sql=-/번역=no/롤백=no (바)안읽은행 catch=none/밖으로=none/sql=-/번역=no/롤백=no (사)낡은version catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes · Boot 4.1.1 iso=rc (가)flush·낙관락 catch=OLFE/밖으로=ErrorCodeException<-StaleStateException/sql=-/번역=yes/롤백=yes (가)noflush·낙관락 catch=none/밖으로=ObjectOptimisticLockingFailureException<-StaleStateException/sql=-/번역=no/롤백=yes (마)version없음 catch=none/밖으로=none/sql=-/번역=no/롤백=no (바)안읽은행 catch=none/밖으로=none/sql=-/번역=no/롤백=no (사)낡은version catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes · Boot 3.5.16 iso=rr (가)flush·낙관락 catch=none/밖으로=CannotAcquireLockException<-PSQLException/sql=0/40001/번역=no/롤백=yes (가)noflush·낙관락 catch=none/밖으로=CannotAcquireLockException<-PSQLException/sql=0/40001/번역=no/롤백=yes (마)version없음 catch=none/밖으로=CannotAcquireLockException<-PSQLException/sql=0/40001/번역=no/롤백=yes (바)안읽은행 catch=none/밖으로=CannotAcquireLockException<-PSQLException/sql=0/40001/번역=no/롤백=yes (사)낡은version catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes · Boot 4.1.1 iso=rr (가)flush·낙관락 catch=none/밖으로=CannotAcquireLockException<-PSQLException/sql=0/40001/번역=no/롤백=yes (가)noflush·낙관락 catch=none/밖으로=CannotAcquireLockException<-PSQLException/sql=0/40001/번역=no/롤백=yes (마)version없음 catch=none/밖으로=CannotAcquireLockException<-PSQLException/sql=0/40001/번역=no/롤백=yes (바)안읽은행 catch=none/밖으로=CannotAcquireLockException<-PSQLException/sql=0/40001/번역=no/롤백=yes (사)낡은version catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes — 증명
인용줄의 iso=rc는 PostgreSQL 17의 기본 격리 수준인 READ COMMITTED를 그대로 둔 상태, iso=rr은 커넥션 풀 설정으로 REPEATABLE READ까지 올린 상태다. (가)는 이 원칙의 본체인 「읽고 고치는 사이에 남이 먼저 고친」 충돌이다. (가)flush는 원칙 2-2대로 try 안에서 flush()를 부른 모양이고, (가)noflush는 부르지 않은 옛 모양이다. (마)·(바)는 아래 「대가」에서 다룬다. (사)는 실험 장치가 catch까지 닿을 수 있는지 보려고 둔 대조군이다. OLFE는 OptimisticLockingFailureException의 줄임이고, sql=0/40001은 DB가 돌려준 SQLState(DB 오류의 다섯 글자 표준 번호)가 40001이었다는 뜻이다.
장면은 이렇다. A의 읽기와 저장은 Service 메서드 하나, 곧 트랜잭션 하나 안에서 일어난다.
- A의 트랜잭션이 회원을 읽는다.
version은 0이다 - 그사이 B가 같은 회원을 고치고 커밋한다.
version은 1이 된다 - A가 같은 트랜잭션 안에서 고친 값을 저장한다
A가 저장하면 B의 수정이 아무도 모르게 사라진다. 이것을 잃어버린 수정이라고 부르고, 낙관적 락은 「A가 읽었을 때의 version 0이 지금도 0인가」를 확인해 이것을 막는다. A처럼 트랜잭션 안에서 읽어 온 Entity를 고친 경우, JPA는 이 확인을 따로 하지 않고 저장 명령 안에 싣는다. DB에 「id가 이 회원이고 version이 0인 행을 고쳐라」라고 보내고, 고쳐진 행이 0개면 번호가 바뀌었다고 판단해 OptimisticLockingFailureException을 던진다. 그러면 원칙 2의 catch가 USER_CONCURRENT_UPDATE(409, 「충돌」을 뜻하는 HTTP 응답 번호)로 바꾼다.
이것이 실제로 도는지는 격리 수준에 달려 있었다. 격리 수준은 동시에 도는 트랜잭션끼리 서로의 변경을 얼마나 보게 할지 정하는 DB 규칙이다. 아래 두 수준이 가르는 것은 다른 트랜잭션이 커밋한 변경을 언제부터 보이게 하느냐다.
- READ COMMITTED(PostgreSQL 기본값) — 문장(쿼리)마다, 그 문장이 시작될 때까지 커밋된 DB 모습을 새로 본다(PostgreSQL 17 문서 13.2.1절). 장면에서 B는 A가 저장하기 전에 커밋했으므로, A의 저장 명령은 처음부터 B가 고친 최신 행(
version1)을 본다. 「version이 0인 행」 조건에 맞지 않아 고쳐진 행이 0개가 되고, JPA가OptimisticLockingFailureException을 던진다. 실측에서 Boot 두 버전 모두iso=rc의(가)flush가catch=OLFE였고USER_CONCURRENT_UPDATE로 번역됐다 - REPEATABLE READ — 트랜잭션의 첫 문장(쿼리)이 시작될 때 찍은 DB 모습(스냅숏)을 트랜잭션이 끝날 때까지 본다(같은 문서 13.2.2절). 같은 절은, 이 수준에서 고치려는 행을 그 뒤에 다른 트랜잭션이 고쳐 커밋했으면 DB가 「could not serialize access due to concurrent update」로 트랜잭션을 롤백한다고 적는다. 실측에서 이것이 SQLState 40001로 왔다. A의 첫 문장은 1단계의 읽기이고 B는 그 뒤에 커밋했으니, 3단계가 정확히 그 경우다. JPA가 「
version이 0인가」를 따져 보기도 전에 DB가 명령을 돌려보낸다. 나오는 예외는 Boot 두 버전 모두CannotAcquireLockException이었다.catch가 기다리는 타입이 아니라서 지나친다. 그러면 사용자는 409가 아니라 500(서버 오류)을 받는다 — 실측이 확인한 것은 예외 타입까지이고, 500은 번역되지 않은 영속성 예외가Exception핸들러로 떨어져 500이 된다는 원칙 2에서 나온 추론이다
CannotAcquireLockException이 되는 길은 소스로 따라갔다. Hibernate가 SQLState 40001을 LockAcquisitionException으로 바꾸고(hibernate-core의 exception/internal/SQLStateConversionDelegate.java:51-52), Spring이 그것을 CannotAcquireLockException으로 바꾼다(spring-orm의 jpa/hibernate/HibernateExceptionTranslator.java:161-162). 이 타입은 낙관락 쪽이 아니라 비관락 실패(PessimisticLockingFailureException)의 자식이라 OLFE catch에 걸리지 않는다(증명 글의 「소스로 따라가면」).
그러면 catch에 CannotAcquireLockException도 넣으면 되지 않나. 이 타입은 낙관락 충돌에만 오지 않는다 — Hibernate는 PostgreSQL의 교착 상태(SQLState 40P01)도 같은 CannotAcquireLockException 타입으로 만든다(hibernate-core의 dialect/PostgreSQLDialect.java:1097-1099, 소스로만 확인했다). 둘을 가르려면 예외 속 SQLState를 꺼내 봐야 한다. 40001(직렬화 실패)은 표준 번호라 꺼내 볼 수는 있다. 다만 그러려면 원칙 2의 번역 방식 — 예외 타입으로 가른다 — 을 벗어나 예외 사슬을 열어 번호를 봐야 하고, 40P01은 PostgreSQL 고유 번호라 그 판별은 DB에 묶인다. 이 원칙은 그 길 대신 격리 수준을 올리지 않는 쪽을 골랐다.
대조군 (사)는 두 격리 수준 모두에서 catch=OLFE였다. 동시 수정 없이 낡은 번호를 든 객체를 저장한 경우다 — 트랜잭션 밖에서 읽어 둔 값을 나중에 들고 와 저장하면 이렇게 된다. 읽어 온 Entity를 고친 것이 아니라 들고 온 객체를 save하면 JPA는 merge로 처리하고, merge는 DB에서 꺼낸 번호와 들고 온 번호를 저장 명령을 보내기 전에 먼저 비교해 실패시킨다(MariaDB 증명 글의 「소스로 따라가면 1」). 그래서 DB의 검사까지 가지 않는다. catch 코드가 죽은 것이 아니라, 「읽고 고치는」 경로에서만 격리 수준이 끼어든다는 뜻이다.
정해야 했던 건 이거였다 — 이 표준이 격리 수준까지 정하는가.
안 A — 원칙 2-3을 지우고 격리 수준은 열린 안건 #126에 넘긴다.
READ COMMITTED가 기본값이니, 아무것도 적지 않아도 지금은 catch가 돈다. 얻는 것은 격리 수준이라는 큰 주제를 이 원칙 하나로 좁혀 정하지 않고, 그 주제만 다루는 안건(#126, 트랜잭션 격리 수준)에서 한꺼번에 정할 수 있다는 점이다. 버린 이유는 누가 격리 수준을 올리는 순간 409 catch가 조용히 죽는데, 그것을 막는 줄이 어디에도 없기 때문이다. 전역 설정 한 줄로 올리면, 실측대로 충돌이 40001로 먼저 거절되어 catch를 지나친다(그 뒤 500이 되는 것은 위에서 적은 추론이다). Service 메서드의 @Transactional(isolation = Isolation.REPEATABLE_READ) 하나로 올려도 같은 DB 격리가 걸리므로 같은 결과로 보지만, 그 방식은 재지 않았다. 컴파일도 테스트도 통과한다. 동시 수정이 실제로 일어나야 드러난다.
골랐다 — 안 B, 격리 수준은 READ COMMITTED에 두고 올리지 않는다는 규칙을 남긴다. 전역 설정으로도 @Transactional(isolation = ...)으로도 올리지 않는다. 안 A가 비워 두는 자리 — 「올리면 원칙 2의 catch가 죽는다」 — 를 규칙 한 줄이 막는다. 기본값을 그대로 쓰는 것이라 설정을 따로 걸 필요도 없다. 이것으로 열린 안건 #126(트랜잭션 격리 수준) 중 「격리 수준을 무엇으로 두나」에는 이 원칙이 답하고, 「더 높은 격리가 필요한 기능은 어떻게 하나」는 남는다. 「전역」은 커넥션 풀 설정(spring.datasource.hikari.transaction-isolation)이나 DB 서버의 기본 격리 수준을 바꾸는 것을 말한다.
// 이렇게 하지 않는다 — 이 트랜잭션만 REPEATABLE READ로 올린다
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void rename(RenameUserCommand command) { ... }
// 이렇게 한다 — 격리 수준을 적지 않는다(PostgreSQL 기본값 READ COMMITTED)
@Transactional
public void rename(RenameUserCommand command) { ... }
한 줄씩 보면, 위쪽은 이 Service 메서드의 트랜잭션만 REPEATABLE READ로 올린다. 아래쪽은 격리 수준을 적지 않아 DB의 기본값을 쓴다.
@Transactional(isolation = ...)으로 올리는 방식은 재지 않았다. 실측의 iso=rr은 커넥션 풀(HikariCP) 설정으로 올린 것이고, 거기서는 구현체가 flush()해도 충돌이 CannotAcquireLockException으로 와 catch를 지나쳤다. @Transactional(isolation = ...)도 같은 DB 격리가 걸리므로 같은 결과로 보고 같은 규칙에 넣었지만, 재지는 않았다.
올리지 않기만 해서는 안 된다. READ COMMITTED에서도 (가)noflush는 catch=none이었다. 원칙 2-2처럼 try 안에서 flush()를 불러야 그 예외가 catch 안에서 터진다. 이 원칙은 원칙 2-2 위에 서 있다.
대가는 둘이다.
- 더 높은 격리가 꼭 필요한 기능이 생기면 그때 예외 경로를 따로 정해야 한다. 이 원칙은 그런 기능을 막는 것이 아니라, 그 기능에서는 원칙 2의 409 번역이 그대로 서지 않는다는 것을 미리 못 박는다. 그런 기능은 아직 없고, 그 경로는 정하지 않았다(#126에 남은 질문이다)
- REPEATABLE READ가 대신 막아 주던 것을 잃는다. 실측에서
iso=rr은(마)·(바)까지 40001로 막았다. READ COMMITTED를 고르면 이 둘을 DB가 막아 주지 않는다. 둘은 각각 다른 규칙이 받는다 —(마)는 Domain 영속화 표준 원칙 5(@Version을 둔다)가,(바)는 일반 경로에서는 Domain 영속화 표준 원칙 1(먼저 읽고 고친다)이, 벌크에서는 배치 실행 표준(조건이 곧 규칙인 연산에만 벌크를 쓴다)이 받는다(마)version칸이 없는 표는 DB가 막아 주지 않는다. READ COMMITTED에서는version이 없는 표를 두 트랜잭션이 읽고 고치자 예외도 롤백도 없이 나중 값으로 덮어써졌다((마)밖으로=none,롤백=no). 비교할 번호가 없으니 낙관락이 개입할 수 없다. 그래서 Domain 영속화 표준 원칙 5에서 수정 경로가 있는 Entity에는@Version을 반드시 두기로 했다(바)트랜잭션이 읽지 않은 행을 고치면 나중 값이 남는다. A의 트랜잭션이 다른 회원을 읽는 사이 B가u4를 고쳐 커밋하고, A가u4를 한 번도 읽지 않은 채 고치는 SQL을 바로 보내는 장면이다. READ COMMITTED에서는 A의 값이 남았다. 일반 요청 경로에는 이 장면이 없다. Domain 영속화 표준 원칙 1대로 고치기 전에 반드시findById로 먼저 읽기 때문이다. 읽지 않고 고치는 곳은 SQL 한 줄로 여러 행을 바꾸는 벌크 경로뿐이고, 벌크는 「PENDING이고 30일 지난 주문은EXPIRED로」처럼 조건이 곧 규칙인 연산에만 쓰기로 했다(배치 실행 표준). READ COMMITTED의UPDATE는 시작할 때까지 커밋된 최신 행을 보고, 도는 도중에 남이 고쳐 커밋한 행은 고쳐진 최신 행에WHERE조건을 다시 맞춰 본다(PostgreSQL 17 문서 13.2.1절).(바)와 벌크가 갈리는 곳이 여기다.(바)의 SQL은WHERE id = :id로 id만 보고 행을 고르므로 B가 고쳐 커밋한 최신 행을 봐도 조건이 그대로 맞아 B의 값을 덮는다. 벌크의WHERE는 B가 바꾼 칸(status)을 다시 보므로, 안 맞으면 그 행을 지나간다. 그래서 벌크가 도는 사이 고객이 주문 하나를PAID로 바꿨다면 그 주문은 조건에 안 맞아 지나간다. 그 보호는 B가 바꾼 칸이 벌크의WHERE안에 있을 때만이다 — B가 조건에 없는 칸, 특히 벌크가SET으로 바꾸는 칸을 고쳤다면 최신 행도 조건에 맞아 벌크가 B의 값을 덮을 수 있다(배치 실행 표준 원칙 2-1의 안 B에 같은 범위를 적었다). 그래서 이 장면에는 새 규칙을 두지 않는다. 방향이 반대인 장면 — 벌크가 먼저 고치고, 그 전에 읽어 둔 수정 경로가 나중에 저장해 벌크의 변경을 덮는 것 — 은 이 장면과 다르고, 배치 실행 표준 원칙 2-1이 벌크 SQL에version = version + 1을 적게 해 막는다. 벌크가 고쳐진 행을 지나간다는 이 주장을 받치는 것은 PostgreSQL 17 문서 13.2.1절뿐이다 —(바)는 덮어쓰기가 일어난 장면이라 이 주장을 받치지 않는다. 벌크 경로 자체로 재지는 않았다
이 원칙은 처음에 MariaDB를 표준 DB로 두고 「스냅숏 격리를 커넥션에서 끈다」로 정했다가 같은 날 바뀌었다. 그 경위와 MariaDB 실측은 아래 「이 표준을 정하기까지」에 있다.
원칙 3. 외부 연동 실패는 5xx로 구분한다
반증: RestClient 로 상대 응답을 받을 때 잡힌 예외(RestClientResponseException 하위인가) — Boot 3.5.16 500→InternalServerError(하위) 깨진JSON→RestClientException(하위아님) · Boot 4.1.1 500→InternalServerError(하위) 깨진JSON→RestClientException(하위아님) — 증명
결제사가 죽어서 타임아웃이 나도 500으로 나가면, 장애 대응할 때 우리 서버 문제인지 상대 문제인지 로그를 파봐야 안다. 상태 코드만으로 구분되게 나눈다.
| 상황 | 상태 | 의미 |
|---|---|---|
| 상대가 5xx나 이상한 응답을 줌 | 502 | 상대 서비스 오류 |
| 연결 자체가 안 됨 | 503 | 상대 서비스 사용 불가 |
| 응답이 안 옴 | 504 | 상대 서비스 응답 지연 |
「이상한 응답」은 한 타입으로 안 잡힌다. 상대가 4xx·5xx를 주면 RestClientResponseException이지만, 상태는 200인데 본문이 깨진 경우는 RestClientException이라 그 catch를 지나친다(두 Boot 버전 동일). 갈리는 기준은 상태 코드가 아니라 「응답을 받았는가」와 「응답을 값으로 바꿨는가」다 — 앞은 응답의 실패이고 뒤는 변환의 실패라 다른 가지로 간다.
번역은 외부 연동 클라이언트에서 한다. 영속성 예외를 Repository에서 번역하는 것과 같은 이유다 — RestClientException 같은 타입이 Service로 새지 않게 한다.
아래 코드는 이 클라이언트를 도메인 하나만 쓴다는 전제 위에 있다. 그래서 ORDER_ 코드를 안에 박아도 된다. 두 번째 도메인이 같은 API를 부르기 시작하면 이 모양은 거짓말이 되고, 그때는 클라이언트를 common/client로 올린 뒤 번역을 도메인마다의 얇은 어댑터로 내린다. 기준은 ErrorCode 계약 표준 원칙 4·5에 있다.
// order/infra/PaymentClient.java — 주문만 쓰는 동안의 모양이다
@Component
@RequiredArgsConstructor
public class PaymentClient {
private final RestClient restClient;
public PaymentResult pay(PaymentCommand command) {
try {
return restClient.post()
.uri("/payments")
.body(command)
.retrieve()
.body(PaymentResult.class);
} catch (ResourceAccessException e) { // 연결 실패 · 응답 지연
// 위 표가 둘을 503과 504로 갈랐으므로 여기서도 갈라야 한다.
// 한 catch 로 묶으면 ORDER_PAYMENT_UNAVAILABLE 을 아무도 안 던진다.
ErrorCode code = e.getCause() instanceof ConnectException
? ErrorCode.ORDER_PAYMENT_UNAVAILABLE // 연결 자체가 안 됨
: ErrorCode.ORDER_PAYMENT_TIMEOUT; // 응답이 안 옴
throw ErrorCodeException.of(code, e);
} catch (RestClientResponseException e) { // 상대가 4xx/5xx 응답
throw ErrorCodeException.of(ErrorCode.ORDER_PAYMENT_API_ERROR, e);
} catch (RestClientException e) { // 본문이 깨져 변환 실패 — 위 catch 를 지나친다
throw ErrorCodeException.of(ErrorCode.ORDER_PAYMENT_API_ERROR, e);
}
}
}
한 줄씩 보면, restClient.post().uri("/payments").body(command).retrieve().body(PaymentResult.class)는 메서드를 점으로 이어 붙여 적는 체이닝이다 — post()가 요청 방식을, uri가 주소를, body가 보낼 내용을 정하고, retrieve()는 “응답을 받아 와라”, 마지막 body(PaymentResult.class)는 “그 응답을 이 타입으로 바꿔 달라”는 뜻이다. 첫 catch의 e.getCause() instanceof ConnectException ? A : B에서 instanceof는 “e.getCause()가 ConnectException의 인스턴스인가”를 검사하는 것이고, 조건 ? A : B는 “조건이 참이면 A, 아니면 B”를 한 줄로 줄여 쓰는 삼항 연산자다 — if문 한 줄짜리를 줄인 모양이라고 보면 된다. 그래서 이 줄은 “연결 자체가 안 된 경우면 UNAVAILABLE, 그 외(응답이 안 온 경우)면 TIMEOUT 코드를 쓴다”는 뜻이다. 두 번째·세 번째 catch는 상대가 4xx·5xx로 답했는지, 응답은 받았지만 본문을 해석하지 못했는지를 가른다.
마지막 catch가 없으면 깨진 본문이 502가 아니라 500으로 나간다. 상대가 준 이상한 응답인데 우리 서버 장애로 기록되는 셈이다. RestClientException이 더 넓은 타입이라 순서를 바꾸면 위의 두 catch가 죽으니 항상 마지막에 둔다.
원칙 4. 로그 레벨은 예외 클래스가 아니라 상태 코드로 정한다
로그 레벨은 로그 한 줄이 얼마나 심각한지 나타내는 등급이다. 여기서 앞선 표준을 하나 고쳐야 한다. 예외 처리 표준에서는 “ErrorCodeException은 warn“이라고 정했는데, 이제 ErrorCodeException이 외부 연동 실패(502/503/504)도 나르게 됐다.
결제사 장애를 warn으로 남기면 아무도 안 본다. 재고 부족과 결제사 다운은 대응이 완전히 다른 사건이다.
예외 클래스를 늘려서 구분할 수도 있지만, 이미 ErrorCode에 상태가 들어 있으므로 그걸로 판단하면 된다.
@ExceptionHandler(ErrorCodeException.class)
public ResponseEntity<HttpApiResponse<Void>> handleErrorCode(ErrorCodeException e) {
ErrorCode errorCode = e.getErrorCode();
if (errorCode.getStatus() >= 500) {
log.error("[{}:{}] {}", errorCode.getCode(), errorCode.name(), e.getMessage(), e);
} else {
log.warn("[{}:{}] {}", errorCode.getCode(), errorCode.name(), e.getMessage());
}
return ResponseEntity
.status(errorCode.getStatus())
.body(HttpApiResponse.error(errorCode, e.getMessage()));
}
여기 나온 log 객체는 클래스에 직접 선언한 것이 아니다. Lombok의 @Slf4j를 클래스에 붙이면 Lombok이 컴파일할 때 log라는 이름의 로그 기록 객체를 자동으로 만들어 준다(이 표준 뒤쪽 예시 코드에도 @Slf4j가 나온다). log.error(...)는 에러 수준으로, log.warn(...)은 경고 수준으로 로그를 남기라는 뜻이고, 상태 코드가 500 이상이면 에러로, 아니면 경고로 남기는 것이 아래 if문이다.
규칙은 하나다 — 4xx는 warn, 5xx는 error에 스택트레이스. 4xx는 클라이언트나 사용자 상황이고, 5xx는 우리가 손을 써야 하는 사건이기 때문이다.
원칙 5. Spring이 던지는 요청 오류도 등록한다
반증: @Valid·JSON파싱 핸들러만 둔 Advice 에서 다섯 요청의 응답 상태 — Boot 3.5.16 타입불일치 400 · 누락 400 · 메서드 405 · 미디어타입 415 · 없는경로 404 · 대조군 @Valid 400 표식있음 / Boot 4.1.1 타입불일치 400 · 누락 400 · 메서드 405 · 미디어타입 415 · 없는경로 404 · 대조군 @Valid 400 표식있음 — 증명
@Valid 실패와 JSON 파싱 실패만 처리하면 나머지는 우리 형식을 벗어나 나간다. 등록하는 이유가 여기 있다.
오래 「500으로 샌다」고 적어뒀는데 그건 틀렸다. 기본 설정 Spring Boot MVC는 타입 불일치·필수 파라미터 누락·405·415·없는 경로를 전부 의미에 맞는 4xx로 내보낸다(두 Boot 버전 동일). 새는 것은 상태 코드가 아니라 응답 형식이다.
Advice 를 탄 것 {"success":false,"status":400,"code":...,"errors":[...]}
등록 안 한 것 {"timestamp":"...","status":400,"error":"Bad Request","path":"/probe"}
같은 400이 두 모양으로 나간다. 클라이언트는 어느 쪽이 올지 모른 채 둘 다 다뤄야 한다. 그래서 등록한다.
@ExceptionHandler({
MethodArgumentTypeMismatchException.class, // /orders/abc (Long 자리에 문자)
MissingServletRequestParameterException.class // 필수 쿼리 파라미터 누락
})
public ResponseEntity<HttpApiResponse<Void>> handleBadRequest(Exception e) {
return toResponse(ErrorCode.INVALID_REQUEST);
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<HttpApiResponse<Void>> handleMethodNotAllowed(Exception e) {
return toResponse(ErrorCode.METHOD_NOT_ALLOWED); // 405
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<HttpApiResponse<Void>> handleUnsupportedMediaType(Exception e) {
return toResponse(ErrorCode.UNSUPPORTED_MEDIA_TYPE); // 415
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<HttpApiResponse<Void>> handleNoHandler(Exception e) {
return toResponse(ErrorCode.NO_HANDLER); // 404
}
한 줄씩 보면, @ExceptionHandler({A.class, B.class})처럼 중괄호 안에 여러 타입을 나열하면 “이 중 아무거나 발생하면 이 메서드가 받는다”는 뜻이다. 타입이 하나뿐이면 중괄호 없이 @ExceptionHandler(A.class)로도 쓴다. 그 아래 세 메서드는 하나씩만 받아 405·415·404를 각각 돌려준다.
이것들을 등록하지 않으면 오타 하나로 잘못된 URL을 호출했을 때 Boot 기본 /error 형식이 나가고, 클라이언트의 오류 처리 경로가 둘로 갈린다.
위 코드의
INVALID_REQUEST·METHOD_NOT_ALLOWED·UNSUPPORTED_MEDIA_TYPE은 아래 원칙 9의 enum에 그 이름으로 없다. 원칙 9가 「식별 가능한 업무 도메인까지 세분화한다」로 가면서ORDER_INVALID_REQUEST처럼 도메인이 앞에 붙었기 때문이다. 이 절의 코드는 그 결정 전의 모양이고, 도메인을 어떻게 알아내는지는 ErrorCode 계약 표준이 URI의 리소스 세그먼트로 정했다.NO_HANDLER만 도메인을 식별할 수 없어 이름이 그대로다.
원칙 6. Checked 예외를 만들지 않는다
우리 코드에서 extends Exception을 만들지 않는다 — 그렇게 만들면 Checked 예외가 된다. 이유가 두 가지인데, 두 번째가 결정적이다.
- 호출부마다
throws나try-catch가 번져서 계층 경계가 지저분해진다 @Transactional은 기본적으로 unchecked 예외만 롤백한다
두 번째가 위험한 이유는 조용히 실패하기 때문이다.
// ❌ checked 예외를 던지면 트랜잭션이 커밋된다
@Transactional
public void createOrder(CreateOrderCommand command) throws PaymentFailedException {
orderRepository.save(order); // ← 이게 저장된 채로
paymentClient.pay(command); // ← 여기서 checked 예외가 나면 롤백되지 않는다
}
주문은 저장됐는데 결제는 안 된 상태가 남는다. 에러도 안 나고 로그도 정상이라, 데이터가 어긋난 걸 한참 뒤에 발견한다. assert 표준에서 경계한 “조용히 넘어가는 코드”의 가장 비싼 형태다.
ErrorCodeException이 RuntimeException을 상속하는 것도 이 때문이다.
원칙 7. Error 계열은 잡지 않는다
반증: Controller 가 던진 AssertionError 가 @ExceptionHandler(Exception.class) 에 닿는가 — Boot 3.5.16 AssertionError 500 표식있음 · RuntimeException 500 표식있음 / Boot 4.1.1 AssertionError 500 표식있음 · RuntimeException 500 표식있음 — 증명
Error 계열인 OutOfMemoryError, StackOverflowError는 잡아도 복구할 수 없다. 오히려 잡으면 망가진 상태로 계속 돌면서 더 이상한 증상을 만든다. 그대로 죽고 재시작되는 편이 낫다.
유일한 예외가 assert 표준에서 정한 AssertionError다. 이건 복구하려고 잡는 것이 아니라 다른 500과 구분하려고 잡는다. 잡아서 ASSERTION_FAILED로 500을 내보내고 로그와 알림에 표시할 뿐, 처리를 계속하지는 않는다.
처음에는 「형식을 지키려고 잡는다」고 적었는데 전제가 틀렸다. 핸들러가 던진 Error는 ServletException으로 감싸져 catch-all에 이미 잡힌다(증명). 안 잡으려면 오히려 다시 던져야 하는데, 그러면 알림이 붙어 있는 자리를 지나치지 않아 아무도 모르게 된다(실패 알림 표준 원칙 2). 그래서 잡되 가른다.
OutOfMemoryError처럼 진짜로 안 잡아야 하는 것들이 실제로 안 잡히는지는 재지 않았다. 감싸지는 경로가 같다면 그것들도 catch-all에 걸릴 수 있다.
원칙 8. 벌크 경로는 예외를 번역하지 않는다
반증: skip 된 예외가 afterStep 의 getFailureExceptions 에 들어오는가 — Boot 3.5.16: skipCount=1(process 1, SkipListener 1:IllegalStateException) 인데 getFailureExceptions=0건(none) · 스텝 COMPLETED/COMPLETED — 증명
여기까지의 원칙은 전부 웹 요청을 전제로 한다. 무슨 일이 있어도 예외가 Advice까지 올라온다는 전제다. 그런데 Repository 설계 표준에서 정한 벌크 경로에는 그 끝이 없다.
벌크 경로란 Domain 객체를 하나씩 만들지 않고 SQL 한 번으로 대량의 행을 한꺼번에 바꾸는 경로다. 이런 대량 작업을 Job·Step 단위로 안전하게 돌리는 틀이 Spring Batch다.
DispatcherServlet을 타지 않으니 @RestControllerAdvice가 없다. Repository 구현체도 없어서 원칙 2(“영속성 예외는 Repository 구현체에서 번역한다”)를 적용할 자리조차 없다. XxxBulkMapper를 부르는 건 ItemWriter다.
그래서 처음엔 ItemWriter가 번역하면 되겠다고 생각했는데, 두 군데서 막혔다.
하나, ErrorCode는 HTTP 상태를 앞자리로 들고 있다. 코드 자체가 상태 + 도메인 + 일련번호로 조립되므로 상태 없이는 코드를 만들 수조차 없다. 벌크 실패에 409나 404를 붙이면 아무 뜻도 없는 값을 억지로 채우게 된다.
둘, 이게 결정적이다 — Spring Batch는 예외 타입으로 분기한다.
.faultTolerant()
.skip(DataIntegrityViolationException.class).skipLimit(100) // 이 건만 건너뛴다
.retry(DeadlockLoserDataAccessException.class).retryLimit(3) // 이건 재시도
한 줄씩 보면, faultTolerant()는 “일부 실패를 봐주겠다”고 Step에 선언하는 것이다. .skip(A.class).skipLimit(100)은 “A 타입 예외가 나면 그 건은 건너뛰고 계속 진행하되, 최대 100건까지만 봐준다”는 뜻이고, .retry(B.class).retryLimit(3)은 “B 타입이면 최대 3번 다시 시도한다”는 뜻이다. 둘 다 예외 타입으로 무엇을 할지 정하기 때문에, 모든 실패가 같은 타입(ErrorCodeException)이 되면 이 설정 자체를 쓸 수 없다.
예외 처리 표준은 “구체 예외 클래스를 만들지 않고 ErrorCodeException 하나로 두고 구분은 ErrorCode로 한다”고 정했다. 웹에서는 맞다 — Advice가 어차피 한 곳에서 다 받으니 타입으로 구분할 이유가 없다. 그런데 배치에서 모든 실패를 ErrorCodeException으로 뭉치면 ErrorCode로는 구분되는데 프레임워크는 구분할 수 없다. skip/retry 정책을 세울 방법이 사라진다.
원칙 6에서 checked 예외를 금지한 이유와 정확히 같은 결이다 — 프레임워크가 타입으로 동작하는 지점에서는 타입을 뭉개면 안 된다.
그래서 벌크는 번역하지 않는다.
// order/bulk — try-catch가 없다. 원본 타입을 그대로 올린다
class OrderBulkStatusWriter implements ItemWriter<OrderBulkMapperParam> {
private final OrderBulkMapper bulkMapper;
@Override
public void write(Chunk<? extends OrderBulkMapperParam> chunk) {
bulkMapper.bulkUpdateStatus(chunk.getItems());
}
}
한 줄씩 보면, ItemWriter는 Spring Batch가 읽어 둔 데이터를 실제로 내보내는(여기서는 DB에 쓰는) 역할을 맡는 인터페이스다. Chunk는 Spring Batch가 한 번에 묶어 처리하는 데이터 묶음이고, <? extends OrderBulkMapperParam>은 “그 안의 데이터는 OrderBulkMapperParam이거나 그걸 상속한 타입”이라는 뜻이다. write 메서드는 Spring Batch가 묶음 하나를 다 읽을 때마다 자동으로 불러 주고, chunk.getItems()는 그 묶음 안의 데이터를 목록으로 꺼낸다. 이 메서드 안에 try-catch가 없다는 것이 이 코드의 핵심이다 — 예외가 나면 번역하지 않고 그대로 위로 올라간다.
로그는 여전히 한 곳에서만 남긴다. 그 한 곳이 Advice가 아니라 리스너다. 단 리스너 하나로는 모자란다.
getFailureExceptions는 이름 그대로 스텝을 실패시킨 예외를 담는다. skip은 「실패시키지 않기로 한 예외」라 거기 들어가지 않는다 — 실측에서 skip 1건인데 목록은 0건이었고, 스텝도 잡도 COMPLETED로 끝났다. 그대로 두면 건너뛴 실패가 로그에 한 줄도 안 남는다.
// skip 된 것은 SkipListener 가 받는다. 이게 없으면 조용히 사라진다
class BatchSkipLogger implements SkipListener<Object, Object> {
@Override
public void onSkipInProcess(Object item, Throwable t) {
log.error("skip item={}", item, t);
}
}
한 줄씩 보면, SkipListener<Object, Object>의 제네릭 <Object, Object>는 “건너뛴 데이터가 어떤 타입이든 상관없이 받는다”는 뜻이다. @Override는 인터페이스가 정해 둔 메서드를 그대로 구현한다는 표시이고, onSkipInProcess는 처리 중 건너뛴 항목마다 Spring Batch가 자동으로 불러 준다.
@Slf4j
class BatchFailureLogger implements StepExecutionListener {
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
stepExecution.getFailureExceptions()
.forEach(e -> log.error("step={} 실패", stepExecution.getStepName(), e));
return stepExecution.getExitStatus();
}
}
한 줄씩 보면, @Slf4j는 앞서와 같이 log 객체를 자동으로 만들어 준다. StepExecutionListener는 Step 하나가 끝난 뒤에 Spring Batch가 자동으로 불러 주는 콜백 인터페이스이고, afterStep이 그 콜백 메서드다. StepExecution은 방금 끝난 Step의 실행 정보(이름, 실패 목록, 처리 건수 등)를 담은 객체이고, getFailureExceptions()로 그 Step을 실패시킨 예외 목록을 꺼낸다. ExitStatus는 이 Step이 어떤 상태(성공·실패 등)로 끝났는지를 나타내는 값이고, afterStep이 받은 그대로 돌려주면 Spring Batch가 이미 정한 상태가 바뀌지 않는다.
원칙은 “로그는 경계에서 한 번”이고, 그 경계가 웹은 Advice, 벌크는 리스너다. 예외 처리 표준의 “@RestControllerAdvice 한 곳에서만”은 웹 경로에 한정된 표현이었고, 그 글도 함께 고쳤다.
대가. “실패 정보는 ErrorCode 한 곳에 모은다”는 원칙에서 벌크가 빠진다. 벌크 실패는 코드 번호로 검색할 수 없고, 예외 타입과 Spring Batch의 BATCH_STEP_EXECUTION.EXIT_MESSAGE로 추적해야 한다. 대신 프레임워크의 skip·retry·재시작을 그대로 쓸 수 있다. 배치에서는 후자가 더 중요하다고 판단했다 — 5만 건 중 3건이 제약 위반일 때 나머지를 살리는 게 코드 번호로 검색하는 것보다 값지다.
원칙 9. ErrorCode는 식별 가능한 업무 도메인까지 세분화한다
지금까지 나온 실패를 9자리 코드 체계에 넣으면 이렇게 된다.
@Getter
public enum ErrorCode {
// /orders 요청에서 도메인을 식별할 수 있는 프레임워크 실패
ORDER_INVALID_REQUEST(400, DomainCode.ORDER, 1, "주문 요청 값이 유효하지 않습니다."),
ORDER_MALFORMED_REQUEST_BODY(400, DomainCode.ORDER, 2, "주문 요청 본문을 해석할 수 없습니다."),
ORDER_INVALID_PARAMETER(400, DomainCode.ORDER, 3, "주문 요청 파라미터가 올바르지 않습니다."),
ORDER_UNAUTHENTICATED(401, DomainCode.ORDER, 1, "주문 요청에 인증이 필요합니다."),
ORDER_ACCESS_DENIED(403, DomainCode.ORDER, 1, "주문 접근 권한이 없습니다."),
// 어떤 도메인의 경로인지조차 식별할 수 없음
NO_HANDLER(404, DomainCode.COMMON, 1, "요청한 경로를 찾을 수 없습니다."),
ORDER_NOT_FOUND(404, DomainCode.ORDER, 1, "존재하지 않는 주문입니다."),
PRODUCT_NOT_FOUND(404, DomainCode.PRODUCT, 1, "등록되지 않은 상품입니다."),
ORDER_METHOD_NOT_ALLOWED(405, DomainCode.ORDER, 1, "지원하지 않는 주문 요청 방식입니다."),
ORDER_UNSUPPORTED_MEDIA_TYPE(415, DomainCode.ORDER, 1, "지원하지 않는 주문 요청 형식입니다."),
STOCK_SHORTAGE(409, DomainCode.ORDER, 1, "재고가 부족합니다."),
ORDER_ALREADY_CONFIRMED(409, DomainCode.ORDER, 2, "이미 확정된 주문입니다."),
DUPLICATE_EMAIL(409, DomainCode.USER, 1, "이미 가입된 이메일입니다."),
USER_CONCURRENT_UPDATE(409, DomainCode.USER, 2, "다른 사용자가 먼저 회원 정보를 수정했습니다."),
// 처리 대상 도메인을 끝내 식별할 수 없는 최후의 실패
INTERNAL_ERROR(500, DomainCode.COMMON, 1, "서버 내부 오류가 발생했습니다."),
ORDER_PAYMENT_API_ERROR(502, DomainCode.ORDER, 1, "결제 서비스 응답이 올바르지 않습니다."),
ORDER_PAYMENT_UNAVAILABLE(503, DomainCode.ORDER, 1, "결제 서비스를 일시적으로 사용할 수 없습니다."),
ORDER_QUERY_TIMEOUT(503, DomainCode.ORDER, 2, "주문 조회가 지연되고 있습니다."),
ORDER_PAYMENT_TIMEOUT(504, DomainCode.ORDER, 1, "결제 서비스 응답이 지연되고 있습니다.");
}
한 줄씩 보면, 여기 쓰인 enum은 미리 정해 둔 상수 목록에 괄호로 추가 값을 실어 보내는 자바 문법이다. ORDER_INVALID_REQUEST(400, DomainCode.ORDER, 1, "...")처럼 괄호 안의 값들이 그 상수를 만들 때 생성자로 전달되어, 상태 코드·도메인·일련번호·메시지를 상수 하나하나가 들고 다니게 된다.
첫 인자가 상태이므로 enum을 상태 코드 순으로 정렬하면 자연스럽게 종류별로 묶인다.
정해야 했던 건 이거였다 — 프레임워크와 기술이 만든 실패도 처리 대상 도메인을 코드에 남길 것인가.
안 A — 발생 기술로 가른다. Spring MVC·Security·JPA가 만든 실패는 COMMON, Domain이 직접 만든 실패만 도메인 코드로 둔다. 얻는 것은 중앙 Advice와 Security 핸들러가 하나의 공통 코드만 반환하면 된다는 단순함이다. 버린 이유는 /orders의 잘못된 JSON과 /users의 잘못된 JSON이 같은 코드가 되고, 주문 저장 충돌과 회원 저장 충돌도 CONCURRENT_UPDATE 하나로 뭉쳐 코드만 보고 실패한 업무 영역을 좁힐 수 없기 때문이다.
안 B — 클라이언트의 복구 행동으로 가른다. 다시 조회처럼 같은 행동으로 복구하면 COMMON, 재결제처럼 행동이 달라질 때만 도메인 코드로 둔다. 얻는 것은 클라이언트 분기 수가 줄어드는 것이다. 버린 이유는 두 번째 사용처가 생기는 순간 기존 도메인 코드를 COMMON으로 옮겨야 하고, 그러면 바뀌지 않아야 할 공개 숫자 코드가 사용처 수에 따라 흔들리기 때문이다.
안 C — 식별 가능한 업무 도메인으로 가른다. 예외를 만든 기술이나 복구 행동과 상관없이 요청 경로·처리 대상·실행 중인 유스케이스에서 도메인을 식별할 수 있으면 그 도메인 코드를 쓴다. /orders의 JSON 파싱 실패는 ORDER_MALFORMED_REQUEST_BODY, 주문 저장 충돌은 ORDER_CONCURRENT_UPDATE, 주문 결제 타임아웃은 ORDER_PAYMENT_TIMEOUT이다. COMMON(0)은 잘못된 미등록 경로처럼 도메인을 전혀 식별할 수 없거나, 처리 대상을 끝내 특정하지 못한 최후의 실패에만 쓴다.
골랐다 — 안 C. 안 A와 안 B가 지워버리는 업무 맥락을 9자리 코드의 도메인 칸에 남기고, COMMON은 유지하되 예외적인 대역으로 좁힌다. 대신 같은 기술 예외라도 도메인마다 코드가 늘어나고, 중앙 Advice·Security 핸들러·공용 외부 연동 클라이언트가 현재 도메인을 어떻게 알아낼지 별도 설계가 필요하다. 이미 공개한 COMMON 코드를 도메인 코드로 바꾸는 마이그레이션도 코드 수명주기 규칙이 정해지기 전에는 자동으로 처리하지 않는다.
이 문단이 미룬 것은 ErrorCode 계약 표준이 갚았다. 도메인 판별은 URI의 리소스 세그먼트로, 공용 클라이언트는 도메인마다의 얇은 어댑터로, 수명주기는
@Deprecated로 남기고 번호를 재사용하지 않는 것으로 정했다. 그 글을 쓰면서 여기 적힌ORDER_METHOD_NOT_ALLOWED(405, ...)가 판별 방식을 하나 지웠다 — 405는 핸들러가 정해지기 전에 터져서HandlerMethod(스프링이 요청을 처리할 Controller 메서드를 가리키는 객체)로는 도메인을 알 수 없다.
판단 기준 정리
| 실패 종류 | 어디서 잡는가 | 상태 | 로그 |
|---|---|---|---|
요청 형식 (@Valid, JSON) |
@RestControllerAdvice |
400 | warn |
| 파라미터 타입·누락 | @RestControllerAdvice |
400 | warn |
| 인증 실패 (필터) | AuthenticationEntryPoint |
401 | warn |
| 인가 실패 (필터) | AccessDeniedHandler |
403 | warn |
인가 실패 (@PreAuthorize) |
@RestControllerAdvice |
403 | warn |
| 경로 없음 / 405 / 415 | @RestControllerAdvice |
404·405·415 | warn |
| 비즈니스 규칙 위반 | Service·Domain에서 던짐 | 4xx | warn |
| DB 제약 위반 | Repository 구현체에서 번역. 수정 경로는 try 안에서 flush() |
409 | warn |
| 낙관락 충돌 | Repository 구현체에서 번역. 수정 경로는 try 안에서 flush(), 격리 수준은 READ COMMITTED에서 올리지 않는다 |
409 | warn |
화면을 연 뒤 남이 저장함 (돌려받은 version ≠ 영속 Entity의 version) |
Repository 구현체가 applyTo 전에 비교해 던진다. 낙관락 충돌과 같은 ErrorCode, 412는 쓰지 않는다 |
409 | warn |
| DB 쿼리 타임아웃 | Repository 구현체에서 번역 | 503 | error |
| 외부 API 실패 | 연동 클라이언트에서 번역 | 502·503·504 | error |
| 우리 코드 버그 | @RestControllerAdvice |
500 | error + 스택 |
| 도달 불가 분기 | AssertionError → ASSERTION_FAILED |
500 | error + 스택 + 알림에서 구분 |
OutOfMemoryError 등 |
잡지 않는다 | — | — |
| 벌크 경로의 모든 실패 | 번역하지 않는다 — 원본 타입을 올린다 | 없음 (HTTP가 아니다) | StepExecutionListener에서 error + 스택 |
이 표준을 정하기까지
출발점은 “우리가 던지는 예외만 다뤘다”는 자각이었다. 앞선 표준은 ErrorCodeException 하나를 잘 설계하는 데 집중했는데, 실제 서비스에서 나오는 예외는 대부분 우리가 던지지 않은 것들이다. 그것들을 늘어놓고 보니 처리 지점이 제각각이었다.
가장 놀란 건 인증 실패였다. Security 필터가 DispatcherServlet보다 앞에서 돈다는 건 알고 있었지만, 그게 @RestControllerAdvice를 우회한다는 것까지는 연결하지 못했다. 공들여 만든 HttpApiResponse 형식이 401에서만 깨지고 있었던 셈이다. AuthenticationEntryPoint와 AccessDeniedHandler를 등록해 같은 형식으로 맞췄다.
DB 제약 위반은 500으로 나가고 있었다. 중복 가입 시도는 명백히 사용자 상황인데 “서버 내부 오류”로 응답하고 있었던 것이다. 번역 위치는 고민할 것도 없이 Repository 구현체였다 — Entity가 패키지 밖으로 안 나가듯 DataIntegrityViolationException도 Service로 새면 안 되기 때문이다. 다만 제약이 여러 개일 때 구분이 안 된다는 한계는 인정하고, 사전 조회로 구체적인 실패를 알려주고 제약 위반은 동시성 최후 방어선으로 쓰기로 했다.
외부 연동에서 로그 규칙이 어긋났다. 결제사 장애를 ErrorCodeException으로 던지면 앞선 표준상 warn이 되는데, 그러면 아무도 안 본다. 예외 클래스를 하나 더 만들까 하다가, ErrorCode에 이미 상태가 들어 있으니 그걸로 판단하면 된다는 걸 깨달았다. 4xx는 warn, 5xx는 error로 바꾸니 예외 클래스는 그대로 하나로 유지되고 로그도 맞았다.
수정 경로의 번역은 나중에 실측으로 구멍이 드러났다(2026-09-16). 처음 실험은 Service에서 잡아 보는 모양이라 「번역 없이도 잡힌다, 다만 트랜잭션은 못 살린다」가 나왔다. 조사해 보니 더 큰 문제가 그 뒤에 있었다 — 수정 경로는 커밋 때 UPDATE가 나가서 구현체의 catch에 아예 닿지 않는다. 표준 모양 구현체로 다시 재자 그대로였다. 번역 자리를 Advice로 옮기는 안도 봤지만 자리가 두 곳이 되어 버렸고, 구현체 안에서 flush()까지 부르기로 했다(원칙 2-2). 낙관락은 MariaDB 설정 때문에 그걸로도 잡히지 않아 따로 열었다.
낙관락은 설정을 끄면 잡힌다는 것을 먼저 재고 나서 정했다(2026-09-18 실측, 2026-10-20 결정). 처음 이슈(#169)에는 「끄면 원래 예외가 나오는지 — 재지 않았다」가 남아 있어서, MariaDB 11.8에 처음부터 켜져 있는 innodb_snapshot_isolation 설정을 끄는 안과 catch 타입을 바꾸는 안을 같은 무게로 놓고 있었다. 실측이 둘을 갈랐다. 끄면 예시 코드가 그대로 동작했고, 타입을 바꾸는 안은 Boot 버전마다 계보가 달라 DataAccessException까지 넓혀야 했다. 결정 직전에 「표준을 실제로 적용하는 프로젝트가 MariaDB가 맞는가」를 확인했는데 PostgreSQL이었다. 그래도 이 안건은 그대로 정했다 — #169가 다루던 것은 표준 글 둘 사이의 모순, 곧 DB를 MariaDB 11.8로 고정한 DB 시간 저장 표준과 그 DB에서 걸리지 않는 이 글의 catch 사이였기 때문이다. 이번에 확인하다 드러난 것은 그와 다른 어긋남, 표준과 실제 프로젝트 사이의 어긋남이라 따로 열었다(#185). 끄는 대가를 따지다 빈칸이 하나 더 드러났다. @Version을 어디 둘지는 정해 두었지만 둬야 하는지는 아무 데도 없었다. 그동안은 DB 설정이 뒤에서 막아 주고 있어서 드러나지 않았던 것이다. 그래서 Domain 영속화 표준에 원칙 5를 더했다. 읽지 않은 행을 고치는 경우는 「보고 고치느냐, 안 보고 규칙대로 고치느냐」로 갈라 보니 일반 경로에는 아예 없고, 벌크에는 지킬 판단이 없어서 새 규칙 없이 닫았다.
MariaDB를 쓰던 때의 원칙 2-3은 「MariaDB의 스냅숏 격리는 커넥션에서 끈다」였다. 그때 받친 실측이 이것이다.
실측(supported): innodb_snapshot_isolation 을 끄면 표준 모양 구현체의 catch(OptimisticLockingFailureException) 가 수정 경로 낙관락 충돌을 번역하는가 — Boot 3.5.16 iso=1 (가)flush·낙관락 catch=none/밖으로=CannotAcquireLockException<-SQLException/sql=1020/HY000/번역=no/롤백=yes (가)noflush·낙관락 catch=none/밖으로=CannotAcquireLockException<-SQLException/sql=1020/HY000/번역=no/롤백=yes (마)version없음 catch=none/밖으로=CannotAcquireLockException<-SQLException/sql=1020/HY000/번역=no/롤백=yes (바)안읽은행 catch=none/밖으로=CannotAcquireLockException<-SQLException/sql=1020/HY000/번역=no/롤백=yes (사)낡은version catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes · Boot 4.1.1 iso=1 (가)flush·낙관락 catch=none/밖으로=JpaSystemException<-SQLException/sql=1020/HY000/번역=no/롤백=yes (가)noflush·낙관락 catch=none/밖으로=JpaSystemException<-SQLException/sql=1020/HY000/번역=no/롤백=yes (마)version없음 catch=none/밖으로=JpaSystemException<-SQLException/sql=1020/HY000/번역=no/롤백=yes (바)안읽은행 catch=none/밖으로=JpaSystemException<-SQLException/sql=1020/HY000/번역=no/롤백=yes (사)낡은version catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes · Boot 3.5.16 iso=0s (가)flush·낙관락 catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes (가)noflush·낙관락 catch=none/밖으로=ObjectOptimisticLockingFailureException<-StaleObjectStateException/sql=-/번역=no/롤백=yes (마)version없음 catch=none/밖으로=none/sql=-/번역=no/롤백=no (바)안읽은행 catch=none/밖으로=none/sql=-/번역=no/롤백=no (사)낡은version catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes · Boot 4.1.1 iso=0s (가)flush·낙관락 catch=OLFE/밖으로=ErrorCodeException<-StaleStateException/sql=-/번역=yes/롤백=yes (가)noflush·낙관락 catch=none/밖으로=ObjectOptimisticLockingFailureException<-StaleStateException/sql=-/번역=no/롤백=yes (마)version없음 catch=none/밖으로=none/sql=-/번역=no/롤백=no (바)안읽은행 catch=none/밖으로=none/sql=-/번역=no/롤백=no (사)낡은version catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes · Boot 3.5.16 iso=0 (가)flush·낙관락 catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes (가)noflush·낙관락 catch=none/밖으로=ObjectOptimisticLockingFailureException<-StaleObjectStateException/sql=-/번역=no/롤백=yes (마)version없음 catch=none/밖으로=none/sql=-/번역=no/롤백=no (바)안읽은행 catch=none/밖으로=none/sql=-/번역=no/롤백=no (사)낡은version catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes · Boot 4.1.1 iso=0 (가)flush·낙관락 catch=OLFE/밖으로=ErrorCodeException<-StaleStateException/sql=-/번역=yes/롤백=yes (가)noflush·낙관락 catch=none/밖으로=ObjectOptimisticLockingFailureException<-StaleStateException/sql=-/번역=no/롤백=yes (마)version없음 catch=none/밖으로=none/sql=-/번역=no/롤백=no (바)안읽은행 catch=none/밖으로=none/sql=-/번역=no/롤백=no (사)낡은version catch=OLFE/밖으로=ErrorCodeException<-StaleObjectStateException/sql=-/번역=yes/롤백=yes — 증명
인용줄의 iso=1은 스냅숏 격리를 켠 상태(MariaDB 11.8 기본값), iso=0은 DB 서버에서 끈 상태, iso=0s는 우리 프로그램의 접속에서만 끈 상태다. MariaDB 11.8에는 innodb_snapshot_isolation이 처음부터 켜져 있어서, 내 스냅숏 이후에 남이 고치고 커밋한 행을 고치려 하면 DB가 오류 1020(「Record has changed since last read」)으로 거절했다. 켠 상태에서는 (가)flush가 Boot 3은 CannotAcquireLockException, Boot 4는 JpaSystemException으로 와서 catch를 지나쳤고, 끈 상태에서는 두 버전 모두 catch=OLFE였다. 그래서 「끈다」(MariaDB 때의 안 A)를 골랐다.
그때 버린 안은 「켠 채로 catch가 기다리는 타입을 바꾼다」(MariaDB 때의 안 B)였다. catch (CannotAcquireLockException | JpaSystemException e)로 두 Boot 버전에서 오는 타입을 함께 잡는 모양이다. 얻는 것은 DB의 보호를 그대로 둔다는 점이었다 — 켠 상태에서는 version 칸이 없는 표((마))와 읽지 않은 행((바))도 1020으로 막혔다. 버린 이유는 둘이었다. 두 타입 모두 낙관락 전용이 아니다. CannotAcquireLockException은 잠금 대기 시간이 다 된 경우에도 온다(Spring의 spring-jdbc의 jdbc/support/sql-error-codes.xml:208-210은 MySQL 계열 오류 1205 「Lock wait timeout」을 이 타입의 번호 목록에 둔다). JpaSystemException은 Spring이 맞는 이름을 찾지 못한 JPA 오류에 마지막으로 붙이는 이름이다(증명 글의 「소스로 따라가면 3」). 둘을 묶을 공통 부모는 더 넓은 DataAccessException뿐이라 좁히는 길도 없었다. 그리고 걸러 내려면 오류 번호를 봐야 한다. 잠금 대기 초과와 1020을 가르는 것은 예외 속 MariaDB 오류 번호이고, 그것은 원칙 2의 「한계는 인정하고 간다」에서 DB 벤더에 종속되므로 쓰지 않기로 한 방법이다.
어디서 끌지는 접속을 골랐다. 서버 설정 파일에서 끄는 안(A-1)과 우리 프로그램의 접속에서만 끄는 안(A-2, 접속 주소 끝에 sessionVariables=innodb_snapshot_isolation=0)을 놓고, 실측에서 둘의 결과가 같았으므로 설정이 코드 저장소 안에 남는 A-2를 골랐다. 서버 설정은 저장소 밖이라 꺼져 있는지 코드만 보고는 알 수 없고, 서버 설정을 바꿀 권한이 없는 프로젝트는 A-1을 쓸 수 없다.
같은 날 표준 DB를 PostgreSQL로 바꾸면서 원칙 2-3을 다시 썼다(2026-10-20). 앞의 「낙관락은 설정을 끄면 잡힌다는 것을…」 문단에서 따로 연 어긋남(#185, 표준은 MariaDB로 고정했는데 적용 프로젝트는 PostgreSQL이다)이 걸린 자리다. 표준 DB 자체를 PostgreSQL로 바꾸기로 했다. 그때까지 돈 실험 21개 중 6개가 이미 PostgreSQL에서 돌았고(MariaDB에서 돈 것은 3개, 나머지 12개는 DB와 상관없는 실험이다), 그것을 다시 돌리지 않고 그대로 쓸 수 있었기 때문이다. MariaDB 전용 규칙은 「MariaDB라면」 갈래로 남기지 않고 PostgreSQL 것으로 바꿨다. 갈래를 남기면 규칙마다 두 벌이 붙어 섞이기 때문이다. MariaDB 실험은 지우지 않고 위처럼 경위로 옮겼다. PostgreSQL에는 끌 설정이 없었다. 이 결정 뒤에 원칙 2-3을 받치려고 실험 하나를 따로 돌렸다. 기본값 READ COMMITTED 그대로 (가)flush가 catch=OLFE였다. 대신 격리 수준을 REPEATABLE READ로 올리자 대조군 (사)를 뺀 모든 경우가 SQLState 40001로 먼저 거절되어 CannotAcquireLockException으로 왔다 — MariaDB의 스냅숏 격리를 켰을 때와 같은 자리에서 막힌 것이다. 그래서 원칙이 「끈다」에서 「올리지 않는다」로 바뀌었다. 처음에는 원칙 2-3을 지우고 격리 수준은 열린 안건 #126에 넘기는 안(원칙 2-3의 안 A)도 봤다. 기본값이 이미 맞으니 적을 것이 없어 보였다. 하지만 누가 격리 수준을 올리면 409 catch가 조용히 죽는데 그것을 막는 줄이 없어서, 「올리지 않는다」를 규칙으로 남겼다. 이것으로 #126 중 「격리 수준을 무엇으로 두나」에는 답이 났고, 「더 높은 격리가 필요한 기능은 어떻게 하나」는 남았다. MariaDB 때의 안 B를 버린 이유는 PostgreSQL에서 일부만 선다. 「Boot 버전마다 오는 타입이 달라 DataAccessException까지 넓혀야 한다」는 서지 않는다 — PostgreSQL 실측에서는 두 버전 모두 CannotAcquireLockException 하나로 왔다. 서는 것은 「낙관락 전용이 아니다」이고, 「가르려면 오류 번호를 봐야 한다」는 모양을 바꿔 선다. 올린 채 CannotAcquireLockException을 잡으면, Hibernate가 PostgreSQL의 교착 상태(40P01)도 같은 타입으로 만든다(hibernate-core의 dialect/PostgreSQLDialect.java:1097-1099, 실측은 하지 않았다). 40001은 표준 번호라 꺼내 볼 수는 있지만, 그러려면 예외 타입으로 가르는 원칙 2의 번역을 벗어나 예외 사슬을 열어야 하고, 40P01은 PostgreSQL 고유 번호라 그 판별은 DB에 묶인다(원칙 2-3). @Version 의무의 이유도 바뀌었다. MariaDB에서는 스냅숏 격리를 끄는 대가였지만, PostgreSQL READ COMMITTED에서는 처음부터 DB가 막아 주지 않아 version 없는 표의 동시 수정이 조용히 덮어써졌다(PostgreSQL 실험의 (마)). 규칙은 그대로 두고 이유만 고쳤다.
Checked 예외를 왜 만들지 않는지도 여기서 분명해졌다. 막연히 “불편해서”라고 생각했는데, 진짜 이유는 @Transactional이 unchecked만 롤백한다는 것이었다. checked 예외를 던지면 주문은 저장되고 결제는 실패한 상태가 조용히 커밋된다. 에러도 안 나고 로그도 정상이라 한참 뒤에 발견된다. assert 표준에서 경계한 “조용히 넘어가는 코드”의 가장 비싼 형태다.
마지막으로 잡지 말아야 할 것을 정했다. Error 계열은 복구가 불가능하므로 잡지 않는다. AssertionError만 예외로 뒀고, 그 이유를 처음에는 「응답 형식을 지키려고」로 적었다.
그 이유가 나중에 실측으로 뒤집혔다. 핸들러가 던진 AssertionError는 감싸져서 catch-all에 이미 잡히고 있었다. 형식은 처음부터 안 깨졌다. 그러면 안 잡는 쪽으로 가야 하나 싶었는데, 알림이 그 catch-all에 붙어 있어서 안 잡으면 아무도 모르게 된다. 그래서 이유를 「구분하려고 잡는다」로 바꿨다 — 전용 ASSERTION_FAILED 코드를 주고 알림과 테스트가 그걸 본다.
벌크 경로는 Repository 설계 표준을 정한 뒤에 덧붙였다. 이 글의 원칙이 전부 “Advice까지 올라온다”는 전제 위에 있었는데, 벌크에는 그 끝이 없다는 걸 뒤늦게 알아차렸다. 처음엔 ItemWriter에서 ErrorCodeException으로 번역하려 했다. 그런데 Spring Batch의 skip/retry가 예외 타입으로 분기한다는 게 걸렸다 — 전부 ErrorCodeException으로 뭉치면 ErrorCode로는 구분되는데 프레임워크는 구분할 수 없다. 원칙 6에서 checked 예외를 금지한 이유와 같은 종류의 제약이었다. 그래서 배치는 번역하지 않고 원본 타입을 올리기로 했다. 대신 “로그는 한 곳에서”라는 원칙은 살렸고, 그 한 곳이 경로마다 다르다는 것으로 정리됐다 — 웹은 Advice, 배치는 StepExecutionListener.
DB 쿼리 타임아웃은 한참 뒤에 스키마 마이그레이션 표준이 슬로우 쿼리 기준을 정하면서 붙었다. 그 표준이 “300ms를 넘기면 끊는다”까지는 정했는데, 끊긴 뒤 무엇으로 응답하는지는 이 표준의 몫으로 남아 있었다. 외부 연동의 502·503·504 삼분법을 그대로 가져오려다 멈췄다 — DB 타임아웃에는 “상대”가 없다. 원인이 하나뿐인데 상태를 셋으로 나누면 안 쓰는 자리가 생긴다. 그래서 503 하나로 좁혔다.
마지막에는 COMMON을 기본값으로 두었던 결정을 뒤집었다. 처음에는 프레임워크·영속성·외부 연동 실패를 전부 COMMON에 넣었다. 하지만 코드가 실패 종류만 말하고 어느 업무에서 실패했는지는 지우고 있었다. 발생 기술과 클라이언트 행동을 기준으로 삼는 안도 검토했지만, 구현 교체나 사용처 증가에 따라 공개 코드가 흔들렸다. 그래서 식별 가능한 업무 도메인을 가장 먼저 코드에 남기고, COMMON은 도메인을 끝내 식별할 수 없을 때만 쓰는 예외 대역으로 좁혔다.
낙관락 409가 덮는 범위는 나중에 넓혔다(2026-10-21). 수정 충돌을 If-Match로 막을지 묻는 안건(#123)을 따지다가, OptimisticLockingFailureException이 두 저장이 같은 순간에 겹칠 때만 난다는 것이 드러났다. 화면을 열어 둔 사이의 충돌은 Domain 영속화 표준이 「돌려받은 version을 구현체가 비교한다」로 막기로 했고, 이 글은 그 실패를 원칙 2의 409와 같은 ErrorCode로 내도록 한 줄을 더했다. 412로 내는 안은 그 표준에서 버렸다.
정리
@RestControllerAdvice는 만능이 아니다. 앞뒤로 새는 곳이 있고, 구멍마다 처리 지점을 따로 둔다- 인증·인가 실패는
AuthenticationEntryPoint와AccessDeniedHandler로 처리한다. Security 필터는 Advice보다 앞에서 돈다 - 영속성 예외는 Repository 구현체에서 번역한다.
DataIntegrityViolationException이 Service로 새지 않게 한다 - 수정 경로는 구현체의 try 안에서
flush()까지 부른다. 더티체킹의 UPDATE는 커밋 때 나가 catch에 닿지 않는다 - 트랜잭션 격리 수준은 READ COMMITTED에 두고 올리지 않는다. 올리면 DB가 40001로 먼저 거절해 낙관락
catch에 닿지 않는다. READ COMMITTED에서는version없는 표가 무방비라 수정 경로가 있는 Entity에는@Version을 반드시 둔다 - 제약 위반은 최후 방어선이다. 평소에는 사전 조회로 구체적인
ErrorCode를 준다 - DB 쿼리 타임아웃은 503 하나로 번역한다. 외부 연동과 달리 원인이 하나뿐이라 나눌 축이 없다
- 외부 연동 실패는 502·503·504로 구분한다. 상태 코드만으로 상대 장애임을 알 수 있어야 한다
- 로그 레벨은 예외 클래스가 아니라 상태 코드로 정한다. 4xx는
warn, 5xx는error+ 스택트레이스 - Spring이 던지는 요청 오류도 등록한다. 안 하면 잘못된 URL 하나가 500으로 찍힌다
ErrorCode는 식별 가능한 업무 도메인까지 세분화한다. 프레임워크·영속성·외부 연동 실패도 도메인을 알 수 있으면 도메인 코드를 쓰고,COMMON(0)은 도메인을 식별할 수 없을 때만 쓴다- checked 예외를 만들지 않는다.
@Transactional이 롤백하지 않아 데이터가 조용히 어긋난다 Error계열은 잡지 않는다.AssertionError만 예외로 두되ASSERTION_FAILED로 갈라 알림과 테스트에 드러낸다- 벌크 경로는 예외를 번역하지 않는다. 원본 타입을 올려 skip/retry가 동작하게 하고, 로그는
StepExecutionListener한 곳에서 남긴다 Domain을 거치는 배치 경로는 반대다. Service가 던진ErrorCodeException이 그대로 올라오므로ErrorCode가 살아 있고, 로그 레벨 규칙도 그대로 적용된다
자신만의 철학을 만들어가는 중입니다.
댓글남기기