DDD에서 가장 중요하면서도 가장 쉽게 가볍게 다뤄지는 개념이 유비쿼터스 언어다. 많은 팀이 유비쿼터스 언어를 “용어집을 만드는 일” 정도로 이해한다. 물론 용어를 정리하는 것은 좋은 시작이다. 하지만 유비쿼터스 언어는 용어집보다 훨씬 더 깊은 개념이다.
유비쿼터스 언어는 도메인 전문가와 개발자가 함께 사용하는 언어다. 회의에서 쓰는 말, 기획서에 적힌 말, 테스트 이름, 클래스 이름, 메서드 이름, 이벤트 이름이 같은 방향을 향하도록 만드는 언어다.
좋은 설계는 좋은 언어에서 시작된다. 반대로 언어가 흐릿하면 설계도 흐릿해진다.
같은 단어가 같은 의미라고 믿는 순간
개발팀에서 자주 일어나는 일이다. 모두가 같은 단어를 쓰기 때문에 같은 것을 이해하고 있다고 착각한다.
예를 들어 구독 서비스에서 “해지”라는 단어를 생각해보자. 기획자는 “사용자가 더 이상 구독하지 않겠다고 신청하는 것”을 해지라고 부른다. 고객센터는 “사용자 요청으로 구독을 종료하고 환불까지 처리하는 것”을 해지라고 부를 수 있다. 결제팀은 “다음 결제가 발생하지 않도록 정기 결제를 중단하는 것”을 해지라고 생각할 수 있다. 개발자는 단순히 SubscriptionStatus를 CANCELED로 바꾸는 것으로 이해할 수도 있다.
모두가 “해지”라고 말하지만 실제로는 다른 것을 말하고 있다.
이 차이가 드러나지 않으면 코드는 모호해진다.
subscription.cancel();
이 메서드는 무엇을 의미할까?
즉시 이용 권한을 제거하는가? 남은 기간 동안은 계속 이용할 수 있는가? 환불을 요청하는가? 다음 결제만 중단하는가? 무료 체험 해지와 유료 구독 해지를 같은 방식으로 처리하는가?
이 질문에 답할 수 없다면 cancel()이라는 이름은 충분하지 않다. 이름이 짧아서 문제가 아니라, 도메인 언어가 아직 충분히 선명하지 않은 것이다.
유비쿼터스 언어는 번역을 줄인다
언어가 맞지 않으면 개발자는 계속 번역해야 한다.
도메인 전문가는 이렇게 말한다.
무료 체험 중인 사용자가 해지하면 즉시 이용 권한이 없어져요.
유료 구독자는 해지해도 현재 이용 기간이 끝날 때까지는 볼 수 있어요.
결제 실패가 반복되면 구독이 일시 중지돼요.
그런데 코드가 이렇게 되어 있다.
user.setType("N");
subscription.setStatus(3);
payment.updateFlag("F");
이런 코드는 도메인의 언어를 보존하지 않는다. 코드를 읽는 사람은 숫자와 플래그를 다시 도메인 용어로 번역해야 한다. 시간이 지나면 번역 규칙은 사람들의 머릿속에만 남고, 새로운 개발자는 코드를 이해하기 어렵다.
유비쿼터스 언어가 코드에 반영되면 번역이 줄어든다.
trialSubscription.cancelImmediately(reason);
paidSubscription.cancelAtPeriodEnd(reason);
subscription.suspendDueToPaymentFailures();
물론 실제 코드는 이보다 더 정교해야 할 수 있다. 중요한 것은 코드가 도메인의 말을 따라간다는 점이다. 코드를 읽는 것만으로도 도메인에서 어떤 일이 일어나는지 알 수 있어야 한다.
용어집은 시작일 뿐이다
유비쿼터스 언어를 만들기 위해 용어집을 작성하는 것은 좋다. 하지만 용어집을 만들었다고 유비쿼터스 언어가 만들어진 것은 아니다.
용어집에 “구독: 사용자가 특정 요금제를 이용하는 계약”이라고 적혀 있어도, 코드에 UserProductMapping, PayInfo, ServiceUseYn 같은 이름만 있다면 언어는 코드에 도달하지 못한 것이다.
유비쿼터스 언어는 다음 위치까지 흘러가야 한다.
회의에서 쓰는 말
기획서와 정책 문서의 용어
도메인 모델의 클래스 이름
메서드 이름
테스트 이름
도메인 이벤트 이름
API의 주요 개념
커밋 메시지와 리뷰 코멘트
예를 들어 도메인 전문가가 “구독이 갱신된다”고 말한다면, 코드에도 갱신이라는 개념이 드러나는 것이 좋다.
subscription.renew(paymentResult);
그리고 테스트도 같은 언어를 사용해야 한다.
결제가_성공하면_구독_기간이_다음_주기로_갱신된다
결제가_실패하면_구독은_결제_실패_상태로_전환된다
무료_체험_구독은_해지하면_즉시_이용_권한을_잃는다
이렇게 되면 테스트는 단순한 검증 코드가 아니라 도메인 규칙을 설명하는 문서가 된다.
좋은 이름은 책임을 드러낸다
유비쿼터스 언어는 단지 예쁜 이름을 짓는 일이 아니다. 좋은 이름은 책임을 드러낸다.
예를 들어 다음 이름들을 보자.
SubscriptionService
SubscriptionManager
SubscriptionProcessor
SubscriptionHandler
이 이름들은 많은 프로젝트에서 흔히 볼 수 있다. 하지만 무엇을 책임지는지는 잘 드러나지 않는다. Manager는 무엇을 관리하는가? Processor는 무엇을 처리하는가? Handler는 어떤 도메인 행위를 다루는가?
반대로 도메인 행위를 더 구체적으로 드러내면 코드의 의도가 선명해진다.
RenewSubscription
CancelSubscription
SuspendSubscription
ResumeSubscription
RefundSubscriptionPayment
반드시 모든 클래스를 이런 식으로 만들어야 한다는 뜻은 아니다. 중요한 것은 이름이 도메인 행위와 정책을 숨기지 않아야 한다는 것이다.
메서드 이름도 마찬가지다.
subscription.updateStatus(CANCELED);
이 코드는 기술적 변경을 말한다.
subscription.cancelAtPeriodEnd(reason);
이 코드는 도메인 행위를 말한다.
도메인 모델에서 좋은 이름은 구현 세부사항보다 도메인 의미를 먼저 드러낸다.
언어는 논쟁을 통해 선명해진다
유비쿼터스 언어는 처음부터 완성되지 않는다. 오히려 좋은 언어는 논쟁을 통해 만들어진다.
“해지”와 “취소”는 같은 말인가?
“환불 요청”과 “환불 완료”는 같은 상태인가, 다른 사건인가?
“구독 만료”는 사용자가 한 행위인가, 시간이 지나면서 발생한 사건인가?
“일시 중지”와 “이용 제한”은 같은 개념인가?
이 질문들은 단순한 단어 선택 문제가 아니다. 단어가 달라지면 모델도 달라진다. 모델이 달라지면 코드의 책임과 경계도 달라진다.
예를 들어 처음에는 모든 종료를 cancel()로 표현할 수 있다.
subscription.cancel();
하지만 대화를 거치다 보면 서로 다른 개념이 드러날 수 있다.
subscription.cancelAtPeriodEnd(reason);
subscription.expire(now);
subscription.suspendDueToPaymentFailure();
subscription.terminateByAdmin(reason);
이 메서드들은 모두 “구독이 더 이상 정상적으로 유지되지 않는다”는 결과를 만들 수 있다. 하지만 도메인 의미는 다르다. 의미가 다르면 이름도 달라져야 한다.
코드가 언어를 왜곡하기도 한다
유비쿼터스 언어는 도메인 전문가의 말을 코드에 그대로 복사하는 일이 아니다. 때로는 코드가 도메인 대화를 더 선명하게 만들기도 한다.
예를 들어 기획서에는 “사용자가 구독을 취소한다”고 적혀 있을 수 있다. 개발자가 구현하려고 보니 취소에는 두 종류가 있다.
하나는 결제 전에 신청을 철회하는 취소다. 다른 하나는 이미 시작된 유료 구독을 다음 갱신일부터 중단하는 해지다. 또 다른 하나는 결제 후 환불까지 동반하는 환불 처리다.
개발자는 질문해야 한다.
여기서 취소는 아직 시작되지 않은 구독을 없애는 건가요?
이미 시작된 구독을 중단하는 건가요?
남은 기간 동안 사용 권한은 유지되나요?
환불은 항상 함께 발생하나요?
이 질문을 통해 기획서의 언어도 정제된다. DDD에서 개발자는 수동적으로 요구사항을 받는 사람이 아니다. 개발자는 모델을 만드는 사람이고, 모델을 만들기 위해 언어를 다듬는 사람이다.
유비쿼터스 언어는 Bounded Context 안에서 유효하다
한 가지 더 중요한 점이 있다. 유비쿼터스 언어는 전체 회사에서 항상 하나의 의미를 가져야 하는 언어가 아니다. 언어는 경계 안에서 일관되어야 한다.
예를 들어 “상품”이라는 단어를 보자.
전시 컨텍스트에서 상품은 고객에게 보여줄 이름, 이미지, 설명, 노출 상태가 중요하다. 주문 컨텍스트에서 상품은 주문 가능한 대상인지, 주문 당시 가격이 얼마였는지가 중요하다. 재고 컨텍스트에서 상품은 창고에서 관리되는 품목과 수량이 중요하다. 정산 컨텍스트에서 상품은 공급가, 수수료율, 정산 기준이 중요하다.
회사 전체에서 “상품”이라는 단어를 하나의 의미로 통일하려 하면 오히려 모델이 망가질 수 있다. 중요한 것은 각 컨텍스트 안에서 언어가 일관되는 것이다.
따라서 유비쿼터스 언어는 Bounded Context와 함께 이해해야 한다. 같은 단어가 다른 의미로 쓰인다면 그것은 언어가 틀렸다는 신호가 아니라, 모델의 경계를 찾아야 한다는 신호일 수 있다.
실무에서 시작하는 방법
유비쿼터스 언어를 만들기 위해 거창한 절차가 필요한 것은 아니다. 다음과 같은 작은 실천으로 시작할 수 있다.
첫째, 도메인 전문가가 반복해서 사용하는 단어를 기록한다. 특히 상태, 행위, 정책을 설명하는 단어를 주의 깊게 본다.
둘째, 모호한 단어에 질문을 던진다. “취소”, “완료”, “처리”, “승인”, “정산” 같은 단어는 거의 항상 더 자세한 의미를 갖고 있다.
셋째, 코드 이름을 도메인 언어에 맞춘다. process, handle, update, changeStatus 같은 이름이 도메인 행위를 숨기고 있다면 더 구체적인 이름을 찾는다.
넷째, 테스트 이름을 도메인 문장으로 쓴다. 테스트 이름은 팀이 도메인 규칙을 어떻게 이해하고 있는지 보여준다.
다섯째, 언어가 바뀌면 코드도 바꾼다. 도메인 전문가와의 대화에서 더 좋은 이름을 발견했다면, 기존 이름에 익숙하다는 이유만으로 낡은 이름을 유지하지 않는다.
마치며
유비쿼터스 언어는 DDD의 장식이 아니다. 유비쿼터스 언어는 도메인 모델의 재료다.
좋은 언어가 없으면 좋은 모델도 만들기 어렵다. 모호한 단어는 모호한 책임을 만들고, 모호한 책임은 모호한 코드를 만든다. 반대로 좋은 언어는 도메인의 차이를 드러내고, 그 차이를 코드 구조로 옮길 수 있게 해준다.
DDD에서 설계는 이름 짓기와 깊게 연결되어 있다. 이름은 단순한 라벨이 아니다. 이름은 우리가 도메인을 어떻게 이해했는지 보여주는 압축된 모델이다.
다음 글에서는 이 언어가 항상 전체 시스템에서 하나의 의미를 가져야 하는 것은 아니라는 점을 다룬다. 모델은 경계 안에서만 일관된다. 그 경계가 바로 Bounded Context다.