* 이 포스팅은 부산대학교 2023 백엔드 미니 부트캠프 2주차 학습 내용을 정리한 글입니다.
스프링을 공부하다보면 @Autowired, @Repository와 같은 어노테이션을 많이 사용하게 된다. 이런 어노테이션들은 의존성 주입(Dependency Injection, DI)과 제어의 역전(Inversion of Control, IoC)이라는 스프링의 핵심 개념과 밀접하게 연관되어 있다. 개발자는 이러한 어노테이션을 사용하여 비즈니스 로직에 더 집중할 수 있으며, 프레임워크가 무거운 설정 작업을 대신 처리하게 된다.
스프링의 IoC 컨테이너는 애플리케이션의 생명주기와 객체 관리를 제어한다. 이로 인해, 개발자는 객체 생성과 서비스 연결 같은 복잡한 과정을 고려하지 않아도 된다. 스프링 부트는 이러한 DI와 IoC의 원리를 사용하여 애플리케이션을 빠르고 쉽게 개발할 수 있도록 도와준다. 결과적으로 스프링 부트는 애플리케이션 개발의 초기 설정을 간소화한다.
CS에서 말하는 의존성은 한 요소(혹은 객체)가 다른 요소 없이는 제대로 기능을 수행하지 못할 때, 두 요소 사이 관계를 말한다. 객체 지향 프로그래밍에서 class A
가 class B
의 메소드, 데이터를 사용해야 할 때, 'class A
는 class B
에 의존한다.'라고 할 수 있다.
이런 종속 관계에는 유지보수, 확장성에 문제가 있다. class B
의 변화가 class A
에 영향을 미친다는 점이다. 이런 의존성 문제를 해결하기 위해 DI(Dependency Injection, 의존성 주입) 개념이 등장했다.
DI는 객체를 외부에서 생성해 필요한 클래스에 주입하는 패턴이다. 객체를 필요로 하는 클래스는 의존하는 객체의 생성 방법, 생명 주기에 상관없이 DI할 객체를 사용할 수 있다. 이를 통해 모듈 간 결합도를 낮출 수 있다. 결합도가 낮은 모듈은 독립적으로 변경하고 비교적 쉽게 업데이트가 가능하다. 테스트에도 용이해 코드의 재사용성을 높인다.
예를 들어, Car
클래스가 Engine
인터페이스에 의존한다고 가정하자. DI 없이 해당 내용을 코드로 구현하면 다음과 같다.
public class Car {
private Engine engine;
public Car() {
this.engine = new GasolineEngine(); // GasolineEngine에 대한 의존성
}
public void start() {
engine.start();
}
}
DI를 적용하면 Car
클래스가 더 이상 Engine
의 구현체를 직접 생성하지 않는다. 그러면 Engine
구현이 어떻게 되더라고 주입받을 수 있는 것이다. 따라서 Car
클래스는 Engine
의 변경에 유연해진다.
public class Car {
private Engine engine;
public Car(Engine engine) { // 생성자를 통한 의존성 주입
this.engine = engine;
}
public void start() {
engine.start();
}
}
하지만 생성자를 통해 DI를 수행하면 단점이 있다.
public class Car {
private Engine engine;
private Wheel wheel;
private Seat seat;
// 기타 다른 의존성들...
public Car(Engine engine, Wheel wheel, Seat seat /* 기타 다른 의존성들 */) {
this.engine = engine;
this.wheel = wheel;
this.seat = seat;
// 기타 다른 의존성들의 초기화...
}
public void start() {
engine.start();
// 기타 기능들...
}
}
@Autowired
어노테이션스프링 프레임워크에선 @Autowired
어노테이션을 사용하여 자동으로 의존성을 주입할 수 있도록 지원한다. 추후에 언급할 스프링 IoC 컨테이너는 객체 생성과 의존성 관리를 담당해 클래스는 자신이 의존하는 객체에 대한 책임이 덜어진다. 이때 IoC 컨테이너가 의존성 관리를 담당할 클래스에는 @Component
가, @Component
가 붙은 클래스 즉, 의존 객체에는 @Autowired
가 붙는다.
@Component
public class Car {
private Engine engine;
@Autowired
public Car(Engine engine) { // 스프링 IoC 컨테이너가 의존성 주입
this.engine = engine;
}
public void start() {
engine.start();
}
}
@RequiredArgsConstructor
어노테이션Lombok 라이브러리의 @RequiredArgsConstructor
를 사용하면, 클래스 내 모든 final
필드와 @NotNull
어노테이션이 붙은 필드에 대해 생성자를 자동으로 생성해준다. 기존 Java에서는 final
필드가 초기화가 되지 않았을 경우, 해당 필드를 호출하면 오류가 발생한다. @RequiredArgsConstructor
로 final
, @NotNull
필드의 생성자 주입 코드(new
로 생성하는 객체)를 줄이고, 해당 클래스 생성자 코드를 생략할 수 있다.
@Component
@RequiredArgsConstructor
public class Car {
private final Engine engine;
public void start() {
engine.start();
}
}
@RequiredArgsConstructor, @Autowired
은 마스터 키인가?@Autowired
어노테이션의 단점@Autowired
를 사용하면 의존성이 클래스 내부에 감춰진다. 외부에서 클래스를 검토할 때, 주입되는 객체의 출처를 한 눈에 파악하기 어렵다. 의존성이 컨테이너에 의해 자동으로 주입되기 때문이다.
@Component
public class Car {
@Autowired
private Engine engine;
public void start() {
if (engine == null) {
throw new IllegalStateException("엔진 없음!");
}
engine.start();
}
}
테스트 관점에서, 생성자나 setter를 통한 주입 없이는 모의 객체(mock object)를 주입하기 어렵다.
public class CarTest {
@Test
public void mockEngine_test() {
Engine mockEngine = mock(Engine.class);
Car car = new Car(); // Car 생성자에 Engine을 주입할 방법이 없음
car.start();
verify(mockEngine).start();
}
}
이런 숨겨진 의존성은 디버깅을 더 어렵게 만든다. 스프링 컨텍스트 없이 클래스를 사용할 경우, 필드가 null
로 남을 위험이 있다. 결국, 코드의 유지보수성이 떨어질 수 있다. 이러한 문제를 피하기 위해서는 생성자 주입을 사용하는 것이 권장된다.
@Component
public class Car {
private Engine engine;
@Autowired
public void setEngine(Engine engine) { // setter 함수
this.engine = engine;
}
public void start() {
if (engine == null) {
throw new IllegalStateException("엔진 없음!");
}
engine.start();
}
}
public class CarTest {
@Test
public void mockEngine_test() {
Engine mockEngine = mock(Engine.class);
Car car = new Car();
car.setEngine(mockEngine); // 세터 메소드를 통해 객체 주입
car.start();
verify(mockEngine).start();
}
}
@Autowired
는 타입을 기반으로 의존성을 주입한다. 동일한 타입의 여러 빈이 있을 경우, 스프링은 어떤 빈을 주입해야 할지 결정하기 어렵다.
public interface Engine {
void start();
}
@Component
public class V8Engine implements Engine {
@Override
public void start() {
// V8 엔진의 시작 로직
}
}
@Component
public class ElectricEngine implements Engine {
@Override
public void start() {
// 전기 엔진의 시작 로직
}
}
@Component
public class Car {
private final Engine engine;
@Autowired
public Car(Engine engine) { // 어떤 엔진?
this.engine = engine;
}
public void start() {
engine.start();
}
}
이를 해결하기 위해 @Qualifier
어노테이션을 사용할 수 있다. 그러나 이는 추가적인 설정을 요구하며, 때로는 코드의 복잡성을 증가시킨다.
@Component
public class Car {
private final Engine engine;
@Autowired
public Car(@Qualifier("v8Engine") Engine engine) {
this.engine = engine;
}
public void start() {
engine.start();
}
}
이를 해결하기 위해 명시적으로 빈을 생성하는 방법이 있다.
@Configuration
public class EngineConfig {
@Bean
public Engine v8Engine() {
return new V8Engine();
}
@Bean
public Engine electricEngine() {
return new ElectricEngine();
}
}
@RequiredArgsConstructor
어노테이션의 단점제한적인 생성자 오버로딩
Lombok은 @RequiredArgsConstructor
를 사용해 생성된 기본 생성자 외에 추가적인 매개변수를 갖는 생성자가 필요할 경우, 이를 자동으로 생성해주지 않는다.
@Component
@RequiredArgsConstructor
public class Car {
private final Engine engine;
private final String brand; // 생성자 자동 생성 X
// Lombok은 다음 생성자를 자동 생성
// public Car(Engine engine) {
// this.engine = engine;
// }
}
@Component
@RequiredArgsConstructor
public class Car {
private final Engine engine;
private final String brand;
// Lombok이 만들어주는 기본 생성자 외에 추가적인 생성자를 직접 작성
public Car(Engine engine, String brand) {
this.engine = engine;
this.brand = brand;
}
}
이는 @AllArgsConstructor
혹은 @Builder
로 해결할 수 있다.
@Component
@AllArgsConstructor
public class Car {
private final Engine engine;
private final String brand;
// 모든 필드 초기화 생성자 자동 생성
}
@Getter
@Component
@Builder
public class Car {
private final Engine engine;
private final String brand;
// 사용 예:
// Car car = Car.builder()
// .engine(new Engine())
// .brand("Toyota")
// .build();
}
컴퓨터 과학에서 제어는 프로그램의 흐름을 의미한다. 이 흐름은 데이터의 처리 순서와 실행되는 조건을 결정한다. 개발자는 흔히 함수나 메서드 호출을 통해 이러한 흐름을 직접 관리한다. 전통적인 프로그래밍에서는 메인 함수가 프로그램의 진입점으로 작동한다. 여기서 모든 객체의 생성과 생명주기가 시작되고 종료된다. 이러한 방식은 명령적 프로그래밍(Imperative Programming)이라 불리며, 명시적인 제어 흐름을 갖는다.
public class TraditionalControl {
public static void main(String[] args) {
Car car = new Car(); // 객체 직접 생성
car.startEngine();
}
}
제어 흐름 관리는 소프트웨어의 복잡성을 증가시킨다. 개발자가 모든 객체의 생성과 소멸을 추적해야 하기 때문이다. 또한, 각 컴포넌트 간의 강한 결합은 유지보수와 확장을 어렵게 만든다. 이런 문제를 해결하기 위해 제어의 역전이라는 개념이 도입되었다. IoC는 프레임워크나 라이브러리가 프로그램의 제어 흐름을 인수하는 방식을 말한다.
제어의 역전에서는 객체의 생성과 생명주기 관리가 프레임워크에 의해 이루어진다. 이는 DI로 구현된다. 개발자는 필요한 객체를 요청하고, 프레임워크는 그 객체를 생성하고 제공한다. 이렇게 함으로써, 프로그램의 각 부분은 더 이상 서로 어떻게 연결되는지에 대해 걱정하지 않아도 된다. IoC는 개발자가 비즈니스 로직에만 집중할 수 있게 도와준다.
public class InversionOfControl {
private Engine engine;
// 의존성 주입을 통한 엔진 객체의 할당
public InversionOfControl(Engine engine) {
this.engine = engine;
}
public void startCar() {
engine.start(); // 제어의 역전을 통한 엔진 시작
}
}
스프링 프레임워크에서 제어하는 주체는 컨테이너(Container)이다. 컨테이너는 객체의 생명주기와 설정을 관리한다. 제어당하는 주체는 빈(Bean)이라 불리는 객체들이다. 이 객체들은 컨테이너에 의해 생성, 관리되고, 종속 객체들과의 관계가 조정된다. 개발자는 빈의 생성과 소멸을 직접 제어하지 않는다. 대신, 스프링의 ApplicationContext
가 이러한 역할을 대신한다.
제어를 담당하는 컨테이너는 빈의 생성, 생명주기, 전체 구성을 총괄한다. 이는 IoC 원칙을 실현하는 핵심 요소다. 빈은 단순히 스프링에 의해 관리되는 객체를 말한다. 이들은 스프링의 설정 메타데이터에 따라 컨테이너에 의해 인스턴스화, 조립, 관리된다. IoC를 통해 우리는 더 높은 수준의 추상화에 집중하면 된다.
스프링에서 제어의 역전은 빈 팩토리나 어플리케이션 컨텍스트와 같은 컨테이너를 통해 달성된다. 이 컨테이너들은 빈의 정의를 읽고, 객체를 생성하고, 의존성을 주입한다. 컨테이너-빈 구조는 애플리케이션의 설정과 실행 사이의 관심사를 분리한다. IoC는 애플리케이션의 흐름을 단순하게, 유연하게 만든다.
스프링의 컨트롤러는 웹 요청을 처리하는 빈이다. 컨트롤러는 특정 경로로 들어오는 HTTP 요청을 처리하고 응답을 반환한다. 이들은 MVC 패턴의 Controller 부분을 담당하며, 뷰와 모델 사이의 상호작용을 관리한다.
빈은 스프링이 관리하는 객체를 말하며, 애플리케이션 컨텍스트가 빈을 관리한다. 빈은 스프링 IoC 컨테이너의 관리 아래에 있으며, DI를 통해 서로 연결된다. 이를 통해 개발자는 객체의 생성과 소멸에 신경 쓰지 않고, 비즈니스 로직의 구현에 집중할 수 있다.
컨트롤러는 사용자 인터페이스와 비즈니스 로직을 연결한다. 빈은 애플리케이션의 기능을 구현하는 재사용 가능한 컴포넌트로, 스프링이 전체 생명주기를 관리한다. 이러한 구조는 애플리케이션의 모듈성을 향상시키고, 개발의 복잡성을 줄여준다.
@Controller
@RequiredArgsConstructor
public class CarController {
private final CarService carService;
@GetMapping("/cars")
public String listCars(Model model) {
model.addAttribute("cars", carService.findAllCars());
return "cars/list"; // 차량 목록을 보여주는 뷰를 반환
}
}
DispatcherServlet
스프링의 DispatcherServlet은 프론트 컨트롤러 패턴의 구현체다. 이 서블릿은 모든 웹 요청을 한 곳에서 처리하고 적절한 핸들러에게 전달하는 역할을 한다. DispatcherServlet은 애플리케이션의 구성 요소와의 연결점 역할을 하며, 요청을 분석하고 처리하는 일련의 작업을 조정한다.
DispatcherServlet은 요청에 맞는 컨트롤러를 찾기 위해 핸들러 매핑을 사용한다. 요청이 들어오면, 핸들러 선택, 요청 처리, 뷰 해석, 그리고 응답 반환의 과정을 관리한다. 이 과정은 스프링의 유연성과 확장성을 보장하는 데 중요하다. DispatcherServlet은 매우 중요한 역할을 수행함에도 불구하고, 개발자로부터 내부 작업을 추상화하여 숨긴다.
DispatcherServlet의 존재는 스프링의 IoC 컨테이너와 긴밀하게 연결되어 있다. 이는 스프링 애플리케이션의 모든 웹 요청의 진입점으로, 요청에 따른 빈의 생성과 의존성 주입을 관리한다. 이 서블릿은 복잡한 웹 애플리케이션의 요청 처리를 단순화하고, 개발자가 비즈니스 로직에 집중할 수 있도록 해준다.
// web.xml에서 DispatcherServlet 설정 예시
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/spring/dispatcher-config.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
결합도를 낮추는 것은 소프트웨어 설계의 중요한 목표 중 하나다. 스프링 프레임워크는 DI를 통해 이를 실현한다. 낮은 결합도는 컴포넌트 독립성을 보장하고, 컴포넌트를 유연하게 변경할 수 있다. 스프링에서 DI는 빈 간의 관계를 외부에서 설정함으로써 이루어진다. 독립적인 빈을 @Autowired
로 주입하는 것이다. 결합도를 낮추는 설계는 애플리케이션의 유지보수를 간단하게 만든다. 개발자는 컴포넌트를 개별적으로 변경할 수 있고, 변경에 따른 영향을 최소화할 수 있다.
@Service
@RequiredArgsConstructor
public class CarService {
private final CarRepository carRepository; // DI
private final RacingService racingService; // DI
// 자동차 구매 및 점검 처리 로직 ...
}