객체 지향 설계와 SOLID 원칙

혁콩·2024년 2월 9일
0

객체지향

목록 보기
3/3


객체 지향 프로그래밍에 대해 공부하다 보면 반드시 마주치는 주제가 있다.
객체 지향 설계 5원칙이라고도 불리는 SOLID 원칙이 그것인데, 이번 포스트에선 각 원칙이 의미하는 내용에 대해 알아보자.

들어가기에 앞서

객체 지향 프로그래밍은 무엇일까? 이전에 봤던 내용을 토대로 생각해보자.
객체 지향 프로그래밍은 상속, 다형성, 추상화, 캡슐화라는 4가지 특성을 가지며, 이를 통해 코드의 재사용과 확장을 쉽게 할 수 있다.

그냥 4가지 특징을 적절히 조합해서 사용하면 안될까? 원칙이 존재하는 이유에 대해 고민하며 각 원칙에 대해 알아보자.

잡설

처음 해당 원칙을 접했을 때를 생각해봤다. 군인 시절 한빛미디어 이벤트로 받은 책을 통해 처음 공부했었는데, 이론을 이해하기 어려워 리뷰를 작성하지 못했던 기억이 있다. (죄송합니다...)

이후 스프링 프레임워크를 공부하며 해당 원칙이 어떻게 적용되어 있는지를 보며, 보다 쉽게 이해할 수 있었던 것 같다.
혹시나 잘 이해가 가지 않아 이런저런 블로그를 탐방하고 있다면, 스프링 프레임워크의 동작 원리와, 구현에 대해 같이 공부해보면 좋을 것 같다고 생각한다.

SRP, 단일 책임 원칙

한 클래스는 하나의 책임만 가져야 한다.

책임이란 무엇일까?
실전 자바 소프트웨어 개발에서 말하고 있는 단일 책임 원칙은 다음과 같다.

  • 한 클래스는 한 기능만 책임진다.
  • 클래스가 바뀌어야 하는 이유는 오직 하나여야 한다.

DTO를 예시로 들어 보자. DTO는 데이터의 전송이라는 책임이 있다. 즉, DTO는 데이터를 전송하는 기능만을 담당해야 한다는 것이다.

DTO에 데이터를 저장하는 비즈니스 로직이 추가되었다고 생각해보자. 단일 책임 원칙에 의하면, DTO 클래스는 데이터 전송 기능이 수정되어야 할 때만 변경되어야 하지만, 데이터 저장에 대한 책임 또한 지고 있다.
데이터를 저장하는 기능에 변경이 발생했다면, 데이터 저장의 책임이 있는 DAO, Repository 클래스 뿐만 아니라, 단일 책임 원칙을 위반한 DTO 클래스 또한 변경이 필요하게 된다.

단일 책임 원칙을 지키게 되면, 위와 같은 불필요한 변경을 막을 수 있어 가독성과 유지보수 측면에서의 이점을 누릴 수 있다.


OCP, 개방/폐쇄 원칙

확장에는 열려있고 변경에는 닫혀 있어야 한다.

확장은 가능한데 변경은 불가능하게 만들라니, 이게 무슨 말일까.

확장이 되어야 할 부분을 구분하고, 이를 인터페이스를 통해 추상화된 계층을 만든다. 인터페이스를 통해 역할과 구현을 분리하고, 확장(구현체만 바꾸면 되게끔)이 가능하게끔 하는 원칙이다.

아래의 간단한 예시를 보자.

public interface A {
    void func();
}

public class B implements A {
    @Override
    public void func() {

    }
}

public class C implements A {
    @Override
    public void func() {

    }
}

public class Test {
    A a = new B();
//    A a = new C();

    public void test() {
        a.func();
    }
}

인터페이스 A를 통해 func() 메소드를 호출하므로, 구현체만 추가하여 바꿔준다면 확장이 가능할 것 같다.
다만 조금 이상한 부분이 있는데, 인터페이스는 인스턴스화 될 수 없다는 것이다. 결국 new 키워드를 통해 생성되는 객체에 실제 구현체를 적어줘야 하는 문제가 발생하며, 확장이 발생하면 실 코드에서 구현체 부분을 수정해줘야 한다.

