들어가며
해당 프로젝트를 통해 앱스토어나 구글 플레이스토어에서만 구매가 가능했던 구독형 VOD OTT 상품을 고객에게 합리적인 가격으로 제공할 수 있게 되었답니다.
아이들나라 엔지니어링 팀은 고객이 원하는 기능을 제때 전달하는 것 뿐만 아니라 아래의 내용에도 집중하고 있습니다.
•
시스템 내에서 중요한 역할을 수행하는 레거시 시스템과의 통합
•
제어하기 힘든 외부 의존 시스템의 내재화 혹은 의존성 제거
이러한 것들을 함께 고민하며 아이들나라를 이용하는 소중한 고객분들의 불편함을 줄여나가고 새로운 기능을 가능한 빠르게 전달할 수 있게 되는 것입니다.
이번 글에서는 아이들나라 아이들나라 플랫폼팀이 subscription 프로젝트를 진행하며 시도했던 시스템 레벨의 의존성의 제어 방법들을 소개하려 합니다
의존성이란?
소프트웨어 시스템에서는 의존성 dependency 은 중요한 키워드 중 하나입니다.
우리는 결국 하나의 완성된 소프트웨어를 개발하기 위해서는 여러 소프트웨어 혹은 기술에 대해 의존하게 됩니다.
가령 우리가 웹 애플리케이션을 개발하기 위해서 Spring 이라는 프레임워크에 의존하여 여러 기술들을 학습하고 실무에 적용하게 되죠.
이러한 의존성은 우리가 작성하는 소스 코드에서 발견될 수 있고, 실행되는 도중에 연산에 의해 발견될수도 있으며 눈에 보이지 않지만 여러 복잡한 관계에 의해 논리적으로 의존하게 될 수도 있습니다.
코드레벨에서 일반적으로 의존성은 다음과 같이 표현됩니다.
위 그림을 통해 알 수 있듯이 우리는 X 라는 컴포넌트가 Y 에 의존하고 있다고 이야기합니다.
이 말은 다시 말하자면 Y 가 변경될 때 X 도 그 변경에 대한 영향을 함께 받게 되는 것이지요.
만약 X 를 A, B, C 라는 컴포넌트가 의존한다고 가정해보겠습니다.
그럼 Y 에 변경에 따라 X 가 변경되고, X 의 변경에 따라 A, B, C 가 변경되는 마치 도미노 게임에서 선두에 있는 도미노가 쓰러지는 광경을 목격할 수 있습니다.
여기서 의존성에 대한 중요한 키워드가 존재합니다. 바로 변경에 대한 영향입니다.
의존성은 우리의 소프트웨어 개발에서 없어서는 안되지만 그만큼 잘 제어해야 쉽게 깨지지 않고 신뢰할 수 있는 소프트웨어가 탄생하게 되는 것입니다.
아이들나라 플랫폼팀은 구독 시스템을 개발하며 이러한 의존성을 제어하기 위해 크게 3가지 포인트에 집중하였습니다.
1.
경계 만들기
2.
번역하기
3.
레거시 통합하기
하나씩 사례를 통해 알아보도록 하겠습니다.
1. 경계 만들기
첫번째로 제안하는 방법은 바로 경계 만들기 입니다.
첫째로 의존성을 잘 제어하기 위해서는 경계를 잘 설정해야 합니다.
MVC 나 MVVM, Present & Container 그리고 layered architecture 나 hexagonal architecture 도 모두 경계를 설정하기 위한 다양한 방법들입니다.
위의 방법은 코드 레벨에서 경계를 나누는 것이고 이 개념을 아키텍처로 그대로 확장하면 의존성에 대해서 더 상위 개념에서 제어가 가능하게 되는 것입니다.
아키텍처 기술을 의존성 관점에서 본다면 모두 각각의 모듈 혹은 레이어가 관리하기에 충분히 작은 책임만을 갖고 그 책임에 주어진 역할을 충실하게 책임지게 됩니다.
경계는 서비스의 규모와 방향에 따라서 하나의 경계로 존재할 수도, 여러개의 세부 경계로 존재할 수도 있습니다.
경계 만들기 - 어떻게 경계를 인식할 것인가?
첫번째는 경계를 인식하는 것에서부터 시작됩니다.
경계 인식 1. 변화의 시점과 흐름이 다릅니다.
변화는 비즈니스의 결정사항 혹은 시스템의 요구사항에 시스템의 전반에 걸쳐 일어나게 됩니다.
이러한 변화는 그 주체가 같을 수도 있지만 대부분의 경우 변화의 주체가 다릅니다. 변화의 주체가 다르다면 그들은 서로 다르게 인식되어야 합니다.
우리가 SRP 를 이야기하는 것도 동일한 이유이지요.
경계 인식 2. 두번째 인식은 바로 일관성입니다.
일단 경계를 통해서 구획을 나누게 되면, 해당하는 구획 내에서 발생하는 일들은 구획 내에서 해결되어야 합니다.
경계 내부에서 일어나는 일들에 대해서는 그들의 상호작용을 통해 일관된 행동은 물론 데이터 또한 일관성 consistency 을 갖도록 해야합니다.
그래서 일반적으로 경계의 주인이 되는 개념이 (Subscription, Order, User) 경계 내의 일관성을 책임지는 핵심 도메인 혹은 엔티티가 될 수 있습니다.
경계 만들기 - 어떻게 경계를 다뤄야 하는가?
경계 다루기 1. 경계는 일관성을 책임져야 하기 때문에 그 경계가 명확해야 합니다.
코드는 물론 사용하는 언어도 명확해야 하고, 코드를 처음 마주하는 사람이 보더라도 경계에 대해 인식할 수 있어야 합니다.
또한 우리는 평생 한 코드만 작성하고 관리하지 않습니다.
언젠간 다른 동료들이 내가 작성한 코드를 유지보수하거나 신규 기능을 추가할 수 있기에, 경계는 명확할 수록 좋습니다.
또한 경계를 대표하는 하나 혹은 여러개의 핵심 도메인 객체가 일관성을 책임질 수 있도록 충분히 작게 설계가 되어야 합니다.
만약 구독에서 Subscription 이라는 한 경계 내부에서 Billing 과 Payment 모두를 책임지고 있다면, 그만큼 관리해야 하는 일관성의 범위가 늘어나게 되고 결국 관리에 어려움을 겪게 될 수 있습니다.
경계 다루기 2. 경계는 지속적으로 관리되어야 합니다.
경계는 public interface 이기도 합니다.
한번 만들어지게 되면 다른 경계와 상호작용을 합니다.
상호작용을 한다는 것은 이 경계가 해야할 책임에 대해서 명확하게 인지하고 올바른 기능을 제공해야 합니다.
어떻게 보면 우리가 Continuous Integration 을 하는 이유도 외부로 노출된 경계에 대한 기능 보장을 지속적으로 하는 것도 이에 포함될 수 있습니다.
경계 만들기 - subscription 으로 알아보는 경계 나누기
이제 실제 아이들나라 구독 시스템에서 나누었던 경계를 한 번 확인해보겠습니다.
구독 시스템은 다음과 같은 usecase 를 포함합니다
•
유저는 상품을 구독할 수 있다.
•
구독은 서비스 이용 권한을 나타낸다
•
구독은 서비스 이용시 주기적으로 청구가 발생한다
•
청구가 발생하면 정기 결제가 수행된다.
•
청구에 실패하면 n 번의 유예 기간을 제공한다
각각의 usecase 는 서로 다른 actor 에 의해 동작하게 되고 각각이 가져야할 책임이 명확하기 때문에 다음과 같이 경계를 나눕니다.
팀은 Subscription 과 Billing 그리고 Payment 의 변화의 시점이 다르다고 일관성을 적용해야할 규칙이 다르다고 규정하였습니다.
3가지의 경계는 다음과 같은 일을 수행할 수 있습니다.
이렇게 3가지의 경계로 나눴다면, 그 경계 내부에서 일어나는 일들은 내부에서 일관성을 책임져야 합니다. 그 일관성을 책임지기 위한 여러가지 기법중 앞선 블로그에서 게시되었던 Aggregate 를 중심으로 데이터 저장하기 도 같은 맥락입니다.
경계의 일관성을 책임진다는 것을 쉽게 이야기하자면 이렇습니다.
Billing 경계에서 Subscription 의 경계 내부에 존재하는 데이터를 쉽게 조작하거나 삭제해서는 안됩니다.
Billing 에서 원하는 무언가가 존재한다면 Subscription 에서 그 요구사항을 일종의 계약관계를 통해서 제공해야합니다
경계 만들기 - 정리
경계를 만든다는 것은 간단하지만 많은 노력이 필요합니다.
보통 경계를 만드는 것은 한번에 일어나지 않습니다.
기획 단계부터 서서히 발견되는 도메인의 지식들과 요구사항들의 합과 현재 시스템의 상태 그리고 정해진 리소스까지 모두 고려가 되어야 합니다.
당장은 큰 경계를 구성하고 추후 경계 내부의 세부 경계들을 하나의 새로운 경계로 도출할 수 있도록 준비하는 과정이 있을 수도 있고, 복잡한 요구사항 뒤에 숨어있는 더 큰 복잡성을 발견하여 작은 경계를 세분화 할 수도 있습니다.
•
경계는 의존성을 제어하기 위한 첫번째 step
•
경계를 나누는 것
◦
변화의 시점에 따라 바라보기
◦
일관성을 책임질 수 있는 충분히 작은 단위로 바라보기
•
경계를 관리하는 것
◦
명확한 범위의 경계와 확장을 위한 경계 내부의 새로운 경계 만들기
◦
continuous integration 를 이용하여 지속적인 기능의 보장 & 경계의 일관성 보장
◦
경계와 경계 사이에는 계약 관계가 존재
경계 안에서 주의해야할 점
경계는 경계의 밖에서 일어나는 일들 때문에 구획 내에 존재하는 모델의 초점이 흐려지거나 혼란스러워져서는 안됩니다.
즉, 경계 밖의 지식이 경계 내부로 무분별하게 들어오지 않도록 조심해야 합니다.
2. 번역하기
앞서 경계를 나누면서 주의해야할 점이라고 이야기했던 것이 있습니다.
즉, 경계 밖의 지식이 경계 내부로 무분별하게 들어오지 않도록 조심해야 합니다.
일단 경계를 나눴다면 서로 독립된 모듈처럼 각개전투가 가능해야 합니다.
이론과 현실은 늘 다르죠 ㅎㅎ.. 우리가 개발하는 소프트웨어는 여러 경계들에 걸쳐 하나 이상의 기능을 수행하게 되므로 쉽지 않습니다.
결국 우리는 경계 외부와 잘 공생할 수 있는 환경을 만드는 것이 중요합니다.
이를 위해서 팀은 경계와 경계 사이에 위치하며 각각의 경계를 넘나들며 경계 내부의 문맥에 맞도록 데이터를 제공해주는 번역 계층 translation layer 를 만들었습니다.
번역하기 - Context, 경계 내부의 문맥
번역 계층을 이해하기 전에, 먼저 문맥 Context 에 대한 이해가 필요합니다.
문맥을 이해하기 위해서 제가 다른 선배 개발자와 나눴던 이야기를 인용해보려 합니다.
선배: 1 + 12 는 몇일까?
나: 13이요
선배: 아니야. 1 + 12 는 1이야.
Plain Text
복사
선배는 저에게 1 + 12 가 무엇이냐는 간단한 질문을 했지만 저는 틀렸습니다.
저는 산술 연산이라는 문맥 위에 있었고, 선배는 시간이라는 문맥 위에 있었기 때문입니다.
이렇듯 문맥은 어떤 상황이나 환경 속에서 사물, 사건, 개념 등을 이해하고 해석하는 데에 영향을 주는 요소들의 모음입니다. 간단히 말해, 문맥은 어떤 것을 이해하거나 해석할 때 그것이 일어나는 배경이나 관련된 조건들을 의미합니다.
경계 내부는 외부와 완전 독립된 새로운 세계입니다.
즉, 서로 다른 문맥을 가지고 있을 수 있다는 것이지요.
어느 한 시스템이 User 라는 개념의 정보들을 가지고 있다면 모든 경계에서 User 는 동일한 User 로 인식될 수 없습니다.
특정한 경계에서는 User 의 인증 정보가 필요할 수도 있고, 다른 경계에서는 User 의 주소 정보가 필요할 수도 있습니다.
결국 특정 경계가 지니고 있는 문맥이라는 것이 존재하고, 그것에 따라 경계 내부의 세계가 정해지기 때문이죠.
이제 구독 시스템으로 예를 들어보겠습니다.
public class Subscription {
private final User user;
private final ProductItem productItem;
}
Java
복사
위와 같은 클래스가 존재할 때, Subscription 은 User 와 ProductItem 이라는 다른 두개의 경계와 접해있습니다.
Subscription 의 경계에서는 User 가 어떤 방식의 인증방식과 어떤 종류의 암호화 알고리즘을 통해 비밀번호를 생성해내는지 알 필요가 없습니다.
Subscription 이 원하는 User 는 단지 식별 가능한 누군가와, 구독 상품을 결제하기 위한 프로모션 정보들만 필요로 할 수도 있겠죠.
User 가 이런 정보들을 다 모르고 있어도 좋습니다. 어디선가 조합해서 Subscription 이라는 경계 내부로 전달하면 되니까요.
이것이 바로 번역 계층이 하는 일 입니다.
번역하기 - Translation Layer, 번역 계층
번역 계층은 경계와 경계 사이에 위치하여 경계 바깥 세상과 내부 세상을 연결하는 통로 역할을 수행합니다.
public class Subscription {
private final User user;
private final ProductItem productItem;
}
class User {
Long id;
String username;
String password;
SnsType snsType;
}
Java
복사
번역 계층에서는 위와 같이 경계가 모호한 것들을 Subscritpion 문맥에 필요한 정보들로 조합해주는 곳입니다.
public class Subscription {
private final SubscribingUser user;
private final SubscribableProductItem productItem;
}
class User {
Long id;
Promotions userOwnPromotions;
ExperiencedSubscriptions experiencedPromotions;
}
class ProductItem {
// productItem 도 동일하게 Subscription 문맥에 필요한 정보들을 가지고 있다
}
Java
복사
User 자체를 DB 에 존재하는 테이블의 Entity 로 바라보면 쉽게 납득하기 어렵습니다. 우리가 일반적으로 사용하는 ORM 이라는 도구를 사용해 영속성 장치에서 꺼낸 entity 는 field 와 1대1이기 때문이죠.
그럼 이 말은 꼭 영속성과 도메인을 분리해야한다 혹은 hexagonal architecture 이나 microservice 를 해야한다는 말일까요?
그렇지 않습니다.
DIP 를 활용한다면 쉽게 이러한 구조를 만들 수 있습니다.
위 그림처럼 우리가 interface 를 의존하여 실제 구현에 의존하지 않도록 하는 DIP 를 이용하면 쉽게 구현할 수 있습니다.
DIP 를 코드 레벨에서 모듈 level 로 올린것과 다름 없습니다.
public interface SubscribingUser {
long getId();
Promotions getPersonalizedPromotions();
ExperiencedSubscriptions getExperiencedPromotions();
}
public class SubscribingUserFinder {
public SubscribingUser findBy(long userId) {
// 다른 경계와의 조합 혹은 영속화된 유저 조회
return new SimpleSubscribingUser(...);
}
}
public class SimpleSubscribingUser implements SubscribingUser {
// impl
}
Java
복사
이러한 과정들을 우리의 선배 개발자들은 이미 경험하였고 정리하였습니다 GoF 의 디자인 패턴에서 이러한 구조와 행위들을 Adapter Pattern 이라고 부르고 있습니다
Adapter pattern
아이들나라의 웹 구독 시스템에서는 이렇게 경계에 위치한 다른 경계의 객체 혹은 컴포넌트들을 모두 dependency 라고 부릅니다.
모듈을 domain 모듈은 오로지 이 dependecy 만 의존하고, 실제 이 경계에 대한 번역은 dependency-impl 라는 모듈에서 수행하도록 되어있습니다.
사실상 번역 계층이란 dependency 의 implementation layer 인 셈이죠
번역하기 - 정리
이렇게 경계를 나누고 경계를 위한 참조 객체들을 새롭게 정의하는 행위는 외부 세상의 변화 흐름과 독립적으로 나아갈 수 있게 합니다.
외부의 변화는 제어하지 못하지만 적어도 구독이라는 세상에서는 유저의 저장 방법이 바뀌거나, 상품의 금액 체계가 바뀌더라도 문제가 되지 않습니다.
구독 입장에서는 어떤 유저가 어떤 개인화된 프로모션을 가지고 있고 어떤 상품 의 sale price 가 얼마인지에만 관심이 있기 때문이죠.
외부 세상이 변경되면 adapting 하는 그 곳까지만 변경을 전파하도록 하는 것입니다
•
경계에는 문맥(Context) 라는 것이 존재
◦
문맥은 바라보는 관점에 따라 다르게 해석될 수 있음
◦
경계 안의 문맥과 경계 외부의 문맥은 다를 수 있음
•
경계외부의 것을 내부로 가져오기 위해서는 누군가가 번역을 해줘야 함
◦
번역 계층이 존재함
◦
의사가 수술실에 들어가기 전 무균실에서 무균 장갑과 수술 가운을 입는 것과 동일한 곳
•
adaptive 한 구조로 경계사이의 번역 계층을 구현
◦
번역계층의 핵심은 객체지향의 DIP
◦
GoF 의 adapter pattern 을 이용하여 구현할 수 있음
3. 레거시 통합하기, Legacy Integration
레거시 시스템은 고통스럽지만 중요합니다.
지금까지 서비스를 성장시키기 위해 지탱하는 핵심 코어 기술들과 로직이 존재하며 이들 덕분에 우리가 개발하는 소프트웨어가 발전하기 위한 시간을 벌어주기도 합니다.
물론 레거시 시스템에 발목이 잡히기도 합니다만 완벽히 레거시 시스템을 제거할 수 없다면 우리는 그들과 잘 지내는 공생관계를 만들어야 합니다.
레거시 통합하기 - 레거시 지식이 경계 내부로 스며들지 않도록
레거시 시스템은 여러 경계에 걸쳐 다양한 이해관계자들과 상호작용하는 살아있는 코드입니다.
우리는 앞선 방법을 포함하여 여러 장치를 통해 새로운 프로젝트를 진행하면서 레거시 시스템이 가진 지식이 경계 내부로 스며들지 않도록 주의해야 합니다.
이 말은 즉, 다음과 같은 상황이 있다고 가정해보겠습니다
웹 구독이 발생하면, 핵심 비즈니스 로직은 다음과 같습니다.
public class SubscribeService {
private final SubscriptionFactory factory;
private final SubscriptionRepository repository;
public void subscribe(SubscribeCommand command) {
Subscription subscription = factory.create(command);
subscription.subscribe();
repository.save(subscription);
}
}
Java
복사
하지만 레거시 시스템을 지원하기 위해서 다음과 같은 요구사항이 있다고 해보겠습니다
•
기존에 존재하는 인앱 구독 조회 정보를 지원
•
vod 시청 권한을 부여하는 권한 등록자에게 권한 등록을 요청해야한다
•
admin page 에서 구독 결제 이력들을 보기 위해 레거시 영수증 데이터베이스 테이블 동기화
그럼 다음과 같이 서비스가 변경될 것입니다.
public class SubscribeService {
private final InAppView view;
private final VodAuthRegistrar vodRegistrar;
private final RegacyReceiptRepository regacyReceiptRepository
private final SubscriptionFactory factory;
private final SubscriptionRepository repository;
public void subscribe(SubscribeCommand command) {
// 위의 참조 객체들이 필요한 정보를 조합하여 상호작용
}
}
Java
복사
문제는 Service 가 협력하는 객체가 많아지는 것이 아닙니다.
협력하기 위한 행위에서 외부 지식이 핵심 서비스 내로 스며들게 되는 것입니다.
결국 원하는 것은 다음과 같이 data-integration 을 수행하는 하나의 객체를 두는 것입니다.
public class SubscribeService {
private final SubscriptionFactory factory;
private final SubscriptionRepository repository;
private final DataIntegrator dataIntegrator;
public void subscribe(SubscribeCommand command) {
// 핵심 비즈니스 로직 수행 후 integration 요청
dataIntegrator.integrate(...);
}
}
Java
복사
끝에서 도메인 행위의 결과로 반환된 데이터들을 잘 조합하여 downstream 으로 보내 이후 어떤 일이 발생하는지도 모르게 하는 것입니다.
혹은 domain event 를 활용하여 핵심 로직상에서 DataIntegrator 라는것 자체도 없앨 수도 있겠지요.
팀은 구독 시스템을 개발하면서 이처럼 레거시 시스템 지원을 위해한 레이어를 데이터 통합 레이어 (data-integration-layer) 이라고 칭하기로 하였습니다.
레거시 통합하기 - 데이터 통합 레이어는 Facade 이다.
데이터 통합 레이어는 구독의 정상적인 비즈니스 로직 하위 말단에 위치합니다.
위 구조를 잘 보면 DataIntegrator 라는 컴포넌트는 역시 GoF 에서 소개된 Facade 패턴 처럼 보입니다.
facade 패턴을 이용한다면 경계 내부에서는 데이터를 통합해야한다는 사실만을 알게 되고 실제 행위 자체를 숨길 수 있습니다.
결국 레거시 시스템과 통합을 위한 data-integration-layer 는 정상 비즈니스 로직의 처리가 끝난 후 레거시 지원을 위한 데이터만 sourcing 하는 역할말 수행합니다.
위 컨셉을 이용한다면 다른 경계와의 통합도 가능해집니다.
data-integrator 자체가 facade 이므로 구현 자체는 여러가지 방법으로 수행될 수 있습니다.
대부분의 레거시 시스템은 우리가 개발하는 자원이 아니기 때문에 통신이 필요할 수도 있습니다.
동기적인 방법을 채택한다면 DB Connection 을 직접 생성하여 데이터를 업데이트할 수도 있을 것이고 HTTP 를 이용하여 기존에 존재하는 기능을 이용할 수도 있습니다.
동기로 발생하는 네트워크는 신뢰할 수 없다는 문제와 언제든지 지연이 발생할 수 있다는 것을 감안한다면 outbox table 이나 log tailing 과 그를 구현하는 aws dms 를 이용할 수도 있겠지요. (관련해서는 또한 팀에서 포스팅을 준비하고 있으니 지속적으로 아이들나라 블로그에 관심가져주세요 ㅎㅎ)
결국 핵심은 변하지 않습니다.
레거시 시스템의 지식이 경계 내부에 직접적으로 스며들지 않는 것입니다.
레거시 통합하기 - 정리
•
경계에 존재하는 시스템은 레거시 시스템과 필수적으로 상호작용 해야할 일이 생긴다
•
레거시 시스템의 지식이 경계 내부에 스며들지 않도록 주의해야 한다
•
레거시 통합은 facade 로 구현될 수 있다.
◦
결국 경계 내부에서는 레거시 시스템과 어떻게 통합해야 하는지 방법을 모른다
◦
레거시와 통합해야한다는 사실만을 알게 한다
•
레거시 통합을 잘 이용한다면 다른 경계와의 통합도 쉽게 가능해진다
◦
레거시 시스템도 다른 하나의 경계로 바라볼 수 있다
◦
data-integration-layer 는 경계 내부에서 발생한 사건을 외부로 전달하는것 뿐이다
끝으로
이렇게 의존성을 제어하는 3가지 방법에 대해서 알아보았습니다.
의존성을 잘 제어해야 특정 변경에 대해 연쇄적으로 그 변경이 전파되는 것을 막을 수 있고 변경에 유연한 소프트웨어가 만들어지게 됩니다.
어쩌면 이 방법들은 특정 상황에서 발견된 것들이기 때문에 글을 읽는 여러분들의 공감을 얻지 못하였을 수 있고 위의 방법들이 지엽적으로 보일 수 있습니다.
글을 쓰는 내내 강력한 어조로 이야기를 했지만 위의 내용들은 어느 상황이나 알맞게 적용할 수 없습니다. 어느곳이든 각자의 복잡성과 제한된 리소스가 있기 때문이죠.
위 내용은 변경에 취약하지 않는 구조를 위해 의존성을 잘 제어하기 위한 팀의 여러 시도들의 결과들일 뿐입니다.
긴 글 읽어주셔서 감사합니다