NestJS middleware는 어떻게 작동할까 (4)

들어가며


  • 목표는 이전 글과 동일합니다.
    • nestJS 미들웨어의 코드가 어떻게 구성되어 있는지 확인해본다.
    • 직접 사용해본다.
    • 사용해보며 문제가 있거나 / 이슈가 열려있다면 기여해본다.
  • 이번 글에서는 이전 글에서 분석한 resloveMiddleware 이후에, 인스턴스화 된 미들웨어를 실제로 등록하는 부분부터 알아보겠습니다.
  • 마지막 글입니다!

1. nestApplication, registerMiddleware


  • resolveMiddleware 을 이제 알아보았으니, 해당 메소드를 호출하던 호출부로 다시 돌아가볼까요?

register


 public async register(
    middlewareContainer: MiddlewareContainer,
    container: NestContainer,
    config: ApplicationConfig,
    injector: Injector,
    httpAdapter: HttpServer,
    graphInspector: GraphInspector,
    options: TAppOptions,
  ) {
    ...
    await this.resolveMiddleware(middlewareContainer, modules);
  }
  • MiddlewareModuleregister 메소드는 다음과 같이 resolveMiddleware를 마지막으로 호출하고 끝냅니다.
  • 이제 더 올라가서, register를 호출하는 호출부로 돌아가보겠습니다.

nestApplication


// packages/core/nest-application.ts
export class NestApplication
  extends NestApplicationContext<NestApplicationOptions>
  implements INestApplication
{
  ...

  public async registerModules() {
    this.registerWsModule();
    ...
     
    // 해당 부분!
    await this.middlewareModule.register(
      this.middlewareContainer,
      this.container,
      this.config,
      this.injector,
      this.httpAdapter,
      this.graphInspector,
      this.appOptions,
    );
  }
  
  public async init(): Promise<this> {
    if (this.isInitialized) {
      return this;
    }

    this.applyOptions();
    await this.httpAdapter?.init();

    const useBodyParser =
      this.appOptions && this.appOptions.bodyParser !== false;
    useBodyParser && this.registerParserMiddleware();
    
    await this.registerModules();
    await this.registerRouter();
    await this.callInitHook();
    await this.registerRouterHooks();
    await this.callBootstrapHook();

    this.isInitialized = true;
    this.logger.log(MESSAGES.APPLICATION_READY);
    return this;
  }
  
  public async registerRouter() {
    await this.registerMiddleware(this.httpAdapter);

    const prefix = this.config.getGlobalPrefix();
    const basePath = addLeadingSlash(prefix);
    this.routesResolver.resolve(this.httpAdapter, basePath);
  }

  public async listen(port: number | string): Promise<any>;
  public async listen(port: number | string, hostname: string): Promise<any>;
  public async listen(port: number | string, ...args: any[]): Promise<any> {
    this.assertNotInPreviewMode('listen');
    !this.isInitialized && (await this.init());

    ...
  }

  private async registerMiddleware(instance: any) {
    await this.middlewareModule.registerMiddleware(
      this.middlewareContainer,
      instance,
    );
  }
}

  • 위에서 살펴본 middlewareModule.register 메소드를 registerModules에서 사용하는 것을 볼 수 있습니다. (모듈을 만들때, 미들웨어 모듈들도 생성됩니다)
  • 이제 이 생성된 미들웨어 모듈들을 활용해서 registerMiddleware 를 하는 부분을 살펴보겠습니다.
  • registerRouter메소드인데요, 해당 메소드에서 registerMiddleware 를 호출하는 부분을 볼 수 있습니다.

registerMiddleware


 public async registerMiddleware(
    middlewareContainer: MiddlewareContainer,
    applicationRef: any,
  ) {
    const configs = middlewareContainer.getConfigurations();
    const registerAllConfigs = async (
      moduleKey: string,
      middlewareConfig: MiddlewareConfiguration[],
    ) => {
      for (const config of middlewareConfig) {
        await this.registerMiddlewareConfig(
          middlewareContainer,
          config,
          moduleKey,
          applicationRef,
        );
      }
    };

    const entriesSortedByDistance = [...configs.entries()].sort(
      ([moduleA], [moduleB]) => {
        return (
          this.container.getModuleByKey(moduleA).distance -
          this.container.getModuleByKey(moduleB).distance
        );
      },
    );
    for (const [moduleRef, moduleConfigurations] of entriesSortedByDistance) {
      await registerAllConfigs(moduleRef, [...moduleConfigurations]);
    }
  }
  • MiddlewareContainer에서 가져온 미들웨어 설정들을 순회하며, 각각의 설정을 애플리케이션의 특정 경로에 등록합니다.

