n8n 카카오톡 나에게 보내기 알림: refresh_token 회전 처리

약 11분 읽기

매달 ISA 자동매수가 끝나면 카카오톡으로 결과가 온다. “오늘 나스닥100 몇 주, S&P500 몇 주 체결” 같은 한두 줄짜리 메시지다. 이 알림을 처음엔 n8n 워크플로 하나로 만들었다가, 나중에 구조를 바꿨다. 이유는 카카오의 토큰 정책 때문이었다. 이 글은 두 버전을 나란히 놓고 무엇이 달랐는지 적는다.

알림톡이 아니라 나에게 보내기

카카오로 메시지를 보내는 방법은 크게 두 가지다. 하나는 비즈니스용 알림톡이다. 사업자 등록, 템플릿 사전 승인, 발송 비용이 따라온다. 다른 하나는 카카오 로그인 사용자 본인에게 보내는 “나에게 보내기” API(/v2/api/talk/memo/default/send)다. 내 휴대폰으로 내 알림을 받는 용도라면 이걸로 충분하고, 템플릿 승인도 필요 없다.

대신 사용자 토큰이 필요하다. 카카오 로그인으로 한 번 인가를 받아 refresh token을 얻고, 메시지를 보낼 때마다 refresh token으로 access token을 새로 받아 쓰는 방식이다. 이 “매번 새로 받는다”가 나중에 문제가 된다.

첫 번째 버전: n8n 노드 네 개

처음 만든 워크플로는 단순했다.

  1. 폼 입력(Form Trigger): 메시지 내용을 받는다.
  2. Access Token 갱신(HTTP Request): https://kauth.kakao.com/oauth/token에 grant_type=refresh_token으로 POST. 본문은 form-urlencoded다.
  3. 카카오톡 발송(HTTP Request): 받은 access token을 Authorization: Bearer에 넣고, template_object에 텍스트와 링크를 JSON 문자열로 담아 보낸다.
  4. 완료 응답: 폼에 결과를 돌려준다.
책상 사진 — n8n 웹훅, 어떻게 작동할까?
Photo by Felicity Tai on Pexels

문제는 2번 노드였다. refresh token과 client secret을 노드 파라미터에 그대로 적어두었다. 처음엔 “어차피 내 서버 안에 있는 값”이라고 생각했다. 하지만 이 방식에는 구멍이 두 개 있다.

refresh token은 가만히 있지 않는다

첫 번째 구멍은 토큰 회전이다. 카카오는 refresh token의 만료가 30일 이내로 남으면, 토큰 갱신 응답에 새 refresh token을 같이 내려준다. 이 새 값을 저장해두지 않으면 기존 refresh token은 결국 만료되고, 어느 날부터 알림이 조용히 끊긴다. n8n 노드에 직접 적어둔 값은 응답이 와도 바뀌지 않는다. 사람이 다시 인가를 받아 값을 갈아끼우기 전까지 알림이 멈춘다.

두 번째 구멍은 비밀값 위치다. 노드 파라미터에 적은 값은 워크플로 JSON에 평문으로 남는다. 워크플로를 내보내거나 백업하면 비밀값도 같이 따라간다. n8n에는 자격증명(Credentials)을 따로 두는 기능이 있는데, 그걸 쓰지 않은 셈이다.

화면 사진 — n8n + Gemini 자동 발행 시스템 연동 팁
Photo by RDNE Stock project on Pexels

두 번째 버전: 토큰은 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건이면 “입금이 안 된 것 같다”는 알림을 따로 보낸다.

처음 만드는 사람이 막히는 곳

나에게 보내기를 처음 붙일 때 순서와 막히기 쉬운 지점을 정리해둔다.

  1. 카카오 디벨로퍼스에서 앱을 만들고 카카오 로그인을 켠다. Redirect URI를 등록한다.
  2. 동의항목에서 카카오톡 메시지 전송(talk_message)을 켠다. 이걸 빠뜨리면 발송할 때 -402(필요한 동의항목 없음)가 난다.
  3. 브라우저로 인가 코드를 받고, grant_type=authorization_code로 토큰을 교환한다. 여기서 받은 refresh token이 출발점이다.
  4. 발송할 때마다 refresh token으로 access token을 받는다. REST API 기준 access token은 6시간, refresh token은 2개월짜리다.
  5. refresh token은 만료가 1개월 이내로 남았을 때 갱신을 요청하면 새로 나온다. 이 값을 저장하는 코드가 없으면 두 달 뒤에 알림이 멈춘다.

access token이 만료됐거나 잘못된 값이면 -401이 온다. 매번 새로 받는 구조라면 이 에러는 대부분 refresh token 자체가 만료됐다는 뜻이다. 처음 인가부터 다시 해야 한다.

버튼이 블로그로 가던 문제

마지막으로 하나 더. 카카오 메시지에는 “자세히 보기” 버튼이 붙는데, 한동안 이 버튼이 포털이 아니라 이 블로그로 연결됐다. 원인은 카카오 링크 정책이었다. 버튼 링크는 카카오 디벨로퍼스의 플랫폼 > Web 사이트 도메인에 등록된 도메인만 동작한다. 링크를 비워두면 앱의 기본 도메인으로 간다. 이 앱의 기본 도메인이 eungming.com이라 블로그가 열렸던 것이다. 2026-10-08에 버튼 링크를 포털 주소로 명시해서 고쳤다. 지정한 도메인이 플랫폼에 등록돼 있어야 버튼이 동작한다.

정리하면 이렇다. n8n은 “언제, 무엇을 보낼지”를 정하는 데 좋고, 회전하는 토큰처럼 상태가 바뀌는 비밀값을 들고 있기에는 맞지 않았다. 상태는 DB가 있는 쪽에 두고, n8n에는 메시지만 넘기게 나눈 것이 이번 변경의 핵심이다.

황민

황민 (Hwang Min)

IT·RPA·AI 분야 개발자. 웹앱 개발, UiPath RPA, n8n 자동화 실무 경력 4년. AI·금융·IT 트렌드를 현장 개발자 시각으로 분석합니다.

댓글 남기기

𝕏fin