본문 바로가기

CS/디자인패턴

[디자인패턴] SOLID 원칙

SOLID

 객체 지향 디자인에 대하여 이해, 변경, 확장 및 유지관리하기 쉽도록 하는 설계 원칙으로, 설계 평가에 사용될 수 있다. SOLID를 구성하는 원칙은 다음과 같다.

  • Single Responsibility Principle: 클래스의 책임을 하나만 둬서 변경해야 할 이유를 제한하자.
  • Open Close Principle: 확장에는 열린 상태로, 변경에는 닫힌 상태로 두자.
  • Liskov Substitution Principle: 서브 타입은 베이스 타입으로 손실 없이 바뀔 수 있어야 한다.
  • Interface Segregation Principle: 하나의 범용 인터페이스보다는 여러 개의 개별 인터페이스를 만들자.
  • Dependency Inversion Principle: 추상적인 것이 구체적인 것에 의존하면 안 된다.

설명만 보면 딱히 와닿지 않아서, 그냥 코드를 보는 게 편하다.


나쁜 설계의 예시

  • 경직성: 하나를 고치면 다른 것들도 영향을 받는다.
  • 부서지기 쉬움: 한 부분을 변경하면 상관없는 영역이 동작을 멈춘다.
  • 부동성: 시스템의 각 요소가 다른 시스템에서 재사용하기 힘들다. ( 상호 결합이 너무 강한 경우 )
  • 끈끈함: 개발 과정에서 너무 달라붙어 한 개발 사이클에 시간이 너무 오래 소요된다.
  • 의미 없는 복잡함: 코드가 의미 없이 장황하고 복잡하다.
  • 필요 없는 반복: 코드가 중복된 부분이 많다.
  • 불투명함: 코드의 의도를 알 수 없다.
  • 스파게티 코드: 코드 사이의 의존성이 지나치게 꼬여 있다.

좋은 설계를 위해 SOLID 원칙을 알고 있는 게 좋다.


Single Responsibility Principle

 단일 책임의 원리는 클래스에게 하나의 책임만 부여함으로써 서로 관련되지 않은 영역이 함께 변경되는 문제를 방지한다. 특정 회사에서 개인과 관련된 기능이라는 이유로 Person이라는 클래스 아래에 여러 유틸리티 함수 및 서비스 기능들을 퉁쳐서 모아 두었다고 가정해 보자. 코드는 아래와 같다.

class Person {
    private id: string;
    private name: string;
    private age: number;
    // 이하 주소 등 많은 개인 정보들

    private totalLoan: number;
    private loanInfo: {date: Date, description: string, amount: number}[];
    // 이하 많은 서비스 관련 정보들

    constructor(name:string, age: number, id?: string) {
        this.name = name;
        this.age = age;
        this.id = id??"";
        this.totalLoan = 0;
        this.loanInfo = [];
    }
    /* 유저 관리 관련 함수들 */
    public printUserInfo() {
        console.log("Name: ", this.name);
        console.log("Age: ", this.age);
    }

    /* 대출 서비스 관련 함수들 */
    public addLoan(date: Date, description: string, amount: number) {
        this.loanInfo.push({date,description,amount});
    }

    public loadLoanInfo() {
        // load loan data from db by person id;
    }

    public printLoanInfo() {
        console.log(`total amount of loan: ${this.totalLoan}`);
        console.log(`Date | Desc | amount`);
        for(const lf of this.loanInfo) {
            console.log(`${lf.date} | ${lf.description} | ${lf.amount}`);
        }
    }

