Mini Project 5 08

신뢰할 수 있는 백엔드 API: 실패와 무결성의 구조

CRUD가 동작하면 예외 처리와 트랜잭션은 부가 기능처럼 보인다. 하지만 실제 API에서는 실패도 frontend가 읽을 수 있어야 한다.

근거 · 교안 예외·트랜잭션 파트

신뢰할 수 있는 백엔드 API: 실패와 무결성의 구조 대표 이미지

1장: CRUD, 그 너머의 API: 실패를 소통하는 법

솔라는 노트북 화면에 떠 있는 코드 몇 줄을 보며 만족스러운 미소를 지었다. 책 정보를 만들고(Create), 읽고(Read), 수정하고(Update), 삭제하는(Delete) 기능, 이른바 CRUD가 완벽하게 돌아가고 있었다. 이 정도면 백엔드 API의 기본은 다 갖춘 셈이다. 이제 프론트엔드 화면을 붙여서 근사하게 보여줄 일만 남았다.

“언니, 나 책 관리 API 기본 CRUD 다 만들었어. 이제 슬슬 프론트엔드 작업 시작해도 되겠지?”

거실 소파에서 책을 읽던 루나가 솔라 쪽으로 고개를 돌렸다.

“벌써? 테스트는 다 해봤고?”

“응. 책 추가하고, 목록 불러오고, 정보 바꾸고, 삭제하는 거 전부 다 잘 돼.”

솔라는 자신만만하게 대답했다. 마치 잘 구워낸 빵을 자랑하는 제빵사 같았다. 루나는 잠시 생각하더니, 읽던 책을 무릎에 내려놓고 솔라에게 물었다.

“좋아. 그럼 상상해 보자. 내가 지금 솔라가 만든 앱을 쓰고 있어. 실수로 목록에 없는 책을 지우려고 ‘삭제’ 버튼을 눌렀어. 그럼 내 화면엔 뭐가 보일까?”

“음…”

솔라는 잠시 머뭇거렸다. 예상치 못한 질문이었다. 성공하는 시나리오만 생각했지, 실패하는 경우는 깊게 고민해 본 적이 없었다.

“일단… 아무 일도 일어나지 않으려나? 어차피 없는 책이니까. 아니, 잠깐만. 백엔드에서 어떤 응답을 주느냐에 따라 다르겠구나. 내 API는 지금… 아마 서버에 에러 로그가 찍히고 그냥 멈춰버릴 것 같은데?”

솔라의 머릿속에 자신의 코드가 스쳐 지나갔다. 존재하지 않는 ID로 책을 삭제하려 하면, 데이터베이스에서 해당 데이터를 찾지 못해 널 포인터 예외(Null Pointer Exception) 같은 것이 발생할 터였다. 그럼 서버는 ‘500 Internal Server Error’라는, 의미를 알 수 없는 응답을 뱉어낼 것이다.

루나는 솔라의 혼란을 지켜보며 말을 이었다.

“프론트엔드 개발자는 그 ‘500 에러’를 받아서 뭘 할 수 있을까? 사용자에게 ‘알 수 없는 오류가 발생했습니다. 관리자에게 문의하세요.’라는 팝업을 보여주는 게 최선일까?”

루나의 차분한 목소리가 솔라의 생각에 파고들었다. 그랬다. 만약 API가 그런 식으로 응답한다면, 프론트엔드 개발자는 속수무책이다. 오류의 원인이 사용자의 잘못된 입력 때문인지, 네트워크 문제인지, 아니면 정말 서버 내부의 심각한 버그인지 전혀 알 수 없다. 사용자는 영문도 모른 채 앱이 고장 났다고 생각할 것이고, 문제를 해결해야 하는 개발자는 백엔드 서버의 로그 파일을 뒤져봐야만 원인을 짐작할 수 있을 것이다.

솔라는 방금 전까지 자신만만했던 자신의 모습이 떠올라 얼굴이 살짝 붉어졌다. 자신이 만든 API는 오직 ‘성공’이라는 정해진 길로만 갈 수 있는 반쪽짜리였다. 길을 벗어났을 때, 즉 ‘실패’했을 때 어떻게 소통해야 하는지에 대한 고민이 전혀 없었다.

“아… 그렇네. ‘책이 존재하지 않아서 삭제에 실패했습니다’라고 알려줘야, 프론트엔드에서도 ‘이미 삭제된 책입니다’ 같은 제대로 된 안내 메시지를 보여줄 수 있겠구나. 그냥 500 에러를 던져버리면, 이건 그냥 ‘고장 난’ API나 다름없네.”

