Comparative · Architecture

같은 아키텍처를
5개 언어로

같은 Repository/Query 분리를 NestJS, Go, Java, Kotlin, FastAPI에서 따로따로 구현했더니, 생김새가 전혀 다른 코드 5개가 나왔다. 이 글에서 보려는 건, 그런데도 밑에 깔린 결정이 얼마나 그대로 남느냐다.

어떤 아키텍처 원칙이 정말 언어와 상관없는 원칙인지, 아니면 어쩌다 TypeScript 모양을 하고 있을 뿐인지 확인하는 방법이 있다. 여러 언어에서 따로 구현해 보면 된다. 언어마다 우연히 붙은 부분은 떨어져 나가고 결정만 남는다. 내 예제 프로젝트에서 그렇게 해 봤다. 같은 예제 도메인 Account(계좌)를 5개 백엔드로 만들었다. NestJS(TypeScript), Go, Spring Boot(Java와 Kotlin 각각), FastAPI(Python)다. 그중 무엇이 본질이고 무엇이 문법일 뿐인지 가장 잘 보이는 곳이 Repository/Query 분리다.

TypeScript는 abstract class로 인터페이스를 대신한다

NestJS의 의존성 주입에는 "interface"를 쓸 수 없다. TypeScript의 interface는 컴파일하면 사라지니 DI 토큰이 될 수 없기 때문이다. 그래서 Query 계약은 런타임까지 남는 abstract class로 만든다.

export abstract class OrderQuery {
  abstract getOrders(query: GetOrdersQuery): Promise<GetOrdersResult>
  abstract getOrder(query: GetOrderQuery): Promise<GetOrderResult>
}

// infrastructure/order-query-impl.ts
export class OrderQueryImpl extends OrderQuery {
  public async getOrders(query: GetOrdersQuery): Promise<GetOrdersResult> {
    // a query optimized for reading, with no Aggregate reconstitution
  }
}

QueryHandler의 의존성 타입은 늘 OrderQuery이고, OrderRepository를 받는 일은 없다. 인터페이스와 구현체는 DI 컨테이너가 이어 준다. 그러니 Query 쪽 코드에서는 컴파일 단계부터 write 메서드를 부를 길이 없다.

Go는 인터페이스를 두 번 만들지 않는다

Go는 구조적 타이핑 덕분에 구현 클래스를 따로 두지 않고도 같은 보장을 얻는다. 큰 인터페이스를 만족하는 타입은 그 안에 임베딩한 작은 인터페이스도 저절로 만족한다. 그래서 Query와 Repository가 구현체 하나를 중복 없이 같이 쓴다.

// Query is a Query-only interface that exposes only read-only lookup methods. Query
// Handlers must depend only on this interface so they have no access to write methods.
// Because Go interfaces use structural typing, any implementation that satisfies
// Repository automatically satisfies Query too — there's no need for two implementations.
type Query interface {
	FindAccounts(ctx context.Context, q FindQuery) ([]*Account, int, error)
	FindTransactions(ctx context.Context, accountID string, page, take int) ([]Transaction, int, error)
	HasTransactionWithReference(ctx context.Context, referenceID string, txType TransactionType) (bool, error)
}

// Repository is a Command-only interface that adds a write method on top of Query's read methods.
type Repository interface {
	Query
	SaveAccount(ctx context.Context, account *Account) error
}

Query Handler는 Query를, Command Handler는 Repository를 의존성으로 받는다. main.go에서 조립할 때는 둘 다 같은 struct 하나를 넘긴다. 분리는 컴파일러가 지켜 주고, 구현 타입을 하나 더 만드는 비용은 들지 않는다. Go에는 findOne 같은 메서드도 없다. 다른 언어처럼 .then(r => r.orders.pop())으로 이어 붙이는 습관이 없어서, 자주 쓰는 단건 조회는 일반 함수로 빼 둔다.

func FindOne(ctx context.Context, q Query, accountID, ownerID string) (*Account, error) {
	accounts, _, err := q.FindAccounts(ctx, FindQuery{AccountID: accountID, OwnerID: ownerID, Take: 1})
	if err != nil {
		return nil, err
	}
	if len(accounts) == 0 {
		return nil, ErrNotFound
	}
	return accounts[0], nil
}

Python은 읽기 인터페이스를 쓰기 인터페이스가 상속한다

