본문 바로가기

🧑🏻‍💻 Dev/SpringBoot

[Spring] ExceptionHandler가 작동안 되는 오류 (컴포넌트 스캔)

🤔 문제 상황

Global Exception 처리에 대한 코드를 작성하는 과정에서 @RestControllerAdvice가 작동되지 않는 오류가 발생했습니다.

프로젝트 구조는 아래와 같이 구성했었습니다.

 

Book을 등록하는 간단한 프로그램입니다. 예상했던 시나리오는 BookController의 "/register"로 POST 요청을 보냅니다. 이때 BookService 로직에서 POST 요청으로 들어온 Book의 name과 같은 name의 Book이 이미 Repositroy에 존재한다면 BookException을 던져주도록 작성했습니다.

 

그 후 BookController에서 BookException이 발생했을 때, Global 하게 예외를 처리하기 위해서 BookExceptionHandler를 추가해 줬습니다. 

 

즉, 제가 생각한 flow는 POST 요청 ->  BookController -> BookService에서 BookException이 발생 -> BookExceptionHandler에서 해당 예외를 처리였습니다.

 

# BookController

package com.example.selftest.controller;

import com.example.selftest.dto.book.CreateBookDto;
import com.example.selftest.dto.book.ResponseBookDto;
import com.example.selftest.service.BookService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class BookController {
    private final BookService bookService;

    @PostMapping("/register")
    public ResponseBookDto registerBook(@RequestBody CreateBookDto dto) {
        return ResponseBookDto.fromEntity(bookService.registerBook(dto));
    }
}

 

# BookService

package com.example.selftest.service;

import com.example.exception.BookException;
import com.example.selftest.dto.book.CreateBookDto;
import com.example.selftest.entity.Book;
import com.example.selftest.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import static com.example.exception.ErrorCode.DUPLICATE_BOOK_NAME;

@RequiredArgsConstructor
@Service
public class BookService {
    private final BookRepository bookRepository;

    @Transactional
    public Book registerBook(CreateBookDto dto) {
        validateRegisterBook(dto);
        return bookRepository.save(dto.toEntity());
    }

    private void validateRegisterBook(CreateBookDto dto) {
        bookRepository.findByName(dto.getName()).ifPresent((book) -> {
            throw new BookException(DUPLICATE_BOOK_NAME);
        });
    }
}

 

# BookException

package com.example.exception;

import lombok.Getter;

@Getter
public class BookException extends RuntimeException {
    private ErrorCode errorCode;

    public BookException(ErrorCode errorCode) {
        super(errorCode.getErrorMessage());
        this.errorCode = errorCode;
    }
}

 

# BookExceptionHandler

package com.example.exception;

import com.example.selftest.dto.book.BookErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.servlet.http.HttpServletRequest;

@RestControllerAdvice
public class BookExceptionHandler {
    @ExceptionHandler(BookException.class)
    public BookErrorResponse handlerException(BookException e, HttpServletRequest request) {
        System.out.println("에러가 발생!!!");
        return BookErrorResponse.builder()
                .errorCode(e.getErrorCode())
                .errorMessage(e.getMessage())
                .build();
    }
}

 

# BookErrorResponse

package com.example.selftest.dto.book;

import com.example.exception.ErrorCode;
import lombok.*;

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class BookErrorResponse {
    private ErrorCode errorCode;
    private String errorMessage;
}

 

🔎 문제 분석 및 시도

코드를 잘 작성한 것 같은데...뭐가 문제지...라는 생각으로 엄청난 구글링에 들어갔습니다. 주요 키워드는 "@RestControllerAdvice not working"였습니다.

 

1. 예외가 Service에서 던져져서 @RestControllerAdvice에서 못 찾는 것인가?

사실 이거도 바보같은 생각이었지만 2시간 내내 이리저리 돌아다녔기 때문에 지푸라기라도 잡는 심정으로 해봤다.

그럼 BookController에서 직접 예외를 던져봤다. POST요청할 때 보내는 값 중에서는 name(책이름), author(저자)가 있었고, 만약 request로 들어온 데이터의 name이 "자바의 정석"이라면 직접 controller에서 BookException을 던지도록 해봤다.

 

@PostMapping("/register")
public ResponseBookDto registerBook(@RequestBody CreateBookDto dto) {
	if (dto.getName().equals("자바의 정석")) {
        throw new BookException(INTERNAL_SERVER_ERROR);
    }
    return ResponseBookDto.fromEntity(bookService.registerBook(dto));
}

