네이버톡톡이나 카카오톡 같은 채팅 플랫폼은 모두 메시지를 수신하기 위한 웹훅을 제공하고 있습니다.
하지만 플랫폼마다 인증 방식이나 페이로드 구조, 이미지/파일과 같은 데이터 처리 방식에는 제법 큰 차이가 있습니다.
하나의 플랫폼만 연동할 때는 이런 차이가 크게 문제가 되지 않습니다.
하지만 여러 채팅 플랫폼을 동시에 지원해야 하는 상황에서는, 플랫폼별 분기 처리와 예외 로직이 서비스 곳곳에 퍼지면서 유지보수나 확장 측면에서 점점 부담이 커지게 됩니다.
오늘은 이런 플랫폼별 차이에서 발생하는 복잡도를 어디에서, 어떤 방식으로 격리했는지에 초점을 맞춰, 웹훅 통합 브릿지를 설계한 과정을 공유해보려 합니다.
최종설계 아키텍처
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ LINE Server │ │ NaverTalk GW │ │ Channel Talk │
│ │ │ │ │ (Kakao) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
│ Webhook │ Webhook │ Webhook
▼ ▼ ▼
┌────────────────────────────────────────────────────────────────┐
│ bridge │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Webhook Router │ │
│ │ POST /api/v1/webhook/:channelId (LINE, NTalk) │ │
│ │ POST /api/v1/webhook/chtalk (Kakao) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Platform Handlers │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ LINE │ │ NTalk │ │ Kakao │ │ │
│ │ └────────┘ └────────┘ └────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Normalized Message Format │ │
│ │ { channelId, chatId, message, origin } │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
└─────────────────────────────┼──────────────────────────────────┘
│
▼
┌─────────────────┐
│ Own Service │
│ (webhook_url) │
└─────────────────┘
플랫폼별 웹훅 차이로 드러난 문제들
플랫폼별 구현 방식에는 각각 두드러진 세 가지의 차이가 있었습니다.
1. 인증 방식의 차이
웹훅 처리 과정에서 가장 먼저 고려해야 할 부분은 해당 요청이 실제 플랫폼에서 전달된 것인지 검증하는 과정입니다.
이 인증 방식은 플랫폼마다 서로 다르게 정의되어 있고, 만약 플랫폼별 인증 처리를 서비스 코드 내부에서 직접 다루게 된다면 유지보수성과 확장성 측면에서 부담으로 이어질 수 있습니다.
LINE
요청 본문 전체를 기준으로 한 HMAC-SHA256 시그니처 검증을 요구합니다.
x-line-signature 헤더에 포함된 값을 검증해야 하며, 이를 위해 JSON 파싱 이전의 원본 요청 본문(raw body)을 별도로 보존해야 합니다.
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);네이버톡톡
웹훅 수신 시 별도의 시그니처 검증을 요구하지 않습니다. 채널 생성 시 발급된 API 토큰은 주로 메시지 발송 시에 사용됩니다.
카카오톡
Channel Talk이라는 중간 플랫폼을 경유합니다.
웹훅 인증과 검증은 Channel Talk 레벨에서 처리되며, 서비스 입장에서는 Channel Talk 형식의 이벤트를 수신하게 됩니다.
|
플랫폼 |
인증 방식 |
검증 시점 |
|---|---|---|
|
LINE |
HMAC-SHA256 시그니처 |
웹훅 수신 시 |
|
네이버톡톡 |
API 토큰 |
메시지 발송 시 |
|
카카오톡 |
Channel Talk 경유 |
Channel Talk 내부 |
2. 페이로드 구조의 차이
메시지 내용을 추출하는 과정에서 같은 텍스트 메시지 이벤트라 하더라도, 각 플랫폼이 제공하는 데이터 구조 또한 서로 크게 다릅니다.
동일한 정보를 얻기 위해 접근해야 하는 경로가 플랫폼마다 모두 다르기 때문에, 이를 그대로 서비스 코드에서 처리할 경우 플랫폼별 분기 로직이 복잡하게 증가할 수 있습니다.
LINE
const userId = body.events[0].source.userId;
const text = body.events[0].message.text;네이버톡톡
const userId = body.user;
const text = body.textContent.text;카카오톡
const userId = body.refers.userChat.id;
const text = body.entity.plainText;
|
플랫폼 |
페이로드 구조 차이 |
|---|---|
|
LINE |
const userId = body.events[0].source.userId; const text = body.events[0].message.text; |
|
네이버톡톡 |
const userId = body.user; const text = body.textContent.text; |
|
카카오톡 |
const userId = body.refers.userChat.id; const text = body.entity.plainText; |
3. 이미지·파일 처리 방식의 차이
텍스트 메시지에 비해 이미지나 파일과 같은 콘텐츠를 처리하는 과정에서는 플랫폼 간 차이가 더욱 크게 나타납니다.
LINE
웹훅에 실제 이미지 데이터를 포함하지 않고, messageId만 전달한 뒤 별도의 Content API를 호출하여 이미지를 조회하는 방식을 사용합니다.
네이버톡톡
웹훅 페이로드에 이미지 접근이 가능한 URL을 직접 포함하여 전달합니다.
카카오톡
fileKey를 전달한 뒤, 해당 키를 이용해 signed URL을 발급받고 다시 해당 URL로 요청을 보내야 실제 파일을 다운로드할 수 있습니다.
즉, 이미지 조회 과정이 두 단계로 나뉘어 있습니다.
|
플랫폼 |
이미지 처리 방식 |
|---|---|
|
LINE |
messageId → Content API 호출 |
|
네이버톡톡 |
imageUrl 직접 접근 |
|
카카오톡 |
fileKey → signed URL 발급 → 다운로드 |
설계 방향 - 브릿지 계층 도입
위와 같은 플랫폼별 차이로 인하여, 웹훅을 단순히 전달하는 수준을 넘어 플랫폼별 의존성을 흡수할 수 있는 별도의 계층이 필요하다는 판단을 하게 되었고, 이를 어떻게 구조적으로 다룰 것인지에 대한 설계가 필요했습니다.
이 과정에서 몇 가지 선택지를 검토했고, 최종적으로 플랫폼별 복잡도를 브릿지 계층에 집중시키는 방식을 선택하게 되었습니다.
선택지 A: 플랫폼별 별도 서비스
[LINE 서비스] → [우리 서비스]
[NTalk 서비스] → [우리 서비스]
[Kakao 서비스] → [우리 서비스]
- 단점
- 중복 코드 발생 (채팅 플랫폼 확장마다 중복된 로직 추가)
- 수정에 취약 (하나의 변경에 서비스 코드 수정이 필요)
선택지 B: 서비스 코드에서 분기 처리
// 서비스 코드 내부
app.post('/webhook/:platform', (req, res) => {
if (platform === 'line') {
// LINE 처리 로직 100줄
} else if (platform === 'ntalk') {
// 네이버톡톡 처리 로직 80줄
} else if (platform === 'kakao') {
// 카카오 처리 로직 120줄
}
});
- 단점
- 플랫폼 연동 로직과 비즈니스 로직이 뒤섞여 역할 분리가 안됨
- 새 플랫폼이 추가될 때마다 서비스 코드를 수정
최종 선택지 C: 중간에 브릿지 계층 도입 ✓
[LINE] ─┐
[NTalk] ─┼─→ [bridge] ─→ 정규화된 메시지 ─→ [서비스]
[Kakao] ─┘
플랫폼별 차이를 흡수하는 브릿지 서비스를 두고, 서비스에는 항상 동일한 형태의 메시지를 전달합니다.
플랫폼 연동의 복잡도가 브릿지에 격리되고, 서비스는 플랫폼을 알 필요가 없어져 명확한 역할 분리가 가능해집니다.
설계 방향은 선택지 C를 택했고. 핵심 설계 원칙은 세 가지였습니다.
- 정규화된 메시지 포맷
어떤 플랫폼에서 메시지가 오든, 서비스에는 동일한 구조로 전달해야합니다.
{
channelId: "채널 식별자",
chatId: "대화 상대 식별자",
message: {
type: "text", // text | image | file
content: "메시지 내용" // 또는 contentId
},
origin: { /* 원본 페이로드 전체 */ }
}
origin 필드에 원본을 보존하는 이유는, 정규화 과정에서 손실되는 플랫폼 고유 정보가 있을 수 있기 때문에, 서비스에서 필요하면 원본을 참조할 수 있도록 합니다.
- 플랫폼 핸들러 분리
플랫폼별 로직을 각각의 모듈로 분리했습니다.
라우터나 공통 코드에는 if (platform === 'line') 같은 분기문이 없어야 합니다.
src/platforms/
├── index.js # 팩토리
├── line.js # LINE 핸들러
├── ntalk.js # 네이버톡톡 핸들러
└── kakao.js # 카카오톡 핸들러
- 단방향 데이터 흐름
데이터는 항상 한 방향으로 흐르도록 합니다.
웹훅 수신 → 시그니처 검증 → 메시지 정규화 → 서비스로 전달
구현: 핵심 코드와 패턴
핵심은 플랫폼별 로직을 핸들러로 분리하고, 라우터에서는 공통 흐름만 유지하는 구조입니다.
플랫폼 핸들러 패턴
먼저 플랫폼 핸들러를 선택하는 팩토리입니다.
플랫폼별 구현을 모아두고, 채널 설정 값(app)에 따라 적절한 핸들러를 반환합니다
// src/platforms/index.js
import * as line from "./line.js";
import * as ntalk from "./ntalk.js";
import * as kakao from "./kakao.js";
export const platforms = {
line,
ntalk,
kakao,
};
export function getPlatform(app) {
return platforms[app];
}각 플랫폼 핸들러는 동일한 인터페이스를 구현합니다.
이를 통해 라우터는 플랫폼별 구현을 알 필요 없이, 동일한 호출 방식으로 동작할 수 있습니다.
// 모든 플랫폼 핸들러가 구현하는 인터페이스
{
handleWebhook(body, channel), // 웹훅 수신 처리 및 전달
sendMessage(chatId, message, config), // 메시지 발송
fetchContent(data, config, res), // 이미지/파일 스트리밍 전달
}이 구조를 적용하면 if (channel.app === 'kakao') 같은 분기문이 라우터에 들어가지 않기 때문에 라우터 코드가 단순해집니다.
// src/v1/webhook.js
router.post("/:id", async (req, res) => {
const channel = await getChannel(req.params.id);
const platform = getPlatform(channel.app);
if (!platform) {
return res.status(400).send({ error: `Not support app: ${channel.app}` });
}
await platform.handleWebhook(req.body, channel);
return res.end();
});메시지 발송 API도 동일한 패턴으로 처리할 수 있습니다.
// src/v1/messages.js
router.post("/", validateToken, async (req, res) => {
const { channelId, chatId } = req.params;
const { message } = req.body;
const channel = await getChannel(channelId);
const platform = getPlatform(channel.app);
await platform.sendMessage(chatId, message, channel.config);
return res.end();
});플랫폼별 웹훅 핸들러 구현
플랫폼별 handleWebhook에서는 플랫폼 페이로드를 파싱한 뒤, 내부 서비스가 처리하기 쉬운 형태로 정규화하고 전달합니다.
LINE
이벤트 배열로 여러 이벤트가 한 번에 들어올 수 있어 반복 처리 구조를 사용합니다.개별 이벤트 단위로 예외를 처리하여, 하나의 이벤트 실패가 전체 웹훅 요청 실패로 이어지지 않도록 구성할 수 있습니다.
// src/platforms/line.js
export async function handleWebhook(body, channel) {
const events = body.events ?? [];
for (const ev of events) {
try {
const message = {
type: "text",
content: ev.message.text,
};
const data = {
channelId: channel.id,
chatId: ev.source.userId,
message,
origin: ev,
};
await axios.post(channel.webhook_url, data, {
headers: { "MChat-Validation-Token": channel.token },
});
} catch (e) {
console.error(e);
}
}
}네이버톡톡
단일 객체 형태로 전달되므로, 반복문 없이 바로 정규화할 수 있습니다.
// src/platforms/ntalk.js
export async function handleWebhook(body, channel) {
try {
const message = {
type: "text",
content: body.textContent.text,
};
const data = {
channelId: channel.id,
chatId: body.user,
message,
origin: body,
};
await axios.post(channel.webhook_url, data, {
headers: { "MChat-Validation-Token": channel.token },
});
} catch (e) {
console.error(e);
}
}카카오톡
봇이 보낸 메시지도 웹훅으로 수신될 수 있어, 사용자가 보낸 메시지만 처리하도록 필터링 로직을 두었습니다.
// src/platforms/kakao.js
export async function handleWebhook(body, channel) {
if (body.entity.personType !== "user") {
return;
}
try {
const message = {
type: "text",
content: body.entity.plainText,
};
const data = {
channelId: channel.id,
chatId: body.refers.userChat.id,
message,
origin: body,
};
await axios.post(channel.webhook_url, data, {
headers: { "MChat-Validation-Token": channel.token },
});
} catch (e) {
console.error(e);
}
}결과적으로, 내부 서비스는 플랫폼에 관계없이 항상 동일한 형태의 데이터를 수신할 수 있습니다.
{
channelId: "019772c8-...",
chatId: "U206d25c2ea...",
message: {
type: "text",
content: "안녕하세요"
},
origin: { /* 플랫폼별 원본 */ }
}이미지/파일 처리는 플랫폼별 접근 방식이 크게 다르기 때문에, 브릿지에서 이미지 데이터를 직접 전달하는 대신 contentId를 발급하고 프록시 역할을 하도록 설계했습니다.
예를 들어 LINE의 이미지 메시지에서는 메시지 ID만 제공되므로,
contentId를 생성한 뒤 플랫폼별 조회에 필요한 메타데이터만 저장하고,
정규화된 메시지에는 contentId만 포함시킵니다.
// LINE 이미지 메시지 처리 예시
if (ev.message.type === "image") {
const contentId = generateContentId(); // UUIDv7
await pool.query(
`INSERT INTO message_contents (id, channel_id, app, data)
VALUES ($1, $2, $3, $4)`,
[contentId, channel.id, "line", { messageId: ev.message.id }]
);
message = {
type: "image",
contentId,
};
}각 플랫폼별 형태에 맞춰 fetchContent는 아래 형태로 구현할 수 있습니다.
- LINE: messageId로 Content API 호출
- 네이버톡톡: imageUrl 직접 접근
- 카카오톡: fileKey -> signed URL 발급 -> 다운로드
장점과 트레이드오프
장점
- 서비스 코드가 단순해집니다. 플랫폼별 분기 없이 정규화된 메시지만 처리하면 됩니다.
- 플랫폼 확장이 쉬워집니다. 새 플랫폼을 추가할 때 라우터를 수정하지 않고, 핸들러만 추가하면 됩니다.
- 장애 격리가 가능합니다. 특정 플랫폼 장애가 다른 플랫폼 처리 흐름에 영향을 주지 않도록 분리할 수 있습니다.
- 테스트가 쉬워집니다. 외부 플랫폼 없이도 정규화된 메시지 형태로 브릿지를 검증할 수 있습니다.
트레이드오프
- 네트워크 홉이 추가
플랫폼 → 브릿지 → 서비스로 요청이 한 단계를 더 거치게 됩니다. - 운영 포인트가 증가
브릿지 서비스 자체에 대한 배포/모니터링/장애 대응이 추가됩니다. - 정규화 과정에서 정보 손실
플랫폼 고유 기능까지 모두 공통 포맷에 담기는 어렵기 때문에, 원본(origin)을 함께 전달하는 방식으로 보완할 수 있습니다.
마치며
이번 글에서는 여러 채팅 플랫폼의 웹훅을 한 흐름으로 묶는 과정에서, 플랫폼별 차이를 브릿지 계층에 모아두고 내부 서비스는 최대한 단순하게 가져가려 했던 설계를 정리해봤습니다.
핵심을 정리하면 크게 네 가지입니다.
1) 외부 시스템 연동에서 생기는 차이(인증/페이로드/콘텐츠 처리)는 서비스 안쪽으로 끌고 오기보다, 경계에서 먼저 정리해두는 편이 훨씬 편합니다.
2) 플랫폼별 로직을 핸들러로 분리하고 인터페이스를 맞춰두면, 플랫폼이 추가될 때 수정 범위가 확 줄어듭니다.
3) 정규화는 편해지는 대신 잃는 정보가 생길 수 있어서, 필요한 경우를 대비해 원본을 같이 보관하는 식의 안전장치를 두는 게 좋습니다.
4) 그리고 “이 브릿지가 어디까지 책임질지” 범위를 초반에 확실히 정해두면, 구현도 덜 흔들리고 운영도 훨씬 수월해집니다.
비슷하게 여러 외부 플랫폼을 연동해야 하는 상황에서, 복잡도가 어디에서 커지고 있는지 정리해보는 데 이 사례가 작은 참고가 되면 좋겠습니다.