“바로 그거야.”

루나가 조용히 동의했다.

“백엔드 API는 단순히 기능을 성공적으로 수행하는 것에서 끝나지 않아. 실패했을 때도, 그 실패가 어떤 종류의 실패인지 명확하게 알려주어 약속된 방식으로 소통할 수 있어야 해. 성공과 실패, 양쪽 경로 모두에 대한 약속이 포함된 ‘읽을 수 있는 계약’이 필요한 거지.”

‘읽을 수 있는 계약.’ 솔라는 그 단어를 마음속으로 되뇌었다. CRUD 기능 구현은 계약서의 일부 조항을 작성한 것에 불과했다. 사용자의 실수, 예기치 않은 데이터 상태, 수많은 실패 가능성들을 어떻게 처리하고 소통할지에 대한 조항들이 빠져있었다. 예외 처리와 같은 기능들은 단순히 코드를 꾸미는 장식이 아니었다. 그것은 API가 자신의 책임을 다하기 위한 필수적인 구조였다.

자신이 만든 코드를 다시 바라보았다. 이제 이 코드들은 더 이상 완성된 결과물이 아니었다. 훨씬 더 중요한 질문에 답해야 할 시작점으로 보였다.

“알겠어, 언니. CRUD만 만들면 끝이라고 생각했는데, 완전히 틀렸네. 실패를 제대로 알려주는 게 정말 중요하구나. 그럼 그 ‘읽을 수 있는 실패’라는 건… 구체적으로 어떻게 만들어야 하는 거야? 그냥 에러 메시지만 잘 써서 보내주면 되는 걸까? 아니면 뭔가 정해진 규칙이라도 있는 거야?”

2장: 내부의 목소리: 도메인 예외로 실패 알리기

솔라의 손가락이 키보드 위를 맴돌았다. 화면에는 어제 막 작성한 BookService.java 파일이 열려 있었다. 성공의 증거였던 코드는 이제 의심의 대상이 되어 있었다. 특히 시선이 머문 곳은 책을 삭제하는 deleteBook 메소드였다.

// public void deleteBook(Long id) {
//     Book book = bookRepository.findById(id).orElse(null);
//     if (book != null) {
//         bookRepository.delete(book);
//     }
// }

어제는 이 코드가 꽤 합리적이라고 생각했다. 책이 있으면 지우고, 없으면 그냥 아무것도 하지 않는다. 서버가 멈추지는 않으니 괜찮다고 여겼다. 하지만 ‘읽을 수 있는 계약’이라는 말을 듣고 나니 이 코드는 침묵으로 일관하는 무책임한 파트너처럼 보였다. 프론트엔드는 자기가 보낸 삭제 요청이 성공했는지, 아니면 그냥 무시당했는지 전혀 알 길이 없다.

솔라는 주석을 풀고 코드를 고치기 시작했다. 없는 책을 삭제하려 할 때, 뭔가 실패했다는 신호를 보내야 한다. 솔라의 머릿속에 가장 먼저 떠오른 건 ‘예외(Exception)’였다.

“언니, 어제 말한 ‘읽을 수 있는 실패’를 만들려면, 여기서 예외를 던지면 되는 거 아닐까? 예를 들어 책을 못 찾았을 때 RuntimeException 같은 걸 던지는 거지. 그럼 최소한 서버가 그냥 멈추거나 아무 일 없다는 듯 넘어가는 것보단 낫잖아.”

솔라가 고친 코드는 이런 모습이었다.

public void deleteBook(Long id) {
    Book book = bookRepository.findById(id)
        .orElseThrow(() -> new RuntimeException("해당 책을 찾을 수 없습니다."));
    bookRepository.delete(book);
}

루나는 소파에서 일어나 솔라의 노트북 화면을 들여다보았다. 루나는 잠시 생각에 잠기더니, 화면 속 RuntimeException을 손가락으로 가리켰다.

“좋은 시도야. ‘실패했음’을 알리기 시작했으니까. 그런데 ‘RuntimeException’이라는 목소리는 너무 일반적이지 않을까? 마치 가게에 들어온 손님에게 점원이 그냥 ‘안 돼요!’라고 소리치는 것과 비슷해. 손님은 자기가 뭘 잘못했는지, 물건이 다 떨어진 건지, 아니면 가게가 문을 닫은 건지 알 수 없지.”

“‘안 돼요!’라니… 너무 퉁명스럽긴 하네.”