    public json(target: any) {
        return JSON.stringify(target);
    }
    public csv(target: any): string {
        switch(typeof target) {
            case 'object':
                if(target != null) {
                    const values = Object.values(target);
                    return values.join(', ');
                }
                break;
            case "undefined":
                return '';
            default:
                return (target).toString();
        }
    }
}

 위 Person 클래스에 부여된 책임은 크게 3가지로 나뉜다.

  1. 개인과 관련된 정보를 관리하는 역할 ( name, age, printUserInfo... )
  2. 대출과 관련된 정보를 관리하는 역할 ( totalLoan, loanInfo, addLoan, printLoanInfo... )
  3. 객체를 포매팅하기 위한 유틸리티 함수 ( json, csv )

 Person 클래스는 현재 3개의 책임을 가지고 있으며, 위 나열한 3가지 요소 중 하나가 변경될 때마다 관련 없는 나머지 영역도 함께 변경되는 문제점이 있다. 만약 위 코드를 C++ 같은 컴파일 언어로 작성했다면, 대출 서비스와 관련된 코드를 수정할 때마다 대출과 직접적인 관계가 없는 유저 및 유틸리티 기능도 다시 컴파일해야 하므로 상당한 낭비가 된다.

 연쇄적인 수정을 막기 위해 Person 클래스에 부여된 3가지 책임을 분산해 보자.

// person.ts
class Person2 {
    private id: string;
    private name: string;
    private age: number;
    // 이하 주소 등 많은 개인 정보들

    constructor(name:string, age: number, id?: string) {
        this.name = name;
        this.age = age;
        this.id = id??"";
    }

    public printUserInfo() {
        console.log("Name: ", this.name);
        console.log("Age: ", this.age);
    }
}
// service/loan.service.ts
class LoanService {
    private pid: string; 
    private totalLoan: number;
    private loanInfo: {date: Date, description: string, amount: number}[];

    constructor(pid: string) {
        this.pid = pid;
        this.totalLoan = 0;
        this.loanInfo = [];
    }


    public addLoan(date: Date, description: string, amount: number) {
        this.loanInfo.push({date,description,amount});
    }

    public loadLoanInfo() {
        // load loan data from db by person id;
    }

    public printLoanInfo() {
        console.log(`total amount of loan: ${this.totalLoan}`);
        console.log(`Date | Desc | amount`);
        for(const lf of this.loanInfo) {
            console.log(`${lf.date} | ${lf.description} | ${lf.amount}`);
        }
    }
}
// util/formatter.ts
class Formatter {
    public static json(target: any) {
        return JSON.stringify(target);
    }
    public static csv(target: any): string {
        switch(typeof target) {
            case 'object':
                if(target != null) {
                    const values = Object.values(target);
                    return values.join(', ');
                }
                break;
            case "undefined":
                return '';
            default:
                return (target).toString();
        }
    }
}

 기존에 Person에 부여되어 있던 책임 3가지를 각각의 클래스로 분산한 후 서로 다른 파일에 저장한 모습이다. 책임이 분리되면서 LoanService가 person 관련 정보를 직접 참조할 수 없게 되었으므로 pid 필드를 추가하였다. 

 SRP를 준수하는 경우 각 클래스의 책임 영역이 확실해지므로 응집도는 높아지고 결합도는 낮아지는 효과가 있다. 이를 통해 코드 가독성 향상, 유지보수 용이 등의 장점을 가져올 수 있다.


Open Close Principle

 개방 폐쇄의 원리는 소프트웨어의 요소들이 확장에는 열려 있지만, 변경에는 닫혀 있어야 한다는 의미를 가진다.

  • 확장에 열려 있다: 요구사항이 변경 또는 추가될 때 대응되는 새로운 동작을 추가, 모듈을 확장할 수 있다.
  • 수정에 닫혀 있다: 모듈의 소스코드 / 바이너리 코드 수정 없이 기능을 확장 또는 변경할 수 있다.

 코드를 별생각 없이 작성하다 보면 새로운 시스템 또는 타깃에 대응되는 기능을 추가할 때마다 기존 코드의 switch문을 변경하여 조건을 판단하고, 대응되는 기능을 따로 작성해야 하는 일이 생긴다. 아래 코드를 보면서 생각해 보자.

class UserNotification {
    public sendAdInfo(target: NotificationTarget) {
        switch(target) {
            case "mail":
                //by mail
                break;
            case "message":
                // by message
                break;
            case "app":
                // app push
                break;
        }
    }

    public sendOTP(target: NotificationTarget) {
        switch(target) {
            case "mail":
                //by mail
                break;
            case "message":
                // by message
                break;
            case "app":
                // app push
                break;
        }
    }

