문서 읽는 데 51분 · B1

B-1: 응집도·결합도와 SOLID 앞 세 글자 — SRP·OCP·LSP

목차 25
전체 11강 중 5강 · 클린코드
난이도 · 중급

ℹ️읽기 좋고 고치기 쉬운 코드를 쓰는 법 — 리팩토링·설계 원칙. 코드를 충분히 써본 뒤에 빛을 발하는 주제예요.

안녕하세요, 여러분의 코드 안목을 길러 드릴 홍순구 튜터입니다. 지난 시간까지 우리는 A 카테고리에서 한 줄 한 줄을 손봤습니다. 의미 있는 이름, 작은 함수, 좋은 객체, 깨끗한 에러 처리까지요. 코드를 "한 줄 단위"로 읽기 좋게 만드는 기본기였죠.

오늘부터는 시야가 한 단계 넓어집니다. 한 줄에서 클래스와 클래스 사이로요. 같은 코드라도 클래스를 어떻게 나누고 어떻게 잇느냐에 따라, 기능 하나를 추가할 때 한 곳만 고치면 끝나기도 하고 사방이 함께 터지기도 합니다. 그 차이를 만드는 게 바로 오늘 배울 응집도·결합도SOLID 설계 원칙이에요.

혹시 코드 리뷰에서 이런 말 들어 보셨나요? "이 클래스, 바꿀 이유가 다섯 개나 되네요." "기능 하나 추가하려고 멀쩡한 기존 코드를 또 고치고 있어요." "자식 클래스가 부모가 한 약속을 어겨요." 셋 다 오늘 우리가 정면으로 풀 지적들입니다. 지난 시간 외부를 인터페이스로 감쌌던 그 감각이, 오늘 설계 원칙으로 그대로 이어질 거예요.

텍스트
 오늘의 여정 — 두 축을 잡고, 세 원칙으로 들어간다

  [좋은 설계의 두 축]
   Step 1  응집도   "이 클래스, 한 가지에 집중하나?"
   Step 2  결합도   "한 곳 바꾸면 몇 곳이 깨지나?"
        │
        
  [SOLID 앞 세 글자 — 두 축을 실천하는 원칙]
   Step 3  SRP   단일 책임   "바꿀 이유가 몇 개예요?"
   Step 4  OCP   개방-폐쇄   "기능 추가에 기존 코드를 또 고치나요?"
   Step 5  LSP   리스코프 치환  "자식이 부모 약속을 어긴다"
        │
        
   Step 6  세 원칙이 결국 두 축으로 모인다 + 언제 이 규칙을 깨나

SOLID는 좋은 객체지향 설계를 위한 다섯 가지 원칙의 머리글자입니다. 오늘은 그중 앞 세 글자(S·O·L)를 다루고, 나머지 두 글자(I·D)는 다음 시간에 마저 채웁니다. 자, 클래스 사이의 구조를 보는 눈을 길러 봅시다!

💡 오늘 수업의 핵심 — "좋은 설계는 결국 '낮은 결합, 높은 응집'이고, SOLID는 그걸 이루는 구체적인 손잡이다"

오늘 하루를 한 문장으로 줄이면 이렇습니다. 좋은 설계인지 아닌지는 두 가지 질문으로 거의 판가름 납니다. "클래스 안의 것들이 한 가지 일로 뭉쳐 있나(응집도)", 그리고 "한 클래스를 바꿀 때 옆 클래스가 얼마나 함께 흔들리나(결합도)". SRP·OCP·LSP는 이 두 질문에 "그렇다"고 답하게 만드는 실천 원칙이고요.

그리고 오늘 처음부터 끝까지 함께 갈 태도가 하나 있습니다. 원칙을 외워서 맹목적으로 따르지 않는다는 것. 각 원칙마다 "이게 오히려 해로운 경우"를 같이 봅니다. 규칙을 아는 건 시작일 뿐이고, 언제 그 규칙을 깨야 하는지 판단하는 게 진짜 실력이니까요.

🎯 학습 목표

  • 응집도와 결합도라는 두 축으로 설계의 좋고 나쁨을 스스로 평가할 수 있다.
  • SRP·OCP·LSP 세 원칙을 Before/After 코드로 이해하고, 지저분한 코드를 직접 고칠 수 있다.
  • 각 원칙을 언제 적용하고 언제 깨야 하는지 판단 기준을 갖는다.

Step 1: "이 클래스, 한 가지에 집중하나?" (응집도)

먼저 응집도(cohesion)부터 잡고 갑시다. 응집도란 한 클래스(또는 모듈) 안에 모인 것들이 얼마나 한 가지 목적으로 뭉쳐 있는가를 말합니다. 응집도가 높다는 건 그 클래스의 필드와 메서드가 서로 긴밀하게 얽혀 한 가지 일을 한다는 뜻이고, 낮다는 건 상관없는 것들이 한 지붕 아래 어쩌다 모여 있다는 뜻이에요.

말로는 추상적이니 코드로 봅시다. 아래는 직원 정보를 다루는 클래스인데, 가만히 뜯어보면 성격이 완전히 다른 두 무리가 섞여 있습니다.

Java
public class LowCohesionEmployee {

    private static final int HOURLY_WAGE = 15_000;

    private final int baseSalary;
    private final int workedHours;
    private final String name;
    private final String email;

    /** 급여 무리 — baseSalary, workedHours 만 만진다. */
    public int monthlyPay() {
        return baseSalary + workedHours * HOURLY_WAGE;
    }

    /** 프로필 무리 — name, email 만 만진다. (급여와 아무 상관이 없다) */
    public String profileCard() {
        return name + " <" + email + ">";
    }
}

monthlyPay()baseSalaryworkedHours만 씁니다. profileCard()nameemail만 쓰고요. 두 메서드가 함께 쓰는 필드가 하나도 없어요. 이게 낮은 응집의 전형적인 신호입니다. 한 클래스처럼 생겼지만, 사실은 급여 계산기와 프로필 표시기라는 두 클래스가 한 몸에 끼어 있는 거죠.

텍스트
 ❌ 낮은 응집 — 한 지붕 아래 남남

   ┌─────────────────────┐
   │  monthlyPay()       │── baseSalary · workedHours 만 만진다
   │  profileCard()      │── name · email 만 만진다
   └─────────────────────┘
     두 메서드가 함께 쓰는 필드가 0개  두 클래스가 한 몸에 끼어 있다

뭐가 문제냐고요? 급여 정책이 바뀌어서 monthlyPay()를 고치러 이 파일을 열면, 전혀 상관없는 프로필 코드까지 같이 눈에 들어옵니다. 반대로 프로필 표시를 손보려는 사람도 급여 로직을 헤집고 지나가야 하죠. 서로 무관한 변경이 한 파일에서 자꾸 부딪힙니다.

해법은 단순합니다. 함께 가는 것끼리 모아 두 클래스로 가릅니다.

