Backend 01

Java 'new'에서 Spring 자동 주입으로: 의존성 관리의 전환

Java에서 new로 직접 넣어주던 일을 Spring이 대신한다는 말이 아직 손에 잡히지 않는다.

근거 · 교안 p7-p10

Java 'new'에서 Spring 자동 주입으로: 의존성 관리의 전환 대표 이미지

1장: Java ‘new’에 담긴 의존성 주입의 책임

솔라의 손가락이 노트북 트랙패드 위에서 잠시 멈췄다. 화면에 띄워진 코드 예제와 짧은 문장 하나가 시선을 붙들고 있었다. ‘Java에서 new로 직접 넣어주던 일을 Spring이 대신한다.’

분명 여러 번 읽었던 문장이었다. 머리로는 이해가 되는 말이었다. 하지만 그 ‘대신’이라는 단어가 손에 잡히는 실제 감각으로 다가오지 않았다. 마치 친구가 “내가 저 무거운 짐을 대신 들어줄게”라고 말하는 것과는 전혀 다른, 추상적인 마법처럼 느껴졌다. 짐을 넘겨주는 구체적인 행위 없이, 어느 순간 짐이 사라져 버리는 일 같았다.

솔라는 미간을 찌푸리며 화면을 노려봤다. 대체 Spring은 어떤 ‘일’을 대신한다는 걸까? new 키워드를 코드에서 삭제해주는 편리한 마법? 그게 전부일 리는 없었다.

“언니.”

솔라의 부름에, 맞은편에서 조용히 책을 읽던 루나가 고개를 들었다.

“Spring이 new를 대신해준다는 말이, 꼭 마법 주문 같아. new를 안 쓰게 해주는 건 알겠는데, 그럼 객체는 대체 어떻게 만들어지고 어떻게 연결되는지 모르겠어. 그냥 ‘짠’ 하고 나타나는 것처럼 느껴져.”

루나는 솔라의 화면을 잠시 들여다보더니, 이내 솔라의 얼굴로 시선을 옮겼다. 루나는 아무 말 없이 노트북을 솔라 쪽으로 조금 더 밀어주었다. 마치 ‘네가 직접 답을 찾아봐’라고 말하는 듯한 움직임이었다.

“좋아. 그럼 딱 5분만 Spring을 잊어보는 거야.” 루나가 말했다. “우리가 온라인 서점에서 주문하는 기능을 만든다고 상상해 봐. Spring 없이, 순수 Java로만. OrderService라는 주문 서비스 클래스가 필요하겠지? 그리고 주문을 하려면 PaymentMethod 같은 결제 수단도 필요할 거고.”

솔라는 루나의 말에 고개를 끄덕이며 새 클래스 파일을 열었다. 마법 같은 Spring의 세계에서 잠시 벗어나, 자신이 가장 잘 아는 땅으로 돌아온 기분이었다. 익숙하게 키보드를 두드려 OrderService 클래스와 PaymentMethod 인터페이스의 뼈대를 만들었다.

// 결제 방식을 나타내는 인터페이스
interface PaymentMethod {
    void pay(int amount);
}

// 신용카드 결제 방식
class CreditCardPayment implements PaymentMethod {
    public void pay(int amount) {
        System.out.println(amount + "원을 신용카드로 결제합니다.");
    }
}

// 주문 서비스
class OrderService {
    private final PaymentMethod paymentMethod;

    public OrderService(PaymentMethod paymentMethod) {
        this.paymentMethod = paymentMethod;
    }

    public void processOrder(int price) {
        // 주문 처리 로직...
        paymentMethod.pay(price);
    }
}

금세 익숙한 코드가 완성되었다. OrderService는 생성자를 통해 PaymentMethod 구현체를 받아서, 자신의 필드에 저장했다. 이제 이 OrderService를 실제로 사용하는 코드를 짤 차례였다.

솔라는 잠시 고민하다 main 메소드를 만들고 그 안에 코드를 작성했다.

public class OrderApplication {
    public static void main(String[] args) {
        PaymentMethod paymentMethod = new CreditCardPayment();
        OrderService orderService = new OrderService(paymentMethod);

        orderService.processOrder(10000);
    }
}

코드를 다 작성하고 나자, 솔라는 잠시 자신의 화면을 멍하니 바라보았다. 루나가 조용히 물었다.

“자, OrderApplication 클래스를 봐. 거기서 CreditCardPayment 객체를 만들고, 그걸 OrderService에 연결해주는 책임은 누가 지고 있지?”

루나의 질문에 솔라의 시선이 new CreditCardPayment()라는 코드에 꽂혔다. 그리고 그 다음 줄, new OrderService(paymentMethod). 너무나 당연해서 의식조차 하지 못했던 부분이었다.

