NestJS Backend 초기 설정

원민관·2026년 7월 2일

[TIL]

목록 보기
215/215
post-thumbnail

0. 들어가며 ✅

백엔드를 구성할 때 가장 먼저 마주하는 고민은 "설정 코드를 어디에 어떻게 둘 것인가"입니다.

애플리케이션을 켜는 흐름(Bootstrap), 환경 변수를 읽고 가공하는 흐름(Configuration), 데이터베이스 연결(Database), 요청 처리 파이프라인(Cross-cutting) 네 가지 관심사로 분리했습니다.

이번 포스팅에서는 실제 코드를 따라가며 각 파일이 어떤 역할을 하고, 어떤 기준으로 관심사를 분리했는지 소개하고자 합니다.

1. Bootstrap ✅

애플리케이션이 켜지는 순서를 담당하는 세 파일입니다. main.ts가 시작점이고, app.module.ts가 의존성 그래프의 뿌리이며, application.setup.ts가 전역 설정을 한곳에서 조율합니다.

backend/src/main.ts ✍️

import { NestFactory } from '@nestjs/core';
import { AppModule } from '@/app.module';
import { setupApplication } from '@/setup/application.setup';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const { port } = setupApplication(app);
  await app.listen(port);
}

bootstrap();

bootstrap 함수는 NestJS 애플리케이션의 진입점입니다. 실행 순서를 한 줄씩 따라가 보겠습니다.

먼저 NestFactory.create(AppModule)은 AppModule을 루트 모듈로 삼아 Nest 애플리케이션 인스턴스를 생성합니다. 이 한 줄이 실행되는 순간에 Nest는 AppModule에서 시작해 imports로 연결된 모듈들인 ConfigModule과 DatabaseModule을 따라가며 의존성 그래프를 구성하고 DI 컨테이너를 초기화합니다.

이어서 setupApplication(app)은 생성된 app에 미들웨어, 파이프, 인터셉터, 필터 같은 전역 설정을 적용하고 설정 객체인 config를 반환합니다. 이때 const { port } = ...는 구조 분해 할당으로, 반환된 객체에서 port 값만 뽑아내는 문법입니다. 마지막으로 app.listen(port)이 앞에서 추출한 포트로 HTTP 서버를 띄웁니다.

backend/src/app.module.ts ✍️

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from '@/modules/database/database.module';
import registerAppConfig from '@/setup/config/register-app.config';
import registerDatabaseConfig from '@/setup/config/register-database.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
      load: [registerAppConfig, registerDatabaseConfig],
    }),
    DatabaseModule,
  ],
})

export class AppModule {}

AppModule은 애플리케이션의 루트 모듈입니다. 모듈이란 관련 기능인 프로바이더나 컨트롤러 등을 하나로 묶는 단위이며 @Module 데코레이터로 정의합니다. 설정 단계에서는 imports에 두 개의 모듈을 등록합니다.

첫 번째는 ConfigModule.forRoot로, 환경 변수 관리를 담당하는 설정 모듈을 등록합니다.

forRoot는 클래스 인스턴스를 만들지 않고 클래스 자체에서 바로 호출하는 정적 메서드로, 해당 메서드가 호출되는 시점이 곧 .env 파일을 찾아 읽고 dotenv로 파싱해 process.env에 병합하는 로직이 실제로 실행되는 시점입니다.

옵션을 보면 isGlobal을 true로 두어 ConfigModule을 전역 모듈로 등록하는데, 전역으로 등록하면 루트에서 한 번만 선언해도 다른 어떤 모듈에서든 별도 import 없이 ConfigService를 주입받아 쓸 수 있습니다. envFilePath는 읽어들일 환경 변수 파일의 경로를 지정하고, load는 .env의 flat한 KEY=VALUE 값을 중첩 객체 형태로 가공하는 팩토리 함수들을 등록합니다. 이 덕분에 configService.get('app.port')처럼 점 표기법으로 조회할 수 있게 되며, 자세한 내용은 2장에서 다룹니다.

