타입스크립트의 네임스페이스와 모듈

😢 이 페이지는 다음 주소로 변경될 예정입니다.

TypeScript Handbook의 Namespaces and Modules를 번역했다.


용어에 대한 노트: 타입스크립트 1.5에서 기록해둘 만큼 중요한 명명법 변경이 있었습니다. “내부 모듈(Internal modules)”은 “네임스페이스”가 되었습니다. “외부 모듈(External modules)”은 이제 간단하게 “모듈(modules)”이 되어 ECMAScript 2015의 용어와 맞췄습니다. (module X {namespace X {와 동일하며 후자가 선호됩니다.)

개요

이 포스트는 타입스크립트에서 네임스페이스와 모듈을 사용해 코드를 조직하는 여러가지 방법을 설명합니다. 그리고 어떻게 네임스페이스와 모듈을 사용하고 일반적으로 겪을 수 있는 위험성에는 어떤 부분이 있는지 깊이 있는 주제도 살펴봅니다.

모듈에 대해 더 알고 싶다면 모듈 문서를 참고하세요. 네임스페이스에 대해 더 알고 싶다면 네임스페이스 문서를 참고하세요.

네임스페이스 사용하기

네임스페이스는 단순히 전역 네임스페이스에서의 자바스크립트 개체에 붙은 명칭입니다. 그래서 네임스페이스를 아주 단순한 구조로 사용할 수 있습니다. 여러 파일로 확장해서 작성할 수 있지만 --outFile로 합치는 것도 가능합니다. 네임스페이스는 웹 애플리케이션에서 코드를 구조화하기 좋은 방법이며 HTML 페이지 내에 모든 의존성이 <script> 태그로 포함됩니다.

이 접근 방법은 전역 네임스페이스 오염이 발생하기 때문에 특히 대형 어플리케이션의 경우는 컴포넌트의 의존성을 파악하기 쉽지 않습니다.

모듈 사용하기

모듈도 네임스페이스와 같이 코드와 선언을 모두 포함합니다. 주된 차이점은 모듈은 의존성을 선언한다는 점입니다.

또한 모듈은 의존성을 모듈 로더(module loader)를 통해 처리합니다. (예로 CommonJs/Require.js) 작은 크기의 JS 애플리케이션에서는 최적이라고 보기 어렵지만 대형 애플리케이션에서는 장기적인 관점에서 모듈성(modularity)과 유지 가능성(maintainability)에서 이득이 있습니다.
모듈은 코드를 더 쉽게 재사용할 수 있고 더 강하게 고립되어 있으며 번들링을 위해 더 나은 도구를 지원합니다.

Node.js 애플리케이션에서는 모듈을 사용하는 것이 기본적인 방법이며 코드를 구조화하는데 추천되는 접근 방식입니다.

ECMAScript 2015를 사용하면 모듈은 언어에서 제공하는 기능이며 호환 엔진 구현에서는 다 지원해야 합니다. 그러므로 새 프로젝트에서는 코드를 조직하는 방법으로 모듈을 사용하는 것을 추천합니다.

네임스페이스와 모듈의 위험성

여기서는 네임스페이스와 모듈을 사용했을 때 일반적으로 나타나는 다양한 위험에 대해 살펴보고 어떻게 피하는지 확인합니다.

/// <reference>를 사용한 모듈

모듈에서 import 문 대신에 /// <reference ... /> 문법을 사용하는 것이 가장 일반적인 실수입니다. 이 차이를 이해하려면 컴파일러가 import를 사용했을 떄 모듈을 위한 타입 정보를 어디서 가져오는지 이해해야 합니다. (예를 들면 import x from "..."; 또는 import x = require("...");에서의 "...".)

컴파일러는 .ts, .tsx를 먼저 찾은 후에 적절한 경로에 있는 .d.ts를 찾습니다. 특정 파일을 찾지 못한 경우 컴파일러는 구현 없는 모듈 선언(ambient module declaration)를 찾습니다. 이런 경우에는 .d.ts의 경로를 선언할 필요가 있습니다.

역자 주: ambient는 주변이란 의미인데 이 이슈의 설명을 기준으로 의역했습니다.

  • myModules.d.ts
    // .d.ts 파일 또는 모듈이 아닌 .ts 파일
    declare module "SomeModule" {
      export function fn(): string;
    }
    
  • myOtherModule.ts
    /// <reference path="myModules.d.ts" />
    import * as m from "SomeModule";
    

reference 태그는 정의 없는 모듈의 선언에 필요한 선언 파일 위치를 지정할 수 있습니다.
이런 방법으로 여러 타입스크립트 예제에서 node.d.ts을 사용하는 것을 확인할 수 있습니다.

불필요한 네임스페이스

네임스페이스를 사용하는 프로그램을 모듈로 변경한다면 다음과 같은 형태가 되기 쉽습니다.

  • shapes.ts
    export namespace Shapes {
      export class Triangle { /* ... */ }
      export class Square { /* ... */ }
    }
    

최상위 모듈을 Shapes로 두고 TriangleSquare를 감쌌는데 이런 방식을 사용할 이유가 없습니다. 이 모듈을 사용하는 입장에서는 혼란스럽고 짜증나는 방식입니다.

  • shapeConsumer.ts
    import * as shapes from "./shapes";
    let t = new shapes.Shapes.Triangle(); // shapes.Shapes?
    

타입스크립트에서 모듈의 가장 중요한 기능은 다른 두 모듈이라면 절대 동일한 스코프에서 같은 명칭을 사용하지 않는다는 점입니다. 모듈을 사용할 때 직접 명칭에 배정하기 때문에 네임스페이스에서 심볼을 내보내며 일일이 감쌀 필요가 없기 때문입니다.

왜 모듈 내에서 네임스페이스를 사용할 필요가 없는지 고민하게 될지도 모르겠습니다. 네임스페이스는 일반적으로 구조의 논리적인 구분을 제공하고 명칭 충돌을 예방하기 위해 존재하는 개념입니다. 이미 모듈은 논리적으로 구분되어 있고 최상위 명칭은 코드를 불러오는 쪽에서 지정하기 때문에 개체를 내보내기 위해서 추가적인 모듈 계층을 더할 필요가 없는 것입니다.

이런 관점에서 작성한 예제는 다음과 같습니다.

  • shapes.ts
    export class Triangle { /* ... */ }
    export class Square { /* ... */ }
    
  • shapeConsumer.ts
    import * as shapes from "./shapes";
    let t = new shapes.Triangle();
    

모듈 사용의 트레이드오프

JS 파일과 모듈이 일대일 대응을 하는 것처럼 타입스크립트도 모듈 소스 파일과 생성된 JS 파일이 일대일로 대응합니다. 이 방식은 어떤 모듈 시스템을 사용하느냐에 따라서 여러 모듈 소스 파일을 합치는 작업이 불가능 할 수 있습니다. outFile 옵션을 commonjs 또는 umd인 경우 외에는 사용할 수 없습니다. 타입스크립트 1.8 이후 버전이라면 amd 또는 system에서도 사용 가능합니다.

이벤트 소싱 event-sourcing 패턴 JavaScript로 구현하기

😢 이 페이지는 다음 주소로 변경될 예정입니다.

얼마 전 이벤트 소싱 패턴에 대한 글을 작성했다. 글을 읽고나서 js로 간략하게 구현해봤던 내용을 글로 정리했다. 개념을 나눠 설명하기 위해 CQRS 부분은 다른 글을 통해 덧붙이려고 한다. 여기서 사용하는 구현은 프로덕션에서 사용하기에 부족한 점이 많기 때문에 개념 이해에 중점을 맞춰 코드를 보면 좋겠다. 여기서는 은행 계좌를 예시로 작성했으며 구현하는 부분은 다음과 같다.

  • AggregateRoot: 이벤트가 반영될 집합체
  • BankAccount: 집합체 구현
  • *Event: 각각의 이벤트
  • EventSourcingRepository: 이벤트를 사용해서 데이터를 다루는 리포지터리 클래스
  • InMemoryForTestingEventStore: 이벤트 저장소 동작을 확인하기 위한 클래스
  • EventStoreData: 이벤트 저장소에서 저장되는 데이터 개체 클래스

전체 코드는 gist에서 확인할 수 있으며 jsbin.com에서 테스트해볼 수 있다.


가장 먼저 집합체를 구현한다. 집합체는 일련의 이벤트를 투영할 수 있는 개체다. 은행 계좌를 열고, 닫고, 입출금을 한다면 그 정보의 집합체는 은행 계좌가 될 것이다. 먼저 AggregateRoot 클래스를 작성한다. 이 클래스는 이벤트를 받고 해당 메소드를 호출한다. js는 메서드 오버로딩이 없기 때문에 handle(event) 메소드가 apply{이벤트명} 메소드를 찾는다.

class AggregateRoot {
    apply(event) {
        this.handle(event)
        return this
    }

    handle(event) {
        var eventName = event.constructor.name
        var eventMethod = `apply${eventName}`

        if (! this[eventMethod]) {
            throw new TypeError(`${eventMethod} is not a function`)
        }

        this[eventMethod](event)
    }
}

은행계좌를 개설하는 이벤트를 작성한다.

class OpenedEvent {
    constructor(id: number, name: string) {
        this.id = id
        this.name = name
    }
}

은행 계좌 클래스를 추가한다. 이 클래스로 생성한 개체가 집합체가 되며 이벤트를 통해 갱신된다.

class BankAccount extends AggregateRoot {
    static open(id: number, name: string) {
        var bankAccount = new BankAccount
        bankAccount.apply(new OpenedEvent(id, name))
        return bankAccount
    }

    applyOpenedEvent(event) {
        this.id = event.id
        this.name = event.name

        this.closed = false
        this.balance = 0
    }
}

아래 예제에서 이벤트로 프로퍼티가 갱신되는 것을 확인할 수 있다.

var bankAccount = BankAccount.open(123456, 'Koala')
console.log(bankAccount.id, bankAccount.name) // 123456, 'Koala'

이제 여러 이벤트를 추가한다.

class WithdrawnEvent {
  constructor(id: number, amount: number) {
    this.id = id
    this.amount = amount
  }
}

class DepositedEvent {
  constructor(id: number, amount: number) {
    this.id = id
    this.amount = amount
  }
}

class ClosedEvent {
  constructor(id: number) {
    this.id = id
  }
}

메소드도 추가한다.

class BankAccount extends AggregateRoot {
    // ... 이전 코드
    withdraw(amount) {
        if (this.closed) {
            throw new Error(`${this.id} account is closed.`)
        }
        this.apply(new WithdrawnEvent(this.id, amount))
        return this
    }

    deposit(amount) {
        if (this.closed) {
            throw new Error(`${this.id} account is closed.`)
        }
        this.apply(new DepositedEvent(this.id, amount))
        return this
    }

    close() {
        if (!this.closed) {
            this.apply(new ClosedEvent(this.id))
        }
        return this
    }

    applyWithdrawnEvent(event) {
        this.balance -= event.amount
    }

    applyDepositedEvent(event) {
        this.balance += event.amount
    }

    applyClosedEvent(event) {
        this.closed = true
    }
}
var bankAccount = BankAccount.open(123456,  'Koala')
  .deposit(10000)
  .withdraw(1000)
  .deposit(3000)
  .close()

console.log(bankAccount.name, bankAccount.balance, bankAccount.closed ? 'closed' : 'opened')
// Koala 12000 closed

집합체를 사용해서 내부적으로는 이벤트를 통해 데이터가 갱신되고 있지만 개체의 일반적인 사용과 큰 차이가 없는 것을 확인할 수 있다. 각각의 메소드는 실제로 개체의 정보를 갱신하지 않고 이벤트를 생성하는 역할만 하며 이벤트를 적용하는 apply* 메소드에서만 실질적인 변화가 일어나고 있다.

정말 이 은행 계좌는 일련의 이벤트로 결과를 얻을 수 있을까? 다음 예를 보면 알 수 있다.

var events = [
    new OpenedEvent(123456, 'Koala'),
    new DepositedEvent(123456, 10000),
    new WithdrawnEvent(123456, 1000),
    new DepositedEvent(123456, 3000),
    new ClosedEvent(123456),
]

var bankAccount = new BankAccount
events.forEach(event => bankAccount.apply(event))

console.log(bankAccount.name, bankAccount.balance, bankAccount.closed ? 'closed' : 'opened')
// Koala 12000 closed

동일한 결과를 확인할 수 있다. 위 코드는 AggregateRoot에 다음처럼 추가한다.

class AggregateRoot {
    // ... 이전 코드
    initializeState(events) {
        events.forEach(event => this.apply(event))
    }
}

이제 일련의 이벤트를 다루고 저장할 수 있도록 리포지터리를 만든다.

class EventSourcingRepository {
    constructor(eventStore, aggregateType) {
        this.eventStore = eventStore
        this.aggregateType = aggregateType
    }

    load(id) {
        var events = this.eventStore.load(id)

        var aggregate = Object.create(this.aggregateType.prototype)
        aggregate.initializeState(events)
        return aggregate
    }

    save(aggregate) {
        var uncommittedEvents = aggregate.getUncommittedEvents()
        this.eventStore.append(uncommittedEvents)
    }
}

저장하지 않은 이벤트를 가져올 수 있도록 getUncommittedEvents()AggregateRoot에 구현한다. 또한 상태 초기화 시 저장하지 않은 이벤트로 다루지 않도록 initializeState 메소드도 변경한다.

class AggregateRoot {
    // ...
    uncommittedEvents = []

    getUncommittedEvents() {
        var events = this.uncommittedEvents
        this.uncommittedEvents = []
        return events
    }

    apply(event) {
        this.handle(event)
        this.uncommittedEvents.push(event)
        return this
    }

    initializeState(events) {
        events.forEach(event => this.handle(event))
    }
}

여기서는 예로 간단한 이벤트 저장소를 구현해서 사용한다. 이벤트를 저장하기 위한 구조로 EventStoreData를 다음처럼 작성한다.

class EventStoreData {
    constructor(rootId, event, createdAt) {
        this.rootId = rootId
        this.event = event
        this.createdAt = createdAt
    }
}

이벤트가 데이터베이스에 저장될 때 EventStoreData의 정의대로 저장된다고 생각해보자. 관계형 데이터베이스를 예로 든다면 이 클래스가 테이블의 스키마를 반영하고 있다고 볼 수 있다. 다음은 이 개체를 그대로 메모리에서 사용하는 예제 이벤트 저장소 클래스다.

class InMemoryForTestingEventStore {
    constructor(events) {
        this.data = events ? this.convertEvents(events) : []
    }

    load(rootId) {
        return this.data
            .filter(data => data.rootId === rootId)
            .map(data => data.event)
    }

    append(events) {
        var newData = this.convertEvents(events)
        this.data = this.data.concat(newData)
    }

    convertEvents(events) {
        return events.map(event => this.convertEventToData(event))
    }

    convertEventToData(event) {
        var createdAt = new Date().getTime()
        return new EventStoreData(event.id, event, createdAt)
    }
}

다음처럼 리포지터리로 저장하고 불러올 수 있게 되었다.

var eventStore = new InMemoryForTestingEventStore()
var repository = new EventSourcingRepository(eventStore, BankAccount)

var bankAccount = BankAccount.open(654321,  'Edward')
  .deposit(20000)
  .withdraw(1000)
  .withdraw(1000)

repository.save(bankAccount)
var loaded = repository.load(654321)
console.log(bankAccount.name, bankAccount.balance, bankAccount.closed ? 'closed' : 'opened')
// Edward 18000 opened

eventStore에 저장된 이벤트 저장소 데이터를 살펴보면 이 동작이 좀 더 와닿는다.

console.log(eventStore.data)

eventStore.data


앞에서도 말했지만 여기서 사용하는 구현은 프로덕션에서 사용하기에 부족한 점이 많다. 예를 들면 AggregateRoot에 최종 일관성을 확인하기 위한 version도 구현되어 있지 않고 EventData에 id도 정의되어 있지 않다. 패턴에 대해 이해가 되었다면 실제로 구현한 패키지를 확인하면 도움이 된다.

그리고 CQRS 패턴을 함께 사용하지 않는다면 이벤트소싱은 반쪽에 불과하다. 여기서 살펴본 이벤트소싱에 더해 CQRS를 적용하면 유연하고 강력한 아키텍처를 구성할 수 있다. CQRS 패턴은 다음 글에서 살펴보려고 한다.

이벤트 소싱 event-sourcing 패턴 정리

😢 이 페이지는 다음 주소로 변경될 예정입니다.

최근 프로젝트에서 audit을 생성하는 코드를 작성하면서 이벤트 소싱 패턴을 찾아보게 되었다. 여러 포스트를 통해 접해본 내용이지만 실제로 구현해보지 않아서 크게 와닿지 않았었다. 특히 용어가 익숙하지 않았는데 읽으며 궁금해서 찾아봤던 순서대로 정리했다.


전통적으로 사용하는 CRUD 모델을 생각해보자. 데이터를 갱신하기 위해서는 데이터 저장소에서 해당 데이터를 가져오는 작업이 필요하다. 동시성 문제가 나타날 수도 있고 확장성을 낮추는 지점이 될 수도 있다. 이벤트소싱 패턴은 일련의 이벤트를 통해 데이터를 조작하는 접근 방식이다. 데이터에 영향을 주는 모든 동작을 이벤트라는 저수준의 데이터로 관리하는 것으로 여러 문제를 해결할 수 있다. 물론 단점도 여러가지 존재하므로 필요에 따라 적용해야 한다.

이벤트소싱 패턴도 성숙한 아키텍처이기 때문에 다양한 주제로 세분화되어 있고 각각의 키워드를 알아야 쉽게 찾아볼 수 있다. 예를 들면 이벤트를 저장하고 불러오는 방식 하나만으로도 큰 주제다. 특히 이 패턴을 제대로 쓰기 위해서는 CQRS를 빼놓을 수 없기 때문에 두 이야기가 뒤섞이기도 한다.

이벤트 소싱

앞서 적은 것처럼 이벤트소싱 패턴은 일련의 이벤트를 통해 데이터를 조작한다. 현재의 상태는 변화의 총합으로 표현할 수 있다. 이 패턴을 설명할 때는 쇼핑몰을 많이 예로 든다.

다음과 같이 일련의 이벤트가 있다고 생각해보자.

id root_id event
1 1 카트 생성함
2 1 상품1 추가함
3 1 상품2 추가함
4 1 상품2 제거함
5 1 배송정보 입력함

이 이벤트를 개체에 하나씩 적용한다면 최종적으로 생성된 카트에 상품2가 추가되어 있고 배송정보까지 입력된 개체를 얻을 수 있게 된다. 기존 CRUD 모델과 비교한다면 이미 정형화된 모델에 상태를 저장하는 과정에서 나타나는 문제를 해결할 수 있다. CRUD에서는 카트에 추가되었다가 제거된 상품 목록을 뽑고 싶다고 했을 때 별도로 그 특정한 상태를 저장하지 않는다면 어려운 작업이 될 것이다. 게다가 작업을 한다 하더라도 그 작업 이후의 데이터에 대해서만 볼 수 있는 한계점이 있다. 이처럼 정형화되지 않은 데이터인 이벤트를 저장하고 있다는 점에서 더욱 유연한 변화가 가능하다.

여기서 사용되는 개체는 도메인 모델이고 흔히 집합체(aggregate)로 불리며 root_idaggregateRoot로 삼아서 각 개체로 전환한다.

var cart = events.reduce((aggregate, event) => aggregate.apply(event), new CartAggregate);
console.log(cart.getItems()) // ['상품1']
console.log(cart.shippingInfoExists()) // true

내 경우에는 다음과 같은 궁금점이 생겼다.

  • 이벤트는 어떻게 데이터로 저장하고 복원하지?
  • 이벤트가 많이 쌓이면 느려지지 않나?
  • 데이터가 필요할 때마다 매번 전부 이벤트를 돌려봐야 한다면 번거롭지 않을까?
  • 이벤트는 어떻게 다시 재생하지?

이벤트 저장(Eventstore)

이벤트 저장에 사용할 수 있는 저장소는 크게 세 가지로 분류된다.

  • 이벤트 저장에 특화된 데이터 저장소 사용 (e.g. eventstore.org)
  • NoSQL을 사용
  • 관계형 데이터베이스 사용

이벤트는 데이터베이스에 직렬화(serialize)해서 저장하고 역직렬화(deserialize)해서 사용한다. 각 저장하는 방식은 전략에 따라 다른데 관계형 테이터베이스를 사용한다면 이벤트명(주로 이벤트 타입명)과 데이터(주로 payload) 등으로 분리해서 저장한다. 이벤트의 정규 구조가 단순하기 때문에 단순히 위에서 언급한 데이터베이스가 아니더라도 용도에 맞게 선택할 수 있다.

스냅샷

스냅샷은 이벤트 저장에서 사용할 수 있는 전략이다. 기준에 따라서 이벤트가 많이 쌓이면 중간에 스냅샷을 만들고 그 스냅샷 이후의 이벤트만 가져와서 사용하는 방식이다. 특화된 저장소라면 시스템이 알아서 처리해주지만 그 외에는 이 문제를 고민해서 저장 방식을 설계해야 한다.

명령과 조회 책임 분리

필요한 데이터를 얻기 위해 모든 이벤트를 반복해서 재생하는 일은 많은 자원을 필요로 한다. 일련의 이벤트를 여러 장의 필름이라고 생각한다면 현재의 상태란 이 여러 필름을 한 위치에 투영(projecting)해서 나타난 그림이라고 볼 수 있겠다. 매번 모든 필름을 겹쳐 투영하는 대신 현재의 상태를 어딘가 저장하고 있다면 좀 더 쉽게 데이터를 사용할 수 있을 것이다.

현재 상품1의 재고량을 파악하기 위해 모든 이벤트를 투영하는 대신 재고 테이블에서 상품1의 재고량을 바로 찾아보는 것이 훨씬 쉽다. 다시 말하면 저장은 이벤트로 하지만 조회는 투영된 데이터, 구체화(materialised)된 데이터를 대상으로 수행하고 싶은 것이다. 이런 맥락에서 자연스럽게 명령과 조회의 책임을 분리하는 패턴(Command and Query Responsibility Segregation, CQRS)을 적용하게 된다. 명령으로 이벤트를 쌓고 리드 모델에서 조회하는 것이다. 명령과 조회에서의 책임 분리는 이 아키텍처의 장점을 끌어올릴 수 있다.

위에서 예시로 든 테이블의 경우는 id를 increment id와 같이 지정했지만 CQRS에서는 무작정 생성해도 충돌을 피할 수 있는 만큼 큰 id(예로 128bit GUID)를 생성해서 사용한다.

리드 모델

그렇다면 리드 모델은 어떻게 최신 데이터를 계속 유지할 수 있을까? 새 이벤트는 이벤트 버스(event bus)를 통해 전파되는데 리드 모델에 변화를 투영하는 경우에도 이벤트 리스너로서 발행되는 이벤트를 관찰하고 있다가 리드 모델을 갱신해야 하는 이벤트가 발생하는 순간에 갱신할 수 있다.

이벤트에 의해 갱신되는 리드 모델은 자료를 모두 유실하더라도 이벤트를 모두 저장하고 있다면 모든 이벤트를 이벤트 버스에 보내는 것으로 다시 복구할 수 있게 된다. 구체화된 데이터가 자료의 원형이 아니라 이벤트가 자료의 원형이기 때문에 필요에 따라서 언제든 비정규화된 조회를 새로 생성하고 삭제하는 것이 가능하다.

하지만 이 방식을 적용하면 리드 모델을 비동기적으로 처리하기 때문에 최종 일관성(eventual consistency)을 유지하게 된다. 리드 모델 갱신이 빠르다면 다시 조회를 수행했을 때 최신의 자료를 볼 수 있겠지만 연산이 많거나 부하가 커서 갱신이 느려진다면 이벤트는 생성되었지만 리드 모델은 갱신되지 않아 일시적으로 이전 데이터를 조회하게 될 수 있다. 분산 환경에서는 흔하게 나타나는 문제로 요구사항과 충돌한다면 이런 부분에 대한 대책을 세워야 한다.


간략하게 JavaScript로 이벤트소싱 패턴을 구현해 글로 작성했다.

더 읽기