[ 주니어 탈출기 ] 고수준 컴포넌트와 저수준 컴포넌트

코드를 짜다가 아래와 같은 경험을 겪었습니다.

"외부 카카오톡 알림톡 API 가 버전업 되었는데, 서비스 레이어 코드까찌 싹 다 고쳐야 하네" 

"데이터베이스를 MySQL 에서 MongoDB 로 바꾸자고 하는데, 비즈니스 로직 전체를 뜯어야 하는구나"

 

저와 같은 고통을 겪고 있는 개발자분들은, 100% 고수준 컴포넌트와 저수준 컴포넌트의 관계가 잘못 설계되었기 때문일 것입니다.

이 글을 쓰는 목적은 두 개념을 완벽하게 마스터하고, 단단한 코드를 짜기 위해 정리할 겸 같은 고민을 겪고 있는 개발자들에게 조금이나마 도움이 될 것 같아 정리하였습니다.

 

컴포넌트란 무엇인가?

본론으로 들어가기 전에 '컴포넌트' 라는 단어부터 짚고 넘어가자면, 컴포넌트라고 하면 프론트엔드의 React 컴포넌트가 먼저 떠오를 수 있지만, 백엔드나 시스템 아키텍처에서는 더 넓은 의미로 쓰입니다.

쉽게 말해 컴포넌트란 "특징적인 기능을 수행하기 위해 독립적으로 구성된 소프트웨어 모듈" 을 뜻합니다.

자바 세계에서는 하나의 독립된 기능을 담당하는 객체(Bean)나 패키지 단위를 컴포넌트라고 이해하시면 편합니다.

 

고수준 컴포넌트 vs 저수준 컴포넌트

두 컴포넌트를 나누는 가장 중요한 기준은 바로 "비즈니스의 핵심 정책을 담고 있는가, 아니면 구체적인 세부 기술을 담고 있는가?" 입니다. 

고수준 컴포넌트

시스템의 핵심 정책과 비즈니스 로직을 담당합니다.

"이 서비스가 무엇(What) 을 해야 하는가?" 를 담당하며 비즈니스 모델이 바뀌지 않는 한 자주 바뀌지 않습니다.

예를 들어 OrderServcice, MemberStatus, DiscountPolicy 등이 있습니다.

저수준 컴포넌트

정책을 실현하기 위한 구체적인 세부사항을 담당합니다.

"그 기능을 어떻게(How) 기술적으로 구현할 것인가?" 를 담당하며 기술 트랜드나 외부 인프라에 따라 자주 바뀌게 됩니다.

예를 들어 SmsSender, KakaoSender, MySqlRepository 등이 있습니다.

 

주니어들이 가장 많이 만드는 파멸의 아키텍처

소프트웨어 세계에서 가장 이상적인 구조는 "고수준은 저수준의 변경에 영향을 받지 않아야 한다" 입니다.

인턴이 엑셀을 쓰다가 구글 스프레드시트로 도구를 바꿨다고 해서, CEO 의 "10% 적립해 준다" 는 정책이 바뀌거나 CEO 가 일을 못 하게 되면 안되는 것처럼 말입니다.

하지만 제가 경험한 많은 주니어 개발자분들께서는 코드를 고수준 컴포넌트가 저수준 컴포넌트에 의존하는 형태로 짜는 모습을 많이 목격했습니다.

 

안좋은 예시: (고수준 → 저수준)

주문이 완료되면 '카카오톡 알림톡' 을 보내는 시스템 예시입니다.

// 1. 저수준 컴포넌트: 카카오톡 API를 사용해 메시지를 보내는 구체적인 기술 클래스
public class KakaoTalkSender {
    public void sendKakao(String phone, String message) {
        // 카카오톡 외부 API 연동 라이브러리 코드...
        System.out.println("카카오톡 발송: " + message);
    }
}

// 2. 고수준 컴포넌트: 주문이라는 핵심 비즈니스 정책을 다루는 클래스
public class OrderService {
    // 🚨 문제 발생: 고수준 컴포넌트가 구체적인 저수준 컴포넌트를 직접 참조(new)함
    private final KakaoTalkSender kakaoSender = new KakaoTalkSender();

    public void completeOrder(Long orderId) {
        // 1. 주문 완료 처리 로직 (비즈니스 정책)
        System.out.println(orderId + "번 주문이 완료되었습니다.");

        // 2. 알림 발송 (세부 구현 기술에 의존)
        kakaoSender.sendKakao("010-1234-5678", "주문이 완료되었습니다!");
    }
}

이 구조가 시한폭탄인 이유

몇 달 뒤, 경영진이 "카카오톡 비용이 너무 비싸니, 비용이 저렴한 일반 SMS 문자로 알림 시스템을 바꾸세요!" 라고 요구하면 개발자는 SmsSender 라는 새로운 저수준 클래스를 만든 뒤, 핵심 비즈니스 로직이 들어있는 OrderService 코드를 열어서 직접 뜯어 고쳐야 합니다. 아래와 같이 말입니다.

