컴퓨터공학

SOLID 원칙 완전정복

nyambu 2025. 5. 21. 19:00

SOLID 원칙 완전정복
SOLID 원칙 완전정복

1. SOLID 원칙이란?

1-1. 소프트웨어 설계의 5대 원칙

 소프트웨어 개발에서 “잘 설계된 코드”란 과연 어떤 것일까? 유지보수가 쉽고, 변경에 유연하며, 재사용성이 높은 코드가 바로 그 기준이다. 이러한 이상적인 구조를 현실에서 구현하기 위해 등장한 개념이 바로 SOLID 원칙이다. SOLID는 객체지향 설계의 다섯 가지 핵심 원칙을 묶은 약어로, 각각의 원칙은 객체 간의 관계, 클래스의 역할, 모듈의 구조 등 전반적인 시스템의 견고함을 높이는 데 기여한다.

 

 이 다섯 가지는 다음과 같다.

  • S (SRP): 단일 책임 원칙 (Single Responsibility Principle)
  • O (OCP): 개방-폐쇄 원칙 (Open/Closed Principle)
  • L (LSP): 리스코프 치환 원칙 (Liskov Substitution Principle)
  • I (ISP): 인터페이스 분리 원칙 (Interface Segregation Principle)
  • D (DIP): 의존성 역전 원칙 (Dependency Inversion Principle)

1-2. SOLID는 왜 중요한가?

 코드의 규모가 작을 때는 구조가 다소 엉성하더라도 금방 수정이 가능하다. 하지만 사용자가 늘어나고 기능이 복잡해질수록 코드베이스는 거대한 유기체처럼 작동하게 된다. 이때 SOLID 원칙은 시스템이 무너지지 않고 확장 가능하도록 버텨주는 구조적 프레임워크 역할을 한다.

 

 예를 들어, 단일 책임 원칙을 따르면 클래스는 단 하나의 변경 이유만 가지므로, 버그 수정이나 기능 추가가 전체 코드에 영향을 주지 않는다. 개방-폐쇄 원칙을 따르면 기존 코드를 손대지 않고도 새로운 기능을 추가할 수 있으므로, 레거시 코드에 대한 리스크가 줄어든다.

1-3. 역사적 배경과 창시자

 SOLID라는 용어는 2000년대 초, **로버트 C. 마틴(Robert C. Martin, Uncle Bob)**이 처음 제안하고 정립하였다. 그러나 각 원칙 자체는 훨씬 이전부터 객체지향 설계 원칙으로 존재했다. 예컨대, SRP는 1980년대 중반부터 객체지향 설계자들 사이에서 널리 논의되던 개념이다. Uncle Bob은 이 흩어져 있던 원칙들을 묶어 기억하기 쉽게 만든 것이다.

1-4. 누구를 위한 원칙인가?

 SOLID는 단순히 이론적인 원칙이 아니다. 규모가 있는 서비스를 개발하거나 팀 단위로 협업하는 개발자라면 반드시 이해하고 활용해야 할 지침이다. 또한 이 원칙은 TDD(Test Driven Development), Clean Architecture, DDD(Domain Driven Design) 등 다양한 현대 소프트웨어 개발 방법론과도 깊게 연결되어 있다. 결국, SOLID는 코드가 아니라 시스템 전체의 품질을 좌우하는 핵심 철학이다.


2. SRP – 단일 책임 원칙

2-1. 정의와 핵심 개념

 SRP(Single Responsibility Principle)란, “클래스는 단 하나의 책임만 가져야 한다”는 원칙이다. 여기서 ‘책임’이란 단순히 기능이 아니라, 변경의 이유를 의미한다. 즉, 클래스가 변경되어야 하는 이유는 하나뿐이어야 한다. 책임이 많아질수록 클래스는 결합도가 높아지고, 변경이 어려워진다. SRP는 이를 최소화하고, 각 클래스가 명확한 목적을 가지고 작동하도록 유도한다.

2-2. 실무 예시: 급여 관리 시스템

