Backend 23

Optional에서 사용자 정의 예외로 바꾸기: '의미 있는 실패'의 가치

Optional.empty면 그냥 RuntimeException을 던져도 되는데 왜 BookNotFoundException을 따로 만들까?

근거 · 교안 p149-p154

Optional에서 사용자 정의 예외로 바꾸기: '의미 있는 실패'의 가치 대표 이미지

1장: Luna: ‘의미 없는 실패’는 어디에도 쓸모가 없어요, 솔라.

솔라는 막 완성한 코드 한 줄을 보며 만족스러운 미소를 지었다. 어두운 테마의 코드 에디터 화면에서 그 줄만 유독 선명하게 빛나는 것 같았다.

public Book findById(Long id) {
    return bookRepository.findById(id)
        .orElseThrow(() -> new RuntimeException("Not Found"));
}

깔끔했다. 데이터베이스에서 id로 책을 찾고, 없으면 Optional.empty가 반환될 것이다. 그리고 orElseThrow는 그 빈 Optional을 받아 예외를 던져준다. “찾을 수 없음(Not Found)”이라는 메시지와 함께 RuntimeException을 던지는 것. 이만하면 충분하지 않은가? 실패 상황을 알렸고, 프로그램도 멈출 테니 말이다. 적어도 지금 단계에서는 완벽한 임시방편이라고 생각했다.

“거의 다 됐나 보네?”

언제 다가왔는지, 루나 언니가 솔라의 어깨너머로 화면을 들여다보고 있었다.

“응. 일단 책 조회하는 기능은 만들었어. ID로 찾았는데 없으면, 이렇게 예외를 던지게 했고.”

솔라는 자신이 만든 orElseThrow 부분을 가리키며 말했다. 간결하고 효율적이라는 칭찬을 기대하는 마음이 조금 섞여 있었다. 루나는 잠시 동안 말없이 코드를 응시했다. 무언가 생각에 잠긴 듯한 침묵이었다.

“솔라, 이 findById 메서드를 호출하는 쪽은 누구지?”

“음… 아마 BookController가 되겠지? HTTP 요청을 받아서 우리 BookService의 이 메서드를 호출할 거야.”

“좋아. 그럼 잠깐 역할을 바꿔서 상상해보자.”

루나가 말했다.

“네가 지금 만든 BookService라고 생각해봐. 나는 너를 호출하는 BookController야. 내가 존재하지 않는 책의 ID, 예를 들면 ‘999번’을 달라고 너한테 요청했어. 그럼 너는 나한테 뭘 던지지?”

new RuntimeException("Not Found")를 던지지.”

솔라는 자신 있게 대답했다. 너무나 명백한 사실이었다.

“그래. 그럼 나는 RuntimeException을 받았어. 이제 나는 사용자에게 ‘해당 책을 찾을 수 없습니다’라는 친절한 메시지를 보여주고 싶어. 어떻게 코드를 짜야 할까?”

솔라는 머릿속으로 Controller의 코드를 그려보았다.

“간단해. try-catch로 잡으면 되지.”

솔라가 키보드에 손을 얹고 가상의 코드를 설명하기 시작했다.

