문제 상황
항해99 실전 6주 WebFlux 기반의 팀프로젝트가 끝난 이후, 급하게 개발한 부분들이 신경쓰여서 팀원 한분과 프로젝트 디벨롭을 결심했고, 가장 먼저 눈에 띈 부분은 예외 처리 로직이였습니다.
기존 예외 처리 로직
기존 아래와 같은 방식으로 예외를 처리하고있었는데.
if (!signupRequestDto.getPassword().equals(signupRequestDto.getPassword2())) {
return Mono.error(new ResponseStatusException(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."));
}
client에게 보여질 Exception을 handle 하는 로직이 서비스 클래스 단에서 이루어지고 있는 것이 불편했다. 예외상황을 Functional Level에서 Handle할 수도 있지만, GlobalExceptionHandler를 사용하는 것이 바람직해 보임
CustomError + GlobalExceptionHandler 적용
스트리머가 방송을 시작할 때 입력한 스트림키가 일치하는지 확인하는 함수
public Mono<Boolean> checkStreamValidity(String streamerId, StreamKeyRequestDto streamKey) {
return findMemberByLoginId(streamerId)
.subscribeOn(IO.scheduler())
.publishOn(COMPUTE.scheduler())
.filter(member -> member.getStreamKey().equals(streamKey.getStreamKey()))
.switchIfEmpty(Mono.defer(() -> Mono.error(new ExpectedException(ErrorCode.StreamKeyMismatch))))
.thenReturn(true);
}
스트림 키가 일치하지 않는다면(filter - switchIfEmpty) Mono.defer를 통한 cold publishing으로 커스텀 예외를 발생시키고있다.
ExpectedException 커스텀 예외 클래스
@Getter
public class ExpectedException extends RuntimeException {
private final ErrorCode errorCode;
public HttpStatusCode getStatus(){
return errorCode.getStatus();
}
public ExpectedException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
RuntimeException을 상속받는 ExpectedException 클래스는 ErrorCode를 인자로 받으며,
ErrorCode는 Client에게 보여질 HttpStatus와 에러 메세지, 에러들을 관리하기 위한 약속된 코드를 포함하고 있습니다.
GlobalExceptionHandler 클래스
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ExpectedException.class)
protected Mono<ResponseEntity<String>> handleExpectedException(final ExpectedException exception) {
return Mono.just(ResponseEntity.status(exception.getStatus()).body(exception.getErrorCode().getCode() + exception.getMessage()));
}
@ExceptionHandler(RuntimeException.class)
protected Mono<ResponseEntity<String>> handleUnExpectedException(final RuntimeException exception) {
System.out.println(exception.getClass() + " exception occurred");
System.out.println("Cause : " + exception.getCause());
System.out.println("Message : " + exception.getMessage());
Arrays.stream(exception.getStackTrace()).forEach(System.out::println);
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("예기치 못한 문제가 발생했습니다."));
}
@ExceptionHandler(WebExchangeBindException.class)
protected Mono<ResponseEntity<String>> processValidationError(WebExchangeBindException exception) {
return Mono.just(exception.getBindingResult().getFieldErrors().get(0))
.map(fieldError -> ResponseEntity.status(HttpStatus.BAD_REQUEST).body(fieldError.getDefaultMessage()));
}
}
- ExpectedException : 정의된 예외가 발생한 상황이므로, ErrorCode에 정의된 http status, 에러 코드, 에러 메세지 등을 반환합니다.
다른 프로젝트에서는 모든 Custom 예외들에 각각 Handler를 등록했었는데, 예외처리 로직이 변경되면 모든 handler를 수정해야하기 때문에 부적절했던 것 같습니다. Custom Exception을 활용해야 한다면 위의 패턴을 사용하는 것이 좋다고 생각합니다. - RuntimeException : 1번에서 handle되지 못한, 정의되지 않은 예외가 발생한 상황이므로 예외 발생 상황을 파악할 수 있도록 Exception 정보들을 출력합니다.
(실제 서비스 상황이라면 로그로 남기고, slack과 연동한다던가 하는 방식으로 에러를 관리해야 할 것 같습니다.) - WebExchangeBindException : @Valid 어노테이션에 의해 주로 dto의 유효성을 검증합니다. jakarta라이브러리의 validation을 활용하여 Controller 메서드에 mapping 될 때 dto의 필드가 유효하지 않은 경우 WebExchangeBindException이 발생합니다.
기존에는 서비스 로직에서 dto 유효성 검사를 수행했는데, 서비스 로직이 불필요하게 복잡해지기때문에 가능한 서비스 로직을 가볍게 하는 방향으로 수정했습니다.
dto validation 예시 - SignUpRequestDto
package com.hanghae.lemonairservice.dto.member;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
@Getter
public class SignUpRequestDto {
@Email(message = "올바른 이메일 형식이 아닙니다.")
private String email;
@Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=!])(?=\\\\S+$).{8,20}$", message = "비밀번호는 영문 대소문자 숫자 및 특수문자를 포함한 8~20자리 문자여야합니다.")
private String password;
@NotBlank(message = "비밀번호 확인란을 입력해주세요")
private String password2;
@NotBlank(message = "아이디를 입력해주세요")
private String loginId;
@NotBlank(message = "닉네임을 입력해주세요")
private String nickname;
@AssertTrue(message = "비밀번호 확인이 일치하지 않습니다.")
private boolean isPasswordRetypeValid() {
return password.equals(password2);
}
}