5주차. SOLID 원칙 (2)

박서영·2025년 10월 4일

SOLID 원칙

사실 이번주차 수업 내용의 주는 SOLID 원칙을 다루는 부분이었는데 조금 길어져서, 이 내용을 뒤에 정리하게되었다.

SOLID란?

SOLID 원칙이란 우선 변화에 강하고 재사용에 유리한 클래스 구조를 만드는 법칙이자 원칙이라고한다. 앞에서 정리한 클린코드를 위해서 지켜야하는 법칙과도 같다. 이 원칙을 지키면서 코딩하면, 유지보수 및 확장이 편리하며 재사용성이 높고 테스트가 쉬운 코드를 작성할 수 있다.

SSingle Responsibility단일 책임 원칙
OOpen/Closed개방-폐쇄 원칙
LLiskov Substitution리스코프 치환 원칙
IInterface Segregation인터페이스 분리 원칙
DDependency Inversion의존성 역전 원칙

정리하면 위의 표와 같은 내용들이 담겨져 있다. 위의 개념들은 서로 연과되어 있다. 하지만, 모든 원칙을 적용할 필요는 없다고 한다.

S(Single Responsibility): 단일 책임의 원칙

단일 책임의 원칙(SRP)는 앞에서도 몇 번 언급이 되었듯이 클래스가 단 하나의 기능/책임에 집중할 수 있도록하는 것을 말한다. 이를 통해 코드의 이해가 쉬워지고, 코드가 변경되었을 때 서로에게 미치는 영향이 명확해진다. 또 단일 책임의 원칙을 적용했을 때, 코드의 재사용성이 높아진다고한다.

SRP의 적용 전후를 비교하는 예를 통해 한 번 살펴보면 다음과 같다.

예: UserServiceReportService 클래스의 연관성

class ReportService() {
	public void generateReport() {...}
    public void sendEmail() {...}
}

위의 코드에서는 ReportService()라는 클래스에서 문서도 작성하고, 이메일 역시 전송하고 있다. 이를 SRP를 적용해 수정하면 아래와 같다.

class ReportGenerator {
	public void generateReport() {...}
}

class EmailSender {
	public void sendEmail() {...}
}

O(Open/Closed): 개방-폐쇄 원칙

개방-폐쇄 원칙(OCP)란 확장에는 열려 있고, 수정에는 닫혀있어야한다는 내용의 원칙이다. 즉, 기능을 추가/확장하는 행동이 쉽게 열려 있어야하지만, 그 영향이 해당 클래스 내에서 되도록이면 끝날 수 있도록 한정해야한다는 느낌의 내용이다.

OCP 원칙을 지키게되면, 기존의 코드를 변경하지 않고도 새로운 기능을 추가할 수 있다. 이는 인터페이스나 추상클래스와 같은 추상화를 사용해, 클래스들이 구체적인 구현 클래스가 아니라 그 추상화들에 의존하게함으로써 구현할 수 있다.

예: 결제방식의 구현
결제라는 메소드가 만약 하나의 메소드로 구현이 된다면, 아래와 같은 코드가 만들어질 것이다.

