본문 바로가기
Side Projects/프로젝트 사이

9. 개선사항(비즈니스 로직 제거, Exception Handler 적용, DataIntegrityViolationException 처리 방법)

by devraphy 2022. 9. 12.

0. 개요

- 우연한 기회로 SI 업체에 면접을 보러 가게 되었다.

- 많은 기대는 하지 않았지만, 기대 이상으로 면접 경험이 좋았다.

- 문답 형식의 이론 질문이 없었고 코드를 보여주고 해당 코드를 분석하는 방식의 면접이었다.

- 확실히 다른 사람이 작성한 코드에서 그 맥락을 모른 체 문제점을 파악한다는 것은 아직은 쉽지 않다.

 

- 면접에서 내가 작성한 사이(SAI)의 코드를 보면서 RestAPI, Java, Spring에 관련된 질문을 받았다.

- 그 과정에서 몇 가지 프로젝트의 개선사항을 듣게 되었고 하나씩 개선해보았다. 

 

 

1. 비즈니스 로직은 Service에서 처리하자.

a) 고민은 개선의 시작

- 사실 SAI를 만들면서 방법론적인 고민을 많이 했다.

- 그중 하나가 비즈니스 로직의 기준에 대한 고민이다.

- 어디까지를 비즈니스 로직이라 판단할 것인지, 해당 코드가 Service에 있는 것이 맞는지 등 말이다.

- 이 과정에서 Controller의 역할에 대한 궁금증이 생겼다. 

 

b) Controller의 역할 

- 기본적으로 MVC에서 Controller는 요청을 처리할 수 있는 Model과 View를 이어주는 역할을 한다.

- 그러나 일반적으로 현업에서 사용하는 프로젝트의 구조는 프런트와 서버가 분리되어있는 구조이다. 

- 사이(SAI) 또한 마찬가지다.  RestAPI 서버이므로 요청을 처리할 수 있는 Service를 연결해주고
   처리된 요청에 대한 응답을 전달하는 것이 Controller의 역할이 되겠다. 

 

c) 비즈니스 로직을 분리하는 이유

- 어떤 코드를 어디에 작성할 것인지는 개발자의 마음이다.

- 다만, 코드의 재사용성을 기준으로 이를 생각하고 결정해야 한다.

- 그렇다면 왜 코드의 재사용성을 고려해야 할까? 

 

- 비즈니스 로직을 Service 단에서 처리함으로써 추후에 또는 현시점에서 재사용이 가능하기 때문이다.

- 즉, Controller와 비즈니스 로직을 분리하는 이유는 해당 코드를 재사용하기 위함이다.

그것이 객체지향 프로그래밍을 하는 이유다. 

 

d) 개선 이전의 Controller

- 내가 작성한 Controller는 아래의 사진과 같다. 

 

 

- 딱 봐도 비즈니스 로직이 잔뜩 들어있다. Controller에서 try~catch까지 수행한다. 

- 이대로라면 Controller 없이 서비스는 제 기능을 할 수 없다. 

 

- 그렇다면 재사용성을 기준으로 생각했을 때 어디까지를 비즈니스 로직이라고 생각해야 할까?

- 혼자 고민하기보다는 다양한 의견을 찾아보기 시작했다.

 

e) 고수의 말씀

- 내가 좋아하는 개발자 김영한 님의 답변을 발견하였다.

 

 

- 공감되는 답변이다. 결국 Controller는 요청과 비즈니스 로직의 중개자다.

- 즉, Controller가 없어도 온전히 재기능을 발휘할 수 있어야 한다.

 

f) 개선 이후의 Controller 

- 개선된 Controller는 다음과 같은 모습으로 바뀌었다.

 

 

- 기존의 비즈니스 로직은 LoginService 클래스를 새로 작성하여 logoutApi()라는 메서드에 정의하였다.

 

 

g) 고민의 흔적

- 사실 개선 과정에서 한 가지 고민이 있었다.

- Controller가 없어도 온전히 재기능을 발휘할 수 있어야 한다는 말에 너무 집착한 것일까?

- 위의 logoutApi() 메서드에 작성했듯이, Service에서 ObjectMapper를 이용한 응답까지 처리한다. 

- 이렇게 작성하는 것이 올바른 것일까? 응답을 Service에서 하는 게 맞는 것일까? 스스로에게 계속 되물었다. 

 

- 해당 메서드는 logout 기능에 종속적이다. 

- 즉, logout 기능의 응답은 위의 코드에서 볼 수 있듯이 2가지 응답으로 한정된다.

- 그러므로 해당 메서드는 이대로 재사용될 수 있다고 판단하였다. 

 

2. Exception Handler(공통 예외 처리) 적용

