Skip to content

Plugins

플러그인이 필요한 이유

ORM은 CRUD, 스키마 동기화, 이벤트 시스템, Unit of Work, 감사 로깅, soft delete 등 많은 관심사를 다뤄요. 이걸 전부 코어에 넣으면 쓰지 않는 기능까지 번들 크기, 메모리, 버그 표면적을 키우게 돼요.

플러그인은 코어를 작고 핵심적으로 유지하면서, 선택적 기능은 별도 모듈로 분리해서 필요할 때만 설치할 수 있게 해줘요.

브라우저 확장 프로그램과 비슷해요. 브라우저는 페이지 렌더링, 탭 관리, 북마크 같은 필수 기능만 제공하고, 광고 차단이나 비밀번호 관리는 확장 프로그램으로 설치하죠. 필요한 것만 설치하고, 하나를 제거해도 나머지는 영향받지 않아요.

플러그인 동작 방식 -- Lifecycle

플러그인은 명확한 lifecycle을 거쳐요:

1. INSTALL      em.extend(myPlugin)
                  |
                  v
2. RECEIVE      plugin.install(ctx) is called
   CONTEXT        -- ctx gives controlled access to EntityManager internals
                  |
                  v
3. ADD          install() returns an API object
   METHODS        -- those methods are mixed into the EntityManager instance
                  |
                  v
4. (active)     Your code calls em.myNewMethod()
                  -- the plugin is fully operational
                  |
                  v
5. SHUTDOWN     em.propagateShutdown() calls plugin.shutdown()
                  -- clean up timers, connections, caches

각 단계를 하나씩 살펴볼게요.

Step 1: Install

em.extend()에 플러그인 객체를 전달해요. Stingerloom은 같은 이름의 플러그인이 이미 설치되어 있는지 확인하고, 이미 있으면 아무 동작도 하지 않아요. 멱등성(idempotent) 설계라서 여러 번 호출해도 안전해요.

typescript
import { EntityManager } from "@stingerloom/orm";

const em = new EntityManager();
em.extend(myPlugin());

register()plugins 배열로도 전달할 수 있어요. DB 연결이 완료된 후 자동으로 설치돼요:

typescript
await em.register({
  type: "postgres",
  host: "localhost",
  port: 5432,
  username: "postgres",
  password: "password",
  database: "mydb",
  entities: [User, Post, Comment],
  synchronize: true,
  plugins: [myPlugin()],
});

Step 2: Receive Context

install()이 호출되면 PluginContext 객체를 받아요. EntityManager 내부를 직접 노출하는 게 아니라, 플러그인이 사용할 수 있도록 정제된 API 표면이에요. EntityManager 내부가 변경되어도 플러그인은 안정적으로 동작해요.

Step 3: Add Methods

install() 함수가 객체를 반환하면, 그 객체의 모든 메서드가 EntityManager 인스턴스에 믹스인돼요. 설치 후에는 em에서 직접 호출할 수 있어요.

Step 4: Active Operation

플러그인이 EntityManager의 일부가 됐어요. 이벤트 리스너가 동작하고, 메서드를 호출할 수 있고, 네이티브 기능처럼 동작해요.

Step 5: Shutdown

em.propagateShutdown()이 호출되면 (예: NestJS 앱 종료 시) 플러그인은 역순으로 shutdown()을 받아요. 마지막에 설치된 플러그인이 가장 먼저 종료돼요. interval 정리, 소켓 닫기, 버퍼 flush 등을 여기서 해요.


커스텀 플러그인 작성하기

플러그인은 StingerloomPlugin 인터페이스를 구현한 객체예요: name, install() 함수, 그리고 선택적인 shutdown() 함수로 구성돼요.

전체 예제: Timestamp Logger

엔티티가 INSERT 또는 DELETE될 때마다 타임스탬프와 함께 로그를 기록하는 플러그인이에요.

typescript
import { StingerloomPlugin, PluginContext } from "@stingerloom/orm";