두 번째는 DatabaseModule로, TypeORM 기반 DB 연결을 담당하는 모듈이며 3장에서 다룹니다.

backend/src/setup/application.setup.ts ✍️

import { INestApplication } from '@nestjs/common';
import { AppConfig, getAppConfig } from '@/setup/config/get-app-config';
import { setupFilters } from '@/setup/filters/setup';
import { setupInterceptors } from '@/setup/interceptors/setup';
import { setupMiddleware } from '@/setup/middleware/setup';
import { setupPipes } from '@/setup/pipes/setup';

export function setupApplication(app: INestApplication): AppConfig {
  const config = getAppConfig(app);

  setupMiddleware(app, config);
  setupPipes(app);
  setupInterceptors(app);
  setupFilters(app);

  return config;
}

setupApplication은 흩어져 있는 전역 설정을 하나의 순서로 조율하는 함수입니다.

먼저 getAppConfig(app)으로 앱 설정인 포트와 CORS 허용 출처를 읽어 config에 담고, 이어서 setupMiddleware, setupPipes, setupInterceptors, setupFilters 순으로 전역 설정을 적용합니다. 마지막으로 config를 반환해 main.ts가 port를 꺼내 서버를 띄울 수 있게 합니다.

2. Configuration ✅

환경 변수를 다루는 방식은 두 단계로 나뉩니다. 등록 단계에서 .env의 flat한 값을 의미 있는 중첩 객체로 가공하고, 조회 단계에서 그 값을 타입 안전하게 꺼내 씁니다.

backend/src/setup/config/register-app.config.ts ✍️

import { registerAs } from '@nestjs/config';

export default registerAs('app', () => ({
  port: parseInt(process.env.PORT ?? '3000', 10),
  cors: {
    origins: process.env.ALLOWED_ORIGINS
      ? process.env.ALLOWED_ORIGINS.split(',').map((o) => o.trim())
      : [],
  },
}));

registerAs('app', factory)는 설정 덩어리에 app이라는 네임스페이스, 즉 이름을 붙여 등록하는 함수입니다. 팩토리 함수가 반환하는 객체가 app 네임스페이스 아래에 자리 잡아 나중에 configService.get('app.port')처럼 조회할 수 있게 됩니다.

여기서 눈여겨볼 지점은 두 가지입니다.

하나는 타입 변환인데, process.env에 담기는 값은 언제나 문자열이므로 포트를 숫자로 쓰기 위해 parseInt(process.env.PORT ?? '3000', 10)으로 변환합니다. ?? '3000'은 PORT가 없을 때의 기본값이고 두 번째 인자 10은 10진수 파싱을 의미합니다.

다른 하나는 문자열을 배열로 가공하는 부분으로, ALLOWED_ORIGINS는 a.com,b.com처럼 콤마로 이어진 하나의 문자열이기 때문에 split(',')으로 나누고 map으로 공백을 제거해 배열로 만들며 값이 없으면 빈 배열을 씁니다.

backend/src/setup/config/register-database.config.ts ✍️

import { registerAs } from '@nestjs/config';

export default registerAs('database', () => ({
  host: process.env.DB_HOST ?? '127.0.0.1',
  port: parseInt(process.env.DB_PORT ?? '3306', 10),
  username: process.env.DB_USERNAME ?? 'root',
  password: process.env.DB_PASSWORD ?? '',
  database: process.env.DB_DATABASE ?? 'portfolio',
}));

database라는 네임스페이스로 DB 접속 정보를 등록합니다. app 설정과 동일한 패턴으로, 각 값에 기본값을 두어 환경 변수가 비어 있어도 로컬 개발 기본값인 127.0.0.1, 3306, root 등으로 동작하도록 안전장치를 마련했습니다. DB_PORT 역시 문자열이므로 parseInt로 숫자 변환합니다. 이렇게 app과 database를 별도 네임스페이스로 분리해두면 설정이 늘어나도 도메인별로 깔끔하게 관리할 수 있습니다.

backend/src/setup/config/get-app-config.ts ✍️

import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

export type AppConfig = {
  port: number;
  corsOrigins: string[];
};

