01. SOLID 원칙
SOLID 원칙이란 객체 지향 설계에서 유지보수 가능하고 확장 가능한 코드를 만들기 위한 5가지 설계 원칙의 앞 글자를 모은 약어이다. 이러한 SOLID 원칙은 유지보수가 쉬운 코드 설계, 변경에 유연한 설계, 테스트하기 좋은 구조, 협업 시 충돌 최소화에 있어서 막대한 영향을 끼치기에 중요하다.
| 설계 원칙의 종류 | |
| 단일 책임 원칙 (SRP) |
하나의 클래스는 하나의 책임만 가져야 한다 |
| 개방 / 폐쇄 원칙 (OCP) |
확장에는 열려있고, 수정에는 닫혀있어야 한다. |
| 리스코브 치환 원칙 (LSP) |
자식 클래스는 반드시 부모 클래스를 대체할 수 있어야 한다. |
| 인터페이스 분리 원칙 (ISP) |
하나의 범용 인터페이스보다 다수의 구체적인 인터페이스가 낫다. 클라이언트는 사용하지 않는 메서드에 의존하면 안 된다. |
| 의존성 역전 원칙 (DIP) |
구체적인 클래스 보다는 인터페이스나 추상 클래스와 관계를 맺어야 한다. 상위 모듈은 하위 모듈에 의존하면 안 된다. 추상화에 의존해야지, 구체화에 의존하면 안 된다. |
02. 단일 책임 원칙 (Single Responsibillity Principle, SRP)
단일 책임 원칙이란 하나의 클래스는 하나의 책임만 가져야 한다는 객체 지향 설계 원칙이다. 여기서 책임이란 객체의 역할이자 객체의 변경 사유로 해석할 수 있다. 즉, 하나의 클래스는 반드시 하나의 이유로만 변경되어야 함을 의미한다. 이러한 설계 원칙은 유지보수와 확장성을 높이는 가장 기본적인 출발점이다.
| 방법 | |
| 기능별 클래스로 분리 | 하나의 클래스에 여러 역할을 넣지 않기 |
| 관심사의 분리(SoC) | UI, 로직, 저장소 등을 명확히 분리 |
| 하나의 책임만 갖도록 설계 | 변경 원인이 하나뿐인 구조로 작성 |
// 1. 위반 코드
class Report {
String title;
String content;
void saveToFile() {} // 책임 1: 파일 저장
void print() {} // 책임 2: 콘솔 출력
}
// 2. 올바른 코드
class Report {
String title;
String content;
}
class ReportPrinter {
void print(Report report) { ... }
}
class ReportSaver {
void saveToFile(Report report) { ... }
}
03. 개방 / 폐쇄의 원칙 (Open / Closed Principle, OCP)
개방 / 폐쇄 원칙이란 확장에는 열려있고, 수정에는 닫혀 있어야 한다는 객체 지향 설계 원칙 중 하나이다. 즉, 기존 코드는 변경하지 않고 새로운 기능은 확장을 통해 추가할 수 있도록 설계하라는 뜻이다. 이러한 설계 원칙은 새로운 요구 사항이 생길 때마다 기존 클래스를 수정하는 오류의 위험을 줄여줄 뿐만 아니라 유지보수에 용이하다는 장점이 있다.
이러한 개방 / 폐쇄의 원칙은 전략 패턴(Strategyt Pattern)을 통해 구현하는데, 전략 패턴이란 행위를 별도의 클래스에 캡슐화하여 객체에 주입함으로써 동작을 유연하게 변경할 수 있도록 하는 대표적인 OOP 설계 방법이다.
// 1. 위반 코드
class PaymentService {
// 결제 수단 종류에 따라 코드를 계속해서 수정해야 함
void pay(String method) {
if (method.equals("card")) {
System.out.println("카드 결제");
} else if (method.equals("kakao")) {
System.out.println("카카오페이");
}
}
}
// 2. 올바른 코드
interface PayStrategy {
void pay();
}
class CardPay implements PayStrategy {
public void pay() {
System.out.println("카드 결제");
}
}
class KakaoPay implements PayStrategy {
public void pay() {
System.out.println("카카오페이");
}
}
class PaymentService {
private PayStrategy payStrategy;
public PaymentService(PayStrategy payStrategy) {
this.payStrategy = payStrategy;
}
public void processPayment() {
payStrategy.pay(); // 구현체에 따라 동작이 바뀜
}
}
/* 전략 패턴 에제 */
// Strategy 인터페이스: 공통 알고리즘 정의
public interface PaymentStrategy {
void pay(int amount);
}
// ConcreteStrategy 클래스들: Strategy 인터페이스 구현
public class CreditCardStrategy implements PaymentStrategy {
private String cardNumber;
// 생성자 및 기타 필드 생략
@Override
public void pay(int amount) {
System.out.println(amount + "원을 신용카드로 결제합니다.");
}
}
public class PayPalStrategy implements PaymentStrategy {
private String email;
// 생성자 및 기타 필드 생략
@Override
public void pay(int amount) {
System.out.println(amount + "원을 페이팔로 결제합니다.");
}
}
// Context 클래스: Strategy 객체를 참조하여 알고리즘 실행
public class ShoppingCart {
private PaymentStrategy paymentStrategy;
public void setPaymentStrategy(PaymentStrategy strategy) {
this.paymentStrategy = strategy;
}
public void checkout(int amount) {
paymentStrategy.pay(amount);
}
}
// 사용 예
public class Main {
public static void main(String[] args) {
ShoppingCart cart = new ShoppingCart();
cart.setPaymentStrategy(new CreditCardStrategy(/* 카드 정보 */));
cart.checkout(10000);
cart.setPaymentStrategy(new PayPalStrategy(/* 이메일 */));
cart.checkout(20000);
}
}
04. 리스코브 치환의 원칙 (Liskov's Substitution Principle, LSP)
리스코프 치환 원칙이란 다형성을 전제로 하는 원칙으로, 자식 클래스는 언제나 부모 클래스의 역할을 대체할 수 있어야 한다는 객체 지향 설계 원칙 중 하나이다. 즉, 상위 타입(부모 클래스)으로 하위 타입(자식 클래스)를 사용해도 프로그램의 동작에 문제가 없어야 한다는 뜻이다.
대개 메소드는 입력 조건, 출력 조건, 불변 조건 등을 명확하게 정해놓고 지키는 설계 방식인 계약(Contract)를 통해 정의되는데, 리스코브 치환 원칙은 하위 타입의 클래스는 반드시 상위 타입의 계약을 지켜야 한다는 뜻이다. 즉, 하위 클래스가 메소드를 재정의하더라도 기존 계약을 위반해서는 안 된다.
| 조건 종류 | 사전 조건 (Pre-Condition) |
사후 조건 (Post-Condition) |
| 정의 | 메서드가 실행되기 전에 만족해야 할 조건 | 메소드 실행 후에 반드시 만족해야 하는 결과 |
| 하위 클래스 계약 | 사후 조건을 강화하면 안 됨 | 사후 조건을 약화하면 안 됨 |
| 항목 | |
| 계약 유지 | 자식 클래스는 부모의 약속(사전 / 사후조건)을 깨지 않아야 함 |
| 다형성 보장 | 부모 자리에 자식을 넣어도 동일하게 작동해야 함 |
| 위반 예 | 오버라이딩 시 기능을 제거하거나 조건을 강화하는 경우 |
// 1. 위반 코드
class Bird {
void fly() { System.out.println("날아간다"); }
}
class Ostrich extends Bird { // Ostrich는 Bird이지만 날지 못하게 됨
void fly() { throw new UnsupportedOperationException(); }
}
// 2. 사전 조건 위반 예시
class Payment {
void refund(int amount) {
sout(amout + "만큼 환불이 진행되었습니다.");
}
}
class SecurePayment extends Payment { // amount 범위 제한
void refund(int amount) {
if (amount > 1000) throw new IllegalArgumentException();
sout(amout + "만큼 환불이 진행되었습니다.");
}
}
// 3. 사후 조건 위반 예시
class Service {
String getStatus() { return "OK"; }
}
class FailingService extends Service {
String getStatus() { return null; } // 사후 조건 약화
}
05. 인터페이스 분리의 원칙 (Interface Segregation Principle, ISP)
인터페이스 분리 원칙이란 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 객체 지향 설계 원칙 중 하나이다. 즉, 하나의 커다란 인터페이스보다는 작고 구체적인 인터페이스 여러 개로 나누는 것이 더 유연한 설계라는 뜻이다. 인터페이스의 크기가 크면 사용하지 않는 기능까지 구현해야 해서 불필요한 의존성과 결합이 생기기 때문에, 사용자는 자신이 필요한 기능만 정의된 인터페이스에만 의존해야 유지보수가 쉬워진다.
뿐만 아니라 하나의 인터페이스에 포함된 메소드들은 하나의 목적을 공유해야 한다. 이처럼 같은 목적을 공유하는 인터페이스를 응집도가 높은 인터페이스라고 한다.
| 항목 | |
| ISP 목적 | 불필요한 의존 제거, 작고 명확한 인터페이스 제공 |
| 핵심 설계 방식 | 인터페이스 분리 + 응집도 향상 |
| 효과 | 유연한 구현, 높은 재사용성, 테스트 용이성 |
// 1. 위반 코드
interface MultiFunctionDevice { // 복합기
void print();
void scan();
void fax();
}
// 출력만 하는 프린터에서도 스캔, 팩스 기능을 구현해야 함
class SimplePrinter implements MultiFunctionDevice {
public void print() { ... }
public void scan() { throw new UnsupportedOperationException(); }
public void fax() { throw new UnsupportedOperationException(); }
}
// 2. 올바른 코드
interface Printer {
void print();
}
interface Scanner {
void scan();
}
interface Fax() {
void fax();
}
// 단순 프린터: 출력
class SimplePrinter implements Printer {
public void print() { ... }
}
// 단순 스캐너: 스캔
class SimpleScanner implements Scanner {
public void scan() { ... }
}
// 가정용 복합기: 출력, 스캔
class HomeMultiFunctionalPrinter implements Printer, Scanner {
public void print() { ... }
public void scan() { ... }
}
// 1. 위반 코드
interface Repository {
void save();
void delete();
void sendEmail(); // 저장소는 이메일을 전송할 필요 없음
}
// 2. 올바른 코드
interface Saveable {
void save();
}
interface Deletable {
void delete();
}
06. 의존성 역전의 원칙 (Dependency Inversion Principle, DIP)
의존성 역전 원칙이란 구체적인 클래스에 직접 의존하지 않고 추상화된 인터페이스에 의존해야 한다는 객체 지향 설계의 원칙 중 하나이다. 즉, 고수준 모듈은 저수준 모듈이 아닌 저수준 모듈을 추상화한 인터페이스에 의존해야 한다는 뜻이다. 이러한 의존성 역전 원칙은 의존 방향을 뒤집는 방법을 통해 유연하고 확장 가능한 구조를 설계한다.
이처럼 의존성 주입이란 객체가 의존하는 구현체를 직접 생성하지 않고, 외부로부터 주입 받는 방식을 의미한다. DIP를 실현하기 위해 의존성 주입은 필수적으로 일어나야 한다. 그렇기에 Spring 프레임워크는 이를 자동으로 처리해주는 DI 컨테이너를 제공한다.
| 수준에 따른 모듈 분리 | 예시 | |
| 고수준 모듈 | 핵심 비ㅈ니스 로직 | 주문 서비스, 결제 처리 등 |
| 저수준 모듈 | 세부 구현 로직 | 파일 저장, 데이터베이스 연동 등 |
| 생성자 주입 (Constructor) |
세터 주입 (Setter) |
필드 주입 (Field) |
|
| 주입 시점 | 객체 생성 시 딱 한 번 | 객체 생성 후, 의존성 필요 시 | 객체 생성 후, 스프링이 직접 주입 |
| 변경 가능성 | 불변(Immutable) 유지 가능 | 언제든 변경 가능 (가변) | 외부에서 변경 불가능 |
| 필수 여부 | 필수 의존성 (final 가능) | 선택 의존성 (옵션) | 스프링 없인 주입 불가 |
| 테스트 용이성 | 매우 높음 (순수 자바 코드 가능) | 높음 (Setter 호출 필요) | 매우 낮음 (스프링 컨테이너 필수) |
| 권장 여부 | 적극 권장 (표준) | 가끔 사용 (선택적일 때) | 권장하지 않음 (@Autowired) |
| 항목 | |
| DIP 목적 | 고수준 모듈이 저수준 구현에 직접 의존하지 않도록 설계 |
| 구현 방법 | 인터페이스 분리 + 의존성 주입(DI) |
| 효과 | 유연한 확장, 테스트 용이, 결합도 낮은 구조 |
| 스프링 연계 | DI 컨테이너를 통해 DIP 구조를 자연스럽게 구현 |
// 1. 위반 코드
class FileLogger {
void log(String msg) {
// 파일에 기록
}
}
class OrderService {
private FileLogger logger = new FileLogger(); // 구체 클래스에 직접 의존
void processOrder() {
logger.log("주문 처리");
}
}
// 2. 올바른 코드
interface Logger {
void log(String msg);
}
class FileLogger implements Logger {
public void log(String msg) { ... }
}
class OrderService {
private Logger logger;
public OrderService(Logger logger) {
this.logger = logger;
}
void processOrder() {
logger.log("주문 처리");
}
}'[Codeit Review] > 객체 지향 프로그래밍' 카테고리의 다른 글
| [06] UML을 활용한 객체 지향 설계 시각화 (0) | 2026.01.26 |
|---|---|
| [05] 예외 처리 (Exception Handling) (0) | 2026.01.26 |
| [04] 내부 클래스와 익명 클래스, 그리고 열거 타입(ENUM)의 활용 (0) | 2026.01.26 |
| [02] 객체 지향 프로그래밍의 4가지 핵심 개념 (0) | 2026.01.26 |
| [01] 객체 지향 프로그래밍 (0) | 2026.01.16 |