이번 편은 조금 쉬어가고자(?) 조금 익숙한 주제를 골라봤습니다. 바로 SOLID입니다!
엥, 갑자기 무슨 SOLID냐? 너무 기본 아니냐? 라고 하실 수 있습니다.
아무래도 이전 글들에 비하면 주제가 몇 년 전으로 돌아간 느낌입니다.
그래도 막 들어온 따끈따끈한 우테코 7기 친구들도 있고, 학교에서 이제 객체지향을 배우는 친구들도 어딘가에 있을 테니까요?
이전에 SOPT에서 SOLID를 주제로 세미나를 하기도 해서 금방 적을 수 있을 거로 생각했습니다. 그런데 알고 있는 것과 별개로 간만에 이야기 하려니 생각보다 오래 걸리네요.
예시도 바로 안 떠오르고.. 어쩌면 잊고 살았는지도 모르겠습니다.
SOLID라는 단어를 들으면 각자의 세대에 따라 다른 단어를 떠올릴 것입니다.
누군가는 가수를, 누군가는 카트라이더를, 누군가는 고체를 떠올릴 수 있습니다.
그러나 우리가 오늘 볼 SOLID는 다섯 가지 원칙의 앞 글자를 따서 만든 단어입니다.
오늘은 그중에서 0번째 인덱스에 위치한 S를 만나보려고 합니다.
(원래는 SOLID 모두 작성하려 했지만, 시간이 없었다는 슬픈 사연...)
그럼 시작해 보죠!
return "SOLID".charAt(0);
글을 읽기 앞서 오늘 글의 대부분은 로버트 C 마틴의 클린 아키텍처에서 발췌된 내용입니다. 더 깊게 이해하고 싶다면 SOLID 원칙을 직접 정리한 저자가 직접 쓴 책을 읽어주시기를 바랍니다.
단일 책임 원칙 (Single Responsibility Principle)
단일 책임 원칙은 이름이 직관적으로 보이는 원칙입니다.
"한 가지 책임만 가져야 한다"는 원칙. 간결하고 직관적이죠?
하지만 그 직관적인 워딩은 오히려 사람들에게 큰 오해를 불러일으켰습니다. 로버트 C 마틴은 이를 두고 SOLID 원칙 중 그 의미가 가장 잘 전달되지 못한 원칙이라고 말했습니다.
단일 책임 원칙을 이름 그대로 받아들이면 "하나의 클래스는 하나의 책임만 가져야 한다"로 생각됩니다.
그럼 여기서 "하나의 책임"은 무엇일까요?
저는 약 2~3년 전쯤 개발하면서 이런 코드리뷰를 받았습니다.
"함수는 한 가지 일만 해야 합니다."
"하나의 함수가 너무 많은 일을 하고 있어요"
"함수를 분리해 주세요"
여러분은 받아보신 적 없으신가요?
지금은 체화되어 있지만, 예전의 저는 하나의 함수가 수많은 일을 수행하게 했습니다.
하지만 함수가 하나의 일만 수행해야 네이밍으로 역할을 드러내기도, 재활용하기도 좋습니다.
그러면 여기서 묻고 싶습니다. 하나의 일만 수행하는 함수는 하나의 책임만 갖고 있는 것 아닌가요?
그렇다면 하나의 클래스가 하나의 책임만 갖는다는 말은, 하나의 클래스는 하나의 함수만 갖는다는 말이 아닌가요?
하나의 클래스가 하나의 함수만 가져야한다라.. 일단 그 하나의 함수는 공개된 함수(public)라고 생각해 보죠.
저를 비롯한 많은 개발자는 이미 잘 짜여있는 코드를 보고 학습하곤 합니다.
예를 들면 젯브레인 개발자들이 많든 코틀린의 API나, 구글 개발자들이 만든 안드로이드의 여러 API를 보면서 말이죠.
그리고 그들이 만든 클래스에는 여러 함수가 존재합니다.
그들이 SOLID의 S조차 모를까? 하는 의문을 가져볼 수 있습니다.
물론 그들의 코드가 늘 정답은 아닙니다. 하지만 참고는 해볼 수 있죠.
적어도 제 생각엔, 하나의 클래스가 하나의 공개된 함수만 가져야 한다는 말은 틀렸습니다.
틀렸다기보단, 단일 책임 원칙이 전하고자 하는 의도는 아니었다고 말할 수 있겠네요.
하나의 책임을 가져야한다는 원칙은 클래스보다 함수에 적용되는 원칙입니다.
로버트 C 마틴의 워딩을 옮겨와서 이야기하자면 단일 책임 원칙은 다음과 같습니다.
"단일 모듈의 변경의 이유가 하나, 오직 하나뿐이어야 한다."
하나의 책임이 아닌, 하나의 변경의 이유입니다. 이해가 되시나요?
저는 사실 안됐습니다. 처음 이 말을 들었을 때 더 혼란스러웠죠. 차라리 하나의 책임이라는 말이 더 와닿았습니다.
변경의 이유가 하나여야 한다고? 내가 만든 코드는 너무나 많은 이유에 의해 변경되는걸?
그래서 로버트 C 마틴은 다음과 같이 자세한 설명을 덧붙입니다.
"하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다."
여기서 "모듈"은 하나의 클래스, "액터"는 시스템을 사용하는 목적이 같은 집단으로 이해하시면 됩니다.
(모듈: 클래스, 액터: 시스템을 사용하는 목적이 같은 집단)
이제 이해가 조금 될 수도 있습니다. 풀어서 말하면 다음과 같습니다.
하나의 클래스는 오직 하나의 집단(시스템을 사용하는 목적이 같은)에 대해서만 책임져야 한다.
저는 이 말을 들었을 때 어렴풋이 이해됐습니다. 하지만 그렇지 않은 분들도 계실 것을 알고 예시를 준비했습니다. (로버트의 예시를 조금 다듬어서 설명합니다)
로버트 C 마틴은 예시와 함께 여러 액터가 하나의 클래스를 사용하며 위 원칙을 위반할 두 가지 징후를 이야기합니다.
우발적 중복과 병합입니다.
우발적 중복
먼저 우발적 중복입니다.
Employee라는 클래스가 있습니다. 그리고 Employee 클래스의 기능을 회계팀과 인사팀이 함께 사용합니다.
회계팀은 Employee의 calculatePay 기능(함수)를, 인사팀은 reportHours 기능(함수)를 사용합니다.
이때 Employee 클래스는 회계팀과 인사팀이라는 두 액터에 대해 책임지고 있다고 할 수 있습니다.
calculatePay 함수와 reportHours 함수는 초과 근무를 제외한 업무 시간 정보를 필요로 하는데, Employee 클래스를 담당한 개발자가 getRegularHours라는 private 함수를 만들어서 공통으로 사용했습니다.
// kotlin
class Employee {
fun calculatePay(): Int {
...
val overtimeHours = totalHours - getRegularHours()
val overtimePay = overtimeHours * overtimeAllowance
...
}
fun reportHours(): Int {
val regularHours = getRegularHours()
...
}
private fun getRegularHours(): Int { ... }
}
// java
public class Employee {
public int calculatePay() {
...
int overtimeHours = totalHours - getRegularHours();
int overtimePay = overtimeHours * overtimeAllowance;
...
}
public int reportHours() {
int regularHours = getRegularHours();
...
}
private int getRegularHours() { ... }
}
이 회사는 아쉽게도 포괄 임금제입니다. 그래서 주 10시간을 초과해서 야근할 시에만 초과 근무 수당을 받을 수 있습니다. getRegularHours는 이를 고려하여 초과 근무가 아닌 업무 시간을 반환합니다.
그런데 어느 날, 인사팀에서 인사 평가를 위해 주 10시간이 포함되지 않은, 진짜 야근 시간을 계산하기로 했습니다. 그래서 개발자를 통해 getRegularHours의 로직을 변경했습니다.
그리고 그달에, 회계팀에서 발생한 잘못된 데이터로 회사는 수억 원의 예산을 지출하며 큰 피해를 입었습니다.
회계팀이 사용하는 calculatePay 함수는 총 업무 시간에서 getRegularHours의 반환 값을 제외하여 초과 근무를 계산하고 있었습니다.
그런데 인사팀에서 계산 방식을 바꾸는 바람에 초과 근무로 인정되지 않아야 할 주 10시간까지도 초과 근무로 계산된 것입니다.
이것이 바로 우발적 중복으로 인해 단일 책임 원칙을 지키지 않은 경우입니다.
하나의 클래스가 두 액터에 대해 책임지며 SRP를 위반했습니다. 이는 회사에 큰 타격을 가져왔습니다.
병합
병합은 크게 더 설명할 것도 없습니다. 깃을 사용하여 협업해 본 개발자라면 누구라도 이해할 수 있을 것입니다.
한 클래스(소스 파일)을 여러 액터가 사용한다면 병합 과정에서 충돌이 발생할 수 있습니다.
인사팀, 회계팀을 포함한 여러 팀이 Employee 클래스를 사용한다고 해봅시다. 그들 모두가 각자 사용하는 함수를 동시에 수정했습니다.
이때 이들의 변경 사항은 필수적으로 충돌을 수반한 병합을 발생시킵니다.
만약 하나의 액터(팀)에서 여러 개발자가 동시에 수정하여 충돌이 발생했다면, 이는 같은 도메인 지식을 바탕으로 일어난 일이니 비교적 쉽게 병합해 볼 수 있습니다. 그러나 여러 액터에 의해 발생한 충돌은 해결하기도 어려우며 액터들을 큰 위험에 빠트릴 수 있습니다.
해결책
그럼 Employee 클래스를 어떻게 관리해야 할까요? 이에 대한 해결책을 제시합니다.
정답은 하나가 아닙니다. 여러 가지 해결책이 존재할 수 있습니다.
1. 액터 수대로 클래스를 분리하기 (데이터와 메서드를 분리하기)
우선 액터 수대로 클래스를 분리합니다. 회계팀을 위한 PayCalculator와 인사팀을 위한 HourReporter 클래스를 만듭니다.
두 클래스는 서로에 대해 전혀 알지 못합니다. 이제 우려하던 문제가 발생하지 않을 겁니다.
그리고 아무런 메서드가 없는 간단한 데이터 구조인 EmployeeData 클래스를 만들어 두 액터가 공유하도록 합니다.
// kotlin
data class EmployeeData(...)
class PayCalculator(private val employeeData: EmployeeData) {
fun calculatePay(): Int { ... }
}
class HourReporter(private val employeeData: EmployeeData) {
fun reportHours(): Int { ... }
}
// java
public record EmployeeData( ... ){}
public class PayCalculator {
private final EmployeeData employeeData;
public PayCalculator(EmployeeData employeeData) {
this.employeeData = employeeData;
}
public int calculatePay() { ... }
}
public class HourReporter {
private final EmployeeData employeeData;
public HourReporter(EmployeeData employeeData) {
this.employeeData = employeeData;
}
public int reportHours() { ... }
}
하지만 이 방식은 개발자가 같은 EmployeeData를 다루는 클래스들을 각각 인스턴스화하고 관리해야 한다는 어려움이 발생할 수 있습니다. 어쩌면 다른 버전의 EmployeeData를 다루면서 문제가 발생할 수도 있죠.
2. 퍼사드 패턴
각 액터를 담당하는 클래스들을 한데 모아 퍼사드 클래스를 만듭니다. 이 퍼사드 클래스에는 로직이 거의 없습니다.
다만 두 액터 클래스를 생성하고, 요청된 행위를 수행할 수 있는 클래스에 위임하는 역할을 합니다.
// kotlin
class EmployeeFacade(
private val payCalculator: PayCalculator,
private val hourReporter: HourReporter,
) {
fun calculatePay(): Int {
return payCalculator.calculatePay()
}
fun reportHours(): Int {
return hourReporter.reportHours()
}
}
// java
class EmployeeFacade {
private final PayCalculator payCalculator;
private final HourReporter hourReporter;
public EmployeeFacade(PayCalculator payCalculator, HourReporter hourReporter) {
this.payCalculator = payCalculator;
this.hourReporter = hourReporter;
}
public int calculatePay() {
return payCalculator.calculatePay();
}
public int reportHours() {
return hourReporter.reportHours();
}
}
하지만 누군가는 "Facade 클래스가 두 액터에 대해 책임지므로 SRP를 위반한 것이 아니냐!" 라고 말할 수 있습니다.
그렇지만 우리는 우리가 왜 SRP를 지키는 지에 대해 생각해 봐야 합니다.
원칙은 단순히 우리를 불편하게 하려고 존재하는 것이 아니니까요.
Facade 클래스는 두 액터에 대해 책임지고 있지만, 우발적 중복이나 병합이 일어날 일이 없습니다.
단순히 두 클래스를 모아놓기만 했을 뿐, 어떠한 비즈니스 로직도 존재하지 않기 때문입니다.
우리는 SRP를 지켜야 하는 이유를 충분히 이해했고 Facade 클래스 또한 이를 충분히 만족하므로 저는 문제가 없다고 생각합니다.
어떤 개발자는 이를 보고 이런 생각을 할 수도 있습니다. "하나의 데이터를 사용하는데 이왕이면 데이터도 모여있는 게 좋지 않을까? 데이터들이 업무 규칙(비즈니스 로직)과 가까이 존재하는 게 훨씬 유지 보수하기 편할 것 같은데?"
3. 다시 Employee 클래스
사용하는 하나의 데이터를 함께 두기 위해 EmployeeData를 EmployeeFacade 클래스 내부로 옮겼습니다.
이제 데이터와 메서드가 한데 존재하니, Facade라는 이름보다는 Employee라는 이름으로 돌아가도 좋을 것 같습니다.
// kotlin
// 두 액터 클래스를 생성자 파라미터로 받지 않고 내부에서 생성해도 괜찮습니다.
class Employee(
private val payCalculator: PayCalculator,
private val hourReporter: HourReporter,
private val employeeData: EmployeeData,
) {
fun calculatePay(): Int {
return payCalculator.calculatePay(employeeData)
}
fun reportHours(): Int {
return hourReporter.reportHours(employeeData)
}
}
class PayCalculator {
fun calculatePay(employeeData: EmployeeData): Int { ... }
}
class HourReporter {
fun reportHours(employeeData: EmployeeData): Int { ... }
}
// java
// 두 액터 클래스를 생성자 파라미터로 받지 않고 내부에서 생성해도 괜찮습니다.
public class Employee {
private final PayCalculator payCalculator;
private final HourReporter hourReporter;
private final EmployeeData employeeData;
public Employee(PayCalculator payCalculator, HourReporter hourReporter, EmployeeData employeeData) {
this.payCalculator = payCalculator;
this.hourReporter = hourReporter;
this.employeeData = employeeData;
}
public int calculatePay() {
return payCalculator.calculatePay(employeeData);
}
public int reportHours() {
return hourReporter.reportHours(employeeData);
}
}
class PayCalculator {
public int calculatePay(EmployeeData employeeData) { ... }
}
class HourReporter {
public int reportHours(EmployeeData employeeData) { ... }
}
마무리
이렇게 우리는 SRP를 위반하며 생긴 문제들을 풀기 위한 세 가지 해결책을 만나 보았습니다.
어떠신가요, 이해가 조금 되셨을까요?
SRP는 이름 덕분에 많은 이들이 SOLID 중에서 가장 잘 외우면서도, 이름 때문에 가장 많은 오해를 갖는 원칙입니다.
오늘 여러분의 오해가 풀렸기를 바랍니다.
로버트 C 마틴은 SRP가 클래스 수준의 원칙이지만, 비슷한 개념으로 상위 컴포넌트에서 다시 등장한다고 말합니다.
컴포넌트 수준에서는 공통 폐쇄 원칙으로 나타나고, 아키텍처 수준에서는 아키텍처 경계 생성을 책임지는 변경의 축이 된다고 합니다. 더 궁금하신 분은 클린 아키텍처 책을 읽어 보시는 것을 추천드립니다.
그럼 저는 이만 여기서 마치며 인사드리겠습니다!
'디자인 패턴' 카테고리의 다른 글
Why MVVM (부제: MVP to MVVM) (1) | 2023.08.25 |
---|---|
Why MVP (부제: MVC to MVP) (1) | 2023.05.30 |