paint-brush
Java의 SOLID 원칙: 초보자 가이드~에 의해@ps06756
14,604 판독값
14,604 판독값

Java의 SOLID 원칙: 초보자 가이드

~에 의해 Pratik Singhal14m2024/03/22
Read on Terminal Reader

너무 오래; 읽다

SOLID 원칙은 확장 가능한 소프트웨어를 개발하는 데 필수적인 객체 지향 프로그래밍의 원칙입니다. 원칙은 다음과 같습니다. S: 단일 책임 원칙 O: 개방형/폐쇄형 원리 L: 리스코프의 대체 원리 I: 인터페이스 분리 원칙 D: 종속성 반전 원리
featured image - Java의 SOLID 원칙: 초보자 가이드
Pratik Singhal HackerNoon profile picture
0-item

SOLID 디자인 원칙은 깔끔한 코드를 작성하기 위해 알아야 할 가장 중요한 디자인 원칙입니다. SOLID 원칙에 대한 확실한 명령을 갖는 것은 모든 프로그래머에게 없어서는 안 될 기술입니다. 이는 다른 디자인 패턴이 개발되는 기초입니다.


이 기사에서는 실제 사례를 사용하여 SOLID 설계 원칙을 다루고 그 중요성을 이해합니다.


다형성, 추상화, 상속과 함께 SOLID 원칙은 목표 지향 프로그래밍을 잘하는 데 정말 중요합니다.

SOLID 원칙이 중요한 이유는 무엇입니까?

SOLID 원칙은 여러 가지 이유로 중요합니다.


  • SOLID 원칙을 사용하면 깔끔하고 유지 관리 가능한 코드를 작성할 수 있습니다. 새 프로젝트를 시작할 때 구현하는 기능 세트가 제한되어 있으므로 처음에는 코드 품질이 좋습니다. 그러나 더 많은 기능을 통합할수록 코드가 복잡해지기 시작합니다.


  • SOLID 원칙은 추상화, 다형성 및 상속의 기초를 기반으로 하며 일반적인 사용 사례에 대한 디자인 패턴으로 이어집니다. 이러한 디자인 패턴을 이해하면 프로그래밍에서 일반적인 사용 사례를 구현하는 데 도움이 됩니다.


  • SOLID 원칙은 코드의 테스트 가능성을 향상시키는 깔끔한 코드를 작성하는 데 도움이 됩니다. 이는 코드가 모듈식이고 느슨하게 결합되어 있기 때문입니다. 각 모듈은 독립적으로 개발되고 독립적으로 테스트될 수 있습니다.


이제 실제 사례를 통해 각 SOLID 원칙을 자세히 살펴보겠습니다.

1. 단일 책임 원칙

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 클래스를 보면 여러 가지 이유로 변경될 수 있습니다.


  1. SavingsAccount 클래스의 핵심 로직(예: debit , credit 등)에 변경 사항이 있는 경우.


  2. 은행이 새로운 알림 매체(예: 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 클래스도 변경됩니다.


이는 첫 번째 예에서 관찰한 위반 사항을 수정합니다.

2. 개방/폐쇄 원칙

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 , GiftItemElectronicItem 과 같은 각 구체적인 항목 클래스 내에는 과세 및 가치 계산을 위한 비즈니스 논리가 포함된 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() 메서드가 호출됩니다.

3. 리스코프의 대체 원리

Liskov의 대체 원칙은 주어진 코드에서 상위 클래스의 객체를 하위 클래스의 객체로 대체하더라도 코드가 중단되어서는 안 된다는 것입니다. 즉, 하위 클래스가 상위 클래스를 상속하고 해당 메서드를 재정의할 때 상위 클래스의 메서드 동작과 일관성을 유지해야 합니다.


예를 들어 다음 Vehicle 클래스와 CarBicycle 클래스 두 개를 만든다고 가정해 보겠습니다. 이제 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의 대체 원칙)을 위반하는 것입니다.


이 문제를 해결하기 위해 MotorizedVehicleNonMotorizedVehicle 두 클래스를 만들고 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. } }


4. 인터페이스 분리 원칙

SOLID 원칙의 "I"는 인터페이스 분리 원칙을 나타냅니다.


인터페이스 분리 원칙은 구현 클래스가 사용되지 않는 메소드를 구현하도록 강제하는 더 큰 인터페이스를 갖기보다는 더 작은 인터페이스를 갖고 클래스를 구현해야 한다고 명시합니다. 이런 방식으로 클래스는 관련 메서드만 구현하고 깔끔하게 유지됩니다.

하나의 큰 인터페이스가 아닌 여러 개의 작은 인터페이스로 인터페이스를 나눕니다.

예를 들어 Java에 내장된 컬렉션 프레임워크를 살펴보겠습니다. 다른 데이터 구조 중에서 Java는 LinkedListArrayList 데이터 구조도 제공합니다.


ArrayList 클래스는 Serializable , Cloneable , Iterable , Collection , ListRandomAccess 인터페이스를 구현합니다.

LinkedList 클래스는 Serializable , Cloneable , Iterable , Collection , Deque , ListQueue 구현합니다.


정말 많은 인터페이스가 있습니다!


Java 개발자는 너무 많은 인터페이스를 사용하는 대신 Serializable , Cloneable , Iterable , Collecton , ListRandomAccess 하나의 인터페이스, 즉 IList 인터페이스로 결합할 수 있었습니다. 이제 ArrayListLinkedList 클래스 모두 이 새로운 IList 인터페이스를 구현할 수 있었습니다.


그러나 LinkedList 임의 액세스를 지원하지 않기 때문에 RandomAccess 인터페이스에서 메서드를 구현했을 수 있으며 누군가 호출을 시도할 때 UnsupportedOperationException 발생했을 수 있습니다.


그러나 이는 필요하지 않더라도 LinkedList 클래스가 RandomAccess 인터페이스 내부에 메서드를 구현하도록 "강제"하므로 SOLID 원칙의 인터페이스 분리 원칙을 위반하는 것입니다.


따라서 공통 동작에 따라 인터페이스를 분할하고 각 클래스가 하나의 큰 인터페이스보다는 여러 개의 인터페이스를 구현하도록 하는 것이 좋습니다.

종속성 반전 원리

종속성 역전 원칙은 상위 수준의 클래스가 하위 수준의 클래스에 직접 종속되어서는 안 된다는 것을 명시합니다. 이로 인해 두 레벨 간에 긴밀한 결합이 발생합니다.


그 대신 하위 클래스는 상위 클래스가 의존해야 하는 인터페이스를 제공해야 합니다.


클래스보다는 인터페이스에 의존


예를 들어, 위에서 본 Cart 예제를 계속해서 몇 가지 결제 옵션을 추가하도록 개선해 보겠습니다. DebitCardPaypal 에 두 가지 유형의 결제 옵션이 있다고 가정해 보겠습니다. 이제 Cart 클래스에서 장바구니 값을 계산하고 제공된 결제에 따라 결제를 시작하는 placeOrder 메서드를 추가하려고 합니다. 방법.


이를 위해 Cart 클래스 내부에 두 가지 결제 옵션을 필드로 추가하여 위의 Cart 예제에 종속성을 추가할 수 있었습니다. 그러나 이렇게 하면 Cart 클래스가 DebitCardPaypal 클래스와 긴밀하게 결합됩니다.


그 대신 Payment 인터페이스를 만들고 DebitCardPaypal 클래스 모두 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 디자인 패턴 과정을 확인하세요.