1. SOLID 원칙 개요
- S : SRP (Single Responsibility Principle) - 단일 책임 원칙
- O : OCP (Open-Closed Principle) - 개방-폐쇄 원칙
- L : LSP (Liskov Substitution Principle) - 리스코프 치환 원칙
- I : ISP (Interface Segregation Principle) - 인터페이스 분리 원칙
- D : DIP (Dependency Inversion Principle) - 의존성 역전 원칙
2. SRP (Single Responsibility Priciple) - 단일 책임 원칙
2.1 원칙의 개념
클래스는 하나의 책임만 가져야 한다.
하나의 클래스가 여러 책임을 가지면 한 기능을 변경할 때 다른 기능까지 영향을 받게되어 유지보수가 어렵다.
클래스가 하나의 책임만 가지면 응집도를 높이고 결합도를 낮출 수 있다.
2.2 잘못된 예시
아래 예시의 경우 UserService가 사용자 정보 관리와 인증/인가를 모두 처리한다.
그 결과, 인증 로직이 바뀌면 UserService 전체를 수정해야 하고, 사용자 정보 관련 기능에도 영향을 미칠 수 있다.
public class UserService {
private final Map<String, String> userDb = new HashMap<>();
// 사용자 등록
public void register(String userId, String password) {
userDb.put(userId, password);
}
// 사용자 비밀번호 조회
public String getUserPassword(String userId) {
return userDb.get(userId);
}
// 사용자 인증
public boolean login(String userId, String password) {
if (!userDb.containsKey(userId)) {
return false;
}
return userDb.get(userId).equals(password);
}
// 관리자 권한 확인
public boolean checkAdminPrivileges(String userId) {
// 단순 예시: userId가 'admin'이면 관리자
return "admin".equals(userId);
}
}
2.3 개선된 예시
아래 예시는 단일 책임 원칙(SRP)을 적용하기 위해, 기존에 하나의 클래스(UserService) 안에 몰려 있던 사용자 정보 관리와 인증/인가 로직을 3개 클래스로 나눈 코드 예시다. 각 클래스는 하나의 책임(또는 기능)만을 맡도록 설계하여, 변경 사항이 생길 때 서로 영향을 최소화한다. 책임을 분리하면, 변경 사항이 각 기능에만 국한되어 수정 범위가 명확해진다.
2.3.1 UserRepository - 사용자 데이터 관리 책임
UserRepository.java
public class UserRepository {
private final Map<String, String> userDb = new HashMap<>();
// 사용자 등록 (ID-비밀번호 저장)
public void saveUser(String userId, String password) {
userDb.put(userId, password);
}
public String getUserPassword(String userId) {
return userDb.get(userId);
}
public boolean existsUser(String userId) {
return userDb.containsKey(userId);
}
}
역할
- 사용자 정보를 저장, 조회, 존재 여부 확인 등 데이터 관리 기능만 담당.
- “어떻게 로그인 검증을 할지”, “어떻게 관리자 권한을 확인할지”는 전혀 관여하지 않는다.
2.3.2 UserService - 사용자 서비스 로직 책임
UserService.java
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// 새 사용자 등록
public void registerUser(String userId, String password) {
// 유효성 검사, 비즈니스 로직 등
userRepository.saveUser(userId, password);
System.out.println("사용자 [" + userId + "] 등록 완료");
}
// 사용자 정보(예: 비밀번호) 조회
public String findPassword(String userId) {
return userRepository.getUserPassword(userId);
}
}
역할
- 사용자 등록, 비즈니스 로직을 다루면서, 실제 데이터 저장·조회는 UserRepository를 통해 위임(Delegation)
- 인증 관련 로직은 전혀 포함하지 않는다.
2.3.3 AuthService - 인증/인가(Authorization) 책임
AuthService.java
public class AuthService {
private final UserRepository userRepository;
public AuthService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public boolean login(String userId, String password) {
if (!userRepository.existsUser(userId)) {
return false;
}
return userRepository.getUserPassword(userId).equals(password);
}
public boolean checkAdminPrivileges(String userId) {
// 단순 예시: userId가 'admin'이면 관리자
return "admin".equals(userId);
}
}
역할
- 로그인(사용자 식별, 비밀번호 검증), 권한 확인 등의 로직 담당
- 내부에서 필요한 사용자 정보는 UserRepository에 질의하여 얻는다.
2.4 개선된 구조의 이점
- 단일 책임
- UserRepository: 사용자 정보의 저장·조회만 담당
- UserService: 사용자 등록 등 일반적인 서비스 로직만 담당
- AuthService: 인증(로그인), 인가(권한 확인)만 담당
- 변경 범위 최소화
- 로그인 로직 변경 시, AuthService만 수정하면 되고, UserService나 UserRepository는 대부분 영향이 없다.
- 사용자 정보 구조가 바뀌면(예: HashMap→Database 연동), UserRepository만 주로 수정한다.
- 테스트와 확장 용이
- AuthService를 테스트할 때는, UserRepository에 대한 Mock이나 Stub을 제공해 인증 로직만 집중적으로 검증 가능하다.
- 새로운 인증 방식(예: OAuth2, JWT) 등을 추가할 때도 AuthService 중심으로 구현하면 된다. 이렇게 SRP(단일 책임 원칙)를 적용하면, 시스템이 확장될 때 모듈 간 결합도가 낮아지고, 변경 사항의 파급 범위가 줄어들어 유지보수가 용이해진다.
3. OCP (Open-Closed Principle) - 개방-폐쇄 원칙
3.1 원칙의 개념
개방-폐쇄 원칙이란 “확장에는 열려 있으나 변경에는 닫혀 있어야 한다”는 의미이다.
- 코드를 확장하는 것은 자유롭게 하되, 이미 안정적으로 동작하는 기존 코드를 자주 수정하지 않도록 설계하라는 것이다.
3.2 예시 시나리오
전자상거래에서 결제 방식이 늘어날 때마다 결제 처리 코드를 매번 수정해야 한다면, 기존 코드를 건드리는 과정에서 버그나 리스크가 생길 수 있다.
3.2.1 잘못된 예시
public class PaymentProcessor {
// 모든 결제 방식을 한 메소드에서 처리
public void pay(String paymentType, double amount) {
if (paymentType.equals("creditCard")) {
// 신용카드 결제 로직
} else if (paymentType.equals("accountTransfer")) {
// 계좌이체 결제 로직
} else if (paymentType.equals("mobilePay")) {
// 모바일 결제 로직
}
// 결제 방식이 추가될 때마다 else if 블록을 계속 늘려가야 함
}
}
- 새로운 결제 방식을 추가할 때마다 PaymentProcessor를 수정해야 함 (변경에 열려 있음 → OCP 위배)
3.2.2 개선된 예시
인터페이스를 이용해 결제 방식을 추상화하고, PaymentProcessor는 인터페이스만 참조한다.
public interface Payment {
void pay(double amount);
}
public class CreditCardPayment implements Payment {
@Override
public void pay(double amount) {
System.out.println("신용카드 결제: " + amount + "원");
}
}
public class AccountTransferPayment implements Payment {
@Override
public void pay(double amount) {
System.out.println("계좌이체 결제: " + amount + "원");
}
}
public class MobilePayment implements Payment {
@Override
public void pay(double amount) {
System.out.println("모바일 결제: " + amount + "원");
}
}
public class PaymentProcessor {
public void pay(Payment payment, double amount) {
payment.pay(amount);
}
}
- 새 결제 방식을 추가하고 싶다면, PaymentMethod 인터페이스를 구현한 새로운 클래스를 작성하기만 하면 된다.
- PaymentProcessor 코드는 수정 없이 그대로 사용 가능하므로, 기존 코드 변경 없이 확장만 가능한 구조가 된다.
4. LSP (Liskov Substitution Principle) - 리스코프 치환 원칙
4.1 원칙의 개념
리스코프 치환 원칙(Liskov Substitution Principle)은 “부모 타입으로 선언된 객체 자리에 자식 객체를 대입하더라도, 프로그램이 문제없이 동작해야 한다.”는 것을 의미한다.
- 간단히 말해: “자식 클래스는 언제나 부모 클래스가 할 수 있는 행위를 수행할 수 있어야 한다.”
- 자식 클래스가 상속받은 부모의 계약(Contract)을 깨뜨리는 행동을 하면, 다형성 사용 시 예기치 않은 문제가 발생한다.
4.2 잘못된 예시: Rectangle vs Square
아래 예시에서 Square는 Rectangle을 상속받지만, 정사각형(Square)이 직사각형(Rectangle)의 일반적 규칙(가로, 세로가 각각 다른 값을 가질 수 있음)을 위반한다. 정사각형은 setWidth()와 setHeight()가 서로 독립적이지 않기 때문이다.
Rectangle.java
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 getWidth() {
return width;
}
public int getHeight() {
return height;
}
public int getArea() {
return width * height;
}
}
Square.java
public class Square extends Rectangle {
// width, height가 항상 동일해야 하는 제약
@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 LspTest {
public static void main(String[] args) {
Rectangle rect = new Rectangle();
rect.setWidth(5);
rect.setHeight(10);
// 예상: width=5, height=10, area=50
System.out.println("Rectangle area: " + rect.getArea()); // 50
// LSP 관점에서 Rectangle 타입 자리에 Square 객체 대입
Rectangle square = new Square();
square.setWidth(5);
square.setHeight(10);
// 개발자는 "직사각형"이라고 생각하고 area=50을 기대할 수 있으나,
// 실제로는 정사각형 로직 때문에 width=height=10이 되어버림.
System.out.println("Square area: " + square.getArea());
// 결과: 100 (가로, 세로가 동일)
}
}
- 문제점: square 객체는 Rectangle의 규약(가로·세로가 독립적)과 불일치하므로, “직사각형인 줄 알고 썼을 때” 잘못된 동작을 유발한다.
- 이렇게 부모 클래스를 대체(치환)했을 때, 기대한 동작이 깨지는 것은 LSP 위반이다.
4.3 개선된 예시: LSP 준수
접근: 상속 구조 자체를 재설계
- Rectangle과 Square가 공유하는 부분은 “사각형”이지만, 실제로는 “정사각형은 직사각형이 아니다”라는 논란이 생길 수 있다(수학적/개념적으로는 맞지만, 프로그래밍적으로 부모 클래스의 계약을 충실히 이행하지 못함).
- 따라서, 둘을 Shape 같은 상위 추상 클래스로 묶고, 각각 구현을 달리하는 방법을 택할 수 있다. Shape.java (추상 클래스 혹은 인터페이스)
public interface Shape {
int getArea();
}
Rectangle.java
public class Rectangle extends Shape {
private int width;
private int height;
public Rectangle(int width, int height) {
setWidth(width);
setHeight(height);
}
public void setWidth(int width) {
if (width <= 0) {
throw new IllegalArgumentException("Width must be > 0");
}
this.width = width;
}
public void setHeight(int height) {
if (height <= 0) {
throw new IllegalArgumentException("Height must be > 0");
}
this.height = height;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
@Override
public int getArea() {
return width * height;
}
}
Square.java
public class Square extends Shape {
private int side;
public Square(int side) {
setSide(side);
}
public void setSide(int side) {
if (side <= 0) {
throw new IllegalArgumentException("Side must be > 0");
}
this.side = side;
}
public int getSide() {
return side;
}
@Override
public int getArea() {
return side * side;
}
}
- 이제 Square와 Rectangle은 “공통된 메소드: getArea()만 강제하는 상위 추상 클래스(Shape)”를 둔다.
- Square는 정사각형 로직(한 변만 가지는 구조)을 독립적으로 구현하고, Rectangle은 가로·세로를 별도 필드로 둔다.
- “가로·세로” 설정을 요구하는 setWidth(), setHeight()는 Rectangle에만 존재하므로, 정사각형이 직사각형의 세부 동작을 깨뜨리는 일이 사라진다.
테스트 시나리오
public class LspTestImproved {
public static void main(String[] args) {
Shape rect = new Rectangle(5, 10);
System.out.println("Rectangle area: " + rect.getArea()); // 50
Shape square = new Square(5);
System.out.println("Square area: " + square.getArea()); // 25 (5x5)
// 이제 'square'는 'rectangle'처럼 setWidth, setHeight를 지원하지 않음.
// 각자 자신의 불변 계약(정사각형, 직사각형)을 지키도록 설계.
}
}
- 결과: LSP가 온전하게 적용된다. Rectangle이 필요한 자리에 Square를 대입하려고 시도하면 컴파일 오류가 날 것이고, Shape 타입으로 두 객체를 동등하게 취급할 수 있다(단, getArea()만 사용).
4.4 정리: LSP 적용 포인트
- 부모 클래스(또는 인터페이스)가 요구하는 기능(메소드, 계약)을 자식이 항상 지켜야 한다
- 사전조건(메소드 호출 전 가정), 사후조건(메소드 호출 후 결과), 예외 상황 등이 어긋나면 LSP 위반.
- IS-A 관계가 실제로 적합한가?
- 상속은 “A는 B이다”라는 논리를 반영해야 하며, 자식이 부모의 규칙을 깨뜨리지 않아야 함.
- 상속보다는 컴포지션 고려
- LSP가 위배되는 상속 구조라면, 컴포지션(“A는 B를 가진다”)으로 재설계하여 문제를 해결할 수 있다.
이를 통해, 객체지향 다형성을 활용할 때 기대하는 동작(“부모 타입 참조 변수로 자식 객체를 처리”)이 일관성 있게 유지된다. LSP는 SOLID 원칙 중에서도 상속과 다형성을 올바르게 사용하는 핵심 원칙이므로, 실무에서 객체 구조를 설계할 때 항상 유념해야 한다.
5. ISP (Interface Segregation Principle) - 인터페이스 분리 원칙
5.1 원칙의 개념
인터페이스 분리 원칙(Interface Segregation Principle)은 “클라이언트가 실제로 사용하지 않는 메소드에 의존하지 않도록, 인터페이스를 작게 나누라”는 것이다.
- 과도하게 비대한 인터페이스에 많은 메소드가 들어 있으면, 인터페이스 구현체가 불필요한 메소드까지 구현·의존해야 한다.
5.2 예시: 복잡한 인터페이스
public interface FullService {
void orderFood(); // 음식 주문
void processPayment();
void startDelivery();
void provideCustomerSupport();
void handleReturns();
}
- 모든 기능을 한 인터페이스에 담아놓으면, 어떤 클래스가 FullService를 구현할 때 필요 없는 기능까지 구현(혹은 빈 메소드)해야 한다.
5.3 개선된 예시
- 필요한 기능별로 인터페이스를 분리해, 클라이언트가 “자신이 사용하고자 하는 기능”만 구현하면 되도록 한다.
public interface OrderService {
void orderFood();
}
public interface PaymentService {
void processPayment();
}
public interface DeliveryService {
void startDelivery();
}
public interface CustomerSupportService {
void provideCustomerSupport();
void handleReturns();
}
- 이렇게 분리하면, “배달 관련 클래스는”는 DeliveryService만 구현할 수 있고, “온라인 결제 관련 클래스”는 PaymentService만 구현할 수 있다.
6. DIP (Dependency Inversion Principle) - 의존성 역전 원칙
6.1 원칙의 개념
의존성 역전 원칙(Dependency Inversion Principle)은 “고수준 모듈이 저수준 모듈에 의존하지 않고, 둘 다 추상화(인터페이스)에 의존하도록 하라”는 것이다.
- 전통적으로 상위 레벨 비즈니스 로직(고수준 모듈의 로직)이 하위 레벨 구현 상세(저수준 모듈의 로직)에 직접 의존하면, 하위 레벨 구현이 변경될 때 상위 로직이 함께 수정되어야 한다.
- DIP를 적용하면, 상위 로직이 ‘인터페이스’만 의존하고, 구체 구현체는 하위에서 갈아끼울 수 있게 된다.
6.2 예시: 전형적인 스프링 구조
스프링에서 의존성 주입(DI, Dependency Injection)을 통해 DIP를 구현하는 방식은 매우 흔하다.
public interface NotificationSender {
void send(String message);
}
// 저수준 모듈
public class EmailNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("이메일 전송: " + message);
}
}
public class SmsNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("SMS 전송: " + message);
}
}
// 고수준 모듈
public class NotificationService {
private final NotificationSender sender;
// 의존성 주입(생성자 주입) 통해, 구체 클래스 대신 인터페이스만 의존
public NotificationService(NotificationSender sender) {
this.sender = sender;
}
public void notify(String message) {
sender.send(message);
}
}
- NotificationService는 NotificationSender 인터페이스에만 의존한다.
- 실제 구현체(EmailNotificationSender, SmsNotificationSender)는 외부에서 주입받는다.
- 구현체가 바뀌어도 NotificationService는 코드를 변경할 필요가 없다.
7. 정리
- SRP: 한 클래스는 한 가지 책임만 가져야, 변경 시 영향 범위를 줄일 수 있다.
- OCP: 확장에는 열려 있고, 기존 코드 변경에는 닫혀 있어야 한다(인터페이스와 추상화로 유연성을 확보).
- LSP: 상위 타입을 사용하는 곳에 하위 타입을 자유롭게 넣어도 정상 동작해야 한다(부모-자식 계약 준수).
- ISP: 인터페이스를 기능별로 잘게 나누어, 불필요한 메소드 의존을 피한다.
- DIP: 고수준 모듈이 저수준 모듈의 구체 구현이 아닌 추상화(인터페이스)에 의존하도록 설계한다.
이들은 “객체지향 프로그래밍의 원칙”을 보다 체계화한 것이므로, 실무 환경(특히 스프링)에서 빈 구성, 의존성 주입, 인터페이스 설계 등에 직접적으로 적용된다. 스프링 프레임워크에서도 이 원칙들이 녹아 있으므로, 코드를 작성할 때 의식적으로 SOLID 원칙을 지키려 노력해보는 것이 좋다.