“…나네.” 솔라가 작게 중얼거렸다.

“내가 직접 하고 있었어. OrderService를 쓰려는 OrderApplication이 어떤 결제 수단(CreditCardPayment)을 사용할지 결정하고, new 키워드로 직접 객체를 만들어서 OrderService의 생성자에 인수로 넘겨주고 있잖아. 객체를 만드는 책임, 그리고 두 객체를 연결하는 책임까지 전부 이 main 메소드 안에서 내가 지고 있는 거였어.”

그제야 솔라는 자신이 짠 코드의 구조가 선명하게 보였다. OrderService는 수동적이었다. 누군가 외부에서 PaymentMethod를 만들어서 넣어주기만을 기다리고 있었다. 그리고 그 ‘누군가’의 역할을 바로 자신이, 개발자가 코드로 직접 수행하고 있었다. new는 단순한 키워드가 아니라, 객체 생성과 관계 설정의 제어권을 개발자가 쥐고 있다는 명백한 증거였다.

솔라는 다시 아까 전의 문장을 떠올렸다. ‘Java에서 new로 직접 넣어주던 일을 Spring이 대신한다.’

“그렇구나. Spring이 대신해준다는 ‘일’이 바로 이거였어. 내가 지금 main 메소드에서 했던 ‘객체를 만들고, 필요한 곳에 연결해주는’ 바로 그 책임.”

솔라의 눈빛이 바뀌었다. 더 이상 Spring은 막연한 마법이 아니었다. 명확한 ‘책임’을 넘겨받는 대리인이었다. 하지만 한 가지 의문이 더 강하게 피어올랐다.

“그런데 언니, 한 강의 노트에서 ‘이 패턴을 그대로 Spring으로 가져옵니다’라는 구절을 봤어. 방금 내가 한 것처럼 외부에서 객체를 만들어 넣어주는 게 의존성 주입 ‘패턴’이라면, new를 쓰는 이 방식이 바로 그 패턴이잖아? 그런데 Spring은 new를 안 쓰게 해준다면서, 어떻게 이 패턴을 ‘그대로’ 가져간다는 걸까? 책임은 넘기는데 패턴은 유지된다니, 그게 대체 무슨 말이야?”

2장: Spring IoC: ‘new’의 제어권을 넘겨받다

솔라의 질문이 공중에 떠다녔다. ‘책임은 넘기는데 패턴은 유지된다.’ 모순처럼 들리는 문장이었다. new라는 구체적인 코드가 곧 패턴이라고 생각했던 솔라에게는 풀 수 없는 수수께끼였다.

루나는 솔라의 노트북 화면에서 시선을 떼고, 잠시 창밖을 바라보았다. 그러고는 펜을 들어 옆에 놓인 빈 노트 페이지에 단순한 그림 두 개를 나란히 그리기 시작했다. 왼쪽에는 카운터 뒤에서 직접 커피 머신을 만지고 있는 사람을, 오른쪽에는 카운터 앞에서 팔짱을 끼고 기다리는 사람과 그에게 커피를 건네는 바리스타를 그렸다.

“솔라, 우리가 자주 가는 카페를 생각해 보자.” 루나가 그림을 내밀며 말했다. “왼쪽은 네가 직접 주방에 들어가서, 원두를 고르고, 그라인더로 갈고, 에스프레소 샷을 내리고, 우유 거품을 내서 라떼를 만드는 모습이야. 오른쪽은 그냥 카운터에 가서 ‘라떼 한 잔 주세요’라고 말하고, 바리스타가 만들어준 라떼를 받아오는 모습이고.”

솔라는 어리둥절한 표정으로 그림을 들여다보았다. 갑자기 웬 카페 이야기람.

“두 상황 모두 ‘솔라가 라떼를 마신다’는 결과는 같아. 하지만 과정이 완전히 다르지. 뭐가 가장 크게 다르지?”

“음… 왼쪽은 내가 모든 걸 다 하고, 오른쪽은 바리스타가 대신해 주니까 훨씬 편하다는 거?” 솔라가 대답했다. 편리함. 그게 가장 먼저 떠오른 차이였다.

“편리함도 맞지만, 조금 더 깊게 들어가 보자.” 루나가 왼쪽 그림의 ‘직접 커피를 만드는 사람’을 펜으로 톡톡 건드렸다. “이 사람은 두 가지 역할을 동시에 하고 있어. 커피를 마실 ‘고객’ 역할과, 커피를 만드는 ‘바리스타’ 역할. 하지만 오른쪽은 어때?”

“역할이 분리되어 있네. 고객은 주문만 하고, 바리스타는 만들기만 해.”

