학습 자료

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.persistem.getTransaction() 이 저 안에 이미 적혀 있다. 누군가 대신 써둔 것이다.

우리가 적은 것과 실제로 도는 것
  1. BookRepository우리가 적었다. 텅 비었다
  2. $Proxy116Spring 이 실행 중에 만들었다
  3. SimpleJpaRepository진짜 코드는 여기 있다
  4. EntityManager15강에서 우리가 손으로 쓴 그것
우리는 이름표만 적었고, 내용물은 Spring 이 넣었다.

자바 편 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 를 상속한 인터페이스를 찾는다.

빈이 되기까지
  1. 01

    Spring Data 가 패키지를 훑는다

    7강의 컴포넌트 스캔과는 다른 스캔이다. 표식이 아니라 JpaRepository 상속을 본다.

  2. 02

    BookRepository 를 찾는다

    기동 로그의 Found 1 JPA repository interface 가 이 순간이다.

  3. 03

    그 인터페이스를 구현한 프록시를 만든다

    실행 중에 만든다. 이름은 $Proxy116 같은 것이 붙는다.

  4. 04

    bookRepository 라는 이름으로 컨테이너에 담는다

    이 뒤로는 5강 · 6강에서 배운 대로 꺼내 쓰면 된다.

표식을 붙이는 것이 방아쇠가 아니다. JpaRepository 를 상속하는 것이 방아쇠다.

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 인 이유가 있다
엔티티가 늘어나면 리포지토리도 늘어난다. 회원 표에는 interface MemberRepository extends JpaRepository<Member, Long> 가 붙는다.

long 이 아니라 Long 인 것에 이유가 있다. 자바 편 17강이 못박았다.

꺾쇠 안에는 참조형만 들어간다.

꺾쇠 안에 기본형은 못 들어간다. 그래서 Long 이다. 그리고 자바 편 17강은 한 걸음 더 갔다.

다만 Integer 는 참조형이다. null 이 될 수 있다.

Long 도 마찬가지다. 15강에서 persist 하기 전의 idnull 이었던 것을 기억하는지.

15강의 터미널
  • ### persist 전 id = null
  • ### persist 후 id = 5

idlong 이었다면 저 null 이 불가능하다. 기본형에는 null 이 없으니 저장 전에도 0 이었을 것이고, 그러면 아직 번호를 못 받은 책0번 책을 구별할 수 없다.

37개의 메서드

우리가 적지 않은 37개 중에 이번 강의에서 쓸 것만 본다.

상속받은 메서드
메서드주면돌려주는 것
findAll()List<Book>
save(book)Book저장된 Book (번호가 붙어서)
existsById(id)Longboolean
deleteById(id)Long없음
count()long — 표에 있는 행 수
findById(id)LongOptional<Book> — 이 줄만 다르다
마지막 줄만 낯설다. Book 이 아니라 Optional<Book> 이다. 그건 17강의 몫이다.

꺾쇠에 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강의 OrderServiceNotifier 를 받던 그 모양 그대로다.

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}]

13 이 남았다. HTTP 는 여전히 3번이다. 13강을 열었던 그 사고 — 앞의 책을 지우면 뒤의 번호가 당겨지던 것 — 가 데이터베이스에서도 일어나지 않는다.

서버를 껐다 켠다

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/99500 을 내던 구멍을 21강까지 열어뒀던 것처럼.

정리하면

인터페이스는 여전히 new 할 수 없다. 다만 우리가 new 할 필요가 없어졌다.

다시 짚어보기
  1. 01

    빈 인터페이스 하나가 리포지토리가 된다

    표식도 필요 없다. JpaRepository 를 상속하는 것이 방아쇠다.

    public interface BookRepository extends JpaRepository<Book, Long> {
    }
  2. 02

    구현체는 Spring 이 실행 중에 만든다

    getBean 으로 꺼내 이름을 찍어보면 $Proxy116 이 나온다. 그 뒤에 SimpleJpaRepository 가 있다. 자바 편 15강의 "이름표로만 쓴다"가 지켜지고 있다.

  3. 03

    꺾쇠 안의 둘은 엔티티와 번호표 타입이다

    Long 인 것은 꺾쇠 안에 기본형이 못 들어가기 때문이고, 덕분에 저장 전 idnull 일 수 있다.

  4. 04

    컨트롤러는 생성자로 받는다

    6강 그대로다. 받는 쪽은 그것이 프록시인지 모른다.

    public BookController(BookRepository repository) {
        this.repository = repository;
    }
  5. 05

    deleteById 는 없는 행에 조용하다

    14강의 Update count: 0 과 같은 태도다. 404 를 지키려면 existsById 로 먼저 물어본다.

  6. 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강에서 그것이 왜 여기 있는지 본다.

JpaRepository — 인터페이스만 적으면 구현이 생긴다 — 디코드랩(DCODELAB)