Java
public class Payroll {

    private static final int HOURLY_WAGE = 15_000;

    private final int baseSalary;
    private final int workedHours;

    public int monthlyPay() {
        return baseSalary + workedHours * HOURLY_WAGE;
    }
}

Payroll은 급여에 필요한 필드만 가지고, 그 필드를 monthlyPay()가 전부 함께 씁니다. 프로필은 따로 EmployeeProfile로 떼어 냈고요(이름과 이메일만 들고 card()를 만듭니다). 이제 각 클래스 안에서는 모든 것이 한 가지 목적으로 긴밀하게 얽혀 있습니다. 이게 높은 응집이에요. 급여 정책이 바뀌면 Payroll만, 화면 표시가 바뀌면 EmployeeProfile만 열면 됩니다. 쪼개기 전과 후의 계산 결과가 똑같다는 건 코드베이스 테스트로 확인해 뒀어요.

다만 응집도를 신주단지처럼 모시면 또 다른 함정에 빠집니다.

⚠️ 언제 깨나 — 응집을 높이겠다고 무작정 잘게 쪼개면 오히려 역효과가 납니다. 함께 움직여야 자연스러운 것들까지 다른 클래스로 흩어 놓으면, 기능 하나를 이해하려고 파일 열 개를 왔다 갔다 해야 해요. 그건 응집이 높아진 게 아니라 흩어진 겁니다. 응집의 목표는 "관련된 것을 가까이"이지 "무조건 작게"가 아닙니다.

💡 한 줄 정리

응집도는 "한 클래스 안의 것들이 한 가지 일로 뭉쳐 있는가"입니다. 서로 다른 필드만 만지는 메서드들이 한 클래스에 모여 있다면, 그건 쪼개라는 신호예요.

🙋 학생 질문 — "튜터님, 응집도가 높은지 낮은지 눈으로 어떻게 빨리 알아채나요?"

가장 빠른 눈대중은 방금 본 그대로예요. 메서드들이 만지는 필드를 묶어 보는 겁니다. 클래스의 메서드를 쭉 훑으면서 "이 메서드는 어떤 필드를 쓰지?"를 표시해 보세요. 필드 사용이 두세 무리로 뚜렷하게 갈라지고 무리끼리 겹치는 필드가 없다면, 그 경계가 바로 클래스를 나눌 선입니다.

또 하나, 클래스 이름을 한 문장으로 설명해 보세요. "이 클래스는 ~를 한다"에 "그리고", "또"가 자꾸 붙으면 응집이 낮다는 뜻이에요. "직원의 급여를 계산하고, 그리고 프로필을 출력하고, 또 …"처럼요. 좋은 클래스는 "그리고" 없이 한 가지로 설명됩니다.


Step 2: "한 곳을 바꾸면 몇 곳이 깨지나?" (결합도)

응집도가 클래스 안쪽을 보는 눈이라면, 결합도(coupling)는 클래스 바깥쪽, 즉 클래스끼리의 연결을 보는 눈입니다. 결합도란 한 클래스가 다른 클래스에 얼마나 강하게 매여 있는가를 말해요. 강하게 매여 있으면(강결합) 한쪽을 바꿀 때 다른 쪽도 함께 깨지고, 느슨하게 연결돼 있으면(약결합) 한쪽을 바꿔도 옆이 멀쩡합니다.

지난 시간 외부 SDK를 우리 인터페이스 뒤에 감췄던 것, 기억하시죠? 그 감각이 여기서 그대로 살아납니다. 주문이 들어오면 알림을 보내는 서비스를 예로 볼게요. 먼저 강결합 버전입니다.

Java
public class OrderServiceBefore {

    private final EmailNotifier notifier = new EmailNotifier();

    public String placeOrder(String item) {
        return notifier.send(item);
    }
}

문제는 첫 줄에 있습니다. new EmailNotifier()구체적인 클래스를 직접 만들어 붙들고 있어요. 이러면 두 가지가 곤란해집니다. 하나, 알림을 SMS로 바꾸려면 이 서비스 코드를 직접 뜯어고쳐야 합니다. 둘, 테스트할 때도 진짜 이메일 발송기가 강제로 끼어들어, 원하는 가짜 발송기로 갈아 끼울 수가 없어요. EmailNotifier라는 한 곳의 변화가 OrderServiceBefore까지 곧장 번집니다.

텍스트
 ❌ 강결합               ✅ 약결합
   OrderService           OrderService
       │ new                  │ 약속(인터페이스)에만 기댄다
                             
   EmailNotifier          Notifier ── EmailNotifier
   (구체 클래스에 딱 붙음)   (무엇이 끼워질지 모른다) ── SmsNotifier

해법은 구체적인 것 대신 약속에 기대게 만드는 겁니다. Notifier라는 인터페이스(약속)를 두고, 서비스는 그 약속만 알게 하죠.

Java
public class OrderServiceAfter {

    private final Notifier notifier;

    public OrderServiceAfter(Notifier notifier) {
        this.notifier = notifier;
    }

    public String placeOrder(String item) {
        return notifier.send(item);
    }
}

이제 OrderServiceAfter는 누가 어떻게 알림을 보내는지 모릅니다. 그저 Notifier라는 약속만 믿고, 실제로 뭘 끼울지는 바깥에서 생성자로 넘겨줘요. 그래서 이메일을 SMS로 바꿔도 이 서비스는 한 줄도 안 바뀝니다. SmsNotifier를 만들어 넣어 주기만 하면 끝이죠. 테스트에서도 원하는 구현을 자유롭게 끼울 수 있고요. 이게 약결합입니다.

⚠️ 언제 깨나 — 결합도를 0으로 만들 수는 없습니다. 객체는 서로 협력해야 일을 하니까요. 연결을 아예 없애는 게 목표가 아니라 느슨하게 만드는 게 목표예요. 그리고 변할 일이 거의 없는 안정적인 클래스(예: 문자열 유틸리티)까지 굳이 인터페이스로 감싸면, 얻는 것 없이 따라가야 할 단계만 늘어납니다. "정말 갈아 끼울 일이 있나?"를 먼저 물으세요.

참고로 방금 "구체적인 것 말고 약속에 의존하라"는 이 감각을 정식 원칙으로 끌어올린 게 DIP(의존성 역전)인데, 그건 다음 시간에 제대로 다룹니다. 오늘은 "느슨하게 잇는다"까지만 잡아 두면 충분해요.

💡 한 줄 정리

결합도는 "한 곳을 바꿀 때 몇 곳이 함께 깨지는가"입니다. 구체 클래스를 직접 new 하는 대신 인터페이스라는 약속에 기대면, 변화가 한 곳에 갇혀 옆으로 번지지 않아요.

🙋 학생 질문 — "튜터님, 결합도랑 응집도는 따로 노는 두 개념인가요?"

