Search
💼

비즈니스 로직에 대한 고찰

1. 시작하기에 앞서

현재 재직중인 회사에서 기본적으로 Layered Architecture (Clean Architecture)를 사용해 왔다. 지금까지 여러 서비스를 개발하고 유지보수해오면서, 어떻게하면 코드의 가독성, 확장성, 유지보수성을 높일 수 있는지에 대하여 많은 고민들을 해왔다.
사내 아키텍쳐 또한 팀원분들과 함께 논의하고 고민하며 지속적으로 점차 발전시켜 왔는데, 결국 돌고 돌아 항상 귀결되는 주제는 비즈니스 로직, 도메인 규칙 등을 어떻게 잘 관리할 것인가? 였다.
이를 위해 개인적으로 겪었던 시행착오와 경험들을 정리한 내용이다.

2. 비즈니스 로직을 어떻게 발전시켜왔는가?

2.1. Phase 1: 비즈니스 로직을 use case에 구현

회사에 입사했을 때, 처음에 주로 사용했던 방식이다. 전형적인 Transaction Script Pattern 의 방식으로 특정 비즈니스 로직이 유즈케이스 클래스에 구현된 구조이다.
class CreateGroupCallUseCase: def execute(self, dto: CreateGroupCallDto) -> UseCaseOutput: def __private_method_1(): def __private_method_2(): def __private_method_3():
Python
복사
Transaction Script Pattern 은 하나의 트랜잭션으로 구성된 로직을 단일 함수 또는 단일 스크립트에서 처리하는 구조를 말하는데, 구현방법이 매우 단순해 개발하기 쉬운 장점이 있다. 그러나, 로직의 복잡도가 높아지면 코드의 가독성이 떨어지고 유지보수가 더욱 어려워진다.
서비스가 고도화됨에 따라 Transaction Script Pattern 의 단점을 뼈저리게 체감하였다. 일부 유즈케이스마다 반복적으로 구현되는 공통 로직이 발생하게 되었는데, 해당 로직들이 특정 유즈케이스의 private method로 구현되어있어서 다른 유즈케이스에서 참조하기 어려운 상황이 발생하였고, 이러한 공통적인 로직의 책임을 어떠한 유즈케이스가 갖고 있을지에 대한 기준도 모호한 상황이었다.
예를 들면, 현재는 Deprecated 된 사내 서비스 중 라이브 서비스가 있었다. 단순히 아프리카, 트위치 등 1:N 관계의 라이브 스트리밍 서비스였는데, 여기서 시청자(Guest)가 참여중인 방을 나가는 로직이 다음과 같이 여러 유즈케이스에서 중복 구현되는 경우가 있었다. 만약 Guest가 참여중인 방을 나가는 로직에 변경점이 발생할 경우, 연관된 유즈케이스 모두 찾아서 하나하나 수정해야하는 발생하였다.
예시
LeaveGroupCallUseCase
Guest가 직접 방을 나가는 경우
TerminateGroupCallUserUseCase
Guest가 알 수 없는 이유 (예. 네트워크 이슈) 등으로 방에서 나가진 경우 논리적으로 방나가기 처리
CreateGroupCallPunishmentUseCase
관리자 제재를 통해 Guest가 방에서 강퇴당하는 경우
이는 결국, 개발자들의 생산성을 저하시키고 유지보수를 더욱 어렵게 만드는 등 많은 문제점을 야기하였다. 그 뿐만 아니라, Clean Architecture 및 객체 중심 프로그래밍(OOP)를 지향한다고 하나, Transaction Script Pattern 은 상당히 절차지향적인 코드에 불과했다.
또한, Clean Architecture의 핵심이 되어야하는 Entity는 단순히 Repository Layer → Service Layer(Use case Layer) 간의 데이터를 전달하기 위한 자료구조에 불과했다. (Database의 Model (Table)과 1:1 맵핑된 구조의 역할만 수행)
Summary Architecture
Transaction Script Pattern
구현방법이 단순하여 개발에 용이
use case에 모든 business logic이 구현되어있음
use case class의 private method로 구현
Flow
view/controller use case repository
아쉬운 점
core business logic이 use case에 highly dependant
usecase 마다 일부 core logic이 중복으로 구현됨
유지 보수의 어려움 → 생산성 저하
상당히 절차 지향적인 코드
Entity가 단순 자료구조로 사용

