서비스 계층과 트랜잭션(서비스 사용한 CRUD)

OneTwoThree·2022년 10월 19일
0

유튜브

서비스란?

서비스는 컨트롤러와 리포지토리 중간에 위치한다
주방을 예로 들면 컨트롤러가 웨이터, 서비스가 셰프, 리포지토리가 보조셰프다. 클라이언트가 메뉴를 요청하면 컨트롤러가 주문을 받아서 서비스한테 전달하고 서비스가 요리를 만드는데 필요한 재료는 리포지토리가 가져다준다.

트랜잭션 , 롤백이란?

서비스의 업무처리는 트랜잭션 단위로 진행된다
트랜잭션은 모두 성공되어야 하는 일련의 과정이다.

트랜잭션의 중간에서 실패하면 내용을 다 날리고 처음으로 돌아가야 한다.
처음으로 돌아가는 걸 롤백이라고 한다.

지금까지 만든 구조는 컨트롤러가 웨이터 역할이랑 주방장 역할을 다했다.
요청을 받고 응답하는거랑 리포지토리한테 데이터를 가져오라고 시키는 것까지 컨트롤러에서 진행했다. 지금까지는 간단한 수준의 처리라 이렇게 해도 문제가 없었다. 하지만 일반적으로 웹 서비스는 컨트롤러와 리포지토리 사이에 서비스 계층을 둔다..


기존에 만들었던 ArticleApiController 클래스에 서비스 계층을 추가해서 역할을 분담해주자

public class ArticleApiController {

    @Autowired // DI : 의존성 주입, 외부에서 가져옴
    private ArticleService articleService;
//
//    //GET
//    @GetMapping("/api/articles")
//    public List<Article> index(){
//        //리포지토리를 이용해서 모든 article을 가져옴
//        return articleRepository.findAll();
//    }
//
//    @GetMapping("/api/articles/{id}")
//    public Article show(@PathVariable Long id){
//        return articleRepository.findById(id).orElse(null);
//    }
//
//    //POST
//    @PostMapping("/api/articles")
//    public Article create(@RequestBody ArticleForm dto){
//        //dto로(ArticleForm) 클라이언트가 보낸 json을 받아서 엔티티인 Article로 변환함
//        Article article = dto.toEntitiy();
//
//        return articleRepository.save(article);
//    }
//
//    //PATCH
//    @PatchMapping("/api/articles/{id}")  //ResponseEntity에 담아서 보내면 상태 코드를 같이 담을 수 있다
//    public ResponseEntity<Article> update(@PathVariable Long id, @RequestBody ArticleForm dto) {
//        //1. 수정용 엔티티 생성
//        Article article = dto.toEntitiy();
//        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("Wrong Request! id : {}, article : {}",id,article.toString());
//            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
//        }
//
//        //4. 업데이트 및 정상 응답(200)
//        target.patch(article);
//        Article updated = articleRepository.save(target);
//
//        return ResponseEntity.status(HttpStatus.OK).body(updated);
//    }
//
//    //DELETE
//    @DeleteMapping("/api/articles/{id}")
//    public ResponseEntity<Article> delete(@PathVariable Long id){
//
//        // 대상 찾기
//        Article target = articleRepository.findById(id).orElse(null);
//
//        //잘못된 요청 처리
//        if (target==null){
//            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
//        }
//
//        // 대상 삭제
//        articleRepository.delete(target);
//
//        // 데이터 반환
//        return ResponseEntity.status(HttpStatus.OK).build();
//    }


}

ArticleService의 내용을 모두 주석처리해주고 ArticleRepository를 ArticleService로 바꾸자. ArticleService 클래스를 만들자

package com.example.firstproject.service;

import com.example.firstproject.repository.ArticleRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service //서비스 선언 (서비스 객체를 스프링부트에 생성)
public class ArticleService {

    @Autowired //서비스가 리포지토리와 협업할 수 있도록 멤버로 넣어준다 , 마찬가지로 @Autowired를 이용해서 DI
    private ArticleRepository articleRepository;


}

먼저 ArticleService 클래스의 뼈대를 만들어준다.
@Service 어노테이션으로 서비스 객체를 스프링부트에 생성한다.
그리고 멤버로 ArticleRepository를 넣어서 서비스와 리포지토리가 협업할 수 있게 했다.

이제 ArticleApiController로 돌아가서 CRUD 기능의 주석을 하나씩 해제하며 리팩토링 해주자.

GET

    //GET
    @GetMapping("/api/articles")
    public List<Article> index(){
        //리포지토리를 이용해서 모든 article을 가져옴
        return articleService.index();
    }

게시물 전체롤 조회하기 위한 메소드 index()는 위처럼 수정했다.
articleService의 index 메소드를 호출한다.
따라서 articleService에 index 메소드를 만들어주자..