- Exception Handler를 사용하는 이유는 throws로 넘긴 예외를 공통적으로 처리하기 위함이며, 

  동시에 오류 응답에 대한 규격을 통일시키기 위함이다. 

 

a) @RestContollerAdvice 

- 예외 처리를 할 때에는 대표적으로 다음 3가지를 사용한다.

 

  ▶ @ControllerAdivce - 전역적인 예외를 처리한다. 반환 값을 페이지 이름으로 처리한다.

  ▶ @RestControllerAdivce - 전역적인 예외를 처리한다. 반환 값을 객체로 처리한다. 

  ▶ @ExceptionHandler - 클래스의 예외를 처리한다.

  ▶ Try ~ catch - 메서드의 예외를 처리한다. 

 

- @RestControllerAdvice의 경우, @ControllerAdvice + @ResponseBody라고 이해할 수 있다. 

- 이는 @Controller와 @RestController의 차이점과 동일하다.

 

b) 공통 예외 처리 클래스 생성

- 공통적으로 발생하는 예외를 전역으로 처리하기 위해서 클래스를 생성한다.

- 그리고 해당 클래스에 @RestControllerAdvice를 붙여주었다.

- 현재 프로젝트는 RestAPI 서버이므로 @RestControllerAdvice를 사용한다.

 

- 클래스 내부에 특정 에러를 처리하는 메서드를 생성하고, 해당 에러에 대한 반환 값을 설정한다. 

- 이 반환 값을 만드는 방법은 다양하지만, 나는 ResponseEntity를 사용하였다.

- ResponseEntity의 자료형으로(Generic) 사용하기 위한 커스텀 에러 코드 Enum 클래스 또한 생성하였다.

 

- 즉, 다음과 같이 적용하였다. 

 

 

@Data
@AllArgsConstructor
public class ErrorResponse {

    private final LocalDateTime dateTime = LocalDateTime.now();
    private final int status;
    private final String error;
    private final String message;

    public ErrorResponse(ErrorCode errorCode) {
        this.status = errorCode.getStatus().value();
        this.error = errorCode.getStatus().name();
        this.message = errorCode.getMessage();
    }
}

 

@Getter
@AllArgsConstructor
public enum ErrorCode {

    // Client
    BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청"),
    NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 URL"),
    UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "토큰 검증 실패"),
    NULL_REQUEST(HttpStatus.BAD_REQUEST, "NULL 요청 오류"),
    JSON_ERROR(HttpStatus.BAD_REQUEST, "JSON Parsing 오류"),
    INVALID_INPUT_ERROR(HttpStatus.BAD_REQUEST, "Input 값 오류"),
    NO_HANDLER_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 API"),

    // Server
    IO_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "IO 오류"),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 오류");

    private final HttpStatus status;
    private final String message;

}

 

3. DataIntegrityViolationException 처리하기

a) 문제의 발단

- Exception Handler를 작성하면서 모든 예외를 처리할 수 없다는 것을 깨달았다.

- 당연히 Java에 등록되어 있는 예외 클래스만 해도 엄청나게 많으니, 그걸 다 처리할 수는 없다.

- 그러나 이를 말하고자 하는 것이 아니다.

 

- 특정 상황에 어떤 예외가 발생할 수 있다는 것을 알면서도, 이를 처리할 수 있는 방법이 없다는 것을 의미한다. 

- 어쩌면 나의 지식이 부족한 것일 수도 있다. 그럴 확률이 높기는 하다. 

 

- DataIntegrityViolationException 또는 ContraintViolationException이 대표적인 예시이다.

- API 서버를 테스팅하면서 일부러 중복 값을 이용한 회원가입을 시도해보았다.

 

- 다음 사진과 같이 기존에 'abc@gmail.com'이라는 아이디가 DB에 존재한다. 

 

- 해당 아이디가 이미 존재하기 때문에 DB에는 해당 아이디가 중복으로 삽입될 수 없다. 

- 그러나 다음과 같은 결과를 얻을 수 있었다. 

 

 

- Client에게는 해당 아이디로 가입이 완료된 것처럼 응답을 반환한다.

- 하지만 서버 쪽 로그를 살펴보면 다음과 같이 에러가 발생한 것을 확인할 수 있다. 

 

 

- 등록해놓은 DataIntegrityViolation() 예외 처리 메서드가 동작하는 것이다. 

- 그리고 실제로 DB에는 아무런 값도 등록되지 않는다. ConstraintVioation과 함께 Rollback 처리되는 것이다.

 

- 당연히 이메일 중복 확인 로직을 해당 메서드에 집어넣으면 충분히 걸러낼 수 있는, 예방할 수 있는 예외이다.

- 다만, 해당 기능은 Front 쪽에서 이메일 중복 테스트를 통과했다는 가정 하에 작성한 메서드이므로 추가적인 검증 로직이
   API 메서드 내부에 존재하지 않기에 해당 예외가 발생하는 것이다.

 