“바로 그거야.” 루나가 말했다. “중요한 건, 라떼를 만드는 데 필요한 작업들—원두 갈기, 샷 내리기, 우유 데우기—그 자체가 사라졌나? 아니면 그 작업을 수행하는 ‘주체’가 바뀐 건가?”

루나의 질문에 솔라의 머릿속에서 무언가 번쩍했다. 라떼를 만드는 복잡한 과정은 사라지지 않았다. 여전히 누군가는 그 일을 해야만 한다. 다만 그 일을 하는 사람이 ‘나’에서 ‘바리스타’로 바뀌었을 뿐이다.

순간, 솔라는 숨을 멈추고 아까 자신이 짰던 OrderApplication 코드를 다시 떠올렸다.

public class OrderApplication {
    public static void main(String[] args) {
        // '바리스타'의 역할
        PaymentMethod paymentMethod = new CreditCardPayment();
        // '고객'에게 필요한 것을 전달
        OrderService orderService = new OrderService(paymentMethod);

        // '고객'의 역할
        orderService.processOrder(10000);
    }
}

“아…!”

솔라의 입에서 짧은 탄성이 터져 나왔다. 코드가 다르게 보이기 시작했다. main 메소드 안에서 new CreditCardPayment()를 하고, 그것을 new OrderService(...)에 넣어주던 자신의 모습이, 마치 카페 주방에 들어가 직접 커피를 만들던 그림 속 사람과 정확히 겹쳐 보였다. OrderService를 사용하는 ‘고객’이면서, 동시에 그것이 필요로 하는 PaymentMethod를 만드는 ‘바리스타’의 역할까지 하고 있었던 것이다.

“그럼… ‘이 패턴을 그대로 Spring으로 가져옵니다’에서 말하는 ‘패턴’은….” 솔라가 더듬거리며 말했다. “new를 써서 객체를 만드는 구체적인 행동이 아니라, ‘OrderService는 외부에서 PaymentMethod를 전달받는다’는 이 구조 자체를 말하는 거였구나!”

마치 단어의 진짜 의미를 처음 깨달은 사람처럼 솔라의 눈이 커졌다. 내가 직접 만들어서 주든(수동 주입), 바리스타가 만들어서 주든(Spring), OrderService가 외부로부터 의존성을 주입받는다는 큰 그림, 즉 ‘의존성 주입 패턴’은 그대로 유지되는 것이었다. Spring이 가져간 것은 new 키워드가 아니라, new를 사용해 객체를 만들고 연결하던 ‘책임’ 그 자체였다.

“맞아. 제어의 흐름이 역전된 거지.” 루나가 조용히 덧붙였다. “원래는 개발자인 네가 모든 객체의 생성과 연결을 제어했잖아. 그걸 프레임워크, 즉 Spring에게 넘겨주는 거야. 그래서 ‘제어의 역전(Inversion of Control, IoC)’이라고 불러. Spring에서는 그 바리스타 역할을 하는 존재를 ‘IoC 컨테이너’라고 부르고.”

‘제어의 역전’. 솔라는 그 단어를 가만히 곱씹었다. 더 이상 마법 같은 말이 아니었다. 그림 속에서 카운터의 제어권이 고객에게서 바리스타에게로 넘어가는, 지극히 당연하고 명확한 권한의 위임이었다. Spring은 마법사가 아니라, 유능한 바리스타이자 모든 것을 관리하는 총지배인이었던 셈이다.

이제 솔라는 ‘패턴’이라는 단어의 의미를 명확히 이해했다. 그리고 Spring이 가져가는 ‘책임’의 정체도 알게 되었다. 혼란스럽던 문장이 명쾌한 구조로 머릿속에 자리 잡았다.

하지만 곧 새로운 궁금증이 고개를 들었다.

“알겠어, 언니. 내가 할 일을 IoC 컨테이너라는 바리스타에게 맡기는 거구나. 그런데… 그 바리스타한테는 어떻게 주문을 하지? 카페에서는 메뉴를 보고 말로 주문하면 되잖아. Spring 컨테이너한테는 코드로 주문해야 할 텐데, new를 안 쓴다면 대체 어떤 말로 ‘이 OrderService에는 CreditCardPayment가 필요하니 만들어서 넣어줘’라고 알려주는 거야?”

3장: 어노테이션: IoC 컨테이너에게 보내는 지시

솔라의 질문이 남긴 여운을 깨고, 루나의 손가락이 키보드 위에서 움직였다. 루나는 솔라가 방금 전까지 작업하던 OrderApplication.java 파일의 내용을 지우기 시작했다. main 메소드 안에서 new 키워드로 객체를 생성하고 연결하던, 솔라에게는 이제 너무나 익숙해진 그 코드들이 사라졌다. 잠시의 정적이 흘렀다. 텅 빈 파일 대신, 루나는 이전에 솔라가 만들었던 CreditCardPayment 클래스와 OrderService 클래스를 나란히 화면에 띄웠다.