아주 좋은 질문이에요. 둘은 동전의 양면처럼 맞물려 움직입니다. 보통 응집도를 높이면 결합도는 자연스럽게 낮아져요. 한 클래스가 한 가지 일에만 집중하면(높은 응집), 그 클래스가 다른 일을 하려고 여기저기 손 뻗을 일이 줄어드니까(낮은 결합) 그렇습니다.

반대로 응집이 낮은 God 클래스는 온갖 일을 다 하느라 사방의 클래스를 끌어다 쓰고, 그래서 결합도까지 높아지는 경우가 많아요. 그래서 "낮은 결합, 높은 응집"은 따로 외우는 두 구호가 아니라 좋은 설계라는 한 그림의 두 축입니다. 오늘 배울 세 원칙도 결국 이 두 축을 좋게 만드는 방법들이에요.


Step 3: "이 클래스가 바뀔 이유가 몇 개예요?" (SRP)

이제 SOLID의 첫 글자, SRP(Single Responsibility Principle, 단일 책임 원칙)입니다. 한 클래스는 바뀔 이유가 단 하나여야 한다는 원칙이에요. 여기서 "책임"을 "기능"이 아니라 "바뀔 이유"로 잡는 게 핵심입니다. 더 정확히는, 그 변경을 요구하는 사람(액터)이 누구냐로 보는 거예요.

A-2에서 함수가 한 가지 일만 하게 쪼갰던 것 기억나시죠? 그 시야를 이번엔 클래스 단위로 넓히는 겁니다. 아래 리포트 클래스를 봅시다.

Java
public class OrderReportBefore {

    private final List<Integer> amounts;

    public int totalSales() {
        int total = 0;
        for (int amount : amounts) {
            total += amount;
        }
        return total;
    }

    public String toHtml() {
        return "<p>총 매출: " + totalSales() + "원</p>";
    }

    public String fileName(String date) {
        return "sales-" + date + ".html";
    }
}

세 메서드가 있는데, 각각 바뀔 이유가 다릅니다. totalSales()는 매출 집계 규칙이 바뀌면 손봐야 하니 회계팀의 요구로 바뀝니다. toHtml()은 리포트 화면 디자인이 바뀌면 손봐야 하니 디자인팀의 요구로 바뀌고요. fileName()은 저장 규칙이 바뀌면 손봐야 하니 인프라팀의 요구로 바뀝니다. 한 클래스에 세 부서의 변경 이유가 모여 있는 거죠.

텍스트
 OrderReportBefore — 바뀔 이유가 셋

   totalSales()  ── 회계팀  (집계 규칙이 바뀌면)
   toHtml()      ── 디자인팀 (화면이 바뀌면)
   fileName()    ── 인프라팀 (저장 규칙이 바뀌면)

   세 부서의 요구가 한 파일에서 부딪힌다

이게 왜 위험할까요? 디자인팀 요청으로 toHtml()만 고치려다 실수로 집계 코드를 건드릴 수 있고, 그 반대도 생깁니다. 서로 다른 이유의 변경이 한 클래스에서 엉키면, 한쪽을 고칠 때마다 다른 쪽이 멀쩡한지 매번 확인해야 해요. 그래서 바뀔 이유별로 클래스를 가릅니다.

Java
public class SalesCalculator {

    public int total(List<Integer> amounts) {
        int total = 0;
        for (int amount : amounts) {
            total += amount;
        }
        return total;
    }
}

집계는 SalesCalculator로, 화면 표현은 SalesReportFormatter로, 파일 이름 규칙은 ReportFileName으로 떼어 냈습니다. 이제 각 클래스는 바뀔 이유가 딱 하나예요. 회계팀이 오면 SalesCalculator만, 디자인팀이 오면 SalesReportFormatter만 열면 됩니다. 쪼개기 전과 후의 집계·HTML·파일명 결과가 모두 동일하다는 건 코드베이스 테스트로 확인했어요.

코드 리뷰에서 "이 클래스, 바꿀 이유가 다섯 개나 되네요"라는 말을 듣는다면, 바로 이 SRP를 지적하는 겁니다. 클래스를 설명할 때 "그리고", "또"가 붙던 Step 1의 신호와도 자연스럽게 통하죠.

⚠️ 언제 깨나 — SRP를 광신하면 클래스가 폭발합니다. 메서드 하나에 클래스 하나씩 만들어 버리면, 단순한 흐름을 따라가는데도 파일을 수십 개 열어야 해요. "책임"의 경계는 절대적인 게 아니라 맥락에 따라 달라집니다. 작은 스크립트에서는 세 가지 일이 한 클래스에 있어도 충분히 괜찮아요. 변경 이유가 실제로 따로 움직이기 시작할 때 나누면 됩니다.

💡 한 줄 정리

SRP의 "책임"은 기능이 아니라 바뀔 이유입니다. 한 클래스에 서로 다른 부서(액터)의 변경 이유가 모여 있다면, 그 이유별로 나누세요.

🙋 학생 질문 — "튜터님, 그럼 모든 클래스는 메서드를 하나만 가져야 하나요?"

전혀 아닙니다! 흔한 오해예요. SRP는 "메서드 하나"가 아니라 "바뀔 이유 하나"입니다. 한 클래스에 메서드가 열 개 있어도, 그것들이 모두 같은 이유로 함께 바뀐다면 SRP를 잘 지키고 있는 거예요.

방금 본 SalesCalculator에 나중에 average(), max() 같은 메서드가 더 붙어도 괜찮습니다. 전부 "매출 집계 규칙"이라는 한 가지 이유로 바뀌니까요. 반대로 메서드가 단 두 개여도 그 둘이 서로 다른 부서 때문에 바뀐다면 나눠야 하고요. 개수가 아니라 변경 이유가 한 방향인가를 보세요.


Step 4: "기능 추가에 기존 코드를 또 고치나요?" (OCP)

두 번째 글자 OCP(Open-Closed Principle, 개방-폐쇄 원칙)입니다. 이름이 좀 묘한데, 풀면 이래요. 확장에는 열려 있고(open), 수정에는 닫혀 있어야 한다(closed). 새 기능을 추가할 때는 코드를 더하는 것으로 끝나야지, 잘 돌던 기존 코드를 고치게 만들면 안 된다는 뜻입니다.

지난 시간 본 할인 정책으로 가 봅시다. 등급에 따라 가격을 깎는 코드인데, 흔히 이렇게 짭니다.

Java
public class DiscountCalculatorBefore {

    public int discountedPrice(String grade, int price) {
        if (grade.equals("GOLD")) {
            return price - price * 20 / 100;
        } else if (grade.equals("SILVER")) {
            return price - price * 10 / 100;
        } else if (grade.equals("BRONZE")) {
            return price - price * 5 / 100;
        }
        return price;
    }
}

