Java 29

인터페이스 타입 변수, DI, Spring Boot 자동 주입: 결제 시스템으로 미리보기

인터페이스 타입 변수는 가능하다고 배웠지만, 이것이 DI나 Spring 자동 주입과 어떻게 이어지는지 손에 잡히지 않는다.

근거 · 교안 p210-p218

인터페이스 타입 변수, DI, Spring Boot 자동 주입: 결제 시스템으로 미리보기 대표 이미지

1장: 변수와 객체의 이별: 인터페이스 타입 변수의 첫 걸음

솔라의 손가락이 노트북 트랙패드 위에서 멈췄다. 화면에는 간결한 코드 예시와 함께 이런 문장이 적혀 있었다. ‘OrderService는 구현체가 아니라 PaymentMethod 인터페이스에 의존하면 결제 수단을 쉽게 바꿀 수 있다.’

솔라는 문장을 여러 번 곱씹었다. 단어 하나하나는 다 아는 말이었다. 인터페이스, 의존, 구현체 교체. 머릿속에서는 개념들이 둥둥 떠다녔지만, 문장이 약속하는 ‘쉬운 교체’라는 결과물과는 단단히 연결되지 않았다. 마치 레고 블록들이 흩어져 있는데, 완성된 성의 모습만 보여주는 설명서를 보는 기분이었다.

“인터페이스 타입 변수에 카카오페이 객체를 담든, 네이버페이 객체를 담든… 그냥 큰 상자에 내용물만 바꿔 넣는 거랑 뭐가 다르지?”

혼잣말은 허공에 흩어졌다. 인터페이스가 여러 구현체를 담을 수 있는 ‘그릇’이라는 건 이미 알고 있었다. 하지만 그게 어떻게 ‘결제 수단을 쉽게 바꾸는’ 결정적인 열쇠가 되는지는 와닿지 않았다. 그저 편리한 기능 정도로만 느껴졌다.

그때, 거실 소파에 앉아 있던 루나 언니가 조용히 고개를 들었다. 솔라의 혼잣말을 들은 모양이었다. 루나는 솔라가 뚫어져라 보고 있는 화면을 힐끗 보더니, 말없이 옆에 있던 스케치북과 펜을 끌어당겼다.

“솔라야, 이리 와서 이거 한번 볼래?”

스케치북에는 장난감 가게의 진열대를 닮은 간단한 그림이 그려지기 시작했다. 루나는 ‘판매 가능한 물건(Sellable)’이라는 제목 아래, 텅 빈 네모칸을 하나 그렸다.

“이 칸에는 ‘판매 가능한 물건’만 들어올 수 있어. 이 가게의 규칙이야.”

루나는 펜으로 ‘판매 가능한 물건’이 가져야 할 단 하나의 조건, ‘가격을 알려줄 수 있다(getPrice)’를 적었다. 그리고는 그 규칙을 따르는 두 개의 작은 객체 모형을 그렸다. 하나는 자동차(Car), 다른 하나는 트럭(Truck)이었다.

솔라는 언니의 의도를 따라가 보기로 했다. “당연히 둘 다 가격을 알려줄 수 있으니, ‘판매 가능한 물건’이 맞네.”

“그렇지.” 루나는 고개를 끄덕이며 코드처럼 보이는 짧은 글을 스케치북에 적어 내려갔다.

// '판매 가능한 물건'이라는 규칙 (인터페이스)
interface Sellable {
    int getPrice();
}

// 규칙을 지키는 '자동차'와 '트럭'
class Car implements Sellable {
    public int getPrice() { return 1000; }
}

class Truck implements Sellable {
    public int getPrice() { return 2000; }
}

“자, 그럼 이제 가게 진열대에 물건을 하나 올려놓아 보자.” 루나는 텅 비었던 네모칸을 가리키며 말했다.

Sellable item = new Car();

“진열대의 이름표는 ‘Sellable’ 타입인데, 실제 놓인 물건은 ‘Car’ 객체네.” 솔라가 말했다. 아직까진 당연한 이야기였다.

“이제 손님이 와서 트럭을 찾아서, 진열된 상품을 트럭으로 바꿔야 해.”

루나는 솔라가 보고 있는 item 변수 코드를 손가락으로 짚었다. 솔라는 고개를 갸웃하며 대답했다. “그럼… item = new Truck(); 이렇게 바꾸면 되지.”

“맞아.” 루나는 다음 줄을 쓰는 대신, 솔라에게 질문을 던졌다. “방금 코드에서 바뀐 건 어느 부분이지? item이라는 변수 자체가 바뀐 거야, 아니면 그 변수가 가리키는 대상이 바뀐 거야?”

