JpaRepository — 인터페이스만 적으면 구현이 생긴다
Article
15강 끝에서 책 한 권을 저장했다. 이렇게 저장했다.
em.getTransaction().begin();
em.persist(book);
em.getTransaction().commit();
트랜잭션을 열고, 넣고, 닫는다. 목록을 읽으려면 질의문을 문자열로 적어야 했다.
1강에서 ServerSocket 으로 서버를 짜본 뒤, 4강에서 다시는 안 쓰게 됐다. 여기서도 같은 일이 벌어진다.
인터페이스 하나를 적는다
파일을 하나 만든다. BookRepository.java 다.
public interface BookRepository extends JpaRepository<Book, Long> {
}
이게 전부다. 중괄호 안이 비어 있다.
메서드를 하나도 적지 않았다. @Component 도, @Repository 도, 표식을 하나도 안 붙였다. 클래스도 아니다. 인터페이스다.
띄워보면 기동 로그에 새로운 줄이 하나 지나간다.
- INFO --- RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
- INFO --- RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 36 ms. Found 1 JPA repository interface.
하나를 찾았다고 한다. 우리가 방금 적은 그것이다.
자바 편 15강은 안 된다고 했다
잠깐. 인터페이스는 몸통이 없다. 자바 편 15강에서 직접 확인했다.
Notifier n = new Notifier();
- error: Notifier is abstract; cannot be instantiated
그리고 이렇게 정리했다.
타입으로만 쓴다. 상자의 이름표로는 쓰고, 내용물로는 못 쓴다.
그런데 지금 우리는 몸통이 없는 BookRepository 로 데이터베이스를 만지려 한다. 내용물이 없는데 무슨 수로.
꺼내서 확인해보자. 5강에서 배운 getBean 이다.
BookRepository repo = ctx.getBean(BookRepository.class);
System.out.println("### 우리가 적은 메서드 수 = " + BookRepository.class.getDeclaredMethods().length);
System.out.println("### 쓸 수 있는 메서드 수 = " + BookRepository.class.getMethods().length);
System.out.println("### getBean 이 준 것 = " + repo.getClass().getName());
System.out.println("### JDK 동적 프록시인가? " + Proxy.isProxyClass(repo.getClass()));
- ### 우리가 적은 메서드 수 = 0
- ### 쓸 수 있는 메서드 수 = 37
- ### getBean 이 준 것 = jdk.proxy2.$Proxy116
- ### JDK 동적 프록시인가? true
우리가 적은 메서드는 0개다. 쓸 수 있는 메서드는 37개다.
그리고 getBean 이 돌려준 물건의 이름이 이상하다. $Proxy116. 우리가 지은 이름이 아니다. 사람이 지은 이름이 아니다.
자바는 실행 중에 인터페이스를 구현한 클래스를 즉석에서 만들어낼 수 있다. 이름을 지어줄 사람이 없으니 번호를 붙인다. 그 물건을 프록시라고 부른다.
한 겹 더 벗기면 진짜 일꾼이 나온다.
- ### 프록시 뒤의 실제 객체 = org.springframework.data.jpa.repository.support.SimpleJpaRepository
SimpleJpaRepository. Spring Data 안에 들어 있는 클래스다. 15강에서 우리가 손으로 쓴 em.persist 와 em.getTransaction() 이 저 안에 이미 적혀 있다. 누군가 대신 써둔 것이다.
- BookRepository우리가 적었다. 텅 비었다
- $Proxy116Spring 이 실행 중에 만들었다
- SimpleJpaRepository진짜 코드는 여기 있다
- EntityManager15강에서 우리가 손으로 쓴 그것
자바 편 15강은 여전히 옳다. 우리는 지금도 new BookRepository() 를 할 수 없다. 해본 적도 없다. BookRepository 는 상자의 이름표로만 쓰였고, 내용물은 다른 것이 들어왔다. 달라진 것은 하나다. 그 내용물을 우리가 만들지 않았다.
표식이 없는데 어떻게 빈이 됐나
7강에서 우리는 Spring 의 스캐너를 직접 꺼내 돌려봤다. @Component 가 붙은 클래스를 찾아내는 물건이었다.
같은 스캐너를 지금 다시 돌려본다.
- ### 7강 스캐너가 찾은 것: kr.dcodelab.bookstore.BookController
- ### 7강 스캐너가 찾은 것: kr.dcodelab.bookstore.BookstoreApplication
- ### 7강 스캐너가 찾은 것: kr.dcodelab.bookstore.messaging.EmailNotifier
- ### 7강 스캐너가 찾은 것: kr.dcodelab.bookstore.messaging.SmsNotifier
- ### 7강 스캐너가 찾은 것: kr.dcodelab.bookstore.OrderService
- ### BookRepository 에 붙은 표식 수 = 0
BookRepository 가 없다. 표식이 하나도 안 붙었으니 당연하다.
그런데도 컨테이너에는 들어 있다. 이름까지 붙어서.
- ### 빈 이름들 = [bookRepository]
bookRepository. 5강에서 본 그 규칙 그대로다 — 이름의 첫 글자를 소문자로 내린 것.
답은 아까 지나간 기동 로그에 있었다. Found 1 JPA repository interface. Spring Data 가 따로 훑는다. 표식을 찾는 게 아니라, JpaRepository 를 상속한 인터페이스를 찾는다.
- 01
Spring Data 가 패키지를 훑는다
7강의 컴포넌트 스캔과는 다른 스캔이다. 표식이 아니라
JpaRepository상속을 본다. - 02
BookRepository를 찾는다기동 로그의
Found 1 JPA repository interface가 이 순간이다. - 03
그 인터페이스를 구현한 프록시를 만든다
실행 중에 만든다. 이름은
$Proxy116같은 것이 붙는다. - 04
bookRepository라는 이름으로 컨테이너에 담는다이 뒤로는 5강 · 6강에서 배운 대로 꺼내 쓰면 된다.
7강에서 @Repository 라는 표식을 곧 만나게 된다고 했다. 여기에는 안 붙였다. 붙여도 동작하지만 필요가 없다. 상속만으로 충분하다.
꺾쇠 안의 두 글자
public interface BookRepository extends JpaRepository<Book, Long> {
}
꺾쇠 안에 둘이 들어 있다. 자바 편 17강에서 봤다.
둘 이상도 된다.
class Pair<K, V> {
K key;
V value;
Pair(K k, V v) { key = k; value = v; }
}
JpaRepository 도 둘을 받는다. 어떤 엔티티를 다루는지, 그리고 그 엔티티의 번호표가 무슨 타입인지.
JpaRepository<Book, Long>첫째 — Book
무엇을 다루는가
- 15강에서 만든
@Entity클래스 book표의 행 하나에 대응한다findAll()이List<Book>을 준다
둘째 — Long
번호표가 무슨 타입인가
Book의@Id필드 타입findById(Long id)의 인자가 된다long이 아니라Long인 이유가 있다
long 이 아니라 Long 인 것에 이유가 있다. 자바 편 17강이 못박았다.
꺾쇠 안에는 참조형만 들어간다.
꺾쇠 안에 기본형은 못 들어간다. 그래서 Long 이다. 그리고 자바 편 17강은 한 걸음 더 갔다.
다만
Integer는 참조형이다.null이 될 수 있다.
Long 도 마찬가지다. 15강에서 persist 하기 전의 id 가 null 이었던 것을 기억하는지.
- ### persist 전 id = null
- ### persist 후 id = 5
id 가 long 이었다면 저 null 이 불가능하다. 기본형에는 null 이 없으니 저장 전에도 0 이었을 것이고, 그러면 아직 번호를 못 받은 책과 0번 책을 구별할 수 없다.
37개의 메서드
우리가 적지 않은 37개 중에 이번 강의에서 쓸 것만 본다.
| 메서드 | 주면 | 돌려주는 것 |
|---|---|---|
findAll() | — | List<Book> |
save(book) | Book | 저장된 Book (번호가 붙어서) |
existsById(id) | Long | boolean |
deleteById(id) | Long | 없음 |
count() | — | long — 표에 있는 행 수 |
findById(id) | Long | Optional<Book> — 이 줄만 다르다 |
꺾쇠에 Book 이라고 적었더니 findAll() 이 List<Book> 을 돌려준다. Book 이라고 적어둔 것이 여기서 일한다.
컨트롤러에 꽂는다
15강까지 BookController 는 이렇게 비어 있었다.
@GetMapping
public List<Book> list() {
return List.of(); // 16강에서 데이터베이스와 잇는다
}
이제 잇는다. 리포지토리를 생성자로 받는다. 6강 그대로다.
@RestController
@RequestMapping("/books")
public class BookController {
private final BookRepository repository;
public BookController(BookRepository repository) {
this.repository = repository;
}
@GetMapping
public List<Book> list() {
return repository.findAll();
}
}
@Autowired 는 안 붙였다. 6강에서 생성자가 하나뿐이면 생략할 수 있다고 했다.
주입되는 것은 $Proxy116 이다. 컨트롤러는 그걸 모른다. BookRepository 라는 이름표만 보고 쓴다. 자바 편 15강의 OrderService 가 Notifier 를 받던 그 모양 그대로다.
C — 만든다
@PostMapping
public ResponseEntity<Book> create(@RequestBody Book book) {
Book saved = repository.save(book);
return ResponseEntity.created(URI.create("/books/" + saved.getId())).body(saved);
}
13강의 create 와 비교하면 한 줄이 바뀌었다. store.put(...) 이 repository.save(...) 가 됐다.
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
- Transfer-Encoding: chunked
- {"title":"자바","price":20000,"id":1}
id 가 붙어서 돌아왔다. 우리는 보낸 적이 없다.
13강에서는 nextId++ 로 서버가 번호를 매겼다. 15강에서 그 권한이 데이터베이스로 내려갔다. save() 가 돌려주는 Book 에는 데이터베이스가 붙인 번호가 들어 있다. 15강의 persist 뒤에 id 가 채워지던 것과 같은 일이다.
두 권 더 넣었다.
- $ curl -X POST ... -d '{"title":"스프링","price":32000}'
- {"title":"스프링","price":32000,"id":2}
- $ curl -X POST ... -d '{"title":"HTTP","price":15000}'
- {"title":"HTTP","price":15000,"id":3}
키 순서가 13강과 다르다. id 가 뒤에 붙었다. JSON 객체에서 키의 순서는 뜻이 없다. 읽는 쪽은 이름으로 찾지 자리로 찾지 않는다.
R — 읽는다
@GetMapping
public List<Book> list() {
return repository.findAll();
}
- $ curl http://localhost:8080/books
- [{"title":"자바","price":20000,"id":1},{"title":"스프링","price":32000,"id":2},{"title":"HTTP","price":15000,"id":3}]
14강 이후로 계속 [] 이던 그 주소다.
14강에서 우리는 데이터베이스에 책을 두 권 넣어놓고도 GET /books 가 빈 배열을 돌려주는 것을 봤다. 컨트롤러가 자기 Map 을 보고 있었기 때문이다. 이제 표를 본다.
D — 지운다
deleteById(id) 를 부르면 된다. 없는 번호를 주면 어떻게 되는지부터 보자.
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
repository.deleteById(id);
return ResponseEntity.noContent().build();
}
- $ curl -o /dev/null -w '%{http_code}' -X DELETE http://localhost:8080/books/99
- 204 ← 없는 책을 지웠는데 성공이라고 한다
에러가 안 난다. 조용히 아무 일도 안 하고 성공했다고 답한다.
14강에서 이미 봤던 태도다. SQL 로 없는 행을 지웠을 때 이렇게 나왔다.
에러가 아니다. 지운 게 0건이고, 읽은 게 0건일 뿐이다.
deleteById 는 그 태도를 그대로 물려받았다. 문제는 13강에서 우리가 다른 약속을 했다는 것이다. 없는 책을 지우라고 하면 404.
existsById 로 먼저 물어본다.
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!repository.existsById(id)) return ResponseEntity.notFound().build();
repository.deleteById(id);
return ResponseEntity.noContent().build();
}
- $ curl -o /dev/null -w '%{http_code}' -X DELETE http://localhost:8080/books/99
- 404
- $ curl -o /dev/null -w '%{http_code}' -X DELETE http://localhost:8080/books/2
- 204
- $ curl -o /dev/null -w '%{http_code}' -X DELETE http://localhost:8080/books/2
- 404
13강과 같은 답이다. 204 뒤에 404. 3강에서 DELETE 는 멱등하다고 했고, 두 번 다 서버의 상태는 "그 책 없음"이니 약속을 지켰다.
2번 책을 지운 뒤 목록을 본다.
- $ curl http://localhost:8080/books
- [{"title":"자바","price":20000,"id":1},{"title":"HTTP","price":15000,"id":3}]
1 과 3 이 남았다. HTTP 는 여전히 3번이다. 13강을 열었던 그 사고 — 앞의 책을 지우면 뒤의 번호가 당겨지던 것 — 가 데이터베이스에서도 일어나지 않는다.
서버를 껐다 켠다
13강은 이 장면으로 끝났다.
- $ curl http://localhost:8080/books
- []
Ctrl+C 로 서버를 끄고, 다시 켠다.
- ### 지금 표에 있는 책 수 = 2
- $ curl http://localhost:8080/books
- [{"title":"자바","price":20000,"id":1},{"title":"HTTP","price":15000,"id":3}]
그대로 있다.
13강에서 "메모리에 담은 것은 서버가 죽으면 사라진다"고 했다. 14강에서 데이터베이스가 그것을 받아 적었고, 15강에서 Book 이 표와 이어졌고, 16강에서 컨트롤러가 마침내 그 표를 본다. 14강이 열어둔 간극이 여기서 메워진다.
함정 — save() 는 persist 가 아니다
한 가지가 남았다. 13강에서 이렇게 못박았다.
번호를 붙이는 것은 서버의 권한이다.
클라이언트가 {"id":99,...} 를 보내도 무시했다. 지금도 그런지 확인해보자. 컨트롤러가 받은 것을 찍어봤다.
System.out.println("### 컨트롤러가 받은 것: id=" + book.getId()
+ " title=" + book.getTitle() + " price=" + book.getPrice());
- $ curl -X POST ... -d '{"id":1,"title":"덮어썼다","price":1}'
- ### 컨트롤러가 받은 것: id=1 title=덮어썼다 price=1
- HTTP/1.1 201
- Location: /books/1
- {"title":"덮어썼다","price":1,"id":1}
id 가 그대로 들어왔다. 그리고 201 Created 가 돌아왔다. 만들었다는 뜻이다.
목록을 보자.
- $ curl http://localhost:8080/books
- [{"title":"덮어썼다","price":1,"id":1},{"title":"HTTP","price":15000,"id":3}]
책이 한 권도 안 늘었다. 1번 책이 덮어씌워졌다. POST 로 만들라고 했는데 수정이 일어났다.
없는 번호를 보내면 더 나쁘다.
- $ curl -X POST ... -d '{"id":99,"title":"유령","price":1}'
- {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/books"}
- # 서버 로그
- ObjectOptimisticLockingFailureException: Row was already updated or deleted
- by another transaction for entity [kr.dcodelab.bookstore.Book with id '99']
지금은 구멍인 채로 둔다. 12강에서 /books/99 가 500 을 내던 구멍을 21강까지 열어뒀던 것처럼.
정리하면
인터페이스는 여전히 new 할 수 없다. 다만 우리가 new 할 필요가 없어졌다.
- 01
빈 인터페이스 하나가 리포지토리가 된다
표식도 필요 없다.
JpaRepository를 상속하는 것이 방아쇠다.public interface BookRepository extends JpaRepository<Book, Long> { } - 02
구현체는 Spring 이 실행 중에 만든다
getBean으로 꺼내 이름을 찍어보면$Proxy116이 나온다. 그 뒤에SimpleJpaRepository가 있다. 자바 편 15강의 "이름표로만 쓴다"가 지켜지고 있다. - 03
꺾쇠 안의 둘은 엔티티와 번호표 타입이다
Long인 것은 꺾쇠 안에 기본형이 못 들어가기 때문이고, 덕분에 저장 전id가null일 수 있다. - 04
컨트롤러는 생성자로 받는다
6강 그대로다. 받는 쪽은 그것이 프록시인지 모른다.
public BookController(BookRepository repository) { this.repository = repository; } - 05
deleteById는 없는 행에 조용하다14강의
Update count: 0과 같은 태도다.404를 지키려면existsById로 먼저 물어본다. - 06
이제 서버를 껐다 켜도 책이 남는다
13강이 남긴 마지막 숙제가 끝났다.
CRUD 넷 중 셋을 되살렸다. 만들고, 목록을 읽고, 지웠다.
빠진 것이 있다. 한 권만 읽기다. 13강에서는 이렇게 적었다.
Book found = store.get(id);
if (found == null) return ResponseEntity.notFound().build();
findById 로 바꾸면 될 것 같은데, 그럴 수 없다. 위의 표를 다시 보자. findById 가 돌려주는 것은 Book 이 아니다.
Optional<Book> findById(Long id);
Optional<Book> 이다. 상자에 담겨서 온다.
자바 편 20강에서 이 상자를 열어봤다. 그때는 null 을 다루는 문법 하나였다. 17강에서 그것이 왜 여기 있는지 본다.