언뜻 멀쩡해 보이죠? 문제는 새 등급이 생길 때 드러납니다. PLATINUM 등급이 추가되면 이 if-else 사슬을 다시 열어 분기를 끼워 넣어야 해요. 그 과정에서 잘 돌던 GOLD·SILVER 분기까지 같은 메서드 안에서 함께 위태로워집니다. 기능을 더하려고 기존 코드를 수정하게 되는 거죠. 수정에 활짝 열려 있는, OCP 위반의 전형입니다.

해법은 바뀌는 부분을 추상화 뒤로 숨기는 겁니다. "등급마다 가격을 어떻게 깎느냐"를 DiscountPolicy라는 약속으로 빼내요.

Java
public interface DiscountPolicy {

    int discountedPrice(int price);
}

그리고 등급별로 이 약속을 구현한 클래스를 따로 둡니다. GoldDiscount는 20%, SilverDiscount는 10%, 이런 식으로요. 계산기는 어떤 등급인지 전혀 모른 채 약속에만 기댑니다.

Java
public class DiscountCalculatorAfter {

    public int discountedPrice(DiscountPolicy policy, int price) {
        return policy.discountedPrice(price);
    }
}

이제 PLATINUM 등급이 필요하면 PlatinumDiscount 클래스를 하나 추가하면 끝입니다. DiscountCalculatorAfter는 영원히 한 줄도 바뀌지 않아요. 기존 코드는 건드리지 않고(수정에 닫힘), 새 클래스를 더하는 것으로 기능이 늘어납니다(확장에 열림). 등급별 할인가가 Before와 After 모두 동일하다는 건 코드베이스에서 확인해 뒀어요.

텍스트
 ❌ Before — 새 등급 = if 사슬을 또 수정
    if GOLD … else if SILVER … else if BRONZE … else if PLATINUM …   계속 열어 고친다

 ✅ After — 새 등급 = 클래스 하나 추가
    DiscountPolicy ── GoldDiscount
                   ── SilverDiscount
                   ── BronzeDiscount
                   ── PlatinumDiscount    새로 더할 뿐, 기존은 안 건드린다

재미있는 건, 정적 타입 인터페이스가 없는 언어에서는 이 확장점을 더 가볍게 열기도 한다는 점입니다. 함수를 값처럼 다루는 파이썬에서는 등급에서 함수로 가는 사전(dict) 하나면 같은 일을 해요.

Python
DISCOUNT_POLICIES = {
    "GOLD": lambda price: price - price * 20 // 100,
    "SILVER": lambda price: price - price * 10 // 100,
    "BRONZE": lambda price: price - price * 5 // 100,
}

새 등급은 이 사전에 한 줄을 더하면 되고, 기존 함수는 건드리지 않습니다. 인터페이스와 구현 클래스 대신 일급 함수로 확장점을 연 거죠. "같은 원칙이라도 언어에 따라 모습이 달라진다"를 잘 보여 주는 대목입니다.

⚠️ 언제 깨나 — OCP의 함정은 과한 추상화입니다. "혹시 나중에 바뀔지 모르니까" 모든 부분을 미리 인터페이스로 빼 두면, 정작 바뀌지도 않을 것에 대비하느라 코드만 복잡해져요. 이걸 YAGNI(You Aren't Gonna Need It, 그거 안 쓸 거다)라고 부릅니다. 확장점은 정말로 자주 바뀌는 축에만 여세요. 등급처럼 실제로 계속 늘어나는 것에는 OCP가 약이지만, 한 번 정하면 안 바뀌는 것까지 추상화하면 독입니다.

💡 한 줄 정리

OCP는 "기능 추가를 수정이 아니라 추가로"입니다. 자주 바뀌는 부분을 인터페이스 뒤로 빼면, 새 기능은 새 구현을 더하는 것으로 끝나고 기존 코드는 안전하게 닫힙니다.

🙋 학생 질문 — "튜터님, if-else가 무조건 나쁜 건가요? 분기 두 개도 인터페이스로 빼야 하나요?"

아니요, if-else 자체는 죄가 없습니다. 분기가 두세 개고 앞으로 늘어날 일이 없다면, 그냥 if-else가 가장 읽기 쉽고 정직한 코드예요. 괜히 인터페이스로 빼면 오히려 복잡해집니다.

OCP를 꺼내야 할 때는 그 분기가 같은 이유로 계속 늘어날 조짐이 보일 때입니다. 등급이 GOLD·SILVER로 시작해 BRONZE, PLATINUM, VIP로 자꾸 추가된다면, 그건 추상화로 확장점을 열라는 신호예요. 판단 기준은 "이 분기가 앞으로 또 추가될까?"입니다. 추가될 것 같으면 OCP, 고정이면 if-else 그대로. 미래에 대한 과한 베팅도, 무대비도 둘 다 경계하세요.


Step 5: "자식이 부모의 약속을 어긴다" (LSP)

세 번째 글자 LSP(Liskov Substitution Principle, 리스코프 치환 원칙)입니다. 이름은 어렵지만 뜻은 명료해요. 자식 클래스는 부모 클래스를 온전히 대신할 수 있어야 한다. 부모를 쓰던 코드에 자식을 넣어도 똑같이 잘 돌아가야 한다는 거죠. 자식이 부모가 한 약속을 어기면 LSP 위반입니다.

이걸 보여 주는 가장 유명한 예가 직사각형-정사각형 문제입니다. 수학 시간엔 "정사각형은 직사각형의 일종"이라고 배우죠? 그 직관을 그대로 상속으로 옮기면 사고가 납니다. 먼저 직사각형입니다.

Java
public 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 area() {
        return width * height;
    }
}

이 직사각형의 약속은 분명합니다. "너비와 높이를 따로 정할 수 있다." 너비를 5로, 높이를 4로 맞추면 넓이는 20이 나와야 해요. 이제 정사각형을 자식으로 만들어 봅시다.

Java
public 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;
    }
}

정사각형은 네 변이 같아야 하니, 너비를 바꾸면 높이도 같이 바꿉니다. 논리적으로 맞는 것 같죠? 그런데 직사각형을 믿고 쓰는 코드 입장에서 보면 약속이 깨집니다. "너비 5, 높이 4로 맞췄으니 넓이는 20"이라고 기대하는 코드에 정사각형을 넣으면, 마지막에 호출한 setHeight(4)가 너비까지 4로 만들어 버려 넓이가 16이 나와요.

텍스트
 직사각형을 기대하는 코드:  setWidth(5); setHeight(4); area() ?

   Rectangle 을 넣으면   너비 5 · 높이 4   넓이 20  ✅ 약속대로
   Square 를 넣으면      너비 4 · 높이 4   넓이 16  ❌ 약속을 깬다

