본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2026. 1. 31. · 7 Views
Multi-Channel 라우팅 시스템 완벽 가이드
슬랙, 디스코드, 텔레그램 등 여러 메시징 채널을 하나의 시스템으로 통합 관리하는 라우팅 시스템을 처음부터 끝까지 배워봅니다. 실무에서 바로 활용할 수 있는 어댑터 패턴과 라우팅 로직을 쉽게 설명합니다.
목차
1. 도입 왜 여러 채널을 통합해야 하나
김개발 씨는 AI 챗봇 서비스를 운영하는 스타트업에 입사했습니다. 첫 출근날, 선배가 보여준 코드는 슬랙용 봇, 디스코드용 봇, 텔레그램용 봇이 각각 별도로 작성되어 있었습니다.
"새로운 기능을 추가하려면 세 곳을 다 수정해야 해요..."라는 말에 김개발 씨는 고개를 갸우뚱했습니다.
Multi-Channel 라우팅은 여러 메시징 플랫폼을 하나의 통합 시스템으로 관리하는 설계 방식입니다. 마치 공항의 허브 터미널처럼, 어디서 들어온 메시지든 중앙에서 처리한 뒤 다시 각 채널로 보내주는 것입니다.
이를 통해 코드 중복을 없애고 새로운 채널 추가를 쉽게 만들 수 있습니다.
다음 코드를 살펴봅시다.
// 채널별로 분산된 코드 (문제가 있는 방식)
// slack-bot.ts
app.post('/slack', async (req, res) => {
const message = req.body.text;
const response = await processAI(message); // 중복 코드
await sendToSlack(response);
});
// discord-bot.ts
client.on('message', async (msg) => {
const message = msg.content;
const response = await processAI(message); // 또 중복!
await msg.reply(response);
});
김개발 씨가 처음 마주한 코드베이스는 혼란 그 자체였습니다. 슬랙 봇을 위한 파일이 따로 있고, 디스코드 봇을 위한 파일도 따로 있었습니다.
텔레그램 봇도 마찬가지였습니다. 문제는 이 모든 파일이 거의 같은 일을 한다는 점이었습니다.
선배 개발자 박시니어 씨가 한숨을 쉬며 말했습니다. "지난주에 AI 응답 로직을 수정했는데, 세 파일을 다 고쳐야 했어요.
한 곳을 빠뜨려서 슬랙에서만 버그가 발생했죠." 이런 문제가 왜 발생할까요? 각 메시징 플랫폼은 서로 다른 API를 제공합니다.
슬랙은 웹훅을 사용하고, 디스코드는 웹소켓 이벤트를 쓰며, 텔레그램은 또 다른 방식입니다. 처음에는 "플랫폼마다 파일을 만들면 되겠지"라고 생각하기 쉽습니다.
하지만 서비스가 성장하면서 문제가 드러납니다. 새로운 AI 기능을 추가할 때마다 모든 파일을 수정해야 합니다.
한 채널에서 버그를 고치면 다른 채널도 똑같이 고쳐야 합니다. 새로운 채널을 추가하려면 기존 코드를 복사해서 또 다른 파일을 만들어야 합니다.
코드 리뷰도 세 배로 늘어납니다. 김개발 씨는 생각했습니다.
"분명히 더 나은 방법이 있을 텐데..." 바로 그때 박시니어 씨가 화면을 가리키며 말했습니다. "우리 팀에서는 이 문제를 Multi-Channel 라우팅 시스템으로 해결하고 있어요.
한번 같이 볼까요?" Multi-Channel 라우팅의 핵심 아이디어는 간단합니다. 메시지 처리 로직은 채널과 무관하게 하나로 통합하고, 각 채널의 특수한 부분만 얇은 어댑터 레이어로 감싸는 것입니다.
마치 여러 나라에서 온 손님들을 맞이하는 호텔과 같습니다. 프런트 데스크는 하나지만, 각 언어를 구사하는 통역사가 손님과 데스크 사이를 연결해줍니다.
이렇게 설계하면 어떤 이점이 있을까요? 첫째, 코드 중복이 사라집니다.
AI 응답 로직, 데이터베이스 처리, 에러 핸들링 등 핵심 비즈니스 로직은 한 곳에만 존재합니다. 둘째, 새로운 채널 추가가 쉬워집니다.
얇은 어댑터만 작성하면 기존 시스템과 바로 연결됩니다. 셋째, 테스트가 간단해집니다.
핵심 로직을 채널과 독립적으로 테스트할 수 있습니다. 김개발 씨는 이제 이해가 되기 시작했습니다.
"그러니까 채널별 차이는 어댑터가 처리하고, 실제 비즈니스 로직은 공통으로 사용한다는 거죠?" 박시니어 씨가 웃으며 답했습니다. "정확해요!
이제 우리 코드를 한번 살펴볼까요?" 실제 현업에서는 이런 패턴이 매우 중요합니다. 스타트업은 빠르게 성장하면서 새로운 채널을 계속 추가해야 합니다.
처음에는 슬랙만 지원하다가, 고객 요청으로 디스코드를 추가하고, 해외 진출을 위해 텔레그램도 지원합니다. 라우팅 시스템이 없다면 매번 전체 코드를 복사해야 하지만, 시스템이 있다면 어댑터 하나만 추가하면 됩니다.
실전 팁
💡 - 처음부터 완벽한 통합 시스템을 만들려고 하지 마세요. 두세 개 채널이 생기면 그때 리팩토링해도 늦지 않습니다.
- 각 채널의 고유 기능(슬랙의 스레드, 디스코드의 임베드 등)도 어댑터에서 추상화할 수 있도록 설계하세요.
2. 채널 라우팅 개념
박시니어 씨가 화이트보드에 간단한 다이어그램을 그렸습니다. "라우팅 시스템은 크게 세 부분으로 나뉩니다.
입구, 중앙 처리부, 출구죠." 김개발 씨는 메모를 시작했습니다.
채널 라우팅은 메시지가 들어온 채널을 식별하고, 공통 포맷으로 변환한 뒤, 처리 결과를 다시 원래 채널로 되돌려 보내는 과정입니다. 마치 택배 물류 센터처럼, 어디서 왔는지 구분하고, 내용물을 확인한 뒤, 올바른 목적지로 배송하는 것과 같습니다.
이 과정에서 각 채널의 특수성은 감춰지고 표준화된 메시지만 처리됩니다.
다음 코드를 살펴봅시다.
// 라우팅의 핵심 흐름
interface UnifiedMessage {
channelType: 'slack' | 'discord' | 'telegram';
userId: string;
content: string;
timestamp: number;
}
async function routeMessage(channel: string, rawData: any): Promise<void> {
// 1. 입구: 채널별 어댑터로 변환
const adapter = getAdapter(channel);
const unified: UnifiedMessage = adapter.toUnified(rawData);
// 2. 중앙 처리: 비즈니스 로직 실행
const response = await processMessage(unified);
// 3. 출구: 다시 채널 포맷으로 변환하여 전송
await adapter.send(unified.userId, response);
}
김개발 씨는 화이트보드를 보며 고개를 끄덕였습니다. 다이어그램은 생각보다 단순했습니다.
하지만 단순함 속에 강력한 아이디어가 숨어있었습니다. "라우팅의 핵심은 통합 포맷이에요." 박시니어 씨가 설명을 시작했습니다.
"슬랙에서 오는 메시지도, 디스코드에서 오는 메시지도, 모두 같은 형태로 만드는 거죠." 왜 통합 포맷이 필요할까요? 각 플랫폼은 메시지를 서로 다른 구조로 전달합니다.
슬랙은 event.text에 메시지가 있고, 디스코드는 message.content에, 텔레그램은 update.message.text에 들어있습니다. 사용자 ID도 제각각입니다.
이런 차이를 매번 신경 쓰면 핵심 로직이 복잡해집니다. 그래서 통합 인터페이스를 정의합니다.
위 코드의 UnifiedMessage를 보세요. 어느 채널에서 왔든 모든 메시지는 이 형태로 변환됩니다.
channelType으로 출처를 기록하고, userId로 사용자를 식별하며, content에 실제 메시지를 담습니다. 간단하지만 강력합니다.
라우팅 과정은 세 단계로 이루어집니다. 첫 번째 단계는 입구입니다.
각 채널에서 메시지가 도착하면 해당 채널의 어댑터가 받아서 UnifiedMessage로 변환합니다. 이때 채널별 복잡한 구조는 모두 숨겨집니다.
슬랙의 이벤트 객체가 들어오든, 디스코드의 메시지 객체가 들어오든, 출력은 항상 같습니다. 두 번째 단계는 중앙 처리입니다.
이제 채널을 신경 쓸 필요가 없습니다. processMessage 함수는 통합된 메시지만 받아서 처리합니다.
AI 모델에 전달하고, 데이터베이스에 저장하고, 비즈니스 로직을 실행합니다. 이 부분이 바로 핵심 가치를 만드는 곳입니다.
세 번째 단계는 출구입니다. 처리 결과를 다시 원래 채널로 보내야 합니다.
여기서도 어댑터가 활약합니다. adapter.send()는 채널별로 올바른 API를 호출해서 응답을 전달합니다.
슬랙이면 웹훅을, 디스코드면 메시지 API를, 텔레그램이면 sendMessage를 사용합니다. 김개발 씨가 질문했습니다.
"그런데 getAdapter(channel) 부분은 어떻게 동작하나요?" "좋은 질문이에요!" 박시니어 씨가 답했습니다. "팩토리 패턴을 사용합니다.
채널 이름을 받아서 해당하는 어댑터 인스턴스를 반환하는 거죠. 마치 공장에서 주문에 맞는 제품을 만들어주는 것처럼요." 실제 서비스에서는 이런 라우팅 로직이 어떻게 쓰일까요?
예를 들어 고객 문의 봇을 만든다고 가정해봅시다. 고객은 슬랙, 디스코드, 텔레그램 중 편한 곳에서 질문합니다.
라우팅 시스템은 질문을 받아서 통합 포맷으로 만들고, AI가 답변을 생성하며, 다시 고객이 사용한 채널로 응답을 보냅니다. 고객은 채널 차이를 전혀 느끼지 못합니다.
이런 설계의 장점은 확장성입니다. 새로운 채널을 추가하려면 어댑터만 구현하면 됩니다.
핵심 로직은 건드릴 필요가 없습니다. 기존 채널의 버그를 고칠 때도 해당 어댑터만 수정하면 됩니다.
각 부분이 독립적이라서 팀원들이 동시에 작업하기도 쉽습니다. 김개발 씨는 노트에 요점을 정리했습니다.
"입구에서 통합 포맷으로 변환, 중앙에서 비즈니스 로직 처리, 출구에서 다시 채널 포맷으로 변환. 이 흐름만 기억하면 되겠네요."
실전 팁
💡 - 통합 메시지 인터페이스는 처음부터 완벽하게 만들 수 없습니다. 채널을 추가하면서 필요한 필드를 점진적으로 추가하세요.
- 에러 처리도 통합 포맷에 포함시키면 좋습니다. 어느 채널에서 에러가 났는지 추적할 수 있습니다.
3. src/channels 디렉토리 구조
박시니어 씨가 VS Code를 열고 프로젝트 구조를 보여줬습니다. src/channels 폴더를 펼치자 깔끔하게 정리된 파일들이 나타났습니다.
"우리 팀은 이렇게 구조를 잡았어요."
채널 디렉토리 구조는 각 채널의 어댑터를 독립적인 모듈로 분리하고, 공통 인터페이스를 통해 연결하는 방식입니다. 마치 도서관에서 책을 분류하듯, 슬랙 관련 코드는 슬랙 폴더에, 디스코드 코드는 디스코드 폴더에 넣어 관리합니다.
이를 통해 코드 찾기가 쉬워지고, 각 채널을 독립적으로 개발할 수 있습니다.
다음 코드를 살펴봅시다.
// src/channels 디렉토리 구조
/*
src/
channels/
types.ts // 통합 인터페이스 정의
factory.ts // 어댑터 팩토리
slack/
adapter.ts // 슬랙 어댑터 구현
types.ts // 슬랙 전용 타입
discord/
adapter.ts // 디스코드 어댑터 구현
types.ts // 디스코드 전용 타입
telegram/
adapter.ts // 텔레그램 어댑터 구현
types.ts // 텔레그램 전용 타입
*/
김개발 씨는 폴더 구조를 보며 감탄했습니다. 한눈에 들어오는 깔끔한 구조였습니다.
"이렇게 정리하니까 어디에 뭐가 있는지 바로 알 수 있네요." 폴더 구조는 프로젝트의 얼굴입니다. 좋은 구조는 새로운 팀원이 와도 금방 이해할 수 있게 만듭니다.
나쁜 구조는 경험 많은 개발자도 헤매게 합니다. Multi-Channel 시스템에서는 구조가 특히 중요합니다.
채널이 늘어날수록 복잡도가 기하급수적으로 증가하기 때문입니다. 우리 팀의 구조는 계층화 원칙을 따릅니다.
가장 상위에는 types.ts와 factory.ts가 있습니다. 이 파일들은 모든 채널이 공통으로 사용하는 인터페이스와 팩토리를 정의합니다.
일종의 계약서입니다. "우리 시스템에서 어댑터가 되려면 이런 규칙을 따라야 해요"라고 선언하는 것입니다.
그 아래에는 각 채널별 폴더가 있습니다. slack/, discord/, telegram/ 각 폴더는 완전히 독립적입니다.
슬랙 폴더 안에서는 슬랙 API의 복잡한 부분을 다루고, 디스코드 폴더는 디스코드만 신경 씁니다. 서로 의존하지 않기 때문에 한 채널을 수정해도 다른 채널에 영향을 주지 않습니다.
각 채널 폴더 안에는 두 파일이 있습니다. adapter.ts는 실제 구현입니다.
메시지를 받아서 통합 포맷으로 변환하고, 응답을 다시 채널 포맷으로 바꿔서 전송하는 코드가 들어있습니다. types.ts는 채널 전용 타입입니다.
슬랙 이벤트 구조, 디스코드 메시지 객체 등 채널별로 필요한 타입을 정의합니다. 박시니어 씨가 types.ts 파일을 열었습니다.
typescript // src/channels/types.ts export interface ChannelAdapter { toUnified(rawData: any): UnifiedMessage; send(userId: string, content: string): Promise<void>; } export interface UnifiedMessage { channelType: string; userId: string; content: string; timestamp: number; } "이 인터페이스가 핵심이에요. 모든 어댑터는 이 규약을 따라야 합니다." 김개발 씨가 물었습니다.
"그러면 새로운 채널을 추가할 때도 이 인터페이스를 구현하면 되는 거죠?" 정확합니다. 예를 들어 LINE 메신저를 추가한다고 가정해봅시다.
src/channels/line/ 폴더를 만들고, adapter.ts에서 ChannelAdapter 인터페이스를 구현합니다. toUnified()와 send()만 작성하면 기존 시스템에 바로 연결됩니다.
다른 파일은 건드릴 필요가 없습니다. factory.ts는 어떤 역할을 할까요?
팩토리는 채널 이름을 받아서 적절한 어댑터 인스턴스를 반환합니다. 마치 레스토랑 주방장이 주문을 받아서 요리를 만들어주는 것과 같습니다.
"슬랙 어댑터 하나요!"라고 요청하면 슬랙 어댑터를 만들어줍니다. 실무에서는 환경 변수나 설정 파일도 같이 관리합니다.
각 채널의 API 키, 웹훅 URL 등은 .env 파일이나 AWS Secrets Manager에 저장합니다. 어댑터는 초기화할 때 이런 설정을 읽어옵니다.
덕분에 코드에 민감한 정보를 하드코딩하지 않아도 됩니다. 김개발 씨는 이제 구조가 명확해졌습니다.
"공통 부분은 최상위에, 채널별 구현은 각자 폴더에. 명확하고 확장하기 쉬운 구조네요!" 이런 구조의 또 다른 장점은 테스트입니다.
각 어댑터를 독립적으로 테스트할 수 있습니다. 슬랙 어댑터 테스트를 작성할 때 디스코드를 신경 쓸 필요가 없습니다.
Mock 객체도 만들기 쉽습니다. 인터페이스가 명확하니까요.
실전 팁
💡 - 새로운 채널을 추가할 때는 기존 채널 폴더를 복사해서 시작하세요. 구조가 비슷하니 빠르게 작업할 수 있습니다.
types.ts에 채널별 설정 인터페이스도 같이 정의하면 타입 안전성이 높아집니다.
4. 채널별 어댑터 패턴
박시니어 씨가 슬랙 어댑터 파일을 열었습니다. "어댑터 패턴이 뭔지 알아요?" 김개발 씨는 디자인 패턴 책에서 본 것 같다고 대답했습니다.
"맞아요. 여기서 실제로 어떻게 쓰이는지 보죠."
어댑터 패턴은 서로 호환되지 않는 인터페이스를 연결해주는 다리 역할을 합니다. 마치 여행할 때 사용하는 멀티 플러그처럼, 각 나라의 콘센트 모양이 달라도 어댑터만 있으면 전자기기를 쓸 수 있습니다.
Multi-Channel 시스템에서는 각 플랫폼의 API를 통합 인터페이스로 변환하는 역할을 담당합니다.
다음 코드를 살펴봅시다.
// src/channels/slack/adapter.ts
import { ChannelAdapter, UnifiedMessage } from '../types';
export class SlackAdapter implements ChannelAdapter {
constructor(private webhookUrl: string) {}
toUnified(rawData: any): UnifiedMessage {
// 슬랙 이벤트를 통합 포맷으로 변환
return {
channelType: 'slack',
userId: rawData.event.user,
content: rawData.event.text,
timestamp: Date.now()
};
}
async send(userId: string, content: string): Promise<void> {
// 슬랙 웹훅으로 메시지 전송
await fetch(this.webhookUrl, {
method: 'POST',
body: JSON.stringify({ text: content, channel: userId })
});
}
}
김개발 씨는 코드를 보며 고개를 끄덕였습니다. 생각보다 간단했습니다.
하지만 이 간단함이 강력한 힘을 만들어냈습니다. 어댑터 패턴은 GoF 디자인 패턴 중 하나입니다.
원래는 "기존 클래스를 수정하지 않고 인터페이스를 변경"하기 위해 고안되었습니다. 우리는 슬랙 API를 수정할 수 없습니다.
슬랙이 제공하는 구조를 그대로 받아야 합니다. 하지만 우리 시스템은 통합 포맷을 원합니다.
어댑터가 바로 이 간극을 메웁니다. 위 코드를 자세히 살펴봅시다.
SlackAdapter 클래스는 ChannelAdapter 인터페이스를 구현합니다. 이것이 계약을 따르는 방법입니다.
TypeScript는 두 메서드 toUnified()와 send()가 제대로 구현되었는지 컴파일 타임에 체크합니다. 실수로 메서드를 빠뜨리면 빌드가 실패합니다.
생성자에서는 설정을 받습니다. 슬랙 어댑터는 웹훅 URL이 필요합니다.
이 URL로 메시지를 전송하기 때문입니다. private 키워드 덕분에 클래스 내부에서만 사용 가능한 프로퍼티가 됩니다.
toUnified() 메서드가 핵심입니다. 슬랙에서 오는 rawData는 복잡한 중첩 구조입니다.
event 객체 안에 user와 text가 있습니다. 어댑터는 이 구조를 뜯어서 우리 시스템이 이해하는 평면적인 구조로 바꿉니다.
userId, content, timestamp 세 가지만 추출합니다. 이 변환 덕분에 나머지 시스템은 슬랙 구조를 몰라도 됩니다.
비즈니스 로직을 작성하는 개발자는 UnifiedMessage만 알면 됩니다. 슬랙 API 문서를 읽을 필요가 없습니다.
이것이 바로 추상화의 힘입니다. 복잡한 것을 숨기고 간단한 인터페이스만 제공합니다.
send() 메서드는 반대 방향입니다. 우리 시스템은 간단한 문자열 응답을 만들어냅니다.
어댑터는 이것을 슬랙이 이해하는 형태로 포장합니다. { text: content, channel: userId } 객체를 만들어서 웹훅으로 전송합니다.
슬랙 API의 복잡한 옵션들은 어댑터 내부에 숨겨집니다. 박시니어 씨가 디스코드 어댑터도 보여줬습니다.
typescript export class DiscordAdapter implements ChannelAdapter { toUnified(rawData: any): UnifiedMessage { return { channelType: 'discord', userId: rawData.author.id, content: rawData.content, timestamp: Date.now() }; } async send(userId: string, content: string): Promise<void> { const channel = await client.channels.fetch(userId); await channel.send(content); } } "보세요. 구조는 똑같지만 내부 구현이 달라요." 김개발 씨는 차이점을 발견했습니다.
디스코드는 author.id를 쓰고, 메시지 전송도 다른 방식입니다. 하지만 외부에서 보면 둘 다 같은 ChannelAdapter입니다.
이것이 다형성의 아름다움입니다. 라우팅 로직은 "네가 슬랙 어댑터든 디스코드 어댑터든 상관없어.
toUnified()와 send()만 제공하면 돼"라고 말합니다. 덕분에 라우터 코드는 채널이 10개가 되든 100개가 되든 변하지 않습니다.
실무에서 어댑터에는 더 많은 기능이 들어갑니다. 에러 처리, 재시도 로직, 속도 제한 처리, 로깅 등이 추가됩니다.
하지만 핵심 아이디어는 동일합니다. "플랫폼별 복잡함은 어댑터가 흡수하고, 나머지 시스템에는 깔끔한 인터페이스만 제공한다." 김개발 씨는 이제 어댑터 패턴이 왜 필요한지 완전히 이해했습니다.
"각 플랫폼의 차이를 어댑터가 감싸서, 시스템은 하나의 인터페이스만 보게 하는 거네요!"
실전 팁
💡 - 어댑터에 너무 많은 로직을 넣지 마세요. 변환과 전송만 담당하게 하고, 비즈니스 로직은 별도 레이어에 두세요.
- 에러 처리 시 채널별 에러 코드를 통합 에러로 매핑하는 로직을 추가하면 좋습니다.
5. 메시지 라우팅 로직 분석
"이제 실제 라우터가 어떻게 동작하는지 볼까요?" 박시니어 씨가 router.ts 파일을 열었습니다. 김개발 씨는 지금까지 배운 내용이 어떻게 합쳐지는지 궁금했습니다.
메시지 라우팅 로직은 들어온 요청을 분석하여 적절한 어댑터를 선택하고, 메시지를 처리한 뒤, 응답을 다시 보내는 전체 흐름을 조율합니다. 마치 교통 관제센터처럼, 어디서 온 차량인지 확인하고, 적절한 경로로 안내하며, 목적지까지 안전하게 도착하도록 관리합니다.
이 로직에서 팩토리 패턴, 어댑터 패턴, 전략 패턴이 모두 조화롭게 사용됩니다.
다음 코드를 살펴봅시다.
// src/router.ts
import { getAdapter } from './channels/factory';
import { processMessage } from './services/message-processor';
export async function routeIncomingMessage(
channelType: string,
rawData: any
): Promise<void> {
try {
// 1. 팩토리로 어댑터 가져오기
const adapter = getAdapter(channelType);
// 2. 원시 데이터를 통합 포맷으로 변환
const unified = adapter.toUnified(rawData);
// 3. 비즈니스 로직 실행
const response = await processMessage(unified);
// 4. 응답을 원래 채널로 전송
await adapter.send(unified.userId, response);
} catch (error) {
console.error(`Routing failed for ${channelType}:`, error);
// 에러 처리 로직
}
}
김개발 씨는 코드를 보며 감탄했습니다. 불과 20줄 정도의 코드가 전체 시스템을 연결하고 있었습니다.
이 함수가 바로 시스템의 심장입니다. 모든 메시지는 이 함수를 거칩니다.
슬랙에서 오든, 디스코드에서 오든, 텔레그램에서 오든 상관없습니다. 이 함수는 채널을 구분하지 않습니다.
그저 channelType이라는 문자열만 받아서 처리합니다. 첫 번째 단계: 어댑터 가져오기.
getAdapter(channelType)이 호출됩니다. 이 팩토리 함수는 내부적으로 간단한 스위치 문을 사용합니다.
"slack"이면 SlackAdapter 인스턴스를, "discord"면 DiscordAdapter를 반환합니다. 여기서 팩토리 패턴의 장점이 드러납니다.
라우터는 어떤 어댑터가 존재하는지 몰라도 됩니다. 두 번째 단계: 메시지 변환.
adapter.toUnified(rawData)가 실행됩니다. 각 어댑터는 자신만의 방식으로 원시 데이터를 해석합니다.
슬랙 어댑터는 슬랙 이벤트 구조를 알고, 디스코드 어댑터는 디스코드 메시지 객체를 압니다. 하지만 출력은 언제나 UnifiedMessage입니다.
이 시점부터 채널의 차이는 완전히 사라집니다. 세 번째 단계: 비즈니스 로직 실행.
processMessage(unified)가 핵심 가치를 만들어냅니다. 이 함수 안에서는 AI 모델을 호출하거나, 데이터베이스를 조회하거나, 외부 API를 호출합니다.
여기가 바로 서비스의 본질입니다. 그리고 이 함수는 채널을 전혀 신경 쓰지 않습니다.
UnifiedMessage만 받아서 처리합니다. 박시니어 씨가 message-processor.ts를 잠깐 보여줬습니다.
typescript export async function processMessage(msg: UnifiedMessage): Promise<string> { // AI 모델 호출 const aiResponse = await callAI(msg.content); // 데이터베이스에 로그 저장 await saveToDatabase(msg); return aiResponse; } "보세요. 여기는 완전히 채널에 독립적이죠.
슬랙이든 디스코드든 똑같이 처리합니다." 김개발 씨는 이해했습니다. 이것이 바로 관심사의 분리였습니다.
네 번째 단계: 응답 전송. adapter.send(unified.userId, response)가 마지막 단계입니다.
처리 결과를 다시 원래 채널로 보냅니다. 여기서도 어댑터가 채널별 차이를 흡수합니다.
슬랙 어댑터는 웹훅을 쓰고, 디스코드 어댑터는 채널 API를 씁니다. 라우터는 그저 "보내줘"라고만 요청합니다.
에러 처리도 중요합니다. try-catch 블록으로 전체를 감싸서, 어느 단계에서든 에러가 나면 잡아냅니다.
에러 로그에는 channelType이 포함되어 있어서, 어느 채널에서 문제가 생겼는지 바로 알 수 있습니다. 실무에서는 여기에 재시도 로직, 알림 전송 등을 추가합니다.
이 라우터의 아름다움은 확장성에 있습니다. 새로운 채널을 추가해도 이 함수는 한 줄도 바뀌지 않습니다.
팩토리에 새 어댑터를 등록하기만 하면 됩니다. 라우터는 "어댑터가 있으면 되는 거 아니야?"라고 생각합니다.
어떤 어댑터든 상관없습니다. 실무에서는 미들웨어 패턴도 추가됩니다.
인증 체크, 속도 제한, 로깅 등을 미들웨어로 구현하면 라우터 전후에 끼워넣을 수 있습니다. Express.js의 미들웨어와 비슷한 개념입니다.
이렇게 하면 라우터 자체는 더욱 간결해집니다. 김개발 씨는 전체 흐름을 머릿속으로 그려봤습니다.
웹훅이 들어오면 → 라우터가 받아서 → 팩토리로 어댑터를 만들고 → 어댑터가 변환하고 → 비즈니스 로직이 처리하고 → 다시 어댑터가 전송한다. 깔끔한 흐름이었습니다.
"이제 새 채널을 추가하는 게 어렵지 않을 것 같아요." 김개발 씨가 자신 있게 말했습니다. 박시니어 씨가 웃으며 답했습니다.
"그럼 직접 해볼까요?"
실전 팁
💡 - 라우터에 로깅을 추가할 때는 구조화된 로그(JSON 포맷)를 사용하세요. 나중에 분석하기 쉽습니다.
- 비동기 처리를 할 때 타임아웃을 설정하세요. 한 채널이 느려져도 다른 채널에 영향을 주지 않게 합니다.
6. 실전 새 채널 추가하기
박시니어 씨가 과제를 내줬습니다. "카카오톡 채널을 추가해보세요.
지금까지 배운 걸 활용하면 할 수 있을 거예요." 김개발 씨는 노트북을 열고 손가락을 움직이기 시작했습니다.
새 채널 추가는 기존 시스템의 구조를 활용하여 점진적으로 확장하는 과정입니다. 마치 레고 블록을 추가하듯, 정해진 규칙에 따라 어댑터를 만들고 팩토리에 등록하면 됩니다.
핵심 시스템은 전혀 수정하지 않아도 되며, 새로운 폴더와 파일만 추가하면 통합됩니다.
다음 코드를 살펴봅시다.
// src/channels/kakao/adapter.ts
import { ChannelAdapter, UnifiedMessage } from '../types';
export class KakaoAdapter implements ChannelAdapter {
constructor(private apiKey: string) {}
toUnified(rawData: any): UnifiedMessage {
// 카카오톡 메시지를 통합 포맷으로 변환
return {
channelType: 'kakao',
userId: rawData.user_key,
content: rawData.content,
timestamp: Date.now()
};
}
async send(userId: string, content: string): Promise<void> {
// 카카오톡 API로 메시지 전송
await fetch('https://kapi.kakao.com/v2/api/talk/memo/default/send', {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.apiKey}` },
body: JSON.stringify({
template_object: {
object_type: 'text',
text: content
}
})
});
}
}
// src/channels/factory.ts에 추가
import { KakaoAdapter } from './kakao/adapter';
export function getAdapter(channelType: string): ChannelAdapter {
switch (channelType) {
case 'slack': return new SlackAdapter(process.env.SLACK_WEBHOOK);
case 'discord': return new DiscordAdapter(process.env.DISCORD_TOKEN);
case 'telegram': return new TelegramAdapter(process.env.TELEGRAM_TOKEN);
case 'kakao': return new KakaoAdapter(process.env.KAKAO_API_KEY); // 새로 추가
default: throw new Error(`Unknown channel: ${channelType}`);
}
}
김개발 씨는 첫 번째 단계로 폴더를 만들었습니다. src/channels/kakao/를 생성하고, 그 안에 adapter.ts 파일을 추가했습니다.
가장 먼저 할 일은 인터페이스를 구현하는 것입니다. class KakaoAdapter implements ChannelAdapter라고 선언하는 순간, TypeScript가 "두 메서드를 구현해야 합니다"라고 알려줍니다.
IDE에 빨간 줄이 그어집니다. 좋은 신호입니다.
컴파일러가 가이드를 해주고 있습니다. toUnified() 메서드부터 작성합니다.
카카오톡 API 문서를 열어서 메시지 구조를 확인했습니다. 카카오는 user_key로 사용자를 식별하고, content에 메시지가 들어있었습니다.
이 정보를 UnifiedMessage 형태로 매핑합니다. 다른 어댑터들과 똑같은 패턴입니다.
send() 메서드는 조금 복잡했습니다. 카카오톡 API는 template_object라는 특별한 구조를 요구했습니다.
김개발 씨는 API 문서를 보며 천천히 작성했습니다. object_type을 'text'로 설정하고, text 필드에 메시지를 넣었습니다.
Authorization 헤더에는 API 키를 Bearer 토큰으로 전달했습니다. 여기서 중요한 점이 있습니다.
카카오톡의 복잡한 템플릿 구조는 어댑터 안에 숨겨집니다. 라우터는 여전히 간단한 문자열만 전달합니다.
adapter.send(userId, "안녕하세요")처럼 단순하게 호출합니다. 어댑터가 이것을 카카오톡 형식으로 변환해줍니다.
다음 단계는 팩토리에 등록하는 것입니다. factory.ts 파일을 열어서 switch 문에 새로운 case를 추가했습니다.
case 'kakao': return new KakaoAdapter(process.env.KAKAO_API_KEY); 단 한 줄입니다. 이제 누군가 getAdapter('kakao')를 호출하면 카카오 어댑터가 반환됩니다.
김개발 씨는 .env 파일도 업데이트했습니다. KAKAO_API_KEY=your_kakao_api_key_here 환경 변수로 관리하면 코드에 민감한 정보를 넣지 않아도 됩니다.
개발 환경과 프로덕션 환경에서 다른 키를 사용할 수도 있습니다. 이제 테스트할 차례입니다.
간단한 테스트 스크립트를 작성했습니다. 카카오톡에서 오는 것처럼 가짜 데이터를 만들고, routeIncomingMessage('kakao', fakeData)를 호출했습니다.
라우터는 팩토리에서 카카오 어댑터를 가져오고, 메시지를 변환하며, 비즈니스 로직을 실행한 뒤, 응답을 보냈습니다. 모든 것이 완벽하게 작동했습니다.
김개발 씨는 감격했습니다. 핵심 라우터 코드는 한 줄도 수정하지 않았습니다.
비즈니스 로직도 그대로입니다. 단지 어댑터를 추가하고 팩토리에 등록했을 뿐인데, 시스템이 카카오톡을 지원하게 되었습니다.
박시니어 씨가 코드 리뷰를 해줬습니다. "잘했어요!
한 가지만 추가하면 좋겠네요. 에러 처리를 어댑터 안에 넣어보세요.
카카오 API가 실패하면 어떻게 할 건가요?" 김개발 씨는 send() 메서드에 try-catch를 추가했습니다. 실패하면 재시도하거나 로그를 남기도록 했습니다.
실무에서는 더 많은 고려사항이 있습니다. 속도 제한(rate limiting)을 처리해야 합니다.
카카오톡 API는 분당 호출 횟수 제한이 있을 수 있습니다. 어댑터에 큐를 두거나 throttle 로직을 추가합니다.
또한 메시지 형식도 다양합니다. 텍스트만이 아니라 이미지, 버튼, 카드 등을 지원해야 할 수 있습니다.
하지만 기본 구조는 동일합니다. 통합 인터페이스를 확장하고, 어댑터가 채널별 복잡함을 처리하게 합니다.
시스템의 나머지 부분은 여전히 간단한 인터페이스만 봅니다. 이것이 좋은 아키텍처의 힘입니다.
김개발 씨는 이제 자신감이 생겼습니다. "다음에 LINE이나 WhatsApp을 추가하라고 해도 할 수 있을 것 같아요!" 박시니어 씨가 웃으며 답했습니다.
"그럼 다음 주에 LINE 추가를 부탁할게요."
실전 팁
💡 - 새 채널을 추가할 때는 먼저 테스트 케이스를 작성하세요. 어댑터가 제대로 동작하는지 확인한 후 통합하면 안전합니다.
- API 문서를 꼼꼼히 읽고, 특히 에러 응답 형식을 파악하세요. 각 플랫폼마다 에러 처리 방식이 다릅니다.
- 처음에는 최소 기능만 구현하고, 점진적으로 고급 기능(이미지, 버튼 등)을 추가하세요.
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
UX와 협업 패턴 완벽 가이드
AI 에이전트와 사용자 간의 효과적인 협업을 위한 UX 패턴을 다룹니다. 프롬프트 핸드오프부터 인터럽트 처리까지, 현대적인 에이전트 시스템 설계의 핵심을 배웁니다.
자가 치유 및 재시도 패턴 완벽 가이드
AI 에이전트와 분산 시스템에서 필수적인 자가 치유 패턴을 다룹니다. 에러 감지부터 서킷 브레이커까지, 시스템을 스스로 복구하는 탄력적인 코드 작성법을 배워봅니다.
Feedback Loops 컴파일러와 CI/CD 완벽 가이드
컴파일러 피드백 루프부터 CI/CD 파이프라인, 테스트 자동화, 자가 치유 빌드까지 현대 개발 워크플로우의 핵심을 다룹니다. 초급 개발자도 쉽게 이해할 수 있도록 실무 예제와 함께 설명합니다.
실전 MCP 통합 프로젝트 완벽 가이드
Model Context Protocol을 활용한 실전 통합 프로젝트를 처음부터 끝까지 구축하는 방법을 다룹니다. 아키텍처 설계부터 멀티 서버 통합, 모니터링, 배포까지 운영 레벨의 MCP 시스템을 구축하는 노하우를 담았습니다.
MCP 동적 도구 업데이트 완벽 가이드
AI 에이전트의 도구를 런타임에 동적으로 로딩하고 관리하는 방법을 알아봅니다. 플러그인 시스템 설계부터 핫 리로딩, 보안까지 실무에서 바로 적용할 수 있는 내용을 다룹니다.