물컵 사진을 찍으면 습관 시트에 체크 — n8n 웹훅으로 이미지 받아 OpenAI로 분류하기
이 글과 연결된 글 보기
드래그로 이동 · 휠/두 손가락으로 확대 · 방향키로 글 선택 · 0으로 맞춤. 실선: 본문 링크, 점선: 시리즈·주제의 대표 연결.표시된 글 7편 목록
물컵 사진 한 장을 웹훅으로 보내면 OpenAI가 "물 마시기"라고 분류하고, 구글시트의 습관 목록에서 그 행을 찾아 체크를 찍어주는 워크플로우를 n8n 노드 6개로 만들 수 있습니다. 이 편에서 처음으로 입력이 텍스트나 JSON이 아니라 이미지가 됩니다. 웹훅으로 바이너리를 받는 법, OpenAI 노드의 Analyze Image 사용법, 분류 결과를 시트와 대조해 특정 행만 고쳐 쓰는 법이 한 워크플로우에 들어 있습니다.
이 워크플로우 하나로 보여줄 수 있는 것
앞의 NFC 웹훅 편이 "태그를 찍으면 웹훅이 열린다"는 트리거 감각을 익히는 편이었다면, 이 편은 같은 Webhook 노드로 파일을 받는 편입니다. 사진 한 장이 들어와서 시트의 셀 하나가 바뀌어 나가는 동안 드러나는 역량은 세 가지입니다.
- 바이너리 입력 처리 — 웹훅으로 JSON이 아니라 이미지 파일을 받아 다음 노드로 넘겨본 경험
- 이미지 분류 — LLM의 비전 기능을 "자유 서술"이 아니라 정해진 후보 중 하나를 고르는 분류기로 쓴 경험
- 대조 후 부분 갱신 — 시트 전체를 다시 쓰지 않고, 조건에 맞는 행 하나만 찾아 특정 컬럼을 고친 경험(Update + 매칭 컬럼)
세 번째가 은근히 중요합니다. 시트를 읽거나 행을 추가하는 것과 달리, 기존 행을 고치는 Update에는 "어느 행인지 어떻게 특정하나"라는 질문이 따라붙습니다. 그 답이 이 편의 row_number입니다.
배경 — 습관 앱 대신 사진 한 장
습관 트래커 앱은 많지만 결국 앱을 열어 버튼을 눌러야 합니다. 이 워크플로우의 발상은 그 버튼을 없애는 것입니다. 물을 마셨으면 물컵 사진, 운동했으면 운동화 사진을 찍어 보내면 끝이고, 무슨 습관인지 판단하는 일은 AI가 하고 체크는 시트에 남습니다. 캔버스의 스티키 노트 세 장이 이 발상을 그대로 적고 있습니다. Trigger는 "사진을 업로드하기 (예. 물컵 사진)", Task는 "사진을 판별해서 습관을 분류하기 (예. 물 마시기 분류)", Target은 "구글 시트에 해당 습관이 맞으면 체크 표시 하기"입니다.
한 가지 미리 말해두면, 이 원본은 데이터팝콘 n8n 인스턴스에 설계 뼈대만 그려 둔 상태입니다. 노드 6개와 스티키 노트 3장은 있지만 연결이 두 군데 비어 있고 시트 ID도 자리표시자 그대로입니다. 그래서 이 글은 원본의 설계를 따라가되 실제로 돌아가려면 무엇을 채워야 하는지까지 같이 쓰고, 어디가 원본이고 어디가 이 글의 수정인지 문장마다 밝혀 둡니다. 설치나 기본 노드 설명은 n8n으로 반복업무 자동화 포트폴리오 만들기에 있으니 여기서는 워크플로우 자체만 다룹니다.
구조 — Trigger / Task / Target
- Trigger — 웹훅
POST /habit-tracker에 사진이 들어오면 - Task — 시트 ID 같은 설정값을 한 곳에 모음 → OpenAI가 사진을 보고 습관 이름 하나를 답함 → 시트의 습관 목록을 읽어 한 행씩 분류 결과와 대조
- Target — 일치한 행의 체크 컬럼만 Update
만들기
0. 시트 준비
시트에는 헤더 행에 habit, checked 두 컬럼을 두고 habit 아래에 물 마시기, 운동, 독서, 명상, 건강한 식사를 한 행씩 적습니다. habit이라는 헤더 이름은 원본 If 노드가 $json.habit을 읽기 때문에 그대로 맞춰야 하고, checked는 원본에 시트 구조가 남아 있지 않아 이 글에서 정한 이름입니다. 습관 후보 다섯 개는 아래 프롬프트에 적힌 목록과 같아야 분류 결과와 시트가 맞아떨어집니다.
1. Webhook — 사진을 바이너리로 받기
Webhook 노드에서 HTTP Method를 POST, Path를 habit-tracker로 두고, Options에서 Raw Body를 켭니다. 원본 설정이 이 세 가지입니다. Raw Body를 켜면 요청 본문이 파싱되지 않고 그대로 아이템의 바이너리 필드 data에 담기고, 요청의 Content-Type 헤더가 그 바이너리의 MIME 타입이 됩니다. 뒤의 OpenAI 노드가 이 MIME 타입을 데이터 URL 앞에 붙여 보내기 때문에, 사진을 보낼 때 Content-Type: image/jpeg처럼 실제 타입을 명시해야 합니다.
Respond는 원본대로 When Last Node Finishes로 둡니다. 마지막 노드의 출력이 그대로 응답이 됩니다.
2. Set — 설정값을 한 노드에 모으기
Edit Fields(Set) 노드의 이름이 원본에서 "Workflow Configuration"입니다. 여기에 sheetId, habitColumn(값 B), dateColumn(값 A) 세 문자열 필드가 있고 Include Other Input Fields가 켜져 있습니다. 여러 노드가 같이 쓰는 값을 각 노드에 복사해 넣는 대신 한 곳에 두고, 뒤에서 {{ $('Workflow Configuration').first().json.sheetId }}로 꺼내 쓰는 패턴입니다. 실제로 원본의 Sheets 노드 두 개가 Document ID를 모두 이 표현식으로 받습니다. 시트를 바꿀 때 노드 하나만 고치면 되는 것이 이 노드가 있는 이유입니다.
이 노드에서 신경 쓸 것은 바이너리입니다. Include Other Input Fields를 켜 두면 웹훅이 넘긴 JSON 필드와 함께 바이너리 data도 그대로 다음 노드로 넘어갑니다. Options에 Strip Binary Data를 추가하면 사진이 여기서 잘려 나가니 그 옵션은 건드리지 않습니다. 원본은 Options가 비어 있어 바이너리가 통과합니다.
3. OpenAI — Analyze Image로 습관 이름 하나만 받기
OpenAI 노드에서 Resource를 Image, Operation을 Analyze Image로 두고 Model에 gpt-4o를 고릅니다. Input Type은 Binary File(s), Input Data Field Name은 기본값 data입니다. 원본 프롬프트는 다음과 같습니다.
Analyze this image and identify what habit it represents. Common habits include: drinking water (물 마시기), exercise (운동), reading (독서), meditation (명상), healthy eating (건강한 식사). Return ONLY the habit name in Korean.
이 프롬프트의 핵심은 마지막 문장입니다. 후보 다섯 개를 한글 표기까지 정해 주고 "이름만 한국어로" 답하게 해서, 비전 모델을 설명 생성기가 아니라 분류기로 씁니다. 답이 "물 마시기" 한 단어로 오기 때문에 뒤에서 시트 셀과 그대로 비교할 수 있습니다. 결과는 Simplify Output이 켜진 기본 상태에서 content 필드에 담깁니다.
4. Google Sheets — 습관 목록 읽기
Google Sheets 노드(Get Row(s))에서 Document ID를 By ID로 바꾸고 {{ $('Workflow Configuration').first().json.sheetId }}를 넣습니다. Sheet는 Sheet1입니다. 실행하면 습관 한 개가 아이템 하나가 되고, 각 아이템에는 시트 헤더 그대로 habit, checked와 함께 n8n이 붙여 주는 row_number가 들어옵니다. 이 row_number가 6단계에서 "어느 행을 고칠지"의 열쇠가 됩니다.
5. If — 분류 결과와 대조
If 노드에 조건 하나를 넣습니다. 원본은 왼쪽 값이 {{ $json.habit }}, 연산자가 String → contains, 오른쪽 값이 OpenAI 노드의 출력입니다. 오른쪽 값은 원본에 {{ $('Analyze Photo and Classify Habit').first().json.text }}로 적혀 있는데, Analyze Image 노드의 출력 필드는 text가 아니라 content이므로 이 글에서는 아래처럼 씁니다.
- 왼쪽 값:
{{ $json.habit }} - 연산자: String → contains
- 오른쪽 값:
{{ $('Analyze Photo and Classify Habit').first().json.content }}
시트 행 다섯 개가 각각 이 조건을 통과하므로, 분류 결과가 "물 마시기"면 그 행 하나만 true로 나가고 나머지 넷은 false에서 멈춥니다. 이것이 "체크할 행을 특정하는" 방식이고, 그래서 3단계에서 답을 한 단어로 묶어 둔 것이 중요합니다. 모델이 "이 사진은 물 마시기 습관입니다"처럼 문장으로 답하면 셀이 그 문장을 포함하지 않아 아무 행도 통과하지 못합니다.
6. Google Sheets — 그 행의 체크 컬럼만 Update
두 번째 Sheets 노드는 Operation이 Update, Document ID가 4단계와 같은 표현식, Mapping Column Mode가 Map Each Column Manually입니다. 원본은 매칭 컬럼과 값 이름을 rowNumber로 적어 두었는데, 4단계에서 본 대로 n8n이 붙여 주는 필드 이름은 row_number라 이 글에서는 그렇게 맞춥니다.
- Column to match on:
row_number - Values to Send:
row_number={{ $json.row_number }},checked=✅
한 가지 짚어 둘 점이 있습니다. 원본의 Update 노드는 값으로 설정 노드의 habitColumn, 즉 문자열 B를 보냅니다. 그런데 Map Each Column Manually는 컬럼을 헤더 이름으로 찾지 B 같은 열 문자로 찾지 않습니다. 설정 노드의 habitColumn: B, dateColumn: A는 열 문자 기준으로 설계하던 흔적이고, 이 노드 모드에서는 쓰이지 않습니다. 그래서 이 글에서는 checked 헤더에 체크 값을 쓰는 방식으로 바꿨습니다.
7. 테스트 — curl로 사진 보내기
Webhook 노드의 Listen for Test Event를 누른 뒤 IDE 터미널에서 사진을 보냅니다. Raw Body 방식이라 폼이 아니라 파일 자체를 본문으로 보내고, Content-Type을 꼭 명시합니다.
curl -X POST "https://YOUR-N8N-HOST/webhook-test/habit-tracker" \
-H "Content-Type: image/jpeg" \
--data-binary "@water-cup.jpg"
캔버스에서 OpenAI 노드의 출력 content가 "물 마시기"인지, If 노드의 true 쪽에 아이템 하나만 나갔는지, 시트의 그 행에 체크가 찍혔는지 순서대로 확인합니다. Active로 켜면 URL의 webhook-test가 webhook으로 바뀝니다. 휴대폰 단축어 등에서 폼 업로드(multipart)로 보내면 바이너리 필드 이름이 data가 아니라 폼 필드 이름이 되므로, 그때는 OpenAI 노드의 Input Data Field Name을 거기에 맞춥니다.
원본 캔버스에 남아 있는 흔적 — 뼈대만 그린 워크플로우
이 워크플로우의 export를 열면 만든 시각과 마지막 수정 시각이 6분 차이입니다. 스티키 노트 3장으로 Trigger/Task/Target을 정하고 노드 6개를 올려 둔 뒤 멈춘 상태라 Active도 꺼져 있습니다. 그래서 이 편에는 "돌려보니" 대신 뼈대에서 발견한 빈칸을 적습니다. 처음 만드는 사람이 똑같이 빠뜨리기 쉬운 자리입니다.
첫째, 연결이 두 군데 비어 있습니다. Webhook → Set, Sheets 읽기 → If → Update는 이어져 있지만, Set → OpenAI와 OpenAI → Sheets 읽기는 선이 없습니다. 이 상태로 실행하면 웹훅이 사진을 받고 설정값을 붙인 뒤 조용히 끝납니다. 둘째, 필드 이름이 두 곳에서 어긋납니다. If 노드는 OpenAI 출력을 text로 참조하지만 실제 필드는 content이고, Update 노드는 rowNumber를 매칭 컬럼으로 쓰지만 실제 필드는 row_number입니다. 이름이 한 글자만 달라도 값이 비어서 조건은 통과하지 못하고 갱신할 행도 찾지 못합니다. 셋째, 시트 ID가 자리표시자입니다. 설정 노드의 sheetId가 실제 ID가 아니라 채워 넣으라는 표시 문자열 그대로입니다.
세 가지 모두 설정 화면에서는 잘 안 보이고 실행해 봐야 드러납니다. 노드를 하나씩 실행하면서 출력 패널의 필드 이름을 다음 노드의 표현식과 맞춰 보는 습관이 이런 빈칸을 가장 빨리 찾는 방법입니다.
한계
- 오늘 체크인지 알 수 없음 —
checked한 컬럼에 체크를 찍는 구조라 날짜가 남지 않습니다. 매일 쓰려면 체크 대신{{ $now.toFormat('yyyy-MM-dd') }}처럼 시각을 적거나 날짜별 컬럼을 두어야 합니다. 원본 설정 노드의dateColumn은 어디에서도 쓰이지 않는 채로 남아 있습니다. - 후보 밖 사진 — 다섯 습관 중 어느 것도 아닌 사진을 보내면 모델이 억지로 하나를 고르거나 다른 답을 냅니다. 프롬프트에 "해당 없으면
없음이라고 답하라"를 추가하고 If에서 걸러야 합니다. - 응답이 불친절함 — When Last Node Finishes라 응답 본문이 Sheets 노드의 원시 출력입니다. 휴대폰에서 "물 마시기 체크 완료"만 보고 싶으면 Respond to Webhook 노드로 메시지를 정리해야 합니다.
- 비용 — Analyze Image는 OpenAI API 호출이라 사진 한 장마다 과금됩니다. 개인 습관 트래커 수준이면 소액이지만 무료는 아니니 실습 전에 충전해 두세요.
포트폴리오·면접 포인트
"사진 → 분류 → 시트의 특정 행 갱신" 뼈대를 유지하면 입력 사진과 시트만 바꿔 다른 프로젝트가 됩니다. 냉장고 사진을 보내면 재료 목록에 있음/없음을 표시하는 장보기 시트, 영수증 사진을 보내면 지출 카테고리 행을 갱신하는 가계부가 같은 구조입니다. 포트폴리오에는 curl 요청부터 시트 셀이 바뀌는 장면까지 이어지는 캡처와, 위의 한계 중 날짜 문제를 직접 해결한 버전이면 충분합니다.
면접에서는 "비전 모델에 왜 설명이 아니라 이름 하나만 답하게 했나요?"라는 질문이 올 수 있습니다. 답은 뒤 노드가 그 답을 시트 셀과 문자열로 비교하기 때문이고, 자유 서술을 받으면 대조 단계가 무너진다는 것입니다. 여기에 "원본 캔버스에서 필드 이름이 어긋난 곳을 실행 결과로 찾아 고쳤다"는 이야기를 붙이면, 노드를 이어 붙인 사람이 아니라 데이터 흐름을 읽는 사람으로 보입니다.
다음 액션
지금까지 웹훅 두 편은 모두 내가 직접 요청을 보내는 쪽이었습니다. 다음 편은 반대로, 외부 결제 서비스가 결제가 일어날 때마다 n8n 웹훅으로 데이터를 보내 주면 그것을 받아 바로 알림으로 바꾸는 편입니다. 같은 Webhook 노드지만 요청 본문의 모양을 내가 정하지 못하고 상대가 정한 대로 파싱해야 한다는 점이 다릅니다.