부모를 쓰던 코드에 자식을 넣었더니 결과가 달라졌습니다. 정사각형은 직사각형을 온전히 대신하지 못해요. 코드베이스에서 같은 코드에 직사각형을 넣으면 20, 정사각형을 넣으면 16이 나와 기대를 깬다는 걸 확인했습니다. "자식 클래스가 부모 약속을 어겨요"라는 리뷰 지적이 바로 이 장면이에요.

해법은 억지 상속을 끊는 겁니다. 정사각형을 직사각형의 자식으로 두지 말고, 둘 다 "넓이를 구할 수 있다"는 공통 약속만 지키게 해요.

Java
public record RectangleShape(int width, int height) implements Shape {

    @Override
    public int area() {
        return width * height;
    }
}

RectangleShapeSquareShape는 각각 Shape라는 약속(넓이를 구한다)을 따로 지킵니다. 게다가 만들 때 값을 정하면 바뀌지 않는 불변 객체(setter가 없죠)라, "한 변을 바꾸면 다른 변이 따라 바뀌는" 위반 자체가 생길 수 없어요. 무엇을 넣어도 area()는 정확합니다. 이게 LSP를 지키는 설계예요.

⚠️ 언제 깨나 — LSP 위반의 진짜 교훈은 "상속을 함부로 쓰지 마라"입니다. 상속은 부모와 자식을 강하게 묶는 강결합이라, 부모의 약속을 자식이 빠짐없이 지키기가 생각보다 어려워요. "is-a(∼는 ∼다)"가 말이 된다고 무조건 상속하면 이런 사고가 납니다. LSP를 지키기 어렵다 싶으면, 상속을 버리고 조합으로 가는 게 답일 때가 많아요. 이 이야기는 다음 시간 "상속보다 컴포지션"에서 정면으로 다룹니다.

💡 한 줄 정리

LSP는 "자식은 부모를 온전히 대신할 수 있어야 한다"입니다. 자식이 부모의 약속을 어긴다면, 그건 상속을 잘못 쓴 신호예요.

🙋 학생 질문 — "튜터님, 정사각형이 직사각형의 자식인 게 수학적으론 맞잖아요. 왜 코드에선 틀린가요?"

핵심을 찌르는 질문이에요. 수학에서 "정사각형은 직사각형이다"는 속성에 대한 이야기입니다. 정사각형은 직사각형의 모든 성질을 갖죠. 하지만 코드의 상속은 속성이 아니라 행동(behavior)에 대한 약속입니다.

Rectangle의 약속은 단순히 "네 각이 직각"이 아니라 "너비와 높이를 독립적으로 바꿀 수 있다"는 행동까지 포함해요. 정사각형은 이 행동 약속을 지킬 수 없습니다. 한 변을 바꾸면 다른 변이 따라 바뀌어야 하니까요. 그래서 "정사각형 is-a 직사각형"이 수학적으론 참이어도, 행동의 약속으로 보면 자식이 부모를 대신할 수 없는 관계인 거예요. LSP는 "is-a처럼 보인다"가 아니라 "행동까지 대체 가능한가"를 보라고 가르칩니다.


Step 6: "세 원칙은 결국 두 축으로" (종합과 도그마 경계)

여기까지 다섯 걸음을 걸었습니다. 마지막으로 오늘 배운 것들이 어떻게 한 그림으로 모이는지 정리하고, 가장 중요한 태도 하나를 못 박고 끝내겠습니다.

오늘의 두 축은 응집도와 결합도였습니다. 그리고 SRP·OCP·LSP 세 원칙은 따로 노는 규칙이 아니라, 결국 이 두 축을 좋게 만드는 방법들이었어요.

텍스트
                   좋은 설계
            ┌──────────┴──────────┐
        높은 응집              낮은 결합
            │                     │
          SRP                   OCP
   (바뀔 이유로 나눠        (인터페이스 뒤로 숨겨
    한 가지에 집중)          변화를 한 곳에 가둠)
            └──────────┬──────────┘
                      LSP
            (치환이 안전해야 OCP의
             다형성이 무너지지 않는다)
  • SRP는 클래스를 바뀔 이유별로 나눠서 응집도를 높입니다.
  • OCP는 변하는 부분을 인터페이스 뒤로 숨겨서 결합도를 낮춥니다.
  • LSP는 자식이 부모를 안전하게 대신하게 해서, OCP가 기대는 다형성이 무너지지 않게 받쳐 줍니다. 정사각형 같은 깨진 치환이 섞이면, 인터페이스로 갈아 끼우는 OCP의 확장이 엉뚱하게 동작하니까요.

그래서 세 원칙을 따로 외울 필요가 없어요. "지금 이 코드가 응집은 높은가, 결합은 낮은가"를 물으면, 세 원칙은 그 답을 만드는 손잡이로 자연스럽게 따라옵니다.

마지막으로 오늘 처음부터 강조한 태도입니다. 원칙은 목적이 아니라 수단입니다. SRP·OCP·LSP의 목적은 "낮은 결합, 높은 응집"이고, 그 궁극의 목적은 "바꾸기 쉬운 코드"예요. 그런데 이 수단을 목적으로 착각하는 순간 사고가 납니다. SRP를 지키겠다고 클래스를 수십 개로 쪼개고, OCP를 지키겠다고 바뀌지도 않을 것까지 추상화하면, 오히려 코드가 더 복잡하고 바꾸기 어려워져요. 규칙을 위한 규칙, 이걸 카고컬트(맹목적 따라 하기)라고 부릅니다.

⚠️ 언제 깨나 — 종합 — 원칙끼리 충돌할 때도 있습니다. SRP를 위해 잘게 나누면 결합도가 올라가기도 하고, OCP를 위한 추상화가 코드를 더 어렵게 만들기도 해요. 그럴 땐 "어느 쪽이 이 코드를 더 바꾸기 쉽게 하는가"를 기준으로 판단하세요. 원칙은 그 판단을 돕는 도구지, 그 자체가 정답이 아닙니다. 규칙을 아는 게 시작이고, 언제 깰지 아는 게 실력입니다.

💡 한 줄 정리

SRP·OCP·LSP는 "낮은 결합, 높은 응집"이라는 한 그림의 세 손잡이입니다. 원칙은 바꾸기 쉬운 코드를 위한 수단이지 목적이 아니에요. 수단을 목적으로 착각하면 오히려 코드를 망칩니다.

🙋 학생 질문 — "튜터님, 그럼 원칙을 언제 지키고 언제 깰지, 초보는 어떻게 판단하나요?"

솔직하게 말하면, 그 판단력은 하루아침에 생기지 않습니다. 그래서 초보에게 권하는 순서는 이래요. 처음에는 원칙을 충실히 따라 보세요. 규칙을 몸에 익히는 단계니까요. 그 과정에서 "어, 이 규칙대로 했더니 오히려 더 복잡해지는데?" 하는 순간을 만나게 됩니다. 바로 그 불편함이 "여기선 깨도 되겠다"를 배우는 신호예요.