이렇게 하고 POST 요청을 아래와 같이 보내봤다.

POST http://localhost:8080/register
Content-Type: application/json

{
  "name": "자바의 정석",
  "author": "남궁성"
}

예상했던 결과는 에러코드인 INTERNAL_SERVER_ERROR과 errorMessage인 "서버에 오류가 발생했습니다."가 BookErrorResponse에 저장되어 반환되는 것이었습니다. 즉, 정상적으로 ExceptionHandler가 작동했다면 아래와 같은 결과가 나왔을 것입니다.

{
  "errorCode": INTERNAL_SERVER_ERROR,
  "errorMessage": "서버에 오류가 발생했습니다."
}

하지만 실제 결과는 다음과 같이 나왔습니다.

{
  "timestamp": "2023-04-03T07:58:38.217+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/register"
}

음 ExceptionHandler가 작동하지 않았구나... 일단 위에서 작성했던 코드는 다시 원상복구 해놨습니다.

 

 

2. 내 프로젝트 빌드 버전에 문제가 있는 것일까?

저는 SpringBoot 2.5.2 버전을 사용했습니다. 몇몇 검색 결과에서는 @RestController가 4.3 버전 이후부터 지원이 된다는...그런 말들이 돌아다녔습니다. 물론 정확한 정보인지는 공식문서를 찾아보는 것이 정확하기 때문에 이곳저곳 찾아봤습니다. 제 구글링 능력 밖인지 아니면 영어 난독증에 걸려서인지 찾을 수 없었습니다.

 

@RestControllerAdvice가 아닌 @ExceptionHandler의 문제인지 궁금했습니다. 그래서 Controller 내부에서 메서드로 @ExceptionHandler로 처리를 해봤습니다.

@RequiredArgsConstructor
@RestController
public class BookController {
    private final BookService bookService;

    @PostMapping("/register")
    public ResponseBookDto registerBook(@RequestBody CreateBookDto dto) {
        return ResponseBookDto.fromEntity(bookService.registerBook(dto));
    }

    @ExceptionHandler(BookException.class)
    public BookErrorResponse handlerException(BookException e, HttpServletRequest request) {
        System.out.println("에러가 발생!!!");
        return BookErrorResponse.builder()
                .errorCode(e.getErrorCode())
                .errorMessage(e.getMessage())
                .build();
    }
}

이렇게 작성하고 {name = "자바의 정석", author = "남궁성"}을 넣어서 POST 요청을 해봤습니다. 첫 번째 요청은 역시 성공적으로 되었고, 같은 요청으로 두 번째 요청을 해봤습니다.

 

결과는 다음과 같이 성공적으로 나왔습니다.

{
  "errorCode": "DUPLICATE_BOOK_NAME",
  "errorMessage": "중복되는 이름의 도서가 존재합니다."
}

역시 @ExceptionHandler가 잘못된 게 아니고 내가 잘못된 거구나 컴퓨터는 역시 거짓말을 하지 않는다. 다른 방법을 찾아보기로 했습니다.

 

 

3. @RestControllerAdvice도 IoC 컨테이너에 등록되는 Bean이 아니었던가?

코드를 다시 원상 복구한 후 @RestControllerAdvice를 한번 쭉 살펴봤습니다. (차분하게)

@RestControllerAdvice 어노테이션

음 @RestControllerAdvice는 @ControllerAdvice와 @ResponseBody의 기능을 모두 할 수 있는 어노테이션이구나... 그러고 나서 @ControllerAdvice로 들어가 봤습니다.

@ControllerAdvice 어노테이션

@ControllerAdvice는 @Component 어노테이션을 가지고 있어서 Spring Bean으로 등록되어 IoC 컨테이너에서 관리될 수 있도록 되어 있었습니다.

 

그럼 @RestControllerAdvice를 사용해서 작성했던 BookExceptionHandler는 스프링 Bean으로 등록이 될 것이고, DI를 받아도 오류가 나지 않아야 한다는 말인데... 문득 궁금했습니다.

 

ApplicationContext를 가져와서 Bean 목록에 존재하는지 확인하는 방법도 있었지만, 간단하게 테스트해보고 싶었기 때문에 다음과 같은 생각을 한번 해봤습니다.

 

Spring Application이 실행이 되면 Component Scan을 하면서 @Component가 붙어있는 클래스들을 IoC 컨테이너에 등록합니다. 정상적으로 등록이 되었다면 BookController에서 생성자 DI로 BookExceptionHandler를 주입받은 다음에 한번 확인해 보자라는 생각을 했습니다.

 

