좋은 객체 지향 설계의 원칙 - SOLID

김소희·2024년 11월 18일
2

SOLID란

'클린 코드' 책을 쓴 '로버튼 마틴'이 좋은 객체 지향 설계의 5가지 원칙을 정리하였다.
그 원칙의 앞글자를 따서 SOLID가 되었는데 하나씩 살펴보자.

  • SRP : 단일 책임 원칙(single responsibility principe)
  • OCP : 개방-폐쇄 원칙(Open/Closed principe)
  • LSP : 리스코프 치환 원칙(Liskov substitution principe)
  • ISP : 인터페이스 분리 원칙(Interface segregation principe)
  • DIP : 의존관계 역전 원칙(Dependency inversion principe)

아래처럼 다시 정리해 줄게요.
이번에는 마크다운 문법만 사용하고, 복사할 때 깨질 수 있는 특수문자나 스타일을 최소화했어요.


SRP — 단일 책임 원칙

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

단일 책임 원칙(SRP)은 모든 클래스가 오직 하나의 책임만 가져야 한다는 객체 지향 설계 원칙이다.
여기서 책임이란 문맥과 상황에 따라 클 수도, 작을 수도 있지만 핵심은 변경이 발생했을 때 파급 효과가 최소화되는가 이다.
즉, 어떤 변경이 생겼을 때 하나의 클래스나 모듈만 수정하면 된다면 SRP를 잘 지키고 있는 것이다.

SRP가 중요한 이유

  • 변경 영향 최소화 : 책임이 여러 개 섞여 있는 클래스는 한 가지 변경이 다른 기능까지 영향을 미치게 된다.
  • 코드 복잡성 감소 : 코드가 단순해지고 테스트와 유지보수가 쉬워진다.
  • 확장성 향상 : 새로운 기능을 추가하거나 변경할 때 안정성을 유지할 수 있다.

책임을 분리하는 기준

하나의 클래스나 메소드가 여러 가지 일을 하고 있다면 관심사(Concern) 를 기준으로 분리해야 한다.
일반적으로 다음과 같은 기준으로 책임을 나눌 수 있다:

  1. 관심사(Concern)에 따라 분리
    • 예: UI 처리 로직과 비즈니스 로직을 분리
  2. 변경 이유(Change Reason)에 따라 분리
    • 예: 데이터베이스 변경 vs. 화면 디자인 변경
  3. 공통 코드 및 중복 코드 추출
    • 예: 여러 곳에서 반복되는 유틸리티 메서드를 별도의 클래스로 분리

SRP를 잘 지킨 설계란?

  • 변경이 발생했을 때 한 지점만 수정하면 충분한 구조
  • 각 클래스가 명확한 역할과 경계를 가짐
  • 읽는 사람도 “이 클래스는 이 역할만 한다”라고 쉽게 이해할 수 있는 코드

간단한 예시

  • UI 변경
    화면 디자인이 바뀌어도 비즈니스 로직 클래스는 수정하지 않아도 됨

  • 객체 생성과 사용 분리
    팩토리 패턴을 사용하여 객체 생성 책임을 별도의 클래스에 위임

// ❌ SRP 위반
class UserController {
    public void createUser() {
        // 사용자 생성 로직
        // DB 연결 로직
        // UI 업데이트 로직
    }
}

// ✅ SRP 준수
class UserService {
    public void createUser() { /* 사용자 생성 로직 */ }
}

class UserRepository {
    public void save(User user) { /* DB 저장 로직 */ }
}

class UserView {
    public void render(User user) { /* UI 표시 로직 */ }
}

OCP 개방-폐쇄 원칙

OCP는 5가지 원칙중에 특히 중요한 원칙이다.

소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.

기능을 확장 하려면 코드가 변경되는 것이 당연한 수순으로 여겨지지만 기존 코드를 바꾸지 않고 다형성을 활용한다면 이 원칙을 지킬 수 있다.(역할과 구현의 분리를 기억하자.)
인터페이스를 구현한 새로운 클래스를 하나 만들어서 새로운 기능을 구현하는 것은 기존 코드를 바꾸는 것이 아니다.

다만 구현 객체를 변경하려면 클라이언트 코드를 변경해야한다.
(예시 - memoryRepository -> jdbcRepository)
분명 다형성을 사용했지만 적용을 하려는 때에 코드 변경이 발생해 OCP원칙이 깨지게 된다.
이를 해결하기 위해서는 객체를 생성하고, 연관관계를 맺어주는 별도의 조립, 설정자가 필요하다.
그리고 이 역할을 스프링이 DI와 IOC컨테이너를 통해 사용 영역의 코드를 변경하지 않고도 해결할 수 있다.

LSP 리스코프 치환 원칙

프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야 한다는 것을 말한다.
인터페이스를 구현한 구현체를 믿고 사용할 수 있게 기능적으로 보장을 하게끔 만들어져야 한다.
클래스는 상속되기 마련이고 부모, 자식이라는 계층 관계가 만들어지는데 부모 객체 자리에 자식 객체를 넣어도 시스템이 문제없이 돌아가게 만들어야 한다.

ISP 인터페이스 분리 원칙

하나의 일반적인 인터페이스보다 구체적인 여러개의 인터페이스를 만들자.

특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
예시로 자동차 인터페이스를 운전 인터페이스와 정비 인터페이스로 분리하고,
사용자 클라이언트는 운전자 클라이언트와, 정비사 클라이언트로 분리한다.
분리해서 얻은 이점으로 정비 인터페이스 자체가 변하더라도 운전자 클라이언트에 영향을 주지 않는다.
따라서 인터페이스가 명확해지고, 대체 가능성이 높아진다.
결국 인터페이스도 클래스처럼 적당한 범위로 조절해야 한다는 의미이다.