순간 솔라의 머릿속에서 무언가 번쩍했다. 늘 한 덩어리로 생각했던 변수 선언과 객체 생성이 분리되어 보이기 시작했다.

“아… Sellable item이라는 변수의 선언, 그러니까 ‘판매 가능한 물건을 담는 자리’라는 사실 자체는 그대로 있네. 그 자리에 놓이는 실제 물건만 Car에서 Truck으로 바뀐 거고.”

솔라는 자신이 뱉은 말을 스스로 곱씹었다. 그릇의 내용물을 바꾼다는 단순한 생각을 넘어서는 지점이었다. 중요한 것은 ‘그릇’ 자체가 어떤 종류의 물건을 담을지 약속하고 있다는 점이었다. 그릇의 타입은 Sellable, 즉 ‘판매 가능한 물건’으로 고정되어 있었다. 그릇은 내용물이 자동차인지 트럭인지 신경 쓰지 않았다. 그저 ‘판매 가능한’ 규칙을 지켰는지에만 관심이 있었다. 변수의 타입과 실제 객체가 서로 다른 정체성을 가지고 각자의 역할을 하고 있었다.

루나는 말없이 스케치북의 마지막 부분을 채웠다.

Sellable[] items = { new Car(), new Truck() };

for (Sellable item : items) {
    System.out.println("가격: " + item.getPrice());
}

“이렇게 하면 진열대에 뭐가 있든, 우리는 그냥 ‘판매 가능한 물건’들을 쭉 훑으면서 가격을 물어보면 돼. 각각이 자동차인지, 트럭인지 일일이 확인할 필요 없이.”

솔라는 스케치북의 코드를 보며 고개를 끄덕였다. “변수의 타입(Sellable)과 실제 객체(Car, Truck)가 분리된다는 게 이런 의미였구나. 변수는 ‘어떤 행동을 할 수 있다’는 약속만 정의하고, 실제 행동은 객체가 하는 거네.”

이제야 처음 봤던 문장이 조금 다르게 보이기 시작했다. ‘OrderService는 PaymentMethod 인터페이스에 의존한다.’ 이 말은 OrderService가 ‘결제’라는 행동 약속에만 의존한다는 뜻이었다. Sellable 변수가 CarTruck의 구체적인 모습에 관심 없었던 것처럼.

하지만 여전히 풀리지 않은 매듭이 남아 있었다.

솔라는 다시 노트북 화면으로 시선을 돌리며 중얼거렸다. “좋아, 변수 타입과 객체가 분리되는 건 알겠어. PaymentMethod라는 이름표를 단 자리에 KakaoPayNaverPay든 놓을 수 있다는 거. 그런데… 결국 OrderService 안에서 PaymentMethod payment = new KakaoPay(); 같은 코드를 써서 실제 결제 수단을 정해줘야 하는 거 아냐? 그럼 결제 수단을 바꾸려면 OrderService 코드를 열어서 new KakaoPay()new NaverPay()로 직접 고쳐야 하잖아. 이게 어떻게 ‘쉬운 교체’라는 거지? 그냥 문제를 한 단계 뒤로 미룬 것뿐인데.”

2장: 직접 ‘new’의 함정: 강한 결합의 대가

솔라의 중얼거림을 들은 루나는 잠시 생각에 잠기더니, 아까 사용했던 스케치북의 새 페이지를 펼쳤다. ‘판매 가능한 물건’ 진열대 그림 대신, 이번에는 큼직한 직사각형 두 개를 그렸다. 왼쪽 사각형에는 ‘OrderService(주문 서비스)’, 오른쪽에는 ‘KakaoPay(카카오페이)’라고 적었다.

루나는 아무 말 없이 두 사각형 사이에 굵은 선을 그어 직접 연결했다. 마치 하나의 부품처럼 단단히 붙어있는 모습이었다. 이 그림은 솔라가 방금 머릿속으로 그렸던 코드의 구조와 정확히 일치했다. OrderService가 자신의 코드 안에서 직접 KakaoPay 객체를 생성하는 모습.

솔라는 그 그림을 보고 자신의 생각이 맞았다고 확신하며 말했다. “언니, 내 말이 맞지? 결국 OrderService 안에서 어떤 결제 수단을 쓸지 new KakaoPay()처럼 정해줘야 하잖아. 이게 제일 직관적이고 간단한 방법 아니야? 코드를 여기서 만들고, 여기서 바로 쓰는 거니까.”

