프로그래밍에서 사용되는 응집도과 결합도에 대해 살펴보자
참고로 좋은 아키텍처는 높은 응집도와 낮은 결합도를 추구한다.
응집도
관련 요소가 얼마나 한 모듈에 모여 있는가를 나타낸다.
메서드, 함수 수준부터 크게는 모듈 수준에 이르기까지 모든 수준에서 응집도를 판단할 수 있다.
몇 가지 기준을 대입해서 생각해보면 응집도를 판단하는데 도움이 된다.
- 관련 코드가 한 패키지(또는 한 모듈)에 모여 있는가?
- 관련 코드가 한 클래스에 모여 있는가?
- 관련 코드가 한 함수에 모여 있는가?
관련 코드가 한 패키지에 모여 있는가?
카드가 등록되고 등록 결과를 SMS에 전송하는 기능이 필요하다고 하자.
만약 카드가 등록되고 SMS 전송이 필요하니 SMS 전송기능이 card 패키지안에 있다면 응집도가 낮다고 볼 수 있다.
통지 전송 관련 기능이 card 패키지 안에 존재하기 때문이다.
card
ㄴ CardService
ㄴ CardDao
ㄴ SmsNotifier // NoticeDao 를 사용함
notifier
ㄴ NoticeDao
ㄴ ...
이 경우 notifier 관련 기능이 notifier 패키지에 모여 있지 않아 notifier(sms) 관련 기능 수정이 필요한 경우 card 패키지 수정도 필요하게 된다. 응집도를 높이기 위해서는 SmsNotifier를 notifier 패키지 하위로 이동시켜야 한다.
관련 코드가 한 클래스에 모여 있는가?
구분되는 기능은 서로 다른 곳에 분리되어야 한다.
카드 등록, 삭제, 변경, 인증 관련된 기능이 CardService 에 존재하는 것보다는 기능별로 클래스를 분리하는 것이 좋다.
CardService 에 모든 기능을 구현하게 되면 목적이 다른 코드가 한 클래스에 뒤섞여 응집도를 떨어트린다.
CardService
- 카드 등록
- 카드 정보 수정
- 카드 삭제
- 카드 인증
------- 기능별 분리 -------
CardRegistreationService
- 등록
CardDeletionService
- 삭제
CardCertificationService
- 인증
관련 코드가 한 함수에 모여 있는가?
fun registerCard(request: CardRegistrationRequest): RegisteredCard {
if (request.cardNo.isBlank()) {
throw IllegalArgumentException("cardNo는 필수")
}
if (request.userNo.isBlank()) {
throw IllegalArgumentException("cardNo는 필수")
}
val key = repository.genereateKey()
val saveRequestCard = SaveRequestCard(key, ...)
return repository.save(saveRequestCard)
}
카드 저장 관련 코드가 메소드에 포함되어 있어 응집도가 높다고 생각할 수 있지만 코드 수준에서 서로 다른 역할을 하는 코드가 섞여 있다.
필수값을 검증하는 코드인데, 서로 다른 역할을 하는 코드는 별도의 메소드를 분리하면 가독성을 높이면서 응집도까지 높일 수 있다.
fun registerCard(request: CardRegistrationRequest): RegisteredCard {
validate(request)
val key = repository.genereateKey()
val saveRequestCard = SaveRequestCard(key, ...)
return repository.save(saveRequestCard)
}
fun validate(request: CardRegistrationRequest) {
if (request.cardNo.isBlank()) {
throw IllegalArgumentException("cardNo는 필수")
}
if (request.userNo.isBlank()) {
throw IllegalArgumentException("cardNo는 필수")
}
}
왜 응집도를 높여야 하는가?
- 역할에 따라 클래스가 분리되면 클래스 길이가 줄고, 메서드 단위로 작성되기 때문에 가독성이 좋아진다.
- 기능을 변경할 때 수정할 범위가 줄어든다.
구성요소가 역할에 따라 분리될수록 소프트웨어를 수정해야 할 변경 범위도 좁아진다. 응집도가 높아질수록 구성 요소를 수정하려는 이유도 하나로 줄어든다.
- 서비스를 분리하여 응집도를 높인 경우
- 카드 등록관련 수정이 필요하면 CardRegistrationService를 수정한다. 카드 인증관련 수정이 필요하면 CardCertificationService를 수정한다. 즉, 두 클래스를 수정하려는 이유는 각각 하나씩이다.
- 기능을 분리하여 메소드를 추출한 경우
- validation 을 추가하려면 validate 메소드만 수정하면 된다.
클래스를 수정하려는 이유는 하나이다. 어디서 들어본 말 아닌가?
단일 책임 원칙(SRP)에서는 하나의 모듈은 변경의 이유가 오직 하나뿐이어야 한다고 하였다. 응집도가 높아지면 단일 책임 원칙을 따를 가능성이 올라간다.
참고) 2023.10.17 - [프로그래밍 노트/아키텍처] - [클린 아키텍처] 3장. 설계 원칙 - 좋은 아키텍처를 정의하는 원칙(SOLID)
결합도
결합도는 소프트웨어 모듈이 서로 의존하는 정도를 말한다.
한 요소를 수정할 때 다른 요소도 함께 수정이 필요하다면 두 요소 간 결합도가 높다고 표현한다.
수정할 대상이 많아지면 코드 분석과 수정에 필요한 시간이 증가한다. 다시 말해, 결합도가 높아지면 유지보수 비용이 증가한다. 따라서 수정 비용을 낮추기 위해서는 응집도는 높이고 결합도는 낮춰야
한다.
응집도가 높다고 해서 반드시 결합도가 낮아지는 것은 아니다. 응집도를 높이고 결합도를 낮추려면 구성 요소 간 상호 작용을 최소화해야 한다. (상호 작용을 최소화하려면 구현에 대한 의존을 줄여야 한다.)
캡슐화를 사용하면 구현을 감춤으로써 두 구성 요소 간의 상호 작용을 줄여주어 응집도를 높이면서 동시에 결합도를 낮출 수 있다.
아래 방법들을 사용하여 결합도를 낮출 수 있다.
1. 추상화 타입을 사용
class CardRegistrationService(
private val jdbcTemplate: JdbcTemplate,
...
){
fun registerCard(request: CardRegistrationRequest): RegisteredCard {
validate(req)
val card = SaveRequestCard(...)
saveCard(card)
sendNotiSms(card)
}
// sms_send 테이블에 데이터를 저장하면, 별도 에이전트가 데이터를 조회해서 SMS 를 발송하는 방식으로 동작한다.
private fun sendNotiSms(card: SaveRequestCard) {
val content = "등록 완료되었습니다."
jdbcTemplate.update("insert into sms_send (....) values (...)", card.cardName, card.cardImageUrl)
}
}
sendNotiSms() 메서드는 SMS 발송을 위해 sms_send 테이블이라는 구현에 직접 의존하고 있는데 이렇게 되면 아래와 같은 상황에서 CardRegistrationService 클래스를 함께 변경해야 한다.
- LMS로 발송하기 위해 insert 쿼리에 LMS_YN 컬럼을 추가해야 함
- 문자가 아닌 알림톡으로 발송하기 위해 저장 테이블을 변경해야 함
- 테이블 삽입이 아닌 API 호출하는 방식으로 변경필요할 때
카드 등록 서비스에 통지 구현
이 포함되어 있기 때문에 통지 구현을 변경하면 카드 등록을 다루는 CardRegistrationService변경이 필요하게 된다. 이는 통지와 카드 등록 프로세스가 밀접하게 결합이 되어있기 때문이다.
추상화로 이 강결합을 낮출 수 있는데, SMS 문자 통지 기능을 Notifier 타입으로 추상화해서 분리할 수 있다.
Notifier<<Interface>> <- SmsNotifier (구현체)
// sms_send 테이블에 직접 접근하지 않고 간접적으로 Notifier 타입을 통해 접근한다.
class CardRegistrationService(
private val jdbcTemplate: JdbcTemplate,
private val notifier
...
){
fun registerCard(request: CardRegistrationRequest): RegisteredCard {
validate(req)
val card = SaveRequestCard(...)
saveCard(card)
notifyTo(card)
}
private fun notifyTo(card: SaveRequestCard) {
notifier.notifyTo(card)
}
}
Notifier 실제 구현체는 CardRegistrationService 객체를 생성할때 전달된다.
val notifier: Notifier = SmsNotifier()
val registrationService = CardRegistrationService(jdbcTemplate, notifier, ...)
이렇게 되면 통지를 변경할때 SmsNotifier 클래스만 수정하면 된다. 만약 통지 수단을 알림톡으로 변경해야하는 경우 알림톡용 구현체를 CardRegistrationService생성자에 전달하면 된다. 물론 CardRegistrationService 는 수정할 필요가 없다.
val notifier: Notifier = AlaramTalkNotifier()
val registrationService = CardRegistrationService(jdbcTemplate, notifier, ...)
2. 이벤트를 사용
class CardRegistrationService(
private val jdbcTemplate: JdbcTemplate,
...
){
fun registerCard(request: CardRegistrationRequest): RegisteredCard {
validate(req)
val card = SaveRequestCard(...)
saveCard(card)
Events.raise(new CardRegisteredEvent(card)) // 이벤트 발행
}
}
class CardEventListener {
fun handle(event: CardRegisteredEvent) {
// SMS 전송을 한다.
}
}
추상화 타입을 사용해서 결합을 낮춘 경우에도 CardRegistrationService클래스는 Notifier 인터페이스에 의존하고 있었다.
반면에 이벤트를 사용한 코드에서 CardRegistrationService클래스는 더 이상 통지에 대한 코드에 의존하지 않는다. CardEventListener 또한 카드 등록 관련 코드가 없다. 단지 CardRegisteredEvent 를 수신하면 통지할 뿐이다.
참고) 2023.08.22 - [프로그래밍 노트/도메인 주도 설계(DDD)] - [도메인 주도 설계] 이벤트 활용하기
결합도를 낮추기 위해 추상화 타입을 중간에 위치시키거나 이벤트를 사용하면 두 코드를 직접 연결하지 않고 간접적으로 연결하게 된다. 이는 결합성을 낮추는 장점이 있지만 직접 연결된 코드에 비해 코드를 분석하는 데 더 많은 노력이 들어간다는 단점이 존재한다. (그럼에도 불구하고 결합도를 낮추는 것이 유지보수성측면에서 좋다는 개인적인 의견이 있다.)
따라서 추상화나 이벤트를 적요할 때는 결합도 감소, 응집도 증가, 변경 비용 감소, 테스트 가능성 등 얻을 수 있는 이점을 따져봐야 한다.
참고) 육각형 개발자
'프로그래밍 노트 > 아키텍처' 카테고리의 다른 글
[클린 아키텍처] 5장. 아키텍처 (0) | 2023.11.11 |
---|---|
[클린 아키텍처] 4장. 컴포넌트 원칙 (4) | 2023.11.09 |
[클린 아키텍처] 3장. 설계 원칙 - 좋은 아키텍처를 정의하는 원칙(SOLID) (1) | 2023.10.17 |
[클린 아키텍처] 객체 지향 프로그래밍 (0) | 2023.09.04 |
[클린 아키텍처] 행위, 아키텍처 두가지 가치에 대하여 (0) | 2023.08.28 |