𝝅번째 알파카의 개발 낙서장

screen

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

posts

CS

시리즈 톺아보기

객체지향

객체지향
count

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

개방 폐쇄 원칙이란 객체를 다룸에 있어서 객체의 확장은 개방적으로, 객체의 수정은 폐쇄적으로 대하는 원칙이다. 한 마디로, 보여줄 건 보여주고, 숨길 건 숨긴다는 의미.

좀 더 쉽게 말하자면, 기능이 변하거나 확장 가능하지만, 해당 기능의 코드는 수정하면 안 된다는 뜻이다. 그런데 이 원칙, 말이 좀 이상하다. 기능이 변하는 거 OK. 확장되는 거 OK. 근데 코드를 수정하면 안 된다?? 다소 이해가 되지 않는 요구사항이다.

만약, 객체 하나를 수정한다고 가정하자. 이 때 단순히 해당 객체만 수정하는 것 뿐만 아니라 해당 객체에 의존하는 다른 객체들의 코드까지 줄줄이 고쳐야한다면 좋은 설계로 보기 힘들다. 대표적으로 라이브러리를 생각해보자. 라이브러리를 사용하는 객체의 코드가 변경된다고 해서 라이브러리 코드까지 변경하지 않는다.

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

코드로 보는 개방-폐쇄 원칙 🔗


If...


저명한 IT업체에서 일하는 당신. 어느덧 정년을 바라보고 있다. 노후 대비를 위해 작은 편의점의 점주로 새로운 시작을 하는 당신. 예전부터 봐뒀던 곳에 적지 않은 비용을 지불하기까지 했다.

다행히 안목이 틀리지 않았는지, 아침은 아침대로, 새벽은 새벽대로 끊임없는 유동인구 덕분에 생각했던 것 이상으로 수입이 들어오고 있다. 좀 더 일찍 시작했어도 됐으려나...

여기저기 신경쓰다보니 초기 비용이 여의치 않아, POS기기는 저렴한 걸 선택했다. 영업사원이 사용 카드가 어쩌네 넌지시 얘기한 거 같은데, 그래봐야 POS가 거기서 거기겠지 뭐.


요즘 들어 매체에 신생 카드 업체에 대한 주제가 끊이질 않는다. 공격적인 혜택과 이전 카드에선 찾아볼 수 없었던 아기자기한 디자인이 그렇게 인기랜다. 이름이 초콜릿뱅크였나..? 혜택은 좋은데, 카드에 저런 디자인이 무슨 소용이람.


요즘들어 그 초코 뭐시긴가 하는 카드를 쓰는 사람이 많아졌다. 문제는 저 놈의 POS기가 새로운 카드는 전혀 인식을 못 한다. 이 문제 때문에 이번 주에만 반 이상이 넘는 고객을 돌려보냈다. 매출도 문제지만, 손님한테 아쉬운 소리하면서 사과하는 게 더 고역이다.

POS 업체에 전화해봤는데, 구조 상 그런거라며 계약 이전에 다 설명하고 서명받은 내용이란다. 난 그런 기억이 없는데....? 어쨌든 내게 남은 선택지라곤 지금 유지비용의 두 배 가까이 되는 신규 POS로 교체하던가, 위약금을 물고 새로운 POS 업체로 갈아타는 것 뿐이다. 이 문제 때문에 잠을 통 잘 수가 없다.


그래도 명색이 개발자인 당신. 어쩌면 내가 해결할 수도 있지 않을까? 수 십년 간의 경험을 토대로 기억을 되짚어가며 기기를 분석해보기 시작했다.

JAVA

0/**
1 * 포스 클래스
2 *
3 * @author RWB
4 * @since 2021.08.14 Sat 02:10:12
5 */
6public class Pos
7{
8 /**
9 * 결제 및 결과 반환 함수
10 *
11 * @param card : [Object] 카드 객체
12 * @param name : [String] 카드사명
13 * @param price: [int] 금액
14 *
15 * @return [boolean] 결제 결과
16 */
17 public boolean purchase(Object card, String name, int price)
18 {
19 boolean result;
20
21 switch (card.toUpperCase())
22 {
23 case "A" -> result = ((CardA) card).send(price);
24 case "B" -> result = ((CardB) card).send(price);
25 case "C" -> result = ((CardC) card).send(price);
26
27 default -> {
28 System.out.println("유효하지 않은 카드사");
29 result = false;
30 }
31 }
32
33 return result;
34 }
35}

다행히 아직 감이 죽진 않았는지, 어렵지 않게 관련 모듈을 특정할 수 있었다. 카드 리더기에서 카드 인식 시 카드 정보가 담긴 객체를 Object로 캐스팅하여 전송한다. 정보 구분을 위해 카드사명까지 같이 전송하는 모양이다.

딱 봐도 난감하기 그지없는 구조다. 실제로 초콜릿뱅크의 카드 정보는 리더기에서 잘 전달되고 있으나, purchase 메소드에서 초콜릿뱅크 카드를 구분하는 로직이 없어서 결제가 되지 않는다.

JAVA

0public boolean purchase(String card, int price)
1{
2 boolean result;
3
4 switch (card.toUpperCase())
5 {
6 // 신생 업체가 생길 때마다 해당 업체를 구분하는 로직을 추가한다.
7 case "A" -> result = ((CardA) card).send(price);
8 case "B" -> result = ((CardB) card).send(price);
9 case "C" -> result = ((CardC) card).send(price);
10 case "D" -> result = ((CardD) card).send(price);
11 case "E" -> result = ((CardE) card).send(price);
12 case "F" -> result = ((CardF) card).send(price);
13
14 default -> {
15 System.out.println("유효하지 않은 카드사");
16 result = false;
17 }
18 }
19
20 return result;
21}