public class PaymentProcessor {
	public void process (String paymentType) {
    	if ("creditCard".equals(paymentType) {
        	...
        }
        else if ("kakaoPay".equals(paymentType) {
        	...
        }
    }
}

위와 같은 코드를 사용한다면, 만약 결제방식이 추가된다면, 조건문이 무한정으로 길어지게될 것이다. 만약 현금, 쿠폰, 할인 등을 적용하게되면, 위의 거대한 메소드를 직접 하나하나 수정해야하는 번거로움이 존재한다.

하지만 이를 OCP 원칙을 적용해 추상화에 의존하도록 코드를 수정하면 아래와 같다.

public interface PaymentMethod {
	void pay();
}

public class CreditCard implements PaymentMethod {...}
public class KakaoPay implements PaymentMethod {...}

public class PaymentProcessor {
	public void process (PaymentMethod paymentMethod) {	
    	paymentMethod.pay();
    }
}

결제방식을 PaymentMethod라는 인터페이스를 활용하여 정의한 뒤에, 구체적인 부분은 해당 인터페이스를 구현하는 클래스들을 통해 구현하기 때문에, 만약 결제방식이 추가되어도 기존의 코드를 건들지 않고 새로운 PaymentMethod 인터페이스의 구현 클래스를 작성해 내의 코드를 해당 결제방식에 맞게 작성해주면된다.

또, 결제방식을 문자열로 입력받아 각각 조건문으로 확인할 필요없이, 각 결제방식 내의 pay() 메소드를 호출하기만 하면 되기에, 결합도 역시 낮다. 이는 PaymentMethod라는 인터페이스를 사용해 상위참조를 하고 있기에 가능하다.

L(Liscov Substitution): 리스코프 치환 원칙

리스코프 치환 원칙(LSP)란 하위타입이 언제나 상위 타입으로 대체될 수 있어야한다는 내용의 원칙이다.

즉, 자식 클래스는 부모 클래스가 사용되는 곳에 문제없이 들어가야한다는 내용인데, 핵심은 상속이 'IS-A' 관계를 명확하게 따라야한다이다.

예: Bird라는 클래스를 상속한 Ostrich 클래스에서 fly()라는 메소드를 오버라이딩해야하는 문제(예외)의 발생

class Bird {
	public void fly() {...}
}

class Ostrich extends Bird {
	@Override
    public void fly() {
    	throw new UnsupportedOperationException("타조는 날지 못함");
    }
}

위와 같이 타조는 날지 못하지만, 강제로 fly()라는 메소드를 오버라이딩해야하기에 UnsupportedOperationException 이라는 메소드는 존재하지만, 특정 객체의 상태/조건 탓에 호출된 시점에 해당 작업을 수행할 수 없다는 예외를 처리해야한다.

이는 아래와 같이 BirdFlyingBirdWalkingBird로 분리하여 해결할 수 있다.

interface Bird{
	void move();
}

class FlyingBird implements Bird {
	public void move () {
    	System.out.println("날아요");
    }
}

class WalkingBird implements Bird {	
	public void move () {
    	System.out.println("걸어요");
    }
}

I(Interface Segregation): 인터페이스 분리 원칙

인터페이스 분리 원칙(ISP)는 하나의 거대한 인터페이스보다, 여러 개의 구체적인 인터페이스를 선호한다. 즉, 클라이언트가 자신이 사용하지 않는 메소드에 의존해서는 안된다는 내용의 원칙이다.

중요한 점은 클래스가 불필요한 메소드를 구현하지 않도록 강제하고, 인터페이스를 변경할 때 최소한의 영향만 받도록 하는 것이다.

예: Worker 인터페이스에 eat() 메소드를 포함해, 불필요하게 Robot 에서도 해당 메소드를 구현해야하는 경우

interface Worker {
	void work();
    void eat();
}

class Robot implements Worker {
	public void work () {...}
    public void eat() {throw new UnsupportedOperationException());}
}

위의 코드가 문제가 있는 코드이다. 사실 인터페이스라는 점만 제외하면 LSP와 상당히 비슷한 부분이다. 이 역시 LSP 때와 비슷하게 인터페이스를 분리해 해결할 수 있다.

interface Workable {
	void work();
}

interface Eatable {
	void eat();
}

class Robot implements Workable {
	public void work() {...}
}

이렇게 두 개의 인터페이스로 분리하면, Robot이 불필요하게 eat()을 오버라이딩하지 않아도된다.


SOLID원칙 실습: 커피 메이커

시나리오: 카페를 운영한다고 생각한다.

  • 커피머신은 EspressoMachineDripCoffeeMachine이 있다.
  • 커피 음료로는 Espresso, Americano, Latte가 있다.
  • 각 음료는 누군가가 만드는 것이 아니라, 음료 클래스가 스스로 만드는 행동 중심의 구현을 한다.
  • 라떼 = 에스프레소 + 우유
  • 아메리카노 = 에스프레스 + 뜨거운 물

조건은 DI/IoC를 생성자 주입을 통해 하며, SOLID 원칙 중, 단일 책임 원칙(SRP), 개방-폐쇄 원칙(OCP), 의존성 주입(DIP)를 중심으로 구현한다는 것이다.

주요 코드 일부만 정리해보면 아래와 같다.

package Machine;

public interface CoffeeMachine {
    String brew();
}
package Beverage;

public interface Coffee {
    String prepare();
}

Coffee를 구현하는 클래스들은 구체적인 음료에 대한 클래스들이며, 추상화를 사용하였기에, 새로운 메뉴를 추가한다고해도 기존의 코드를 수정하는 것이 아니라, 새로운 구현 클래스를 작성하면 된다. 이는 다형성을 이용한 부분이다.

package Beverage;

import Machine.CoffeeMachine;

//에스프레소
public class Espresso implements Coffee {
    private final CoffeeMachine espressoMachine;

    public Espresso(CoffeeMachine espressoMachine) {
        this.espressoMachine = espressoMachine;
    }

    public String prepare() {
        return espressoMachine.brew();
    }
}

위는 Espresso 클래스의 코드이다.

Coffee 인터페이스를 구현하고 있기에 prepare() 메소드를 오버라이딩한다. 우선, 단일 책임 원칙(SRP)에 따라 음료 메뉴에서는 해당 음료를 준비하는 동작만을 한다. 또, 클래스 내에서 사용하는 CoffeeMachine을 바로 생성해서 사용하지 않고, 생성자를 통해 주입받아서 사용하는 의존성 주입(DI)이 일어나고 있다.

이는 다른 음료 클래스들인 Coffee를 구현하는 아메리카노, 라떼 역시 위와 동일하게 의존성을 주입 받고있다.

package CoffeeMaker;

import Beverage.Coffee;

//커피 제작(의뢰)기: IoC와 DI 적용
public class CoffeeMaker {
    private Coffee coffee;

