SOLID 디자인 원칙은 깔끔한 코드를 작성하기 위해 알아야 할 가장 중요한 디자인 원칙입니다. SOLID 원칙에 대한 확실한 명령을 갖는 것은 모든 프로그래머에게 없어서는 안 될 기술입니다. 이는 다른 디자인 패턴이 개발되는 기초입니다.
이 기사에서는 실제 사례를 사용하여 SOLID 설계 원칙을 다루고 그 중요성을 이해합니다.
다형성, 추상화, 상속과 함께 SOLID 원칙은 목표 지향 프로그래밍을 잘하는 데 정말 중요합니다.
SOLID 원칙은 여러 가지 이유로 중요합니다.
이제 실제 사례를 통해 각 SOLID 원칙을 자세히 살펴보겠습니다.
SOLID 원칙의 S는 단일 책임 원칙을 나타냅니다. 단일 책임 원칙은 클래스가 변경해야 하는 단 하나의 이유만 가져야 한다고 명시합니다. 이는 프로젝트에 추가 요구 사항을 통합할 때 변경해야 하는 위치의 수를 제한합니다.
각 클래스에는 변경해야 할 이유가 정확히 하나만 있어야 합니다.
예를 들어, 직불, 신용 및 sendUpdates
와 같은 기본 작업을 허용하는 SavingsAccount
클래스가 있는 Java로 뱅킹 애플리케이션을 설계한다고 가정해 보겠습니다. sendUpdate
메소드는 NotificationMedium
(예: 이메일, SMS 등)이라는 열거형을 사용하여 적절한 매체와 함께 업데이트를 보냅니다. 이에 대한 코드를 아래와 같이 작성하겠습니다.
public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void sendNotification(NotificationMedium medium) { if (medium == NotificationMedium.SMS) { // Send SMS here } else if (medium == NotificationMedium.EMAIL) { // Send Email here } } }
public enum NotificationMedium { SMS, EMAIL }
이제 위의 SavingsAccount
클래스를 보면 여러 가지 이유로 변경될 수 있습니다.
SavingsAccount
클래스의 핵심 로직(예: debit
, credit
등)에 변경 사항이 있는 경우.
은행이 새로운 알림 매체(예: WhatsApp)를 도입하기로 결정한 경우.
이는 SOLID 원칙의 단일 책임 원칙을 위반하는 것입니다. 이 문제를 해결하기 위해 알림을 보내는 별도의 클래스를 만들겠습니다.
SOLID 원칙에 따라 위의 코드를 리팩터링해 보겠습니다.
public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void printBalance() { System.out.println("Name: " + name+ " Account Balance: " + balance); } public void sendNotification(Medium medium) { Sender.sendNotification(medium, this); } }
public enum NotificationMedium { SMS, EMAIL }
public class Sender { public static void sendNotification(NotificationMedium medium, SavingsAccount account) { // extract account data from the account object if (medium == NotificationMedium.SMS) { //logic to send SMS here } else if (medium == NotificationMedium.EMAIL) { // logic to send Email here } } }
이제 코드를 리팩터링했으므로 NotificationMedium
이나 형식이 변경되면 Sender
클래스를 변경하겠습니다. 그러나 SavingsAccount
의 핵심 로직이 변경되면 SavingsAccount
클래스도 변경됩니다.
이는 첫 번째 예에서 관찰한 위반 사항을 수정합니다.
Open Close 원칙은 클래스가 확장(추가 기능을 추가하는 경우)에는 열려 있지만 수정에는 닫혀 있도록 클래스를 디자인해야 한다고 명시합니다. 수정을 위해 폐쇄되면 다음 두 가지 면에서 도움이 됩니다.
Open Close 원칙의 예를 보려면 전자상거래 웹사이트에서 구현된 것과 같은 장바구니의 예를 살펴보겠습니다.
추가할 수 있는 Item
목록이 포함된 Cart
라는 클래스를 만들겠습니다. 항목 유형과 그에 대한 과세에 따라 Cart
클래스 내에서 총 장바구니 값을 계산하는 메서드를 만들고 싶습니다.
수업은 연장을 위해 열려야 하고 수정을 위해 닫혀야 합니다.
import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart() { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { if (item.getItemType() == GIFT) { // 8% tax on gift + 2% gift wrap cost value += (item.getValue()*1.08) + item.getValue()*0.02 } else if (item.getItemType() == ItemType.ELECTRONIC_ITEM) { value += (item.getValue()*1.11); } else { value += item.getValue()*1.10; } } return value; } }
@Getter @Setter public abstract class Item { protected double price; private ItemType itemType; public double getValue() { return price; } }
public enum ItemType { ELECTRONIC, GIFT }
위의 예에서 calculateCartValue
메소드는 카트 내부의 모든 항목을 반복하고 항목 유형에 따라 로직을 호출하여 카트 값을 계산합니다.
이 코드는 정확해 보이지만 SOLID 원칙을 위반합니다.
장바구니 값을 계산하는 동안 다른 유형의 항목(예: 식료품)에 대한 새 규칙을 추가해야 한다고 가정해 보겠습니다. 이 경우 원래 클래스인 Cart
수정하고 그 안에 Grocery
유형의 항목을 확인하는 또 다른 else if
조건을 작성해야 합니다.
그러나 리팩토링을 거의 하지 않으면 코드가 열기/닫기 원칙을 준수하도록 만들 수 있습니다. 방법을 살펴보겠습니다.
먼저, Item
클래스를 추상화하고 아래와 같이 다양한 유형의 Item
에 대한 구체적인 클래스를 만듭니다.
public abstract class Item { protected double price; public double getValue() { return price; } }
public class ElectronicItem extends Item { public ElectronicItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.11; } }
public class GiftItem extends Item { public GiftItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.08 + super.getValue()*0.02; } }
public class GroceryItem extends Item { public GroceryItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.03; } }
GroceryItem
, GiftItem
및 ElectronicItem
과 같은 각 구체적인 항목 클래스 내에는 과세 및 가치 계산을 위한 비즈니스 논리가 포함된 getValue()
메서드를 구현합니다.
이제 아래와 같이 Cart
클래스를 추상 클래스 Item
에 종속시키고 각 항목에 대해 getValue()
메서드를 호출하겠습니다.
import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } }
이제 이 리팩터링된 코드에서는 새로운 유형의 Item
도입되더라도 Cart
클래스는 변경되지 않습니다. 다형성으로 인해 items
ArrayList
내부에 있는 Item
의 실제 유형이 무엇이든 해당 클래스의 getValue()
메서드가 호출됩니다.
Liskov의 대체 원칙은 주어진 코드에서 상위 클래스의 객체를 하위 클래스의 객체로 대체하더라도 코드가 중단되어서는 안 된다는 것입니다. 즉, 하위 클래스가 상위 클래스를 상속하고 해당 메서드를 재정의할 때 상위 클래스의 메서드 동작과 일관성을 유지해야 합니다.
예를 들어 다음 Vehicle
클래스와 Car
및 Bicycle
클래스 두 개를 만든다고 가정해 보겠습니다. 이제 Vehicle 클래스 내에 startEngine()
이라는 메서드를 생성한다고 가정해 보겠습니다. 이 메서드는 Car
클래스에서 재정의될 수 있지만 Bicycle
에는 엔진이 없으므로 Bicycle
클래스에서는 지원되지 않습니다(아래 코드 샘플 참조).
하위 클래스는 메서드를 재정의할 때 상위 클래스의 동작과 일관성을 유지해야 합니다.
class Vehicle { public void startEngine() { // start engine of the vehicle } } class Car extends Vehicle { @Override public void startEngine() { // Start Engine } } class Bicycle extends Vehicle { @Override public void startEngine(){ throw new UnsupportedOperationException("Bicycle doesn't have engine"); } }
이제 vehicle 유형의 객체를 기대하고 startEngine()
메서드에 의존하는 일부 코드가 있다고 가정해 보겠습니다. Vehicle
유형의 객체를 전달하는 대신 해당 코드 조각을 호출하는 동안 Bicycle
객체를 전달하면 코드에 문제가 발생할 수 있습니다. Bicycle
(s) 클래스의 메소드는 startEngine()
메소드가 호출될 때 예외를 발생시킵니다. 이는 SOLID 원칙(Liskov의 대체 원칙)을 위반하는 것입니다.
이 문제를 해결하기 위해 MotorizedVehicle
과 NonMotorizedVehicle
두 클래스를 만들고 Car
MotorizedVehicle
클래스에서 상속되고 Bicycle
NonMotorizedVehicle
에서 상속되도록 할 수 있습니다.
class Vehicle { } class MotorizedVehicle extends Vehicle { public void startEngine() { // start engine here } } class Car extends MotorizedVehicle { @Override public void startEngine() { // Start Engine } } class NonMotorizedVehicle extends Vehicle { public void startRiding() { // Start without engine } } class Bicycle extends NonMotorizedVehicle { @Override public void startRiding(){ // Start riding without the engine. } }
SOLID 원칙의 "I"는 인터페이스 분리 원칙을 나타냅니다.
인터페이스 분리 원칙은 구현 클래스가 사용되지 않는 메소드를 구현하도록 강제하는 더 큰 인터페이스를 갖기보다는 더 작은 인터페이스를 갖고 클래스를 구현해야 한다고 명시합니다. 이런 방식으로 클래스는 관련 메서드만 구현하고 깔끔하게 유지됩니다.
하나의 큰 인터페이스가 아닌 여러 개의 작은 인터페이스로 인터페이스를 나눕니다.
예를 들어 Java에 내장된 컬렉션 프레임워크를 살펴보겠습니다. 다른 데이터 구조 중에서 Java는 LinkedList
및 ArrayList
데이터 구조도 제공합니다.
ArrayList
클래스는 Serializable
, Cloneable
, Iterable
, Collection
, List
및 RandomAccess
인터페이스를 구현합니다.
LinkedList
클래스는 Serializable
, Cloneable
, Iterable
, Collection
, Deque
, List
및 Queue
구현합니다.
정말 많은 인터페이스가 있습니다!
Java 개발자는 너무 많은 인터페이스를 사용하는 대신 Serializable
, Cloneable
, Iterable
, Collecton
, List
및 RandomAccess
하나의 인터페이스, 즉 IList
인터페이스로 결합할 수 있었습니다. 이제 ArrayList
및 LinkedList
클래스 모두 이 새로운 IList
인터페이스를 구현할 수 있었습니다.
그러나 LinkedList
임의 액세스를 지원하지 않기 때문에 RandomAccess
인터페이스에서 메서드를 구현했을 수 있으며 누군가 호출을 시도할 때 UnsupportedOperationException
발생했을 수 있습니다.
그러나 이는 필요하지 않더라도 LinkedList 클래스가 RandomAccess
인터페이스 내부에 메서드를 구현하도록 "강제"하므로 SOLID 원칙의 인터페이스 분리 원칙을 위반하는 것입니다.
따라서 공통 동작에 따라 인터페이스를 분할하고 각 클래스가 하나의 큰 인터페이스보다는 여러 개의 인터페이스를 구현하도록 하는 것이 좋습니다.
종속성 역전 원칙은 상위 수준의 클래스가 하위 수준의 클래스에 직접 종속되어서는 안 된다는 것을 명시합니다. 이로 인해 두 레벨 간에 긴밀한 결합이 발생합니다.
그 대신 하위 클래스는 상위 클래스가 의존해야 하는 인터페이스를 제공해야 합니다.
클래스보다는 인터페이스에 의존
예를 들어, 위에서 본 Cart
예제를 계속해서 몇 가지 결제 옵션을 추가하도록 개선해 보겠습니다. DebitCard
와 Paypal
에 두 가지 유형의 결제 옵션이 있다고 가정해 보겠습니다. 이제 Cart
클래스에서 장바구니 값을 계산하고 제공된 결제에 따라 결제를 시작하는 placeOrder
메서드를 추가하려고 합니다. 방법.
이를 위해 Cart
클래스 내부에 두 가지 결제 옵션을 필드로 추가하여 위의 Cart
예제에 종속성을 추가할 수 있었습니다. 그러나 이렇게 하면 Cart
클래스가 DebitCard
및 Paypal
클래스와 긴밀하게 결합됩니다.
그 대신 Payment
인터페이스를 만들고 DebitCard
및 Paypal
클래스 모두 Payment
인터페이스를 구현하도록 합니다. 이제 Cart
클래스는 개별 결제 유형이 아닌 Payment
인터페이스에 따라 달라집니다. 이렇게 하면 클래스가 느슨하게 결합됩니다.
아래 코드를 참조하세요.
public interface Payment { void doPayment(double amount); } public class PaypalPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate paypal payment } } public class DebitCardPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate payment via debit card } } import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; private Payment paymentOption; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); this.paymentOption = paymentOption; } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } public void placeOrder() { this.paymentOption.doPayment(calculateCartValue()); } }
객체 지향 프로그래밍, GoF 디자인 패턴 및 하위 수준 디자인 인터뷰에 대해 더 자세히 알아보고 싶다면 내 높은 평가를 받는 객체 지향 프로그래밍 + Java 디자인 패턴 과정을 확인하세요.