2026년 4월 19일

SOLID 원칙 마스터하기: 견고하고 유연한 소프트웨어 설계의 초석

190
SOLID 원칙 마스터하기: 견고하고 유연한 소프트웨어 설계의 초석

SOLID 원칙 마스터하기: 견고하고 유연한 소프트웨어 설계의 초석

SOLID 원칙 마스터하기: 견고하고 유연한 소프트웨어 설계의 초석

1. 개념 소개: 좋은 코드를 위한 나침반, SOLID

1. 개념 소개: 좋은 코드를 위한 나침반, SOLID

소프트웨어 개발은 끊임없이 변화하는 요구사항과 마주하며 진화합니다. 처음에는 완벽해 보이던 코드도 시간이 지나면 복잡해지고, 수정하기 어려워지며, 새로운 기능을 추가할 때마다 버그가 속출하는 '레거시 코드'가 되곤 합니다. 왜 이런 일이 발생할까요? 대부분의 경우, 코드의 설계가 미래의 변화를 제대로 수용하지 못했기 때문입니다.

여기서 SOLID 원칙이 등장합니다. SOLID는 객체지향 프로그래밍 및 설계에서 사용되는 5가지 기본 원칙의 앞글자를 딴 약어입니다. 로버트 C. 마틴(Robert C. Martin), 일명 '엉클 밥(Uncle Bob)'이 2000년대 초반에 제시한 이 원칙들은 소프트웨어가 변경에 유연하고, 이해하기 쉬우며, 확장하기 쉽고, 테스트하기 쉽게 만들어주는 가이드라인 역할을 합니다.

SOLID가 왜 중요할까요?

  • 유지보수성 향상: 코드를 변경해야 할 때, 변경의 영향 범위가 최소화되어 버그 발생 가능성이 줄어듭니다.
  • 확장성 증대: 새로운 기능을 추가하거나 기존 기능을 변경할 때, 기존 코드를 수정하지 않고도 쉽게 확장할 수 있습니다.
  • 재사용성 증가: 잘 분리된 컴포넌트는 다른 곳에서 재사용하기 용이합니다.
  • 테스트 용이성: 각 부분이 독립적으로 동작하므로, 단위 테스트(Unit Test)를 작성하기 훨씬 수월해집니다.
  • 협업 효율 증대: 명확한 설계 원칙을 따르는 코드는 팀원들이 이해하고 작업하기 편리합니다.

초중급 개발자라면 SOLID 원칙을 이해하고 적용하는 것이 장기적으로 훨씬 더 견고하고 품질 높은 소프트웨어를 만드는 데 필수적인 역량이 될 것입니다. 이는 단순히 '코드를 잘 짜는 것'을 넘어 '설계를 잘하는 것'의 시작점입니다.

2. 핵심 원리 설명: 5가지 원칙 자세히 보기

2. 핵심 원리 설명: 5가지 원칙 자세히 보기

SOLID는 다음 5가지 원칙으로 구성됩니다.

S: SRP (Single Responsibility Principle) - 단일 책임 원칙

"하나의 클래스는 하나의, 오직 하나의 책임만 가져야 한다."

비유: 요리사가 음식 조리, 설거지, 손님 응대 등 모든 일을 다 한다고 상상해 보세요. 효율적이지 않고, 하나에 문제가 생기면 다른 일에도 영향을 미칩니다. 단일 책임 원칙은 요리사, 설거지 담당, 홀 담당을 분리하는 것과 같습니다. 각자의 역할에만 집중하여 전문성을 높이고, 한 부분이 변경되어도 다른 부분에 영향을 주지 않도록 합니다.

설명: 어떤 클래스나 모듈을 변경해야 하는 이유가 오직 하나여야 한다는 뜻입니다. 예를 들어, Report 클래스가 보고서 데이터를 로드하고, 포맷을 지정하고, 파일로 저장하는 세 가지 책임을 모두 가지고 있다면, 데이터 로딩 방식이 바뀌어도 Report 클래스를 수정해야 하고, 포맷 방식이 바뀌어도, 저장 방식이 바뀌어도 Report 클래스를 수정해야 합니다. 이는 SRP를 위반한 것입니다. SRP를 따르면 각 책임을 별도의 클래스나 모듈로 분리하여 변경의 파급 효과를 최소화합니다.

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

