학습 자료

계층 — 컨트롤러 · 서비스 · 리포지토리, 그리고 @Transactional


Article

지금 컨트롤러를 열어보면 이렇게 생겼다.

@PutMapping("/{id}")
@Transactional
public BookResponse update(@PathVariable Long id, @Valid @RequestBody BookRequest request) {
    Book found = repository.findById(id)
            .orElseThrow(() -> new BookNotFoundException(id));
    found.changeTo(request.title(), request.price());
    return BookResponse.from(found);
}

이 한 메서드가 하는 일을 세어보자.

update 한 메서드가 하는 일
  1. 01

    주소에서 번호를 꺼낸다

    @PathVariable — HTTP 의 일이다 (12강)

  2. 02

    본문을 객체로 만든다

    @RequestBody — HTTP 의 일이다 (11강)

  3. 03

    값을 검사한다

    @Valid — HTTP 로 들어온 것을 검사한다 (20강)

  4. 04

    트랜잭션을 연다

    @TransactionalHTTP 와 아무 상관 없다 (18강)

  5. 05

    책을 찾아 고친다

    HTTP 와 아무 상관 없다

  6. 06

    응답 객체로 바꾼다

    BookResponse.from — HTTP 의 일이다 (19강)

여섯 중 둘이 HTTP 와 무관하다. 그 둘이 섞여 있다.

18강에서 @Transactional 을 붙이며 이렇게 적어뒀다.

22강에서 컨트롤러와 리포지토리 사이에 층을 하나 세우고, 이 표식을 거기로 옮긴다.

21강에서 에러 처리를 컨트롤러 밖으로 뺐다. 이것도 있을 자리가 따로 있다.

층을 하나 세운다

BookService 를 만든다. 표식은 @Service 다.

@Service
public class BookService {

    private final BookRepository repository;

    public BookService(BookRepository repository) {
        this.repository = repository;
    }

    @Transactional(readOnly = true)
    public Book findOne(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new BookNotFoundException(id));
    }

    @Transactional
    public Book create(String title, int price) {
        return repository.save(new Book(title, price));
    }

    @Transactional
    public Book update(Long id, String title, int price) {
        Book found = repository.findById(id)
                .orElseThrow(() -> new BookNotFoundException(id));
        found.changeTo(title, price);
        return found;
    }

    @Transactional
    public void delete(Long id) {
        if (!repository.existsById(id)) throw new BookNotFoundException(id);
        repository.deleteById(id);
    }
}

이 클래스에는 HTTP 가 한 글자도 없다. @PathVariableResponseEntity 도 상태코드도 없다. 없으면 예외를 던지고, 있으면 고친다. 그것뿐이다.

@Service 는 7강에서 이미 봤다. 그때 리플렉션으로 확인했다 — Service.class.isAnnotationPresent(Component.class)true 였다. @Component 의 다른 이름이다. 컨테이너에 담긴다는 뜻은 똑같고, 이름이 사람에게 뜻을 알려줄 뿐이다.

컨트롤러는 얇아진다.

@RestController
@RequestMapping("/books")
public class BookController {

    private final BookService service;