registerAllConfigs


const registerAllConfigs = async (
      moduleKey: string,
      middlewareConfig: MiddlewareConfiguration[],
    ) => {
      for (const config of middlewareConfig) {
        await this.registerMiddlewareConfig(
          middlewareContainer,
          config,
          moduleKey,
          applicationRef,
        );
      }
    };
  • 각 모듈의 미들웨어 설정들을 순회하며 registerMiddlewareConfig 메서드를 통해 개별 설정을 등록합니다.

registerMiddlewareConfig


public async registerMiddlewareConfig(
  middlewareContainer: MiddlewareContainer,
  config: MiddlewareConfiguration,
  moduleKey: string,
  applicationRef: any,
) {
  const { forRoutes } = config;
  for (const routeInfo of forRoutes) {
    await this.registerRouteMiddleware(
      middlewareContainer,
      routeInfo as RouteInfo,
      config,
      moduleKey,
      applicationRef,
    );
  }
}
  • config에서 경로 기반 미들웨어 설정 (forRoutes)을 추출합니다.
  • 각 경로 설정 (routeInfo)에 대해 registerRouteMiddleware 메서드를 호출해 미들웨어를 등록합니다.

registerRouteMiddleware


public async registerRouteMiddleware(
  middlewareContainer: MiddlewareContainer,
  routeInfo: RouteInfo,
  config: MiddlewareConfiguration,
  moduleKey: string,
  applicationRef: any,
) {
  const middlewareCollection = [].concat(config.middleware);
  const moduleRef = this.container.getModuleByKey(moduleKey);

  for (const metatype of middlewareCollection) {
    const collection = middlewareContainer.getMiddlewareCollection(moduleKey);
    const instanceWrapper = collection.get(metatype);

    if (isUndefined(instanceWrapper)) {
      throw new RuntimeException();
    }
    if (instanceWrapper.isTransient) {
      return;
    }

    this.graphInspector.insertClassNode(
      moduleRef,
      instanceWrapper,
      'middleware',
    );
    const middlewareDefinition: Entrypoint<MiddlewareEntrypointMetadata> = {
      type: 'middleware',
      methodName: 'use',
      className: instanceWrapper.name,
      classNodeId: instanceWrapper.id,
      metadata: {
        key: routeInfo.path,
        path: routeInfo.path,
        requestMethod:
          (RequestMethod[routeInfo.method] as keyof typeof RequestMethod) ??
          'ALL',
        version: routeInfo.version,
      },
    };
    this.graphInspector.insertEntrypointDefinition(
      middlewareDefinition,
      instanceWrapper.id,
    );

    await this.bindHandler(
      instanceWrapper,
      applicationRef,
      routeInfo,
      moduleRef,
      collection,
    );
  }
}
const middlewareCollection = [].concat(config.middleware);
const moduleRef = this.container.getModuleByKey(moduleKey);
  • config.middleware를 배열로 변환하여 middlewareCollection에 저장합니다.
  • moduleKey에 해당하는 모듈 참조를 가져옵니다.
