계층 — 컨트롤러 · 서비스 · 리포지토리, 그리고 @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);
}
이 한 메서드가 하는 일을 세어보자.
- 01
주소에서 번호를 꺼낸다
@PathVariable— HTTP 의 일이다 (12강) - 02
본문을 객체로 만든다
@RequestBody— HTTP 의 일이다 (11강) - 03
값을 검사한다
@Valid— HTTP 로 들어온 것을 검사한다 (20강) - 04
트랜잭션을 연다
@Transactional— HTTP 와 아무 상관 없다 (18강) - 05
책을 찾아 고친다
HTTP 와 아무 상관 없다
- 06
응답 객체로 바꾼다
BookResponse.from— HTTP 의 일이다 (19강)
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 가 한 글자도 없다. @PathVariable 도 ResponseEntity 도 상태코드도 없다. 없으면 예외를 던지고, 있으면 고친다. 그것뿐이다.
@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 로 바뀌었을 뿐이다.
- BookControllerHTTP 를 안다. 도메인 규칙은 모른다
- BookService규칙을 안다. HTTP 는 모른다
- 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강에서 배운 것을 뒤집어 읽으면 된다. 더티 체킹은 커밋할 때 일어난다. 예외가 나면 커밋을 안 한다. 롤백한다. 고친 흔적이 데이터베이스에 안 남는다.
이것이 트랜잭션의 진짜 값이다. 책 두 권을 한꺼번에 고치다가 두 번째에서 터지면, 첫 번째도 안 고쳐진 것이 된다. 반쯤 고쳐진 상태가 남지 않는다.
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 | 쓴다. 커밋이 필요하다 |
표식이 안 먹는 자리가 있다
여기서 조심할 것이 하나 있다. 서비스 안에서 자기 자신의 메서드를 부를 때다.
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 를 불러도 저장되어야 할 것 같다. 불러본다.
- 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 를 밖에서 직접 부르면 바뀐다.
- 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 이 그것을 감싼 물건이다.
트랜잭션을 열고 닫는 일은 그 껍데기가 한다. 우리 메서드는 껍데기 안쪽에 있다.
- 컨트롤러service.update(...) 를 부른다
- CGLIB 껍데기표식을 보고 트랜잭션을 연다
- 우리 BookService진짜 일을 한다
- 껍데기돌아오면 커밋한다
this.dirtyOnly(id) 는 껍데기를 거치지 않는다. 우리 객체 안에서 우리 메서드를 부르는 것뿐이다. 껍데기는 그 호출을 보지도 못한다. 그러니 트랜잭션도 안 열린다.
정리하면
컨트롤러는 HTTP 를 알고, 서비스는 규칙을 알고, 리포지토리는 표를 안다. 트랜잭션은 규칙이 사는 층에 있다.
- 01
@Service는@Component의 다른 이름이다7강에서 리플렉션으로 확인했다. 컨테이너에 담기는 것은 똑같고, 이름이 뜻을 알려준다.
- 02
서비스에는 HTTP 가 없다
@PathVariable도ResponseEntity도 상태코드도 없다. 없으면 예외를 던지고, 그것을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; } - 03
예외가 나면 되돌린다
고친 뒤 터뜨리면
UPDATE가 안 나간다. 더티 체킹은 커밋할 때 일어나고, 예외가 나면 커밋을 안 한다. - 04
checked 예외는 롤백 안 된다
RuntimeException만 되돌린다. 자바 편 18강의 checked · unchecked 구분이 여기서 값을 한다. - 05
읽기만 하면
readOnly18강에서 본
SimpleJpaRepository의 규칙 그대로다.readOnly안에서는 더티 체킹을 안 한다. - 06
this.메서드()로 부르면 표식이 안 먹는다트랜잭션을 여는 것은 CGLIB 껍데기다. 자기 자신을 부르면 껍데기를 안 거친다. 조용히 안 먹는다.
Part 5 가 끝났다
19강에서 문을 세웠고, 20강에서 문지기를 세웠고, 21강에서 사고가 났을 때 뭐라고 말할지 정했고, 22강에서 각자가 있을 자리를 정했다.
- 19강DTO — 엔티티가 밖으로 안 나간다
- 20강@Valid — 이상한 값이 안 들어온다
- 21강핸들러 — 실패도 약속대로 답한다
- 22강계층 — 각자 아는 것만 안다
CRUD 다섯 메서드가 데이터베이스 위에서 돈다. 껐다 켜도 남고, 이상한 값은 막히고, 없는 책은 404 다.
그런데 우리는 이것을 한 번도 자동으로 검사해본 적이 없다. 매번 curl 을 손으로 쳤다. 스물두 강 내내 그랬다.
자바 편 15강에서 이런 말을 했다.
OrderService는Notifier를 생성자로 받는다. 가짜Notifier를 넣으면 된다.
6강에서 그 말이 컨테이너 이야기라고 했다. 아직 가짜를 넣어본 적은 없다.
방금 만든 BookService 도 BookRepository 를 생성자로 받는다.
23강에서 그 자리에 가짜를 넣는다.