SOLID 원칙으로 객체지향 설계의 변경 비용 낮추기
SOLID 원칙의 책임 분리, 확장 설계, 상속 계약, 인터페이스 분리, 의존성 역전 방식을 객체지향 코드 예제로 정리합니다.
2026-08-15 · 최초 발행 2025-05-23
객체지향 코드의 품질은 기능을 처음 구현할 때보다 변경 요구가 쌓일 때 드러난다. SOLID는 유지보수와 확장을 고려해 클래스와 모듈의 경계를 잡는 데 쓰는 설계 지침이다.
변경에 강한 객체지향 설계의 기준
SOLID는 로버트 C. 마틴(Uncle Bob)이 2000년대 초반 정립한 객체지향 설계 원칙을 묶어 부르는 이름이다. 소프트웨어가 복잡해지며 나타나는 경직성(Rigidity), 취약성(Fragility), 부동성(Immobility)을 다루기 위한 이 원칙들은 코드의 가독성, 확장성, 리팩토링 가능성을 높이고 코드 스멜을 줄이는 방향을 제시한다.
- Single Responsibility Principle: 단일 책임 원칙
- Open/Closed Principle: 개방/폐쇄 원칙
- Liskov Substitution Principle: 리스코프 치환 원칙
- Interface Segregation Principle: 인터페이스 분리 원칙
- Dependency Inversion Principle: 의존성 역전 원칙
책임이 섞인 클래스를 나누는 SRP
단일 책임 원칙은 한 클래스가 바뀌는 이유를 하나로 두라는 뜻이다. 급여 계산, 저장, 출력처럼 서로 다른 변경 요인을 한 클래스에 넣으면 수정 범위가 불필요하게 넓어진다.
// SRP 위반
public class Employee {
public void calculatePay() { /* ... */ }
public void saveEmployee() { /* ... */ }
public void printTimesheet() { /* ... */ }
}
// SRP 준수
public class Employee {
private String name;
private int id;
// 기타 필드 및 getters/setters
}
public class PayCalculator {
public void calculatePay(Employee employee) { /* ... */ }
}
public class EmployeeRepository {
public void save(Employee employee) { /* ... */ }
}
public class TimesheetPrinter {
public void print(Employee employee) { /* ... */ }
}
책임을 분리하면 코드 응집도가 높아지고, 테스트와 유지보수가 단순해진다. 클래스 사이의 결합도도 낮아진다.
확장을 위해 기존 코드를 건드리지 않는 OCP
개방/폐쇄 원칙은 클래스, 모듈, 함수 같은 소프트웨어 엔티티가 확장에는 열려 있고 수정에는 닫혀 있어야 한다는 원칙이다. 새 기능을 넣기 위해 기존 분기문을 계속 수정하는 구조를 피하는 것이 핵심이다.
// OCP 위반
public class Rectangle {
public double width;
public double height;
}
public class AreaCalculator {
public double calculateArea(Object shape) {
if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape;
return rectangle.width * rectangle.height;
} else if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * circle.radius * circle.radius;
}
return 0;
}
}
// OCP 준수
public interface Shape {
double calculateArea();
}
public class Rectangle implements Shape {
private double width;
private double height;
@Override
public double calculateArea() {
return width * height;
}
}
public class Circle implements Shape {
private double radius;
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
// 새로운 도형을 추가해도 AreaCalculator 클래스를 수정할 필요 없음
public class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
}
인터페이스와 다형성을 이용하면 새 도형을 추가해도 AreaCalculator를 수정하지 않아도 된다. 변경 위험을 낮추고 기존 코드를 재사용하는 데도 유리하다.
상속 관계가 지켜야 할 계약, LSP
리스코프 치환 원칙은 상위 타입을 사용하는 위치에 하위 타입을 넣어도 프로그램의 정확성이 깨지지 않아야 한다는 요구다. 자식 클래스는 부모 클래스가 제공한 기능과 계약을 호출자가 예측 가능한 방식으로 수행해야 한다.
// LSP 위반
public 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 width * height;
}
}
public 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;
}
}
// LSP 준수
public interface Shape {
int getArea();
}
public class Rectangle implements Shape {
private int width;
private int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
}
public class Square implements Shape {
private int side;
public void setSide(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
}
이 원칙을 지키면 다형성을 올바르게 활용할 수 있고, 상속 계층의 동작을 예측하기 쉬워진다. 코드 재사용성과 테스트 용이성도 함께 높아진다.
클라이언트에 맞춰 인터페이스를 쪼개는 ISP
인터페이스 분리 원칙은 클라이언트가 사용하지 않는 메서드에 의존하게 해서는 안 된다는 원칙이다. 하나의 큰 인터페이스보다, 사용하는 기능에 맞춘 인터페이스가 구현 클래스의 불필요한 부담을 덜어 준다.
// ISP 위반
public interface Worker {
void work();
void eat();
void sleep();
}
public class Robot implements Worker {
@Override
public void work() {
// 작업 수행
}
@Override
public void eat() {
// 로봇은 먹지 않음 - 의미 없는 구현
throw new UnsupportedOperationException();
}
@Override
public void sleep() {
// 로봇은 자지 않음 - 의미 없는 구현
throw new UnsupportedOperationException();
}
}
// ISP 준수
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
public class HumanWorker implements Workable, Eatable, Sleepable {
@Override
public void work() {
// 작업 수행
}
@Override
public void eat() {
// 식사
}
@Override
public void sleep() {
// 수면
}
}
public class Robot implements Workable {
@Override
public void work() {
// 작업 수행
}
}
클라이언트 특화 인터페이스는 의존성을 줄이고 인터페이스의 목적을 분명하게 만든다. 구현 클래스도 자신에게 필요한 계약만 따르면 된다.
구현이 아니라 추상화에 의존하는 DIP
의존성 역전 원칙은 상위 수준 모듈과 하위 수준 모듈이 서로의 구체 구현이 아니라 추상화에 의존해야 한다는 원칙이다. 상위 모듈이 특정 서비스 객체를 직접 생성하면 구현 교체와 테스트가 어려워진다.
// DIP 위반
public class EmailService {
public void sendEmail(String message) {
// 이메일 전송 로직
}
}
public class Notification {
private EmailService emailService;
public Notification() {
this.emailService = new EmailService();
}
public void send(String message) {
emailService.sendEmail(message);
}
}
// DIP 준수
public interface MessageService {
void sendMessage(String message);
}
public class EmailService implements MessageService {
@Override
public void sendMessage(String message) {
// 이메일 전송 로직
}
}
public class SMSService implements MessageService {
@Override
public void sendMessage(String message) {
// SMS 전송 로직
}
}
public class Notification {
private MessageService messageService;
// 의존성 주입
public Notification(MessageService messageService) {
this.messageService = messageService;
}
public void send(String message) {
messageService.sendMessage(message);
}
}
추상화는 고수준 정책과 저수준 구현 사이에 공통 계약을 두어 설계 구조를 연결한다. 구현체는 그 계약을 따르므로 정책을 결정하는 모듈은 구체적인 서비스나 저장소의 세부 사항을 직접 알 필요가 없다. 이 연결 방식은 구현 교체가 정책 결정에 미치는 영향을 줄이고, 의존성 주입 패턴을 적용할 기반도 마련한다.
원칙은 서로 분리되어 작동하지 않는다
SOLID의 각 원칙은 독립적인 체크리스트가 아니다. 책임을 나누는 과정은 확장을 위한 구조로 이어지고, 다형성과 인터페이스 설계는 상속 계약 및 추상화 의존과 맞물린다. SRP와 ISP는 객체와 인터페이스의 크기를 조절하고, OCP와 DIP는 확장 가능한 연결을 만들며, LSP는 그 연결 위에서 다형성이 안전하게 동작하도록 받친다.
코드 스멜에서 설계 문제를 찾는 방법
코드 스멜은 더 심각한 문제를 암시하는 코드의 특성이다. SOLID 위반은 다음과 같은 형태로 드러나는 경우가 많다.
| 코드 스멜 | 관련 SOLID 원칙 | 대응 방향 |
|---|---|---|
| 거대 클래스 | SRP | 클래스를 책임에 따라 분리 |
| 변경의 파급 효과 | OCP | 인터페이스 도입, 다형성 활용 |
| 불안정한 하위 클래스 | LSP | 계약 조건 준수, 올바른 상속 관계 설계 |
| 인터페이스 비대화 | ISP | 인터페이스 분리, 클라이언트 특화 인터페이스 |
| 강한 결합 | DIP | 추상화에 의존, 의존성 주입 |
리팩토링 과정에 SOLID를 넣는 방식
기존 코드베이스 전체를 한 번에 바꾸기보다 리팩토링 과정에서 점진적으로 적용하는 편이 현실적이다. 자주 변경되는 코드와 외부 라이브러리와 맞닿는 경계부터 개선하고, 리팩토링 전후에 같은 기능을 보장할 자동화된 테스트를 마련한다. 코드 리뷰 체크리스트에 원칙 준수 여부를 넣는 방법도 있다.
디자인 패턴은 SOLID와 함께 살펴볼 수 있다. 다음 전략 패턴 예시는 OCP와 DIP를 적용한다.
// 실무 적용 예시: 디자인 패턴과 SOLID 원칙
// 전략 패턴 - OCP, DIP 원칙 적용
// 전략 인터페이스
public interface SortStrategy {
void sort(List<Integer> list);
}
// 구체적인 전략 구현
public class QuickSort implements SortStrategy {
@Override
public void sort(List<Integer> list) {
// 퀵 정렬 구현
System.out.println("퀵 정렬로 정렬됨");
}
}
public class MergeSort implements SortStrategy {
@Override
public void sort(List<Integer> list) {
// 병합 정렬 구현
System.out.println("병합 정렬로 정렬됨");
}
}
// 컨텍스트 클래스
public class Sorter {
private SortStrategy strategy;
// 의존성 주입 (DIP)
public Sorter(SortStrategy strategy) {
this.strategy = strategy;
}
// 전략 변경 가능 (OCP)
public void setStrategy(SortStrategy strategy) {
this.strategy = strategy;
}
public void sortList(List<Integer> list) {
strategy.sort(list);
}
}
// 클라이언트 코드
public class Client {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 5, 3, 2, 4);
// 초기 전략 설정
Sorter sorter = new Sorter(new QuickSort());
sorter.sortList(numbers);
// 런타임에 전략 변경 가능
sorter.setStrategy(new MergeSort());
sorter.sortList(numbers);
}
}
SOLID는 코드 가독성, 유지보수 용이성, 시스템 확장성, 버그 발생 가능성, 개발 생산성에 영향을 준다. 모든 상황에 기계적으로 맞추기보다 코드의 변경 맥락과 요구에 맞춰 적용해야 한다.
Sources
- Robert C. Martin, "Agile Software Development, Principles, Patterns, and Practices"
- Martin Fowler, "Refactoring: Improving the Design of Existing Code"
- Gang of Four, "Design Patterns: Elements of Reusable Object-Oriented Software"