Backend 03
DI와 생성자 주입 패턴: Spring의 마법 너머 설계의 진리
DI가 그냥 @Autowired 같은 어노테이션 이름인지, 설계 패턴인지 헷갈린다.
근거 · 교안 p19-p24
1장: DI, Spring 없이도 너는 이미 알고 있었다
1. DI, Spring 없이도 너는 이미 알고 있었다
솔라의 손가락이 노트북 트랙패드 위에서 멈칫했다. 화면에는 팀 스터디 그룹의 채팅창이 떠 있었다. 방금 올라온 한 줄짜리 명쾌한 답변이 솔라의 미간을 찌푸리게 만들었다.
Q: DI가 뭔가요? -> A: @Autowired 쓰시면 됩니다. 스프링이 다 알아서 해줘요.
분명 틀린 말은 아니었다. 실제로 그렇게 쓰고 있었으니까. 하지만 어딘가 석연치 않았다. 며칠 전, 시니어 개발자와의 코드 리뷰에서 들었던 말이 머릿속을 맴돌았다. “솔라 님, 이 부분은 DI 패턴 관점에서 보면 의존성 분리가 더 필요해 보여요.” 패턴? @Autowired는 특정 기능을 하는 어노테이션, 즉 도구의 이름이 아니었던가? 왜 같은 대상을 누구는 도구라 부르고, 누구는 패턴이라고 부를까. 마치 망치를 보고 ‘못 박는 것’이라고 부르는 사람과 ‘타격 도구의 한 종류’라고 부르는 사람을 동시에 만난 기분이었다.
“언니.”
거실 테이블에서 조용히 책을 읽던 루나가 고개를 들었다.
“DI가 대체 뭐야? 누구는 @Autowired라고 하고, 누구는 설계 패턴이래. 완전히 다른 말 같아.”
루나는 책을 덮고 솔라의 노트북 화면을 잠시 들여다보았다. 짧은 문답이 오간 채팅창을 확인한 루나는 별다른 말 없이 솔라의 노트북을 자기 쪽으로 살짝 돌렸다.
“잠깐 스프링은 잊어볼까?”
루나는 새 코드 에디터 창을 열었다.
“우리가 아주 간단한 주문(Order) 시스템을 자바 코드로 직접 만든다고 상상해 봐. 주문을 처리하는 OrderService가 필요하고, 주문 내역을 데이터베이스에 저장하는 OrderRepository가 필요하겠지.”
솔라는 루나의 말에 고개를 끄덕였다. 루나는 익숙하게 두 개의 클래스 파일을 만들었다.
// OrderRepository.java
public class OrderRepository {
public void save(Order order) {
System.out.println("데이터베이스에 주문을 저장합니다.");
// ...실제 저장 로직...
}
}
// OrderService.java
public class OrderService {
// ?
public void createOrder(Order order) {
// ?
System.out.println("주문 생성 로직을 실행합니다.");
// ...주문 처리 로직...
// 그리고 저장
}
}
“자, OrderService가 주문을 만들고 나서, 최종적으로 OrderRepository를 사용해서 저장을 해야 해. 그럼 OrderService는 OrderRepository를 어떻게 가져와서 써야 할까?”
솔라는 잠시 고민하다 자신 있게 말했다.
“그야 간단하지. OrderService 안에서 OrderRepository 객체를 직접 만들어서 쓰면 되잖아.”
솔라는 루나를 대신해 키보드에 손을 얹고 OrderService 클래스를 완성했다.
// OrderService.java (솔라's version)
public class OrderService {
private OrderRepository orderRepository = new OrderRepository();
public void createOrder(Order order) {
System.out.println("주문 생성 로직을 실행합니다.");
// ...주문 처리 로직...
orderRepository.save(order);
}
}
스스로 보기에도 깔끔하고 직관적인 코드였다. OrderService는 OrderRepository가 필요하고, 그래서 OrderService가 직접 new 키워드로 OrderRepository를 만든다. 문제 될 게 없어 보였다.
“좋아.”
루나가 말했다.
“그럼 여기서 질문. 만약 우리가 테스트를 위해서, 실제 데이터베이스에 저장하는 OrderRepository 말고, 그냥 메모리에 저장하는 가짜 TestOrderRepository를 사용하고 싶으면 어떻게 해야 할까?”
“어…?”
솔라의 손이 다시 멈칫했다. TestOrderRepository를 사용하려면 OrderService 클래스 내부의 코드를 직접 수정해야 했다.
private OrderRepository orderRepository = new TestOrderRepository();
이렇게 말이다. 서비스를 테스트하려는데, 서비스의 코드를 변경해야만 하는 상황. 뭔가 단단히 잘못되었다는 느낌이 들었다. OrderService의 역할은 ‘주문을 처리하는 것’이지, ‘어떤 Repository를 쓸지 결정하는 것’이 아니었다. 역할이 다른데도 둘의 관계가 너무 단단하게 얽혀 있었다. 마치 한 몸처럼.
“OrderService가 신분증처럼 OrderRepository의 정체를 자기 안에 새겨버린 느낌이네.”
솔라가 중얼거렸다. 루나는 그 말을 놓치지 않고 받았다.
“정확해. 그럼 신분증을 뺏어보면 어떨까? OrderService가 직접 OrderRepository를 만들지 않고, 누군가 밖에서 만들어서 건네주는 거야. 마치 필요한 부품을 외부에서 조달받는 공장처럼.”
루나는 솔라가 작성한 코드를 살짝 수정했다. new OrderRepository() 라인을 지우고, 대신 클래스 이름과 똑같은 이름의 메소드, 즉 생성자를 추가했다.
// OrderService.java (루나's refactoring)
public class OrderService {
private OrderRepository orderRepository;
// 생성자: OrderService를 만들 때, 외부에서 OrderRepository를 받는다.
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public void createOrder(Order order) {
System.out.println("주문 생성 로직을 실행합니다.");
// ...주문 처리 로직...
orderRepository.save(order); // 건네받은 것을 사용
}
}
그리고 이 서비스를 실제로 사용하는 가상의 조립 라인(main 메소드 같은)을 보여주었다.
// App.java (Assembly Line)
public class App {
public static void main(String[] args) {
// 1. 부품(의존성)을 먼저 만든다.
OrderRepository realRepository = new OrderRepository();
// 2. 부품을 '주입'하며 서비스를 조립한다.
OrderService orderService = new OrderService(realRepository);
// 이제 orderService를 사용한다.
orderService.createOrder(new Order());
}
}
솔라는 두 버전의 OrderService 코드를 번갈아 보았다. 처음 자신이 짠 코드는 스스로 모든 것을 해결하는 독립적인 존재처럼 보였지만, 실제로는 OrderRepository라는 특정 구현에 묶여 옴짝달싹 못 하는 구조였다. 반면 루나가 수정한 코드는 생성자를 통해 OrderRepository를 ‘건네받았다’. 이제 OrderService는 자기에게 주어진 것이 진짜 데이터베이스용이든 테스트용이든 신경 쓰지 않고 save 메소드를 호출하기만 하면 됐다.
“아…!”
탄성과 함께 깨달음이 밀려왔다.
“객체가 필요로 하는 다른 객체, 즉 ‘의존성’을 자기가 직접 만드는 게 아니라, 외부에서 만들어서 ‘주입’해주는 거구나. 이게 바로 ‘의존성 주입(Dependency Injection)‘이라는 설계 방식, 즉 패턴인 거네.”
@Autowired는 이 패턴을 구현하는 여러 도구 중 하나일 뿐, 패턴 그 자체는 아니었다. 스프링이 없어도, 어노테이션이 없어도, 우리는 이미 객체 간의 관계를 어떻게 맺어줄지 고민하며 이 패턴을 사용하고 있었던 것이다. 솔라는 방금 자신이 직접 겪은 이 과정을 ‘수동 의존성 주입’이라고 부를 수 있겠다는 생각이 들었다.
솔라가 고개를 들었다. 새로운 질문이 머릿속에 떠올랐다.
“알겠어. DI가 이런 설계 패턴이라는 건 이제 확실히 알겠어. 그런데 내가 방금 한 이 조립 과정, 그러니까 new OrderRepository()를 만들고, 이걸 new OrderService(...)에 넣어주는 이 일을… 만약 서비스가 수십 개고 서로 물고 물리는 관계라면 정말 끔찍하게 복잡해질 것 같아.”
솔라는 자신이 썼던 App.java 코드를 가리켰다.
“설마… 스프링이 ‘자동으로’ 해준다는 게, 바로 이 지루한 조립 과정을 대신 해준다는 뜻이야?“
2장: Spring의 마법? 그 안의 DI 패턴
솔라의 질문에 루나는 말없이 노트북을 다시 자기 앞으로 가져왔다. 그리고는 솔라가 방금 전 가리켰던 App.java 파일, 그들이 ‘조립 라인’이라고 불렀던 코드를 열었다.
루나의 손가락이 delete 키를 눌렀다. main 메소드 안을 채우고 있던 new OrderRepository()와 new OrderService(...) 코드가 흔적도 없이 사라졌다. 방금 전까지 의존성을 만들고 주입하던 핵심 코드가 있던 자리에 텅 빈 공백만 남았다. 솔라의 눈이 동그래졌다. 조립 설명서의 가장 중요한 부분을 찢어버린 것과 같았다.
“언니, 그걸 지우면 어떡해? 그럼 누가 OrderService랑 OrderRepository를 연결해줘?”
“스프링의 세계에선, 우리가 방금 본 조립 라인이 눈에 보이지 않는 거대한 공장 안으로 들어간다고 생각하면 돼. 우리는 더 이상 부품을 직접 만들고 끼우지 않아. 대신 부품에 이름표를 붙여서 공장에 등록하고, 필요한 곳에선 ‘이 부품 가져다주세요’라고 요청만 하면 돼.”
루나는 OrderRepository.java와 OrderService.java 파일로 돌아갔다. 그리고 각 클래스 선언부 위에 간결한 어노테이션 한 줄씩을 추가했다.
@Component
public class OrderRepository {
// ...
}
@Component
public class OrderService {
// ...
}
“@Component? 이건 그냥 이름표 같은 거야?” 솔라가 물었다.
“맞아. 스프링이라는 거대한 부품 공장(컨테이너)에게 ‘이 클래스는 네가 관리할 부품(Bean)이야’라고 알려주는 표식이야. 이제 스프링은 시작될 때 @Component가 붙은 클래스들을 전부 찾아서 객체로 만들어 자기 공장 안에 보관해 둬.”
이제 스프링이 부품들을 가지고 있다는 것은 알겠다. 하지만 여전히 의문은 풀리지 않았다. 그래서, 어떻게 둘을 연결한다는 거지?
루나는 이어서 OrderService 클래스의 내부를 수정하기 시작했다. 솔라는 자기도 모르게 숨을 죽였다. 루나는 이전 장에서 그토록 중요하게 다뤘던 생성자, 즉 public OrderService(OrderRepository orderRepository) { ... } 부분을 통째로 삭제했다. 그리고 그 자리에 단 두 줄을 추가했다.
@Component
public class OrderService {
@Autowired
private OrderRepository orderRepository;
// 생성자가 사라졌다!
public void createOrder(Order order) {
System.out.println("주문 생성 로직을 실행합니다.");
orderRepository.save(order); // 이 orderRepository는 어디서 오는 걸까?
}
}
코드가 극단적으로 간결해졌다. 생성자도, this.orderRepository = orderRepository 같은 할당문도 없었다. 그저 덩그러니 놓인 @Autowired 어노테이션 하나. 솔라는 혼란스러웠다. 이것이 바로 스터디 채팅방에서 봤던 ‘마법’의 실체였다.
“와… 진짜 간단해지네. 그럼 그냥 @Autowired라고 쓰면 스프링이 알아서 OrderRepository를 저 필드에 넣어준다는 거야? 이게 DI의 전부였던 거네?”
솔라의 목소리에는 감탄과 함께 약간의 허탈함이 섞여 있었다. DI 패턴이니, 외부 주입이니 복잡하게 생각할 것 없이, 그냥 어노테이션 하나면 끝나는 일이었나.
루나는 고개를 저으며 솔라가 수정한 OrderService 코드와 이전 장에서 만들었던 App.java의 지워진 내용을 나란히 상상해보라는 듯 허공에 손짓했다.
“전혀. @Autowired는 DI 패턴 그 자체가 아니야. 우리가 App.java에서 했던 ‘수동 조립’을 대신 해달라고 스프링 공장에 보내는 ‘작업 지시서’에 가까워.”
루나는 말을 이었다.
“봐봐. 스프링은 @Component 이름표가 붙은 객체들을 미리 다 만들어 뒀지. 그러다 @Autowired 지시서를 발견하면, ‘아, OrderService는 OrderRepository 타입의 부품이 필요하구나’라고 인지해. 그리고 공장 창고에서 이미 만들어 둔 OrderRepository 객체를 찾아서, 이 필드에 ‘주입’해주는 거야.”
그제야 솔라의 머릿속에서 모든 조각이 맞춰졌다.
new OrderRepository() → @Component가 붙은 OrderRepository 클래스를 스프링이 미리 객체로 만들어 둠.
new OrderService(repository) → @Autowired가 붙은 orderRepository 필드에 스프링이 찾아와서 객체를 넣어 줌.
@Autowired는 ‘마법’이 아니었다. 이전 장에서 솔라가 직접 했던, 지루하고 반복적일 수 있는 ‘의존성 주입’이라는 행위를 스프링이라는 자동화된 시스템이 대신 처리하도록 만드는 명령이었을 뿐이다. 스프링은 DI 패턴을 없앤 게 아니라, 오히려 그 패턴을 훨씬 더 편리하게 사용할 수 있도록 자동화해주고 있었던 것이다. 솔라는 자신이 방금 ‘자동 의존성 연결기’의 작동 원리를 엿본 것 같다고 느꼈다.
“그렇구나… 스프링이 ‘자동으로’ 해준다는 말은, DI 패턴이라는 설계 원칙을 자동화해준다는 뜻이었어. 내가 했던 수동 조립 과정을 대신해주는 거였네.”
솔라는 명쾌해진 기분으로 화면을 바라봤다. @Component와 @Autowired 덕분에 코드는 놀랍도록 깔끔해졌다. 그런데 문득, 시선이 한곳에 머물렀다. 방금 전 루나가 삭제했던 생성자 코드의 빈자리였다.
“그런데 언니, 좀 이상해. 지난번에는 의존성을 외부에서 받기 위해 일부러 생성자를 만들었잖아. 그게 DI의 핵심 원리라고 생각했는데… 지금은 그냥 필드에 어노테이션 하나 붙이고 끝냈어. 이게 더 편한데, 굳이 생성자를 쓰는 방법이 따로 있는 이유가 뭐야? 왜 다들 생성자 주입, 생성자 주입 하는 거지?“
3장: 생성자 주입: 불변성을 지키는 가장 깔끔한 DI
솔라의 질문이 공중에 맴돌았다. 필드에 어노테이션 하나만 붙이면 끝나는 간편한 방식이 있는데, 왜 굳이 번거롭게 생성자를 만들어 의존성을 주입해야 하는 걸까. 시니어 개발자들이나 블로그 포스트들이 입을 모아 ‘생성자 주입’을 권장하는 이유가 선뜻 이해되지 않았다. 그저 낡은 관습 같은 게 아닐까.
루나는 솔라의 질문에 바로 답하는 대신, 화면에 떠 있는 OrderService 코드를 가만히 들여다보았다. 솔라의 시선도 자연스레 코드로 향했다.
@Component
public class OrderService {
@Autowired
private OrderRepository orderRepository;
// ...
}
침묵 속에서 루나의 손가락이 키보드 위로 움직였다. 그녀는 private과 OrderRepository 사이에 한 단어를 추가했다. final.
@Component
public class OrderService {
@Autowired
private final OrderRepository orderRepository; // <- Error!
// ...
}
코드가 입력되자마자, IDE는 즉시 해당 라인에 매서운 빨간 밑줄을 그어 보였다. ‘초기화되지 않은 final 필드’라는 에러 메시지가 작게 떠올랐다. 솔라의 미간이 좁혀졌다. 일부러 코드를 망가뜨린 언니의 의도를 알 수 없었다.
“언니, final을 붙이면 당연히 에러 나지. 필드에 final을 쓰려면 선언할 때나 생성자에서 값을 넣어줘야 하잖아.”
“맞아.”
루나가 짧게 답했다. 그리고는 솔라를 보며 물었다.
“우리가 왜 이 필드에 final을 붙이고 싶어 할까? final을 씀으로써 뭘 보장받고 싶은 거지?”
“그야… 한번 정해지면 절대 바뀌지 않는다는 거. 이 OrderService 객체가 살아있는 동안 orderRepository가 다른 객체로 교체될 수 없다는 걸 보장하는 거지. 불변성.”
말을 내뱉는 순간, 솔라는 스스로의 답변에 갇힌 기분이 들었다. OrderService에게 OrderRepository는 심장과도 같은 핵심 부품이다. 당연히 불변이어야 한다. 서비스가 동작하는 중간에 누군가 이 부품을 슬쩍 바꿔치기하거나, 실수로 null로 만들어 버린다면 상상만 해도 끔찍한 장애로 이어질 것이다. final은 그 무엇보다 확실한 안전장치였다.
하지만 모순이었다. @Autowired 필드 주입 방식은 이 안전장치를 다는 것을 허락하지 않았다. 스프링이 객체를 먼저 생성한 뒤, 나중에 리플렉션 기술을 이용해 필드에 몰래 의존성을 ‘찔러 넣어’주는 방식이라, final 필드를 초기화할 타이밍을 놓치기 때문이다.
“편리하긴 한데… 안전장치를 달 수 없는 구조구나.”
솔라가 나직이 중얼거렸다. @Autowired 필드 주입의 달콤함 뒤에 숨겨진 위험성을 깨달은 것이다.
그때 루나가 다시 코드를 수정하기 시작했다. 필드에 붙어있던 @Autowired 어노테이션을 지우고, 지난 1장에서 만들었다가 지웠던 생성자를 다시 타이핑했다.
@Component
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public void createOrder(Order order) {
// ...
orderRepository.save(order);
}
}
거짓말처럼 빨간 에러 라인이 사라졌다. 코드는 다시 평온을 되찾았다.
final 키워드는 OrderService가 생성되는 바로 그 순간, 생성자의 인자로 들어온 orderRepository를 받아 자신의 필드에 할당해야만 한다는 ‘계약’을 강제했다. 그리고 이 계약은, 첫 번째 장에서 솔라가 깨달았던 DI 패턴의 본질과 정확하게 일치했다. ‘객체는 필요한 의존성을 외부로부터, 생성 시점에, 반드시 전달받아야 한다.’
솔라는 두 가지 방식의 차이를 머릿속에 나란히 그려보았다.
필드 주입은 마치 완제품으로 조립된 컴퓨터의 옆판을 몰래 열고 부품을 끼워 넣는 것 같았다. 당장은 편하지만, 언제든 부품이 빠지거나 잘못 끼워질 위험이 있었다.
반면 생성자 주입은 컴퓨터를 조립하는 설계도 자체에 ‘CPU와 메모리 없이는 전원을 켤 수 없음’이라고 명시하는 것과 같았다. 필수 부품이 하나라도 빠지면 아예 조립 자체가 실패했다. 훨씬 안전하고, 의존 관계가 명확하게 드러나는 방식이었다.
“아…!”
솔라의 입에서 작은 탄성이 터져 나왔다.
“생성자 주입은 그냥 여러 방법 중 하나가 아니었어. 의존성의 ‘외부 주입’이라는 DI 패턴의 원칙과 ‘불변성’이라는 안정성을 모두 지키는 가장 완벽한 방법이었던 거야.”
솔라는 이제 왜 스프링 공식 문서와 수많은 개발자들이 생성자 주입을 권장하는지 온몸으로 이해할 수 있었다. 그것은 낡은 관습이 아니라, 수많은 시행착오 끝에 얻어낸 설계의 지혜였다.
문득, 새로운 요구사항이 떠올랐다. ‘주문을 하기 전에, 고객의 등급을 확인해서 할인율을 적용해야 한다면?’ OrderService는 이제 MemberService라는 새로운 의존성이 필요해졌다.
솔라는 루나를 보지 않고, 스스로 키보드에 손을 얹었다. 망설임은 없었다. 그녀는 먼저 OrderService 클래스에 새로운 final 필드를 추가했다.
private final MemberService memberService;
IDE가 다시 에러를 표시했다. 솔라는 익숙하게 생성자로 이동해 새로운 인자를 추가하고, 필드에 할당하는 코드를 넣었다.
// 솔라's final code
@Component
public class OrderService {
private final OrderRepository orderRepository;
private final MemberService memberService;
public OrderService(OrderRepository orderRepository, MemberService memberService) {
this.orderRepository = orderRepository;
this.memberService = memberService;
}
// ...
}
코드가 완성되자, 솔라는 만족스러운 미소를 지었다. 이제 OrderService를 만들려는 그 누구도 OrderRepository와 MemberService 없이는 단 한 걸음도 나아갈 수 없을 것이다. 그녀는 DI 패턴의 본질을 지키는 ‘불변 의존성 확보’라는 든든한 도구를 손에 넣은 기분이었다. 더 이상 @Autowired라는 편리한 마법에 현혹되지 않고, 설계의 진리를 좇을 자신이 생겼다.