for (const metatype of middlewareCollection) {
  const collection = middlewareContainer.getMiddlewareCollection(moduleKey);
  const instanceWrapper = collection.get(metatype);

  if (isUndefined(instanceWrapper)) {
    throw new RuntimeException();
  }
  if (instanceWrapper.isTransient) {
    return;
  }
  • middlewareCollection을 순회하며, 각 미들웨어에 대한 instanceWrapper를 가져옵니다.
  • instanceWrapper가 정의되지 않았거나 일시적인(transient) 경우 예외를 처리합니다.
this.graphInspector.insertClassNode(
  moduleRef,
  instanceWrapper,
  'middleware',
);
const middlewareDefinition: Entrypoint<MiddlewareEntrypointMetadata> = {
  type: 'middleware',
  methodName: 'use',
  className: instanceWrapper.name,
  classNodeId: instanceWrapper.id,
  metadata: {
    key: routeInfo.path,
    path: routeInfo.path,
    requestMethod:
      (RequestMethod[routeInfo.method] as keyof typeof RequestMethod) ??
      'ALL',
    version: routeInfo.version,
  },
};
this.graphInspector.insertEntrypointDefinition(
  middlewareDefinition,
  instanceWrapper.id,
);

await this.bindHandler(
  instanceWrapper,
  applicationRef,
  routeInfo,
  moduleRef,
  collection,
);

  • 그래프 검사기를 통해 미들웨어 클래스 노드를 삽입합니다.
  • 미들웨어 엔트리포인트 정의를 삽입합니다.
  • bindHandler 메서드를 호출해 미들웨어 핸들러를 바인딩합니다.
  • middlewareDefinition 메타데이터의 경우 graphInspector(그래프 검사기)를 위해 설정하고 적용하는 부분이며, 실제 미들웨어 적용은 bindHandler 메서드를 통해 이루어집니다.
    • MiddlewareEntrypointMetadataEntrypoint는 그래프 검사 및 시각화 목적으로 사용됩니다. 실제 미들웨어 적용에는 사용되지 않습니다!

bindHandler


private async bindHandler(
  wrapper: InstanceWrapper<NestMiddleware>,
  applicationRef: HttpServer,
  routeInfo: RouteInfo,
  moduleRef: Module,
  collection: Map<InjectionToken, InstanceWrapper>,
) {
  const { instance, metatype } = wrapper;
  if (isUndefined(instance?.use)) {
    throw new InvalidMiddlewareException(metatype.name);
  }
  const isStatic = wrapper.isDependencyTreeStatic();
  if (isStatic) {
    const proxy = await this.createProxy(instance);
    return this.registerHandler(applicationRef, routeInfo, proxy);
  }

  const isTreeDurable = wrapper.isDependencyTreeDurable();

  await this.registerHandler(
    applicationRef,
    routeInfo,
    async <TRequest, TResponse>(
      req: TRequest,
      res: TResponse,
      next: () => void,
    ) => {
      try {
        const contextId = this.getContextId(req, isTreeDurable);
        const contextInstance = await this.injector.loadPerContext(
          instance,
          moduleRef,
          collection,
          contextId,
        );
        const proxy = await this.createProxy<TRequest, TResponse>(
          contextInstance,
          contextId,
        );
        return proxy(req, res, next);
      } catch (err) {
        let exceptionsHandler = this.exceptionFiltersCache.get(instance.use);
        if (!exceptionsHandler) {
          exceptionsHandler = this.routerExceptionFilter.create(
            instance,
            instance.use,
            undefined,
          );
          this.exceptionFiltersCache.set(instance.use, exceptionsHandler);
        }
        const host = new ExecutionContextHost([req, res, next]);
        exceptionsHandler.next(err, host);
      }
    },
  );
}

  • bindHandler 메서드를 통해 미들웨어 인스턴스를 실제 경로에 바인딩합니다.
  • 미들웨어 인스턴스를 HTTP 서버에 핸들러로 등록하여 요청을 처리합니다.
const { instance, metatype } = wrapper;
  • wrapper에서 미들웨어 인스턴스와 클래스 타입을 추출합니다.
const isStatic = wrapper.isDependencyTreeStatic();
if (isStatic) {
  const proxy = await this.createProxy(instance);
  return this.registerHandler(applicationRef, routeInfo, proxy);
}
  • 인스턴스가 정적 종속성 트리를 가지고 있는지 확인합니다.
  • 정적 종속성 트리라면 바로 프록시를 생성하고 핸들러를 등록합니다.
await this.registerHandler(applicationRef, routeInfo, proxy)
  • 핸들러를 등록합니다.
  • 정적이지 않은 경우, 컨텍스트 ID를 가져오고, 종속성을 주입한 후 프록시를 생성하여 핸들러를 등록합니다.
await this.registerHandler(
  applicationRef,
  routeInfo,
  async <TRequest, TResponse>(
    req: TRequest,
    res: TResponse,
    next: () => void,
  ) => {
    try {
      const contextId = this.getContextId(req, isTreeDurable);
      const contextInstance = await this.injector.loadPerContext(
        instance,
        moduleRef,
        collection,
        contextId,
      );
      const proxy = await this.createProxy<TRequest, TResponse>(
        contextInstance,
        contextId,
      );
      return proxy(req, res, next);
    } catch (err) {
      let exceptionsHandler = this.exceptionFiltersCache.get(instance.use);
      if (!exceptionsHandler) {
        exceptionsHandler = this.routerExceptionFilter.create(
          instance,
          instance.use,
          undefined,
        );
        this.exceptionFiltersCache.set(instance.use, exceptionsHandler);
      }
      const host = new ExecutionContextHost([req, res, next]);
      exceptionsHandler.next(err, host);
    }
  },
);
  • 동적 핸들러를 등록합니다.
const contextId = this.getContextId(req, isTreeDurable);
  • 요청과 내구성을 기반으로 컨텍스트 ID를 생성합니다.
const contextInstance = await this.injector.loadPerContext(
  instance,
  moduleRef,
  collection,
  contextId,
);
  • injector를 사용하여 컨텍스트 인스턴스를 로드합니다.
const proxy = await this.createProxy<TRequest, TResponse>(
  contextInstance,
  contextId,
);
  • 컨텍스트 인스턴스를 기반으로 프록시를 생성합니다.
return proxy(req, res, next);
  • 요청, 응답, next 함수를 프록시를 통해 실행합니다.

  • 해당 코드는 registerHandler 메서드의 proxy 매개변수로 전달됩니다.

// 해당 함수는 아래에서 사용된다 (middlewareFunction)
async <TRequest, TResponse>(
    req: TRequest,
    res: TResponse,
    next: () => void,
) => {
    try {
      const contextId = this.getContextId(req, isTreeDurable);
      const contextInstance = await this.injector.loadPerContext(
        instance,
        moduleRef,
        collection,
        contextId,
      );
      const proxy = await this.createProxy<TRequest, TResponse>(
        contextInstance,
        contextId,
      );
      return proxy(req, res, next);
    } catch (err) {
      let exceptionsHandler = this.exceptionFiltersCache.get(instance.use);
      if (!exceptionsHandler) {
        exceptionsHandler = this.routerExceptionFilter.create(
          instance,
          instance.use,
          undefined,
        );
        this.exceptionFiltersCache.set(instance.use, exceptionsHandler);
      }
      const host = new ExecutionContextHost([req, res, next]);
      exceptionsHandler.next(err, host);
    }
  },
  
// 위에서 전달된 proxy 함수가 활용됩니다.
private async registerHandler(
  applicationRef: HttpServer,
  routeInfo: RouteInfo,
  proxy: <TRequest, TResponse>(
    req: TRequest,
    res: TResponse,
    next: () => void,
  ) => void,
) {
  const { method } = routeInfo;
  const paths = this.routeInfoPathExtractor.extractPathsFrom(routeInfo);
  const isMethodAll = isRequestMethodAll(method);
  const requestMethod = RequestMethod[method];
  const router = await applicationRef.createMiddlewareFactory(method);
  const middlewareFunction = isMethodAll
    ? proxy // 활용
    : <TRequest, TResponse>(
        req: TRequest,
        res: TResponse,
        next: () => void,
      ) => {
        if (applicationRef.getRequestMethod(req) === requestMethod) {
          return proxy(req, res, next);
        }
        return next();
      };
  const pathsToApplyMiddleware = [];
  paths.some(path => path.match(/^\/?$/))
    ? pathsToApplyMiddleware.push('/')
    : pathsToApplyMiddleware.push(...paths);
  pathsToApplyMiddleware.forEach(path => router(path, middlewareFunction));
}

  • 전달받은 proxy 함수를 활용해서 register합니다.
const { method } = routeInfo;
const paths = this.routeInfoPathExtractor.extractPathsFrom(routeInfo);
const isMethodAll = isRequestMethodAll(method);
const requestMethod = RequestMethod[method];
  • 경로 및 메서드를 추출합니다.
  • isMethodAll 변수는 해당 메서드가 모든 메서드를 의미하는지 여부를 나타냅니다.
const router = await applicationRef.createMiddlewareFactory(method);
const middlewareFunction = isMethodAll
  ? proxy
  : <TRequest, TResponse>(
      req: TRequest,
      res: TResponse,
      next: () => void,
    ) => {
      if (applicationRef.getRequestMethod(req) === requestMethod) {
        return proxy(req, res, next);
      }
      return next();
    };
  • 미들웨어 팩토리를 생성합니다.
  • 미들웨어 함수를 정의합니다.
const pathsToApplyMiddleware = [];
paths.some(path => path.match(/^\/?$/))
  ? pathsToApplyMiddleware.push('/')
  : pathsToApplyMiddleware.push(...paths);
pathsToApplyMiddleware.forEach(path => router(path, middlewareFunction));
  • 경로에 미들웨어를 적용합니다.
  • 우선 paths 배열에서 루트 경로 ('/' 또는 빈 문자열 '')를 확인합니다.
    • paths.some(path => path.match(/^/?$/))는 경로 배열 중 하나라도 루트 경로 ('/' 또는 '')와 일치하는지 확인합니다.
    • path.match(/^/?$/) 정규식은 루트 경로('/' 또는 빈 문자열 '')를 찾습니다.
  • 조건에 따라 경로를 추가합니다:
    • 일단 루트 경로가 있을 경우, pathsToApplyMiddleware.push('/')
      • **'/'**를 pathsToApplyMiddleware 배열에 추가합니다.
    • 만약 루트 경로가 없을 경우, pathsToApplyMiddleware.push(...paths)
      • paths 배열의 모든 경로를 pathsToApplyMiddleware 배열에 추가합니다.
  • 마지막으로 경로에 대해 미들웨어를 바인딩합니다.
    • pathsToApplyMiddleware 배열의 각 경로에 대해 미들웨어 함수를 바인딩합니다.
    • **router(path, middlewareFunction)**를 호출하여 각 경로에 middlewareFunction을 적용합니다.

+proxy?


  • proxy 함수는 미들웨어가 요청을 처리하는 동안 요청을 해당 미들웨어로 프록시하고, 미들웨어 처리가 끝나면 **next()**를 호출하여 다음 미들웨어나 라우트 핸들러로 요청을 전달하는데요, 이를 통해 미들웨어가 각 요청을 올바르게 처리하고, 종속성을 주입하며, 예외를 처리할 수 있습니다.
  • 미들웨어는 특정 경로와 HTTP 메서드에 대해 요청을 처리할 수 있도록 설정됩니다!

예시


  • 해당 코드를 이해하기 너무 어려웠습니다. 예시로 풀어서 다시한번 보려고합니다.
@Injectable()
class UserMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: () => void) {
    console.log('user middleware!');
    next();
  }
}
  • 다음과 같은 미들웨어가 있다고 가정하겠습니다.
