안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 Contoller와 Service를 리펙토링 하여 Controller의 부하를 줄이고 계층 간 분리하는 과정을 작성하고자 합니다.

Spring에서 Controller와 Service는 각각 하는 역할이 다릅니다. Controller는 Service에 의존 관계 주입을 받고 특정 Uri로 오는 요청을 처리하여 응답을 보내는 역할을 수행합니다. Service는 Controller에서 받은 요청을 위임받아 핵심 비즈니스 로직을 수행하여 실행하거나 값을 리턴하는 역할을 수행합니다. 

 

저는 코드를 작성할 때 Controller와 Service를 서로 의존 관계 주입으로 설정할 때 고민되는 것들이 있습니다. 

첫째는 Controller의 역할 과중이고 둘 째는 Dto의 계층적 분리입니다.

따라서, 이번 글은 이 두가지를 해결하기 위해 제가 시도했었던 방식을 공유드리고자 합니다.!

 

 

1. Controller의 부담을 줄이는 Fillter와 Interceptor, ControllerAdvice

 

 

a. Filter

 

Filter는 Servlet 컨테이너에서 요청과 응답을 처리하기 전후에 동작하는 기능입니다. 인코딩 변환, 보안 체크, 로깅 등 작업을 수행합니다. Filter는 Servlet 컨테이너에 등록되며 SpringSecurity를 사용한다면 DispatcherServlet 앞 단에서 적용됩니다.

 

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http
            .authorizeHttpRequests((requests) -> requests
                    .requestMatchers(memberServiceWhiteList)
                    .permitAll()
                    .anyRequest()
                    .authenticated()
            )

 

 

b. Interceptor

 

Interceptor는 Spring MVC 요청 전 후에 동작하는 기능입니다. Interceptor도 권한 혹은 인증 등을 처리할 수 있고, 로깅 용도로도 사용됩니다. DispatcherServlet과 Controller 사이에 위치하여 중간 단계에서 요청을 인터셉터하는 역할을 수행합니다.

 

Interceptor는 HandlerInterceptor를 구현하여 생성할 수 있습니다. preHandle은 요청 처리 이전에 적용하는 것으로 보통 preHandle에서 인증 혹은 보안 처리를 담당하고  있습니다.

 

간단한 예시로 하단에는 Authorization이라는 헤더가 존재하는지 여부를 파악하고 없다면 예외처리하여 더 이상 요청이 이뤄지지 않도록 하는 역할을 수행합니다. 주의할 점은 Component 대상으로 Interceptor를 등록하여야 합니다.

 

@Slf4j
@Component
public class WebInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        String userId = request.getHeader("Authorization");
        if (userId == null) {
            log.info("Exception");
            throw new NotFoundUserException();
        }

        log.info("request = {}", request.getRequestURI());
        return true;
    }
}

 

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new WebInterceptor());
    }
}

 

 

C. ControllerAdvice

 

ControllerAdvice는 Controller처럼 MVC 패턴에서 컨트롤러의 역할을 수행합니다, ControllerAdvice는 예외처리에 특화된 기능으로 다수의 컨트롤러에서 발생한 공통적인 예외처리를 담당하는 역할을 수행합니다. 

선언은 @RestControllerAdvice 어노테이션을 활용하여 Rest전용 ControllerAdvice를 생성하겠다고 선언합니다.

이 경우, 예외처리를 공통적으로 ControllerAdvice에서 처리하게 됩니다.

 

@Slf4j
@RestControllerAdvice(annotations = RestController.class)
public class ExceptionControllerAdvice {
    