그리고는 마치 빠진 부품을 채워 넣듯, 몇 글자를 추가했다. CreditCardPayment 클래스 선언부 바로 위에 @Component라는 낯선 표식이 붙었다. OrderService 클래스의 생성자 위에는 @Autowired라는 또 다른 표식이 더해졌다. 솔라가 main 메소드에서 길게 작성했던 객체 생성과 연결의 책임이, 이제는 이 두 개의 @가 붙은 단어들로 대체된 것처럼 보였다.

솔라는 눈을 가늘게 뜨고 화면을 응시했다. ‘주문’의 방식이 궁금하다고 했을 뿐인데, 언니는 주문서 대신 결과물처럼 보이는 코드를 눈앞에 들이밀었다.

“이게… 주문서라고?” 솔라가 혼잣말처럼 중얼거렸다. “new도 없고, 객체를 넘겨주는 코드도 없는데… 이게 어떻게 CreditCardPayment를 만들어서 OrderService에 넣어주라는 말이 되는 거지? 그냥 주석이나 다름없어 보이는데.”

솔라의 목소리에는 당혹감이 묻어났다. @ 기호는 단순한 장식처럼 보였다. 강력한 new 키워드와 명시적인 메소드 호출을 대체하기에는 너무나 무력해 보였다. 마법을 믿지 않기로 했지만, 이건 마법보다 더 허무맹랑한 일처럼 느껴졌다.

루나는 말없이 솔라 옆에 놓인 노트와 펜을 솔라 쪽으로 밀어주었다.

new는 잠시 잊어 봐. 이제부터 우리는 IoC 컨테이너의 입장에서 생각하는 거야. 컨테이너가 이 두 개의 파일을 읽는다고 상상해 봐. 컨테이너는 아주 성실하지만, 딱 적힌 것만 읽고 시키는 일만 해. 이 코드들이 컨테이너에게 어떤 ‘지시’를 내리고 있는지, 그대로 한번 따라가 보는 거야.”

솔라는 잠시 망설이다 펜을 들었다. ‘컨테이너의 입장이라…’

“우선 CreditCardPayment.java 파일부터.” 루나가 화면의 왼쪽을 가리켰다.

솔라는 @Component라는 단어를 노트에 적었다. “@Component… 구성품? 부품이라는 뜻인가?”

“좋은 시작이야. 컨테이너는 @Component가 붙은 클래스를 발견하면, ‘아, 이건 내가 관리해야 할 부품이구나’ 하고 인식해. 그리고 그걸 자기만의 창고에 미리 만들어 보관해 둬. new CreditCardPayment()를 해서 말이야.”

“아, 결국 new를 누군가는 쓰는구나! 내가 안 할 뿐이지, 컨테이너가 대신 해주는 거였네.” 솔라는 노트에 @Component 옆에 ‘컨테이너가 new로 만들어서 보관할 대상’이라고 적고, CreditCardPayment라고 쓰인 네모 상자를 그렸다. 창고에 들어간 부품처럼.

“자, 그럼 이제 두 번째 파일.” 루나가 OrderService.java가 띄워진 오른쪽 화면을 가리켰다.

솔라의 시선이 @Autowired로 향했다.

OrderService 클래스를 보니, 생성자가 PaymentMethod 타입의 객체를 필요로 하고 있어. 그리고 그 위에 @Autowired라고 적혀 있네. 이건 무슨 뜻일까?”

솔라는 자신이 그린 CreditCardPayment 상자를 내려다보았다. 그리고 다시 화면의 코드를 보았다. @Autowired는 무언가 ‘연결’을 바라는 듯한 단어였다. 자동-연결.

“‘이 생성자를 실행하려면 PaymentMethod가 필요한데, 자동으로 연결해 주세요’… 라는 요청인가?”

“정확해. 컨테이너는 @Autowired를 보면, ‘여기 부품이 필요하구나. 어떤 타입의 부품이지? 아, PaymentMethod 타입이네.’ 하고 자신의 창고를 뒤지기 시작해.”

솔라는 펜을 들어 OrderService라고 쓰인 두 번째 네모 상자를 그렸다. 그리고 그 상자 안으로 향하는 화살표를 그리기 시작했다.

“그리고 컨테이너는 창고에서… 아까 @Component 딱지가 붙어서 만들어 뒀던 CreditCardPayment 객체를 발견하겠지! CreditCardPaymentPaymentMethod 인터페이스의 구현체니까 타입이 맞고. 그래서 그걸 OrderService의 생성자에 인자로 쏙 넣어주는 거야.”

