Home 의존성을 가급적 단 방향으로
Post
Cancel

의존성을 가급적 단 방향으로

글을 작성하게 된 계기


회사에서 한 서비스 레이어가 너무 많은 다른 서비스 레이어에 의존 하게 되었습니다. 처음에는 큰 문제가 없었지만, 요구사항이 복잡해지며 순환 참조까지 발생하게 되었고, 이 과정에서 서비스 간 의존성을 줄이는 방법 을 고민하며 알게 된 내용을 정리하기 위해 글을 작성하게 되었습니다.





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 { ...... }




포인트 적립과 고객 등급 업데이트, 이후의 알림 전송에 대한 요구 사항을 정리하면 다음과 같습니다.

  1. (어떤 행위로) 사용자 포인트가 적립된다.
  2. 포인트가 적립되면 고객 등급을 업데이트한다.
  3. 고객 등급이 업데이트되면 고객에게 알림을 전송한다.




의존성 방향을 그림으로 보면 다음과 같습니다. 이 때, 고객 등급을 업데이트하는 기준/로직이 변경되면, 포인트 적립 시 올라야 할 고객 등급이 변하지 않을 수도 있고, 고객에게 전송할 알림 문구가 변경될 수도 있죠. 서로 다른 도메인이 한 레이어에 존재 하기 때문에 사이드 이펙트를 파악하는데 시간도 걸리고요.

image





물론 한 코드의 수정이 다른 코드에 영향을 주는 것 자체가 문제가 되지 않는데요, 진짜 문제는 예측 불가능 한 상황이 발생할 수 있다는 점입니다. 의존성이 높아질수록 애플리케이션이 개발자의 예측 범위, 즉, 개발자의 통제를 벗어날 확률이 높아집니다. 개인적으로 좋은 시스템 이란 개발자가 코드를 꽉 잡고 있는, 개발자가 예측 가능한 통제 범위 내에 둘 수 있는 시스템 이라고 생각하는데요, 언제 어디서 어떤 문제가 발생할지 예측하기 힘들다면, 좋은 시스템이라고 말하기 힘들다고 생각합니다.

애플리케이션은 협업 을 통해 동작하기 때문에 의존성이 생기고 영향을 받는 것은 자연스러운 현상입니다.







2. 해결 방안


한 코드의 수정이 다른 코드에 미치는 영향을 최소화하려면, 모듈 간 의존성을 가급적 단방향으로 설계하는 것이 중요합니다. 단방향 의존성은 변경의 파급 효과가 한쪽 방향으로만 흐르도록 제어하여, 특정 코드 수정 시 고려해야 할 범위를 명확하게 제한합니다. 이를 통해 예측 가능 하며 유지보수가 용이한 시스템 을 구축할 수 있기 때문입니다.

image





의존성이 단방향일 때는 코드가 변경되더라도 어디서 문제가 발생했는지 파악하기가 용이해집니다. 당연히 이를 확인할 수 있는 테스트 코드 는 작성 돼 있어야겠죠. 테스트 코드가 작성 돼 있지 않다면 영향도 파악 자체가 불가능 할 수 있습니다.

image





다양한 방법이 있겠지만 연관된 도메인을 별도의 논리적 레이어로 분리, 이벤트 발행 두 가지 방법을 통해 의존성을 단방향으로 만드는 방법을 살펴보겠습니다.

  1. 연관된 도메인을 별도의 레이어로 분리하기
  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. 정말 해결된 것일까?


이렇게 의존성을 단방향으로 만들면, 서비스 간의 의존성이 줄어들고, 각 서비스 레이어가 독립적으로 동작할 수 있게 됩니다. 하지만 여전히 의존성 문제는 존재합니다.

  1. 의존성을 완전히 끊을 수는 없다
  2. 복잡해지는 시스템



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)
    }
}





또한 패키지 간 의존성 도 여전히 존재합니다. 패키지 의존성이 존재할 경우, 한 쪽 코드가 변경되면 다른 패키지에도 영향을 주기 때문에 서비스가 복잡해질 경우, 변경의 영향도를 추적하기 힘들게 합니다.

image





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. 정리


회사에서 서비스가 커지면서 어떻게 하면 사이드 이펙트를 줄이고, 효과적으로 유지 보수할 수 있는지에 대한 고민이 참 많습니다. 취업 전, 확장성과 유지 보수성 이런 단어를 들을 때 와닿지 않았는데요, 실제 서비스를 운영하다 보니 정말 고민할 부분이 많더라고요. 그렇다고 시간이 촉박한데 이런 고민을 회사에서 할 수는 없습니다. 참 생각이 많아지는데, 또 재미있기도 합니다.


This post is licensed under CC BY 4.0 by the author.

인덱스에도 길이 제한이 있다?

글로벌 락이 없는 레디스의 노드 재분배: REBALANCING