export function getAppConfig(app: INestApplication): AppConfig {
  const configService = app.get(ConfigService);

  return {
    port: configService.get<number>('app.port', 3000),
    corsOrigins: configService.get<string[]>('app.cors.origins', []),
  };
}

등록된 설정을 실제로 꺼내오는 조회 함수입니다.

app.get(ConfigService)는 DI 컨테이너에서 ConfigService 인스턴스를 직접 꺼내는데, getAppConfig는 컨트롤러나 프로바이더가 아니라 부트스트랩 단계에서 호출되는 일반 함수이므로 생성자 주입 대신 app.get으로 서비스를 가져옵니다.

그다음 configService.get('app.port', 3000)은 register-app.config.ts에서 app 네임스페이스로 등록한 값을 점 표기법으로 조회하며, 두 번째 인자 3000은 해당 키가 없을 때 쓰는 기본값입니다.

반환 타입을 AppConfig로 명시해 이 설정을 사용하는 쪽인 application.setup.ts나 setupMiddleware에서 config.port와 config.corsOrigins를 타입 안전하게 쓸 수 있습니다.

또한 AppConfig 타입에 corsOrigins처럼 소비하는 쪽 관점의 이름을 써서 등록 구조인 app.cors.origins와 조회 결과의 형태를 분리해 소비 측 코드를 단순하게 유지했습니다.

backend/src/setup/config/get-database-config.ts ✍️

import { ConfigService } from '@nestjs/config';

export type DatabaseConfig = {
  host: string;
  port: number;
  username: string;
  password: string;
  database: string;
};

export function getDatabaseConfig(
  configService: ConfigService,
): DatabaseConfig {
  return {
    host: configService.get<string>('database.host', '127.0.0.1'),
    port: configService.get<number>('database.port', 3306),
    username: configService.get<string>('database.username', 'root'),
    password: configService.get<string>('database.password', ''),
    database: configService.get<string>('database.database', 'portfolio'),
  };
}

DB 설정 조회 함수입니다.

getAppConfig와 역할은 같지만 ConfigService를 인자로 직접 받는다는 차이가 있습니다. 이는 getDatabaseConfig가 DatabaseModule의 useFactory 안에서 호출되기 때문인데, 그 시점에는 이미 ConfigService가 주입되어 넘어오므로 app.get을 다시 호출할 필요 없이 그대로 받아 씁니다.

database.host나 database.port 같은 네임스페이스 경로로 값을 조회하고 각각 기본값을 두어 안정성을 확보하며, 반환 타입 DatabaseConfig로 TypeORM 설정 객체를 만들 때 타입 오류를 방지합니다.

3. Database ✅

backend/src/modules/database/database.module.ts ✍️

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { getDatabaseConfig } from '@/setup/config/get-database-config';

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        const db = getDatabaseConfig(configService);

        return {
          type: 'mysql',
          host: db.host,
          port: db.port,
          username: db.username,
          password: db.password,
          database: db.database,
          charset: 'utf8mb4',
          autoLoadEntities: true,
          synchronize: false,
        };
      },
    }),
  ],
})

export class DatabaseModule {}

TypeOrmModule은 NestJS에서 TypeORM을 연동하기 위한 공식 모듈입니다. 여기서 TypeORM은 코드의 클래스나 객체와 DB 테이블을 매핑해주는 ORM 라이브러리를 말합니다.

forRoot가 아니라 forRootAsync를 사용했다는 점이 특징적입니다. forRoot는 옵션을 즉시 동기적으로 받아 연결을 구성하기 때문에 process.env.DB_HOST를 직접 참조해 넣을 수도 있지만, ConfigModule이 검증하고 가공한 값을 우회하게 됩니다. 반면 forRootAsync는 다른 프로바이더인 ConfigService가 준비된 뒤에 그 값을 주입받아 설정을 구성할 수 있어 설정 소스를 ConfigService로 일원화할 수 있습니다.