@Service //서비스 선언 (서비스 객체를 스프링부트에 생성)
public class ArticleService {

    @Autowired //서비스가 리포지토리와 협업할 수 있도록 멤버로 넣어준다 , 마찬가지로 @Autowired를 이용해서 DI
    private ArticleRepository articleRepository;

    public List<Article> index(){
        return articleRepository.findAll();
    }
}

이렇게 index 메소드를 만들어줬다. 리포지토리를 통해서 게시물 목록을 가져오게 했다.
Talend API 테스터로 확인해보면 정상작동 하는것을 확인할 수 있다.

  @GetMapping("/api/articles/{id}")
    public Article show(@PathVariable Long id){
        return articleService.show(id);
    }

컨트롤러의 show 메소드 (게시물 하나만 확인하는 메소드) 는 이렇게 바꿔준다. 마찬가지로 서비스의 show를 호출한다. 따라서 서비스에 show 메소드를 만들어줘야 한다.

    public Article show(Long id){
        return articleRepository.findById(id).orElse(null);
    }

서비스의 show 메소드는 다음과 같다. 리포지토리를 통해 findById 해준다.

POST

    @PostMapping("/api/articles")
    public ResponseEntity<Article> create(@RequestBody ArticleForm dto){
        //dto로(ArticleForm) 클라이언트가 보낸 json을 받아서 엔티티인 Article로 변환함
        Article created = articleService.create(dto);

        return created!=null ?
                ResponseEntity.status(HttpStatus.OK).body(created):
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        //build()는 내용 없이 반환, body(null)로 반환해도 된다 

    }

컨트롤러의 create 메소드는 이렇게 만들어준다.
새로 만들 엔티티는 서비스의 create 메소드를 호출해서 만든다 (create 메소드 만들면됨)
return은 삼항연산자를 이용해서 작성했다.

 public Article create(ArticleForm dto){
        Article article = dto.toEntitiy();
        return articleRepository.save(article);
    }

서비스에 create 메소드에서는 dto를 엔티티로 바꾸고 리포지토리를 이용해서 DB에 엔티티를 저장하고 반환해준다.

근데 이렇게 만들면 request body에 json이 이미 있는 id일 경우 해당
게시물이 수정됨. POST 메소드는 수정하면 안되니까 이걸 고쳐줘야한다.

    public Article create(ArticleForm dto){
        Article article = dto.toEntitiy();
        if (article.getId()!=null) {return null;}
        return articleRepository.save(article);
    }

이렇게 바꿔서 해결하면 된다.
id가 null이 아니면 그냥 null을 반환하게 한다.
(id를 따로 입력안하면 null이기 때문에)


이렇게 하면 id값이 있는 아티클 대상으로 POST 요청 했을 때 잘못된 request로 처리된다.

PATCH

    //PATCH
    @PatchMapping("/api/articles/{id}")  //ResponseEntity에 담아서 보내면 상태 코드를 같이 담을 수 있다
    public ResponseEntity<Article> update(@PathVariable Long id, @RequestBody ArticleForm dto) {

        Article updated = articleService.update(id,dto);

        return updated!=null ?
                ResponseEntity.status(HttpStatus.OK).body(updated) :
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();

    }

POST에서 한것과 거의 같다. 서비스에 CREATE 메소드 만들어주면 된다.

   public Article update(Long id, ArticleForm dto) {
        //1. 수정용 엔티티 생성
        Article article = dto.toEntitiy();
        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("Wrong Request! id : {}, article : {}",id,article.toString());
            return null;
        }

        //4. 업데이트
        target.patch(article);
        Article updated = articleRepository.save(target);
        return updated;
    }

Service의 update 메소드는 위와 같다.
원래 컨트롤러에서 쓰던 내용을 복사해서 가져왔다.
그리고 원래는 응답 코드랑 바디를 반환해줬는데 여기는 Service니까 수정한 객체만 반환해주면 된다.

public class Article {

    @Id // 각 객체를 식별하기 위한 Id임 (주민번호 같은거)
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 id를 자동생성함
    private Long id;

    @Column //DB가 필드를 인식할 수 있게 해줌
    private String title;
    @Column
    private String content;

    public void patch(Article article){
        if (article.title!=null){
            this.title = article.title;
        }
        if (article.content != null){
            this.content = article.content;
        }
    }

}

Entitiy 클래스의 patch 메소드에 실수가 있었다. 결과확인하면서 알게된건데 cotent!=null 일 때 this.content = article.title;로 작성했었다.

