[OOP] 객체지향 5원칙(SOLID) - 개방-폐쇄 원칙(Open-Closed Principle)

2022. 10. 4. 10:19TIL💡/Design Pattern

개방-폐쇄 원칙(Open-Closed Principle)

개방 폐쇄 원칙이란 객체를 다룸에 있어서 객체의 확장은 개방적으로, 객체의 수정은 폐쇄적으로 대하는 원칙이다.

한 마디로, 보여줄 건 보여주고, 숨길 건 숨긴다는 의미이다.

 

좀 더 쉽게 말하자면, 기능이 변하거나 확장 가능하지만, 해당 기능의 코드는 수정하면 안된다는 뜻이다.

그런데 조금 모순적이게 느껴지지 않나?

 

이처럼 개방-폐쇄 원칙은 각 객체의 모듈화와 정보 은닉의 올바른 구현을 추구하며, 이를 통해 객체 간의 의존성을 최소화하여 코드 변경에 따른 영향력을 낮추기 위한 원칙이다.

 

예를 들어, 구형 POS 기에 새로운 결제 기능을 덧붙이는 방법을 고안해본다.

/**
 * 포스 클래스
 *
 * @author RWB
 * @since 2021.08.14 Sat 02:10:12
 */
public class Pos
{
    /**
     * 결제 및 결과 반환 함수
     *
     * @param card : [Object] 카드 객체
     * @param name : [String] 카드사명
     * @param price: [int] 금액
     *
     * @return [boolean] 결제 결과
     */
    public boolean purchase(Object card, String name, int price)
    {
        boolean result;
        
        switch (card.toUpperCase())
        {
            case "A" -> result = ((CardA) card).send(price);
            case "B" -> result = ((CardB) card).send(price);
            case "C" -> result = ((CardC) card).send(price);
            
            default -> {
                System.out.println("유효하지 않은 카드사");
                result = false;
            }
        }
        
        return result;
    }
}

카드 리더기 에서 카드 인식 시 카드 정보가 담긴 객체를 Object로 캐스팅하여 전송한다.

정보 구분을 위해 카드사명까지 같이 전송하는 모양이다.

 

정말 난감한 구조다.

만약 카드 정보는 리더기에서 잘 전달되고 있으나, purchase 메소드에서 카드를 구분하는 로직이 없어서 결제가 되지 않는다.

그래서 추가한다고 해결이 완전히 되었는가?

그렇지 않다.

급한 불은 끌 수 있지만, 후에 또 다른 신생 업체가 생기면 같은 문제가 반복될 게 뻔하다.

이 방법은 매우 비효율적이다. 동작의 범위만 넓혔을 뿐, 근본적인 문제는 전혀 해결되지 않는다.

 

/**
 * 결제 인터페이스
 *
 * @author RWB
 * @since 2021.08.14 Sat 02:28:22
 */
public interface Purchasable
{
    /**
     * 카드사 정보 전송 및 결과 반환 함수
     *
     * @param price: [int] 금액
     *
     * @return [boolean] 전송 결과
     */
    boolean send(int price);
}

공통된 형태로 로직을 수행하기 위해 Purchasable 인터페이스를 구현했다.

또한 리더기에서 전송하는 모든 카드 객체는 Purchasable를 상속받도록 강제했다.

/**
 * A 카드 객체
 *
 * @author RWB
 * @since 2021.08.14 Sat 02:36:11
 */
class CardA implements Purchasable
{
    /**
     * 카드사 정보 전송 및 결과 반환 함수
     *
     * @param price: [int] 금액
     *
     * @return [boolean] 전송 결과
     */
    @Override
    public boolean send(int price)
    {
        System.out.println(getClass().getSimpleName() + " " + price + "원 결제 요청");
        return true;
    }
}
/**
 * B 카드 객체
 *
 * @author RWB
 * @since 2021.08.14 Sat 02:38:00
 */
class CardB implements Purchasable
{
    /**
     * 카드사 정보 전송 및 결과 반환 함수
     *
     * @param price: [int] 금액
     *
     * @return [boolean] 전송 결과
     */
    @Override
    public boolean send(int price)
    {
        System.out.println(getClass().getSimpleName() + " " + price + "원 결제 요청");
        return true;
    }
}
/**
 * C 카드 객체
 *
 * @author RWB
 * @since 2021.08.14 Sat 02:39:51
 */
class CardC implements Purchasable
{
    /**
     * 카드사 정보 전송 및 결과 반환 함수
     *
     * @param price: [int] 금액
     *
     * @return [boolean] 전송 결과
     */
    @Override
    public boolean send(int price)
    {
        System.out.println(getClass().getSimpleName() + " " + price + "원 결제 요청");
        return true;
    }
}

이제 리더기에서 전달하는 모든 카드 객체는 Purchasable 인터페이스를 상속받는다.

카드 객체를 부모 객체인 Purchasable로 다룰 수 있을 것이다.

각 카드 객체의 동작에 전송이 각각 구현되어 있어, 타 객체의 코드에 의존하지 않는다.

/**
 * 포스 클래스
 *
 * @author RWB
 * @since 2021.08.14 Sat 02:10:12
 */
public class Pos
{
    /**
     * 결제 및 결과 반환 함수
     *
     * @param purchasable : [Purchasable] Purchasable 인터페이스
     * @param price: [int] 금액
     *
     * @return [boolean] 결제 결과
     */
    public boolean purchase(Purchasable purchasable, int price)
    {
        return purchasable.send(price);
    }
}

이제 결제 함수를 리팩토링해보자.

CardA, CardB, CardC 등 각각 개별적인 객체이지만 이제 Purchasable이라는 부모 객체가 있으니 이를 묶을 수 있다.

우리는 리더기에서 주는 인터페이스 객체만 받아서 해당 객체의 send를 호출하면 된다.

 

성공적으로 리팩토링을 마친 당신.

이제 어떤 카드든 결제가 가능하고 리더기가 정상적으로 인식만 한다면 빠르게 새로운 결제 수단을 도입할 수 있게 되었다.

 

public boolean purchase(Object card, String name, int price)
{
    boolean result;
    
    switch (card.toUpperCase())
    {
        case "A" -> result = ((CardA) card).send(price);
        case "B" -> result = ((CardB) card).send(price);
        case "C" -> result = ((CardC) card).send(price);
        
        default -> {
            System.out.println("유효하지 않은 카드사");
            result = false;
        }
    }
    
    return result;
}
public boolean purchase(Purchasable purchasable, int price)
{
    return purchasable.send(price);
}

위는 이전 코드, 아래는 리팩토링한 코드다.

기능이 변하거나 확장 가능하지만, 해당 기능의 코드는 수정하면 안된다는 의미를 여기에서 찾을 수 있다.

리팩토링 이전 코드의 경우, 새로운 카드 인식. 즉, 기능 추가를 위해선 코드의 추가가 요구됐다.

다시 말해, 기능을 확장하기 위해서는 코드의 수정이 필요하다는 의미다.

 

반대로 리팩토링 후의 코드를 보자. Purchasable 라는 통합된 인터페이스를 사용하기 때문에 기존 코드 단계에서 대응할 필요가 없다. 즉, 코드의 변경 없이 기능이 확장된다.

 

단일 책임 원칙과 마찬가지로, 비슷한 형태의 분기가 반복될 경우 개방-폐쇄 원칙을 준수하지 않았을 가능성이 높다.

이는 곧 높은 리팩토링 비용으로 직결되니, 이를 잘 준수하여 독립적인 모듈을 설계하자.