2.2. Phase 2: 재사용되는 use case를 service로 분리

이에, 공통적으로 사용되는 비즈니스 로직들을 담당하는 서비스 레이어를 만들고 로직들을 관리하기 시작하였다. 그 결과, 재사용되는 코드들이 특정 유즈케이스에 종속되는 경우가 거의 사라졌고, 테스트하기가 더욱 쉬워졌으며 유지보수하기에 용이해지는 긍정적인 결과를 낼 수 있었다. 그러나 서비스 레이어의 책임과 역할에 대해 조금 더 깊게 고민하지 않은 채 공통 비즈니스 로직만 분리하다보니 의도하지 않은 몇가지 사이드 이펙트가 나타나기 시작하였다.
예를 들어, 라이브 방을 종료하는 로직을 담당하는 TermniateGroupCall 이라는 메서드가 있었다. 방이 종료될 때 수행야하는 비즈니스 로직의 복잡도가 상당했다. 그렇다보니 해당 로직만 수행하는데 약 코드가 500줄 가량되었고, 이를 특정 서비스에 두기엔 너무 크다고 판단되어 별도의 서비스 클래스를 생성하게 되었다.
GroupCallService@TerminateGroupCallTermniateGroupCallService
서비스 클래스를 다이어트되었다고 처음에 좋게 생각하였지만, 이러한 케이스가 여러번 반복되다보니 서비스 레이어가 도메인 별로 분리되지 않고 특정 기능 단위로 분리되는 경우가 많아졌다.
뿐만 아니라, 유즈케이스 → 프레젠테이션 레이어로 응답을 전달할때 사용할때 UseCaseOutPut 을 사용했었는데, 동일한 형태로 ServiceOutPut 을 도입하다보니 코드의 복잡도가 증가하는 경우가 생겨났다. Output 은 성공/응답 여부를 포함한 인터페이스인데, 해당 서비스를 사용하는 사용처(유즈케이스)에서 에러 응답을 핸들링하다보니, 불필요한 분기문들이 생기고 코드가 지저분해졌다.
예시 코드
# 특정 유즈케이스의 함수 def __terminate_group_call( self, dto: CreateAdminPunishmentAdminDto ) -> Union[UseCaseOutput, bool]: res = self.__terminate_gc_service.execute( dto=TerminateGroupCallServiceDto( group_call_id=dto.group_call_id, host_id=dto.target_user_id, status=GroupCallStatusEnum.TERMINATED_BY_ADMIN, reason=dto.reason, ) ) # 서비스 레이어의 응답에 실패가 난 경우, 별도로 분기문을 통해 에러 핸들링을 해줬어야함. if res.is_fail(): if res.failure_type == FailureType.ALREADY_DONE_ERROR: return True self.__logger.error(f" {res.failure_description}, dto: {dto}") return res.build_failure() return True
Python
복사
그리고 아래의 예시처럼 서비스 레이어로 분리한 공통 로직중 아주 일부만 다르게 사용하고 싶은 경우도 발생했다.
1번 서비스 메서드
A,B,C,D라는 총 4가지의 로직 수행
신규 유즈케이스에서 1번 서비스 메서드 중 A,B,C 만 필요하고 D 대신 E라는 로직이 필요한 경우 발생
결정 방향에 대한 고민
A,B,C,E 라는 로직을 담당하는 별도의 서비스 메서드를 생성해야하나?
1번 서비스 메서드에서 E도 수행하게 하고 D와 E중 어떤 로직을 동작시킬지를 외부에서 결정하게끔 해야하나?
그외에도 서비스 레이어 input/output Dto가 상당히 많이 생겼고, 엔티티 클래스에도 일부 도메인 규칙을 담당하는 메서드를 만들다보니 서비스 클래스와 엔티티 클래스가 담당하는 도메인 규칙들을 어떻게 구분할건지에 대한 고민들이 깊어졌다.
결과적으로 공통적으로 재사용되는 코드들을 서비스 레이어로 분리함으로써, 공통 로직에 대한 유지보수는 용이해졌지만, 서비스 레이어에 대한 책임과 역할을 어떻게 정의할건지에 대한 고민과, 도메인 규칙/정책을 어떻게 정의하고 관리할건지에 대한 논의가 충분하지 않았다.
자연스레, Domain Driven Design (DDD)에 대한 갈증과 목소리가 팀 내부적으로 나오기 시작했다.
Summary Architecture
Transaction Script Pattern을 탈피하여 Domain Modeling Pattern의 일부를 도입하기 시작
서비스 레이어를 별도로 분리하여 공통 로직 재사용
Flow
view/controller use case service repository
아쉬운 점
도메인 규칙과 정책을 어떻게 정의하고 관리할건지에 대한 고민과 논의가 충분치 않았음

