티스토리 뷰

프로그래밍

SOLID 원칙 가이드

silbaram 2025. 3. 20. 15:19
728x90

Java 기반 SOLID 원칙 가이드

SOLID 원칙 개요

SOLID는 객체지향 설계의 다섯 가지 원칙을 나타내며, 로버트 마틴(일명 Uncle Bob)이 제안한 소프트웨어 설계 철학입니다. 이 원칙들은 소프트웨어를 확장에 열려 있고 유지보수가 용이하도록 만들기 위한 지침이며, 코드 의존성을 줄이고 유연성과 이해도를 높여줍니다. SOLID의 다섯 가지 원칙은 다음과 같습니다.

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

이 가이드에서는 각 원칙을 명확히 설명하고, Java 예제 코드이미지를 통해 실무에 어떻게 적용할 수 있는지 살펴봅니다.

Single Responsibility Principle (SRP) – 단일 책임 원칙

단일 책임 원칙어떤 클래스나 모듈이 하나의 책임만 가져야 한다는 것을 의미합니다. 다시 말해, 클래스가 변경되어야 하는 이유는 오직 하나뿐이어야 한다는 뜻입니다. 하나의 클래스에 여러 가지 역할이나 기능이 섞여 있으면, 변경의 영향범위가 넓어져 유지보수가 어려워집니다. 한 부분을 수정하면 다른 부분에 부작용이 생기기 쉽고, 클래스의 응집도(cohesion)가 낮아지며 테스트도 복잡해집니다.

예시: 한 클래스가 여러 책임을 질 때 발생하는 문제를 보여줍니다. 아래의 Employee 클래스는 급여 계산, 데이터베이스 저장, 보고서 생성을 모두 담당하고 있어 SRP를 위반합니다:

// SRP 위반 - 여러 책임을 가진 하나의 클래스
class Employee {
    public Pay calculatePay() { ... }        // 급여 계산 로직
    public void save() { ... }              // DB 저장 로직
    public String describeEmployee() { ... }// 보고서 생성 로직
}

Employee 클래스는 급여 계산 기능과 직원 정보 저장, 직원 정보 설명 출력 기능을 모두 포함하고 있습니다. 이처럼 여러 책임이 혼합되면, 예를 들어 급여 계산 방식 변경데이터 저장 방식 변경처럼 전혀 다른 이유로도 이 클래스 코드를 수정해야 하므로 유지보수가 취약해집니다.

해결 방법은 클래스들을 각각의 책임으로 분리하는 것입니다. 하나의 클래스에 오직 하나의 역할만 부여하도록 설계합니다. 위 예시의 Employee 클래스를 SRP에 맞게 리팩토링하면 다음과 같이 나눌 수 있습니다.

// SRP 준수 - 책임별로 클래스를 분리
class EmployeeCalculator {                  // 급여 계산 책임
    public Pay calculatePay(Employee e) { ... }
}
class EmployeeRepository {                  // 데이터 저장 책임
    public void save(Employee e) { ... }
}
class EmployeeReporter {                    // 보고서 생성 책임
    public String describeEmployee(Employee e) { ... }
}

이렇게 기능별로 클래스를 쪼개면 각 클래스가 명확한 목적을 가지게 되어 이해하기 쉽고, 한 기능의 변경이 다른 부분에 영향을 주지 않으므로 코드를 안전하게 수정할 수 있습니다.

 

(SOLID design principles: Building stable and flexible systems · Raygun Blog)
그림: 단일 책임 원칙을 적용한 구조. 왼쪽은 하나의 Book 클래스가 책 데이터 관리와 재고 검색 두 가지 책임을 가져 SRP를 위반한 상황이고, 오른쪽은 Book 클래스InventoryView 클래스로 역할을 분리하여 각 클래스가 하나의 책임만 맡도록 개선한 구조입니다. 두 클래스 모두 자신의 목적에만 집중하므로, 한쪽의 변경이 다른 클래스에 영향을 주지 않게 됩니다.

Open-Closed Principle (OCP) – 개방-폐쇄 원칙