옵션을 하나씩 보면, imports는 이 팩토리에서 ConfigModule이 제공하는 프로바이더를 쓰겠다고 선언하는 부분이고, inject는 useFactory 함수에 주입할 프로바이더 목록으로 여기 나열한 순서대로 팩토리의 매개변수로 전달됩니다. useFactory는 최종적으로 TypeORM이 사용할 연결 설정 객체를 반환하는 팩토리 함수로, 주입받은 configService를 getDatabaseConfig에 넘겨 타입이 보장된 db 객체를 얻고 그 값으로 연결 옵션을 구성합니다.

반환하는 연결 옵션 중에서 type은 사용할 데이터베이스 종류인 mysql을 지정하고, charset을 utf8mb4로 두어 이모지를 포함한 다국어 문자를 온전히 저장하도록 합니다. autoLoadEntities를 true로 두면 각 모듈에서 등록한 엔티티를 자동으로 불러와 매번 수동 등록하지 않아도 됩니다. synchronize는 false로 두어 엔티티 정의를 실제 DB 스키마에 자동 반영하는 기능을 꺼두는데, 자동 동기화는 편리하지만 운영 환경에서 의도치 않은 스키마 변경이나 데이터 손실을 부를 수 있어 마이그레이션으로 스키마를 관리하기 위해 꺼두는 것이 안전합니다.

4. Cross-cutting ✅

Cross-cutting, 즉 횡단 관심사는 특정 라우트에 국한되지 않고 모든 요청에 공통으로 적용되는 처리를 뜻합니다. Aspect-Oriented Programming(AOP)는 이러한 횡단 관심사를 비즈니스 로직에서 분리하여 모듈화하는 프로그래밍 기법입니다.

NestJS에서 요청은 대체로 미들웨어, 가드, 인터셉터의 전처리, 파이프, 핸들러, 인터셉터의 후처리, 그리고 예외가 발생하면 필터를 거쳐 응답으로 흐릅니다. 이번 설정에서는 미들웨어, 파이프, 인터셉터, 필터를 각각 별도 파일로 분리하고 application.setup.ts에서 순서대로 등록했습니다.

4-1. Middleware 🎯

backend/src/setup/middleware/setup.ts ✍️

import { INestApplication } from '@nestjs/common';
import helmet from 'helmet';
import { AppConfig } from '@/setup/config/get-app-config';

export function setupMiddleware(
  app: INestApplication,
  config: AppConfig,
): void {
  app.setGlobalPrefix('api');
  app.use(helmet());
  app.enableCors({
    origin: config.corsOrigins.length > 0 ? config.corsOrigins : true,
    methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
    credentials: true,
  });
}

미들웨어는 요청이 컨트롤러에 도달하기 전에 가로채 부가 처리를 하는 계층입니다.

미들웨어 셋업 파일에서는 세 가지 설정을 적용합니다.

먼저 app.setGlobalPrefix('api')는 모든 라우트 앞에 공통 접두어 api를 붙여 /users를 /api/users로 만드는데, 프록시 뒤 라우팅 구분이나 API 경로 통일에 유용합니다.

다음으로 app.use(helmet())은 Express 미들웨어인 helmet을 적용해 Content-Security-Policy나 X-Frame-Options 같은 보안 관련 HTTP 헤더를 자동으로 붙이며, XSS와 클릭재킹, MIME 스니핑 같은 널리 알려진 취약점을 완화하므로 다른 미들웨어보다 앞서 적용하는 것이 좋습니다.

마지막으로 app.enableCors는 브라우저의 교차 출처 요청, 즉 다른 프로토콜이나 도메인, 포트에서 오는 요청을 허용하도록 CORS를 설정하는데, origin은 설정에 허용 출처가 있으면 그 목록을 쓰고 없으면 true로 모든 출처를 허용하며 credentials를 true로 두어 쿠키나 인증 헤더를 주고받을 수 있게 합니다.

여기서 config는 getAppConfig가 만든 AppConfig로, 환경 변수에서 읽은 CORS 허용 출처가 미들웨어까지 이어지는 흐름을 확인할 수 있습니다.

4-2. Pipes 🎯

backend/src/setup/pipes/setup.ts ✍️

import { INestApplication, ValidationPipe } from '@nestjs/common';

