안녕하세요 오늘은 Spring Boot에서 Bean의 동작 원리에 대해 알아보면서 클래스 final 작업을 진행했을 때의 장점에 대해서도 같이 알아보는 시간을 갖겠습니다.
우선 Bean에 대해서 알아보겠습니다. Bean이란 Spring IoC(제어의 역전 : 객체의 생성에서부터 생명 주기의 관리까지 개발자가 결정하지 않고 시스템에서 결정하는 것) 컨테이너에 의해 관리되는 객체입니다. IoC의 설명처럼 개발자가 직접 객체를 생성하는 것이 아니라 시스템에서 객체를 생성 및 관리하여 객체 관리가 보다 편리하다는 장점이 있습니다.
생성된 빈들은 관리되는 범위를 정할 수 있으며, 이 때 빈이 관리되는 범위를 빈 스코프(Bean Scope)라고 합니다. 크게 싱글톤(Singleton) 스코프, 프로토타입(Prototype) 스코프, 리퀘스트(Request) 스코프 등으로 나뉘어집니다. 싱글톤 스코프의 경우 모든 빈 요청에 대해서 동일한 인스턴스가 반환되는, 즉 하나의 빈이 하나의 인스턴스만 생성하는 방식입니다. 반면 프로토타입 스코프의 경우 빈을 요청할 때마다 매번 새로운 인스턴스를 생성하는 방식입니다. 리퀘스트 스코프의 경우 각각의 HTTP 요청마다 새로운 인스턴스를 생성하는 스코프입니다. Spring Boot의 경우 기본적으로 등록된 빈을 싱글톤 패턴으로 처리하여 메모리를 효과적으로 사용할 수 있습니다.
Spring Boot에서는 서비스를 시작하는 클래스에 존재하는 @SpringBootApplication 어노테이션에서 기본적인 빈 설정을 처리하며 자동 구성과 컴포넌트 스캔을 활성화합니다. 자동 구성의 경우 gradle, maven 등에 추가한 dependency들을 읽어 필요한 Bean들이 자동으로 구성되는 것을 의미하며, 컴포넌트 스캔의 경우 프로젝트 내에 존재하는 @Component 어노테이션이 붙은 클래스를 빈으로 등록합니다. 흔히 사용하는 @Controller, @Repository, @Service 등은 @Component를 참조한 어노테이션이기 때문에 해당 어노테이션이 붙은 클래스도 빈으로 등록됩니다.
https://velog.io/@gale4739/Spring-Boot-Singleton-VS-Java-static
이 점을 잘 캐치하신다면 이전 포스팅에서 java의 static factory method 방식의 싱글톤 패턴과 빈을 주입하는 싱글톤 스코프 방식과의 차이를 좀 더 쉽게 이해하실 수 있습니다. 둘 다 싱글톤 패턴으로 객체를 참조하지만 static의 경우 클래스 로더 기준으로 작동하여 톰캣 단위로 객체 참조가 가능한 반면 Bean의 경우 Spring IoC 컨테이너에서 관리하기 때문에 ApplicationContext 기준, 즉 하나의 서블릿 내에서만 참조된다는 점에서 참조 가능 범위가 달라지게 됩니다.
하지만 Spring Boot의 경우 기본적으로 여러 개의 서블릿을 띄우지 않기 때문에 실질적으로 static factory method 방식의 객체와 Bean의 범위가 같게 됩니다. 따라서 이전 방식에서는 코드의 가독성을 위해 생성자를 private하게 막은 싱글톤 패턴의 static factory method로 진행했습니다. 하지만 @Component의 경우 생성자를 public하게 설정하여 @Value 등 Spring Boot에서 제공하는 다양한 기능들을 유연하게 사용 가능하면서 코드도 심플해지고 싱글톤 패턴도 적용이 가능하기 때문에 @Component로 변경하였습니다.
위에서 생성한 빈들을 인스턴스로 사용하기 위해선 의존성 주입(DI) 과정이 필요합니다. Spring Boot에서는 크게 @Autowired를 이용한 필드 주입 방식과 생성자 주입 방식이 존재합니다. 필드 주입 방식의 경우 빈들을 먼저 생성하고 @Autowired 어노테이션이 붙은 필드를 찾은 후 맞는 빈들을 주입하는 방식입니다. 즉 @Autowired을 생성자 등에 붙여 Spring Boot에서 자동으로 의존성을 주입하게 됩니다. 이를 통해 생성자 여부와 상관없이 쉽게 의존성을 주입할 수 있다는 장점이 있으나, 순환 참조 문제와 final 설정이 불가능하다는 단점이 있습니다.
순환 참조란 빈 간의 상호 의존성이 서로를 가리켜 순환하는 상황을 말합니다. 쉽게 말해서 Bean A가 Bean B를 참조하고, Bean B가 Bean A를 참조하는 상황입니다. 이 경우 정상적으로 인스턴스가 생성될 수 없습니다. 하지만 @Autowired는 이를 자동적으로 주입하기 때문에 이를 감지하여 예방할 수 없습니다.
final의 경우 불변성을 보장해주는 명령어입니다. 불변 클래스는 생성된 시점의 상태를 소멸 시까지 그대로 가지고 있기 때문에 클래스의 불변성이 보장되어 안정적으로 사용할 수 있으며 이로 인해 여러 스레드가 동시에 사용해도 훼손되지 않아 thread-safe하다는 장점이 있습니다. 이로 인해 여러 요청이 발생하는 API 서버에서 final 클래스는 thread-safe하기 때문에 독립적으로 요청을 처리할 수 있습니다. 하지만 @Autowired의 경우 클래스 생성 이후 값을 초기화하기 때문에 불변성이 깨져 final 설정을 할 수 없습니다. 따라서 @Autowired를 사용한 필드 주입 방식의 경우 지양하고 있습니다.
반면 생성자 주입의 경우 생성자를 통해 의존 관계를 주입하는 방식으로, 일반적으로 사용하는 생성자를 이용하는 방식입니다. 이 경우 클래스 생성 시 매개변수를 받아 클래스를 생성하기 때문에 별도로 값을 초기화하지 않고 final class로 사용할 수 있습니다. 하지만 생성자를 작성해서 코드가 복잡해질 수 있다는 단점이 존재하지만, Spring Boot에서 제공하는 @RequiredArgsConstructor 어노테이션을 이용하면 생성자 작성을 생략할 수 있어 단점을 상쇄할 수 있습니다.
하지만 특정 경우는 final class로 사용할 수 없습니다. 제가 찾은 경우는 다음과 같습니다.
우선 당연하게도 @Autowired가 클래스가 생성된 이후 불변성을 깨뜨리기 때문에 @Autowired가 존재하는 클래스는 final 설정이 불가능합니다. 또한 같은 이유로 @Value가 존재하는 클래스 역시 생성된 이후 값을 초기화하기 때문에 final 설정이 불가능합니다. 이어서 @Async의 경우 컴포넌트를 프록시로 감싼 후 프록시가 메소드 호출을 가로채서 비동기로 실행되는 과정을 가지는데, 이 때 프록시가 런타입 중 생성되기 때문에 클래스 생성 시 정의할 수 없어 final class로 사용할 수 없습니다. 상속받은 클래스 역시 final class로 사용할 수 없는데, 상속의 경우 부모 클래스의 기능을 재사용 또는 확장하기 위함이므로 부모 클래스가 final이 아니라면 자식 클래스도 final이 될 수 없습니다. 같은 이유로 @Configuration의 경우 프록시를 생성하기 위해 Spring framework를 상속하기 때문에 final이 될 수 없습니다. 마지막으로 @Repository의 경우 다른 클래스들을 extends 또는 implements할 수 있도록 설계되어 Spring Boot에서 필요한 프록시를 자동으로 생성하기 때문에 final class가 될 수 없습니다.
(+ 추가)
@Transactional이 붙어 있는 클래스 역시 final이 될 수 없습니다. @Async와 유사하게 @Transactional을 사용하면 여러 데이터베이스 작업을 하나의 논리적인 단위로 묶으면서 프록시에서 로직을 처리하기 때문에 클래스 생성 시 프록시를 정의할 수 없어 final class로 사용할 수 없습니다.