[Java] SOLID (1) (Feat : 클린 아키텍처)

2025. 2. 5. 22:00·백엔드/Java

이전엔 객체 지향 설계의 5원칙을 나타내는 SOLID를 다음과 같이 이해하고 있었다.

 

 

SRP - 단일 책임 원칙

 

클래스(객체)는 단 하나의 책임만 가져야 한다.

 

 

OCP - 개방 폐쇄 원칙

 

확장에는 열려있고, 수정에는 닫혀있어야 한다. 기능을 추가해야 할 경우엔 확장을 통해 손쉽게 구현하고, 그에 따른 코드 변경은 최소화하는 것이 목적이다.

 

 

LSP - 리스코프 치환 원칙

 

자식 클래스는 언제나 부모 클래스를 사용해도 의도대로 로직이 실행되어야 한다.

 

 

ISP - 인터페이스 분리 원칙

 

인터페이스를 목적에 맞게 분리해야 한다. 단일 책임 원칙은 클래스에 해당하고, 이건 인터페이스에 해당하는 것이다.

 

 

DIP - 의존 역전 원칙

 

구현 클래스에 의존하지 말고 인터페이스에 의존해야 한다. 이를 통해 클래스 간의 결합도를 낮춰 코드 변경에 용이해진다.

 

 

그런데 클린 아키텍처에선 이것과 비슷하지만, 조금 다르게 설명한 부분이 있어서 정리해 본다.

 

 

 

💡 클린 아키텍처 - SOLID

 

SOLID는 함수와 데이터 구조를 클래스로 배치하는 방법, 그리고 클래스를 서로 결합하는 방법을 설명한 원칙!

 

 

클린 아키텍처에서 위의 문장과 같이 SOLID를 설명한다.

 

 

SOLID의 원칙을 새롭게 설명하자면 중간 수준의 소프트웨어 구조가 아래의 원칙을 지킬 수 있도록 한다.

  • 변경에 유연해야 한다.

  • 이해하기 쉬워야 한다.

  • 많은 소프트웨어 시스템에서 사용할 수 있는 컴포넌트의 기반이어야 한다.

 

 

(참고) 중간 수준

 

중간 수준이란, 개발자가 SOLID 원칙을 모듈 수준에서 작업할 때 개발자가 직접 애플리케이션에 적용시킬 수 있다는 의미이다.

 

 

 

💡 SRP: 단일 책임 원칙

 

SOLID 원칙 중에서 아마 가장 잘못 설명되어 내려져오는 원칙일 것일 것이다.

 

 

많은 개발자들이 SRP는 '하나의 객체는 하나의 책임, 즉 하나의 일만 해야 한다.' 라고 알고 있다.

 

 

그런데 이렇게 설명하기보단 '하나의 모듈은 하나의 액터에 대해서만 책임져야 한다.' 가 더 적합하다.

 

 

아래는 블로그 시스템을 사용하는 엑터를 예시로 든 것이다.

 

  1. 사용자 관리 코드
  • 액터 : 시스템 관리자
  • 책임 : 사용자 계정 생성, 수정, 삭제, 권한 관리 등 시스템 관리자가 요청하는 작업을 처리

2. 게시물 관리 코드

  • 액터 : 콘텐츠 작성자
  • 책임 : 블로그 게시물 작성, 수정, 삭제 등 콘텐츠 작성자가 요청하는 작업을 처리

3. 댓글 관리 코드

  • 액터 : 일반 사용자
  • 책임 : 블로그 게시물에 대한 댓글 작성, 수정, 삭제 등 일반 사용자가 요청하는 작업을 처리

이렇게 각 기능의 메인 사용자를 액터라고 표현한다.

 

 

 

‼️ SRP 징후 1 : 우발적 중복

 

위의 Employee 클래스를 보면 서로 다른 액터가 하나의 Employee 클래스에 의존하고 있다.

  • calculatePay() : 회계팀에서 CFO 보고를 위해 사용하는 기능

  • reportHours() : 인사팀에서 COO 보고를 위해 사용하는 기능

  • save() : DBA팀이 CTO 보고를 위해 사용하는 기능

 

