모노산달로스의 행보
[Spring] SOLID 5원칙이란? 본문
Spring - SOLID principles
스프링은 프레임워크로 현대 자바 기반 애플리케이션을 위한 종합 프로그래밍과 환경설정 모델을 제공합니다. 스프링의 중요한 요소는 앱 단계에서 인프라 지원입니다. 즉, 개발자가 앱 단계의 비즈니스 로직에 집중할 수 있도록 만들어줍니다. 결론적으로 스프링은 자바 프로그래밍을 빠르고 쉽고 안전하게 만들어줍니다. 이러한 이점 때문에 많은 사용자를 보유하는데, 특히 한국에서는 백엔드 개발자의 대부분이 스프링을 사용할 정도로 인기가 많습니다.
What is SOLID principles
SOLID 원칙들(SOLID principles)은 2000년 컴퓨터 과학자 Robert J.Martin에 의해 처음 소개되었습니다. 객체지향 클래스 디자인의 다섯 가지 원칙으로, 클래스 구조를 디자인하기 위한 관례와 규칙들을 나타냅니다. 이 원칙들은 개발자가 복잡한 문제들을 작은 조각으로 나누도록 돕습니다. 즉, 느슨하게 결합된 유지보수에 용이한 코드를 작성하도록 돕습니다.
스프링 또한 객체지향 언어인 자바의 프레임워크입니다. 따라서 스프링 프레임워크를 올바르게 사용하기 위해서는 SOLID 원칙을 잘 이해할 필요가 있습니다. 지금부터 5가지의 원칙들을 하나하나 살펴보겠습니다.
단일 책임 원칙(Single-Responsibility Principle)
단일 책임 원칙, 줄여서 SRP는 다음과 같은 의미를 가집니다.
1. 클래스 혹은 메서드는 하나의 이유에 의해서 변화해야 합니다.
2. 하나의 클래스는 하나의 작업을 수행해야 합니다.
클래스 혹은 메서드의 로직을 디자인하는 경우, 한 곳에 모든 책임을 부여해서는 안됩니다. 이는 코드가 작은 변화에도 큰 영향을 받게 만듭니다. 즉, 복잡한 코드가 만들어지고 유지보수가 힘들어집니다.
public class Invoice
{
public void AddInvoice()
{
// your logic
}
public void DeleteInvoice()
{
// your logic
}
public void GenerateReport()
{
// your logic
}
public void EmailReport()
{
// your logic
}
}
예를 들어 위와 같은 청구서를 관리하는 클래스가 존재합니다. 이러한 클래스는 단일 책임 원칙이 잘 지켜졌을까요? 메서드들을 살펴보면 각각 하나의 작업을 수행하는 것을 알 수 있습니다. 예를 들어 AddInvoice() 메서드는 오직 청구서를 추가하는 역할을 수행합니다.
하지만 Invoice클래스의 경우 이야기가 조금 다릅니다. 청구서에 관련된 메서드뿐 아니라 보고서를 생성하고 전송하는 역할까지 모두 수행하고 있습니다. 즉, SRP를 따르기 위해서는 Invoice클래스의 일부 메서드를 다른 클래스로 옮겨야 합니다.
public class Invoice
{
public void AddInvoice()
{
// your logic
}
public void DeleteInvoice()
{
// your logic
}
}
public class Report
{
public void GenerateReport()
{
// your logic
}
}
public class Email
{
public void EmailReport()
{
// your logic
}
}
위와 같이 Report 클래스와 Email 클래스를 생성하여 Invoice 클래스의 역할을 나누어주었습니다. 이제 SRP 원칙을 잘 지키는 코드가 완성되었습니다.
개방 폐쇄 원칙(Open-Closed Principle)
개방 폐쇄 원칙, 줄여서 OCP는 다음과 같은 의미를 가집니다.
1. 소프트웨어의 독립체들(클래스, 모듈, 함수 등)은 확장에는 열려있고 수정에는 닫혀있어야 합니다.
개방 폐쇠 원칙을 지키면 여러 가지 이점을 얻습니다. 새로운 기능을 추가하는 경우 예를 들어봅시다. 우리는 처음부터 기능 개발을 할 필요가 없습니다. 확장에는 열려있기 때문에, 이전 코드를 재활용할 수 있습니다. 거기에 변화에는 닫혀있어, 기존 코드를 수정할 필요가 없습니다. 이는 결국 코드의 유지보수성을 증가시키고, 필요 없는 버그의 발생을 피하도록 돕습니다.
class Footballer {
constructor(name, age, role) {
this.name = name;
this.age = age;
this.role = role;
}
getFootballerRole() {
switch (this.role) {
case 'goalkeeper':
console.log(`The footballer, ${this.name} is a goalkeeper`);
break;
case 'defender':
console.log(`The footballer, ${this.name} is a defender`);
break;
case 'midfielder':
console.log(`The footballer, ${this.name} is a midfielder`);
break;
case 'forward':
console.log(`The footballer, ${this.name} plays in the forward line`);
break;
default:
throw new Error(`Unsupported animal type: ${this.type}`);
}
}
}
const kante = new Footballer('Ngolo Kante', 31, 'midfielder');
const hazard = new Footballer('Eden Hazard', 32, 'forward');
kante.getFootballerRole(); // The footballer, Ngolo Kante is a midfielder
hazard.getFootballerRole(); // The footballer, Eden Hazard plays in the forward line
위와 같이 4가지 역할을 부여할 수 있는 Footballer클래스가 존재합니다. 만약, 위 코드에서 새로운 포지션인 'winger'를 추가하여 5가지 역할을 부여하도록 만든다면 어떨까요? 우리는 switch 구문을 수정해야 합니다. 이는 새로운 기능을 추가하기 위해 기존 코드를 수정하는 것이 됩니다. 즉, 개방 폐쇄 원칙을 위반한 것입니다.
이를 해결하기 위해서 각 역할을 클래스로 분리해야 합니다. 이를 위해 역할을 가져오는 상위 클래스를 만들고 각각의 역할이 이를 상속받도록 만들었습니다.
class Footballer {
constructor(name, age, role) {
this.name = name;
this.age = age;
this.role = role;
}
getRole() {
return this.role.getRole();
}
}
// PlayerRole class uses the getRole method
class PlayerRole {
getRole() {}
}
// Sub classes for different roles extend the PlayerRole class
class GoalkeeperRole extends PlayerRole {
getRole() {
return 'goalkeeper';
}
}
class DefenderRole extends PlayerRole {
getRole() {
return 'defender';
}
}
class MidfieldRole extends PlayerRole {
getRole() {
return 'midfielder';
}
}
class ForwardRole extends PlayerRole {
getRole() {
return 'forward';
}
}
// Putting all of them together
const hazard = new Footballer('Hazard', 32, new ForwardRole());
console.log(`${hazard.name} plays in the ${hazard.getRole()} line`); // Hazard plays in the forward line
const kante = new Footballer('Ngolo Kante', 31, new MidfieldRole());
console.log(`${kante.name} is the best ${kante.getRole()} in the world!`); //Ngolo Kante is the best midfielder in the world!
그 결과 위와 같은 코드가 완성되었습니다. 이제 'winger'라는 새로운 역할을 추가하는 상황을 다시 생각해 봅시다. 우리는 기존 코드를 수정할 필요가 없습니다. 그저 PlayerRole을 상속받는 새로운 WingerRole 클래스를 만들기만 하면 됩니다. 이것이 바로 OCP를 잘 준수한 예시입니다.
리스코프 치환 원칙(Liskov-Substitution Principle)
리스코프 치환 원칙, 줄여서 LSP는 다음과 같은 의미를 가집니다.
1. 상위 클래스의 오브젝트는 프로그램의 정확성에 영향을 주지 않으면서 하위 클래스의 오브젝트를 바꿀 수 있어야 합니다.
2. 하위 클래스는 상위 클래스의 모든 프로퍼티와 메서드에 접근할 수 있어야 합니다.
public class Green {
public void getColor() {
System.out.println("Green");
}
}
public class Blue extends Green {
public void getColor() {
System.out.println("Blue");
}
}
public class Main{
public static void main(String[] args) {
// violate LSP because color of green object is blue
Green green = new Blue();
green.getColor();
//output: Blue
}
}
상위 클래스 Green과 이를 상속받는 Blue클래스가 존재합니다. 하위 클래스인 Blue는 getColor() 메서드를 오버라이드 하고 있습니다. 이러한 상황에서 green 인스턴스가 Blue 클래스 객체를 받게 됩니다. 그렇게 되면 green.getColor()의 결과는 "Green"이 아닌 "Blue"가 출력되게 됩니다.
public interface IColor{
public void getColor();
}
public class Green implements IColor {
public void getColor() {
System.out.println("Green");
}
}
public class Blue implements IColor {
public void getColor() {
System.out.println("Blue");
}
}
public class Main{
public static void main(String[] args) {
IColor color = new Blue();
color.getColor();
//output: Blue
}
}
이를 해결하기 위해서 IColor 인터페이스를 생성합니다. 기존의 Green과 Blue클래스는 해당 인터페이스를 구현합니다. 이렇게 되면 하위 클래스가 모두 getColor()의 기능을 가지게 됩니다. 그렇게 되면 IColor color를 오브젝트로 사용할 수 있습니다. 즉, 중요한 것은 하위 클래스는 인터페이스 규약을 지켜야 한다는 것입니다.
인터페이스 분리 원칙(Interface-Segregation Principle)
인터페이스 분리 원칙, 줄여서 ISP는 다음과 같은 의미를 가집니다.
1. 클래스는 인터페이스에 의해 필요 없는 메서드를 만들지 않아야 합니다.
다시 말해, 하나의 인터페이스가 너무 거대하여, 과도하게 많은 기능이 존재하는 경우 문제가 된다는 뜻입니다.
public interface Payment {
void initiatePayments();
Object status();
List<Object> getPayments();
}
public class BankPayment implements Payment {
@Override
public void initiatePayments() {
// ...
}
@Override
public Object status() {
// ...
}
@Override
public List<Object> getPayments() {
// ...
}
}
예를 들어, 위와 같이 Payment 인터페이스와 이를 구현한 BankPayment 클래스가 존재합니다. 여기서 인터페이스에 기능을 추가하면 어떻게 될까요? 대출 기능을 위해 인터페이스에 메서드를 추가하고 새로운 클래스를 생성하겠습니다.
public interface Payment {
// original methods
...
void intiateLoanSettlement();
void initiateRePayment();
}
public class LoanPayment implements Payment {
@Override
public void initiatePayments() {
throw new UnsupportedOperationException("This is not a bank payment");
}
@Override
public Object status() {
// ...
}
@Override
public List<Object> getPayments() {
// ...
}
@Override
public void intiateLoanSettlement() {
// ...
}
@Override
public void initiateRePayment() {
// ...
}
}
인터페이스에 두 메서드가 추가되고 새로운 LoanPayment 클래스를 만들었습니다. 여기서 문제가 발생합니다. 하나의 Payment라는 인터페이스를 사용하기 때문에 LoanPayment 클래스는 필요하지 않은 모든 메서드를 구현하게 되었습니다. 즉, initiatePayments()와 같은 메서드가 필요하지 않음에도 구현하게 되었습니다.
public class BankPayment implements Payment {
@Override
public void initiatePayments() {
// ...
}
@Override
public Object status() {
// ...
}
@Override
public List<Object> getPayments() {
// ...
}
@Override
public void intiateLoanSettlement() {
throw new UnsupportedOperationException("This is not a loan payment");
}
@Override
public void initiateRePayment() {
throw new UnsupportedOperationException("This is not a loan payment");
}
}
심지어 기존의 BankPayment 클래스도 문제가 발생합니다. BankPayment 클래스 또한 원하지 않게 새로 생긴 두 메서드를 구현하게 되었습니다. 즉, 하나의 인터페이스가 여러 가지 역할을 맡게 되어 문제가 발생한 것입니다. 이를 해결하기 위해 아래와 같이 인터페이스를 분리할 수 있습니다.
public interface Payment {
Object status();
List<Object> getPayments();
}
public interface Bank extends Payment {
void initiatePayments();
}
public interface Loan extends Payment {
void intiateLoanSettlement();
void initiateRePayment();
}
이제 두 클래스는 각각 Bank 인터페이스와 Loan 인터페이스를 상속받습니다. 이렇게 되면 자신에게 필요한 메서드만 구현할 수 있습니다. 즉, ISP 원칙이 잘 적용되었다는 뜻입니다.
의존관계 역전 원칙(Dependency-Inversion Principle)
의존관계 역전 원칙, 줄여서 DIP는 다음과 같은 의미를 가집니다.
1. 높은 단계의 모듈 혹은 클래스는 낮은 단계의 모듈에 의존해서는 안 된다.
2. 추상은 구체에 의존되면 안된다. 구체가 추상을 의존해야 한다.
각 모듈들은 추상화된 인터페이스 혹은 클래스에 의존해야 합니다. 이는 다형성과도 관련이 있습니다.
예를 들어봅시다. 한 뮤지컬에 남자 주인공 배우와 로미오 역할 그리고 여자 주인공 배우와 줄리엣 역할이 존재한다고 생각해 봅시다. 여기서 배우는 구현체, 역할은 추상체입니다.
이때, 남자 주인공 배우가 여자 주인공의 역할이 아닌 배우에 의존한다면 어떨까요? 만약 여자 주인공 배우가 교체되는 경우, 남자 주인공 배우 또한 문제가 생기게 됩니다. 즉, 코드의 유연성이 매우 떨어지게 됩니다.
// High-level module
public class PaymentService
{
private CreditCardProcessor _creditCardProcessor;
public PaymentService()
{
_creditCardProcessor = new CreditCardProcessor();
}
public void ProcessPayment(decimal amount, string creditCardNumber)
{
// Perform payment processing using the CreditCardProcessor
_creditCardProcessor.ProcessPayment(amount, creditCardNumber);
}
}
// Low-level module
public class CreditCardProcessor
{
public void ProcessPayment(decimal amount, string creditCardNumber)
{
// Implementation details of payment processing using a credit card processor
Console.WriteLine($"Processing payment of {amount} using credit card {creditCardNumber}");
}
}
// Usage
PaymentService paymentService = new PaymentService();
paymentService.ProcessPayment(100.0m, "1234 5678 9012 3456");
예를 들어, 위와 같이 하위 모듈인 CreditCardProcessor클래스가 존재하고, 이를 의존하는 상위 모듈 PaymentService클래스가 존재합니다. PaymentService는 생성자 내부에서 CreditCardProcessor의 인스턴스를 만듭니다 그리고 직접적으로 ProcessPayment() 메서드를 호출합니다. 즉, 상위 모듈이 특정 클래스 구현체에 의존하는 강한 결합(Tight coupling)이 생기게 됩니다.
// High-level module
public class PaymentService
{
private IPaymentProcessor _paymentProcessor;
public PaymentService(IPaymentProcessor paymentProcessor)
{
_paymentProcessor = paymentProcessor;
}
public void ProcessPayment(decimal amount, string creditCardNumber)
{
// Perform payment processing using the injected payment processor
_paymentProcessor.ProcessPayment(amount, creditCardNumber);
}
}
// Abstraction or interface for payment processing
public interface IPaymentProcessor
{
void ProcessPayment(decimal amount, string creditCardNumber);
}
// Low-level module implementing the IPaymentProcessor interface
public class CreditCardProcessor : IPaymentProcessor
{
public void ProcessPayment(decimal amount, string creditCardNumber)
{
// Implementation details of payment processing using a credit card processor
Console.WriteLine($"Processing payment of {amount} using credit card {creditCardNumber}");
}
}
// Usage
IPaymentProcessor paymentProcessor = new CreditCardProcessor();
PaymentService paymentService = new PaymentService(paymentProcessor);
paymentService.ProcessPayment(100.0m, "1234 5678 9012 3456");
IPaymentProcessor 인터페이스를 생성하여 PaymentService가 인터페이스에 의존하도록 만들어봅시다. 이제 구현체를 직접 의존하지 않습니다. 이는, DIP 원칙을 잘 준수 한 예시입니다. 이에 대한 효과로, 생성자에서 paymentProcessor를 통해 원하는 구현체를 주입받을 수 있습니다. 즉, 코드가 유연해졌다는 것을 의미합니다.
Ref.
SRP
https://www.geeksforgeeks.org/single-responsibility-in-solid-design-principle/
OCP
https://www.freecodecamp.org/news/open-closed-principle-solid-architecture-concept-explained/
LSP
https://tusharghosh09006.medium.com/liskov-substitution-principle-lsp-744eceb29e8
ISP
https://medium.com/@ramdhas/4-interface-segregation-principle-isp-solid-principle-39e477bae2e3
DIP
https://dev.to/tkarropoulos/the-power-of-dependency-inversion-principle-dip-in-software-development-4klk
Inflearn - 스프링 핵심 원리 기본편(김영한)