BE/스프링 부트 3

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

이제하네 2023. 12. 16. 12:00

서비스와 트랜잭션의 개념

  • 서비스(service) : 컨트롤러와 리파지터리 사이에 위치하는 계층으로 서버의 핵심 기능(비지니스 로직)을 처리하는 순서를 총괄
  • 트랜잭션(transaction) : 모두 성공해야 하는 일련의 과정 , 쪼갤수 없는 업무 처리의 최소 단위
    • 서비스 업무 처리는 트랜잭션 단위로 진행
    • 만약 트랜잭션이 실패로 돌아갈 경우 진행 초기 단계로 돌리는 것을 롤백(rollback)라고 함

간단한 서비스, 컨트롤러의 역할

 

서비스 계층 만들기

  1. ArticleApiController 내에 모든 코드를 주석 처리
  2. 객체 주입된 리파지터리를 서비스로 변경후 밑에코드작성
    @Autowired
        private ArticleService articleService; // 서비스 객체 주입
  3. service 라는 새 패키지 생성
  4. service 패키지 안에 ArticleService.java 파일 생성
  5. ArticleService.java 클래스위에 @Service 어노테이션 작성
    • @Service은 해당 클래스를 서비스로 인식하여 스프링 부트에 서비스 객체를 생성
  6. ArticleService.java에 리파지터리와 협업할수 있도록 articleRepositoty 필드를 추가하고 @Autowired를 통해 객체주입
ArticleApiController.java 파일 

public class ArticleApiController {
	
	@Autowired
	private ArticleService articleService; //서비스 객체 주입
    
==============================================================    
    
ArticleService.java 파일

@Service // 서비스 객체 생성
public class ArticleService {
	
	@Autowired
	private ArticleRepository articleRepository; // 게시글 리파지터리 객체 주입
}

 

게시글 조회 요청 개선하기

  • 모든 게시글 조회 요청 개선하기
    1. 아까 주석 처리한 부분에 index() 메서드의 주석 제거후 return 문의 원래 있던 코드를 제거후 articleService.index() 메서드를 호출 하도록 변경
    2. articleService에 index() 메서드를 생성 후 return문에 return articleRepository.findAll() 으로 작성회 DB에서 조회한 결과를 반환
ArticleApiController.java 파일 

	 @GetMapping("/api/articles") //URL 요청 접수 
	 public List<Article> index(){//index() 메서드 정의
	  return articleService.index(); 
	  }
    
==============================================================    
    
ArticleService.java 파일

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

모든 게시물 조회 결과

  • 단일 게시글 조회 요청 개선하기
    1. 단일 게시글을 조회하는 show() 메서드의 주석 제거 후 return 문의 articleRepository.findById(id).orElse(null)을 삭제하고 articleService.show(id) 작성
    2.  ArticleService에 show() 메서드 생성후  DB에서 id로 조회한 결과를 반환 하도록 return 문 작성
      ArticleApiController.java 파일 
      
      	@GetMapping("/api/articles/{id}")
      	public Article show(@PathVariable Long id) { 
      		return articleService.show(id);
      	}
          
      ==============================================================    
          
      ArticleService.java 파일
      
      public Article show(Long id) {
      		return articleRepository.findById(id).orElse(null);
      	}

단일 게시글 조회 결과

 

 

게시글 생성 요청 개선하기

  1. 컨트롤러의 create()메서드의 주석 제거
    1. dto.toEntity() 를 삭제후 articleService.create()를 호출하고 POST 요청 메세지에 담긴 데이터도 전달해야 하므로 괄호안에 dto 넣음
    2. create() 메서드를 호출해 얻은 결과를 Article 타입의 created 에 넣음
  2. return 문의 결과 created가 null이 아니면 good 요청을 아니면 bad 요청을 보내도록 삼항 연산자 사용
    1. return 문의 조건이 참이면 ResponseEntity의 상태에 OK, 본문에는 created를 보내고 거짓이면 ResponseEntity의 상태에 BAD_REQUEST, 본문은 없으므로 그냥 빌드만 해서 보냄
    2. ResponseEntity에 응답을 실어서 보냈기 때문에 create() 메서드의 반환형 ResponseEntity<Article>로 수정 
  3. 서비스에 create() 메서드 추가 
    1. dto를 엔티티로 변환해 article 객체에 저장
    2. 리파지터리에 article을 DB에 저장하도록 함 
  4. id를 데이터 생성할 때 굳이 넣을 필요가 없기 때문에 article 객체에 id가 존재한다면 null을 반환하는 코드 추가
    ArticleApiController.java 파일 
    