    public BookController(BookService service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    public BookResponse one(@PathVariable Long id) {
        return BookResponse.from(service.findOne(id));
    }

    @PutMapping("/{id}")
    public BookResponse update(@PathVariable Long id, @Valid @RequestBody BookRequest request) {
        return BookResponse.from(service.update(id, request.title(), request.price()));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }
}

update 가 한 줄이 됐다. @Transactional 이 사라졌고, repository 라는 이름도 사라졌다.

컨트롤러는 이제 HTTP 만 한다. 주소에서 꺼내고, 본문을 받고, 응답 객체로 바꾼다. 6강의 생성자 주입은 그대로다. 받는 것이 BookRepository 에서 BookService 로 바뀌었을 뿐이다.

세 층
  1. BookControllerHTTP 를 안다. 도메인 규칙은 모른다
  2. BookService규칙을 안다. HTTP 는 모른다
  3. BookRepository표를 안다. 나머지는 모른다
각 층이 아는 것이 다르다. 아래층은 위층을 모른다.

돌려보면 아무것도 안 바뀐다.

터미널
  • $ curl -X PUT http://localhost:8080/books/1 -d '{"title":"자바","price":18000}'
  • {"id":1,"title":"자바","price":18000}
  • $ curl http://localhost:8080/books/1
  • {"id":1,"title":"자바","price":18000}
  • $ curl http://localhost:8080/books/99
  • {"status":404,"message":"99번 책을 찾을 수 없다"}

save() 는 여전히 없다. 18강의 더티 체킹이 서비스의 트랜잭션 안에서 그대로 일한다. 21강의 404 도 그대로다. 서비스가 던진 예외를 ApiExceptionHandler 가 받는다 — 층을 넘어서 받는다.

예외가 나면 되돌린다

트랜잭션이 정말 하는 일은 하나 더 있다. 18강에서는 "메서드가 끝날 때 커밋한다"까지만 봤다.

끝나지 못하면 어떻게 되나. 책을 고쳐놓고 일부러 터뜨려본다.

@Transactional
public void updateThenFail(Long id) {
    Book found = repository.findById(id).orElseThrow(() -> new BookNotFoundException(id));
    found.changeTo("롤백될 제목", 99999);
    throw new IllegalStateException("일부러 터뜨린다");
}

changeTo 는 분명히 불렸다. 그다음 줄에서 예외가 났다.

서버 로그
  • Hibernate: select b1_0.id,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
  • (UPDATE 가 없다)
터미널
  • $ curl http://localhost:8080/books/1
  • {"id":1,"title":"자바","price":20000}

책이 안 바뀌었다. 객체는 고쳐졌는데 UPDATE 가 안 나갔다.

18강에서 배운 것을 뒤집어 읽으면 된다. 더티 체킹은 커밋할 때 일어난다. 예외가 나면 커밋을 안 한다. 롤백한다. 고친 흔적이 데이터베이스에 안 남는다.

@Transactional 메서드 안에서 벌어지는 일
성공할 때
SELECTchangeTo — 객체만 바뀐다커밋 · UPDATE
예외가 날 때
SELECTchangeTo — 객체만 바뀐다예외 · 롤백
두 줄의 앞부분은 똑같다. UPDATE 가 나가는 자리는 changeTo 가 아니라 맨 끝이고, 거기까지 못 가면 아무 일도 없었던 것이 된다.

이것이 트랜잭션의 진짜 값이다. 책 두 권을 한꺼번에 고치다가 두 번째에서 터지면, 첫 번째도 안 고쳐진 것이 된다. 반쯤 고쳐진 상태가 남지 않는다.

readOnly 는 무엇인가

findOne 에는 @Transactional(readOnly = true) 를 붙였다. 읽기만 한다는 뜻이다.

18강에서 이미 본 적이 있다. SimpleJpaRepository 를 리플렉션으로 열었을 때 이렇게 나왔다.

### 클래스에 붙은 것 = readOnly true
### save() 에 붙은 것 = readOnly false

읽는 메서드는 readOnly, 쓰는 메서드는 아니다. Spring Data 가 자기 구현에 쓰던 규칙을 우리도 그대로 쓴다.

readOnly 를 붙이면 더티 체킹을 안 한다. 확인해봤다. readOnly 메서드 안에서 changeTo 를 불러도 UPDATE 가 안 나가고 책이 안 바뀐다.

메서드마다 다르게 붙는다
메서드붙인 것
findAll · findOne@Transactional(readOnly = true)읽기만 한다. 고칠 일이 없다
create · update · delete@Transactional쓴다. 커밋이 필요하다
readOnly 안에서 엔티티를 고쳐도 저장되지 않는다. 실수를 한 겹 막아준다.

표식이 안 먹는 자리가 있다

여기서 조심할 것이 하나 있다. 서비스 안에서 자기 자신의 메서드를 부를 때다.

public void selfInvoke(Long id) {
    this.dirtyOnly(id);          // @Transactional 이 붙은 메서드를 부른다
}

@Transactional
public void dirtyOnly(Long id) {
    Book found = repository.findById(id).orElseThrow(() -> new BookNotFoundException(id));
    found.changeTo("셀프호출", 55555);
}

dirtyOnly@Transactional 이 붙어 있다. 그러니 selfInvoke 를 불러도 저장되어야 할 것 같다. 불러본다.

서버 로그 — selfInvoke(3) 를 불렀다
  • Hibernate: select b1_0.id,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
  • (UPDATE 가 없다)
  • $ curl http://localhost:8080/books/3
  • {"id":3,"title":"HTTP","price":15000}

안 바뀐다. 그런데 dirtyOnly밖에서 직접 부르면 바뀐다.

서버 로그 — service.dirtyOnly(3) 를 직접 불렀다
  • Hibernate: select b1_0.id,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
  • Hibernate: update book set price=?,title=? where id=?
  • $ curl http://localhost:8080/books/3
  • {"id":3,"title":"셀프호출","price":55555}

같은 메서드인데 누가 부르느냐에 따라 다르다. 왜인가.

16강에서 리포지토리를 꺼내 클래스 이름을 찍어봤다. $Proxy116 이 나왔다. 서비스도 찍어보자.

System.out.println(service.getClass().getName());
터미널
  • kr.dcodelab.bookstore.BookService$$SpringCGLIB$$0

우리가 만든 BookService 가 아니다. Spring 이 그것을 감싼 물건이다.

트랜잭션을 열고 닫는 일은 그 껍데기가 한다. 우리 메서드는 껍데기 안쪽에 있다.

밖에서 부를 때
  1. 컨트롤러service.update(...) 를 부른다
  2. CGLIB 껍데기표식을 보고 트랜잭션을 연다
  3. 우리 BookService진짜 일을 한다
  4. 껍데기돌아오면 커밋한다
표식을 읽는 것은 껍데기다. 우리 코드가 아니다.

this.dirtyOnly(id) 는 껍데기를 거치지 않는다. 우리 객체 안에서 우리 메서드를 부르는 것뿐이다. 껍데기는 그 호출을 보지도 못한다. 그러니 트랜잭션도 안 열린다.

정리하면

컨트롤러는 HTTP 를 알고, 서비스는 규칙을 알고, 리포지토리는 표를 안다. 트랜잭션은 규칙이 사는 층에 있다.

다시 짚어보기
  1. 01