솔라는 루나의 비유를 곱씹었다. RuntimeException. 런타임에 발생하는 모든 예외를 포괄하는 이름. 데이터베이스 연결이 끊어져도, 코드에 논리적 오류가 있어도, 그리고 지금처럼 단순히 데이터가 없어도 모두 이 이름으로 불릴 수 있다. 이 API를 사용하는 프론트엔드 개발자 입장에선, 이 ‘안 돼요!’가 ‘책이 없어서 안 돼요’인지, ‘서버에 불이 나서 안 돼요’인지 구분할 방법이 없다. 결국 어제와 마찬가지로 ‘알 수 없는 오류가 발생했습니다’라고 표시할 수밖에 없다.

“그럼 어떻게 해야 더 구체적으로 말할 수 있는데?”

“목소리에 이름을 붙여주는 거야. 우리만의 예외를 만들어서, 이 특정한 상황을 위한 고유한 이름표를 달아주는 거지.”

루나는 키보드를 가져가 새 파일을 하나 만들었다. 파일 이름은 BookNotFoundException.java.

public class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(String message) {
        super(message);
    }
}

파일 내용은 아주 간단했다. RuntimeException을 상속받는, 이름만 다른 클래스였다. 솔라는 고개를 갸웃했다.

“이게 다야? 그냥 이름만 바꾼 거잖아. 이게 그렇게 큰 차이를 만들어?”

“응. 이제 우리의 서비스는 ‘안 돼요!’라고 소리치는 대신, ‘책을-찾을-수-없습니다(Book-Not-Found)!’라고 정확하게 외칠 수 있게 돼. 들어봐.”

루나는 솔라가 작성했던 BookService 코드를 다시 수정했다.

public Book findById(Long id) {
    return bookRepository.findById(id)
        .orElseThrow(() -> new BookNotFoundException("ID가 " + id + "인 책을 찾을 수 없습니다."));
}

public void deleteBook(Long id) {
    Book book = findById(id); // 이제 이 메소드는 책이 없으면 BookNotFoundException을 던짐
    bookRepository.delete(book);
}

달라진 점은 RuntimeExceptionBookNotFoundException으로 바뀐 것뿐이었다. 하지만 그 의미는 완전히 달랐다. 조회든 삭제든, ‘책이 없는’ 특정 상황이 발생하면 시스템 내부에서는 언제나 BookNotFoundException이라는 명확한 이름의 신호가 발생한다.

솔라는 그제야 깨달았다. 예외는 프로그램의 실행을 멈추는 골칫거리가 아니었다. 오히려 시스템 내부에서 발생하는 특정 사건에 이름을 붙이고, 그 사건의 발생을 다른 부분에 알리는 정교한 신호 체계였다. BookNotFoundException은 단순한 오류가 아니라, ‘책 도메인에서 발생할 수 있는, 책을 찾지 못했다는 의미 있는 사건’을 가리키는 내부의 목소리였다. 이것이야말로 실패에 대한 명확한 ‘소통’의 시작이었다.

“아…! 알겠다. RuntimeException은 그냥 ‘에러’라는 뜻이지만, BookNotFoundException은 ‘책이 없어서 발생한 문제’라는 도메인 지식이 담긴 이름이구나. 이제 API 안에서는 실패의 원인을 명확하게 식별할 수 있게 됐어. 이건 정말 ‘읽을 수 있는 실패’의 첫걸음이네.”

스스로 만든 예외 클래스가 API의 내부 계약 일부가 되는 순간이었다. 솔라의 표정이 한결 밝아졌다. 하지만 이내 새로운 궁금증이 떠올랐다.

“좋아, 이제 우리 백엔드 서버 안에서는 ‘책을 찾을 수 없다!’고 명확하게 소리칠 수 있게 됐어. 그런데… 이 목소리가 프론트엔드 개발자에게도 들릴까? 서버 안에서 외치는 소리가 어떻게 클라이언트까지 전달되는 거지? 결국 이 예외도 서버 밖으로 나가면 그냥 또 다른 ‘500 Internal Server Error’가 되는 거 아니야?”

3장: 외부의 언어: HTTP 상태 코드와 JSON 에러 계약

솔라는 API 테스트 도구의 응답 창을 노려보았다. 이전 장에서 야심 차게 만들었던 BookNotFoundException이 서버 내부에서 분명히 발생했을 터였다. 그러나 그녀의 눈앞에 펼쳐진 결과는 차갑고 무심했다.

{
  "timestamp": "2023-10-27T10:30:00.123+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/api/books/999"
}

