7강

외부 API 활용 & Webhook 응답 서버

실습 대상 — 포켓몬 무료 API (PokeAPI)

이번 강에서는 인증이 필요 없는 무료 API 로 PokeAPI 를 씁니다. 포켓몬 이름·타입·진화체인 등을 GET 요청 하나로 얻을 수 있어 API 학습용으로 딱입니다.

PokeAPI 문서 → Evolution → Evolution Chains 엔드포인트 선택
📋 PokeAPI 진화체인 엔드포인트
https://pokeapi.co/api/v2/evolution-chain/{id}/
🔗 PokeAPI 공식 문서 (Evolution Chains 섹션)
https://pokeapi.co/docs/v2#evolution-chains

GET 방식은 URL 뒤 파라미터로 요청 정보를 담고, POST 방식은 요청 본문(body)에 정보를 담습니다. 이번 강에서는 GET 만 사용합니다.

GET: URL 에 파라미터 노출 / POST: body 에 담아 전송

실제로 브라우저 주소창에 GET URL 을 붙여넣기만 해도 API 가 응답합니다.

브라우저 = 세상에서 가장 간단한 GET 클라이언트
💡
이번 강의 최종 목표는 내 n8n 워크플로우 자체를 하나의 API 서버로 노출 하는 것입니다. 다른 사람이 브라우저에서 https://.../webhook/pokemon?id=10 을 열면 포켓몬 진화 카드가 HTML 로 렌더 되는 서비스를 만듭니다.

HTTP Request 노드로 GET 요청

먼저 새 워크플로우를 만들고 Manual Trigger 뒤에 HTTP Request 노드를 붙입니다.

노드 추가 패널에서 HTTP Request 선택

노드가 캔버스에 붙었으면 HTTP Request 노드를 클릭 해서 파라미터 세팅 화면을 엽니다.

캔버스의 HTTP Request 노드 클릭 → 우측 파라미터 패널 열기

메소드는 GET, URL 은 피카츄가 속한 진화체인(ID 10) 으로 하드코딩합니다. 피추 → 피카츄 → 라이츄 로 이어지는 체인입니다.

GET https://pokeapi.co/api/v2/evolution-chain/10/
📋 실습용 URL (피카츄 진화체인 = 10)
https://pokeapi.co/api/v2/evolution-chain/10/
💡
⚠️ 헷갈리기 쉬운 지점 — 포켓몬 번호(피카츄 = 25) 와 진화체인 번호(피추/피카츄/라이츄 체인 = 10) 는 다른 체계입니다. 이 엔드포인트는 진화체인 번호 를 받습니다.

Execute step 을 누르면 OUTPUT 에 진화체인 원본 JSON 이 그대로 담겨옵니다. baby_trigger_item / chain / evolves_to 필드가 보이면 성공.

OUTPUT: chain > evolves_to > evolves_to ... 형태의 진화 트리
💡
지금 시점의 워크플로우는 수동 트리거 → HTTP GET 두 개 노드가 전부. 여기서부터 트리거를 Webhook 으로 바꿔서 외부에서 호출 가능한 서버 로 확장합니다.

기존 Manual Trigger 삭제

외부에서 호출 가능한 API 서버로 만들려면 수동 트리거를 없애고 Webhook 트리거로 교체 해야 합니다. 우선 기존 Manual Trigger 노드를 선택해서 삭제.

'When clicking Execute workflow' 노드 삭제 → HTTP Request 만 남음
💡
Manual Trigger 는 워크플로우 편집 화면에서 손으로 실행 할 때만 씁니다. 외부 사용자가 호출해야 하면 Webhook 이 정답.

Webhook 트리거 노드 추가

빈 트리거 자리에 Webhook 노드를 검색해서 추가합니다.

트리거 검색창에 'webhook' → Webhook 선택

Webhook 노드 설정. HTTP Method = GET, Path = pokemon, Respond = Using 'Respond to Webhook' Node 로 지정합니다. 상단에 Test URL / Production URL 두 개가 자동으로 만들어집니다.

Method GET · Path pokemon · Respond Using 'Respond to Webhook' Node
💡
Respond = Using Respond to Webhook Node 로 두면 마지막에 붙일 Respond to Webhook 노드가 실제 응답을 만듭니다. 이렇게 해야 HTML·JSON·에러 코드까지 자유롭게 커스텀 가능.

HTTP 노드 URL 을 파라미터로 받도록 변경

이전엔 URL 에 25 를 하드코딩했지만 이제는 Webhook 요청 파라미터 id 를 URL 에 꽂아 씁니다. HTTP 노드의 URL 필드를 다음처럼 표현식(expression) 으로 변경합니다.

URL 필드 → fx 모드 → {{ $json.query.id }} 로 파라미터 주입
📋 HTTP 노드 URL (표현식)
https://pokeapi.co/api/v2/evolution-chain/{{ $json.query.id }}/
💡
Webhook 트리거의 출력에서 $json.query.id 는 URL 뒤 ?id=25 같은 쿼리 파라미터를 그대로 꺼내온 값입니다. 이제 다른 ID 를 요청해도 HTTP 노드가 자동으로 대응.