    @Service@Component 의 다른 이름이다

    7강에서 리플렉션으로 확인했다. 컨테이너에 담기는 것은 똑같고, 이름이 뜻을 알려준다.

  2. 02

    서비스에는 HTTP 가 없다

    @PathVariableResponseEntity 도 상태코드도 없다. 없으면 예외를 던지고, 그것을 ApiExceptionHandler 가 받는다.

    @Transactional
    public Book update(Long id, String title, int price) {
        Book found = repository.findById(id)
                .orElseThrow(() -> new BookNotFoundException(id));
        found.changeTo(title, price);
        return found;
    }
  3. 03

    예외가 나면 되돌린다

    고친 뒤 터뜨리면 UPDATE 가 안 나간다. 더티 체킹은 커밋할 때 일어나고, 예외가 나면 커밋을 안 한다.

  4. 04

    checked 예외는 롤백 안 된다

    RuntimeException 만 되돌린다. 자바 편 18강의 checked · unchecked 구분이 여기서 값을 한다.

  5. 05

    읽기만 하면 readOnly

    18강에서 본 SimpleJpaRepository 의 규칙 그대로다. readOnly 안에서는 더티 체킹을 안 한다.

  6. 06

    this.메서드() 로 부르면 표식이 안 먹는다

    트랜잭션을 여는 것은 CGLIB 껍데기다. 자기 자신을 부르면 껍데기를 안 거친다. 조용히 안 먹는다.

Part 5 가 끝났다

19강에서 문을 세웠고, 20강에서 문지기를 세웠고, 21강에서 사고가 났을 때 뭐라고 말할지 정했고, 22강에서 각자가 있을 자리를 정했다.

제대로 된 API — 네 강
  1. 19강DTO — 엔티티가 밖으로 안 나간다
  2. 20강@Valid — 이상한 값이 안 들어온다
  3. 21강핸들러 — 실패도 약속대로 답한다
  4. 22강계층 — 각자 아는 것만 안다
3강에서 배운 상태코드의 약속을 성공할 때도 실패할 때도 지킨다.

CRUD 다섯 메서드가 데이터베이스 위에서 돈다. 껐다 켜도 남고, 이상한 값은 막히고, 없는 책은 404 다.

그런데 우리는 이것을 한 번도 자동으로 검사해본 적이 없다. 매번 curl 을 손으로 쳤다. 스물두 강 내내 그랬다.

자바 편 15강에서 이런 말을 했다.

OrderServiceNotifier 를 생성자로 받는다. 가짜 Notifier 를 넣으면 된다.

6강에서 그 말이 컨테이너 이야기라고 했다. 아직 가짜를 넣어본 적은 없다.

방금 만든 BookServiceBookRepository 를 생성자로 받는다.

23강에서 그 자리에 가짜를 넣는다.

계층 — 컨트롤러 · 서비스 · 리포지토리, 그리고 @Transactional — 디코드랩(DCODELAB)