SOLID 원칙과 클린 코드 — 좋은 코드란 뭔가
새로운 결제 수단을 추가할 때마다 기존 코드를 수정해야 한다면, 설계의 어디가 잘못된 걸까?
SOLID 원칙과 클린 코드 — 좋은 코드란 뭔가
가독성이 좋은 코드가 좋은 코드라는 건 맞아요. 핵심은 그 다음입니다. 왜 가독성이 중요한지, 유지보수성과 확장성을 어떻게 확보하는지, 그걸 뒷받침하는 원칙이 뭔지를 구체적으로 말할 수 있어야 해요.
SOLID는 로버트 마틴(Uncle Bob)이 정리한 객체지향 설계 5원칙입니다. 이름만 들으면 학부 교재 냄새가 나지만, 실무에서 코드가 꼬이기 시작하는 순간 결국 이 원칙 중 하나를 위반하고 있는 경우가 대부분이에요. 클린 코드도 마찬가지입니다. 원리 자체는 단순한데, 지키기가 어렵습니다.
SOLID를 왜 알아야 하나
SOLID를 안다고 해서 당장 코드가 좋아지진 않습니다. 하지만 모르면 확실히 나빠져요. 이 원칙들이 해결하려는 핵심 문제는 세 가지입니다.
- **유지보수 **: 요구사항이 바뀔 때 수정 범위가 최소화됩니다. 한 곳을 고쳤는데 다른 곳이 터지는 상황을 줄여줘요.
- ** 확장성 **: 기존 코드를 건드리지 않고도 새 기능을 추가할 수 있는 구조를 만들어요.
- ** 테스트 용이성 **: 의존성이 명확하게 분리되어 있으면 단위 테스트 작성이 쉬워집니다. Mock을 넣기도 편하고요.
이 세 가지가 결국 "변경에 강한 코드"라는 하나의 목표로 수렴합니다. SOLID가 중요한 이유도 여기에 있어요 -- 변경에 강한 설계를 고민해본 적이 있는지가 개발자의 역량을 드러내기 때문입니다.
SRP — 단일 책임 원칙 (Single Responsibility Principle)
클래스는 변경의 이유가 하나여야 한다.
"한 가지 일만 해야 한다"로 외우는 사람이 많은데, 엄밀히 말하면 "변경의 이유(reason to change)가 하나"라는 게 더 정확합니다. 변경의 이유가 두 개 이상이면, 한쪽 요구사항이 바뀔 때 다른 쪽까지 영향을 주게 돼요.
위반 사례
public class UserService {
public void registerUser(String name, String email) {
// 1. 비즈니스 로직: 유효성 검사
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("이메일 형식이 잘못됨");
}
// 2. DB 저장
String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
jdbcTemplate.update(sql, name, email);
// 3. 이메일 발송
emailSender.send(email, "가입을 환영합니다!", "안녕하세요 " + name + "님");
}
}
이 클래스는 변경의 이유가 세 개나 됩니다. 유효성 검사 규칙이 바뀌거나, DB 스키마가 바뀌거나, 이메일 템플릿이 바뀌면 전부 이 클래스를 고쳐야 해요. 작은 프로젝트에서는 상관없어 보이지만, 규모가 커지면 이런 클래스가 점점 뚱뚱해지면서 God Class로 변합니다.
준수 사례
public class UserValidator {
public void validate(String name, String email) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("이메일 형식이 잘못됨");
}
}
}
public class UserRepository {
public void save(String name, String email) {
String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
jdbcTemplate.update(sql, name, email);
}
}
public class WelcomeEmailSender {
public void send(String email, String name) {
emailSender.send(email, "가입을 환영합니다!", "안녕하세요 " + name + "님");
}
}
public class UserService {
private final UserValidator validator;
private final UserRepository repository;
private final WelcomeEmailSender welcomeEmailSender;
public void registerUser(String name, String email) {
validator.validate(name, email);
repository.save(name, email);
welcomeEmailSender.send(email, name);
}
}
각 클래스가 자기 변경 이유 하나만 가지게 됐습니다. UserService는 "가입 흐름의 조율"이라는 한 가지 책임만 남아요. 이메일 발송 방식이 바뀌어도 WelcomeEmailSender만 고치면 됩니다.
OCP — 개방-폐쇄 원칙 (Open-Closed Principle)
확장에는 열려 있고, 수정에는 닫혀 있어야 한다.
새로운 기능을 추가할 때 기존 코드를 건드리지 않아야 한다는 뜻입니다. 말로는 쉽지만, 실제로 어떻게 하라는 건지 감이 안 올 수 있어요. 핵심은 ** 추상화 **입니다.
위반 사례
public class DiscountService {
public double calculate(String grade, double price) {
if (grade.equals("VIP")) {
return price * 0.8;
} else if (grade.equals("GOLD")) {
return price * 0.9;
} else if (grade.equals("SILVER")) {
return price * 0.95;
}
return price;
}
}
새 등급이 추가될 때마다 if-else를 계속 늘려야 합니다. 기존 코드를 직접 수정하는 구조예요.
준수 사례 — 전략 패턴 적용
public interface DiscountPolicy {
double discount(double price);
}
public class VipDiscount implements DiscountPolicy {
@Override
public double discount(double price) {
return price * 0.8;
}
}
public class GoldDiscount implements DiscountPolicy {
@Override
public double discount(double price) {
return price * 0.9;
}
}
public class DiscountService {
private final DiscountPolicy policy;
public DiscountService(DiscountPolicy policy) {
this.policy = policy;
}
public double calculate(double price) {
return policy.discount(price);
}
}
새 등급이 추가되면 DiscountPolicy를 구현하는 새 클래스만 만들면 됩니다. DiscountService 코드는 한 줄도 안 바뀌어요. 이게 바로 전략 패턴(Strategy Pattern)이고, OCP의 가장 대표적인 적용 사례입니다.
LSP — 리스코프 치환 원칙 (Liskov Substitution Principle)
하위 타입은 상위 타입을 대체할 수 있어야 한다.
바바라 리스코프가 1987년에 제안한 원칙입니다. 쉽게 말하면, 부모 클래스 자리에 자식 클래스를 넣어도 프로그램이 정상 동작해야 한다는 거예요. 상속을 쓸 때 가장 흔하게 위반되는 원칙이기도 합니다.
고전적인 위반 사례 — Rectangle-Square 문제
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;
}
}
수학적으로 정사각형은 직사각형의 특수한 경우가 맞습니다. 근데 코드에서는 달라요.
void checkArea(Rectangle rect) {
rect.setWidth(5);
rect.setHeight(4);
assert rect.getArea() == 20; // Rectangle이면 20, Square면 16 — 깨진다!
}
Rectangle 자리에 Square를 넣으면 기대한 대로 동작하지 않습니다. 이건 LSP 위반이에요. 상속 관계가 "is-a" 관계처럼 보여도, 행위의 호환성이 깨지면 잘못된 상속입니다.
해결 방법은 Rectangle과 Square를 독립적인 Shape 인터페이스의 구현체로 만들거나, 불변 객체로 설계하는 거예요.
public interface Shape {
int getArea();
}
public class Rectangle implements Shape {
private final int width;
private final int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
}
public class Square implements Shape {
private final int side;
public Square(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
}
ISP — 인터페이스 분리 원칙 (Interface Segregation Principle)
클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다.
하나의 거대한 인터페이스보다 여러 개의 작은 인터페이스가 낫다는 얘기입니다.
위반 사례 — 뚱뚱한 인터페이스
public interface Worker {
void work();
void eat();
void sleep();
}
public class Robot implements Worker {
@Override
public void work() {
System.out.println("작업 수행");
}
@Override
public void eat() {
// 로봇은 밥을 안 먹는데... 빈 구현을 강제당한다
}
@Override
public void sleep() {
// 로봇은 잠도 안 자는데...
}
}
Robot은 eat()과 sleep()이 필요 없는데, 인터페이스가 하나에 다 들어있으니 억지로 구현해야 합니다. 이런 걸 "뚱뚱한 인터페이스(fat interface)"라고 해요.
준수 사례
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
public class Human implements Workable, Eatable, Sleepable {
@Override
public void work() { System.out.println("일하기"); }
@Override
public void eat() { System.out.println("밥먹기"); }
@Override
public void sleep() { System.out.println("잠자기"); }
}
public class Robot implements Workable {
@Override
public void work() { System.out.println("작업 수행"); }
}
Robot은 Workable만 구현하면 됩니다. 필요한 인터페이스만 골라서 구현하는 구조가 ISP의 핵심이에요. Java의 Serializable, Comparable, Iterable 같은 인터페이스들이 잘게 쪼개져 있는 것도 같은 맥락입니다.
DIP — 의존 역전 원칙 (Dependency Inversion Principle)
상위 모듈이 하위 모듈에 직접 의존하면 안 된다. 둘 다 추상화에 의존해야 한다.
여기서 "역전(Inversion)"이란, 전통적으로 상위 모듈이 하위 모듈을 직접 알고 있던 의존 방향을 뒤집겠다는 뜻입니다.
위반 사례
public class OrderService {
private final MySqlOrderRepository repository = new MySqlOrderRepository();
public void placeOrder(Order order) {
repository.save(order);
}
}
OrderService가 MySqlOrderRepository라는 구체 클래스에 직접 의존하고 있어요. DB를 PostgreSQL로 바꾸려면 OrderService 코드를 직접 수정해야 합니다.
준수 사례
public interface OrderRepository {
void save(Order order);
}
public class MySqlOrderRepository implements OrderRepository {
@Override
public void save(Order order) {
// MySQL 저장 로직
}
}
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public void placeOrder(Order order) {
repository.save(order);
}
}
OrderService는 이제 OrderRepository라는 추상화에만 의존합니다. DB가 바뀌면 새로운 구현체를 만들어서 주입하면 그만이에요.
Spring DI와의 연결
Spring의 DI(Dependency Injection)가 바로 DIP를 프레임워크 차원에서 실현한 것입니다. @Autowired나 생성자 주입으로 구현체를 외부에서 넣어주니까, 서비스 코드는 추상화(인터페이스)만 알면 돼요.
@Service
public class OrderService {
private final OrderRepository repository;
// Spring이 OrderRepository 구현체를 알아서 주입해준다
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
테스트할 때도 Mock 구현체를 넣어서 DB 없이 테스트가 가능해집니다. DIP를 지키면 자연스럽게 테스트 용이성도 따라와요.
클린 코드 원칙
SOLID가 설계 수준의 원칙이라면, 클린 코드는 좀 더 코드 레벨에서의 원칙입니다. 로버트 마틴의 Clean Code에서 나온 내용들인데, 핵심만 추리면 세 가지예요.
의미 있는 이름
// 나쁜 예
int d; // elapsed time in days
List<int[]> list1;
// 좋은 예
int elapsedTimeInDays;
List<int[]> flaggedCells;
이름만 보고 무엇인지 알 수 있어야 합니다. 변수 이름에 타입을 붙이는 헝가리안 표기법(strName, intCount) 같은 건 현대 IDE 환경에서 의미가 없어요. 의도를 드러내는 이름을 쓰는 게 중요합니다.
메서드 이름도 마찬가지예요. processData()보다 calculateMonthlyRevenue()가 훨씬 읽기 쉽습니다. 이름이 길어지는 게 두려워서 축약하는 것보다, 길더라도 명확한 게 낫습니다.
함수는 한 가지 일만
함수 하나가 여러 가지 일을 하면 이해하기도 어렵고, 테스트하기도 어렵습니다. SRP를 함수 레벨에 적용한 것이라고 보면 돼요.
// 나쁜 예 — 유효성 검사 + 저장 + 알림을 한 함수에서 처리
public void processOrder(Order order) {
if (order.getItems().isEmpty()) throw new IllegalStateException("주문 항목 없음");
if (order.getTotalPrice() < 0) throw new IllegalStateException("금액 오류");
orderRepository.save(order);
notificationService.sendOrderConfirmation(order);
inventoryService.decreaseStock(order.getItems());
}
// 좋은 예 — 각 단계를 분리
public void processOrder(Order order) {
validateOrder(order);
saveOrder(order);
notifyAndUpdateInventory(order);
}
private void validateOrder(Order order) {
if (order.getItems().isEmpty()) throw new IllegalStateException("주문 항목 없음");
if (order.getTotalPrice() < 0) throw new IllegalStateException("금액 오류");
}
private void saveOrder(Order order) {
orderRepository.save(order);
}
private void notifyAndUpdateInventory(Order order) {
notificationService.sendOrderConfirmation(order);
inventoryService.decreaseStock(order.getItems());
}
주석 대신 코드로 말하기
// 나쁜 예
// 직원의 복지 혜택 자격을 확인한다
if ((employee.flags & HOURLY_FLAG) != 0 && employee.age > 65) { ... }
// 좋은 예
if (employee.isEligibleForBenefits()) { ... }
주석이 필요하다는 건 코드가 충분히 표현적이지 않다는 신호입니다. 물론 API 문서, 법적 주석, 의도 설명(왜 이런 선택을 했는지)은 유용해요. 하지만 "무엇을 하는지"를 설명하는 주석은 대부분 코드 자체로 대체할 수 있습니다.
코드 스멜
코드 스멜(Code Smell)은 마틴 파울러가 Refactoring에서 정의한 개념입니다. 당장 버그는 아니지만, 설계에 문제가 있음을 암시하는 신호예요. 자주 헷갈리는 개념이라 한번 정리해 보겠습니다.
Long Method
메서드가 너무 긴 경우입니다. 스크롤을 여러 번 해야 전체를 볼 수 있다면 거의 확실하게 Long Method예요. 보통 20줄이 넘어가기 시작하면 분리를 고민해야 합니다.
God Class
하나의 클래스가 너무 많은 것을 알고 있는 경우입니다. 필드가 수십 개, 메서드가 수십 개인 클래스가 있다면 SRP를 위반하고 있을 가능성이 높아요. UserManager, OrderProcessor 같은 이름에 모든 로직이 몰려있는 경우가 전형적입니다.
Feature Envy
메서드가 자신이 속한 클래스의 데이터보다 다른 클래스의 데이터를 더 많이 사용하는 경우입니다.
// Feature Envy — Order의 데이터를 이 메서드가 지나치게 많이 참조
public double calculateShipping(Order order) {
double weight = order.getWeight();
String region = order.getAddress().getRegion();
boolean isMember = order.getCustomer().isMember();
if (region.equals("제주") && weight > 10) {
return isMember ? 3000 : 5000;
}
return isMember ? 0 : 2500;
}
이 로직은 Order 쪽에 있는 게 더 자연스럽습니다. 데이터가 있는 곳에 행위를 두는 게 좋아요.
Primitive Obsession
기본 타입만 고집해서 도메인 개념이 코드에 드러나지 않는 문제입니다.
// 나쁜 예 — 전화번호가 그냥 String
public void register(String name, String phone, String email) { ... }
// 좋은 예 — 도메인 객체로 감싸기
public void register(Name name, PhoneNumber phone, Email email) { ... }
PhoneNumber 클래스 안에서 형식 검증, 포매팅, 비교 로직을 캡슐화할 수 있어요. String으로 두면 이 로직이 여기저기 흩어지게 됩니다.
리팩터링 기법
코드 스멜을 발견했으면 고쳐야겠죠. 대표적인 리팩터링 기법 세 가지를 살펴보겠습니다.
Extract Method
긴 메서드에서 의미 있는 단위를 뽑아내는 기법입니다. 가장 많이 쓰이는 리팩터링이기도 해요.
// Before
public void printOwing() {
// 배너 출력
System.out.println("**************************");
System.out.println("**** Customer Owes *****");
System.out.println("**************************");
// 미결제 금액 계산
double outstanding = 0.0;
for (Order order : orders) {
outstanding += order.getAmount();
}
// 상세 출력
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}
// After
public void printOwing() {
printBanner();
double outstanding = calculateOutstanding();
printDetails(outstanding);
}
private void printBanner() {
System.out.println("**************************");
System.out.println("**** Customer Owes *****");
System.out.println("**************************");
}
private double calculateOutstanding() {
double outstanding = 0.0;
for (Order order : orders) {
outstanding += order.getAmount();
}
return outstanding;
}
private void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}
Introduce Parameter Object
매개변수가 3~4개 이상이면 하나의 객체로 묶는 걸 고려해 보세요. 특히 같은 매개변수 묶음이 여러 메서드에서 반복되면, 확실히 객체로 뽑아야 합니다.
// Before
public List<Order> findOrders(LocalDate startDate, LocalDate endDate,
String status, int minAmount) { ... }
// After
public class OrderSearchCriteria {
private final LocalDate startDate;
private final LocalDate endDate;
private final String status;
private final int minAmount;
// 생성자, getter
}
public List<Order> findOrders(OrderSearchCriteria criteria) { ... }
객체로 묶으면 검색 조건에 새 필드가 추가되어도 메서드 시그니처가 바뀌지 않아요. OCP와도 연결되는 부분입니다.
Replace Conditional with Polymorphism
조건문을 다형성으로 대체하는 기법입니다. OCP에서 봤던 전략 패턴이 사실 이 리팩터링의 결과물이에요.
// Before
public double calculatePay(Employee employee) {
switch (employee.getType()) {
case FULL_TIME:
return employee.getMonthlySalary();
case PART_TIME:
return employee.getHourlyRate() * employee.getHoursWorked();
case CONTRACT:
return employee.getDailyRate() * employee.getDaysWorked();
default:
throw new IllegalArgumentException("Unknown type");
}
}
// After
public abstract class Employee {
public abstract double calculatePay();
}
public class FullTimeEmployee extends Employee {
@Override
public double calculatePay() {
return monthlySalary;
}
}
public class PartTimeEmployee extends Employee {
@Override
public double calculatePay() {
return hourlyRate * hoursWorked;
}
}
public class ContractEmployee extends Employee {
@Override
public double calculatePay() {
return dailyRate * daysWorked;
}
}
새로운 직원 유형이 추가될 때 switch 문을 고칠 필요 없이 새 클래스만 만들면 됩니다.
주의할 점
DRY / KISS / YAGNI
SOLID 말고도 소프트웨어 원칙을 물어보는 경우가 있습니다.
| 원칙 | 의미 | 핵심 |
|---|---|---|
| DRY (Don't Repeat Yourself) | 같은 지식을 두 곳 이상에 두지 마라 | 코드 중복뿐 아니라 지식(로직)의 중복도 포함. 단순히 코드가 비슷하다고 무조건 합치는 건 오히려 독이 된다 |
| KISS (Keep It Simple, Stupid) | 불필요한 복잡성을 피하라 | 디자인 패턴을 적용하는 것 자체가 목적이 되면 안 됨 |
| YAGNI (You Ain't Gonna Need It) | 지금 필요하지 않은 건 만들지 마라 | "나중에 필요할 것 같아서" 미리 만들어둔 추상화가 결국 쓰이지 않는 경우가 많다 |
핵심 포인트: DRY를 "코드 복붙을 하지 말라는 것"으로 이해하기 쉬운데, 정확히는 "코드보다는 지식의 중복을 없애라는 의미"입니다. 비슷해 보이는 코드라도 변경 이유가 다르면 분리해두는 게 맞아요.
기술 부채란?
빠른 출시를 위해 품질을 타협한 결과, 나중에 갚아야 할 추가 작업.
워드 커닝햄이 처음 쓴 비유입니다. 빚을 지면 이자가 붙듯이, 기술 부채를 방치하면 새 기능 개발 속도가 점점 느려져요. "기술 부채를 어떻게 관리하나요?"가 궁금하다면, 실무적인 접근은 이런 식입니다:
- 스프린트마다 일정 비율(20% 정도)을 기술 부채 상환에 할당합니다
- 코드 리뷰에서 기술 부채 발생을 조기에 차단해요
- 리팩터링 전에 반드시 테스트를 먼저 확보합니다
레거시 코드에 테스트 붙이는 방법
마이클 페더스의 Working Effectively with Legacy Code에서 가장 핵심적인 내용입니다.
- **변경 지점 파악 **: 수정하려는 코드가 어디에 영향을 미치는지 확인합니다
- ** 이음새(Seam) 찾기 **: 코드 변경 없이 동작을 바꿀 수 있는 지점을 찾아요. 보통 의존성 주입 포인트가 이음새가 됩니다
- ** 특성화 테스트(Characterization Test)**: 현재 동작을 그대로 기록하는 테스트를 먼저 작성합니다. "올바른 동작"을 검증하는 게 아니라 "현재 동작"을 보존하는 거예요
- Extract and Override: 테스트하기 어려운 의존성을 메서드로 추출하고, 테스트에서 오버라이드합니다
- ** 작은 단위로 리팩터링 **: 테스트가 확보되면, 안전하게 조금씩 구조를 개선해요
// 레거시 코드 — 직접 DB에 접근해서 테스트하기 어려움
public class InvoiceGenerator {
public Invoice generate(int orderId) {
Order order = Database.getConnection()
.query("SELECT * FROM orders WHERE id = ?", orderId);
// ... 복잡한 계산 로직 ...
return new Invoice(order, calculatedAmount);
}
}
// Step 1: 의존성을 메서드로 추출 (Extract and Override)
public class InvoiceGenerator {
public Invoice generate(int orderId) {
Order order = findOrder(orderId);
// ... 복잡한 계산 로직 ...
return new Invoice(order, calculatedAmount);
}
protected Order findOrder(int orderId) {
return Database.getConnection()
.query("SELECT * FROM orders WHERE id = ?", orderId);
}
}
// Step 2: 테스트에서 오버라이드
public class TestableInvoiceGenerator extends InvoiceGenerator {
@Override
protected Order findOrder(int orderId) {
return new Order(orderId, "테스트 상품", 10000); // DB 없이 테스트
}
}
파생 개념
이 글에서 다룬 내용과 연결되는 주제들입니다. 같이 봐두면 더 깊이 이해할 수 있어요.
- **디자인 패턴 **: 전략, 팩토리, 옵저버 등 SOLID를 구현 수준에서 실현하는 구체적인 방법론 → [이미 작성](/개발/CS/기타/디자인 패턴 — 면접에서 자주 나오는 7가지)
- Spring IoC/DI: DIP를 프레임워크 차원에서 지원하는 Spring의 핵심 메커니즘 → [이미 작성](/개발/백엔드/스프링/스프링부트/제어의 역전(IoC) 과 의존성_주입(DI))