    /**
     *  권한 없는 사용자 접근
     */
    @ResponseStatus(UNAUTHORIZED)
    @ExceptionHandler
    public ResponseEntity<ErrorDto> notExistsRefreshTokenHandler(NotExistsRefreshTokenException e) {
        return new ResponseEntity<>(new ErrorDto(e.getErrorCode(), e.getMessage()), UNAUTHORIZED);
    }

 

ControllerAdvice는 DispatcherServlet과 Controller 사이에 위치하기 때문에 만약 Controller 다음 단에서 예외가 발생한 경우, ControllerAdvice가 해당 요청을 위임받을 수 있습니다.

 

 

만약 Interceptor에서 예외가 발생하면 어떻게 될까요?

이때는 Interceptor에서 예외가 발생하면 DispatcherServlet으로 이동해서 DispatcherServlet에서 예외 처리를 위해 ControllerAdvice를 호출합니다. 즉, Interceptor를 통과해야만 요청이 전달되는 Controller에는 예외가 전달되지 않는 것입니다. 따라서 Interceptor를 도입할 경우 예외처리기를 ControllerAdvice를 이용하는 것이 좋습니다.!

 

 

그렇다면 Filter에서 예외가 발생한 경우는 어떻게 될까요? 
Filter는 Servlet - Filter - DispathcherServlet 사이에 존재하므로 먼저 Servlet에서 예외 처리기를 찾고 없다면 DispathcherServlet의 ControllerAdvice에서 예외처리를 찾습니다. 없다면 마지막으로 다시 Servlet으로 이동하여 예외처리를 진행합니다.

 

그림으로 정리하면 다음과 같습니다.

 

 

 

 

2. Dto 계층적 분리하기

 

Controller에서 계층적으로 역할을 분리하는 과정을 수행했다면, 다음에 발생하는 문제는 Controller와 Service를 연결해 주는 Dto의 존재입니다. Dto는 Data Transfer Object로 계층 간 데이터 교환 혹은 데이터 전송 객체를 의미합니다. 

계층적인 모델링을 구축할 때 조심해야하는 부분이 바로 Service는 Controller에 의존하지 않아야 하는 것입니다.

즉, Service 레이어는 핵심 비즈니스 로직을 수행해야 하는 곳이기 때문에 Controller가 바뀌더라도 Service 단에서 이루어지는 핵심 비즈니스 로직이 영향을 받으면 안 됩니다. 즉 Controller -> Service로의 접근은 가능하더라도 Service -> Controller로의 접근은 지양되며 순환참조를 발생시킬 수도 있습니다.

 

계층으로 확인하면 다음과 같습니다.

 

 

클라이언트의 요청(Request)는 Controller로 전달이 되며 Controller -> Service -> Repository 순서로 Dto가 전달이 되고 다시 역순으로 Dto로 전달이 됩니다.

 

이 과정에서 생기는 트레이드 오프는 Request요청과 Dto가 서로 동일할 때입니다.

가령, Login을 담당하는 Dto가 있다면, 이는 Client의 Request도 동일할 것입니다.

이 경우 "과연 Dto 두 계층을 분리해야 할까요?"입니다.

 

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class LoginDto {

    @NotNull
    private String email;

    @NotNull
    private String password;

}

 

정답은 없는 것 같습니다. 먼저 그대로 사용할 경우 Dto를 다시 Dto로 변환하는 과정이 생략되고 코드가 간단해지기 때문에 불필요한 코드 중복을 줄일 수 있습니다. 반면 Request 요청과 Dto를 분리할 경우 계층적 역할을 구분할 수 있고 무엇보다 만약 Controller에서 요구하는 Request 요청이 수정되었을지라도 핵심 비즈니스 로직을 처리하는 Service에서도 코드를 수정해야 하는 불상사를 줄일 수 있습니다.

 

저는 Controller와 Service 계층의 구분을 명확하게 하고, 추후 생길 수 있는 코드 수정에서 Service단이 영향을 받는 것을 최소화하기 위해 Dto를 분리하는 선택을 하였습니다. 

 

이제 코드로 리펙토링 하는 과정을 정리하도록 하겠습니다.

 

 

 

3. Controller 리펙토링 

 

먼저 이전에 작성했던 코드는 다음과 같습니다.

 