판단의 기준은 늘 하나로 돌아옵니다. "이게 코드를 바꾸기 더 쉽게 만드는가, 더 어렵게 만드는가." 원칙을 지켰는데 코드가 더 복잡하고 따라가기 어려워졌다면, 그건 원칙이 과하게 적용된 겁니다. 이 감각은 직접 짜 보고, 6개월 뒤 그 코드를 다시 열어 보면서 쌓여요. 그게 주니어에서 미들로 가는 길이고요.


마무리

오늘은 코드의 시야를 한 줄에서 클래스 사이로 넓혔습니다. B 카테고리의 문을 연 첫 시간이었어요. 응집도와 결합도라는 두 축을 잡고, 그 위에서 SOLID의 앞 세 글자를 익혔습니다.

오늘 배운 핵심 세 가지

  • 💡 좋은 설계는 두 축으로 판가름 난다. 한 클래스가 한 가지에 뭉쳐 있는가(높은 응집), 한 곳을 바꿀 때 옆이 안 깨지는가(낮은 결합). 이 두 질문이 설계 품질의 절반이다.
  • 💡 SRP·OCP·LSP는 그 두 축을 이루는 손잡이다. SRP는 바뀔 이유로 나눠 응집을 높이고, OCP는 인터페이스로 변화를 가둬 결합을 낮추며, LSP는 치환이 안전하게 받쳐 준다.
  • 💡 원칙은 수단이지 목적이 아니다. 목적은 바꾸기 쉬운 코드다. 원칙을 맹목적으로 따라 클래스를 폭발시키거나 과하게 추상화하면 오히려 독이 된다. 언제 깰지 아는 게 실력이다.

다음 시간 예고

오늘 SOLID의 앞 세 글자(S·O·L)를 채웠으니, 다음 시간엔 나머지 두 글자 ISP(인터페이스 분리)와 DIP(의존성 역전)로 SOLID를 완성합니다. Step 2에서 "구체적인 것 말고 약속에 기대라"고 했던 그 감각이 DIP로 정식 이름을 얻을 거예요.

그리고 Step 5 끝에서 슬쩍 흘린 이야기, "상속보다 컴포지션(조합)"도 본격적으로 다룹니다. 정사각형이 직사각형 상속에서 사고를 냈던 것처럼, 상속이 왜 위험하고 조합이 왜 더 유연한지를 코드로 비교해 봐요. 오늘 배운 두 축이 다음 시간에도 그대로 잣대가 됩니다.


과제

오늘 배운 원칙을 직접 손으로 적용해 보는 과제입니다. 눈으로 읽는 것과 직접 고치는 것은 전혀 다르니, 꼭 코드를 만져 보세요.

[기초] God 클래스를 응집도로 쪼개기

아래는 알림 한 건을 다루는 클래스인데, 서로 다른 일이 섞여 있습니다.

Java
public class NotificationManager {

    private final String message;
    private final String userEmail;

    public String formatBody() {
        return "안녕하세요. " + message;
    }

    public boolean isValidEmail() {
        return userEmail.contains("@");
    }
}

formatBody()message만 쓰고, isValidEmail()userEmail만 씁니다. 두 메서드가 함께 쓰는 필드가 없죠. 이 클래스를 응집도 높은 두 클래스로 나눠 보세요. 나누기 전과 후의 동작(본문 포맷 결과, 이메일 유효성 판정)이 똑같이 유지되는지도 확인해 보세요.

[응용] if-else 분기를 OCP로 열기

아래는 결제 수단에 따라 수수료를 계산하는 코드입니다. 새 결제 수단이 생길 때마다 이 메서드를 또 고쳐야 해요.

Java
public int fee(String method, int amount) {
    if (method.equals("CARD")) {
        return amount * 3 / 100;
    } else if (method.equals("BANK")) {
        return amount * 1 / 100;
    }
    return 0;
}

이 코드를 OCP에 맞게 고쳐 보세요. FeePolicy 같은 인터페이스를 만들고, 결제 수단별로 구현 클래스를 두어, 새 수단이 생기면 클래스를 추가하는 것만으로 끝나게 만들면 됩니다. 새 수단(예: KAKAO)을 하나 추가해 보면서, 기존 계산기 코드가 정말 안 바뀌는지 확인해 보세요.

[심화] LSP를 깨는 상속 찾아 고치기

아래는 새를 표현한 코드입니다. 모든 새가 날 수 있다고 가정하고 짰어요.

Java
public class Bird {
    public int fly() {
        return 100;
    }
}

public class Penguin extends Bird {
    @Override
    public int fly() {
        throw new UnsupportedOperationException("펭귄은 날 수 없습니다");
    }
}

Bird를 쓰던 코드(fly()를 불러 높이를 기대하는 코드)에 Penguin을 넣으면 예외가 터져 약속이 깨집니다. 이 설계가 왜 LSP를 어기는지 설명하고, 상속 구조를 고쳐 보세요. 힌트는 "모든 새가 나는 건 아니다"입니다. 나는 능력을 어떻게 분리하면 좋을지 고민해 보세요.


생각해볼 주제

정답이 하나가 아닌 주제들입니다. 오늘 배운 "언제 적용하고 언제 깨나"를 직접 고민해 보세요. 혼자 생각해도 좋고, 동료와 논쟁해도 좋습니다.

1. SRP의 "책임"은 누가 정하는가?

SRP는 "바뀔 이유 하나"라고 배웠습니다. 그런데 "바뀔 이유"를 어디까지 묶고 어디서 가를지는 사람마다 다르게 볼 수 있어요. 누군가는 "리포트는 하나의 책임"이라 보고, 누군가는 "계산·표현·저장은 다른 책임"이라 봅니다. 둘 다 틀리지 않았다면, 무엇이 이 경계를 정할까요? 팀 규모, 변경 빈도, 도메인의 성격 같은 맥락이 책임의 경계를 어떻게 바꾸는지 생각해 보세요.

2. OCP를 위한 추상화는 언제가 "이른가"?

OCP는 "확장점을 미리 열어 두라"고 하지만, "필요해질 때까지 만들지 마라(YAGNI)"는 조언과 부딪힙니다. 분기가 두 개일 때 미리 인터페이스로 빼는 건 과한 대비일까요, 현명한 준비일까요? "두 번까지는 그냥 두고 세 번째 같은 변경이 오면 추상화하라"는 경험칙도 있습니다. 어느 시점에 추상화로 넘어가는 게 적절한지, 본인만의 기준을 세워 보세요.

3. LSP를 지키기 위해 상속을 포기해야 한다면?

직사각형-정사각형 문제의 결론은 "상속을 끊어라"였습니다. 그런데 상속은 코드 재사용의 강력한 도구이기도 하죠. LSP를 지키기 어렵다는 이유만으로 상속을 포기하는 게 늘 옳을까요? 상속이 정말 잘 맞는 경우는 언제고, 조합으로 가야 하는 경우는 언제일까요? "is-a처럼 보이는 관계"와 "정말 대체 가능한 관계"의 차이를 중심으로 고민해 보세요.

