[REST API와 테스트 코드 작성하기] 12장 | 서비스 계층과 트랜잭션

2025. 3. 5. 07:14·CS/스프링부트3 자바 백엔드 개발 입문

REST API에 서비스 계층을 추가해보고 문제 생기면 트랜잭션을 이용해 롤백하는 방법 알아보기

12. 1 서비스와 트랜잭션의 개념

✔️ 서비스(service)

컨트롤러와 리포지토리 사이에 위치하는 계층으로, 서버의 핵심 기능(비즈니스 로직)을 처리하는 순서 총괄

 

✔️ 트랜잭션(transaction)

 모두 성공해야 하는 일련의 과정; 트랜잭션 단위로 서비스 업무 처리 진행

e.g. 식당 예약에서 결제를 실패하면 이전까지 진행해도 모두 취소되어야 한다. -> 트랜잭션

트랜잭션이 실패로 돌아갈 경우 진행 초기로 돌리는 것이 롤백(rollback)! 

 

REST 컨트롤러로 서비스 컨트롤러 확인하기

// 컨트롤러 역할: 클라이언트 요청받기
@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()){
        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);
    
    // 컨트롤러 역할: 클라이언트에 응답하기
    return ResponseEntity.status(HttpStatus.OK).body(updated);
}

일반적으로 웹 서비스는 컨트롤러와 리포지토리 사이에 서비스 계층을 둬서 역할을 분업한다. (위 코드는 간단해서 1인 2역 가능)

 


12.2 서비스 계층 만들기

서비스 계층 추가해 컨트롤러, 서비스, 리포지토리 역할 분업하기

✔️ 서비스 클래스 생성하기

@Autowired 어노테이션으로 리포지토리와 협업할 수 있도록 articleRepository 객체 주입

12.2.1 게시글 조회 요청 개선하기

📍모든 게시글 조회 요청 개선하기

  • 기존: /api/articles 로 요청이 들어옴 ➡️ 리포지토리 통해 데이터 가져옴
  • 서비스 이용해 return articleService.index()로 가져오기
@GetMapping("/api/articles") // URL 요청 접수
public List<Article> index(){
    return articleService.index();
}
    public List<Article> index() {
        // 데이터는 리포지토리를 통해 가져온다
        return articleRepository.findAll();
    }

📍단일 게시글 조회 요청 개선하기

: 리포지토리와 컨트롤러가 만날 일 없이 서비스가 조회 요청 처리하도록 수정

    // 단일 게시글 조회하기
    @GetMapping("/api/articles/{id}") // URL 요청 접수
    public Article show(@PathVariable Long id){
        return articleService.show(id);
    }
    public Article show(Long id) {
        return articleRepository.findById(id).orElse(null);
    }

 

12.2.2 게시글 생성 요청 개선하기

    // POST
    @PostMapping("/api/articles")
    public ResponseEntity<Article> create(@RequestBody ArticleForm dto){
     // 반환형이 Article인 create() 정의 후 수정할 데이터를 dto로 받음
        Article created = articleService.create(dto);
        return (created != null) ?
                ResponseEntity.status(HttpStatus.OK).body(created) :
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }
  • article로 하면 헷갈리니깐 created로 객체 이름을 변경한다.
public Article create(ArticleForm dto) {
    // dto -> 엔티티로 변환 후 article에 저장
    Article article = dto.toEntity();
    if (article.getId() != null){
        return null;
    }
    // article을 DB에 저장
    return articleRepository.save(article);
}
  • POST 요청은 수정하는 것이 아닌 생성하는 요청이므로 id가 존재한다면(id 저절로 생성해 주기 때문에) null을 반환한다. 

 

12.2.3 게시글 수정 요청하기

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

서비스계층을 추가하니 코드가 훨씬 짧아진 것을 확인할 수 있었다. 

➡️ 컨트롤러는 서비스에 어떤 지시를 하고 어떤 데이터를 받아 오는지만 알면 된다. (작업은 서비스가)

    public Article update(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()){
            log.info("잘못된 요청! id: {}, article: {}", id, article.toString());
            return null;
        }
        // 4. 업데이트 및 정상 응답(200) 하기
        target.patch(article);
        Article updated = articleRepository.save(target);
        return updated; // 응답은 컨트롤러가 하므로 수정 엔티티만 반환
    }

 

