약 11분 읽기
매달 ISA 자동매수가 끝나면 카카오톡으로 결과가 온다. “오늘 나스닥100 몇 주, S&P500 몇 주 체결” 같은 한두 줄짜리 메시지다. 이 알림을 처음엔 n8n 워크플로 하나로 만들었다가, 나중에 구조를 바꿨다. 이유는 카카오의 토큰 정책 때문이었다. 이 글은 두 버전을 나란히 놓고 무엇이 달랐는지 적는다.
알림톡이 아니라 나에게 보내기
카카오로 메시지를 보내는 방법은 크게 두 가지다. 하나는 비즈니스용 알림톡이다. 사업자 등록, 템플릿 사전 승인, 발송 비용이 따라온다. 다른 하나는 카카오 로그인 사용자 본인에게 보내는 “나에게 보내기” API(/v2/api/talk/memo/default/send)다. 내 휴대폰으로 내 알림을 받는 용도라면 이걸로 충분하고, 템플릿 승인도 필요 없다.
대신 사용자 토큰이 필요하다. 카카오 로그인으로 한 번 인가를 받아 refresh token을 얻고, 메시지를 보낼 때마다 refresh token으로 access token을 새로 받아 쓰는 방식이다. 이 “매번 새로 받는다”가 나중에 문제가 된다.
첫 번째 버전: n8n 노드 네 개
처음 만든 워크플로는 단순했다.
- 폼 입력(Form Trigger): 메시지 내용을 받는다.
- Access Token 갱신(HTTP Request):
https://kauth.kakao.com/oauth/token에grant_type=refresh_token으로 POST. 본문은 form-urlencoded다. - 카카오톡 발송(HTTP Request): 받은 access token을
Authorization: Bearer에 넣고,template_object에 텍스트와 링크를 JSON 문자열로 담아 보낸다. - 완료 응답: 폼에 결과를 돌려준다.

문제는 2번 노드였다. refresh token과 client secret을 노드 파라미터에 그대로 적어두었다. 처음엔 “어차피 내 서버 안에 있는 값”이라고 생각했다. 하지만 이 방식에는 구멍이 두 개 있다.
refresh token은 가만히 있지 않는다
첫 번째 구멍은 토큰 회전이다. 카카오는 refresh token의 만료가 30일 이내로 남으면, 토큰 갱신 응답에 새 refresh token을 같이 내려준다. 이 새 값을 저장해두지 않으면 기존 refresh token은 결국 만료되고, 어느 날부터 알림이 조용히 끊긴다. n8n 노드에 직접 적어둔 값은 응답이 와도 바뀌지 않는다. 사람이 다시 인가를 받아 값을 갈아끼우기 전까지 알림이 멈춘다.
두 번째 구멍은 비밀값 위치다. 노드 파라미터에 적은 값은 워크플로 JSON에 평문으로 남는다. 워크플로를 내보내거나 백업하면 비밀값도 같이 따라간다. n8n에는 자격증명(Credentials)을 따로 두는 기능이 있는데, 그걸 쓰지 않은 셈이다.