이러한 문제를 해결하기 위해선 구현체를 주입해주는 제 3자가 필요한데, 이 내용은 마지막에 다뤄보자.


LSP, 리스코프 치환 원칙

프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.

실전 자바 소프트웨어 개발 책의 설명을 살펴보자.

q(x)는 T 형식의 객체를 증명할 수 있는 공식이다. 그러면 S 형식의 객체 y가 있고 S가 T의 하위 형식이라면 q(y)는 참이다.

정말 하나도 모르겠다.

다행히도 추가적인 설명이 있다!

  • 하위형식에서 선행조건을 더할 수 없음
  • 하위형식에서 후행조건을 약화시킬 수 없음
  • 슈퍼형식의 불변자는 하위형식에서 보존됨
  • 자식 클래스는 부모가 허용하지 않는 상태 변화를 허용하지 않아야 함
처음 설명보단 쉬워졌지만 여전히 어렵다. 이론만 두고 보면 가장 어려운 원칙이 아닐까...
방황하던 중, 김영한님이 스프링 강의 중 간단하게 설명해주신 내용이 있었다.

구현체는 인터페이스가 의도한 동작을 구현해야 한다.

자동차를 구현했다고 가정해보자.
가속, 감속 기능을 추상화해 인터페이스로 선언하고, 각 구현체는 해당 인터페이스를 구현한다.
이 때, 구현체는 인터페이스가 의도한 동작을 구현해야 한다. 가속 기능에 감속, 혹은 다른 기능들을 구현하면 안된다는 것이다.


ISP, 인터페이스 분리 원칙

특정 클라이언트를 위한 여러 인터페이스가 하나의 범용 인터페이스보다 낫다.

마찬가지로, 자동차를 예시로 들어보겠다.

A 자동차엔 크루즈 시스템, 통풍시트, 빌트인 네비게이션 기능이 존재한다.

이를 위해 해당 기능들을 추상화한 범용 인터페이스 1개를 만들었다.
이후 새로운 자동차 모델을 개발하려 한다.

B 자동차엔 크루즈 시스템, 온열시트, 빌트인 네비게이션 기능이 존재한다.

이전 인터페이스를 재사용 하려고 보니, 필요없는 통풍시트 기능을 구현해야 하고, 온열시트 기능은 추가해야 한다.

위 예시를 통해 하나의 인터페이스가 너무 많은 기능을 가질 시 불필요한 기능에 의존하며, 변경이 생길 가능성이 높아진다는 것을 알 수 있었다.

만약 기능을 분리해 여러 인터페이스로 구성했더라면, 필요한 인터페이스만을 골라 구현하고, 기능 추가 또한 기존 코드의 변경이 일어나지 않는 선에서 가능했을 것이다.


DIP, 의존 관계 역전 원칙

구체화에 의존하지 않고, 추상화에 의존해야 한다.

조금 다르게 표현해보자.

구현 클래스를 알지 말고, 인터페이스만 알고 있어야 한다.

간단한 예시를 보자.

public class Test {
	// 인터페이스는 인스턴스가 될 수 없음
    A a = new A();

    public void test() {
        a.func();
    }
}

개방/폐쇠 원칙 에서 나왔던 문제와 동일한 문제가 발생한다.
결국 이를 해결하기 위해선 구현체를 해당 인스턴스의 위치에 삽입해주는 무언가가 필요하다.


SOLID 원칙과 Spring Framework

스프링 프레임워크는 OCPDIP 에서 발생만 문제를 해결하기 위해 IoCDI라는 개념이 도입된다.

IoC(Inversion of Control), 제어의 역전이라는 의미로, 프로그램의 제어 흐름을 외부로 넘기는 것이다.
DI(Dependency Injection), 의존성 주입 혹은 의존 관계 주입이라고도 불리는 방법을 통해 IoC를 실현한다.

실제 작성되는 코드엔 구현체를 명시하지 않고, 런타임 시 프레임워크에 의해 구현체를 주입받음으로써 인터페이스가 인스턴스화 될 수 없다는 문제를 해결한다.

자세히 설명하면 보다 복잡하므로 추후 스프링 프레임워크에 대해 다루는 포스트에서 정리해보겠다.

profile
아는 척 하기 좋아하는 콩

0개의 댓글