캠핑인스타 만들기 4편
지난 3편에서는 앱의 전반적인 UI를 인스타그램 스타일로 리팩토링하고, Hydration Error나 Silent Failure 같은 버그를 잡았다. 이번 4편에서는 공유 기능의 핵심인 Open Graph(OG) 이미지를 구현하면서 겪은, 솔직히 말하면 꽤 삽질한 이야기를 정리한다.
1. 목표: 링크 공유 시 예쁜 미리보기 이미지 만들기
카카오톡이나 슬랙에 링크를 붙여넣으면 자동으로 뜨는 미리보기 이미지, 그게 OG 이미지다. 기존에는 단색 초록 배경에 텍스트만 덩그러니 놓여있었는데, 게시물의 첫 번째 캠핑 사진을 배경으로 꽉 채우고 싶었다.
구현 방식
- Next.js의
next/og(내부적으로 Satori + Resvg 사용)를 통해 서버에서 동적으로 PNG 이미지를 생성 - 라우트 구조:
/og/feed/[id](게시물),/og/camp/[id](캠핑장) - DB에서 게시물 데이터를 조회 → 첫 번째 이미지를 fetch → Base64로 변환 →
<img>태그로 배경에 삽입
2. 오늘의 트러블 슈팅 🔥
이번 편의 하이라이트다. OG 이미지 하나 제대로 띄우려고 원인을 3겹으로 까야 했다. 하나를 고치면 다음 문제가 나타나는, 마치 러시안 인형 같은 디버깅이었다.
🐛 이슈 1: "데이터가 안 나와요" — Supabase RLS의 조용한 거절
증상: OG 이미지에 항상 기본값("캠핑 후기", @camper, ❤️ 0)만 표시되고, 실제 게시물 데이터가 전혀 반영되지 않았다.
삽질 과정: 처음에는 이미지 렌더링 문제인 줄 알고 CSS를 뒤집어엎고, Base64 인코딩 방식을 바꾸고, 타임아웃을 늘리고… 온갖 곳을 건드렸다. 하지만 브라우저에서 직접 스크린샷을 찍어보니, 텍스트 자체가 기본값이었다. 이미지 문제가 아니라 데이터 조회 자체가 실패하고 있었던 것이다.
진짜 원인: Supabase의 RLS(Row Level Security) 정책이 anon 키로 saved_campsites 테이블 읽기를 차단하고 있었다. 코드의 catch 블록이 에러를 조용히 삼켜버려서(// fallback only), 실패해도 아무 로그 없이 기본값만 보여줬다.
// ❌ 이전: anon 키 → RLS에 의해 모든 데이터가 빈 배열로 반환
const supabase = createPublicSupabaseClient(); // anon key 사용
// ✅ 수정: service_role 키 → RLS를 우회하여 공개 게시물 조회 가능
const supabase = createClient(supabaseUrl, serviceRoleKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
교훈: catch 블록에서 에러를 삼키지 말자. 최소한 개발 환경에서는 console.error로 찍어두자. 그리고 데이터가 안 나오면 렌더링보다 DB 조회부터 의심하자.
🐛 이슈 2: "이미지 URL은 맞는데 왜 400이야?" — Supabase Image Transformation은 유료
증상: 데이터 조회는 성공했지만(텍스트는 잘 나옴), 배경 이미지만 여전히 초록색이었다.
원인: OG 이미지 용량을 줄이기 위해 Supabase의 Image Transformation API(/storage/v1/render/image/...)를 사용했는데, 이 기능은 Pro 플랜 이상에서만 동작한다. 무료 플랜에서는 HTTP 400을 반환한다.
# 400 Bad Request! 💀
curl -s -o /dev/null -w "%{http_code}" \
"https://xxx.supabase.co/storage/v1/render/image/public/camping-photos/photo.jpg?width=1200"
# 200 OK ✅
curl -s -o /dev/null -w "%{http_code}" \
"https://xxx.supabase.co/storage/v1/object/public/camping-photos/photo.jpg"
해결: Image Transformation API를 포기하고, 원본 URL(/storage/v1/object/public/...)을 직접 fetch하도록 변경했다. 어차피 업로드 시 클라이언트에서 이미 WebP 압축 + 1600px 리사이징을 하고 있었기 때문에, 원본도 충분히 작았다.
교훈: Supabase 무료 플랜의 제약을 미리 파악하자. 공식 문서에 /render/image/가 Pro 전용이라는 게 작게 적혀있었다. 😅
🐛 이슈 3: "왜 이미지가 꽉 안 차지?" — Satori 엔진의 CSS 제약
증상: 배경 이미지가 드디어 나타났지만, 전체 영역(1200×630)을 꽉 채우지 못하고 초록색 여백이 보였다.
원인: Satori(Next.js OG 이미지를 그리는 SVG 렌더 엔진)는 일반 브라우저가 아니다. width: "100%" 같은 상대 단위를 제대로 계산하지 못하는 경우가 있다.
// ❌ Satori에서 100%가 제대로 동작하지 않음
style: { width: "100%", height: "100%" }
// ✅ 명시적 픽셀 값으로 지정
style: { width: "1200px", height: "630px" }
// + <img> 태그에 width={1200} height={630} 속성도 추가
교훈: Satori는 브라우저가 아니다. 지원하는 CSS 속성이 제한적이므로, 가능하면 절대 단위(px)를 사용하고, backgroundImage보다 <img> 태그가 더 안정적이다.
3. 최종 아키텍처 정리
사용자가 링크 공유
↓
/og/feed/[id] 라우트 호출
↓
Supabase (service_role) → saved_campsites 조회
↓
첫 번째 이미지 URL 추출
↓
서버에서 이미지 fetch → ArrayBuffer → Base64 Data URI 변환
↓
React.createElement로 레이아웃 구성
├─ <img> 배경 (1200x630, objectFit: cover)
├─ <div> 어두운 그라데이션 오버레이
└─ <div> 텍스트 (캠핑장명, 내용, 닉네임, 좋아요/댓글)
↓
next/og ImageResponse → PNG 반환
4. 기타 작업 내역
🔗 공유 기능 404 에러 수정
공유 버튼 클릭 시 404가 뜨는 문제가 있었는데, 라우팅 경로 불일치가 원인이었다. 미들웨어(proxy) 설정을 점검하여 해결했다.
🖼️ 캠핑장 상세 페이지 UI 개선
- 히어로 이미지 영역에 뒤로가기 버튼을 오버레이로 배치
- 헤더 겹침(overlap) 이슈를 해결하여 스크롤 시에도 자연스러운 레이아웃 유지
🔐 카카오 로그인 리다이렉트 수정
배포 환경에서 카카오 로그인 후 로컬(localhost)로 리다이렉트되는 문제를 발견하고, Supabase Auth 콜백 URL 설정을 프로덕션 도메인으로 맞춰 해결했다.
5. 배운 것들 (TL;DR)
| 문제 | 원인 | 해결 |
|---|---|---|
| OG 이미지에 기본값만 표시 | Supabase RLS가 anon 키 차단 | service_role 키 사용 |
| 이미지 fetch 시 400 에러 | Image Transformation API는 Pro 전용 | 원본 URL 직접 사용 |
| 이미지가 꽉 안 참 | Satori의 100% CSS 미지원 | 명시적 픽셀값 1200px 사용 |
catch 블록의 Silent Failure | 에러를 삼키는 빈 catch문 | console.error로 로깅 |
🚀 다음 편 예고
- 스토리 기능 실제 데이터 연동
- 검색 기능 고도화 (지역 + 키워드 복합 필터)
- 알림 센터 MVP 구현
- 배포 후 실제 카카오톡/슬랙 공유 테스트
"에러 메시지가 없다고 에러가 없는 게 아니다." 오늘의 가장 큰 교훈이다.
catch블록을 비워두면 개발 시간이 3배로 늘어난다. 🥲
클릭할 때마다 작가에게 따뜻한 응원이 전달됩니다 ❤️
함께 읽으면 좋은 다른 개발일지
'프로젝트' 및 추천 아카이브 글 모음