"소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하고, 변경에 대해서는 닫혀 있어야 한다."

비유: 스마트폰을 생각해 보세요. 새로운 앱(기능)을 설치(확장)하는 것은 자유롭지만, 스마트폰 자체의 하드웨어(기존 코드)를 매번 뜯어고칠 필요는 없습니다.

설명: 기존 코드를 수정하지 않으면서 새로운 기능을 추가할 수 있도록 설계해야 한다는 원칙입니다. 이는 주로 추상화(인터페이스나 추상 클래스)를 통해 달성됩니다. 예를 들어, 할인 정책을 계산하는 클래스가 있을 때, 새로운 할인 정책이 추가될 때마다 if-else 문을 추가하며 기존 코드를 수정하는 것은 OCP를 위반하는 것입니다. 대신, DiscountPolicy라는 인터페이스를 정의하고 각 할인 정책(예: FixedAmountDiscount, PercentageDiscount)이 이 인터페이스를 구현하도록 하면, 새로운 할인 정책이 추가되어도 기존 코드는 그대로 유지하고 새로운 클래스만 추가하면 됩니다.

+----------------+       +---------------------+
| DiscountPolicy | <-----| FixedAmountDiscount |
| (Interface)    |       +---------------------+
+----------------+       +---------------------+
      ^                  | PercentageDiscount  |
      |                  +---------------------+
      |
      | (확장에 열려 있음)
      |
+----------------+
|  OrderService  | (변경에 닫혀 있음)
| - calculate_total(policy) |
+----------------+

L: LSP (Liskov Substitution Principle) - 리스코프 치환 원칙

"서브타입은 언제나 자신의 기반 타입(부모 클래스)으로 교체할 수 있어야 한다." (클라이언트 코드는 서브타입의 존재를 몰라야 한다.)

비유: 동물원에서 사자, 호랑이, 곰이 모두 '동물'이라는 틀 안에서 각자의 방식으로 울부짖는다고 가정해 봅시다. 만약 '동물'에게 '울부짖어라'는 명령을 내렸을 때, 사자나 호랑이는 잘 따르는데 곰은 갑자기 '춤을 춘다'면, '동물'이라는 기대와 어긋나게 됩니다. LSP는 곰도 '울부짖는' 기능을 수행해야 한다는 것입니다.

설명: 부모 클래스를 상속받은 자식 클래스는 부모 클래스의 역할을 완벽하게 수행할 수 있어야 합니다. 즉, 부모 클래스 타입의 객체를 사용하는 곳에서 자식 클래스 타입의 객체로 바꾸더라도 프로그램의 동작에 문제가 없어야 합니다. 가장 흔한 위반 사례는 '정사각형은 직사각형이다'라는 논리적 관계를 그대로 코드에 적용했을 때 발생합니다. Rectangle 클래스에 set_width, set_height 메서드가 있고, Square 클래스가 Rectangle을 상속받아 이 메서드를 오버라이드하여 너비를 바꾸면 높이도 같이 바뀌도록 구현하면, Rectangle을 기대하는 클라이언트 코드가 Square 객체로 인해 예상치 못한 동작을 할 수 있습니다.

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

"클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다."

비유: 만능 리모컨이 있다고 생각해 보세요. TV, 에어컨, 오디오 등 모든 가전제품을 제어할 수 있지만, 당신은 TV만 보는데 리모컨에 에어컨 온도 조절 버튼이나 오디오 볼륨 버튼이 덕지덕지 붙어 있어 복잡합니다. ISP는 TV 리모컨, 에어컨 리모컨을 따로 만드는 것과 같습니다. 각 클라이언트(사용자)는 자신이 필요한 기능만 있는 리모컨을 사용합니다.

