Skip to content

Blog

BeanCreationException 예외로 알아보는 빈 생명주기

실행 환경: Java 17, Spring Boot 3.1.4

스프링 부트로 커맨드 라인 애플리케이션을 만들던 중, csv 관련 에러 테스트 중 예외 처리가 의도하지 않은 방향으로 흘러가는 것을 발견했다.

우선 아래는 애플리케이션을 실행하고 유지하는 CommandLineRunner 인터페이스를 구현한 CommandLineExecutor 클래스이며,
애플리케이션 실행 및 정책은 다음과 같이 설정하였다.

  • RuntimeException 발생 시: warning 로그를 남기고 실행 상태 유지
  • Exception 발생 시: error 로그를 남기고 실행 종료
@Slf4j
@Component
@RequiredArgsConstructor
public class CommandLineExecutor implements CommandLineRunner {
private final ConsoleIOHandler consoleIOHandler;
private final FunctionHandler functionHandler;
private boolean isRunning = true;
@Override
public void run(String... args) {
while (isRunning) {
progress();
}
}
private void progress() {
try {
consoleIOHandler.printMenuTitle(ConsoleConstants.VOUCHER_PROGRAM_START_MESSAGE);
consoleIOHandler.printEnumString(Function.class);
String command = consoleIOHandler.getInputWithPrint();
Function.fromString(command)
.ifPresentOrElse(
function -> function.execute(functionHandler),
() -> {
throw InputException.of(InputErrorMessage.INVALID_COMMAND);
});
} catch (RuntimeException e) {
log.warn(e.getMessage());
} catch (Exception e) {
isRunning = false;
log.error(Arrays.toString(e.getStackTrace()));
}
}
}

다음으로는 csv 파일을 읽고 쓰는 로직인 CsvFileHandler 클래스이며, 호출 시점 및 에러 처리는 아래와 같이 구현하였다.

// CsvCustomerRepository.java: @PostConstruct와 @PreDestroy를 통해 빈 생성 및 소멸될 때 CsvFileHandler 클래스의 파일 입출력 메서드 호출
@Profile("default")
@Repository
public class CsvCustomerRepository implements CustomerRepository {
private final Map<UUID, Customer> customerDatabase = new ConcurrentHashMap<>();
// ...
@PostConstruct
public void init() {
Function<String[], Customer> parser = line -> { /* ... */ };
List<Customer> customers = csvFileHandler.readListFromCsv(
parser,
CSV_LINE_TEMPLATE
); // CSV 파일 읽기
customers.forEach(customer -> customerDatabase.put(customer.getId(), customer));
}
@PreDestroy
public void destroy() {
List<Customer> customers = customerDatabase.values()
.stream()
.toList();
Function<Customer, String> serializer = customer -> { /* ... */ };
csvFileHandler.writeListToCsv(customers, serializer); // CSV 파일 쓰기
}
}
// CsvFileHandler.java: 파일 입출력 처리 로직, R/W 중 IOException이 발생하면 RuntimeException을 상속 받은 사용자 정의 예외로 변환하여 throw
public class CsvFileHandler {
private static final String CSV_DELIMITER = ",";
private final String filePath;
// ...
public <T> List<T> readListFromCsv(Function<String[], T> parser, String csvLineTemplate) {
List<T> itemList = new ArrayList<>();
try (BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath))) {
while (true) {
String line = bufferedReader.readLine();
if (line == null) {
break;
}
String[] parts = line.split(CSV_DELIMITER);
itemList.add(parser.apply(parts));
}
} catch (IOException e) {
throw FileException.of(
FileErrorMessage.IO_EXCEPTION
); // IOException 발생 시 사용자 정의 예외로 변환하여 throw
}
return itemList;
}
public <T> void writeListToCsv(List<T> itemList, Function<T, String> serializer) {
try (BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(filePath))) {
for (T item : itemList) {
String csvLine = serializer.apply(item);
bufferedWriter.write(csvLine);
bufferedWriter.newLine();
}
} catch (IOException e) {
throw FileException.of(
FileErrorMessage.IO_EXCEPTION
); // IOException 발생 시 사용자 정의 예외로 변환하여 throw
}
}
}

우선 애플리케이션 실행 중에 파일 경로에 파일 명을 수정하여 존재하지 않는 파일을 읽도록 하여 IOException이 발생하도록 했다.
의도한 대로 사용자 정의 에러가 발생하고 CommandLineExecutor에서 예외를 처리하여 정의한 메시지가 warning 로그로 남은 뒤 애플리케이션이 계속 유지됐다.

2023-10-24 23:36:51.326 [main] WARN d.s.commandline.CommandLineExecutor -- An error occurred during file input/output operations.

이번에는 애플리케이션 시작 전 파일명을 잘못 입력하여 애플리케이션 초기화 중에 IOException이 발생하도록 했다.
이번에는 CommandLineExecutor에서 예외를 처리하지 못하고 애플리케이션이 바로 종료되었고, 아래 로그가 출력되었다.

