단일 책임 원칙 (Single Responsibility Principle, SRP) - SOLID
소프트웨어 개발을 하면서 코드를 깨끗하고 유지보수 가능하게 만드는 것은 누구나 추구하는 목표입니다. (부끄럽지만, 저는 진짜 클린 코드에 욕심이 많아요. 욕심만 많아요…)
클린한 코드를 작성하려면 기본적인 설계 원칙을 잘 이해하고, 이를 코드에 적용하는 것이 중요하죠
그 중에서도 단연 많이 언급되는 것이 바로 SOLID 원칙입니다 !
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ[SOLID 에 대해 자세히 알아보기]
오늘 이야기할 단일 책임 원칙(SRP)은 이 다섯 가지 원칙 중 첫 번째에 해당하며, 이를 잘 이해하고 적용하는 것이 나머지 원칙들을 제대로 구현할 수 있는 토대가 된다고 생각합니다
그리고 스파게티 코드로 가지 않도록 하는 가장 핵심적인 원칙…!
"한 클래스는 하나의 책임만 가져야 한다"
각 클래스가 오직 하나의 역할만을 수행하도록 설계하면, 변경의 이유 역시 명확해지고 유지보수도 훨씬 용이해질 수 있습니다 :)
단일 책임 원칙을 지키는 클래스는 특정 기능이나 책임에만 집중하기 때문에 코드가 읽기 쉽고 이해하기도 쉬워집니다.
또한, 이로 인해 각 기능을 쉽게 테스트할 수 있으며, 기능을 변경할 때 다른 부분에 미치는 영향을 최소화할 수 있습니다. 따라서 단일 책임 원칙은 코드의 유연성, 재사용성, 테스트 가능성을 높이는 중요한 원칙이라 할 수 있어요 😃😃
단일 책임 원칙을 이해하는 첫걸음으로, 이 원칙이 왜 SOLID의 첫 번째 원칙으로 자리잡았는지 생각해 보는 것은 의미가 있습니다. 클래스가 하나의 책임만 가질 때, 그 클래스는 단순하고 명확한 역할을 하게 됩니다.
단일 책임 원칙(SRP)으로 더 나은 코드 설계하기
: 나쁜 예시와 좋은 예시
단일 책임 원칙은 머리로는 이해하기 쉽지만 실제 코드에 적용하는 것은 다소 까다로울 수 있습니다. 이를 돕기 위해, SRP를 지키지 않은 나쁜 예시와 SRP를 적용해 개선된 좋은 예시를 살펴보겠습니다.
나쁜 예시: 책임이 분리되지 않은 클래스
아래 코드는 Employee
클래스가 여러 가지 책임을 가지고 있는 전형적인 나쁜 예시입니다.
class Employee {
public void calculatePay() {
// 급여 계산 로직
}
public void saveToDatabase() {
// DB 저장 로직
}
public void generateReport() {
// 보고서 생성 로직
}
}
위의 Employee
클래스는 세 가지 서로 다른 책임을 가지고 있습니다:
급여 계산
데이터베이스에 저장
보고서 생성
이렇게 여러 책임을 한 클래스에 몰아 넣으면, 다음과 같은 문제가 발생합니다:
변경의 이유가 여러 가지
: 급여 계산 로직을 수정해야 할 때도, 데이터베이스 관련 사항을 변경해야 할 때도 이 클래스가 영향을 받습니다. 이는 각기 다른 요구사항으로 인해 클래스가 자주 변경될 가능성을 높이고, 변경에 취약한 구조가 됩니다. 🤮유지보수성 저하
: 클래스의 책임이 많아지면 코드를 읽고 이해하기 어려워집니다. 한 곳에서 여러 가지 역할을 수행하기 때문에 개발자가 수정하려 할 때 어디서부터 손을 대야 할지 명확하지 않을 수 있습니다 😱재사용성 문제
: 특정 기능만 재사용하고 싶어도 전체 클래스를 가져와야 하므로 재사용이 어려워집니다. 🤕
좋은 예시: 책임 분리된 클래스
SRP를 적용해 Employee
클래스를 리팩토링해 보겠습니다. 각 책임을 별도의 클래스로 분리하여, 변경의 이유가 하나씩만 있도록 설계합니다.
class Employee {
private String name;
private double salary;
// 직원 정보 관련 필드와 메서드만 포함
}
class PayrollCalculator {
public double calculatePay(Employee employee) {
// 급여 계산 로직
return employee.getSalary();
}
}
class EmployeeRepository {
public void save(Employee employee) {
// DB 저장 로직
}
}
class ReportGenerator {
public void generateReport(Employee employee) {
// 보고서 생성 로직
}
}
이제 각 클래스는 하나의 책임만 가지며, 변경의 이유도 명확해졌습니다:
Employee
클래스는 직원의 정보만 관리합니다.PayrollCalculator
클래스는 급여 계산 책임만 가지며, 다른 기능에 영향을 주지 않고 독립적으로 수정될 수 있습니다.EmployeeRepository
클래스는 직원 데이터를 데이터베이스에 저장하는 책임을 가집니다.ReportGenerator
클래스는 보고서 생성만을 담당합니다.
이렇게 책임을 분리하면 다음과 같은 장점이 있습니다:
변경의 이유가 명확: 각 클래스는 하나의 책임만 가지므로, 특정 기능을 변경해야 할 때 그 클래스만 수정하면 됩니다.
유지보수성 향상: 클래스가 단순해져서 코드를 이해하고 유지보수하기가 쉬워집니다.
재사용성 증가: 각 클래스가 하나의 역할만 수행하므로 다른 프로젝트나 다른 기능에서 재사용하기가 훨씬 쉬워집니다.
그런데 솔직히 그렇게 생각하시는 분들도 있을 겁니다. ‘아니, 무슨 기능마다 클래스를 다 분리해? 그게 더 유지보수성이 안 좋고, 다 흩어져서 괜히 복잡해지는 거 아냐? 그러다가 한 프로젝트에 클래스만 몇 백 개는 되겠네;;;’ (사실, 제가 그런 생각을 했었습니다…)
위의 예제에서는 정말 간단한 예시로 메서드를 각 클래스마다 분리하는 걸 보여드리기 위한 것이지, 무조건 그렇게 해야 한다는 뜻은 아닙니다. ❌
하지만 위에서도 말씀드렸다시피, ‘단일 책임 원칙’은 생각보다 정말 지키기 어렵습니다. 그런데 이렇게 설계할 수 있다는 것만 말하는 거지, ’무조건, 100% 이렇게만 하세요! 단일 책임 원칙을 지키세요!’는 말이 안 되죠. 저도 그렇고, 아마 많은 선배님들도 100% 단일 책임 원칙을 지키는 분은 많지 않을 겁니다.
모든 디자인 패턴, 설계, 이론 등에는 이러한 방법들이 있고, 이렇게 했을 때 좋은 효과가 있다는 거지, 무조건 이렇게만 해야 한다는 건 아니니까, 저와 같은 삐뚤어진 생각은… 🤔
단일 책임 원칙(SRP)으로 더 나은 코드 설계하기
: SRP를 지켜야 하는 이유
단일 책임 원칙(SRP)은 코드를 유지보수 가능하고 확장성 있게 만들어주는 중요한 객체 지향 설계 원칙 중 하나입니다.
1. 코드의 유지보수성 향상
각 클래스는 하나의 책임만 가지게 됩니다. 이는 코드의 가독성을 높이고, 유지보수 작업에서 변경할 부분을 명확히 파악할 수 있도록 합니다.
// 좋은 예시: 급여 계산 클래스 분리
class PayrollCalculator {
public double calculatePay(Employee employee) {
// 급여 계산 로직
return employee.getSalary();
}
}
// 급여 계산 방식이 변경될 경우 PayrollCalculator만 수정하면 됩니다.
예를 들어, 급여를 계산하는 로직이 Employee
클래스에 있지 않고 PayrollCalculator
클래스에 분리되어 있다면, 급여 계산 방식이 변경되었을 때 이 클래스만 수정하면 됩니다. 다른 기능에 영향을 미칠 걱정을 하지 않아도 되므로 유지보수의 용이성이 향상됩니다 🤓
코드 변경 빈도 감소: 코드 변경의 요구 빈도를 줄입니다.
안전한 변경: 변경 작업을 더욱 안전하게 진행할 수 있도록 돕습니다.
일관성 유지: 코드의 일관성을 유지하여 개발자의 부담을 줄입니다.
2. 테스트 용이성
각 클래스가 독립적으로 하나의 역할만 수행하므로, 해당 클래스의 기능을 테스트하기가 훨씬 쉬워집니다. 급여 계산 로직이 분리되어 있다면 급여 계산 로직만을 위한 테스트를 작성할 수 있습니다.
// 단위 테스트 예시: 급여 계산 로직 테스트
@Test
public void testCalculatePay() {
Employee employee = new Employee("Thingk0", 5000);
PayrollCalculator calculator = new PayrollCalculator();
double pay = calculator.calculatePay(employee);
assertEquals(5000, pay, 0);
}
// PayrollCalculator만 테스트하므로 테스트가 간단하고 명확합니다.
이는 단위 테스트를 효과적으로 작성하고 유지하는 데 큰 장점을 제공합니다.
코드 품질 향상: 각 기능을 독립적으로 테스트하여 코드 품질을 높입니다.
버그 조기 발견: 독립적인 테스트로 버그를 조기에 발견하고 수정할 수 있습니다.
테스트 복잡성 감소: 여러 기능의 얽힘을 줄여 테스트의 복잡성을 감소시킵니다.
3. 재사용성 증가
각 클래스가 명확한 역할을 가지므로, 다른 상황에서도 쉽게 재사용할 수 있는 코드가 됩니다.
// PayrollCalculator 클래스의 재사용 예시
PayrollCalculator calculator = new PayrollCalculator();
Employee employee = new Employee("Thingk0", 3000);
double pay = calculator.calculatePay(employee);
// 다른 도메인에서도 동일한 방식으로 재사용 가능
예를 들어, PayrollCalculator
클래스는 직원의 급여 계산만을 담당하므로, 이 로직이 필요한 다른 프로젝트나 다른 도메인에서도 간편하게 재사용할 수 있습니다.
개발 시간 절약: 재사용 가능한 코드는 개발 시간을 줄입니다.
코드 중복 방지: 중복을 피함으로써 유지보수성을 높입니다.
모듈화 가능: 코드의 모듈화를 가능하게 하여 소프트웨어 개발의 생산성을 극대화합니다.
4. 변경에 따른 영향도 최소화
단일 책임 원칙을 따르면 각 클래스는 독립적인 책임을 가지기 때문에, 특정 기능을 변경할 때 다른 부분에 영향을 줄 가능성이 최소화됩니다.
예를 들어, EmployeeRepository
클래스는 직원 정보를 데이터베이스에 저장하는 책임만 가지므로, 데이터 저장 방식이 변경될 때 이 클래스만 수정하면 됩니다.
// EmployeeRepository 클래스 예시
class EmployeeRepository {
public void save(Employee employee) {
// 기존의 데이터베이스 저장 로직
}
}
// 데이터베이스 저장 방식을 파일 시스템으로 변경할 경우
class EmployeeRepository {
public void save(Employee employee) {
// 파일 시스템에 저장하는 로직으로 변경
}
}
// EmployeeRepository만 수정하여 다른 부분에 영향 없이 변경 가능
이와 반대로 한 클래스가 여러 책임을 가질 경우, 한 부분의 변경이 다른 부분에 영향을 줄 수 있어 변경의 리스크가 크게 증가합니다.
소프트웨어 안정성 유지: 변경에 따른 영향도를 줄여 안정성을 유지합니다.
협업 환경에서의 이점: 큰 프로젝트나 협업 환경에서 모듈별 책임을 명확히 하여 변경이 다른 팀의 작업에 미치는 영향을 줄입니다.
단일 책임 원칙(SRP)으로 더 나은 코드 설계하기
: SRP 적용 시 고려사항과 실제 적용 사례
단일 책임 원칙(SRP)을 제대로 이해하고 적용하려면 몇 가지 고려사항을 염두에 두어야 합니다 ! 분명 코드를 유지보수 가능하게 만들고 확장성을 높이지만, 모든 경우에 일률적으로 적용할 수 있는 것은 아닙니다 🫢🫢
"책임"을 정의하는 기준
단일 책임 원칙에서 말하는 "책임"이란 무엇일까요???
"책임"을 정의하는 것은 SRP를 적용하는 데 가장 중요한 요소 중 하나입니다. 각 클래스가 오직 하나의 역할만을 수행하도록 설계해야 하는데, 이 역할의 범위를 어떻게 정하느냐가 핵심입니다.
너무 넓은 역할을 정의하면 SRP를 위반하게 되며, 반대로 지나치게 작은 역할로 분리하면 오히려 관리해야 할 클래스가 너무 많아져 복잡도가 증가할 수 있습니다.
예를 들어, PayrollCalculator
는 급여 계산이라는 명확한 책임을 가지지만, 이를 세세하게 나눠서 "급여 기본급 계산", "세금 계산", "보너스 계산" 등의 클래스로 분리하는 것이 항상 좋은 것은 아닙니다.
class PayrollCalculator {
private BasicSalaryCalculator basicSalaryCalculator;
private TaxCalculator taxCalculator;
private BonusCalculator bonusCalculator;
public PayrollCalculator() {
this.basicSalaryCalculator = new BasicSalaryCalculator();
this.taxCalculator = new TaxCalculator();
this.bonusCalculator = new BonusCalculator();
}
public double calculateSalary(Employee employee) {
double basicSalary = basicSalaryCalculator.calculateBasicSalary(employee);
double tax = taxCalculator.calculateTax(basicSalary);
double bonus = bonusCalculator.calculateBonus(employee);
return basicSalary - tax + bonus;
}
}
이러한 세분화는 오히려 코드 복잡도를 증가시켜 유지보수를 어렵게 만들 수 있습니다.
너무 작은 단위로 분리하는 것의 문제점
SRP를 지나치게 적용하여 너무 작은 단위로 클래스를 분리하는 것은 종종 실수로 이어집니다.
예를 들어, 단순한 로직을 수행하는 작은 클래스가 지나치게 많아지면, 전체적인 클래스 수가 급격히 증가하여 코드베이스의 복잡도를 높이고 관리가 어려워질 수 있습니다.
또한, 이러한 클래스들은 의존 관계가 복잡해질 가능성이 큽니다. 이는 코드의 이해와 유지보수를 어렵게 만들며, 종종 개발 속도를 저하시킵니다.
// 급여 기본급 계산
class BasicSalaryCalculator {
public double calculateBasicSalary(Employee employee) {
// 기본급 계산 로직
}
}
// 세금 계산
class TaxCalculator {
public double calculateTax(double salary) {
// 세금 계산 로직
}
}
// 보너스 계산
class BonusCalculator {
public double calculateBonus(Employee employee) {
// 보너스 계산 로직
}
}
// 급여 계좌에 입금
class SalaryDeposit {
public void depositSalary(Employee employee, double amount) {
// 급여 입금 로직
}
}
// 급여 통지서 발송
class SalaryNotification {
public void sendSalaryNotification(Employee employee, double amount) {
// 급여 통지서 발송 로직
}
}
적정한 책임의 범위를 정하는 것이 중요한데, 일반적으로 관련된 데이터와 기능을 하나의 클래스로 묶는 것이 좋습니다. 너무 작은 단위로의 분리는 "유지보수성"이라는 SRP의 주요 목표를 오히려 저해할 수 있다는 점을 명심해야 합니다 🤫
실제 적용 사례
애플리케이션에서 SRP를 적용하는 대표적인 방법은 계층 분리입니다. 예를 들어, 컨트롤러, 서비스, 리포지토리 계층으로 나누는 것은 각 계층이 명확한 책임을 가지도록 하여 유지보수를 쉽게 만듭니다.
컨트롤러: 사용자 요청을 처리하고 응답을 반환합니다.
서비스: 비즈니스 로직을 처리합니다.
리포지토리: 데이터베이스와의 상호작용을 담당합니다.
이러한 계층 분리를 통해 각 책임을 명확히 구분할 수 있으며, 변경 시 영향을 최소화할 수 있습니다.
예를 들어, 데이터베이스 변경이 필요할 때 리포지토리 계층만 수정하면 되므로, 나머지 애플리케이션 로직에 미치는 영향을 줄일 수 있습니다.
비즈니스 로직 분리 예시
비즈니스 로직을 명확히 분리하는 것도 SRP의 중요한 적용 사례입니다.
예를 들어, 온라인 쇼핑몰에서 주문 처리와 결제 처리를 각각 별도의 클래스로 나누면, 주문 처리 로직과 결제 로직의 변경이 서로 영향을 미치지 않도록 설계할 수 있습니다.
OrderProcessor: 주문을 처리하는 클래스
PaymentProcessor: 결제를 처리하는 클래스
// 주문을 처리하는 클래스
class OrderProcessor {
public void processOrder(Order order) {
// 주문 처리 로직
System.out.println("주문이 처리되었습니다: " + order.getOrderId());
// 예를 들어, 재고 확인, 배송 준비 등의 처리
}
}
// 결제를 처리하는 클래스
class PaymentProcessor {
public boolean processPayment(Order order, PaymentDetails paymentDetails) {
// 결제 처리 로직
System.out.println("결제가 완료되었습니다: " + order.getOrderId());
// 예를 들어, 결제 승인, 카드 정보 확인 등의 처리
return true; // 결제 성공
}
}
// 주문 객체
@Getter
class Order {
private String orderId;
private double amount;
public Order(String orderId, double amount) {
this.orderId = orderId;
this.amount = amount;
}
}
// 결제 정보 객체
@Getter
class PaymentDetails {
private String cardNumber;
private String cardHolderName;
private String expirationDate;
public PaymentDetails(String cardNumber, String cardHolderName, String expirationDate) {
this.cardNumber = cardNumber;
this.cardHolderName = cardHolderName;
this.expirationDate = expirationDate;
}
}
이렇게 분리하면, 주문 프로세스 변경 시 결제 로직을 수정하지 않아도 되고, 반대로 결제 방식을 변경할 때 주문 프로세스에 영향을 주지 않아 유지보수와 확장에 유리합니다 😃
단일 책임 원칙(=SRP)의 핵심 정리
단일 책임 원칙은 클래스가 하나의 책임만 가지도록 하여 코드의 유지보수성, 테스트 용이성, 재사용성을 높이고 변경에 따른 영향을 최소화하는 객체 지향 설계 원칙입니다.
SRP는 단순해 보이지만, 이를 제대로 적용하기 위해서는 "책임"의 범위를 잘 정의하고, 실무에서 적절한 균형을 찾는 것이 중요합니다 :)
실천 방안 제시
역할을 명확히 정의: 클래스가 하나의 명확한 역할만 수행하도록 설계합니다.
균형 잡기: 지나치게 작은 단위로 분리하는 대신, 관련된 기능과 데이터를 함께 묶어 유지보수성을 고려합니다.
점진적 개선: 초기 단계에서는 빠른 개발을 목표로 하되, 이후 점진적으로 SRP를 적용하여 구조를 개선합니다.
하지만 말이야 쉽지.. 저도 실제로 개발할 때 SRP 지키려고 노력은 하지만 쉽지 않습니다.. 다음 포스트는 SRP 만큼 중요한 'OCP - 개방 폐쇄 원칙'에 대해서 다루어 보도록 하겠습니다