개방-폐쇄 원칙“확장에는 열려 있고, 수정에는 닫혀 있어야 한다”는 원칙입니다. 새로운 기능을 추가할 때 기존 코드의 수정 없이도 확장이 가능하도록 설계하라는 뜻입니다. 즉, 기능을 변경하거나 추가할 수는 있지만 이미 검증된 기존 코드 자체는 수정하지 않아야 한다는 의미입니다. 이러한 구조를 만들려면 클래스 간 의존성을 추상화를 통해 관리해야 합니다. 일반적으로 인터페이스나 추상 클래스를 활용하여 새로운 기능은 새로운 구현 클래스로 추가하고, 기존 코드는 그 인터페이스를 통해 상호작용하도록 함으로써 OCP를 달성합니다. 기존 소스 코드를 수정하지 않으면, 이미 잘 동작하던 부분에 새로운 버그가 생길 위험을 줄이고 유지보수성을 높일 수 있습니다.

예시: 쇼핑몰 애플리케이션의 checkOut 메서드가 처음엔 현금 결제만 처리한다고 가정해보겠습니다. 신용카드 결제 기능을 추가해야 하는 상황에서, 간단하게 if 문으로 기존 코드를 수정하면 OCP를 위반하게 됩니다. 아래는 OCP를 지키지 못한 코드와 이를 준수하도록 개선한 코드입니다:

// OCP 위반 - 기능 추가를 위해 기존 코드 수정
Payment p;
if (isCreditCard) {
    p = acceptCredit(total);   // 신용카드 결제 처리
} else {
    p = acceptCash(total);     // 현금 결제 처리
}
receipt.addPayment(p);

// OCP 준수 - 추상화 도입으로 기능 확장
public interface PaymentMethod { 
    Payment acceptPayment(Money total); 
}
void checkOut(Receipt receipt, PaymentMethod method) {
    Money total = calculateTotal();
    // ...
    Payment p = method.acceptPayment(total);  // 결제 방식을 인터페이스로 처리
    receipt.addPayment(p);
}

위 첫 번째 코드는 isCreditCard 여부에 따라 분기 처리하고 있는데, 신용카드 이외의 새로운 결제 방식(예를 들어 모바일 결제)을 추가하려면 또다시 checkOut 메서드를 수정해야 합니다. 반면 두 번째 코드처럼 PaymentMethod라는 인터페이스를 도입하면, 현금이든 카드든 새로운 결제 수단 구현체를 만들기만 하면 되고 checkOut 메서드 자체는 수정하지 않아도 됩니다. 이처럼 OCP를 적용하면 기존 코드에 영향을 주지 않고도 기능을 추가할 수 있어 유연성이 높아집니다.

 

(SOLID design principles: Building stable and flexible systems · Raygun Blog)
그림: 개방-폐쇄 원칙(OCP) 적용 전후 비교. 왼쪽은 DiscountManagerCookbookDiscountBiographyDiscount 구현 클래스에 직접 의존하여 각 할인 처리 메서드를 개별적으로 가지고 있습니다. 오른쪽은 BookDiscount 인터페이스를 도입하여 DiscountManager는 추상 인터페이스에만 의존하고, 구체 할인 클래스들은 그 인터페이스를 구현하도록 리팩토링한 구조입니다. 개선된 설계에서는 새로운 할인 유형이 생겨도 DiscountManager를 수정하지 않고 새로운 할인 클래스를 추가할 수 있으므로 OCP를 만족합니다.

Liskov Substitution Principle (LSP) – 리스코프 치환 원칙

리스코프 치환 원칙상위 타입의 객체를 하위 타입 객체로 대체해도 프로그램의 동작에 문제가 없어야 한다는 원칙입니다. 즉, 자식 클래스는 언제나 부모 클래스으로서 동작할 수 있어야 한다는 뜻입니다. 이를 위해 하위 클래스는 기반 클래스의 계약(contract)을 위배하지 않아야 합니다. 하위 클래스는 상위 클래스가 갖는 메서드의 의미나 동작을 변경하거나, 상위 클래스에서는 없는 예외를 발생시키는 등의 행위를 해서는 안 됩니다.

간단히 말해, “어떤 클래스의 인스턴스를 그것의 하위 클래스 인스턴스로 교체하더라도 프로그램의 안정성이 유지되어야 한다”는 것입니다. 만약 하위 클래스가 상위 클래스의 기대치를 깨트린다면 LSP를 위반하게 됩니다.

