서비스와 트랜잭션의 개념

서비스란 컨트롤러와 레파지토리 사이에 위치하는 계층으로 서버의 핵심 기능을 처리하는 순서를 총괄한다.

웨이터에게 주문이 들어오면 주방장이 요리를 총괄하고 보조 요리사가 필요한 재료가 있다면 이를 가져온다.

여기서 웨이트는 컨트롤러, 주방장은 서비스, 보조 요리사는 레파지토리라고 생각하면 쉬운데 그림으로 보면 아래와 같다.

또 하나 알아야 할 개념은 트랜잭션인데 트랜잭션이란,

모두 성공해야 하는 일련의 과정을 뜻하며 쪼갤 수 없는 업무 처리의 최소 단위이다.


서비스 계층 만들기

11장에서 만든 REST 컨트롤러 코드를 열고 보면 이 컨트롤러는 레파지토리와 협업해 게시판의 데이터를 CRUD를 했다.

여기에 서비스 계층을 추가해서 컨트롤러와 서비스 레파지토리의 역할을 분업할 것이다.

먼저 객체 주입된 레파지토리를 서비스로 바꾸는 코드를 작성해 준다.

@Autowired
private ArticleService articleService; // 서비스 객체 주입

service 새 패키지 만들어 ArticleService라는 이름으로 서비스 파일을 생성해 준다.

이렇게 ArticleService 클래스에서 객체를 만들면,

REST 컨트롤러에서 객체 주입(@Autowired) 하는 방식으로 서비스 객체를 선언할 수 있게 된다.

ArticleService에서 레파지토리와 협업할 수 있게 articleRepository 필드를 추가하고 객체 주입해 준다.

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
    private ArticleRepository articleRepository;    // 게시글 레파지터리 객체 주입
}

이제 다시 컨트롤러로 가서 서비스 객체를 이용해 CRUD 기능을 리팩터링 해 볼 것인데 먼저 게시글 요청이다.

레퍼지토리를 통해 가져왔던 코드를 articleService에서 가져오도록 수정해 준다.

    // GET - 전체 데이터
    @GetMapping("/api/articles")
    public List<Article> index() {
        return articleService.index();
    }

컨트롤러에서 위와 같이 작성하면 index() 메서드를 아직 만들지 않아 빨간색으로 표시될 테인데,

해결하기 위해 ArticleService에 index 메서드를 생성해 주면 된다.

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

단일 게시글 조회 요청도 위와 유사한 방식으로 수정해 준 후 Talend API Tester로 잘 동작하는지 확인해 보도록 한다.

// GET - 단일 데이터
@GetMapping("/api/articles/{id}")
public Article show(@PathVariable Long id) {
    return articleService.show(id);
}


// ArticleService에 작성
public Article show(Long id) {
    return articleRepository.findById(id).orElse(null);
}

다음으로는 게시글 생성 시에 코드를 수정해 볼 것인데 먼저 컨트롤러에서 코드를 아래와 같이 수정해 준다.

    // 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();
    }

서비스에 creaate() 메서드를 추가하고 dto를 엔티티로 변환해 article 객체에 저장 후,

레퍼지토리에 articled을 DB에 저장하도록 한다.

여기서 하나 생각해 주어야 할 것은 기존에 데이터가 있을 경우 이를 수정하게 되는데 POST 요청은,

수정해 달라는 것이 아닌 생성해 달라는 요청이기 때문에 1번 데이터를 변형하면 안되기 때문에 이 처리도 해 주어야 한다.

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

위와 같이 수정 후 실행한 결과는 다음과 같다.

게시글 수정과 삭제의 경우에도 컨트롤러가 컨트롤러 업무만 처리하도록 하고,

데이터를 수정하고 삭제하는 코드는 서비스에게 위임하도록 코드를 수정하여 작성하였다.

    // PATCH
    @PatchMapping("/api/articles/{id}")
    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();
    }

    // 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();
    }
    public Article update(Long id, ArticleForm dto) {

        // 1. dto -> 엔티티 변환하기
        Article article = dto.toEntity();   // dto를 엔티티로 변환
        log.info("id: {}, article: {}", id, article.toString()); // 로그 찍기

        // 2. 타깃 조회하기
        Article target = articleRepository.findById(id).orElse(null);

        // 3. 잘못된 요청 처리하기
        if (target == null || id != article.getId()) {  // 잘못된 요청인지 판별
            log.info("잘못된 요청! id: {}, article: {}", id, article.toString());    // 로그 찍기
            return null;
        }

        // 4. 업데이트 및 정상 응답(200) 하기
        target.patch(article);
        Article updated = articleRepository.save(target);  // article 엔티티 DB에 저장
        return updated;
    }

    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;
    }

트랜잭션 맛보기

트랜잭션 실습을 위해 ArticleApiController에 post요청으로 transactionTest() 메서드를 먼저 생성해준다.

    // 트랜잭션 테스트
    @PostMapping("/api/transaction-test")
    public ResponseEntity<List<Article>> transactionTest(@RequestBody List<ArticleForm> dtos) {

        // 서비스 호출
        List<Article> createList = articleService.createArticles(dtos);

        // 생성 결과에 따라 응답 처리
        return (createList != null) ?
                ResponseEntity.status(HttpStatus.OK).body(createList) :
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

컨트롤러 코드를 작성했으니 이제 서비스에 아래와 같이 메서드를 만들고 단계에 따른 코드를 작성해준다.

public List<Article> createArticles(List<ArticleForm> dtos) {
        // 1. dto 묶음을 엔티티 묶음으로 변환하기
        List<Article> articleList = dtos.stream()
                .map(dto -> dto.toEntity())
                .collect(Collectors.toList());

        // 2. 엔티티 묶음을 DB에 저장하기
        articleList.stream()
                .forEach(article -> articleRepository.save(article));

        // 3. 강제 예외 발생시키기
        articleRepository.findById(-1L)     // id가 1인 데이터 찾기
                .orElseThrow(() -> new IllegalArgumentException("결제 실패!")); // 찾는 데이터가 없으면 예외 발생

        // 4. 결과 값 반환하기
        return articleList;
    }

서버를 재실행하여  Talend API Tester로 아래와 같이 작성하고 send 시 상태코드 500을 볼 수 있다.

우리가 강제로 예외 상황을 발생시켰기 때문인데 인텔리제이 실행창으로 확인해보면,

"결제 실패!" 메시지를 띄우기 전에 insert문이 이미 수행된 것도 확인할 수 있다.

'http://localhost:8080/articles'로 접속해서 보아도 아래와 같이 데이터가 추가된 것을 볼 수 있다.

데이터 생성에 실패했으니 추가한 데이터가 남지 않기를 원한다.

이 때 사용하는것이 트랜잭션인데 우리가 생성한 createArticles 메서드에다가 트랜잭션 어노테이션을 추가해주면 된다.

@Transactional
public List<Article> createArticles(List<ArticleForm> dtos) {
    ...
    ...
    ...
}