메모리로 만드는 CRUD — 아직 데이터베이스는 없다
Article
12강에서 재료를 다 모았다. 이제 조립한다.
CRUD 는 네 글자다. Create(만들고), Read(읽고), Update(고치고), Delete(지운다). 웹 API 가 하는 일의 거의 전부다.
이번 강의에서 그 넷을 만든다. 데이터베이스는 안 쓴다. 자바 편 16강에서 배운 컬렉션 하나면 된다.
먼저, 번호를 어떻게 붙일 것인가
12강에서는 리스트의 몇 번째인지(인덱스)로 책을 찾았다.
@GetMapping("/books/{index}")
public Book one(@PathVariable int index) {
return books.get(index);
}
읽기만 할 때는 문제가 없었다. 그런데 지우기 시작하면 곧바로 무너진다. 직접 해봤다.
- $ curl http://localhost:8080/books/2
- {"title":"HTTP","price":15000}
- $ curl -X DELETE http://localhost:8080/books/0 # 0번(자바)을 지운다
- $ curl http://localhost:8080/books/2
- 500 ← 방금 있던 책이 사라졌다
- $ curl http://localhost:8080/books/1
- {"title":"HTTP","price":15000} ← 여기로 옮겨왔다
앞의 책을 하나 지웠더니 뒤에 있던 책들의 번호가 전부 한 칸씩 당겨졌다. HTTP 라는 책은 아무 잘못이 없는데 주소가 바뀌었다.
누군가 /books/2 를 즐겨찾기에 넣어뒀다면, 다음에 눌렀을 때 다른 책이 나오거나 없다고 나온다.
인덱스는 순서일 뿐 신원이 아니다. 그래서 책마다 번호표를 붙인다. 한 번 받으면 끝까지 그 번호다.
public record Book(Long id, String title, int price) { }
10강에서 만든 record 에 id 하나가 늘었다.
번호는 서버가 매긴다
책을 새로 만들 때 클라이언트는 번호를 모른다. 몇 번째 책이 될지는 서버만 안다. 3강에서 이렇게 배웠다.
새로 만들 때만
/books이고 나머지는/books/1인 것에 주목할 것. 아직 만들지 않은 책에는 번호가 없기 때문이다. 번호는 서버가 붙여준다.
그래서 서버가 번호를 하나씩 늘려가며 붙인다.
private final Map<Long, Book> store = new LinkedHashMap<>();
private long nextId = 1;
자바 편 16강의 Map 이다. 번호를 키로, 책을 값으로 담는다. 번호로 찾으니 List 보다 자연스럽다.
공통 경로를 클래스에 적는다
네 동작의 주소가 전부 /books 로 시작한다. 매번 적기 번거롭다.
클래스 위에 한 번 적으면 된다.
@RestController
@RequestMapping("/books")
public class BookController {
@GetMapping // GET /books
@GetMapping("/{id}") // GET /books/1
@PostMapping // POST /books
...
}
7강에서 @GetMapping 이 @RequestMapping(method = GET) 을 짧게 쓴 것이라고 했다. 그 @RequestMapping 을 클래스에 붙이면 공통 경로가 된다. 메서드에 적은 것이 뒤에 이어 붙는다.
상태코드를 직접 정한다
지금까지 우리는 값만 돌려줬고 상태코드는 언제나 200 이었다. 그런데 3강에서 배운 대로, 만들었으면 201 이고 지웠으면 204 다.
상태코드와 헤더를 직접 정하려면 ResponseEntity 로 감싸서 돌려준다.
| 이렇게 쓰면 | 이런 응답이 나간다 |
|---|---|
ResponseEntity.ok(book) | 200 + 본문 |
ResponseEntity.created(주소).body(book) | 201 + Location 헤더 + 본문 |
ResponseEntity.noContent().build() | 204 + 본문 없음 |
ResponseEntity.notFound().build() | 404 + 본문 없음 |
C — 만든다
@PostMapping
public ResponseEntity<Book> create(@RequestBody Book book) {
Book saved = new Book(nextId++, book.title(), book.price());
store.put(saved.id(), saved);
return ResponseEntity.created(URI.create("/books/" + saved.id())).body(saved);
}
record 는 못 고치니(자바 편 12강) 번호를 붙인 새 책을 만들어 담는다.
curl -i -X POST http://localhost:8080/books \
-H 'Content-Type: application/json' \
-d '{"title":"자바","price":20000}'
- HTTP/1.1 201
- Location: /books/1
- Content-Type: application/json
- {"id":1,"title":"자바","price":20000}
3강에서 201 Created 는 **"만들었고, 그건 여기 있다"**까지 알려준다고 했다. Location: /books/1 이 그 "여기"다. 클라이언트는 번호를 몰랐는데 이제 안다.
R — 읽는다
목록과 한 권.
@GetMapping
public List<Book> list() {
return new ArrayList<>(store.values());
}
@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);
}
- $ curl http://localhost:8080/books
- [{"id":1,"title":"자바","price":20000},{"id":2,"title":"스프링","price":32000}]
- $ curl http://localhost:8080/books/1
- {"id":1,"title":"자바","price":20000}
- $ curl -o /dev/null -w '%{http_code}' http://localhost:8080/books/99
- 404
404 다. 12강에서 없는 번호를 달라고 하면 500 이 나왔던 그 구멍을 메웠다. if 문 하나로.
자바 편 16강에서 map.get() 은 없는 키를 주면 조용히 null 을 돌려준다고 했다. 그 null 을 우리가 붙잡아 404 로 바꿨다.
U — 고친다
@PutMapping("/{id}")
public ResponseEntity<Book> update(@PathVariable Long id, @RequestBody Book book) {
if (!store.containsKey(id)) return ResponseEntity.notFound().build();
Book updated = new Book(id, book.title(), book.price());
store.put(id, updated);
return ResponseEntity.ok(updated);
}
번호는 주소에서 오고, 새 내용은 본문에서 온다. 12강과 11강이 한 메서드에서 만난다.
- $ curl -X PUT http://localhost:8080/books/1 -H 'Content-Type: application/json' -d '{"title":"자바","price":18000}'
- {"id":1,"title":"자바","price":18000}
가격이 바뀌었다. 번호는 그대로다.
D — 지운다
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (store.remove(id) == null) return ResponseEntity.notFound().build();
return ResponseEntity.noContent().build();
}
- $ curl -i -X DELETE http://localhost:8080/books/1
- HTTP/1.1 204
- (본문 없음)
- $ curl -o /dev/null -w '%{http_code}' -X DELETE http://localhost:8080/books/1
- 404
지웠으니 돌려줄 것이 없다. 204 No Content 다. ResponseEntity<Void> 의 Void 가 "본문 타입이 없다"는 뜻이다.
3강의 약속을 지켰는지 확인한다
3강에서 메서드마다 멱등성을 실측했다. 우리 API 도 그런지 보자.
| 동작 | 결과 | 3강의 약속 |
|---|---|---|
POST /books 두 번 | 책이 두 권 생긴다 (id 1, 2) | 멱등하지 않다 ✓ |
PUT /books/1 두 번 | 가격이 같다. 한 권 그대로 | 멱등하다 ✓ |
DELETE /books/1 두 번 | 책은 없다. 상태는 204 → 404 | 멱등하다 ✓ |
POST 를 두 번 보내면 제목이 같은 책이 두 권 생긴다. id 만 다르다. 3강에서 결제 버튼을 두 번 누르면 두 번 결제된다고 했던 그 이야기다.
저장소를 필드에 둬도 되나
5강에서 이렇게 못박았다.
빈에는 하는 일만 담고, 다루는 값은 메서드 인자로 받는다.
그런데 지금 store 를 필드에 두고 있다. 규칙을 어긴 건가.
아니다. 5강이 말한 것은 요청마다 달라지는 값이다. "지금 요청한 사람이 찾는 번호" 같은 것. 그건 12강에서 배운 대로 메서드 인자로 받는다.
store 는 다르다. 모든 요청이 함께 보는 하나의 저장소다. 빈이 하나뿐이라서 오히려 잘 맞는다. 두 사람이 각각 책을 넣어도 같은 상자에 들어간다.
완성됐다. 그리고 서버를 껐다 켠다
책 세 권을 넣어두고, 잘 도는 것을 확인하고, Ctrl+C 로 서버를 껐다.
다시 켠다.
- $ curl http://localhost:8080/books
- []
전부 사라졌다.
당연하다. store 는 그냥 자바 객체였고, 프로그램이 끝나면 메모리에서 지워진다. 1강에서 main 이 끝나면 프로그램이 죽는다고 했다. 죽으면 들고 있던 것도 같이 사라진다.
- 서버를 켠다store 는 빈 Map
- 책을 넣는다메모리 안에 있다
- 서버를 끈다
- 다시 켠다아무것도 없다
정리하면
표식 몇 개와 Map 하나로 CRUD 가 완성된다. 다만 서버를 끄면 끝이다.
- 01
인덱스가 아니라 번호표를 붙인다
앞의 것을 지우면 뒤의 인덱스가 밀린다.
id는 한 번 받으면 끝까지 그 번호다. - 02
번호는 서버가 매긴다
클라이언트가
id를 보내도 무시한다. 만들 때만/books, 나머지는/books/{id}.Book saved = new Book(nextId++, book.title(), book.price()); - 03
공통 경로는 클래스에 한 번 적는다
@RequestMapping("/books")를 클래스에 붙이면 메서드의 경로가 뒤에 이어진다. - 04
상태코드는
ResponseEntity로 정한다만들었으면
201과Location, 지웠으면204, 없으면404.return ResponseEntity.created(URI.create("/books/" + saved.id())).body(saved); - 05
메모리에 담은 것은 서버가 죽으면 사라진다
배포 한 번에 데이터가 전부 없어진다. 그래서 데이터베이스가 필요하다.
Part 3 이 끝났다.
9강에서 첫 컨트롤러를 만들었고, 10강과 11강에서 객체와 JSON 사이를 오갔고, 12강에서 주소를 뜯었고, 13강에서 넷을 합쳐 CRUD 를 완성했다.
이제 우리 API 는 3강의 약속을 거의 다 지킨다. 경로는 명사고, 메서드는 동사고, 상태코드는 결과다.
딱 하나가 남았다. 아무것도 남지 않는다.
Ctrl+C 한 번이면 손님이 등록한 책이 전부 사라진다. 서버를 재배포할 때마다 그렇다. 어딘가에 적어둬야 한다.
14강에서 데이터베이스를 만난다. 겁먹을 것 없다. 설치할 것도 없다.