설명: 하나의 거대한 인터페이스보다는 여러 개의 작은 인터페이스를 사용하는 것이 좋습니다. 특정 클라이언트가 필요로 하지 않는 메서드까지 포함하는 인터페이스를 구현하게 되면, 그 클라이언트는 불필요한 의존성을 가지게 되고, 인터페이스가 변경될 때 원치 않는 영향을 받을 수 있습니다. 예를 들어, Worker라는 인터페이스가 work(), eat(), sleep() 메서드를 가지고 있는데, 로봇은 eat()이나 sleep()이 필요 없을 수 있습니다. 이 경우 Workable, Eatable, Sleepable과 같은 작은 인터페이스로 분리하는 것이 바람직합니다.

D: DIP (Dependency Inversion Principle) - 의존성 역전 원칙

"고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다. 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다."

비유: 벽에 전등을 달고 싶을 때, 벽에 직접 전구를 납땜하는 대신, 소켓(추상화)을 설치하고 거기에 어떤 전구(저수준 모듈)든 끼울 수 있게 하는 것과 같습니다. 소켓이 있다면 어떤 전구로든 교체할 수 있고, 전등(고수준 모듈)은 특정 전구에 의존하지 않습니다.

설명: 전통적인 계층형 아키텍처에서는 고수준 모듈(비즈니스 로직)이 저수준 모듈(데이터베이스, UI 등)에 의존하는 것이 일반적입니다. 하지만 DIP는 이러한 의존성 관계를 역전시킵니다. 즉, 고수준 모듈과 저수준 모듈 모두 추상화(인터페이스나 추상 클래스)에 의존하게 만듭니다. 이를 통해 고수준 모듈은 특정 구현체에 얽매이지 않고 유연하게 변경될 수 있습니다. **의존성 주입(Dependency Injection, DI)**은 DIP를 구현하는 가장 대표적인 기술이며, 이미 다룬 적이 있는 DI/IoC 개념이 바로 이 DIP를 실현하기 위한 핵심 도구입니다.

+-----------------------+        +--------------------+
| High-Level Module     |------->| Abstraction        |
| (e.g., OrderProcessor)|        | (e.g., PaymentGateway) |
+-----------------------+        +--------------------+
           ^                              ^
           |                              |
           | (의존성 역전)                | (의존성 역전)
           |                              |
+-----------------------+        +--------------------+
| Low-Level Module      |<-------| Concrete Impl.     |
| (e.g., CreditCardProcessor) |        | (e.g., PayPalGateway) |
+-----------------------+        +--------------------+

(고수준 모듈이 저수준 모듈에 직접 의존하는 대신, 추상화에 의존하고, 저수준 모듈은 그 추상화를 구현합니다.)

3. 코드 예제: SOLID 원칙 적용하기 (Python)

예제 1: SRP (단일 책임 원칙) 적용

SRP 위반 코드: ReportGenerator가 데이터 로딩, 포맷팅, 출력을 모두 담당

# SRP 위반 예시
class ReportGenerator:
    def __init__(self, data_source):
        self.data_source = data_source
        self.report_data = None

    def load_data(self):
        print(f"데이터 로딩 중: {self.data_source}에서...")
        # 실제 데이터 로딩 로직 (DB, API 등)
        self.report_data = {"title": "월간 판매 보고서", "sales": [100, 120, 90]}
        print("데이터 로딩 완료.")
        return self.report_data

    def format_report(self):
        if not self.report_data:
            raise ValueError("데이터가 로딩되지 않았습니다.")
        print("보고서 포맷팅 중...")
        formatted_string = f"--- {self.report_data['title']} ---\n"
        for month, sales in enumerate(self.report_data['sales']):
            formatted_string += f"Month {month + 1}: {sales} units\n"
        print("보고서 포맷팅 완료.")
        return formatted_string

    def save_to_file(self, filename="report.txt"):
        if not self.report_data:
            raise ValueError("데이터가 로딩되지 않았습니다.")
        formatted_report = self.format_report() # 여기서 다시 포맷팅
        with open(filename, "w") as f:
            f.write(formatted_report)
        print(f"보고서 저장 완료: {filename}")