예시: LSP 위반의 고전적인 예로 사각형(Rectangle)정사각형(Square) 클래스 문제가 자주 언급됩니다. Rectangle 클래스는 폭과 높이를 각각 설정할 수 있다고 가정하고, Square 클래스는 Rectangle을 상속받아 항상 폭과 높이가 같도록 setWidthsetHeight를 오버라이드한다고 해봅시다. Square는 “모든 변의 길이가 같다”는 제약이 있으므로, Rectangle의 일반적인 사용(폭과 높이를 독립적으로 변경)에 부합하지 않습니다. Rectangle을 기대하는 코드에서 Square를 대입하면, Square의 setWidth/setHeight 동작이 Rectangle과 달라서 예상치 못한 부작용이 발생할 수 있습니다. 아래 코드처럼 Square가 Rectangle의 setter를 오버라이드하여 동작을 변경하는 순간 LSP를 어기게 됩니다:

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

class Square extends Rectangle {
    @Override
    public void setWidth(double w) {
        super.setWidth(w);
        super.setHeight(w);   // 가로를 설정하면 세로도 동일하게 설정
    }
    @Override
    public void setHeight(double h) {
        setWidth(h);          // 세로를 설정하면 가로도 동일하게 설정
    }
}

위의 Square 구현은 Rectangle이 기대하는 동작 (가로, 세로를 개별 설정 가능)을 깨뜨리므로 LSP를 위반합니다. 이런 문제를 해결하려면 상속 구조를 재고해야 합니다. 예를 들어 Square를 Rectangle의 하위 클래스가 아니라 별도 클래스로 취급하거나, Rectangle 인터페이스를 분리하는 등의 대안이 있을 수 있습니다. 핵심은 하위 타입이 상위 타입의 행위를 완전히 대체할 수 있도록 클래스 설계를 해야 한다는 점입니다.

참고: LSP는 다형성을 다룰 때 특히 중요하며, 하위 클래스의 사소한 동작 변경도 전체 시스템 오류로 이어질 수 있으므로 철저한 단위 테스트와 설계 점검이 필요합니다. LSP를 만족하려면 상속 관계를 맺을 때 부모 클래스의 사전조건(precondition), 사후조건(postcondition), 예외 규약 등을 자식 클래스가 강화하거나 변경하면 안 된다는 점을 기억해야 합니다.

Interface Segregation Principle (ISP) – 인터페이스 분리 원칙

인터페이스 분리 원칙“클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다”는 것을 뜻합니다. 여기서 클라이언트란 어떤 인터페이스를 구현하는 클래스 또는 그 인터페이스를 사용하는 클래스를 말합니다. 한 인터페이스에 너무 많은 메서드가 정의되어 있다면, 이를 구현하는 클래스들은 불필요한 메서드까지 구현해야 하고 사용하지도 않는 기능에 대한 의존성이 생기게 됩니다. 이러한 비응집적(non-cohesive) 인터페이스는 변경에 취약하고, 시스템 전반에 불필요한 재컴파일 및 재배포를 야기합니다. ISP는 이러한 문제를 피하기 위해 인터페이스를 역할 별로 작은 단위로 분리하라고 권고합니다. 인터페이스 하나를 여러 개로 분리하면, 구현 클래스들이 자신에게 필요한 인터페이스만 구현하게 되어 결합도를 낮추고 유연성을 높일 수 있습니다.

예시: 은행 ATM 기기의 메시지 출력을 제어하는 소프트웨어를 생각해봅시다. ATM에는 카드 삽입, PIN 입력, 인출, 입금 등 여러 기능이 있고, 각 기능마다 사용자에게 보여줄 메시지가 다릅니다. 아래와 같이 모든 메시지 관련 메서드를 한 인터페이스 Messenger에 넣었다고 가정하면:

// ISP 위반 - 거대한 인터페이스
public interface Messenger {
    void askForCard();                // 카드 삽입 요청
    void tellInvalidCard();           // 잘못된 카드 알림
    void askForPin();                 // PIN 번호 요청
    void tellInvalidPin();            // 잘못된 PIN 알림
    void askForAccount();             // 계좌 유형 요청
    void tellNotEnoughMoney();        // 잔액 부족 알림
    void tellAmountDeposited();       // 입금 완료 알림
    void tellBalance();               // 잔액 표시
    // ... 기타 여러 메시지 관련 메서드
}

Messenger 인터페이스를 구현하는 클래스는 ATM의 모든 시나리오(카드 입출력, 입출금 등)에 대한 메서드를 전부 구현해야 합니다. 그런데 잔액 조회 전용 화면 클래스출금 전용 화면 클래스를 각각 만든다면, 이들은 Messenger의 일부 메서드만 필요로 함에도 불구하고 사용하지도 않을 메서드들까지 구현해야 합니다. 만약 새로운 기능을 추가하기 위해 Messenger 인터페이스에 메서드를 하나 더 넣는다면, 이 인터페이스를 구현한 모든 클래스들을 수정하고 다시 컴파일/배포해야 합니다. 이는 명백히 비효율적이며 OCP 원칙에도 어긋납니다.