그런데 Employee 클래스 사용자들의 사용 목적이 서로 다르다.

 

 

만약 이 기능들 사이에, calculatePay()와 reportHours() 사이에 초과 근무를 제외한 업무시간을 계산하는 로직이 둘 다 필요해서 regularHours()라는 메서드를 만들고 공유한다고 해보자.

 

 

 

만약 CFO 팀에서 caculatePay() 기능에 수정이 필요해 regularHours()를 수정하면, COO 팀 입장에선 이런 변경을 원치 않을 수도 있다.

 

 

왜냐하면 COO 팀은 이 기능을 수정 필요 없이 잘 사용하고 있었기 때문이다.

 

 

CFO 팀의 기능 수정 요청을 듣고 개발팀에서 기능을 수정한다고 하면 당연히 calculatePay()가 regularHours()을 호출한다는 사실은 알고 있을 것이다.

 

 

하지만 reportHours()는..?

 

 

만약에 개발자가 reportHours()에서 regularHours()를 호출하는 것을 알아채지 못한다면??

 

 

reportHours() 기능에서 에러가 발생할 가능성이 커지는 것이다.

 

 

이러한 이유 때문에 서로 다른 목적을 가진 엑터가 같은 클래스를 사용한다면 반드시 분리해야 하는 것이다.

 

 

 

‼️ SRP 징후 2 : 병합

 

프로젝트를 진행하면서 협업하다가 코드를 병합하는 과정은 개발자 입장에선 당연한 과정이다.

 

 

이 과정에서 코드 간의 충돌이 일어나는 것도 충분히 발생할 수 있다.

 

 

하지만 이러한 병합에는 늘 위험이 따른다.

 

 

만약 save()와 regularHours()의 결과를 리턴하는 DTO를 서로 같은 클래스로 공유하고 있다고 하자.

 

 

save() 기능을 사용하는 DBA 팀에서 save()의 결과를 리턴하는 DTO를 다른 팀의 요청에 따라 변경하려고 한다.

 

 

만약 다른 팀의 개발자도 동시에 regularHours()의 응답 DTO를 인사팀의 요청에 따라 변경해 버리면..?

 

 

그럼 당연히 병합할 때 DTO 코드의 충돌이 발생할 수밖에 없다.

 

 

여기서도 알 수 있듯이 서로 다른 엑터가 의존하는 기능적인 부분뿐만 아니라 응답용 DTO와 같은 부가적인 부분 또한 반드시 분리해야 한다.

 

 

위의 병합 관련 문제에서 가장 쉽고 근본적인 해결책은 서로 다른 클래스로 분리하는 것이다.

 

 

하지만 이 해결책은 개발자가 관리해야 할 클래스의 수가 늘어난다는 단점 또한 존재한다.

 

 

 

💡 OCP : 개방 폐쇄 원칙

 

소프트웨어 개체의 행위는 확장할 수 있어야 하지만, 이때 개체를 변경해선 안 되는 것이 개방 폐쇄 원칙의 기본이다.

 

 

많은 신입 개발자들이 OCP는 클래스와 모듈을 설계할 때 도움이 되는 원칙이라고 생각하는데, 아키텍처 컴포넌트 수준에서 OCP를 고려할 때 훨씬 더 중요한 의미를 가진다.

 

 

‼️ 사고 실험

 

재무제표를 웹 페이지에 보여주는 시스템이 있고, 세부 기능은 다음과 같다.

 

 

1. 웹 페이지에 표시되는 데이터는 스크롤을 통해 조회할 수 있음

2. 음수는 빨간색으로 출력돼야 함

 

이러한 형태의 보고서를 출력하는데, 만약 이 보고서를 웹 페이지뿐만 아니라 흑백 프린터를 사용해 출력해 달라는 요청이 오면 어떻게 될까?

 

 