const wrapper = new InstanceWrapper({
  metatype: UserMiddleware,
  instance: new UserMiddleware(),
});

await bindHandler(
  wrapper,
  httpServerInstance,
  { path: '/users', method: RequestMethod.GET },
  userModule,
  middlewareCollection,
);
  • 해당 미들웨어에 적용된 routeInfo는 다음과 같다고 가정하겠습니다. { path: '/users', method: RequestMethod.GET }
  • UserMiddleware/users 경로에 바인딩됩니다.
const paths = this.routeInfoPathExtractor.extractPathsFrom(routeInfo); // ['/users', '/products']
const isMethodAll = isRequestMethodAll(method); // false
const requestMethod = RequestMethod[method]; // 'GET'
  • registerHandler 메소드에서 다음과 같은 정보로 추출됩니다.
const router = await applicationRef.createMiddlewareFactory(method); // GET 메서드에 대한 미들웨어 팩토리 생성
  • 미들웨어 팩토리가 생성됩니다.
  const middlewareFunction = isMethodAll
    ? proxy // pass
    : <TRequest, TResponse>(
        req: TRequest,
        res: TResponse,
        next: () => void,
      ) => {
        if (applicationRef.getRequestMethod(req) === requestMethod) {
          return proxy(req, res, next);
        }
        return next();
      };
  • isMethodAllfalse이므로 두 번째 조건부 함수 정의가 사용됩니다.