try { bookService.findById(999L); } catch (RuntimeException e) { // '책을 찾을 수 없습니다' 응답 반환 } 이렇게.”

“알겠어. 그런데 만약에, 네가 만든 BookService가 다른 이유로도 RuntimeException을 던질 수 있다면 어떡하지?”

루나의 질문은 예상치 못한 방향으로 흘러갔다.

“다른 이유라니?”

“가령… 데이터베이스 연결이 갑자기 끊겼다고 해보자. bookRepositoryRuntimeException을 던질 수도 있잖아. 메시지는 ‘DB connection failed’ 같은 걸로.”

루나는 솔라의 코드 아래에 가상의 시나리오를 덧붙였다.

“이제 나는 여전히 BookController야. 너한테서 RuntimeException이 날아왔어. 이게 ‘책이 없어서’ 발생한 예외인지, 아니면 ‘데이터베이스 연결이 끊겨서’ 발생한 예외인지 내가 어떻게 알 수 있을까?”

순간 솔라의 손가락이 멈칫했다. 아.

“음… 그야…”

솔라는 잠시 머뭇거리다가, 금방 떠오른 생각을 입 밖으로 뱉었다.

“예외 메시지를 확인하면 되지! e.getMessage()를 보고, 그 문자열이 ‘Not Found’인지 확인하는 거야.”

catch (RuntimeException e) {
    if (e.getMessage().equals("Not Found")) {
        // '책을 찾을 수 없습니다' 응답 반환
    } else {
        // '서버 내부 오류' 응답 반환
    }
}

말을 내뱉는 순간, 솔라 스스로도 그게 썩 좋은 방법이 아니라는 것을 깨달았다. 만약 누군가 서비스 코드의 예외 메시지를 “Not Found.”처럼 점 하나만 찍어 바꿔버린다면? 혹은 “Book not found”로 더 친절하게 고친다면? 컨트롤러의 if 문은 곧바로 깨져버릴 것이다. 예외 메시지라는, 언제든 바뀔 수 있는 ‘문자열’에 의존해서 프로그램의 핵심 로직을 제어하는 꼴이었다. 실패를 다루는 방식이 너무나 불안정하고 허술했다.

“메시지 문자열에 의존하는 건… 위험하네.”

솔라가 나지막이 중얼거렸다. 루나는 아무 말 없이 고개만 끄덕였다. 방금 전까지 완벽해 보였던 자신의 코드가 갑자기 초라하게 느껴졌다. RuntimeException은 그저 ‘무언가 잘못됐다’고 소리치는 화재경보기와 같았다. 하지만 그게 연기가 나는 토스터 때문인지, 건물 전체가 불타고 있는 건지는 알려주지 않았다. 호출한 쪽에서는 그저 경보음만 들을 뿐, 원인을 정확히 알 수 없으니 제대로 된 대응도 할 수 없었다.

솔라는 화면의 new RuntimeException("Not Found") 부분을 지웠다 다시 썼다를 반복했다. 실패는 분명히 발생하는데, 그 실패의 ‘의미’가 전달되지 않고 있었다.

“알겠다… 그냥 RuntimeException이라고 뭉뚱그려 던지면, 호출한 쪽에서는 실패의 구체적인 원인을 식별할 방법이 없구나. 그래서 의미가 없다는 거구나.”

단순히 예외를 던지는 것만으로는 부족했다. 호출한 쪽에서 그 실패의 종류를 명확히 ‘식별’하고 그에 맞는 행동을 취할 수 있도록 정보를 담아줘야 했다. 솔라는 자신이 놓친 것이 바로 이 지점이라는 것을 깨달았다.

“그럼… ‘책을 찾을 수 없다’는 이 특정한 실패는 대체 어떻게 알려줘야 하는 거지? 소리만 지르는 게 아니라, 제대로 된 정보를 담아서 말이야.”

2장: Luna: 도메인에 맞는 실패는 직접 이름을 지어줘야죠.

솔라의 커서는 빈 줄 위에서 외롭게 깜빡이고 있었다. 바로 어제, new RuntimeException("Not Found") 코드를 자신 있게 채워 넣었던 바로 그 자리였다. 이제 그 코드는 사라지고, 텅 빈 공간만이 남았다. 무언가 잘못되었다는 것은 알지만, 그것을 어떻게 표현해야 할지 막막했다. ‘의미 있는 실패’를 전달해야 한다는 것은 이해했지만, 그 ‘의미’를 담을 그릇이 무엇인지 짐작조차 가지 않았다.

고민 끝에 솔라는 패키지 탐색기에서 오른쪽 클릭을 하고 ‘새 클래스’를 선택했다. 어떤 내용을 채워야 할지는 모르겠지만, 일단 ‘책을 찾지 못했다’는 이 특별한 상황을 위한 무언가를 만들어야 한다는 생각에서였다. 클래스 이름 입력창이 뜨자, 솔라는 잠시 망설였다. 뭐라고 이름을 붙여야 이 실패를 가장 잘 표현할 수 있을까? 잠시 고민하던 솔라는 Book... Error? 같은 이름을 떠올리다 이내 고개를 저었다. 그러다 문득, 실패의 내용을 그대로 이름으로 만들면 되겠다는 생각이 들었다. BookNotFound. 그리고 예외니까 Exception을 붙여서. 그녀는 키보드로 BookNotFoundException이라고 입력하고 엔터 키를 쳤다. 하얀 화면에 텅 빈 클래스 파일 하나가 덩그러니 열렸다.

public class BookNotFoundException {

}

“그래서, 그 다음은?”

솔라가 혼잣말처럼 중얼거렸다. 클래스 파일은 만들었지만, 막상 안을 어떻게 채워야 할지 감이 오지 않았다. 이건 그냥 껍데기일 뿐인데. 이렇게 클래스 하나 더 만드는 게 과연 의미가 있을까? 코딩만 더 복잡해지는 것 아닌가 하는 의심이 스멀스멀 피어올랐다.

“새로운 이름을 지어줬네.”

루나가 솔라의 화면에 뜬 BookNotFoundException.java 탭을 보며 말했다.

“응. ‘책을 찾을 수 없는 실패’니까. 그런데 뭘 어떻게 해야 할지 모르겠어. 그냥 클래스 하나 만든다고 뭐가 달라져? 어차피 이것도 예외니까, RuntimeException처럼 동작하게 만들어야 하는 거 아냐?”

솔라는 그렇게 말하며 extends RuntimeException을 코드에 추가했다.

public class BookNotFoundException extends RuntimeException {

}

“이제 RuntimeException의 모든 행동을 따라 하겠네. 그럼 결국 어제랑 똑같은 거 아냐?”

솔라의 목소리에는 회의감이 섞여 있었다. 어차피 같은 부모를 뒀다면, 같은 행동을 할 터였다. 결국 이름만 바꾼 RuntimeException일 뿐이라는 생각이 들었다.

“정말 그럴까?”

루나는 솔라의 코드를 지적하는 대신, 어제 함께 들여다보았던 BookController의 가상 코드를 다시 떠올리게 했다.

“어제 우리가 상상했던 컨트롤러 코드를 다시 보자. 예외 메시지 문자열로 실패 원인을 불안하게 추측하던 그 catch 블록 말이야.”

루나의 말에 솔라는 머릿속으로 어제의 코드를 그렸다. if (e.getMessage().equals("Not Found")) 같은, 금방이라도 깨질 것 같던 불안한 조건문이 떠올랐다.

“이제 솔라, 너에게는 RuntimeException 말고도 아주 특별한 이름의 예외가 하나 더 생겼어. BookNotFoundException이라는. 이 새로운 이름을 사용해서 그 catch 블록을 다시 고쳐본다면 어떻게 될까? 더 이상 메시지 문자열을 확인하지 않고 말이야.”

그 순간, 솔라의 눈이 커졌다. 아!

마치 안개가 걷히는 기분이었다. 왜 클래스를 새로 만드는 것이 중요한지, 그 ‘이름’ 자체가 어떤 힘을 갖는지 깨닫는 순간이었다. 솔라는 키보드 위에서 빠르게 손가락을 움직여 가상의 코드를 머릿속에 그렸다.

try {
    // bookService.findById(999L);
} catch (BookNotFoundException e) {
    // 이건 '책을 찾지 못했다'는 것이 확실해!
    // 사용자에게 '해당 책을 찾을 수 없습니다.' 라는 메시지를 보여주자.
} catch (RuntimeException e) {
    // 이건 BookNotFoundException이 아닌 다른 모든 런타임 예외.
    // 아마 DB 연결 문제 같은 심각한 오류일 거야.
    // 사용자에게는 '서버 내부 오류가 발생했습니다.' 라고 알리자.
}

더 이상 예외 메시지라는 위태로운 문자열에 의존할 필요가 없었다. catch 문은 이제 예외의 ‘종류’, 즉 클래스의 ‘타입’을 직접 식별할 수 있었다. BookNotFoundException이라는 이름 그 자체가 ‘책을 찾을 수 없다’는 실패의 원인을 명확하게 알려주는 강력한 신호가 된 것이다. RuntimeException을 상속받아 똑같이 프로그램 실행을 멈추는 ‘행동’을 하지만, 그 ‘정체성’이 완전히 달랐다.

“이름이… 타입이, 그냥 신호등 같은 거구나.”

솔라가 중얼거렸다. RuntimeException이라는 모호한 경고등이 아니라, ‘책 없음’이라는 명확한 표지판을 세운 것과 같았다.

“그래. 실패에 고유한 이름을 붙여주는 거지. 이제 서비스는 ‘알 수 없는 오류’가 아니라 ‘책을 찾지 못하는 오류’가 발생했다고 명확하게 말할 수 있게 됐어. 호출한 쪽에서는 그 이름을 보고 무엇을 해야 할지 정확히 결정할 수 있고.”

루나의 말에 솔라는 고개를 끄덕였다. 비어있던 BookNotFoundException 클래스가 더 이상 텅 빈 껍데기로 보이지 않았다. 그 클래스의 이름 자체가 가장 중요한 내용이었다. 실패를 위한 명확한 ‘명세서’를 만든 셈이었다.

솔라는 방금 만든 BookNotFoundException 클래스에 생성자를 추가해, 예외 메시지도 받을 수 있도록 코드를 다듬었다.

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

이제 ‘왜’ 실패했는지(클래스 이름)와 ‘어떻게’ 실패했는지에 대한 구체적인 정보(메시지)를 모두 담을 수 있는 그릇이 만들어졌다.

“좋아. 이제 실패를 담을 그릇은 만들었어. 그럼 이걸… 서비스 코드에서 정확히 어떻게 던져줘야 하는 거지?”

솔라의 시선은 다시 BookServicefindById 메서드로 돌아갔다. orElseThrow 뒤의 텅 빈 괄호가 그녀의 다음 질문을 기다리고 있었다.

3장: Luna: 서비스 계층은 도메인 실패를 ‘통역’하는 곳이에요.

솔라의 코드 에디터에는 두 개의 파일이 나란히 열려 있었다. 왼쪽에는 방금 전 완벽한 명세서라고 생각했던 BookNotFoundException 클래스가, 오른쪽에는 그 명세서를 사용해야 할 BookService가 있었다. 하지만 서비스의 findById 메서드 안, orElseThrow의 텅 빈 괄호는 여전히 채워지지 못한 채 커서만 깜빡이고 있었다.

실패를 담을 그릇은 만들었다. 하지만 그 그릇에 무엇을 어떻게 담아 던져야 할지, 그리고 던진 다음에는 무슨 일이 일어날지에 대한 확신이 서지 않았다. 솔라는 잠시 망설이다가, 키보드에 손을 얹고 괄호 안을 채워 넣었다.

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

코드는 완성되었다. Optional이 비어있으면, 새로 만든 BookNotFoundException 객체를 생성해서 던진다. 이제 RuntimeException이라는 모호한 이름 대신 BookNotFoundException이라는 명확한 이름표를 달고 예외가 발생할 것이다. 하지만 솔라의 표정은 개운치 않았다.

“결국 똑같은 거 아닌가?”

솔라가 입을 열었다.

RuntimeException을 던지나, BookNotFoundException을 던지나, 어차피 컨트롤러에서 try-catch로 잡아야 하는 건 똑같잖아. 그냥 catch 할 클래스 이름 하나 바뀐 것뿐인데. 일이 더 복잡해진 느낌이야.”

결국 예외를 처리하기 위한 코드가 추가로 필요하다는 사실이 변하지 않는다는 점이 솔라를 답답하게 했다. 편의를 위해 RuntimeException을 썼던 처음의 생각으로 자꾸만 회귀하려는 마음이 들었다.

“서비스 계층의 역할을 잠시 잊었구나, 솔라.”

루나가 솔라의 옆에 앉으며 모니터를 가리켰다. 화면에는 컨트롤러-서비스-리포지토리로 이어지는 애플리케이션의 구조가 어렴풋이 보였다.

“서비스 계층은 단순히 컨트롤러와 리포지토리 사이에서 데이터를 전달만 하는 곳이 아니야. 특히 실패 상황에서 더 중요한 역할을 하지. 일종의 ‘통역사’ 같은 거야.”

“통역사?”

“응. 데이터베이스, 즉 리포지토리 계층에서 오는 신호는 아주 기술적이야. 데이터가 ‘있다’ 또는 ‘없다’ 정도지. 방금 전의 Optional.empty()가 바로 그 ‘없다’는 신호야.”

루나는 솔라의 findById 메서드에서 bookRepository.findById(id) 부분을 손가락으로 가리켰다.

“서비스 계층은 그 기술적인 신호를 받아서, 우리 비즈니스, 즉 ‘도메인’의 언어로 번역해줘야 해. ‘데이터가 없다’는 신호가 ‘ID로 책을 조회하는’ 맥락에서는 무슨 의미지?”

“어… ‘책을 찾을 수 없다’는 뜻이지.”

솔라가 대답했다. 너무나 당연한 말이었다.

“바로 그거야. Optional.empty()라는 기술 언어를 BookNotFoundException이라는 도메인 언어로 ‘통역’하는 것. 그게 바로 지금 솔라 네가 작성한 이 코드 한 줄의 진짜 의미야.”

루나의 말에 솔라의 눈이 천천히 커졌다. orElseThrow(() -> new BookNotFoundException(...)) 코드가 다르게 보이기 시작했다. 이건 단순히 예외를 던지는 행위가 아니었다. 하위 계층의 기술적인 실패를 상위 계층이 이해할 수 있는 의미 있는 실패로 변환하는 과정이었다. 서비스 계층이 중간에서 중심을 잡고, 양쪽의 언어를 정리해주는 경계선 역할을 하고 있었던 것이다.

“그럼 컨트롤러는…”

“컨트롤러는 더 이상 데이터베이스에 데이터가 ‘있는지 없는지’ 같은 기술적인 내용은 신경 쓸 필요가 없어져. 오직 서비스가 번역해 준 도메인 언어, 즉 BookNotFoundException이 날아왔는지, 아니면 DuplicateIsbnException 같은 다른 도메인 예외가 날아왔는지만 보고 자신의 역할을 수행하면 돼.”

이제야 모든 조각이 맞춰지는 기분이었다. 솔라는 다시 한번 머릿속으로 컨트롤러의 코드를 상상했다.

@GetMapping("/books/{id}")
public ResponseEntity<?> getBook(@PathVariable Long id) {
    try {
        Book book = bookService.findById(id);
        return ResponseEntity.ok(book);
    } catch (BookNotFoundException e) {
        // 서비스 계층이 '책을 찾을 수 없음'이라고 명확히 알려줬어.
        // 나는 사용자에게 404 Not Found 상태와 친절한 메시지를 보내면 돼.
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
    }
}

컨트롤러의 코드는 이제 실패의 원인을 추측하거나, 메시지 문자열을 파싱할 필요 없이 너무나 명확해졌다. BookNotFoundException을 잡았다는 것 자체가 ‘책이 없다’는 비즈니스 로직 상의 실패를 의미했기 때문이다. 서비스 계층이 훌륭한 통역사 역할을 해준 덕분에, 컨트롤러는 깔끔하고 견고하게 자신의 임무에만 집중할 수 있게 되었다.

“알겠다… 서비스 계층이 실패의 ‘의미’를 정해주는 거구나. 그래서 호출하는 쪽에서 if문 같은 걸로 고민할 필요 없이, 실패의 종류별로 명확하게 대응할 수 있게 되는 거네.”

솔라는 자신이 작성한 findById 메서드를 뿌듯하게 바라봤다. 더 이상 복잡하기만 한 코드로 보이지 않았다. 시스템의 각 부분이 자신의 역할에 충실하도록 만드는, 아주 중요한 설계 결정이라는 것을 깨달았다.

그러자 새로운 생각이 떠올랐다. 솔라는 BookService 파일의 맨 아래로 스크롤을 내리고 주석을 달기 시작했다. findById에서 했던 고민을 다른 기능으로 확장해보는 것이다.

/*
 * 다음 기능 구현 시 고려할 예외 설계
 *
 * 1. 책 저장 (save)
 *   - 실패 시나리오: 이미 존재하는 ISBN으로 저장을 시도할 경우
 *   - 도메인 예외: DuplicateIsbnException
 *
 * 2. 책 정보 수정 (update)
 *   - 실패 시나리오: 존재하지 않는 책의 정보 수정을 시도할 경우
 *   - 도메인 예외: findById와 동일하게 BookNotFoundException 재사용
 */

스스로 다음 단계의 ‘실패 흐름’을 설계하고 있는 자신을 발견하고 솔라는 흠칫 놀랐다. 이제 실패는 더 이상 귀찮은 예외 상황이 아니었다. 애플리케이션의 견고함을 위해 명확하게 정의하고 관리해야 할 중요한 설계 요소로 보이기 시작했다. 어떻게 실패할지를 정의하는 것이, 어떻게 성공할지를 설계하는 것만큼이나 중요했다.