- 그러나 궁금한 점은 handleDataInegrityViolation() 메서드가 동작했음에도 불구하고 예외 처리 시 반환해야 하는
  객체가 반환되는 것이 아니라 회원이 등록되었다는 StatusCode 200의 응답이 반환되는 것이다. 

 

- 왜 그럴까? 

 

b) DataIntegrityViolationException은 어디서 발생할까? 

- DataIntegrityViolatioException 또는 ConstraintViolationException은 모두 DB에서 발생하는 오류다.

- 즉, JPA가 Transaction Commit을 완료한 시점에서 인지할 수 있는 오류다.

 

- Transaction Commit을 수행하려면 EntityManager에서 Flush()를 작동시켜야 하는데, 
   위에서 설명한 문제가 발생한 이유를 분석해보면 JPA에서 flush()가 동작하는 시점보다

   응답을 주는 시점이 더 빠른 것이다.

 

- 쉽게 설명하자면 다음과 같은 과정을 거치는 것이다. 

  ▶ Client: 서버님, 회원가입 요청 보냅니다(HttpRequest 전송)

  ▶ Server: Client님, 처리 잘 됐습니다(HttpResponse 전송) 
  ▶ Server: DB님, 이거 데이터 저장해주세요. (Flush 시점)

  ▶ DB: 네, 저장하겠습니다 (처리 중) 어... 중복 데이터인데요? Rollback 처리할게요. 

  ▶ Server: 엥? 에러 발사!!!

 

- 비유를 하자면 이런 방식이다. 그럼 어떻게 해결할 수 있을까? 

 

c) 해결책 - Flush() 강제 실행

- 해결방법은 생각보다 간단하다.

- Client에게 응답을 주기 전에 Flush()를 강제로 실행시키면 되는 것이다.

- 그렇다면 DB에서 발생한 에러를 Server 측이 전달받을 것이고, 서버는 응답 전송 시점 이전에 에러를 인식할 수 있다.

- 다음 사진과 같이 EntityManager를 사용하여 Flush()를 강제 호출하면 된다. 

 

 

- 그래도 여전히 해결되지 않은 의문점이 한 가지 남아있다.

- JPA는 언제 Flush()를 실행시킬까? 언제 Transaction Commit을 실행시킬까? 

 

d) JPA의 Transaction Commit 시점과 Flush()

- JPA는 메서드가 완료되는 시점에서 Commit() 메서드를 호출한다.

- 여기서 말하는 메서드는 @Transactional이 붙어있는 메서드를 의미한다. 

 

- 메서드 실행 완료 시점에서 JPA는 Commit() 메서드를 호출한다.

- commit() 메서드가 호출되면 flush()가 호출되고, 1차 캐시를 읽어 dirty checking을 수행한다.

- 그리고 최종적으로 Transaction Commit이 이루어진다. 

 

- 즉, 하나의 메서드가 실행 완료되는 시점에서 Transaction Commit이 이루어진다는 것을 알 수 있다. 

 

e) 결과

- 아래의 사진과 같이 이제는 DataIntegrityViolationException을 잘 캐치하고 처리하는 것을 확인할 수 있다.

 

 

4. 참고 자료

 

https://stackoverflow.com/questions/52456783/cannot-catch-dataintegrityviolationexception

 

Cannot catch DataIntegrityViolationException

I am using Spring Boot 2 with spring-boot-starter-data-jpa with an underlying MariaDB. I have table with a unique key "username". I want to catch DataIntegrityViolationException if this constraint...

stackoverflow.com

 

https://coder-in-war.tistory.com/entry/Spring-Data-JPA-02-Transaction-Commit%EC%9D%80-%EC%96%B4%EB%94%94%EC%97%90%EC%84%9C-%EC%9D%BC%EC%96%B4%EB%82%A0%EA%B9%8C

 

[ Spring Data JPA ] 02. Transaction Commit은 어디에서 일어날까?

트랜잭션 커밋은 어디에서 일어날까? 레파지토리를 만들 때 Spring-Data-Jpa 의 JpaRepository 인터페이스를 상속하였는데, 스프링 데이터에서 기본 구현체를 제공해주기 때문이다. Spring-Data-Jpa 에서 제

coder-in-war.tistory.com

 

https://blog.neonkid.xyz/234

 

[Spring] JPA의 플러시(flush)

JPA를 사용할 때, 객체를 생성하고 이를 영속성 컨텍스트에 영속시켜 커밋하는 과정까지를 알아봤습니다. 그런데, JPA의 commit()을 호출할 때 항상 발생하는 flush()는 어떤 역할을 하는 녀석일까요?

blog.neonkid.xyz

 

댓글