의존성?
의존성(Dependency) 이란, 한 객체가 다른 객체를 필요로 하는 관계이다.
예를 들어, 자동차는 반드시 엔진이 필요하다.
// 자동차는 엔진에 "의존"한다
class Car {
Engine engine; // Car는 Engine이 있어야 동작함
}
이때 Car는 Engine에 의존한다고 할 수 있다.
의존성으로 인해 발생하는 문제 상황
Java 문법만 배웠다면 쉽게 작성할 수 있는 코드를 먼저 확인해보자
class Engine {
public void start() {
System.out.println("엔진 시작!");
}
}
class Car {
// Car가 직접 Engine을 만들어요 (new 키워드 사용) --> 뭔가 좀 이상하다
private Engine engine = new Engine();
public void drive() {
engine.start();
System.out.println("출발!");
}
}
// 사용
Car car = new Car();
car.drive();
동작은 하겠지만 코드에서 냄새가 난다
문제 1: 강한 결합 (Tight Coupling)
Car 안에서 new Engine()을 직접 만들고 있다. 만약 나중에 ElectricEngine으로 바꾸고 싶다면?
class Car {
// Engine을 ElectricEngine으로 바꾸려면
// Car 코드를 직접 수정해야 한다 😰
private ElectricEngine engine = new ElectricEngine(); // Car 내부 수정!
}
Car 클래스에서는 "어떤 엔진을 쓸지"까지 결정하고 있다. 엔진이 바뀌면 Car 코드도 바꿔야 한다. 이게 바로 강한 결합이다.
문제 2: 테스트가 어려움
Car를 테스트할 때, Engine도 항상 실행된다. 가짜 엔진(Mock)으로 테스트하기가 어려운 구조다.
Car에 대한 테스트만 진행하고 싶은데, 강한 결합으로 인해 Engine에 대한 테스트 코드까지 작성해야한다.
높은 확률로 테스트 중복이 발생하게 된다.
💡 해결책 1 — 제어의 역전 (IoC)
IoC(Inversion of Control) 를 한 마디로 설명하면
"객체를 만들고 관리하는 권한을 개발자가 아닌, 프레임워크(스프링)에게 넘기는 것"
| 전통적인 방식 | IoC 방식 | |
| 객체 생성 | 개발자가 new로 직접 생성 | 스프링이 대신 생성 |
| 객체 관리 | 개발자가 직접 관리 | 스프링이 관리 |
| 제어권 | 개발자에게 있음 | 스프링(프레임워크)에게 있음 |
즉, 제어(Control) 가 역전(Inversion) 됐다 = 본래 개발자가 갖고 있던 권한이 스프링으로 넘어갔다는 의미.
스프링의 IoC 컨테이너
스프링에는 IoC 컨테이너 라는 것이 있다. 이 컨테이너가 객체들을 대신 만들어주고, 저장해두고, 관리한다.
스프링이 관리하는 객체를 특별히 빈(Bean) 이라고 부른다.
[스프링 IoC 컨테이너]
┌──────────────────────────────┐
│ Bean: Engine │
│ Bean: Car │
│ Bean: UserService │
│ Bean: OrderService ... │
└──────────────────────────────┘
↑ 스프링이 이 객체들을 만들고 관리해줌
💡 해결책 2 — 의존성 주입 (DI)
DI(Dependency Injection) 를 한 마디로 설명하면
"객체가 필요로 하는 의존성(다른 객체)을, 외부에서 넣어(주입해)주는 것"
주입(Injection)이란, 외부에서 값을 넣어준다는 뜻이다.
// DI 방식 — 외부에서 Engine을 넣어줌
class Car {
private Engine engine;
// 생성자를 통해 Engine을 "주입" 받는다
public Car(Engine engine) {
this.engine = engine; // 직접 만들지 않고, 받아서 씀
}
public void drive() {
engine.start();
System.out.println("출발!");
}
}
// 사용할 때 외부에서 Engine을 넣어줌
Engine engine = new Engine();
Car car = new Car(engine); // 주입!
car.drive();
이제 Car는 Engine을 직접 만들지 않는다. 외부에서 받아서 쓸 뿐이다.
스프링에서 DI 사용하기
스프링에서는 @Component, @Autowired 같은 어노테이션(Annotation) 을 사용한다.
💡 어노테이션(Annotation) 이란? @로 시작하는 메모 같은 것이다.
스프링에게 "이 클래스는 Bean으로 등록해줘", "여기에 의존성을 주입해줘" 같은 지시를 내리는 방식
@Component, @Autowired
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
// @Component: "스프링아, 이 클래스를 Bean으로 등록해줘!"
@Component
class Engine {
public void start() {
System.out.println("엔진 시작!");
}
}
@Component
class Car {
private Engine engine;
// @Autowired: "스프링아, 여기에 Engine Bean을 주입해줘!"
@Autowired
public Car(Engine engine) {
this.engine = engine;
}
public void drive() {
engine.start();
System.out.println("출발!");
}
}
스프링이 알아서:
- Engine 객체를 만들어서 컨테이너에 저장
- Car 객체를 만들 때 Engine을 자동으로 넣어줌
개발자는 new를 한 번도 쓰지 않아도 된다.
@ComponentScan
스프링이 @Component를 찾기위해 어디를 탐색해야할지 가르쳐 준다
"스프링아, 이 패키지 안을 뒤져서 @Component 찾아서 전부 Bean으로 등록해줘!"
@Configuration
@ComponentScan(basePackages = "com.myapp") // 이 패키지부터 뒤져라!
public class AppConfig {
}
com.myapp
├── Car.java ← @Component 있음 → Bean 등록 ✅
├── Engine.java ← @Component 있음 → Bean 등록 ✅
└── utils
└── Helper.java ← @Component 없음 → 무시 ❌
스프링 부트에서는?
@SpringBootApplication 안에 @ComponentScan이 이미 내장되어 있다. 그래서 따로 선언하지 않아도 된다.
@SpringBootApplication // ← 안에 @ComponentScan이 포함되어 있음!
public class MyApp {
public static void main(String[] args) {
SpringApplication.run(MyApp.class, args);
}
}
기준은 @SpringBootApplication이 붙은 클래스의 패키지 위치이고, 그 패키지 아래를 전부 자동으로 스캔한다.
@Qualifier
Engine을 구현하는 두 클래스가 있다고 가정해보자.
interface Engine { void start(); }
@Component
class GasolineEngine implements Engine { ... }
@Component
class ElectricEngine implements Engine { ... }
이 상태에서 Car에 주입하려고 하면 스프링은 어떤 클래스를 Engine으로 주입할지 결정하지 못한다 → NoUniqueBeanDefinitionException 에러 발생
@Component
class Car {
public Car(Engine engine) { // Engine 타입 Bean이 두 개야! 어떤 걸 넣어?
...
}
}
이때 @Qualifier로 "이 이름의 Bean을 써줘" 라고 명시해줄 수 있다.
@Component
class Car {
private final Engine engine;
public Car(@Qualifier("electricEngine") Engine engine) {
// ↑ "electricEngine" 이름의 Bean을 써줘!
this.engine = engine;
}
}
💡 @Qualifier에 들어가는 이름은 기본적으로 클래스명의 첫 글자를 소문자로 바꾼 것이다. ElectricEngine → "electricEngine"
참고로 직접 이름을 지정하는 것도 가능하다
@Component("myElectric") // Bean 이름을 직접 지정
class ElectricEngine implements Engine { ... }
// 사용할 때
public Car(@Qualifier("myElectric") Engine engine) { ... }
final + @RequiredArgsConstructor
생성자 주입을 할 때 이런 코드가 반복될 수 있다.
@Component
class Car {
private final Engine engine;
private final GPS gps;
private final Radio radio;
// 필드가 늘어날수록 생성자가 길어짐 😰
public Car(Engine engine, GPS gps, Radio radio) {
this.engine = engine;
this.gps = gps;
this.radio = radio;
}
}
필드가 10개면 생성자도 엄청 길어질 것이다.
이걸 해결해주는 게 Lombok 라이브러리의 @RequiredArgsConstructor이다.
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor // ← 마법의 어노테이션!
class Car {
private final Engine engine; // ← final 필드들을
private final GPS gps; // 자동으로 모아서
private final Radio radio; // 생성자를 만들어줘요
// 생성자 코드가 사라졌다. Lombok이 컴파일 시점에 자동 생성
}
@RequiredArgsConstructor는 final이 붙은 필드들만 모아서 생성자를 자동으로 만들어준다.
실제로 컴파일되면 아래처럼 변환된다
// Lombok이 자동으로 만들어주는 코드
public Car(Engine engine, GPS gps, Radio radio) {
this.engine = engine;
this.gps = gps;
this.radio = radio;
}
DI의 3가지 방법
1. 생성자 주입 (Constructor Injection) ✅ 가장 권장!
@Component
class Car {
private final Engine engine; // final로 선언 가능 (불변)
@Autowired // 생성자가 하나면 @Autowired 생략 가능
public Car(Engine engine) {
this.engine = engine;
}
}
왜 권장하는가
- final 키워드를 써서 한번 주입되면 바뀌지 않는다
- 객체가 만들어질 때 반드시 의존성이 있어야 해서, 실수를 방지해준다
2. 세터 주입 (Setter Injection)
@Component
class Car {
private Engine engine;
@Autowired
public void setEngine(Engine engine) { // setter 메서드로 주입
this.engine = engine;
}
}
3. 필드 주입 (Field Injection) — 편하지만 비권장
@Component
class Car {
@Autowired
private Engine engine; // 필드에 바로 주입
}
코드가 간단하지만, 테스트하기 어렵고 숨겨진 의존성이 생긴다.
IoC + DI의 장점 — 인터페이스와 함께 쓰기
💡 인터페이스(Interface) 란?
클래스가 반드시 구현해야 하는 기능 목록.
"엔진이라면 반드시 start() 기능이 있어야 해" 같은 약속이라고 볼 수 있다.
// 인터페이스: "엔진은 start() 가 있어야 해"
interface Engine {
void start();
}
// 일반 엔진 구현체
@Component
class GasolineEngine implements Engine {
public void start() {
System.out.println("휘발유 엔진 시작! 부릉부릉~");
}
}
// 전기 엔진 구현체
@Component
class ElectricEngine implements Engine {
public void start() {
System.out.println("전기 엔진 시작! 조용히 윙~");
}
}
// Car는 그냥 "Engine 인터페이스"에만 의존
@Component
class Car {
private final Engine engine;
@Autowired
public Car(Engine engine) { // 어떤 엔진인지 몰라도 됨!
this.engine = engine;
}
public void drive() {
engine.start(); // 그냥 호출하면 됨
System.out.println("출발!");
}
}
이제 어떤 엔진을 쓸지는 스프링이 결정해준다.
GasolineEngine을 쓰다가 ElectricEngine으로 바꾸고 싶으면?
→ Car 코드는 단 한 줄도 수정 안 해도 된다.
전체 요약
| 개념 | 요약 |
| IoC (제어의 역전) | 객체 생성/관리를 개발자 → 스프링으로 넘김 |
| DI (의존성 주입) | 필요한 객체를 직접 만들지 않고, 스프링이 넣어줌 |
| Bean | 스프링이 관리하는 객체 |
| IoC 컨테이너 | Bean들을 담아두고 관리하는 스프링의 저장소 |
| @Component | "이 클래스를 Bean으로 등록해줘"라는 어노테이션 |
| @Autowired | "여기에 Bean을 주입해줘"라는 어노테이션 |
'Learning Log' 카테고리의 다른 글
| [멋사 클라우드 5기] Day 23 - Spring Data JPA (1) (1) | 2026.03.03 |
|---|---|
| [멋사 클라우드 5기] Day 22 - SpringBoot 그리고 웹 서버 (0) | 2026.02.27 |
| [멋사 클라우드 5기] Day 20 - JavaScript의 비동기 (0) | 2026.02.25 |
| [멋사 클라우드 5기] Day 19 - Object.assign과 structuredClone에 대하여 (0) | 2026.02.24 |
| [멋사 클라우드 5기] Day 18 - JavaScript 호이스팅 (0) | 2026.02.15 |