당연히 새로운 기능을 추가해야 하니까 코드를 추가로 작성해야 한다.

 

 

그럼 나머지 의존관계가 있는 코드 수정도 불가피하다.

 

 

OCP는 여기서 SRP와 DIP를 통해 코드 변경량을 최소화하자는 원칙이다.

 

 

OCP를 지킨다면 위 이미지와 같이 각각의 목적에 따라 책임을 분리할 수 있다.

 

 

'보고서용 재무 데이터'를 표시하기 위해선 다음과 같이 나뉜다.

  • 보고서를 웹에 표시하는 기능

  • 보고서를 프린터로 출력하는 기능

 

만약 여기서 보고서를 웹에 표시하는 형태를 스크롤을 통해 조회하는 거 말고, 슬라이드 형태로 조회하고자 하면 ’ 보고서를 웹에 표시하는 기능’ 의 기능 수정은 필요하다.

 

 

하지만 완전히 분리되어 있는 ’보고서를 프린터로 출력하는 기능’은 수정할 필요가 없게 확실히 조직화해야 하고, 새로 조직화한 구조에서 기능이 확장될 때 코드 변경이 발생하지 않음을 보장해야 한다.

 

 

 

그래서 처리 과정을 클래스 단위로 분할하고, 클래스는 컴포넌트 단위로 분리하면 위의 설계도처럼 의존관계가 나오게 되는 것이다.

 

 

위의 설계도를 설명하자면 다음과 같다.

  • FinancialDataGateWay라는 인터페이스가 있음

  • 이 인터페이스를 구현한 FinancialDataMapper 은 당연히 FinancialDataGateWay을 알고 있음

  • 하지만 FinancialDataGateWay 은 FinancialDataMapper에 대해 아무것도 알지 못함

 

이 세 문장을 보면 알 수 있듯이 모든 컴포넌트의 의존관계는 단방향으로 이루어진다.

 

 

그렇기 때문에 위의 설계도처럼 화살표는 변경으로부터 보호하려는(내가 변경했다고 상대도 코드 변경이 발생하지 못하게) 컴포넌트를 향하도록 그려진다.

 

 

A 컴포넌트에서 발생한 변경으로부터 B 컴포넌트를 보호하기 위해선 반드시 A 컴포넌트가 B 컴포넌트를 의존해야 한다.

 

 

이는 곧 B 컴포넌트가 A 컴포넌트에 대해 아무것도 알지 못하게 해야하는 것과 동일한 개념이다.

 

 

 

위의 관계도를 보면 Presenter에서 발생한 변경으로부터 Controller를 보호하고자 한다.

 

 

그 이유는 ScreenPresenter, PrintPresenter → FinancialReportController 이기 때문이다.

 

 

A가 ScreenPresenter, PrintPresenter이고, B가 FinancialReportController이다.

 

 

나머지 의존 관계도 이와 동일하다.

 

 

근데 여기서 가장 주목해야 하는 건 Interactor 부분이다.

 

 

Interactor의 의미는 다음과 같다.

 

 

Interactor는 유스케이스(Use Case)라고도 불리며, 특정 작업을 수행하는 방법을 정의한다.

 

 

예를 들어, 사용자가 주문을 처리하거나 결제를 하는 등의 특정 유스케이스를 담당하는 것이다.

 

 

난 그냥 쉽게 생각해서 주요 비즈니스 로직이 몰려있는 아주 중요한 공간이라고 생각한다.

 

 

그럼 Interactor가 OCP에서 중요한 이유는 뭘까?

 

 

Interactor는 주요 비즈니스 로직을 담당하고 있다고 봐도 무방하다

 

 

그런데 만약 다른 부수적인 부분(Presenter 같은 부분)에 기능 수정이 필요해졌다고 해서 Interactor의 코드 수정이 필요해지면..?

 

 

이 의미는 Interactor가 전혀 보호받지 못하고 있다는 것이다.

 

 

Interactor는 가장 고수준 컴포넌트이기 때문에 코드 수정이 가장 적게 발생해야 한다.

 

 

