단일 책임 원칙 - SRP (Single Responsibility Principle)
단일 책임 원칙(SRP)는 객체는 단 하나의 책임만 가져야 한다는 원칙을 말한다.
여기서 '책임' 이라는 의미는 하나의 '기능 담당'으로 보면 된다.
즉, 하나의 클래스는 하나의 기능만을 담당하여 하나의 책임을 수행하는데 집중되어야 있어야 한다는 의미이다.
실생활의 물체로 SRP 원칙을 이해해보자.
아래 그림과 같이 가위, 커터칼, 드라이버를 따로따로 사용하느냐, 하나의 다용도 공구로 합쳐 다재다능하게 사용하느냐에 따른 차이를 볼 수 있다.
어찌보면 다용도 공구가 공간의 절약도 있을테고 사용하기 좋을것 같지만, 사용이 아닌 코드를 설계하는 입장에서는 이는 단일 책임 원칙을 위반하는 모습이 되어 마이너스 적인 요소로 작용된다.
하나의 공구(클래스)에서 가위질, 칼질, 손톱깎이 기능(책임)을 이것저것 담당하여 수행하기 때문이다.
하나의 클래스에 여러 기능(책임)을 넣느냐, 따로따로 클래스를 분리하여 기능(책임)을 분산시키느냐에 따른 설계는 프로그램의 유지보수와 밀접한 관련이 있다.
단일 책임 원칙 준수 유무에 따른 가장 큰 기준 척도는, '기능 변경(수정)' 이 일어났을때의 파급 효과 이다.
한 객체에 책임이 많아질수록 클래스 내부에서 서로 다른 역할을 수행하는 코드끼리 강하게 결합될 가능성이 높아지게 되어 시스템이 복잡해질 수 있다. 그래서 그 객체가 하는 기능에 변경사항이 생기면 이 기능을 사용하는 대부분의 코드를 모두 다시 테스트를 해야 할 수도 있다.
예를 들어 A를 고쳤더니 B를 수정해야하고 또 C를 수정해야하고, C를 수정했더니 다시 A로 돌아가서 수정해야 하는, 마치 책임이 순환되는 형태를 들 수 있다.
이처럼 책임이 이것저것 포함된 클래스는 한 책임의 변경에서 다른 책임의 변경으로의 연쇄작용이 일어 나게 된다.
여기서 단일 책임 원칙을 적용한다면, 각 클래스 주제마다 알맞는 책임을 가짐으로서 책임 영역이 확실해지게 된다.
그래서 어떠한 역할에 대해 변경사항이 발생했을때, 변경 영향을 받는 기능만 모아둔 클래스라면 그 책임을 지니고 있는 클래스만 수정해주면 될 일이다.
이것을 다르게 말하면, 모듈이 변경되는 이유가 한가지 여야 함을 뜻한다. 여러가지 책임을 가지고 있으면 각기 다른 사유에 의해서 모듈이 변경되는 이유가 여러가지가 되기 때문이다.
※ 참고
한 클래스는 한 가지 책임에 관한 변경사항이 생겼을 때만 코드를 수정하게 되는 구조가 좋은 구조이다.
물론 SRP 원칙을 적용하여 클래스를 세세하게 나눔으로써 전체 코드 길이가 길어졌다 하더라도, 하나의 클래스를 사용하는 것보다 여러 개의 클래스를 사용하는 것이 더 효율적이다. 그래야 각 클래스의 의미를 파악하기도 쉽고 유지보수에 용이하기 때문이다.
즉, SRP 원칙을 잘 따르면 한 책임의 변경으로부터 다른 책임의 변경으로의 연쇄작용에서 자유로울 수 있게 된다.
뿐만 아니라 책임을 적절히 분배함으로써 코드의 가독성 향상, 유지보수 용이라는 이점까지 누릴 수 있으며, 뒤에서 배울 다른 설계 원리들을 적용하는 기초가 되기도 한다.
※ 참고
더 와닿기 쉽게 예를 들자면, 청소기 클래스는 청소 메소드만 잘 구현하면 되지 화분에 물을 주고 드라이 까지 할 책임은 없다.물론 다재다능한 청소기는 좋아보이지만, 만일 청소기가 고장나면 다른 기능까지도 사용을 못해지기 때문이다.즉, 청소기는 청소만 잘하면 된다는 책임만 가지면 된다.쉽게 말해 하나의 클래스로 너무 많은 일을 하지 말고 딱 한 가지 책임만 수행하라는 뜻이다.
SRP 원칙 위반 예제와 수정하기
예제 코드를 통해 SRP 원칙 적용 원리를 알아보자.
다음 직원 정보를 담당하는 Employee 클래스에는 4가지의 주요 메서드가 존재한다.
- calculatePay() : 회계팀에서 급여를 계산하는 메서드
- reportHours() : 인사팀에서 근무시간을 계산하는 메서드
- saveDatabase() : 기술팀에서 변경된 정보를 DB에 저장하는 메서드
- calculateExtraHour() : 초과 근무 시간을 계산하는 메서드 (회계팀과 인사팀에서 공유하여 사용)
각 메서드들은 각 팀에서 필요할때마다 호출해 사용한다고 가정하자.
아래 예시 코드에서는 calculatePay() 메서드와 reportHours() 메서드에서 초과 근무 시간을 계산하기 위해 calculateExtraHour() 메서드를 공유하여 사용하고 있다.
class Employee {
String name;
String positon;
Employee(String name, String position) {
this.name = name;
this.positon = position;
}
// * 초과 근무 시간을 계산하는 메서드 (두 팀에서 공유하여 사용)
void calculateExtraHour() {
// ...
}
// * 급여를 계산하는 메서드 (회계팀에서 사용)
void calculatePay() {
// ...
this.calculateExtraHour();
// ...
}
// * 근무시간을 계산하는 메서드 (인사팀에서 사용)
void reportHours() {
// ...
this.calculateExtraHour();
// ...
}
// * 변경된 정보를 DB에 저장하는 메서드 (기술팀에서 사용)
void saveDababase() {
// ...
}
}
그런데 회계팀에서 급여를 계산하는 기존의 방식을 새로 변경하여, 코드에서 초과 근무 시간을 계산하는 메서드 calculateExtraHour() 의 알고리즘 업데이트가 필요해졌다.
그래서 개발팀에서 회계팀의 요청에 따라 calculateExtraHour() 메서드를 변경했는데, 변경에 의한 파급 효과로 인해 수정 내용이 의도치 않게 인사팀에서 사용하는 reportHours() 메서드에도 영향을 주게 되어버린다. (공유해서 사용하니까)
그리고 인사팀에서는 이러한 변경 사실을 알지 못하고 메서드 반환 결과값 데이터가 잘못되었다며 개발팀에 새로운 요청을 보내게 될 것이다.
바로 이러한 상황이 SRP에 위배되는 상황인 것이다.
Employee 클래스에서 회계팀, 인사팀, 기술팀 이렇게 3개의 Actor에 대한 책임을 한꺼번에 가지고 있기 때문이다.
※ 참고
Actor는 시스템을 수행하는 역할을 하는 요소로써, 시스템을 이용하는 사용자, 하드웨어 혹은 외부 시스템이 될 수 있다. 회계팀, 인사팀, 기술팀에서 데이터를 얻기위해 하나의 Employee 클래스를 사용하기 때문에, 3개의 Actor가 하나의 클래스를 변경할 수 있는 요인이 되어 SRP 원칙을 어긴 구조라고 하는 것이다.
예제 코드가 간단하기에 누가 이런 아마추어적인 실수를 할까 싶지만, 실제 프로그래밍을 해보면 이런 실수를 종종 하게된다. 왜냐하면 모든 코드를 분석할 시간이 없기 때문에 하나의 클래스를 참조하는 모든 연관 관계를 보는 것은 불가능하기 때문이다.
그래서 이러한 실수를 아예 만들지 않게끔 하기 위해, 미리 구조를 세우자는 취지에 SRP 설계 원칙이 등장한 것이다.
예제를 SRP원칙을 적용해 변형한 코드는 다음과 같이 된다.
// * 통합 사용 클래스
class EmployeeFacade {
private String name;
private String positon;
EmployeeFacade(String name, String position) {
this.name = name;
this.positon = position;
}
// * 급여를 계산하는 메서드 (회계팀 클래스를 불러와 에서 사용)
void calculatePay() {
// ...
new PayCalculator().calculatePay();
// ...
}
// * 근무시간을 계산하는 메서드 (인사팀 클래스를 불러와 에서 사용)
void reportHours() {
// ...
new HourReporter().reportHours();
// ...
}
// * 변경된 정보를 DB에 저장하는 메서드 (기술팀 클래스를 불러와 에서 사용)
void EmployeeSaver() {
new EmployeeSaver().saveDatabase();
}
}
// * 회계팀에서 사용되는 전용 클래스
class PayCalculator {
// * 초과 근무 시간을 계산하는 메서드
void calculateExtraHour() {
// ...
}
void calculatePay() {
// ...
this.calculateExtraHour();
// ...
}
}
// * 인사팀에서 사용되는 전용 클래스
class HourReporter {
// * 초과 근무 시간을 계산하는 메서드
void calculateExtraHour() {
// ...
}
void reportHours() {
// ...
this.calculateExtraHour();
// ...
}
}
// * 기술팀에서 사용되는 전용 클래스
class EmployeeSaver {
void saveDatabase() {
// ...
}
}
단인 책임의 적용은 어렵지 않다. 각 책임(기능 담당)에 맞게 클래스를 분리하여 구성하면 끝이다.
회계팀, 인사팀, 기술팀의 기능 담당은 PayCaculator, HourReporter, EmployeeSaver 각기 클래스로 분리하고, 이를 통합적으로 사용하는 클래스인 EmployeeFacade 클래스를 만든다.
EmployeeFacade 클래스의 메서드에는 사실상 아무런 로직이 들어있지 않게 된다. 있는 코드라 봤자 생성자로 인스턴스를 생성하고 각 클래스의 메서드를 사용하는 역할만 할 뿐이다.
이렇게 되면 EmployeeFacade 클래스는 어떠한 Actor도 담당하지 않게 된다.
만일 변경 사항이 생겨도, 각각의 분리된 클래스에서만 수정하면 되기 때문에 EmployeeFacade 클래스는 냅둬도 기능을 이용하는데 있어 아무런 문제가 생기지 않는다.
예를 들어 회계팀에서 초과 근무 시간 계산 방법이 변경되었다고 하면, PayCalculator 클래스의 calculateExtraHour() 메서드를 수정하면 될 일이다. 그리고 이 변경사항이 HourReporter 클래스에는 전혀 영향을 주지 않게 된다.
※ 참고
위와 같은 구성을 디자인 패턴중 하나인 Facade 패턴이라고 한다. Facade란 건물의 정면을 의미한다. Facade Pattern은 말 그대로 건물의 뒷부분이 어떻게 생겼는지는 보여주지 않고 건물의 정면만 보여주는 패턴이다.
예를들어 EmployeeFacade 클래스는 메서드의 구현이 어떻게 되어있는지는(건물의 뒷부분) 보여주지 않고 어떤 메서드가 있는지(건물의 정면)만 보여준다. 구체적인 메서드의 구현은 각각 PayCalculator, HourReporter, EmployeeSaver 클래스에 위임하기 때문이다.
또한 클래스를 사용하는 입장에선 PayCalculator, HourReporter, EmployeeSaver 클래스가 어떤식으로 구성되어있는지 알필요 없이 EmployeeFacade 클래스를 불러와 메서드만 사용하면 되기 때문에 캡슐화(정보 은닉)도 잘 구성되어 있다라고 말할 수 있다.
SRP 원칙의 책임의 범위에 대한 고찰
이처럼 각 책임(기능 담당)에 맞게 잘 조율하여 클래스 분리만 잘 해놓는다면, SRP 원리는 다른 원리들에 비해서 개념이 비교적 단순해 보인다.
그러나 실제는 이 원리를 적용해서 직접 클래스를 설계하는 것은 그리 쉽지만은 않다.
왜냐하면 단일 책임 기준은 사람들마다 생각하는 것이 다르고 상황에 따라서도 달라질 수 있기 때문이다.
실제로 프로그램을 설계 할때 객체에 얼만큼의 책임을 넣어야 단일 책임 원칙을 위배하지 않는지 범위 기준은 개발자 스스로 정해야 한다. 그리고 실무의 프로세스는 매우 복잡하고 다양하며 변경 또한 빈번하기 때문에 경험이 많지 않거나 업무 이해가 부족하면 자기도 모르게 SRP원리에서 멀어져 버리게 될 수도 있다.
따라서 평소에 많은 연습과 경험이 필요한 원칙이다.
이 역시 예제 코드로 살펴보자.
직원에 대한 기본 CRUD 작업을 담당하는 EmployeeManagement 클래스가 존재한다.
직원 정보 데이터를 입력 받으면 이를 서버에 보내주고 로그를 출력하고 로그 파일에 기록하는 역할을 담당한다.
언뜻 보기엔 서버에 전송하고 그 성공/실패 결과를 로깅도 해주는, 그 자체로 완벽한 기능 담당을 하는 클래스 인것 같다.
class EmployeeManagement {
// * Create 작업을 담당하는 CRUD 메소드
void addEmployee(String employee) {
if(Objects.equals(employee, "")) {
postServer(employee); // 서버에 직원 정보를 보냄
logResult("[LOG] EMPLOYEE ADDED"); // 로그 출력
} else {
logResult("[ERROR] NAME MUST NOT BE EMPTY");
}
}
// * 서버에 데이터를 전달하는 메소드
void postServer(String employees) {}
// * 로그를 출력하는 매소드
void logResult(String message) {
System.out.println(message); // 로그를 콘솔에 출력
writeToFile(message); // 로그 내용을 로그 파일에 저장
}
// * 파일에 내용을 저장하는 메소드
void writeToFile(String msg){}
}
그러나 사실 이는 SRP 원칙에 위배된 형태이다.
왜냐하면 서버에 데이터를 보내는 동작과, 작업 결과를 파일에 기록하는 동작은 서로 관련된 동작이 아니기 때문이다.
애초부터 EmployeeManagement 클래스는 CRUD를 담당하는 클래스로 설계 되어 있기 때문이다.
단순히 성공/실패 결과를 로깅해주는 기능을 가미해서 더 안정적이게 클래스를 설계하였다고 보이겠지만, 하나의 책임에서 벗어난 결과가 되어 버린다.
따라서 로깅(logging)만을 담당하는 클래스를 따로 분리하고, EmployeeManagement 클래스에서 합성(composition)하여 사용하기만 하면 된다. 그러면 각기 서버에 전달하는 책임하고 로깅하는 단일 책임으로 나뉘어 구성된 객체를 만들 수 있게 된다.
각 클래스가 변경해야 하는 이유는 단 하나뿐임을 기억하자.
class EmployeeManagement {
Logger logger = new Logger(); // 합성
// * Create 작업을 담당하는 CRUD 메소드
void addEmployee(String employee) {
if(Objects.equals(employee, "")) {
postServer(employee); // 서버에 직원 정보를 보냄
logger.logResult("[LOG] EMPLOYEE ADDED"); // 로그 출력
} else {
logger.logResult("[ERROR] NAME MUST NOT BE EMPTY");
}
}
// * 서버에 데이터를 전달하는 메소드
void postServer(String employees) {}
}
class Logger {
// * 로그를 출력하는 매소드
void logResult(String message) {
System.out.println(message); // 로그를 콘솔에 출력
writeToFile(message); // 로그 내용을 로그 파일에 저장
}
// * 파일에 내용을 저장하는 메소드
void writeToFile(String msg){}
}
이처럼 어떤 클래스를 보고 단일 책임 원칙을 지켰는지 단번에 판단하는 것은 쉽지 않다.
어떤 프로그램을 개발하느냐에 따라 개발자의 생각이 제각기 다르기 때문에 따라서 단일 책임 원칙에 100% 답은 없다.
하지만 중요한 것은 클래스를 작성할 때 단일 책임 원칙을 지켰는지 끊임 없이 생각해보는 것이다. 하나의 클래스가 너무 많은 책임을 가지진 않았는지, 분리할 수 있는 변수와 메소드가 많은 것은 아닌지를 항상 고민해 봐야 한다.
추후 유지보수를 위해서라도 꼭 단일 책임 원칙을 기억하며 코드를 작성하도록 노력해야 한다.
SRP 원칙 적용 주의점
1. 클래스명은 책임의 소재를 알수있게 작명
클래스가 하나의 책임을 가지고 있다는 것을 나타내기 위해, 클래스명을 어떠한 기능을 담당하는지 알수 있게 작명하는 것이 좋다.
즉, 각 클래스는 하나의 개념을 나타내게 구성하는 것이다.
2. 책임을 분리할때 항상 결합도, 응집도 따져가며
SRP 원칙을 따른다고 해서 무턱대로 책임을 아무생각 없이 분리하면 안되고, 항상 결합도와 응집도를 따져가며 구성해야 한다.
응집도란 한 프로그램 요소가 얼마나 뭉쳐있는가를 나타내는 척도이고, 결합도는 프로그램 구성 요소들 사이가 얼마나 의존적인지를 나타내는 척도이다.
좋은 프로그램이란 응집도를 높게, 결합도는 낮게 설계하는 것을 말한다.
따라서 여러가지 책임으로 나눌때는 각 책임간의 결합도를 최소로 하도록 코드를 구성해야 한다.
하지만 그 반대로 너무 많은 책임 분할로 인하여 책임이 여러군데로 파편화 되어있는 경우에는 산탄총 수술로 다시 응집력을 높여주는 작업이 추가로 필요하다.
산탄총 수술
반대로 하나의 책임 담당이 여러 개의 클래스들로 분산되어 있는 경우에도, 단일 책임 원칙에 입각해 설계를 변경해야 하는 케이스도 존재한다.
예를 들어 로깅, 보안, 트랜잭션과 같은 시스템 안에 포함되는 부가 기능을 이것도 하나의 책임으로 보고 분리하라는 것이다.
아래 그림과 같이 여러개의 모듈에서 자체적으로 로깅, 보안, 트랜잭션을 처리하고 있는데, 아무리 로깅과 같은 사소하고 부가적인 기능이라도 여러 모듈에 공통적으로 자주 이용된다면 책임 소지를 분리해 따로 클래스로 관리하라는 뜻이다.
이미 우리는 위에서 EmployeeManagement 클래스를 예시로 들어 로깅 기능을 분리하는 사례를 살펴봤을 텐데, 이것이 바로 산탄총 수술이라고 볼 수 있다.
즉, 부가 기능이라 할 지라도 산발적으로 여러곳에 불포된 책임들을 한 곳에 모으면서 응집도를 높이는 것이다.
참고