    public sendReceipt(target: NotificationTarget) {
        switch(target) {
            case "mail":
                //by mail
                break;
            case "message":
                // by message
                break;
            case "app":
                // app push
                break;
        }
    }
}

type NotificationTarget = 'mail'|'message'|'app';

 UserNotification 클래스는 유저에게 특정한 알림을 전송하는 역할을 담당한다. 알림 방식은 현재 이메일, 문자 메시지 및 앱 푸시 방식이 명시되어 있는 상태다. 위 코드에서 새로운 알림 방식으로 '전화'가 추가된다고 생각해 보자. 전화라는 새로운 알림 서비스를 지원하기 위해서는 위 명시된 모든 메서드 코드에 대해 case을 추가하고, 대응되는 기능을 작성해야 하는 심각한 문제가 발생한다. 지금은 메서드가 3개밖에 없지만, 만약 알림 서비스와 관련된 기능이 100개가 넘는다면? 요구사항이나 추가가 발생할 때마다 지나치게 넓은 영역에서 수정이 발생하며, 이는 바람직하지 않다.

 위 문제를 해결하기 위해 추상화를 활용한다. 기존에는 target 문자열에 맞는 동작을 메서드 내에 명시했는데, 이는 각 메서드를 구체적인 구현과 연결하므로 코드의 지속적인 변경을 야기한다. 따라서 각 구현을 별개의 클래스들로 분리하고, 이에 대한 인터페이스를 메서드에서 취할 수 있다면 메서드는 보다 추상적인 개념에 의존하므로 새로운 타깃이 추가되더라도 코드를 변경할 필요가 없다. 메서드들을 변하지 않는 인터페이스와 연결하는 것이다.

interface INotificationMedium {
    sendAdInfo(): void;
    sendOTP(): void;
    sendReceipt(): void;
}

class MailMedium implements INotificationMedium {
    sendAdInfo() {//by mail
    }
    sendOTP() {//by mail
    }
    sendReceipt() {//by mail
    }
}

class MessageMedium implements INotificationMedium {
    sendAdInfo() {//by message
    }
    sendOTP() {//by message
    }
    sendReceipt() {//by message
    }
}

class AppMedium implements INotificationMedium {
    sendAdInfo() {//by app push
    }
    sendOTP() {//by app push
    }
    sendReceipt() {//by app push
    }
}

 위 코드에서는 INotificationMedium이라는 인터페이스를 추가하여 추상적으로 기능에 접근하도록 만든다. 또한 각 수단과 밀접한 구체적인 구현 사항은 INotificationMedium을 구현하는 클래스 아래에 정의함으로써 추상적 수준과 구체적 수준을 분리한다.

 코드를 위와 같이 작성하면 UserNotification에서는 각 기능을 이용할 때 INotificationMedium 인터페이스에 접근하여 추상적인 수준에서 타깃의 기능을 사용할 수 있게 되므로 구체적인 기능이 변경되더라도 수정이 발생하지 않게 된다.

class UserNotification2 {
    public sendAdInfo(target: INotificationMedium) {
        target.sendAdInfo();
    }

    public sendOTP(target: INotificationMedium) {
        target.sendOTP();
    }

    public sendReceipt(target: INotificationMedium) {
        target.sendReceipt
    }
}

 UserNotification2는 INotificationMedium이라는 추상적인 개념을 통해 각 기능에 접근하므로 변경에 닫혀 있다. 반대로 각 타깃에 대한 기능은 INotificationMedium 인터페이스만 구현하도록 클래스를 작성하면 되므로 확장에 열려 있다.

 OCP의 요점은 변경되는 부분과 변경되지 않는 부분을 구분하고, 둘을 분리하는 데 있다. 구체적인 구현을 추상적 개념과 분리함으로써 수정을 최소화할 수 있게 된다. 많은 디자인 패턴이 OCP 개념을 이용하므로 잘 알아둬야겠다.