솔라의 목소리에는 그 방식이 가장 효율적이라는 믿음이 섞여 있었다. 복잡하게 생각할 것 없이 필요한 부품을 그 자리에서 바로 만드는 것. 그게 왜 문제가 되는지 이해할 수 없었다.

“네 말이 맞아. 가장 직관적인 방법이지.” 루나는 솔라의 말을 부정하지 않고 고개를 끄덕였다. “그럼 그 직관적인 방법으로 한번 코드를 짜볼까?”

루나는 ‘OrderService’라고 적힌 사각형 안쪽에 작은 글씨로 코드를 써 내려가기 시작했다. 솔라가 머릿속으로 상상했던 바로 그 코드였다.

public class OrderService {
    // 주문 서비스가 직접 카카오페이 객체를 생성
    private KakaoPay payment = new KakaoPay();

    public void processOrder(long amount) {
        // ... 주문 처리 로직 ...
        System.out.println("주문 처리를 시작합니다.");
        payment.pay(amount);
    }
}

class KakaoPay {
    public void pay(long amount) {
        System.out.println("카카오페이로 " + amount + "원 결제 완료.");
    }
}

솔라는 코드를 훑어보았다. 모든 것이 OrderService 클래스 안에 깔끔하게 정리되어 있었다. processOrder가 호출되면, 내부에 이미 준비된 payment 객체, 즉 KakaoPay를 사용해 결제를 진행한다. 단순하고 명쾌했다.

“완벽해 보이는데. 문제없어 보여.” 솔라가 자신 있게 말했다.

그때 루나가 스케치북 바깥쪽 여백에 새로운 지시사항을 적었다. ‘긴급: 우리 쇼핑몰의 주 결제 수단을 네이버페이로 변경하기로 결정.’

솔라는 새로운 지시사항과 OrderService 코드를 번갈아 보았다. 잠시 정적이 흘렀다. 변경은 간단해 보였다. OrderService.java 파일을 열고, 코드 한 줄만 고치면 된다.

“음… private KakaoPay payment = new KakaoPay(); 이 부분을… private NaverPay payment = new NaverPay();로 바꾸면 되지. 물론 NaverPay 클래스도 만들어야 하고.” 솔라는 대답하면서도 무언가 찜찜한 기분을 떨칠 수 없었다.

루나는 솔라의 대답을 듣고 펜으로 OrderService 사각형을 툭툭 쳤다. “방금 네가 말한 작업을 다시 생각해 봐. ‘결제 수단을 바꾸기 위해’ 우리는 어딜 수정해야 했지?”

OrderService 클래스.”

“맞아.” 루나는 펜 끝으로 아까 그었던 두 사각형 사이의 굵은 선을 다시 한번 진하게 그렸다. “결제 정책이 바뀌었을 뿐인데, 아무 상관도 없는 주문 처리 로직이 담긴 OrderService 코드를 열어서 수정해야만 했어. OrderServiceKakaoPay가 없으면 아예 동작조차 할 수 없는 상태인 거야. 둘은 너무 단단하게 붙어있어.”

그 순간, 솔라의 머릿속에서 흩어져 있던 의문들이 하나의 이미지로 합쳐졌다. 루나가 그린 그림 속 단단히 붙어버린 두 개의 사각형. 마치 강력 접착제로 붙여 놓은 레고 블록 같았다. 하나를 바꾸려면 다른 하나도 뜯어내거나, 심하면 부숴야 하는 상태.

“아…! OrderServiceKakaoPay라는 특정 구현체를 너무 자세히 알고 있어서 생기는 문제구나. OrderService는 그냥 ‘결제’라는 기능만 필요할 뿐인데, 자기가 직접 ‘카카오페이’라는 부품을 만들어서 쓰니까, 다른 부품으로 바꾸려면 OrderService 자신을 수술해야 하는 거네.”

솔라는 ‘쉬운 교체’의 반대말이 무엇인지 온몸으로 깨달았다. 그것은 ‘어려운 교체’를 넘어 ‘위험한 교체’였다. OrderService를 수정하다가 실수로 주문 처리 로직을 잘못 건드리기라도 하면 어떡하지? 고작 결제 수단 하나 바꿨을 뿐인데, 전체 주문 시스템을 다시 테스트해야 하는 상황이 올 수도 있었다.

“바로 그거야.” 루나가 말했다. “이렇게 한쪽이 다른 쪽의 구체적인 모습에 너무 깊이 의존해서, 서로를 바꾸지 않고는 변경이 불가능한 상태를 ‘강한 결합(Tight Coupling)’이라고 불러.”

