코드 품질 개선 기법 14편: 책임을 부여하는 오직 하나의 책임

2 days ago 1
이 글은 2024년 2월 22일에 일본어로 먼저 발행된 기사를 번역한 글입니다.

안녕하세요. 커뮤니케이션 앱 LINE의 모바일 클라이언트를 개발하고 있는 Ishikawa입니다.

저희 회사는 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰고 있습니다. 이를 위해 다양한 노력을 하고 있는데요. 그중 하나가 Review Committee 활동입니다.

Review Committee에서는 머지된 코드를 다시 리뷰해 리뷰어와 작성자에게 피드백을 주고, 리뷰하면서 얻은 지식과 인사이트를 Weekly Report라는 이름으로 매주 공유하고 있습니다. 이 Weekly Report 중 일반적으로 널리 적용할 수 있는 주제를 골라 블로그에 코드 품질 개선 기법 시리즈를 연재하고 있습니다.

이번에 블로그로 공유할 Weekly Report의 제목은 '책임을 부여하는 오직 하나의 책임'입니다.

불꽃놀이, 로켓, 프로덕트 중 하나를 론치(launch)하는 '론치 버튼'을 구현하고 싶다고 가정해 봅시다. 이 론치 버튼은 '눌렀을 때 무엇을 실행할 것인가'를 동적으로 변경해야 합니다.

다음 LaunchButtonBinder는 론치할 로직 Launcher를 생성자 인수로 받습니다. 그리고 버튼을 눌렀을 때 적절한 Launcher가 실행되도록 init에서 이벤트 리스너를 등록합니다.

class LaunchButtonBinder( button: ObservableButton, private val fireworkLauncher: Launcher, private val rocketLauncher: Launcher, private val productLauncher: Launcher ) { var launchMode: LaunchMode = LaunchMode.OFF init { button.observePush { launchMode.launcherSelector(this).launch() } } enum class LaunchMode(val launcherSelector: LaunchButtonBinder.() -> Launcher) { OFF({ Launcher.NOP_LAUNCHER }), FIREWORK(LaunchButtonBinder::fireworkLauncher), ROCKET(LaunchButtonBinder::rocketLauncher), PRODUCT(LaunchButtonBinder::productLauncher) } }

이 바인더 클래스는 충분히 단순하지만, '버튼이 눌렸을 때 로직을 바인딩하는 책임'과 '어떤 Launcher가 유효한지 결정하는 책임'의 두 가지 책임이 있습니다.  이를 개선하기 위해 LaunchButtonBinder가 하나의 Launcher와 바인딩되도록 변경하고 Launcher를 선택하는 기능은 LaunchBinderSelector라는 별도 클래스로 분리 구현하기로 결정했습니다.

아래 코드에서는 LaunchBinderSelector 인스턴스가 생성될 때 각 Launcher별로 LaunchButtonBinder 인스턴스가 생성됩니다. 각 LaunchButtonBinder에서 이벤트 리스너를 등록하기 때문에 버튼에 등록되는 리스너는 총 세 개입니다. 세 개의 리스너 중 어떤 리스너를 실행할지 제어하기 위해 LaunchButtonBinder.isEnabled라는 속성을 추가했습니다.

class LaunchButtonBinder( button: ObservableButton, private val launcher: Launcher ) { var isEnabled: Boolean = false init { button.observePush { if (isEnabled) { launcher.launch() } } } } class LaunchBinderSelector( button: ObservableButton, fireworkLauncher: Launcher, rocketLauncher: Launcher, productLauncher: Launcher ) { private val binders: Map<LaunchMode, LaunchButtonBinder> = mapOf( LaunchMode.FIREWORK to LaunchButtonBinder(button, fireworkLauncher), LaunchMode.ROCKET to LaunchButtonBinder(button, rocketLauncher), LaunchMode.PRODUCT to LaunchButtonBinder(button, productLauncher) ) fun setLaunchMode(newMode: LaunchMode) = LaunchMode.entries.forEach { mode -> binders[mode]?.isEnabled = newMode == mode } enum class LaunchMode { OFF, FIREWORK, ROCKET, PRODUCT } }