FastAPI 쪽은 Go의 아이디어를 다른 타입 시스템으로 옮겨 놓은 것처럼 보인다. 이미 있는 write 인터페이스에서 read-only 인터페이스를 떼어 내는 방식이 아니다. write까지 되는 인터페이스가 read-only 인터페이스를 확장한다.

class AccountQuery(ABC):
    """A read-only interface — for the Query Handler only. Never exposes a write method
    such as save() (see cqrs-pattern.md). Shares its method signatures with
    AccountRepository (the write model) but is a separate contract — a Query Handler must
    always depend only on this type."""

    @abstractmethod
    async def find_accounts(
        self, page: int, take: int,
        account_id: str | None = None, owner_id: str | None = None,
        status: list[str] | None = None,
    ) -> tuple[list[Account], int]: ...


class AccountRepository(AccountQuery, ABC):
    @abstractmethod
    async def save_account(self, account: Account) -> None: ...

FastAPI에는 프레임워크가 제공하는 DI 컨테이너가 없고, Depends() 팩토리가 컴포지션 루트 노릇을 한다. 그래도 ABC 경계가 하는 일은 TypeScript의 abstract class, Go의 인터페이스 임베딩과 똑같다. AccountQuery 타입만 받는 Query Handler는 어떤 구체 클래스가 들어오든 데이터를 쓰는 메서드를 부를 수 없다.

Kotlin과 Java는 일부러 인터페이스 하나로 간다

Kotlin은 read와 write 메서드를 인터페이스 하나에 같이 둔다. 대신 반환 모양에서 Kotlin다운 방식을 쓴다. TypeScript라면 { accounts, count }를 돌려줄 자리에 Pair<List<Account>, Long>를 돌려준다.

interface AccountRepository {
    fun findAccounts(query: AccountFindQuery): Pair<List<Account>, Long>
    fun saveAccount(account: Account)
    fun deleteAccount(accountId: String)
    fun hasTransactionWithReference(referenceId: String, type: TransactionType): Boolean
}

보다시피 Query/Repository 분리가 아니다. 그렇다고 빠뜨린 것도 아니고, 일부러 다르게 고른 것이다. 루트의 cqrs-pattern.md가 제시하는 스펙트럼이 이 선택을 명시적으로 허용한다. Application Service를 Command 메서드와 Query 메서드로 나누기만 해도 이미 가벼운 CQRS이고, 유스케이스가 적어 Service 클래스가 단순하다면 그걸로 충분하다는 것이다.

Java의 Account 예제는 Query Service에 이 선택을 그대로 적어 두었다. 아직 필요 없는 도메인에 read-only 인터페이스를 하나 더 만들지 않고, write까지 되는 Repository를 바로 쓴다. CQRS 문서의 "언제 도입할 것인가" 표를 코드로 보여 주는 사례이지 위반이 아니다. 같은 모양이 위반이 되는 경우는 따로 있다. Query Handler가 인터페이스 경계를 전혀 고민하지 않고 Repository를 슬그머니 쓰는 경우다. 이건 CQRS-in-practice 글에서 다룬 다른 종류의 실패다.

5개 언어에서 똑같이 남은 것

타입 시스템은 달라도 모든 구현이 몇 가지 규칙에는 똑같이 따른다. 목록 조회 메서드는 늘 복수형 find<Noun>s 모양이고, 단건 조회 메서드를 따로 두지 않는다. Go와 TypeScript는 둘 다 단건 조회를 같은 목록 메서드를 감싼 헬퍼로 뺀다. 한쪽은 일반 함수로, 다른 쪽은 클래스 메서드로 뺄 뿐이다.

Payment BC 이벤트에 반응할 때 쓰는 멱등성 체크(hasTransactionWithReference / HasTransactionWithReference / has_transaction_with_reference)도 모든 언어에 있다. reference ID만 보지 않고 transaction type까지 같이 확인해야 하는 이유를 적은 문서 주석도 거의 토씨 하나 다르지 않다. 그리고 모든 언어에서 Repository 인터페이스는 domain 계층에, SQL과 ORM이 들어간 구현은 infrastructure에 있다. Application 계층은 인터페이스에만 의존한다.

더 볼 자료

docs/architecture/repository-pattern.md(같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트의 Repository 규칙) · docs/architecture/cqrs-pattern.md(Java의 Account 예제가 놓인 "언제 도입할 것인가" 스펙트럼) · implementations/(5개 언어 구현을 나란히 볼 수 있다)