📂 목차
- Spring Core
📚 본문
Spring Core
IoC, DI 에 대해 살펴본다.
IoC
객체의 생성과 의존성 관리를 개발자가 직접하는 것이 아니라 프레임워크가 대신 관리해주는 원리를 IoC 라고 한다.
전통적으로는 new 를 통해 객체의 생성과 의존성을 개발자가 관리했지만, 프레임워크가 객체를 생성하고 연결시키는 책임을 받게 된다.
장점
- 객체 간 결합도를 낮추고, 유지보수를 쉽게 할 수 있음
- 코드 재사용성을 높이고 테스트를 쉽게 할 수 있음
IoC 를 달성하기 위해서는 주로 DI(Dependency Injection) 기술로 구현하게 된다.
Dependency Injection
스프링에서는 3가지의 의존성 주입이 있다. 마지막 방식은 안쓰이니 그냥 삭제한다.
생성자 주입
@Component
public class Car {
private final Engine engine;
@Autowired
public Car(Engine engine) {
this.engine = engine;
}
}
세터 주입
@Component
public class Car {
private Engine engine;
@Autowired
public void setEngine(Engine engine) {
this.engine = engine;
}
}
생성자가 하나인 경우에는
@Autowired
를 생략 가능하다.
Bean
IoC
가 관리하는 객체를 Bean
이라고 하고, 이 Bean 이 살고 있는 곳이 IoC 컨테이너이다. Bean 은 그냥 구현체이며 Bean 으로 등록된 객체들은 다음 특징을 가진다:
- 생명주기 관리 대상: IoC 컨테이너가 객체의 생성부터 소멸까지 관리
- 재사용 가능: 필요할 때마다 컨테이너에서 가져다 쓸 수 있음
- 의존성 주입 지원: 다른 Bean 과 연결할 때 DI 를 통해 주입 가능
결국에는 Bean
으로 등록되어야 DI 의 대상이 된다는 것이다.
Bean 등록
IoC 컨테이너에 Bean 을 등록하기 위해 3가지 방법이 있다.
어노테이션 기반
@Component // 일반적인 Bean
@Service // 비즈니스 로직 Bean
@Repository // DAO Bean
@Controller // MVC Controller Bean
Java Config 기반
@Configuration
public class AppConfig {
@Bean
public Car car() {
return new Car(engine());
}
@Bean
public Engine engine() {
return new Engine();
}
}
함수를 빈으로 하여서 함수 시그니처(함수명, 반환값) 을 토대로 의존성 주입을 할 수 있다.
ApplicationContext.getBean()
메서드를 통해 들고 올 수 있다.
XML 기반
옛날 방식이며 지금은 잘 안쓰인다.
<bean id="car" class="com.example.Car"/>
<bean id="engine" class="com.example.Engine"/>
Bean Scope
보통 Bean
으로 등록된 객체들은 Singleton
패턴을 따르게 된다. 따라서 @Bean
, @Component
, @Service
등등에는 다음 어노테이션이 포함되어 있다.
@Component
@Scope("singleton") // 생략 가능, 기본값이 singleton
public class Car { }
@Scope
어노테이션은 인자로 다음을 넣을 수 있다.
prototype
: 요청할 때마다 새로운 Bean 생성request
: HTTP 요청 당 하나의 Bean 생성session
: HTTP 세션 당 하나의 Bean 생성application
:ServletContext
범위에서 하나의 Bean 생성websocket
:WebSocket
세션 당 하나의 Bean 생성
ComponentScan
빈만 이렇게 선언해놓고 전부 등록된다면 정말 좋겠지만, Bean 들이 각 파일들로 흩어져 있는 것을 Spring Boot 가 일일히 전부 들어가서 찾아내진 않는다. 우리가 찾을 범위를 지정해주어야 한다. 이를 ComponentScan
어노테이션이 하는 일인데 이는 SpringBootApplication
어노테이션이 가지고 있으므로 그 내부를 파헤쳐보자.
Annotation 정의
자바에서는 특별한 형태의 인터페이스가 있는데 바로 애너테이션이다. 애너테이션은 보통 클래스, 메서드, 필드 등에 대한 부가 정보(메타 데이터)를 제공하고 싶을 때 사용하는 문법이며, 프로그램의 실행 로직에는 직접 영향을 주지 않지만, 컴파일러나 프레임워크가 해석할 때 특별한 동작을 수행하도록 할 수 있다. 즉, 런타임 이전에 특수한 동작을 하여 런타임 때 의도한 동작을 수행하도록 할 수 있다는 것이다.
애너테이션의 선언은 다음과 같다.
// @interface 가 선언 키워드
public @interface MyAnnotation {
String value(); // <- 속성임, 요소라고도 불림
int count() default 1; // default 로 기본값 지정 가능
}
개념을 보자면 애너테이션 정의 내부에 들어가는 함수를 보통 속성 이라고 하며 이 속성은 필드와 유사하여 어노테이션의 소괄호 블록에 들어갈 인자로 사용되게 된다.
여기서 value()
는 무조건 있어야 하며, @MyAnnotation("value 입니다.")
처럼 붙일 수 있다(암묵적 표기). 명시적으로 다음과 같이 선언하는 것도 동일한 결과이다.
@MyAnnotation("value 입니다.") // == @MyAnnotation(value = "value 입니다.")
class Hello {
...
특히 애너테이션을 정의할 때 그 위에 붙이는 자주 쓰이는 메타 애너테이션 4개가 있다.
Meta Annotation
@Target
: 애너테이션을 붙일 수 있는 범위 지정,ElementType
열거형 클래스를 통해 상수 설정ElementType.TYPE
ElementType.FIELD
ElementType.PARAMETER
ElementType.CONSTRUCTOR
ElementType.ANNOTATION_TYPE
ElementType.PACKAGE
- 기본값은 모든 곳 사용 가능
@Retention
: 애너테이션의 생명주기가 어디까지 유지가 되는지RetentionPolicy
설정RetentionPolicy.RUNTIME
RetentionPolicy.CLASS
- 기본값은
CLASS
@Documented
: javadoc 등 문서에 포함되도록 표시- 기본값은 docs 에 안나타나도록
@Inherited
: 이 애너테이션이 서브 클래스에 자동 상속되도록 함- 기본값은 없는 걸로, 상속되지 않는게 기본값
위를 토대로 아래를 읽어보자.
@SpringBootApplication
스프링부트 어플리케이션 애너테이션은 @SpringBootConfiguration
을 가지며, 이 Configuration
은 위에 배웠던 IoC 의 기술 중 한 방법으로 Config 방식임을 볼 수 있다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {
여기서 SpringBootConfiguration
이 있음을 볼 수 있는데, SpringBootConfiguration
은 다시 @Configuration
애너테이션이 붙어있음을 볼 수 있다. 즉, @SpringBootConfiguration
자체가 스프링 설정 클래스로 인식을 하며, 추가로 @SpringBootConfiguration
은 인자로 proxyBeanMethods
를 받을 수 있으며, true
, false
를 가짐을 볼 수 있다.
proxyBeanMethods
- CGLIB 프록시를 통해@Bean
메서드 간 호출 시 싱글톤 보장, true 가 기본값이며, 이때Bean
들은Singleton
이게 됨
또 ComponentScan
이 있음을 볼 수 있다. 이름 그대로 컴포넌트들을 스캔하는 방식을 지정하는 역할을 한다.
ComponentScan 어노테이션 정의
SpringBootApplication
이 쓰는 ComponentScan
어노테이션을 이해하기 전에 ComponentScan
을 파헤쳐보자
@AliasFor("basePackages")
String[] value() default {};
@AliasFor("value")
String[] basePackages() default {};
Class<?>[] basePackageClasses() default {};
Filter[] includeFilters() default {};
Filter[] excludeFilters() default {};
boolean lazyInit() default false;
자주 쓰이는 것들만 모았다. 우선 AliasFor
은 “이 속성은 다른 속성과 의미적으로 동일하다” 라는 의미를 가지는 어노테이션임을 알려준다. 따라서 value=
로 하던 basePackages=
로 선언하던 동일하다(value 이기에 명시적 속성 표기 생략 가능).
-
basePackages
:String[]
을 인자로 받고,package
명들을 넣어주면 해당 패키지들을 스캔 대상으로Component
들을 가져오게 된다. -
basePackageClasses
: 위 기능과 유사하지만 단위를 클래스 별로 가져오게 할 수 있다.@ComponentScan(basePackageClasses = MyApp.class)
, 문자열보다는 컴파일 단계에서 타입 안전성을 보장 받기 때문에 안전하다 -
includeFilters
:ComponentScan
내부에 선언되어져 있는Filter
어노테이션 을 보면 알 수 있다. 예제를 보면서 설명한다.
Filter 어노테이션 정의
@Retention(RetentionPolicy.RUNTIME)
@Target({})
@interface Filter {
FilterType type() default FilterType.ANNOTATION;
Class<?>[] classes() default {};
String[] pattern() default {};
}
위는 필터 가 정의되어 있는 방식을 볼 수 있다.
type
: 필터를 거를 방식에서 조건의 대상에 대한 형태를 정한다.classes
: 그 type 을 가지는 class 를 넣어준다.pattern
: 클래스 이름을 기준으로 정규식(Regex) 매칭, 배열 가능
이제 컴포넌트 스캔을 보자.
@ComponentScan(
includeFilters = @ComponentScan.Filter(
type = FilterType.ANNOTATION,
classes = CustomAnnotation.class
)
)
type
은 필터 방식을 지정하는 옵션인데 아래에 정리해 두었다, classes
로 @CustomAnnotation
이 붙은 클래스도 Bean 등록 대상에 포함하겠다는 의미이다.
FilterType.ANNOTATION
: 지정한 애너테이션이 붙은 경우에만 필터FilterType.ASSIGNABLE_TYPE
: 지정한 클래스 또는 그 자식 클래스만 필터FilterType.REGEX
: 클래스 이름이 정규식과 매칭되는 경우 필터FilterType.ASPECTJ
:AspectJ
라는 표현식에 맞는 필터FilterType.CUSTOM
:TypeFilter
를 구현한 커스텀 필터 클래스 사용
따라서 맨 위 includeFilters
는 CustomAnnotion
이 붙은 클래스만 포함하겠다 라는 의미가 된다.
excludeFilters
: include 와 동일하지만 제외하는 경우다
ComponentScan
은 그냥 스캔만 할 뿐이다. 해당 어노테이션이 붙었다고 하여서 그 클래스가 Bean 으로 등록되는건 아니다. 따라서 다음과 같이 사용한다고 하여서 config 가 bean 으로 등록되진 않는다.
@ComponentScan(basePackages = {"sample"})
public class UserConfig { }
SpringBootApplication 의 ComponentScan
아래 애너테이션을 토대로 SpringApplication
이 컴포넌트를 읽게 된다. 컴포넌트를 읽는 범위는 자기 자신 클래스가 있는 패키지와 그 하위 패키지를 기본적으로 스캔하는 방식이다.
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
CUSTOM
으로 Filter
타입이 지정되었다면, classes
에 오는 클래스들은 전부 TypeFilter
인터페이스를 구현하는 클래스가 와야 한다.
@FunctionalInterface
public interface TypeFilter {
boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory)
throws IOException;
}
타입 필터는 match
를 구현하도록 되어 있다. 이제 SpringBootApplication
에서의 TypeExcludeFilter
를 보자.
public class TypeExcludeFilter implements TypeFilter, BeanFactoryAware {
...(중간 생략)
@Override
public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory)
throws IOException {
if (this.beanFactory instanceof ListableBeanFactory
&& getClass() == TypeExcludeFilter.class) {
for (TypeExcludeFilter delegate : getDelegates()) {
if (delegate.match(metadataReader, metadataReaderFactory)) return true;
}
}
return false;
}
각 클래스에서 필터 조건에 해당하는지 확인하는 매서드인데, true 면 스캔에서 제외되고, false
면 포함되는 형태이다. 인자를 먼저 보자.
metadataReader
: 현재 검사 중인 클래스의 메타데이터(클래스명, 어노테이션 등) 을 읽어들이는 객체metadataReaderFactory
: 다른 클래스 정보를 가져오는 도구
- beanFactory 가
ListableBeanFactory
인지 확인- Bean 목록을 조회할 수 있는 타입인지 체크한다.
- 현재 객체가 정확히
TypeExcludeFilter
클래스인지 확인getClass() == TypeExcludeFilter.class
따라서 기본 TypeExcludeFilter
만 처리하고, 서브 클래스는 여기서 패스하게 된다.
필터도 여러 개 일텐데 TypeExcludeFilter 인 애만 처리하고 그 하위 클래스는 넘어가겠다는 의미이다.
AutoConfigurationExcludeFilter
에 대한 것도 그러면 유추해볼 수 있을 것이다. Spring Boot 에서는 기본적으로 IoC 에 대한 자동적인 Bean 설정이 들어가기에 내부 구현을 따로 읽어서 어떤걸 제외시키고 있는지 보면 될 것이다.
이렇게 SpringBootApplication
이 동작하게 된다.
@Configuration
이제 Configuration
을 이해할 수 있다. Configuration
의 의미는 구성, 설정이다. 구성은 객체 간의 이루어져있는 다이어그램 형태를 의미할 수 있다. Spring 에서의 객체는 Bean 이기 때문에 Bean 간의 이루어져있는 관계들을 말할 수 있다. 따라서 Configuration
애너테이션은 Bean 의 구성 관계들을 전부 아우르는 책임을 가지는 클래스여야 한다. 따라서 @Configuration
과 @Bean
애너테이션을 통해 이 클래스는 configuration 이며, 그 내부에 bean 들을 토대로 구성 설정을 할 것이다 라는 의미이다.
@Configuration
public class AppConfig {
@Bean
public Engine engine() {
return new Engine();
}
@Bean
public Car car() {
// car는 engine Bean을 의존
return new Car(engine());
}
}
따라서 SpringBootApplication
에 SpringBootConfiguration
애너테이션이 들어가 있었음을 알 수 있다.
⭐️ @Configuration 의 CGLIB 프록시 메서드 proxyBeanMethods()
실습을 하면서 느꼈던 의아했던 점을 적어본다. 중요한 내용일 수 있고, IoC 의 핵심 동작 부분을 건드린거 같기도 하다.
@Configuration
public class OrderConfig {
@Bean
// @Scope("prototype")
public Drink coffee() {
return new Coffee();
}
@Bean
// @Scope("prototype")
public Drink tea() {
return new Tea();
}
@Bean
// @Scope("singleton")
public OrderHistory orderHistory() {
return new OrderHistory();
}
@Bean
@Scope("prototype")
public OrderService orderService() {
return new OrderServiceImpl(orderHistory());
}
}
위 코드는 그냥 Config 를 통한 의존성 주입을 위해 bean 을 정의하는 클래스이다. 실습 문제는 다음과 같다.
여러
OrderService
에서 주문해도 주문 내역이 공유되도록 구현하세요
그냥 Scope
를 prototype 으로 만들고 해버리면 되긴 된다. 여기서 궁금했던 점은 만약 @Configuration
을 없앴을 때 어떤 동작을 하는지이다. 실제로 @Configuration
을 없애도 기본적으로 context 를 통해 getBean
을 하면 @Scope("prototype")
이 없는 이상 동일한 bean 을 반환하게 된다. 즉 기능은 아무 이상이 없었다.
하지만, 서비스를 여러개 만들었을때 서비스 간에 가지고 있는 orderHistory
의 주입이 다 다른 객체로 들어가게 되었다. 즉 @Scope("singleton")
이었음에도 불구하고 여러 객체가 생성되어 주입이 된 것이다. 이에 대한 분석으로는 다음과 같다.
우선 Configuration 이 있을때를 보자. @Configuration
이 붙은 클래스는 Spring 이 CGLIB 프록시 를 만들어서 관리한다(중요). 이때 @Bean
메서드 호출이 내부적으로 프록시를 거치므로, 스프링 컨테이너에서 관리되는 싱글톤(@Bean 기본 scope)을 항상 반환하게 된다.
예:
orderService()
에서orderHistory()
를 호출해도, 이미 생성된 singletonOrderHistory
를 주입받음
그래서 @Configuration
이 없는 클래스에서 @Bean
메서드를 호출하면, 단순한 일반 메서드 호출처럼 동작하며, 따라서 orderHistory()
를 호출할 때 새로운 인스턴스가 생성됨. 결과적으로 prototype OrderService
가 생성될 때마다 각기 다른 OrderHistory
를 참조하게 되어, 스레드별로 다른 저장소처럼 동작하는 현상이 발생할 수 있음.
핵심 요약:
@Configuration
+@Bean
→ 프록시가 호출을 가로채고 스코프 규칙 적용 → singleton 보장- 일반 클래스 +
@Bean
→ 프록시 없음 → 단순 메서드 호출 → 매번 새 객체 생성- 매번 새 객체 생성이지만
@Scope
는 적용됨 - ⭐️ 하지만,
getBean
을 사용할 때 가져오는 것은 동일 인스턴스를 가져오게 된다. 이건 getBean 내부에서 일어나는 어떤 과정이 있어서 그런 듯하다.
- 매번 새 객체 생성이지만
- 따라서 singleton 을 보장하고 prototype 빈에서 주입받도록 하려면
@Configuration
을 반드시 사용해야 함
이제 다음으로 @Bean
과 함수 정의 스니펫 사이에 @Scope
를 통해 조금이나마 생명주기를 제어할 수 있다.
@Scope 애너테이션으로 Bean 생명주기와 공유 범위 제어
SpringBootApplication
이 Configuration
을 가지고 ComponentScan
을 통해 Configuration
에 Bean 으로 등록될 각각의 클래스들을 스캔하는 것을 보았다.
이번에는 Bean
의 생명주기를 제어해보자. @Scope
애너테이션은 class 와 method 범위에 사용할 수 있는 애너테이션이며, 다음 value 를 가진다.
Scope | 설명 | 사용 환경 |
---|---|---|
singleton | 컨테이너당 하나의 Bean만 생성 (기본값) | 모든 환경 |
prototype | 요청할 때마다 새 Bean 생성 | 모든 환경 |
request | HTTP 요청당 하나의 Bean 생성 | 웹 애플리케이션 |
session | HTTP 세션당 하나의 Bean 생성 | 웹 애플리케이션 |
application | ServletContext 단위로 하나의 Bean 생성 | 웹 애플리케이션 |
websocket | WebSocket 세션당 하나의 Bean 생성 | 웹 애플리케이션 |
@PostConstruct 와 @PreDestroy 사용하여 생명주기에 대한 로그 찍어보기
- Bean 정의 읽기:
@Component
애너테이션 및@Bean
메서드 등, XML 설정 파일을 읽어 Bean 정의를 스프링이 파악 - Bean 인스턴스 생성: 컨테이너가 실제 객체를 생성
- 의존성 주입: 필요한 의존성 주입:
@Autowired
나 생성자 / 세터를 통하여 차례차례 필요한 다른 Bean 주입 - 초기화:
@PostConstruct
메서드 실행: Bean 이 생성되고 의존성이 주입된 후에 실행 - 사용: 애플리케이션에서 Bean 사용
- 소멸: Bean 이 종료될 때 호출(실질적으로는 IoC 컨테이너가 종료될 때 함께 소멸되기 때문에 IoC 컨테이너가 종료될때 호출)
@Component
public class LifecycleBean {
public LifecycleBean() {
System.out.println("1. Constructor called");
}
@PostConstruct
public void init() {
System.out.println("2. @PostConstruct - Bean initialized");
}
public void doSomething() {
System.out.println("3. Bean is being used");
}
@PreDestroy
public void cleanup() {
System.out.println("4. @PreDestroy - Bean cleanup");
}
}
Properties 를 사용해 키 값을 Config 로 등록하기
Config
는 Bean 을 위한 파일이다. Bean 에 대한 책임만을 가지지 얘가 key-value 와 같이 env 변수나 그런 형태의 값을 저장하는 것은 따로 없다. 이를 하고자 순수 자바 라이브러리는 표준 클래스로 Properties
라는게 있다(java.util.Properties
).
Key-Value
쌍으로 데이터를 저장하는 특수한 Map
이다. 주로 설정 파일(.properties
) 을 읽어오거나 환경 변수 같은 설정을 관리하고 싶을 때 사용한다.
// 마비노기 최고
Properties props = new Properties();
props.setProperty("game.name", "Mabinogi");
props.setProperty("game.level.max", "50");
String name = props.getProperty("game.name"); // "Mabinogi"
프로퍼티는 코드 단에서 뿐 아니라 파일을 자동으로 읽어들여서 사용할 수도 있다.
public class Exam {
public static void main(String[] args) throws IOException {
Properties props = new Properties();
props.load(new FileInputStream("config.properties"));
}
}
이제 application.properties
를 어떻게 읽어들이는지 파악될 것이다.
@Value 로 프로퍼티 주입해보기
Spring Boot 에서는 기본적으로 application.properties
를 자동으로 읽어온다(따로 어디에도 이를 읽는 코드를 찾을 수 없지만, 내부적으로 어딘가에 읽는 코드가 있을 것이다). 이를 Bean 으로 정의된 클래스 내부에 @Value
를 통해 간단하게 읽어들여서 쓸 수 있다.
application.properties 파일
# 서버 설정
server.port=8080
server.servlet.context-path=/api
# DB 설정
spring.datasource.url=jdbc:mysql://localhost:3306/shop
spring.datasource.username=root
spring.datasource.password=1234
# 커스텀 프로퍼티
shop.name=MyCafe
shop.owner=Seonghun
shop.maxCustomers=50
코드
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class ShopInfo {
@Value("${shop.name}")
private String name;
@Value("${shop.owner}")
private String owner;
@Value("${shop.maxCustomers}")
private int maxCustomers;
public void printInfo() {
System.out.println("Shop: " + name);
System.out.println("Owner: " + owner);
System.out.println("Max Customers: " + maxCustomers);
}
}
실무에서는 커스텀 프로퍼티(application.properties
를 제외한 개발자가 서비스를 위해 임의로 만든 프로퍼티들)을 @ConfigurationProperties
로 그룹화하여 설정 주입을 한다.
@ConfigurationProperties 를 사용해 key 를 기준으로 값 들고오기
아래를 보면 알겠지만 ConfigurationProperties
는 key 의 prefix 를 기준으로 쌍을 들고올 수 있다. 이는 전부 필드와 매핑되게 되는데, shop.name
, shop.owner
, shop.maxCustomers
로 매핑됨을 볼 수 있다.
@Component
@ConfigurationProperties(prefix = "shop")
public class ShopProperties {
private String name;
private String owner;
private int maxCustomers;
// getter / setter 필수
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getOwner() { return owner; }
public void setOwner(String owner) { this.owner = owner; }
public int getMaxCustomers() { return maxCustomers; }
public void setMaxCustomers(int maxCustomers) { this.maxCustomers = maxCustomers; }
}
// ============================
@Service
public class ShopService {
private final ShopProperties shopProperties;
public ShopService(ShopProperties shopProperties) {
this.shopProperties = shopProperties;
}
public void info() {
System.out.println("Shop: " + shopProperties.getName());
System.out.println("Owner: " + shopProperties.getOwner());
System.out.println("Max Customers: " + shopProperties.getMaxCustomers());
}
}
이렇게 가져간다면 컴파일 수준에서 타입 안전성을 가져갈 수 있어 실무에서 자주 사용한다.
@PropertySource
라는 것도 있는데, 굳이 싶다.application.properties
혹은application.yml
에 key-value 를 체계적으로 써 놓고, 그걸 가져오는 편이 더 좋을 듯하다. 개발자 마음대로 사용하면 되는 듯하다. 하지만 만약 Spring 이 자동으로 로드하지 않는 별도의properties
나yml
파일을 읽어들이고 싶을때 사용할 수 있을거 같다. 또한Property
를 따로 클래스를 두어 가져간다면 Bean 으로 등록하여 POJO 를 Bean 으로 연결시킬 수도 있을거 같다.
DI 시 주의점
기본적으로 다음 규칙이 있다.
- 생성자 주입은 생성자 하나에 대해 주입 동작이 실행된다. 만약 2개가 있다면 default 생성자를 기준으로 한다(인자가 없는).
@Autowired
를 생성자에 명시하면, 여러 생성자 중 어디에 주입할지 명확히 지정 가능하다.- 세터 주입은 선택적 의존성(optional) 주입에 유용하며, 순환 의존성이 있는 경우 유리하다.
순환 의존성(Circular Dependency): 두 개 이상의 Bean 이 서로를 참조하는 경우 생성자 주입에서는 에러가 발생할 수 있으므로 세터 주입이나
@Lazy
옵션을 고려해야 함
@Lazy
Bean 의 초기화 시점을 늦춰 순환 참조를 막고자 할 때 사용하는 어노테이션이다. Bean 의 생성을 실제 사용 시점때 생성해달라고 스프링에게 요청하는 것이다.
대상
- 무거운 초기화 작업이 필요한 Bean
- 순환 의존성을 피하고 싶은 Bean
클래스 레벨에서 사용
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
@Component
@Lazy
public class HeavyBean {
public HeavyBean() {
System.out.println("HeavyBean 생성됨!");
}
}
의존성 주입에 Lazy 사용
@Component
public class UserService {
private final HeavyBean heavyBean;
public UserService(@Lazy HeavyBean heavyBean) {
this.heavyBean = heavyBean;
}
}
Bean 에 사용
@Configuration
public class AppConfig {
@Bean
@Lazy
public HeavyBean heavyBean() {
return new HeavyBean();
}
}
에러 발생
@Component
public class A {
private final B b;
@Autowired
public A(B b) {
this.b = b;
}
}
@Component
public class B {
private final A a;
@Autowired
public B(A a) {
this.a = a;
}
}
세터로 해결
해결 방법: Spring 이 Bean 인스턴스를 생성(생성자 실행) -> 세터를 통해 서로 주입
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class A {
private B b;
@Autowired
public void setB(B b) {
this.b = b;
}
}
@Component
public class B {
private A a;
@Autowired
public void setA(A a) {
this.a = a;
}
}
Lazy 를 통해 해결
한쪽만 붙이면 된다.
@Component
public class A {
private final B b;
@Autowired
public A(@Lazy B b) {
this.b = b;
}
}
@Component
public class B {
private final A a;
@Autowired
public B(A a) {
this.a = a;
}
}
장점
- 어플리케이션 시작 속도 향상
- 순환 의존성 해결
- 메모리 최적화
주의점
- 싱글톤 Bean 과 잘 맞지만, 프로토타입 Bean 에는 사용 안함
Spring Application 의 실행 과정 중 실행 코드 넣기
가끔 어플리케이션이 실행되는 도중에 특정 시점에 특정 코드를 실행시키고 싶을 수 있을 것이다. 이를 위해 Spring Application 의 실행과정을 먼저 살펴보자.
Spring Application
순수 스프링은 보통 AnnotationConfigApplicationContext
(컨피그를 넣어서 사용) 나 ClassPathXmlApplicationContext
(xml 파일 넣어서 사용) 를 통해 IoC 를 직접 띄우게 된다.
실행 순서는 다음과 같다:
순수 Spring
SpringApplication
실행(SpringAppication.run()
)ClassPathXmlApplicationContext
또는AnnotationConfigApplicationContext
생성BeanFactory
생성
Bean Definition
메타데이터 읽기- XML 파싱 또는 Java Config 클래스 스캔
@Component
,@Service
,@Repository
등 어노테이션 스캔- Bean 메타데이터를
BeanDefinition
객체로 변환
- Bean 생성 및 의존성 주입
BeanFactory
에서 Bean 인스턴스 생성- 생성자 주입, 세터 주입, 필드 주입 처리
BeanPostProcessor
실행
- 초기화 콜백
@PostConstruct
메서드 실행InitializingBean.afterPropertiesSet()
실행init-method
실행
Spring Boot Application
SpringApplication
실행SpringApplication.run(MyApplication.class, args)
호출- 실행 환경(Web, Reactive, None) 결정
ApplicationContextInitializer
로 초기화 인스턴스 로드ApplicationListener
로 이벤트 리스너 로드- 메인 애플리케이션 클래스 결정
Environment
준비application.properties
/application.yml
로딩- 활성 프로파일(
Profile
) 적용 - 커맨드라인 인자, 시스템 프로퍼티, 환경 변수 바인딩
PropertySource
우선순위 적용
ApplicationContext
생성- 웹 애플리케이션:
AnnotationConfigServletWebServerApplicationContext
- 리액티브:
AnnotationConfigReactiveWebServerApplicationContext
- 일반(non-web):
AnnotationConfigApplicationContext
- 웹 애플리케이션:
- Auto-Configuration 적용
@EnableAutoConfiguration
처리spring.factories
에서 Auto-Configuration 클래스 로드- 조건부 빈 등록:
@ConditionalOnClass
: 특정 클래스 존재 여부@ConditionalOnMissingBean
: 특정 빈 부재 여부@ConditionalOnProperty
: 프로퍼티 값 존재 여부
ComponentScan
@SpringBootApplication
위치 기준 하위 패키지 스캔@Component
,@Service
,@Repository
,@Controller
등 감지
- Bean 생성 및 의존성 주입
- 일반 Spring과 동일
- Auto-configured Bean들이 먼저 등록
- 생성자 주입, 세터 주입, 필드 주입 수행
BeanPostProcessor
실행
- 내장 웹 서버 시작
ServletWebServerFactory
빈을 통해 Tomcat/Jetty/Undertow 시작- 기본 포트 8080,
server.port
프로퍼티로 변경 가능
CommandLineRunner
/ApplicationRunner
실행- 모든 Bean 초기화 완료 후 실행
- 애플리케이션 시작 직후 초기화 로직 수행 가능
@Component
public class MyRunner implements CommandLineRunner {
@Override
public void run(String... args) throws Exception {
System.out.println("애플리케이션 시작 후 실행되는 코드");
}
}
이 흐름을 가지고 다음을 읽자.
CommandLineRunner
Spring Boot 어플리케이션이 완전히 실행된 직후(의존서 주입이 끝난 뒤) 자동으로 실행되는 초기화용 인터페이스이며, @FunctionalInterface
이다.
@FunctionalInterface
public interface CommandLineRunner {
void run(String... args) throws Exception;
}
- Spring Boot 가 실행될 때,
SpringApplication.run()
이 끝난 뒤에 모든CommandLineRunner
Bean 들의run()
메서드를 자동으로 호출한다. @Order
을 통해 실행 순서도 지정할 수 있다.
@Component
@RequiredArgsConstructor
public class StartupRunner implements CommandLineRunner {
private final RedisTemplate<String, Object> redisTemplate;
private final KafkaTemplate<String, String> kafkaTemplate;
@Override
public void run(String... args) throws Exception {
// 1. Redis 연결 확인
try {
redisTemplate.opsForValue().set("health:check", "ok");
System.out.println("✓ Redis 연결 성공");
} catch (Exception e) {
System.err.println("✗ Redis 연결 실패");
}
// 2. Kafka 연결 확인
try {
kafkaTemplate.send("health-check", "ping");
System.out.println("✓ Kafka 연결 성공");
} catch (Exception e) {
System.err.println("✗ Kafka 연결 실패");
}
// 3. 초기 데이터 로딩
loadInitialData();
}
private void loadInitialData() {
System.out.println("초기 데이터 로딩 중...");
}
}
기본적으로
CommandLineRunner
는 Bean 으로 등록되어야 실행이 되게 된다.
⭐️ Spring Event System
Spring의 이벤트 시스템은 Publisher-Subscriber 패턴을 구현한다. 이는 이벤트 기반 통신 구조를 구현하는 디자인 패턴이며, 역할은 다음과 같다.
- Publisher(발행자): 이벤트를 발생시키는 주체
- Subscriber(구독자): 이벤트가 발생하면 이를 감지하고 처리하는 주체들
즉 이벤트라는 매개체로만 소통하는 구조이다.
장점
- 느슨한 결합
- 코드의 유연성
- 이벤트 발생 시 여러 구독자가 동시에 반응 가능
이벤트 정의
// 1. 이벤트 클래스 정의 (POJO)
public class UserRegisteredEvent {
private final String email;
private final String username;
private final LocalDateTime registeredAt;
public UserRegisteredEvent(String email, String username) {
this.email = email;
this.username = username;
this.registeredAt = LocalDateTime.now();
}
// Getters
public String getEmail() { return email; }
public String getUsername() { return username; }
public LocalDateTime getRegisteredAt() { return registeredAt; }
}
Publisher
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;
public User registerUser(String email, String username) {
// 1. 비즈니스 로직 실행
User user = new User(email, username);
userRepository.save(user);
// 2. 이벤트 발행
UserRegisteredEvent event = new UserRegisteredEvent(email, username);
eventPublisher.publishEvent(event);
return user;
}
}
Subscriber
구독자의 구현은 다양하다. 선호에 따라 사용하자.
@Component
@Slf4j
public class UserEventListener {
@Autowired
private EmailService emailService;
// 회원가입 시 환영 이메일 발송
@EventListener
public void handleUserRegistered(UserRegisteredEvent event) {
log.info("새로운 사용자 등록: {}", event.getUsername());
emailService.sendWelcomeEmail(event.getEmail());
}
// 조건부 리스너
@EventListener(condition = "#event.email.endsWith('@company.com')")
public void handleCompanyUserRegistered(UserRegisteredEvent event) {
log.info("회사 이메일로 등록: {}", event.getEmail());
// 특별한 처리
}
}
@Component
public class LegacyUserEventListener
implements ApplicationListener<UserRegisteredEvent> {
@Override
public void onApplicationEvent(UserRegisteredEvent event) {
System.out.println("레거시 방식 리스너: " + event.getUsername());
}
}
Asynchronized Event 로 구성하기
이벤트의 동작이 너무 무겁거나 오래 걸리는 작업이면 원래 작업이 지연되기 때문에 비동기로 처리할 수 있다.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("event-");
executor.initialize();
return executor;
}
}
@EnableAsync
는 Spring 에서 비동기 메서드를 실행을 활성화시키는 어노테이션인데, 쉽게 말하여 특정 메서드를 별도의 스레드에서 동시에 실행하도록 허용하는 기능이다. 이렇게 해두고 비동기 메서드에 @Async
만 붙이면 간단하게 비동기로 실행이 가능하게 된다. 우선 실행할 스레드 풀을 커스터마이징하여 선언해놓고, Subscriber
를 구현하면 된다.
@Component
@Slf4j
public class AsyncEventListener {
// 비동기로 처리 - 메인 스레드 블로킹 없음
@Async
@EventListener
public void handleUserRegisteredAsync(UserRegisteredEvent event) {
log.info("비동기 이벤트 처리 시작: {}", Thread.currentThread().getName());
// 시간이 오래 걸리는 작업
try {
Thread.sleep(3000);
sendSlackNotification(event);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
log.info("비동기 이벤트 처리 완료");
}
private void sendSlackNotification(UserRegisteredEvent event) {
// Slack 알림 발송
}
}
Transaction Event Listener
스프링은 트랜잭션 별로 세세한 컨트롤이 가능하도록 각 생명주기 사이사이에 코드를 실행할 수 있는 애너테이션들을 제공한다.
@Component
@Slf4j
public class TransactionalEventListener {
// 트랜잭션 커밋 후에만 실행
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleAfterCommit(UserRegisteredEvent event) {
log.info("트랜잭션 커밋 후 실행: {}", event.getUsername());
// 외부 API 호출, 메시지 큐 발송 등
}
// 트랜잭션 롤백 시 실행
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleAfterRollback(UserRegisteredEvent event) {
log.error("트랜잭션 롤백됨: {}", event.getUsername());
// 롤백 처리
}
// 트랜잭션 완료 후 실행 (커밋/롤백 상관없이)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void handleAfterCompletion(UserRegisteredEvent event) {
log.info("트랜잭션 완료: {}", event.getUsername());
}
// 트랜잭션 시작 전 실행
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleBeforeCommit(UserRegisteredEvent event) {
log.info("트랜잭션 커밋 직전: {}", event.getUsername());
}
}
활용 예시
// 이벤트 정의
public class OrderCreatedEvent {
private final Long orderId;
private final Long userId;
private final BigDecimal amount;
private final LocalDateTime createdAt;
public OrderCreatedEvent(Long orderId, Long userId, BigDecimal amount) {
this.orderId = orderId;
this.userId = userId;
this.amount = amount;
this.createdAt = LocalDateTime.now();
}
// Getters...
}
// 서비스에서 이벤트 발행
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
public Order createOrder(OrderRequest request) {
// 주문 생성
Order order = Order.builder()
.userId(request.getUserId())
.amount(request.getAmount())
.status(OrderStatus.PENDING)
.build();
orderRepository.save(order);
// 이벤트 발행
eventPublisher.publishEvent(
new OrderCreatedEvent(order.getId(), order.getUserId(), order.getAmount())
);
return order;
}
}
// 다양한 리스너들
@Component
@Slf4j
@RequiredArgsConstructor
public class OrderEventHandlers {
private final EmailService emailService;
private final InventoryService inventoryService;
private final PaymentService paymentService;
private final SlackService slackService;
// 1. 재고 차감 (동기)
@EventListener
public void handleInventoryDeduction(OrderCreatedEvent event) {
log.info("재고 차감 시작: Order {}", event.getOrderId());
inventoryService.deductInventory(event.getOrderId());
}
// 2. 결제 처리 (트랜잭션 커밋 후)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePayment(OrderCreatedEvent event) {
log.info("결제 처리 시작: Order {}", event.getOrderId());
paymentService.processPayment(event.getOrderId(), event.getAmount());
}
// 3. 이메일 발송 (비동기)
@Async
@EventListener
public void sendOrderConfirmationEmail(OrderCreatedEvent event) {
log.info("주문 확인 이메일 발송: Order {}", event.getOrderId());
emailService.sendOrderConfirmation(event.getUserId(), event.getOrderId());
}
// 4. Slack 알림 (비동기, 고액 주문만)
@Async
@EventListener(condition = "#event.amount.compareTo(new java.math.BigDecimal('1000000')) > 0")
public void notifyLargeOrder(OrderCreatedEvent event) {
log.info("고액 주문 알림: Order {} - {}", event.getOrderId(), event.getAmount());
slackService.sendMessage("고액 주문 발생: " + event.getAmount() + "원");
}
}
Spring 내장 EventListener
@Component
@Slf4j
public class ApplicationEventListener {
// 1. 애플리케이션 시작 중
@EventListener
public void handleContextRefreshed(ContextRefreshedEvent event) {
log.info("1. ApplicationContext가 초기화되거나 리프레시됨");
}
// 2. 애플리케이션 준비 완료
@EventListener
public void handleApplicationReady(ApplicationReadyEvent event) {
log.info("2. 애플리케이션이 요청을 처리할 준비가 됨");
// 헬스체크, 외부 시스템 연결 등
}
// 3. 애플리케이션 시작 완료
@EventListener
public void handleApplicationStarted(ApplicationStartedEvent event) {
log.info("3. 애플리케이션이 시작됨 (리스너 호출 전)");
}
// 4. 애플리케이션 종료 시작
@EventListener
public void handleContextClosing(ContextClosedEvent event) {
log.info("4. ApplicationContext가 닫히는 중");
// 리소스 정리, 연결 종료 등
}
}
✒️ 용어
CGLIB 프록시
Spring에서 실제 클래스 자체를 상속받아 동적으로 프록시 객체를 생성하는 기술을 의미한다.
Spring AOP
,@Configuration(proxyBeanMethods = true)
등에서 사용- 클래스 기반 프록시이기 때문에 인터페이스가 없어도 프록시를 만들 수 있음
- Spring이 대상 클래스(예: AppConfig)를 상속한 서브클래스를 런타임에 생성
- Bean 메서드 호출 시 원래 객체가 아닌 프록시 객체를 통해 호출
- 프록시가 메서드 호출을 가로채어 싱글톤 보장, AOP 적용 등 추가 기능 수행
프록시 객체를 호출한다는 점이 핵심, proxyBeanMethods = true → CGLIB 프록시를 통해
Bean 메서드 간
호출 시 동일한 싱글톤 인스턴스 반환
제일 중요한 것은 ⭐️ Bean 메서드 간 호출 시 동일한 싱글톤 인스턴스 반환 의 문장이다.