예상했지만, 실망스러운 결과였다. 서버 안에서는 “ID 999인 책을 찾을 수 없어!”라고 애써 외쳤지만, 서버 밖으로 나온 목소리는 결국 또다시 의미 없는 ‘500 내부 서버 오류’일 뿐이었다. 프론트엔드 개발자는 이 응답을 보고 ‘책이 없다’는 사실을 어떻게 알 수 있을까? 불가능했다. 내부의 목소리는 외부로 전달되지 못하고 서버라는 벽 안에서 공허한 메아리로 사라져 버렸다.

“언니 말대로야. 서버 안에서 BookNotFoundException이라고 아무리 소리쳐도, 밖에서는 그냥 ‘서버 고장’으로 들리는구나. 이걸 어떻게 프론트엔드까지 전달하지? 모든 API 메소드마다 예외를 잡아서, 직접 에러 메시지를 만들어주는 코드를 일일이 넣어야 하나?”

솔라는 책을 삭제하는 deleteBook 메소드를 상상하며 말했다.

// @DeleteMapping("/{id}")
// public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
//     try {
//         bookService.deleteBook(id);
//         return ResponseEntity.noContent().build();
//     } catch (BookNotFoundException e) {
//         // 여기서 뭔가 특별한 응답을 만들어서 반환해야 하나?
//         return ResponseEntity.status(404).body("{\"message\": \"" + e.getMessage() + "\"}");
//     }
// }

가능은 하겠지만, 끔찍한 일이었다. API가 수십, 수백 개가 되면 모든 곳에 저 try-catch 블록을 복사해서 붙여넣어야 한다. 누군가는 404 상태 코드를 쓸 것이고, 다른 누군가는 400을 쓸지도 모른다. JSON 응답 형태도 개발자마다 제각각일 것이다. ‘읽을 수 있는 계약’은커녕, ‘개발자 마음대로’ 계약이 될 판이었다.

루나가 솔라의 고민을 들여다보며 말했다.

“모든 창구 직원이 각자 다른 방식으로 고객을 응대하면 혼란스럽겠지? 그래서 우리는 안내 데스크를 하나 두는 거야. 어떤 종류의 문제든, 일단 안내 데스크로 오면 정해진 절차에 따라 담당 부서로 연결해 주거나 표준화된 안내를 제공하는 곳.”

“안내 데스크?”

“응. 우리 애플리케이션 전체에 대한 ‘예외 안내 데스크’를 만드는 거야. 어떤 예외가 발생하든 그곳을 거쳐 가도록 해서, 외부와 소통하는 언어를 통일시키는 거지.”

루나는 GlobalExceptionHandler.java라는 이름의 새 파일을 만들었다. 이름부터 ‘전역 예외 처리기’라는 정체성이 명확히 드러났다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleBookNotFoundException(BookNotFoundException e) {
        ErrorResponse response = new ErrorResponse("NOT_FOUND", e.getMessage());
        return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); // 404
    }
}

솔라는 코드를 유심히 살폈다. @RestControllerAdvice 어노테이션은 이 클래스가 애플리케이션 전역에서 발생하는 예외를 처리할 것임을 암시했다. 그리고 @ExceptionHandler(BookNotFoundException.class) 부분은 마치 특정 손님을 기다리는 창구처럼 보였다. ‘BookNotFoundException이라는 손님이 오면, 이 창구에서 응대하겠습니다.’라는 선언과 같았다.

그리고 그 창구에서는 약속된 일을 수행한다. 내부의 BookNotFoundException을 받아, 외부의 언어인 ‘HTTP 404 Not Found 상태 코드’와 { "error": "NOT_FOUND", "message": "..." } 형태의 ‘JSON 에러 계약’으로 번역해서 돌려주는 것이다.

솔라는 반신반의하며 아까 실패했던 API 테스트를 다시 실행했다. 없는 책 ID인 999로 삭제를 요청했다. 잠시 후, 응답 창에 이전과는 완전히 다른 결과가 나타났다.

HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "error": "NOT_FOUND",
  "message": "ID가 999인 책을 찾을 수 없습니다."
}

“와…!”

솔라의 입에서 짧은 탄성이 터져 나왔다. 500 Internal Server Error는 사라졌다. 대신, 프론트엔드 개발자가 명확하게 이해하고 처리할 수 있는 응답이 도착했다. HTTP 상태 코드는 404, 리소스를 찾지 못했다는 명백한 신호. 본문에는 어떤 종류의 오류인지, 그리고 구체적인 메시지가 무엇인지 담긴 정형화된 JSON이 들어 있었다. 이것이야말로 진정한 ‘읽을 수 있는 실패’였다.

