학습 자료

DTO — 엔티티를 그대로 내보내지 않는다


Article

책 세 권이 들어 있다. 자바 · 스프링 · HTTP 다.

새 책을 하나 만들자고 POST 를 보낸다. 그런데 손님이 본문에 id 를 적어 보냈다.

curl -i -X POST http://localhost:8080/books \
  -H 'Content-Type: application/json' \
  -d '{"id":1,"title":"머지","price":9000}'
터미널
  • HTTP/1.1 201
  • Location: /books/1
  • Content-Type: application/json
  • $ curl http://localhost:8080/books/1
  • {"title":"머지","price":9000,"id":1}

201 Created 라고 답했다. Location 까지 붙여서 "만들었고, 그건 여기 있다"고 알려줬다.

아무것도 안 만들었다. 1번 책이었던 자바 가 사라지고 그 자리에 머지 가 들어앉았다.

13강에서 이렇게 못박았다.

번호를 붙이는 것은 서버의 권한이다.

지금 그 권한은 손님에게 있다.

왜 손님이 번호를 정하게 됐나

컨트롤러를 다시 본다.

@PostMapping
public ResponseEntity<Book> create(@RequestBody Book book) {
    Book saved = repository.save(book);
    ...
}

@RequestBody Book 이다. 손님이 보낸 JSON 이 Book 이 된다. 그리고 Book 은 15강에서 @Entity 가 붙은, book 표의 한 행이다.

16강에서 이미 짚어뒀다.

13강의 약속이 깨진 진짜 원인은 따로 있다. 요청 본문을 엔티티로 그대로 받았기 때문이다. 손님이 보낸 JSON 이 표의 행에 곧바로 닿아 있다.

saveid 를 보고 갈린다. 18강에서 봤다. null 이면 persist, 값이 있으면 merge 다. 손님이 id 를 채워 보냈으니 merge 로 갔고, 1번 행을 읽어와 그 위에 베껴 썼다.

없는 번호를 보내면 더 나빴다.