이렇게 해서 버튼에 대한 로직 구현과 Launcher의 책임을 분리할 수 있었습니다. 하지만 오히려 새로운 문제도 발생했습니다. 어떤 문제일까요?

책임 떠넘기기

새로운 문제점 중 하나는 '사양의 제약 조건을 보장하는 책임이 분산된다'는 점입니다. 변경 전 코드에서는 버튼을 눌렀을 때 셋 중 오직 한 가지만 실행된다는 제약 조건이 있었습니다. 새로운 코드에서도 forEach, isEnabled, getBinder의 조합을 통해 이를 구현했지만, 이것이 올바른지 확인하려면 모든 코드를 읽어야 합니다. 이 문제는 '어떤 Launcher가 선택됐는지'를 나타내는 속성을 LaunchBinderSelector에 추가해 해결할 수 있지만, 이렇게 하면 이번에는 추가한 속성과 LaunchButtonBinder.isEnabled 사이에 상태 중복이 발생합니다.

또한 세부 사항 은닉과 관련된 문제도 있습니다. 분리된 코드에서 LaunchBinderSelector는 LaunchButtonBinder를 선택하는 것만 담당하도록 설계하고자 했습니다. 그러나 LaunchButtonBinder 인스턴스를 생성하려면 Launcher에 직접 의존해야 합니다. 이를 개선하기 위해 Launcher를 LaunchBinderSelector로부터 숨기도록 아래와 같이 LaunchButtonBinder의 인스턴스를 직접 보유하는 방법도 생각해 볼 수 있습니다.

class LaunchButtonBinder( button: ObservableButton, private val launchAction: () -> Unit, ) { var isEnabled: Boolean = false init { button.observePush { if (isEnabled) { launchAction() } } } } class LaunchBinderSelector( private val fireworkBinder: LaunchButtonBinder, private val rocketBinder: LaunchButtonBinder, private val productBinder: LaunchButtonBinder, ) { fun setLaunchMode(newMode: LaunchMode) = LaunchMode.entries.forEach { mode -> getBinder(mode)?.isEnabled = newMode == mode } private fun getBinder(mode: LaunchMode): LaunchButtonBinder? = when (mode) { LaunchMode.OFF -> null LaunchMode.FIREWORK -> fireworkBinder LaunchMode.ROCKET -> rocketBinder LaunchMode.PRODUCT -> productBinder } enum class LaunchMode { OFF, FIREWORK, ROCKET, PRODUCT } }

하지만 이렇게 되면 호출자가 모든 의존성(Selector, Binder, Button, Launcher)을 해결해야 하기 때문에 결과적으로 호출자가 비대해지거나(소위 God class) 전체 그림을 파악하기 위해 수많은 클래스를 읽어야 하는 경우가 발생하기도(소위 Ravioli code) 합니다.

LaunchBinderSelector( LaunchButtonBinder(button, fireworkLauncher), LaunchButtonBinder(button, rocketLauncher), LaunchButtonBinder(button, productLauncher) )

책임을 고려하는 책임

LaunchButtonBinder의 초기 구현은 사양의 제약 조건을 한 곳에서 구현했다는 장점이 있기 때문에 종합적으로 생각해 봤을 때 더 이상 클래스를 분할할 필요가 없었을 수도 있습니다. 클래스를 분할할 때는 '개별 클래스의 책임 범위나 응집도에 지나치게 집중한 나머지 호출자의 책임이 비대해지거나 클래스 및 모듈 간의 의존성과 결합도가 악화되는 것을 피해야 한다'는 것을 고려해야 합니다.


한 줄 요약: 책임을 분할하면 의존성이 복잡해질 수 있다는 점에 주의한다.

키워드: single responsibility, dependency, implicit constraints

Read Entire Article