
1화: NestJS 프로젝트 준비와 텔레그램 봇 생성
이번 첫 번째 글에서는 NestJS 프로젝트를 설정하고, 텔레그램 봇을 생성하여 필요한 API 토큰을 발급받는 과정을 다룹니다.
1. NestJS 기본 프로젝트 초기화
가장 먼저 NestJS CLI를 사용하여 새로운 프로젝트를 생성합니다. 아직 CLI가 설치되어 있지 않다면 아래 명령어를 실행해주세요.
npm i -g @nestjs/cli
텔레그램 봇 프로젝트를 위한 디렉토리를 만들고 초기화합니다. 프로젝트 이름은 telegram-bot-nest로 지정하겠습니다.
nest new telegram-bot-nest
- 프로젝트 생성 시 옵션 선택:
- npm 또는 yarn을 패키지 관리자로 선택합니다.
- NestJS는 TypeScript 기반이므로 기본 설정을 유지하고 진행합니다.
프로젝트 생성이 완료되면 해당 디렉토리로 이동하여 서버가 정상적으로 실행되는지 확인합니다.
cd telegram-bot-nest
npm run start:dev
2. 환경 변수(.env) 설정
텔레그램 봇의 API 토큰과 같은 중요한 정보는 코드에 직접 노출하지 않고 환경 변수로 관리해야 합니다.
- 패키지 설치: 환경 변수를 쉽게 로드하기 위해 @nestjs/config 패키지를 설치합니다.
npm i @nestjs/config - .env 파일 생성: 프로젝트의 루트 디렉토리에 .env 파일을 생성하고, API 토큰을 저장할 변수를 미리 정의합니다.
# .env 파일 내용 TELEGRAM_BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN_HERE - AppModule에 적용: src/app.module.ts 파일을 열어 ConfigModule을 임포트하고 글로벌(global)로 설정합니다. 이렇게 하면 프로젝트의 모든 모듈에서 환경 변수에 접근할 수 있습니다.
// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // 전역으로 설정하여 어느 모듈에서나 사용 가능 }), ], // ... (기존 설정) }) export class AppModule {}
3. BotFather를 통해 텔레그램 API 토큰 발급
텔레그램 봇을 생성하고 API 토큰을 발급받는 것은 텔레그램 플랫폼 내에서 이루어집니다.
- BotFather 찾기: 텔레그램 앱을 열고 검색창에 "BotFather"를 검색하여 공식 계정을 찾습니다. (이름 옆에 파란색 체크 표시가 있는지 확인하세요.)
- 봇 생성 시작: BotFather에게 /start 명령어를 입력하고, 이어서 봇 생성 명령어인 /newbot을 입력합니다.
- 봇 이름 설정: 사용자들에게 표시될 친숙한 이름을 입력합니다. (예: NestBotHelper)
- 사용자명 설정: 봇의 고유 사용자명(Username)을 입력해야 합니다. 이 사용자명은 반드시 _bot으로 끝나야 합니다. (예: nest\_helper\_bot)
- 토큰 발급 완료: 사용자명 설정까지 완료되면 BotFather가 HTTP API Token을 포함한 성공 메시지를 보내줍니다.
📢 토큰 보관: 메시지에 포함된 긴 문자열(e.g., 123456:ABC-DEF1234ghIkl-KjL-MnoPQRSTUV)을 복사합니다. 이 토큰은 봇의 비밀 키와 같으므로 외부에 노출되지 않도록 주의해야 합니다.
4. .env 파일 업데이트
발급받은 토큰을 앞서 생성한 .env 파일의 TELEGRAM\_BOT\_TOKEN 변수에 붙여넣고 저장합니다.
# .env 파일 내용 (실제 토큰으로 대체)
TELEGRAM_BOT_TOKEN=789123456:AAH-qwertyuiopasdfghjklzxcvbnm
이제 NestJS 프로젝트는 환경 변수를 읽을 준비가 되었으며, 다음 화에서는 이 토큰을 사용하여 텔레그램 라이브러리를 통합하고 봇을 실제로 구동해 보겠습니다.
2화: 텔레그램 라이브러리 통합 및 Module 설정
지난 1화에서 프로젝트를 초기화하고 API 토큰을 발급받았습니다. 이번 2화에서는 NestJS 프로젝트에 텔레그램 라이브러리를 통합하고, 이를 NestJS의 모듈(Module)과 서비스(Service) 구조에 맞게 설정하는 방법을 알아보겠습니다.
1. 텔레그램 라이브러리 선정 및 설치
NestJS에서 텔레그램 봇을 개발할 때 가장 인기 있고 사용하기 쉬운 라이브러리 중 하나는 Telegraf입니다. Telegraf는 봇 업데이트를 처리하는 데 필요한 많은 기능을 제공하며, NestJS 생태계와도 잘 통합될 수 있습니다.
- Telegraf 설치: Telegraf 본체와 NestJS와의 연동을 도와주는 공식 NestJS 패키지인 @nestjs/telegraf를 설치합니다.
npm install telegraf @nestjs/telegraf - 타입 정의 설치: TypeScript 환경에서 Telegraf의 타입 힌트를 사용하기 위해 타입 정의(Type Definition) 패키지도 설치합니다.
npm install -D @types/telegraf
2. TelegrafModule 설정 및 API 토큰 연동
이제 AppModule에 TelegrafModule을 등록하고, 앞서 .env에 저장한 API 토큰을 환경 변수에서 가져와 설정해줍니다.
- AppModule 수정 (src/app.module.ts):
참고: AppController와 AppService는 봇 기능 구현에는 당장 필요하지 않아 주석 처리하거나 제거해도 무방합니다.
// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TelegrafModule } from '@nestjs/telegraf'; // import { AppController } from './app.controller'; // (필요 시 주석 처리) // import { AppService } from './app.service'; // (필요 시 주석 처리) @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, }), TelegrafModule.forRootAsync({ imports: [ConfigModule], // ConfigService를 사용하기 위해 ConfigModule 임포트 useFactory: (configService: ConfigService) => ({ token: configService.get<string>('TELEGRAM_BOT_TOKEN'), // .env에서 토큰 가져오기 }), inject: [ConfigService], // ConfigService 주입 }), ], // controllers: [AppController], // providers: [AppService], }) export class AppModule {} - ConfigService를 사용하여 환경 변수에 접근하고, forRootAsync 방식으로 비동기적으로 모듈을 설정합니다.
- 토큰 유효성 확인: 봇이 성공적으로 구동되려면 TELEGRAM_BOT_TOKEN이 필수입니다. 토큰이 없는 경우 TelegrafModule 초기화가 실패합니다.
3. 봇 서비스 (Bot Service) 구조 생성
NestJS에서는 비즈니스 로직을 Service에 분리하는 것이 모범 사례입니다. 텔레그램 봇의 업데이트를 처리할 전용 모듈과 서비스를 생성하여 코드를 구조화하겠습니다.
- 봇 모듈 생성: 봇 기능을 담을 bot 모듈을 생성합니다.
nest generate module bot - 봇 업데이트 핸들러 생성: Telegraf의 업데이트를 수신할 클래스를 만들고, NestJS의 Service로 등록합니다.
nest generate service bot/bot --no-spec - BotUpdate 클래스 구현 (src/bot/bot.update.ts):
// src/bot/bot.service.ts import { Injectable } from '@nestjs/common'; import { Update, Ctx, Start, Action, Command, Hears } from 'nestjs-telegraf'; import { Context } from 'telegraf'; // @Injectable() 대신 @Update() 데코레이터를 사용합니다. @Update() export class BotUpdate { // /start 명령어에 대한 핸들러 @Start() async onStart(@Ctx() ctx: Context) { // ctx.reply는 텔레그램 메시지 응답 함수입니다. await ctx.reply(`안녕하세요, NestJS 텔레그램 봇입니다!`); } // /help 명령어에 대한 핸들러 @Command('help') async onHelp(@Ctx() ctx: Context) { await ctx.reply(`저는 도움을 드릴 준비가 되었어요.`); } // 특정 텍스트에 반응하는 핸들러 @Hears('안녕') async onHello(@Ctx() ctx: Context) { await ctx.reply(`저도 반가워요!`); } } - Telegraf의 @nestjs/telegraf 패키지에서는 @Update() 데코레이터를 사용하여 봇 업데이트를 처리하는 클래스를 정의합니다. 이 클래스는 Bot 서비스와 동일한 파일에 위치하거나 별도로 분리할 수 있습니다. 여기서는 Service 파일에 코드를 추가하겠습니다.
- BotModule에 등록 (src/bot/bot.module.ts):
// src/bot/bot.module.ts import { Module } from '@nestjs/common'; import { BotUpdate } from './bot.service'; // BotUpdate 클래스를 임포트 @Module({ // controllers: [], providers: [BotUpdate], // Telegraf 업데이트 핸들러를 여기에 등록 }) export class BotModule {} - 생성한 BotUpdate 클래스를 providers 배열에 등록하여 NestJS와 Telegraf 프레임워크가 이를 인식하고 봇 업데이트 핸들러로 사용하도록 합니다.
- AppModule에 BotModule 등록: 마지막으로 메인 모듈에 BotModule을 임포트합니다.
// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TelegrafModule } from '@nestjs/telegraf'; import { BotModule } from './bot/bot.module'; // BotModule 임포트 @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), TelegrafModule.forRootAsync({ /* ... (위와 동일한 설정) ... */ }), BotModule, // BotModule 등록 ], // ... }) export class AppModule {}
이제 프로젝트가 Telegraf 라이브러리와 NestJS의 구조에 맞게 설정되었습니다. 다음 화에서는 실제로 봇을 Polling 방식으로 구동하여 메시지를 주고받는 예제를 구현해 보겠습니다.
3화: Polling 방식으로 기본 명령어 처리 예제
지난 2화에서 NestJS 프로젝트에 Telegraf 라이브러리를 성공적으로 통합하고 핸들러의 기본 구조를 설정했습니다. 이번 3화에서는 서버를 구동하고 가장 간단한 방법인 Polling 방식을 사용하여 텔레그램 봇과 실제로 메시지를 주고받는 예제를 구현해 보겠습니다.
1. Polling 방식의 이해
텔레그램 봇이 사용자 메시지를 수신하는 방법에는 크게 Polling과 Webhook 두 가지가 있습니다.
- Polling (롱 폴링): 봇 서버가 텔레그램 서버에 주기적으로 "새 메시지가 있니?"라고 물어보고 메시지를 가져오는 방식입니다. 개발 단계에서 가장 빠르고 설정이 간단하여 로컬 테스트에 유용합니다.
- Webhook: 텔레그램 서버가 새 메시지가 있을 때 봇 서버의 특정 URL로 직접 알림()을 보내는 방식입니다. 실제 운영 환경에서 주로 사용됩니다.
우리는 지금 설정이 간단한 Polling으로 봇을 테스트할 것입니다. @nestjs/telegraf는 기본적으로 설정을 제공합니다.
2. 기본 명령어 핸들러 코드 작성
2화에서 생성한 src/bot/bot.service.ts 파일( 클래스가 있는 파일)에 사용자의 명령과 일반 텍스트에 응답하는 핸들러 코드를 작성합니다.
// src/bot/bot.service.ts
import { Update, Ctx, Start, Command, Hears } from 'nestjs-telegraf';
import { Context } from 'telegraf';
import { Injectable, Logger } from '@nestjs/common';
// Injectable() 대신 @Update() 데코레이터를 사용합니다.
@Update()
export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name);
/**
* @Start() 데코레이터: 사용자가 봇에게 처음으로 /start 명령어를 입력했을 때 실행됩니다.
*/
@Start()
async onStart(@Ctx() ctx: Context) {
this.logger.log(`[START] User: ${ctx.from.username}`);
await ctx.reply(`안녕하세요, ${ctx.from.first_name}님! NestJS 텔레그램 봇에 오신 것을 환영합니다.\n/help 명령어로 사용 가능한 기능을 확인하세요.`);
}
/**
* @Command('help') 데코레이터: 사용자가 /help 명령어를 입력했을 때 실행됩니다.
*/
@Command('help')
async onHelp(@Ctx() ctx: Context) {
this.logger.log(`[HELP] User: ${ctx.from.username}`);
await ctx.reply(`저는 다음과 같은 명령어를 지원합니다:\n- /start: 봇 시작\n- /help: 도움말\n- /echo [텍스트]: 입력한 텍스트를 따라 합니다.\n'안녕'이라고 입력해 보세요.`);
}
/**
* @Command('echo') 데코레이터: /echo 명령어를 처리합니다.
* /echo hello world -> message[1] = "hello", message[2] = "world"
*/
@Command('echo')
async onEcho(@Ctx() ctx: Context) {
// ctx.message는 Telegraf의 기본 메시지 객체입니다.
// @ts-ignore
const text = ctx.message.text;
// 명령어 부분(/echo)을 제외한 나머지 텍스트를 가져옵니다.
const messageToEcho = text.substring('/echo'.length).trim();
if (messageToEcho.length > 0) {
await ctx.reply(`[에코 응답]: ${messageToEcho}`);
} else {
await ctx.reply('따라 할 텍스트를 입력해주세요. 예: /echo 봇 테스트');
}
}
/**
* @Hears('안녕') 데코레이터: 사용자가 "안녕"이라는 정확한 텍스트를 입력했을 때 실행됩니다.
*/
@Hears('안녕')
async onHello(@Ctx() ctx: Context) {
await ctx.reply(`저도 반가워요! 저는 ${new Date().toLocaleTimeString()}에 응답했어요.`);
}
}
3. NestJS 서버 구동 및 봇 테스트
코드를 작성했으면 이제 NestJS 애플리케이션을 구동하여 텔레그램 봇을 실행할 차례입니다.
- 서버 실행: 개발 모드로 서버를 시작합니다.서버가 정상적으로 구동되면 NestJS의 시작 메시지와 함께 Telegraf가 Polling을 시작했다는 로그가 터미널에 출력됩니다.
[Nest] 1234 - 12/03/2025, 2:49 PM LOG [NestFactory] Starting Nest application... ... [Nest] 1234 - 12/03/2025, 2:49 PM LOG [TelegrafModule] Telegraf is running in polling mode...npm run start:dev - 텔레그램 봇 접속: 텔레그램 앱을 열고, 1화에서 BotFather를 통해 설정한 봇의 사용자명을 검색하거나, BotFather가 제공했던 봇 링크를 통해 봇 채팅방에 접속합니다.
- 명령어 테스트: 채팅창에 다음 명령어들을 입력하고 봇의 응답을 확인합니다.
- /start 입력 환영 메시지 응답
- /help 입력 도움말 메시지 응답
- /echo NestJS 봇 멋져요 입력 [에코 응답]: NestJS 봇 멋져요 응답
- 안녕 입력 시간 정보가 포함된 인사 메시지 응답
결론: 이로써 Polling 방식을 사용하여 NestJS 기반의 텔레그램 봇이 성공적으로 동작하는 것을 확인했습니다. 하지만 방식은 배포 환경에 적합하지 않으므로, 다음 화에서는 실제 운영 환경에서 사용하는 Webhook 방식으로 전환하고 로컬 테스트 환경을 구축하는 방법을 알아보겠습니다.
4화: Webhook 방식으로 전환 및 로컬 테스트 환경 구축
지난 3화에서 방식으로 봇의 기본 기능을 테스트했습니다. 이제 실제 서비스 환경을 위해 Webhook 방식으로 전환하고, 로컬 개발 환경에서 이를 테스트할 수 있도록 을 설정하는 방법을 다룹니다.
1. Webhook 방식의 이해 및 장점
Polling은 개발 시 편리하지만, 새로운 메시지를 확인하기 위해 봇 서버가 텔레그램 서버에 끊임없이 요청을 보내야 합니다.
- Webhook: 텔레그램 서버가 새로운 업데이트가 발생하면 지정된 URL (봇 서버의 엔드포인트)로 요청을 해주는 방식입니다.
| 특징 | Polling | Webhook |
| 효율성 | 낮음 (주기적인 요청) | 높음 ( 방식) |
| 응답 속도 | 지연될 수 있음 | 실시간 |
| 배포 적합성 | 낮음 (로컬 테스트용) | 높음 (운영 환경 표준) |
2. Webhook 설정
Telegraf는 기본적으로 Polling으로 설정되지만, 설정에 관련 옵션을 추가하여 쉽게 전환할 수 있습니다.
src/app.module.ts 파일을 수정하여 설정에 객체를 추가하고, 봇 업데이트를 수신할 경로를 정의합니다.
- .env에 Webhook 관련 변수 추가: 프로젝트 루트의 .env 파일에 경로 및 포트 관련 변수를 추가합니다.
# .env 파일 내용 추가 TELEGRAM_BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN_HERE WEBHOOK_DOMAIN=YOUR_PUBLIC_DOMAIN_OR_NGROK_URL # ngrok 주소가 여기에 들어갑니다. WEBHOOK_PATH=/telegraf-webhook - 수정 (): 설정에서 옵션을 제거하고 옵션을 활성화합니다.
// src/app.module.ts // ... (기존 임포트) @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), TelegrafModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ token: configService.get<string>('TELEGRAM_BOT_TOKEN'), // Webhook 설정 추가 launchOptions: { webhook: { // NestJS가 실행되는 로컬 포트 (기본 3000) // Telegraf 모듈이 NestJS의 HTTP 서버에 연결되므로 별도의 포트 설정은 필요 없습니다. domain: configService.get<string>('WEBHOOK_DOMAIN'), // 외부에서 접근 가능한 도메인 (ngrok 주소) hookPath: configService.get<string>('WEBHOOK_PATH'), // 텔레그램 업데이트를 수신할 경로 }, }, }), inject: [ConfigService], }), BotModule, ], // ... }) export class AppModule {}
3. ngrok을 이용한 로컬 테스트 환경 구축
방식은 봇 서버가 외부 인터넷으로부터 접근 가능한 공개 을 가져야 합니다. 로컬 개발 환경에서 이를 만족시키기 위해 ngrok을 사용합니다.
- 설치: 공식 웹사이트에서 다운로드하거나 npm을 통해 설치합니다.
npm install -g ngrok - NestJS 서버 실행: 먼저 NestJS 애플리케이션을 구동합니다. 기본적으로 포트 3000에서 실행됩니다.
npm run start:dev - 터널링 시작: NestJS 서버가 실행 중인 3000번 포트를 외부로 공개합니다.명령어를 실행하면 ngrok이 랜덤한 공개 URL을 생성해줍니다.
Session Status online ... Web Interface http://127.0.0.1:4040 Forwarding https://xxxx-xx-xxx-xxx-xx.ngrok-free.app -> http://localhost:3000 Forwarding http://xxxx-xx-xxx-xxx-xx.ngrok-free.app -> http://localhost:3000ngrok http 3000 - 파일 업데이트: Forwarding 주소 중 https로 시작하는 주소()를 복사하여 .env파일의 WEBHOOK_DOMAIN에 붙여넣습니다.
# .env 파일 업데이트 (ngrok 주소로 대체) WEBHOOK_DOMAIN=https://xxxx-xx-xxx-xxx-xx.ngrok-free.app WEBHOOK_PATH=/telegraf-webhook
4. Webhook 동작 확인
파일을 업데이트한 후, NestJS 서버()를 재시작해야 변경된 환경 변수가 적용됩니다.
- 서버 재시작 시 Telegraf는 WEBHOOK\_DOMAIN을 사용하여 텔레그램 서버에 Webhook URL(https://xxxx.../telegraf-webhook})을 자동으로 등록합니다.
- 텔레그램 채팅창에서 봇에게 /start 또는 다른 명령어를 입력하면, 텔레그램 서버가 주소를 통해 로컬의 NestJS 서버로 요청을 보내게 되며, 핸들러가 이를 처리하게 됩니다.
이제 Webhook 방식으로 NestJS 봇을 개발할 준비가 완료되었습니다. 다음 화에서는 사용자 입력에 더 유연하게 반응하기 위한 텍스트 패턴() 처리 방법을 다루겠습니다.
5화: 사용자 입력 처리 및 명령어 구현 (Text Patterns)
지난 4화에서 Webhook 방식으로 성공적으로 전환하고 ngrok을 이용해 로컬 테스트 환경을 구축했습니다. 이번 5화에서는 봇이 사용자가 입력하는 다양한 형태의 일반 텍스트에 유연하게 반응하도록 정규 표현식(RegExp})을 사용한 핸들러를 구현하는 방법을 알아보겠습니다.
1. 데코레이터와 정규 표현식
Telegraf의 패키지는 @Hears() 데코레이터를 제공하여 특정 텍스트 패턴에 맞는 메시지를 처리할 수 있게 합니다.
- @Hears('특정 단어'): 정확히 일치하는 단어에만 반응합니다.
- @Hears(/패턴/i): 정규 표현식 패턴에 따라 반응하며, 플래그는 대소문자를 구분하지 않게 합니다.
src/bot/bot.service.ts의 BotUpdate 클래스에 다음 핸들러들을 추가하여 사용자 입력에 반응하는 예제를 구현합니다.
2. 다양한 텍스트 패턴 핸들러 구현
1. 특정 키워드 포함 메시지 처리 (Global Search)
메시지 내용에 "정보" 또는 "궁금"이라는 단어가 포함되어 있으면 응답하도록 구현합니다.
// src/bot/bot.service.ts (BotUpdate 클래스 내부)
import { Hears, Ctx } from 'nestjs-telegraf';
import { Context } from 'telegraf';
// ... (다른 임포트)
/**
* @Hears(/정보|궁금/i) 데코레이터: 메시지에 '정보' 또는 '궁금'이 포함되어 있으면 반응합니다.
* 'i' 플래그는 대소문자 구분을 무시합니다.
*/
@Hears(/정보|궁금/i)
async handleInformationRequest(@Ctx() ctx: Context) {
const userMessage = (ctx.message as any).text;
this.logger.log(`[Hears: 정보/궁금] User: ${ctx.from.username}, Msg: ${userMessage}`);
await ctx.reply(`요청하신 정보에 대해 응답할 준비가 되었어요. 어떤 정보가 궁금하신가요?`);
}
2. 특정 문자열로 시작하는 메시지 처리
사용자가 "예약"으로 시작하는 메시지를 보낼 경우, 예약 관련 프로세스를 시작하도록 유도합니다.
// src/bot/bot.service.ts (BotUpdate 클래스 내부)
/**
* @Hears(/^예약\s/i) 데코레이터: 메시지가 '예약'으로 시작하고 그 뒤에 공백이 있을 때 반응합니다.
* '^'는 문자열의 시작, '\s'는 공백 문자를 의미합니다.
*/
@Hears(/^예약\s/i)
async handleReservationStart(@Ctx() ctx: Context) {
await ctx.reply('네, 예약을 시작합니다. 원하시는 날짜와 시간을 입력해 주세요.');
}
3. 입력 값 캡처 및 활용 (정규 표현식 그룹)
정규 표현식의 캡처 그룹을 사용하여 사용자 입력에서 특정 데이터를 추출하고 응답에 활용할 수 있습니다. 예를 들어, 이름을 입력받아 추출해 보겠습니다.
// src/bot/bot.service.ts (BotUpdate 클래스 내부)
/**
* @Hears(/내 이름은\s(.+)/i) 데코레이터: '내 이름은 [이름]' 형태의 메시지에서 [이름] 부분을 캡처합니다.
* '(.+)' 부분이 캡처 그룹이며, 뒤따르는 모든 텍스트를 잡습니다.
*/
@Hears(/내 이름은\s(.+)/i)
async handleNameCapture(@Ctx() ctx: Context) {
// Telegraf 컨텍스트에서 정규 표현식 매칭 결과를 추출
// @ts-ignore
const match = (ctx.message as any).text.match(/내 이름은\s(.+)/i);
if (match && match[1]) {
const capturedName = match[1].trim();
await ctx.reply(`만나서 반가워요, **${capturedName}**님!`);
// 여기서 추출된 이름을 DB에 저장하거나 다음 상태로 전달할 수 있습니다.
} else {
await ctx.reply('이름을 정확하게 입력해주세요. 예: 내 이름은 홍길동');
}
}
3. 미처리된 메시지(Fallback) 핸들러 구현
위의 @Command(), @Hears() 등의 모든 핸들러가 메시지를 처리하지 못했을 때 동작하는 최종 응답() 핸들러를 구현합니다. @nestjs/telegraf에서 일반 텍스트에 대한 처리는 @On('text') 데코레이터를 사용합니다.
// src/bot/bot.service.ts (BotUpdate 클래스 내부)
/**
* @On('text') 데코레이터: Command, Hears 등으로 처리되지 않은 모든 일반 텍스트 메시지를 처리합니다.
*/
@On('text')
async onFallbackText(@Ctx() ctx: Context) {
// @ts-ignore
const userMessage = ctx.message.text;
this.logger.warn(`[Fallback] Unhandled text: ${userMessage}`);
await ctx.reply(`죄송해요, 저는 **"${userMessage"**가 무슨 뜻인지 이해하지 못했어요. /help 명령어를 이용해 보세요.`);
}
4. 테스트 및 요약
NestJS 서버를 npm run start:dev로 실행하고 ngrok이 활성화된 상태에서 텔레그램 봇에게 다음과 같은 메시지를 입력하여 테스트합니다.
- 봇 정보가 궁금해 정보 요청 핸들러(handleInformationRequest) 응답
- 예약하고 싶어요 예약 시작 핸들러(handleReservationStart) 응답
- 내 이름은 김철수 이름 캡처 핸들러(handleNameCapture) 응답
- 이건 아무 말이야 핸들러(onFallbackText) 응답
이로써 봇이 정형화된 명령어뿐만 아니라 사용자 입력 패턴에 따라 유연하게 반응할 수 있게 되었습니다. 다음 6화에서는 더욱 사용자 친화적인 인터페이스를 위해 키보드 및 인라인 버튼을 구현하는 방법을 알아보겠습니다.
6화: Inline/Reply Keyboard를 이용한 메뉴 구현
지난 5화에서는 정규 표현식을 사용해 사용자의 텍스트 입력에 유연하게 반응하는 방법을 배웠습니다. 이번 6화에서는 봇과의 상호작용을 더욱 사용자 친화적으로 만들기 위해 텔레그램의 핵심 기능인 키보드(Keyboard)와 인라인 버튼(Inline Button)을 구현하는 방법을 다룹니다.
1. Reply Keyboard 구현 (메시지 입력창 하단 메뉴)
Reply Keyboard는 사용자의 메시지 입력창 하단에 나타나는 키보드 형태의 메뉴입니다. 사용자가 직접 텍스트를 입력하는 대신 버튼을 눌러 미리 정의된 명령어나 텍스트를 쉽게 보낼 수 있게 해줍니다.
src/bot/bot.service.ts 파일의 BotUpdate 클래스에 /menu 명령어를 처리하는 핸들러를 추가하고 Reply Keyboard를 생성합니다.
// src/bot/bot.service.ts (BotUpdate 클래스 내부)
import { Update, Ctx, Start, Command, Hears, On } from 'nestjs-telegraf';
import { Context } from 'telegraf';
// ... (다른 임포트)
/**
* /menu 명령어에 응답하는 핸들러
*/
@Command('menu')
async onMenu(@Ctx() ctx: Context) {
// Telegraf의 Markup 객체를 사용하여 키보드 레이아웃 정의
const replyKeyboard = {
reply_markup: {
// 버튼 배열. [ ['버튼1', '버튼2'], ['버튼3'] ] 형태로 행과 열을 지정
keyboard: [
[{ text: '📢 공지사항' }, { text: '📞 문의하기' }],
[{ text: '🔍 봇 정보' }, { text: '❌ 키보드 닫기' }],
],
resize_keyboard: true, // 키보드 크기를 작게 조절
one_time_keyboard: false, // 한 번 사용 후 사라지지 않게 설정
},
};
await ctx.reply('원하는 메뉴를 선택해주세요:', replyKeyboard as any);
}
/**
* 키보드 닫기 버튼을 눌렀을 때 처리
*/
@Hears('❌ 키보드 닫기')
async closeKeyboard(@Ctx() ctx: Context) {
await ctx.reply('키보드를 닫습니다.', {
// remove_keyboard 속성으로 키보드 제거
reply_markup: { remove_keyboard: true },
} as any);
}
/**
* 문의하기 버튼을 눌렀을 때 처리
*/
@Hears('📞 문의하기')
async onInquiry(@Ctx() ctx: Context) {
await ctx.reply('문의사항을 입력하시면 관리자에게 전달됩니다.');
}
2. Inline Keyboard 구현 (메시지에 붙는 버튼)
Inline Keyboard는 특정 메시지 바로 아래에 붙어 나타나는 버튼입니다. 사용자가 이 버튼을 누르면 메시지가 아니라 콜백 데이터(Callback Data)가 봇으로 전송됩니다. 이 방식은 상태 저장 및 데이터 기반의 상호작용에 유용합니다.
/inline 명령어를 처리하는 핸들러를 추가하고 Inline Keyboard를 생성합니다.
// src/bot/bot.service.ts (BotUpdate 클래스 내부)
/**
* /inline 명령어에 응답하는 핸들러
*/
@Command('inline')
async onInlineMenu(@Ctx() ctx: Context) {
const inlineKeyboard = {
reply_markup: {
// inline_keyboard를 사용하여 버튼 배열 정의
inline_keyboard: [
// 각 버튼은 text와 data를 가짐
[
{ text: '👍 좋아요 (0)', callback_data: 'like_count:0' },
{ text: '👎 싫어요 (0)', callback_data: 'dislike_count:0' },
],
// URL 버튼도 추가 가능
[{ text: 'Google 검색', url: 'https://www.google.com' }],
],
},
};
await ctx.reply('이 글에 대해 투표해주세요:', inlineKeyboard as any);
}
3. Inline Keyboard 이벤트 처리 (@Action())
Inline Keyboard의 버튼을 누르면 텍스트 대신 callback_data에 지정된 문자열이 봇으로 전송됩니다. @nestjs/telegraf에서는 `@Action()` 데코레이터를 사용하여 이 콜백 쿼리(Callback Query)를 처리합니다.
@Action() 데코레이터의 인자로는 정규 표현식을 사용하여 특정 패턴의 callback_data를 처리할 수 있습니다.
// src/bot/bot.service.ts (BotUpdate 클래스 내부)
/**
* @Action(/like_count:\d+/) 데코레이터: 'like_count:숫자' 형태의 콜백 데이터를 처리합니다.
*/
@Action(/like_count:\d+/)
async handleLikeAction(@Ctx() ctx: Context) {
// 콜백 데이터에서 현재 카운트를 추출
// @ts-ignore
const callbackData = ctx.callbackQuery.data;
const currentCount = parseInt(callbackData.split(':')[1], 10);
const newCount = currentCount + 1;
// 텔레그램 버튼에는 응답이 필요합니다. 응답하지 않으면 로딩이 걸린 것처럼 보입니다.
await ctx.answerCbQuery(`좋아요를 눌렀습니다! (현재 ${newCount}개)`);
// 메시지 버튼 자체를 수정(업데이트)하여 변경 사항을 반영합니다.
await ctx.editMessageText(
'이 글에 대해 투표해주세요:',
{
// @ts-ignore
reply_markup: {
inline_keyboard: [
// 좋아요 버튼만 새 카운트로 업데이트
[{ text: `👍 좋아요 (${newCount})`, callback_data: `like_count:${newCount}` }],
// 싫어요 버튼은 그대로 유지
[{ text: '👎 싫어요 (0)', callback_data: 'dislike_count:0' }],
[{ text: 'Google 검색', url: 'https://www.google.com' }],
]
}
}
);
}
/**
* @Action(/dislike_count:\d+/) 데코레이터: 싫어요 버튼의 콜백 데이터를 처리합니다.
*/
@Action(/dislike_count:\d+/)
async handleDislikeAction(@Ctx() ctx: Context) {
// 단순히 응답 팝업만 띄우고 메시지 업데이트는 생략할 수 있습니다.
await ctx.answerCbQuery('싫어요는 다음 기회에!', { show_alert: true }); // show_alert: true는 팝업창을 띄웁니다.
}
4. 테스트 및 요약
- /menu 입력: 메시지 입력창 하단에 Reply Keyboard가 나타나는지 확인합니다. '❌ 키보드 닫기'를 눌러 키보드가 사라지는지 확인합니다.
- /inline 입력: 메시지 아래에 Inline Keyboard가 붙어 나타나는지 확인합니다.
- Inline 버튼 클릭: '👍 좋아요 (0)' 버튼을 누르면 숫자가 증가하고 메시지가 업데이트되는지 확인합니다. '👎 싫어요 (0)' 버튼을 누르면 팝업창이 뜨는지 확인합니다.
이로써 NestJS 봇에 필수적인 상호작용 도구인 Reply Keyboard와 Inline Keyboard를 구현했습니다. 다음 7화에서는 이 Callback Query를 더 심화하여 처리하고, 봇에서 사용자 상태 관리의 필요성을 다루겠습니다.
7화: Callback Query 심화 처리 및 사용자 상태 관리
지난 6화에서 Inline Keyboard와 Callback Query}의 기본 처리를 구현했습니다. 이번 7화에서는 Callback Query를 더욱 심화하여 처리하고, 봇 개발에서 필수적인 개념인 사용자 상태 관리(Session/State Management})의 필요성과 기본 구현을 다룹니다.
1. Callback Query 심화: 메시지 업데이트 및 알림
Callback Query는 사용자가 버튼을 눌렀을 때 봇에게 전송되는 데이터입니다. 이를 처리하는 두 가지 핵심 동작은 메시지 내용 수정과 응답 알림입니다.
- ctx.answerCbQuery(): 버튼 클릭 후 로딩 상태를 해제하고 사용자에게 간단한 알림( 또는 팝업)을 띄웁니다.
- ctx.editMessageText() / ctx.editMessageReplyMarkup(): 이미 보낸 메시지의 내용이나 버튼 배열을 수정합니다.
예제: 다단계 메뉴 구현
메뉴 버튼을 누르면 메시지 자체를 다음 단계 메뉴로 업데이트하여, 마치 웹 페이지를 탐색하는 듯한 효과를 구현합니다.
src/bot/bot.service.ts의 BotUpdate 클래스에 다음 코드를 추가합니다.
// src/bot/bot.service.ts (BotUpdate 클래스 내부)
// ... (기존 임포트)
/**
* @Command('steps') : 다단계 메뉴를 시작하는 명령어
*/
@Command('steps')
async onStepsMenu(@Ctx() ctx: Context) {
const startKeyboard = {
reply_markup: {
inline_keyboard: [
[{ text: '설정 시작', callback_data: 'step_start' }],
],
},
};
await ctx.reply('안녕하세요! 봇 설정을 시작해 볼까요?', startKeyboard as any);
}
/**
* @Action(/step_start|step_2|step_3/) : 다단계 콜백을 처리하는 범용 핸들러
*/
@Action(/step_start|step_2|step_3/)
async handleStepActions(@Ctx() ctx: Context) {
// @ts-ignore
const callbackData = ctx.callbackQuery.data;
// 1. 콜백 쿼리 응답 (로딩 해제 및 토스트 알림)
await ctx.answerCbQuery('다음 단계로 이동합니다.', { show_alert: false });
let newText = '오류: 알 수 없는 단계입니다.';
let newKeyboard;
if (callbackData === 'step_start') {
newText = '➡️ 1단계: 언어를 선택해주세요.';
newKeyboard = [
[{ text: '한국어 🇰🇷', callback_data: 'lang_ko' }],
[{ text: '영어 🇺🇸', callback_data: 'lang_en' }],
[{ text: '다음 단계', callback_data: 'step_2' }],
];
} else if (callbackData === 'step_2') {
newText = '➡️ 2단계: 알림 주기를 선택해주세요.';
newKeyboard = [
[{ text: '매일', callback_data: 'notify_daily' }],
[{ text: '매주', callback_data: 'notify_weekly' }],
[{ text: '이전 단계', callback_data: 'step_start' }],
[{ text: '설정 완료', callback_data: 'step_3' }],
];
} else if (callbackData === 'step_3') {
newText = '✅ 설정이 완료되었습니다. 감사합니다!';
newKeyboard = [
[{ text: '처음으로 돌아가기', callback_data: 'step_start' }],
];
}
// 2. 기존 메시지 업데이트
await ctx.editMessageText(newText, {
// @ts-ignore
reply_markup: { inline_keyboard: newKeyboard },
});
}
2. 사용자 상태 관리()의 필요성
위의 다단계 메뉴 예제는 잘 작동하지만, 사용자의 선택()을 기억하지 못합니다.
텔레그램은 Stateless (무상태) 프로토콜을 사용합니다. 즉, 봇이 한 번 응답하면 이전 상호작용에 대한 정보를 자동으로 보존하지 않습니다. 하지만 실제 봇 개발에서는 다음 정보가 필요합니다.
- 세션 (): 현재 사용자가 어떤 단계를 진행 중인지 (예: "예약 진행 중", "이름 입력 대기 중")
- 데이터 저장 (): 사용자가 입력하거나 선택한 값을 임시로 저장 (예: "예약 날짜", "선택한 언어")
NestJS/Telegraf 환경에서 상태를 관리하는 일반적인 방법은 다음과 같습니다.
- 메모리: 간단하지만 서버 재시작 시 데이터 손실 (비추천).
- 파일/JSON: 조금 더 안정적이지만 비효율적.
- 데이터베이스 (DB): 가장 권장되는 방법입니다. 사용자 ID를 키로 하여 상태와 데이터를 저장합니다. (8화, 9화에서 자세히 다룰 예정)
3. 간단한 메모리 기반 상태 관리 구현 (예시)
본격적인 연동에 앞서, Service를 활용하여 임시적인 메모리 기반 상태 관리를 구현하여 개념을 익힙니다.
- 상태 관리 생성: 봇 업데이트와 별도로 상태 관리를 전담하는 서비스를 만듭니다.
nest generate service bot/state --no-spec - 구현 ():
// src/bot/state.service.ts import { Injectable } from '@nestjs/common'; // Chat ID를 키로, 상태 데이터를 값으로 저장하는 임시 Map interface UserState { step: string; language?: string; temp_data?: string; } @Injectable() export class StateService { // 🚨 서버 재시작 시 데이터가 사라지는 임시 저장소입니다. private readonly userStates = new Map<number, UserState>(); getState(userId: number): UserState { // 상태가 없으면 기본 상태를 반환합니다. return this.userStates.get(userId) || { step: 'idle' }; } setState(userId: number, newState: Partial<UserState>): void { // 기존 상태와 새 상태를 병합(merge)합니다. const currentState = this.getState(userId); this.userStates.set(userId, { ...currentState, ...newState }); } clearState(userId: number): void { this.userStates.delete(userId); } } - BotUpdate에서 사용: BotUpdate 클래스에 StateService를 Inject하고 사용자의 선택을 저장합니다.
// src/bot/bot.service.ts (BotUpdate 클래스 내부) import { Inject, ... } from '@nestjs/common'; import { StateService } from './state.service'; // StateService 임포트 // ... @Update() export class BotUpdate { private readonly logger = new Logger(BotUpdate.name); constructor(private readonly stateService: StateService) {} // 서비스 주입! // 언어 선택 시 상태 저장 예제 @Action(/lang_ko|lang_en/) async handleLanguageAction(@Ctx() ctx: Context) { // @ts-ignore const userId = ctx.from.id; // @ts-ignore const selectedLang = ctx.callbackQuery.data.split('_')[1]; // StateService를 사용하여 사용자 상태 업데이트 this.stateService.setState(userId, { language: selectedLang, step: 'language_selected' }); await ctx.answerCbQuery(`${selectedLang.toUpperCase()}를 선택했습니다!`); // 다음 단계로 이동하는 등 다른 로직 처리... } // ... 기존 핸들러들 } - BotModule에 등록: src/bot/bot.module.ts에 StateService를 providers에 추가해야 사용 가능합니다.
// src/bot/bot.module.ts import { Module } from '@nestjs/common'; import { BotUpdate } from './bot.service'; import { StateService } from './state.service'; // 임포트 @Module({ providers: [BotUpdate, StateService], // StateService 등록 }) export class BotModule {}
결론: 이제 봇은 Callback Query를 이용해 메시지를 동적으로 업데이트할 수 있게 되었고, StateService를 통해 사용자의 상태와 데이터를 일시적으로 저장하는 기본 개념을 적용했습니다. 다음 8화에서는 이 StateService와 같은 비즈니스 로직을 NestJS의 계층에 제대로 분리하고 구조화하는 방법을 다루겠습니다.
8화: NestJS Service를 이용한 비즈니스 로직 분리
지난 7화에서 StateService를 만들어 임시적인 상태 관리를 시도했습니다. 이 과정은 봇의 업데이트 처리(BotUpdate)와 핵심 기능(StateService)을 분리하는 NestJS의 핵심 원칙을 따릅니다.
이번 8화에서는 텔레그램 봇의 비즈니스 로직을 NestJS의 Service 계층으로 완전히 분리하고, 의존성 주입(Dependency Injection, DI)을 활용하여 봇의 구조를 견고하게 만드는 방법을 심층적으로 다룹니다.
1. NestJS 구조와 로직 분리의 중요성
NestJS는 기본적으로 MVC (Model-View-Controller) 패턴과 유사한 계층형 아키텍처를 따릅니다.
| 계층 | NestJS 구성 요소 | 텔레그램 봇 역할 |
| Presentation/Input | Controller/Gateway/Update | 사용자 입력(Message, Command, Action) 수신 및 응답 (BotUpdate 클래스) |
| Business Logic | Service | 핵심 비즈니스 로직 처리 및 데이터 조작 (BotService CustomService 클래스) |
| Data | Repository | 데이터베이스 접근 및 CRUD 작업 |
로직 분리의 장점:
- 테스트 용이성: Service 로직만 독립적으로 테스트할 수 있습니다.
- 재사용성: 텔레그램 외 다른 채널(e.g., REST API)에도 동일한 Service 로직을 재사용할 수 있습니다.
- 유지보수: 봇의 인터페이스(BotUpdate)와 핵심 로직(Service)이 분리되어 코드가 깔끔해집니다.
2. 비즈니스 로직 Service 생성 및 분리
현재 BotUpdate 클래스가 메시지를 수신하는 역할과 로직을 처리하는 역할을 모두 수행하고 있습니다. 이 중 핵심 로직을 전담할 Service를 새로 정의하고 코드를 이동시킵니다.
- 핵심 기능 Service 생성: 봇의 인사 및 핵심 명령어를 처리할 BotLogicService를 생성합니다.
nest generate service bot/bot-logic --no-spec - BotLogicService 구현 (src/bot/bot-logic.service.ts):
// src/bot/bot-logic.service.ts import { Injectable, Logger } from '@nestjs/common'; @Injectable() export class BotLogicService { private readonly logger = new Logger(BotLogicService.name); /** * 사용자의 텍스트를 받아 그대로 반환하는 핵심 에코 로직 * @param messageText 명령어 전체 텍스트 (e.g., "/echo 안녕하세요") * @returns 응답할 메시지 문자열 */ getEchoResponse(messageText: string): string { // @Command('echo')에서 사용하는 로직과 동일 const messageToEcho = messageText.substring('/echo'.length).trim(); if (messageToEcho.length > 0) { this.logger.log(`Echo 처리: "${messageToEcho"`); return `[에코 응답]: ${messageToEcho}`; } else { return '따라 할 텍스트를 입력해주세요. 예: /echo 봇 테스트'; } } /** * 공지사항 목록을 가져오는 시뮬레이션 로직 */ getNoticeList(): string[] { // 실제로는 DB나 외부 API에서 데이터를 가져오는 로직이 들어갑니다. return [ "1. 12월 정기 점검 안내", "2. 봇 기능 업데이트 예정", "3. 문의는 /help를 이용해주세요." ]; } } - 3화에서 BotUpdate에 작성했던 /echo와 같은 비즈니스 로직을 Service로 이동시킵니다.
3. 의존성 주입(DI)을 통한 연동
이제 BotUpdate 클래스에서 BotLogicService를 주입받아 사용하고, 텔레그램 응답 처리 로직(ctx.reply())만 남깁니다.
- BotUpdate 수정 (src/bot/bot.service.ts):
// src/bot/bot.service.ts import { Update, Ctx, Command, ... } from 'nestjs-telegraf'; import { Context } from 'telegraf'; import { BotLogicService } from './bot-logic.service'; // 서비스 임포트 // ... @Update() export class BotUpdate { // DI: 생성자를 통해 BotLogicService 인스턴스를 주입받음 constructor( private readonly botLogicService: BotLogicService, // private readonly stateService: StateService, // 7화의 상태 관리 서비스도 여기에 주입 ) {} /** * /echo 명령어 처리 로직을 Service로 위임 */ @Command('echo') async onEcho(@Ctx() ctx: Context) { // @ts-ignore const messageText = ctx.message.text; // 1. 핵심 로직은 Service에 위임하고 결과를 받습니다. const responseText = this.botLogicService.getEchoResponse(messageText); // 2. 받은 결과로 텔레그램에 응답합니다. (Presentation layer의 역할) await ctx.reply(responseText); } /** * /notice 명령어 핸들러 추가 */ @Command('notice') async onNotice(@Ctx() ctx: Context) { // 1. Service에서 공지사항 목록을 가져옵니다. const notices = this.botLogicService.getNoticeList(); // 2. 목록을 포맷하여 응답합니다. const response = `📢 **현재 공지사항**\n\n${notices.join('\n')}`; await ctx.reply(response); } // ... (기존의 /start, /help, @Hears 등의 핸들러는 그대로 유지) } - BotUpdate의 생성자(constructor)를 통해 BotLogicService를 주입받고, 이를 사용하여 명령어를 처리합니다.
- BotModule에 BotLogicService 등록 (src/bot/bot.module.ts):
// src/bot/bot.module.ts import { Module } from '@nestjs/common'; import { BotUpdate } from './bot.service'; import { BotLogicService } from './bot-logic.service'; // 임포트 @Module({ // controllers: [], providers: [BotUpdate, BotLogicService], // BotLogicService 등록 }) export class BotModule {} - NestJS가 DI를 수행할 수 있도록 providers 배열에 BotLogicService를 반드시 등록해야 합니다.
4. 테스트 및 요약
NestJS 서버를 재시작하고 텔레그램 봇에 /echo hello 또는 /notice 명령어를 입력해 봅니다. 모든 기능은 정상적으로 동작하지만, 핵심 로직은 BotUpdate에서 분리되어 이제 BotLogicService가 처리합니다.
결론: BotUpdate는 이제 Telegraf의 요청을 받아 Service에 전달하고 응답하는 단순한 프레젠테이션 계층의 역할만 수행하게 됩니다. 이는 NestJS의 구조적 장점을 극대화하는 가장 모범적인 형태입니다. 다음 9화에서는 이 Service 계층에서 TypeORM을 사용하여 데이터베이스와 연동하고 사용자 정보를 영구적으로 관리하는 방법을 다루겠습니다.
9화: TypeORM을 이용한 데이터베이스 연동 및 사용자 정보 관리
지난 8화에서 NestJS Service를 이용해 비즈니스 로직을 분리하여 봇의 구조를 견고하게 만들었습니다. 이번 9화에서는 봇의 상태나 사용자 데이터를 영구적으로 저장하기 위해 TypeORM을 사용하여 데이터베이스와 연동하고, 텔레그램 사용자 정보를 관리하는 방법을 실습합니다.
1. 데이터베이스 환경 설정 및 TypeORM 설치
텔레그램 봇은 사용자 ID와 같은 데이터를 저장해야 하므로 데이터베이스 연동이 필수적입니다. 이 시리즈에서는 PostgreSQL을 사용한다고 가정하고 TypeORM을 설정하겠습니다.
- 패키지 설치: TypeORM과 PostgreSQL 드라이버를 설치합니다.
npm install @nestjs/typeorm typeorm pg - .env 파일 업데이트: 데이터베이스 접속 정보를 .env파일에 추가합니다.
# .env 파일 내용 추가 DB_TYPE=postgres DB_HOST=localhost DB_PORT=5432 DB_USERNAME=myuser DB_PASSWORD=mypassword DB_DATABASE=telegram_bot_db - AppModule에 TypeOrmModule 등록 (src/app.module.ts):
// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; // TypeOrmModule 임포트 // ... (기존 임포트) @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), // 1. TypeOrmModule 설정 TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => ({ type: configService.get<'postgres'>('DB_TYPE'), host: configService.get<string>('DB_HOST'), port: configService.get<number>('DB_PORT'), username: configService.get<string>('DB_USERNAME'), password: configService.get<string>('DB_PASSWORD'), database: configService.get<string>('DB_DATABASE'), entities: [__dirname + '/**/*.entity.{ts,js}'], // 엔티티 파일 경로 지정 synchronize: true, // 개발 단계에서만 사용 (DB 스키마 자동 생성) }), }), TelegrafModule.forRootAsync({ /* ... */ }), BotModule, ], // ... }) export class AppModule {} - ConfigService를 이용하여 환경 변수를 읽어 TypeORM을 전역으로 설정합니다.
2. 사용자 엔티티(Entity) 정의
텔레그램 사용자 정보를 저장할 TypeORM 엔티티를 정의합니다.
- UserEntity 파일 생성 (src/user/user.entity.ts):
nest generate module user # 엔티티 파일은 수동으로 생성 (src/user/user.entity.ts)// src/user/user.entity.ts import { Entity, Column, PrimaryGeneratedColumn, Unique } from 'typeorm'; @Entity() @Unique(['telegramId']) // telegramId는 중복되면 안 됩니다. export class User { @PrimaryGeneratedColumn() id: number; // DB 내부 Primary Key @Column({ type: 'bigint' }) telegramId: number; // 텔레그램 사용자 ID (숫자가 매우 크기 때문에 bigint 사용) @Column({ nullable: true }) username: string; // 텔레그램 사용자명 (@username) @Column({ default: 'idle' }) state: string; // 봇 상호작용 상태 (예: 'reservation_step1', 'idle') @Column({ default: 'ko' }) language: string; // 사용자가 선택한 언어 @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) createdAt: Date; } - 새로운 user 모듈을 만들고 엔티티를 생성합니다.
- UserModule 및 UserRepository 연결:
// src/user/user.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './user.entity'; import { UserService } from './user.service'; @Module({ imports: [TypeOrmModule.forFeature([User])], // User 엔티티 등록 providers: [UserService], exports: [UserService, TypeOrmModule], // BotModule에서 사용하기 위해 export }) export class UserModule {} - TypeOrmModule.forFeature()를 사용하여 User 엔티티를 UserModule에 등록합니다.
3. UserService를 이용한 사용자 CRUD 구현
UserService에서 TypeORM의 Repository를 주입받아 사용자 정보를 저장하고 조회하는 CRUD 로직을 구현합니다.
// src/user/user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UserService {
constructor(
// Repository 주입
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
/**
* 텔레그램 ID로 사용자 정보를 찾거나, 없으면 새로 생성합니다.
*/
async findOrCreate(telegramId: number, username: string): Promise<User> {
let user = await this.usersRepository.findOneBy({ telegramId });
if (!user) {
// 새로운 사용자 생성 및 저장
user = this.usersRepository.create({ telegramId, username });
await this.usersRepository.save(user);
}
return user;
}
/**
* 사용자의 상태(state)를 업데이트합니다.
*/
async updateState(telegramId: number, newState: string): Promise<void> {
await this.usersRepository.update({ telegramId }, { state: newState });
}
/**
* 텔레그램 ID로 사용자 정보를 조회합니다.
*/
async findOneByTelegramId(telegramId: number): Promise<User | null> {
return this.usersRepository.findOneBy({ telegramId });
}
}
4. BotUpdate에서 UserService 활용
UserModule을 BotModule에 임포트(imports)하고 UserService를 BotUpdate에 주입하여 사용합니다.
- BotModule 수정 (src/bot/bot.module.ts):
// src/bot/bot.module.ts import { Module } from '@nestjs/common'; import { UserModule } from '../user/user.module'; // UserModule 임포트 import { BotUpdate } from './bot.service'; // ... @Module({ imports: [UserModule], // UserModule의 export된 UserService를 사용 가능하게 함 providers: [BotUpdate, /* ... 다른 Service ... */], }) export class BotModule {} - BotUpdate 수정 (src/bot/bot.service.ts):
// src/bot/bot.service.ts import { Update, Ctx, Start } from 'nestjs-telegraf'; import { Context } from 'telegraf'; import { UserService } from '../user/user.service'; // UserService 임포트 // ... @Update() export class BotUpdate { constructor( private readonly userService: UserService, // UserService 주입 // private readonly botLogicService: BotLogicService, ) {} /** * /start 명령어 실행 시 사용자 정보 저장 또는 업데이트 */ @Start() async onStart(@Ctx() ctx: Context) { // @ts-ignore const telegramId = ctx.from.id; // @ts-ignore const username = ctx.from.username; // 1. 사용자 정보를 DB에 저장하거나 불러옵니다. const user = await this.userService.findOrCreate(telegramId, username); // 2. 사용자의 상태를 'idle'로 업데이트 await this.userService.updateState(telegramId, 'idle'); this.logger.log(`[START] User: ${user.username || user.telegramId} (State: ${user.state})`); await ctx.reply(`환영합니다, ${ctx.from.first_name}님! 현재 DB에 당신의 정보가 저장되었고, 상태는 'idle'입니다.`); } // ... (다른 핸들러에서 this.userService를 이용해 상태나 데이터를 영구적으로 관리할 수 있습니다.) }
결론: 이제 NestJS 봇은 TypeORM과 PostgreSQL을 통해 사용자 정보를 영구적으로 저장하고 관리할 수 있게 되었습니다. 봇의 구조는 BotUpdate} \rightarrow UserService} \rightarrow Repository} \rightarrow DB의 깔끔한 계층을 갖게 되었습니다. 다음 10화에서는 NestJS의 Guard기능을 활용하여 특정 사용자에게만 봇 관리 권한을 부여하는 방법을 다루겠습니다.
10화: Guard를 이용한 관리자 권한 및 접근 제어 구현
지난 9화에서 TypeORM을 이용해 사용자 데이터를 영구적으로 저장하는 방법을 구현했습니다. 이제 봇의 특정 기능(예: 공지사항 발송, 사용자 통계 조회 등)을 특정 사용자(관리자)에게만 허용하도록 접근을 제어하는 방법을 알아보겠습니다.
NestJS에서는 Guard 기능을 사용하여 요청이 처리되기 전에 특정 조건을 검사하고 접근을 제어할 수 있습니다. 이는 봇의 보안과 권한 관리에 필수적인 패턴입니다.
1. NestJS Guard의 이해
- Guard란? Guard는 ExecutionContext를 기반으로 요청이 라우트 핸들러(BotUpdate의 @Command() 함수)에 도달하기 전에 실행되어, 요청을 허용(true)할지 거부(false)할지를 결정하는 클래스입니다.
- 적용 위치: 텔레그램 봇 환경에서는 BotUpdate 클래스의 특정 @Command() 또는 @Action() 핸들러 위에 @UseGuards() 데코레이터를 붙여 사용합니다.
2. 관리자 ID 설정
관리자 ID는 봇의 가장 중요한 접근 제어 정보이므로, 환경 변수에 안전하게 저장합니다.
- .env 파일 업데이트: 봇 관리자로 지정할 사용자 ID를 추가합니다. TELEGRAM_ADMIN_ID는 텔레그램에서 제공하는 숫자 ID여야 합니다.
# .env 파일 내용 추가 TELEGRAM_ADMIN_ID=123456789 # 당신의 텔레그램 숫자 ID로 대체하세요. - ConfigService 접근: BotUpdate 클래스 및 Guard에서 이 환경 변수에 접근할 수 있도록 준비합니다.
3. AdminGuard 구현
관리자 ID와 현재 메시지를 보낸 사용자의 ID를 비교하여 접근을 제어하는 AdminGuard를 생성합니다.
- AdminGuard 생성:
nest generate guard bot/admin --no-spec - AdminGuard 구현 (src/bot/admin.guard.ts):
// src/bot/admin.guard.ts import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TelegrafExecutionContext } from 'nestjs-telegraf'; // Telegraf 전용 컨텍스트 임포트 @Injectable() export class AdminGuard implements CanActivate { // ConfigService를 주입받아 관리자 ID에 접근 constructor(private configService: ConfigService) {} canActivate(context: ExecutionContext): boolean { // Telegraf 환경에 맞는 컨텍스트 추출 const telegrafContext = TelegrafExecutionContext.create(context); // Telegraf Context 객체 (ctx)를 가져옵니다. const ctx = telegrafContext.getContext(); // 1. 메시지를 보낸 사용자 ID (from.id)를 가져옵니다. // @ts-ignore const userId = ctx.from.id; // 2. 환경 변수에 저장된 관리자 ID(문자열)를 가져와 숫자로 변환합니다. const adminId = this.configService.get<number>('TELEGRAM_ADMIN_ID'); // 3. 현재 사용자 ID와 관리자 ID를 비교합니다. const isAdmin = userId === adminId; if (!isAdmin) { // 접근이 거부되었을 때 사용자에게 직접 응답을 보낼 수도 있습니다. // 하지만 Guard는 동기적으로 동작해야 하므로, Guard 내에서 reply는 권장되지 않습니다. // ctx.reply("❌ 접근 권한이 없습니다."); // (테스트 목적으로는 가능) } // true면 핸들러 실행, false면 접근 거부 return isAdmin; } }
4. Guard 적용 및 AdminCommand 구현
BotUpdate에 관리자만 사용할 수 있는 명령어를 구현하고, 그 위에 AdminGuard를 적용합니다.
- BotUpdate 수정 (src/bot/bot.service.ts):
// src/bot/bot.service.ts import { Update, Ctx, Command, UseGuards, Inject } from 'nestjs-telegraf'; import { AdminGuard } from './admin.guard'; // Guard 임포트 import { BotLogicService } from './bot-logic.service'; // ... @Update() export class BotUpdate { private readonly logger = new Logger(BotUpdate.name); constructor( private readonly botLogicService: BotLogicService, // ... (다른 Service 주입) ) {} // ... (기존 핸들러들) /** * @UseGuards(AdminGuard) 데코레이터를 적용: * 이 핸들러는 AdminGuard가 true를 반환할 때만 실행됩니다. */ @UseGuards(AdminGuard) @Command('admin_stats') async onAdminStats(@Ctx() ctx: Context) { this.logger.log(`[ADMIN] 관리자 ${ctx.from.username}이 통계 요청`); // 실제로는 BotLogicService를 통해 DB에서 통계 데이터를 가져옵니다. const totalUsers = 100; // 가상 데이터 await ctx.reply(`📊 **관리자 통계 정보**\n\n현재 총 사용자 수: ${totalUsers}명`); } /** * Guard가 false를 반환했을 때의 처리 (Fallback) * Guard에 의해 거부된 요청은 일반적으로 응답이 없으므로, Fallback 핸들러를 사용하여 * 거부된 사용자에게 메시지를 전달하는 것이 좋습니다. * * [참고]: Guard가 false를 반환하면 Command 핸들러가 실행되지 않고 응답이 없으므로, * Guard 내부에서 강제로 응답하거나, 더 복잡한 인터셉터(Interceptor)나 예외 필터(Exception Filter)를 사용해 처리해야 합니다. * 가장 간단한 방법은 Guard에서 직접 응답하는 것입니다. */ @Command('admin_stats') async onNonAdminRequest(@Ctx() ctx: Context) { // 이 핸들러는 Guard에 의해 실행되지 않지만, Guard 내에서 처리하는 것이 일반적입니다. // 안전을 위해 Guard 내에서 직접 응답을 넣어줍니다. } }
5. 테스트 및 요약
- 서버 재시작: npm run start:dev로 서버를 재시작하고 ngrok을 확인합니다.
- 관리자 ID로 테스트: .env에 등록된 ID를 가진 텔레그램 계정으로 봇에게 /admin_stats를 입력합니다. 통계 정보가 출력됩니다.
- 일반 사용자 ID로 테스트: 다른 텔레그램 계정으로 봇에게 /admin_stats를 입력합니다. 아무 응답도 받지 못합니다. (이는 Guard가 응답을 거부했기 때문이며, 응답을 원한다면 Guard 내에서 ctx.reply()를 사용해야 합니다.)
결론: NestJS Guard를 사용하여 봇의 특정 명령에 대한 접근을 텔레그램 ID 기반으로 성공적으로 제어했습니다. 이는 봇 개발에 있어 보안 및 권한 관리를 NestJS 프레임워크의 강력한 기능으로 해결하는 모범적인 사례입니다.
'Nest.js를 배워보자' 카테고리의 다른 글
| ✨ Prisma 7과 NestJS, MySQL로 시작하기: 차세대 ORM 가이드 (1) | 2025.12.09 |
|---|---|
| 💡 NestJS에서 Gateway란? (0) | 2025.12.05 |
| 📢 NestJS: 멀티 WebSocket 서버 운용 전략 (4000번 & 4001번) (0) | 2025.12.05 |
| NestJS와 텔레그램 연동 실습 중심 목차 (N화 구성) (0) | 2025.12.03 |
| Next.js 서버 헬스 모니터링: 안정적인 서비스 운영을 위한 필수 전략 (0) | 2025.12.03 |