const pathsToApplyMiddleware = [];
paths.some(path => path.match(/^\/?$/))
  ? pathsToApplyMiddleware.push('/')
  : pathsToApplyMiddleware.push(...paths); // ['/users']
pathsToApplyMiddleware.forEach(path => router(path, middlewareFunction)); // 각 경로에 미들웨어 함수 바인딩
  • 각 경로에 대해서 적용합니다.
    • 여기서는 전달받은 /user 경로 + GET method에 대해서 middlewareFunction을 적용합니다.
    • /users 경로에 대해 GET 요청이 들어오면 해당 미들웨어가 실행됩니다.

모듈 정렬


const entriesSortedByDistance = [...configs.entries()].sort(
  ([moduleA], [moduleB]) => {
    return (
      this.container.getModuleByKey(moduleA).distance -
      this.container.getModuleByKey(moduleB).distance
    );
  },
);
  • 모듈들을 distance 기준으로 정렬합니다.
  • distance는 모듈 간의 종속성을 기반으로 한 거리 값입니다.
  • 정렬을 왜 하는가?

  • dependency hierarchy를 고려하여 올바른 순서대로 미들웨어를 초기화하고 등록하기 위함입니다.
  • 첫번째, 종속성 해결을 위해서 입니다.
    • 모듈 간에 종속성이 있는 경우, 종속된 모듈이 먼저 초기화되어야 합니다. 예를 들어, ModuleBModuleA에 의존하는 경우, ModuleA가 먼저 초기화되어야 ModuleB가 올바르게 동작할 수 있습니다.
    • 이러한 종속성을 고려하여 모듈을 정렬하면, 필요한 모듈들이 먼저 초기화되고, 이후에 이를 참조하는 모듈들이 초기화됩니다.
  • 두번째, 올바른 초기화 순서를 지키기 위해서 입니다.
    • 위와 비슷한 이유입니다. 미들웨어 등록 과정에서 미들웨어가 참조하는 다른 모듈들이 올바르게 초기화된 상태여야 합니다. 그렇지 않으면 미들웨어가 올바르게 동작하지 않을 수 있습니다.
    • 정렬을 통해 초기화 순서를 보장함으로써, 미들웨어가 필요한 종속성을 확실하게 사용할 수 있게 합니다.
  • 세번째, 의존성 그래프를 구성하기 위해서 입니다.
    • NestJS는 의존성 그래프를 통해 모듈과 미들웨어의 관계를 구성합니다. 이 그래프를 구성할 때 올바른 순서로 모듈을 처리해야 전체적인 애플리케이션 구조가 올바르게 형성됩니다.