const timestampLogger: StingerloomPlugin<{ getLog(): string[] }> = {
  name: "timestamp-logger",

  install(ctx: PluginContext) {
    const log: string[] = [];

    ctx.events.on("afterInsert", ({ entity }) => {
      log.push(`[${new Date().toISOString()}] INSERT ${entity.name}`);
    });

    ctx.events.on("afterDelete", ({ entity }) => {
      log.push(`[${new Date().toISOString()}] DELETE ${entity.name}`);
    });

    return {
      getLog: () => [...log],
    };
  },

  shutdown() {
    // Nothing to clean up in this simple example,
    // but this is where you would clear timers or close connections.
  },
};

em.extend(timestampLogger);
em.getLog(); // typed as string[]

동작 과정 추적

  1. em.extend(timestampLogger)를 호출해요.
  2. Stingerloom이 "timestamp-logger" 이름의 플러그인이 이미 있는지 확인해요. 없으니 진행해요.
  3. timestampLogger.install(ctx)를 호출하면서 PluginContext를 전달해요.
  4. install() 내부에서 클로저 스코프에 빈 log 배열을 만들어요. 이 배열은 플러그인 전용이라 외부에서 직접 접근할 수 없어요.
  5. ctx.events"afterInsert""afterDelete" 두 이벤트를 구독해요. EntityManager가 INSERT나 DELETE를 완료할 때마다 콜백이 실행되면서 log 배열에 문자열을 추가해요.
  6. install(){ getLog: () => [...log] }를 반환해요. spread 연산자 [...log]로 복사본을 만들기 때문에 호출자가 내부 배열을 변경할 수 없어요.
  7. Stingerloom이 반환값을 em에 믹스인해요. 이제 em.getLog()를 호출할 수 있고, TypeScript도 string[] 반환 타입을 알아요.
  8. 나중에 em.save(User, { name: "Alice" })를 호출하면, EntityManager가 "afterInsert" 이벤트를 발생시키고, 플러그인 콜백이 "[2026-03-22T10:00:00.000Z] INSERT User"를 로그에 추가해요.
  9. em.getLog()를 호출하면 ["[2026-03-22T10:00:00.000Z] INSERT User"]를 받아요.
  10. 앱이 종료되고 em.propagateShutdown()이 호출되면, timestampLogger.shutdown()이 실행돼요.

PluginContext Reference

install() 함수가 받는 PluginContext는 EntityManager 내부에 대한 제어된 접근을 제공해요. 각 속성과 메서드를 언제 사용하는지 정리했어요.

Property / Method설명언제 사용하나요?
ctx.emEntityManager 인스턴스find(), save(), delete() 등 EntityManager 메서드를 호출할 때. hook과 이벤트가 정상 동작하려면 raw SQL 대신 ctx.em을 사용해야 해요.
ctx.driver현재 SQL driver (register() 전에는 undefined)raw SQL을 실행하거나 DB별 기능을 확인할 때. register() 호출 전에 설치하면 undefined예요.
ctx.eventsEntity event emitter (on, off, emit)엔티티 lifecycle 이벤트에 반응할 때: beforeInsert, afterInsert, beforeUpdate, afterUpdate, beforeDelete, afterDelete. 가장 흔한 플러그인 패턴이에요.
ctx.connectionName연결 이름 (기본값: "default")multi-database 환경에서 연결별로 다르게 동작해야 할 때. 예: audit 플러그인이 "primary" 연결에서만 로그를 남기는 경우.
ctx.addSubscriber(sub)EntitySubscriber 등록클래스 기반 EntitySubscriber를 제공할 때 (listenTo() 필터링이 가능한 이벤트 콜백 대안).
ctx.removeSubscriber(sub)EntitySubscriber 제거런타임에 동적으로 구독을 해제해야 할 때.
ctx.getEntities()등록된 모든 엔티티 클래스스키마를 분석해야 할 때. 예: 문서 생성, 엔티티 정의 검증.
ctx.getPlugin(name)다른 플러그인의 API 접근다른 플러그인에 의존해서 그 메서드를 호출해야 할 때. 아래 "Plugin Dependencies" 참고.
ctx.isMySqlFamily()MySQL/MariaDB 드라이버인지 확인SQL 생성 시 backtick vs double-quote 구분이 필요할 때.
ctx.isPostgres()PostgreSQL 드라이버인지 확인PostgreSQL 전용 기능(schema, JSONB 연산자, RETURNING 절)을 사용할 때.
ctx.isSqlite()SQLite 드라이버인지 확인SQLite가 지원하지 않는 기능(ALTER COLUMN, EXPLAIN 등)을 건너뛸 때.
ctx.wrap(identifier)드라이버의 quoting 방식으로 식별자를 감싸기SQL 문자열을 만들 때 컬럼명이나 테이블명을 안전하게 quote해야 할 때. MySQL은 backtick, PostgreSQL/SQLite는 double quote를 사용해요.
ctx.wrapTable(tableName)테이블명 quote (schema prefix 포함)wrap()과 같지만 schema 한정 이름을 처리해요 (예: PostgreSQL의 "public"."user").
ctx.executeInTransaction(fn)트랜잭션 안에서 콜백 실행여러 SQL 문을 원자적으로 실행해야 할 때. 성공 시 commit, 에러 시 rollback돼요.
ctx.executeReadOnly(fn)읽기 전용 트랜잭션에서 콜백 실행읽기만 할 때 DB에 최적화 신호를 보내고 싶을 때 (read-replica 환경에서 replica를 사용해요).