다음은 SRP를 위반한 간단한 예다.

class Employee {
    void calculateSalary() { ... }
    void printPayslip() { ... }
    void saveToDatabase() { ... }
}

 위 클래스는 급여 계산, 급여 명세서 출력, DB 저장이라는 세 가지 책임을 가진다. 이는 세 가지 서로 다른 변경 이유를 가지고 있기 때문에 SRP를 위반한 것이다. 예를 들어, 명세서 출력 방식이 바뀌면 printPayslip() 메서드만 바꾸면 될 것 같지만, 결국 Employee 클래스 자체가 변경되어야 하므로 다른 기능에도 영향을 줄 수 있다. SRP를 지키려면 책임별로 클래스를 나누어야 한다.

class SalaryCalculator { void calculate() { ... } }
class PayslipPrinter { void print() { ... } }
class EmployeeRepository { void save(Employee e) { ... } }

이렇게 책임이 분리되면 각 클래스는 단 하나의 변경 이유만 가지게 되고, 테스트와 유지보수가 쉬워진다.

2-3. SRP는 단순함이 아니다

 많은 개발자들이 “작은 클래스 = 단일 책임”으로 오해한다. 하지만 클래스가 작다고 해서 반드시 SRP를 만족하는 것은 아니다. 중요한 것은 클래스가 처리하는 기능의 의미적 일관성이다. 예컨대, SNS API에서 ‘게시물 작성’과 ‘댓글 추가’는 기술적으로 비슷한 CRUD 연산이지만, 책임은 분명히 다르다. 두 기능은 분리된 서비스나 컴포넌트로 나누는 것이 적절하다.

2-4. SRP 적용의 효과

 SRP를 따를 경우 다음과 같은 장점을 얻을 수 있다.

  1. 유지보수성 향상: 특정 기능 수정 시 영향 범위 최소화
  2. 단위 테스트 용이: 책임이 명확하므로 테스트 대상이 뚜렷하다
  3. 재사용성 증가: 독립된 책임은 다양한 곳에서 활용 가능
  4. 협업 생산성 향상: 충돌 없이 동시에 여러 기능 수정 가능

 SRP는 SOLID 원칙 중 가장 기본적이며, 동시에 다른 모든 원칙들의 기반이 되는 핵심 개념이다.


3. LSP – 리스코프 치환 원칙

3-1. 정의와 핵심 개념

 LSP(Liskov Substitution Principle)는 바바라 리스코프(Barbara Liskov)가 1987년에 발표한 원칙으로, 다음과 같이 정의된다.

“서브 타입은 언제나 기반 타입으로 대체할 수 있어야 한다.”
즉, 어떤 클래스 S가 클래스 T를 상속받았다면, 프로그램의 동작은 S를 T로 대체했을 때도 여전히 올바르게 작동해야 한다. 이 말은 단순히 컴파일 에러 없이 치환이 가능해야 한다는 의미가 아니다. 기능적 관점에서 동일한 ‘계약’을 지켜야 한다는 것에 초점이 있다.

 

 이 원칙은 상속과 다형성의 본질적인 목적을 제대로 실현하기 위한 기준점이며, 객체지향에서 잘못된 상속 구조로 인해 발생하는 문제를 사전에 방지하는 데 결정적인 역할을 한다.

3-2. 예시: 사각형과 정사각형 문제

 객체지향 설계 수업에서 자주 등장하는 고전적인 LSP 위반 사례가 바로 사각형(Rectangle)과 정사각형(Square) 클래스이다.

class Rectangle {
    int width, height;
    void setWidth(int w) { this.width = w; }
    void setHeight(int h) { this.height = h; }
    int getArea() { return width * height; }
}

class Square extends Rectangle {
    void setWidth(int w) {
        super.setWidth(w);
        super.setHeight(w); // 정사각형은 너비와 높이가 같아야 하니까
    }