Liskov Substitution Principle

 리스코프 교체(치환) 원리는 서브 타입이 베이스 타입으로 손실 없이 교체될 수 있어야 함을 의미한다. 이 원칙을 만족하면 상속 관계에서 인터페이스를 동일하게 유지함으로써 확장성이 보장된다.

  • 하위 클래스는 상위 클래스의 메서드를 수정 없이 모두 구현해야 한다.
  • 상위 클래스를 사용했을 때 유효하다면 하위 클래스를 사용하더라도 유효해야 한다.

 최소 위 조건들을 만족해야 손실 없이 상속 관계의 클래스가 교체될 수 있게 된다. 만약 상위 클래스의 기능 중 하위 클래스에서 구현할 수 없는 기능이 있다면, 이는 리스코프 교체 원리를 만족할 수 없으므로 상속 대신 인터페이스나 컴포지션을 통한 구현을 고려해야 한다.

abstract class Animal {
    abstract cry(): void;
    abstract eat(): void;
    abstract move(): void;
}

class Dog extends Animal {
    override cry(): void {
        console.log("멍멍");
    }
    override eat(): void {
        console.log("밥 먹는중");
    }
    override move(): void {
        console.log("네발로 걷는 중");
    }   
}

class Cat extends Animal {
    override cry(): void {
        console.log("야옹");
    }
    override eat(): void {
        console.log("밥 먹는중");
    }
    override move(): void {
        console.log("네발로 걷는 중");
    }       
}

 위 클래스에서 Dog 및 Cat은 추상 클래스에 정의된 모든 추상 메서드를 손실 없이 구현할 수 있으므로 LSP을 만족한다. 이때 동물 클래스에 Bird가 추가된다고 생각해 보자. Bird는 날기 동작을 수행할 수 있으므로 이에 대응하기 위해 Animal에 추상 메서드인 fly을 추가한 코드는 다음과 같다.

abstract class Animal2 {
    abstract cry(): void;
    abstract eat(): void;
    abstract move(): void;
    abstract fly(): void;
}

class Dog2 extends Animal2 {
    override cry(): void {
        console.log("멍멍");
    }
    override eat(): void {
        console.log("밥 먹는중");
    }
    override move(): void {
        console.log("네발로 걷는 중");
    }
    override fly(): void {
        throw new Error("개는 날개가 없어요");
    }
}

class Cat2 extends Animal2 {
    override cry(): void {
        console.log("야옹");
    }
    override eat(): void {
        console.log("밥 먹는중");
    }
    override move(): void {
        console.log("네발로 걷는 중");
    } 
    override fly(): void {
        throw new Error("고양이는 날개가 없어요");
    }     
}

class Bird extends Animal2 {
    override cry(): void {
        console.log("짹짹");
    }
    override eat(): void {
        console.log("밥 먹는중");
    }
    override move(): void {
        console.log("총총 걷는 중");
    } 
    override fly(): void {
        console.log("날고 있어요")
    }     
}

 Animal 클래스에 fly가 존재하는 경우, 하위 모든 클래스는 fly을 구현해야 한다. 문제는 모든 동물이 날 수는 없다는 점이다. 기존에 있던 클래스인 Dog 및 Cat은 날개가 없어 나는 동작을 구현할 수 없으므로 fly을 호출하는 경우 에러를 반환하도록 코드를 작성하게 된다. 이 경우 상위 클래스의 내용이 하위 클래스에서 손실되는 것이므로 LSP를 위반하게 된다.

 fly 동작은 모든 동물에게 보편적인 기능이 아니므로 Animal에서 정의하여 상속 기반으로 구현하기에는 적합하지 않다. 대안으로 선택할 수 있는 방법은 인터페이스 또는 컴포지션을 이용하여 다형성을 구현하는 것이다. 현재 상황은 인터페이스 기반 구현이 더 적합하므로, fly 동작을 IFlyable 인터페이스로 분리하고, Bird에서 구현하도록 코드를 변경하자.

abstract class Animal3 {
    abstract cry(): void;
    abstract eat(): void;
    abstract move(): void;
}

interface IFlyable {
    fly(): void;
}

class Dog3 extends Animal3 {
    override cry(): void {
        console.log("멍멍");
    }
    override eat(): void {
        console.log("밥 먹는중");
    }
    override move(): void {
        console.log("네발로 걷는 중");
    }
}