DELETE

    //DELETE
    @DeleteMapping("/api/articles/{id}")
    public ResponseEntity<Article> delete(@PathVariable Long id) {

        Article deleted = articleService.delete(id);
        
        return (deleted != null) ?
                ResponseEntity.status(HttpStatus.OK).body(deleted) :
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

컨트롤러의 delete 메소드
서비스의 delete 메소드를 호출해서 처리하고 반환받는다
반환값에 따라 리포지토리로 상태코드와 바디 반환
강의에서는 OK응답코드일 경우에도 build()로 빈 바디를 응답했다. (어차피 삭제니까)

public Article delete(Long id) {

        // 대상 찾기
        Article target = articleRepository.findById(id).orElse(null);

        //잘못된 요청 처리
        if (target==null){
            return null;
        }

        // 대상 삭제
        articleRepository.delete(target);

        // 데이터 반환
        return target;
    }

서비스의 delete 메소드다.
마찬가지로 처리해서 객체 반환만 해주면 된다. 컨트롤러가 반환된 객체로 응답을 해줄거니까

트랜잭션

    //트랜잭션 -> 실패 -> 롤백!
    @PostMapping("/api/transaction-test")
    public ResponseEntity<List<Article>> transactionTest(@RequestBody List<ArticleForm> dtos){
        List<Article> createdList = articleService.createArticles(dtos);
        return (createdList!=null) ?
                ResponseEntity.status(HttpStatus.OK).body(createdList):
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

트랜잭션을 연습하기 위해 컨트롤러에 transactionTest 메소드를 만들자
request로 json 데이터를 보낼건데 이렇게 보낼거임

이렇게 Article이 여러개 들어있는 json 데이터를 보낸다. json 의 List를 보내는 것이므로 []로 묶어줘야 한다.
따라서 매개변수로 @RequestBody를 통해 받는데 List에 ArticleForm이 들어간 형태로 받는다.
위에서 POST를 처리할 때 json 데이터를 dto를 통해 받는데 @RequestBody 어노테이션을 달아서 받았다. 마찬가지로 json 데이터의 리스트를 받기 때문에 어노테이션을 달고 받아준다.
그리고 반환형은 응답 코드랑 같이 보내기 위해 ResponseEntitiy에 List Article 형으로 보낸다.
내부에서 처리하는건 마찬가지로 articleService에 createArticles 메소드를 만들어서 처리하면 된다.

이제 서비스에 transactionTest 메소드를 만들면 된다.


    public List<Article> createArticles(List<ArticleForm> dtos){
        // dto 묶음을 entitiy 묶음으로 변환, 스트림 문법 사용
        List<Article> articleList = dtos.stream()
                .map(dto -> dto.toEntitiy())
                .collect(Collectors.toList());

        // entity 묶음을 DB에 저장
        articleList.stream()
                .forEach(article->articleRepository.save(article));

        // 강제로 예외 발생
        articleRepository.findById(-1L).orElseThrow(
                ()->new IllegalArgumentException("실패!")
        );

        // 결과값 반환
        return articleList;

    }

서비스에 createArticles를 위와 같이 만들었다.
스트림 문법을 사용해서 dto 리스트를 엔티티 리스트로 변환하고 엔티티 리스트의 각 요소를 db에 저장했다. for문을 사용해서 진행해도 된다.
findById에 음수롤 전달해서 강제로 예외를 발생시켰다.

이렇게 하고 TalendAPI를 이용해서 request를 보내보면 내가 강제로 예외 발생 시킨 시점에서 예외가 발생하지만 그 전에 이미 save로 데이터를 저장해서 DB에 데이터 자체는 들어간다..
이렇게 예외가 발생했는데 DB에 데이터는 들어가면 안된다.

    //트랜잭션 -> 실패 -> 롤백!
    @PostMapping("/api/transaction-test")
    @Transactional //해당 메소드를 트랜잭션으로 묶는다! 도중에 실패하면 롤백 (이전상태로)
    public ResponseEntity<List<Article>> transactionTest(@RequestBody List<ArticleForm> dtos){
        List<Article> createdList = articleService.createArticles(dtos);
        return (createdList!=null) ?
                ResponseEntity.status(HttpStatus.OK).body(createdList):
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

이런 문제를 해결하기 위해 컨트롤러에서 해당 메소드에 @Transactional 어노테이션을 달아서 서비스가 트랜잭션으로 관리하도록 한다. 이렇게 어노테이션을 달아주면 도중에 실패하면 롤백해서 원래 상태로 돌아간다.

이렇게 하고 request를 보내고 db를 확인해보면 에러가 발생해서 롤백했기 때문에 DB에 변화는 없다..


요약

서비스 : 컨트롤러와 리포지토리에 중간에 위치

  • 컨트롤러 : 클라이언트로부터 요청 받고 응답 처리에만 집중
  • 서비스 : 업무의 일반적인 처리흐름, 흐름에 실패했을 경우를 대비한 트랜잭션 관리
  • 트랜잭션에 실패하면 ? 롤백

0개의 댓글