# 사용 예시
# generator = ReportGenerator("sales_db")
# generator.load_data()
# generator.save_to_file()

SRP 적용 코드: 각 책임을 별도의 클래스로 분리

# SRP 적용 예시
class ReportDataLoader:
    def __init__(self, data_source):
        self.data_source = data_source

    def load_data(self):
        print(f"데이터 로딩 중: {self.data_source}에서...")
        # 실제 데이터 로딩 로직
        data = {"title": "월간 판매 보고서", "sales": [100, 120, 90]}
        print("데이터 로딩 완료.")
        return data

class ReportFormatter:
    def format(self, report_data):
        print("보고서 포맷팅 중...")
        formatted_string = f"--- {report_data['title']} ---\n"
        for month, sales in enumerate(report_data['sales']):
            formatted_string += f"Month {month + 1}: {sales} units\n"
        print("보고서 포맷팅 완료.")
        return formatted_string

class ReportSaver:
    def save(self, content, filename="report.txt"):
        with open(filename, "w") as f:
            f.write(content)
        print(f"보고서 저장 완료: {filename}")

# 사용 예시
data_loader = ReportDataLoader("sales_db")
formatter = ReportFormatter()
saver = ReportSaver()

report_data = data_loader.load_data()
formatted_report = formatter.format(report_data)
saver.save(formatted_report, "monthly_sales_report.txt")

# 이제 데이터 로딩 방식이 바뀌어도 ReportDataLoader만 수정하면 되고,
# 포맷팅 방식이 바뀌어도 ReportFormatter만 수정하면 됩니다.

예제 2: OCP (개방-폐쇄 원칙) 적용

OCP 위반 코드: DiscountCalculatorif-else로 할인 정책을 처리

# OCP 위반 예시
class DiscountCalculator:
    def calculate_discount(self, order_total, discount_type):
        if discount_type == "fixed":
            return min(order_total, 10000) # 10000원 고정 할인
        elif discount_type == "percentage":
            return order_total * 0.1 # 10% 할인
        elif discount_type == "seasonal":
            return order_total * 0.15 # 15% 계절 할인 (새로운 정책)
        else:
            return 0

# 새로운 할인 정책이 추가될 때마다 calculate_discount 메서드를 수정해야 합니다.
# 예를 들어, 'seasonal' 할인을 추가하려면 이 클래스를 수정해야 합니다.
# print(DiscountCalculator().calculate_discount(50000, "fixed")) # 10000
# print(DiscountCalculator().calculate_discount(50000, "percentage")) # 5000

OCP 적용 코드: 전략 패턴(Strategy Pattern)을 활용하여 할인 정책을 확장 가능하게 설계

# OCP 적용 예시 (DIP도 일부 적용됨)
from abc import ABC, abstractmethod

# 1. 추상화 (인터페이스 역할)
class DiscountPolicy(ABC):
    @abstractmethod
    def calculate_discount(self, order_total):
        pass

# 2. 구체적인 구현 (확장에 열려 있음)
class FixedAmountDiscount(DiscountPolicy):
    def __init__(self, amount):
        self.amount = amount

    def calculate_discount(self, order_total):
        return min(order_total, self.amount)

class PercentageDiscount(DiscountPolicy):
    def __init__(self, percentage):
        self.percentage = percentage

    def calculate_discount(self, order_total):
        return order_total * self.percentage

# 새로운 할인 정책 추가: 기존 코드 수정 없이 새로운 클래스만 추가
class SeasonalDiscount(DiscountPolicy):
    def calculate_discount(self, order_total):
        return order_total * 0.15 # 15% 계절 할인

# 3. 고수준 모듈 (변경에 닫혀 있음)
class OrderService:
    def __init__(self, discount_policy: DiscountPolicy):
        # DIP: OrderService는 구체적인 할인 정책 클래스가 아닌, 추상화(DiscountPolicy)에 의존
        self.discount_policy = discount_policy

    def get_final_price(self, order_total):
        discount = self.discount_policy.calculate_discount(order_total)
        return order_total - discount

