학습 자료

findById — 자바 편 20강의 Optional 이 여기서 일한다


Article

16강에서 CRUD 넷 중 셋을 되살렸다. 만들고, 목록을 읽고, 지웠다.

한 권만 읽는 일이 남았다. 13강에서는 이렇게 적었다.

@GetMapping("/{id}")
public ResponseEntity<Book> one(@PathVariable Long id) {
    Book found = store.get(id);
    if (found == null) return ResponseEntity.notFound().build();
    return ResponseEntity.ok(found);
}

store.get(id)repository.findById(id) 로 바꾸면 될 것 같다. 바꿔보자.

Book found = repository.findById(id);
터미널
  • error: incompatible types: Optional<Book> cannot be converted to Book
  • 1 error

컴파일이 안 된다.

책 세 권을 다시 넣어두고 시작한다. 자바 · 스프링 · HTTP 다.

상자에 담겨서 온다

16강 끝에서 표로 봤던 그 줄이다.

Optional<Book> findById(Long id);

findAll()List<Book> 을 준다. save()Book 을 준다. findById() 만 다른 것을 준다.

찍어보면 눈에 보인다.

System.out.println(repository.findById(1L).map(Book::getTitle));
System.out.println(repository.findById(99L).map(Book::getTitle));
터미널
  • Optional[자바]
  • Optional.empty

자바 편 20강에서 본 그대로다. 값이 있으면 Optional[자바], 없으면 Optional.empty. 둘 다 상자다.

그 강의는 이렇게 정리했다.

값이 있으면 Optional[자바], 없으면 Optional.empty 다. 둘 다 상자다. 상자 자체는 절대 null 이 아니다.

store.get(id) 는 없으면 null 을 줬다. 그래서 if (found == null) 을 우리가 기억해서 적어야 했다. findById 는 그럴 수가 없다. 상자를 받았으니 열어야 하고, 여는 자리에서 없는 경우를 만난다.

컴파일 에러는 사고가 아니다. 자바가 우리를 붙잡은 것이다.

상자를 열어 꺼내면 어떻게 되나

가장 빨리 컴파일을 통과시키는 방법이 있다. orElse(null) 이다.

Book found = repository.findById(id).orElse(null);
if (found == null) return ResponseEntity.notFound().build();
return ResponseEntity.ok(found);

돈다. 상태코드도 맞다. /books/1200 이고 /books/99404 다.

그런데 무엇이 남았는지 보자.

findById(99) 를 두 가지로 받아보면

99번 책은 없다

Optional<Book> o
Optional.empty
→ 비어 있는 상자. 상자는 실재한다
Book found
null
→ orElse(null) 로 꺼낸 뒤. 건드리면 NPE
한 줄로 상자를 부쉈다. null 이 돌아왔다 — 13강에서 손으로 붙잡던 그것이다.

orElse(null) 을 부른 순간 found 는 다시 null 이 될 수 있는 변수다. 자바 편 20강이 무엇을 문제 삼았는지 다시 읽어보자.

문제는 null 을 돌려준 게 아니다. 메서드의 생김새 어디에도 "없을 수 있다"고 적혀 있지 않다는 것이다.

findById 의 생김새는 사실을 말하고 있었다. Optional<Book> 이라고 적혀 있었다. 우리가 그것을 한 줄로 지웠다.

지금은 if 를 잊지 않았으니 아무 일도 없다. 다음 줄에 found.getTitle() 을 적는 사람은 컴파일러에게 아무 경고도 못 듣는다. orElse(null)검사를 잊을 자유를 되돌려준다. 그게 아까운 것이다.

같은 404, 다른 코드

상자를 부순다

orElse(null)

  • Book foundnull 일 수 있는 변수다
  • if (found == null) 을 사람이 기억해서 적는다
  • 잊어도 컴파일된다
  • 13강과 코드가 똑같다

상자째 다룬다

map · orElseGet

  • Book 이라는 변수가 아예 없다
  • 없는 경우를 적지 않으면 컴파일이 안 된다
  • null 이라는 글자가 코드에서 사라진다
  • 16강이 준 타입을 그대로 쓴다
자바 편 20강: 상자를 열어 꺼내지 말고, 상자째로 다룬다.

상자째 다룬다

자바 편 20강이 준 도구가 둘 있다. map 과, 상자를 여는 orElse 계열이다. 그 강의는 map 을 이렇게 설명했다.

상자 안에 값이 있으면 바꾸고, 비어 있으면 아무것도 안 한다.

우리에게 필요한 것도 정확히 그것이다. 책이 있으면 200 응답으로 바꾸고, 없으면 404 를 만든다.