@RequiredArgsConstructor
@RestController
public class BookController {
    private final BookService bookService;

    private final BookExceptionHandler exceptionHandler;
    
    /* ... 생략 ... */
}

이렇게 하고 한번 Application을 실행시켜 봤습니다.

***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 1 of constructor in com.example.selftest.controller.BookController required a bean of type 'com.example.exception.BookExceptionHandler' that could not be found.

BookController의 생성자 주입 과정에서 BookExceptionHandler를 찾을 수 없다는 문구를 날리면서 프로그램이 종료되었습니다. 이 말은 즉... BookExceptionHandler가 아예 Bean으로 조차 등록되지 않았다는 것을 의미합니다.

 

이때부터 거의 멘탈이 와사삭 내려앉았습니다. 아니 이게 뭐야...@RestControllerAdvice를 붙이면...Bean으로 등록이 되어야 하는데...

 

 

 

🙆🏻‍♂️ 문제해결

계속 붙잡고 싸움만 하다가 정말 어이없는 곳에서 문제를 해결했습니다. 사실 창피해서 기록하지 않으려고 하다가 컴포넌트 스캔에 대해서 다시 생각해 볼 수 있는 기회인 것 같아서 기록하기로 결심했습니다.

 

정확한 원인은 바로 프로젝트의 패키지 구조에 있었습니다.

눈치채셨을까요. 제 프로젝트의 시작할 때 초기 구조는 com.example.selftest 였습니다. selftest 패키지 내부에 service, entity, repositroy, controller 등의 패키지가 존재합니다. 

 

빨리 코드를 치려고 하다 보니깐 example에 새로운 패키지인 exception 패키지를 만들게 되었고, Spring의 컴포넌트 스캔은 com.example.selftest 부터 스캔되기 때문에 BookExceptionHandler는 아예 스캔되지도 않았던 것입니다.

 

정말 바보 같은 실수를 하게 된 것입니다. 해결 방안은 모두가 알고 있듯이 exception 패키지를 selftest 내부에 넣어주면 됩니다.

첫 번째 POST 요청 응답 결과
두 번째 POST 요청 응답 결과 (BookExceptionHandler 정상 작동)

이제 정상적으로 제가 생각했던 결과가 나오는 것을 볼 수 있습니다. 

 

 

 

👀 문제를 해결하면서 느낀 점

일단 가장 컸던 것은 컴포넌트 스캔에 대해서 좀 알게 되었던 거 같습니다. 우리가 간단하게만 사용했던 SpringBoot에서 어떠한 설정도 해주지 않았는데, 자동으로 Component Scan을 하는 것인지 궁금해서 찾아봤습니다.

 

정답은 main 메서드가 존재하는 클래스에 붙은 @SpringBootApplication이라는 어노테이션 안에 있었습니다.

@SpringBootApplication

 

@ComponentScan이라는 어노테이션이 있어서 우리가 별도로 설정파일을 구성해주지 않아도 자동으로 Component Scan을 하고, Bean으로 등록해 줬던 것입니다.

 

단순한 궁금점으로 만약 문제 상황과 같이 com.example 아래 exception과 selftest 패키지가 있으니, 컴포넌트 스캔의 시작 지점을 기본 설정값인 com.example.selftest가 아닌 com.example로 바꿔도 문제가 해결되지 않을까라는 생각으로 한번 시도해 봤습니다.

 

컴포넌트 스캔의 시작 지점을 바꿀 때는 main 메서드가 있는 클래스에 @ComponentScan() 어노테이션을 붙여주고, basePackages 옵션을 넣어주면 됩니다.

@EnableJpaAuditing
@ComponentScan(basePackages = {"com.example"})
@SpringBootApplication
public class SelfPracticeApplication {
    public static void main(String[] args) {
        SpringApplication.run(SelfPracticeApplication.class, args);
    }

}

이렇게 설정한 뒤 한번 실행시켜 봤습니다.

같은 데이터로 POST 요청을 2번 했을 때의 응답 결과

 

생각했던 대로 정상적으로 작동되는 것을 확인할 수 있었습니다. 

 

Spring에서 컴포넌트 스캔의 디폴트 값을 설정해 둔 이유가 있다고 생각되기 때문에 위와 같이 basePackage를 바꾸는 방법은 이 문제의 근본적인 해결방법은 아니라고 생각합니다. 

 

결론은 눈을 똑바로 뜨고, 정확히 클릭해서 정확하게 잘 코딩하자입니다.