‘강한 결합’. 솔라는 그 단어를 입안에서 굴려보았다. 이전까지는 그저 추상적인 개념이었지만, 이제는 코드 수정의 번거로움과 잠재적인 위험이라는 명확한 실체로 다가왔다. 직접 new를 사용하는 직관적인 방식이 만들어낸 함정이었다.

솔라는 고개를 끄덕였다. 이제 OrderService가 구현체에 직접 의존하면 왜 변경이 어려운지 명확히 이해했다. 문제는 선명해졌다. 하지만 해답은 보이지 않았다.

“알겠어, 언니. 이렇게 둘을 강력 접착제로 붙여놓는 게 문제라는 건 알겠어. 그럼… 접착제를 안 쓰고 어떻게 둘을 연결하지? OrderService 코드는 전혀 건드리지 않고 결제 수단을 바꿀 수 있는 방법이 대체 뭐야?”

3장: 인터페이스에 기대다: 구현체 교체의 비밀

솔라의 마지막 질문이 맴도는 거실에는 잠시 정적이 흘렀다. “접착제를 안 쓰고 어떻게 둘을 연결하지?”

루나는 대답 대신, 강력 접착제로 붙어있던 두 개의 레고 블록 그림이 그려진 스케치북 페이지를 가만히 내려다보았다. ‘OrderService’와 ‘KakaoPay’가 굵은 선으로 연결된 그림. 언니는 말없이 펜 뒤쪽으로 그 굵은 선을 벅벅 지워버렸다. 두 사각형은 이제 아무 연결 없이 허공에 덩그러니 놓여 있었다. 분리되었지만, 동시에 아무 일도 할 수 없는 외딴 섬이 되었다.

루나는 두 사각형 사이에 작은 직사각형 하나를 새로 그렸다. 그리고 그 안에 ‘결제 방식 계약서’라고 썼다.

“접착제 대신 계약서를 쓰는 거야.”

루나의 말에 솔라는 고개를 갸웃했다. “계약서?” 인터페이스에 의존해야 한다는 말은 여러 번 들어봤지만, 그게 코드 상에서 어떤 모습으로 구현되는지, 어떤 효과를 가져오는지 명확히 그려지지 않았다. 그냥 ‘그래야 한다’는 규칙처럼 느껴질 뿐이었다.

OrderService가 원하는 건 ‘카카오페이’라는 특정 회사가 아니야. 그냥 ‘결제 기능’ 그 자체지.” 루나는 ‘결제 방식 계약서’ 사각형 안쪽에 코드 한 줄을 적었다.

// 결제 방식 계약서 (인터페이스)
interface PaymentMethod {
    void pay(long amount);
}

“이 계약서에 서명한 회사는 누구든 pay라는 행동을 할 수 있음을 보장해야 해. 이제 카카오페이와 네이버페이가 이 계약서에 서명하도록 만들어 보자.”

루나는 ‘KakaoPay’와 ‘NaverPay’ 사각형 옆에 각각 ‘implements PaymentMethod’라는 글자를 덧붙였다.

class KakaoPay implements PaymentMethod {
    public void pay(long amount) {
        System.out.println("카카오페이로 " + amount + "원 결제 완료.");
    }
}

class NaverPay implements PaymentMethod {
    public void pay(long amount) {
        System.out.println("네이버페이로 " + amount + "원 결제 완료.");
    }
}

“좋아, 이제 계약서도 있고, 계약서에 서명한 회사들도 준비됐어.” 솔라가 말했다. “이제 이걸로 어떻게 OrderService를 바꾸는데? new KakaoPay()를 못 쓰게 됐잖아.”

“바로 그게 핵심이야.” 루나는 이전 장에서 작성했던 OrderService 코드를 다시 스케치북에 옮겨 적었다. 그리고 수정이 필요한 부분에 펜을 가져갔다.

“이전 코드의 문제는 OrderService가 ‘카카오페이’라는 특정 직원의 이름표를 직접 들고 있었던 거야. 이제 우리는 그 직원 이름표 대신, ‘결제 담당’이라는 직책 이름표를 들고 있을 거야.”

루나는 private KakaoPay payment 부분을 지우고 새로 고쳐 썼다.

// 수정 전
public class OrderService {
    private KakaoPay payment = new KakaoPay();

    public void processOrder(long amount) {
        payment.pay(amount);
    }
}

// 수정 후
public class OrderService {
    private PaymentMethod payment; // '카카오페이'가 아닌 '결제 담당' 직책을 가리킨다.

