급식 API로 배우는 n8n 페이지네이션 — 마지막 페이지까지 다 받는 루프 만들기
이 글과 연결된 글 보기
드래그로 이동 · 휠/두 손가락으로 확대 · 방향키로 글 선택 · 0으로 맞춤. 실선: 본문 링크, 점선: 시리즈·주제의 대표 연결.표시된 글 7편 목록
공공데이터 API는 대부분 한 번에 일부만 돌려주고 "다음 페이지는 pIndex를 올려서 다시 부르세요"라고 합니다. 이 글의 워크플로우는 NEIS 급식식단정보 API를 학교마다 총 건수부터 물어본 뒤, 필요한 페이지 수만큼 HTTP Request를 돌려 마지막 페이지까지 받아 DB에 쌓습니다. 이 패턴을 한 번 만들어두면 pIndex·pSize 방식을 쓰는 다른 공공데이터 API에도 URL과 컬럼만 바꿔 그대로 붙일 수 있습니다.
이 워크플로우 하나로 보여줄 수 있는 것
앞선 응급실 공공데이터 API 편은 요청 한 번에 응답 하나가 오는 구조였습니다. 이 편은 응답이 여러 페이지로 쪼개져 오는 API를 끝까지 받는 구조라서, 데이터 수집 포트폴리오에서 면접관이 가장 먼저 확인하는 질문에 답할 수 있게 됩니다. "페이지네이션은 어떻게 처리했어요?"라는 질문입니다.
- 반복 호출 설계 — 총 건수를 먼저 받아 페이지 목록을 만들고, 그 수만큼 HTTP Request를 돌리는 구조를 짜본 경험
- 종료·예외 조건 — 데이터가 없는 학교는 건너뛰고, 마지막 페이지에서 정확히 멈추게 만든 경험
- 재실행 안전성 — 이미 받은 학교를 DB에서 확인해 빼고, 중간에 끊겨도 이어서 받을 수 있게 만든 경험
세 번째가 의외로 중요합니다. 수천 건을 받는 수집기는 한 번에 끝나지 않는 일이 흔한데, 다시 돌려도 중복이 안 쌓이게 만들어뒀다는 말을 할 수 있으면 "돌려본 사람"으로 보입니다.
배경 — 한 번의 요청으로는 끝나지 않는 API
NEIS 교육정보 개방 포털의 급식식단정보 API는 학교 코드와 날짜 범위를 주면 그 학교의 급식 메뉴, 칼로리, 원산지, 영양 정보를 돌려줍니다. 문제는 한 번에 주는 양입니다. 공식 데이터셋 페이지 기준으로 pSize(페이지당 건수) 기본값은 100이고, 인증키 없이 부르면 5건만 샘플로 옵니다. 샘플로 확인한 학교 하나만 해도 몇 년치 중식이 900건이 넘으니, 학교 수십 곳을 받으려면 페이지를 넘기는 구조가 필수입니다.
이 워크플로우는 데이터팝콘 n8n 인스턴스에 만들어 둔 것으로, 캔버스의 스티키 노트에 API 호출 예시와 "이미 입력한 데이터는 넘기기"라는 메모가 남아 있습니다. 설치나 HTTP Request 노드 기본은 n8n으로 반복업무 자동화 포트폴리오 만들기에 정리돼 있으니, 여기서는 페이지네이션 구조만 다룹니다.
구조 — Trigger / Task / Target
- Trigger — 수동 실행. 캔버스에 Schedule Trigger가 비활성 상태로 붙어 있어 스케줄로 바꾸려던 흔적이 있습니다
- Task — 학교 목록 준비 → 학교마다 총 건수 조회 → 페이지 목록 생성 → 페이지별 호출 → 행 분리
- Target — DB 테이블
meal_service_diet_info. 원본은 Google BigQuery에 넣고 있고, Postgres Insert 노드가 연결이 끊긴 채 남아 있습니다(아래에서 설명)
만들기
0. NEIS 인증키와 저장소 준비
NEIS 교육정보 개방 포털에 구글·네이버 같은 소셜 계정으로 로그인하면 별도 회원가입 없이 인증키를 발급받을 수 있습니다. 키 없이도 호출은 되지만 5건으로 잘리니 페이지네이션 실습에는 키가 있어야 합니다. 원본 캔버스의 스티키 노트에 적힌 호출 예시는 이렇습니다.
curl -X GET "https://open.neis.go.kr/hub/mealServiceDietInfo?Type=json&pIndex=1&pSize=10&ATPT_OFCDC_SC_CODE={OFFICE_CODE}&SD_SCHUL_CODE={SCHOOL_CODE}&KEY={API_KEY}"
ATPT_OFCDC_SC_CODE는 시도교육청 코드(서울은 B10), SD_SCHUL_CODE는 학교 코드입니다. 학교 코드는 같은 포털의 학교기본정보 API(schoolInfo)로 받을 수 있습니다.
저장소는 원본이 BigQuery지만, 따라 만들 때는 Postgres(Supabase 무료 티어로 만들 수 있습니다)나 n8n의 Data Table을 권합니다. 원본 캔버스에도 Postgres용 Insert 노드가 같은 테이블명으로 남아 있어서 노드만 바꿔 끼우면 됩니다. 테이블 컬럼은 API 응답 필드명(SD_SCHUL_CODE, MLSV_YMD, DDISH_NM, CAL_INFO 등)을 그대로 쓰면 자동 매핑이 됩니다.
1. 학교 목록 만들기 — 이미 받은 학교는 빼기
Manual Trigger 다음의 Edit Fields(Set) 노드에는 API_KEY 필드 하나만 있습니다. 뒤의 HTTP Request 노드들이 {{ $('Edit Fields').item.json.API_KEY }}로 이 값을 참조합니다. 원본은 키 값을 이 노드에 직접 적어뒀는데, 따라 만들 때는 HTTP Request 노드의 Authentication을 Generic → Query Auth로 두고 credential에 KEY를 넣는 편이 안전합니다. 워크플로우를 내보내도 키가 같이 나가지 않기 때문입니다.
학교 목록은 n8n Data Table school_info에서 가져옵니다. Get row(s) 노드 두 개가 각각 중학교·고등학교 조건으로 행을 읽고 Merge(Append)로 합칩니다. 여기까지가 "받아야 할 학교 전체"입니다.
그다음이 스티키 노트의 "이미 입력한 데이터는 넘기기"입니다. DB에서 SELECT DISTINCT SD_SCHUL_CODE FROM meal_service_diet_info로 이미 적재된 학교 코드를 읽어 와서, 두 번째 Merge 노드를 Combine by SQL 모드로 두고 아래 쿼리를 넣습니다.
SELECT *
FROM input1
RIGHT JOIN input2
ON input1.SD_SCHUL_CODE = input2.SD_SCHUL_CODE
WHERE input1.SD_SCHUL_CODE IS NULL
input1이 DB에 있는 학교, input2가 전체 학교입니다. 전체 학교 중 DB에 없는 것만 남기는 anti join이라, 수집이 중간에 끊겨도 다시 실행하면 남은 학교부터 이어서 받습니다. 그 뒤에 Limit 노드(Max Items 10000)가 있어서, 이 숫자를 줄이면 학교 몇 곳만으로 시험 실행할 수 있습니다.
2. Loop Over Items — 학교 한 곳씩
Loop Over Items(Split in Batches) 노드는 기본 Batch Size 1로 두었습니다. 학교 한 곳이 들어가 그 학교의 모든 페이지를 받고 나면 다시 이 노드로 돌아와 다음 학교를 꺼내는 구조입니다. done 출력에는 No Operation 노드만 붙어 있어서 모든 학교가 끝나면 조용히 종료됩니다. 루프 안의 노드들이 현재 학교를 가리킬 때는 {{ $('Limit').item.json.SD_SCHUL_CODE }}처럼 루프 앞 노드의 아이템을 참조합니다. n8n이 아이템 간 연결(paired item)을 따라가서 지금 처리 중인 학교의 값을 찾아줍니다.
3. 탐색 요청 — pSize=1로 총 건수만 물어보기
여기가 이 워크플로우의 핵심 아이디어입니다. 페이지를 넘기려면 몇 페이지까지 있는지 알아야 하는데, NEIS 응답의 head에 list_total_count가 들어 있습니다. 그래서 본 요청 전에 pIndex=1, pSize=1로 딱 한 건만 받는 탐색 요청을 먼저 보냅니다. HTTP Request 노드(GET)의 Query Parameters는 이렇습니다.
Type:jsonpIndex:1pSize:1KEY:{{ $('Edit Fields').item.json.API_KEY }}ATPT_OFCDC_SC_CODE:{{ $('Limit').item.json.ATPT_OFCDC_SC_CODE }}SD_SCHUL_CODE:{{ $('Limit').item.json.SD_SCHUL_CODE }}
인증키 없이 샘플로 호출해 보면 응답이 mealServiceDietInfo 배열 하나로 오고, [0].head[0].list_total_count에 총 건수, [0].head[1].RESULT.CODE에 INFO-000(정상), [1].row에 실제 데이터가 들어 있습니다. 이 경로를 그대로 다음 Code 노드가 씁니다.
4. If — 데이터 없는 학교는 건너뛰기
데이터가 한 건도 없으면 NEIS는 위 구조 대신 최상위에 RESULT.CODE가 INFO-200("해당하는 데이터가 없습니다")인 짧은 응답을 돌려줍니다. If 노드의 조건은 딱 하나, {{ $json.RESULT.CODE }} equals INFO-200입니다. true 출력은 Loop Over Items로 바로 되돌려 다음 학교로 넘어가고, false 출력만 페이지 생성으로 갑니다.
이 분기가 없으면 Code 노드가 list_total_count를 읽는 순간 에러로 멈춥니다. 급식 데이터가 없는 학교가 섞여 있는 게 정상이라, 이 If는 예외 처리가 아니라 본 흐름의 일부입니다.
5. Code — 페이지 목록 미리 만들기
Generate Page List라는 이름의 Code 노드입니다. 총 건수를 페이지 크기로 나눠 올림하고, 페이지 번호마다 아이템을 하나씩 만들어 돌려줍니다.
const totalCount = $input.first().json.mealServiceDietInfo[0].head[0].list_total_count;
const pSize = 1000;
const totalPages = Math.ceil(totalCount / pSize);
const items = [];
for (let i = 1; i <= totalPages; i++) {
items.push({
pIndex: i,
pSize: pSize
});
}
return items;
총 건수가 2,350건이면 {pIndex: 1}, {pIndex: 2}, {pIndex: 3} 세 아이템이 나옵니다. n8n은 다음 노드를 입력 아이템 수만큼 실행하니, HTTP Request 노드는 자동으로 세 번 호출됩니다. 별도의 카운터 변수나 "다음 페이지가 있나" 검사가 필요 없고, 종료 조건이 곧 아이템 개수입니다.
pIndex를 올리는 방법은 이것 말고도 두 가지가 더 있습니다. HTTP Request 노드의 Pagination 옵션에 {{ $pageCount + 1 }}을 넣어 노드 안에서 돌리는 방법, 그리고 Set 노드로 pIndex를 1씩 올리며 If로 빈 응답이 올 때까지 도는 방법입니다. 원본처럼 페이지 목록을 미리 만들면 요청 횟수가 실행 전에 정해져 어느 페이지가 실패했는지 바로 보이고, 빈 응답을 확인하려고 마지막에 한 번 더 부르는 낭비가 없습니다. 대신 총 건수를 알려주는 API에서만 쓸 수 있습니다.
pSize를 1000으로 잡은 건 원본의 선택입니다. 공식 데이터셋 페이지에는 기본값 100만 적혀 있고 상한이 명시돼 있지 않으니, 더 키우고 싶으면 실제 응답 건수가 요청과 같은지 먼저 확인하세요.
6. HTTP Request — 페이지마다 호출
본 요청 노드는 탐색 요청과 URL이 같고 파라미터만 다릅니다.
pIndex:{{ $('Generate Page List').item.json.pIndex }}pSize:{{ $('Generate Page List').item.json.pSize }}KEY,ATPT_OFCDC_SC_CODE,SD_SCHUL_CODE: 탐색 요청과 동일MLSV_FROM_YMD:20250101— 이 날짜 이후만MMEAL_SC_CODE:2— 중식만
조식·석식까지 받으려면 MMEAL_SC_CODE를 빼면 됩니다. 이 노드 뒤에도 같은 조건의 If 노드가 한 번 더 있습니다. 탐색 요청에는 날짜·식사 필터가 없어서, 필터를 걸고 나니 데이터가 없어진 학교가 생길 수 있고 그 경우도 루프로 돌려보내기 위해서입니다.
7. Split Out → Insert → 루프로 복귀
응답 하나에 최대 1,000건이 mealServiceDietInfo[1].row 배열로 들어 있습니다. Split Out 노드의 Field To Split Out에 mealServiceDietInfo[1].row를 넣으면 급식 한 끼가 아이템 하나가 됩니다. 그다음 DB Insert 노드에서 Mapping Column Mode를 Map Automatically로 두면 필드명이 같은 컬럼에 들어갑니다.
Insert 노드의 출력은 다시 Loop Over Items로 연결합니다. 이 연결선이 있어야 다음 학교로 넘어갑니다. 처음 만들 때 가장 자주 빠뜨리는 선입니다.
원본 캔버스에 남아 있는 흔적
이 워크플로우는 실행 이력 대신 캔버스에 만든 과정이 남아 있습니다. 비활성 노드와 끊긴 연결이 그대로 있어서 어떤 순서로 만들어졌는지 읽힙니다.
첫째, 학교 목록도 같은 패턴으로 받았습니다. 비활성 상태의 schoolInfo API 호출 노드 두 개가 있는데, 하나는 pSize=1로 총 건수를 받고 다른 하나는 pIndex·pSize를 받아 페이지를 부르는 같은 2단 구성입니다. 그 결과를 Data Table school_info에 넣어두고 본 흐름이 읽습니다. 이 글의 패턴을 학교 목록과 급식 데이터에 한 번씩, 두 번 적용한 셈입니다.
둘째, 저장소가 Postgres에서 BigQuery로 바뀌었습니다. Postgres Insert 노드가 같은 테이블명, 자동 매핑, Skip on Conflict 옵션까지 설정된 채 연결 없이 남아 있고, 실제 연결된 건 BigQuery Insert입니다. "이미 받은 학교" 조회도 연결 없는 Postgres 쿼리 노드(시도교육청 코드 18개가 pinned data로 붙어 있음)와 BigQuery 쿼리 노드가 둘 다 있습니다. 스티키 노트의 "1. 교육청별 2. 학교별 3. 날짜"라는 메모와 비활성 date_range Code 노드까지 보면, 교육청·날짜 단위로 도는 버전을 시도하다가 학교 단위로 정리한 것으로 보입니다.
셋째, 완료 알림용 Slack 노드가 비활성으로 남아 있습니다. {{ $input.all().length }}로 처리 건수를 세서 보내는 메시지라, Loop의 done 출력에 붙이면 처리 건수 알림으로 쓸 수 있습니다.
한계
- 키가 노드에 박혀 있음 — Set 노드에 키를 직접 적는 방식은 export 파일에 키가 남습니다. credential로 옮기는 게 첫 번째 할 일입니다.
- 페이지 하나가 실패하면 — 기본 설정에서는 페이지 호출 중 하나라도 실패하면 워크플로우가 거기서 멈추고, 남은 학교는 다음 실행으로 넘어갑니다. 반대로 HTTP Request의 On Error를 Continue로 바꾸면 실패한 페이지만 빠진 채 그 학교가 "받은 학교"로 기록돼 다시는 채워지지 않습니다. 학교 단위가 아니라 (학교, 페이지) 단위로 완료를 기록하거나, Insert에 Skip on Conflict를 켜고 학교 필터를 빼는 방식이 필요합니다.
- 메뉴 필드가 통째로 들어감 —
DDISH_NM은 메뉴가<br/>로 이어진 문자열 하나이고 알레르기 번호가 메뉴 뒤에 붙어 옵니다. 분석하려면 메뉴별로 쪼개는 전처리가 따로 필요합니다. - 호출 간격 없음 — 학교 수십 곳을 연달아 부르는데 Wait 노드가 없습니다. 공공 API는 짧은 시간에 많이 부르면 막힐 수 있으니 페이지 호출 사이에 Wait를 두는 걸 권합니다.
포트폴리오·면접 포인트
이 구조는 pIndex·pSize(또는 page·numOfRows) 방식을 쓰는 공공데이터 API라면 거의 그대로 재사용됩니다. 포트폴리오에는 급식 대신 자기 관심 분야의 API를 골라 붙이고, 실행 화면에서 Generate Page List 노드가 페이지 아이템 N개를 뿌리고 HTTP Request가 N번 도는 장면을 캡처하면 됩니다. 두 번째 실행에서 Merge 노드 출력이 0개로 줄어드는 화면을 나란히 두면 "재실행해도 중복이 안 쌓인다"는 증거가 됩니다.
면접에서 "페이지네이션은 어떻게 처리했나요?"가 오면, 총 건수를 먼저 받아 페이지 목록을 만들고 그 수만큼 호출해서 종료 조건을 따로 두지 않았다고 답하면 됩니다. 이어서 "총 건수를 안 주는 API는요?"가 올 수 있는데, 그때는 빈 응답이 올 때까지 도는 방식이나 HTTP Request 노드의 Pagination 옵션으로 바꾼다고 말하면 두 방식의 차이를 아는 사람으로 보입니다.
다음 액션
급식 데이터를 받아 보면 DDISH_NM처럼 사람이 읽기엔 좋지만 분석하기엔 불편한 필드가 있다는 걸 알게 됩니다. 다음 편은 서울시 열린데이터의 따릉이 대여소 데이터를 받아 전처리하고 적재하는 워크플로우입니다. 이 편이 "끝까지 받기"였다면 다음 편은 "받은 걸 쓸 수 있는 모양으로 바꾸기"라서, 두 편을 이어 만들면 수집부터 적재까지 한 줄로 설명할 수 있는 포트폴리오가 됩니다.