Code 노드 추가 — 진화체인 파싱 + HTML 생성

HTTP 노드 뒤에 Code 노드를 붙입니다. 이 노드가 원본 JSON 을 진화 트리로 파싱 하고, 그 결과를 HTML 문자열 로 만들어 다음 노드(Respond to Webhook)에 넘겨줍니다.

HTTP 노드 뒤에 + → 'code' 검색 → Code 노드

Code 노드 스크립트 (JavaScript)

Mode = Run Once for All Items, Language = JavaScript. 아래 스크립트를 통째로 붙여넣으세요. PokeAPI 의 chain 트리를 순회하며 노드 카드와 진화 조건 뱃지 를 HTML 로 조립합니다.

Run Once for All Items · JavaScript · 진화체인 파싱 코드
📋 Code 노드 — 진화체인 → HTML 렌더
// PokéAPI evolution chain 데이터에서 시작 노드 찾기
const root = $json.chain ?? $json;

// 진화 조건을 읽기 쉽게 변환
function getEvolutionCondition(details = []) {
  if (!details.length) return '';

  const d = details[0];
  const conditions = [];

  if (d.min_level) {
    conditions.push(`Lv.${d.min_level}`);
  }

  if (d.min_happiness) {
    conditions.push(`친밀도 ${d.min_happiness}+`);
  }

  if (d.time_of_day) {
    conditions.push(
      d.time_of_day === 'day'
        ? '낮'
        : d.time_of_day === 'night'
        ? '밤'
        : d.time_of_day
    );
  }

  if (d.item?.name) {
    conditions.push(`아이템: ${d.item.name}`);
  }

  if (d.held_item?.name) {
    conditions.push(`소지 아이템: ${d.held_item.name}`);
  }

  if (d.known_move?.name) {
    conditions.push(`기술: ${d.known_move.name}`);
  }

  if (d.min_beauty) {
    conditions.push(`아름다움 ${d.min_beauty}+`);
  }

  if (d.trigger?.name === 'trade') {
    conditions.push('통신교환');
  }

  if (d.trigger?.name === 'use-item' && !d.item) {
    conditions.push('아이템 사용');
  }

  return conditions.length
    ? conditions.join(' · ')
    : '';
}


// 진화 트리를 HTML로 변환
function renderEvolution(node) {
  const name = node.species?.name ?? 'unknown';

  let html = `
    <div class="pokemon">
      <div class="pokemon-name">${name}</div>
    </div>
  `;

  if (node.evolves_to?.length) {

    for (const next of node.evolves_to) {

      const condition = getEvolutionCondition(
        next.evolution_details
      );

      html += `
        <div class="arrow">
          <div class="arrow-icon">↓</div>
          ${
            condition
              ? `<div class="condition">${condition}</div>`
              : ''
          }
        </div>
      `;

      html += renderEvolution(next);
    }
  }

  return html;
}


const evolutionHtml = renderEvolution(root);


const html = `
<!DOCTYPE html>
<html lang="ko">

<head>
<meta charset="UTF-8">

<title>포켓몬 진화 과정</title>

<style>

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family:
    Arial,
    "Apple SD Gothic Neo",
    "Noto Sans KR",
    sans-serif;

  background: #f5f7fb;

  display: flex;
  justify-content: center;

  padding: 60px 20px;
}

.container {
  width: 100%;
  max-width: 600px;

  background: white;

  border-radius: 24px;

  padding: 40px;

  box-shadow:
    0 12px 40px rgba(0,0,0,0.08);

  text-align: center;
}

.title {
  font-size: 28px;
  font-weight: 800;

  margin-bottom: 40px;
}

.evolution {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.pokemon {

  width: 260px;

  padding: 22px;

  background:
    linear-gradient(
      135deg,
      #fff7d6,
      #fff
    );

  border: 2px solid #ffd84d;

  border-radius: 18px;

  box-shadow:
    0 5px 15px rgba(0,0,0,0.07);
}

.pokemon-name {

  font-size: 24px;

  font-weight: 800;

  text-transform: capitalize;
}

.arrow {

  padding: 12px 0;

  display: flex;
  flex-direction: column;
  align-items: center;
}

.arrow-icon {

  font-size: 30px;

  color: #888;
}

.condition {

  margin-top: 5px;

  padding: 6px 12px;

  background: #eef2ff;

  border-radius: 20px;

  color: #4a5ab8;

  font-size: 13px;

  font-weight: 600;
}

</style>

</head>


<body>

<div class="container">

  <div class="title">
    Pokémon Evolution
  </div>

  <div class="evolution">

    ${evolutionHtml}

  </div>

</div>

</body>

</html>
`;

return [
  {
    json: {
      html
    }
  }
];
💡
핵심은 마지막의 return [{ json: { html } }]. 다음 노드(Respond to Webhook) 에서 {{ $json.html }} 로 이 문자열을 그대로 응답 본문으로 씁니다.

Respond to Webhook 노드 추가

