본문으로 건너뛰기
취준생을 위한 n8n 실전 워크플로우 레시피 · 10/44시리즈 보기 →

따릉이 대여소 데이터 수집·전처리·적재 — 서울 열린데이터 API로 n8n 첫 데이터 파이프라인 만들기

· 약 13분
Datapopcorn CEO / AI automation educator
이 글과 연결된 글 보기
7편 · 20개 연결
100%
n8n Data Table 처음 써보기 — 트렌드 3개를 합쳐 매일 아침 분석 주제 뽑는 워크플로우n8n으로 정적 웹서버 운영하기 — 수료증 발급부터 진위 확인까지, 멱등 처리로 안전하게벡터DB 없이 실시간으로 사이트를 읽는 챗봇 — 경복대학교 안내 챗봇 해부n8n으로 반복업무 자동화 포트폴리오 만들기따릉이 대여소 데이터 수집·전처리·적재 — 서울 열린데이터 API로 n8n 첫 데이터 파이프라인 만들기구글시트에 쌓인 데이터를 인스타그램 카드뉴스로 — n8n 시트 인스타그램 자동발행서브워크플로우를 Tool로 감싸 AI Agent에게 서버 점검시키기 — 그리고 시스템 프롬프트만으로는 부족한 이유

드래그로 이동 · 휠/두 손가락으로 확대 · 방향키로 글 선택 · 0으로 맞춤. 실선: 본문 링크, 점선: 시리즈·주제의 대표 연결.

표시된 글 7편 목록
전체 글 연결 탐색

서울 열린데이터광장 인증키 하나와 Supabase 무료 프로젝트만 있으면, n8n 노드 8개로 따릉이 대여소 2,700여 개의 현황을 매시간 테이블에 스냅샷으로 쌓는 파이프라인을 만들 수 있습니다. 이 워크플로우를 따라 만들면 "수집 → 정리 → 전처리 → 적재"라는 ETL 한 바퀴가 손에 남고, 데이터 소스를 다른 공공 API로 바꿔도 뼈대는 그대로 재사용됩니다.

이 워크플로우 하나로 보여줄 수 있는 것​

응급실 API 편과 급식 API 편이 "공공데이터를 받아서 어딘가에 쌓는다"였다면, 이 편은 그 뒤에 붙는 질문에 답하는 편입니다. 쌓인 데이터를 나중에 분석하려면 어떤 컬럼이 더 있어야 하고, 같은 데이터가 두 번 들어가는 건 어떻게 막는가. 면접에서 이 워크플로우는 한 문장으로 설명됩니다. "따릉이 대여소 현황 API를 매시간 부르고, 스냅샷 시각을 붙여 Supabase에 쌓되 대여소·시각 조합이 중복되지 않게 제약을 걸었습니다."

이 한 문장 안에 데이터 엔지니어링의 기본기 네 가지가 들어 있습니다.

  • 대용량 API 호출 — 한 번에 1,000건까지만 주는 API를 여러 번 나눠 부르고 Merge로 합치는 경험
  • 응답 구조 풀기 — 중첩 JSON 응답에서 실제 행 배열만 꺼내 아이템으로 만드는 경험(Split Out)
  • 스냅샷 전처리 — 원본에는 없는 "언제 찍었는지" 컬럼을 시간 단위로 잘라 붙이는 경험
  • 적재와 무결성 — 테이블에 자동 매핑으로 넣고, DB 쪽 UNIQUE 제약으로 중복을 막는 경험

여기에 대여소 마스터 테이블을 따로 만드는 변형까지 붙이면 "매시간 바뀌는 사실(현황)"과 "거의 안 바뀌는 기준 정보(대여소)"를 분리하는 설계까지 설명할 수 있습니다. 취준생 포트폴리오에서 이 구분을 말할 수 있는 사람은 생각보다 드뭅니다.

배경 — 실시간 API는 지나가면 사라집니다​