2023-10-24 23:06:39.147 [main] WARN o.s.c.a.AnnotationConfigApplicationContext -- Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'commandLineExecutor' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/CommandLineExecutor.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'functionHandler' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/function/FunctionHandler.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'customerController' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/controller/CustomerController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
2023-10-24 23:06:39.169 [main] ERROR o.s.boot.SpringApplication -- Application run failed
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'commandLineExecutor' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/CommandLineExecutor.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'functionHandler' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/function/FunctionHandler.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'customerController' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/controller/CustomerController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'functionHandler' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/function/FunctionHandler.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'customerController' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/controller/CustomerController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'customerController' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/controller/CustomerController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: springbootbasic.exception.FileException: An error occurred during file input/output operations.
...

로그를 살펴보면 직접 정의한 FileException은 가장 마지막 라인에 존재하고, 그 위엔 BeanCreationException가 존재하여 빈 생성 중 발생한 예외로 추측 할 수 있다.
빈 관련 에러라는 것을 확인했으니 빈 생명주기를 살펴보자.

  1. 스프링 컨테이너 생성
  2. 스프링 빈 생성
  3. 의존 관계 주입
  4. 초기화 콜백
  5. 사용(실제 애플리케이션(빈) 동작 단계)
  6. 소멸 전 콜백
  7. 스프링 종료

여기서 파일을 읽어오는 단계는 @PostConstruct는 4번 초기화 콜백에 해당하며, 빈이 생성되고, 빈의 의존관계 주입이 완료된 후 호출된다.
하지만 CommandLineExecutor가 동작하는 순간은 5번 사용 단계에 해당하기 때문에, 애초에 해당 에러를 처리하지 못하는 것이다.
그 흐름을 자세히 살펴보면 아래와 같다.

  1. 애플리케이션 시작 전 파일명 잘못 입력
  2. 빈 초기화 중 @PostConstruct 애노테이션을 통해 CsvCustomerRepositoryinit() 메서드 호출
  3. init() 메서드에서 CsvFileHandlerreadListFromCsv() 메서드 호출
  4. CsvFileHandler 내부에서 IOException 발생
  5. FileException으로 변환하여 throw
  6. @PostContruct 애노테이션에서 발생한 빈 초기화 중 발생한 예외이기 때문에 BeanCreationException으로 감싸져서 throw
  7. 애플리케이션 초기화 중 발생했기 때문에 CommandLineExecutor이 동작하기 전에 예외가 발생

사실 어찌보면 너무나 당연한 지식을 기반한 내용이지만, Spring의 여러 기능을 사용하게 되면서 생각하지 못한(의도하지 않은) 경로로 예외가 흘러가는 것을 확인할 수 있었다.
다시 한 번 빈 생명주기에 대해 공부할 수 있었고, 그 흐름을 이해하는 것이 중요하다는 것을 깨달았다.
만약 BeanCreationException이 발생하면, 빈이 생성되는 과정에서 문제가 있는 것이므로 빈 생명주기를 생각하면서 디버깅을 해보자.

`null`은 오버 로딩된 메서드 중 어떤 메서드를 호출할까?

실행 환경: Java 17

우선 해당 주제를 본격적으로 다루기 전에 아래의 코드를 보자. 아래 코드는 null을 참조하는 변수를 사용했을 때와, 리터럴 null을 사용했을 때 String.valueOf() 메서드의 동작을 보여준다.

class NullTest {
public static void main(String[] args) {
String s = null;
String nullValue = String.valueOf(s);
System.out.println(nullValue); // null, 정상 출력 ---- 1
nullValue = String.valueOf(null); // NullPointerException ---- 2
System.out.println(nullValue);
}
}

1번 코드는 정상적으로 null을 출력하지만, 2번 코드는 NullPointerException이 발생하는 것을 확인할 수 있다. 디버깅 모드를 통해 호출 된 메서드를 추적했을 때, 두 라인은 서로 다른 메서드를 호출하고 있음을 알 수 있었다.

java.lang.String
public final class String {
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
}

String.valueOf(s)String.valueOf(Object obj)를 호출하는데, 이 메서드는 null 체크를 하는 것을 확인할 수 있다. 때문에 넘겨 받은 obj 값이 null이기 때문에 자연스럽게 "null"을 반환하게 된다.

하지만 직접 null을 넘겨 받은 경우에는 String.valueOf(char data[])를 호출하게 된다.

java.lang.String
public final class String {
public String(char value[]) {
this(value, 0, value.length, null); // 3. value.length에서 NullPointerException 발생
// Exception in thread "main" java.lang.NullPointerException: Cannot read the array length because "value" is null
}
// 1. 메서드 호출 받음
public static String valueOf(char data[]) {
return new String(data); // 2. 위의 String(char value[])를 호출
}
}

주석의 순번대로 코드가 실행되는데, 결국 3번에서 null인 값에서 length를 읽으려고 하기 때문에 NullPointerException이 발생하게 된다. 그렇다면 왜 nullchar data[] 타입이 아닌데 해당 메서드가 호출되는 것일까?

호출 메서드는 어떻게 결정되는가?

Section titled “호출 메서드는 어떻게 결정되는가?”

