[SOLID] 단일 책임 원칙(SRP) 적용해보기

빙티·2025년 3월 1일

SOLID 적용기

목록 보기
1/1
post-thumbnail

객체 지향 프로그래밍에는 5대 설계 원칙(SOLID)이 있다.

객체 지향 설계 5대 원칙 SOLID

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

하지만 내가 정말 위 특성들을 깊이 이해하고 각 설계 원칙들을 준수하며 코드를 작성하고 있는지 의문이 들었다.

기존에 SOLID를 설명하는 좋은 글과 예제 코드가 많기 때문에, 새롭게 예제를 만들기 보다는 기존 프로젝트에서 원칙을 위반하는 부분을 찾아 개선해보려 한다.

이번 글에서는 첫번째 원칙 - 단일 책임 원칙(SRP)을 알아보고 이를 위반하는 부분을 찾아 개선해보겠다.


📍 SRP : Single Responsibility Principle (단일 책임 원칙)

모든 클래스는 오직 한 가지의 책임만 가지며 클래스가 제공하는 모든 기능은 이 책임과 부합해야 한다.

개인적으로 SOLID 원칙 중에 가장 명확하면서도 모호한 개념이라고 생각했다.
어디까지가 하나의 책임인지 딱 잘라 정의하기 어렵기 때문이다.
특히 한 가지라는 단어에 매몰되면, 마치 함수를 작게 만드는 것처럼 클래스도 최대한 하나의 일만 하도록 작게 만들라는 의미로 해석할 수 있다.

그러나 로버트 C. 마틴의 클린 아키텍처와 다른 기술 블로그를 보고, 내가 'SRP 원칙'과 '하나의 책임'을 오해하고 있었다는 것을 깨달았다.

클린 아키텍처에서는 액터라는 개념과 함께 SRP 원칙을 설명한다.
(UML의 액터와 단어가 같아 헷갈릴 수 있는데, 다른 개념이다.)
이 책에 나온 설명과 예시가 매우 와닿았기 때문에 이를 조금 변형해서 소개해보려 한다.


💡 액터란?
시스템이 특정 방식으로 동작하기를 원하는 사용자 집단.
즉, 시스템의 이해관계자를 의미한다.

액터라는 개념을 빌리면 책임이 곧 변경의 이유를 의미한다는 것을 알 수 있다.
아래 사진을 살펴보자!

UserProfileService 클래스는 서로 다른 세 액터의 요구를 반영하고 있다.
기능 별로 액터와 변경 이유를 정리해보면 아래와 같다.

  • getUserInfo()
    액터 : 개발팀
    변경 이유 : API 응답 구조 변경

  • updateProfileImage()
    액터 : 인프라팀
    변경 이유 : 성능 최적화, 스토리지 정책 변경

  • formatUserName()
    액터 : 디자인팀
    변경 이유 : UI 표현 방식 변경


🚨 문제점

UserProfileService 처럼 하나의 클래스가 여러 액터의 요구를 처리할 경우, 특정 기능의 수정이 의도치 않게 다른 기능에 영향을 줄 가능성이 생긴다.
한 팀의 요구사항에 따라 코드를 수정할 때 다른 팀이 기대하던 동작을 무너뜨릴 위험이 생기는 것이다.
이를 방지하기 위해, SRP 원칙에 따라 하나의 클래스가 하나의 액터만 갖도록 책임을 분리하면 아래와 같은 구조가 될 것이다.

클래스는 작아지고 개수도 늘어났지만 역할이 명확해지고 변경이 격리되며 응집도와 테스트 용이성이 증가했다.

SRP 위반은 흔히 많은 일을 하는 클래스에서 발생한다고 생각하지만, 사실은 많은 사람의 말을 듣는 클래스에서 더 자주 일어난다.
따라서 하나의 클래스는 한 액터의 요구만 책임지도록 하여, 변경의 이유가 하나만 있도록 설계하는 것이 SRP의 핵심이다.
이제 이 원칙을 적용해 스타카토 프로젝트의 구조를 직접 개선해보자.