따릉이 실시간 대여정보 API는 "지금 이 순간" 각 대여소에 자전거가 몇 대 있는지를 돌려줍니다. 문제는 이 값이 어디에도 남지 않는다는 점입니다. 오후 6시 강남역 근처 대여소가 매번 비는지, 비 오는 날은 어떤지 알고 싶다면 누군가 주기적으로 찍어서 쌓아둬야 합니다. 이 워크플로우는 그 "찍어서 쌓는" 일을 n8n에 맡긴 것입니다.

원본은 코드잇 강의용으로 데이터팝콘 n8n 인스턴스에 만들어 공유한 워크플로우입니다. 캔버스에 스티키 노트로 TRIGGER / TASK / TARGET이 구역별로 적혀 있고, 보너스 구역에는 페이지네이션 버전이 따로 있습니다. 노드 수는 43개로 많아 보이지만, 같은 블록이 두 번 복사되어 있고 대여소 마스터 블록과 보너스 블록이 옆에 놓여 있어서 그렇습니다. 핵심 경로는 노드 8개입니다. 설치와 기본 노드 설명은 n8n으로 반복업무 자동화 포트폴리오 만들기에 있으니 이 글은 파이프라인 자체만 다룹니다.

데이터는 서울 열린데이터광장의 서울시 공공자전거 따릉이 실시간 대여정보입니다. 원본 스티키 노트에도 이 링크와 "2700여개의 정류소의 현황 조회"라는 메모가 붙어 있습니다. 인증키는 열린데이터광장에서 발급받으면 되고, 적재 대상인 Supabase는 무료 프로젝트로 충분합니다.

구조 — Trigger / Task / Target​

  • Trigger — 1시간 간격 스케줄
  • Task — API를 1,000건씩 세 번 호출 → 응답 세 덩어리를 합침 → 행 배열을 아이템으로 풀기 → 스냅샷 시각 컬럼 추가
  • Target — Supabase 테이블에 행 추가. 같은 데이터를 CSV로도 변환해 파일 보관용 가지가 하나 더 있음

만들기​

0. 인증키와 요청 URL 구조​

서울 열린데이터광장의 Open API는 URL 경로에 인증키가 들어가는 방식입니다. 이용 안내 기준으로 인증키, 파일타입, 서비스명, 시작 위치, 종료 위치 다섯 개를 이 순서대로 적습니다.

http://openapi.seoul.go.kr:8088/{API_KEY}/json/bikeList/1/1000

여기서 bikeList가 따릉이 실시간 대여정보의 서비스명이고, 마지막 두 숫자가 몇 번째 행부터 몇 번째 행까지 달라는 뜻입니다. 한 번 요청에 가져올 수 있는 건수는 최대 1,000건입니다(2026년 8월 기준, 데이터셋 페이지의 "한번에 최대 1,000건를 초과할수 없습니다" 안내). 대여소가 2,700개를 넘으니 최소 세 번은 불러야 하고, 이게 뒤에 나오는 HTTP 노드가 세 개인 이유입니다.

1. Schedule Trigger — 매 1시간​

Trigger Interval을 Hours, 간격을 1로 둡니다. 실습 중에는 스케줄을 기다리지 말고 Execute workflow 버튼으로 실행하면 되고, 마지막에 Active를 켜면 그때부터 매시간 돕니다.

2. Set — API_KEY를 한 곳에​

Edit Fields(Set) 노드에 API_KEY라는 문자열 필드 하나를 만들고 발급받은 인증키를 넣습니다. 원본 캔버스에도 이 노드가 있는데, 정작 뒤의 HTTP 노드 URL은 이 값을 참조하지 않고 키를 URL에 직접 적어 두었습니다. 따라 만들 때는 HTTP 노드의 URL을 이렇게 표현식으로 바꿔 키가 한 곳에만 있게 하세요.

http://openapi.seoul.go.kr:8088/{{ $json.API_KEY }}/json/bikeList/1/1000

이 API는 키가 쿼리 파라미터가 아니라 경로에 들어가서 n8n의 Query Auth credential로는 붙이기 어렵습니다. 그래서 Set 노드 방식이 현실적인 타협인데, 대신 이 워크플로우를 공유하거나 export할 때 키가 같이 나간다는 점은 기억해 두세요. 공유 전에는 값을 비우는 습관이 필요합니다.