루나는 여기서 한 걸음 더 나아갔다.

“그럼 이건 어떨까? 책을 등록할 때 제목을 비워서 보내면 어떻게 될까? 이것도 실패인데, BookNotFoundException과는 종류가 다른 실패잖아.”

솔라는 책을 등록하는 API에 제목을 빈 값으로 보내는 요청을 날렸다. 이번에는 404가 아닌, 400 Bad Request 에러가 발생했다. 프레임워크가 기본적으로 막아주긴 했지만, 응답 본문은 여전히 복잡하고 장황했다.

루나는 GlobalExceptionHandler에 코드 몇 줄을 추가했다. @NotBlank 같은 유효성 검사에 실패했을 때 발생하는 MethodArgumentNotValidException을 처리하는 핸들러였다.

// ... GlobalExceptionHandler.java
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException e) {
    String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
    ErrorResponse response = new ErrorResponse("BAD_REQUEST", message);
    return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); // 400
}

다시 API를 호출하자, 이번에는 BookNotFoundException 때와 똑같은 구조의, 그러나 내용은 다른 응답이 돌아왔다.

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": "BAD_REQUEST",
  "message": "제목은 비워둘 수 없습니다."
}

이제야 솔라는 모든 조각이 맞춰지는 것을 느꼈다. GlobalExceptionHandler는 단순히 예외를 처리하는 편리한 도구가 아니었다. 그것은 API의 신뢰성을 떠받치는 구조적인 기둥이었다. 제각각일 수 있는 수많은 내부의 실패 신호들을, 일관되고 예측 가능한 외부의 ‘API 에러 계약’으로 표준화하는 번역가이자 통역관이었다. 개발자마다 다른 방식으로 응답을 만들던 혼란은 사라지고, 이제 모든 실패는 이 ‘안내 데스크’를 통해 하나의 목소리와 형식으로 소통된다.

“알겠어. 이제 실패를 소통하는 방식은 확실히 신뢰할 수 있게 됐어. 어떤 실패가 발생하든 GlobalExceptionHandler가 일관된 형식으로 번역해서 알려줄 테니까. 프론트엔드는 이제 어떤 에러가 올지 예측하고 대비할 수 있겠네.”

솔라는 만족스러운 표정으로 말했다. 자신의 API가 한 단계 더 견고해진 기분이었다. 실패를 명확히 소통할 수 있게 된 것이다. 하지만 문득, 새로운 종류의 불안감이 고개를 들었다.

“그런데 언니, 실패를 ‘알려주는’ 건 이제 알겠어. 하지만 데이터베이스에 뭔가를 쓰는 도중에 실패하면 어떡하지? 예를 들어 책 정보를 수정하는데, 이름은 바뀌었는데 표지 URL 업데이트는 실패했다면? 데이터가 반만 바뀐 채로 이상하게 남는 거 아냐? 그런 불안정한 상황은 어떻게 막아야 해?”

4장: 흔들림 없는 데이터: 트랜잭션 경계 설정

솔라의 노트북 옆에는 방금 무언가를 급하게 휘갈겨 쓴 노트가 놓여 있었다. 그 페이지 위에는 단순한 순서도가 그려져 있었다. ‘책 정보 수정’이라는 제목 아래, 두 개의 상자가 화살표로 연결되어 있었다.

[1. 책 제목 변경 (DB 업데이트)][2. 표지 이미지 유효성 검사 (외부 API 호출)]

솔라는 2번 단계로 향하는 화살표 위에 붉은 펜으로 커다란 X자를 그었다. 그리고 그 아래에 이렇게 적었다. ‘만약 여기서 실패하면? 1번은 이미 성공했는데?’

이 작은 그림은 그녀의 머릿속에 싹튼 새로운 불안감의 구체적인 형태였다. 지금까지는 실패가 발생했을 때 어떻게 그 사실을 외부에 잘 ‘알릴까’에 집중했다. 하지만 실패가 남기고 가는 뒷수습은 생각하지 못했다. 책 제목을 바꾸는 데는 성공했지만, 이어서 시도한 표지 이미지 URL 검증에서 오류가 난다면? 데이터베이스에는 제목만 바뀐, 어딘가 이상한 상태의 데이터가 영원히 남게 될 것이다.