💡 실습 방법

  • 너무 많은 책임을 가진 클래스를 선정한다.
  • 클래스의 책임과 변경 이유를 살펴본다.
  • 클래스가 하나의 명확한 변경 이유를 가지도록 리팩터링한다.



📍 너무 많은 책임을 가진 클래스

스타카토에는 기록을 만들 때 ‘날짜’와 ‘시간’을 선택하는 기능이 있다.
(이해를 돕기 위해 영상을 첨부한다.)

해당 UI를 나타내는 BottomSheet는 VisitedAtSelectionFragment 클래스이며, BottomSheetDialogFragmentNumberPicker를 사용해 구현했다.
간단해 보이지만 생각보다 로직이 복잡한데, 위 기능을 구현하기 위해 필요한 것들은 아래와 같다.
(굳이 이해하지 않아도 괜찮다. 가볍게 이런 게 있구나 하며 읽고 넘기자.)

UI 로직

  1. BottomSheetDialogFragment의 binding을 초기화 및 해제한다.
  2. NumberPicker를 초기화하고, 값이 변경될 때의 이벤트 리스너를 설정한다.
  3. 확인 버튼 클릭 이벤트를 설정한다.

도메인 로직

  1. 기간 여부에 따라 선택 가능한 날짜를 계산한다.
    • 기간이 있는 기록의 경우 : 해당 기간 내에서 선택
    • 기간이 없는 기록의 경우 : 현재 날짜 ±100년 내에서 선택
  2. 선택된 날짜와 시간을 저장한다.
    • 확인 버튼이 눌렸을 때 최종 선택된 날짜, 시간 값을 전달하기 위함.
  3. 년, 월의 값이 바뀔 때 하위요소를 연쇄적으로 다시 계산한다.
    • ‘년’이 바뀌면 해당 년도에 선택 가능한 ‘월’ 리스트를 다시 계산한다. (윤년 고려)
    • ‘월’이 바뀌면 해당 월에 선택 가능한 ‘일’ 리스트를 다시 계산한다. (31일이 있는 달과 없는 달 고려)
    • 다시 계산된 리스트에서 이전에 선택했던 값과 가장 가까운 값을 선택한다.
      (e.g. 25년 3월 31일에서 월을 2월로 바꾸면, 25년 2월 28일이 선택되도록 구현)

중요한 것은 VisitedAtSelectionFragment가 위의 모든 로직들을 담당하며 ‘UI를 초기화하고 제어하는 책임’과 ‘날짜를 선택하고 계산하는 책임’이 내부에서 뒤섞이게 됐다는 것이다.

다시 말해 VisitedAtSelectionFragment 클래스는 UI 책임도메인 책임을 모두 가져 여러 이유로 변경될 수 있는 상태이며, 이는 SRP 원칙을 위반한다고 볼 수 있다.

실제로 스타카토 개발 중 이 코드로 인해 여러 문제점을 몸소 체험할 수 있었다…


문제 1. 코드를 읽고 이해하기 어렵다.

하나의 클래스에서 너무 많은 책임을 다루는 것은 코드의 독자에게 큰 부담을 준다.
이러한 코드를 이해하기 위해선 세부적인 내용까지 한번에 알고 있어야 하기 때문이다.
결국, 동료 개발자는 커녕 짠 사람조차 읽기 힘든 코드가 될 수 있다.

문제 2. 요구사항 변경의 대응과 유지보수가 어렵다.

클래스가 여러 책임을 가질수록 서로 다른 역할을 하는 코드들이 내부에서 영향을 주고받을 가능성이 커진다.
이는 요구사항이 변경 됐을 때 어느 부분을 수정해야 할 지 명확히 구분하기 어렵게 만든다.
또한, 한 책임의 변경이 다른 책임에도 영향을 미쳐 변경의 전파로 인한 연쇄적인 수정이 발생할 위험이 높아진다.

지금은 NumberPicker를 사용하고 있지만, 이를 SpinnerDatePicker로 교체해야 한다면? 혹은, 날짜나 시간 정책이 변경된다면?