    void setHeight(int h) {
        super.setHeight(h);
        super.setWidth(h); // 동일하게 설정
    }
}

 위 예제에서 Square는 Rectangle을 상속받았지만, 실제로 Rectangle이 기대하는 행위를 완전히 뒤엎고 있다. Rectangle을 사용하는 클라이언트 코드가 다음과 같이 구성돼 있다고 해보자.

void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(10);
    assert r.getArea() == 50;
}

 만약 Square 객체를 인자로 넘기면, setWidth(5)를 호출한 후 setHeight(10)을 호출하는 순간 너비도 함께 10이 되므로 면적은 100이 되어버린다. 즉, Rectangle을 사용할 때 기대했던 행위가 깨지게 된다. 이 경우가 바로 LSP 위반이다.

3-3. “IS-A” 관계의 함정

 많은 개발자들이 "정사각형은 사각형의 일종이니까 상속해야 하지 않나?"라고 생각하지만, 현실 세계의 모델링과 소프트웨어 모델링은 다르다. 정사각형은 사각형이라는 '개념적' 관계는 존재하지만, 프로그래밍의 관점에서는 행동 계약(Behavior Contract)을 우선해야 한다. LSP는 이러한 "IS-A" 관계의 논리적 확장이 언제나 옳지 않음을 경고하는 철학적 원칙이다. 현실의 개념을 그대로 코드에 적용하기보다는, 클래스 간 행위의 호환성예측 가능성을 우선시하는 것이 올바른 객체지향 설계다.

3-4. 계약 프로그래밍(Design by Contract)과의 연관

 LSP는 사실상 계약 기반 프로그래밍(Design by Contract)의 한 요소로 볼 수 있다. 부모 클래스가 정의한 행동의 전제조건(pre-condition), 결과 조건(post-condition), 부작용(invariant)을 자식 클래스가 변경해서는 안 된다.

 

구체적으로 말하면 다음과 같다.

  • 자식 클래스는 더 강화된 전제조건을 추가해서는 안 된다.
  • 자식 클래스는 더 느슨한 결과조건으로 결과를 흐리게 만들어서는 안 된다.
  • 자식 클래스는 부모 클래스의 불변 조건을 깨서는 안 된다.

 이런 계약을 지키지 않으면, 다형성이 제공하는 유연성이 오히려 독이 될 수 있다. 예상치 못한 동작과 사이드 이펙트는 시스템 전체의 신뢰성을 훼손한다.

3-5. 실무에서의 예시: Repository 패턴

 예를 들어 UserRepository라는 인터페이스가 있고, 이를 구현한 SqlUserRepository와 MockUserRepository가 있다고 하자.

public interface UserRepository {
    User findById(String id);
}

public class SqlUserRepository implements UserRepository {
    public User findById(String id) {
        // SQL 조회
    }
}

public class MockUserRepository implements UserRepository {
    public User findById(String id) {
        return null; // 실제 구현 안 함
    }
}

 이때 MockUserRepository가 테스트 용도로만 사용된다고 해도, 실제로는 User를 반환할 것으로 기대한 서비스에서 null을 반환하게 되면 NullPointerException이 발생할 수 있다. 이는 LSP를 위반한 Mock 객체 설계이다. MockUserRepository도 실제 동작을 에뮬레이션 할 수 있도록 설계되어야 진정한 LSP 만족이라고 할 수 있다.

3-6. 대안: 상속보다 조합(Composition)

 LSP 위반이 우려되는 경우, 대부분의 문제는 상속 대신 조합(Composition)을 사용함으로써 해결된다. 예컨대, Rectangle 클래스와 Square 클래스 모두 공통 인터페이스 Shape를 구현하고, 각자 고유한 기능을 캡슐화하면 LSP 위반 없이 구조화할 수 있다. 즉, 행위는 인터페이스로 통일하고, 내부 동작은 클래스별로 독립시키는 방식이 더 바람직하다.

3-7. 테스트 코드로 확인하는 LSP

 LSP 위반 여부를 가장 명확히 파악할 수 있는 방법 중 하나는 테스트 코드이다. 기존 부모 클래스를 사용하는 테스트 코드에서 자식 클래스를 넣었을 때, 모든 테스트가 정상적으로 통과하는가? 이를 확인하면 LSP 위반 여부를 실질적으로 검증할 수 있다. TDD(Test-Driven Development)에서는 이 과정을 통해 구조의 건전성을 평가한다.