    /**
     *
     * 회원 가입 요청
     * BAD_REQUEST: 400 (요청한 파라미터의 타입 에러 혹은 바인딩 에러)
     * CONFLICT: 409 (요청한 회원가입 이메일 이미 존재)
     * OK: 200 (가입 완료)
     *
     * @return
     */
    @PostMapping("/register")
    public ResponseEntity formRegister(@Valid @RequestBody FormRegisterUserDto dto,
                                       BindingResult bindingResult) {

        if (bindingResult.hasErrors()) throw new BindingInvalidException();

        if (!memberService.registerForm(dto)) {
            throw new UserRegisterConflictException();
        }

        return ResponseEntity.ok(SendSuccess.of());
    }

 

이 코드는 Json으로 폼 회원가입을 요청을 처리하는 Controller입니다.

이 코드에는 BindingError를 처리하는 코드와 registerForm()을 처리하는 코드로 구성되어 있습니다.

Controller에서는 다음과 같이 바인딩 에러를 처리하는 부가기능과 핵심 비즈니스 로직을 수행해야 하는 코드가 같이 있기 때문에 이를 분리하는 과정이 필요하였습니다.

 

먼저, 바인딩 에러와 관련된 부분은 Dto에 작성된 @NotNull과 같은 어노테이션의 제약 조건에 위배되지 않는지 판단하는 로직입니다. 이 코드에서 @Validated 어노테이션을 적용하면 Dto의 제약 조건에 위배된다면 MethodArgumentNotValidException() 예외를 발생시킵니다.

 

앞 서 정의한 ExceptionControllerAdvice를 활용하면, Controller에서 BindingResult를 처리하여 Exception 예외를 발생시키는 코드를 제거할 수 있습니다.

 

코드를 수정하면 다음과 같습니다.

 

@PostMapping("/register")
public ResponseEntity formRegister(@Validated @RequestBody FormRegisterUserDto dto) {
    
    if (!memberService.registerForm(dto)) {
        throw new UserRegisterConflictException();
    }

    return ResponseEntity.ok(SendSuccess.of());
}

 

앞 서 정의한 bindingResult.hasError()를 제외함으로써 코드가 간결해졌습니다.

이제 bindingError를 ExceptionControllerAdvice에서 예외를 처리하도록 하겠습니다.

 

/**
 *  바인딩 에러
 */
@ResponseStatus(BAD_REQUEST)
@ExceptionHandler
public ResponseEntity<ErrorDto> MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {

    List<String> errorMessages = new ArrayList<>();
    for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
        errorMessages.add(fieldError.getDefaultMessage());
    }

    return new ResponseEntity<>(new ErrorDto("400", String.join(".", errorMessages)), BAD_REQUEST);
}

 

여기서 확인할 부분은, e.getBindingResult(). getFieldErrors()입니다.

기본적으로 e.getMessage()는 해당 예외에 대한 모든 로그를 제공합니다. 클라이언트는 실제로 잘못 작성한 예외 로그만 보길 원하지만 g.getMessage()로 전송하게 되면 복잡한 예외 메시지까지 전부 전달되게 됩니다.

 

{ "code": "400", "message": "Validation failed for argument [0] in public org.springframework.http.ResponseEntity liar.memberservice.auth.controller.controller.AuthController.formRegister(liar.memberservice.auth.service.dto.FormRegisterUserDto): [Field error in object 'formRegisterUserDto' on field 'password': rejected value [kose1234]; codes [Length.formRegisterUserDto.password,Length.password,Length.java.lang.String,Length]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [formRegisterUserDto.password,password]; arguments []; default message [password],100,10]; default message [password는 10자 이상입니다.]] " }

 

따라서, FiledError에 해당하는 값을 받은 후 getDefaultMessage()를 호출하여 실제 실패한 예외 메시지만 받도록 처리합니다.

 

List<String> errorMessages = new ArrayList<>();
for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
    errorMessages.add(fieldError.getDefaultMessage());
}

 

 

그 결과 응답 메시지가 제가 원하는 형태로 전달되는 것을 확인할 수 있습니다.

 

 

 

4. FacadeService 도입하여 Dto 리펙토링 

 

현재 컨트롤러는 다양한 Service에 의존성 주입을 받고 있습니다. 실제로 코드상 문제는 없지만, 많은 의존성은 코드의 복잡성을 가중시킬 수 있습니다. 무엇보다 코드를 수정해야할 때, "이 서비스가 무슨 서비스더라...?" 생각하는 시간이 많게 되었던 것 같습니다.

 