NumberPicker라는 특정 UI 컴포넌트에 도메인 로직이 결합되어 있는 현재 구조에서는 VisitedAtSelectionFragment의 코드 전반에 걸쳐 대대적인 수정이 필요할 것이다.

문제 3. 테스트하기 어렵다.

마찬가지로 ‘UI를 초기화하고 제어하는 로직’과 ‘날짜를 선택하고 계산하는 로직’이 이리저리 뒤섞여 서로 영향을 미치면서, 각 로직을 별도로 검증하기 어려운 구조가 되었다.

만약 날짜를 계산하는 책임이 다른 클래스로 적절히 분리되어 있었다면 해당 클래스의 단위 테스트를 작성하는 것만으로 간단히 계산 로직을 검증할 수 있었을 것이다.

그러나 VisitedAtSelectionFragmentBottomSheetDialogFragment를 상속받으며 내부적으로 안드로이드 프레임워크와 생명주기에 강하게 의존하고 있다.

이 경우 테스트를 위해서는 생명주기와 UI 관련 테스트 환경을 수동으로 설정해야 하므로, 일반적인 단위 테스트보다 복잡해진다.


📍 클래스의 책임과 변경 이유 살펴보기

앞서 살펴본 VisitedAtSelectionFragment 클래스의 주요 역할은 아래와 같다.

  1. 날짜/시간 선택 UI 제공 및 이벤트 처리
    • NumberPicker로 사용자가 년, 월, 일, 시간을 선택할 수 있게 함
    • NumberPicker 이벤트 리스너를 설정해 사용자의 선택 값을 업데이트
  2. Fragment 생명주기 관리
    • onCreateView()onDestroyView()에서 뷰 바인딩을 관리하고 메모리 누수를 방지
  3. 확인 버튼 클릭 시 선택된 날짜/시간 전달
    • 사용자가 확인 버튼을 누르면 VisitedAtSelectionHandler를 통해 선택된 날짜/시간을 저장
  4. 선택 가능한 날짜/시간 범위 제공 및 초기화
    • 초기 날짜/시간 설정
    • 선택 가능한 날짜/시간 목록 생성
    • 날짜가 변경되면 새로운 하위 목록을 설정 및 선택

VisitedAtSelectionFragment 클래스는 BottomSheetDialogFragment를 상속받고 있으므로 가장 적절한 역할은 2번이다.
그렇다면 1번, 3번, 4번은 어떻게 분리해야 할까?

1번, 3번은 UI와 관련되어 있고, BottomSheetDialogFragment는 기본적으로 UI 요소를 담당하는 컴포넌트이다.
그래서 일단은 Fragment 내부의 UI 요소와 관련된 로직을 담당하는 게 (SRP를 위반할지언정) 아주 어색해보이진 않는다.

반면 4번은 UI와는 독립적인, 날짜/시간과 관련된 책임이므로 별도의 클래스로 분리하기 아주 좋아보인다.
이를 통해 VisitedAtSelectionFragment에 집중된 책임을 분산시키고, VisitedAtSelectionFragment는 최대한 UI 관련 로직만 담당하도록 수정해보자.




📍 책임을 다른 클래스로 분리하기

아래는 수정 전 VisitedAtSelectionFragment 클래스의 인스턴스 변수들로 어림잡아 10개 이상이다.

class VisitedAtSelectionFragment : BottomSheetDialogFragment() {
    
    // 바인딩
    private var _binding: FragmentVisitedAtSelectionBinding? = null
    private val binding get() = _binding!!
		
	// 완료 버튼 이벤트 처리를 위한 핸들러
    private lateinit var handler: VisitedAtSelectionHandler

    // NumberPicker에서 선택되어있는 값
	private var selectedYear: Int = 0
	private var selectedMonth: Int = 0
	private var selectedDate: Int = 0
	private var selectedHour: Int = 0
		
    // 선택할 수 있는 모든 년, 월, 일 정보
    private var yearCalendar: Map<Int, Map<Int, List<Int>>>
    private var monthCalendar: Map<Int, List<Int>>
    
    // UI에 표시할 선택지 리스트
    private var years : List<Int>
    private var months : List<Int>
    private var dates : List<Int>
    private val hours = (0 until 24).toList()
		
