한줄 설명(meta description): Java 17 기준으로 SOLID 5원칙을 Before/After 코드로 빠르게 체감하고, 실무에서 과도한 추상화로 망하는 포인트까지 정리합니다.
도입 (문제 상황)
리팩토링을 하다 보면 “이 클래스는 책임이 너무 많다”, “확장에 닫혀 있다” 같은 피드백을 종종 받게 됩니다. 그런데 SOLID를 머리로는 알겠는데, 막상 코드로 바꾸려면 어디부터 손대야 할지 막막하실 때가 있어요. 이 글은 각 원칙을 Before/After 코드로 바로 체감하고, 과도한 적용으로 복잡도만 늘어나는 함정까지 함께 짚어봅니다.
핵심 개념 (Java SOLID 원칙을 ‘왜’ 적용하는가)

SOLID는 “정답 설계”가 아니라, 변경이 생겼을 때 안전하게 고칠 수 있는 코드로 가는 체크리스트에 가깝습니다. 핵심은 공통적으로 두 가지예요.
- 변경 이유를 분리해서 한 변화가 다른 곳으로 번지지 않게 합니다.
- 의존성을 안정적인 방향(구체 → 추상)으로 돌려서 테스트/확장 비용을 낮춥니다.
아래 표처럼 “무엇을 지키는가”보다 “어떤 고통을 줄이는가”로 보면 적용 포인트가 더 명확해집니다.
| 원칙 | 줄이는 고통(증상) | 실무에서 자주 보이는 냄새 |
|---|---|---|
| SRP | 작은 변경이 큰 수정으로 번짐 | 한 클래스가 DB/검증/포맷/외부연동까지 다 함 |
| OCP | 기능 추가 때 기존 코드 대수술 | if/switch가 계속 늘어남 |
| LSP | 상속 때문에 런타임 예외/우회코드 증가 | “이 서브클래스는 이 메서드 쓰면 안 됨” |
| ISP | 필요 없는 메서드 구현 강요 | 빈 구현, UnsupportedOperationException 남발 |
| DIP | 테스트 어려움, 결합도 증가 | new로 외부 의존 생성, 프레임워크에 강하게 묶임 |
그리고 한 가지 더 중요합니다. SOLID는 “클래스/인터페이스를 늘리는 기술”이 아니라 변경 축을 분리하는 기술입니다. 바꿀 일이 거의 없는 영역까지 억지로 추상화하면 오히려 유지보수가 어려워집니다(뒤에서 함정으로 정리합니다).
flowchart LR
A[""변경 요구사항""] --> B[""변경 축(이유) 파악""]
B --> C[""SRP: 책임 분리""]
B --> D[""OCP: 확장 포인트 분리""]
B --> E[""DIP: 의존 방향 안정화""]
C --> F[""테스트 용이성 증가""]
D --> F
E --> F
SOLID는 결국 “변경 축을 분리해 테스트/확장을 쉽게 하는 흐름”으로 이해하시면 적용이 쉬워집니다.
코드 예제 (SOLID 5원칙 Before/After 한 번에 보기)
아래 코드는 한 파일로 복붙해서 실행할 수 있게 구성했습니다(Java 17). 각 원칙마다 “Before/After”를 최소 예제로 보여주고, main()에서 결과가 출력됩니다.
import java.util.*;
import java.util.function.Supplier;
public class SolidPrinciplesDemo {
public static void main(String[] args) {
System.out.println("=== SRP ===");
srpDemo();
System.out.println("\n=== OCP ===");
ocpDemo();
System.out.println("\n=== LSP ===");
lspDemo();
System.out.println("\n=== ISP ===");
ispDemo();
System.out.println("\n=== DIP ===");
dipDemo();
}
// ------------------------------------------------------------
// 1) SRP (Single Responsibility Principle)
// ------------------------------------------------------------
// BEFORE: 주문 처리 + 영수증 포맷 + 출력(외부 I/O)까지 한 클래스가 다 함
static class OrderServiceBefore {
void placeOrder(String customerEmail, int amount) {
if (customerEmail == null || !customerEmail.contains("@")) {
throw new IllegalArgumentException("invalid email");
}
String receipt = "RECEIPT: email=" + customerEmail + ", amount=" + amount;
// 콘솔 출력은 대표적인 외부 I/O (변경이 자주 생김: 로깅/메일/파일)
System.out.println(receipt);
}
}
// AFTER: 검증/포맷/출력을 분리해 변경 이유를 분리
record Order(String customerEmail, int amount) {}
static class OrderValidator {
void validate(Order order) {
if (order.customerEmail() == null || !order.customerEmail().contains("@")) {
throw new IllegalArgumentException("invalid email");
}
if (order.amount() <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
}
}
static class ReceiptFormatter {
String format(Order order) {
return "RECEIPT: email=" + order.customerEmail() + ", amount=" + order.amount();
}
}
interface ReceiptOutput {
void output(String receipt);
}
static class ConsoleReceiptOutput implements ReceiptOutput {
public void output(String receipt) {
System.out.println(receipt);
}
}
static class OrderServiceAfter {
private final OrderValidator validator;
private final ReceiptFormatter formatter;
private final ReceiptOutput output;
OrderServiceAfter(OrderValidator validator, ReceiptFormatter formatter, ReceiptOutput output) {
this.validator = validator;
this.formatter = formatter;
this.output = output;
}
void placeOrder(Order order) {
validator.validate(order);
String receipt = formatter.format(order);
output.output(receipt);
}
}
static void srpDemo() {
new OrderServiceBefore().placeOrder("dev@example.com", 100);
var service = new OrderServiceAfter(
new OrderValidator(),
new ReceiptFormatter(),
new ConsoleReceiptOutput()
);
service.placeOrder(new Order("dev@example.com", 200));
}
// ------------------------------------------------------------
// 2) OCP (Open/Closed Principle)
// ------------------------------------------------------------
// BEFORE: 할인 정책 추가할 때마다 switch가 커지고 기존 코드 수정이 필수
enum MemberGrade { BASIC, VIP, VVIP }
static class DiscountServiceBefore {
int discount(MemberGrade grade, int price) {
return switch (grade) {
case BASIC -> 0;
case VIP -> (int) (price * 0.10);
case VVIP -> (int) (price * 0.20);
};
}
}
// AFTER: 확장 포인트(정책)를 분리해 "추가"는 새 클래스로, 기존 코드는 닫힘
interface DiscountPolicy {
int discount(int price);
MemberGrade supports();
}
static class BasicDiscount implements DiscountPolicy {
public int discount(int price) { return 0; }
public MemberGrade supports() { return MemberGrade.BASIC; }
}
static class VipDiscount implements DiscountPolicy {
public int discount(int price) { return (int) (price * 0.10); }
public MemberGrade supports() { return MemberGrade.VIP; }
}
static class VvipDiscount implements DiscountPolicy {
public int discount(int price) { return (int) (price * 0.20); }
public MemberGrade supports() { return MemberGrade.VVIP; }
}
static class DiscountServiceAfter {
private final Map<MemberGrade, DiscountPolicy> policies;
DiscountServiceAfter(List<DiscountPolicy> policyList) {
Map<MemberGrade, DiscountPolicy> map = new EnumMap<>(MemberGrade.class);
for (DiscountPolicy p : policyList) map.put(p.supports(), p);
this.policies = Collections.unmodifiableMap(map);
}
int discount(MemberGrade grade, int price) {
DiscountPolicy policy = policies.get(grade);
if (policy == null) throw new IllegalStateException("No policy for " + grade);
return policy.discount(price);
}
}
static void ocpDemo() {
System.out.println("Before VIP discount: " + new DiscountServiceBefore().discount(MemberGrade.VIP, 1000));
var service = new DiscountServiceAfter(List.of(
new BasicDiscount(), new VipDiscount(), new VvipDiscount()
));
System.out.println("After VIP discount: " + service.discount(MemberGrade.VIP, 1000));
}
// ------------------------------------------------------------
// 3) LSP (Liskov Substitution Principle)
// ------------------------------------------------------------
// BEFORE: 상속을 잘못 쓰면 "부모 타입으로 대체 가능"이 깨짐
static class Rectangle {
protected int width;
protected int height;
void setWidth(int width) { this.width = width; }
void setHeight(int height) { this.height = height; }
int area() { return width * height; }
}
// 정사각형은 "직사각형의 한 종류"처럼 보이지만,
// setWidth/setHeight 계약을 깨기 쉬운 대표 예시
static class Square extends Rectangle {
@Override void setWidth(int width) {
this.width = width;
this.height = width;
}
@Override void setHeight(int height) {
this.height = height;
this.width = height;
}
}
static int computeAreaAssumingIndependentSides(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
return r.area(); // Rectangle이면 20을 기대
}
// AFTER: 상속 대신 "도형"이라는 추상화로 대체 가능성을 지킴(합성/인터페이스)
interface Shape {
int area();
}
record Rect(int width, int height) implements Shape {
public int area() { return width * height; }
}
record Sq(int side) implements Shape {
public int area() { return side * side; }
}
static void lspDemo() {
Rectangle rect = new Rectangle();
System.out.println("Rectangle area (expected 20): " + computeAreaAssumingIndependentSides(rect));
Rectangle squareAsRect = new Square();
System.out.println("Square as Rectangle area (LSP broken, not 20): " + computeAreaAssumingIndependentSides(squareAsRect));
Shape s1 = new Rect(5, 4);
Shape s2 = new Sq(5);
System.out.println("Rect area: " + s1.area());
System.out.println("Square area: " + s2.area());
}
// ------------------------------------------------------------
// 4) ISP (Interface Segregation Principle)
// ------------------------------------------------------------
// BEFORE: "다 되는" 인터페이스는 구현체에 불필요한 메서드를 강요
interface MachineBefore {
void print(String content);
void scan();
void fax();
}
static class SimplePrinterBefore implements MachineBefore {
public void print(String content) { System.out.println("print: " + content); }
public void scan() { throw new UnsupportedOperationException("scan not supported"); }
public void fax() { throw new UnsupportedOperationException("fax not supported"); }
}
// AFTER: 역할을 쪼개서 필요한 기능만 의존
interface Printer {
void print(String content);
}
interface ScannerDevice {
void scan();
}
interface Fax {
void fax();
}
static class SimplePrinter implements Printer {
public void print(String content) { System.out.println("print: " + content); }
}
static class MultiFunctionMachine implements Printer, ScannerDevice, Fax {
public void print(String content) { System.out.println("print: " + content); }
public void scan() { System.out.println("scan"); }
public void fax() { System.out.println("fax"); }
}
static void ispDemo() {
MachineBefore p = new SimplePrinterBefore();
p.print("hello");
try {
p.scan();
} catch (UnsupportedOperationException e) {
System.out.println("Before ISP problem: " + e.getMessage());
}
Printer printer = new SimplePrinter();
printer.print("hello after ISP");
}
// ------------------------------------------------------------
// 5) DIP (Dependency Inversion Principle)
// ------------------------------------------------------------
// BEFORE: 고수준 정책(주문)이 저수준 구현(특정 PG/HTTP)에 직접 의존 -> 테스트/교체가 어려움
static class PaymentClientBefore {
boolean pay(int amount) {
// 실제로는 HTTP 호출 등 외부 의존
return amount < 10_000;
}
}
static class CheckoutServiceBefore {
private final PaymentClientBefore client = new PaymentClientBefore(); // new로 고정 결합
boolean checkout(int amount) {
return client.pay(amount);
}
}
// AFTER: 고수준은 추상(Port)에 의존, 저수준은 구현(Adapter)로 갈아끼움
interface PaymentPort {
boolean pay(int amount);
}
static class FakePaymentAdapter implements PaymentPort {
private final Supplier<Boolean> result;
FakePaymentAdapter(Supplier<Boolean> result) { this.result = result; }
public boolean pay(int amount) { return result.get(); }
}
static class CheckoutServiceAfter {
private final PaymentPort paymentPort;
CheckoutServiceAfter(PaymentPort paymentPort) {
this.paymentPort = paymentPort;
}
boolean checkout(int amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
return paymentPort.pay(amount);
}
}
static void dipDemo() {
System.out.println("Before checkout: " + new CheckoutServiceBefore().checkout(5000));
// 테스트/로컬에서는 Fake로 빠르게 검증 가능
var okService = new CheckoutServiceAfter(new FakePaymentAdapter(() -> true));
var failService = new CheckoutServiceAfter(new FakePaymentAdapter(() -> false));
System.out.println("After checkout (fake ok): " + okService.checkout(5000));
System.out.println("After checkout (fake fail): " + failService.checkout(5000));
}
}
실무 팁 (과도한 적용의 함정 포함)
💡 실무에서는
“추상화는 변경이 ‘반복적으로’ 생기는 경계에만” 두는 편이 안전합니다. 예를 들어 OCP를 지키겠다고 모든 로직을 전략 패턴으로 바꾸면 클래스 수만 늘고, 실제 변경이 없을 때는 읽기/탐색 비용이 더 커져요. 먼저 if/switch가 2~3번 이상 비슷한 형태로 늘어나는지, 혹은 외부 연동/정책이 환경별로 달라지는지를 확인한 뒤 확장 포인트를 분리해 보세요.
💡 실무에서는
LSP는 특히 “상속을 쓰면 공짜로 재사용된다”는 기대 때문에 깨지기 쉽습니다. 서브클래스에서 UnsupportedOperationException을 던지거나, 입력 제약을 더 강하게 만들거나, 상태 변경 규칙을 바꾸는 순간 대체 가능성이 무너집니다. 상속을 고려하실 때는 “is-a” 문장보다 부모 타입의 계약(불변식/사전조건/사후조건)을 그대로 지킬 수 있는가를 먼저 체크하고, 애매하면 합성(인터페이스 + 구현)으로 선회하는 게 보통 더 단단합니다.
핵심 요약
SOLID는 클래스 늘리기가 아니라 “변경 축 분리”로 유지보수를 쉽게 하는 원칙입니다.
Before/After로 바꿀 때는 SRP로 책임을 나누고, OCP/DIP로 확장·테스트 경계를 만드세요.
과도한 추상화는 복잡도만 올리니 “변경이 반복되는 지점”에만 적용해 보세요.
다음 글: [실무에서 자주 쓰는 디자인 패턴 5가지]
'JAVA' 카테고리의 다른 글
| Java 불변 객체와 방어적 복사: record로 안전한 도메인 만들기 (0) | 2026.02.26 |
|---|---|
| Java 실무에서 자주 쓰는 디자인 패턴 5가지 (Strategy, Factory, Builder, Singleton, Observer) (0) | 2026.02.26 |
| Java 모던 동시성 — ExecutorService & CompletableFuture로 스레드 풀과 비동기 처리 정리 (0) | 2026.02.25 |
| Java 스레드 기초와 동기화(Thread, synchronized, volatile) 그리고 데드락까지 한 번에 정리 (0) | 2026.02.24 |
| Java 파일 I/O 현대적으로 하기: Files/Path, try-with-resources, 인코딩까지 깔끔하게 (0) | 2026.02.24 |