export function setupPipes(app: INestApplication): void {
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );
}

파이프는 요청이 핸들러에 도달하기 직전에 데이터를 검증하고 변환하는 계층입니다.

useGlobalPipes로 ValidationPipe를 전역 등록하면 모든 요청의 DTO, 즉 요청 데이터 구조를 정의한 클래스가 class-validator 데코레이터 기준으로 자동 검증됩니다.

옵션을 보면 whitelist를 true로 두어 DTO에 정의되지 않은 속성을 자동으로 제거하고, forbidNonWhitelisted를 true로 두어 정의되지 않은 속성이 들어오면 제거에서 그치지 않고 요청 자체를 오류로 막아 예상치 못한 필드를 차단하는 강한 보호막을 둡니다. 그리고 transform을 true로 두어 들어온 값을 DTO 타입에 맞게 자동 변환하는데, 예를 들어 URL 파라미터의 문자열 "1"을 숫자 타입으로 바꿔줍니다.

4-3. Interceptor 🎯

backend/src/setup/interceptors/setup.ts ✍️

import { ClassSerializerInterceptor, INestApplication } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

export function setupInterceptors(app: INestApplication): void {
  app.useGlobalInterceptors(
    new ClassSerializerInterceptor(app.get(Reflector)),
  );
}

인터셉터는 요청 처리 전후 양쪽에 개입할 수 있는 계층으로 로깅이나 응답 변형, 캐싱 등에 쓰이며, 검증만 담당하는 파이프나 예외만 처리하는 필터보다 개입 범위가 넓습니다.

인터셉터 셋업 파일에서는 ClassSerializerInterceptor를 전역 등록하는데, 이 인터셉터는 응답으로 나가는 객체에서 @Exclude 같은 데코레이터로 표시된 필드, 예를 들어 비밀번호를 직렬화 시, 즉 객체를 JSON으로 변환할 때 자동으로 제외합니다. 생성자에 넘기는 app.get(Reflector)는 데코레이터로 붙은 메타데이터를 읽어들이는 데 필요한 도구로 DI 컨테이너에서 꺼내 전달합니다.

4-4. Filters 🎯

예외 처리는 네 파일로 나뉩니다.

등록을 담당하는 setup, 처리 진입점인 filter, 응답을 조립하는 build, 그리고 형태를 정의하는 types입니다. 관심사를 잘게 쪼개 각 파일이 한 가지 일만 하도록 설계했습니다.

backend/src/setup/filters/setup.ts ✍️

import { INestApplication } from '@nestjs/common';
import { HttpExceptionFilter } from '@/setup/filters/http-exception.filter';

export function setupFilters(app: INestApplication): void {
  app.useGlobalFilters(new HttpExceptionFilter());
}

useGlobalFilters로 커스텀 예외 필터를 전역 등록합니다. 이렇게 하면 컨트롤러마다 개별 처리를 하지 않아도 애플리케이션 전역에서 일관된 에러 응답 포맷을 보장할 수 있습니다.

backend/src/setup/filters/http-exception.filter.ts ✍️

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { buildErrorResponse } from '@/setup/filters/build-error-response';

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(HttpExceptionFilter.name);

  catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse<Response>();
    const req = ctx.getRequest<Request>();

    const errorResponse = buildErrorResponse(exception, req.url);

    if (errorResponse.statusCode >= HttpStatus.INTERNAL_SERVER_ERROR) {
      this.logger.error(exception);
    }

    res.status(errorResponse.statusCode).json(errorResponse);
  }
}

예외 처리의 진입점입니다.

host.switchToHttp는 실행 컨텍스트를 HTTP 컨텍스트로 전환해 Express의 Request와 Response 객체를 꺼냅니다. buildErrorResponse에 예외와 요청 경로를 넘겨 표준화된 에러 응답 객체를 만듭니다. 조립 로직 자체는 별도 파일로 분리했습니다.