class Cat3 extends Animal3 {
    override cry(): void {
        console.log("야옹");
    }
    override eat(): void {
        console.log("밥 먹는중");
    }
    override move(): void {
        console.log("네발로 걷는 중");
    } 
}

class Bird3 extends Animal3 implements IFlyable {
    override cry(): void {
        console.log("짹짹");
    }
    override eat(): void {
        console.log("밥 먹는중");
    }
    override move(): void {
        console.log("총총 걷는 중");
    } 
    fly(): void {
        console.log("날고 있어요")
    }     
}

  수정된 코드에서는 Animal 클래스와 하위 클래스를 변경할 때 Animal 입장에서 손실되는 메서드가 존재하지 않으므로 LSP을 만족한다.

 컴포지션 기반으로 다형성을 구현해야 하는 경우도 존재한다. 리스트(배열)를 사용하는 자료구조 Stack과 Heap을 생각해 보자. 두 자료구조는 리스트를 저장 공간으로 사용하기는 하지만 삽입 및 삭제의 논리는 리스트와 완전히 다르며, 데이터를 다루기 위한 인터페이스도 다르다. 따라서 Stack, Heap이 리스트를 상속하도록 코드를 작성하면 LSP가 완전히 망가진다. 이렇듯 기존 클래스의 기능을 단순히 사용하는 경우는 컴포지션을 통해 다형성을 만족하는 것이 자연스럽다.

class Heap<E> {
    private list: E[];
    // 이외의 변수들...
    constructor() {
        this.list = [];
    }
    insert(data: E) {
        // 데이터의 순서를 찾는 코드... O(log2 N)
        this.list.push(data); // 여러 로직들...
    }
    delete(): E {
        return this.list.splice(0,1)[0];
    }
}

class Stack<E> {
    private list: E[];
    //이외의 변수들...
    constructor() {
        this.list = [];
    }
    push(data: E) {
        this.list.push(data);
    }
    pop() {
        return this.list.pop();
    }
    peak() {
        return this.list.at(-1);
    }
}

 위 코드에서는 컴포지션을 통해 Heap 및 Stack 자료구조에서 리스트 자료구조를 저장 공간으로 사용하고 있다. list의 push, pop 인터페이스는 Heap의 insert, delete와 호환되지 않으므로 컴포지션을 통해 다형성을 만족한다.

 만약 다른 파라미터 또는 반환형의 메서드를 가진 하위 클래스들에 대해 LSP을 만족하고 싶은 경우, 하위 클래스들의 입력 및 출력을 모두 포괄할 수 있도록 부모 클래스에서 인터페이스를 정의해야 한다. 이런 개념은 개인적으로 동적 언어에서 오버로딩을 하는 개념과 유사해 보인다. 요점은 입출력을 포괄하는 인터페이스를 정의하는 것이다.

function add(a: number, b: number): number;
function add(a: number, b: number, c: number): number;
function add(a: number, b: number, c: number, d: string): string;

function add(a: number, b:number, c?:number, d?: string): number|string {
    if(c != undefined) {
        if(d != undefined) {
            return a + b + c + d;
        }
        return a + b + c;
    }
    return a + b;
}

 위 코드는 타입스크립트에서 함수 오버로딩을 하는 모습을 보여준다. 각 오버로딩 시그니처를 특정 클래스에서 요구하는 메서드의 구조로, 구현 시그니처 인터페이스를 부모 클래스에서 선언해야 하는 인터페이스 구조라고 생각해 보자. 구현 시그니처의 인터페이스는 제시된 3개 오버로딩 시그니처가 요구하는 파라미터 및 반환 타입을 만족하도록 포괄적인 구조를 가지고 있으므로 LSP을 만족한다. 굳이 클래스 기반 코드로 나타낸다면 다음과 같다.

abstract class Add {
    abstract add(a: number, b: number, c?: number, d?: string): number|string;
}

class Add1 extends Add {
    override add(a: number, b: number, c?: number | undefined, d?: string | undefined):number {
        return a + b;
    }
}

class Add2 extends Add {
    override add(a: number, b: number, c: number, d?: string | undefined): number {
        return a + b + c;
    }
}