    	@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();
    	}
        
    ==============================================================    
        
    ArticleService.java 파일
    
    public Article create(ArticleForm dto) {
    		Article article = dto.toEntity();
    		if (article.getId() != null) {
    			return null;
    		}
    		return articleRepository.save(article);
    	}

데이터 생성 완료
id가 존재하면 데이터 생성 안됨

 

게시글 수정 요청 개선하기

  1.  컨트롤러의 update() 메서드의 주석을 제거
  2. 클라이언트의 요청을 받아 응답하는 코드 작성, 데이터를 수정하는 코드는 서비스에 위임
    1. 서비스의 update() 메서드를 호출하고 매개변수로 id와 dto를 전달하고 updated 객체로 반환값을 받아 저장
    2. updated에 내용이 있다면 ResponseEntity의 상태에는 OK, 본문에는 updated을 넣고 내용이 없다면 ResponseEntity의 상태에는 BAD_REQUEST, 본문에는 그냥 빌드만 보냄
  3. 서비스에 update() 메서드 작성
    1. 수정용 엔티티를 생성하고 로그를 찍는 코드 작성 ,ArticleService 클래스 위에 @Slf4j 어노테이션 추가
    2. 대상 엔티티를 찾는 코드 작성
    3. 잘못된 요청을 처리하고 응답하는 코드를 작성 
    4. DB에 수정 데이트를 업데이트 한후 최정적으로 updated에 저장된 데이터를 컨트롤러에 반환
      ArticleApiController.java 파일 
      
      	@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();
      	  }
          
      ==============================================================    
          
      ArticleService.java 파일
      
      	public 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 null;  // 응답은 컨트롤러가 하므로 여기서는 그냥  null 반환
              }
              // 4. 업데이트하기
              target.patch(article);
              Article updated = articleRepository.save(target);
              return updated; // 응답은 컨트롤러가 함므로 여기서는 수정 데이터만 반환
              }

게시글 수정 결과

 

 

게시글 삭제 요청 개선하기

  1. 컨트롤러의 delete() 메서드의 주석 제거
  2. 게시글 삭제 요청이 오면 컨트롤러는 요청을 받아 결과를 응답하는 역할만 하도록 수정 실제 작업은 서비스에 위임
    1. 서비스에서 delete(id) 메서드를 호출하고 데이터를 deleted 객체로 받아옴
    2. deleted 에 내용이 있으면 삭제가 되었다는 뜻이므로 ResponseEntity의 상태에는 NO_CONTENT , 본문은 그냥 빌드만 해서 보내고 deleted 에 내용이 없으면 삭제하지 못했다는 뜻이므로 ResponseEntity의 상태에는 BAD_REQUEST , 본문에는 그냥 빌드만 보냄
  3. 서비스에 delete()메서드 생성
    1. 대상 엔티티를 찾는 코드 작성
    2. 잘못된 요청을 처리하는 코드 작성
    3. 삭제한 대상을 컨트롤러에 보내기 위해 target을 반환
      ArticleApiController.java 파일 
      
      	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();
      	}
          
      ==============================================================    
          
      ArticleService.java 파일
      
      	public Article delete(Long id) {
      	    // 1. 대상 찾기
      	    Article target = articleRepository.findById(id).orElse(null); 
      	    // 2. 잘못된 요청 처리하기
      	    if (target == null) {
      	        return null; //응답은 컨트롤러가 하므로 여기서는 null 반환
      	    }
      	    // 3. 대상 삭제하기
      	    articleRepository.delete(target);
      	    return target; // DB에서 삭제한 대상을 컨트롤러에 반환
      	}

게시글 삭제 요청

 

 

 

 