3-8. 리스코프 원칙을 지키면 얻는 이점

  • 신뢰성 향상: 예측 가능한 행위를 보장한다.
  • 유지보수 용이: 자식 클래스를 추가해도 시스템이 깨지지 않는다.
  • 테스트 간결성: 부모 타입의 테스트 코드가 모든 자식 클래스에도 적용된다.
  • 정확한 모델링: 현실을 맹목적으로 따르지 않고, 소프트웨어적 타당성을 우선시한다.

4. ISP – 인터페이스 분리 원칙

4-1. 정의와 철학적 배경

 ISP (Interface Segregation Principle)는 로버트 마틴(Uncle Bob)이 제시한 SOLID 원칙 중 네 번째로, 다음과 같이 정의된다. "클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다." 이 원칙은 다형성과 인터페이스 기반 설계의 핵심 철학을 다룬다. 즉, 클래스가 구현해야 하는 인터페이스는 작고 명확하게 분리되어야 하며, 클라이언트는 자신에게 필요한 기능만 의존해야 한다는 것이다. 이 개념은 단순히 인터페이스를 "작게 쪼개자"는 얘기가 아니다. 사용자의 입장에서 설계를 재구성하는 사고 전환을 요구한다.

4-2. Fat Interface가 만드는 문제

 다음은 ISP를 위반한 전형적인 구조이다.

public interface MultiFunctionDevice {
    void print(Document d);
    void scan(Document d);
    void fax(Document d);
}

 이 인터페이스를 구현하는 클래스가 있다고 해보자.

public class SimplePrinter implements MultiFunctionDevice {
    public void print(Document d) { ... }
    public void scan(Document d) {
        throw new UnsupportedOperationException();
    }
    public void fax(Document d) {
        throw new UnsupportedOperationException();
    }
}

 이 구조는 ISP 위반이다. SimplePrinter는 인쇄 기능만 필요하지만, 인터페이스상 존재하는 모든 기능을 억지로 구현해야 한다. 

 

 이 경우 다음과 같은 문제가 발생한다.

  1. 불필요한 코드 작성
    사용하지도 않는 기능에 대해 무의미한 구현을 추가해야 한다.
  2. 예외 처리의 복잡성
    UnsupportedOperationException은 런타임 오류를 유발할 수 있으며, 클라이언트 입장에서 매우 불쾌한 경험이 된다.
  3. 기능 오해 가능성
    문서화를 통해 “이 클래스는 print만 지원함”이라고 명시해도, 인터페이스 자체가 그걸 강제하지 못한다.

4-3. ISP를 만족하는 방식: 역할 기반 인터페이스

 위 문제를 해결하기 위한 가장 좋은 방법은 기능별로 인터페이스를 분리하는 것이다.

public interface Printer {
    void print(Document d);
}

public interface Scanner {
    void scan(Document d);
}

public interface Fax {
    void fax(Document d);
}

 이제 각 클래스는 자신이 필요한 기능만 구현할 수 있다.

public class BasicPrinter implements Printer {
    public void print(Document d) { ... }
}

public class AllInOneMachine implements Printer, Scanner, Fax {
    public void print(Document d) { ... }
    public void scan(Document d) { ... }
    public void fax(Document d) { ... }
}

 이 구조는 명확하다. BasicPrinter는 인쇄만 지원하고, AllInOneMachine은 전체 기능을 제공한다. 클라이언트는 자신이 필요한 인터페이스만 의존하기 때문에, 불필요한 코드나 예외 처리가 전혀 없다.