DIP 의존관계 역전 원칙

DIP도 무척 중요한 원칙이다.

프로그래머는 "추상화에 의존해야지, 구체화에 의존하면 안된다." 의존성 주입은 이 원칙을 따르는 방법 중 하나다.

쉽게 이야기해서 클라이언트가 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻이다.
예시로서 맴버 서비스는 맴버 레포지토리에 대해 몰라도 레포지토리의 역할을 알고 있기에 사용할 수 있다. 역할(Role)에 의존한다는 점이 중요하다. 그래야 시스템이 언제 바꿔더라도 유연하게 갈아끼워 변경할 수 있게끔 설계해야 한다. 구현체에 의존하게 되면 변경이 아주 어려워진다.

만약 맴버 서비스에서 new 키워드로 리포지토리를 의존하고 있다면 DIP를 위반한 셈이다.
맴버 서비스는 맴버 리포지토리 인터페이스에만 의존하도록 클래스 레벨에서 설계를 해야한다.
하지만 인터페이스만으로는 아무것도 실행할 수가 없으니, 의존관계를 주입해주는 Config 클래스를 사용하며 해결할 수 있다.

지금 까지의 요약

  • 모든 설계에 역할과 구현을 분리하고, 언제든지 구현을 변경할 수 있도록 만드는 것이 좋은 객체 지향 설계이다.
  • 이상적으로는 모든 설계에 인터페이스를 부여하자. 그러면 확정되지 않은 사항(DB결정, 할인율 결정)에 대해서 우선적으로 개발을 시작할 수 있다. 그러다가 나중에 결정되면 작은 변경으로 구현을 마칠 수 있을 것 이다.
  • 하지만 실무적으로 인터페이스를 무분별하게 도입하면 추상화라는 비용이 발생한다. 추상화가 되버리면 개발자는 코드를 볼때 인터페이스만 보이므로 구현 클래스를 한번 더 열어야만 확인이 가능하다는 단점도 있기 때문에 장점이 단점을 넘어설 때에만 선택해야 한다.
  • 가장 합리적인 방안으로 기능을 확장할 가능성이 없다면, 구체 클래스를 직접 사용하고, 향후 꼭 필요할 때 리팩토링하여 인터페이스를 도입하는 방법으로 개발하자.
  • 객체 지향의 핵심은 다형성이지만 다형성만으로는 구현 객체를 변경 할때 클라이언트 코드도 변경해야 하는 한계가 존재한다. 다형성만으로는 OCP와 DIP를 지킬 수 없다.
  • 객체 지향 설계를 공부하다보면 누구나 여기에 도달하게 된다. 클라이언트 코드의 변경을 막을 수 없다는 한계점에서 똑같은 고민을 시작하게 된다. 스프링 프레임워크를 만들었던 개발자들도 마찬가지 였고 이제 그들이 해결한 방법을 배워보자.

OCP와 DIP를 지키는 객체 지향 설계 해결 방법

객체 지향 설계와 스프링

스프링은 다형성과 OCP, DIP를 가능하게 지원하기위해 기술을 개발했다.

  • DI(Dependency Injection) : 의존관계, 의존성 주입
  • DI 컨테이너 제공

위 기술을 활용하면 클라이언트의 코드를 변경하지 않고도 기능을 확장 할 수 있다.
이제 쉽게 부품을 교체하듯이 개발할 수 있게 되었다.

제어의 역전 IoC(Inversion of Control)

처음 코드를 작성했을 때는 구현 객체에서 필요한 객체들을 생성하고, 연결하면서 의존하고, 프로그램의 제어 흐름을 조종했다. 개발자의 입장에서는 자연스러운 흐름이다.
하지만 Config 클래스를 만들어서 관심사를 분리한 이후에는 구현 객체는 자신의 로직에만 집중하게 되고, 프로그램의 제어 흐름의 모든 권한은 config가 담당하게 된다.
이렇듯 프로그램의 제어 흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것을 제어의 역전(IoC)라고 한다.

프레임 워크 VS 라이브러리
프레임워크가 내가 작성항 코드를 제어하고, 대신 실행하면 그것은 프레임워크가 맞다(JUnit)
반면에 내가 작성한 코드가 직접 제어의 흐름을 담당한다면 그것은 라이브러리이다.

의존관계 주입 DI(Dependency Injection)

인터페이스에 의존하게 되면서 실제 쓰이는 구현체에 대해서는 이제 모르게 된다.
의존관계는 정적인 클래스 의존 관계와, 실행 시점에 결정되는 동적인 객체(인스턴스) 의존관계를 분리해서 생각해야 한다.
코드로서 어디에 참조하고 있는지 확인할 수 있는 부분이 클래스 다이어그램으로 표현 할 수 있는 정적 의존 관계이고, 애플리케이션이 실행되면서(런타임) 실제 생성된 객체 인스턴스의 참조 값을 전달해서 연결된 의존 관계를 동적인 객체 인스턴스 의존관계라고 하는데 이것을 의존관계 주입이라고 한다.
의존관계 주입을 사용하면 정적인 클래스 의존관계를 변경하지 않고, 동적인 객체 인스턴스 의존관계를 쉽게 변경 할 수 있다.

IoC 컨테이너, DI 컨테이너

위 사진의 AppConfig처럼 객체를 생성, 관리, 의존관계 연결해주는 것을
IoC 컨테이너 또는 DI 컨테이너라고 부르는데 최근에는 주로 DI 컨테이너로 불린다.
또한 AppConfig역할을 어샘블러나, 오브젝트 팩토리로 부르기도 한다.

profile
백엔드 개발자의 노트

0개의 댓글