3. HTTP Request 세 개 — 1,000건씩 나눠 부르기​

HTTP Request 노드 세 개를 Set 노드 뒤에 나란히 붙입니다. Method는 GET, URL만 시작·종료 위치가 다릅니다.

  • bikeList/1/1000
  • bikeList/1001/2000
  • bikeList/2001/3000

Set 노드 하나에서 선이 세 갈래로 나가므로 세 노드가 같은 실행 안에서 각각 호출됩니다. 원본 노드 이름도 bike api (1-1000), bike api (1001-2000), bike api(2001-3000)이라 캔버스만 봐도 어느 구간을 부르는지 보입니다.

응답은 이런 모양으로 옵니다. row 안에 대여소 행이 들어 있고, list_total_count가 이번 요청으로 받은 건수입니다.

{
"rentBikeStatus": {
"list_total_count": 1000,
"RESULT": { "CODE": "INFO-000", "MESSAGE": "정상 처리되었습니다." },
"row": [
{
"rackTotCnt": "15",
"stationName": "102. 망원역 1번출구 앞",
"parkingBikeTotCnt": "28",
"shared": "187",
"stationLatitude": "37.55564880",
"stationLongitude": "126.91062927",
"stationId": "ST-4"
}
]
}
}

원본 스티키 노트에 붙어 있는 필드 설명을 옮기면 rackTotCnt 거치대개수, parkingBikeTotCnt 자전거주차총건수, shared 거치율, stationLatitude 위도, stationLongitude 경도, stationId 대여소ID, stationName 대여소이름입니다. 숫자처럼 보이는 값이 전부 문자열로 온다는 점을 눈여겨봐 두세요. 뒤의 변형 1에서 다시 나옵니다.

4. Merge → Split Out — 응답 세 덩어리를 행 2,727개로​

Merge 노드의 Number of Inputs를 3으로 두고 HTTP 노드 세 개를 각각 입력 1, 2, 3에 연결합니다. 기본 모드(Append)라 세 응답이 아이템 세 개로 이어 붙습니다. 그 다음 Split Out 노드에서 Field To Split Out을 rentBikeStatus.row로 두면, 각 응답 안의 row 배열이 풀려 대여소 하나가 아이템 하나가 됩니다.

원본 캔버스에 고정(pin)된 실행 결과를 보면 이 지점에서 아이템이 2,727개입니다. 세 번째 요청이 1,000건을 다 못 채우고 끝나는데, 그건 에러가 아니라 대여소가 3,000개보다 적다는 뜻입니다.

5. Set — 스냅샷 시각 붙이기 (전처리)​

여기가 이 워크플로우의 핵심 전처리입니다. API 응답에는 "이 값이 언제 기준인지"가 없습니다. 지금 저장하면 한 시간 뒤에 저장한 행과 구분할 방법이 없어집니다. 그래서 Edit Fields(Set) 노드에 updated_at 필드 하나를 추가하고, Include Other Input Fields를 켜서 나머지 컬럼은 그대로 통과시킵니다.

{{ $now.setZone('Asia/Seoul').startOf('hour').toISO() }}

startOf('hour')가 포인트입니다. 실행 시각을 그대로 쓰면 아이템 2,727개가 밀리초 단위로 조금씩 다른 시각을 갖게 되고, "오후 3시 스냅샷"으로 묶어서 조회하기가 어려워집니다. 시간 단위로 잘라 두면 한 번의 실행에서 나온 행 전부가 같은 updated_at을 갖고, 이 값이 곧 스냅샷의 키가 됩니다. 원본 스티키 노트도 이 단계를 "데이터가 기록된 updated_at(snapshot_at) 추가하기"라고 적어 두었습니다. 타임존을 Asia/Seoul로 고정한 건 n8n 클라우드의 기본 타임존이 내 지역과 다를 수 있기 때문입니다.

6. Supabase — Create a row와 UNIQUE 제약​

