
(Robert C. Martin (Uncle Bob))
SOLID 원칙(Principle)이란 무엇인가?
OOP(Object-oriented programming 객체지향 프로그래밍)는 사실 SOLID 원칙을 사용하지 않고, 작성할 수 있다.
SOLID 원칙을 무시한 채 클래스,상속 or 조합(컴포지트),캡슐화,다형성 만으로도 충분히 프로그램은 동작한다.
그렇다면 왜 SOLID 원칙이 등장했을까?
SOLID 원칙은 유지보수성, 확장성,결합도 관리를 위해 일종의 가이드 라인이다.
즉 고품질 'OOP' 코드를 작성하는 가이드 라인이 SOLID 원칙인 것이다.

SOLID 원칙의 시작
2001년, 소프트웨어 개발자들이 한데 모여 애자일 선언문(Agile Manifesto)을 발표했다. 핵심은 간단하다. 무거운 프로세스와 문서보다 사람과 협업을, 계획의 고수보다 변화에 대한 적응을 우선하자는 것이었다. 철학으로서는 혁신적이었지만, "구체적으로 어떻게 코드를 짜야 하는가"에 대한 답은 없었다. 선언문은 방향을 제시했지만 어떤 길을 가야하는 가에 대한 정확한 방법론을 설명한 것은 아니었다.

"Agile Software Development: Principles, Patterns, and Practices" (2002)
이듬해 로버트 C. 마틴(Robert C. Martin)은 Agile Software Development: Principles, Patterns, and Practices(2002)에서 그 공백을 채웠다. 애자일의 '철학'을 코드 수준의 '방법론'으로 번역한 것이다. 이 책을 기점으로 Agile은 이론에서 실무 표준으로 자리잡기 시작했다.

초판에서 SRP, OCP, LSP, ISP, DIP는 개별 원칙으로 소개됐다. 각 원칙은 마틴이 직접 고안한 것이 아니라, 이미 프로그래밍 커뮤니티에 흩어져 있던 개념들을 애자일 설계 관점에서 추려내고 재정의한 것이다.
OCP(개방-폐쇄 원칙)는 버트런드 마이어(Bertrand Meyer)가 Object-Oriented Software Construction(1988)에서 처음 제안했고, LSP(리스코프 치환 원칙)는 바바라 리스코프(Barbara Liskov)가 1987년 OOPSLA 컨퍼런스에서 발표한 데이터 추상화 논문에 뿌리를 두고 있다. SRP(단일 책임 원칙)는 데이비드 파르나스(David L. Parnas)의 모듈 분해 개념과 다익스트라(Edsger W. Dijkstra)의 관심사 분리(Separation of Concerns)를 마틴이 하나의 원칙으로 정리한 것이다. ISP(인터페이스 분리 원칙)와 DIP(의존 역전 원칙)는 마틴이 1990년대 중반 실무 컨설팅 과정에서 직접 도출해 1996년 에세이로 발표했다. 즉 SOLID는 수십 년에 걸쳐 서로 다른 맥락에서 등장한 아이디어들을 하나의 일관된 설계 철학 아래 묶은 결과물이다.
이후 마이클 C. 페더스(Michael C. Feathers)가 각 원칙의 앞 글자를 모아 SOLID라는 이름을 제안했고, 마틴이 블로그와 강연에서 이를 채택하면서 오늘날의 형태가 됐다. SOLID가 "단단하다"는 의미의 단어이기도 하다는 점은 이름 자체에 의도가 담긴 말장난이다. 명칭이 정착된 시기는 대략 2004년경으로 추정된다.