    ...
}

날짜/시간에 관한 세부적인 상태들을 가지고 있으며, 코드에 포함하진 않았지만 이 모든 상태들을 계산하고 변경하기 위한 함수도 갖고있다.
그렇다면 이를 분리하기 위해 객체지향 생활 체조 원칙에서 제시하는 구체적인 가이드를 참고해보자.

규칙 3 : 모든 원시값과 문자열을 포장한다.
규칙 8 : 일급 컬렉션을 쓴다.

왜 이런 규칙이 필요할까?
원시값이나 컬렉션을 포장하지 않으면 이들을 사용하는 상위 클래스가 불필요하게 많은 책임을 떠안게 된다.
이 때 원시값이나 컬렉션을 의미 있는 객체로 포장한다면 관련 로직과 상태를 한곳에 모아 캡슐화하여 상위 클래스에 책임이 집중되는 것을 막을 수 있다.

여기서 ‘모든’ 이란 키워드가 다소 극단적이라고 느껴질 수 있는데, 상황에 따라 적절히 판단하여 원시값 포장이나 일급 컬렉션 사용 여부를 결정하면 좋을 것 같다.

나는 아래처럼 년, 월, 일 관련 상태와 로직들을 xxxCalendar 객체로 분리했다.

규칙 6 : 모든 엔티티를 작게 유지한다.
규칙 7 : 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.

마찬가지로 위 규칙의 의도를 생각해보자.
클래스의 규모가 커지고 인스턴스 변수가 많아질수록 클래스의 응집도(cohesion)가 낮아질 가능성이 커지기 때문이다.

사실 응집도에 영향을 미치는 주요 요인은 클래스의 크기변수의 개수가 아니라 클래스 내 요소들이 동일한 목적을 공유하는지 여부이다.

다만, 인스턴스 변수의 개수를 의식적으로 제한하면 자연스럽게 클래스를 작게 유지하려 노력할텐데, 그에 따라 클래스가 여러 책임을 떠안고 있진 않은지 지속적으로 점검하며 자연스럽게 응집도가 높은 구조를 만들 수 있을 것이다.

이로써 하나의 클래스는 하나의 책임만 가져야 한다는 SRP원칙을 더욱 쉽게 지킬 수 있을 것이다.

나는 VisitedAtSelectionFragment를 작게 유지하기 위해, CalendarState 클래스를 만들어주었다.

CalendarState 클래스는 크게 두 상태를 담당한다.

  1. 선택할 수 있는 모든 년, 월, 일 정보 : xxxCalendar
  2. 선택된 날짜/시간 : selectedXXX

이에 따라 VisitedAtSelectionFragmentCalendarState 라는 클래스만을 상태로 들고 있도록 변경해보았다.

이제 날짜/시간 관련 상태와 세부 로직이 VisitedAtSelectionFragment에서 분리되어, 각 담당 객체 내부에 적절히 캡슐화되었다.
또한 리팩토링으로 이전의 문제들도 해결할 수 있었다.

📍 SRP의 효능

문제 1. 코드를 읽고 이해하기 어렵다.
→ 코드가 186줄에서 131줄로 줄었고, 인스턴스 변수와 메서드 수도 줄어들며 가독성이 확실히 개선되었다. (팀원 후기ㅋㅋ)

문제 2. 요구사항 변경의 대응과 유지보수가 어렵다.
→ 아직은 해당 클래스와 관련된 정책 변경이 없어 실감하긴 어려웠다. 변경이 일어난다면 적용해보고 후기를 남길 예정!

문제 3. 테스트하기 어렵다.
날짜/시간 관련 로직을 검증하는 유닛 테스트를 작성해 안정적인 기능을 구현할 수 있었다. 원래는 한 줄만 바꿔도 여기저기서 버그가 터지는 스파게티 코드였기에 테스트는 꿈도 못 꾸고 있었는데, 얼마나 잘못된 구조였는지 다시 한 번 깨달았다... 속이 다 시원하다~

profile
할머니에게 설명할 수 없다면 제대로 이해한 게 아니다

0개의 댓글