    public void processOrder(long amount) {
        // ... 주문 처리 로직 ...
        System.out.println("주문 처리를 시작합니다.");
        payment.pay(amount);
    }
}

솔라는 수정된 코드를 뚫어지라 쳐다보았다. private KakaoPay payment = new KakaoPay(); 한 줄이 private PaymentMethod payment;로 바뀌었을 뿐이었다. 하지만 그 작은 차이가 코드의 전체적인 의미를 뒤흔들고 있었다.

솔라의 입에서 나직한 탄성이 터져 나왔다. “아…!”

이전의 OrderServiceKakaoPay라는 구체적인 클래스의 존재를 알고 있어야만 했다. 마치 특정 회사 제품의 사용법까지 속속들이 알아야 하는 것과 같았다. 하지만 이제 OrderServicePaymentMethod라는 ‘계약’의 존재만 알면 되었다. 그 계약을 이행하는 실제 대상이 KakaoPay이든 NaverPay이든, 심지어 미래에 나타날 TossPay이든 전혀 상관하지 않았다. OrderServiceprocessOrder 메서드는 그저 자신의 멤버 변수인 paymentpay()라는 행동을 할 수 있다는 사실에만 의존했다.

“이렇게 되면… 나중에 신용카드 결제(CreditCard)가 추가되어도, OrderService.java 파일은 아예 열어볼 필요도 없게 되네! 그냥 CreditCard 클래스가 PaymentMethod 계약서에 서명하기만 하면 되니까.”

강력 접착제로 붙어 있던 두 개의 블록이 떨어지고, 그 사이에 ‘표준 규격의 콘센트’가 생긴 느낌이었다. OrderService는 콘센트를 제공할 뿐, 거기에 어떤 플러그를 꽂는지는 신경 쓰지 않는 것이다. 이것이 바로 ‘느슨한 결합’의 실체였다. 변경이 일어날 때, 그 영향이 다른 곳으로 퍼져나가지 않고 해당 부분에만 국한되는 유연한 구조.

솔라는 방금 자신이 깨달은 것을 확인하듯 말했다. “구현체(KakaoPay)에 의존하는 게 아니라, 추상화(PaymentMethod 인터페이스)에 의존하라는 말이 바로 이 뜻이었구나.”

한참 동안 코드의 변화가 가져온 유연함에 감탄하던 솔라의 눈썹이 문득 한곳에 모였다. 그녀는 수정된 OrderService 코드의 private PaymentMethod payment; 부분을 손가락으로 짚었다.

“그런데 언니, 이상한 점이 있어. new KakaoPay()를 지워버려서 강한 결합은 피했는데… 그럼 이 payment 변수는 그냥 텅 빈 상태잖아. 이 코드를 그대로 실행하면 processOrder 메서드 안에서 payment.pay()를 호출할 때, payment가 아무것도 가리키지 않으니까… 그냥 프로그램이 멈춰버리는 거 아냐?”

솔라의 지적은 정확했다. OrderService는 이제 어떤 결제 수단이든 받아들일 준비가 되었지만, 정작 그 중요한 결제 수단 객체를 스스로 만들지 않으니, 누가, 언제, 어떻게 OrderService에게 건네줘야 하는가?

강력 접착제를 떼어내는 데는 성공했지만, 이제 그 자리에 누구의 손으로 플러그를 꽂아줄 것인지 새로운 문제가 떠올랐다.

4장: 생성자로 건네는 객체: 의존성 주입의 수동 체험

솔라의 손가락 끝이 스케치북에 그려진 OrderService 코드의 한 줄에 멈춰 섰다. 바로 private PaymentMethod payment; 부분이었다. 이전의 new KakaoPay()가 사라진 그 자리는 이제 텅 비어 있었다. 강력 접착제를 떼어낸 대가로, 블록을 연결할 방법 자체가 사라져 버린 셈이었다.

솔라는 이 코드를 실행했을 때 벌어질 일을 머릿속으로 그려보았다. processOrder 메서드가 호출되는 순간, 비어있는 payment 변수를 사용하려다 NullPointerException이라는 붉은 경고 메시지와 함께 프로그램 전체가 멈추는 모습. 인터페이스에 의존해서 유연함을 얻었지만, 정작 가장 중요한 부품이 없어서 아무것도 할 수 없는 모순적인 상황이었다.

