| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- GCP
- 도커
- dockercompose
- Flyway
- MSA
- 마이크로서비스아키텍처
- 멀티모듈
- GitHub Packages
- docker
- gradle
- 인프라
- java
- Java 8
- 아키텍처
- springboot
- 백엔드면접준비
- 자바
- 백엔드
- 분산시스템
- 컨테이너
- 공통모듈
- 마이그레이션
- 트러블슈팅
- Database
- PostgreSQL
- CS
- github actions
- SpringCloud
- 마이크로서비스
- ci/cd
- Today
- Total
NYO_O
SOLID 원칙, 좋은 객체지향 설계를 위한 다섯 가지 기준 (feat. Java) 본문
지난 시간, 우리는 객체지향 프로그래밍의 네 가지 원칙인 캡슐화, 상속, 다형성, 추상화를 살펴보았습니다. 각 원칙이 변경에 강하고 이해하기 쉬운 구조를 만들기 위해 어떻게 함께 작동하는지도 정리했습니다.
그렇다면 이 원칙들을 실제 설계에 적용할 때, "어떤 기준으로 클래스를 나누고 의존성을 설계해야 하는가?"라는 더 구체적인 질문이 남습니다.
오늘은 이 질문에 답하기 위해 로버트 C. 마틴(Robert C. Martin, 일명 Uncle Bob)이 정리한 SOLID 원칙 다섯 가지를 Java 코드와 함께 정리해 보겠습니다.
SOLID란 무엇인가
SOLID는 다섯 가지 객체지향 설계 원칙의 앞 글자를 모은 약어입니다. 2000년대 초 로버트 C. 마틴이 체계화했으며, 유지보수하기 좋고 확장하기 쉬운 소프트웨어를 만들기 위한 실용적인 지침들입니다.
| 약자 | 원칙 | 핵심 질문 |
| S | 단일 책임 원칙 (SRP) | 이 클래스가 변경되는 이유는 하나뿐인가? |
| O | 개방-폐쇄 원칙 (OCP) | 기존 코드를 수정하지 않고 기능을 확장할 수 있는가? |
| L | 리스코프 치환 원칙 (LSP) | 자식 클래스가 부모 클래스를 완전히 대체할 수 있는가? |
| I | 인터페이스 분리 원칙 (ISP) | 클라이언트가 사용하지 않는 메서드에 의존하고 있지 않은가? |
| D | 의존성 역전 원칙 (DIP) | 고수준 모듈이 저수준 모듈의 구체 구현에 의존하고 있지 않은가? |
각 원칙을 하나씩 살펴보겠습니다.
S — 단일 책임 원칙 (Single Responsibility Principle)
원칙
"클래스는 변경되어야 할 이유가 오직 하나여야 합니다."
단일 책임 원칙은 하나의 클래스가 하나의 책임만 가져야 한다는 원칙입니다. 여기서 "책임"은 기능의 개수가 아니라 변경의 이유를 의미합니다. 클래스를 수정해야 하는 이유가 두 가지 이상이라면, 그 클래스는 두 가지 이상의 책임을 가지고 있을 가능성이 높습니다.
위반 사례
// 나쁜 예 — 하나의 클래스가 너무 많은 책임을 가짐
public class UserService {
public void registerUser(String name, String email, String password) {
// 1. 유효성 검사
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("유효하지 않은 이메일입니다.");
}
// 2. 비밀번호 암호화
String hashedPassword = BCrypt.hashpw(password, BCrypt.gensalt(12));
// 3. DB 저장
String sql = "INSERT INTO users (name, email, password) VALUES (?, ?, ?)";
database.execute(sql, name, email, hashedPassword);
// 4. 이메일 전송
emailClient.send(email, "가입을 환영합니다!", "안녕하세요, " + name + "님.");
// 5. 로그 기록
logger.info("신규 사용자 등록: " + email);
}
}
이 클래스는 유효성 검사 규칙이 바뀌어도, 암호화 알고리즘이 바뀌어도, DB 스키마가 바뀌어도, 이메일 내용이 바뀌어도 수정해야 합니다. 변경의 이유가 다섯 가지나 됩니다.
개선 후
// 각 책임을 독립된 클래스로 분리
public class UserValidator {
public void validate(String email, String password) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("유효하지 않은 이메일입니다.");
}
if (password == null || password.length() < 8) {
throw new IllegalArgumentException("비밀번호는 8자 이상이어야 합니다.");
}
}
}
public class PasswordEncoder {
public String encode(String rawPassword) {
return BCrypt.hashpw(rawPassword, BCrypt.gensalt(12));
}
}
public class UserRepository {
public void save(User user) {
// DB 저장 로직
}
}
public class WelcomeEmailSender {
public void send(String email, String name) {
emailClient.send(email, "가입을 환영합니다!", "안녕하세요, " + name + "님.");
}
}
// UserService는 이제 '등록 흐름 조율'이라는 단 하나의 책임만 가짐
public class UserService {
private final UserValidator validator;
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
private final WelcomeEmailSender emailSender;
public UserService(UserValidator validator, PasswordEncoder passwordEncoder,
UserRepository userRepository, WelcomeEmailSender emailSender) {
this.validator = validator;
this.passwordEncoder = passwordEncoder;
this.userRepository = userRepository;
this.emailSender = emailSender;
}
public void registerUser(String name, String email, String password) {
validator.validate(email, password);
String encodedPassword = passwordEncoder.encode(password);
userRepository.save(new User(name, email, encodedPassword));
emailSender.send(email, name);
}
}
이제 각 클래스는 딱 하나의 이유로만 수정됩니다. 암호화 알고리즘을 바꾸고 싶다면 PasswordEncoder만, 이메일 내용을 바꾸고 싶다면 WelcomeEmailSender만 수정하면 됩니다.
O — 개방-폐쇄 원칙 (Open-Closed Principle)
원칙
"소프트웨어 요소는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 합니다."
새로운 기능을 추가할 때 기존 코드를 수정하지 않고, 새로운 코드를 추가하는 방식으로 확장할 수 있어야 한다는 원칙입니다. 이를 위해서는 변하지 않는 부분(추상화)과 변하는 부분(구현)을 분리하는 것이 핵심입니다.
위반 사례
// 나쁜 예 — 새로운 할인 정책이 생길 때마다 이 메서드를 수정해야 함
public class DiscountCalculator {
public double calculate(Order order, String discountType) {
if (discountType.equals("NONE")) {
return order.getTotalPrice();
} else if (discountType.equals("FIXED")) {
return order.getTotalPrice() - 1000;
} else if (discountType.equals("PERCENTAGE")) {
return order.getTotalPrice() * 0.9;
} else if (discountType.equals("VIP")) {
// VIP 정책이 추가될 때마다 기존 코드를 수정
return order.getTotalPrice() * 0.7;
}
return order.getTotalPrice();
}
}
개선 후
// 할인 정책을 추상화
public interface DiscountPolicy {
double apply(double price);
}
// 각 정책은 독립된 클래스로 구현 — 기존 코드 수정 없이 추가만 하면 됨
public class NoDiscount implements DiscountPolicy {
@Override
public double apply(double price) {
return price;
}
}
public class FixedDiscount implements DiscountPolicy {
private final double discountAmount;
public FixedDiscount(double discountAmount) {
this.discountAmount = discountAmount;
}
@Override
public double apply(double price) {
return price - discountAmount;
}
}
public class PercentageDiscount implements DiscountPolicy {
private final double discountRate;
public PercentageDiscount(double discountRate) {
this.discountRate = discountRate;
}
@Override
public double apply(double price) {
return price * (1 - discountRate);
}
}
// VIP 정책이 새로 생겨도 DiscountCalculator는 수정하지 않음
public class VipDiscount implements DiscountPolicy {
@Override
public double apply(double price) {
return price * 0.7;
}
}
// 계산기는 정책이 무엇인지 몰라도 됨
public class DiscountCalculator {
private final DiscountPolicy discountPolicy;
public DiscountCalculator(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
public double calculate(Order order) {
return discountPolicy.apply(order.getTotalPrice());
}
}
새로운 할인 정책이 필요하면 DiscountPolicy를 구현하는 클래스를 하나 추가하면 됩니다. 기존의 DiscountCalculator는 건드릴 필요가 없습니다.
L — 리스코프 치환 원칙 (Liskov Substitution Principle)
원칙
"자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 합니다."
1987년 바바라 리스코프(Barbara Liskov)가 제안한 원칙입니다. 부모 클래스 타입을 사용하는 코드에서 자식 클래스로 교체했을 때, 프로그램이 올바르게 동작해야 한다는 의미입니다. 단순히 컴파일이 되는 것이 아니라 행동적으로도 호환되어야 합니다.
위반 사례 — 직사각형과 정사각형
수학적으로 정사각형은 직사각형의 특수한 경우이므로, Square extends Rectangle이 자연스럽게 느껴집니다. 그러나 이는 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; }
}
// 정사각형은 가로와 세로가 같아야 하므로 setter를 재정의
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;
}
}
// 부모 타입으로 작성된 코드가 자식으로 교체되면 기대와 다르게 동작함
public class Main {
public static void testArea(Rectangle rectangle) {
rectangle.setWidth(4);
rectangle.setHeight(5);
// Rectangle이라면 넓이가 20이어야 한다고 기대
System.out.println(rectangle.getArea());
// Square를 넘기면 25가 출력됨 — LSP 위반
}
}
testArea()는 Rectangle의 행동 계약("가로와 세로를 독립적으로 설정할 수 있다")을 전제로 작성됐습니다. Square는 이 계약을 깨기 때문에 LSP를 위반합니다.
개선 후
// 상속 대신 공통 추상화로 설계
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;
}
}
Rectangle과 Square를 상속 관계로 묶는 대신, Shape라는 공통 인터페이스 아래 독립된 클래스로 설계했습니다. 이제 두 클래스 모두 Shape를 완전히 대체할 수 있습니다.
LSP의 핵심: 상속을 사용할 때는 "is-a 관계가 성립하는가"뿐 아니라 "행동 계약도 유지되는가"를 함께 검토해야 합니다. 자식 클래스가 부모의 메서드를 재정의할 때 부모의 기대치(사전 조건, 사후 조건)를 어기지 않아야 합니다.
I — 인터페이스 분리 원칙 (Interface Segregation Principle)
원칙
"클라이언트는 자신이 사용하지 않는 메서드에 의존하도록 강제되어서는 안 됩니다."
하나의 거대한 인터페이스보다 목적에 맞게 잘게 분리된 여러 인터페이스가 낫다는 원칙입니다. 인터페이스가 지나치게 크면, 그것을 구현하는 클래스가 필요하지 않은 메서드까지 구현해야 하는 상황이 발생합니다.
위반 사례
// 나쁜 예 — 모든 기능이 하나의 인터페이스에 뭉쳐 있음
public interface Worker {
void work();
void eat();
void sleep();
void attendMeeting();
void submitReport();
}
// 로봇은 eat(), sleep()이 필요 없지만 구현을 강제당함
public class RobotWorker implements Worker {
@Override
public void work() {
System.out.println("로봇이 작업을 수행합니다.");
}
@Override
public void eat() {
throw new UnsupportedOperationException("로봇은 식사하지 않습니다.");
}
@Override
public void sleep() {
throw new UnsupportedOperationException("로봇은 수면하지 않습니다.");
}
@Override
public void attendMeeting() { ... }
@Override
public void submitReport() { ... }
}
RobotWorker는 eat()과 sleep()을 사용하지 않지만, 인터페이스가 강제하기 때문에 예외를 던지는 형태로라도 구현해야 합니다. 이는 LSP도 함께 위반하는 구조입니다.
개선 후
// 역할별로 인터페이스를 분리
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
public interface MeetingAttendable {
void attendMeeting();
}
public interface Reportable {
void submitReport();
}
// 사람은 필요한 인터페이스만 구현
public class HumanWorker implements Workable, Eatable, Sleepable, MeetingAttendable, Reportable {
@Override public void work() { ... }
@Override public void eat() { ... }
@Override public void sleep() { ... }
@Override public void attendMeeting() { ... }
@Override public void submitReport() { ... }
}
// 로봇은 실제로 필요한 인터페이스만 구현
public class RobotWorker implements Workable, MeetingAttendable, Reportable {
@Override public void work() { System.out.println("로봇이 작업을 수행합니다."); }
@Override public void attendMeeting() { ... }
@Override public void submitReport() { ... }
}
각 클래스는 자신이 실제로 지원하는 기능만 구현합니다. 불필요한 의존성이 사라지고, 코드의 의도가 훨씬 명확해집니다.
D — 의존성 역전 원칙 (Dependency Inversion Principle)
원칙
"고수준 모듈은 저수준 모듈에 의존해서는 안 됩니다. 둘 다 추상화에 의존해야 합니다."
고수준 모듈은 비즈니스 로직을 담당하는 부분(예: OrderService)이고, 저수준 모듈은 구체적인 기술 구현을 담당하는 부분(예: MySQLDatabase, SmtpEmailClient)입니다. 고수준 모듈이 저수준 모듈에 직접 의존하면, 저수준 구현이 바뀔 때마다 비즈니스 로직도 함께 수정해야 합니다.
"역전(Inversion)"이라는 표현은 의존성의 방향을 뒤집는다는 의미입니다. 원래는 고수준이 저수준을 직접 사용하는 방향이지만, 추상화를 사이에 두면 저수준 구현체가 추상화에 의존하는 방향으로 바뀝니다.
위반 사례
// 나쁜 예 — 비즈니스 로직이 구체 구현에 직접 의존
public class OrderService {
// 구체 클래스에 직접 의존 — MySQL을 PostgreSQL로 바꾸면 여기도 수정 필요
private final MySQLOrderRepository repository = new MySQLOrderRepository();
private final SmtpEmailNotifier notifier = new SmtpEmailNotifier();
public void placeOrder(Order order) {
repository.save(order);
notifier.sendConfirmation(order.getCustomerEmail());
}
}
MySQLOrderRepository를 PostgreSQLOrderRepository로 교체하거나, 이메일 대신 SMS를 보내도록 바꾸려면 OrderService 코드를 직접 수정해야 합니다.
개선 후
// 추상화 정의 — 고수준과 저수준 모두 이 추상화에 의존
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(Long id);
}
public interface OrderNotifier {
void sendConfirmation(String email);
}
// 저수준 구현체 — 추상화를 구현
public class MySQLOrderRepository implements OrderRepository {
@Override
public void save(Order order) {
// MySQL 저장 로직
}
@Override
public Optional<Order> findById(Long id) {
// MySQL 조회 로직
return Optional.empty();
}
}
// 테스트용 인메모리 구현체도 쉽게 만들 수 있음
public class InMemoryOrderRepository implements OrderRepository {
private final Map<Long, Order> store = new HashMap<>();
@Override
public void save(Order order) {
store.put(order.getId(), order);
}
@Override
public Optional<Order> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
}
public class EmailOrderNotifier implements OrderNotifier {
@Override
public void sendConfirmation(String email) {
// 이메일 전송 로직
}
}
public class SmsOrderNotifier implements OrderNotifier {
@Override
public void sendConfirmation(String phoneNumber) {
// SMS 전송 로직
}
}
// 고수준 모듈 — 추상화에만 의존, 구체 구현은 외부에서 주입
public class OrderService {
private final OrderRepository orderRepository;
private final OrderNotifier orderNotifier;
// 의존성 주입 — 어떤 구현체가 들어오는지 OrderService는 알 필요 없음
public OrderService(OrderRepository orderRepository, OrderNotifier orderNotifier) {
this.orderRepository = orderRepository;
this.orderNotifier = orderNotifier;
}
public void placeOrder(Order order) {
orderRepository.save(order);
orderNotifier.sendConfirmation(order.getCustomerEmail());
}
}
// 조합은 외부(설정 클래스 또는 DI 컨테이너)에서 결정
public class AppConfig {
public OrderService orderService() {
return new OrderService(
new MySQLOrderRepository(),
new EmailOrderNotifier()
);
}
}
이제 DB를 교체하거나 알림 방식을 바꾸어도 OrderService는 한 줄도 수정할 필요가 없습니다. 테스트 시에는 InMemoryOrderRepository를 주입해서 DB 없이도 비즈니스 로직을 검증할 수 있습니다.
Spring Framework의 @Service, @Repository, @Autowired는 이 DIP를 프레임워크 수준에서 자동으로 처리해 주는 도구입니다. 원리를 이해하고 나면 Spring의 DI 컨테이너가 왜 그런 방식으로 동작하는지 더 자연스럽게 이해됩니다.
다섯 원칙은 어떻게 연결되는가
SOLID의 다섯 원칙은 각각 독립적이지만, 함께 적용했을 때 시너지가 납니다.
SRP로 클래스를 잘 분리하면, OCP를 적용하기 위한 추상화의 단위도 명확해집니다. OCP를 위해 인터페이스를 도입하면, DIP를 자연스럽게 따르게 됩니다. ISP로 인터페이스를 적절히 분리하면 LSP를 지키기도 쉬워집니다. 책임이 명확하고 추상화가 잘 된 설계는 곧 지난 글에서 다룬 응집도가 높고 결합도가 낮은 구조와도 일치합니다.
처음부터 다섯 원칙을 완벽하게 적용하려 하기보다는, 코드를 작성하면서 "이 클래스를 수정하는 이유가 하나인가?", "새 기능을 추가할 때 기존 코드를 수정하고 있지 않은가?"를 스스로 물어보는 습관을 들이는 것이 현실적인 시작점입니다. 개인적으로는 SRP와 DIP 두 가지를 먼저 체득하는 것만으로도 코드 품질이 눈에 띄게 달라지는 경험을 할 수 있습니다.
마무리
오늘은 SOLID 원칙 다섯 가지를 Java 코드와 함께 살펴보았습니다.
단일 책임으로 클래스를 명확하게, 개방-폐쇄로 확장을 유연하게, 리스코프 치환으로 상속을 안전하게, 인터페이스 분리로 의존성을 최소화하게, 의존성 역전으로 구현이 아닌 추상화에 의존하게 — 다섯 원칙 모두 결국 변경에 강하고 테스트하기 쉬운 코드를 만들기 위한 구체적인 지침입니다.
'Architecture' 카테고리의 다른 글
| 객체지향 프로그래밍(OOP)의 4대 원칙, (feat. Java) (0) | 2026.05.27 |
|---|---|
| 응집도와 결합도, 좋은 코드가 지켜야 할 두 가지 원칙 (feat. Java) (0) | 2026.05.27 |
| JPA ddl-auto는 이제 그만! 실무에서 쓰는 DB 마이그레이션 툴 비교 (Flyway vs Liquibase) (0) | 2026.05.20 |