Supabase에 seoulbike_rentalhistory 테이블을 만듭니다. 컬럼 이름은 API 필드 그대로 rackTotCnt, stationName, parkingBikeTotCnt, shared, stationLatitude, stationLongitude, stationId에 updated_at을 더한 여덟 개입니다. Supabase 노드에서 Operation을 Create a row, Data to Send를 Auto-Map Input Data로 두면 아이템의 필드명과 같은 이름의 컬럼에 값이 들어갑니다. 컬럼 이름을 API 필드와 똑같이 맞춰야 하는 이유가 이것입니다.

그리고 원본 캔버스의 마지막 스티키 노트에 SQL이 하나 적혀 있습니다. Supabase의 SQL Editor에서 한 번 실행해 두는 제약식입니다.

ALTER TABLE public.seoulbike_rentalhistory
ADD CONSTRAINT seoulbike_rentalhistory_uniq UNIQUE ("stationId", updated_at);

같은 대여소·같은 시각 조합이 두 번 들어오면 DB가 insert를 거부합니다. 5번의 startOf('hour')와 짝을 이루는 장치인데, 워크플로우를 같은 시간대에 두 번 실행해도(실습 중에 자주 그럽니다) 테이블에 같은 스냅샷이 두 벌 쌓이지 않습니다. n8n 쪽에서 중복을 걸러내는 대신 DB 쪽에 규칙을 두는 방식이라, 나중에 다른 도구로 같은 테이블에 쓰더라도 규칙이 그대로 유지됩니다.

변형 1 — 대여소 마스터 테이블: 컬럼 이름 바꾸기와 타입 캐스팅​

캔버스 왼쪽 블록은 다른 API를 부릅니다. 서비스명 tbCycleStationInfo, 즉 대여소 정보(구, 주소, 거치대 수, 좌표)입니다. 매시간 바뀌는 현황과 달리 이 정보는 거의 바뀌지 않으니 별도 테이블 bikestation에 한 번 적재해 두고 현황 테이블과 stationId로 이어 보는 구조입니다. 흐름은 같습니다. HTTP 노드 네 개(1,000건씩 4,000까지) → Split Out stationInfo.row → Merge(입력 4개) → Set → Supabase.

다른 점은 Set 노드에서 하는 일입니다. 원본 스티키 노트에 "데이터전처리 (컬럼명 수정, 필요한 필드만 가져오기)"라고 적혀 있고, 실제 설정은 이렇습니다.

API 필드저장 컬럼타입
STA_LOCdistrict_namestring
RENT_IDstation_idstring
RENT_ID_NMstation_identifierstring
HOLD_NUMrack_countnumber
STA_ADD1address_primarystring
STA_ADD2address_detailstring
STA_LATlatitudenumber
STA_LONGlongitudenumber

여기서 두 가지가 일어납니다. 첫째, API의 대문자 약어 필드명을 테이블에서 읽기 좋은 snake_case 이름으로 바꿉니다. RENT_NO, RENT_NM, RNUM처럼 표에 없는 필드는 버려집니다(Include Other Input Fields를 끈 상태). 둘째, 거치대 수와 위도·경도는 타입을 number로 지정해서 문자열 "12"가 아니라 숫자 12로 저장되게 합니다. 좌표를 문자열로 넣어두면 나중에 거리 계산이나 지도 시각화에서 매번 형변환을 해야 하니, 적재 단계에서 타입을 잡아두는 편이 뒷일을 줄입니다.

JSON export 기준으로 이 블록은 비활성화(disabled) 상태이고 옆에 "TODO — 적재할 Target Table 생성하기"라는 스티키 노트가 남아 있습니다. Supabase에 bikestation 테이블을 위 컬럼으로 만들면 바로 켤 수 있는 상태입니다.

변형 2 — CSV로 파일 보관​

