임대일
@Pattern: message 으로 클라이언트에게 응답하기(feat. JSON) 본문
배경
@NotBlank(message = "비밀번호: 필수 정보입니다.")
@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}"
, message = "비밀번호: 8~20자 영문 대소문자, 숫자, 특수문자를 조합하여 작성해야 합니다.")
@Size(min = 8, max = 20)
private final String password
클라이언트로부터 회원가입 데이터가 올바르게 입력 됐는지 @Pattern 으로 필드마다 유효성 검사(@Vaild)를 수행하는 시나리오를 생각하고 구현을 목표로 정했다. 하지만 생각과 다르게 스프링에서 제공되는 기본적인 에러 메시지를 응답하거나 응답 형식 혹은 메시지가 원하는 형식이 전혀 아니었다. @Pattern 의 message 속성으로 클라이언트에게 응답을 성공적으로 마친 것을 회고하고자 정리한다.
1. Spring AOP 사용하기
스프링 강의 혹은 책을 살펴보면 항상 등장하는 중요한 단어 AOP 를 한 번도 직접 사용한 경험이 없었다. AOP 의 이론적인 부분만 머릿속에 추상적으로 남고 휘발되거나 왜곡되는 일이 허다했기 때문에, 프로젝트를 새로 진행한다면 반드시 AOP 를 적용해보고자 했다. AOP 이론에 대해 프로젝트 기간 동안에 시간이 남으면 정리하여 링크를 추가할 예정이다.
AOP 를 적용한 CustomValidationAdvice
아래 코드는 스프링 AOP 를 사용하여 데이터의 유효성을 검사하는 클래스이다. HTTP Body 안에 데이터가 포함이 반드시 되는 HTTP Method 의 POST 및 PUT 요청에 대해 입력 데이터의 유효성을 검증을 수행하고, BindingResult 를 검사하여 유효성 검사 오류가 있을 경우, 사용자 정의 예외를 발생시킨다.
정리하자면, 컨트롤러에서 POST 혹은 PUT 매핑이면서 BindingResult 가 매개변수로 존재하는 메서드에 대해 유효성 검사를 수행한다.
import com.fastcampus.aptner.global.handler.exception.CustomValidationException;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Component;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import java.util.HashMap;
import java.util.Map;
@Component
@Aspect
public class CustomValidationAdvice {
private final MessageSource messageSource;
public CustomValidationAdvice(@Qualifier("messageSource") MessageSource messageSource) {
this.messageSource = messageSource;
}
// post, put (body) 어드바이스 작성하기
@Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
public void postMapping() {}
@Pointcut("@annotation(org.springframework.web.bind.annotation.PutMapping)")
public void putMapping() {}
@Around("postMapping() || putMapping()") // joinPoint 의 전후 제어
public Object validationAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
Object[] args = proceedingJoinPoint.getArgs(); // joinPoint 의 매개변수
for (Object arg : args) {
if (arg instanceof BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
Map<String, String> errorMap = new HashMap<>();
for (FieldError error : bindingResult.getFieldErrors()) {
errorMap.put(error.getField(), error.getDefaultMessage());
}
throw new CustomValidationException("유효성 검사 실패", errorMap);
}
}
}
return proceedingJoinPoint.proceed(); // 정상적으로 해당 메서드를 실행해라!!
}
}
CustomValidationAdvice 클래스의 전체 코드이다. CustomValidationAdvice 클래스에서 가장 핵심적인 부분들에 대해 살펴보자.
@Component
- 스프링 컴포넌트 스캔으로 CustomValidationAdvice 를 빈으로 등록한다.
@Aspect
- CustomValidationAdvice 클래스가 AOP 에서 사용할 수 있도록 지정한다.
@Aspect 만으로는 스프링 IoC 컨테이너에 빈으로 등록이 안되기 때문에 @Component 을 함께 작성하자.
의존성 주입
private final MessageSource messageSource;
public CustomValidationAdvice(@Qualifier("messageSource") MessageSource messageSource) {
this.messageSource = messageSource;
}
MessageSource 는 유효성 검사 메시지를 다국어로 처리하기 위해 사용되는 MessageSource 클래스이다. 그러나 전체 코드에서는 사용되지 않고, FieldError 의 getDefaultMessage() 가 기본 메시지를 제공하고 있다. 따라서 전체 코드에서 한 번도 사용되지 않는다. MessageSource 는 전체 코드에서 불필요한 코드이지만 스프링에서 제공되는 기본 에러 메시지를 받는 방법도 소개하기 위해 남긴 코드라고 생각하면 된다.
@Pointcut
// post, put (body) 어드바이스 작성하기
@Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
public void postMapping() {}
@Pointcut("@annotation(org.springframework.web.bind.annotation.PutMapping)")
public void putMapping() {}
@POST, @PUT 어노테이션이 적용된 모든 메서드에 대해 @Around 의 적용 대상이 된다.
@Around
@Around("postMapping() || putMapping()") // joinPoint 의 전후 제어
public Object validationAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
Object[] args = proceedingJoinPoint.getArgs(); // joinPoint 의 매개변수
for (Object arg : args) {
if (arg instanceof BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
Map<String, String> errorMap = new HashMap<>();
for (FieldError error : bindingResult.getFieldErrors()) {
errorMap.put(error.getField(), error.getDefaultMessage());
}
throw new CustomValidationException("유효성 검사 실패", errorMap);
}
}
}
return proceedingJoinPoint.proceed(); // 정상적으로 해당 메서드를 실행해라!!
}
@POST, @PUT 요청에 대해 조인포인트(JoinPoint)를 전후로 제어한다. validationAdvice() 메서드 안의 코드는 크게 두 가지 수행으로 나뉘게 되는데, 메서드 파라미터 확인과 유효성 검사 처리이다. 두 수행 과정에 대해 살펴보자.
메서드 파라미터 확인
Object[] args = proceedingJoinPoint.getArgs();
- 조인포인트(JoinPoint)의 매개변수를 가지고 온다.
for (Object arg : args) {}
- 매개변수를 순회하며 BindingResult 가 존재하는 인스턴스를 찾는다.
컨트롤러에서 @POST, @PUT 매핑과 BindingResult 가 매개변수로 존재하는 메서드를 확인하는 코드이다.
유효성 검사 처리
if (arg instanceof BindingResult bindingResult) {}
- 매개변수가 BindingResult 가 존재하는지 확인한다.
- 유효성 검사 오류가 발생하는 경우
- 모든 필드 오류를 순회하고, 오류 메시지를 Map 에다가 필드 이름과 오류 메시지 저장한다.
- 유효성 검사 실패 메시지와 함께 Map 에 저장된 오류 메시지를 CustomValidationException 클래스를 통해 예외를 발생시킨다.
if (bindingResult.hasErrors()) {
Map<String, String> errorMap = new HashMap<>();
for (FieldError error : bindingResult.getFieldErrors()) {
errorMap.put(error.getField(), error.getDefaultMessage());
}
throw new CustomValidationException("유효성 검사 실패", errorMap);
}
- 정상 처리되는 경우
- 메서드를 정상적으로 수행한다.
return proceedingJoinPoint.proceed(); // 정상적으로 해당 메서드를 실행해라!!
2. 사용자 예외 처리 클래스
유효성 검사에 실패하면 유효성 검사 실패 메시지와 함께 Map 에 저장된 오류 메시지를 CustomValidationException 클래스를 통해 예외를 발생시키는데, CustomValidationException 클래스는 RuntimeException 을 상속받는다.
@Getter
public class CustomValidationException extends RuntimeException {
private final Map<String, String> errorMap;
public CustomValidationException(String message, Map<String, String> errorMap) {
super(message);
this.errorMap = errorMap;
}
}
3. 사용자 예외 처리 핸들러
@RestControllerAdvice 을 사용하여 애플리케이션 전역에서 발생되는 예외를 처리하는 CustomExceptionHandler 클래스이다. 쉽게 말하자면 컨트롤러 레벨에서 메서드가 실행될 때 발생되는 예외를 AOP 를 적용해 예외를 처리한다. @RestController 와 동일하게 @ResponseBody 가 안에 포함되어 있기 때문에 응답을 JSON 형식이라는 특징이 있다. 이제 CustomExceptionHandler 의 전체 코드를 살펴보자.
@RestControllerAdvice
public class CustomExceptionHandler {
private final Logger log = LoggerFactory.getLogger(getClass());
...
@ExceptionHandler(CustomValidationException.class)
public ResponseEntity<?> validationApiException(CustomValidationException e) {
log.error(e.getMessage());
return new ResponseEntity<>(new HttpResponse<>(-1, e.getMessage(), e.getErrorMap()), HttpStatus.BAD_REQUEST);
}
...
}
@ExceptionHandler
- @ExceptionHandler(CustomValidationException.class) 의 의미는 CustomValidationException 이 발생하면
public ResponseEntity<?> validationApiException(CustomValidationException e){}
메서드가 호출된다.
public ResponseEntity<?> validationApiException(CustomValidationException e)
- CustomValidationException 에서 발생한 예외를 처리하고 ResponseEntity<> 으로 응답하는데, 클라이언트에게 모든 응답은 일관된 JSON 형식으로 보내주는 방식을 채택했다. 따라서 ResponseEntity 클래스 안에 직접 구현한 HttpResponse 클래스로 예외를 포함하여 응답한다.
4. 공통 응답 클래스
@RequiredArgsConstructor
@Getter
public class HttpResponse<T> {
// TODO: Record 클래스으로 변환 가능하다.
private final Integer code; // 1성공, 실패
private final String message;
private final T data;
}
클라이언트에게 성공 유무, 메시지, 데이터 세 개의 정보를 응답하게 된다.
5. 테스트 실행 결과
package com.fastcampus.aptner.member.controller;
import com.fastcampus.aptner.member.dto.reqeust.SignUpMemberRequest;
import com.fastcampus.aptner.member.service.JoinMemberServiceImpl;
import com.fastcampus.aptner.member.service.loginMemberServiceImpl;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
@ActiveProfiles("test")
@AutoConfigureMockMvc
@SpringBootTest
class JoinMemberControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private JoinMemberServiceImpl joinMemberService;
@Autowired
private ObjectMapper objectMapper;
...
@Test
@DisplayName("[회원가입]: 유효성 검사 실패")
public void GivenMember_WhenJoinMember_ThenFailure() throws Exception {
// given
SignUpMemberRequest request = SignUpMemberRequest.builder()
.termsService(true)
.privateInformationCollection(true)
.snsMarketingInformationReceive(true)
.fullName("홍길동")
.birthFirst("970520")
.gender("1")
.phoneCarrier("SKT")
.phone("01012341234")
.username("asd123")
.password("qwer") // 유효성 검사 실패: 특수문자 누락
.nickname("화난어피치123")
.dong("101동")
.ho("101호")
.apartmentName("반포자이")
.build();
// when
String requestBody = objectMapper.writeValueAsString(request);
// then
mockMvc.perform(MockMvcRequestBuilders.post("/api/join/member")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isBadRequest())
.andExpect(MockMvcResultMatchers.jsonPath("$.code").value(-1))
.andExpect(MockMvcResultMatchers.jsonPath("$.message").value("유효성 검사 실패"))
.andExpect(MockMvcResultMatchers.jsonPath("$.data.password").value("비밀번호: 8~20자 영문 대소문자, 숫자, 특수문자를 조합하여 작성해야 합니다."))
.andDo(MockMvcResultHandlers.print());
}
}
WARNING: A Java agent has been loaded dynamically (C:\Users\piay8\.gradle\caches\modules-2\files-2.1\net.bytebuddy\byte-buddy-agent\1.14.13\979ce25f7d3096a2e82214ba7dc972a05ce7a171\byte-buddy-agent-1.14.13.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release
2024-05-18T18:56:48.700+09:00 ERROR 24364 --- [aptner] [ Test worker] c.f.a.g.handler.CustomExceptionHandler : 유효성 검사 실패
MockHttpServletRequest:
HTTP Method = POST
Request URI = /api/join/member
Parameters = {}
Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"327"]
Body = {"termsService":true,"privateInformationCollection":true,"snsMarketingInformationReceive":true,"fullName":"홍길동","birthFirst":"970520","gender":"1","phoneCarrier":"SKT","phone":"01012341234","username":"asd123","password":"qwer","nickname":"화난어피치123","dong":"101동","ho":"101호","apartmentName":"반포자이"}
Session Attrs = {}
Handler:
Type = com.fastcampus.aptner.member.controller.JoinMemberController
Method = com.fastcampus.aptner.member.controller.JoinMemberController#joinMember(SignUpMemberRequest, BindingResult)
Async:
Async started = false
Async result = null
Resolved Exception:
Type = com.fastcampus.aptner.global.handler.exception.CustomValidationException
ModelAndView:
View name = null
View = null
Model = null
FlashMap:
Attributes = null
MockHttpServletResponse:
Status = 400
Error message = null
Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", Content-Type:"application/json", X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"SAMEORIGIN"]
Content type = application/json
Body = {"code":-1,"message":"유효성 검사 실패","data":{"password":"비밀번호: 8~20자 영문 대소문자, 숫자, 특수문자를 조합하여 작성해야 합니다."}}
Forwarded URL = null
Redirected URL = null
Cookies = []
6. 정리
@Pattern 의 message 속성값을 사용하고 싶다면 bindingResult 으로부터 추출한 FieldError 클래스에서 getDefaultMessage() 메서드로 가져오자.
@Component
@Aspect
public class CustomValidationAdvice {
...
@Around("postMapping() || putMapping()") // joinPoint 의 전후 제어
public Object validationAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
...
for (FieldError error : bindingResult.getFieldErrors()) {
errorMap.put(error.getField(), error.getDefaultMessage());
}
...
}
}