class Add3 extends Add {
    override add(a: number, b: number, c: number, d: string): string {
        return a + b + c + d;
    }
}

 부모 클래스인 Add 의 add 인터페이스는 하위 모든 클래스가 요구하는 파라미터를 포괄하도록 정의되며, 하위 클래스 Add1 ~ Add3에서는 인터페이스에 제시된 파라미터들을 선택적으로 사용하여 원하는 결과를 구현하고 있다.


Interface Segregation Principle

 인터페이스 격리의 원리는 하나의 범용적인 인터페이스를 구성하는 대신 여러 개의 개별적인 인터페이스를 구성하는 것으로, 특정 클래스가 인터페이스의 부분만을 사용한다면 해당 부분만을 새로운 인터페이스로 분리하여 클래스에 대해 기대하는 기능만을 제공할 수 있도록 구성해야 함을 의미한다. 클라이언트가 사용하지 않는 인터페이스에 의존하거나 구현하지 않도록 인터페이스를 분리하는 것이 주가 된다.

 보안 상의 이유로 읽기 기능만 제공하는 데이터베이스 접근 클래스 ReadonlyDB를 생각해 보자. 해당 클래스는 당연히 update, delete과 같은 기능을 사용할 수 없다. 이 상황에서 데이터베이스 전체 동작을 정의한 인터페이스 IDatabase <T>을 구현하게 한다면, 읽기 기능을 제외한 모든 메서드가 에러를 반환하도록 구현해야만 할 것이다.

interface IDatabase<T> {
    findById(id: string): T|null;
    findMany(): T[]; 

    update(id: string, values: Partial<T>): void;
    updateMany(...items: {id: string, values: Partial<T>}[]): void;
    delete(id: string): void;
    deleteMany(...id_list: string[]): void;
}

interface Data {}

class db {
    static somethingReturn(): Data {
        return {} as Data;
    }
    static somethingReturnMany(): Data[] {
        return [{},{}];
    }
    static somethingDo() {

    }
}

class ReadonlyDB implements IDatabase<Data> {
    findById(id: string): Data | null {
        return db.somethingReturn();
    }
    findMany(): Data[] {
        return db.somethingReturnMany();
    }
    update(id: string, values: Partial<Data>): void {
        throw new Error("Method not implemented.");
    }
    updateMany(...items: { id: string; values: Partial<Data>; }[]): void {
        throw new Error("Method not implemented.");
    }
    delete(id: string): void {
        throw new Error("Method not implemented.");
    }
    deleteMany(...id_list: string[]): void {
        throw new Error("Method not implemented.");
    }
}

  ReadonlyDB 클래스는 IDatabase 인터페이스를 구현하므로 읽기 동작과 관련 없는 update, delete 등의 메서드를 구현하며, '읽기 전용' 이라는 의미와 맞지 않는 인터페이스를 외부에 노출한다. 이는 사용하지도 않는 인터페이스를 포함하기 때문에 발생하는 현상이므로, IDatabase의 각 기능을 좀 더 세부적으로 쪼개 해결 가능하다.

interface IReadDatabase<T> {
    findById(id: string): T|null;
    findMany(): T[]; 
}
interface IWriteDatabase<T> {
    update(id: string, values: Partial<T>): void;
    updateMany(...items: {id: string, values: Partial<T>}[]): void;
    delete(id: string): void;
    deleteMany(...id_list: string[]): void;
}
interface IDatabase2<T> extends IReadDatabase<T>, IWriteDatabase<T> {}

class ReadonlyDB2 implements IReadDatabase<Data> {
    findById(id: string): Data | null {
        return db.somethingReturn();
    }
    findMany(): Data[] {
        return db.somethingReturnMany();
    }
}

 위 코드에서는 IDatabase에 정의된 메서드들을 읽기 / 쓰기 기능 단위로 쪼갠 후 IReadDatabase<T>만을 ReadonlyDB가 구현하도록 함으로써 읽기 전용 데이터베이스에 대해 요구하는 사항을 만족했다.