ISP에 따라 우리는 거대한 Messenger 인터페이스를 기능별 작은 인터페이스들로 나눌 수 있습니다. 예를 들어 ATM 기능별로 아래처럼 인터페이스를 분리합니다:

// ISP 준수 - 작은 인터페이스들로 분리
public interface CardAuthMessenger {        // 카드 인증 관련 메시지
    void askForCard();
    void tellInvalidCard();
    void askForPin();
    void tellInvalidPin();
}
public interface WithdrawalMessenger {      // 출금 기능 관련 메시지
    void askForAccount();
    void tellNotEnoughMoney();
    void askForFeeConfirmation();
    // ...
}
public interface DepositMessenger {         // 입금 기능 관련 메시지
    void tellAmountDeposited();
    void tellBalance();
    // ...
}

// 각 인터페이스를 필요한 곳에서만 구현
public class BasicMessenger implements CardAuthMessenger, WithdrawalMessenger {
    ... // 필요한 메서드만 구현
}

위처럼 인터페이스를 세분화하면, 구현 클래스가 자신의 역할에 맞는 인터페이스만 선택적으로 구현할 수 있습니다. 예를 들어 BasicMessenger는 CardAuthMessenger와 WithdrawalMessenger만 구현하고 입금 관련 메서드는 갖지 않으므로, 입금 화면과 무관한 코드 변경에 영향을 받지 않습니다. 인터페이스를 변경하거나 추가하더라도 관련된 구현체들만 수정하면 되므로 시스템 전체에 미치는 파급효과가 줄어듭니다. 결과적으로 각 클래스는 자신이 사용하는 메서드에만 의존하게 되어, 불필요한 결합이 사라지고 코드의 유연성과 재사용성이 높아집니다.

 

(SOLID design principles: Building stable and flexible systems · Raygun Blog)
그림: 인터페이스 분리 원칙 적용 전후. 왼쪽은 하나의 BookAction 인터페이스가 과도하게 많은 메서드(리뷰 보기, 중고 검색, 샘플 듣기 등)를 갖고 있고 HardcoverUIAudiobookUI 클래스가 이 큰 인터페이스를 구현하고 있습니다. 오른쪽은 HardcoverActionAudioAction으로 인터페이스를 분리하여, 각 UI 클래스가 필요한 인터페이스만 구현하도록 개선한 모습입니다. 인터페이스가 분리되면서 클래스 설계가 간결해지고, 변경 요구에 유연하게 대처할 수 있게 되었습니다.

Dependency Inversion Principle (DIP) – 의존관계 역전 원칙

의존관계 역전 원칙상위 수준 모듈(high-level module)이 하위 수준 모듈(low-level module)에 의존하지 말고, 추상화(abstraction)에 의존하라는 원칙입니다. 또한 추상화는 세부 사항에 의존하지 말고, 세부 사항이 추상화에 의존해야 한다고 말합니다. 쉽게 말해, 구체적인 구현에 직접 의존하지 말고 인터페이스나 추상 클래스와 같은 추상 계층에 의존하도록 설계하라는 뜻입니다. 이를 통해 상위 모듈과 하위 모듈 간의 결합도를 낮추고, 한쪽의 변경이 다른 쪽에 직접적인 영향을 주는 것을 방지할 수 있습니다. DIP를 적용하면 시스템을 더 유연하고 변경에 강하게 만들 수 있으며, 각 부분을 독립적으로 테스트하기도 쉬워집니다.

전통적으로 상위 모듈(비즈니스 로직이나 규칙을 담은 코드)이 하위 모듈(DB 접근, 파일 I/O, 네트워크, UI 등 구체적 기능 구현)에 의존하기 마련이지만, DIP에서는 이러한 방향을 뒤집어(Swap) 상위 모듈이 추상 인터페이스에 의존하고 하위 모듈이 그 인터페이스를 구현하도록 합니다. 그러면 새로운 하위 구현을 만들어도 상위 모듈을 수정할 필요가 없습니다.