따라서, Controller는 FacadeService에만 의존 관계 주입을 받도록 처리하여 FacadeService에서 기타 다른 서비스로 의존성 주입을 받아 처리할 수 있도록 하였습니다.

 

 

FacadeService를 중간단계에 놓은 후, 얻을 수 있었던 장점은 Controller와 Service 사이에 Dto를 분리할 수 있었다는 점입니다.

코드로 확인해 보면 다음과 같습니다.

 

    /**
     *
     * 회원 가입 요청
     * BAD_REQUEST: 400 (요청한 파라미터의 타입 에러 혹은 바인딩 에러)
     * CONFLICT: 409 (요청한 회원가입 이메일 이미 존재)
     * OK: 200 (가입 완료)
     *
     * @return
     */
    @PostMapping("/register")
    public ResponseEntity formRegister(@Validated @RequestBody FormRegisterRequest request) {
        facadeService.register(request);
        return ResponseEntity.ok(SendSuccess.of());
    }

 

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class FacadeService {

    private final MemberService memberService;
    private final TokenService tokenService;

    @Transactional
    public void register(FormRegisterRequest request) {
        if (!memberService.registerForm(RequestMapperFactory.mapper(request))) {
            throw new UserRegisterConflictException();
        }
    }

 

@Component
public class RequestMapperFactory {

    public static FormRegisterUserDto mapper(FormRegisterRequest request) {
        return FormRegisterUserDto.builder()
                .email(request.getEmail())
                .password(request.getPassword())
                .username(request.getUsername())
                .build();
    }

 

컨트롤러는 FacadeService에만 의존하게 되고, FacadService는 memberService, tokenService와 의존 관계 주입을 받습니다. 이 단계에서 Request와 Dto를 변형해 주는 Factory클래스의 mapper 메서드를 도입함으로써 Dto를 분리할 수 있었습니다.

 

그 결과, Dto에 있었던 검증 로직은 FormRegisterRequest에서 처리하게 되고, FormRegisterDto는 Dto 임무만 처리할 수 있도록 할 수 있었습니다.

 

@Getter
@NoArgsConstructor
public class FormRegisterRequest {

    @NotNull
    private String username;

    @NotNull
    private String email;

    @NotNull
    @Length(min = 10, max = 100, message = "password는 10자 이상입니다.")
    private String password;

    @Builder
    public FormRegisterRequest(String username, String email, String password) {
        this.username = username;
        this.email = email;
        this.password = password;
    }

}

 

 

@Getter
@NoArgsConstructor
public class FormRegisterUserDto {
    
    private String username;
    private String email;
    private String password;

    @Builder
    public FormRegisterUserDto(String username, String email, String password) {
        this.username = username;
        this.email = email;
        this.password = password;
    }
}

 

다시 테스트를 진행하면 테스트를 성공한 것을 확인할 수 있습니다.

 

 

 

5. 느낌점

 

리펙토링하는 과정에서 Filter, Interceptor, ControllerAdvice 등의 역할을 한 번 더 짚고 넘어갈 수 있었습니다.

크게 문제가 없다고 넘길 수 있는 부분이었지만, 이 코드보다 더 좋은 선택지는 없을지 고민하고, 한 번 이 방법을 적용해볼까?

하며 코드를 작성하니, 이전에 미처 생각하지 못했던 예외 전파 순서를 깊게 공부해볼 수 있었습니다.

 

Dto는 항상 코드를 작성할 때마다 고민이 되는 부분입니다. mapper를 도입하는 것이 오히려 복잡성을 가중시키는 것 같아보이지만, 추후 인증의 문제로 다른 field가 추가되어야 하는 상황이라면 Dto를 분리한 코드는 이러한 문제에 직면하더라도 수정해야할 코드의 수가 매우 줄어들 것이라고 생각하게 되었습니다.

 

 

부족하지만 오늘도 읽어주셔서 감사드립니다.!

+ Recent posts