그다음 상태 코드가 500 이상일 때만 logger.error로 로그를 남기는데, 이는 서버 내부 오류인 5xx만 로그로 남기고 잘못된 요청 같은 4xx는 클라이언트 책임이므로 서버 에러 로그를 남기지 않아 로그 노이즈를 줄이기 위함입니다.

마지막으로 res.status(...).json(...)으로 상태 코드와 본문을 실어 응답합니다. 이 파일은 예외를 받아 로깅하고 응답한다는 흐름만 담당하고 응답 본문을 어떻게 만들지는 아래 파일에 위임한 점이 특징입니다.

backend/src/setup/filters/build-error-response.ts ✍️

import { HttpException, HttpStatus } from '@nestjs/common';
import { ExceptionBody, ErrorResponse } from '@/setup/filters/error-response.types';

export function buildErrorResponse(
  exception: unknown,
  path: string,
): ErrorResponse {
  const timestamp = new Date().toISOString();

  if (exception instanceof HttpException) {
    const statusCode = exception.getStatus();
    const body = exception.getResponse() as ExceptionBody;

    return {
      statusCode,
      message: body.message ?? 'Internal server error',
      error: body.error,
      path,
      timestamp,
    };
  }

  return {
    statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
    message: 'Internal server error',
    path,
    timestamp,
  };
}

에러 응답 본문을 조립하는 함수입니다.

예외를 두 갈래로 나눠 처리하는데, 예외가 HttpException인 경우는 Nest가 던지는 알려진 예외이므로 getStatus로 상태 코드를, getResponse로 본문을 꺼내 message와 error를 채우며 메시지가 없으면 기본 문구로 대체합니다.

그 외의 경우는 예상치 못한 오류이므로 무조건 500 Internal Server Error로 처리하고 내부 정보가 새어 나가지 않도록 일반적인 메시지만 노출합니다.

backend/src/setup/filters/error-response.types.ts ✍️

export type ExceptionBody = {
  message?: string | string[];
  error?: string;
};

export type ErrorResponse = {
  statusCode: number;
  message: string | string[];
  error?: string;
  path: string;
  timestamp: string;
};

에러 응답과 관련한 타입을 모아둔 파일입니다.

ExceptionBody는 HttpException의 getResponse가 돌려주는 본문의 형태로, message가 문자열 하나일 수도 배열일 수도 있어 string 또는 string 배열로 두었는데 이는 ValidationPipe가 여러 검증 오류를 배열로 넘기기 때문입니다.

ErrorResponse는 최종적으로 클라이언트에 내려가는 응답 구조로 상태 코드, 메시지, 에러 종류, 경로, 시각을 담습니다. 타입을 별도 파일로 분리하면 필터와 조립 함수가 같은 규격을 공유하게 되어 응답 구조가 코드 전반에서 일관성을 유지할 수 있게 됩니다.

5. 마치며 ✅

지금까지 15개의 백엔드 설정 파일을 Bootstrap, Configuration, Database, Cross-cutting이라는 네 관심사로 나눠 살펴봤습니다.

다만 4장에서 요청 파이프라인 순서를 미들웨어, 가드, 인터셉터의 전처리, 파이프, 핸들러, 인터셉터의 후처리, 필터라고 밝혀두고도 정작 Guard는 다루지 않았습니다.

Guard는 요청을 처리해도 되는지 여부, 즉 인증이나 인가를 판단하는 계층인데 현재 구조에는 아직 인증 로직이 붙어 있지 않기 때문입니다.

인증이 추가되는 시점에는 미들웨어가 아니라 Guard 자리에 로직이 들어가야 합니다. 미들웨어는 Express 레벨에서 동작해 라우트 핸들러나 컨트롤러의 메타데이터에 접근할 수 없는 반면 Guard는 ExecutionContext를 통해 어떤 컨트롤러와 핸들러로 향하는 요청인지, 그리고 @Roles 같은 커스텀 데코레이터로 붙은 메타데이터까지 읽을 수 있어 라우트별로 세밀한 인가 조건을 걸 수 있기 때문입니다.

이에 대해서는 다음 포스팅에서 다루겠습니다.

profile
Write a little every day, without hope, without despair ✍️

0개의 댓글