위 상황과 비슷하게 char data[] 타입과 Object o 타입을 파라미터로 갖는 메서드를 호출했을 때 어떤 메서드가 호출되는지 확인해보자.

class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // char[] Param Method Called
}
public static void testMethod(char data[]) {
System.out.println("char[] Param Method Called");
}
public static void testMethod(Object o) {
System.out.println("Object Param Method Called");
}
}

처음의 예제 코드와 같이 char[] Param Method Called가 출력된다. 그럼 nullchar[]과 특수한 관계가 있는 것일까? 그것도 아니다.

class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // Object Param Method Called
}
// public static void testMethod(char data[]) {
// System.out.println("char[] Param Method Called");
// }
public static void testMethod(Object o) {
System.out.println("Object Param Method Called");
}
}

char data[] 타입의 메서드를 주석 처리하고 실행해보면 Object Param Method Called가 출력된다. 즉, nullchar[]과 특수한 관계가 있는 것이 아니라, Object 보다는 char[] 타입이 더 높은 우선순위를 가진다고 추측해 볼 수 있다. 다른 타입들도 더 살펴보자.

class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // no suitable method found for testMethod(<nulltype>)
}
public static void testMethod(int i) {
System.out.println("int Param Method Called");
}
public static void testMethod(long i) {
System.out.println("Integer Param Method Called");
}
// ...
// char, byte, short, int, long, float, double, boolean
}

당연하게도 원시 타입은 null을 가질 수 없기 때문에 일치하는 메서드가 없다는 에러가 발생한다. 그럼 null이 호출할 수 있는 메서드는 참조(주소) 타입 인자의 메서드만 호출할 수 있는 것으로 추측할 수 있다.

class Test {
int x;
int y;
}
class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // reference to testMethod is ambiguous
}
public static void testMethod(Object o) {
System.out.println("Object Param Method Called");
}
public static void testMethod(char data[]) {
System.out.println("char[] Param Method Called");
}
public static void testMethod(Test t) {
System.out.println("Test Param Method Called");
}
public static void testMethod(String s) {
System.out.println("String Param Method Called");
}
public static void testMethod(int... i) {
System.out.println("int... Param Method Called");
}
public static void testMethod(Integer i) {
System.out.println("Integer Param Method Called");
}
}

위의 메서드들은 단일로 존재했을 때 전부 호출 될 수 있는 메서드들인데, 동시에 존재하는 경우 호출할 수 있는 메서드가 많아 모호하다는 에러 메시지가 발생한다.

reference to testMethod is ambiguous
both method testMethod(int...) in MethodCallTest and method testMethod(java.lang.Integer) in MethodCallTest match

그 중 int ...i 타입과 Integer i 두 개의 메서드에 대해 언급하면서 에러 메시지가 발생했는데, 두 타입이 더 null과 관련이 있는 것일까? 아니다, 그 이유는 다시 아래의 코드와 에러 메시지를 보면 알 수 있다.

class Test {
int x;
int y;
}
class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // reference to testMethod is ambiguous
}
public static void testMethod(Object o) {
System.out.println("Object Param Method Called");
}
public static void testMethod(int... i) {
System.out.println("int... Param Method Called");
}
public static void testMethod(Integer i) {
System.out.println("Integer Param Method Called");
}
public static void testMethod(char data[]) {
System.out.println("char[] Param Method Called");
}
public static void testMethod(Test t) {
System.out.println("Test Param Method Called");
}
public static void testMethod(String s) {
System.out.println("String Param Method Called");
}
}
reference to testMethod is ambiguous
both method testMethod(Test) in MethodCallTest and method testMethod(java.lang.String) in MethodCallTest match

에러 메시지를 다시 살펴 보면 Test 타입과 String 타입이 언급되는 것을 볼 수 있다. 결국 특정 타입이 아닌, 완전히 일치하는 타입이 없어 null을 호출 할 수 있는 메서드를 탐색하게되고, 마지막 두 타입에 대해 모호하다는 에러 메시지가 발생한 것으로 추측해 볼 수 있다.

  1. null은 원시 타입을 제외한 모든 타입의 인자로 호출 당할 수 있다.
  2. nullObject 타입으로도 호출 당할 수 있다.
  3. Object가 아닌 참조 타입 메서드가 존재하면 더 높은 우선순위를 가진다.
  4. 만약 Object 타입을 제외한 참조 타입 메서드가 두 개 이상 존재하면 refrence to method is ambiguous 에러가 발생하게 된다.
  5. 에러 메시지는 null을 호출 할 수 있는 메서드를 탐색하다가 마지막 두 가지 타입에 대해 모호하다는 에러 메시지가 발생한 것으로 추측해 볼 수 있다.

다소 애매한 결과라고 볼 수 있지만, 사실 null 타입을 그대로 넣는 일은 거의 없기 때문에 이러한 상황은 잘 발생하지 않을 것이라고 생각한다. 다시 한 번 null을 사용할 때는 주의 해야 한다는 것을 체감할 수 있었고, null 자체를 파라미터로 넘기는 것을 지양하는 것이 좋을 것 같다.