2.3. Phase 3: 모든 비즈니스 로직을 service로 구현

Phase 2에서 생긴 사이드 이펙트들을 조금씩 개선해가나던 찰나에 해당 아키텍처가 적용된 라이브 서비스를 종료하기로 회사에서 결정이 났다. 이후 전사적인 정책 방향에 따라 새로운 모놀리틱 플랫폼을 구축하게 되었고, 엔지니어링 조직 내부에서 어떻게 아키텍처를 정의할 건지에 대한 고민들을 나눴고, 기존의 아키텍처와는 상당히 다른 형태의 아키텍처가 나왔다.
우선 엔티티 중심의 클린 아키텍처를 제거하였다. 클린 아키텍처가 좋은 아키텍처인건 맞으나, 하나의 API를 개발하는데도 불필요하게 많은 작업들이 선행되어서 개발 속도가 느려, 빠른 개발 프로세스를 추구하는 회사의 방향성과 조금 적합하지 않을 수도 있겠다는 의견들이 나왔다. 마찬가지로 DDD도 진입장벽이 높고 개발 속도의 측면에서 반대의 의견들이 나와 채택되지 못하였다.
이에 클린 아키텍처를 조금 걷어내고 3 Layered Architecture 기반의 형태를 취해서 조금더 간결하고 빠르게 개발할 수 있는 형태를 갖고 가려고 노력했다. 레이어간의 응답/요청은 심플하게 DTO를 사용하였다. 새로운 플랫폼은 Nest.js + Prisma 기술스택을 사용했는데, entity 기반의 TypeORM과 달리 Prisma가 단순히 Type Alias 형태의 자료구조만 반환하는 형태를 취하고 있어서 우리 아키텍처와도 적합하였다.
또한, 유즈케이스 레이어는 완전히 제거하고 서비스 레이어만 사용하였다. 서비스 레이어의 각 로직들은 순수 함수를 기반으로 부수효과없이 동일한 output이 나오게끔 집중하였고, 최대한 원자적으로 쪼개서 서비스간에 비즈니스 로직들을 잘 호출해서 조합할 수 있게끔 형태를 취하였다. 그 외에도 3 Layered Architecture에서는 객체지향스럽게 클래스를 쓰기에는 적합하지 않다고 판단되어 서비스 레이어를 함수의 형태를 가지게 되었다. Pure Functional Programming을 사용한게 된건데, Typescript와도 궁합이 잘 맞았던 것 같다.
이를 통해, 좀 더 빠른 호흡을 바탕으로 개발을 진행할 수 있었고 이전 Phase에서 나왔던 여러 사이드 이펙트들을 개선할 수 있었다. 하지만 여전히 몇가지 이슈들은 존재했다. 어딜가나 Silver Bullet은 없다는 생각이 들었다.
유즈케이스와 서비스가 합쳐지다보니 어떠한 함수가 정말 Core한 도메인 로직을 갖고 있는지가 알기가 어려웠다. 그렇다면 여기서 말하는 Core한 도메인 로직은 도대체 무엇일까? 어떤게 Core 도메인 로직이지? 와 같은의문으로 이어지기도 했다. 또한 작업자들 마다 요구사항을 바라보는 관점이 달라 일관성 있지 않은 서비스 로직들이 나오기 시작했고, 특정 서비스 모듈이 너무 비대해지는 경우도 생겨났다. (한 모듈이 2000줄, 3000줄 되는 경우)
Summary Architecture
Pure Functional programming
3 Layered Architecture
Flow
controller service repository
아쉬운 점
유즈케이스와 서비스 레이어의 병합으로 인해 Core 비즈니스 로직을 알기 어려움
작업자마다 도메인을 바라보는 생각들이 다름
서비스 모듈이 방대해짐