루나는 솔라의 멈춘 손가락과 굳은 표정을 잠시 지켜보았다. 그리고는 솔라가 던졌던 질문, “이 payment 변수는 그냥 텅 빈 상태잖아?”라는 물음에 대답하는 대신, 펜을 들어 OrderService 클래스 코드에 몇 글자를 추가하기 시작했다. 클래스 이름과 똑같은 이름의, 하지만 반환 타입이 없는 특별한 메서드였다.

public class OrderService {
    private final PaymentMethod payment; // 이제 final 키워드로 '반드시 채워져야 함'을 명시

    // 생성자: OrderService 객체가 태어나는 순간, '결제 담당자'를 요구한다.
    public OrderService(PaymentMethod payment) {
        this.payment = payment;
    }

    public void processOrder(long amount) {
        System.out.println("주문 처리를 시작합니다.");
        payment.pay(amount);
    }
}

“이게 뭐야? 생성자네.” 솔라가 말했다. 이전에 new 키워드 옆 괄호에 값을 넘겨 객체의 초기 상태를 설정하던 것을 떠올렸다. 하지만 이번에는 무언가 달랐다. OrderService는 이제 문자열이나 숫자 같은 데이터가 아니라, PaymentMethod라는 ‘기능’을 통째로 요구하고 있었다.

루나는 고개를 끄덕이며 말했다. “이제 OrderService는 스스로 결제 수단을 만들지 않아. 대신, 자기가 태어나는 순간에 ‘나에게 결제 담당자를 데려와. 없으면 난 만들어지지 않을 거야’라고 선언하는 거지.”

final 키워드와 생성자의 조합은 강력한 요구처럼 보였다. OrderService가 자신의 의존성에 대한 책임을 외부로 명확하게 떠넘기는 순간이었다.

솔라는 잠시 생각에 잠겼다. “책임을 밖으로 떠넘긴 건 알겠어. 그럼 그 ‘밖’이 대체 어디야? 누가 OrderService한테 결제 담당자를 데려다주는데?” 인터페이스만 선언하면 마법처럼 객체가 연결될 리는 없었다.

루나는 스케치북의 새 페이지를 펼쳤다. 그리고는 프로그램의 시작점을 의미하는 main 메서드를 가진, Application이라는 새로운 클래스를 그리기 시작했다. 이 Application 클래스는 OrderService도, KakaoPay도 아니었다. 모든 것을 조립하고 지휘하는 제3의 존재, 일종의 ‘조립 담당자’였다.

루나의 펜이 조립 담당자의 역할을 코드로 써 내려갔다.

public class Application {
    public static void main(String[] args) {
        // 1. 조립 담당자가 실제 부품(KakaoPay 객체)을 먼저 만든다.
        PaymentMethod kakaoPayment = new KakaoPay();

        // 2. 부품을 필요로 하는 OrderService를 만들면서,
        //    생성자를 통해 방금 만든 부품을 '건네준다(주입한다)'.
        OrderService orderService = new OrderService(kakaoPayment);

        // 3. 완성된 서비스를 실행시킨다.
        orderService.processOrder(10000);
    }
}

코드가 완성되는 순간, 솔라의 머릿속을 채우고 있던 안개가 걷혔다. “아…!”

누가, 언제, 어떻게? 그 모든 질문에 대한 답이 이 코드 안에 있었다.

  • 누가? OrderService 자신이 아닌, 외부의 조립 담당자(Application)가.
  • 언제? OrderService가 생성되는 바로 그 시점에.
  • 어떻게? 생성자를 통해.

OrderService는 더 이상 KakaoPay의 존재를 알 필요가 없었다. ApplicationKakaoPay 객체를 만들어 OrderService의 생성자에 건네주면, OrderService는 그저 PaymentMethod라는 계약서에 서명한 누군가가 왔다고만 인지할 뿐이었다.

“그럼… 만약에 결제 수단을 네이버페이로 바꾸고 싶으면…?” 솔라는 질문을 던지며 스스로 답을 찾기 시작했다. 그녀의 눈은 OrderService.java가 아닌, Application.java의 코드를 향했다.

OrderService 코드는 손댈 필요가 전혀 없고, 여기 Application에서 new KakaoPay() 이 부분만 new NaverPay()로 바꾸면… 끝나는 거네.”

이전에는 결제 수단을 바꾸기 위해 OrderService라는 핵심 로직을 건드려야 한다는 위험 부담이 있었다. 하지만 이제는 모든 의존 관계를 설정하는 외부의 ‘설정 파일’ 같은 곳만 수정하면 되었다. 책임과 역할이 명확하게 분리된 것이다.