5번 Set 노드에서 선이 하나 더 나가 Convert to File 노드로 갑니다. 같은 2,727행을 CSV 바이너리로 바꾸는 노드이고, 그 뒤에 AWS S3 노드(Operation: Upload)가 캔버스에 놓여 있습니다. 파일 이름은 {{ $now.format('yyyyMMddHHmm') }}.csv처럼 실행 시각으로 짓습니다. 테이블 적재와 별도로 원본 파일을 시각별로 남겨두면, 나중에 테이블 스키마를 바꾸거나 재적재할 일이 생겨도 원본이 있습니다. S3 대신 Google Drive 노드로 바꿔도 같은 효과라 취준생이라면 그쪽이 더 간단합니다.

변형 3 (보너스) — Code 노드로 페이지네이션​

HTTP 노드를 세 개 나란히 두는 건 대여소가 3,000개를 넘는 순간 깨집니다. 그래서 보너스 구역에는 구간을 코드로 만들어 HTTP 노드 하나로 돌리는 버전이 있습니다.

const result = [];

for (let i = 1; i <= 3000; i += 500) {
result.push({ json: { value: i } });
}

return result;

이 Code 노드가 value가 1, 501, 1001, 1501, 2001, 2501인 아이템 여섯 개를 내놓고, 다음 HTTP 노드의 URL이 bikeList/{{ $json.value }}/{{ $json.value + 499 }}로 구간을 받습니다. 아이템 여섯 개가 들어가니 HTTP 노드는 자동으로 여섯 번 호출됩니다. 급식 편에서 Loop로 페이지를 넘겼다면, 이 편은 구간 목록을 미리 만들어 뿌리는 방식입니다. 총 건수를 모를 때는 Loop가, 상한을 아는 공공 API에는 이 방식이 단순합니다.

원본 스티키 노트에는 이런 팁이 적혀 있습니다. "처음부터 Pagination을 적용하려고 하지 마세요. 먼저는 위처럼 빠르게 만들고 이후에 Pagination을 적용하시는 과정을 거치세요. 갯수가 적은 경우에는 Pagination 적용을 하지 않는 게 나을 수 있습니다." HTTP 노드 세 개짜리 버전이 먼저 있고 페이지네이션이 보너스로 붙어 있는 건 이 순서 그대로입니다.

핀(pin) — API를 매번 부르지 않고 개발하는 습관​

원본 캔버스에는 실행 결과가 고정(pin)된 노드가 일곱 개 있습니다. 마스터 블록의 Merge(3,202행), 보너스 블록의 Code 노드, 페이지네이션 HTTP 노드, Split Out이 두 벌씩입니다. 노드 출력 패널의 핀 아이콘을 누르면 그 실행 결과가 노드에 저장되고, 이후 에디터에서 실행할 때는 API를 다시 부르지 않고 저장된 결과를 그대로 내보냅니다.

이 습관이 중요한 이유는 뒤 노드를 만질 때마다 API를 호출하지 않아도 되기 때문입니다. Set 노드의 표현식을 고치고 Supabase 매핑을 확인하는 동안 실제 호출은 한 번이면 됩니다. 공공 API는 대개 일일 호출 제한이 있고, 유료 API라면 비용이 나갑니다. 두 가지는 알아두세요. n8n 문서에 따르면 Active 상태의 프로덕션 실행은 핀 데이터를 무시하고 실제로 호출하니 "핀을 풀지 않아서 옛날 데이터가 쌓이는" 걱정은 없습니다. 반대로 핀 데이터는 워크플로우 JSON 안에 같이 저장됩니다. 이 워크플로우 export 파일에 2,727행이 통째로 들어 있는 게 그 증거이니, 개인정보가 섞인 응답은 핀하지 않는 편이 안전합니다.

원본 캔버스에 남아 있는 흔적​

이 워크플로우는 강의 공유용이라 운영 이력은 없습니다. 대신 캔버스에 남은 흔적 세 가지가 따라 만들 때 그대로 힌트가 됩니다.