Dependency Inversion Principle

 

 의존관계 역전의 원리는 추상적인 것이 구체적인 것에 의존하는 대신, 구체적인 것이 추상적인 것에 의존해야 한다는 의미를 담고 있다. 구체적으로 다음 조건을 만족한다.

  1. 상위 모듈은 하위 모듈에 의존해서는 안되며, 상위 모듈 및 하위 모듈 모두 추상화에 의존해야 한다.
  2. 추상화는 세부적인 것에 의존해서는 안되며, 세부적인 것이 추상화에 의존해야 한다.

 상위 / 하위 모듈에 관계 없이 모두 추상적인 인터페이스에 의존하도록 구현함으로써 모듈 사이의 결합도 및 종속 수준을 낮추고 재사용성을 높이는 것이 DIP의 목적이다. 이 원칙은 위키피디아를 읽어보는 게 더 좋을 것 같다.

https://en.wikipedia.org/wiki/Dependency_inversion_principle


전통적 레이어 구조

 전통적인 레이어 구조에서는 상위 구성요소가 하위 구성요소를 직접 사용한다. 아래 그림에서 Policy 모듈은 구체적인 Mechanism 모듈에 의존하며, Mechanism 모듈 역시 구체적인 Utility 모듈에 의존한다.

 Policy 및 Mechanism은 구체적인 하위 모듈을 사용하고 있으므로 재사용이 제한된다. 만약 현재 메커니즘 레이어를 다른 모듈로 대체하고 싶다면? Policy 레이어는 Mechanism에 직접적으로 의존하고 있는 코드들을 모두 걷어 내고 새로운 메커니즘 모듈에 대응되도록 코드를 작성해야 한다. 

 이런 현상은 각 모듈이 구체적인 모듈에 의존하기 때문에 발생한다. 만약 각 계층이 추상적인 인터페이스에 의존하고, 해당 인터페이스를 하위 모듈에서 구현하도록 구현했다면 계층의 코드는 추상적 수준을 유지하기 때문에 별다른 변경 없이 하위 모듈을 교체할 수 있을 것이다. 이 개념이 DIP이다.

전통적 레이어 구조: https://en.wikipedia.org/wiki/Dependency_inversion_principle

사진 출처

DIP를 적용한 레이어 구조

DIP를 적용한 구조: https://en.wikipedia.org/wiki/Dependency_inversion_principle

 DIP을 적용하면 모든 모듈이 추상적인 인터페이스에 의존하게 된다. 변하는 부분과 변하지 않는 부분으로 구분해보자면 인터페이스는 변하지 않는 추상적 개념으로, 구현은 변하는 구체적 객체로 분류할 수 있다. 여기서 각 모듈은 변하지 않는 추상적 요소를 의존하므로 구체적 구현을 담고 있는 모듈이 변경되더라도 내부 코드에 영향을 주지 않게 된다.

 DIP의 예시는 굳이 들 필요가 없을 정도로 자주 사용된다. 어댑터 패턴, 브릿지 패턴 등 다양한 패턴에서  DIP를 직접적으로 느낄 수 있으며, 프레임워크 수준에서도 DIP을 사용하는 경우가 매우 많다. 프로젝트에서 데이터베이스 연동을 위해 mysql 커넥터 대신 ORM을 사용하고 있다면, 그것 역시 DIP를 사용하고 있다고 볼 수 있다. 구체적인 쿼리 대신 추상적인 인터페이스를 이용하고 있기 때문이다. 그래도 예시 코드를 들어본다면 다음과 같다.

class mysqlLib {
    static query(query: string): any {
        return "";// do something
    }
    static execute(query: string): any {
        return "";// do something
    }
}