“맞아. 그렇게 외부에서 의존 객체를 생성해서 건네주는 방식을 ‘의존성 주입(Dependency Injection, DI)’이라고 불러. 지금 우리가 main 메서드에서 손으로 직접 했으니까 ‘수동 의존성 주입’인 셈이지.”

‘수동 의존성 주입’. 솔라는 그 단어를 입안에서 굴려보았다. 객체들이 서로에게 필요한 부품을 각자 만드는 게 아니라, 조립 담당자가 필요한 부품을 챙겨서 건네주는 모습. 결합도를 낮추고 유연성을 확보하는 구체적인 실천 방법이었다.

솔라는 완전히 이해했다는 듯 고개를 끄덕였다. 하지만 이내 새로운 의문이 고개를 들었다.

“좋아, 훨씬 깔끔하고 안전한 방법인 건 알겠어. 그런데 언니, 지금은 OrderService 하나뿐이지만, 나중에 서비스가 수십, 수백 개가 되면 어떡해? 그때마다 이 Application 파일 안에서 모든 객체를 new로 만들고, 생성자로 건네주는 코드를 전부 손으로 써야 하는 거야? 상상만 해도 끔찍한데. 너무 번거롭잖아.”

수동으로 처리하는 이 방식은 원리를 이해하기엔 더할 나위 없이 좋았지만, 거대한 시스템에 적용하기에는 너무 많은 반복 작업을 요구하는 것처럼 보였다. 이 번거로운 패턴이 정말 최선일까? 그렇다면 모두가 극찬하는 스프링 부트(Spring Boot)는 이 문제를 어떻게 해결한 걸까?

5장: Spring의 마법 뒤편: 자동 주입의 원동력

솔라는 노트북 화면에 떠 있는 Application.java 코드를 노려보았다. 이전에는 모든 의문이 풀리는 명쾌한 해답처럼 보였던 코드였다. 조립 담당자가 new KakaoPay() 부품을 만들어 OrderService에 건네주는, 수동 의존성 주입의 명확한 증거. 하지만 이제는 그 코드가 거대하고 성가신 숙제처럼 느껴졌다.

솔라는 손가락으로 화면을 가리키며 허공에 مربعة를 그렸다. “OrderService 하나에 PaymentMethod 하나. 지금은 간단하지. 근데 여기에 ShippingService가 들어가고, UserService도 필요하고, 각 서비스마다 또 다른 부품들이 필요해진다면…?”

그녀의 머릿속에서는 Application.java 파일이 걷잡을 수 없이 길어지는 상상이 펼쳐졌다. 수십, 수백 개의 new 키워드와 생성자 호출이 거미줄처럼 얽히는 모습. 객체 하나를 추가할 때마다 이 거대한 조립 설명서의 어디를 고쳐야 할지 한참을 찾아 헤매는 개발자의 모습까지.

솔라는 한숨을 쉬며 중얼거렸다. “이걸 다 사람이 손으로 관리하라고? 이건 ‘유연한 구조’가 아니라 ‘번거로운 구조’ 같은데. 모두가 극찬하는 스프링 부트는 대체 이런 번거로움을 어떻게 해결했다는 거지? 그냥 마법처럼 짠하고 연결해주는 건가?”

솔라의 혼잣말을 듣고 있던 루나가 조용히 입을 열었다. “솔라야, 네가 방금 말한 그 ‘번거로움’이 바로 핵심이야.”

루나는 솔라가 노려보던 Application.javamain 메서드를 손가락으로 가리켰다.

“지금 이 ‘조립 담당자’는 아주 성실하지만, 한 번에 한 가지 일밖에 못하고 모든 걸 기억해서 직접 해야 해. 그런데 만약, 이 모든 조립 과정을 대신 해주는 거대한 자동화 기계가 있다면 어떨까?”

“자동화 기계?”

“응. 우리는 그 기계에게 재료(KakaoPay, OrderService 같은 클래스들)가 어디 있는지 알려주고, ‘이 부품은 저 부품이 필요해’라는 조립 설명서(생성자)만 등록해 놓는 거야. 그러면 기계가 알아서 필요한 부품들을 창고에서 꺼내 조립하고, 또 다른 부품이 필요하면 창고를 뒤져서 가져와 끼워 넣어주는 거지.”

루나는 스케치북에 커다란 상자를 그리고, 그 안에 작은 상자들이 담겨 있는 모습을 그렸다. 큰 상자에는 ‘객체 컨테이너(Object Container)’, 작은 상자들에는 ‘KakaoPay 객체’, ‘OrderService 객체’라고 적었다.