(참고) 컴포넌트 수준과 코드 변경의 관계

 

보호의 계층구조가 수준이라는 개념을 바탕으로 결정된다. 보통 컴포넌트 계층구조는 다음과 같다. (왼쪽으로 갈수록 고수준 컴포넌트)

 

Interactor > Controller > Presentation > View

 

 

 

‼️ 방향성 제어

 

위에서 설계도를 보면 FinancialDataGateWay 인터페이스는 FinancialReportGenerator와 FinancialDataMapper 사이에 위치해 있는데, 이는 DIP 의존성 역전을 위한 것이다.

 

 

만약 FinancialDataGateWay 가 없다면 Interactor 컴포넌트에서 Database 컴포넌트로 바로 향하게 된다.

 

 

 

‼️ 정보 은닉

 

 

위의 설계도에서 FinancialReportRequester 인터페이스는 방향성 제어와 다른 목적을 가진다.

 

 

위의 의존도를 보면 FinancialReportController 가 Interactor 컴포넌트에 의존하고 있는데, 이는 곧 Interactor의 내부를 알고 있는 것과 같다.

 

 

하지만 중간에 FinancialReportRequester 인터페이스를 두게 된다면 FinancialReportController는 Interactor 내부의 구현부를 전혀 알지 못한다.

 

 

만약 인터페이스가 없다면 Controller는 Financialentities에 대해 추이 종속성을 가지게 된다.

 

 

이전에 Interactor는 가장 변경으로부터 보호해야 하는, 가장 우선순위가 높은 컴포넌트지만 반대로 Interactor에서 발생한 변경으로부터 Controller를 보호하기 위한 목적도 있다.

 

 

(참고) 추이 종속성

 

추이 종속성이란 A → B, B → C 라면 A → C 가 되는 상황을 의미한다. 추이 종속성을 가지게 되면, 소프트웨어 엔티티는 '자신이 직접 사용하지 않는 요소에는 절대로 의존해서는 안된다'라는 원칙을 위반하게 된다.

 

 

 

💡 결론

 

OCP의 목표는 시스템을 확장하기 쉬운 동시에 변경으로 인해 시스템이 너무 많은 영향을 받지 않도록 하기 위함이다.

 

 

이를 위해선 시스템을 각 목적에 맞는 컴포넌트 단위로 분리하고, 저수준 컴포넌트에서 발생한 변경을 고수준 컴포넌트까지 영향을 끼치지 않도록 하는 의존성 계층구조가 만들어지도록 해야 한다.

 

 

 

‼️ 참고

 

클린 아키텍처: 소프트웨어 구조와 설계의 원칙

 

'백엔드 > Java' 카테고리의 다른 글

[Java] 리플렉션  (0) 2024.11.07
[Java] 멀티스레딩 - 가상 스레드  (0) 2024.11.04
[Java] 멀티스레딩 - 스레드 간 통신  (2) 2024.11.04
[Java] 멀티스레딩 - 락 심화  (0) 2024.11.04
[Java] 멀티스레딩 - 스레드간 데이터 공유  (0) 2024.10.31
'백엔드/Java' 카테고리의 다른 글
  • [Java] 리플렉션
  • [Java] 멀티스레딩 - 가상 스레드
  • [Java] 멀티스레딩 - 스레드 간 통신
  • [Java] 멀티스레딩 - 락 심화
오도형석
오도형석
  • 오도형석
    형석이의 성장일기
    오도형석
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • MSA 모니터링 서비스
        • DB
      • 스파르타 코딩클럽
        • SQL
        • Spring
      • 백엔드
        • Internet
        • Java
        • DB
      • 캡스톤
        • Django
        • 자연어처리
      • Spring
        • JPA
        • MSA
      • ETC
        • ERROR
      • 개발 일기 N
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 인기 글

  • 태그

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
오도형석
[Java] SOLID (1) (Feat : 클린 아키텍처)
상단으로

티스토리툴바