NestJS i18n 다국어 처리

NestJS에서 다국어가 필요한 이유

글로벌 서비스를 운영하면 API 응답 메시지, 유효성 검증 에러, 이메일 템플릿 등을 사용자 언어에 맞게 제공해야 합니다. 프론트엔드만 다국어를 처리하면 되는 것이 아니라, 서버 사이드 메시지도 로케일에 맞춰야 합니다. NestJS에서는 nestjs-i18n 라이브러리를 통해 타입 안전한 다국어 처리를 구현할 수 있습니다.

설치와 기본 설정

npm install nestjs-i18n

번역 파일을 JSON 형식으로 구성합니다:

src/
├── i18n/
│   ├── ko/
│   │   ├── common.json
│   │   ├── error.json
│   │   └── validation.json
│   ├── en/
│   │   ├── common.json
│   │   ├── error.json
│   │   └── validation.json
│   └── ja/
│       ├── common.json
│       ├── error.json
│       └── validation.json
// i18n/ko/common.json
{
  "welcome": "{username}님, 환영합니다!",
  "goodbye": "안녕히 가세요",
  "items": {
    "created": "{item}이(가) 생성되었습니다",
    "deleted": "{item}이(가) 삭제되었습니다",
    "notFound": "{item}을(를) 찾을 수 없습니다"
  }
}

// i18n/en/common.json
{
  "welcome": "Welcome, {username}!",
  "goodbye": "Goodbye",
  "items": {
    "created": "{item} has been created",
    "deleted": "{item} has been deleted",
    "notFound": "{item} not found"
  }
}
// i18n/ko/error.json
{
  "unauthorized": "인증이 필요합니다",
  "forbidden": "접근 권한이 없습니다",
  "insufficientBalance": "잔액이 부족합니다. 현재: {current}원, 필요: {required}원",
  "duplicateEmail": "이미 등록된 이메일입니다: {email}",
  "rateLimited": "요청이 너무 많습니다. {seconds}초 후 다시 시도해주세요"
}

// i18n/en/error.json
{
  "unauthorized": "Authentication required",
  "forbidden": "Access denied",
  "insufficientBalance": "Insufficient balance. Current: {current}, Required: {required}",
  "duplicateEmail": "Email already registered: {email}",
  "rateLimited": "Too many requests. Retry after {seconds} seconds"
}

I18nModule 구성

// app.module.ts
import {
  I18nModule,
  AcceptLanguageResolver,
  HeaderResolver,
  QueryResolver,
  CookieResolver,
} from 'nestjs-i18n';
import * as path from 'path';

@Module({
  imports: [
    I18nModule.forRoot({
      fallbackLanguage: 'ko',
      fallbacks: {
        'ko-*': 'ko',
        'en-*': 'en',
        'ja-*': 'ja',
      },
      loaderOptions: {
        path: path.join(__dirname, '/i18n/'),
        watch: true,    // 개발 중 파일 변경 감지
      },
      resolvers: [
        // 우선순위: 쿼리 → 헤더 → Accept-Language → 쿠키
        { use: QueryResolver, options: ['lang'] },
        { use: HeaderResolver, options: ['x-lang'] },
        AcceptLanguageResolver,
        { use: CookieResolver, options: ['lang'] },
      ],
      typesOutputPath: path.join(
        __dirname, '../src/generated/i18n.generated.ts'),
    }),
  ],
})
export class AppModule {}

로케일 감지 우선순위:

Resolver 소스 예시
QueryResolver URL 쿼리 파라미터 ?lang=en
HeaderResolver 커스텀 헤더 x-lang: ja
AcceptLanguageResolver 표준 헤더 Accept-Language: ko-KR
CookieResolver 쿠키 lang=ko

서비스에서 번역 사용

import { I18nService, I18nContext } from 'nestjs-i18n';

@Injectable()
export class OrderService {
  constructor(private readonly i18n: I18nService) {}

  async createOrder(dto: CreateOrderDto): Promise<OrderResponse> {
    const order = await this.orderRepository.save(dto);

    // 현재 요청의 로케일로 메시지 생성
    const message = this.i18n.t('common.items.created', {
      args: { item: order.name },
    });

    return { order, message };
  }

  // 특정 로케일 지정
  async sendNotification(userId: string, orderId: string) {
    const user = await this.userRepository.findById(userId);

    const message = this.i18n.t('common.items.created', {
      lang: user.preferredLanguage,  // 사용자 설정 언어
      args: { item: `주문 #${orderId}` },
    });

    await this.notificationService.send(userId, message);
  }
}
// 컨트롤러에서 직접 사용
@Controller()
export class OrderController {

  @Get(':id')
  async getOrder(
    @Param('id') id: string,
    @I18n() i18n: I18nContext,   // 데코레이터로 컨텍스트 주입
  ): Promise<Order> {
    const order = await this.orderService.findById(id);
    if (!order) {
      throw new NotFoundException(
        i18n.t('common.items.notFound', { args: { item: 'Order' } })
      );
    }
    return order;
  }
}

Validation 에러 다국어 처리

class-validator의 에러 메시지를 다국어로 제공하는 패턴입니다. NestJS Pipe 변환·검증 심화와 함께 활용하세요:

// i18n/ko/validation.json
{
  "isNotEmpty": "{field}은(는) 필수입니다",
  "isEmail": "올바른 이메일 형식이 아닙니다",
  "minLength": "{field}은(는) 최소 {min}자 이상이어야 합니다",
  "maxLength": "{field}은(는) 최대 {max}자까지 가능합니다",
  "isPositive": "{field}은(는) 양수여야 합니다"
}

// i18n/en/validation.json
{
  "isNotEmpty": "{field} is required",
  "isEmail": "Invalid email format",
  "minLength": "{field} must be at least {min} characters",
  "maxLength": "{field} must not exceed {max} characters",
  "isPositive": "{field} must be a positive number"
}
// DTO에서 i18n 키 사용
import { i18nValidationMessage } from 'nestjs-i18n';

export class CreateUserDto {
  @IsNotEmpty({ message: i18nValidationMessage('validation.isNotEmpty') })
  @MinLength(2, { message: i18nValidationMessage('validation.minLength') })
  @MaxLength(50, { message: i18nValidationMessage('validation.maxLength') })
  name: string;

  @IsNotEmpty({ message: i18nValidationMessage('validation.isNotEmpty') })
  @IsEmail({}, { message: i18nValidationMessage('validation.isEmail') })
  email: string;

  @IsPositive({ message: i18nValidationMessage('validation.isPositive') })
  age: number;
}
// main.ts — I18nValidationPipe 설정
import { I18nValidationPipe, I18nValidationExceptionFilter } from 'nestjs-i18n';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(new I18nValidationPipe({
    whitelist: true,
    transform: true,
  }));

  app.useGlobalFilters(new I18nValidationExceptionFilter({
    detailedErrors: true,  // 필드별 에러 상세 포함
  }));

  await app.listen(3000);
}

커스텀 Exception Filter와 i18n

@Catch(HttpException)
export class I18nExceptionFilter implements ExceptionFilter {
  constructor(private readonly i18n: I18nService) {}

  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();
    const status = exception.getStatus();

    // 요청의 로케일 감지
    const lang = request.headers['x-lang']
      || request.headers['accept-language']?.split(',')[0]
      || 'ko';

    // 에러 코드를 i18n 키로 매핑
    const errorResponse = exception.getResponse() as any;
    const messageKey = errorResponse.i18nKey;

    let message: string;
    if (messageKey) {
      message = this.i18n.t(messageKey, {
        lang,
        args: errorResponse.args || {},
      });
    } else {
      message = exception.message;
    }

    response.status(status).json({
      statusCode: status,
      message,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

// 사용: i18n 키가 포함된 커스텀 예외
export class InsufficientBalanceException extends HttpException {
  constructor(current: number, required: number) {
    super(
      {
        i18nKey: 'error.insufficientBalance',
        args: { current, required },
      },
      HttpStatus.UNPROCESSABLE_ENTITY,
    );
  }
}

타입 안전한 번역 키

nestjs-i18n은 번역 키의 타입을 자동 생성하여 오타를 방지합니다:

// tsconfig.json — 생성된 타입 포함
{
  "compilerOptions": {
    "paths": {
      "@generated/*": ["src/generated/*"]
    }
  }
}

// 자동 생성된 타입 (src/generated/i18n.generated.ts)
export type I18nTranslations = {
  common: {
    welcome: string;
    goodbye: string;
    items: {
      created: string;
      deleted: string;
      notFound: string;
    };
  };
  error: {
    unauthorized: string;
    forbidden: string;
    insufficientBalance: string;
  };
};

// 서비스에서 타입 안전하게 사용
@Injectable()
export class NotificationService {
  constructor(
    private readonly i18n: I18nService<I18nTranslations>,
  ) {}

  getMessage() {
    // ✅ 자동완성 + 컴파일 타임 검증
    return this.i18n.t('common.items.created', {
      args: { item: 'Order' },
    });

    // ❌ 컴파일 에러: 존재하지 않는 키
    // return this.i18n.t('common.items.unknown');
  }
}

테스트 전략

describe('OrderController (i18n)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = module.createNestApplication();
    await app.init();
  });

  it('should return Korean error message', () => {
    return request(app.getHttpServer())
      .get('/orders/nonexistent')
      .set('Accept-Language', 'ko')
      .expect(404)
      .expect((res) => {
        expect(res.body.message).toContain('찾을 수 없습니다');
      });
  });

  it('should return English error message', () => {
    return request(app.getHttpServer())
      .get('/orders/nonexistent')
      .set('Accept-Language', 'en')
      .expect(404)
      .expect((res) => {
        expect(res.body.message).toContain('not found');
      });
  });

  it('should fallback to Korean for unknown locale', () => {
    return request(app.getHttpServer())
      .get('/orders/nonexistent')
      .set('Accept-Language', 'fr')
      .expect(404)
      .expect((res) => {
        expect(res.body.message).toContain('찾을 수 없습니다');
      });
  });
});

마치며

NestJS에서 nestjs-i18n을 활용하면 API 응답, 유효성 검증 에러, 예외 메시지를 모두 다국어로 제공할 수 있습니다. JSON 파일 기반의 번역 관리, 다중 Resolver를 통한 로케일 감지, I18nValidationPipe로 class-validator 연동, 그리고 자동 생성 타입으로 오타 방지까지. NestJS ExceptionFilter와 조합하면 글로벌 서비스 수준의 에러 처리 체계를 구축할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux