좋은 객체 지향 설계의 5 가지 원칙 : SOLID

청주는사과아님·2024년 12월 17일
1
post-thumbnail

Github Repository

결국 좋은 객체 지향 설계 라는건 잘 만들어서 유지보수가 쉬운 설계 라고 생각한다.

강의에서는 다음 5 가지 원칙을 설명하고 있으며, 사실 이들의 내용은 그 명칭과 동치한다.

  • Single Responsibility Principle (SRP)
  • Open/Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

이들을 한번 내가 보기 쉽게 정리해 보도록 하겠다.


I. SRP, OCP : 유지보수 쉽게 만들어라

SRP : 단일 책임 원칙

클래스가 담당하는 책임을 하나로 제한하는 것이 좋다.

다음과 같은 상황을 생각해보자.
어떤 기능 구현을 위해 클래스를 설계한다. 기능은 크게 A, B, C 로 나뉘며 추후 유지보수가 용이하도록 만들고자 한다.

이 때 만약 SRP 를 지키지 않는다면, 다음처럼 아주 길고 유지보수하기 어려운 클래스를 만들 수 있을 것이다.

class ImproperPrinciple {
    
    public void proceedTaskA() {
        this.internalTaskA();
    }
    public void internalTaskA() {}

    public void proceedTaskB() {
        this.internalTaskB();
    }
    public void internalTaskB() {}

    public void proceedTaskC() {
        this.internalTaskC();
    }
    public void internalTaskC() {}
}

위 코드에서 어떤 기능 하나를 고치려 하면 해당 기능이 ImproperPrinciple 의 어디있는지 찾아 해당 부분만 올바르게 수정 해야 한다.
즉, 유지보수가 불편하고 코드가 더럽다.

이를 SRP 를 지켜 다시 설계하면 다음처럼 보일 수 있다.

class A {
    public void proceedTaskA() {
        this.internalTaskA();
    }
    private void internalTaskA() {}
}

class ProperPrinciple {
    private A taskA;
    
    public void doTaskA() {
        taskA.proceedTaskA();
    }
}

이를 통해 기능별 독립적인 변경 이 가능해졌고 유지보수에 더 용이해졌다.

OCP : 개방 폐쇄 원칙

소프트웨어 요소의 확장은 가능하나 변경은 불가능한 것이 좋다.

OCP 는 쉽게말해 적절한 추상화로 유지보수를 높이라 는 원칙이다.

앞선 예시에서 기능 A이메일 알림 기능 이라 생각해보자.

class EmailNotif {
    public void sendEmail() {
        this.logAction();
    }
    private void logAction() {}
}

class ProperPrinciple {
    private EmailNotif notif;
    
    public void notif() {
        notif.sendEmail();
    }
}

그런데 만약 이것이 이메일 알림 에서 앱 푸시 알림 으로 교체되어야 한다면 어떨까?
EmailNotif 가 딱딱한 구현체이기 때문에 앱 푸시 알림 전용 클래스 를 또 만들어야 하고, 무엇보다 ProperPrinciple 의 코드에도 변경이 생긴다.

class PushNotif {
    public void sendPush() {
        this.logAction();
    }
    private void logAction() {}
}

class ProperPrinciple {
    private PushNotif notif;
    
    public void notif() {
        notif.sendPush();
    }
}

이를 위한 원칙이 바로 OCP 로, 해당 원칙을 통해 ProperPrinciple 의 변경을 최소한으로 바꿀 수 있다.

abstract class Notif {
    abstract public void sendNotif();
    abstract protected void logAction();
}

class EmailNotif extends Notif {
    /* ... 생략 ... */
}
class PushNotif extends Notif {
    /* ... 생략 ... */
}

class ProperPrinciple {
    private Notif notif;
    
    public void setNotif() {
        notif.sendNotif();
    }
}

위 코드를 보면 ProperPrinciple알림 기능을 담당하는 요소 를 상위 타입인 Notif 로 변경된 것을 볼 수 있다.

이를 통해 EmailNotif, PushNotif 두 기능을 모두 통합 가능하고, 무엇보다 다른 알림 기능이 추가되었을 때, ProperPrinciple 의 코드는 변경하지 않으면서 기능 추가가 가능하다.


II. LSP : 구현 똑바로 만들어라

LSP : 리스코프 치환 원칙

하위 타입의 개체는 상위 타입의 것으로 온전히 대체 가능해야 한다.