4-4. 실무 적용: 프론트엔드 API, 서비스 계층에서도

 ISP는 백엔드 설계뿐 아니라, 프론트엔드 API 연동, 서비스 계층, 외부 라이브러리 설계에서도 매우 중요한 원칙이다. 예를 들어, 프론트에서 REST API를 사용할 때 /users API에서 조회, 생성, 삭제, 갱신을 모두 제공한다고 해보자. 이걸 하나의 UserService에 몰아넣으면, 사용하지 않는 API가 함께 포함될 수 있다. 하지만 관리자 페이지는 삭제, 일반 사용자는 조회/갱신만 필요할 경우, 아래처럼 나눌 수 있다.

interface UserQueryService {
    getUser(id: number): Promise<User>;
    updateUser(user: User): Promise<void>;
}

interface UserAdminService {
    deleteUser(id: number): Promise<void>;
}

 이 구조는 보안에도 효과적이다. 인터페이스로 동작을 분리함으로써, 각 계층에서 노출되는 기능의 범위를 제한할 수 있기 때문이다. 최소 권한 원칙(Principle of Least Privilege)과도 맞닿아 있다.

4-5. ISP 위반으로 생기는 실제 문제들

 실무에서는 인터페이스를 너무 광범위하게 설계하면서 다음과 같은 문제가 발생한다.

  • 테스트할 때 Mock 객체에서 쓸모없는 메서드까지 오버라이딩해야 함
  • 리팩터링 시, 기존 인터페이스 변경이 모든 구현체에 영향
  • 새 기능 추가 시, 인터페이스 전체 구조 재설계가 필요해짐
  • 사용하지 않는 기능에 대한 의존성 주입으로 복잡도 증가

 결국 이런 문제는 시스템 전반의 유연성을 떨어뜨리고, 변경에 대한 저항력을 높이는 구조로 이어진다.

4-6. 인터페이스 분리의 기준은 무엇인가?

 그렇다면 언제 인터페이스를 분리해야 할까? 다음 질문을 통해 판단할 수 있다.

  1. 이 인터페이스를 사용하는 클라이언트가 어떤 기능을 실제로 사용하는가?
  2. 모든 구현체가 모든 기능을 완전하게 구현할 수 있는가?
  3. 어떤 기능이 변경되었을 때, 관련 없는 클라이언트가 영향을 받는가?

 이 질문에 “아니오”가 나온다면, 역할 단위로 인터페이스를 분리할 타이밍이다. 또한 “지나친 인터페이스 분리”는 설계를 복잡하게 만들 수 있으므로, 유의미한 차이가 있는 기능들을 기준으로 나누는 것이 좋다.

4-7. ISP와 SRP, DIP와의 연계

  • SRP와의 연계: 인터페이스의 분리 기준이 '변경 이유'라면, 이는 SRP와 같은 철학을 공유하는 것이다.
  • DIP와의 연계: 인터페이스가 잘게 분리되어 있어야 DIP(의존성 역전 원칙)도 제대로 작동할 수 있다.
    즉, ISP는 나머지 SOLID 원칙들과도 깊은 연결 고리를 가지며, 실질적으로 모듈 간 의존성 구조의 뼈대가 된다.

4-8. ISP의 궁극적인 목표

 ISP는 다음을 궁극적인 목표로 삼는다.

  • 의존성 최소화: 필요한 것만 알고, 나머지는 무시하자
  • 변경 영향 최소화: 쓸데없는 연결고리를 끊어내자
  • 역할 중심의 설계: 사용자(클라이언트)의 입장에서 기능을 바라보자
  • 모듈화된 구조: 기능 단위로 나눠야 교체도 쉽고, 테스트도 간편하다

 이런 설계는 규모가 커질수록 빛을 발하며, 특히 마이크로서비스, 플러그인 아키텍처, 테스트 자동화에서 ISP는 핵심 원칙이 된다.

'컴퓨터공학' 카테고리의 다른 글

디자인 패턴 총정리  (0) 2025.05.22
SRP vs OCP, 실제 적용 사례  (0) 2025.05.22
의존성 역전 원칙의 진짜 의미  (0) 2025.05.21
Clean Architecture 원칙  (0) 2025.05.21
헥사고날 아키텍처란?  (0) 2025.05.20