12.2.4 게시글 삭제 요청 개선하기

    // DELETE
    @DeleteMapping("/api/articles/{id}")
    public ResponseEntity<Article> delete(@PathVariable Long id){
        Article deleted = articleService.delete(id);
        return (deleted != null) ?
                ResponseEntity.status(HttpStatus.NO_CONTENT).build():
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
   }
  • deleted에 내용 있으면 상태에 NO_CONTENT 보내고 본문 빌드
  • deleted에 내용 없으면 삭제 못한거니까 BAD_REQUEST 하고 본문 빌드
    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; // 보내줘야 if문 사용가능
    }
  • 역시나 상태와 본문 실어 보내는건 컨트롤러 일이니까 여기서는 null
  • 삭제할 대상 있으면 삭제하고, target은 보내준다 
    • 그래야 deleted != null 로 조건문 이용 가능

 


12.3 트랜잭션 맛보기

[시나리오]

  1. 게시판에 데이터 3개 한꺼번에 생성 요청하기
  2. 데이터를 DB에 저장하는 과정에서 의도적으로 오류 발생시키기
  3. 어떻게 롤백되는지 확인하기

1. REST 컨트롤러가 2개의 요청 받아 결과 응답하도록 ArticleApiController에 transactionTest() 메서드 추가

2. 컨트롤러는 요청 결과 반환 / 서비스에 createArticles()메서드 작성

3. 강제 예외상황 발생: findById()로 id가 -1인 데이터 받기 - id는 음수가 될 수 없다. 

    @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();
    }
    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)
                .orElseThrow(() -> new IllegalArgumentException("결제 실패!"));
        // 4. 결과 값 반환하기
        return articleList;
    }

서버에서 예외 상황 발생시켰기때문에 상태코드 500

결제 실패 메세지 확인 전에 INSERT문이 2번 수행된 것을 확인할 수 있다 -> DB에 데이터가 생성되긴 했다. 

-> DB에 데이터가 생성된 것을 확인할 수 있다. 

근데 오류가 떴는데 데이터가 생성되면 안되니깐 데이터 생성 실패 이전 상황으로 되돌리기 위해 트랜잭션을 선언한다. 

트랜잭션은 서비스에서 관리: 서비스의 메서드에 @Transactional을 붙이면 해당 메서드가 하나의 트랜잭션으로 묶임 

 

✔️ @Transactional 처리 해준다면 롤백을 통해 이전 상태로 돌아갈 수 있다. 

INSERT문이 두번 실행되기는 함

결제 실패 메세지 출력됐는데 게시판에 데이터가 추가됐는지 아닌지 확인해보면

추가된 데이터가 없는 것을 확인할 수 있었다. (기본 데이터만 있음)

📌 셀프 체크

✔️ CoffeeController

package com.example.firstproject.api;

import com.example.firstproject.dto.CoffeeDto;
import com.example.firstproject.entity.Coffee;
import com.example.firstproject.repository.CoffeeRepository;
import com.example.firstproject.service.CoffeeService;
import org.apache.coyote.Response;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
public class CoffeeApiController {
    @Autowired
    private CoffeeService coffeeService;
    // GET
    // 전체 메뉴 조회하기
    @GetMapping("/api/coffee")
    public List<Coffee> index(){
        return coffeeService.index();
    }

    // 단일 게시글 조회하기
    @GetMapping("/api/coffee/{id}")
    public Coffee show(@PathVariable Long id){
        return coffeeService.show(id);
    }
    // POST
    @PostMapping("/api/coffee")
    public ResponseEntity<Coffee> create(@RequestBody CoffeeDto dto){
        // DTO 받아와서 엔티티로 변경하기
        Coffee created = coffeeService.create(dto);
        return (created != null)?
                ResponseEntity.status(HttpStatus.OK).body(created):
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }
    // PATCH
    @PatchMapping("/api/coffee/{id}")
    public ResponseEntity<Coffee> update(@PathVariable Long id, @RequestBody CoffeeDto dto){
        Coffee updated = coffeeService.update(id, dto);
        return (updated != null) ?
                ResponseEntity.status(HttpStatus.OK).body(updated):
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }
    // DELETE
    @DeleteMapping("/api/coffee/{id}")
    public ResponseEntity<Coffee> delete(@PathVariable Long id){
        Coffee deleted = coffeeService.delete(id);
        return (deleted != null) ?
                ResponseEntity.status(HttpStatus.NO_CONTENT).build():
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();

    }
}

 