@GetMapping("/{id}")
public ResponseEntity<Book> one(@PathVariable Long id) {
    return repository.findById(id)
            .map(ResponseEntity::ok)
            .orElseGet(() -> ResponseEntity.notFound().build());
}

세 줄을 하나씩 읽는다.

한 줄씩
  1. 01

    repository.findById(id)

    Optional<Book> 이 나온다. 책이 든 상자이거나 빈 상자다.

  2. 02

    .map(ResponseEntity::ok)

    상자에 책이 있으면 ResponseEntity.ok(책) 으로 바꾼다. 비어 있으면 아무 일도 안 한다. 결과는 Optional<ResponseEntity<Book>>상자는 그대로 있고 안의 것만 바뀌었다.

  3. 03

    .orElseGet(() -> ...notFound()...)

    여기서 상자가 열린다. 안에 있으면 그것을 내놓고, 비어 있으면 404 를 만들어 내놓는다.

Book 이라는 이름의 변수가 한 번도 등장하지 않았다. null 도 없다.

ResponseEntity::ok 는 자바 편 19강에서 본 메서드 참조다. book -> ResponseEntity.ok(book) 을 짧게 쓴 것이다.

돌려보자.

curl -i http://localhost:8080/books/1
터미널
  • HTTP/1.1 200
  • Content-Type: application/json
  • Transfer-Encoding: chunked
  • {"title":"자바","price":20000,"id":1}

없는 책도 물어본다.

터미널
  • $ curl -i http://localhost:8080/books/99
  • HTTP/1.1 404
  • Content-Length: 0

404 다. 그리고 Content-Length: 0본문이 0바이트다. 3강에서 204 의 본문이 0바이트인 것을 재봤다. notFound().build()build() 도 같은 뜻이다. 본문 없이 끝낸다.

orElse 가 아니라 orElseGet 인 이유

방금 orElseGet 이라고 썼다. orElse 도 있는데 왜.

자바 편 20강이 표 아래에 한 문장을 달아뒀다.

orElse(계산해서만든기본값()) 이라고 쓰면, 값이 있어도 그 계산이 실행된다. 인자니까 먼저 평가된다.

말로만 들으면 잘 안 믿긴다. 확인해보자. 404 를 만드는 일을 메서드로 빼고, 불릴 때마다 흔적을 남기게 한다.

private ResponseEntity<Book> notFound() {
    System.out.println("### notFound() 가 불렸다");
    return ResponseEntity.notFound().build();
}

먼저 orElse 로 쓴다.

return repository.findById(id)
        .map(ResponseEntity::ok)
        .orElse(notFound());

있는 책을 달라고 한다. /books/1 이다. 1번 책은 분명히 있다.

터미널 · 서버 로그
  • $ curl -o /dev/null -w '%{http_code}' http://localhost:8080/books/1
  • 200
  • # 서버 로그
  • ### notFound() 가 불렸다

책을 찾았는데 notFound() 가 불렸다.

응답은 멀쩡하게 200 이다. 상자에 값이 있었으니 orElse 는 인자를 쓰지 않고 버렸다. 그런데 버릴 물건을 만들기는 했다.

당연하다. orElse(notFound()) 는 메서드 호출이고, 자바는 메서드를 부르기 전에 인자를 먼저 계산한다. notFound() 가 먼저 돈다. 그 결과를 orElse 에게 넘긴다. orElse 는 상자를 열어보고 "값이 있네" 하며 받은 것을 버린다.

이제 orElseGet 으로 바꾼다.

.orElseGet(() -> notFound());
터미널 · 서버 로그
  • $ curl -o /dev/null -w '%{http_code}' http://localhost:8080/books/1
  • 200
  • $ curl -o /dev/null -w '%{http_code}' http://localhost:8080/books/99
  • 404
  • # 서버 로그 — 두 번 요청했는데 한 줄뿐이다
  • ### notFound() 가 불렸다

두 번 요청했는데 한 번만 찍혔다. 없는 책을 물어봤을 때만 불렸다.

orElseGet 이 받는 것은 값이 아니라 값을 만드는 법이다. 상자가 비어 있을 때만 그 법을 실행한다.

둘의 차이
orElse(x)orElseGet(() -> x)
받는 것값을 만드는 법
상자에 값이 있으면x 를 만들고 버린다아무 일도 안 한다
상자가 비어 있으면x 를 내놓는다그때 x 를 만들어 내놓는다
orElse(0) 처럼 상수라면차이 없다차이 없다
기본값을 만드는 데 비용이 들면 orElseGet 이다. 데이터베이스를 한 번 더 뒤지는 기본값이었다면 매 요청마다 헛일을 한 것이다.

U — 고친다

GET 이 돌아왔으니 PUT 차례다. 13강의 코드는 이랬다.