솔라는 마침내 화살표의 끝을 OrderService 상자에 연결했다. CreditCardPayment 상자에서 시작된 화살표가 OrderService 상자에 꽂히는 그림. 그 화살표 위에는 @Autowired라고 적혀 있었다. new 키워드 하나 없이, 두 객체가 연결되는 과정이 눈앞의 그림으로 명확하게 완성되었다.

“이거였구나!”

솔라는 무릎을 탁 쳤다. @ 기호는 주석이나 장식이 아니었다. 그것은 IoC 컨테이너라는 거대한 기계를 움직이는 명백한 ‘지시문’이자 ‘요청서’였다. @Component는 ‘이 부품을 등록하고 보관하라’는 지시였고, @Autowired는 ‘보관된 부품 중 맞는 것을 찾아 연결하라’는 요청이었다. 개발자는 더 이상 new를 이용해 직접 객체를 조립하지 않는다. 대신, 어노테이션이라는 ‘자동 주입 표식’을 통해 조립 설명서를 작성하고, 실제 조립은 컨테이너에게 맡기는 것이었다.

“알겠어. new가 사라진 게 아니라, 내 코드에서 컨테이너의 코드로 책임이 옮겨간 거였어. 그리고 어노테이션은 그 책임을 넘기기 위한 소통 수단인 거고.”

솔라는 자신이 그린 그림을 만족스럽게 바라보았다. 의존성이라는 보이지 않는 관계가 선과 상자로 명확하게 드러나 있었다. 그런데 그림을 계속 들여다보던 솔라의 얼굴에 새로운 의문이 떠올랐다.

“그런데 언니, 좀 이상한 게 있어. 방금 내가 그린 그림대로라면, 컨테이너는 CreditCardPayment 객체를 딱 하나만 만들어서 창고에 넣어두는 거잖아. 그리고 OrderService가 필요하다고 할 때 그걸 주고, 나중에 또 다른 서비스, 예를 들면 SubscriptionService 같은 게 PaymentMethod를 달라고 하면… 또 똑같은 걸 주는 거야?”

솔라는 잠시 말을 멈추고 자신의 질문이 가진 의미를 생각했다.

“만약 OrderService가 그 CreditCardPayment 객체 안의 어떤 값을 잠시 바꿨다고 생각해 봐. 그런데 그사이에 SubscriptionService가 와서 똑같은 객체를 가져다 쓰면… 엉뚱한 값으로 결제되는 거 아냐? 여러 사람이 같은 물건을 돌려쓰는 셈인데, 이거 괜찮은 거야?”

4장: Spring Bean의 무상태: 공유와 재사용의 원칙

솔라의 펜이 다시 노트 위를 움직였다. @Component 딱지가 붙은 CreditCardPayment 상자에서 @Autowired 화살표가 OrderService 상자로 이어지는 그림. 여기에 무언가 빠졌다는 생각이 들었다. 그녀는 그림 한쪽에 SubscriptionService라는 새 상자를 그리고, 그 안에도 PaymentMethod가 필요하다고 적었다.

그리고 잠시 망설이던 솔라는, 기존 CreditCardPayment 상자에서 두 번째 화살표를 끌어와 새로 그린 SubscriptionService 상자에 연결했다. 그러자 단 하나의 CreditCardPayment 객체에서 두 개의 화살표가 뻗어 나가는 모양이 되었다. 하나의 객체를 두 서비스가 공유하는 모습. 솔라는 펜을 내려놓고 그림을 뚫어지게 쳐다보았다. 이 그림은 어딘가 불안했다.

루나는 솔라의 질문에 바로 대답하는 대신, 잠시 생각에 잠긴 듯 허공을 응시했다. 그러더니 엉뚱하게도 이렇게 물었다.

“솔라, 사람이 붐비는 기차역의 공용 화장실과, 우리 집 안방에 딸린 전용 화장실을 생각해 봐. 둘의 가장 큰 차이점이 뭘까?”

갑작스러운 화장실 비유에 솔라는 잠시 어리둥절했다. 코드 얘기를 하다가 갑자기 웬 화장실. 하지만 솔라는 루나의 질문에는 항상 의도가 있다는 것을 알았다. 솔라는 잠시 기차역의 번잡함과 우리 집의 편안함을 떠올렸다.

“음… 일단 전용 화장실은 나만 쓰니까 내 칫솔이나 수건 같은 개인 물품을 그냥 둘 수 있지. 하지만 공용 화장실에 내 물건을 두고 나오진 않잖아. 다음 사람이 그걸 쓸 수도 있고, 잃어버릴 수도 있으니까.”

