임대일

@Pattern: message 으로 클라이언트에게 응답하기(feat. JSON) 본문

스프링/지식

@Pattern: message 으로 클라이언트에게 응답하기(feat. JSON)

limdae94 2024. 5. 18. 20:09

배경

@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() 메서드 안의 코드는 크게 두 가지 수행으로 나뉘게 되는데, 메서드 파라미터 확인유효성 검사 처리이다. 두 수행 과정에 대해 살펴보자.

 

메서드 파라미터 확인

  1. Object[] args = proceedingJoinPoint.getArgs();
    • 조인포인트(JoinPoint)의 매개변수를 가지고 온다.
  2. for (Object arg : args) {}
    • 매개변수를 순회하며 BindingResult 가 존재하는 인스턴스를 찾는다.

컨트롤러에서 @POST, @PUT 매핑과 BindingResult 가 매개변수로 존재하는 메서드를 확인하는 코드이다.

 

 

유효성 검사 처리

  1. if (arg instanceof BindingResult bindingResult) {}
    • 매개변수가 BindingResult 가 존재하는지 확인한다.
  1. 유효성 검사 오류가 발생하는 경우
    • 모든 필드 오류를 순회하고, 오류 메시지를 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);
}

 

 

  1. 정상 처리되는 경우
    • 메서드를 정상적으로 수행한다.
  2. 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());
                    }
        ...
    }
}