솔라는 이 문제를 직접 해결하려 시도했다. 머릿속으로 코드를 짜 보았다. try-catch 구문으로 작업을 감싸고, catch 블록 안에서 원래 제목으로 되돌리는 DB 업데이트 코드를 다시 실행하면 될 것 같았다. 하지만 그 되돌리는 작업마저 실패하면 어떡하지? 생각은 꼬리에 꼬리를 물고 복잡해졌다. 모든 데이터 변경 로직마다 이런 식의 수동 복구 코드를 넣는 것은 끔찍한 재앙이 될 것 같았다.

“언니, 이것 좀 봐봐.”

솔라가 루나에게 자신의 고민이 담긴 가상의 코드를 보여주었다.

// public void updateBookDetails(Long id, String newTitle, String newCoverUrl) {
//     String originalTitle = bookRepository.findById(id).getTitle(); // 1. 원래 제목 저장
//
//     try {
//         // 2. 일단 새 제목으로 DB 업데이트
//         bookRepository.updateTitle(id, newTitle);
//
//         // 3. 외부 서비스로 표지 URL 유효성 검사
//         boolean isValid = externalCoverValidator.validate(newCoverUrl);
//         if (!isValid) {
//             throw new CoverValidationException("유효하지 않은 표지 URL입니다.");
//         }
//
//         // 4. 유효하면 표지 URL도 업데이트
//         bookRepository.updateCoverUrl(id, newCoverUrl);
//
//     } catch (Exception e) {
//         // 5. 뭐라도 실패하면 원래 제목으로 되돌리기
//         bookRepository.updateTitle(id, originalTitle);
//         // 만약 이 복구 작업도 실패하면...?
//         throw e;
//     }
// }

“데이터를 바꾸는 작업이 여러 단계일 때, 중간에 실패하면 데이터가 꼬일까 봐 걱정돼서 한번 짜봤어. 제목을 바꿨는데 표지 검증이 실패하면, 원래 제목으로 되돌리는 식으로. 근데 이거 너무 복잡하고 위험해 보이지 않아? 모든 메소드에 이런 코드를 넣어야 한다고 생각하니 아찔해.”

루나는 솔라의 코드와 노트에 그려진 다이어그램을 번갈아 보았다. 그리고는 조용히 말했다.

“우리가 누군가에게 택배 상자를 보낸다고 생각해 봐. 상자 안에 책과 노트, 연필을 정성껏 담았어. 그런데 배송 중에 문제가 생겨서 연필만 분실됐다고 상상해 보자. 그럼 받는 사람은 책과 노트만 받게 될 거야. 보낸 사람은 연필이 사라진 사실을 영원히 모를 수도 있고.”

“그건 안 되지. 상자가 통째로 도착하든가, 아니면 통째로 반송되어야지.”

솔라가 즉각 대답했다.

“바로 그거야. 솔라가 지금 하려는 건, 연필이 없어졌다는 소식을 듣고 택배 기사님한테 다시 전화해서 ‘죄송하지만 책이랑 노트도 다시 회수해서 돌려주세요’라고 개별적으로 요청하는 것과 같아. 대신 우리는 처음부터 상자를 포장하며 이런 약속을 할 수 있어. ‘이 상자에 담긴 물건들은 전부 함께 가거나, 하나라도 빠지면 전부 되돌려 보내주세요’라고. 이 상자가 바로 ‘트랜잭션(Transaction)’이라는 보이지 않는 경계선이야.”

루나는 솔라의 코드에서 복잡한 try-catch 블록과 수동 복구 로직을 지웠다. 그리고 메소드 맨 위에 딱 한 줄의 어노테이션을 추가했다.

@Transactional
public void updateBookDetails(Long id, String newTitle, String newCoverUrl) {
    // 1. 새 제목으로 DB 업데이트
    bookRepository.updateTitle(id, newTitle);

    // 2. 외부 서비스로 표지 URL 유효성 검사
    boolean isValid = externalCoverValidator.validate(newCoverUrl);
    if (!isValid) {
        throw new CoverValidationException("유효하지 않은 표지 URL입니다.");
    }

    // 3. 유효하면 표지 URL도 업데이트
    bookRepository.updateCoverUrl(id, newCoverUrl);
}

솔라가 걱정했던 모든 복구 로직이 사라지고, 코드는 놀랍도록 간결해졌다.

“이 @Transactional 어노테이션 하나가 그 ‘상자’ 역할을 하는 거야?”