class UserRepository {
    save(user: {id?: string, name: string, age: number}) {
        if(user.id != undefined) {
            mysqlLib.query(`UPDATE USERDB 
            SET
            name = ${user.name}, 
            age = ${user.age} 
            WHERE ID = ${user.id}`);
        } else {
            const {id, name, age} = mysqlLib.query(`INSERT INTO USERDB (name, age) 
            VALUES (${user.name}, ${user.age})`);
            user.id = id;
        }
    } // 등등 여러 코드들...
}

 UserRepository에서는 mysql를 연결하는 라이브러리인 mysqlLib을 이용한다. 만약 현재 프로젝트에서 다른 DBMS을 사용할 가능성이 낮고, 성능을 중시한다면 mysqlLib을 직접 사용하더라도 상관없다.

 그러나 DBMS을 변경할 가능성이 있다면 말이 달라진다. OracleDB, sqlServer을 사용해야 하는 경우 기존에 작성된 코드들을 대응되는 라이브러리 기준으로 변경하고 SQL 차이를 보정하기 위해 일부 쿼리문을 수정할 필요가 있다. 아예 mongodb 같은 NoSQL을 사용한다면? 쿼리문 수준에서부터 전부 다시 작성해야 한다. 쿼리문만 수정해도 복잡한 상황에서 사용되는 라이브러리에 맞게 전반적인 코드 베이스를 수정하는 것은 너무 지치는 작업이다. 이 문제는 특정 DBMS에 종속된 구체적인 라이브러리를 코드 상에서 직접 의존하기 때문에 발생한다.

 이를 DIP를 통해 해결하면 다음과 같은 코드가 될 수 있다. 간단하게 구현한 것이므로 타입이 어떤지는 신경쓰지 말자. 

interface IDBAction<T> {
    findById(id: string, table: string): T | null;
    findMany(table: string): T[];
    update(id: string, values: Record<string, any>, table: string): void;
    insert(values: Record<string, any>, table: string): T;
}

class mysqlDBImpl implements IDBAction<any> {
    findById(id: string, table: string) {
        return mysqlLib.query(`SELECT * FROM ${table} where ID = ${id}`);
    }
    findMany(table: string): any[] {
        return mysqlLib.query(`SELECT * FROM ${table}`);
    }
    update(id: string, values: Record<string, any>, table: string) {
        const target = Object.entries(values);

        mysqlLib.query(`UPDATE ${table} 
        SET ${target.map((k, v) => `${k} = ${v}`).join(',')} 
        WHERE ID=${id}`);
    }
    insert(values: Record<string, any>, table: string) {
        const keylist = Object.keys(values);
        const valueList = Object.values(values);

        return mysqlLib.query(`INSERT INTO ${table} (${keylist.join(',')}) 
        VALUES (${valueList.join(',')})`);
    }
}

class UserRepository2 {
    private static SCHEMA = "USERDB";
    constructor(private db: IDBAction<any>) { }

    save(user: { id?: string, name: string, age: number }) {
        if (user.id != undefined) {
            this.db.update(user.id, { name: user.name, age: user.age }, UserRepository2.SCHEMA);
        } else {
            const {id, name, age} = this.db.insert(user, UserRepository2.SCHEMA);
            user.id = id;
        }
    } // 등등 여러 코드들...
}

 dbms에서 제공해야 하는 기능을 담은 추상적 인터페이스인 IDBAction <T>을 정의하고, UserRepository에서 해당 인터페이스 객체를 받도록 설정한다. 이를 통해 UserRepository는 추상적인 인터페이스를 이용하여 dbms 관련 라이브러리에 접근할 수 있다.

 이때 mysqlLib는 IDBAction<T>와 인터페이스가 대응되지 않는다. 따라서 어댑터 패턴을 사용하여 인터페이스에 정의된 기능을 구현한 mysqlDBImpl을 생성하여 대신 사용한다. UserRepository는 IDBAction <T>에만 의존하므로, 차후 새로운 dbms로 교체하고 싶다면 IDBAction <T> 인터페이스를 구현하는 어댑터를 만들어 UserRepository와 결합하면 된다.


 SOLID 원칙이 항상 정답인 것은 아니며, 모든 패턴이 SOLID 원칙을 전부 준수하지는 않는다. 그럼에도 5개 원칙은 객체 설계에 있어서 매우 중요한 지위에 있으므로 잘 알아둬야 겠다.

'CS > 디자인패턴' 카테고리의 다른 글

[디자인패턴] Mediator 패턴  (0) 2023.04.30
[디자인패턴] Facade 패턴  (0) 2023.04.30
[디자인패턴] Bridge 패턴  (0) 2023.04.25
[디자인패턴] Singleton 패턴  (0) 2023.04.20
[디자인패턴] Adapter 패턴  (1) 2023.04.14