[DAY 12] 컨트롤러 쪼개기 : 서비스 계층 구현과 트랜잭션
지금까지 일은 컨트롤러가 다 했다... 컨트롤러는 사실 웨이터였고 실주방장이 나설 차례.
컨트롤러 안의 로직을 조각내어서 서비스가 처리하도록 리팩토링해야 한다.
// PATCH
@PatchMapping("/api/articles/{id}")
public ResponseEntity<Article> update(@PathVariable Long id,
@RequestBody ArticleForm dto) {
// 1. DTO -> 엔티티 변환
Article article = dto.toEntity();
log.info("id: {}, article: {}", id, article.toString());
// 2. 타깃 조회
Article target = articleRepository.findById(id).orElse(null);
// 3. 잘못된 요청 처리
if (target == null || id != article.getId()) {
// 400, 잘못된 요청 응답
log.info("잘못된 요청! id: {}, article: {}", id, article.toString());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
}
// 4. 업데이트 및 정상 응답(200)
target.patch(article); // 기존 데이터에 새 데이터 붙임
Article updated = articleRepository.save(target); // 수정 내용 db에 최종 저장
return ResponseEntity.status(HttpStatus.OK).body(updated);
}
현재 컨트롤러가 수행하고 있는 역할이다. 컨트롤러는 클라이언트에게 요청을 받고, 서비스가 가공한 데이터를 다시 클라이언트에게 전달하는 일만 수행하도록 바꾼다. 그렇게 하면 다음과 같이 코드를 쪼갤 수 있다.
컨트롤러 : 클라이언트 요청 받기
// PATCH
@PatchMapping("/api/articles/{id}")
public ResponseEntity<Article> update(@PathVariable Long id,
@RequestBody ArticleForm dto) {
서비스 : 리파지터리에 데이터 가져오도록 명령
// 1. DTO -> 엔티티 변환
Article article = dto.toEntity();
log.info("id: {}, article: {}", id, article.toString());
// 2. 타깃 조회
Article target = articleRepository.findById(id).orElse(null);
// 3. 잘못된 요청 처리
if (target == null || id != article.getId()) {
// 400, 잘못된 요청 응답
log.info("잘못된 요청! id: {}, article: {}", id, article.toString());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
}
// 4. 업데이트 및 정상 응답(200)
target.patch(article); // 기존 데이터에 새 데이터 붙임
Article updated = articleRepository.save(target); // 수정 내용 db에 최종 저장
컨트롤러 : 클라이언트에 응답 보내기
return ResponseEntity.status(HttpStatus.OK).body(updated);
}
이제 RESTful API로 설계한 CRUD를 하나 하나 리팩토링 하자.
GET
// controller
// GET
@GetMapping("/api/articles")
public List<Article> index() {
return articleService.index();
}
@GetMapping("/api/articles/{id}")
public Article show(@PathVariable Long id) {
return articleService.show(id);
}
// service
public List<Article> index() {
return articleRepository.findAll();
}
public Article show(Long id) {
return articleRepository.findById(id).orElse(null);
}
GET 요청은 데이터 조회이기 때문에 쉽게 쪼갤 수 있다.
클라이언트가 GET 요청을 보내면 서비스 계층에서 리파지터리에 데이터가 있는지 조회하고,
있으면 데이터를 반환, 없으면 null. 컨트롤러는 서비스에게 받은 데이터를 다시 클라이언트에게 넘기면 된다.
return 문만 수정하면 완료.
POST
// controller
// POST
@PostMapping("/api/articles")
public ResponseEntity<Article> create(@RequestBody ArticleForm dto) {
Article created = articleService.create(dto);
return (created != null) ?
ResponseEntity.status(HttpStatus.OK).body(created) :
ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
// service
public Article create(ArticleForm dto) {
Article article = dto.toEntity();
if (article.getId() != null) {
return null;
}
return articleRepository.save(article);
}
지금 코드는 POST 요청이 들어오면 컨트롤러가 바로 리파지터리로 가서 명령을 수행한다.
서비스가 이를 수행하도록 바꾸자.
dto에 접근하는 내용을 모두 삭제하고 서비스에서 접근하도록 서비스의 메소드를 호출한다.
이때 return 되는 값을 생각해보면, 데이터 생성이 됐다면 ok 아니면 bad_request다. -> 삼항 연산자를 사용해야한다!
return (created != null) ? ok : bad;
클라이언트에게 보내는 응답은 컨트롤러가 하기때문에 HttpStatus도 컨트롤러에 적으면 된다.
created(서비스에서 반환된 결과값)이 null 이 아니라면 OK와 body에 created를,
null 이면 BAD_REQUEST와 body는 null로 보낸다.
서비스에서는 기존 코드에서 하던 dto를 엔티티로 변환해 저장한 후, 이를 db에 저장하면 된다.
PATCH
// controller
// PATCH
@PatchMapping("/api/articles/{id}")
public ResponseEntity<Article> update(@PathVariable Long id,
@RequestBody ArticleForm dto) {
Article updated = articleService.updated(id, dto);
return (updated != null) ?
ResponseEntity.status(HttpStatus.OK).body(updated) :
ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
// service
public Article updated(Long id, ArticleForm dto) {
// 1. DTO -> 엔티티 변환
Article article = dto.toEntity();
log.info("id: {}, article: {}", id, article.toString());
// 2. 타깃 조회
Article target = articleRepository.findById(id).orElse(null);
// 3. 잘못된 요청 처리
if (target == null || id != article.getId()) {
// 400, 잘못된 요청 응답
log.info("잘못된 요청! id: {}, article: {}", id, article.toString());
return null; // 컨트롤러가 상태 코드 반환해주므로 서비스는 null만 반환
}
// 4. 업데이트 및 정상 응답(200)
target.patch(article); // 기존 데이터에 새 데이터 붙임
Article updated = articleRepository.save(target); // 수정 내용 db에 최종 저장
return updated; // 상태코드와 응답은 컨트롤러가 함
}
코드를 수정하는 내용은 POST와 동일하지만 서비스의 return 값이 최종적으로 저장된 데이터를 컨트롤러에게 반환한다는 것만 다르다.
컨트롤러는 서비스에게 받은 데이터가 null 이 아니면 잘 받은 수정 데이터를 body에 실어 응답하고, null이면 bad_request를 보내면 된다.
DELETE
// controller
// DELETE
@DeleteMapping("/api/articles/{id}")
public ResponseEntity<Article> delete(@PathVariable Long id) {
Article deleted = articleService.delete(id);
return (deleted != null) ?
ResponseEntity.status(HttpStatus.OK).build() :
ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
// service
public Article delete(Long id) {
// 1. 대상 찾기
Article target = articleRepository.findById(id).orElse(null);
// 2. 잘못된 요청 처리
if (target == null) {
return null;
}
// 3. 대상 삭제
articleRepository.delete(target);
return target;
}
삭제 요청도 PATCH와 동일하다. 컨트롤러에게 받은 삭제할 데이터의 대상을 리파지터리에서 찾고,
삭제가 됐으면 삭제한 대상을 컨트롤러에게 반환, 잘못된 요청이라면 null을 반환.
컨트롤러는 서비스에게 받은 데이터로 클라이언트에게 응답하면 된다.
트랜잭션
트랜잭션은 한꺼번에 수행되어야 할 연산을 모아둔 작업의 단위로, 작업의 일부가 성공했을지라도 모두 성공하지 않으면 원 상태로 복구한다. 즉, 완전성을 보장해주는 것이다.
이는 트랜잭션의 특징과 이어지는데, 원자성, 일관성, 독립성, 지속성이다.
트랜잭션 특징 (ACID)
원자성 Atomicity
작업의 모든 단계가 완전히 수행되거나, 아무 작업도 수행하지 않는 상태로 유지되어야 한다.
일관성 Consistency
트랜잭션은 DB의 일관성을 유지해야 한다. 즉, DB의 정의된 규칙과 제약 조건을 준수하여 트랜잭션의 시작 전과 후에도 DB는 일관된 상태를 유지해야 한다.
고립성 Isolation
트랜잭션은 다른 트랜잭션으로부터 독립적으로 실행되어야 한다. 각각의 트랜잭션은 서로 영향을 주지 않고, 영향을 받지 않아야 한다.
지속성 Durability
트랜잭션이 성공적으로 완료되면, 해당 트랜잭션에 의한 변경 내용은 영구적으로 저장되어야 한다.
정처기에서 지겹도록 외웠다... 트랜잭션을 활용하려면 `@Tranactional` 어노테이션을 사용하면 된다.
책에서 여러 개의 데이터를 생성하기 위해 스트림 문법을 이용한다.
스트림은 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소로, for문으로 길게 적을 코드를 짧고 가독성 있게 만드는 코드 패턴이다. 내용이 많으니 복습할 때 더 깊게 파고 들어야겠다...
트랜잭션을 적용하지 않고 DB에 저장하는 과정에서 의도적으로 에러를 내면, 데이터가 저장되지 않아야 하는데 롤백되지 않아 저장되는 상황이 발생한다. 이때 트랜잭션 어노테이션을 주입하고 다시 실행하면 에러가 나면 롤백되어 작업 시작 전으로 돌아가게 된다.