들어가며
객체지향 설계 과정은 왜 필요할까?
그 이유는 지속적으로 변화하는 요구사항에 유연하게 대응하며 코드를 작성하기 위해서다.
예를 들어, 카페 포스기 프로그램을 생각해보자.
신메뉴가 추가되거나, 이벤트로 인해 특정 메뉴에 할인을 적용해야 하는 상황 등이 발생할 수 있다.
이러한 다양한 요구사항이 발생하더라도 최소한의 코드 수정으로 적은 시간 내에 프로그램이 안정적으로 동작하도록 만드는 것이 중요하다.
나는 객체지향 설계가 이런 변화에 강한 코드를 효율적으로 작성할 수 있게 도와준다고 생각한다.
따라서 코드를 객체 지향적으로 작성하는 것은 매우 중요하며, 이를 위해 객체 지향 설계 5가지 원칙에 대해 알아보려 한다.
SOLID
1️⃣ 단일 책임 원칙(SRP, Single Responsibility Principle)
- 하나의 클래스는 하나의 책임만 가져야 한다.
- 관심사 분리로 인한 높은 응집도, 낮은 결합도
-> 클래스를 책임별로 분리해두었기 때문에, 한 클래스에서 변경이 발생하더라도 다른 클래스에 미치는 파급 효과가 적다.
-> 이로 인해 코드의 유지보수성 및 확장성이 개선된다.
변경 전 ⬇️
public class Kiosk {
public void processOrder(String menu, int amount) {
System.out.println("메뉴: " + menu + " 주문이 접수되었습니다.");
System.out.println("결제 금액: " + amount + "원이 처리되었습니다.");
System.out.println("영수증: 메뉴 - " + menu + ", 금액 - " + amount + "원");
}
}
변경 후 ⬇️
// 주문 처리 책임
public class OrderHandler {
public void takeOrder(String menu) {
System.out.println("메뉴: " + menu + " 주문이 접수되었습니다.");
}
}
// 결제 처리 책임
public class PaymentProcessor {
public void processPayment(int amount) {
System.out.println("결제 금액: " + amount + "원이 처리되었습니다.");
}
}
// 영수증 출력 책임
public class ReceiptPrinter {
public void printReceipt(String menu, int amount) {
System.out.println("영수증: 메뉴 - " + menu + ", 금액 - " + amount + "원");
}
}
// 각 클래스를 조합하여 동작
public class Kiosk {
private final OrderHandler orderHandler = new OrderHandler();
private final PaymentProcessor paymentProcessor = new PaymentProcessor();
private final ReceiptPrinter receiptPrinter = new ReceiptPrinter();
public void processOrder(String menu, int amount) {
orderHandler.takeOrder(menu);
paymentProcessor.processPayment(amount);
receiptPrinter.printReceipt(menu, amount);
}
}
변경에 따른 영향 최소화
- 결제 방식이 변경되더라도 PaymentProcessor만 수정하면 되며, 다른 클래스에는 영향을 미치지 않는다.
- 영수증 포맷을 수정하더라도 ReceiptPrinter만 수정하면 된다.
2️⃣ 개방 폐쇄 원칙(OCP, Open/Closed Principle)
- 확장에는 열려 있고, 수정에는 닫혀 있어야 한다.
- 즉, 기존의 코드를 변경하지 않고 기능을 새로 추가할 수 있어야 한다.
- 주의🚨: 모든 코드를 변경하지 말라는 말이 아니다.
- 같은 역할이지만 세부 구현이 다른 경우, 확장 가능성이 높은 기능 등 서로 관련이 있는 부분은 기존 코드 수정없이 기능을 확장할 수 있어야 한다.
이 때, 아래 2가지를 활용하여 기존 코드를 수정하지 않고 기능을 확장할 수 있다.- 상속을 통한 다형성 활용
- 인터페이스를 적용하여 추상화 하기
예를 들어 카페에서 새로운 할인 이벤트를 실행한다고 가정해보자.
변경 전 ⬇️
class DiscountCalculator {
public int calculateDiscount(String discountType, int price) {
if (discountType.equals("Member")) {
return price - 500;
} else if (discountType.equals("Event")) {
return price - 1000;
}
return price;
}
}
변경 후 ⬇️
interface DiscountPolicy {
int applyDiscount(int price);
}
class MemberDiscount implements DiscountPolicy {
public int applyDiscount(int price) {
return price - 500;
}
}
class EventDiscount implements DiscountPolicy {
public int applyDiscount(int price) {
return price - 1000;
}
}
class DiscountCalculator {
public int calculate(DiscountPolicy discountPolicy, int price) {
return discountPolicy.applyDiscount(price);
}
}
변경에 따른 영향 최소화
- 새로운 할인 정책이 추가되어도 기존 DiscountCalculator 수정없이(closed) 새로운 할인 클래스를 구현(open)할 수 있다.
3️⃣ 리스코프 치환 원칙(LSP, Liskov Subsitution principle)
- 상속구조에서 부모 클래스 인스턴스를 자식 클래스의 인스턴스로 치환이 가능해야 한다.
- 즉, 자식 클래스는 부모 클래스가 하던 책임을 그대로 준수를 해야 되며, 부모 클래스의 행동을 변경하지 않아야 된다.
- 자식 클래스는 부모 클래스를 상속하는 입장이니 부모 클래스가 하던 책임을 그대로 가져가면서, 추가적으로 뭔가를 더 해야 함.
- 따라서 기존 부모 클래스 타입이 자식 클래스 타입으로 변경되어도 오동작하지 않아야 한다.
(예상 밖의 시나리오 발생, 혹은 오동작 방지를 위한 불필요한 타입 체크 로직 등)
예를 들어 카페의 여러 음료에 대해 생각해보자.
변경 전 ⬇️
// 음료 클래스
public class Beverage {
private String name;
private int price;
public Beverage(String name, int price) {
this.name = name;
this.price = price;
}
public int getPrice() {
return price;
}
public void display() {
System.out.println(name + ": " + price + "원");
}
}
// 이달의 음료 클래스
public class SpecialBeverageOfTheMonth extends Beverage {
public Coffee(String name, int price) {
super(name, price);
}
// 할인 가격 계산 (기존 메서드 동작 변경)
@Override
public int getPrice() {
return super.getPrice() / 2;
}
}
변경 후 ⬇️
// 음료 기본 클래스
public class Beverage {
private String name;
private int price;
public Beverage(String name, int price) {
this.name = name;
this.price = price;
}
public int getPrice() {
return price;
}
public void display() {
System.out.println(name + ": " + price + "원");
}
}
// 이달의 음료 클래스
public class SpecialBeverage extends Beverage {
private int discount;
public SpecialBeverage(String name, int price, int discount) {
super(name, price);
this.discount = discount;
}
// 할인 가격 반환
public int getDiscountedPrice() {
return super.getPrice() - discount;
}
}
변경에 따른 영향 최소화
- SpecialBeverage의 할인 가격은 Beverage 클래스의 동작을 변경하지 않고, 별도의 메서드로 추가하여 부모 클래스와 자식 클래스가 교체 가능하며, 예상치 못한 동작을 방지할 수 있다.
4️⃣ 인터페이스 분리 원칙(ISP, Interface Segregation Principle)
- 사용하지 않는 인터페이스에 의존하면 안 된다.
-> 인터페이스를 잘게 쪼개라!- 🚨 사용하지 않는 인터페이스에 의존하게 될 경우, 불필요한 의존성으로 인해 결합도가 높아지며
특정 기능의 변경이 여러 클래스에 영향을 미칠 수 있음
- 🚨 사용하지 않는 인터페이스에 의존하게 될 경우, 불필요한 의존성으로 인해 결합도가 높아지며
예를 들어 음료 준비과정에 대해 생각해보자.
변경 전 ⬇️
// 음료 인터페이스
public interface BeveragePrepable {
void addMilk(); // 우유 추가
void addShot(); // 샷 추가
}
// 아메리카노 클래스
public class Americano implements BeveragePrepable {
@Override
public void addMilk() {
throw new UnsupportedOperationException("아메리카노는 우유를 추가하지 않습니다.");
}
@Override
public void addShot() {
System.out.println("샷을 추가합니다.");
}
}
변경 후 ⬇️
// 우유 추가 인터페이스
public interface MilkAddable {
void addMilk();
}
// 샷 추가 인터페이스
public interface ShotAddable {
void addShot();
}
// 아메리카노 클래스
public class Americano implements ShotAddable {
@Override
public void addShot() {
System.out.println("샷을 추가합니다.");
}
}
변경에 따른 영향 최소화
- 변경 전에는 Americano 클래스가 자신과 무관한 addMilk() 메서드를 구현해야하여 불필요한 의존성이 생겼었다.
만약 addMilk() 메서드와 관련한 수정사항이 생겼을 경우, 불필요한 의존성으로 인해 수정하지 않아도 되는 Americano 클래스에도 영향이 가는 것이다. - 따라서 각 음료가 필요한 인터페이스만 의존할 수 있도록 인터페이스를 분리해줌으로써 유지보수성을 높여줄 수 있다.
5️⃣ 의존관계 역전 원칙(DIP, Dependency Inversion Principle)
- 구체화가 아닌 추상화에 의존해야 한다.
- 즉, 상위 수준의 모듈은 변경 가능성이 높은 하위 수준의 모듈에 의존해서는 안되며,
하위 클래스가 인터페이스, 추상 클래스 등 변경 가능성이 낮은 추상화에 의존해야 한다.
변경 전 ⬇️
// 카드 결제 방식 클래스
class CardPayment {
public void pay(int amount) {
System.out.println("카드로 " + amount + "원을 결제합니다.");
}
}
class Kiosk {
private CardPayment payment;
public Kiosk() {
this.payment = new CardPayment();
}
public void processPayment(int amount) {
payment.pay(amount);
}
}
변경 후 ⬇️
// 결제 방식 인터페이스
interface Payment {
void pay(int amount);
}
// 카드 결제 방식
class CardPayment implements Payment {
public void pay(int amount) {
System.out.println("카드로 " + amount + "원을 결제합니다.");
}
}
// 현금 결제 방식
class CashPayment implements Payment {
public void pay(int amount) {
System.out.println("현금으로 " + amount + "원을 결제합니다.");
}
}
class Kiosk {
private Payment payment;
public Kiosk(Payment payment) {
this.payment = payment;
}
public void processPayment(int amount) {
payment.pay(amount);
}
}
변경에 따른 영향 최소화
- Kiosk가 특정 결제 방식에 의존하지 않고 Payment 인터페이스에 의존하도록 설계해 유연성을 높일 수 있다.
코너속의 코너
- DIP(Dependency Inversion Principle): 고수준 모듈과 저수준 모듈이 모두 구체적인 구현이 아닌 추상화에 의존하는 것을 의미한다.
- DI(Dependency Injection): 객체 간의 의존성을 외부에서 주입받는 방식을 의미한다.
- Ioc(Inversion of Control): 제어의 역전으로 프로그램의 흐름을 개발자가 아닌 프레임워크가 관리.
-> 런타임 시점에 스프링 컨테이너라는 제3자가 항상 두 객체 간 의존성을 맺어주면서 객체의 생명주기를 관리한다.
⭐️ 한줄정리
- SRP: 하나의 클래스는 하나의 책임만 가져야 한다.
- OCP: 확장에는 열려 있고, 수정에는 닫혀 있어야 한다.
- LSP: 부모 클래스 인스턴스를 자식 클래스의 인스턴스로 치환이 가능해야 한다.
- ISP: 사용하지 않는 인터페이스에 의존하면 안 된다.
- DIP: 구체화가 아닌 추상화에 의존해라.
Reference
Readable Code: 읽기 좋은 코드 섹션4
Readable Code: 읽기 좋은 코드를 작성하는 사고법 강의 | 박우빈 - 인프런
박우빈 | , [사진]저 사람은 코드를 되게 잘 짜네. 어떻게 저런 코드를 작성하는 걸까? 🤔어떤 사람의 코드를 보고 '와 잘 짰다' 라고 느낄 때가 있습니다.우리가 '코드를 잘 짠다' 라고 표현하는
www.inflearn.com