# 사용 예시
order_total = 50000

# 고정 할인 적용
fixed_discount = FixedAmountDiscount(10000)
order_service_fixed = OrderService(fixed_discount)
print(f"고정 할인 적용 후 최종 가격: {order_service_fixed.get_final_price(order_total)}")

# 비율 할인 적용
percentage_discount = PercentageDiscount(0.1)
order_service_percentage = OrderService(percentage_discount)
print(f"비율 할인 적용 후 최종 가격: {order_service_percentage.get_final_price(order_total)}")

# 새로운 계절 할인 적용 (OrderService 코드는 전혀 수정되지 않음)
seasonal_discount = SeasonalDiscount()
order_service_seasonal = OrderService(seasonal_discount)
print(f"계절 할인 적용 후 최종 가격: {order_service_seasonal.get_final_price(order_total)}")

4. 실무 적용 사례

SOLID 원칙은 이론에 그치지 않고 실제 프로젝트에서 다음과 같은 형태로 빛을 발합니다.

  • 마이크로서비스 아키텍처: 각 마이크로서비스는 단일 책임 원칙(SRP)을 따라 특정 비즈니스 도메인에 대한 책임을 가집니다. 이를 통해 서비스 간의 결합도를 낮추고 독립적인 배포와 확장을 가능하게 합니다.
  • 플러그인/모듈 시스템: 새로운 기능이나 모듈을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있도록 OCP를 적용합니다. 예를 들어, 웹 프레임워크의 미들웨어(Middleware)나 ORM의 데이터베이스 드라이버는 OCP를 잘 따른 예시입니다. 새로운 데이터베이스를 지원하려면 새로운 드라이버만 추가하면 됩니다.
  • 테스트 주도 개발(TDD): 유닛 테스트를 작성할 때, 각 클래스나 함수가 단일 책임(SRP)을 가지고 다른 모듈에 대한 의존성이 낮다면(DIP), 테스트 코드를 작성하기가 훨씬 수월해집니다. Mock 객체를 사용하여 의존성을 끊고 테스트하기 용이합니다.
  • 프레임워크 및 라이브러리 개발: 잘 설계된 프레임워크는 사용자가 프레임워크의 핵심 로직을 건드리지 않고도 기능을 확장할 수 있도록 OCP와 DIP를 적극적으로 활용합니다. 예를 들어, 웹 프레임워크의 RequestResponse 객체는 추상화된 형태로 제공되어 다양한 상황에서 일관되게 사용될 수 있습니다.
  • 클린 아키텍처/헥사고날 아키텍처: 이러한 아키텍처 패턴들은 DIP를 핵심 원칙으로 삼아 비즈니스 로직이 데이터베이스나 UI 같은 외부 인프라에 의존하지 않고 추상화에 의존하도록 설계합니다. 이는 핵심 비즈니스 로직의 독립성과 테스트 용이성을 극대화합니다.

5. 자주 하는 실수와 해결법

