글을 작성하게 된 계기
회사에서 한 서비스 레이어가 너무 많은 다른 서비스 레이어에 의존 하게 되었습니다. 처음에는 큰 문제가 없었지만, 요구사항이 복잡해지며 순환 참조까지 발생하게 되었고, 이 과정에서 서비스 간 의존성을 줄이는 방법 을 고민하며 알게 된 내용을 정리하기 위해 글을 작성하게 되었습니다.
1. 문제 상황
서비스 레이어가 다른 서비스 레이어에 의존하게 되면, 한쪽 코드를 수정했을 때, 다른 쪽 코드도 수정 해야 하는 상황이 발생할 수 있습니다. 예를 들어, 고객 포인트가 적립되며, 포인트 적립 후에는 등급을 업데이트하고, 등급이 변경되면 고객에게 알림을 전송해야 합니다. 이 과정에서 각 서비스가 서로 의존하게 되며, 한 서비스의 코드가 변경되면 다른 서비스의 코드도 변경해야 하는 상황이 발생할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
@Service
class PointCommandService(
private val pointQueryService: PointQueryService,
private val pointCommandRepository: PointCommandRepository,
// 다른 서비스 레이어에 의존
private val userQueryService: UserQueryService,
private val gradeQueryService: GradeQueryService,
private val notificationCommandService: NotificationCommandService
) : PointCommandUseCase { ...... }
포인트 적립과 고객 등급 업데이트, 이후의 알림 전송에 대한 요구 사항을 정리하면 다음과 같습니다.
- (어떤 행위로) 사용자 포인트가 적립된다.
- 포인트가 적립되면 고객 등급을 업데이트한다.
- 고객 등급이 업데이트되면 고객에게 알림을 전송한다.
의존성 방향을 그림으로 보면 다음과 같습니다. 이 때, 고객 등급을 업데이트하는 기준/로직이 변경되면, 포인트 적립 시 올라야 할 고객 등급이 변하지 않을 수도 있고, 고객에게 전송할 알림 문구가 변경될 수도 있죠. 서로 다른 도메인이 한 레이어에 존재 하기 때문에 사이드 이펙트를 파악하는데 시간도 걸리고요.
물론 한 코드의 수정이 다른 코드에 영향을 주는 것 자체가 문제가 되지 않는데요, 진짜 문제는 예측 불가능 한 상황이 발생할 수 있다는 점입니다. 의존성이 높아질수록 애플리케이션이 개발자의 예측 범위, 즉, 개발자의 통제를 벗어날 확률이 높아집니다. 개인적으로 좋은 시스템 이란 개발자가 코드를 꽉 잡고 있는, 개발자가 예측 가능한 통제 범위 내에 둘 수 있는 시스템 이라고 생각하는데요, 언제 어디서 어떤 문제가 발생할지 예측하기 힘들다면, 좋은 시스템이라고 말하기 힘들다고 생각합니다.
애플리케이션은
협업을 통해 동작하기 때문에 의존성이 생기고 영향을 받는 것은 자연스러운 현상입니다.
2. 해결 방안
한 코드의 수정이 다른 코드에 미치는 영향을 최소화하려면, 모듈 간 의존성을 가급적 단방향으로 설계하는 것이 중요합니다. 단방향 의존성은 변경의 파급 효과가 한쪽 방향으로만 흐르도록 제어하여, 특정 코드 수정 시 고려해야 할 범위를 명확하게 제한합니다. 이를 통해 예측 가능 하며 유지보수가 용이한 시스템 을 구축할 수 있기 때문입니다.
의존성이 단방향일 때는 코드가 변경되더라도 어디서 문제가 발생했는지 파악하기가 용이해집니다. 당연히 이를 확인할 수 있는 테스트 코드 는 작성 돼 있어야겠죠. 테스트 코드가 작성 돼 있지 않다면 영향도 파악 자체가 불가능 할 수 있습니다.
다양한 방법이 있겠지만 연관된 도메인을 별도의 논리적 레이어로 분리, 이벤트 발행 두 가지 방법을 통해 의존성을 단방향으로 만드는 방법을 살펴보겠습니다.
- 연관된 도메인을 별도의 레이어로 분리하기
- 이벤트 발행하기
2-1. 연관된 도메인을 별도의 레이어로 분리하기
연관된 도메인을 별도의 레이어로 분리하는 것은 연관된 두 도메인만 다루는 논리적 레이어를 만드는 것 을 의미합니다. 예를 들어, 포인트 적립과 등급 업데이트는 서로 연관된 도메인이기 때문에 이를 별도의 서비스 레이어로 분리할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
class PointGradeService(
private val pointQueryService: PointQueryService,
private val pointCommandService: PointCommandService,
private val gradeQueryService: GradeQueryService,
private val gradeCommandService: GradeCommandService,
) {
@Transactional
fun savePoint(command: PointSaveCommand): GradePointDto {
/**
* 별도의 연산 로직(생략)
* ......
*
* */
val savedPoint = pointCommandService.save(command)
val updatedGrade = gradeCommandService.updateGrade(command.userId)
return GradePointDto(
point = savedPoint,
grade = updatedGrade
)
}
}
즉, 포인트 적립과 등급을 업데이트하는 책임을 별도의 레이어로 분리하는 것을 말하는데요, 이는 포인트가 적립되면 등급을 갱신한다 는 특정 비즈니스 정책 자체를 하나의 응집도 높은 서비스로 캡슐화하는 것입니다. 이를 통해 기존의 PointCommandService나 GradeCommandService는 각자 자신의 핵심 도메인 책임에만 집중할 수 있게 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
class PointCommandService(
private val pointQueryService: PointQueryService,
private val pointCommandRepository: PointCommandRepository,
) {
@Transactional
fun save(command: PointSaveCommand): Point {
......
return pointCommandRepository.save(command.toEntity())
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
class GradeCommandService(
private val gradeQueryService: GradeQueryService,
private val gradeCommandRepository: GradeCommandRepository,
) {
@Transactional
fun updateGrade(userId: Long): Grade {
......
return gradeCommandRepository.update(userId)
}
}
이렇게 연관된 도메인들을 별도의 레이어로 분리한 후, 한 곳에서 조합해주면 각 서비스 레이어가 자신의 책임에만 집중할 수 있게 되며, 의존성을 단방향으로 만들 수 있습니다. 한 곳에서 각 레이어를 조합해야 할 경우, 퍼사드 패턴을 활용할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
class PointFacade(
private val pointGradeService: PointGradeService,
private val userQueryService: UserQueryService,
private val notificationService: NotificationCommandService
) {
@Transactional
fun savePointAndNotify(command: PointSaveCommand) {
val findUser = userQueryService.findById(command.userId)
val gradePoint: GradePointDto = pointGradeService.savePoint(command)
notificationService.send(findUser.id, gradePoint)
}
}
왜 퍼사드 레이어를 만든 후, PointGradeService와 같은 별도의 논리적 레이어 를 두는지 의문이 들 수 있는데, 이는 퍼사드 레이어가 비즈니스 로직을 가져서는 안 되기 때문입니다. 퍼사드에서 직접 비즈니스 로직을 구현하게 되면, 퍼사드가 서비스 레이어와 유사해지면서 계층 간 역할 구분이 모호해지죠.
- 퍼사드 레이어는 여러 유즈케이스를 조립하는 역할
- 서비스는 실제 비즈니스 로직 담당
2-2. 이벤트 발행
서비스 간 의존성을 낮추는 다른 방법으로 이벤트 발행 이 있습니다. 이벤트를 구독하는 다른 서비스들은 발행자를 직접 참조하는 대신 오직 이벤트의 발생 여부만 감지하여 독립적으로 동작하므로, 두 서비스 간의 직접적인 결합이 사라지고 의존성이 효과적으로 끊어지기 때문입니다. 단, 이벤트를 발행할 경우, 이벤트 유실, 중복 처리, 이벤트 순서 보장 등의 문제를 고려해야 할 수 있습니다. 이 부분은 주제를 벗어나므로 별도로 학습해 보실 것을 권장드립니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Service
class PointGradeService(
private val pointQueryService: PointQueryService,
private val pointCommandService: PointCommandService,
private val gradeQueryService: GradeQueryService,
private val gradeCommandService: GradeCommandService,
private val eventPublisher: ApplicationEventPublisher
) {
@Transactional
fun savePoint(command: PointSaveCommand) {
/**
* 별도의 연산 로직(생략)
* ......
*
* */
val savedPoint = pointCommandService.save(command)
val updatedGrade = gradeCommandService.updateGrade(command.userId)
val event = PointSavedEvent(
userId = command.userId,
point = savedPoint,
grade = updatedGrade
)
eventPublisher.publishEvent(event)
}
}
3. 정말 해결된 것일까?
이렇게 의존성을 단방향으로 만들면, 서비스 간의 의존성이 줄어들고, 각 서비스 레이어가 독립적으로 동작할 수 있게 됩니다. 하지만 여전히 의존성 문제는 존재합니다.
- 의존성을 완전히 끊을 수는 없다
- 복잡해지는 시스템
3-1. 의존성을 완전히 끊을 수는 없다
다음과 같이 Point와 Grade 서비스를 별도의 레이어로 분리했습니다. 하지만 여전히 PointGradeService는 다른 두 서비스 PointCommandService와 GradeCommandService에 의존하고 있습니다. 즉, 서비스 간의 의존성은 여전히 존재하는 것이죠.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
class PointGradeService(
private val pointQueryService: PointQueryService,
private val pointCommandService: PointCommandService,
private val gradeQueryService: GradeQueryService,
private val gradeCommandService: GradeCommandService,
) {
@Transactional
fun savePoint(command: PointSaveCommand): GradePointDto {
/**
* 별도의 연산 로직(생략)
* ......
*
* */
val savedPoint = pointCommandService.save(command)
val updatedGrade = gradeCommandService.updateGrade(command.userId)
return GradePointDto(
point = savedPoint,
grade = updatedGrade
)
}
}
만약 퍼사드 레이어를 사용할 경우, 여전히 다른 도메인을 알고 있는 문제도 있습니다. 물론 애플리케이션은 여러 객체의 협업 을 통해 동작하기 때문에 한 서비스가 다른 서비스에 의존하는 것은 자연스러운 현상입니다. 꼭 의존성을 끊어야 하는 것도 아니고요. 하지만 서비스 간 의존이 많을 경우, 사이드 이펙트 관리가 힘들 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
class PointFacade(
private val pointGradeService: PointGradeService,
private val userQueryService: UserQueryService,
private val notificationService: NotificationCommandService
) {
@Transactional
fun savePointAndNotify(command: PointSaveCommand) {
val findUser = userQueryService.findById(command.userId)
val gradePoint: GradePointDto = pointGradeService.savePoint(command)
notificationService.send(findUser.id, gradePoint)
}
}
또한 패키지 간 의존성 도 여전히 존재합니다. 패키지 의존성이 존재할 경우, 한 쪽 코드가 변경되면 다른 패키지에도 영향을 주기 때문에 서비스가 복잡해질 경우, 변경의 영향도를 추적하기 힘들게 합니다.
3-2. 복잡해지는 시스템
PointGradeService는 논리적 레이어 로, 팀원 간 협업을 위해 만들었습니다. 별도의 레이어를 만들면 시스템이 복잡해지고 이를 이해하기 힘들 수 있습니다. 기존에는 서비스 레이어 하나에서 해결하던 문제를 별도의 레이어를 추가해 처리하고 있으니까요. 따라서 문서화나 주석 등을 통해 각 레이어의 역할과 책임을 명확히 하고, 팀원 간의 협업을 원활하게 하는 것이 중요합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
class PointGradeService(
private val pointQueryService: PointQueryService,
private val pointCommandService: PointCommandService,
private val gradeQueryService: GradeQueryService,
private val gradeCommandService: GradeCommandService,
) {
@Transactional
fun savePoint(command: PointSaveCommand): GradePointDto {
/**
* 별도의 연산 로직(생략)
* ......
*
* */
val savedPoint = pointCommandService.save(command)
val updatedGrade = gradeCommandService.updateGrade(command.userId)
return GradePointDto(
point = savedPoint,
grade = updatedGrade
)
}
}
4. 정리
회사에서 서비스가 커지면서 어떻게 하면 사이드 이펙트를 줄이고, 효과적으로 유지 보수할 수 있는지에 대한 고민이 참 많습니다. 취업 전, 확장성과 유지 보수성 이런 단어를 들을 때 와닿지 않았는데요, 실제 서비스를 운영하다 보니 정말 고민할 부분이 많더라고요. 그렇다고 시간이 촉박한데 이런 고민을 회사에서 할 수는 없습니다. 참 생각이 많아지는데, 또 재미있기도 합니다.