“스프링 프레임워크의 핵심이 바로 이 자동화 기계, ‘스프링 컨테이너’라고 불리는 존재야. 우리가 손으로 했던 new KakaoPay()new OrderService(...) 같은 객체 생성과 의존성 주입 작업을, 스프링 컨테이너가 전부 대신 해주는 거지.”

솔라의 눈이 동그래졌다. 마법처럼 보였던 스프링의 자동 주입 기능이, 사실은 자신들이 방금까지 손으로 했던 ‘수동 DI’ 작업을 거대한 규모로 자동화한 결과물이라는 사실을 깨달았기 때문이다.

“그럼… 개발자는 그냥 스프링 컨테âni어에게 ‘이 클래스로 객체를 만들어서 관리해줘’(@Component)라고 표시하고, ‘나는 이런 타입의 부품이 필요해’(@Autowired)라고 요청하기만 하면 되는 거네? 그러면 컨테이너가 알아서 OrderService의 생성자를 보고 ‘아, PaymentMethod 타입의 부품이 필요하구나!’하고 창고에서 KakaoPay 객체를 찾아서 넘겨주는 거고?”

“바로 그거야.” 루나가 고개를 끄덕였다. “스프링은 마법을 부리는 게 아니야. 우리가 세웠던 ‘인터페이스에 의존하고, 생성자로 외부에서 주입받는다’는 원칙을 충실하게, 그리고 아주 효율적으로 자동화해주고 있을 뿐이야. 그 덕분에 개발자는 객체를 생성하고 연결하는 번거로운 작업에서 벗어나, 진짜 비즈니스 로직에만 집중할 수 있게 되는 거고.”

솔라는 비로소 모든 조각이 맞춰지는 것을 느꼈다. OrderServicePaymentMethod 인터페이스에 의존해야 하는 이유, 그것이 어떻게 ‘느슨한 결합’으로 이어지는지, 그리고 그 느슨한 결합을 완성하기 위한 의존성 주입의 원리, 마지막으로 그 원리를 거대한 시스템에서 가능하게 만드는 스프링 컨테이너의 역할까지. 하나의 거대한 흐름이 눈앞에 선명하게 그려졌다.

처음 보았던 문장, ‘OrderService는 구현체가 아니라 PaymentMethod 인터페이스에 의존하면 결제 수단을 쉽게 바꿀 수 있다.’는 이제 단순한 지식이 아니었다. 유연한 설계를 위한 첫 단추이자, 스프링의 강력한 자동화가 작동하기 위한 필수적인 약속이라는 사실을 이해하게 된 것이다.

솔라는 흥분된 목소리로 말했다. “알겠어! 이제 완전히 알겠어. 우리가 직접 했던 수동 조립 방식이 바로 스프링이 하는 자동 조립의 원리였던 거야!”

한참 동안 깨달음의 여운을 즐기던 솔라는 문득 새로운 아이디어가 떠오른 듯 스케치북의 빈 페이지를 자기 앞으로 끌어당겼다. 그녀는 펜을 들고 새로운 설계를 그려나가기 시작했다.

“언니, 만약 우리 서비스에 주문이 완료되면 고객에게 알림을 보내는 기능이 필요하다면… 이렇게 하면 되겠네.”

솔라는 ‘NotificationService’라는 사각형을 그리고, 그 옆에 ‘NotificationSender’라는 인터페이스 사각형을 그렸다.

[ NotificationSender (인터페이스) ]
  - send(message)

[ SmsSender ] --구현--> [ NotificationSender ]
[ EmailSender ] --구현--> [ NotificationSender ]

[ NotificationService ] --의존--> [ NotificationSender ]
  - NotificationService(NotificationSender sender) { ... }

NotificationServiceNotificationSender라는 ‘알림 발송’ 계약서에만 의존하게 만드는 거야. 그리고 실제 구현체인 SmsSenderEmailSender는 외부에서, 즉 스프링 컨테이너가 생성자를 통해 주입해주겠지. 이렇게 하면 나중에 카카오톡 알림을 추가하더라도 NotificationService 코드는 전혀 건드릴 필요가 없어!”

스스로 문제를 내고, 인터페이스와 생성자 주입을 활용해 유연한 구조를 설계하고, 그 구조 위에서 스프링이 어떻게 동작할지까지 완벽하게 예측해내는 솔라의 모습을 보며, 루나는 조용히 미소 지었다. 솔라는 더 이상 ‘수동 DI는 번거롭다’고 불평하는 사람이 아니었다. 자동화의 원리를 이해하고 그것을 활용해 새로운 설계를 창조하는 개발자로 성장해 있었다.