“응. 이 어노테이션이 붙은 메소드 안에서 일어나는 모든 데이터베이스 작업은 하나의 묶음으로 취급돼. 메소드가 아무런 문제 없이 성공적으로 끝나면, 그제야 모든 변경사항이 데이터베이스에 영구적으로 기록되지. 만약 중간에 CoverValidationException 같은 예외가 발생해서 메소드가 중단되면, 이 메소드가 시작하기 전 상태로 프레임워크가 알아서 데이터베이스를 되돌려줘. 제목을 변경했던 첫 번째 작업까지 전부 없던 일이 되는 거야. ‘전부 아니면 전무(All or Nothing)’ 원칙이지.”

솔라는 그제야 깨달았다. 자신이 왜 그토록 복잡한 코드를 짜며 괴로워했는지. 그녀는 모든 DB 작업을 개별적인 것으로 보고, 실패의 뒷수습 책임을 자신이 직접 지려고 했다. 하지만 트랜잭션은

5장: 신뢰할 수 있는 API: 실패와 무결성의 구조

솔라의 손가락이 멈춰 있었다. 화면에는 지난 며칠간의 고민이 담긴 두 개의 파일이 나란히 열려 있었다. 하나는 애플리케이션의 모든 예외를 도맡아 처리하는 든든한 안내 데스크, GlobalExceptionHandler.java. 다른 하나는 데이터 변경 작업을 원자적으로 묶어 보호하는 보이지 않는 안전 상자, @Transactional 어노테이션이 붙은 BookService.java였다.

분명 두 가지 강력한 무기를 손에 넣은 것 같았다. 실패를 명확하게 ‘소통’하는 방법과 데이터의 ‘무결성’을 지키는 방법. 하지만 솔라는 두 그림을 나란히 놓고 보는 것처럼, 둘 사이의 연결 고리를 찾지 못해 답답함을 느꼈다. 마치 잘 만든 부품 두 개를 손에 들고 정작 이것들로 무엇을 조립해야 할지 모르는 기분이었다.

“언니.”

솔라가 화면에서 눈을 떼지 않은 채 루나를 불렀다.

GlobalExceptionHandler는 실패를 외부와 소통하는 책임, @Transactional은 데이터베이스를 보호하는 책임을 맡고 있어. 각각의 역할은 이제 알겠어. 그런데… 이게 어떻게 합쳐져서 ‘신뢰할 수 있는 API’가 되는 거지? 그냥 좋은 기능 두 개를 나중에 덧붙인 것뿐 아닌가? 처음 만들었던 CRUD에 비하면 확실히 좋아졌지만, 여전히 장식처럼 느껴져.”

‘장식’. 솔라는 자신이 처음 가졌던 생각으로 다시 돌아온 것 같아 기분이 이상했다. 처음에는 CRUD만으로 충분하다고 생각했고, 이제는 예외 처리와 트랜잭션이라는 멋진 장식을 달았다고 생각하고 있었다. 본질적인 변화를 아직 체감하지 못하고 있었다.

루나는 솔라의 책상으로 다가와, 솔라가 맨 처음 작성했던, 이제는 구석에 밀려난 아주 단순한 BookController의 초기 버전을 가리켰다. 예외 처리도, 트랜잭션도 없던 순수한 CRUD 코드였다.

“그 코드가 나쁘다는 게 아니야. 다만 그 코드가 고객, 즉 프론트엔드 개발자에게 어떤 약속을 하고 있었는지 다시 생각해 보자. 그 API를 사용하려는 사람에게 설명서를 써준다고 상상해 보는 거야.”

“설명서…?”

“응. ‘이 API는 책을 생성, 조회, 수정, 삭제할 수 있습니다.’ 거기서 끝 아니었을까?”

루나의 말에 솔라는 고개를 끄덕였다. 정말 그랬다. 성공했을 때의 기능만 나열했을 것이다.

“그럼 이제 우리가 만든 두 가지 장치, 예외 핸들러와 트랜잭션을 적용한 지금의 API 설명서는 어떻게 달라질까? 우리가 직접 진단해 보는 거야. 예전 API에 어떤 약속이 빠져 있었는지.”

루나는 솔라의 노트에 간단한 표를 그렸다. ‘시나리오’, ‘초기 API의 문제’, ‘현재 API의 약속’이라는 세 개의 칸이 있었다.

첫 번째 시나리오 칸에 루나가 적었다. ‘존재하지 않는 책(ID: 999) 삭제 요청’.

솔라는 즉시 대답할 수 있었다.

“초기 API는 그냥 500 에러를 뱉었을 거야. 원인을 알 수 없는 서버 고장 신호지. 프론트엔드는 아무것도 할 수 없어.”

“그럼 현재 API는?”

