읽기 쉬운, 누수 방지 API와 제로 비용 추상화
Source: Dev.to
위의 링크에 있는 전체 텍스트를 제공해 주시면, 해당 내용을 한국어로 번역해 드리겠습니다.
Source:
Writing code provides everyday challenges
우리가 직면했던 도전 중 하나는 백엔드 트레이드‑서비스 라이브러리를 설계할 때였습니다.
이 라이브러리는 사용자가 거래를 예약할 수 있는 API를 제공합니다. 아래 이야기는 처음에는 어댑터와 빌더에 데이터를 어떻게 전달할지라는 기계적인 설계 문제처럼 보였지만, 곧 더 깊은 도메인 및 경계 문제로 드러난 사례를 보여줍니다.
라이브러리는 사용자가 다른 엔터티와 거래를 생성할 수 있는 API를 노출합니다. 내부적으로는 여러 하위 서비스에 대한 I/O 호출을 조정하고, 결과를 집계한 뒤 최종 응답을 생성합니다. 각 하위 의존성은 자체적인 데이터 형태를 요구하므로, 들어오는 사용자 입력은 시스템을 흐르면서 반복적으로 변환되어야 합니다.
우리의 초기 접근 방식은 전통적인 것이었습니다: 어댑터와 빌더를 사용해 사용자 입력을 각 하위 서비스가 요구하는 구조로 변환합니다. 겉으로 보기엔 간단해 보였지만, 실제로는 계약의 좁음과, 더 중요한 경계‑컨텍스트 위반에 대한 반복적인 설계 긴장을 드러냈습니다.
The problem
- 사용자에게 직접 노출되는 기본 입력 객체인
TradeInformation은 거의 100개의 필드를 포함하고 있습니다. - 특정 어댑터가 일반적으로 필요로 하는 필드는 10개 미만에 불과합니다.
TradeInformation을 어댑터에 직접 전달하는 것은 메서드 시그니처를 작고 선언적으로 유지한다는 점에서 “깨끗해 보입니다”. 그러나 이 깨끗함은 착각을 일으킵니다.
도메인‑주도 설계(Domain‑Driven Design) 관점에서 보면, 이 접근 방식은 여러 경계‑컨텍스트를 하나로 압축합니다. 어댑터의 실제 도메인은 작고 구체적이며—추상적인 “거래” 전체가 아니라 특정 통합이나 워크플로와 관련된 명확히 정의된 거래 데이터의 부분집합에만 작동합니다. 방대한, 전역적인 객체를 전달하면 어댑터가 의도 여부와 관계없이 경계‑컨텍스트 밖의 정보를 접근할 수 있게 됩니다.
이로 인해 다음과 같은 세 가지 구체적인 문제가 발생합니다:
- 암묵적 의존성 –
TradeInformation이 시그니처 수준의 마찰 없이 계약을 조용히 확장합니다. - 경계‑컨텍스트 침식 – 컨텍스트가 서로 섞여 모델이 잡음으로 가득 차게 됩니다.
- 테스트 마찰의 증상 –
TradeInformation객체는 어댑터가 시스템의 훨씬 더 많은 부분에 결합되어 있음을 나타내는 신호입니다.
명백한 대안 평가
세 가지 가능한 빌더 API를 고려해 보세요:
// 1. 선언형이지만 오해를 일으킴
Deal buildDeal(const TradeInformation& request);
// 2. 가차 없이 명시적
Deal buildDeal(
int makerId,
int takerId,
double amount,
const std::string& tradeIdentifierInternal,
const std::string& tradeIdentifierExternal,
Venue::Enum tradingVenue,
const MakerDetails& makerDetails
);
// 3. “절충” 구조체
struct DealStruct {
int makerId;
int takerId;
double amount;
const std::string& tradeIdentifierInternal;
const std::string& tradeIdentifierExternal;
Venue::Enum tradingVenue;
const MakerDetails& makerDetails;
};
Deal buildDeal(const DealStruct&);
각각은 서로 다른 철학을 보여줍니다.
| 옵션 | 장점 | 단점 |
|---|---|---|
| 1. 선언형 | 호출 지점에서 보기 좋고, 짧은 시그니처. | 실제 의존성을 숨깁니다; 빌더가 실제로는 특정 투영만 필요함에도 “거래 정보”가 필요하다고 가장합니다. |
| 2. 명시적 | 의존성이 명확히 드러나며, 단위 테스트가 간단합니다. | 호출자는 도메인 추출 로직을 수행해야 하고, 긴 매개변수 목록이 도메인 언어를 평탄화합니다. |
| 3. 구조체 | 매개변수를 하나의 타입으로 묶습니다. | 구조체는 동작이나 불변성을 갖지 않으며, 단순히 위장된 매개변수 목록에 불과하고 목적을 전혀 나타내지 않는 타입을 추가합니다. |
세 옵션 모두 같은 이유로 실패합니다: 적절한 컨텍스트를 모델링하지 못한다는 점입니다. 빌더가 실제로 필요로 하는 것은 “거래 정보”도 아니고 “일곱 개의 원시값”도 아니라, 이 컨텍스트에 맞는 거래에 대한 도메인‑특화 뷰입니다.
역사적으로 팀들은 상속 기반 어댑터로 이 문제를 해결했습니다. 좁은 인터페이스를 도입하고, 큰 입력 객체가 이를 구현하도록 했습니다. 효과적이긴 했지만, 이 접근 방식은 관련 없는 도메인들을 긴밀히 결합시키고 경직된 계층 구조를 도입합니다—많은 팀이 현재는 피하고자 하는 방법입니다.
Source: …
해결책: Views
우리는 궁극적으로 views—더 큰 객체에 대한 읽기 전용 투영—를 도입함으로써 문제를 해결했습니다. 이는 std::string_view와 같은 정신을 가지고 있습니다.
뷰는 컨텍스트‑특정 계약을 나타냅니다:
- API가 필요로 하는 바운드 컨텍스트에 정확히 맞는 정보를 제공합니다.
- 해당 바운드 컨텍스트의 언어로 말합니다.
- 타입 자체를 변경하지 않고는 실수로 범위를 확장할 수 없습니다.
핵심은, 뷰가 DDD 아티팩트라는 점입니다. 이는 컨텍스트 간 경계를 형식화하고 그 경계에서 번역을 강제합니다.
예시: PartiesView
우리에게 뷰는 TradeInformation을 사용한 선언적 API보다 누수가 적고 가독성이 높은 최소한의 도메인 개념 집합을 표시합니다. 아래는 거래 당사자에 대한 세부 정보를 제공하는 구체적인 예시입니다.
#include <string>
#include <concepts>
template <typename V>
concept PartiesViewLike = requires(const V& v) {
{ v.makerId() } -> std::convertible_to<int>;
{ v.takerId() } -> std::convertible_to<int>;
{ v.amount() } -> std::convertible_to<double>;
{ v.internalId() } -> std::same_as<const std::string&>;
{ v.externalId() } -> std::same_as<const std::string&>;
{ v.venue() } -> std::same_as<Venue>;
{ v.makerDetails() } -> std::same_as<const MakerDetails&>;
};
class PartiesView {
public:
explicit PartiesView(const TradeInformation& ti) : d_trade(ti) {}
int makerId() const { return d_trade.makerId(); }
int takerId() const { return d_trade.takerId(); }
double amount() const { return d_trade.amount(); }
const std::string& internalId() const { return d_trade.internalId(); }
const std::string& externalId() const { return d_trade.externalId(); }
Venue venue() const { return d_trade.venue(); }
const MakerDetails& makerDetails() const { return d_trade.makerDetails(); }
private:
const TradeInformation& d_trade;
};
PartiesView는 읽기‑전용 투영으로, 파티 정보를 다루는 어댑터가 필요로 하는 데이터만 노출합니다. PartiesViewLike를 받는 모든 어댑터는 올바른 바운드 컨텍스트 내에서 동작하고 있음을 확신할 수 있으며, 컴파일러가 계약을 강제합니다.
핵심 정리
- “갓 오브젝트”(예:
TradeInformation)를 해당 컴포넌트가 필요로 하는 일부분만 전달하도록 절대 전달하지 마세요. - 각 컴포넌트가 실제로 요구하는 계약을 모델링하세요—원시 파라미터 리스트보다 명시적이고 도메인‑특정 타입을 선호합니다.
- 뷰 객체를 사용해 가볍고 읽기 전용인 투영을 만들고, 이는 바운드 컨텍스트 간 명확한 경계 역할을 합니다.
- **Concepts(또는 인터페이스)**를 활용해 이러한 경계를 컴파일 타임에 강제할 수 있도록 합니다.
뷰를 도입함으로써 우리는 명확한 도메인 경계를 복원하고, 암묵적인 의존성을 제거했으며, 테스트를 간단하게 만들었습니다—호출 지점의 가독성을 희생하지 않고도 말이죠.
// Builder 패턴에서 뷰 어댑터 사용 예시
class Builder1 {
public:
// Builder는 거래 정보의 읽기‑전용 뷰와 함께 작동합니다.
// 뷰는 Builder가 실제로 필요로 하는 데이터만 노출합니다.
template <typename TradeView>
Deal buildDeal(const TradeView& v) const {
Deal d;
// ... v.makerId(), v.internalId() 등을 사용
return d;
}
private:
const TradeInformation& d_trade;
};
class Builder2 {
public:
// 다른 서브셋의 데이터를 필요로 하는 두 번째 Builder
template <typename V>
Deal buildDeal(const V& v) const {
Deal d;
// ... v.makerId(), v.internalId() 등을 사용
return d;
}
};
왜 뷰 어댑터를 사용하나요?
- Builder는 정확히 필요한 것만 의존합니다 – 더 이상, 더 적게도 없습니다.
- 호출 지점이 깔끔합니다. 뷰는 기존 입력으로부터 저렴하게 생성될 수 있기 때문입니다.
- 단위 테스트가 더 단순하고 집중됩니다; 뷰는 최소 데이터만으로 인스턴스화할 수 있습니다.
- 컨텍스트 누수가 눈에 보이고 의도적이 됩니다, 무심코 발생하지 않도록.
ccidental.
Benefits
- 상속 없이, 계약을 확장하지 않으면서 Adapter pattern의 장점을 다시 소개합니다.
- 어댑터가 10개의 필드만 필요하다면, 100개를 보게 해서는 안 됩니다.
- 어려운 단위 테스트는 경계가 잘못되었을 수 있음을 나타냅니다.
- “청결함”이라는 이유로 의존성을 숨기는 것은 종종 도메인 모델을 약화시킵니다.
Final Takeaway
좁은 계약은 미학이나 테스트 편의성만을 위한 것이 아닙니다.
이들은 bounded contexts를 존중하고 시간이 지나도 domain integrity를 유지합니다.