미들웨어 설정 등록


for (const [moduleRef, moduleConfigurations] of entriesSortedByDistance) {
  await registerAllConfigs(moduleRef, [...moduleConfigurations]);
}
  • 정렬된 모듈들의 미들웨어 설정들을 순회하며 registerAllConfigs 함수를 호출해 미들웨어를 등록합니다.
  • registerMiddlewareConfig 메서드는 특정 모듈의 미들웨어 설정을 등록하는 역할을 합니다.

registerMiddleware 정리


  • registerMiddleware 메서드는 MiddlewareContainer에서 모든 미들웨어 설정을 가져와 각 모듈에 대해 순차적으로 미들웨어를 등록합니다.
  • registerMiddlewareConfig 메서드는 특정 모듈의 미들웨어 설정을 경로 기반으로 등록합니다.
  • registerRouteMiddleware 메서드는 특정 경로에 미들웨어를 실제로 등록하고(프록시를 설정하여 미들웨어를 거치게 만듦), 그래프 검사기와 연동하여 미들웨어를 관리합니다.

2. 다시 마지막 nestApplication, init()


 public async init(): Promise<this> {
    if (this.isInitialized) {
      return this;
    }

    this.applyOptions();
    await this.httpAdapter?.init();

    const useBodyParser =
      this.appOptions && this.appOptions.bodyParser !== false;
    useBodyParser && this.registerParserMiddleware();

    await this.registerModules();
    await this.registerRouter();
    await this.callInitHook();
    await this.registerRouterHooks();
    await this.callBootstrapHook();

    this.isInitialized = true;
    this.logger.log(MESSAGES.APPLICATION_READY);
    return this;
  }
  • registerRouter() 에서 해당 과정을 거쳐 미들웨어들이 등록됩니다.
  • 그리고 아래 메소드들을 호출하고 nestApplication이 initialized됩니다!
    • 아래 메소드에 대해서는 다음 주제에서 알아보도록 하겠습니다.
  • 이제 미들웨어가 적용된 nestApplication을 사용할 수 있게 되었습니다.

이어가며


  • 너무 긴 과정이였습니다! 다음 글에는 긴 글을 읽기 쉽게 총 정리하고 마무리하려고합니다.