public class OrderService {
    // private final KakaoTalkSender kakaoSender = new KakaoTalkSender(); // 삭제
    private final SmsSender smsSender = new SmsSender(); // 추가 (결국 코드 수정 유발)

    public void completeOrder(Long orderId) {
        System.out.println(orderId + "번 주문이 완료되었습니다.");
        // kakaoSender.sendKakao(...);
        smsSender.sendSms(...); // 비즈니스 로직과 상관없는 기술 변경 때문에 코드가 바뀜!
    }
}

 

이것이 바로 "저수준 컴포넌트(알림 기술) 의 변경이 고수준 컴포넌트(주문 정책) 를 흔드는 아키텍처" 입니다.

서비스가 커지면 이런 기술적 변경이 하루에도 수십 번씩 일어나는데, 그때마다 핵심 비즈니스 코드가 출렁거린다면 버그가 걷잡을 수 없이 터지게 됩니다.

해결방법: DIP(의존성 역전 원칙)

고수준 컴포넌트는 저수준 컴포넌트에 의존하면 안 된다. 양쪽 모두 '추상화'에 의존해야 한다

 

자바에서 추상화를 구현하는 가장 대표적인 도구는 인터페이스입니다.

고수준 컴포넌트와 저수준 컴포넌트 사이에 인터페이스라는 장막을 쳐서 의존성의 방향을 뒤틀어버리는것입니다.

// 고수준 영역에 위치한 '추상화 인터페이스'
// "우리는 알림을 보낼 건데, 구체적으로 어떻게 보낼지는 나중에 구현해라"
public interface NotificationSender {
    void send(String target, String message);
}

// 고수준 컴포넌트: 이제 오직 인터페이스(추상화)에만 의존합니다.
public class OrderService {
    private final NotificationSender notificationSender;

    // 의존성 주입(DI)을 받도록 설계
    public OrderService(NotificationSender notificationSender) {
        this.notificationSender = notificationSender;
    }

    public void completeOrder(Long orderId) {
        System.out.println(orderId + "번 주문이 완료되었습니다.");
        
        // 카카오톡인지 SMS인지 이 코드는 전혀 알 바가 아님!
        notificationSender.send("010-1234-5678", "주문 완료!");
    }
}

 

이제 저수준 컴포넌트들은 이 고수준의 인터페이스를 구현하기만 하면 됩니다.

 

// 저수준 컴포넌트들: 고수준이 정의한 규칙을 따르는 플러그인들
public class KakaoTalkSender implements NotificationSender {
    @Override
    public void send(String target, String message) {
        System.out.println("카카오톡으로 발송: " + message);
    }
}

public class SmsSender implements NotificationSender {
    @Override
    public void send(String target, String message) {
        System.out.println("일반 문자로 발송: " + message);
    }
}

 

왜 의존성 '역전' 일까

전통적인 개발 방식에서는 고수준 컴포넌트에서 저수준 컴포넌트로 화살표가 흘러갔습니다.

하지만 DIP 를 적용하면 저수준 컴포넌트에서 고수준 인터페이스로, 마지막은 고수준 컴포넌트 형태로 화살표가 모입니다.

즉, 저수준 컴포넌트가 고수준 컴포넌트가 만들어 놓은 규칙에 대고 의존하게 되므로 의존성의 방향이 거꾸로 뒤집혔다고 해서 역전이라고 부르는 것입니다.

 

이제 알림 수단을 카카오톡에서 SMS 로 바꾸고 싶다면, OrderService 코드는 단 한 줄도 건드리지 않고 그저 조립기인 Spring Context 에서 SmsSender 를 갈아 끼워 주기만 하면 됩니다.

 

마무리하며

우리가 매일 쓰는 스프링 프레임워크의 핵심 기능은 바로 IoC(제어의 역전) 와 DI(의존성 주입) 입니다.

많은 주니어 개발자들이 면접을 위해 "DI 는 객체를 스프링이 대신 생성해서 주입해 주는 것입니다." 라고 외우지만, "왜?" 대신 해주는지는 체감하지 못합니다. 스프링이 대신 객체를 생성하고 조립해 주는 진짜 이유는 바로 고수준 컴포넌트와 저수준 컴포넌트를 완벽하게 분리하여 엔지니어가 DIP 를 쉽게 구현할 수 있도록 돕기 위해서입니다.

 

스프링 컨테이너가 없다면 우리는 어딘가에서 아래와 같이 구체적인 저수준 객체를 직접 new 해서 조립해야 합니다.

// 스프링이 없다면 직접 해야 하는 조립 과정
NotificationSender sender = new KakaoTalkSender(); 
OrderService orderService = new OrderService(sender);

 

하지만 스프링에서는 @Component 나 @Configuration 을 통해 스프링에게 조립 책임을 떠넘깁니다.

덕분에 우리의 고수준 비즈니스 로직은 외부의 지저분한 저수준 기술(DB, 파일 시스템등) 로부터 완전히 독립하여 청정 구역을 유지할 수 있게 되는 것입니다.