스프링 프레임워크의 구성 요소 중 대표적인 3가지는 IoC/DI, AOP, PSA 입니다. 이 중에서도 IoC/DI는 스프링의 근간을 이루는 핵심 요소이며 스프링 어플리케이션의 설계와 구조를 이해하는데 필수적입니다. 그러나 제대로 이해하지 못할 경우 개념을 헷갈리기 쉽습니다. 저 또한 단순히 "객체의 생성을 개발자가 아니라 프레임워크가 대신 해주는 것"이라고 이해했었습니다. 스프링을 사용해 개발한다면 IoC/DI를 제대로 이해해야만 깊이 있는 성장이 가능합니다. 이 글을 통해 IoC와 DI에 대해 오해를 바로잡고 제대로 이해할 수 있기를 기대합니다.
IoC는 Inversion of Control, 즉 제어의 역전을 의미합니다. 제어의 역전이란 프로그램의 제어 흐름을 외부 프레임워크나 컨테이너가 관리하도록 하는 소프트웨어 설계 원칙입니다. 이것은 스프링 같은 특정 프레임워크에 국한된 개념이 아니라 더 추상적인 개념입니다. IoC를 통해 추구하는 목표는 어플리케이션의 주요 제어 로직을 외부에 위임함으로써 개발자가 비즈니스 로직에 더 집중할 수 있게 하는 것입니다. 외부에 위임한 제어 로직에는 객체의 생성, 생명주기 관리, 의존성 관리 등이 있습니다. IoC를 구체적으로 구현하는 방법 중에는 DI(Dependency Injection), 의존성 주입 방식이 있습니다.
DI는 Dependency Injection, 의존성 주입이라는 뜻입니다. 의존성 주입은 IoC를 구현하는 주요 방법 중 하나로, 객체가 필요로 하는 의존성을 외부에서 주입받도록 하는 디자인 패턴입니다. 이를 통해 객체 간의 결합도를 낮추고, 코드의 재사용성과 테스트 용이성을 높일 수 있습니다. DI를 통해 의존성을 주입하는 방법에는 생성자 주입, 수정자 주입, 필드 주입 등이 있습니다.
스프링에서 IoC는 객체의 생성, 생명주기, 의존성 관리 등의 작업을 스프링 IoC 컨테이너가 담당하는 것을 의미합니다. XML 설정 또는 Java 코드에서 @Component 어노테이션 등 여러가지 방법을 통해 스프링에서 관리하는 객체로 등록할 수 있습니다. 스프링 프레임워크를 사용한 프로젝트에서 사용되는 모든 객체가 스프링 IoC 컨테이너에 의해 관리되는 것은 아니라는 것도 기억해야합니다. 만약 앞에서 설명한 방법 중 하나를 이용해서 IoC 컨테이너가 관리하는 객체로 등록했다면, 이렇게 등록한 객체를 ‘빈(스프링 빈)' 이라고 부릅니다.
스프링 IoC 컨테이너라는 개념은 스프링 프레임워크의 ApplicationContext 인터페이스를 통해 구현할 수 있습니다. ApplicationContext를 [스프링 컨테이너, 스프링 IoC 컨테이너, IoC 컨테이너] 등으로 부르기도 합니다. 스프링 IoC 컨테이너(ApplicationContext)는 다음 기능들을 제공합니다.
스프링 프레임워크를 공부할 때 스프링 빈, 자바 빈, POJO라는 개념이 등장합니다. 각 개념에 따라 구현하는 방법에 차이가 있으므로 기억할 필요가 있습니다.
위 개념에 따라 POJO는 자바 빈이 될 수 있고, 스프링 빈이 될 수도 있습니다. 단순히 기본적인 자바 객체인 것입니다. 자바 빈도 어노테이션을 통해 스프링 빈이 될 수 있고, 스프링 빈이 규칙을 만족하면 자바 빈이 될 수도 있습니다. 그리고 스프링에서 흔히 '빈' 이라고 부르는 것은 '스프링 빈' 이라는 것도 알아두어야 합니다.
컴포넌트 스캔은 특정 패키지 내의 클래스들을 스캔하여 @Component, @Service, @Repository, @Controller 등의 어노테이션이 붙은 클래스를 자동으로 빈으로 등록하는 방법입니다. 이러한 어노테이션들을 스테레오 타입 어노테이션이라고 부르며, 컴포넌트 스캔 방식이 가장 많이 사용됩니다.
@Controller
public class UserController {
}
@Service
public class UserService {
}
@Repository
public class UserRepository {
}
자바 코드를 사용하여 직접 빈을 정의하고 등록하는 방법입니다. @Configuration 어노테이션을 클래스에 붙이고 @Bean 어노테이션을 메서드에 붙여서 사용합니다. 이 방법은 빈 간의 의존관계를 명시적으로 표현할 수 있어서 복잡한 의존관계를 가진 빈을 설정할 때 유용합니다. 컴포넌트 스캔을 통한 빈 등록의 경우 같은 이름의 빈이 있을 때 @Qualifier 어노테이션을 통해 특정 클래스에 의존하게되는 문제가 있는데, 같은 Repository interface를 구현하는 InMemoryDBRepository 클래스에서 MySQLRepository 클래스로 등록할 빈을 변경하는 과정에서 이런 문제가 일어날 수 있습니다. 이런 상황에서 자바 기반 설정 방식을 사용하면 @Profile 어노테이션을 통해 환경에 따라 다른 구현체를 사용하거나, 테스트 시 설정을 쉽게 오버라이드 하거나 모의 객체로 대체할 수 있습니다.
@Configuration
public class AppConfig {
@Bean
public UserService userService() {
return new UserService(userRepository());
}
@Bean
public UserRepository userRepository() {
return new UserRepository();
}
}
XML을 통한 의존성 주입은 자바 코드를 수정하지 않고도 빈 설정을 변경할 수 있는 장점이 있지만, IDE의 자동완성 지원을 받기 어렵다는 단점이 있습니다. 최근에는 XML을 통한 방법보다 컴포넌트 스캔과 자바 기반 설정 방식을 조합하여 사용하는 방법이 주로 선택되고 있습니다.
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="userRepository" class="com.example.UserRepository" />
<bean id="userService" class="com.example.UserService">
<constructor-arg ref="userRepository" />
</bean>
</beans>
생성자를 통해 의존성을 주입받는 방식으로, 불변성을 보장하고 테스트가 용이하다는 장점이 있습니다. 가장 권장되는 방식입니다.
@Service
public class UserService {
private final UserRepository userRepository;
@Autowired // 생성자가 하나일 경우 @Autowired 생략 가능
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
런타임에 의존성을 변경할 수 있어서 유연성을 제공하는 방식입니다. 하지만 최근에는 한번 주입한 의존성을 런타임에 변경할 필요성이 없어서 잘 사용되지 않는 방식입니다.
@Service
public class UserService {
private UserRepository userRepository;
@Autowired
public void setUserRepository(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
필드에 직접 @Autowired 어노테이션을 사용해 의존성을 주입받는 방식입니다. 코드가 간결하지만, 테스트 시 의존성 주입이 어려울 수 있기 때문에 권장되지 않는 방식입니다.
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
}
XML 설정 파일을 통해 의존성을 주입하는 방식입니다. 자바 코드를 수정하지 않고도 의존성을 변경할 수 있는 장점이 있지만, 컴파일 타임에 타입 검사를 할 수 없고, 가독성이 떨어질 수 있는 단점이 있습니다. 최근에는 자바 코드를 통한 생성자 주입 방식이 가장 선호되고 있습니다.
<bean id="userRepository" class="com.example.UserRepository" />
<bean id="userService" class="com.example.UserService">
<constructor-arg ref="userRepository" /> <!-- 생성자 주입 -->
</bean>
<!-- 또는 -->
<bean id="userService" class="com.example.UserService">
<property name="userRepository" ref="userRepository" /> <!-- 수정자 주입 -->
</bean>
스프링 프레임워크에서 빈은 어플리케이션의 핵심을 이루는 객체입니다. 이 빈들의 생명주기와 사용 범위를 정하는 것을 빈 스코프라고 합니다. 스프링은 다양한 빈 스코프를 제공하여 개발자가 어플리케이션의 요구사항에 맞게 빈의 인스턴스를 관리할 수 있게 해줍니다. 싱글톤 스코프를 제외한 나머지 스코프는 특정한 조건에 따라 인스턴스가 새로 생성되기 때문에 메모리 사용량에 주의해야한다는 공통 주의사항이 존재합니다. 어플리케이션 요구사항에 따라 적절한 스코프를 적절하게 활용하는 것이 중요합니다.
특별한 설정을 하지 않으면 스프링의 빈은 기본적으로 싱글톤 스코프로 관리되며, 이는 스프링 IoC 컨테이너(ApplicationContext)내에서 하나의 인스턴스만 유지됨을 의미합니다. 가장 흔히 사용되는 스코프이고, 메모리 사용을 최소화하고 여러 스레드에서 동시에 같은 인스턴스를 사용할 수 있어서 스레드 안전성을 보장합니다. 하지만 스레드 안정성으로 인해 개별적인 상태를 가져서는 안됩니다.
빈을 요청할 때마다 새로운 인스턴스가 생성됩니다. 스프링 IoC 컨테이너는 생성과 의존관계 주입, 초기화까지만 관여하고 빈이 초기화된 후에는 스프링 IoC 컨테이너의 제어를 받지 않습니다. 따라서 상태를 가질 수 있지만 빈이 자주 생성되면 메모리 사용량이 증가할 수 있고, 프로토타입 빈이 싱글톤 빈에 주입되면 싱글톤처럼 동작할 수 있는 점을 주의해야합니다.
HTTP 요청마다 새로운 인스턴스가 생성됩니다. 이후 요청이 끝나면 소멸됩니다. 각 요청마다 독립적인 상태를 가질 수 있기 때문에 요청 정보를 포함하는 빈을 만들 때 유용합니다.
HTTP 세션마다 새로운 인스턴스가 생성됩니다. 이후 세션이 종료되면 소멸됩니다. 사용자별로 독립적인 상태를 가질 수 있기 때문에 로그인 정보같은 사용자별 데이터를 관리할 때 유용합니다.
@PostConstruct 어노테이션을 메서드에 달아놓으면 빈 오브젝트가 생성되고 DI 작업까지 마친 다음에 처리할 초기화 작업을 수행합니다.
@Component
public class MyBean {
@PostConstruct // 의존성 주입 완료 후 실행
public void init() {
System.out.println("MyBean is initialized");
}
@PreDestroy 어노테이션을 메서드에 달아놓으면 컨테이너가 종료될 때 호출돼서 빈이 사용한 리소스를 반환하거나 종료 전에 처리해야 할 소멸자 작업을 수행합니다.
@Component
public class MyBean {
@PreDestroy // 컨테이너 종료될 때 실행
public void cleanup() {
System.out.println("MyBean is being destroyed");
}
스프링 IoC 컨테이너가 객체 간의 관계를 관리하므로, 각 계층에서는 SRP를 지킬 수 있게 됩니다. 서비스 계층에서는 비즈니스 로직에만 집중하고, 어느 레포지토리가 주입되는 지는 컨테이너가 관리하는 것입니다. 이러한 특성 덕분에 InMemoryDB에서 MySQL로 교체하거나 MSSQL로 교체하는 동안 서비스 계층은 코드의 변경이 필요없습니다. 또한 이렇게 의존성을 외부에서 주입할 수 있기 때문에 테스트가 용이합니다. 외부 인프라 환경과 독립적으로 Mock, Stub 같은 테스트더블을 통해 비즈니스 로직만 집중해서 테스트할 수 있습니다. 그리고 스프링의 또다른 주요 요소인 AOP를 구현할 수 있게 도와주므로 로깅, 트랜잭션 관리, 보안 등 횡단 관심사를 비즈니스 로직으로부터 관심사를 분리할 수 있습니다.
빈의 관리를 외부에 위임하는 것이기 때문에 이러한 설정에 대해 반드시 학습해야합니다. 설정 방법이 XML, 자바 설정, 어노테이션 등 다양하기도 하고 스테레오 타입 어노테이션을 활용하는 것도 팀 내 컨벤션이 필요할 수 있습니다. 많은 설정이 추상화되어 편리하게 사용할 수 있는 만큼, 잘못된 사용으로 인해 오히려 관리하기 힘들어지고 성능이 저하될 가능성도 존재합니다. 그리고 기존 레거시 시스템과의 통합이 어려울 수 있기 때문에 프레임워크 사용에 대해 팀 상황에 맞게 선택할 필요가 있습니다.
스프링 프레임워크는 IoC/DI 방식을 통해 강력한 기능을 제공한다. 하지만 처음 접했을 때는 기본 개념을 정확하게 이해하는 것조차 힘들었기 때문에 러닝커브가 꽤 존재한다고 느꼈다. 그럼에도 불구하고 POJO 기반으로 IoC/DI를 통한 객체 관리를 위임하는 것은 정말 중요한 비즈니스 로직에 집중하는 것에 매우 중요한 도움을 준다. 스프링 프레임워크는
low coupling, high cohesion을 자연스럽게 유지할 수 있도록 도와주는 프레임워크라고 생각한다. 프레임워크가 주는 장점을 최대화하고 단점을 최소화 하기 위해 지속적으로 학습하고, 베스트 프렉티스를 적용하는 연습을 계속 해야겠다.