“‘HTTP 404 Not Found’ 상태 코드와 { "error": "NOT_FOUND", "message": "..." } 형식의 JSON을 반환해. 이건 ‘당신이 요청한 책이 없어요’라는 명확한 약속이지.”

솔라는 ‘현재 API의 약속’ 칸에 ‘읽을 수 있는 실패를 알려줄 것을 약속함’이라고 적었다. GlobalExceptionHandler가 더 이상 단순한 기능이 아니라, ‘소통의 약속’을 지키는 구조적 장치로 보이기 시작했다.

루나는 두 번째 시나리오를 적었다. ‘책 제목은 바꾸고, 표지 URL 검증은 실패하는 수정 요청’.

이번에도 솔라는 막힘없이 설명했다.

“초기 API 설계에서는… 상상만 해도 끔찍하네. 제목만 바뀐 채로 데이터가 오염됐을 거야. 겉으로는 성공처럼 보였을 수도 있지만, 데이터베이스는 망가진 상태가 되는 거지.”

“현재 API는?”

@Transactional이 마법의 상자 역할을 해주지. 표지 URL 검증에서 예외가 터지면, 이미 실행된 제목 변경까지 전부 없던 일로 되돌려줘. 데이터베이스는 작업 시작 전의 깨끗한 상태로 돌아가.”

솔라는 노트의 빈칸을 채웠다. ‘어떤 경우에도 데이터 무결성을 해치지 않을 것을 약속함.’

노트 위에 적힌 두 개의 약속을 보자, 모든 조각이 마침내 제자리를 찾았다.

“아…!”

솔라는 나지막이 탄성을 내뱉었다. 예외 처리와 트랜잭션은 API의 핵심 기능인 CRUD에 나중에 덧붙이는 장식이 아니었다. 그것들은 API의 ‘책임’ 그 자체를 정의하는 구조였다.

“알겠어. 이제야 알겠어. CRUD만 있는 API는 ‘성공하면 데이터를 줄게’라는 반쪽짜리 약속만 한 거였어. 하지만 신뢰할 수 있는 API는 거기에 두 가지 핵심 약속을 더 해야만 해. 첫째, ‘실패하면 그 이유를 명확하게 알려줄게.’ 둘째, ‘네가 어떤 요청을 하든, 내 데이터는 절대 망가뜨리지 않을게.’ 이 두 약속이 바로 API의 신뢰를 만드는 기둥이었구나. 그리고 그 약속을 지키는 구체적인 방법이 도메인 예외, 전역 예외 핸들러, 그리고 트랜잭션이었던 거야.”

솔라는 처음 API를 만들었을 때의 자신만만했던 자신을 떠올렸다. 성공이라는 하나의 길만 보고 달렸다. 이제 그녀는 수많은 갈림길과 낭떠러지까지 포함된 전체 지도를 손에 넣은 기분이었다. 실패는 더 이상 두려운 예외 상황이 아니라, API가 책임지고 관리해야 할 당연한 시나리오 중 하나였다.

솔라는 열려 있던 코드 파일을 닫고, 새로운 빈 노트를 열었다. 그리고 스스로를 위한 ‘API 설계 진단 가이드라인’을 작성하기 시작했다. 거창한 문서가 아니었다. 그녀가 방금 깨달은 두 가지 핵심 약속에 대한 질문이었다.

나의 API는 신뢰할 수 있는가?

  1. 실패는 읽을 수 있는가?
    • 실패 상황(예: 없는 데이터 조회, 잘못된 입력)에서, API 소비자가 원인을 명확히 이해할 수 있는 HTTP 상태 코드와 일관된 JSON 형식으로 응답하는가?
  2. 데이터는 안전한가?
    • 데이터를 변경하는 작업(생성, 수정, 삭제)이 여러 단계로 이루어질 때, 중간에 실패하더라도 데이터가 오염되거나 일관성이 깨지지 않음을 보장하는가? (All or Nothing)

두 개의 질문 아래, 솔라는 자신이 거쳐온 길을 작게 덧붙였다. (도메인 예외 → 전역 핸들러, 트랜잭션 경계)

이것은 더 이상 루나에게 의존하는 질문 목록이 아니었다. 앞으로 자신이 만들 모든 API가 통과해야 할, 스스로 만든 품질 보증 마크였다. 솔라는 자신의 짧은 가이드라인을 보며 조용히 미소 지었다. 이제 그녀는 기능이 ‘동작하는’ 코드를 넘어, ‘책임질 수 있는’ 코드를 만드는 첫걸음을 뗄 준비가 되었다.