n8n으로 정적 웹서버 운영하기 — 수료증 발급부터 진위 확인까지, 멱등 처리로 안전하게
이 글과 연결된 글 보기
드래그로 이동 · 휠/두 손가락으로 확대 · 방향키로 글 선택 · 0으로 맞춤. 실선: 본문 링크, 점선: 시리즈·주제의 대표 연결.표시된 글 35편 목록
수료증을 발급하는 스케줄 워크플로우 하나, 수료증 열람과 진위 확인을 처리하는 웹훅 워크플로우 하나, 랜딩 페이지를 서빙하는 웹훅 워크플로우 하나. 이 셋이 Data Table 하나를 같이 보며 작은 웹서비스를 이룹니다. 별도 서버 없이 n8n만으로 페이지를 서빙하고, 메일 발송이 실패해도 다음 날 자동 재시도되는 멱등 구조까지 갖춘 사례입니다.
이 워크플로우 하나로 보여줄 수 있는 것
지금까지 이 시리즈에서 다룬 워크플로우는 전부 "무언가 일어나면 흐름을 태워 처리한다"는 구조였습니다. 이번은 다릅니다. n8n이 흐름을 처리하는 도구가 아니라, 요청이 오면 페이지를 돌려주는 백엔드 그 자체로 씁니다. 브라우저가 특정 주소로 GET 요청을 보내면 웹훅이 받아 HTML을 만들어 돌려줍니다. 별도 서버나 호스팅 서비스가 필요 없습니다.
뜯어보면 세 가지 역량이 드러납니다.
- 웹훅으로 페이지 서빙 — Webhook과 Respond to Webhook만으로 정적·동적 HTML을 돌려주는 경험
- 멱등한 배치 설계 — 같은 스케줄이 여러 번 돌아도 중복·누락 없이 안전하게 재시도되는 구조를 설계한 경험
- 접근 수준 분리 — 같은 데이터를 조회 목적에 따라 다르게 노출하는 설계 경험
세 번째까지 갖추면 "자동화를 만들 줄 안다"에서 "운영까지 고려해 설계한다"로 한 단계 올라간 인상을 줍니다.
배경 — 별도 호스팅 없이 굴러가는 작은 웹서비스
datapopcorn의 n8n 실무 과정은 수료증을 자동으로 발급합니다. 수료 요건을 채운 수강생에게 매일 아침 수료증 메일을 보내고, 그 수료증에는 링크 두 개가 담깁니다. 인쇄용 수료증을 여는 링크, 진짜인지 제3자가 확인할 수 있는 링크입니다. 이 두 링크와 랜딩 페이지까지, 별도 서버 없이 n8n의 웹훅만으로 서빙합니다. 수료증 자동 발급 자체가 이 과정의 교보재이자 마케팅 포인트가 되도록 설계한 셈입니다.
설치나 기본 노드 설명은 n8n으로 반복업무 자동화 포트폴리오 만들기에 정리돼 있으니, 이 글에서는 시스템 자체만 다룹니다. 직전 편이 벡터DB 없이 실시간으로 웹페이지를 읽는 챗봇이었다면, 이번 편은 반대편입니다. 외부 페이지를 읽는 대신 n8n 스스로 페이지를 만들어 내보냅니다. 지난 Data Table 편에서 처음 다룬 이 리소스는, 여기서는 발급 기록과 열람 조회 양쪽의 공유 저장소로 재사용됩니다.
구조 — Trigger / Task / Target
세 워크플로우는 각각 독립적으로 활성화돼 있지만, cert_students Data Table 하나를 같이 봅니다.
- Trigger — 워크플로우 1은 매일 09:00 스케줄, 워크플로우 2는 GET
/cert-view·/cert-verify두 웹훅, 워크플로우 3은 GET/n8n-course웹훅 - Task — 발급 배치는 대상 조회 → 필터링 → ID 생성 → 메일 발송 → 기록. 페이지 서빙은 Data Table 조회 → 상태별 HTML 조립
- Target — 발급 배치는 메일함·Data Table, 페이지 서빙은 요청을 보낸 브라우저
만들기
1. Data Table 준비 — cert_students
name, email, eligible(Boolean), cert_id(String), issued_at(String), is_public(Boolean) 컬럼을 만듭니다. eligible은 수료 요건을 채운 수강생에게 true로 표시하고, cert_id는 처음엔 비워둡니다. 이 값이 채워지는 순간이 곧 "발급됨"의 기준입니다.
2. 워크플로우 1 — 발급 대상 조회와 필터링
Schedule Trigger를 매일 09:00으로 두고, Data Table 노드(Get Row, Return All)에서 eligible이 true인 행을 전부 가져옵니다. 다음 Filter 노드가 가장 중요한 지점입니다.
- 왼쪽 값:
{{ $json.cert_id }} - 연산자: String → is empty
이미 발급된 수강생은 cert_id가 채워져 있어 여기서 걸러집니다. 이 필터 하나가 "같은 사람에게 두 번 발급하지 않는다"를 보장합니다.
3. Certificate ID 생성 — Code 노드
Filter를 통과한 행마다 32자리 무작위 hex 문자열을 Certificate ID로 만들고, 열람·진위 확인 링크를 함께 조립합니다.
let certId = '';
for (let i = 0; i < 32; i++) certId += Math.floor(Math.random() * 16).toString(16);
const issuedAt = $now.toFormat('yyyy-LL-dd');
const base = '<N8N_WEBHOOK_BASE_URL>/webhook';
return { json: { ...$json, certId, issuedAt, certUrl: base + '/cert-view?id=' + certId, verifyUrl: base + '/cert-verify?id=' + certId } };
base 자리에는 자신의 n8n 웹훅 기본 주소를 넣습니다. Webhook 노드 화면의 Production URL에서 확인됩니다. 코드에 직접 적기보다 $vars로 빼두면 도메인이 바뀌어도 이 노드만 손대면 됩니다.
4. 멱등 처리 — 메일 발송 성공 후에만 기록한다
Gmail 노드로 수료증 메일을 보냅니다. 제목은 [datapopcorn] {{ $json.name }}님, n8n 업무자동화 실무 과정 수료증이 발급되었습니다이고, 본문 HTML에는 확인 버튼과 Certificate ID, 진위 확인 링크, "n8n GmbH 공식 인증 아님" 면책 문구가 들어 있습니다. retryOnFail: true, maxTries: 3, waitBetweenTries: 5000이 설정돼 있어 일시적 전송 실패는 5초 간격으로 세 번까지 자체 재시도합니다.
여기서 멱등성(idempotency)이라는 개념이 등장합니다. 같은 작업을 여러 번 실행해도 결과가 한 번 실행한 것과 같아야 한다는 원칙이고, 이 워크플로우는 두 축으로 지킵니다.
- 중복 방지 — 앞서 본 Filter 노드가
cert_id가 이미 있는 행을 걸러내므로, 같은 수강생을 두 번 처리하지 않습니다. - 누락 방지 — 발급 기록 노드는 메일 발송 노드 뒤에 연결돼 있습니다. 메일이 세 번의 재시도 끝에도 실패하면 흐름은 여기서 끊기고
cert_id는 채워지지 않습니다. 다음 날 09:00, 이 수강생은 여전히eligible=true이면서cert_id가 비어 있으니 Filter를 다시 통과해 재시도됩니다.
이 두 축을 합치면 "메일이 최소 한 번은 전달된다(at-least-once)"를 보장하면서도 중복 걱정이 없습니다. 실패 건은 다음 날 다시 시도되고, 성공 건은 재시도되지 않습니다. 별도 큐 없이 스케줄 자체가 재시도 메커니즘을 겸합니다.
5. 발급 완료 기록
Data Table 노드(Update Row)에서 Code 노드가 만든 행의 id와 일치하는 행을 찾아 cert_id·issued_at을 채웁니다.
필터: id equals {{ $('수료 정보 생성').item.json.id }}
컬럼: cert_id = {{ $('수료 정보 생성').item.json.certId }}
issued_at = {{ $('수료 정보 생성').item.json.issuedAt }}
이 업데이트가 끝나야 해당 수강생이 다음 스케줄의 Filter를 통과하지 않게 됩니다.
6. 워크플로우 2 — 수료증 페이지 서빙 (/cert-view)
Webhook 노드를 GET, path cert-view, Response Mode를 Respond to Webhook Node로 둡니다. 뒤의 Data Table 노드는 쿼리 파라미터의 id와 cert_id가 일치하는 행을 하나만(limit: 1) 가져오는데, alwaysOutputData: true와 onError: continueErrorOutput이 함께 설정돼 있어 조회 결과가 없거나 조회 자체가 에러여도 흐름이 멈추지 않고 다음 Code 노드로 넘어갑니다.
Code 노드는 요청받은 id와 조회된 행의 cert_id가 정확히 일치할 때만 이름·과정명·Certificate ID·발급일·진위 확인 링크가 들어간 인쇄용 HTML을 만듭니다. 일치하는 행이 없으면 404 HTML을, 조회가 에러였으면 500 HTML을 반환합니다. 마지막 Respond to Webhook 노드는 {{ $json.html }}을 본문으로, {{ $json.statusCode }}를 응답 코드로, Content-Type: text/html; charset=utf-8을 헤더로 붙여 그대로 돌려줍니다.
7. 워크플로우 2 — 진위 확인 페이지 (/cert-verify)
같은 워크플로우 안에 웹훅이 하나 더 있습니다. GET cert-verify도 같은 방식으로 cert_id를 조회하지만 목적이 다릅니다. 수료증을 받은 사람이 보는 페이지가 아니라, 수료증 하단 링크로 제3자(채용 담당자 등)가 "이 수료증이 진짜인지" 확인하러 오는 페이지입니다.
그래서 결과 HTML 로직도 다릅니다. 일치하는 행이 있으면 이름·과정명·수료일·Certificate ID가 담긴 "VALID" 카드를 보여주되, 그 행의 is_public이 false면 이름 자리에 "비공개 설정 수료자"라고만 표시합니다. 같은 Data Table, 같은 cert_id 조회지만 cert-view는 본인 확인용 전체 정보를, cert-verify는 불특정 다수가 오는 공개 페이지라 is_public 플래그로 노출 수준을 낮춰 두는 구조입니다. 일치하는 행이 없으면 마찬가지로 404입니다.
8. 워크플로우 3 — 랜딩 페이지 (/n8n-course)
세 번째 워크플로우는 훨씬 단순합니다. GET n8n-course 웹훅 하나가 Respond to Webhook 노드로 바로 이어지고, 응답 본문에 과정 커리큘럼과 수료 요건, 모집 안내가 담긴 정적 HTML이 그대로 들어 있습니다. Content-Type을 text/html로 지정해두어 이 주소에 접속하면 브라우저가 진짜 웹페이지로 렌더링합니다. 조건 분기도, 데이터 조회도 없이 노드 두 개로 정적 사이트를 서빙하는 가장 단순한 형태입니다.
원본 캔버스에 남아 있는 흔적
세 워크플로우 각각에 스티키 노트가 남아 있고, 위에서 설명한 설계 의도와 정확히 겹칩니다. 발급 워크플로우의 메모는 이렇습니다. "cert_students에서 eligible=true & cert_id 미발급 행을 찾아 Certificate ID 생성 → 수료증 메일 발송 → 발급 기록. 메일 실패 시 기록하지 않으므로 다음 날 자동 재시도(멱등)." 멱등 구조가 만든 사람의 의도였음을 확인할 수 있는 대목입니다.
페이지 서빙 워크플로우 메모는 두 개입니다. cert-view 쪽은 "미발급/불일치 ID는 404, 조회 실패는 500", cert-verify 쪽은 "is_public=false면 이름 비공개 처리 후 유효성만 표시"라고 적혀 있습니다. 랜딩 페이지 워크플로우의 메모는 유지보수 방법을 남겨뒀습니다. "정적 HTML 서빙. 내용 수정은 저장소 landing/index.html 수정 후 이 노드의 responseBody 교체." 페이지 원본은 저장소에 두고, n8n 노드는 그걸 반영한 사본을 서빙한다는 뜻입니다.
한계
- 인증 없는 열람 링크 — cert-view·cert-verify 모두 요청 쿼리의
id와cert_id가 일치하는지만 확인합니다. 로그인·만료 로직이 없어, 32자리 무작위 문자열인cert_id자체가 사실상 유일한 접근 통제입니다. - 랜딩 페이지는 두 곳을 손대야 함 — 스티키 노트대로 내용을 바꾸려면 저장소의
landing/index.html과 Respond to Webhook 노드의 responseBody를 각각 고쳐야 합니다. 자동 동기화는 없습니다. - 발급 취소 로직 없음 —
eligible이 나중에 false로 바뀌어도 이미 발급된cert_id·issued_at은 그대로 남습니다. 취소·무효화 흐름은 이 3개 워크플로우 안에 없습니다. - 트래픽 관측 없음 — 요청량이나 404·500 비율을 보여주는 노드가 따로 없습니다. n8n 실행 로그를 직접 열어야 확인됩니다.
포트폴리오·면접 포인트
포트폴리오에는 워크플로우 캔버스 세 개를 정리한 다이어그램과, 실제로 열어본 수료증 열람·진위 확인 페이지 스크린샷을 같이 넣으면 좋습니다. "자동화 흐름을 만들었다"가 아니라 "n8n 웹훅만으로 정적 사이트를 운영해봤다"는 흔치 않은 경험입니다.
면접에서는 "왜 발급 기록을 메일 발송 전이 아니라 후에 남겼나요?"가 나올 수 있습니다. 발송 전에 기록하면 실패해도 "발급됨"으로 남아 영영 수료증을 못 받습니다. 발송 후에 기록해야 실패 건이 다음 스케줄에서 다시 시도됩니다. "cert-view와 cert-verify는 왜 응답이 다른가요?"에는 조회 주체가 달라 is_public 플래그로 노출 수준을 나눴다고 답하면 됩니다.
다음 액션
이 시스템은 조회와 응답까지만 다룹니다. 다음 편에서는 서브워크플로우를 Tool로 감싸 AI Agent에게 SSH로 서버를 직접 점검시키는 사례로 넘어갑니다. Chat Trigger로 받은 요청을 Agent가 판단해 서브워크플로우를 호출하고, 그 서브워크플로우가 서버에 SSH 명령을 실행하는 구조인데, "조회만 허용한다"는 안전장치를 시스템 프롬프트에 못 박아두는 방법이 핵심입니다. 이번 배치의 마지막 편입니다.