Code 노드 뒤에 Respond to Webhook 노드를 붙입니다. 이 노드가 실제로 브라우저·클라이언트에게 반환할 HTTP 응답 을 만드는 곳입니다.

노드 검색창에 'res' → Respond to Webhook 추가

Respond With = Text 선택

Respond With 드롭다운에서 Text 를 선택합니다. HTML 문자열을 그대로 본문으로 넘길 거라 Text 가 적합.

First Incoming Item / JSON / JWT / No Data / Redirect / Text 중 Text
💡
Respond With 옵션 요약 — Text: 단순 문자열 본문(HTML/plain) · JSON: 구조화된 JSON API · Redirect: 다른 URL 로 리다이렉트 · No Data: 빈 응답.

Response Body — Code 노드 결과 참조

Response Body 에는 앞 Code 노드가 만든 HTML 문자열을 표현식 으로 꽂아 넣습니다.

Response Body: {{ $json.html }} · 하단 안내: Content-Type 헤더 추가 권장
📋 Response Body 표현식
{{ $json.html }}
💡
n8n 이 "Content-Type 응답 헤더를 명시적으로 붙이라" 고 경고합니다. 안 붙이면 브라우저가 HTML 을 텍스트로 렌더할 수 있어 다음 단계에서 Content-Type: text/html 헤더를 추가할 겁니다.

Options → Response Headers 추가

Options 섹션에서 Response Headers 옵션을 추가합니다.

Options → Response Code / Response Headers / Enable Streaming 중 Response Headers

Content-Type: text/html 헤더 값 지정

Entries 에 새 헤더 하나를 추가하고 Name 과 Value 를 아래처럼 지정합니다.

Name: Content-Type / Value: text/html; charset=utf-8
📋 Name (헤더 이름)
Content-Type
📋 Value (헤더 값)
text/html; charset=utf-8
💡
브라우저가 응답을 HTML 페이지로 인식 하려면 이 헤더가 필수. 빠뜨리면 <div> 태그가 그대로 텍스트로 노출됩니다.

워크플로우 Publish — 활성화

여기까지 세팅했으면 오른쪽 상단 Publish 를 눌러 워크플로우를 활성화합니다. 활성화 전에는 Test URL 로만 호출되고 매번 "Listen for test event" 를 눌러야 하지만, Publish 후에는 Production URL 이 상시 대기합니다.

우상단 Publish → 활성화 · 0/3 → 활성 표시
💡
이 순간부터 내 n8n 인스턴스가 하나의 웹 API 서버 로 동작합니다. 특정 경로(/webhook/pokemon) 로 GET 요청이 오면 n8n 이 자동으로 워크플로우를 실행하고 HTML 응답을 돌려줍니다.

Production URL 확인 · 기억

Webhook 노드를 다시 열고 Production URL 탭으로 전환하면 활성화된 서비스의 실제 접근 URL 이 나옵니다. 이 URL 을 복사해두세요.

GET https://[my-instance].app.n8n.cloud/webhook/pokemon
💡
Production URL 은 Publish 상태인 동안에만 응답 합니다. 편집 후 Publish 를 다시 눌러야 최신 로직으로 서빙됩니다.

브라우저에서 ?id=… 파라미터 붙여 호출

브라우저 주소창에 방금 복사한 Production URL 뒤에 ?id=10 을 붙여서 접속합니다. 10 은 피추(Pichu) → 피카츄 → 라이츄 진화체인 ID.

?id=10 → Pichu → 친밀도 220+ → Pikachu → 아이템 thunder-stone → Raichu 카드 렌더
📋 실습용 호출 URL 예시 (id 값만 바꿔가며)
https://[내-n8n-도메인]/webhook/pokemon?id=10
💡
🎯 완성. 남에게 이 URL 만 넘기면 그 사람도 브라우저에서 진화 트리를 볼 수 있습니다. n8n 이 내 프론트엔드 코드 없이 API 서버 + 페이지 서빙까지 처리했습니다.
  • 다른 진화체인 ID 시도: ?id=1 (이상해씨 체인), ?id=2 (파이리 체인), ?id=10 (피추/피카츄 체인) — 포켓몬 도감 번호가 아닌 체인 번호 임에 주의
  • 확장 아이디어 ① 진화 체인 대신 포켓몬 상세 정보(/api/v2/pokemon/{id}) 를 렌더
  • 확장 아이디어 ② OpenAI 노드 를 붙여 각 포켓몬 소개문을 AI 로 자동 작성
  • 확장 아이디어 ③ 응답을 JSON 으로 바꿔서 다른 프론트엔드 앱의 백엔드로 활용
  • 확장 아이디어 ④ 인증 필요한 사내 API 를 붙여 내부 대시보드 로 만들기
💡
이 강까지 마치면 n8n 은 더 이상 "자동화 툴" 이 아니라 백엔드가 없는 사람도 API 서버를 만들 수 있게 해주는 최소 코드 웹 프레임워크 에 가깝다는 걸 실감할 수 있습니다. 다음 강에서는 AI 로 콘텐츠 자체를 자동 생성 하는 흐름(영상 생성) 으로 확장합니다.