    public void setCoffee (Coffee coffee) { //setter DI
        this.coffee = coffee;
    }

    public void makeCoffee() {
        System.out.println(coffee.prepare());
    }
}

CoffeeMaker 클래스의 코드이다. 여기서 커피메이커는 바리스타마냥 직접 재료를 조합하고 기계를 다루지 않는다. 사용해야하는 기계는 음료 클래스가 알고 있고, 해당 클래스들에서 기계들의 메소드를 실행시킨다. 즉, 커피 메이커는 만들어야할 음료를 주입받고, 해당 음료가 만들어지도록 음료의 제작을 실행하는 메소드를 실행시킨다. 사실 이 부분이 처음에 살짝 헷갈렸다. 보통 커피메이커에서 재료랑 기계를 섞는다고 생각하게 되니까...

해당 클래스의 setCoffee() 메소드를 통해 만들어야할 음료(Coffee 객체)를 주입받고, 해당 음료의 prepare() 메소드를 실행한다. 즉, 매개자 같은 역할을 하고있다.

이외의 대부분의 코드들이 단일 책임 원칙(SRP)을 지키며 작성되었다. 조금 색다른 부분은 Latte 클래스의 코드인 것 같아, 그 부분만 마지막으로 살펴보려한다.

package Beverage;

import Machine.MilkFrother;

//라떼=에스프레소와 우유의 조합
public class Latte implements Coffee {
    private final Coffee espresso; //에스프레소 구성
    private final MilkFrother milkFrother; //우유 구성

    public Latte(Coffee espresso, MilkFrother milkFrother) {
        this.espresso = espresso;
        this.milkFrother = milkFrother;
    }

    public String prepare() {
        return espresso.prepare()+" + "+milkFrother.frothMilk();
    }
}

신기한 부분은 라떼에 에스프레소와 우유가 필요하다고해서, 단순히 에스프레소 머신을 부르지 않는다는 점이다. 이미 만들어진 에스프레소라는 음료 메뉴를 가져오고, 거기에 우유를 추가하는 방식을 취한다.

따라서 생성자를 통해 필드에 Coffee 인터페이스를 구현하는 객체-이것 역시 생성자를 통해 주입받고 있어, 변경할 수있지만, 여기서는 에스프레소(Espresso)를 받아온다고 생각한다..-와 우유 스티밍을 위한 기계인 MilkFrother 객체를 주입받는다.

음료 준비를 위해서는 먼저 espresso 변수에 담긴 커피객체-여기서는 Espresso-를 준비하는 메소드를 실행시켜 에스프레소를 만들고, 그 후에 우유 스티밍을 위한 기계의 메소드를 실행시킨다.

이 부분에서 합성과 개방-폐쇄 원칙(OCP)이 쓰였다고한다. 추상화를 사용하였기에, 라떼라는 메뉴를 만들기 위해서 기존 코드를 수정하지 않기도하였고, 이미 작성한 에스프레소 코드는 재사용하여 불필요하게 중복되는 코드 역시 줄인 것을 알 수 있다.

소감
이론적인 양이 많아서 내용을 정리하는게 쉽지 않은...

클린코드나 리팩토링이라는 개념을 뭔가 이론적으로는 어떤 느낌이구나 싶긴한데, 뭔가 직접 코드를 쓸 때 적용하기는 사실 상당히 쉽지 않을 것 같다는 생각이 들었습니다. 그런데 구글에 리팩토링vs클린코드 충돌 사례를 찾아보다 알게된 것인데, 대부분의 사람들이 리팩토링이나 클린코드 작성을 어려워한다는...점?

아, 코딩을 아직 많이 해보지 않아서 뭔가 확 와닿는 부분이 아직..? 없는 것 같긴하지만, 웹페이지 자바스크립트 작성해봤을 때-사실 작성했다고 하기 민망한 수준이지만..-, 다른 사람 코드 읽고.., 수정하고 하면서 고생한걸 지금 생각하면, 메소드도 엄청 정리안되었고, 거대 메소드이고... 책임 분리도 정말 하나도 안되어있는?^^...게 이유였을까 싶기도합니다. 그때는 웹페이지 현직자들은 어떻게 서로 코드 읽고 협업하는거지 싶을정도로 하나도 정리가 안되어있었어서..흠. 뭔가 공부를 더 하고 진행하면 더 수월하게 할 수 있는걸까...하는 생각이 들었습니다.

뭔가 주저리주저리 하나도 정리가 되지 않은 소감이지만, SOLID원칙을 배우면서 이래저래 많은 생각이 들었던 것 같습니다..

profile
이불 밖은 위험해.

0개의 댓글