SOLID 원칙은 강력하지만, 잘못 적용하면 오히려 코드를 복잡하게 만들 수 있습니다.

  1. SRP 과도한 분리: 모든 것을 너무 작게 쪼개려다 보면 오히려 클래스의 수가 폭증하고, 각 클래스의 응집도가 낮아져 전체적인 복잡도가 증가할 수 있습니다.
    • 해결법: '변경의 이유'를 기준으로 삼으세요. 특정 변경 사항이 발생했을 때 여러 클래스를 수정해야 한다면 SRP 위반일 수 있지만, 너무 사소한 변경까지 예측하여 분리할 필요는 없습니다. 응집도(Cohesion)와 결합도(Coupling)를 함께 고려하여 적절한 균형을 찾는 것이 중요합니다.
  2. OCP 오해 및 과도한 추상화: 미래에 발생할 수 있는 모든 변화에 대비하여 모든 것을 추상화하려고 합니다. 이는 불필요한 복잡성과 오버헤드를 초래합니다.
    • 해결법: 현재 요구사항과 예측 가능한 확장 지점에 집중하여 추상화를 적용하세요. "YAGNI (You Ain't Gonna Need It)" 원칙을 기억하고, 정말 필요한 시점에 리팩토링을 통해 확장성을 확보하는 것이 좋습니다.
  3. LSP 위반의 간과: 상속 관계에서 자식 클래스가 부모 클래스의 계약(Contract)을 위반하는 경우가 흔합니다. 특히 'is-a' 관계가 아닌데도 상속을 사용하는 경우에 발생합니다.
    • 해결법: 상속을 사용하기 전에 '정말 이 자식 클래스가 부모 클래스의 역할을 완벽하게 대체할 수 있는가?'를 면밀히 검토하세요. 만약 그렇지 않다면, 상속 대신 합성(Composition)을 고려하는 것이 좋습니다. (예: Engine 클래스를 Car가 '가지는' 것이지, CarEngine을 '상속받는' 것이 아님)
  4. DIP를 DI 컨테이너 사용으로만 이해: 의존성 주입(DI)은 DIP를 구현하는 강력한 도구이지만, DIP의 핵심은 고수준 모듈이 추상화에 의존하도록 설계하는 것입니다. DI 컨테이너 없이도 수동 DI나 팩토리 패턴 등을 통해 DIP를 적용할 수 있습니다.
    • 해결법: DI 컨테이너의 편리함에 앞서, 추상화에 의존하는 설계를 먼저 고민하세요. 인터페이스나 추상 클래스를 사용하여 모듈 간의 느슨한 결합을 만드는 것이 DIP의 본질입니다.

6. 더 공부할 리소스 추천

SOLID 원칙은 한 번에 완전히 이해하고 적용하기 어려운 개념입니다. 지속적인 학습과 연습이 중요합니다.

  • :

    • 로버트 C. 마틴 (Uncle Bob) - 『Clean Code』: SOLID 원칙을 포함한 좋은 코드 작성법에 대한 바이블입니다.
    • 로버트 C. 마틴 (Uncle Bob) - 『Clean Architecture』: SOLID 원칙이 아키텍처 수준에서 어떻게 적용되는지 심도 있게 다룹니다.
    • 마틴 파울러 (Martin Fowler) - 『Refactoring: Improving the Design of Existing Code』: 기존 코드를 개선하는 과정에서 SOLID 원칙을 자연스럽게 적용하는 방법을 배울 수 있습니다.
    • 샌디 메츠 (Sandi Metz) - 『Practical Object-Oriented Design in Ruby』: Ruby를 사용하지만, 객체지향 설계 원칙을 매우 실용적이고 이해하기 쉽게 설명합니다.
  • 온라인 강좌 및 블로그:

    • 유튜브에 "SOLID principles explained"를 검색하면 다양한 언어로 된 좋은 설명들을 찾을 수 있습니다.
    • medium.com, dev.to와 같은 개발자 커뮤니티에서 "SOLID principles" 태그로 검색하면 실제 사례와 함께 원칙을 설명하는 글들을 많이 찾을 수 있습니다.
    • 특히 'Uncle Bob'의 블로그나 강연 자료는 원본에 가까운 설명을 제공하므로 참고할 가치가 높습니다.
  • 디자인 패턴 학습: SOLID 원칙은 종종 디자인 패턴과 함께 사용됩니다. 예를 들어 OCP를 적용할 때 전략 패턴(Strategy Pattern)이나 템플릿 메서드 패턴(Template Method Pattern)이 활용될 수 있습니다. 디자인 패턴을 함께 학습하면 SOLID 원칙을 코드로 구현하는 구체적인 방법을 익힐 수 있습니다.

SOLID 원칙은 단순히 코드를 예쁘게 만드는 것을 넘어, 소프트웨어의 수명을 연장하고, 팀의 생산성을 높이며, 궁극적으로 사용자에게 더 나은 경험을 제공하는 데 기여하는 핵심적인 사고방식입니다. 지금 당장 모든 것을 완벽하게 적용하기보다, 작은 부분부터 의식적으로 SOLID 원칙을 고려하며 개발하는 습관을 들이는 것이 중요합니다.