예시: 텍스트 편집기에서 문자 복사(copy) 기능을 구현한다고 가정합니다. 문자 데이터를 읽어오는 입력 장치와 출력하는 장치가 있을 때, DIP를 적용하지 않은 코드는 특정 클래스에 직접 의존할 수 있습니다 (예: Keyboard로부터 입력 받고 Printer로 출력). DIP를 적용하면 아래처럼 입력과 출력에 대한 인터페이스를 도입할 수 있습니다:

// DIP 준수 - 추상 인터페이스를 사이에 둠
public interface Reader { char getChar(); }    // 문자 입력 인터페이스
public interface Writer { void putChar(char c); } // 문자 출력 인터페이스

class CharCopier {
    void copy(Reader reader, Writer writer) {
        char c;
        while ((c = reader.getChar()) != EOF) {
            writer.putChar(c);
        }
    }
}

위 코드에서 CharCopier (상위 모듈)는 ReaderWriter 인터페이스에만 의존하고, 실제 구현은 전달된 객체(reader, writer)에 맡깁니다. 이제 Reader를 구현하는 하위 모듈로 KeyboardFileReader를 만들 수도 있고, Writer 구현으로 PrinterConsoleWriter 등을 만들 수 있습니다. 어떤 구체 클래스가 오더라도 CharCopier의 코드는 변경할 필요 없이 동작합니다. 새로운 데이터 소스나 출력 장치를 추가할 때도 Reader 또는 Writer 인터페이스를 구현하기만 하면 되므로 OCP도 만족하게 됩니다. 반대로, CharCopierKeyboard 클래스에 직접 의존하고 있었다면, 파일로부터 복사하는 기능을 추가할 때 CharCopier의 코드를 수정해야 할 뿐만 아니라 잘못하면 전체 동작에 영향을 주어 버그가 발생할 수도 있었을 것입니다.

 

(SOLID design principles: Building stable and flexible systems · Raygun Blog)
그림: 의존관계 역전 원칙(DIP) 적용 전후. 왼쪽은 상위 모듈 Shelf 클래스가 하위 모듈인 Book 클래스에 직접 의존하는 구조입니다. 이 경우 새로운 미디어(DVD 등)를 선반에 추가하려면 Shelf 코드를 수정해야 하므로 OCP에도 위배됩니다. 오른쪽은 Product라는 추상 인터페이스를 도입하여 Shelf가 추상화에 의존하도록 개선한 구조입니다. BookDVDProduct 인터페이스를 구현하며, Shelf는 어떤 구체 제품이 오더라도 Product 인터페이스를 통해 처리하므로 DIP 원칙을 만족합니다. 이처럼 DIP를 적용하면 상위 모듈(Shelf)과 하위 모듈(Book, DVD) 간 결합이 느슨해져 기능 확장이 용이하고, 한 모듈의 변경이 다른 모듈에 직접 영향을 주지 않게 됩니다.

실무 팁: DIP를 실천하기 위해 의존성 주입(Dependency Injection) 패턴을 자주 활용합니다. 객체가 내부에서 직접 다른 객체를 생성하지 않고, 생성자나 팩토리, IoC 컨테이너 등을 통해 필요한 의존 객체를 외부로부터 주입받는 방식입니다. 이렇게 하면 클래스들은 추상 타입에만 의존하고 구체적인 생성이나 연결은 외부에서 관리하게 되어 DIP 원칙에 부합하는 유연한 설계를 얻을 수 있습니다.

마무리 및 활용 방법

SOLID 원칙들은 코드 구조를 개선하고 유지보수성을 향상시키는 강력한 도구입니다. 새로운 기능이나 모듈을 설계할 때 이 원칙들을 염두에 두면, 향후 요구사항 변경이나 확장에 대비한 보다 견고한 코드를 작성할 수 있습니다. 특히 대규모 프로젝트나 장기간 유지보수되는 시스템에서는 SOLID 원칙을 지키는 것이 기능 추가 시 발생하는 리스크를 줄이고 개발 생산성을 높이는 데 큰 도움이 됩니다.

다만, SOLID 원칙들은 엄격한 규칙이 아닌 지침(guideline)으로 이해해야 합니다. 상황에 따라 적절히 응용하는 것이 중요하며, 원칙을 지키는 것 자체가 목표가 되어 과도하게 세분화된 클래스나 인터페이스를 남발하지 않도록 주의해야 합니다. 궁극적으로는 이 다섯 가지 원칙을 균형 있게 적용하여, 변화에 유연하면서도 읽기 좋은 견고한(SOLID) 코드를 구현하는 것이 목표입니다.

728x90