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
'Side Projects > 프로젝트 사이' 카테고리의 다른 글
11. SAI(RestAPI) 사용 설명서 (0) | 2022.09.14 |
---|---|
10. 배포 완료 (0) | 2022.09.14 |
8. 백엔드 개발 완료(OpenAPI 소개 및 문제해결 회고록) (2) | 2022.09.06 |
7. 오류 해결 스토리 - constraint ["PUBLIC.UK_MBMCQELTY0"] (0) | 2022.07.30 |
6. 오류 해결 스토리 - Update/delete queries cannot be typed (0) | 2022.07.30 |
댓글