✅ 예시 답안정답 보기

과제와 생각해볼 주제의 예시답안입니다. 정답이 하나뿐인 건 아니에요. 클래스 이름이나 나누는 단위는 "이렇게도 할 수 있다"는 한 갈래일 뿐, 응집이 높아지고 동작이 보존되면 다른 모습도 좋습니다. 중요한 건 "왜 이렇게 바꿨는가"입니다.


과제 예시답안

🎯 [과제 1 예시답안] God 클래스를 응집도로 쪼개기

채점 포인트

항목 보는 것 배점
경계 식별 메서드가 만지는 필드로 두 무리를 갈랐는가 ★★★
두 클래스 분리 본문 담당과 검증 담당을 따로 뒀는가 ★★★
높은 응집 각 클래스의 필드를 그 메서드가 함께 쓰는가 ★★☆
동작 보존 포맷 결과·유효성 판정이 그대로인가 ★★☆

풀이 예시

❌ BeforeformatBody()message만, isValidEmail()userEmail만 만진다. 함께 쓰는 필드가 없다.

Java
public class NotificationManager {

    private final String message;
    private final String userEmail;

    public String formatBody() {
        return "안녕하세요. " + message;
    }

    public boolean isValidEmail() {
        return userEmail.contains("@");
    }
}

✅ After — 함께 가는 것끼리 모아 두 클래스로 가른다.

Java
public class NotificationBody {

    private final String message;

    public NotificationBody(String message) {
        this.message = message;
    }

    public String format() {
        return "안녕하세요. " + message;
    }
}
Java
public class EmailValidator {

    private final String email;

    public EmailValidator(String email) {
        this.email = email;
    }

    public boolean isValid() {
        return email.contains("@");
    }
}

💡 튜터의 한마디: 쪼갤 선을 찾는 가장 쉬운 방법은 "메서드가 어떤 필드를 쓰나"를 표시해 보는 겁니다. 본문 포맷은 message만, 이메일 검증은 userEmail만 쓰니, 그 둘 사이가 바로 가를 선이에요. 나눈 뒤에는 각 클래스 안에서 필드와 메서드가 한 가지 일로 긴밀하게 얽혀 응집이 높아집니다. 본문 양식이 바뀌면 NotificationBody만, 검증 규칙이 바뀌면 EmailValidator만 열면 되고요.

🎯 [과제 2 예시답안] if-else 분기를 OCP로 열기

채점 포인트

항목 보는 것 배점
확장점 추상화 FeePolicy 인터페이스로 변하는 부분을 뺐는가 ★★★
구현 분리 결제 수단별 구현 클래스를 따로 뒀는가 ★★★
수정에 닫힘 새 수단 추가 시 계산기 코드가 안 바뀌는가 ★★★
동작 보존 기존 수단의 수수료가 그대로인가 ★★☆

풀이 예시

❌ Before — 새 결제 수단마다 이 메서드를 또 열어 고쳐야 한다.

Java
public int fee(String method, int amount) {
    if (method.equals("CARD")) {
        return amount * 3 / 100;
    } else if (method.equals("BANK")) {
        return amount * 1 / 100;
    }
    return 0;
}

✅ After — "수수료를 어떻게 매기나"를 FeePolicy로 추상화하고, 수단별로 구현을 둔다.

Java
public interface FeePolicy {

    int fee(int amount);
}
Java
public class CardFee implements FeePolicy {

    @Override
    public int fee(int amount) {
        return amount * 3 / 100;
    }
}
Java
public class FeeCalculatorAfter {

    public int fee(FeePolicy policy, int amount) {
        return policy.fee(amount);
    }
}

이제 카카오페이가 필요하면 KakaoFee 클래스를 하나 더하면 끝입니다. FeeCalculatorAfter는 한 줄도 안 바뀌어요.

Java
public class KakaoFee implements FeePolicy {

    @Override
    public int fee(int amount) {
        return amount * 2 / 100;
    }
}

💡 튜터의 한마디: 핵심은 "바뀌는 부분"을 정확히 골라낸 겁니다. 수단마다 달라지는 건 "수수료율"이라는 한 가지였고, 그걸 FeePolicy 뒤로 숨겼어요. 계산기는 어떤 수단인지 모른 채 약속만 부르니, 새 수단은 구현을 더하는 것으로 끝납니다. 단, 결제 수단이 앞으로 늘어날 게 분명할 때 이렇게 여는 거예요. 끝까지 카드·계좌 둘뿐이라면 if-else가 더 정직합니다.

🎯 [과제 3 예시답안] LSP를 깨는 상속 찾아 고치기

채점 포인트

항목 보는 것 배점
위반 설명 펭귄이 부모의 fly() 약속을 깬다고 짚었는가 ★★★
능력 분리 나는 능력을 별도 약속으로 뺐는가 ★★★
안전한 치환 나는 코드가 펭귄을 받을 수 없게 됐는가 ★★☆
펭귄의 능력 펭귄에게 자기 능력을 따로 줬는가 ★★☆

풀이 예시

❌ BeforeBird의 약속은 "fly()로 높이를 돌려준다"인데, 펭귄이 이걸 막아 약속을 깬다. Bird를 기대하는 코드에 Penguin을 넣으면 예외가 터진다.

Java
public class Bird {
    public int fly() {
        return 100;
    }
}

public class Penguin extends Bird {
    @Override
    public int fly() {
        throw new UnsupportedOperationException("펭귄은 날 수 없습니다");
    }
}

✅ After — "날 수 있다"를 별도의 약속(Flyable)으로 분리한다. 진짜 나는 새만 이 약속을 구현하고, 펭귄은 애초에 날라는 약속을 하지 않는다.

Java
public interface Flyable {
    int fly();
}

public class Sparrow implements Flyable {
    @Override
    public int fly() {
        return 100;
    }
}

public class SwimmingPenguin {
    public int swim() {
        return 50;
    }
}

💡 튜터의 한마디: 위반의 뿌리는 "모든 새가 난다"는 잘못된 가정이었습니다. 그 가정을 Bird에 넣는 순간, 못 나는 새는 전부 약속을 깰 수밖에 없어요. 해법은 "나는 능력"을 모든 새의 공통 약속에서 빼내, 정말 나는 새만 Flyable을 구현하게 하는 겁니다. 이제 fly()로 높이를 기대하는 코드는 Flyable만 받으니, 펭귄이 끼어들 일 자체가 없어 위반이 생길 수 없습니다. 펭귄에게는 수영이라는 자기 능력을 따로 주고요.


생각해볼 주제 예시답안

🤔 [생각해볼 주제 1] SRP의 "책임"은 누가 정하는가?

문제 상황 요약