왜 SOLID가 필요한가, 설계 냄새(Design Smells) 이야기
SOLID가 무엇인지 설명하기 전에 마틴이 먼저 던진 질문은 이것이었다. "나쁜 설계란 무엇인가?" 원칙을 외우는 것보다 왜 그 원칙이 필요한지를 먼저 이해해야 한다고 봤기 때문이다. 그래서 책 7장은 SOLID 원칙 자체가 아니라, 원칙 없이 작성된 코드가 시간이 지나면서 어떤 방식으로 망가지는지를 다루는 것으로 시작한다.
마틴은 나쁜 설계를 추상적인 개념으로 설명하는 대신, 코드에서 실제로 나타나는 증상을 기준으로 분류했다. 그는 이것을 설계 냄새(Design Smell)라고 불렀다. 냄새가 난다고 해서 당장 시스템이 죽지는 않는다. 하지만 방치하면 썩는다. 그가 제시한 냄새는 크게 네 가지다.
경직성(Rigidity)
한 곳을 고치려 했는데 열 곳을 수정해야 하는 상황이다. 변경이 두려운 코드, 건드리기 전에 한숨부터 나오는 코드가 여기에 해당한다. 코드가 서로 단단하게 묶여 있어서 작은 변경 하나가 연쇄적인 수정을 유발한다.
정의: 소프트웨어가 변경하기 어려운 상태. 한 부분을 수정하려 할 때, 그 변경이 시스템의 다른 여러 부분에 영향을 미쳐 예상보다 훨씬 많은 작업이 필요해지는 것.
특징: 한 클래스의 메서드를 수정하려 했더니 이를 호출하는 수십 개의 다른 클래스를 함께 수정해야 하는 경우. 데이터베이스 스키마를 변경하려면 UI, 비즈니스 로직, 데이터 접근 계층 전체를 손대야 하는 상황.
문제 원인: 높은 결합도(High Coupling)와 낮은 응집도(Low Cohesion).
해결 방법: SRP(단일 책임 원칙)와 DIP(의존 역전 원칙)으로 모듈 간 결합도를 낮추고, 변경이 다른 부분에 미치는 영향을 최소화한다.
취약성(Fragility)
경직성이 "고치기 어렵다"라면, 취약성은 "고쳤더니 엉뚱한 데가 부서진다"이다. 개발자가 변경의 영향 범위를 예측할 수 없을 때 발생한다. 코드를 수정할수록 시스템이 더 불안정해지는 악순환이 여기서 시작된다.
정의: 한 부분을 수정했을 때, 예상치 못한 다른 부분에서 문제가 발생하거나 시스템이 불안정해지는 상태.
특징: 코드 수정 후 버그가 빈번히 발생하고, 수정한 부분과 직접 관련 없는 영역에서 오류가 나타남.
예시: 결제 모듈을 수정했는데 갑자기 로그인 시스템이 작동하지 않는 경우. 한 메서드의 로직을 개선했는데, 이를 사용하던 다른 모듈에서 런타임 오류가 발생하는 상황.
문제 원인: 부적절한 상속 사용(LSP 위반)이나 의존성 관리 실패.
해결 방법: LSP(리스코프 치환 원칙)로 상속 구조의 안정성을 확보하고, ISP(인터페이스 분리 원칙)로 불필요한 의존성을 제거한다.
부동성(Immobility)
잘 만든 모듈이 있는데 다른 프로젝트에서 가져다 쓸 수가 없는 상태다. 재사용하고 싶지만 잘라낼 수가 없는 것이 부동성이다. 코드가 특정 환경에 너무 강하게 묶여 있어서, 분리하는 비용이 새로 만드는 비용보다 커지는 순간이 온다.
정의: 특정 모듈이나 코드를 다른 프로젝트나 다른 맥락에서 사용하려 할 때, 과도한 의존성 때문에 분리하기 어렵거나 재사용이 불가능한 상태.
특징: 코드가 특정 환경에 너무 강하게 묶여 있고, 재사용하려면 대규모 수정이 필요함.
예시: 데이터베이스 쿼리 로직이 UI 코드와 얽혀 있어, 다른 프로젝트에서 쿼리 로직만 재사용할 수 없는 경우. 특정 하드웨어에 의존적인 코드가 다른 플랫폼에서 동작하지 않는 상황.
문제 원인: 모듈 간 높은 결합도와 추상화 부족.
해결 방법: DIP(의존 역전 원칙)로 구체적인 구현이 아닌 추상화에 의존하도록 설계하고, OCP(개방-폐쇄 원칙)로 재사용 가능한 구조를 만든다.
점성(Viscosity)
앞의 세 가지가 코드 자체의 문제라면, 점성은 조금 다른 결을 가진다. 설계가 나빠지는 것은 개발자의 의지 문제가 아니라 환경이 나쁜 선택을 유도하기 때문인 경우가 많다. 올바른 방법이 잘못된 방법보다 더 힘들 때, 사람은 결국 잘못된 방법을 선택한다.
정의: 소프트웨어 개발 환경이나 코드가 작업을 어렵고 느리게 만드는 상태. 점성이 높다는 것은 올바른 방법으로 작업하는 것이 잘못된 방법보다 더 힘들 때를 말한다.
특징: 두 가지 형태로 나뉜다. 소프트웨어 점성(Viscosity of the Software)은 코드 자체가 유지보수하기 어렵거나 좋은 설계를 적용하기 힘든 경우이고, 환경 점성(Viscosity of the Environment)은 빌드, 테스트, 배포 등의 개발 환경이 비효율적이어서 작업 속도가 느려지는 경우다.
예시: 리팩토링 대신 복사-붙여넣기로 코드를 추가하는 것이 더 쉬운 경우(소프트웨어 점성). 빌드 시간이 너무 길어 코드 수정 후 확인이 느려지는 상황(환경 점성).
문제 원인: 설계 원칙 미준수, 복잡한 개발 프로세스.
해결 방법: SRP로 코드 단순성을 유지하고, 지속적인 리팩토링과 자동화된 테스트 및 빌드 환경을 통해 점성을 낮춘다.
마틴은 이 네 가지 냄새를 진단 도구로 삼고, SOLID를 그에 대한 처방으로 제시했다. 각 원칙이 어떤 냄새를 완화하는지 정리하면 다음과 같다.
SRP는 코드의 경직성(Rigidity)을 줄인다.
OCP는 기존 코드를 수정하지 않고 확장 가능하게 한다. 이는 코드 간의 점성(Viscosity)을 줄인다.
LSP는 상속 구조의 안전성을 보장해 취약성(Fragility)을 줄인다.
DIP와 ISP는 결합도를 낮춰 부동성(Immobility)을 줄인다.
다만 SOLID가 모든 냄새의 해답은 아니다. 대표적인 예가 산탄총 수술(Shotgun Surgery)이다. 하나의 변경이 동시에 여러 클래스를 수정하게 만드는 이 안티패턴은, 아이러니하게도 SRP를 지나치게 적용해 책임을 과도하게 분산시켰을 때 오히려 악화된다. SOLID는 강력한 가이드라인이지만, 맥락 없이 기계적으로 적용하면 냄새를 없애는 것이 아니라 다른 냄새로 교체하는 결과를 낳는다. 결국 맥락이 중요하고, 어디에 있고 어떻게 나누는지 합의에 대한 이야기가 가장 중요하다. 그리고 가장 어려운 지점이기도 하다.
어떤 가르침이든 너무 맹목적으로 따르면 교조화되지만 또 교조화 되보지 않으면 그 위험성을 알 수가 없기에 결국 하나의 과정으로써 경험을 한다라고 필자는 개인적으로 생각하는 바이다.

