[태그:] 클린코드

  • 개발자 필독서: 좋은 코드를 넘어 위대한 코드로 가는 5가지 길, SOLID 원칙

    개발자 필독서: 좋은 코드를 넘어 위대한 코드로 가는 5가지 길, SOLID 원칙

    소프트웨어 개발의 세계에서 ‘작동하는 코드’를 작성하는 것은 기본 중의 기본입니다. 하지만 진짜 실력은 ‘변경하기 쉬운 코드’, 즉 유지보수가 용이하고 확장에 유연한 코드를 작성하는 데서 드러납니다. 시간이 흐르고 요구사항이 끊임없이 변화하는 프로젝트의 혼돈 속에서, 어떻게 하면 우리 코드가 스파게티처럼 얽히지 않고 견고한 구조를 유지할 수 있을까요? 그 해답은 2000년대 초, 로버트 C. 마틴(Robert C. Martin, a.k.a. Uncle Bob)이 집대성한 다섯 가지 객체지향 설계 원칙, SOLID에 있습니다.

    SOLID는 단순히 따라야 할 규칙의 목록이 아닙니다. 이것은 수많은 개발자들이 경험한 성공과 실패를 통해 얻어진 지혜의 결정체이며, 소프트웨어가 시간이 지나도 부패하지 않고 지속 가능한 가치를 지니게 하는 철학입니다. 이 원칙들을 이해하고 적용하면, 개발자는 변화를 두려워하지 않게 되고, 협업은 원활해지며, 시스템 전체의 안정성은 극적으로 향상됩니다. 그중에서도 가장 이해하기 쉽고 즉각적인 효과를 볼 수 있는 원칙은 바로 단일 책임 원칙(Single Responsibility Principle)입니다. 모든 위대한 설계는 ‘각자 자기 일만 잘하자’는 이 단순한 진리에서 출발합니다. 이 글을 통해 SOLID의 다섯 가지 원칙 각각이 무엇을 의미하며, 어떻게 서로를 보완하여 우리의 코드를 위대한 코드로 격상시키는지 깊이 있게 탐험해 보겠습니다.


    1. SRP: 단일 책임 원칙 (Single Responsibility Principle)

    “클래스는 단 하나의 변경 이유만을 가져야 한다.”

    단일 책임 원칙(SRP)은 SOLID의 ‘S’를 담당하며, 가장 기본적이면서도 강력한 원칙입니다. 이 원칙은 하나의 클래스(또는 모듈, 함수)는 오직 하나의 책임, 즉 하나의 기능에 대해서만 책임을 져야 한다는 것을 의미합니다. 여기서 ‘책임’을 판단하는 중요한 기준은 ‘변경의 이유’입니다. 만약 어떤 클래스를 수정해야 하는 이유가 두 가지 이상이라면, 그 클래스는 SRP를 위반하고 있을 가능성이 높습니다.

    예를 들어, 직원(Employee) 클래스가 직원의 정보를 관리하는 책임과, 이 정보를 바탕으로 회계 보고서를 생성하는 책임을 모두 가지고 있다고 상상해 봅시다.

    [SRP 위반 사례]

    Java

    class Employee {
    public String name;
    public int salary;

    // 책임 1: 직원 정보 관리
    public void getEmployeeInfo() { /* ... */ }

    // 책임 2: 회계 보고서 생성
    public String generateAccountingReport() { /* ... */ }
    }

    이 설계에는 두 가지 변경의 축이 존재합니다. 첫째, 직원의 정보 구조가 변경될 때(예: 직급 추가) Employee 클래스를 수정해야 합니다. 둘째, 회계 보고서의 양식이나 계산 방식이 변경될 때도 Employee 클래스를 수정해야 합니다. 이처럼 서로 다른 이유로 클래스가 계속 변경된다면, 하나의 수정이 다른 기능에 예상치 못한 부작용(Side Effect)을 일으킬 위험이 커지고, 코드의 복잡성은 기하급수적으로 증가합니다.

    [SRP 준수 사례]

    SRP를 적용하면 이 문제를 해결할 수 있습니다. 각 책임을 별도의 클래스로 분리하는 것입니다.

    Java

    // 책임 1: 직원 정보 관리 클래스
    class Employee {
    public String name;
    public int salary;
    public void getEmployeeInfo() { /* ... */ }
    }

    // 책임 2: 회계 보고서 생성 클래스
    class AccountingReporter {
    public String generateReport(Employee employee) { /* ... */ }
    }

    이제 직원 정보 변경은 Employee 클래스에만 영향을 주고, 보고서 양식 변경은 AccountingReporter 클래스에만 영향을 줍니다. 각 클래스는 오직 하나의 변경 이유만을 가지게 되어 코드의 이해와 테스트, 유지보수가 훨씬 용이해졌습니다. SRP는 기능별로 코드를 명확하게 분리하여 시스템의 응집도(Cohesion)는 높이고 결합도(Coupling)는 낮추는 첫걸음입니다.


    2. OCP: 개방-폐쇄 원칙 (Open/Closed Principle)

    “소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만, 변경에 대해서는 닫혀 있어야 한다.”

    개방-폐쇄 원칙(OCP)은 SOLID의 ‘O’를 담당하며, 소프트웨어의 유연성과 확장성을 위한 핵심 원칙입니다. 이 원칙의 의미는, 새로운 기능이 추가되거나 기존 기능이 변경될 때, 이미 존재하는 기존 코드는 수정하지 않고(폐쇄), 새로운 코드를 추가하는 방식(확장)으로 시스템이 동작해야 한다는 것입니다. 어떻게 이것이 가능할까요? 바로 추상화와 다형성을 통해 가능해집니다.

    결제 시스템을 예로 들어 보겠습니다. 처음에는 ‘신용카드’ 결제 방식만 지원하는 PaymentProcessor 클래스가 있었다고 가정합시다.

    [OCP 위반 사례]

    Java

    class PaymentProcessor {
    public void processPayment(String type, int amount) {
    if (type.equals("creditCard")) {
    // 신용카드 결제 로직
    } else if (type.equals("cash")) { // 새로운 기능 '현금 결제' 추가
    // 현금 결제 로직
    } else if (type.equals("kakaoPay")) { // 또 다른 기능 '카카오페이' 추가
    // 카카오페이 결제 로직
    }
    // 새로운 결제 방식이 추가될 때마다 이 클래스의 코드가 계속 수정되어야 한다.
    }
    }

    이 설계는 새로운 결제 수단이 추가될 때마다 if-else 구문이 계속 늘어나며 PaymentProcessor 클래스 자체를 직접 수정해야 합니다. 이는 OCP를 명백히 위반하며, 코드를 불안정하게 만듭니다.

    [OCP 준수 사례]

    OCP를 준수하기 위해, 결제 방식이라는 변하는 부분을 추상화합니다. PaymentMethod라는 인터페이스를 만들고, 각 결제 방식은 이 인터페이스를 구현한 구체적인 클래스로 만듭니다.

    Java

    // '결제 방식'이라는 역할(추상화)
    interface PaymentMethod {
    void pay(int amount);
    }

    // 구체적인 구현 클래스들
    class CreditCard implements PaymentMethod {
    public void pay(int amount) { /* 신용카드 결제 로직 */ }
    }

    class Cash implements PaymentMethod {
    public void pay(int amount) { /* 현금 결제 로직 */ }
    }

    // OCP를 준수하는 결제 처리기
    class PaymentProcessor {
    public void processPayment(PaymentMethod method, int amount) {
    // 어떤 결제 방식이 오든, 그저 pay()를 호출할 뿐이다.
    method.pay(amount);
    }
    }

    이제 ‘카카오페이’라는 새로운 결제 방식이 추가되어도 PaymentProcessor의 코드는 단 한 줄도 수정할 필요가 없습니다. 그저 KakaoPay라는 새로운 클래스를 만들어 PaymentMethod 인터페이스를 구현하기만 하면 됩니다. 이처럼 OCP는 변경이 발생할 가능성이 높은 부분을 추상화하여, 시스템이 변화에 유연하게 대응하고 안정적으로 확장될 수 있도록 만듭니다.


    3. LSP: 리스코프 치환 원칙 (Liskov Substitution Principle)

    “하위 타입은 언제나 자신의 상위 타입으로 교체될 수 있어야 한다.”

    리스코프 치환 원칙(LSP)은 SOLID의 ‘L’을 담당하며, 올바른 상속 관계를 설계하기 위한 지침입니다. 이 원칙의 핵심은, 자식 클래스(하위 타입)는 부모 클래스(상위 타입)의 역할을 온전히 수행할 수 있어야 한다는 것입니다. 즉, 부모 클래스의 객체를 사용하는 코드에서 그 객체를 자식 클래스의 객체로 바꾸어 넣어도, 프로그램의 정확성이나 동작 방식에 아무런 문제가 없어야 합니다.

    LSP를 위반하는 가장 고전적인 예는 ‘정사각형은 직사각형이다’ 문제입니다. 수학적으로는 맞는 말이지만, 객체지향 설계에서는 문제가 될 수 있습니다.

    [LSP 위반 사례]

    직사각형(Rectangle) 클래스가 있고, 가로(width)와 세로(height)를 각각 설정할 수 있다고 합시다.

    Java

    class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int getArea() { return this.width * this.height; }
    }

    이제 정사각형(Square) 클래스가 직사각형을 상속받는다고 가정해 봅시다. 정사각형은 가로와 세로의 길이가 항상 같아야 하므로, setWidth나 setHeight가 호출될 때 두 값을 모두 변경하도록 오버라이딩합니다.

    Java

    class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
    this.width = width;
    this.height = width; // 정사각형의 규칙을 위해 세로도 변경
    }
    @Override
    public void setHeight(int height) {
    this.width = height; // 정사각형의 규칙을 위해 가로도 변경
    this.height = height;
    }
    }

    이제 Rectangle 타입을 사용하는 클라이언트 코드를 봅시다.

    Java

    public void test(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    // 클라이언트는 r의 넓이가 20(5*4)일 것이라고 기대한다.
    assert r.getArea() == 20;
    }

    test 메서드에 Rectangle 객체를 넘기면 넓이는 20이 되어 테스트를 통과합니다. 하지만 Square 객체를 넘기면 어떻게 될까요? r.setHeight(4)가 호출되는 순간, Square의 오버라이딩된 메서드에 의해 가로 길이(width)까지 4로 변경됩니다. 결국 넓이는 16(4*4)이 되어 클라이언트의 기대(20)를 깨뜨립니다. 이는 Square가 Rectangle의 역할을 온전히 대체할 수 없다는 의미이며, LSP를 위반한 것입니다.

    LSP는 상속이 단순히 코드 재사용의 목적이 아니라, ‘IS-A’ 관계의 행위적 유사성까지 보장해야 함을 강조합니다. 이 원칙을 따르면 다형성을 신뢰하고 사용할 수 있으며, 예기치 않은 오류로부터 시스템을 보호할 수 있습니다.


    4. ISP: 인터페이스 분리 원칙 (Interface Segregation Principle)

    “클라이언트는 자신이 사용하지 않는 메서드에 의존해서는 안 된다.”

    인터페이스 분리 원칙(ISP)은 SOLID의 ‘I’를 담당하며, SRP가 클래스의 책임 분리를 다룬다면 ISP는 인터페이스의 책임 분리를 다룹니다. 이 원칙은 하나의 거대하고 비대한 ‘만능’ 인터페이스보다는, 특정 클라이언트에 특화된 여러 개의 작은 인터페이스로 분리하는 것이 더 낫다고 말합니다.

    예를 들어, 복합기(프린트, 복사, 팩스 기능)를 위한 MultiFunctionMachine이라는 인터페이스가 있다고 가정해 봅시다.

    [ISP 위반 사례]

    Java

    interface MultiFunctionMachine {
    void print();
    void copy();
    void fax();
    }

    이 인터페이스를 구현하는 ‘고급 복합기’ 클래스는 모든 기능을 구현하는 데 문제가 없습니다. 하지만 ‘저가형 프린터’ 클래스는 어떨까요? 이 프린터는 오직 프린트 기능만 가지고 있습니다.

    Java

    class CheapPrinter implements MultiFunctionMachine {
    public void print() { /* 실제 프린트 로직 */ }
    public void copy() {
    // 기능이 없으므로 예외를 던지거나 아무것도 안 함 -> 문제 발생!
    throw new UnsupportedOperationException();
    }
    public void fax() {
    // 이것도 마찬가지
    throw new UnsupportedOperationException();
    }
    }

    CheapPrinter 클래스는 자신이 사용하지도 않는 copy()와 fax() 메서드를 억지로 구현해야만 합니다. 이는 불필요한 의존성을 만들고, 클라이언트가 지원되지 않는 기능을 호출할 위험을 낳습니다.

    [ISP 준수 사례]

    ISP에 따라 각 기능별로 인터페이스를 잘게 분리합니다.

    Java

    interface Printer { void print(); }
    interface Copier { void copy(); }
    interface Fax { void fax(); }

    // 이제 각 클래스는 자신이 필요한 인터페이스만 구현하면 된다.
    class CheapPrinter implements Printer {
    public void print() { /* 실제 프린트 로직 */ }
    }

    class AdvancedMachine implements Printer, Copier, Fax {
    public void print() { /* ... */ }
    public void copy() { /* ... */ }
    public void fax() { /* ... */ }
    }

    이렇게 하면 CheapPrinter는 더 이상 불필요한 메서드에 의존하지 않게 됩니다. 클라이언트는 이제 Printer 인터페이스 타입으로 CheapPrinter 객체를 사용함으로써, print 기능만 존재함을 명확히 알 수 있습니다. ISP는 시스템의 내부 의존성을 줄여 결합도를 낮추고, 각 모듈의 독립성을 높여줍니다.


    5. DIP: 의존관계 역전 원칙 (Dependency Inversion Principle)

    “상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다. 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.”

    의존관계 역전 원칙(DIP)은 SOLID의 ‘D’를 담당하며, 전통적인 의존성 흐름을 ‘역전’시키는 중요한 개념입니다. 일반적으로 상위 수준 모듈(비즈니스 로직)이 하위 수준 모듈(데이터베이스, 외부 API 등 구체적인 구현체)을 직접 호출하고 의존합니다. 하지만 DIP는 이러한 직접적인 의존 관계를 끊고, 둘 사이에 ‘추상화(인터페이스)’를 두어 서로 추상화에만 의존하도록 만듭니다.

    전구를 켜는 스위치를 생각해 봅시다.

    [DIP 위반 사례]

    Switch(상위 모듈)가 IncandescentLamp(하위 모듈)라는 특정 전구 클래스에 직접 의존합니다.

    Java

    class IncandescentLamp { // 백열등 (구체적인 하위 모듈)
    public void turnOn() { /* 필라멘트에 불 켜는 로직 */ }
    }

    class Switch { // 스위치 (상위 모듈)
    private IncandescentLamp lamp;
    public Switch() {
    this.lamp = new IncandescentLamp(); // 직접 생성하고 의존한다!
    }
    public void operate() {
    lamp.turnOn();
    }
    }

    이 설계의 문제는, 만약 백열등을 LedLamp로 교체하고 싶다면 Switch 클래스의 내부 코드를 직접 수정해야 한다는 것입니다. 상위 모듈이 하위 모듈의 변경에 직접적인 영향을 받게 됩니다.

    [DIP 준수 사례]

    DIP는 둘 사이에 Switchable이라는 인터페이스(추상화)를 도입합니다.

    Java

    // 추상화: 켜고 끌 수 있는 '것'
    interface Switchable {
    void turnOn();
    }

    // 구체적인 하위 모듈들이 추상화에 의존(구현)한다.
    class IncandescentLamp implements Switchable {
    public void turnOn() { /* ... */ }
    }
    class LedLamp implements Switchable {
    public void turnOn() { /* ... */ }
    }

    // 상위 모듈도 추상화에 의존한다.
    class Switch {
    private Switchable device;
    // 의존성 주입(DI): 외부에서 구체적인 객체를 주입받는다.
    public Switch(Switchable device) {
    this.device = device;
    }
    public void operate() {
    device.turnOn();
    }
    }

    이제 Switch는 IncandescentLamp나 LedLamp의 존재 자체를 모릅니다. 오직 Switchable이라는 추상적인 약속에만 의존합니다. 어떤 종류의 전구를 연결할지는 외부(Switch를 사용하는 클라이언트)에서 결정하여 주입(의존성 주입, DI)해 줍니다. 이로써 Switch 코드는 변경 없이 다양한 종류의 전구나 심지어 Fan(선풍기)과 같은 다른 Switchable 장치와도 함께 동작할 수 있게 되었습니다. DIP는 유연하고, 재사용 가능하며, 테스트하기 쉬운 코드를 만드는 핵심 원칙이며, 현대적인 프레임워크와 아키텍처의 근간을 이룹니다.


    6. 결론: SOLID는 유연한 소프트웨어를 위한 나침반

    SOLID의 다섯 가지 원칙—단일 책임 원칙(SRP), 개방-폐쇄 원칙(OCP), 리스코프 치환 원칙(LSP), 인터페이스 분리 원칙(ISP), 의존관계 역전 원칙(DIP)—은 서로 독립적인 규칙이 아니라, 상호보완적으로 작용하며 객체지향 설계의 품질을 극대화하는 유기적인 체계입니다. SRP와 ISP는 코드를 작은 단위로 응집력 있게 분리하도록 유도하고, OCP, LSP, DIP는 이렇게 분리된 코드들을 유연하고 확장 가능하게 연결하는 방법을 제시합니다.

    물론, 모든 코드에 이 원칙들을 칼같이 적용하는 것이 항상 정답은 아닙니다. 때로는 과도한 설계가 오히려 생산성을 저해할 수도 있습니다. 중요한 것은 이 원칙들의 ‘정신’을 이해하고, 프로젝트의 규모, 복잡도, 미래의 변화 가능성을 고려하여 적절한 수준에서 균형을 맞추는 것입니다. SOLID는 우리에게 ‘정답’을 알려주는 것이 아니라, 더 나은 설계를 향해 나아갈 수 있도록 방향을 제시하는 ‘나침반’과 같습니다. 이 나침반을 손에 쥔 개발자는 예측 불가능한 요구사항의 폭풍우 속에서도 길을 잃지 않고, 시간이 흘러도 변치 않는 가치를 지닌 견고하고 아름다운 소프트웨어를 만들어낼 수 있을 것입니다.

  • 보이지 않는 재앙: 결합도(Coupling)가 당신의 프로젝트를 서서히 망가뜨리는 방법

    보이지 않는 재앙: 결합도(Coupling)가 당신의 프로젝트를 서서히 망가뜨리는 방법

    성공적인 소프트웨어 프로젝트는 견고한 건축물과 같습니다. 각각의 벽돌(모듈)이 제 역할을 충실히 하면서도 서로에게 불필요한 부담을 주지 않아야 전체 구조가 안정적으로 유지됩니다. 지난 ‘응집도’에 대한 글에서 우리는 벽돌 자체가 얼마나 단단하고 잘 만들어졌는지에 대해 이야기했습니다. 이제 우리는 그 벽돌들이 서로 어떻게 연결되어 있는지, 즉 ‘결합도(Coupling)’에 대해 이야기할 차례입니다. 결합도는 모듈과 모듈 사이의 상호 의존성 정도를 나타내는 척도로, 코드의 유연성, 확장성, 그리고 유지보수성을 결정하는 가장 중요한 요소 중 하나입니다.

    결합도가 높은 시스템은 마치 모든 가구가 바닥에 강력 접착제로 붙어있는 방과 같습니다. 의자 하나를 옮기려고 해도 바닥 전체를 뜯어내야 하는 대공사가 필요합니다. 이와 같이, 코드의 결합도가 높으면 간단한 기능 변경 하나가 예기치 않은 수많은 다른 모듈의 수정을 요구하며, 이는 개발 일정 지연, 예측 불가능한 버그 발생, 그리고 개발자의 번아웃을 초래하는 ‘보이지 않는 재앙’이 됩니다. 이 글에서는 결합도의 정확한 의미와 그 수준을 나누는 6가지 레벨을 구체적인 예시와 함께 깊이 있게 탐구할 것입니다. 또한, 이것이 현대적인 API 설계나 의존성 주입 패턴과 어떻게 연결되는지, 그리고 왜 제품 관리자와 UX/UI 디자이너조차 이 개념을 이해해야 하는지를 명확히 설명해 드릴 것입니다.

    목차

    1. 결합도란 무엇인가?: 시스템 유연성의 척도
    2. 결합도의 6가지 레벨: 단단한 악연부터 건강한 관계까지
    3. 현대 소프트웨어 개발에서 결합도 관리하기
    4. 결론: 유연한 시스템을 향한 여정

    결합도란 무엇인가?: 시스템 유연성의 척도

    결합도의 정의와 중요성

    결합도(Coupling)는 하나의 모듈이 변경될 때 다른 모듈이 함께 변경되어야 하는 정도를 측정하는 지표입니다. 즉, 모듈 간의 의존성이 얼마나 강한지를 나타냅니다. 이상적인 소프트웨어는 각 모듈이 독립적으로 작동하여, 하나의 모듈을 수정하거나 교체하더라도 다른 부분에 미치는 영향(Side Effect)이 거의 없어야 합니다. 이러한 상태를 ‘느슨한 결합(Loose Coupling)’ 또는 ‘약한 결합’이라고 부릅니다. 반대로, 여러 모듈이 서로의 내부 구조나 데이터에 깊숙이 관여하여 떼려야 뗄 수 없는 관계가 된 상태를 ‘강한 결합(Tight Coupling)’ 또는 ‘높은 결합’이라고 합니다.

    결합도가 낮은 시스템은 여러 가지 중요한 이점을 제공합니다. 첫째, 유지보수가 매우 쉬워집니다. 특정 기능의 요구사항이 변경되었을 때, 해당 모듈만 집중적으로 수정하면 되므로 작업 범위가 명확해지고 버그 발생 가능성이 줄어듭니다. 둘째, 테스트가 용이합니다. 각 모듈을 독립적으로 테스트할 수 있기 때문에, 문제의 원인을 신속하게 파악하고 격리할 수 있습니다. 셋째, 재사용성이 향상됩니다. 다른 모듈에 대한 의존성이 적은 모듈은 다른 프로젝트나 시스템에서도 쉽게 가져다 쓸 수 있는 부품이 됩니다. 넷째, 팀 단위의 병렬 개발이 가능해집니다. 각 팀이 맡은 모듈이 다른 팀의 작업에 큰 영향을 주지 않으므로, 대규모 프로젝트에서 개발 생산성을 극대화할 수 있습니다.

    응집도(Cohesion)와의 관계: 좋은 설계의 두 기둥

    결합도는 지난 글에서 다룬 응집도(Cohesion)와 함께 소프트웨어 설계 품질을 평가하는 핵심적인 두 축을 이룹니다. 이 둘의 관계는 ‘모듈 내부는 단단하게, 모듈 외부는 유연하게’라는 한 문장으로 요약할 수 있습니다. 즉, 좋은 설계의 목표는 ‘높은 응집도와 낮은 결합도(High Cohesion, Low Coupling)’를 동시에 달성하는 것입니다.

    이 관계를 오케스트라에 비유해 봅시다. 바이올린 파트는 오직 바이올린 연주에만 고도로 집중하고 연습합니다(높은 응집도). 트럼펫 파트 역시 트럼펫 연주에만 몰두합니다(높은 응집도). 이 두 파트는 서로의 악기 연주법이나 내부 연습 과정에 대해 전혀 알 필요가 없습니다. 그들이 소통하는 유일한 방법은 지휘자의 지휘와 악보라는 명확하고 잘 정의된 인터페이스를 통해서입니다(낮은 결합도). 만약 바이올린 연주자가 트럼펫 연주자의 연주법에 사사건건 간섭하거나, 악보 없이 서로의 눈치만 보며 연주한다면(높은 결합도), 그 오케스트라는 아름다운 하모니를 만들어낼 수 없을 것입니다. 소프트웨어 모듈도 마찬가지입니다. 각자의 책임에만 충실하도록 응집도를 높이고, 모듈 간의 소통은 최소한의 표준화된 방법으로만 이루어지도록 결합도를 낮추는 것이 견고하고 아름다운 시스템을 만드는 비결입니다.


    결합도의 6가지 레벨: 단단한 악연부터 건강한 관계까지

    결합도는 그 강도에 따라 여러 수준으로 분류됩니다. 일반적으로 6가지 레벨로 나누며, 가장 강한 결합(최악)부터 가장 느슨한 결합(최상) 순으로 살펴볼 것입니다. 내 코드가 어느 수준에 해당하는지 파악하고 개선 방향을 찾는 것은 매우 중요합니다.

    결합도 수준 (영문명)설명좋은가?
    1. 내용 결합도 (Content Coupling)한 모듈이 다른 모듈의 내부 데이터나 코드를 직접 수정.매우 나쁨
    2. 공통 결합도 (Common Coupling)여러 모듈이 하나의 공통된 전역 변수나 데이터를 공유하고 수정.나쁨
    3. 외부 결합도 (External Coupling)여러 모듈이 외부의 특정 파일 포맷이나 통신 프로토콜을 공유.좋지 않음
    4. 제어 결합도 (Control Coupling)한 모듈이 다른 모듈의 동작을 제어하는 제어 신호(플래그)를 전달.보통
    5. 스탬프 결합도 (Stamp Coupling)모듈 간에 필요한 데이터만 전달하는 것이 아니라, 구조체나 객체 전체를 전달.양호
    6. 자료 결합도 (Data Coupling)모듈 간에 필요한 최소한의 데이터(매개변수)만으로 통신.매우 좋음

    1. 내용 결합도 (Content Coupling)

    가장 최악의 결합도 수준으로, 한 모듈이 다른 모듈의 내부로 직접 침투하여 그 모듈의 지역 데이터를 수정하거나, 코드의 특정 부분으로 직접 분기하는 경우를 말합니다. 이는 다른 모듈의 주권을 완전히 무시하는 행위이며, 객체지향 프로그래밍에서는 private으로 선언된 멤버 변수를 외부에서 강제로 바꾸는 것과 같습니다.

    • 예시: 모듈 A가 모듈 B 내부에 선언된 count라는 변수의 값을 B.count = 10; 과 같이 직접 수정하는 코드입니다. 이렇게 되면 모듈 B는 자신의 상태를 스스로 제어할 수 없게 되며, count 값이 예상치 못하게 변경되어 심각한 버그를 유발합니다. 모듈 B를 수정하면 모듈 A까지 반드시 함께 검토해야 하므로 유지보수가 극도로 어려워집니다.

    2. 공통 결합도 (Common Coupling)

    여러 모듈이 하나의 공통된 데이터 영역, 예를 들어 전역 변수(Global Variable)를 공유하고, 이를 통해 서로 통신하며 데이터를 변경하는 구조입니다. 전역 변수는 프로그램 어디서든 접근하고 수정할 수 있기 때문에 매우 편리해 보이지만, 치명적인 단점을 가집니다.

    • 예시: currentUser라는 전역 객체를 두고, ‘로그인 모듈’이 이 객체에 사용자 정보를 채워 넣고, ‘게시판 모듈’과 ‘알림 모듈’이 이 객체를 참조하여 사용자 이름을 화면에 표시한다고 가정해 봅시다. 만약 ‘프로필 수정 모듈’이 currentUser의 이름을 변경했는데, ‘알림 모듈’이 변경 사실을 인지하지 못하고 이전 이름으로 알림을 보낸다면 데이터 불일치 문제가 발생합니다. 어떤 모듈이 전역 변수를 언제, 어떻게 바꾸었는지 추적하기가 매우 어려워 시스템 전체가 불안정해집니다.

    3. 외부 결합도 (External Coupling)

    두 개 이상의 모듈이 외부의 특정 파일 포맷, 통신 프로토콜, 또는 데이터베이스 스키마와 같은 외부 요소에 함께 의존하는 경우입니다. 공통 결합도와 유사하지만, 공유 대상이 프로그램 내부의 데이터가 아닌 외부의 요소라는 차이가 있습니다.

    • 예시: ‘주문 생성 모듈’과 ‘재고 관리 모듈’이 모두 데이터베이스의 ‘Products’라는 테이블 구조에 직접 의존하고 있다고 생각해 봅시다. 만약 관리자가 ‘Products’ 테이블에 가격(price) 컬럼의 데이터 타입을 정수(int)에서 실수(float)로 변경한다면, 이 테이블을 직접 참조하는 ‘주문 생성 모듈’과 ‘재고 관리 모듈’ 두 곳 모두에서 에러가 발생하며 코드를 수정해야 합니다. 외부의 변경 하나가 시스템의 여러 부분에 파급 효과를 일으키는 것입니다.

    4. 제어 결합도 (Control Coupling)

    한 모듈이 다른 모듈로 제어 플래그(Control Flag)와 같은 값을 전달하여, 전달받은 모듈의 동작 방식을 결정하는 구조입니다. 즉, 호출하는 모듈이 호출되는 모듈의 내부 로직을 알고 있다는 것을 전제로 합니다.

    • 예시: processData(data, sortOption)이라는 함수가 있고, sortOption 값에 따라 ‘이름순 정렬’ 또는 ‘날짜순 정렬’을 수행한다고 가정해 봅시다. processData를 호출하는 모듈은 sortOption에 어떤 값을 넣어야 하는지, 그리고 그 값에 따라 processData가 어떻게 동작할지를 미리 알고 있어야 합니다. 이는 두 모듈 간의 논리적인 의존성을 만들어냅니다. 만약 processData에 ‘가격순 정렬’ 기능이 추가된다면, 이 함수를 호출하는 모든 모듈의 코드를 검토하고 수정해야 할 수도 있습니다.

    5. 스탬프 결합도 (Stamp Coupling)

    두 모듈이 데이터를 주고받을 때, 필요한 개별 데이터가 아닌 데이터 구조(자료 구조, 객체 등) 전체를 전달하는 경우입니다. 마치 편지를 보낼 때 필요한 내용 한 줄만 보내면 되는데, 집문서 전체를 복사해서 보내는 것과 같습니다.

    • 예시: 학생의 이름만 필요한 printStudentName() 함수에 student 객체 전체(이름, 학번, 주소, 성적 등 모든 정보 포함)를 매개변수로 전달하는 경우입니다. printStudentName() 함수는 이름 외의 다른 데이터는 전혀 사용하지 않음에도 불구하고, student 객체의 구조가 변경될 때마다(예: ‘전공’ 필드 추가) 영향을 받을 잠재적 가능성이 생깁니다. 또한, 불필요하게 많은 데이터를 주고받는 것은 비효율적일 수 있습니다.

    6. 자료 결합도 (Data Coupling)

    가장 이상적이고 바람직한 결합도 수준입니다. 모듈 간의 데이터 교환이 오직 필요한 최소한의 데이터, 즉 매개변수를 통해서만 이루어지는 경우입니다. 호출된 모듈은 전달받은 데이터로 자신의 작업을 수행하고 결과를 반환할 뿐, 호출한 모듈이나 시스템의 다른 부분에 대해 전혀 알 필요가 없습니다.

    • 예시: calculateArea(width, height) 함수는 가로 길이와 세로 길이만 인자로 받아 넓이를 계산하여 반환합니다. 이 함수는 width와 height가 어디서 왔는지, 이 함수를 누가 호출했는지 전혀 신경 쓰지 않습니다. 오직 자신의 기능에만 충실합니다. 이러한 자료 결합도는 모듈의 독립성을 최대로 보장하며, 재사용성과 테스트 용이성을 극대화합니다. 우리가 작성하는 대부분의 함수는 바로 이 자료 결합도를 목표로 설계되어야 합니다.

    현대 소프트웨어 개발에서 결합도 관리하기

    결합도를 낮추는 것은 단순히 코드를 깔끔하게 만드는 것을 넘어, 변화에 빠르게 대응하고 안정적으로 서비스를 운영하기 위한 현대 소프트웨어 개발의 핵심 전략입니다. 특히 복잡한 시스템을 다루는 제품 관리자나 사용자 경험의 일관성을 책임지는 UX/UI 디자이너에게도 결합도에 대한 이해는 필수적입니다.

    API와 느슨한 결합

    오늘날 마이크로서비스 아키텍처(MSA)의 핵심은 서비스 간의 느슨한 결합을 유지하는 것입니다. 이를 가능하게 하는 가장 중요한 도구가 바로 잘 정의된 API(Application Programming Interface)입니다. 각 서비스는 자신의 기능을 API라는 표준화된 창구를 통해서만 외부에 공개합니다. 다른 서비스는 그 서비스의 내부 구현(어떤 프로그래밍 언어를 썼는지, 어떤 데이터베이스를 사용하는지 등)을 전혀 몰라도, 약속된 API 명세에 따라 요청을 보내고 응답을 받기만 하면 됩니다.

    예를 들어, ‘결제 서비스’는 POST /payments라는 API를 제공하여 결제를 처리합니다. ‘주문 서비스’는 이 API를 호출할 때 필요한 최소한의 정보(주문 금액, 사용자 ID 등)만 전달하면 됩니다(자료 결합도). 만약 ‘결제 서비스’ 내부에서 사용하는 PG(Payment Gateway)사가 변경되거나 결제 로직이 복잡하게 바뀌더라도, API 명세만 그대로 유지된다면 ‘주문 서비스’는 아무런 코드를 변경할 필요가 없습니다. 이처럼 API는 서비스 간의 강력한 방화벽 역할을 하여, 변경의 파급 효과를 차단하고 각 서비스의 독립적인 발전을 가능하게 합니다.

    의존성 주입(Dependency Injection)과 제어의 역전(IoC)

    애플리케이션 내부의 클래스나 컴포넌트 간의 결합도를 낮추기 위해 널리 사용되는 중요한 디자인 패턴으로 ‘의존성 주입(DI)’과 ‘제어의 역전(IoC)’이 있습니다. 과거에는 객체 A가 객체 B의 기능을 필요로 할 때, A 내부에서 직접 B b = new B(); 와 같이 B 객체를 생성했습니다. 이는 A가 B라는 구체적인 클래스에 직접 의존하는 강한 결합을 만듭니다.

    의존성 주입은 이러한 의존 관계를 외부에서 결정하고 주입해주는 방식입니다. 객체 A는 더 이상 B를 직접 생성하지 않고, 외부의 조립기(Assembler)나 프레임워크(예: Spring)가 생성된 B 객체를 A에 전달(주입)해 줍니다. 이를 통해 A는 B라는 구체적인 구현이 아닌, B가 구현한 추상적인 인터페이스에만 의존하게 되어 결합도가 크게 낮아집니다. 객체가 자신의 의존성을 직접 관리하는 것이 아니라 외부로부터 제어받는다고 해서 ‘제어의 역전(Inversion of Control)’이라고 부릅니다. 이는 코드의 유연성과 확장성을 높이고, 단위 테스트 시 실제 객체 대신 가짜 객체(Mock Object)를 쉽게 주입할 수 있게 하여 테스트 효율을 극대화합니다.

    PM, UX/UI 관점에서의 결합도

    기술적인 개념처럼 보이는 결합도는 제품 개발의 속도와 방향성에 직접적인 영향을 미칩니다. 제품 관리자(PM)가 ‘상품 상세 페이지에 새로운 추천 상품 로직을 추가해주세요’라는 간단한 요구사항을 제시했다고 가정해 봅시다. 만약 프론트엔드 코드와 백엔드 코드가 강하게 결합되어 있다면, 이 작은 UI 변경을 위해 백엔드의 데이터 조회 방식, API 구조, 심지어 데이터베이스 스키마까지 변경해야 할 수도 있습니다. 이는 예상보다 훨씬 큰 개발 공수로 이어져 다른 중요한 기능의 개발 일정을 지연시킵니다.

    반면, 각 부분이 느슨하게 결합되어 있다면, 백엔드팀은 기존 API를 유지한 채 새로운 추천 로직을 개발하고, 프론트엔드팀은 해당 API를 호출하여 화면에 표시하기만 하면 됩니다. 이처럼 낮은 결합도는 기능 개발을 독립적으로 진행할 수 있게 하여 제품의 시장 출시 시간(Time-to-Market)을 단축시킵니다. UX/UI 디자이너 역시 마찬가지입니다. 디자인 시스템을 구축할 때 컴포넌트 간의 결합도를 고려하여 설계하면, 특정 컴포넌트의 디자인 변경이 다른 컴포넌트에 미치는 영향을 최소화하여 전체 UI의 일관성을 유지하기 쉬워집니다.


    결론: 유연한 시스템을 향한 여정

    결합도는 소프트웨어의 건강 상태를 진단하는 청진기와 같습니다. 결합도를 세심하게 관리하는 것은 당장의 기능 구현보다 훨씬 더 중요한 장기적인 투자입니다. 낮은 결합도는 변화의 충격을 흡수하는 유연한 구조를 만들어, 예측 불가능한 비즈니스 요구사항과 급변하는 기술 환경 속에서 우리의 소프트웨어가 살아남고 지속적으로 발전할 수 있는 힘을 제공합니다.

    우리의 목표는 명확합니다. 모듈 간의 의존성을 최소화하고, 불가피한 의존성은 가장 이상적인 ‘자료 결합도’ 수준으로 유지하기 위해 노력해야 합니다. 이를 위해 명확한 인터페이스를 설계하고, 전역 변수 사용을 지양하며, 의존성 주입과 같은 검증된 디자인 패턴을 적극적으로 활용해야 합니다.

    하지만 기억해야 할 점은, ‘제로 결합도’는 현실적으로 불가능하며 바람직하지도 않다는 것입니다. 모든 모듈이 완벽히 고립되어 있다면 시스템은 아무런 일도 할 수 없습니다. 중요한 것은 결합 그 자체가 아니라 ‘결합을 어떻게 관리할 것인가’입니다. 각 모듈이 꼭 필요한 최소한의 약속(인터페이스)을 통해 소통하도록 설계하고, 그 약속이 깨졌을 때의 파급 효과를 최소화하는 것이 핵심입니다.

    결합도 관리는 한 번에 끝나는 작업이 아니라, 프로젝트 생명주기 전체에 걸쳐 계속되는 여정입니다. 개발자, 아키텍트, 그리고 제품 관리자 모두가 결합도의 중요성을 이해하고, 코드 리뷰와 설계 논의 과정에서 “이 변경이 다른 부분에 어떤 영향을 미칠까?”라는 질문을 습관처럼 던지는 문화를 만들어야 합니다. 그럴 때 비로소 우리는 단단하면서도 유연하고, 세월의 변화를 견뎌내는 위대한 소프트웨어를 만들어낼 수 있을 것입니다.