기록방

12장 : 서비스 계층과 트랜잭션 본문

FrameWork/Spring

12장 : 서비스 계층과 트랜잭션

Soom_1n 2024. 7. 9. 18:12

길벗 IT도서에서 주관하는 코딩 자율학습단 8기 : Spring Boot 파트에 참여한 기록입니다 [ 목록 ]

12.1 서비스와 트랜잭션의 개념

  • 서비스(service)
    • 컨트롤러와 리포지토리 사이에 위치하는 계층
    • 서버의 핵심 기능(비지니스 로직)을 처리하는 순서를 총괄
    • ex) 식당
      • 손님 - (웨이터 - 주방장 - 보조 요리사) - 창고
      • 웨이터 : 컨트롤러
      • 주방장 : 서비스
      • 보조 요리사 : 리포지토리
    • 일반적으로 서비스 업무 처리는 트랜잭션 단위로 진행
  • 트랜잭션(transaction)
    • 모두 성공해야 하는 일련의 과정
    • 쪼갤 수 없는 업무 처리의 최소 단위
    • ex) 식당 예약
      • 시간 예약 - 테이블 지정 - 메뉴 선택 - 결제 - 영수증 발행 - 예약 완료
      • 결제 단계에서 실패했다면, 앞 기록은 모두 제거 되어야 함. (롤백)
        실패 기록이 계속 남아 있으면 또 다른 예약을 받을 수 없기 때문.
    • 롤백(rollback)
      • 트랜잭션이 실패로 돌아갈 경우 진행 초기 단계로 돌리는 것
  • 앞선 코드는 컨트롤러에서 서비스까지 처리했으니, 서비스 계층을 분리해보자

12.2 서비스 계층 만들기

@Service
public class ArticleService {
    @Autowired
    private ArticleRepository articleRepository;
}

public class ArticleApiController {
    @Autowired
    private ArticleService articleService
}
  • 서비스에서 리포지토리, 컨트롤러에서 서비스를 사용

컨트롤러 수정

package com.example.firstproject.api;

import com.example.firstproject.dto.ArticleForm;
import com.example.firstproject.entity.Article;
import com.example.firstproject.repository.ArticleRepository;
import com.example.firstproject.service.ArticleService;
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.web.bind.annotation.*;

import java.util.List;

@RestController
@Slf4j
public class ArticleApiController {
    @Autowired
    private ArticleService articleService;

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

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

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

    // DELETE
    @DeleteMapping("/api/articles/{id}")
    public ResponseEntity<Article> create(@PathVariable Long id) {
        Article deleted = articleService.delete(id);
        return (deleted != null) ?
                ResponseEntity.status(HttpStatus.OK).body(deleted):
                ResponseEntity.status(HttpStatus.OK).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 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 java.util.List;

@Service
@Slf4j
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) {
        Article article = dto.toEntity();
        if(article.getId() != null){
            return null;
        }
        return articleRepository.save(article);
    }

    public Article update(Long id, ArticleForm dto) {
        // 1. DTO -> 엔티티 변환하기
        Article article = dto.toEntity();
        log.info("id: {}, article: {}", id, article);

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

        // 3. 잘못된 요청 처리하기
        if (target == null || id != article.getId()) {
            // 400, 잘못된 요청 응답!
            log.info("잘못된 요청! id: {}, article: {}", id, article);
            return null;
        }

        // 4. 업데이트 및 정상 응답(200)하기
        target.patch(article);
        Article updated = articleRepository.save(target);
        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 ResponseEntity.status(HttpStatus.OK).body(null);
        return target;
    }
}
  • 이전 서비스 로직은 service단에서 처리

🚀 1분 퀴즈

  1. 클라이언트의 요청이 오면 ‘클라이언트 → 컨트롤러 → 서비스 → 리포지토리 → DB’ 순으로 작업해 결과를 응답합니다. 이를 음식점에 빗대어 클라이언트를 ‘손님’, DB를 ‘식자재 창고’라고 할 때, ‘컨트롤러 → 서비스 → 리포지토리’의 관계로 적절한 것을 고르세요.
  2. 2) 웨이터 → 주방장 → 보조 요리사

12.3 트랜잭션 맛보기

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

