WallpaperMachine
당신이 사랑하는 배경화면, 맥에서 새롭게 만나보세요
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기https://temporary-flying-mistral-kc8mjzk.vercel.app/
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
기관이 "이 물건이 이만큼 필요해요"를 먼저 올리고, 개인이 사진 한 장으로 그 목표를 나눠 채우는 서비스입니다. 게시판에 물건을 올려두고 기관이 일일이 골라가는 방식(당나귀형)의 모니터링 부담과 한 기관이 싹쓸이해가는 허점을, 크라우드펀딩식 구조로 뒤집었습니다. .env.local에 OpenAI API 키를 넣습니다. 키가 없어도 목업으로 동작하지만, 실제 사진 판독은 안 됩니다. http://localhost:3000 에서 열립니다. /beneficiary는 헤더 메뉴의 "기관등록"으로도 들어갈 수 있습니다. 시연할 때 두 번째 창으로 띄워 기관 역할을 맡겨도 됩니다.README 전문 보기 ↗
기관이 "이 물건이 이만큼 필요해요"를 먼저 올리고, 개인이 사진 한 장으로 그 목표를 나눠 채우는 서비스입니다.
게시판에 물건을 올려두고 기관이 일일이 골라가는 방식(당나귀형)의 모니터링 부담과 한 기관이 싹쓸이해가는 허점을, 크라우드펀딩식 구조로 뒤집었습니다.
npm install
.env.local에 OpenAI API 키를 넣습니다. 키가 없어도 목업으로 동작하지만, 실제 사진 판독은 안 됩니다.
OPENAI_API_KEY=발급받은_키
# OPENAI_MODEL=gpt-5.4-mini # 선택. 비우면 이 값을 쓴다
# 선택. 기관 목록을 공공데이터에서 다시 받아올 때만 필요합니다(아래 "기관 데이터" 참고).
# DATA_GO_KR_SERVICE_KEY=발급받은_서비스키
# ADMIN_SYNC_TOKEN=아무_문자열
# 기관 관리 코드. 신청 수락·거절, 연락처 보기, 요청 등록에 필요합니다.
# 로컬에서는 비워두면 코드 없이 열리고, 운영(Vercel)에서는 비워두면 이 기능들이 막힙니다.
# ADMIN_TOKEN=기관들과_공유할_코드
npm run dev
http://localhost:3000 에서 열립니다.
| 탭 | 역할 |
|---|---|
| 진행률 게시판 | 기관들이 올린 요청과 현재 진행률을 한눈에 |
| 기부자 모드 | 사진 등록 → AI 인식 → 매칭 → 신청 (4단계) |
| 기관 관리 | 기관 선택 → 기관 홈 → 요청 등록 또는 신청 확인·수락/거절 |
/beneficiary는 헤더 메뉴의 "기관 관리"로 들어갑니다. 시연할 때 두 번째 창으로 띄워 기관 역할을 맡겨도 됩니다.
| 화면 | 주소 | 하는 일 |
|---|---|---|
| 기관 입장 | /beneficiary | 기관 선택, 관리 코드 확인 |
| 기관 홈 | /beneficiary/[orgId] | 처리할 신청과 요청별 진행률 확인 |
| 요청 올리기 | /beneficiary/[orgId]/needs/new | 필요 물품 등록, 등록 완료 안내 |
| 신청 확인 | /beneficiary/[orgId]/applications/[applicationId] | 물품·연락처 확인, 날짜 골라 수락 또는 거절, 진행률 변화 확인 |
관리 코드는 탭을 닫기 전까지 유지됩니다. 기관별 계정은 없고 코드 하나를 함께 쓰므로, 주소의 기관 id는 권한 경계가 아니라 화면 범위입니다.
OpenAI Vision(gpt-5.4-mini)으로 실제 사진을 판독합니다. 품목명·카테고리·유통기한을 읽어 옵니다. 응답은 JSON 스키마로 강제하고, 카테고리는 앱이 아는 6종 안에서만 고르게 합니다.
사진은 한 장 또는 두 장을 받습니다. 두 장이면 한 번의 요청에 같이 실어 보내고, 각 사진 앞에 [사진 1] 제품 사진 / [사진 2] 유통기한 사진 라벨 텍스트를 끼워 역할을 알려줍니다. 이 라벨이 없으면 모델이 어느 쪽이 유통기한 사진인지 모릅니다. 날짜는 사진 2를 우선해 읽고, 거기서 못 찾으면 사진 1에서 찾습니다.
모델은 아래 데모 사진(라벨 2026.09.04)으로 후보를 여러 번 돌려보고 골랐습니다.
| 모델 | 날짜 정확도 | 응답 시간 |
|---|---|---|
gpt-5.4-mini (effort medium) | 5/5 | 2.1~3.4초 |
gpt-4.1-mini (temperature 0) | 3/3 | 3.5~5.1초 |
gpt-5.4-nano (effort low) | 1/2 — 연도를 틀림 | 2.0~3.0초 |
reasoning.effort를 low로 낮추면 gpt-5.4-mini도 2026을 2028로 읽은 적이 있어 medium으로 고정했습니다. 유통기한 오독은 이 앱에서 가장 비싼 실수라 속도보다 정확도를 삽니다.
화면 1의 예시 사진 불러오기 (2장) 버튼은 저장소에 함께 둔 실제 제품 사진 두 장을 두 슬롯에 한 번에 넣습니다. 발표 중에 파일 탐색기를 열거나 물건을 들고 다닐 필요가 없습니다.
| 파일 | 내용 |
|---|---|
public/demo/juice-front.jpg | 아침에주스 앞면 — 품목명·용량 |
public/demo/juice-back.jpg | 뒷면 — 유통기한 2026.09.04 |
품목명은 앞면에, 유통기한은 뒷면에만 있어서 두 슬롯이 왜 필요한지 그 자체로 보여주는 예시입니다. 뒷면에는 품목보고번호 2006240126도 같이 찍혀 있는데 그걸 날짜로 착각하지 않습니다.
판독 우선순위는 이렇습니다.
milk_expired.jpg → 우유), 없으면 파일 이름+크기 해시(같은 사진이면 항상 같은 결과). 이때는 결과 카드 맨 위에 주황 배너로 *"AI 서버에 연결하지 못했어요 — 아래 내용은 실제 판독 결과가 아니라 예시 데이터예요"*가 뜹니다. 뱃지가 "나눔 가능"을 확신에 차서 보여주는데 그 근거가 가짜 데이터라, 뱃지보다 위에서 먼저 읽히게 뒀습니다.사진에 물품이 없으면 목업으로 덮지 않고 422와 함께 "물품이 잘 보이게 다시 찍어주세요"를 돌려줍니다. 엉뚱한 품목을 확신에 차서 보여주는 게 더 나쁘기 때문입니다.
나눔 가능 여부는 모델이 정하지 않습니다. 모델이 읽은 유통기한을 받아 서버의 evaluateShareable가 판정합니다.
다른 모델·다른 벤더로 바꾸려면 app/api/recognize/route.ts 한 파일만 고치면 됩니다. 응답 형태(itemName, category, expiryDate, shareable, reason)만 맞추면 나머지는 그대로 동작합니다.
사진 2장을 gpt-5.4-mini(effort medium)로 판독한 실제 사용량입니다.
| 항목 | 값 |
|---|---|
| 입력 토큰 | 3,917 (사진 2장 + 프롬프트) |
| 출력 토큰 | 464 (추론 382 포함) |
| 1회 비용 | $0.005 (약 7원) |
돈은 병목이 아닙니다. 한도가 병목입니다. 무료 등급은 모델마다 하루 50회(RPD)로
묶여 있고, 24h ÷ 50 = 28.8분마다 1회분씩 차는 leaky bucket이라 실질 시간당 2회입니다.
크레딧 잔액이 남아 있어도 이 제한은 그대로입니다 — 등급은 잔액이 아니라 실제 결제한
누적 금액으로 올라갑니다(프로모션 크레딧은 자격에 안 잡힙니다).
한도에 걸리면 앱은 목업으로 떨어지고 화면이 "오늘 AI 판독 한도를 다 썼어요"라고 말합니다. 리허설은 데모 모드로 하면 호출이 아예 안 나갑니다.
AI로 확인하기
→ 실제 냉동 생지 봉지 사진에서 치즈볼생지 750g과 소비기한 2027-03-09을 읽어내고
초록 나눔 가능 뱃지가 뜸성인용 기저귀로 다시 인식 → 다음이 날짜로 수락 (진행률 +10) 클릭 → 결과 화면에서 30% → 50% 확인진행 현황 보기로 진행률 게시판에 가면 **50%**로 올라 있음요청·기부·신청·기관은 Firestore에 있습니다(lib/firebase.ts, lib/store.ts). 팀원 전체가 같은
DB를 보므로 각자 실행해도 데이터가 공유됩니다. 컬렉션이 비어 있으면 시드 데이터로 한 번 채웁니다.
신청 화면(/apply)의 전달 장소에 기관 주소와 지도를 보여줍니다. 좌표는 공공데이터에서
받아온 값을 그대로 씁니다(lib/data/pohang-orgs.json).
지도는 두 겹입니다.
JavaScript 키는 DeliveryMap.tsx에 코드로 박아뒀습니다. lib/firebase.ts의 Firebase 웹
설정과 같은 판단입니다 — 브라우저에 실려 나가는 공개 값이고, 숨겨도 개발자도구를 열면
보입니다. 환경변수로 뒀을 때 Vercel에 변수가 없어서 배포에서 조용히 폴백되는 문제도
있었습니다.
진짜 보안은 카카오 콘솔의 사이트 도메인 제한입니다. 그래서 이것만 등록해두면 됩니다.
카카오 개발자 콘솔 → 내 애플리케이션 → 앱 설정 → 플랫폼 → Web → 사이트 도메인
http://localhost:3000
https://sharing-khaki.vercel.app
등록되지 않은 도메인에서는 SDK가 401을 주고 OSM으로 떨어집니다. 응답 메시지가
domain mismatched! caller=... 라서 원인을 바로 알 수 있고, 브라우저 콘솔에도
[DeliveryMap] 카카오 지도 실패 — 가 찍힙니다.
REST API 키와 네이티브 앱 키는 성격이 다릅니다. 그건 비밀 값이니 코드나 저장소에 넣지 마세요. 이 기능에 필요한 건 JavaScript 키뿐입니다.
지도 아래 "카카오맵으로 길찾기"는 도착지가 채워진 상태로 길찾기를 엽니다. 이 링크는 키가 필요 없습니다. 모바일에서는 카카오가 applink로 넘겨 "앱으로 열기 / 설치 없이 웹으로 보기"를 사용자가 고릅니다.
무료급식소 같은 기관은 지도 서비스에 장소로 등록돼 있지 않습니다(검색해도 안 나옵니다). 그래서 장소 ID가 아니라 이름과 좌표를 직접 넘깁니다 — 이러면 등록되지 않은 장소도 이름표 붙은 핀으로 뜹니다.
네이버 지도 길찾기는 쓰지 않습니다. 모바일 브라우저에서 URL이 통합검색으로 리다이렉트되어 도착지가 전달되지 않았습니다(실측).
기부하려는 물품과 가장 비슷한 요청을 위로 올립니다. 정렬 키는 세 단계입니다
(lib/store.ts의 matchNeeds):
1순위 품목 일치도 exact → similar → different
2순위 필요도 점수 부족분·긴급도·거리로 계산
3순위 거리
품목 일치도가 점수보다 우선입니다. 그래서 20점짜리 "찹쌀"(같은 분류)이 33점짜리 "스팸"(다른 분류)보다 위에 옵니다 — 점수만으로 정렬하면 분류가 다른데 아주 급한 요청이 딱 맞는 요청보다 위로 오게 됩니다.
두 겹입니다. 위가 실패하면 아래로 떨어집니다.
genericName을 같이 뽑습니다
("백미 5kg" → "쌀", "햇반 210g" → "즉석밥"). 기관이 요청을 등록할 때도
한 번 뽑습니다. 매칭 경로에서는 모델을 부르지 않습니다 — 화면을 열 때마다
API를 쓰게 되고, 순위가 매번 달라지면 설명할 수 없습니다.lib/rules.ts의 canonicalItemName, isSameItem).포함 관계는 짧은 쪽이 두 글자 이상일 때만 인정합니다. 한 글자를 허용하면
"찹쌀"이 "쌀"을 포함해 같은 물건이 되고, 두 글자까지 막으면 "우유"와 "흰 우유"를
놓칩니다. 이 경계는 npm run check:items가 지킵니다.
npm run check:items # 품목 매칭 회귀 검사 (러너 없이 node로 돈다)
# 아직 비어 있는 요청만 채움 (품목명 종류가 몇 개든 호출은 1회)
curl "http://localhost:3000/api/admin/backfill-generic-names"
# 전부 다시 계산 — 문자열 규칙으로 들어간 값을 AI 값으로 갈아끼울 때
curl "http://localhost:3000/api/admin/backfill-generic-names?force=1"
키가 없거나 한도에 걸리면 문자열 규칙 값으로 채워집니다. 매칭이 멈추지는 않고 정확도만 낮아집니다.
기부를 받는 기관 114곳은 지어낸 데이터가 아니라 공공데이터포털에서 받아온 실제 기관입니다.
출처는 두 곳이고, 서비스 소개 페이지(/service) 에 밝혀 둡니다.
| 출처 | 기관 | 수 | 좌표 |
|---|---|---|---|
| 경상북도_푸드뱅크 현황 | 푸드뱅크·푸드마켓 | 5 | 주소를 Nominatim으로 지오코딩 |
| 포항시_생활 지도 시설 현황 | 지역아동센터 63 · 요양원 34 · 무료급식소 12 | 109 | 원본에 포함 |
두 출처는 서로를 대체하지 않습니다. 푸드뱅크는 기부를 받아 나눠주는 거점이고, 나머지는 물품이
실제로 가 닿는 수혜 기관입니다. Beneficiary.source로 구분하며, 한쪽을 갱신할 때 다른 쪽을 지우지 않습니다.
생활지도 API에는 카테고리 필터가 없습니다(ctgry_nm 등을 넘겨도 무시되고 항상 전체가 옵니다).
109곳을 얻으려면 64,426건을 1,000건씩 65번 받아 직접 걸러야 하고, 한 번에 2분쯤 걸립니다.
게다가 원본이 2023-08-28 수집 스냅샷이라 자주 불러도 내용이 같습니다. 그래서
lib/data/pohang-orgs.json에 스냅샷을 커밋해 두고 Firestore로 서비스합니다.
# 공공데이터를 다시 받아 Firestore에 반영 (2분 소요, 서비스키 필요)
curl "http://localhost:3000/api/admin/sync-orgs"
# 커밋된 스냅샷으로만 반영 (즉시, 서비스키 불필요)
curl "http://localhost:3000/api/admin/sync-orgs?source=snapshot"
# 받아온 결과만 확인 (Firestore 안 건드림) — 스냅샷 파일을 이걸로 만듭니다
curl "http://localhost:3000/api/admin/sync-orgs?dryRun=1" > lib/data/pohang-orgs.json
# 기관이 늘어난 뒤 기존 요청을 새 기관에도 골고루 나누기
curl "http://localhost:3000/api/admin/sync-orgs?source=snapshot&redistribute=1"
운영 환경에서는 ?token=$ADMIN_SYNC_TOKEN이 필요합니다.
두 원본 모두 기관 운영시간·연락처가 없습니다. 실존 기관 이름 옆에 지어낸 운영시간을 붙이면 화면에서 사실로 읽히므로, 비워 두고 "운영일은 신청 후 협의"·"수거 시간은 미확인"으로 표시합니다. AI 추천 날짜도 기관 운영일 제약을 빼고 기부자가 가능한 요일만으로 계산합니다.
app/
page.tsx 3개 탭 전환
_components/
NeedBoard.tsx 진행률 게시판
DonorFlow.tsx 기부자 4단계 (핵심 화면)
OrgEntry.tsx 기관 입장
OrgHome.tsx 기관 홈
NeedForm.tsx 요청 올리기
ApplicationReview.tsx 신청 확인·처리
AdminCode.tsx 관리 코드 입력·확인 (공용)
NeedProgress.tsx 진행률 바 (공용)
api/
recognize/ AI 인식 (OpenAI Vision + 목업 폴백)
needs/ 기관 요청 등록·조회
donations/ 기부 물품
match/[id]/ 필요도·거리 기반 추천
slots/[id]/ 양쪽 가능 날짜 계산
applications/ 신청·수락·거절
ui.ts 공용 스타일
lib/
rules.ts 순수 규칙 (유통기한 판정, 거리, 샘플 데이터)
store.ts Firestore 저장소 + 추천 로직
Next.js 15 (App Router) · React 19 · TypeScript. 외부 의존성 없음.