트랜잭션 맛보기

  1. 컨트롤러가 데이터 3개의 생성 요청을 받아 결과를 응답하도록 transactionTest() 라는 메서드 추가
    1. @PostMapping으로 "/api/traansaction-test" URL 요청으로 데이터 생성을 요청받음
    2. transaactionTest()라는 메서드생성후 매개변수는 ArticleForm 데이터를 List로 묶은 dtos를 선언하고 REST API 방식으로 POST요청을 받고 있으므로 @RequestBody로 붙여주고 서버에서 응답할 때 데이터 생성 결과뿐만 아니라 상태 코드도 함께 보내므로 메서드의 반환형 ResponseEntity<List<Article>>로 설정
  2. 컨트롤러는 결과만 반환하기에 실제 작업하는 서비스 하도록 컨트롤러에 코드를 작성
    1. 서비스의 createArticles() 메서드를 호출 할때 매개변수로 받은 dtos도 함께 전달하고 반환값을 createdList라는 리스트에 저장
    2. createdList에 내용이 있으면 ResponseEntity의 상태에는 OK ,  본문에는 createdList를 실어서 보내고 내용이 없으면 ResponseEntity의 상태에는 BAD_REQUEST , 본문에는 빌듬만 보냄 
  3. 서비스에서 createdArticles() 메서드 작성
    1. dto 묶음(리스트)을 엔티티 묶음(리스트)으로 변환하기
      1. dtos를 스트림화 함
      2. map() 으로 dto가 하나하나 올 때마다 dto.toEntity()를 수행해 매핑
      3. 매핑된것을 리스트로 묶기
      4. 결과를 articleList에 저장
    2. 엔티티 묶음(리스트)을 DB에 저장하기
      1. articleList를 스트림화 함
      2. article이 하나씩 올 때마다 articleRepository을 통해 DB에 저장
    3. 강제로 에러를 발생시키기
      1. orElseThrow() 메서드로 IllegalArgumentException을 발생시켜 "결제 실패!"라는 메시지 남김
          • orElseThrow() 메서드는 값이 존재함면 그 값을 반환하고 값이 존재하지 않으면 전달값으로 보낸 예외를 발생시킴
          • IllegalArgumentException은 전달값이 없거나 유효하지 않는 경우를 뜻 함
    4. 결과 값 반환하기
      1. 강제로 예외가 발생했지만 형식상 articleList을 반환
  4. 서버의 재시작하고 Talend API Tester에서 POST 메서드, URL  http://localhost:8887/api/transaction-test 하고 데이터를 입력하고 [Send] 버튼을 클릭하면 상태코드 500으로 응답
  5. IDE의 실행창으로 가면 "결제 실패!" 메시지 확인 가능 하지만 메시지를 띄우기 전에 insert문이 3번 수행됨
  6. http://localhost:8887/articles 페이지에 접속하면 데이터가 추가된 게 확인 가능 하지만 데이터 생성에 실패해서 나중에 추가한 데이터 3개가 남지않기를 원하기 때문에 트랜잭션을 선언
  7. 보통 트랜잭션은 서비스에서 관리하기 때문에 createArticles() 메서드 위에 @Transaction을 작성
  8. @Transaction 어노테이션은 동일한 이름의 어노테이션이 있기 때문에 org.springframework.transaction.annotation에서 임포트 하도록 선택
  9. 다시 서버를 재시작 하고 4번처럼 다시 POST 요청 함
  10. IDE의 실행창으로 가면 5번과 같은 결과를 확인 가능
  11. 6번과 같은 결과가 나오는것이 아닌 결제에 실패한 데이터는 롤백된것을 확인 가능 
ArticleApiController.java 파일 

	@PostMapping("/api/transaction-test") // 여러 게시글 생성 요청 접수
	public ResponseEntity<List<Article>> transactionTest(@RequestBody List<ArticleForm> dtos) {
		// transactionTest() 메서드 정의
		List<Article> createdList = articleService.createArticles(dtos); // 서비스 호출
		return (createdList != null) ? ResponseEntity.status(HttpStatus.OK).body(createdList)
				: ResponseEntity.status(HttpStatus.BAD_REQUEST).build();

	}
    
==============================================================    
    
ArticleService.java 파일

	@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. 강제 예외 발생시키기
		// id가 -1인 데이터 찾고 찾는 데이터가 없으면 예외 발생
		articleRepository.findById(-1L).orElseThrow(() -> new IllegalArgumentException("결제 실패!"));
		// 4. 결과 값 반환하기
		return articleList;
	}

3개의 게시글 생성 요청 했으나 서버 내부 에러 발생

2023-12-12 14:06:31.321 ERROR 7456 --- [nio-8887-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.IllegalArgumentException: 결제 실패!] with root cause

java.lang.IllegalArgumentException: 결제 실패!

에러의 원인

에러가 발생했지만 3개의 데이터가 생성
@Transactional을 붙히고 POST 요청
결제에 실패해서 데이터가 롤백되어짐