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/1 은 200 이고 /books/99 는 404 다.
그런데 무엇이 남았는지 보자.
99번 책은 없다
- Optional<Book> o
- Optional.empty
- → 비어 있는 상자. 상자는 실재한다
- Book found
- null
- → orElse(null) 로 꺼낸 뒤. 건드리면 NPE
orElse(null) 을 부른 순간 found 는 다시 null 이 될 수 있는 변수다. 자바 편 20강이 무엇을 문제 삼았는지 다시 읽어보자.
문제는
null을 돌려준 게 아니다. 메서드의 생김새 어디에도 "없을 수 있다"고 적혀 있지 않다는 것이다.
findById 의 생김새는 사실을 말하고 있었다. Optional<Book> 이라고 적혀 있었다. 우리가 그것을 한 줄로 지웠다.
지금은 if 를 잊지 않았으니 아무 일도 없다. 다음 줄에 found.getTitle() 을 적는 사람은 컴파일러에게 아무 경고도 못 듣는다. orElse(null) 은 검사를 잊을 자유를 되돌려준다. 그게 아까운 것이다.
상자를 부순다
orElse(null)
Book found는null일 수 있는 변수다if (found == null)을 사람이 기억해서 적는다- 잊어도 컴파일된다
- 13강과 코드가 똑같다
상자째 다룬다
map · orElseGet
Book이라는 변수가 아예 없다- 없는 경우를 적지 않으면 컴파일이 안 된다
null이라는 글자가 코드에서 사라진다- 16강이 준 타입을 그대로 쓴다
상자째 다룬다
자바 편 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());
}
세 줄을 하나씩 읽는다.
- 01
repository.findById(id)Optional<Book>이 나온다. 책이 든 상자이거나 빈 상자다. - 02
.map(ResponseEntity::ok)상자에 책이 있으면
ResponseEntity.ok(책)으로 바꾼다. 비어 있으면 아무 일도 안 한다. 결과는Optional<ResponseEntity<Book>>— 상자는 그대로 있고 안의 것만 바뀌었다. - 03
.orElseGet(() -> ...notFound()...)여기서 상자가 열린다. 안에 있으면 그것을 내놓고, 비어 있으면
404를 만들어 내놓는다.
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) 처럼 상수라면 | 차이 없다 | 차이 없다 |
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강에서 Book 은 record 를 그만두고 클래스가 됐다. 이제 고칠 수 있다.
그런데 15강에서 이렇게 못박았다.
setter 는 안 만들었다. 바꿀 일이 생기면 그때 뜻이 담긴 메서드를 만든다.
바꿀 일이 생겼다. 만든다.
public void changeTo(String title, int price) {
this.title = title;
this.price = price;
}
setTitle 과 setPrice 를 따로 만들지 않았다. 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/99 가 IndexOutOfBoundsException 으로 500 을 내던 것과 정확히 같은 자리다.
orElseThrow 가 틀린 게 아니다. 던진 예외를 받아 404 로 옮겨줄 사람이 아직 없을 뿐이다.
- 12강500 이 났다. 구멍을 짚고 넘어갔다
- 13강if (found == null) 로 404 를 손으로 만들었다
- 17강Optional 을 상자째 다뤄 404 를 만든다
- 21강예외를 던지고, 그것을 404 로 옮긴다
그러니 지금은 orElseGet 으로 되돌린다. 21강에서 @ExceptionHandler 를 배우면 저 짧은 코드가 진짜로 짧아진다.
정리하면
findById 가 상자를 주는 것은 불친절이 아니다. 없을 수 있다는 사실을 타입에 적어 우리에게 건넨 것이다.
- 01
findById는Optional<Book>을 준다Book으로 받으면 컴파일이 안 된다. 자바가 없는 경우를 생각하라고 붙잡은 것이다.Book found = repository.findById(id); // error: incompatible types: Optional<Book> cannot be converted to Book - 02
orElse(null)은 13강으로 되돌아가는 문이다컴파일은 통과하지만
null이 될 수 있는 변수가 되살아난다. 상자를 만든 이유가 사라진다. - 03
상자째 다룬다
map으로 응답을 만들고, 비었을 때 할 일은orElseGet에 적는다.null이라는 글자가 코드에 없다.return repository.findById(id) .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); - 04
괄호 안에서 일이 벌어지면
orElseGetorElse의 인자는 값이 있어도 계산된다. 있는 책을 조회했는데notFound()가 불리는 것을 직접 봤다. - 05
고칠 때는 뜻이 담긴 메서드를 만든다
setter 대신
changeTo하나다.PUT은 통째로 바꾸는 일이므로 메서드도 하나다. - 06
orElseThrow는 아직500이다예외를 받아
404로 옮겨줄 사람이 21강에서 온다.
CRUD 가 넷 다 돌아왔다. 데이터베이스 위에서 돈다. 껐다 켜도 남는다.
이제 이상한 것을 하나 볼 차례다. 방금 PUT 에서 이렇게 썼다.
found.changeTo(book.getTitle(), book.getPrice());
return repository.save(found);
고쳤으니 저장한다. 당연해 보인다.
그런데 found 는 우리가 new 로 만든 객체가 아니다. findById 가 데이터베이스에서 꺼내 준 객체다. 같은 책을 두 번 꺼내면 두 객체가 나올까, 하나가 나올까.
이 시리즈는 여섯 강에 걸쳐 == 로 비교하면 안 된다고 가르쳤다. 자바 편 14강까지 거슬러 올라가는 이야기다.
18강에서 그 말을 뒤집는다.