터미널 — 16강에서 본 것
  • $ curl -X POST http://localhost:8080/books -d '{"id":99,"title":"유령","price":1}'
  • {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/books"}

PUT 은 무사했다. 17강에서 확인했고, 그때 이렇게 적었다.

id 가 무시된 것은 우리가 그 필드를 안 읽었기 때문이지, Spring 이 막아준 것이 아니다.

세 강의에 걸쳐 같은 구멍을 세 번 봤다. 이제 막는다.

지금의 통로
  1. 손님의 JSONid 를 마음대로 적을 수 있다
  2. Book@Entity — 표의 한 행
  3. book 표merge 로 덮어써진다
중간에 아무것도 없다. 손님의 손이 표에 닿는다.

받는 문을 세운다

문 하나면 된다. 손님에게서 받을 것만 적은 클래스를 따로 만든다.

public record BookRequest(String title, int price) { }

id 가 없다. 필드가 둘뿐이다.

10강에서 record 를 JSON 으로 내보내봤고, 11강에서 JSON 을 객체로 받아봤다. 그때 이런 표를 만들었다.

보낸 본문 결과
{"title":"자바","price":20000,"extra":1} extra 는 조용히 무시된다

이제 id 가 그 extra 다. 받을 곳이 없는 키는 조용히 버려진다.

컨트롤러는 이렇게 바뀐다.

@PostMapping
public ResponseEntity<Book> create(@RequestBody BookRequest request) {
    Book saved = repository.save(new Book(request.title(), request.price()));
    return ResponseEntity.created(URI.create("/books/" + saved.getId())).body(saved);
}

new Book(title, price) 다. id 를 넘길 방법이 아예 없다. 15강에서 Book 의 생성자에 id 를 안 넣었기 때문이다. 그때 그 이유를 이렇게 적었다.

생성자에 id 가 없다 — 우리가 정하는 값이 아니기 때문이다.

같은 요청을 다시 보낸다.

curl -i -X POST http://localhost:8080/books \
  -H 'Content-Type: application/json' \
  -d '{"id":1,"title":"머지","price":9000}'
터미널
  • HTTP/1.1 201
  • Location: /books/4
  • $ curl http://localhost:8080/books/1
  • {"title":"자바","price":20000,"id":1}

4번으로 만들어졌다. 1번 책은 그대로다. 손님이 적어 보낸 1 은 어디에도 닿지 않았다.

없는 번호도 이제 아무 일이 아니다.

curl -i -X POST http://localhost:8080/books \
  -H 'Content-Type: application/json' \
  -d '{"id":99,"title":"유령","price":1}'
터미널
  • HTTP/1.1 201
  • Location: /books/5
  • Content-Type: application/json
  • Transfer-Encoding: chunked
  • {"title":"유령","price":1,"id":5}
  • $ curl -o /dev/null -w '%{http_code}' http://localhost:8080/books/99
  • 404

500 이 사라졌다. merge 로 갈 일이 없으니 StaleObjectStateException 도 없다. 99번 행은 생기지 않았고, 서버가 5번을 붙였다.

13강의 약속이 돌아왔다.

나가는 문도 세운다

받는 쪽은 막았다. 그런데 응답을 보면 아직 Book 이 그대로 나간다.

{"title":"자바","price":20000,"id":1}

id 가 뒤에 있다. 13강에서는 앞에 있었다. 16강에서 Bookrecord 에서 클래스가 되면서 키 순서가 바뀌었고, 우리는 그것을 한 문장으로만 짚고 넘어갔다. 응답의 생김새를 우리가 정하고 있지 않다는 뜻이다.

Book 은 표의 행이다. 표에 열이 하나 늘면 응답도 따라 바뀐다. 그건 손님과 한 약속이 아니다.

(방금 만든 책들은 지우고, 다시 세 권만 둔 상태로 이어간다.)

10강이 답을 미리 줬다.

그래서 내보낼 객체는 record 로 만든다

15강도 같은 말을 했다.

엔티티는 클래스, DTO 는 record. 19강에서 둘로 나눈다.

여기가 그 19강이다.

public record BookResponse(Long id, String title, int price) {

    public static BookResponse from(Book book) {
        return new BookResponse(book.getId(), book.getTitle(), book.getPrice());
    }
}

fromBook 을 받아 BookResponse 를 만들어 돌려주는 메서드다. new 를 부르는 자리를 한 곳으로 모아둔 것뿐이고, 특별한 문법은 아니다.

record 를 쓸 수 있다는 것에 주의하자. 15강에서 Bookrecord 를 그만뒀다. JPA 가 빈 객체를 만든 뒤 값을 채워야 하는데 record 는 못 바꾸기 때문이었다. DTO 에는 그 사정이 없다. 우리가 값을 다 알고 나서 만들고, 만든 뒤로는 아무도 안 바꾼다.

두 개의 Book 처럼 생긴 것

Book

엔티티 · 클래스

  • book 표의 한 행이다
  • id 는 데이터베이스가 붙인다
  • JPA 가 빈 객체를 만들어 채운다
  • 그래서 record 가 될 수 없다 (15강)

BookRequest · BookResponse

DTO · record

  • 손님과 주고받는 종이다
  • 필드는 우리가 정한다
  • 만든 뒤로는 안 바뀐다
  • 그래서 record 가 알맞다 (10강)
DTO 는 Data Transfer Object 다. 옮기는 일만 한다.

컨트롤러가 문 앞에 선다

바꿀 곳은 컨트롤러뿐이다. BookBookRepository 도 한 글자도 안 고친다.

@GetMapping
public List<BookResponse> list() {
    return repository.findAll().stream()
            .map(BookResponse::from)
            .toList();
}

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

@PostMapping
public ResponseEntity<BookResponse> create(@RequestBody BookRequest request) {
    Book saved = repository.save(new Book(request.title(), request.price()));
    return ResponseEntity.created(URI.create("/books/" + saved.getId()))
            .body(BookResponse.from(saved));
}

@PutMapping("/{id}")
@Transactional
public ResponseEntity<BookResponse> update(@PathVariable Long id, @RequestBody BookRequest request) {
    return repository.findById(id)
            .map(found -> {
                found.changeTo(request.title(), request.price());
                return found;
            })
            .map(BookResponse::from)
            .map(ResponseEntity::ok)
            .orElseGet(() -> ResponseEntity.notFound().build());
}

one()map 이 하나 늘었다. 17강에서 배운 그대로다. 상자 안의 BookBookResponse 로 바꾸고, 그것을 200 응답으로 바꾼다. 상자를 열지 않았다.

BookResponse::from 은 자바 편 19강의 메서드 참조다. 17강에서 ResponseEntity::ok 를 쓴 것과 같은 모양이다.

응답을 본다.

터미널
  • $ curl http://localhost:8080/books/1
  • {"id":1,"title":"자바","price":20000}
  • $ curl http://localhost:8080/books
  • [{"id":1,"title":"자바","price":20000},{"id":2,"title":"스프링","price":30000},{"id":3,"title":"HTTP","price":15000}]

id 가 앞으로 돌아왔다. 우리가 record 에 적은 순서 그대로다. 이제 응답의 생김새는 표가 아니라 BookResponse 가 정한다.

PUT 도 확인한다. 주소에는 1, 본문에는 99 를 적어 보낸다. 17강에서 "운이 좋았을 뿐"이라던 그 요청이다.

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

결과는 17강과 똑같다. 그런데 이유가 다르다.

같은 결과, 다른 이유

17강

운이 좋았다

  • 본문의 id=99 가 컨트롤러 안까지 들어왔다
  • 우리가 book.getId() 를 안 읽어서 무사했다
  • 누가 그 줄을 쓰면 그날 깨진다

19강

들어올 수가 없다

  • BookRequestid 라는 필드가 없다
  • 읽으려 해도 읽을 것이 없다
  • 실수할 자리가 사라졌다
막는다는 것은 기억해서 안 하는 게 아니라, 할 수 없게 만드는 것이다.

DTO 는 검증이 아니다

문을 세웠으니 이제 아무거나 못 들어오는가. 해보자.

curl -X POST http://localhost:8080/books \
  -H 'Content-Type: application/json' \
  -d '{"title":"","price":-5000}'
터미널
  • HTTP/1.1 201
  • {"id":4,"title":"","price":-5000}

제목이 빈 책이 가격 -5000 원에 만들어졌다. 201 이다.

BookRequest 는 "무엇을 받을지"만 정했다. "어떤 값이어야 하는지"는 한 글자도 안 적었다.

11강에서 이미 예고했다.

빠뜨린 값을 제대로 막는 것은 타입의 일이 아니다. 20강에서 @Valid 로 제대로 막는다.

그 말이 여기서도 그대로다. int priceprice빠뜨리는 것만 막는다.

터미널 — price 를 아예 안 보내면
  • $ curl -o /dev/null -w '%{http_code}' -X POST http://localhost:8080/books -d '{"title":"자바"}'
  • 400

400 이다. 11강 그대로다. 그러나 -5000 은 통과한다. 타입이 막아주는 것은 거기까지다.

정리하면

엔티티는 표와 한 약속이고, DTO 는 손님과 한 약속이다. 둘이 같은 클래스면 한쪽을 고칠 때 다른 쪽이 깨진다.

다시 짚어보기
  1. 01

    엔티티를 @RequestBody 로 받으면 손님이 표를 만진다

    id 를 실어 보내면 savemerge 로 가서 남의 행을 덮어쓴다. 201 Created 라고 답하면서.

    // 16강 · 17강 · 19강이 세 번 본 구멍
    public ResponseEntity<Book> create(@RequestBody Book book) { ... }
  2. 02

    받는 문 — 받을 것만 적는다

    id 가 없으니 들어올 곳이 없다. 11강의 extra 처럼 조용히 버려진다.

    public record BookRequest(String title, int price) { }
  3. 03

    나가는 문 — 내보낼 것만 적는다

    응답의 생김새를 표가 아니라 우리가 정한다. id 가 앞으로 돌아온 것이 그 증거다.

    public record BookResponse(Long id, String title, int price) {
        public static BookResponse from(Book book) {
            return new BookResponse(book.getId(), book.getTitle(), book.getPrice());
        }
    }
  4. 04

    엔티티는 클래스, DTO 는 record

    15강이 Book 에게서 record 를 뺏은 이유는 JPA 가 값을 채워야 해서였다. DTO 는 만들고 나면 안 바뀐다.

  5. 05

    막는다는 것은 할 수 없게 만드는 것이다

    17강의 PUT 은 우리가 그 필드를 안 읽어서 무사했다. 지금은 읽을 필드가 없다.

  6. 06

    DTO 는 값을 검사하지 않는다

    빈 제목과 -5000 원이 201 로 통과한다. 20강의 일이다.

Part 5 가 시작됐다. 여기서부터는 제대로 된 API 를 만든다.

문은 세웠는데 문지기가 없다. 빈 제목이 들어오고, 음수 가격이 들어온다. 지금 그것을 막으려면 컨트롤러 안에 if 를 쌓아야 한다.

if (request.title() == null || request.title().isBlank()) { ... }
if (request.price() < 0) { ... }

3강에서 배웠다. 손님이 잘못 보낸 것은 400 이다. 그런데 저 if 들을 손으로 쌓으면 13강에서 null 을 손으로 붙잡던 그 자리로 되돌아간다.

20강에서 표식 하나로 400 을 받는다.

DTO — 엔티티를 그대로 내보내지 않는다 — 디코드랩(DCODELAB)