첫째, 위에서 말한 API_KEY Set 노드입니다. 노드는 있지만 HTTP 노드가 참조하지 않아서, 키를 바꾸려면 URL 네 곳을 고쳐야 하는 상태로 남아 있습니다. {{ $json.API_KEY }}로 연결하는 게 첫 번째 개선입니다. 둘째, AWS S3 노드의 파일 경로가 publicdata/emergencyroom/으로 되어 있습니다. 응급실 API 편의 워크플로우에서 노드를 복사해 온 흔적인데, 그만큼 "HTTP → Split → 전처리 → 적재" 뼈대가 데이터 소스에 관계없이 같다는 뜻이기도 합니다. 물론 따라 만들 때는 경로를 bike/ 같은 이름으로 바꿔야 합니다. 셋째, 같은 블록이 두 벌 있는데 한 벌은 Supabase 노드가 연결되지 않은 채 남아 있습니다. 노드가 캔버스에 있다고 실행되는 게 아니니, 실행 결과 화면에서 Supabase 노드에 체크가 뜨는지 확인하는 습관이 필요합니다.

한계​

  • S3 미연결 — export 기준으로 Convert to File 뒤의 S3 노드에 선이 연결되어 있지 않습니다. CSV까지만 만들고 끝나니, 파일 보관을 쓰려면 직접 연결하고 credential을 넣어야 합니다.
  • 현황 테이블은 타입 캐스팅 없음 — 마스터 블록은 number 타입을 지정했지만 현황 블록은 API 문자열을 그대로 넣습니다. Supabase 컬럼을 integer로 만들면 Postgres가 형변환해 주지만, 명시적으로 Set 노드에서 타입을 잡아주는 편이 안전합니다.
  • insert 실패 처리 없음 — UNIQUE 제약에 걸리면 Supabase 노드가 에러로 멈춥니다. 같은 시간대에 두 번 실행되는 게 정상 시나리오라면 노드 설정의 On Error를 Continue로 두거나, 실행 전에 해당 시각 데이터가 있는지 조회하는 노드를 앞에 두어야 합니다.
  • API 건수 상한 하드코딩 — 대여소가 3,000개를 넘으면 세 번째 노드 뒤의 데이터가 빠집니다. 변형 3의 페이지네이션 상한도 3,000으로 박혀 있습니다. list_total_count를 먼저 읽어 구간을 만드는 게 다음 단계입니다.

포트폴리오·면접 포인트​

포트폴리오에는 워크플로우 캔버스 스크린샷과 Supabase 테이블에서 updated_at별로 행 수를 센 쿼리 결과 한 장이면 충분합니다. 거기에 며칠치 쌓인 데이터로 "특정 대여소의 시간대별 자전거 수" 그래프 한 장을 붙이면, 수집만 한 사람과 수집한 데이터로 질문에 답한 사람이 구분됩니다. 응용은 소스만 바꾸면 됩니다. 서울 열린데이터광장의 다른 실시간 API(지하철 도착, 주차장 현황)는 URL 구조가 같아서 서비스명과 Split Out 경로만 바꾸면 이 워크플로우가 그대로 돕니다.

면접에서 "왜 updated_at을 시간 단위로 잘랐나요?"라는 질문이 오면, 실행 시각 그대로 쓰면 한 번의 실행에서 나온 2,727행이 서로 다른 시각을 갖게 되어 스냅샷 단위로 조회할 수 없다는 점, 그리고 그 값이 UNIQUE 제약의 키가 되어 중복 적재를 DB 차원에서 막는다는 점을 말하면 됩니다. "대여소 마스터 테이블을 왜 따로 두었나요?"에는 매시간 바뀌는 값과 거의 안 바뀌는 값을 한 테이블에 넣으면 주소·좌표가 2,700행씩 매시간 중복 저장된다는 답이 나옵니다.

다음 액션​

공공 API 세 편을 지나면서 HTTP 노드로 데이터를 받는 일은 손에 익었을 겁니다. 다음 편은 받는 쪽이 아니라 받은 데이터를 고치는 쪽입니다. 구글 뉴스 RSS의 링크는 그대로 열리지 않아서 디코드가 필요한데, 그 디코드 로직을 서브워크플로우로 분리해 여러 워크플로우가 같이 쓰는 방법을 다룹니다. 이 편에서 API_KEY를 한 곳에 모으려 했던 것과 같은 동기, 즉 "같은 로직을 두 번 만들지 않기"가 워크플로우 단위로 커지는 편입니다.