임도현의 성장
[Spring-Boot] 객체 지향 설계 원칙 SOLID 본문

📚객체지향 프로그래밍의 설계 원칙 SOLID란?
SOLID란 객체 지향 프로그래밍을 하면서 지켜야하는 5대 원칙으로 각각 SRP(단일 책임 원칙), OCP(개방-폐쇄 원칙), LSP(리스코프 치환 원칙), DIP(의존 역전 원칙), ISP(인터페이스 분리 원칙)의 앞글자를 따서 만들어졌다. SOLID 원칙을 적용하면 코드를 확장하고 유지 보수 관리하기가 더 쉬워지며, 불필요한 복잡성을 제거해 리팩토링에 소요되는 시간을 줄임으로써 프로젝트 개발의 생산성을 높일 수 있다.
🤔단일 책임 원칙 - SRP (Single Responsibility Principle)
- 단일 책임 원칙은 클래스(객체)는 단 하나의 책임(단일 책임)만 가져야 한다.
- 객체가 가진 공개 메서드, 필드, 상수 등은 해당 객체의 단일 책임에 의해서만 변경 되는가?
❌SRP 위반 코드 예시
지금 Person 클래스는 손 씻기와 밥 먹기 두가지 역할을 하고있습니다. 만약 손 씻기 방법이 바뀌면 Person 클래스를 수정해야 하기에 SRP 위반하게 됩니다.
public class Person {
public void washHands() {
System.out.println("👐 손을 씻습니다.");
}
public void eatFood() {
System.out.println("🍚 밥을 먹습니다.");
}
}
✅SRP를 적용한 코드 (책임 분리)
HygieneService는 손 씻기만 담당 MealService는 밥 먹기만 담당 하여 각 클래스가 하나의 책임만 수행하여 코드 변경에 유연해 진것을 알 수 있다.
// 손 씻기만 담당하는 클래스
public class HygieneService {
public void washHands() {
System.out.println("👐 손을 씻습니다.");
}
}
// 밥 먹기만 담당하는 클래스
public class MealService {
public void eatFood() {
System.out.println("🍚 밥을 먹습니다.");
}
}
😪개방 폐쇄 원칙 - OCP (Open Closed Principle)
- OCP원칙은 클래스는 확장에 열려있어야 하며 수정에는 닫혀있어야 한다.
- 확장에 열려있다 - 요구사항이 발생했을 때 새로운 동작을 추가하 기능을 확장 할 수 있어야 함
- 수정에 닫혀있다 - 요구사항이 발생했을 때 기존 코드를 수정 하지 않고 동작을 추가하거나 변경 할 수 있어야 함
- 즉 기존 코드의 변경 없이, 시스템의 기능을 확장할 수 있어야 한다.
- 초상화와 다형성을 활용해서 OCP를 지킬 수 있다.
❌OCP 위반 코드 예시
예를 들어 우리 가게 결제 시스템은 처음에 카드 결제만 지원했는데 나중에 계좌 이체 기능을 추가했다. 그러면 코드를 아래 코드 처럼 processPayment()를 수정해야 해서 OCP 위반을 하게 된다.
public class PaymentService {
public void processPayment(String paymentType) {
if (paymentType.equals("CARD")) {
System.out.println("💳 카드 결제 처리");
} else if (paymentType.equals("BANK_TRANSFER")) {
System.out.println("🏦 계좌 이체 결제 처리");
}
}
}
✅OCP를 적용한 코드 (확장 가능, 기존 코드 수정 없음)
OCP를 적용하면 새로운 결제 방식이 추가될 때 기존 코드를 수정할 필요 없이, 다형성을 활용하여 클래스를 추가하는 방식으로 확장할 수 있다.
public interface Payment {
void pay();
}
public class CardPayment implements Payment {
@Override
public void pay() {
System.out.println("💳 카드 결제 처리");
}
}
public class BankTransferPayment implements Payment {
@Override
public void pay() {
System.out.println("🏦 계좌 이체 결제 처리");
}
}
public class PaymentService {
public void processPayment(Payment payment) {
payment.pay();
}
}
😨리스코프 치환 원칙 - LSP (Liskov Substitution Principle)
- 부모 클래스 객체를 자식 클래스 객체로 대체해도 문제없이 동작해야 한다.
- LSP를 위반하면 상속 클래스를 사용할 때 오동작, 예상 밖의 예외가 발생한다.
❌LSP 위반 코드 예시
아래 코드를 보면 Bird 부모 클래스의fly()메서드는 모든 새가 날 수 있다는 가정을 전재로 작성 됨 하지만 펭귄은 날 수 없는 새이기 때문에 fly()를 오버라이딩해서 예외를 던져야 함 즉 Penguin을 Bird로 대체했을 때 예상과 다른 동작이 일어나기 때문에 LSP 위반을 하게 된다.
public class Bird { // 부모 클래스
public void fly() {
System.out.println("날아갑니다! 🕊");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("펭귄은 날 수 없습니다! 🐧");
}
}
✅LSP를 적용한 코드 (올바른 설계)
Bird 인터페이스는 모든 새가 공통적으로 갖는 동작인 eat() 메서드를 정의합니다. FlyingBird 인터페이스는 Bird를 상속하여 fly()를 정의하게 되어 날 수 있는 새만 추가적으로 fly() 메서드를 정의하고 날 수 없는 새는 Bird만 상속하여 eat()만 제공합니다.
이렇게 LSP를 적용하면 팽귄은 eat() 메서드는 가지지만 fly()메서드는 가지지 않아 FlyingBird 타입을 Bird로 대체 할 수 있게됩니다.
// 공통 인터페이스
public interface Bird {
void eat();
}
// 날 수 있는 새들 인터페이스
public interface FlyingBird extends Bird {
void fly();
}
// 날 수 있으면 FlyingBird 상속
public class Eagle implements FlyingBird {
@Override
public void eat() {
System.out.println("독수리가 먹이를 먹습니다. 🍂");
}
@Override
public void fly() {
System.out.println("독수리가 날아갑니다! 🕊");
}
}
// 날 수 없으면 Bird 상속
public class Penguin implements Bird {
@Override
public void eat() {
System.out.println("펭귄이 물고기를 먹습니다. 🐟");
}
}
😠인터페이스 분리 원칙 - ISP (Interface Segregation Principle)
- 클라이언트는 자신이 사용하지 않는 인터페이스에 의존하면 안 된다.
- SRP 원칙이 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조하는 것으로 보면 된다.
- 즉 인터페이스를 잘께 쪼개라!
- ISP를 위반하면 불필요한 읜존성으로 인해 결합도가 높아지고 특정 기능의 변경이 여러 클래스에 영향을 미칠 수 있다.
❌ISP 위반 코드 예시
아래 코드를 보면 하나의 인터페이스가 너무 많은 책임을 지고있다. Ai는 eat()와 sleep()메서드를 사용하지 않지만 강제로 Worker 인터페이스를 구현해야 하므로 불필요한 메서드를 강제하게 되어 ISP 위반을 하게 된다.
public interface Worker {
void work();
void eat();
void sleep();
}
// 직장인: 일하고 먹고 자는 것을 전부 해야 한다고 강제
public class Employee implements Worker {
@Override
public void work() {
System.out.println("일한다.");
}
@Override
public void eat() {
System.out.println("먹는다.");
}
@Override
public void sleep() {
System.out.println("잔다.");
}
}
// Ai: 일만 하면 되는데, eat()와 sleep()은 필요 없음
public class Ai implements Worker {
@Override
public void work() {
System.out.println("일한다.");
}
@Override
public void eat() {
throw new UnsupportedOperationException("Ai는 eat()을 구현할 필요 없다.");
}
@Override
public void sleep() {
throw new UnsupportedOperationException("Ai는 sleep()을 구현할 필요 없다.");
}
}
✅ISP를 적용한 코드 (인터페이스 분리)
아래 코드 처럼 자신이 필요한 인터페이스만 구현하고 불필요한 메서드는 구현하지 않게 클래스는 작은 인터페이스를 통해 확장성 있게 설계할 수 있다.
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
// 직장인: 일을 하고, 먹고 자는 기능 모두 필요
public class Employee 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("직장에서 자는다.");
}
}
// 프리랜서: 일만 하면 되므로 `Workable`만 필요
public class Freelancer implements Workable {
@Override
public void work() {
System.out.println("프리랜서로 일한다.");
}
}
🥵의존 역전 원칙 - DIP (Dependency Inversion Principle)
- 고수준 모듈이 저수준 모듈에 의존하지 않고, 두 모듈이 모두 추상화된 인터페이스에 의존하도록 하여 의존 관계를 역전시켜 결합도를 낮추고 확장성을 높입니다.
- 구체적인 클래스가 아닌, 추상적인 것에 의존해야 한다. 쉽게 말해 인터페이스에 의존 하라는 뜻
❌DIP 위반 코드 예시
OrderService는 EmailService라는 구체적인 클래스에 의존하고 있어 EmailService를 다른 구현으로 변경 할 수 가 없다. 고수준 모듈이 저수준 모듈에 강하게 결합되어 있어 확장이나 유지보수에 여러움이 있다.
// 고수준 모듈
public class OrderService {
private EmailService emailService = new EmailService();
public void processOrder(Order order) {
// 주문 처리 로직
emailService.sendEmail(order);
}
}
// 저수준 모듈
public class EmailService {
public void sendEmail(Order order) {
System.out.println("주문 확인 이메일을 보냅니다.");
}
}
✅DIP를 지키는 코드 (인터페이스를 활용)
고수준 모듈(OrderService)은 이제 EmailSender라는 추상화된 인터페이스에 의존하고 있어 이메일 보내기, SMS 보내기 등 다양한 방식으로 구현 할 수 있게 되었다.
저수준 모듈 (EmailService, SMSService)은 EmailSender라는 인터페이스를 구현하여 서로 다른 방식으로 이메일을 보낼 수 있습니다.
// 고수준 모듈
public class OrderService {
private EmailSender emailSender;
// 의존성 주입을 통해 EmailSender 인터페이스에 의존
public OrderService(EmailSender emailSender) {
this.emailSender = emailSender;
}
public void processOrder(Order order) {
// 주문 처리 로직
emailSender.sendEmail(order);
}
}
// 추상화된 인터페이스
public interface EmailSender {
void sendEmail(Order order);
}
// 저수준 모듈 1: EmailService
public class EmailService implements EmailSender {
@Override
public void sendEmail(Order order) {
System.out.println("주문 확인 이메일을 보냅니다.");
}
}
// 저수준 모듈 2: SMSService
public class SMSService implements EmailSender {
@Override
public void sendEmail(Order order) {
System.out.println("주문 확인 SMS를 보냅니다.");
}
}
'Spring Boot' 카테고리의 다른 글
[Spring-Boot] JUnit & Mockito기반테스트 코드 작성 (0) | 2025.03.22 |
---|---|
[Spring-Boot] Promtail + Loki + Logback 모니터링 (0) | 2025.03.09 |
[Spring-Boot] QueryDsl 적용해보기 (0) | 2025.02.17 |
[Spring-Boot] Prometheus + Grafana 모니터링 구축 (1) | 2025.02.09 |
[Spring-Boot] 스프링 데이터 JPA (0) | 2025.02.04 |