[OOP] 단일 책임 원칙, 응집도, 관심사
단일 책임 원칙, 응집도, 관심사
📌 서론
최근 조영호 선생님의 "객체지향의 사실과 오해"라는 책을 다시 읽었다.
확실히 다시 읽어보니 처음 읽었을 때와는 다른 느낌을 받을 수 있었다. 내 개발 지식이 조금 더 늘어서 그런 건지 해왔던 프로젝트가 책의 내용과 겹쳐서 보였다. 그래서 다시 읽은 후 코드를 바라보는 시야가 더 넓어진 것 같다는 느낌이 든다.
조영호 선생님이 작성해 주신 이 책에서는 "책임", "응집도"에 대한 얘기를 풀어가며 "단일 책임 원칙"을 설명해 주는데 나는 내 방식대로 고민해 본 것을 한번 정리해서 설명을 적어봤다.
또한 책에서는 역할은 인터페이스 책임은 메시지(메서드 명)로 표현한다. 이것을 보고 나는 그럼 관심사는 어떻게 표현할지 많이 고민해 보게 되었고 내부 로직(메서드)의 동작을 관심사로 표현하는 것이 아닐까 생각하여 정리해 봤다.
<개인적인 생각을 섞어서 적은 내용이라 잘못된 내용이 있을 수도 있습니다. 조언해 주신다면 얼른 수정하겠습니다!>
1. 단일 책임 원칙과 응집도란?
단일 책임 원칙(SRP)
단일 책임 원칙이란 하나의 클래스나 메서드가 오직 하나의 기능이나 책임만을 가져야 한다는 원칙을 말한다.
단일 책임 원칙을 메서드에 적용하면, 각 메서드는 하나의 명확한 기능만을 수행해야 한다. 이렇게 하면 메서드를 이해하고, 테스트하고, 재사용하기가 더 쉬워진다.
예를 들어, 사용자 관리 시스템에서 사용자 정보를 생성하고, 수정하는 기능이 한 클래스에 모두 들어 있을 때를 생각해 보자. 이 클래스는 사용자를 생성하는 책임과 사용자 정보를 수정하는 책임 두 가지를 가지고 있다고 할 수 있다. 하지만 단일 책임 원칙에 따르면, 이 두 기능은 서로 다른 책임을 가지고 있으므로 분리되어야 한다.
따라서, 사용자 생성을 담당하는 클래스와 사용자 정보 수정을 담당하는 클래스 두 개로 나누어 설계하는 것이 바람직하다. 이렇게 하면, 각 클래스는 자신의 책임에만 집중할 수 있으며, 추후 사용자 정보에 관련된 변경사항이 있을 때 유연하게 대응할 수 있다.
응집도
응집도는 한 모듈 내부의 요소들이 서로 얼마나 밀접하게 연관되어 있는지를 나타낸다. 응집도가 높다고 말하는 것은 모듈 내부의 모든 요소가 단일한 목적이나 목표를 위해 긴밀하게 작동한다는 뜻이다.
우리가 자주 사용하는 스프링 부트를 생각해 보면 컴포넌트(예: 서비스, 컨트롤러, 리포지토리 등)들은 각각의 독립된 기능을 담당하며, 이들 각각이 높은 응집도를 유지하는 것이 중요하다.
예를 들어, UserService라는 서비스 클래스는 사용자와 관련된 기능(사용자 조회, 등록, 수정, 삭제 등)만을 담당해야 하며, 이러한 기능들은 모두 사용자 관리라는 단일 목적에 집중돼야 한다.
단일 책임 원칙과 응집도
단일 책임 원칙을 지키며 응집도가 높은 컴포넌트를 만들기 위해서는 각 컴포넌트가 하나의 책임만을 가지도록 설계해야 한다. 이렇게 하면, 코드의 재사용성과 유지보수성이 향상되고, 변화에 더 유연하게 대응할 수 있다.
예를 들어, 사용자 정보를 데이터베이스에 저장하고 동시에 이메일을 발송하는 메서드가 있다고 생각해 보자.
이 메서드는 "데이터 저장", "이메일 발송"이라는 두 가지의 책임을 가지고 있다. 이를 개선하기 위해 데이터베이스 저장을 담당하는 메서드와 이메일 발송을 담당하는 메서드로 각각 분리할 수 있다. 이렇게 분리하면 각 메서드는 자신의 책임에 집중할 수 있게 되고, 응집도가 향상된다.
2. 응집도가 낮아지는 경우와 해결 방법
응집도가 낮아지는 경우 1
응집도가 낮아지는 경우는 주로 클래스나 메서드가 여러 책임을 가지게 될 때 발생한다.
예를 들어, 로그인 기능 내에서 사용자 인증 외에도 알림 보내기, 다른 기기 체크 등의 작업을 수행한다면, 이는 단일 책임 원칙을 위배하고 응집도를 낮추는 결과를 초래한다.
해결 방법
이런 경우, 로그인 과정 자체에만 집중하는 클래스나 메서드와, 알림을 보내거나 기기를 체크하는 등의 작업을 별도의 클래스나 메서드로 분리하는 작업을 해야 한다. 이렇게 하면 각 클래스나 메서드가 하나의 책임만을 가지게 되어, 응집도가 높아지고 유지보수나 재사용성 측면에서도 이점을 가질 수 있게 된다.
응집도가 낮아지는 경우 2
서비스가 사용자의 프로필 정보를 처리하는 기능을 가지고 있고, 이 기능 내에서 사용자의 사진을 업로드하고, 사용자의 설명을 수정하고, 사용자의 연락처 정보를 업데이트하는 기능이 모두 포함되어 있다고 가정해 보자. 이 경우, 이 서비스의 응집도는 상대적으로 낮다고 할 수 있다.
해결 방법
이를 해결하기 위해, 각 기능을 별도의 메서드로 분리하고, 이 메서드들이 모두 사용자 프로필 관련 책임만을 가지도록 설계한다면 응집도를 향상시킬 수 있다. 예를 들어, '사용자 사진 업로드', '사용자 설명 수정', '사용자 연락처 정보 업데이트'라는 세 개의 메서드로 분리하면, 각 메서드는 더 명확한 책임을 가지게 되고, 서비스 전체의 응집도도 향상된다.
📌 정리
단일 책임 원칙을 따르는 것은 한 클래스나 메서드가 하나의 기능에만 집중하도록 하여 응집도를 높이는 것이다.
로그인 기능을 예로 들면, 로그인 과정에 필요한 모든 작업은 포함되어야 하지만, 그 과정이 완료된 후 발생하는 부수적인 기능들(알림 보내기, 기기 체크 등)은 별도의 책임으로 보고, 다른 클래스나 메서드에서 처리하는 것이 적절하다.
이렇게 함으로써 각각의 컴포넌트가 더 명확하고, 관리하기 쉬우며, 재사용 가능한 코드를 작성할 수 있게 된다.
3. 관심사란 무엇인가?
관심사란?
"책임"은 객체나 클래스가 담당해야 하는 구체적인 행동이나 역할을 의미한다(메서드가 할 일). 반면, '관심사'는 프로그램의 다양한 기능이나 문제 영역을 가리키는, 좀 더 넓은 개념이다. 이 두 개념은 서로 밀접하게 연관되어 있기는 하지만, 세밀하게 살펴보면 차이점이 있다.
로그인 예시로 살펴보는 책임과 관심사의 분리
로그인 과정에서의 주요 책임은 "사용자 인증" 즉 사용자가 제공한 자격 증명이 유효한지 확인하고 결과에 따라 사용자를 시스템에 로그인시키거나 에러 메시지를 반환하는 것이다.
이 과정에서 로그인 로직을 담당하는 객체나 클래스는 사용자 인증이라는 구체적인 행동(메서드)과 역할(인터페이스)에 집중한다.
관심사는 여기서 한 발 더 나아간다. 로그인 과정의 주 관심사는 사용자 인증이지만, 로그인 성공 후 실행되어야 할 다양한 부가 작업들도 존재한다.
예를 들어, 로그인 성공 시 "로그 기록하기", 사용자의 마지막 "로그인 시간 업데이트하기", 로그인 위치 "이메일 발송하기" 등이 있다. 이러한 부가 작업들은 로그인 처리 과정과는 직접적인 관련이 없으며, 로그인 성공이라는 사실에 기반해 실행되는 것이다.
관심사와 비관심사 정리
이렇게 로그인이라는 하나의 과정에서도 여러 가지 관심사들이 존재한다는 것을 알 수 있었다.
로그인 기능은 꼭 실행되어야 하니(단일 책임 원칙) "주 관심사"라고 얘기하고 로그인으로 인해 발생하게 되는 나머지 부가 작업들(로그인 이외의 다른 책임)은 로그인이라는 중요한 작업이 처리된 후 실행되는 "비관심사"라고 할 수 있다.
4. 스프링 이벤트로 관심사를 분리하자
스프링 이벤트로 관심사 분리하기
스프링에서는 이벤트 기능을 사용해서 로그인 로직(책임)과 로그인 성공 후 필요한 부가적인 작업들(관심사)을 분리하여 관리할 수 있다.
이러한 접근 방식은 코드의 유지보수성을 높이고, 각각의 부분이 자신의 책임과 관심사에만 집중할 수 있게 하여 설계의 명확성과 재사용성을 향상하는 효과적인 방법이다.
그럼 어떻게 분리할까?
스프링의 이벤트 시스템을 사용해서 로그인 처리를 담당하는 클래스는 로그인 과정에만 집중할 수 있으며, 로그인 성공 후 필요한 부가적인 작업은 별도의 이벤트 리스너에서 처리되도록 하면 된다.
이러한 방식으로 각 클래스나 메서드는 하나의 책임(또는 관심사)만을 명확하게 가지게 되어, 코드를 더 명확하고 유지보수하기 쉽게 만들며 재사용성을 향상한다.
코드로 이해하기 (이벤트 발행 X)
- 이벤트 발행 없이 서비스 레이어에서 로그인 로직과 비관심사(로깅, 업데이트, 이메일 전송)를 모두 처리하는 방식은 다음과 같이 구현될 수 있다. 이 경우, AuthenticationService 클래스 내에서 로그인 성공 후 필요한 모든 작업을 직접 호출한다.
@RequiredArgsConstructor
@Service
public class AuthenticationService {
private final LoggingService loggingService;
private final UserService userService;
private final EmailService emailService;
public void authenticate(String username, String password) {
// 로그인 로직 처리 (주관심사)
// 여기서는 로그인 성공으로 가정
boolean loginSuccess = true;
// 비관심사를 메서드 내부에서 이어서 처리
if (loginSuccess) {
// 로그인 성공 로그 기록
loggingService.logSuccess(username);
// 사용자의 마지막 로그인 시간 업데이트
userService.updateLastLoginTime(username);
// 로그인 성공 이메일 전송
emailService.sendLoginSuccessEmail(username);
}
}
}
- 이 방법은 로그인 과정과 관련된 모든 로직이 AuthenticationService 내에 집중되어 있기 때문에, 모든 작업을 한 곳에서 관리할 수 있는 장점이 있다. 하지만, 이로 인해 AuthenticationService의 책임이 커지고, 로직이 복잡해져 유지보수와 테스트가 어려워진다. 또한, 다른 기능에서 비슷한 비관심사 작업(예: 로깅, 이메일 전송)이 필요할 때 코드의 중복 사용이 발생한다.
코드로 이해하기 (이벤트 발행 O)
- 먼저, 로그인 성공 시 발행할 이벤트를 정의한다. 이 이벤트에는 사용자 정보를 포함시켰다.
@AllArgsConstructor
@Getter
public class LoginSuccessEvent {
private final String username;
}
- AuthenticationService에서 로그인 성공 시 ApplicationEventPublisher를 사용하여 LoginSuccessEvent를 발행한다. 이 과정은 주 관심사인 로그인 로직 처리 후, 추가 작업을 위한 이벤트를 발행하는 것으로, 로그인 처리와 비관심사 작업을 분리한다.
@RequiredArgsConstructor
@Service
public class AuthenticationService {
private final ApplicationEventPublisher eventPublisher;
public void authenticate(String username, String password) {
// 로그인 로직 처리
// 여기서는 로그인 성공으로 가정
boolean loginSuccess = true;
// 스프링 이벤트 발행하기
if (loginSuccess) {
eventPublisher.publishEvent(new LoginSuccessEvent(username));
}
}
}
- 아래의 LoginSuccessListener에서 @EventListener를 사용하여 로그인 성공 시 발행된 스프링 이벤트(LoginSuccessEvent)를 수신하고, 로깅, 사용자 정보 업데이트, 이메일 전송 등의 비관심사를 처리한다. 이벤트 리스너를 사용함으로써, 비관심사 작업을 분리하여 관리할 수 있게 되며, 이로 인해 코드의 가독성과 유지보수성이 향상된다.
@Component
public class LoginSuccessListener {
// 이벤트가 발행되면 동작할 이벤트 리스너
@EventListener
public void onLoginSuccess(LoginSuccessEvent event) {
System.out.println("Login successful for user: " + event.getUsername());
// 추가적인 로그인 처리 로직 (로깅, 업데이트, 이메일 전송)
}
}
관심사 분리의 이점
1. 유지보수성 향상
각 부분이 자신의 책임에만 집중함으로써, 시스템의 다양한 부분을 독립적으로 수정하고 개선할 수 있게 된다. 이는 특히 크고 복잡한 시스템에서 유지보수 작업의 부담을 줄여준다.
2. 재사용성 증가
이벤트 리스너는 다른 콘텍스트에서도 재사용할 수 있으며, 새로운 이벤트에 대해서도 유사한 방식으로 쉽게 확장할 수 있다. 이는 코드의 재사용성을 크게 향상한다.
3. 결합도 감소
이벤트 기반 모델은 발행자와 구독자 간의 결합도를 낮춤으로써, 시스템의 각 부분을 독립적으로 변경할 수 있게 해 준다. 이로 인해 시스템을 유연하게 확장하고 수정할 수 있다.
4. 테스트 용이성
관심사의 분리는 테스트를 용이하게 한다. 각 컴포넌트를 독립적으로 테스트할 수 있으므로, 에러를 쉽게 식별하고 수정할 수 있다.
5. 스프링 이벤트로 관심사를 분리하면 SRP를 지킨 것일까?
결론
스프링의 이벤트 발행 기능을 사용해서 주 관심사(예: 로그인 처리)와 비관심사(예: 알림 보내기, 기기 체크 등)를 분리하는 것은 단일 책임 원칙을 잘 지키면서도 응집도를 높이는 훌륭한 실천 방법이다.
로그인 기능을 구현하는 클래스가 로그인 과정에만 집중할 수 있게 되고, 로그인 성공 후 필요한 부가적인 작업들은 이벤트 리스너들이 처리하게 됨으로써, 각 클래스나 메서드가 하나의 책임만을 가지게 된다.
이는 소프트웨어 설계에서 '책임'과 '관심사'의 분리를 모두 고려한 효과적인 접근 방식이라 할 수 있다.
이번 포스트는 여기까지다.
다음 포스트를 통해 스프링 이벤트로 관심사를 분리했을 때 스레드가 어떻게 동작하는지 정리하도록 하겠다.