3. 비즈니스 로직을 어떻게 잘 관리할 것인가?

3.1. Application(Service) & Domain Layer

Domain Modeling Pattern을 보면 서비스 레이어가 크게 2가지로 나뉘게 된다.
Application Layer
Domain Layer
각 레이어가 하는 책임과 역할을 나누면 다음과 같다.

3.1.1. Application Layer

클라이언트 / 요구사항에 대한 명세를 정의
도메인 레이어를 호출, 조합하는 계층
비즈니스 로직을 가지고 있지 않음
각 작업을 도메인 계층으로 위임
문제 해결을 위한 보조적인 기능을 도메인 계층으로 부터 분리
트랜잭션 관리
외부로의 데이터 전달
저장
조영호님의 Rich Domain Model
애플리케이션의 경계
애플리케이션 로직
도메인 로직의 재사용성을 촉진
예시
도메인 객체를 사용
도메인 객체를 조회, 저장
// service layer public Order cancelOrderService(Long orderId){ Order order = orderRepository.findById(orderId); // domain layer의 코어 비즈니스 로직 order.cancel(); OrderRepository.save(order); return order; }
Java
복사

3.1.2. Domain Layer

도메인의 Core Business Logic을 구현
도메인의 상태 정보
도메인 규칙
외부 특정 기술이나 구현 의존성을 최대한 피함
도메인 로직은 Application 로직에 의존하면 안됨
참고 레퍼런스

3.1.3. Conclusion

Phase 3. 모든 비즈니스 로직을 service로 구현 에서 나왔던 “어떤 서비스 로직이 Core한 도메인 로직인지를 알기가 어렵다” 라는 고민을 해결하기 위해 Application Layer와 Domain Layer에 대해 살펴봤지만 여전히 모호하고 의뭉스러운 부분들이 있었다.
외부 서비스의 의존성은 어떻게 제거하지?
Domain Layer와 Service Infra Layer(Repo Layer)의 의존성을 어떻게 제거?
단순 CRUD는 service layer?
어떤것을 정말 도메인 레이어로 봐아햐지?
도메인에 대한 분석을 깊게 해야함
근본적으로 계층이 추가/변경되는 경우
계층이 추가되거나 변경되기 위해 코드를 복사해야한다면 도메인 로직일 확률이 높다..? (무슨말일까)
도메인의 경계를 어디까지?
회원 가입 시, 각종 인증정보를 저장하고 스토어 데이터를 생성하는 것은 회원각입 도메인 레이어에 구현되어야 하는 작업인가? 혹은 어플리케이션 레이어에서 구현되어야하는 작업인가?
그렇다면 회원가입의 책임을 가진 도메인 레이어는 어디까지 책임을 가져야하나?
결과적으로 도메인 레이어의 경계와 기준을 구분짓기에 어렵다고 결론을 내렸다. 어떤 비즈니스 로직이 Core한 도메인 로직인가? 라고 질문했을 때 명확하게 답변할 수 있는 기준이 아직 우리에겐 없었다. 그리고 Domain Modeling Pattern에서 말하는 Application Layer와 Domain Layer는 그 중심에 Domain 객체(Entity)가 있는데 엔티티를 제거하고 순수함수의 형태로 비즈니스 로직을 구성하고 있는 사내 아키텍처에는 적합하지 않은 것도 큰 한몫을 했다.
그렇다면 다시 원점으로 돌아와 우리에게 필요한 것은 무엇인가에 대해 질문을 던졌을 때,
1.
비대한 서비스의 다이어트가 필요하다.
2.
작업자 마다 요구사항을 이해하고 비즈니스 로직을 구현하는데 관점과 시야가 다르다 → 도메인 규칙이 일관성 있지 않게 구현되어있다.
로 요약할 수 있었다. 이를 위해 도메인과 서브 도메인을 잘 나눈것이 중요하다는 것을 깨닫고 DDD의 전략적 설계를 적극 도입하기로 하였다.

3.2. DDD의 전략적 설계