if (!store.containsKey(id)) return ResponseEntity.notFound().build();
Book updated = new Book(id, book.title(), book.price());
store.put(id, updated);

record 는 못 고치니 새 책을 만들어 덮어썼다. 15강에서 Bookrecord 를 그만두고 클래스가 됐다. 이제 고칠 수 있다.

그런데 15강에서 이렇게 못박았다.

setter 는 안 만들었다. 바꿀 일이 생기면 그때 뜻이 담긴 메서드를 만든다.

바꿀 일이 생겼다. 만든다.

public void changeTo(String title, int price) {
    this.title = title;
    this.price = price;
}

setTitlesetPrice 를 따로 만들지 않았다. PUT 은 3강에서 배운 대로 통째로 바꾸는 메서드다. 제목만 바꾸고 가격은 안 바꾸는 상태가 중간에 생기지 않는다. 메서드 하나가 그 뜻을 담는다.

컨트롤러는 이렇게 된다.

@PutMapping("/{id}")
public ResponseEntity<Book> update(@PathVariable Long id, @RequestBody Book book) {
    return repository.findById(id)
            .map(found -> {
                found.changeTo(book.getTitle(), book.getPrice());
                return repository.save(found);
            })
            .map(ResponseEntity::ok)
            .orElseGet(() -> ResponseEntity.notFound().build());
}

map 이 두 번이다. 첫 map찾은 책을 고쳐서 저장한 책으로 바꾸고, 둘째 map 은 그것을 200 응답으로 바꾼다. 마지막 두 줄은 GET 과 글자까지 같다.

번호는 주소에서 오고 내용은 본문에서 온다. 12강과 11강이 한 메서드에서 만나는 것도 13강 그대로다.

curl -X PUT http://localhost:8080/books/1 \
  -H 'Content-Type: application/json' \
  -d '{"title":"자바","price":18000}'
터미널
  • {"title":"자바","price":18000,"id":1}
  • $ curl http://localhost:8080/books/1
  • {"title":"자바","price":18000,"id":1}
  • $ curl -o /dev/null -w '%{http_code}' -X PUT http://localhost:8080/books/99 -d '...'
  • 404

가격이 20000 에서 18000 으로 바뀌었다. 번호는 그대로다. 없는 책을 고치라고 하면 404 다.

PUT 을 두 번 보내도 결과가 같다. 3강에서 PUT 은 멱등하다고 했고, 13강에서 확인했고, 여기서도 그렇다.

본문에 id 를 실어 보내면

16강에서 하나 짚어뒀다. 요청 본문을 Book 엔티티로 그대로 받으면 id 까지 채워진다. 필드가 전부 private 이고 setter 가 하나도 없는데도 그렇다.

PUT@RequestBody Book 으로 받는다. 그러면 주소의 id 와 본문의 id 가 어긋날 때 무슨 일이 날까. 1번 책을 고치라면서 본문에는 99 를 적어 보낸다.

curl -X PUT http://localhost:8080/books/1 \
  -H 'Content-Type: application/json' \
  -d '{"id":99,"title":"엇갈림","price":7777}'
터미널
  • # 컨트롤러가 받은 것을 찍어봤다
  • ### PUT 이 받은 것: 주소 id=1 / 본문 id=99 title=엇갈림 price=7777
  • HTTP/1.1 200
  • {"title":"엇갈림","price":7777,"id":1}
  • $ curl -o /dev/null -w '%{http_code}' http://localhost:8080/books/99
  • 404 ← 99번 책은 안 생겼다

1번 책이 바뀌었고 99번 행은 생기지 않았다.

16강에서 POST 로 같은 짓을 했을 때는 남의 책이 덮어씌워지고, 없는 번호를 보내면 500 까지 났다. PUT 은 멀쩡하다. 왜인가.

우리 코드가 book.getId()한 번도 쓰지 않기 때문이다. 고칠 책은 findById(id) 로 주소에서 찾았고, 본문에서는 제목과 가격만 꺼냈다. book 은 값을 실어 나르는 그릇으로만 쓰였다.

orElseThrow 를 쓰면 어떻게 되나

자바 편 20강은 Optional 을 여는 방법을 하나 더 줬다. 없으면 예외를 던지는 orElseThrow 다.

Optional 은 "없어도 괜찮다"는 뜻이고, orElseThrow 는 "여기서는 없으면 안 된다"고 선언하는 자리다.

한 권을 달라고 했는데 없다. 없으면 안 되는 자리 같다. 그리고 자바 편 18강에서 예외까지 만들어뒀다.

class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(String title) {
        super(title + " 을(를) 찾을 수 없다");
    }
}