SRP는 "바뀔 이유 하나"라고 하지만, 그 "이유"를 어디까지 묶을지는 사람마다 다르게 봅니다. 리포트를 하나의 책임으로 볼 수도, 계산·표현·저장을 다른 책임으로 볼 수도 있어요. 둘 다 틀리지 않았다면, 무엇이 이 경계를 정할까요?

튜터의 가이드 및 해설

핵심은 책임의 경계가 코드 안이 아니라 코드 바깥, 즉 변경을 요구하는 사람(액터)에게 있다는 점입니다. "계산과 표현이 다른 책임인가?"의 답은 코드만 봐서는 안 나와요. 회계팀과 디자인팀이 서로 다른 시점에 서로 다른 이유로 이 코드를 고치러 온다면, 그건 두 책임입니다. 둘이 늘 함께 바뀐다면 하나로 둬도 괜찮고요.

그래서 같은 코드라도 맥락에 따라 답이 달라집니다. 1인 토이 프로젝트에서는 리포트 하나가 한 책임이어도 충분해요. 변경을 요구하는 사람이 나 혼자니까요. 반면 부서가 나뉜 큰 조직에서는 같은 코드가 세 책임으로 갈립니다. 변경 빈도도 기준이 됩니다. 거의 안 바뀌는 것을 미리 나누는 건 과한 분할이고, 자주 따로 바뀌는 것이 한 클래스에 묶여 있으면 그게 진짜 문제예요.

🎯 면접에선 이렇게 나온다

"SRP의 책임은 '기능'이 아니라 '바뀔 이유', 더 정확히는 '그 변경을 요구하는 액터'로 봅니다. 그래서 책임의 경계는 코드가 아니라 조직과 변경 패턴에서 와요. 같은 코드도 팀 구조와 변경 빈도에 따라 한 책임일 수도, 세 책임일 수도 있습니다."

💡 실무에선

처음부터 완벽히 나누려 하지 말고, 변경이 실제로 따로 움직이기 시작할 때 나누세요. 한곳을 고칠 때마다 무관한 코드가 자꾸 함께 바뀐다면, 그때가 가를 시점입니다.

🤔 [생각해볼 주제 2] OCP를 위한 추상화는 언제가 "이른가"?

문제 상황 요약

OCP는 "확장점을 미리 열어 두라"고 하지만, "필요해질 때까지 만들지 마라(YAGNI)"는 조언과 부딪힙니다. 분기가 두 개일 때 미리 인터페이스로 빼는 건 과한 대비일까요, 현명한 준비일까요?

튜터의 가이드 및 해설

두 조언은 사실 충돌하지 않습니다. OCP는 "바뀌는 축을 열어 두라"는 것이지 "모든 것을 열어 두라"가 아니거든요. 그래서 질문은 "추상화할까 말까"가 아니라 "이 분기가 앞으로 같은 이유로 또 늘어날까"가 됩니다. 늘어날 조짐이 보이면 OCP, 고정이면 그냥 두는 거예요.

실무에서 널리 쓰는 경험칙이 "세 번째에 추상화하라"입니다. 처음 분기가 생기면 그냥 둡니다. 두 번째가 생겨도 일단 if-else로 둬요. 그런데 세 번째 같은 변경이 또 오면, 그건 "이 축은 계속 바뀐다"는 분명한 신호니 그때 인터페이스로 엽니다. 두 개일 때 미리 빼는 건 대개 이른 추상화예요. 쓰지도 않을 유연성에 코드만 복잡해지니까요. 다만 도메인 지식상 "여긴 무조건 계속 는다"가 확실하면(결제 수단, 알림 채널 등) 두 번째에 미리 열어도 좋습니다.

🎯 면접에선 이렇게 나온다

"OCP와 YAGNI는 모순이 아니라 짝입니다. 자주 바뀌는 축에만 확장점을 열고 나머지는 단순하게 두는 거죠. 저는 같은 변경이 세 번째 올 때를 추상화 시점의 기본값으로 잡고, 도메인상 확실히 늘어날 축은 그보다 일찍 엽니다."

💡 실무에선

이른 추상화는 과소 추상화만큼이나 흔한 실수입니다. 확신이 없으면 if-else로 두세요. 나중에 추상화로 바꾸는 리팩토링은 생각보다 쉽지만, 쓸데없이 벌려 둔 인터페이스를 걷어 내는 일은 더 번거롭습니다.

🤔 [생각해볼 주제 3] LSP를 지키기 위해 상속을 포기해야 한다면?

문제 상황 요약

직사각형-정사각형 문제의 결론은 "상속을 끊어라"였습니다. 그런데 상속은 강력한 재사용 도구이기도 하죠. LSP를 지키기 어렵다는 이유만으로 상속을 포기하는 게 늘 옳을까요?

튜터의 가이드 및 해설

판단의 기준은 "is-a처럼 보이나"가 아니라 "행동까지 온전히 대체되나"입니다. 정사각형은 직사각형의 일종처럼 보이지만, "너비와 높이를 따로 바꾼다"는 행동 약속을 지킬 수 없었어요. 이렇게 부모의 행동 약속을 자식이 어기는 관계라면, 상속은 잘못된 선택이고 포기하는 게 맞습니다.

반대로 상속이 정말 잘 맞는 경우도 있습니다. 자식이 부모의 모든 행동을 그대로 지키면서 확장만 하는 경우예요. 부모가 할 수 있는 일은 자식도 똑같이 할 수 있고, 거기에 더 얹기만 한다면 LSP가 자연스럽게 지켜집니다. 문제는 자식이 부모의 약속을 줄이거나 바꿀 때 생겨요. 펭귄이 fly()를 막아 버린 것처럼요. 그래서 "상속을 쓸까?"의 진짜 질문은 "자식이 부모를 대신해도 아무도 놀라지 않을까?"입니다. 조금이라도 놀랄 것 같으면 조합으로 가는 게 안전해요. 이 "상속보다 조합" 이야기는 다음 시간에 더 깊이 다룹니다.

🎯 면접에선 이렇게 나온다

"상속의 판단 기준은 'is-a'가 아니라 '치환 가능한가'입니다. 자식이 부모의 행동 약속을 빠짐없이 지키며 확장만 한다면 상속이 좋고, 약속을 줄이거나 바꿔야 한다면 그건 상속이 아니라 조합으로 풀 신호예요. LSP 위반은 대개 잘못 쓴 상속의 증상입니다."

💡 실무에선

상속은 "꼭 필요할 때만" 쓰는 도구로 두세요. 기본값은 조합입니다. 실무 코드에서 상속이 다섯 단계씩 깊어지면 어느 단계에서 약속이 깨졌는지 추적하기가 정말 어려워져요. 얕고 명확한 상속, 아니면 조합 — 이 둘 사이에서 고민하는 습관이 좋습니다.

전체 목록 클린코드