Plugin Dependencies

플러그인이 다른 플러그인의 선행 설치를 요구할 수 있어요. 의존성이 누락되면 extend()가 명확한 에러 메시지와 함께 즉시 throw해요.

typescript
const derivedPlugin: StingerloomPlugin = {
  name: "derived",
  dependencies: ["base-plugin"],

  install(ctx) {
    const baseApi = ctx.getPlugin<{ getData(): any[] }>("base-plugin");
    // use baseApi...
  },
};

// This throws -- "base-plugin" is not installed
em.extend(derivedPlugin);

// This works -- install the dependency first
em.extend(basePlugin());
em.extend(derivedPlugin);

서로 의존하는 플러그인 패밀리를 만들 때 유용해요. 예를 들어 "soft-delete-audit" 플러그인이 "soft-delete"와 "audit" 플러그인 둘 다에 의존하는 경우요.


Method Name Conflicts

플러그인이 EntityManager에 이미 존재하는 메서드(예: find, save)를 추가하려 하면 PLUGIN_CONFLICT 에러가 발생해요. 코어 메서드를 덮어쓰면 예측할 수 없는 방식으로 ORM이 깨지기 때문에 이런 안전 장치가 있어요.

플러그인 API 메서드 이름은 고유하고 설명적으로 지어야 해요. 플러그인 이름을 접두사로 붙이는 게 좋은 관례예요: getLog() 대신 auditGetLog() 같은 식이에요.


API Reference

StingerloomPlugin<TApi>

typescript
interface StingerloomPlugin<TApi = {}> {
  /** Unique plugin name (used for dependency resolution and dedup) */
  readonly name: string;

  /** Names of plugins that must be installed before this one */
  readonly dependencies?: readonly string[];

  /**
   * Called once when the plugin is installed via em.extend(plugin).
   * May return an API object whose methods will be mixed into the EntityManager.
   */
  install(context: PluginContext): TApi | void;

  /**
   * Called during propagateShutdown() in reverse installation order.
   * Used to clean up plugin resources (timers, connections, caches).
   */
  shutdown?(): Promise<void> | void;
}

EntityManager Plugin Methods

MethodSignature설명
extend<TApi>(plugin): this & TApi플러그인을 설치하고 API를 EntityManager에 믹스인해요
hasPlugin(name: string): boolean이름으로 플러그인 설치 여부를 확인해요
getPluginApi<T>(name: string): T | undefined이름으로 플러그인의 API 객체를 가져와요

Built-in Plugins

Stingerloom에는 기본 제공 플러그인이 하나 있어요:

PluginImport설명
Buffer (UoW)bufferPlugin()Unit of Work -- 엔티티 변경사항을 메모리에 추적하고 단일 원자적 트랜잭션으로 flush해요. Identity Map, dirty checking, cascade, pessimistic locking 등을 지원해요.

자세한 내용은 전용 가이드를 참고하세요: WriteBuffer (Unit of Work)

Next Steps

Released under the MIT License.