✔️ 서비스 추가

package com.example.firstproject.service;

import com.example.firstproject.dto.ArticleForm;
import com.example.firstproject.entity.Article;
import com.example.firstproject.repository.ArticleRepository;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service // 서비스 객체 생성
public class ArticleService {
    @Autowired
    private ArticleRepository articleRepository;

    public List<Article> index() {
        // 데이터는 리포지토리를 통해 가져온다
        return articleRepository.findAll();
    }

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

    public Article create(ArticleForm dto) {
        // dto -> 엔티티로 변환 후 article에 저장
        Article article = dto.toEntity();
        if (article.getId() != null){
            return null;
        }
        // article을 DB에 저장
        return articleRepository.save(article);
    }

    public Article update(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()){
            log.info("잘못된 요청! id: {}, article: {}", id, article.toString());
            return null;
        }
        // 4. 업데이트 및 정상 응답(200) 하기
        target.patch(article);
        Article updated = articleRepository.save(target);
        return null;
    }

    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; // 보내줘야 if문 사용가능
    }

    @Transactional
    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)
                .orElseThrow(() -> new IllegalArgumentException("결제 실패!"));
        // 4. 결과 값 반환하기
        return articleList;
    }
}

'CS > 스프링부트3 자바 백엔드 개발 입문' 카테고리의 다른 글

[댓글 CRUD 만들기] 14장 | 댓글 엔티티와 리포지토리 만들기  (1) 2025.03.08
[REST API와 테스트 코드 작성하기] 13장 | 테스트 코드 작성하기  (0) 2025.03.06
[REST API와 테스트 코드 작성하기] 11장 | HTTP와 REST 컨트롤러  (0) 2025.03.01
[REST API와 테스트 코드 작성하기] 10장 | REST API와 JSON  (0) 2025.02.27
[게시판 CRUD 만들기] 9장 | CRUD와 SQL 쿼리 종합  (0) 2025.02.27
'CS/스프링부트3 자바 백엔드 개발 입문' 카테고리의 다른 글
  • [댓글 CRUD 만들기] 14장 | 댓글 엔티티와 리포지토리 만들기
  • [REST API와 테스트 코드 작성하기] 13장 | 테스트 코드 작성하기
  • [REST API와 테스트 코드 작성하기] 11장 | HTTP와 REST 컨트롤러
  • [REST API와 테스트 코드 작성하기] 10장 | REST API와 JSON
순토언니
순토언니
기록
  • 순토언니
    secrete_parallel
    순토언니
  • 전체
    오늘
    어제
    • 분류 전체보기 (49)
      • CS (23)
        • 혼자 공부하는 컴퓨터 구조 + 운영체제 (7)
        • 파이썬 알고리즘 인터뷰 (0)
        • 스프링부트3 자바 백엔드 개발 입문 (14)
        • 개발 (2)
      • AI (4)
        • 혼자 공부하는 머신러닝 딥러닝 (0)
        • 논문 리뷰 (0)
        • LLM을 활용한 실전 AI 애플리케이션 개발 (4)
      • 프로젝트 (0)
        • GDSC 겨울 프로젝트 (0)
        • forA (0)
      • 시험 후기 (2)
      • 손에 잡히는 경제 (14)
      • 영화 (0)
      • 독서기록 (2)
      • 맛집DB (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    손경제
    한국어 형태소 분석기
    손에 잡히는 경제 플러스
    양귀자
    테스트 프레임워크
    론도론도
    손경제 리뷰
    백엔드
    손경제 플러스 리뷰
    정처기
    pytest
    코딩 자율학습 스프링 부트3 자바 백엔드 개발 입문
    스프링부트3
    손잡경
    손에 잡히는 경제
    kiwipiepy
    손경제 플러스
    임베딩
    딥러닝
    코딩자율학습단
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
순토언니
[REST API와 테스트 코드 작성하기] 12장 | 서비스 계층과 트랜잭션
상단으로

티스토리툴바