(이미지 출처:https://www.instagram.com/techwithisha/reel/C1Ws1ZDt8_j/)
SOLID 원칙?
SOLID의 각 원칙을 살펴보기 전에, 그 철학적 뿌리를 한 번 짚고 넘어갈 필요가 있다. 1974년 다익스트라는 훗날 SOLID의 핵심 개념이 될 아이디어를 이미 이렇게 표현했다.

내가 생각하는 모든 지적인 사고의 특징을 설명해 보겠다.
그것은, 자신이 다루는 주제의 특정 측면을 깊이 연구하는 태도를 의미한다.
그리고 이 연구는 그 측면 자체의 일관성을 유지하기 위한 것이며, 동시에 자신이 다루고 있는 것이 전체의 일부에 불과하다는 사실을 인지하는 상태에서 이루어진다.
우리는 프로그램이 올바르게 동작해야 함을 알고 있으며,그 관점에서만 프로그램을 연구할 수 있다.또한, 프로그램이 효율적이어야 함을 알고 있으며,이를 분석하는 작업은 다른 날, 말하자면 따로 수행할 수도 있다.
어떤 때에는, 프로그램이 정말로 필요하며, 그렇다면 왜 그런지에 대해 고민할 수도 있다.그러나 이러한 다양한 측면을 동시에 다루는 것은 아무런 이득이 없으며, 오히려 방해만 될 뿐이다.
나는 이것을 "관심사의 분리(Separation of Concerns)" 라고 부르는데,비록 완벽하게 실현할 수는 없지만, 사고를 효과적으로 정리하는 유일한 방법이라고 생각한다.내가 "어떤 한 가지 측면에 초점을 맞춘다" 라고 말할 때,
그것은 다른 측면을 무시한다는 의미가 아니다.
오히려, 특정 측면의 관점에서 보면 다른 측면은 당장은 중요하지 않다는 사실을 받아들이는 것이다.
즉, 한 가지에 집중하면서도 동시에 여러 가지를 고려하는 사고방식을 의미하는 것이다. -에츠허르 다익스트라(EWD 447, 1974; Selected Writings on Computing, 1982)
1. Single Responsibility Principle (SRP, 단일 책임 원칙)
제안자: Robert C. Martin
정의
-"한 클래스는 하나의 책임만 가져야 한다." 즉, 클래스는 단 하나의 이유로만 변경되어야 한다.
의미
-클래스가 여러 역할을 맡으면, 한 역할의 변경이 다른 역할에 영향을 미쳐 경직성과 취약성이 증가한다.
-책임을 분리하면 코드가 단순해지고 유지보수가 쉬워진다
예시
BadExample
using System;
using System.Data.SqlClient;
public class Employee_BadExample // 클래스명으로 BadExample임을 명시
{
public string Name { get; set; }
public double BaseSalary { get; set; }
public string Department { get; set; }
private SqlConnection dbConnection; // Employee 클래스가 데이터베이스 연결까지 담당하고 있다
public Employee_BadExample(string name, double baseSalary, string department, SqlConnection dbConnection)
{
Name = name;
BaseSalary = baseSalary;
Department = department;
this.dbConnection = dbConnection; // Employee 클래스가 데이터베이스 연결 객체를 받는다
}
public double CalculateSalary()
{
// 급여 계산 메서드 (부서마다 다른 보너스 비율을 가정)
double bonusRate = 0;
if (Department == "Sales")
{
bonusRate = 0.1;
}
else if (Department == "Marketing")
{
bonusRate = 0.05;
}
return BaseSalary * (1 + bonusRate);
}
public void SaveToDatabase()
{
// 직원 정보를 데이터베이스에 저장하는 메서드
double salary = CalculateSalary(); // 급여 계산 로직이 Employee 내부에 있다
try
{
dbConnection.Open();
SqlCommand command = new SqlCommand(
"INSERT INTO Employees (Name, Salary, Department) VALUES (@Name, @Salary, @Department)",
dbConnection);
command.Parameters.AddWithValue("@Name", Name);
command.Parameters.AddWithValue("@Salary", salary);
command.Parameters.AddWithValue("@Department", Department);
command.ExecuteNonQuery();
}
catch (Exception ex)
{
Console.WriteLine("데이터베이스 저장 오류: " + ex.Message);
}
finally
{
dbConnection.Close();
}
}
}
public class BadExample_Program // BadExample을 사용하는 메인 프로그램 클래스
{
public static void Main(string[] args)
{
// 잘못된 예시: Employee_BadExample 클래스가 너무 많은 책임을 지고 있다
SqlConnection dbConn = null; // 실제 데이터베이스 연결 객체로 교체 필요 (여기서는 null로 대체)
Employee_BadExample employee = new Employee_BadExample("홍길동", 3000000, "Sales", dbConn);
employee.SaveToDatabase(); // Employee_BadExample이 급여 계산과 데이터베이스 저장을 모두 담당한다
}
}잘못된 경우: Employee 클래스가 급여 계산과 데이터베이스 저장을 모두 처리 ->급여 로직이 바뀌면 DB 코드도 영향을 받음.
GoodExample
using System;
using System.Data.SqlClient;
public class Employee // Employee 클래스는 데이터만 담당한다
{
public string Name { get; set; }
public double BaseSalary { get; set; }
public string Department { get; set; }
public Employee(string name, double baseSalary, string department)
{
Name = name;
BaseSalary = baseSalary;
Department = department;
}
}
public class SalaryCalculator // 급여 계산을 담당하는 클래스
{
public double CalculateSalary(Employee employee) // Employee 객체를 매개변수로 받는다
{
// 급여 계산 메서드
double bonusRate = 0;
if (employee.Department == "Sales")
{
bonusRate = 0.1;
}
else if (employee.Department == "Marketing")
{
bonusRate = 0.05;
}
return employee.BaseSalary * (1 + bonusRate);
}
}
public class EmployeeRepository // 데이터베이스 저장을 담당하는 클래스
{
private SqlConnection dbConnection;
public EmployeeRepository(SqlConnection dbConnection)
{
this.dbConnection = dbConnection;
}
public void Save(Employee employee, double salary) // Employee 객체와 계산된 급여를 받는다
{
// 직원 정보를 데이터베이스에 저장하는 메서드
try
{
dbConnection.Open();
SqlCommand command = new SqlCommand(
"INSERT INTO Employees (Name, Salary, Department) VALUES (@Name, @Salary, @Department)",
dbConnection);
command.Parameters.AddWithValue("@Name", employee.Name);
command.Parameters.AddWithValue("@Salary", salary);
command.Parameters.AddWithValue("@Department", employee.Department);
command.ExecuteNonQuery();
}
catch (Exception ex)
{
Console.WriteLine("데이터베이스 저장 오류: " + ex.Message);
}
finally
{
dbConnection.Close();
}
}
}
public class GoodExample_Program // GoodExample을 사용하는 메인 프로그램 클래스
{
public static void Main(string[] args)
{
// 올바른 예시: 각 클래스가 단일 책임만 진다
SqlConnection dbConn = null; // 실제 데이터베이스 연결 객체로 교체 필요
Employee employee = new Employee("김철수", 3500000, "Marketing");
SalaryCalculator calculator = new SalaryCalculator(); // 급여 계산 담당 객체 생성
double salary = calculator.CalculateSalary(employee);
EmployeeRepository repository = new EmployeeRepository(dbConn); // 데이터베이스 저장 담당 객체 생성
repository.Save(employee, salary);
}
}올바른 경우: SalaryCalculator와 EmployeeRepository로 분리.
장점: 코드의 응집도(Cohesion)가 높아지고, 결합도(Coupling)가 낮아짐.
연관 설계 냄새: 경직성, 점성 완화.

(로버트 C 마틴 블로그에서 SRP 설명)
역사적으로는 로버트 C 마틴 블로그에 따르면 데이비드 L 파르나스의 모듈 분해와 다익스트라의 관심사 분리라는 용어를 시작으로
결합과 응집의 개념이 프로그래밍 커뮤니티에서 퍼져있을때, 각 개념들을 추려내 종합했다고 나와있다.
로버트 C 마틴 블로그에 말해지듯이 SRP는 사람에 관한 것이다.
현실적으로 소프트 웨어는 기업과 조직의 요구사항에 따라 변화하기때문에, 각 모듈이 비즈니스 기능만 담당하게 만들어서,
그 기능을 변경하는 팀이 누군지 쉽게 알기 위해서 만들어진 것이다.

2. Open/Closed Principle (OCP, 개방-폐쇄 원칙)
제안자: Bertrand Meyer
정의
-"소프트웨어 개체(클래스, 모듈 등)는 확장에는 열려 있고, 수정에는 닫혀 있어야 한다."
의미
-기존 코드를 수정하지 않고 새로운 기능을 추가할 수 있어야 한다.
-추상화(인터페이스, 추상 클래스)와 다형성을 활용해 구현한다.
다만 OCP는 시대에 따라 해석이 달라졌다는 점을 짚고 넘어갈 필요가 있다. Meyer가 Object-Oriented Software Construction(1988) 제2장에서 OCP를 처음 제안했을 때, 그가 상정한 구현 수단은 상속(Inheritance)이었다. 부모 클래스를 수정하지 않고 자식 클래스에서 확장하는 방식이다. 이후 마틴은 이 원칙을 인터페이스와 다형성 기반으로 재해석했다. 구체적인 클래스 대신 추상화에 의존하게 만들어, 새로운 구현체를 추가하는 것만으로 기능을 확장할 수 있게 한 것이다.
아래 GoodExample의 코드가 인터페이스를 사용하는 이유가 바로 여기에 있다.
예시
BadExample
using System;
public class PaymentProcessor_BadExample // 클래스명으로 BadExample임을 명시
{
public void ProcessPayment(string paymentMethod, double amount)
{
// 결제 방식에 따라 처리하는 메서드 (if-else 문을 대량으로 사용)
if (paymentMethod == "Card")
{
// 신용카드 결제 처리 로직
Console.WriteLine($"신용카드로 {amount}원 결제");
}
else if (paymentMethod == "Cash")
{
// 현금 결제 처리 로직
Console.WriteLine($"현금으로 {amount}원 결제");
}
else if (paymentMethod == "MobilePay") // 새로운 결제 방식 추가 시 코드를 수정해야 한다
{
// 모바일 결제 처리 로직
Console.WriteLine($"모바일 결제로 {amount}원 결제");
}
else
{
Console.WriteLine("지원하지 않는 결제 방식");
}
}
}
public class BadExample_Program // BadExample을 사용하는 메인 프로그램 클래스
{
public static void Main(string[] args)
{
// 잘못된 예시: PaymentProcessor_BadExample은 OCP를 위반하며, 쉽게 확장할 수 없다
PaymentProcessor_BadExample processor = new PaymentProcessor_BadExample();
processor.ProcessPayment("Card", 10000);
processor.ProcessPayment("Cash", 5000);
processor.ProcessPayment("MobilePay", 7000); // 새로운 결제 방식 사용
}
}잘못된 경우: PaymentProcessor 클래스에 새로운 결제 방식(카드, 현금)을 추가할 때마다 if-else 조건을 수정.
GoodExample
using System;
// IPayment 인터페이스: 모든 결제 방식에 적용되는 공통 인터페이스
public interface IPayment
{
void ProcessPayment(double amount);
}
// 신용카드 결제 클래스 (IPayment 구현)
public class CardPayment : IPayment
{
public void ProcessPayment(double amount)
{
Console.WriteLine($"[신용카드 결제] {amount:N0}원 결제 완료");
}
}
// 현금 결제 클래스 (IPayment 구현)
public class CashPayment : IPayment
{
public void ProcessPayment(double amount)
{
Console.WriteLine($"[현금 결제] {amount:N0}원 결제 완료");
}
}
// 모바일 결제 클래스 (IPayment 구현)
public class MobilePayPayment : IPayment
{
public void ProcessPayment(double amount)
{
Console.WriteLine($"[모바일 결제] {amount:N0}원 결제 완료");
}
}
// 결제 처리 클래스: OCP를 준수한다
public class PaymentProcessor
{
private readonly IPayment paymentMethod;
// 생성자를 통해 결제 방식을 주입한다 (의존성 주입 DI 적용 가능)
public PaymentProcessor(IPayment paymentMethod)
{
this.paymentMethod = paymentMethod ?? throw new ArgumentNullException(nameof(paymentMethod));
}
public void Process(double amount)
{
paymentMethod.ProcessPayment(amount);
}
}
public class Program
{
public static void Main()
{
// 각 결제 방식 객체 생성
var cardPayment = new CardPayment();
var cashPayment = new CashPayment();
var mobilePayPayment = new MobilePayPayment();
// OCP 준수: 새로운 결제 방식 추가 시 PaymentProcessor 코드를 수정할 필요가 없다
var processor1 = new PaymentProcessor(cardPayment);
processor1.Process(10000);
var processor2 = new PaymentProcessor(cashPayment);
processor2.Process(5000);
var processor3 = new PaymentProcessor(mobilePayPayment);
processor3.Process(7000);
}
}올바른 경우: IPayment 인터페이스를 만들고, CardPayment, CashPayment 클래스로 확장.
장점: 기존 코드의 안정성을 유지하며 새로운 요구사항에 유연하게 대응.
연관 설계 냄새: 점성 완화

참고로 자세한 설명을 보고 싶으면 "Object-Oriented Software Construction" (1988)에서 5가지 원칙 부분에 자세한 설명이 있다.
개방-폐쇄 원칙(OCP)은 책의 파트 B 3장 "Modularity"에 나타나는데, 더 설명이 필요한 독자 제언은 여기서 찾길 바란다.

(Data Abstraction and Hierarchy) (1987, OOPSLA 컨퍼런스)
3. Liskov Substitution Principle (LSP, 리스코프 치환 원칙)
제안자: 바바라 리스코프 (Barbara Liskov)
정의
-"자식 클래스는 부모 클래스의 동작을 방해하지 않고 대체 가능해야 한다."
-즉, 프로그램에서 부모 타입을 자식 타입으로 교체해도 문제없이 작동해야 한다.
의미
-상속 관계에서 자식 클래스가 부모 클래스의 계약(Contract)을 위반하면 안된다.
-다형성을 안전하게 사용하기 위한 원칙
예시
public class Bird
{
public virtual void Fly() => Console.WriteLine("새가 날고 있다.");
}
public class Penguin : Bird
{
public override void Fly() // 펭귄은 날 수 없다
{
throw new NotImplementedException("펭귄은 날 수 없다.");
}
}
public class Program
{
public static void MakeBirdFly(Bird bird)
{
bird.Fly(); // Penguin 객체가 전달되면 예외가 발생한다
}
static void Main()
{
Bird myBird = new Penguin();
MakeBirdFly(myBird); // 프로그램이 중단될 수 있다
}
}잘못된 경우: Bird 클래스의 Fly() 메서드가 있고, Penguin 자식 클래스가 이를 무시하거나 예외를 던짐.
GoodExample
// Bird를 추상화하여 직접 사용하지 않는다
public abstract class Bird { }
// 비행 인터페이스 정의
public interface IFlyable
{
void Fly();
}
// 참새 클래스 (IFlyable 인터페이스 구현)
public class Sparrow : Bird, IFlyable
{
public void Fly() => Console.WriteLine("참새가 날고 있다.");
}
// 펭귄 클래스 (IFlyable 인터페이스를 구현하지 않아 날 수 없음을 표현한다)
public class Penguin : Bird { }
public class Program
{
public static void MakeBirdFly(IFlyable bird)
{
bird.Fly();
}
static void Main()
{
IFlyable sparrow = new Sparrow();
MakeBirdFly(sparrow); // 정상 실행
}
}올바른 경우: FlyingBird(날 수 있는 새)와 WalkingBird(걸을 수만 있는 새)로 분리하기 위해, 난다는 기능을 분리해서 Penguin은 Fly()를 강요받지 않게해야함
장점:상속 구조의 안정성과 예측 가능성 확보.
연관 설계 냄새: 취약성 완화.
1987년 OOPSLA 컨퍼런스에서 발표된 논문에서 파생된 것으로,
논문에서 데이터 추상화와 계층구조를 다루면서 객체 지향에서 올바른 '상속(Inheritance)'이 무엇인가? 에 대한 철학적 실무적 가이드라인을 제시한다.
여기서 데이터 추상화를
프로그램에서 데이터의 내부 구현을 숨기고, 인터페이스를 통해서만 접근하도록 하는 것을 지칭한다.
결국 따지고보면 현실의 문제를 추상화할때, 얼마나 잘 추상화 시키느냐의 문제이다.
포유류를 정의할때 발이 달렸다라고 정의해버리면 고래를 포유류라고 칭하기 어려워지듯,
어떤 문제에 대한 추상화를 어떻게 잘 시키느냐에 대한 문제라고 이해함이 옳다.
4. Interface Segregation Principle (ISP, 인터페이스 분리 원칙)
제안자: 로버트 C. 마틴 (Robert C. Martin)
정의
-"클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다."
-즉, 인터페이스를 작고 구체적으로 분리해야 한다는 것
의미
큰 범용 인터페이스 대신, 클라이언트가 필요로 하는 기능만 제공하는 인터페이스를 설계한다.
불필요한 의존성을 제거해 결합도를 낮춤
예시
BadExample
using System;
// IWorker_BadExample 인터페이스: 너무 많은 기능을 포함하고 있다 (ISP 위반)
public interface IWorker_BadExample
{
void Work(); // 작업 기능
void Eat(); // 식사 기능 - 하지만 Robot에게는 불필요하다
}
// Robot_BadExample 클래스: IWorker_BadExample을 구현하므로 불필요한 Eat() 메서드를 강제로 구현해야 한다
public class Robot_BadExample : IWorker_BadExample
{
public void Work()
{
Console.WriteLine("로봇이 열심히 작업하고 있다.");
}
public void Eat() // 로봇은 식사가 필요 없지만 구현해야만 한다
{
// 로봇은 식사를 하지 않으므로 아무것도 하지 않거나 예외를 던질 수밖에 없다
Console.WriteLine("로봇은 식사를 할 수 없다."); // 또는 throw new NotImplementedException();
}
}
// HumanWorker_BadExample 클래스: IWorker_BadExample을 구현하며 Work()와 Eat()을 올바르게 구현한다
public class HumanWorker_BadExample : IWorker_BadExample
{
public void Work()
{
Console.WriteLine("인간이 열심히 작업하고 있다.");
}
public void Eat()
{
Console.WriteLine("인간이 점심을 먹고 있다.");
}
}
public class BadExample_Program // BadExample을 사용하는 프로그램 클래스
{
public static void Main(string[] args)
{
// 잘못된 예시: Robot_BadExample은 Eat() 메서드를 가져서는 안 된다
IWorker_BadExample robot = new Robot_BadExample();
robot.Work();
robot.Eat(); // 로봇이 Eat() 메서드를 호출하는 것은 어색하다
IWorker_BadExample human = new HumanWorker_BadExample();
human.Work();
human.Eat();
}
}잘못된 경우: IWorker 인터페이스에 Work()와 Eat()가 모두 포함되어, Robot 클래스가 불필요한 Eat()을 구현해야 함.
GoodExample
using System;
// IWorkable 인터페이스: 작업 기능만 포함한다 (ISP 준수)
public interface IWorkable
{
void Work(); // 작업 기능
}
// IEatable 인터페이스: 식사 기능만 포함한다 (ISP 준수)
public interface IEatable
{
void Eat(); // 식사 기능
}
// Robot_GoodExample 클래스: IWorkable 인터페이스만 구현한다 (필요한 기능만 구현)
public class Robot_GoodExample : IWorkable // 로봇은 작업만 할 수 있다
{
public void Work()
{
Console.WriteLine("로봇이 효율적으로 작업을 수행한다.");
}
// Eat() 미구현: 로봇은 식사가 필요 없다
}
// HumanWorker_GoodExample 클래스: IWorkable과 IEatable 인터페이스를 모두 구현한다 (필요한 기능을 모두 보유)
public class HumanWorker_GoodExample : IWorkable, IEatable // 인간은 작업도 하고 식사도 할 수 있다
{
public void Work()
{
Console.WriteLine("인간이 창의적으로 작업한다.");
}
public void Eat()
{
Console.WriteLine("인간이 맛있는 점심을 즐기고 있다.");
}
}
public class GoodExample_Program // GoodExample을 사용하는 프로그램 클래스
{
public static void Main(string[] args)
{
// 올바른 예시: Robot_GoodExample은 IWorkable만 구현하면 된다
IWorkable robot = new Robot_GoodExample(); // 로봇은 IWorkable 타입으로만 사용된다
robot.Work();
// robot.Eat(); // Robot은 IEatable을 구현하지 않으므로 Eat() 호출 불가 (컴파일 오류)
IWorkable humanWorker = new HumanWorker_GoodExample(); // HumanWorker는 IWorkable 타입으로 사용 가능
humanWorker.Work();
IEatable humanEater = new HumanWorker_GoodExample(); // HumanWorker는 IEatable 타입으로도 사용 가능
humanEater.Eat();
}
}올바른 경우: IWorkable과 IEatable로 분리해 Robot은 IWorkable만 구현.
장점: 코드의 유연성과 재사용성 증가.
연관 설계 냄새: 취약성, 점성 완화.

(1996년 로버트 C 마틴의 에세이에서 나온 DIP)
5. Dependency Inversion Principle (DIP, 의존 역전 원칙)
제안자: 로버트 C. 마틴 (Robert C. Martin)
정의
-"상위 모듈은 하위 모듈에 의존하지 않아야 하며, 둘 다 추상화에 의존해야 한다."
-또한, "구체적인 것에 의존하지 말고 추상적인 것에 의존하라."
의미
-모듈 간 의존성을 줄이기 위해 인터페이스나 추상 클래스를 중간에 둠
-의존성 주입(Dependency Injection)을 통해 구현됨
예시
BadExample
using System;
// SqlDatabase 클래스: 구체적인 데이터베이스 구현체 (저수준 모듈, 특정 데이터베이스에 직접 의존한다)
public class SqlDatabase_BadExample
{
public void Save(string data)
{
// 실제로 SqlDatabase에 데이터를 저장하는 로직 (구현 생략)
Console.WriteLine($"SqlDatabase에 데이터 저장 완료: {data}");
}
}
// OrderService_BadExample 클래스: SqlDatabase에 직접 의존한다 (DIP 위반, 고수준 모듈)
public class OrderService_BadExample
{
private SqlDatabase_BadExample database; // 구체적인 SqlDatabase 클래스에 직접 의존한다
public OrderService_BadExample()
{
database = new SqlDatabase_BadExample(); // OrderService가 SqlDatabase 인스턴스를 직접 생성한다
}
public void PlaceOrder(string orderData)
{
// 주문 처리 로직 (여기서는 단순히 데이터를 저장한다)
Console.WriteLine($"주문 처리 중: {orderData}");
database.Save(orderData); // OrderService가 SqlDatabase의 Save() 메서드를 직접 호출한다
Console.WriteLine("주문 처리 완료");
}
}
public class BadExample_Program // BadExample을 사용하는 프로그램 클래스
{
public static void Main(string[] args)
{
// 잘못된 예시: OrderService_BadExample이 SqlDatabase와 강하게 결합되어 있어 확장이 어렵다
OrderService_BadExample service = new OrderService_BadExample();
service.PlaceOrder("고객: 홍길동, 상품: 노트북");
}
}잘못된 경우: OrderService가 직접 SqlDatabase에 의존 -> DB를 교체하려면 코드 수정 필요.
GoodExample
using System;
// DIP(의존 역전 원칙) 준수: OrderService는 IDatabase 인터페이스에만 의존한다
public interface IDatabase
{
void SaveOrder(string orderDetails);
}
// SqlDatabase가 IDatabase 인터페이스를 구현한다
public class SqlDatabase : IDatabase
{
public void SaveOrder(string orderDetails)
{
Console.WriteLine($"[SqlDatabase] 주문 저장 완료: {orderDetails}");
}
}
// MongoDatabase가 IDatabase 인터페이스를 구현한다 (새로운 데이터베이스 타입을 추가할 수 있다)
public class MongoDatabase : IDatabase
{
public void SaveOrder(string orderDetails)
{
Console.WriteLine($"[MongoDatabase] 주문 저장 완료: {orderDetails}");
}
}
// OrderService는 구체적인 구현체가 아닌 인터페이스(IDatabase)에 의존한다
public class OrderService
{
private readonly IDatabase database;
// DIP(의존 역전 원칙): OrderService는 구체적인 구현체가 아닌 인터페이스에 의존한다
public OrderService(IDatabase database)
{
this.database = database; // 의존성 주입 (Dependency Injection)
}
public void PlaceOrder(string orderDetails)
{
database.SaveOrder(orderDetails); // 인터페이스를 통해 주문을 저장한다
}
}
// OrderService가 특정 데이터베이스에 의존하지 않으므로 데이터베이스를 쉽게 교체할 수 있다
public class Program
{
public static void Main()
{
// DI 컨테이너 없이 객체를 직접 생성하여 의존성을 주입한다
IDatabase sqlDatabase = new SqlDatabase();
IDatabase mongoDatabase = new MongoDatabase();
// SqlDatabase를 사용하는 OrderService
OrderService orderService1 = new OrderService(sqlDatabase);
orderService1.PlaceOrder("상품 A 주문");
// MongoDatabase를 사용하는 OrderService
OrderService orderService2 = new OrderService(mongoDatabase);
orderService2.PlaceOrder("상품 B 주문");
}
}올바른 경우: IDatabase 인터페이스를 만들고 OrderService가 이를 의존, SqlDatabase는 구현체로 제공.
장점: 시스템의 유연성과 테스트 용이성 증가.
연관 설계 냄새: 경직성, 부동성 완화.
SOLID 원칙의 전체적 의의는
유지보수성과 확장성을 높여 유연하게 대응하는 소프트웨어를 만드는 것이다.
우리가 앞서 이야기 한 이야기를 한눈에 보기 좋게 정리해서 요약하면 다음과 같다.
S - 단일 책임 원칙 (SRP) | 한 클래스는 하나의 책임만 가져야 한다. | Robert C. Martin(2002) |
O - 개방-폐쇄 원칙 (OCP) | 기존 코드를 변경하지 않고 확장할 수 있어야 한다. | Bertrand Meyer (1988) |
L - 리스코프 치환 원칙 (LSP) | 하위 클래스는 상위 클래스를 대체할 수 있어야 한다. | Barbara Liskov (1987) |
I - 인터페이스 분리 원칙 (ISP) | 클라이언트가 사용하지 않는 인터페이스에 의존하면 안 된다. | Robert C. Martin(2002) |
D - 의존 역전 원칙 (DIP) | 고수준 모듈은 저수준 모듈에 의존하면 안 된다. | Robert C. Martin(1996) |
하지만 SOLID 원칙이 반드시 정답일까?
SOLID 원칙이 무조건 적인 정답이라고 할 수 있을까?
당연히 아니다. SOLID는 가이드라인일뿐이다.

가령 현재 게임 엔진 중에서 가장 널리 쓰이는 언리얼 엔진의 Actor는
물리,충돌,조명,메시 렌더링, 등등 수 많은 것들을 작업한다
즉 단일 클래스에서 너무 많은 작업을 한다. 그렇다면 이걸 분리하면 과연 편해질까? 전혀 아니다. 오히려 나누는 비용대비 효과가 힘들다.
Actor는 언리얼 엔진의 핵심적 기반 클래스이고 SRP를 나누도록 분리한다면 엔진 전체에 영향을 끼친다.
그렇다면 이게 잘못된 설계일까? 아니다.
게임은 Object를 위주로 설계하기때문에 이 단위에서 충분히 납득가는 설계 이유이다.
오히려 저걸 나누어 버린다면 객체별로 다시 조합(컴포지트)하는데 너무나 많은 비용이 든다.
LSP도 그렇다. GUI 프레임워크에서 Button이 Widget을 상속받을때, 반드시 기존 부모의 버튼 동작을 보장해야하는데,
이러면 오히려 현실적으로 문제가 된다. 이렇게 될 경우 버튼 디자인을 다양하게 설계해야할때, 디자인적 독창성이 깨질 수가 있다.
이때문에 이러한 예외를 많은 GUI 프레임워크에서는 보장하고 있다. (C++의 QT 프레임워크, GTK 등등)
그리고 또한 인터페이스에서 오는 오버헤드 등으로 OOP자체가 아닌 DOP등의 다른 지향을 선택해야하는 것이 맞고.
SOLID는 OOP 설계에 맞는 좋은 가이드라인이지만, OOP가 가질 수 있는 성능적 한계, 그리고 설계적 맥락에 의해서 명확하게 어길때를 알아야하기도 하다
SOLID를 어길만한 순간은 어떻게 정해야할까?
SRP: 강한 응집력을 가진 기능들인 경우, SRP를 엄격하게 지키면 클래스간 메서드 호출이 빈번해져 성능 오버헤드가 발생할 수 있다.
OCP: 확장이 빈번하면 좋지만, 성능이 우선이면 수정이 더 나을 수도 있다.
LSP: 상속 구조가 단순할 땐 강요할 필요 없다
ISP: 인터페이스가 너무 많아지면 오히려 혼란스럽다
DIP: 추상화가 오버헤드를 만들면 구체 의존이 더 낫다.

당신의 코드를 유지보수할 사람이 당신의 집 주소를 알고 있는 폭력적인 사이코패스라고 생각하고 코딩하라.- John F Woods(1991)
마치며
SOLID 원칙은 애자일 철학에서 태어나 유지보수성과 확장성을 위한 강력한 도구로 자리잡았지만,
그것이 모든 상황의 '은 탄환(Silver Bullet)'은 아니다.
언리얼 엔진의 Actor처럼 도메인 특성이 강한 경우나, GUI 프레임워크에서 창의성을 발휘해야 할 때, 또는 성능이 생명인 시스템에선 SOLID를 억지로 따르기보다 맥락에 맞는 설계를 선택하는 게 더 중요하다. 물론 그 경계를 알아채는 것은 프로그래머의 경험이지만 말이다.
John F. Woods라는 C++ 프로그래머가 1991년 말한,
"당신의 코드를 유지보수할 사람이 폭력적인 사이코패스라고 생각하라"는 격언은 단순히 가독성 높은 코드를 쓰라는 경고가 아니다.
그 뒤에 숨은 뜻은, 코드를 다루는 사람이 어떤 상황에서도 쉽게 이해하고 적응할 수 있게 하라는 것이다.
SOLID는 그 목표를 위한 하나의 길일 뿐, 유일한 길은 아니다. 때론 SRP를 어기고 통합된 클래스를 유지하거나, DIP를 무시하고 구체 의존을 선택하는 것이 그 "사이코패스"가 폭력을 휘두르지 않게 만드는 실용적인 선택일 수도 있다.