💻 Backend
SOLID 원칙: 소프트웨어 개발을 위한 핵심 원칙 5가지 with Java
SOLID 원칙이란?단일 책임 원칙(Single Responsibility Principle, SRP)개방-폐쇄 원칙(Open-Closed Principle, OCP) 리스코프 치환 원칙(Liskov Substitution Principle, LSP)인터페이스 분리 원칙 (Interface Segregation Principle, ISP)의존 역전 원칙 (Dependency Inversion Principle, DIP)
이 포스팅에서는 SOLID 원칙을 이해하고 적용한 Java 예시 코드와 함께 알아봅니다 🙂
SOLID 원칙이란?
SOLID는 소프트웨어 개발의 다섯 가지 기본 원칙을 나타내는 앞글자들로 구성된 약어입니다.
SOLID 원칙은 소프트웨어 설계와 개발을 향상시키고 유지보수성을 높이기 위한 지침을 제공합니다.
SOLID 원칙을 준수함으로써 아래와 같은 이점을 얻을 수 있습니다.
- 유지보수성과 확장성 향상
- SOLID 원칙을 준수하는 코드는 변경에 유연하고 확장이 쉽습니다. 코드의 한 부분을 수정하거나 새로운 기능을 추가할 때, 다른 부분에 미치는 영향을 최소화하면서 변경이나 확장을 수행할 수 있습니다.
- 재사용성 증대
- SOLID 원칙을 잘 지킨 코드는 재사용성이 높아집니다. 단일 책임 원칙을 준수하고 인터페이스 분리 원칙을 적용한 코드는 모듈이나 클래스를 다른 프로젝트나 시스템에서 재사용할 수 있습니다.
- 가독성과 이해도 향상
- SOLID 원칙을 준수하는 코드는 응집성이 높아지고 의존성이 명확해집니다. 이로 인해 코드의 가독성과 이해도가 향상되어 다른 개발자들이 코드를 쉽게 이해하고 유지보수할 수 있습니다.
- 유연한 아키텍처 구축
- SOLID 원칙은 좋은 소프트웨어 아키텍처를 구축하는 기반이 됩니다. 단일 책임 원칙과 인터페이스 분리 원칙은 모듈 간의 결합도를 낮추고 응집도를 높여 모듈화된 아키텍처를 설계하는 데 도움을 줍니다.
- 테스트 용이성
- SOLID 원칙을 준수하는 코드는 테스트하기 쉽습니다. 단일 책임 원칙과 의존 역전 원칙은 의존성 주입(Dependency Injection)과 같은 테스트 용이한 패턴을 적용하기 쉽게 해줍니다.
단일 책임 원칙(Single Responsibility Principle, SRP)
단일 책임 원칙은 클래스나 모듈은 하나의 책임만을 가져야 한다는 원칙입니다.
이를 통해 코드를 더 작고 이해하기 쉽게 유지할 수 있습니다.
클래스가 변경되어야 하는 이유는 단 하나여야합니다.
Java는 클래스, 인터페이스, 패키지 등의 구조를 통해 책임을 분리하고 모듈화하여 SRP를 준수하는 코드를 작성할 수 있습니다.
public class PaymentService { public void processPayment(PaymentInfo paymentInfo) { // 결제 처리 로직 } } public class NotificationService { public void sendNotification(String message) { // 알림 전송 로직 } }
개방-폐쇄 원칙(Open-Closed Principle, OCP)
기존의 코드를 수정하지 않으면서 기능을 추가할 수 있도록 설계해야한다는 원칙입니다.
새로운 기능이 추가되더라도 기존의 코드를 변경하지 않고, 인터페이스나 추상 클래스 등을 활용하여 새로운 기능을 추가할 수 있도록 하는 것이 좋습니다.
public class PaymentRequest { // 결제 요청 정보 필드들... } public interface RequestBuilder { PaymentRequest build(PaymentInfo paymentInfo); } public class CreditCardRequestBuilder implements RequestBuilder { public PaymentRequest build(PaymentInfo paymentInfo) { // 신용카드 결제 요청 정보 구성 // ... return paymentRequest; } } public class PayPalRequestBuilder implements RequestBuilder { public PaymentRequest build(PaymentInfo paymentInfo) { // PayPal 결제 요청 정보 구성 // ... return paymentRequest; } } public class ExternalPaymentApi { private final Map<PaymentType, RequestBuilder> requestBuilders; public ExternalPaymentApi() { requestBuilders = new HashMap<>(); requestBuilders.put(PaymentType.CREDIT_CARD, new CreditCardRequestBuilder()); requestBuilders.put(PaymentType.PAYPAL, new PayPalRequestBuilder()); // 다른 결제 타입에 대한 RequestBuilder 추가 } public void processPayment(PaymentType paymentType, PaymentInfo paymentInfo) { RequestBuilder requestBuilder = requestBuilders.get(paymentType); if (requestBuilder != null) { PaymentRequest paymentRequest = requestBuilder.build(paymentInfo); // 외부 결제 API 호출 로직 } else { // 처리할 수 없는 결제 타입에 대한 예외 처리 또는 기본 처리 로직 } } }
ExternalPaymentApi 클래스는 RequestBuilder 인터페이스를 도입하여 결제 타입별로 RequestBuilder 구현체를 사용하여 결제 요청 정보를 구성합니다. 이렇게 하면 새로운 결제 타입이 추가되어도 기존 코드를 수정하지 않고도 새로운 RequestBuilder를 추가하여 결제 요청 정보를 구성할 수 있습니다.
또한, ExternalPaymentApi 클래스는 결제 타입별로 다른 로직을 처리하기 위해 RequestBuilder 인터페이스에 의존하고 있습니다. 이를 통해 기존 코드를 수정하지 않고도 새로운 결제 타입에 대한 처리 로직을 추가할 수 있습니다. 즉, 외부로부터의 확장은 열려있지만, 기존 코드의 수정은 닫혀있는 상태를 유지하면서 새로운 기능을 추가할 수 있는 유연성을 제공합니다.
리스코프 치환 원칙(Liskov Substitution Principle, LSP)
자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다는 원칙입니다.
자식 클래스는 부모 클래스에서 정의한 모든 기능을 수행할 수 있어야 합니다.
자식 클래스는 부모 클래스의 제약 조건을 더 강화하거나 변경해서는 안 됩니다.
부모 클래스에서 정의한 사전 조건(precondition), 사후 조건(postcondition), 클래스 불변식(class invariant) 등을 자식 클래스에서 변경하거나 약화시키면 안됩니다.
자식 클래스에서는 부모 클래스가 기대하는 동작을 유지하고, 추가적인 기능을 제공하는 방식으로 확장해야 합니다.
리스코프 치환 원칙을 준수함으로써 아래와 같은 이점을 얻을 수 있습니다.
- 대체 가능성 유지
- 자식 클래스는 부모 클래스의 역할을 대체할 수 있습니다. 이는 클라이언트 코드에서 부모 클래스 타입으로 자식 클래스 객체를 사용하여도 문제없이 동작함을 의미합니다.
- 모듈성과 유연성
- 새로운 자식 클래스를 추가하거나 기존의 자식 클래스를 변경할 때, 기존의 코드를 수정하지 않고도 대체할 수 있습니다. 이는 소프트웨어 시스템을 확장하고 유지보수하는 데 유리한 구조를 제공합니다.
- 다형성과 확장성
- 리스코프 치환 원칙을 준수하면 다형성을 활용할 수 있습니다. 클라이언트 코드는 부모 클래스 타입을 사용하여 여러 종류의 자식 클래스를 다룰 수 있으며, 새로운 자식 클래스를 추가하여 시스템의 기능을 확장할 수 있습니다.
따라서, 리스코프 치환 원칙을 준수함으로써 코드의 재사용성, 모듈성, 유지보수성, 확장성을 향상시킬 수 있습니다.
인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
인터페이스는 클라이언트에서 필요한 기능만 포함하도록 분리하는 것이 좋습니다.
하나의 인터페이스가 너무 많은 기능을 가지고 있으면, 해당 인터페이스를 구현하는 클래스들은 불필요한 기능도 함께 구현해야 하므로 유지보수가 어려워집니다.
// Payment 인터페이스 public interface Payment { void processPayment(); } // Refundable 인터페이스 public interface Refundable { void processRefund(); } // 신용카드 결제 클래스 public class CreditCardPayment implements Payment { public void processPayment() { // 신용카드 결제 처리 로직 } } // 환불 처리 클래스 public class RefundProcessor implements Refundable { public void processRefund() { // 환불 처리 로직 } }
의존 역전 원칙 (Dependency Inversion Principle, DIP)
고수준 모듈은 저수준 모듈의 구현에 의존해서는 안된다는 원칙입니다.
추상화 된 인터페이스에 의존해야 하며, 구체적인 구현에는 의존하지 않아야합니다.
public enum PaymentType { CREDIT_CARD, PAYPAL, // 다른 결제 타입들... } public class PaymentInfo { private PaymentType paymentType; // 결제 정보 관련 필드들... } public interface PaymentProcessor { void processPayment(PaymentInfo paymentInfo); } public class CreditCardPaymentProcessor implements PaymentProcessor { public void processPayment(PaymentInfo paymentInfo) { // 신용카드 결제 처리 로직 } } public class PayPalPaymentProcessor implements PaymentProcessor { public void processPayment(PaymentInfo paymentInfo) { // PayPal 결제 처리 로직 } } public class PaymentService { private final Map<PaymentType, PaymentProcessor> paymentProcessors; public PaymentService() { paymentProcessors = new HashMap<>(); paymentProcessors.put(PaymentType.CREDIT_CARD, new CreditCardPaymentProcessor()); paymentProcessors.put(PaymentType.PAYPAL, new PayPalPaymentProcessor()); // 다른 결제 타입에 대한 처리 클래스들 추가 } public void processPayment(PaymentInfo paymentInfo) { PaymentProcessor paymentProcessor = paymentProcessors.get(paymentInfo.getPaymentType()); if (paymentProcessor != null) { paymentProcessor.processPayment(paymentInfo); } else { // 처리할 수 없는 결제 타입에 대한 예외 처리 또는 기본 처리 로직 } } }
위의 코드에서 PaymentService는 PaymentProcessor 인터페이스에 의존하고 있습니다. PaymentProcessor 인터페이스는 실제 결제 처리 로직을 담당하는 구현체들(CreditCardPaymentProcessor, PayPalPaymentProcessor)에 의존됩니다. 이렇게 인터페이스에 의존함으로써 PaymentService는 구체적인 구현체에 대한 의존성을 가지지 않고, 추상화된 인터페이스에 의존하게 됩니다. 이는 의존성 역전 원칙을 잘 지키는 것입니다.
PaymentService의 생성자에서 결제 타입에 따른 PaymentProcessor 구현체를 맵으로 관리합니다. 이렇게 맵에 결제 타입과 처리 클래스를 등록함으로써 PaymentService는 동적으로 적절한 처리 클래스를 선택할 수 있습니다. 이는 OCP(Open-Closed Principle)를 준수하면서도 결제 타입이 추가되거나 변경되더라도 PaymentService 코드의 수정이 필요 없음을 의미합니다.
이렇게 인터페이스와 구현체를 사용하여 의존성을 역전시킴으로써 결제 처리 로직과 구체적인 구현체 간의 결합도를 낮추고, 유연성과 확장성을 제공합니다. 또한, 인터페이스를 통해 다른 구현체를 쉽게 주입할 수 있으므로 테스트 용이성이 향상됩니다.