그렇다면 case 구문에서 초콜릿뱅크를 구분하여 결제 정보를 전송하면 해결되지 않을까? 이 방식을 쓴다면 급한 불은 끌 수 있겠지만, 후에 또 다른 신생업체가 생기면 같은 문제가 반복될 게 뻔하다.

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

당신은 이 코드를 좀 더 객체지향의 관점으로 접근하여 리팩토링을 실시한다.

JAVA

0/**
1 * 결제 인터페이스
2 *
3 * @author RWB
4 * @since 2021.08.14 Sat 02:28:22
5 */
6public interface Purchasable
7{
8 /**
9 * 카드사 정보 전송 및 결과 반환 함수
10 *
11 * @param price: [int] 금액
12 *
13 * @return [boolean] 전송 결과
14 */
15 boolean send(int price);
16}

공통된 형태로 로직을 수행하기 위해 Purchasable 인터페이스를 구현했다. 또한 리더기에서 전송하는 모든 카드 객체는 Purchasable를 상속받도록 강제했다.

JAVA

0/**
1 * A 카드 객체
2 *
3 * @author RWB
4 * @since 2021.08.14 Sat 02:36:11
5 */
6class CardA implements Purchasable
7{
8 /**
9 * 카드사 정보 전송 및 결과 반환 함수
10 *
11 * @param price: [int] 금액
12 *
13 * @return [boolean] 전송 결과
14 */
15 @Override
16 public boolean send(int price)
17 {
18 System.out.println(getClass().getSimpleName() + " " + price + "원 결제 요청");
19 return true;
20 }
21}
22
23/**
24 * B 카드 객체
25 *
26 * @author RWB
27 * @since 2021.08.14 Sat 02:38:00
28 */
29class CardB implements Purchasable
30{
31 /**
32 * 카드사 정보 전송 및 결과 반환 함수
33 *
34 * @param price: [int] 금액
35 *
36 * @return [boolean] 전송 결과
37 */
38 @Override
39 public boolean send(int price)
40 {
41 System.out.println(getClass().getSimpleName() + " " + price + "원 결제 요청");
42 return true;
43 }
44}
45
46/**
47 * C 카드 객체
48 *
49 * @author RWB
50 * @since 2021.08.14 Sat 02:39:51
51 */
52class CardC implements Purchasable
53{
54 /**
55 * 카드사 정보 전송 및 결과 반환 함수
56 *
57 * @param price: [int] 금액
58 *
59 * @return [boolean] 전송 결과
60 */
61 @Override
62 public boolean send(int price)
63 {
64 System.out.println(getClass().getSimpleName() + " " + price + "원 결제 요청");
65 return true;
66 }
67}

이제 리더기에서 전달하는 모든 카드 객체는 Purchasable 인터페이스를 상속받는다. 카드 객체를 부모 객체인 Purchasable로 다룰 수 있을 것이다. 각 카드 객체의 동작에 전송이 각각 구현되어있어, 타 객체의 코드에 의존하지 않는다.

JAVA

0/**
1 * 포스 클래스
2 *
3 * @author RWB
4 * @since 2021.08.14 Sat 02:10:12
5 */
6public class Pos
7{
8 /**
9 * 결제 및 결과 반환 함수
10 *
11 * @param purchasable : [Purchasable] Purchasable 인터페이스
12 * @param price: [int] 금액
13 *
14 * @return [boolean] 결제 결과
15 */
16 public boolean purchase(Purchasable purchasable, int price)
17 {
18 return purchasable.send(price);
19 }
20}

이제 결제 함수를 리팩토링 해보자. CardA, CarB, CardC 등 각각 개별적인 객체지만, 이제 Purchasable이라는 부모 객체가 있으므로 이를 묶을 수 있다. 우리는 리더기에서 주는 인터페이스 객체만 받아서 해당 객체의 send를 호출하면 된다.

성공적으로 리팩토링을 마친 당신. 이제 어떤 카드든 결제가 가능하고 리더기가 정상적으로 인식만 한다면 결제를 진행할 수 있게됐다.

당신이 한 각고의 노력과 빠른 대처로 인해 얼마 안 가 다시금 매출을 정상화시킬 수 있었다.

정리 🔗

리팩토링 전과 후를 비교해보자.

JAVA

0public boolean purchase(Object card, String name, int price)
1{
2 boolean result;
3
4 switch (card.toUpperCase())
5 {
6 case "A" -> result = ((CardA) card).send(price);
7 case "B" -> result = ((CardB) card).send(price);
8 case "C" -> result = ((CardC) card).send(price);
9
10 default -> {
11 System.out.println("유효하지 않은 카드사");
12 result = false;
13 }
14 }
15
16 return result;
17}
18
19public boolean purchase(Purchasable purchasable, int price)
20{
21 return purchasable.send(price);
22}

위는 이전 코드, 아래는 리팩토링한 코드다. 기능이 변하거나 확장 가능하지만, 해당 기능의 코드는 수정하면 안 된다는 의미를 여기에서 찾을 수 있다.

리팩토링 이전 코드의 경우, 새로운 카드 인식. 즉, 기능 추가를 위해선 코드의 추가가 요구됐다. 다시 말해, 기능을 확장하기 위해선 코드의 수정이 필요하다는 의미다.

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

단일 책임 원칙과 마찬가지로, 비슷한 형태의 분기가 반복될 경우 개방-폐쇄 원칙을 준수하지 않았을 가능성이 높다. 이는 곧 높은 리팩토링 비용으로 직결되니, 이를 잘 준수하여 독립적인 모듈을 설계하자.