“그렇지. 공용 화장실을 쓸 때의 암묵적인 규칙은 ‘자신이 사용하기 전의 깨끗한 상태 그대로 다음 사람에게 넘겨주는 것’이야. 내 개인적인 흔적, 즉 ‘상태’를 남기면 안 돼.”

루나는 말을 잠시 끊고 솔라의 눈을 바라보았다.

“만약 어떤 사람이 공용 화장실을 쓰고 나오면서 물을 내리지 않았다고 생각해 봐. 그럼 다음 사람은 어떻게 될까?”

“으… 끔찍하지. 그 사람은 이전 사람이 남긴 ‘상태’ 때문에 곤란을 겪게 되는 거네.”

순간, 솔라의 머릿속에서 두 개의 이미지가 겹쳐졌다. 물이 내려가지 않은 공용 화장실과, OrderService가 사용한 후 값이 변경된 채로 남아있는 CreditCardPayment 객체.

“아…!”

짧은 탄성과 함께 솔라의 표정이 환하게 바뀌었다.

“똑같구나! 언니가 말한 ‘공용 화장실’이 바로 Spring 컨테이너가 관리하는 싱글톤(singleton) 객체, 즉 ‘빈(Bean)’이었어! 내가 걱정했던 건, OrderService라는 사람이 공용 화장실에 자기 물건을 두고 나와서, 다음에 들어간 SubscriptionService라는 사람이 피해를 보는 상황이었던 거야.”

솔라는 흥분하며 말을 이었다.

“그래서 Spring의 기본 원칙이 그거였구나. 여러 요청이 함께 쓰는 공용 객체, 즉 빈은 ‘상태를 가지지 않도록(Stateless)’ 만들어야 한다는 것! 애초에 개인 물품을 두고 나올 수 없는 구조로 만들어야 문제가 생기지 않는 거였어.”

솔라는 다시 자신의 CreditCardPayment 클래스 코드를 들여다보았다.

class CreditCardPayment implements PaymentMethod {
    // 멤버 변수(상태)가 없음!

    public void pay(int amount) { // 필요한 데이터는 파라미터로 받음
        System.out.println(amount + "원을 신용카드로 결제합니다.");
    }
}

이제 보니 이 클래스는 완벽하게 ‘상태가 없는’ 구조였다. 클래스 내부에 사용자 정보나 결제 금액 같은 데이터를 저장하는 멤버 변수가 없었다. pay 메소드는 실행에 필요한 모든 데이터(amount)를 외부에서 파라미터로 전달받아 그 순간에만 사용하고, 메소드가 끝나면 데이터도 함께 사라졌다. 마치 화장실에 들어갈 때 필요한 휴지만 들고 들어가고, 나올 땐 아무것도 남기지 않는 사람처럼.

“문제가 생길까 봐 걱정했는데, 그게 아니라 그렇게 만들면 안 된다는 ‘규칙’이었네. 컨테이너가 객체를 하나만 만들어 재사용하는 전략은, 개발자가 그 객체를 ‘무상태’로 만들 거라는 믿음 위에서 성립하는 거였어.”

솔라는 고개를 끄덕였다. 모든 객체가 상태를 가져야 유용하다고 생각했던 것은 편협한 시각이었다. 오히려 상태가 없기 때문에 여러 요청이 안전하고 효율적으로 공유할 수 있는 것이었다. 빈을 무상태로 유지하는 것이 단순한 권장 사항이 아니라, Spring의 DI 컨테이너가 원활하게 작동하기 위한 핵심적인 설계 원칙임을 깨달았다.

이제 Spring이 객체를 관리하는 방식의 큰 그림이 보였다. new의 책임을 가져간 IoC 컨테이너, @ 어노테이션이라는 주문서, 그리고 무상태(Stateless) 빈이라는 공유 자원의 원칙까지. 퍼즐 조각들이 하나씩 맞춰지고 있었다.

하지만 조각들이 맞춰질수록 마지막에 남을 그림의 전체 모습이 더욱 궁금해졌다.

“알겠어, 언니. 수동으로 내가 하나하나 객체를 만들 때와 비교해서, Spring이 IoC 컨테이너를 통해 무상태 빈을 자동으로 주입해주는 방식의 개별적인 장점들은 이제 알 것 같아. 그런데… 이 모든 걸 합쳤을 때, 개발자로서 우리가 최종적으로 얻는 이득은 정확히 뭘까? 그냥 코드가 좀 짧아지고, 객체를 재사용해서 메모리가 절약된다는 게 전부일까? 이 복잡한 구조를 배우는 것 이상의 더 큰 가치가 있을 것 같은데.”

5장: 수동에서 자동으로: DI 전환의 이점