두 번째 버전: 토큰은 DB에, 발송은 포털에서
그래서 실제 운영 알림은 ISA 포털(Next.js) 쪽으로 옮겼다. 바뀐 점은 세 가지다.
- refresh token을 DB에 저장한다. 포털 DB의 캐시 테이블에 두고, 비어 있으면 환경변수 값으로 시작한다.
- 갱신 응답에 새 refresh token이 있으면 바로 DB에 다시 쓴다. 회전이 일어나도 다음 발송은 새 토큰으로 이어진다.
- client id와 secret은 환경변수로 뺐다. 코드와 워크플로 어디에도 비밀값이 없다.
// isa-portal/lib/kakaoNotify.ts (일부)
async function getAccessToken(target: KakaoTarget): Promise<string> {
const refreshToken = await getRefreshToken(target); // DB → 없으면 환경변수
const res = await fetch("https://kauth.kakao.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: process.env.KAKAO_REST_API_KEY!,
client_secret: process.env.KAKAO_CLIENT_SECRET!,
refresh_token: refreshToken,
}),
});
const data = await res.json();
if (!data.access_token) throw new Error(`카카오 토큰 발급 실패: ${JSON.stringify(data)}`);
// 새 refresh_token이 발급됐으면 DB에 저장 (만료 30일 이내일 때 카카오가 새로 발급)
if (data.refresh_token) await saveRefreshToken(data.refresh_token, target);
return data.access_token;
}
알림을 받는 사람이 둘이라, 대상(main, sub)마다 refresh token을 따로 저장한다. 최초 refresh token은 카카오 인가 코드를 한 번 교환해서 받고, 그 뒤로는 사람 손이 가지 않는다.
n8n은 메시지만 넘긴다
자동매수 워크플로는 n8n에 그대로 있다. 바뀐 건 마지막 단계다. n8n이 카카오를 직접 부르지 않고, 포털의 내부 API(/api/internal/notify)에 메시지 텍스트만 넘긴다. 토큰 관리는 포털이 맡는다.
이 내부 API는 문을 좁게 열었다. 요청 헤더 x-notify-secret이 환경변수 NOTIFY_SECRET과 같아야 하고, 이 비밀값이 16자 미만이면 아예 거부한다. 본문은 1~4000자 텍스트만 받는다. 이 엔드포인트로는 주문도 조회도 할 수 없다. 메시지 전달 말고는 아무것도 못 하게 해둬야, 비밀값이 새더라도 피해가 알림 몇 통으로 끝난다.
// app/api/internal/notify/route.ts
import { NextRequest, NextResponse } from "next/server";
import { sendKakaoMessage } from "@/lib/kakaoNotify";
import { audit } from "@/lib/db";
// 외부(클라우드 루틴 등)에서 카톡 텍스트 알림만 보낼 수 있는 최소 권한 엔드포인트.
// 주문·조회 권한 없음. x-notify-secret 헤더 = NOTIFY_SECRET.
export async function POST(req: NextRequest) {
const expected = process.env.NOTIFY_SECRET;
if (!expected || expected.length < 16 || req.headers.get("x-notify-secret") !== expected) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { text } = await req.json().catch(() => ({} as { text?: unknown }));
if (typeof text !== "string" || !text.trim() || text.length > 4000) {
return NextResponse.json({ error: "text required (1~4000 chars)" }, { status: 400 });
}
await sendKakaoMessage(text.trim(), "main");
await audit("notify_sent", { chars: text.length }, req);
return NextResponse.json({ ok: true });
}
n8n 쪽 설정도 두 가지만 기억하면 된다. 호출 주소는 http://isa-portal:3000/api/internal/notify처럼 같은 Docker 네트워크의 컨테이너 이름을 쓴다. 외부 도메인을 거치지 않으니 인증서나 방화벽을 신경 쓸 필요가 없다. 헤더 값은 노드에 직접 적지 않고 {{ $env.NOTIFY_SECRET }}로 n8n 컨테이너 환경변수에서 읽는다. 첫 번째 버전에서 비밀값을 노드에 적었던 실수를 반복하지 않으려고 정한 규칙이다.
메시지를 만드는 Code 노드는 보낼 내용이 없으면 빈 배열을 돌려준다. n8n은 입력 아이템이 없으면 다음 노드를 실행하지 않으니, “오늘은 보낼 게 없다”를 분기 노드 없이 처리할 수 있다. 체결된 종목은 ✅로 수량, 가격, 금액을 한 줄에 적고, 30초 안에 체결이 확인되지 않은 종목은 ❌로 따로 표시한다. 마지막 줄에는 남은 현금을 붙인다.
알림 정책도 이때 정리했다. 원래는 결과를 이메일로 보냈는데, 지금은 실제로 주문이 있었던 날에만 카카오톡을 보낸다. 대신 25~28일에 그 달 매수가 0건이면 “입금이 안 된 것 같다”는 알림을 따로 보낸다.
처음 만드는 사람이 막히는 곳
나에게 보내기를 처음 붙일 때 순서와 막히기 쉬운 지점을 정리해둔다.
- 카카오 디벨로퍼스에서 앱을 만들고 카카오 로그인을 켠다. Redirect URI를 등록한다.
- 동의항목에서 카카오톡 메시지 전송(talk_message)을 켠다. 이걸 빠뜨리면 발송할 때
-402(필요한 동의항목 없음)가 난다. - 브라우저로 인가 코드를 받고,
grant_type=authorization_code로 토큰을 교환한다. 여기서 받은 refresh token이 출발점이다. - 발송할 때마다 refresh token으로 access token을 받는다. REST API 기준 access token은 6시간, refresh token은 2개월짜리다.
- refresh token은 만료가 1개월 이내로 남았을 때 갱신을 요청하면 새로 나온다. 이 값을 저장하는 코드가 없으면 두 달 뒤에 알림이 멈춘다.
access token이 만료됐거나 잘못된 값이면 -401이 온다. 매번 새로 받는 구조라면 이 에러는 대부분 refresh token 자체가 만료됐다는 뜻이다. 처음 인가부터 다시 해야 한다.
버튼이 블로그로 가던 문제
마지막으로 하나 더. 카카오 메시지에는 “자세히 보기” 버튼이 붙는데, 한동안 이 버튼이 포털이 아니라 이 블로그로 연결됐다. 원인은 카카오 링크 정책이었다. 버튼 링크는 카카오 디벨로퍼스의 플랫폼 > Web 사이트 도메인에 등록된 도메인만 동작한다. 링크를 비워두면 앱의 기본 도메인으로 간다. 이 앱의 기본 도메인이 eungming.com이라 블로그가 열렸던 것이다. 2026-10-08에 버튼 링크를 포털 주소로 명시해서 고쳤다. 지정한 도메인이 플랫폼에 등록돼 있어야 버튼이 동작한다.
정리하면 이렇다. n8n은 “언제, 무엇을 보낼지”를 정하는 데 좋고, 회전하는 토큰처럼 상태가 바뀌는 비밀값을 들고 있기에는 맞지 않았다. 상태는 DB가 있는 쪽에 두고, n8n에는 메시지만 넘기게 나눈 것이 이번 변경의 핵심이다.