LSP 의 정확한 내용은 "T 의 하위타입 S 가 있을 때, T 가 가능한 모든 행동은 S 에서도 동일해야 한다"[1] 라는 내용이다.

다음 예시를 보자.

@Setter @Getter
class Sup {

  protected char x, y;

  public void echoVars() {
    System.out.printf(
            "%s - %s %s\n",
            this.getClass().getSimpleName(),
            x, y
    );
  }
}

class Sub extends Sup {

  @Override
  public void setX(char x) {
    this.x = this.y = x;
  }

  @Override
  public void setY(char y) {
    this.x = this.y = y;
  }
}

Sup sup = new Sup();
sup.setX('X');
sup.setY('Y');
sup.echoVars();

Sup sub = new Sub();    // 위에랑 동일하니까
sub.setX('X');
sub.setY('Y');
sub.echoVars();     // 결과도 동일해야 원칙 위배 X
Sup - X Y
Sub - Y Y   // 근데 아님. 원칙 위배

위 코드는 문법상 문제는 없지만, 객체 지향의 대체성 (Substitutability) 에서 생각해보면 하위 타입 SubSup 를 전혀 대체하지 못하고 있다.

SupX Y 로 보이지만 SubY Y 로 보이기 때문이다.

LSP 는 이런 하위 타입의 행동이 상위 타입의 행동을 대체 가능해야 함을 지적하는 원칙으로 올바른 상속 관계와 구현까지 이루어져야 함을 시사한다.

한마디로 똑바로 만들라는 이야기


III.ISP, DIP : 적절한 추상화를 사용해라

ISP : 인터페이스 분리 원칙

너무 커다란 인터페이스는 나누는 것이 좋다.

아래 예시를 보자.

interface Huge {
    void typeA1();
    void typeA2();
    void typeA3();
    
    void typeB1();
    void typeB2();
    void typeB3();
}

Huge 를 보면 메서드의 종류가 A, B 로 나뉘어 있음을 볼 수 있다.
ISP 는 이러한 경우 아래처럼 메서드를 한번 더 분리하는 것을 권유하는 원칙이다.

interface A {
    void typeA1();
    void typeA2();
    void typeA3();
}

interface B {
    void typeB1();
    void typeB2();
    void typeB3();
}

// ISP 가 아래처럼 사용하라는 것은 아니지만
// Huge 인터페이스에 전부 몰아넣는 것 보단 아래가 낫다.
interface Huge extends A, B {}

ISP 를 통해 인터페이스간 역할이 더욱 명확해지고 대체 가능성이 높아진다.
하지만 불필요한 분리는 오히려 인터페이스를 많게 하므로 적절한 분리가 중요하다.

DIP : 의존관계 역전 원칙

추상화된 개체를 속성으로 갖는게 좋다.

마지막으로 DIP 는 추상체에 의존하는 것을 권유하는 원칙이다.

class AImpl implements A {
    /* ... 구현 ... */
}

class Feature {
    private AImpl a;
}

위 코드를 보면 어느 기능 FeatureA 를 수행하기 위해 AImpl a 속성을 갖고 있다.
하지만 AImpl 는 인터페이스 A 의 구현체이므로, Feature 는 딱딱한 구현체에 의존 한다.

DIP 는 위 상황에서 아래처럼 추상화 에 의존하라 권유한다.

class Feature {
    private A a;
    
    public Feature(A a) {
        this.a = a;
    }
}

Feature feat = new Feature(
        new AImpl()
);

이러한 경우 FeatureAImpl 구현체 의존에 벗어나 더 유연한 대처가 가능해진다.


SOLID더 편하고 쉬운 코드 를 만드는데 의의가 있다 생각한다.
원칙들은 설계 과정에서 올바른 방향성을 제시하지만 그것이 실제 상황에선 정답이라 할 순 없기 때문이다.

그러니 SOLID 원칙에 너무 연연하지 않고 각자 상황에 따른 적절한 타협점을 찾았으면 한다.


Reference

  • Liskov substitution principle - Wikipedia

    [1] : Liskov's notion of a behavioural subtype defines a notion of substitutability for objects; that is, if S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program

profile
나 같은게... 취준?!

1개의 댓글

comment-user-thumbnail
2024년 12월 22일

solid에 대해 너무 잘 작성해주셨네요

답글 달기