솔라의 머릿속은 잘 정리된 부품 창고 같았다. new의 책임을 넘겨받는 ‘IoC 컨테이너’라는 관리자, 부품을 등록하고 연결을 요청하는 @ 어노테이션이라는 주문서, 그리고 여러 곳에서 안전하게 공유되는 ‘무상태 빈’이라는 공용 부품까지. 각 부품의 역할과 원리는 이제 선명하게 이해됐다. 하지만 솔라는 여전히 고개를 갸웃거리고 있었다. 이 모든 것을 합쳐 만든 최종 제품, 즉 Spring DI라는 시스템이 주는 가장 큰 가치가 무엇인지 한 문장으로 명쾌하게 설명하기 어려웠다. 단순히 코드가 짧아지고 편리해진다는 것만으로는 이 복잡한 구조를 도입하는 이유를 설명하기에 부족하게 느껴졌다.

그때, 루나가 솔라의 노트북 옆에 있던 화이트보드를 가져와 책상 사이에 세웠다. 매직으로 가운데에 길게 선을 긋고, 왼쪽에는 ‘수동 조립 (new)’, 오른쪽에는 ‘자동 조립 (Spring DI)’라고 적었다. 마치 토론 대결을 준비하는 것 같았다.

“자, 이제 솔라 네가 우리 팀에 새로 온 주니어 개발자에게 Spring DI를 왜 써야 하는지 설명해야 하는 상황이야. 나는 아무것도 모르는 신입이고.” 루나가 장난기 섞인 얼굴로 말했다. “이 화이트보드에 두 방식의 장단점을 적어가면서 나를 한번 설득해 봐.”

갑작스러운 역할극 제안이었지만, 솔라는 오히려 마음이 편해졌다. 막연하게 머릿속으로만 맴돌던 생각을 끄집어낼 좋은 기회였다. 솔라는 매직을 들고 잠시 고민하다, 오른쪽 ‘자동 조립’ 칸에 자신 있게 첫 항목을 적었다.

‘- 코드가 간결해짐 (new 직접 안 씀)’ ‘- 객체 재사용으로 메모리 효율 좋음 (싱글톤)’

“보세요.” 솔라가 신입 개발자를 대하듯 루나에게 설명하기 시작했다. “Spring을 쓰면, 우리가 main 메소드에서 new CreditCardPayment(), new OrderService(...) 이렇게 직접 객체를 만들고 연결할 필요가 없어요. 그냥 어노테이션만 붙이면 컨테이너가 알아서 해주거든요. 코드도 짧아지고, 객체도 하나만 만들어서 재사용하니까 효율적이죠.”

루나는 팔짱을 끼고 고개를 갸웃하며, 정말 아무것도 모르는 신입처럼 질문을 던졌다.

“흠… 코드가 몇 줄 짧아지는 건 좋은데요. 그것 때문에 이 복잡한 프레임워크 전체를 배워야 하나요? 그리고 만약에, 저희 서비스에 신용카드 결제 말고 카카오페이를 추가해야 하면 어떻게 되죠? 수동 방식에서는 그냥 new CreditCardPayment()new KakaoPayPayment()로 한 줄만 바꾸면 되잖아요. 오히려 더 간단한 거 아니에요?”

날카로운 질문이었다. 솔라는 순간 말문이 막혔다. 편리함과 효율성만으로는 루나의 반론에 제대로 답할 수 없었다. ‘오히려 더 간단하다’는 말에 허를 찔린 기분이었다. 솔라는 매직을 든 채 화이트보드를 멍하니 바라보았다. 수동 방식의 OrderApplication 코드가 머릿속에 떠올랐다.

// 수동 방식
public class OrderApplication {
    public static void main(String[] args) {
        // 만약 카카오페이를 추가하려면? 이 부분을 수정해야 함!
        PaymentMethod paymentMethod = new CreditCardPayment(); 
        // PaymentMethod paymentMethod = new KakaoPayPayment();
        OrderService orderService = new OrderService(paymentMethod);
        orderService.processOrder(10000);
    }
}

루나의 말이 맞았다. 결제 수단을 바꾸고 싶으면, OrderApplication.java 파일을 열어서 new 뒷부분만 고치면 끝이었다. 정말 간단해 보였다. 그런데… 정말 그럴까? 솔라는 고개를 저었다. 무언가 중요한 걸 놓치고 있었다.

바로 그 순간, 솔라의 시선이 OrderApplication이라는 클래스 이름에 꽂혔다. 이 클래스는 OrderService를 ‘사용하는’ 클라이언트 코드다. 그런데 이 클라이언트가 자기가 사용할 서비스의 의존성인 CreditCardPayment의 구체적인 타입까지 직접 알고, 생성하고 있다. OrderService뿐만 아니라 CreditCardPayment에도 단단히 묶여있는 것이다.