그때는 제목으로 책을 찾았으니 제목을 받았다. 우리는 번호로 찾으니 번호를 받게만 고친다. 그리고 자바 편 20강이 쓴 모양 그대로 옮겨 적는다.

@GetMapping("/{id}")
public Book one(@PathVariable Long id) {
    return repository.findById(id)
            .orElseThrow(() -> new BookNotFoundException(id));
}

ResponseEntity 도 사라지고 orElseGet 도 사라졌다. 훨씬 짧다. 돌려보자.

터미널
  • $ curl -i http://localhost:8080/books/99
  • HTTP/1.1 500
  • Content-Type: application/json
  • {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/books/99"}

서버 로그에는 우리가 던진 예외가 그대로 찍혀 있다.

서버 로그
  • ERROR --- [dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] threw exception
  • kr.dcodelab.bookstore.BookNotFoundException: 99번 책을 찾을 수 없다

500 이다.

3강에서 배운 약속이 있다. 4xx 는 손님이 잘못한 것이고, 5xx 는 서버가 잘못한 것이다. 없는 책을 달라고 한 것은 손님의 실수다. 그런데 서버가 "내 잘못이다"라고 답했다.

Spring 은 우리가 만든 예외를 모른다. 컨트롤러에서 예외가 튀어나오면 아는 게 없으니 500 을 내보낸다. 12강에서 /books/99IndexOutOfBoundsException 으로 500 을 내던 것과 정확히 같은 자리다.

orElseThrow 가 틀린 게 아니다. 던진 예외를 받아 404 로 옮겨줄 사람이 아직 없을 뿐이다.

없는 책을 어떻게 알릴 것인가
  1. 12강500 이 났다. 구멍을 짚고 넘어갔다
  2. 13강if (found == null) 로 404 를 손으로 만들었다
  3. 17강Optional 을 상자째 다뤄 404 를 만든다
  4. 21강예외를 던지고, 그것을 404 로 옮긴다
그래서 이 강의는 orElseGet 으로 남겨둔다. orElseThrow 는 받을 사람이 생기는 21강의 것이다.

그러니 지금은 orElseGet 으로 되돌린다. 21강에서 @ExceptionHandler 를 배우면 저 짧은 코드가 진짜로 짧아진다.

정리하면

findById 가 상자를 주는 것은 불친절이 아니다. 없을 수 있다는 사실을 타입에 적어 우리에게 건넨 것이다.

다시 짚어보기
  1. 01

    findByIdOptional<Book> 을 준다

    Book 으로 받으면 컴파일이 안 된다. 자바가 없는 경우를 생각하라고 붙잡은 것이다.

    Book found = repository.findById(id);
    // error: incompatible types: Optional<Book> cannot be converted to Book
  2. 02

    orElse(null) 은 13강으로 되돌아가는 문이다

    컴파일은 통과하지만 null 이 될 수 있는 변수가 되살아난다. 상자를 만든 이유가 사라진다.

  3. 03

    상자째 다룬다

    map 으로 응답을 만들고, 비었을 때 할 일은 orElseGet 에 적는다. null 이라는 글자가 코드에 없다.

    return repository.findById(id)
            .map(ResponseEntity::ok)
            .orElseGet(() -> ResponseEntity.notFound().build());
  4. 04

    괄호 안에서 일이 벌어지면 orElseGet

    orElse 의 인자는 값이 있어도 계산된다. 있는 책을 조회했는데 notFound() 가 불리는 것을 직접 봤다.

  5. 05

    고칠 때는 뜻이 담긴 메서드를 만든다

    setter 대신 changeTo 하나다. PUT 은 통째로 바꾸는 일이므로 메서드도 하나다.

  6. 06

    orElseThrow 는 아직 500 이다

    예외를 받아 404 로 옮겨줄 사람이 21강에서 온다.

CRUD 가 넷 다 돌아왔다. 데이터베이스 위에서 돈다. 껐다 켜도 남는다.

이제 이상한 것을 하나 볼 차례다. 방금 PUT 에서 이렇게 썼다.

found.changeTo(book.getTitle(), book.getPrice());
return repository.save(found);

고쳤으니 저장한다. 당연해 보인다.

그런데 found 는 우리가 new 로 만든 객체가 아니다. findById 가 데이터베이스에서 꺼내 준 객체다. 같은 책을 두 번 꺼내면 두 객체가 나올까, 하나가 나올까.

이 시리즈는 여섯 강에 걸쳐 == 로 비교하면 안 된다고 가르쳤다. 자바 편 14강까지 거슬러 올라가는 이야기다.

18강에서 그 말을 뒤집는다.

findById — 자바 편 20강의 Optional 이 여기서 일한다 — 디코드랩(DCODELAB)