개발자 필독서: 좋은 코드를 넘어 위대한 코드로 가는 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는 우리에게 ‘정답’을 알려주는 것이 아니라, 더 나은 설계를 향해 나아갈 수 있도록 방향을 제시하는 ‘나침반’과 같습니다. 이 나침반을 손에 쥔 개발자는 예측 불가능한 요구사항의 폭풍우 속에서도 길을 잃지 않고, 시간이 흘러도 변치 않는 가치를 지닌 견고하고 아름다운 소프트웨어를 만들어낼 수 있을 것입니다.