“아니요, 간단하지 않아요!”

솔라가 외치듯 말했다. 그녀는 화이트보드의 왼쪽 ‘수동 조립’ 칸 아래에 새로운 내용을 빠르게 적어 내려갔다.

‘- 사용하는 쪽(Client)과 부품(Dependency)이 단단히 묶여있음 (강한 결합)’

“보세요!” 솔라의 목소리에 확신이 실렸다. “수동 방식에서는 OrderApplicationCreditCardPayment라는 구체적인 클래스를 알아야만 해요. 만약 KakaoPayPayment로 바꾸려면 OrderApplication.java 파일 자체를 수정하고 다시 컴파일해야 하죠. 지금은 결제 방식 하나지만, 만약 주문 서비스가 배송 업체, 쿠폰 시스템 등 수십 개의 부품을 사용한다면요? 부품 하나 바뀔 때마다 주문 서비스를 사용하는 모든 코드를 다 찾아다니면서 고쳐야 해요. 이건 재앙이에요!”

솔라는 숨을 고르고, 시선을 오른쪽 ‘자동 조립’ 칸으로 옮겼다. 이제 Spring 방식의 진짜 가치가 보이기 시작했다.

“하지만 Spring을 쓰면 이야기가 완전히 달라져요. OrderService는 그냥 생성자에 @Autowired만 붙여놓고 ‘나는 PaymentMethod 인터페이스를 구현한 부품이 필요해’라고 선언만 할 뿐, 그게 CreditCardPayment인지 KakaoPayPayment인지 전혀 몰라요. 전혀 관심도 없고요.”

솔라는 오른쪽 칸에 새로운 항목들을 추가했다.

‘- 사용하는 쪽과 부품이 분리됨 (느슨한 결합)’ ‘- 부품 교체가 자유로움 (유연성, 확장성)’ ‘- 테스트가 쉬워짐’

“우리는 그냥 KakaoPayPayment 클래스에 @Component 딱지만 붙여주면 돼요. 그러면 외부 설정 파일이나 다른 방식을 통해 어떤 부품을 사용할지 선택만 하면 끝이죠. OrderService 코드는 단 한 줄도 건드릴 필요가 없어요. 이게 바로 ‘유연성’이에요. 그리고 테스트할 때는 실제 결제 모듈 대신에 ‘가짜 결제 모듈(Mock Object)’을 쉽게 주입해서 테스트할 수 있고요. 유지보수, 확장, 테스트 모든 면에서 비교가 안 될 만큼 좋아지는 거예요.”

솔라는 설명을 마치고 화이트보드를 바라보았다. 이제 양쪽의 차이가 명확하게 보였다. Spring DI는 단순히 코드를 줄여주는 편의 기능이 아니었다. 그것은 부품(객체)들을 서로에게서 자유롭게 만들어, 언제든 쉽게 갈아 끼울 수 있는 유연한 설계를 가능하게 하는 강력한 ‘구조’였다. new의 제어권을 포기함으로써, 개발자는 그보다 훨씬 더 큰 ‘유연성’과 ‘확장성’이라는 가치를 얻는 것이었다.

루나가 조용히 미소 지으며 고개를 끄덕였다. 더 이상 질문이 필요 없는 완벽한 설명이었다.

솔라는 다시 한번 강의 노트의 그 문장을 떠올렸다. ‘이 패턴을 그대로 Spring으로 가져옵니다.’ 이제 그 의미가 뼛속까지 이해되었다. ‘외부에서 의존성을 주입한다’는 설계 ‘패턴’은 그대로 가져오되, 그 실행 주체를 개발자에서 컨테이너로 ‘전환’함으로써 코드의 결합을 끊고 자유를 얻는 것. 그것이 바로 이 모든 이야기의 핵심이었다.

솔라는 자신의 자리로 돌아와, 새로 시작할 프로젝트의 기획 문서를 화면에 띄웠다. ‘배송 시스템 모듈: 기본 우체국 택배, 추후 CJ 대한통운 및 퀵서비스 추가 예정.’ 이 문구를 본 순간, 솔라의 머릿속에는 더 이상 막막함이 떠오르지 않았다. 대신, ShippingService라는 인터페이스와 @Component가 붙을 여러 배송 업체 클래스, 그리고 이들을 주입받을 OrderService의 모습이 선명하게 그려졌다. 그녀는 망설임 없이 키보드에 손을 올리고, 미래의 변경을 유연하게 받아들일 수 있는 첫 인터페이스 코드를 작성하기 시작했다. 수동 주입의 굴레에서 벗어나, 진정한 자동 주입의 힘을 자신의 설계에 녹여내고 있었다.