DDD는 크게 2가지로 나뉜다.
전략적 설계
유비쿼터스 언어를 통해 도메인 지식을 공유/이해하여 Sync맞추고, 이를 바탕으로 개념과 경계를 식별하여여 바운디드 컨텍스트를 정의하여 경계의 관계를 컨텍스트 맵으로 정의 (고수준의 논리적 설계)
유비쿼터스 언어, 바운디드 컨텍스트, 컨텍스트 맵
전술적 설계
전략적 설계에서 도출된 개념과 바운디드 컨텍스트를 바탕으로 실제 구현을 진행하는것 (일종의 저수준의 구현)
애그리게이트 패턴, 엔티티, Value Object, 레토지토리, Domain Layer 등을 설계하고 구현
앞서 말한것처럼 DDD는 상당히 진입장벽이 높고 아키텍처를 구성하기 위한 난이도가 높은 편이다. 따라서 전술적 설계는 도입하지 않되, 기존의 우리가 가지고 있는 문제를 해결하기 위해 전략적 설계를 적극 도입했다. 전략적 설계를 어떻게 할 건지에 대한 방법으로 이벤트 스토밍 등이 있지만, 실효성이 크게 높진 않아 보였다.어떻게 잘 할 수 있는지에 대한 방법에는 사실 정답이 없다. 이벤트 스토밍 등이 있지만 크게 실효성이 높은 방법처럼 보이진 않았다.
결국 도메인을 누구보다 잘 이해하고 있는 사람들이 바로 “우리” 이기 때문에 우리가 “잘” 정의하고 “잘” 관리하면서, 우리가 세운 기준을 잘 지키면서 유지보수 및 개발을 하는 것이 중요하다고 생각했다.
기존에 우리가 갖고 있는 도메인들을 하나하나 전수로 살펴보면서 스스로 각 도메인과 서브도메인을 정의하고 경계를 설정하여 백엔드 개발 조직 내 개발자들이 도메인에 대한 sync가 잘되도록 진행하였다. 그리고, 설계 단계에서 부터 도메인에 대한 리뷰를 반드시 진행하도록 하고 (특히 신규 도메인이 나오는경우), 구현을 진행 시, 스켈레톤 및 인터페이스 리뷰를 받아서 도메인을 잘 구분하여 구현이 진행되는지를 리뷰하였다.
Core 도메인 로직에 대한 고민은 아직까지 현재 진행중이나 특정 서비스가 너무 비대해지거나 작업자마다 서로 다른 기준을 갖고 구현하여 비즈니스 로직이 일관성있지 않고 파편화되는 경우를 최소화 할 수 있었다.

4. 포스팅을 마치며

어플리케이션 디자인이란? - 변화에 대응하기 위해 어떻게 코드를 잘 배치할지를 고민 하는 것
나는 보통 개발자를 오랜 옛날의 수백만권의 책을 관리하는 도서관 사서로 비유하곤 한다. 도서관 사서로서 도서관 의 책들을 다양한 기준에 따라 배치하고 분류할 수 있다. 예를 들면, 국가별로 분류할 수도 있을 것이고, 알파벳 순으로도 분류할 수 있을 것이고, 연도, 분야 별로도 분류할 수도 있다. 그렇지만 어떠한 기준을 가지고 분류하고 관리하느냐에 따라 도서관 방문객이 원하는 책을 찾아가는데 드는 시간이 천차만별일 것이다. 그 뿐만 아니라, 제한된 공간속에서 앞으로 더 들어올 책들 까지 고려하면서 도서관의 책을 분류하고 구성하는것이 중요할 것이다.
우리가 개발하고 유지보수하는 서비스도 마찬가지라고 생각한다. 끊임없이 변하고 발전하는 요구사항에 맞춰 우리는 서비스를 잘 관리하고 발전시키는 것이 중요하다. 좋은 코드란, 코드의 Readability가 좋으며 Navigation하기 쉬우며 유지보수 및 테스트하기 쉬운 코드라고 생각한다. 좋은 코드를 작성하는데 왕도는 없다. 끊임 없이 지속적으로 유지보수하고 개선해나가면서 사이드 이펙트와 기술부채를 최소화하는 것이 중요하고, 그 가운데 핵심은 변화하는 비즈니스 로직을 어떻게 잘 관리하고 유지해나가는 것이라고 생각든다.