3장의 끝에서 우리는 작은 찜찜함 하나를 안고 헤어졌다. 첫 엔드포인트를 띄웠고, 브라우저에 JSON이 떴고, 분명 잘 동작했다. 그런데 가만히 생각해 보면 이상한 구석이 하나 있다. 우리가 만든 TaskController라는 클래스 말이다. 그 클래스를 어디서도 new로 만들지 않았다. 자바를 배운 사람이라면 본능적으로 알 것이다. 객체를 쓰려면 누군가는 new TaskController()를 호출해야 한다. 하지만 그런 코드는 단 한 줄도 쓰지 않았다. 그런데도 요청이 들어오자 그 컨트롤러의 메서드가 멀쩡히 실행됐다.
자, 그렇다면 누가 그 객체를 만들었을까? 만들어서 어디에 보관해 두었다가, 요청이 들어오는 순간 꺼내 쓴 걸까? 3장에서는 이걸 “마법”이라 불렀고, “지금은 그냥 누리자”고 약속했다. 이제 그 약속을 지킬 시간이다. 오늘 할 일은 단 하나 — 그 마법의 정체를 손으로 헤집어 보는 것. 미리 말해 두자면, 정체를 알고 나면 마법은 더 이상 무섭지 않다. 오히려 든든한 도구로 보이기 시작한다.
이론을 미루고, 우리 코드부터 다시 보자
의존성 주입이니 제어의 역전이니 하는 말을 먼저 꺼내지는 않겠다. 거창한 용어로 시작하면 오히려 막힌다. 우리에겐 손으로 직접 띄운 진짜 코드가 있으니, 그 코드를 표본 삼아 거꾸로 파헤쳐 보자.
@RestController
public class TaskController {
@GetMapping("/api/tasks")
public List<String> getTasks() {
return List.of("Spring 첫 엔드포인트 띄우기", "커피 마시기");
}
}
이 클래스를 우리가 작성한 건 맞다. 하지만 “이 클래스의 객체를 만들어라”라는 명령은 어디에도 없다. 그런데도 동작했다. 비밀의 단서는 클래스 위에 붙은 @RestController라는 작은 표식에 있다.
new TaskController()는 어디에도 없다. 그런데 요청을 처리한 그 객체는 누가, 언제 만들었을까?
여기서 한 가지 의문이 생긴다. 그게 우리한테 무슨 이득이지? 객체 하나 만드는 일을 Spring한테 넘긴 게 그렇게 대단한가? 컨트롤러 하나만 보면 별것 아니다. 진짜 이야기는 객체가 다른 객체를 필요로 할 때 시작된다.
“객체가 객체를 필요로 할 때”의 난감함
상황을 하나 가정해 보자. TaskController가 이제 좀 더 그럴듯한 일을 해야 한다. 할 일 목록을 코드에 박아 두는 게 아니라, 어딘가에서 진짜 데이터를 가져오고 비즈니스 규칙을 적용해야 한다. 이 모든 걸 컨트롤러가 직접 하면 코드가 뒤죽박죽이 되니, 이를 전담하는 TaskService라는 객체를 따로 두기로 했다. 컨트롤러는 요청만 받고, 실제 처리는 서비스에게 맡긴다.
자, 그럼 컨트롤러는 서비스를 어떻게 손에 넣을까? 가장 먼저 떠오르는 방법은 이렇다.
@RestController
public class TaskController {
private final TaskService taskService = new TaskService(); // 직접 만들기
@GetMapping("/api/tasks")
public List<String> getTasks() {
return taskService.findAll();
}
}
컨트롤러가 자기에게 필요한 서비스를 new TaskService()로 직접 만들어 쥐고 있다. 동작은 한다. 그런데 이 코드를 가만히 보고 있으면 어딘가 찜찜하지 않은가? 무엇이 문제인지 짚어 보자.
private final TaskService taskService = new TaskService(); 이 한 줄의 가장 큰 문제는?
그렇다면 어떻게 해야 할까? 문제의 뿌리는 “컨트롤러가 자기 협력자를 직접 만든다”는 데 있다. 그러니 발상을 뒤집어 보자. 컨트롤러가 서비스를 직접 만들지 않게 하면 된다. 대신 누군가 밖에서 만들어 건네주면 어떨까?
ch04-1 서비스를 컨트롤러가 직접 new로 만들기명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl http://localhost:8080/api/tasks응답은 3장과 똑같다. 동작에는 문제가 없다.
문제는 "어떤 TaskService를 쓸지"를 컨트롤러가 쥐고 있다는 점이다. 테스트에서 가짜로 바꾸거나, 다른 구현으로 갈아 끼우려면 컨트롤러 코드를 고쳐야 한다.
제어를 뒤집는다는 것 — IoC와 DI
이 발상의 전환에는 이름이 있다. 제어의 역전(IoC, Inversion of Control)이다. 말은 어렵게 들리지만 방금 떠올린 생각 그대로다. 원래는 객체가 “내가 필요한 협력자는 내가 만든다”며 주도권(제어)을 쥐고 있었다. 이제는 그 주도권을 외부로 넘겨, “내 협력자는 누가 밖에서 만들어 넣어줘”라고 맡긴다. 통제권의 방향이 객체 안에서 밖으로 뒤집혔다고 해서 “제어의 역전”이라 부른다.
그리고 그 역전을 실제로 구현하는 구체적인 방식이 의존성 주입(DI, Dependency Injection)이다. 객체가 의존하는 다른 객체(의존성)를 외부에서 만들어 넣어 준다(주입)는 뜻이다. 소프트웨어 설계의 거장 마틴 파울러(Martin Fowler)는 2004년에 쓴 유명한 글에서, 모호하게 쓰이던 “IoC”라는 말 대신 이 방식을 가리키는 더 또렷한 이름으로 “의존성 주입”을 제안하고 정착시켰다. 정리하면 이렇다. IoC는 “주도권을 넘긴다”는 큰 원칙, DI는 “협력 객체를 외부에서 주입한다”는 그 구체적 방법.
프런트 개발자인 우리는 이미 비슷한 감각을 갖고 있다. 잠시 React를 떠올려 보자. 자식 컴포넌트가 필요한 데이터를 스스로 어디선가 끌어오게 만들지 않는다. 대신 부모가 props로 내려 준다. 자식은 “이게 필요해”라고 선언만 하고, 실제로 무엇이 들어올지는 바깥에서 정해진다. 의존성 주입의 핵심 감각이 바로 이것이다. 혹시 NestJS의 @Injectable()이나 Angular의 DI를 만져 봤다면 더 가깝게 느껴질 것이다. 이들은 모두 Spring이 닦아 놓은 길을 따라간 후예이니, 당신은 이미 Spring DI의 사촌을 만나 본 셈이다. 낯선 땅이 아니다.
프런트 다리 · 원문프런트엔드와 Spring의 개념을 빠르게 맞춰 보고 싶다면 부록 A의 멘탈 모델 대조표를 곁에 두고 보자. props/context ↔︎ DI, NestJS
@Injectable↔︎ Spring@Component처럼 익숙한 것과 새것을 나란히 짝지어 두었다.
function TaskList({ service })public TaskController(TaskService taskService)컨테이너에게 일을 맡기자 — 컨트롤러와 서비스를 분리하기
원리를 알았으니 이제 손을 움직여 우리 TaskBoard를 고쳐 보자. 목표는 두 가지다. 첫째, 할 일 처리 로직을 TaskService로 분리한다. 둘째, 그 서비스를 컨트롤러가 직접 만들지 않고 Spring이 주입하게 한다.
먼저 서비스를 만든다. 컨트롤러가 있던 패키지에 TaskService.java를 새로 두자.
package com.example.taskboard;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
public List<String> findAll() {
return List.of("Spring 첫 엔드포인트 띄우기", "커피 마시기");
}
}
여기서 눈여겨볼 표식이 @Service다. 3장의 @RestController가 그랬듯, 이 @Service도 Spring에게 “이 클래스를 빈으로 만들어 컨테이너에 담아 두라”고 알리는 표식이다. 그렇다면 @Component, @Service, @RestController는 뭐가 다를까? 솔직히 말하면, 컨테이너에 빈으로 등록된다는 본질적인 동작은 거의 같다. @Component가 가장 일반적인 표식이고, @Service와 @RestController는 그 특수한 형태다. 이름이 다른 이유는 이 클래스가 어떤 역할을 맡았는지를 코드를 읽는 사람에게 알려주기 위해서다. @Service라고 적혀 있으면 “아, 비즈니스 로직을 담당하는구나” 하고 한눈에 알 수 있다. 같은 빈이라도 옷차림으로 직책을 드러내는 셈이다.
이제 컨트롤러가 이 서비스를 주입받도록 고친다.
package com.example.taskboard;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) { // 생성자로 주입받는다
this.taskService = taskService;
}
@GetMapping("/api/tasks")
public List<String> getTasks() {
return taskService.findAll();
}
}
무엇이 달라졌는지 보자. 아까처럼 new TaskService()로 직접 만들던 코드가 사라졌다. 대신 컨트롤러의 생성자가 TaskService를 매개변수로 받아 자기 필드에 보관할 뿐이다. 컨트롤러는 이제 “나는 TaskService가 필요해”라고 선언만 한다. 그 객체를 실제로 만들어 건네주는 일은 Spring 컨테이너의 몫이다.
흐름을 그려 보자. 애플리케이션이 시작되면 Spring은 @Service가 붙은 TaskService를 발견해 빈으로 만들어 컨테이너에 담는다. 그다음 @RestController가 붙은 TaskController도 만들려고 보니, 이 컨트롤러의 생성자가 TaskService를 달라고 한다. Spring은 “그거라면 내 보관함에 이미 있지” 하고 그 빈을 꺼내 생성자에 넣어 준다. 이렇게 컨테이너가 객체들의 의존 관계를 알아서 이어 붙이는 과정을 와이어링(wiring)이라 부른다. “선을 잇는다”는 뜻이다. 우리가 손으로 콘센트를 일일이 꽂지 않아도, 컨테이너가 회로도를 보고 알아서 배선해 주는 셈이다.
4장 코드에 저장소 TaskStore 한 겹을 얹은 가상의 TaskBoard입니다. TaskService는 생성자로 TaskStore를 받습니다. 설정을 바꿔 가며 시작해 보세요. 실패 문구는 Spring Boot 3.5의 실제 시작 로그입니다.
- 해볼 것
@Service를 떼고 시작해 “빈을 찾을 수 없음” 실패 보기- TaskStore 구현체를 하나 더 두고 “2개 발견” 실패 보기
@Primary나@Qualifier로 하나를 골라 시작에 성공하기- 순환 의존을 만들어 시작 실패 보기
- 순환을 필드 주입으로 바꿔도 여전히 막히는 것 확인하기
이게 바로 3장에서 우리를 어리둥절하게 했던 그 “마법”의 정체다. 보이지 않는 손의 주인은 Spring 컨테이너였다. 그 손은 @Component 계열 표식을 단 클래스들을 찾아 빈으로 만들고, 생성자가 요구하는 의존성을 보관함에서 꺼내 주입했다. 신비로울 것 하나 없다. 정체를 알고 나니 한결 든든하지 않은가?
ch04-2 @Service와 생성자 주입명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl http://localhost:8080/api/tasks응답은 그대로다. 동작은 같고, 누가 만드는지만 바뀌었다.
직접 깨뜨려 보기 1: 빈이 없으면
TaskService.java의 @Service 줄을 지우고 ./gradlew bootRun을 실행한다. 컴파일은 되지만 시작하다 멈춘다.
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.example.taskboard.TaskController required a bean of type 'com.example.taskboard.TaskService' that could not be found.
컨테이너가 TaskService 빈을 찾지 못해 컨트롤러를 만들 수 없다는 뜻이다. 확인했으면 되돌린다.
git checkout -- .직접 깨뜨려 보기 2: 컨트롤러는 몇 번 만들어질까
TaskController 생성자에 출력 한 줄을 넣는다.
public TaskController(TaskService taskService) {
this.taskService = taskService;
System.out.println("TaskController 생성됨");
}서버를 띄우고 curl을 여러 번 보내도 TaskController 생성됨은 시작할 때 한 번만 찍힌다.
빈의 기본 스코프는 싱글톤이라, 시작할 때 하나 만들어 모든 요청이 같은 객체를 쓴다(4장 6절). 확인했으면 git checkout -- .으로 되돌린다.
왜 하필 생성자로 주입할까
의존성을 주입하는 방법이 하나뿐인 건 아니다. 방금 쓴 생성자 주입 말고도, 필드에 @Autowired를 붙여 바로 꽂는 필드 주입이라는 방식도 있다. 인터넷이나 AI가 주는 예제 중에는 후자가 적지 않다. 이런 모양이다.
@RestController
public class TaskController {
@Autowired
private TaskService taskService; // 필드 주입 — 짧지만 권하지 않는다
// 생성자가 없다
}
얼핏 보면 더 짧고 편해 보인다. 그런데 Spring 진영에서는 생성자 주입을 권장한다. 두 가지 이유가 핵심이다.
첫째는 불변성이다. 생성자 주입을 쓰면 taskService 필드를 final로 선언할 수 있다. 객체가 만들어지는 순간 의존성이 확정되고 그 뒤로는 절대 흔들리지 않는다. 반면 필드 주입은 final을 붙일 수 없어, 의존성이 나중에 슬그머니 바뀔 여지가 남는다. 어딘가 찜찜하지 않은가?
둘째는 테스트다. 생성자로 받으면 new TaskController(가짜서비스)로 가짜를 직접 끼워 만들 수 있다 — 컨테이너를 띄울 필요도 없다. 필드 주입은 그게 안 돼 복잡한 우회로를 거쳐야 한다.
그래서 이 책은 내내 생성자 주입을 기본으로 삼는다. 기억해 두자. 의존성은 생성자로 받는 편이 낫다.
반가운 소식 하나. 생성자가 단 하나뿐인 빈이라면, Spring은 그 생성자에
@Autowired를 붙이지 않아도 알아서 주입해 준다. 그래서 우리TaskController의 생성자에도 별도 표식이 없었다.
빈은 언제 태어나고 언제 사라질까
/api/tasks에 요청이 천 번 들어왔다. 그동안 TaskController 객체는 몇 개 만들어졌을까?
다만 두고두고 주의할 점이 하나 숨어 있다. 빈이 하나뿐이라는 말은 여러 요청이 동시에 같은 객체를 함께 쓴다는 뜻이기도 하다. 그러니 빈 안에 요청마다 달라지는 값을 함부로 필드로 들고 있으면, 여러 요청이 그 값을 서로 덮어쓰는 끔찍한 일이 벌어질 수 있다. 지금은 “빈은 기본적으로 하나를 공유한다”는 점만 머리 한구석에 새겨 두자. 나중에 분명 다시 만날 이야기다.
AI에게 와이어링을 맡길 때 — 무엇이 무엇으로 치환되나
먼저 한 가지를 직접 물어보는 습관을 들이자. AI에게 “필드 주입 대신 생성자 주입을 쓰는 게 나은 이유가 뭐야?”라고 묻고, 그 답을 방금 이야기한 두 가지(불변성·테스트 용이성)와 견줘 보자. AI의 답이 그럴듯하기만 하고 핵심을 비껴가지는 않는지, 이제는 가려낼 눈이 있다. 답을 맹목적으로 받아들이지 않고 검증한다. 이것이 2장에서 약속한 태도다.
AI가 만든 TaskService 클래스에 @Service가 빠져 있다. 문제는 언제 드러날까?
“할 일 목록 기능을 컨트롤러-서비스 구조로 나눠줘.
owner 파라미터로 그 사람의 할 일만 보여줘.”의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.
기억해 두자. AI는 컨트롤러-서비스 골격을 순식간에 짜 준다. 하지만 그 골격이 컨테이너 안에서 제대로 배선되는지 확인하는 일은 끝까지 우리 몫이다. AI는 선을 잇고, 회로도가 맞는지 보는 건 사람이다.
마무리
이번 장에서 우리는 3장에 남겨 둔 찜찜함을 정면으로 풀어냈다. new를 쓰지 않았는데도 객체가 동작한 이유는, Spring 컨테이너가 @Component 계열 표식을 단 클래스들을 빈으로 만들어 보관하고, 객체가 다른 객체를 필요로 할 때 그 의존성을 생성자에 끼워 넣어 주기 때문이었다. “내 협력자는 외부에서 넣어줘”라고 주도권을 넘기는 큰 원칙이 IoC, 그것을 구현하는 구체적 방법이 DI다. 무엇보다 TaskBoard를 컨트롤러와 서비스로 한 겹 나눴다. 작아 보여도, 다음에 진짜 데이터를 다룰 때 든든한 발판이 되는 걸음이다.
하지만 TaskBoard에는 아직 큰 약점이 있다. 잘못된 요청이 들어오면? 비어 있으면 안 되는 값이 비어 있다면? 게다가 이 API를 React 앱에서 호출하면 그 빨간 CORS 에러가 다시 튀어나올지도 모른다. 다음 장에서는 DTO·검증·예외 처리, 그리고 1장에서 “증상”으로만 남겨 둔 CORS까지 다루며 제대로 된 REST API의 모습을 갖춰 보자.
1. IoC와 DI의 관계로 맞는 것은?
2. @Component, @Service, @RestController의 차이로 맞는 것은?
3. 생성자가 하나뿐인 빈에 @Autowired를 붙이지 않으면?
4. 같은 타입의 빈이 둘인데 생성자가 그 타입 하나를 요구하면? (매개변수 이름은 어느 빈 이름과도 다르다)
이 장의 실습은 단계마다 커밋으로 준비해 두었고, 각 단계는 본문의 해당 자리에 있습니다. 처음이라면 레포부터 받습니다.
git clone https://github.com/younggeun0/toby-react-to-spring-taskboard.git
cd toby-react-to-spring-taskboard
git checkout ch04-1ch04-1서비스를 컨트롤러가 직접new로 만들기ch04-2@Service와 생성자 주입