컨트롤러

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

서비스

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

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

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

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

요청 후 강제 오류 발생

 

3번 insert 후 오류가 발생

트랜잭션 취소 결과

삽입이 실행 된 3개의 데이터가 오류 발생으로 삭제 되었음

🚀 1분 퀴즈

  • 다음 빈칸에 들어갈 용어를 쓰세요
    • ( 트랜잭션 )(이)란 모두 성공해야 하는 일련의 과정으로 쪼갤 수 없는 업무 처리의 최소 단위다.
    • ( 트랜잭션 )이/가 선언된 코드 내부에서 만약 실행에 실패하면 변경된 데이터를 모두 이전 값으로 되돌리는데, 이를 ( 롤백 )(이)라 한다.

✅ 셀프 체크

Coffees REST API 개선 : 서비스 추가

컨트롤러 수정

package com.example.firstproject.api;

import com.example.firstproject.dto.CoffeeDto;
import com.example.firstproject.entity.Coffee;
import com.example.firstproject.service.CoffeeService;
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.web.bind.annotation.*;

import java.util.List;

@RestController
@Slf4j
public class CoffeeApiController {
    @Autowired
    private CoffeeService coffeeService;

    // GET
    @GetMapping("/api/coffees")
    public ResponseEntity<List<Coffee>> index() {
        List<Coffee> coffeeList = coffeeService.index();
        return coffeeList != null ?
                ResponseEntity.status(HttpStatus.OK).body(coffeeList) :
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

    @GetMapping("/api/coffees/{id}")
    public ResponseEntity<Coffee> show(@PathVariable Long id) {
        Coffee coffee = coffeeService.show(id);
        return coffee != null ?
                ResponseEntity.status(HttpStatus.OK).body(coffee) :
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

    // POST
    @PostMapping("/api/coffees")
    public ResponseEntity<Coffee> create(@RequestBody CoffeeDto dto) {
        Coffee created = coffeeService.create(dto);
        return created != null ?
                ResponseEntity.status(HttpStatus.OK).body(created) :
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

    // PATCH
    @PatchMapping("/api/coffees/{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/coffees/{id}")
    public ResponseEntity<Coffee> create(@PathVariable Long id) {
        Coffee deleted = coffeeService.delete(id);
        return deleted != null ?
                ResponseEntity.status(HttpStatus.OK).body(deleted) :
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

}

서비스 생성

package com.example.firstproject.service;

import com.example.firstproject.dto.CoffeeDto;
import com.example.firstproject.entity.Coffee;
import com.example.firstproject.repository.CoffeeRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class CoffeeService {
    @Autowired
    private CoffeeRepository coffeeRepository;

    public List<Coffee> index() {
        return coffeeRepository.findAll();
    }

    public Coffee show(Long id) {
        return coffeeRepository.findById(id).orElse(null);
    }

    public Coffee create(CoffeeDto dto) {
        Coffee coffee = dto.toEntity();
        if (coffee.getId() != null) return null;
        return coffeeRepository.save(coffee);
    }

    public Coffee update(Long id, CoffeeDto dto) {
        // 1. DTO -> 엔티티 변환하기
        Coffee coffee = dto.toEntity();

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

        // 3. 잘못된 요청 처리하기
        if (target == null || id != coffee.getId()) {
            // 400, 잘못된 요청 응답!
            return null;
        }

        // 4. 업데이트 및 정상 응답(200)하기
        target.patch(coffee);
        return coffeeRepository.save(target);
    }

    public Coffee delete(Long id) {
        // 1. 대상 찾기
        Coffee target = coffeeRepository.findById(id).orElse(null);

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

        // 3. 대상 삭제하기
        coffeeRepository.delete(target);
        return target;
    }
}

🏓 더 알아볼 내용

1. 컨트롤러, 서비스, 리포지토리의 역할

컨트롤러, 서비스, 리포지토리의 세 계층은 모두 추상화와 모듈화가 되어 있기 때문에 쉽게 분리가 가능합니다. 이 점을 인지하며 다음 글을 읽어주세요.

1) 컨트롤러

컨트롤러(Controller)는 Handler(핸들러)라고도 불리며 클라이언트의 요청을 가장 우선적으로 처리하는 로직입니다. 어떤 데이터를 클라이언트로부터 받을지 정의하고, 이를 서비스로 넘겨주는 역할을 합니다. 따라서 컨트롤러가 너무 많은 로직을 들고 있으면 안되겠죠? 컨트롤러는 클라이언트로부터 받은 값을 서비스로 넘겨주고(물론 넘겨줄 때 약간의 처리는 필요합니다) 서비스로부터 처리된 값을 클라이언트로 내려주는 역할만 합니다.

2) 서비스

서비스(Service)는 ‘비즈니스 로직’을 처리합니다. 값의 계산, 조합, 처리 등을 담당하죠.
회원 가입을 예로 들어봅시다.

  1. email과 비밀번호, 이름, 생년월일을 POST 요청을 통해 서버에 전송합니다.
  2. email 중복 확인을 합니다.
  3. email, 비밀번호, 이름, 생년월일을 담은 User 객체를 생성합니다.
  4. DB에 저장합니다.

여기서 2, 3번에 해당하는 것이 바로 서비스 계층에서 처리해야 하는 로직입니다.

3) 리포지토리

리포지토리(Repository)를 직역하면 ‘저장소’입니다. 흔히 DAO(data access object)로도 불립니다. 저장소 역할을 하는 곳은 어디일까요? DB겠죠. 즉 리포지토리는 DB와 서버의 연결 고리 역할을 합니다. DB에서 값을 불러오고, 저장하고, 삭제하는 등의 로직이 모여있습니다. 앞의 예시에서는 4번에 해당하는 것이죠.

이렇게 컨트롤러, 서비스, 리포지토리 세 개의 계층을 알아봤습니다. 각 계층을 잘 추상화 해야 계층을 잘 사용할 수 있기 때문에 객체 지향을 공부하는 것을 추천 드립니다.

2. 트랜잭션과 롤백

트랜잭션은 더 이상 쪼갤 수 없는 DB의 상태를 변화 시키는 원자적인 행동의 단위를 말합니다. 사실 이 말로는 이해가 어려울 수 있습니다. 다음 글을 보시죠.

트랜잭션은 작업의 완전성을 보장해주는 것이다. 즉, 논리적인 작업 셋을 모두 완벽하게 처리하거나, 처리하지 못할 경우에는 원 상태로 복구해서 작업의 일부만 적용되는 현상이 발생하지 않게 만들어 주는 기능이다.

- Real MySQL

프로그래밍에서는 트랜잭션을 위와 같은 개념으로 사용합니다. 즉 작업의 완전성을 위해 고안된 개념이라고 할 수 있습니다.

트랜잭션 단위로 작업을 하는 도중 작업의 완전성이 깨지면 ‘롤백’을 합니다. 롤백은 DB를 원래 상태로 되돌리는 것입니다. 그렇다면 롤백은 왜 필요할까요? 예를 들어 봅시다.

한 쇼핑몰에서 전체 회원을 대상으로 프로모션을 할인 행사를 기획했습니다. 회원 수만큼 물건을 발주하려고 합니다. 하나의 서버 인스턴스에서는 SQL count 쿼리를 날리고, 또 다른 서버 인스턴스에서는 고객 생성 API 요청을 합니다. 이때 서버에 알 수 없는 문제가 생겨 고객 생성 API 요청이 실패했습니다. 그런데 고객 생성 API 요청에 트랜잭션이 적용되어 있지 않아 고객 DB에 쓰레기 값이 들어가 버렸습니다. 엎친 데 덮친 격으로 SQL count 쿼리가 실행돼 기존 고객 수보다 고객 수가 더 많이 집계되는 문제가 발생했습니다.
이렇듯 트랜잭션이 적용되고 아니고는 매우 중요합니다. 만약 트랜잭션이 적용되었다면 알수 없는 문제가 생겼을 때 롤백이 돼서 쓰레기 값이 DB에 저장되지 않았을 겁니다.
트랜잭션에는 ACID라는 4대 원칙이 있습니다.

  • Atomicity(원자성)
  • Consistency(일관성)
  • Isolation(격리성)
  • Durability(지속성)

각각의 속성에 대해서는 다음 문서를 참고해주세요.

728x90