Day 3-4: 디테일이 만든 차이 - 48시간의 삽질 기록
김솔
새벽 3시, 같은 버튼을 열 번째 누르고 있었다
이미지를 클릭했다.
Lightbox가 열렸다.
닫았다.
다시 클릭했다.
안 열린다.
"뭐야?"
새로고침. 클릭.
열린다.
닫는다.
다시 클릭.
또 안 열린다.
모니터를 노려봤다. 분명 3시간 전에는 잘 됐는데.
콘솔을 열었다.
클릭 이벤트는 발생한다.
하지만 상태가 안 바뀐다.
왜?
useCallback으로 감싸봤다. 실패.
클린업 함수를 추가했다. 실패.
이벤트 핸들러를 재등록했다. 실패.
시계를 봤다. 새벽 3시 14분.
"제발, 왜 안 돼?"
Day 2까지는 순조로웠다. 핵심 기능의 78%가 완성됐다.
하지만 Day 3-4는 달랐다.
'기능'은 끝났지만, '디테일'의 영역은 이제 막 시작이었다.
Day 3 아침: "뭘 개선하면 좋을까?"
2025년 11월 16일, 토요일 아침.
블로그를 직접 써보니 뭔가 아쉬웠다.
기능은 돌아가는데, '쓰고 싶은' 블로그는 아니었다.
"Claude, 내 블로그 개선할 점이 어떤 게 있어?"
예상은 "여기 버그 있어요" 정도였다.
하지만 Claude는 체계적인 개선 계획을 내놓았다.
"Phase별로 나눠서 진행하면 좋겠어요."
Phase 0: Quick Wins (즉시 효과)
- 타이포그래피 개선
- 카드 호버 효과
- 여백 조정
Phase 1: 핵심 인프라
- 읽기 진행도 바
- 코드 블록 복사 버튼
- TOC 스크롤 동기화
Phase 2: 탐색 강화
- Lightbox 이미지 뷰어
- 관련 글 추천
"이걸 내가 요청한 게 아닌데?"
Claude가 알아서 분석하고, 알아서 우선순위를 정해줬다.
바이브 코딩의 진짜 힘은 이런 거구나 싶었다.
내가 생각 못한 부분까지 AI가 채워준다.
"좋아, Phase 0부터 시작하자."
Phase 0: 작은 변화의 큰 효과
타이포그래피 개선
"글자가 좀 작고 답답해 보여요. 줄 간격도 넓히면 좋겠어요."
.markdown-content p {
@apply text-lg leading-relaxed;
}
.markdown-content h2 {
@apply text-2xl font-bold mt-12 mb-4;
}
이것만 바꿨는데 블로그가 훨씬 읽기 편해졌다.
카드 호버 효과
.blog-card {
@apply transition-all duration-300;
@apply hover:shadow-lg hover:-translate-y-1;
}
마우스를 올리면 카드가 살짝 떠오른다.
별거 아닌 것 같은데, 블로그가 '살아있다'는 느낌이 들었다.
읽기 진행도 바
"글 읽다가 '얼마나 남았지?' 싶을 때 있잖아요. 상단에 진행도 바를 넣으면 어떨까요?"
'use client'
export default function ReadingProgress() {
const [progress, setProgress] = useState(0)
useEffect(() => {
const updateProgress = () => {
const scrollTop = window.scrollY
const docHeight = document.documentElement.scrollHeight - window.innerHeight
setProgress((scrollTop / docHeight) * 100)
}
window.addEventListener('scroll', updateProgress)
return () => window.removeEventListener('scroll', updateProgress)
}, [])
return (
<div
className="fixed top-0 left-0 h-1 bg-blue-600 z-50"
style={{ width: `${progress}%` }}
/>
)
}
20줄도 안 되는 코드.
하지만 "이 글이 30% 남았구나"를 알 수 있다는 것만으로 읽기 경험이 달라졌다.
코드 블록 복사 버튼
개발 블로그에서 코드를 복사하려면 마우스로 드래그해야 한다.
이게 은근히 귀찮다.
"코드 블록에 복사 버튼 넣으면 편하겠죠?"
export default function CopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button onClick={handleCopy} className="absolute top-2 right-2">
{copied ? <Check /> : <Copy />}
</button>
)
}
복사하면 체크 아이콘으로 바뀌고, 2초 후 다시 복사 아이콘으로 돌아온다.
작은 피드백이 사용자를 안심시킨다.
삽질 #1: Lightbox의 배신
Phase 1까지는 순조로웠다. 문제는 Lightbox였다.
"이미지 클릭하면 확대되게 해줘"
Framer Motion으로 구현했다.
이미지를 클릭하면 화면이 어두워지면서 이미지가 커진다.
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/90 z-50"
onClick={() => setIsOpen(false)}
>
<motion.img
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
src={src}
className="max-w-[90%] max-h-[90%]"
/>
</motion.div>
)}
</AnimatePresence>
테스트했다. 완벽하게 작동했다.
"됐다!"
커밋하고 다른 작업으로 넘어갔다.
그런데 문제가 생겼다
저녁에 전체 테스트를 하다가 발견했다.
이미지 클릭. 열림.
닫음. 다시 클릭.
안 열린다.
"어?"
새로고침하면 열린다. 한 번 닫으면 다시 안 열린다.
정확히 같은 코드인데 왜?
3시간의 미궁
여기서부터 삽질이 시작됐다.
"Claude, 왜 Lightbox가 한 번만 열리고 다시 안 열려?"
"useCallback으로 핸들러를 메모이제이션해보세요."
시도 1: useCallback 적용 → 실패
"클린업 함수가 제대로 동작 안 하는 것 같아요. 확인해볼게요."
시도 2: 클린업 함수 추가 → 실패
"혹시 이벤트 핸들러가 제거됐다가 재등록이 안 되는 걸까요?"
시도 3: 이벤트 핸들러 재등록 → 실패
자정이 넘었다. 눈이 따가웠다.
콘솔에 디버그 로그를 찍어봤다.
클릭 이벤트는 발생한다.
하지만 isOpen 상태가 안 바뀐다.
"도대체 뭐가 문제야?"
새벽 3시의 깨달음
포기하려던 순간, 문득 생각이 스쳤다.
"잠깐, 마크다운이 HTML로 변환될 때 이미지 요소가 새로 생성되는 건 아닐까?"
Claude에게 물었다.
"맞아요. 마크다운이 렌더링될 때 DOM이 새로 생성돼요. 기존 이미지에 걸어둔 이벤트 핸들러가 새 이미지에는 연결되지 않은 거예요."
"아..."
원인을 찾았다.
해결: Event Delegation
개별 이미지에 이벤트를 거는 대신, document 전체에 이벤트를 걸고 클릭된 요소가 이미지인지 확인한다.
useEffect(() => {
const handleImageClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (target.tagName === 'IMG' && target.closest('.markdown-content')) {
const img = target as HTMLImageElement
openLightbox(img.src, img.alt)
}
}
document.addEventListener('click', handleImageClick)
return () => document.removeEventListener('click', handleImageClick)
}, [])
테스트.
클릭. 열림.
닫음. 다시 클릭.
열린다.
새벽 3시 47분. 드디어.
d89c227 - fix: Event Delegation으로 Lightbox 재클릭 문제 해결
Day 4: 또 다른 벽
Day 4 아침, Cloudinary 이미지 호스팅을 설정하고 있었다.
목표는 간단했다. 옵시디언에서 이미지를 붙여넣으면 자동으로 Cloudinary에 업로드되게 하는 것.
PicGo라는 도구를 설치했다. 터미널에서 테스트.
picgo upload ~/Desktop/test.png
"[PicGo SUCCESS]"
완벽해.
그런데...
옵시디언 플러그인에 PicGo 경로를 연결했다.
이미지를 붙여넣었다.
Failed request: "Please check PicGo-Core config"
"????"
터미널에서는 되는데 옵시디언에서는 안 된다.
정확히 같은 명령어인데 왜?
5시간의 늪
여기서부터가 진짜 지옥이었다.
경로 문제? → 심볼릭 링크 생성 → 여전히 실패
권한 문제? → 모든 권한 부여 → 여전히 실패
설정 파일? → 수십 번 검증 → 여전히 실패
구글링하다 문득, '설마...' 하는 생각이 스쳤다.
터미널에서 옵시디언을 직접 실행해봤다.
open -a Obsidian
이미지 붙여넣기.
성공.
"뭐야, 이게 뭐야?!"
진짜 범인: 보이지 않는 벽
그 순간 퍼즐이 맞춰졌다.
macOS의 GUI 앱은 터미널과 완전히 다른 세계에 산다.
우리가 터미널을 열 때마다 자동으로 로드되는 ~/.zshrc 파일.
그 안의 nvm 설정.
이 소중한 환경 변수들을 GUI로 실행한 옵시디언은 전혀 물려받지 못한다.
PicGo는 Node.js가 필요한데, GUI 옵시디언은 Node.js가 어디 있는지조차 모른다.
"설정을 확인하라"는 오류는 사실 "Node.js를 찾을 수 없어!" 라는 비명이었다.
해결: 두 세계를 잇는 다리
답은 '래퍼 스크립트'였다.
#!/bin/bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
exec picgo "$@"
단 4줄.
이 작은 스크립트가 터미널 세계와 GUI 세계를 연결해줬다.
옵시디언에 이 스크립트를 연결하자, 모든 것이 마법처럼 작동했다.
"터미널에선 되는데 앱에선 안 된다" = 99% 환경 변수 문제다.
이 한 문장을 처음부터 알았다면 4시간 30분을 아꼈을 것이다.
보안 감사: 충격적인 발견
Day 4 저녁.
기능 구현이 거의 끝나가고 있었다. 문득 보안이 걱정됐다.
"Claude, 내 블로그 보안 감사 좀 해줘."
몇 분 후, 보고서가 나왔다.
# 보안 감사 보고서
## CRITICAL
1. SQL Injection 가능성 (댓글 시스템)
2. XSS 취약점 (마크다운 렌더링)
## HIGH
3. 세션 ID가 API 응답에 노출됨
4. Rate Limiting 부재
"CRITICAL이 2개?"
솔직히 충격이었다.
XSS 방어
마크다운 렌더링 시 악성 스크립트가 실행될 수 있었다.
npm install rehype-sanitize
import rehypeSanitize from 'rehype-sanitize'
// 마크다운 → HTML 변환 파이프라인에 추가
.use(rehypeSanitize)
한 줄 추가로 XSS 방어 완료.
세션 ID 노출 수정
댓글 API 응답에 session_id가 포함되어 있었다.
이걸 악용하면 다른 사람의 댓글을 수정/삭제할 수 있다.
// 수정 전
return {
id: comment.id,
content: comment.content,
session_id: comment.session_id, // ❌ 노출
}
// 수정 후
return {
id: comment.id,
content: comment.content,
// session_id는 서버에서만 사용
}
보안은 나중에 추가할 수 있는 게 아니었다.
처음부터 설계에 녹여야 했다. 하지만 이번에는 Claude 덕분에 배포 전에 잡을 수 있었다.
48시간의 성과
Day 4가 끝날 무렵, 완성된 것들:
UI 개선 (Claude 제안):
- ✅ 타이포그래피 개선
- ✅ 카드 호버 효과
- ✅ 읽기 진행도 바
- ✅ 코드 블록 복사 버튼
- ✅ Lightbox 이미지 뷰어
- ✅ TOC 스크롤 동기화
인프라:
- ✅ Cloudinary 이미지 호스팅
- ✅ PicGo nvm 래퍼 스크립트
- ✅ GitHub Actions CI/CD
보안:
- ✅ XSS 방어 (rehype-sanitize)
- ✅ 세션 ID 노출 수정
- ✅ CSP 헤더 추가
진행률: 78.4% → 95%
배운 것들
1. AI는 내가 생각 못한 것도 제안한다
타이포그래피, 호버 효과, 진행도 바, 복사 버튼, Lightbox.
이 모든 게 "블로그 개선할 점 있어?"라는 한 마디에서 시작됐다.
바이브 코딩의 진짜 힘은 '대화'에 있다.
2. Event Delegation을 기억하자
동적으로 생성된 요소에 이벤트를 걸어야 할 때.
개별 요소가 아니라 부모에 이벤트를 걸고, 클릭된 요소를 확인한다.
이 패턴 하나로 많은 버그를 예방할 수 있다.
3. "터미널에선 되는데" = 환경 변수
GUI 앱은 쉘 설정을 읽지 못한다.
래퍼 스크립트나 launchctl로 환경 변수를 설정해야 한다.
4. 보안은 처음부터
"나중에 추가하지 뭐"는 위험한 생각이다.
보안 취약점은 구조적인 문제인 경우가 많다.
나중에 고치려면 전체를 다시 짜야 할 수도 있다.
5. 사용자 경험은 디테일의 누적이다
진행도 바, 복사 버튼, 호버 효과.
하나하나는 별거 아닌 것 같다.
하지만 이것들이 모이면 "쓰고 싶은 블로그" 가 된다.
기능은 48시간 만에 완성했지만, '프로덕션 레벨'이 되기까지는 디테일이 필요했다.
다음 편 예고
Day 5. 마지막 날.
- 뉴스레터 시스템 완성
- Mailgun → Resend 마이그레이션
- 자동 환영 이메일
- 그리고 드디어... 런칭
5일간의 여정이 마무리된다.
관련 링크
- 1편: 코딩 모르는 의사가 5일 만에 블로그를 만든 이유
- 2편: Day 1-2: 첫 삽질의 기록
- 4편: Day 5: 런칭, 그리고 배운 것들 (준비 중)
- macOS PATH 트러블슈팅 상세: 터미널에선 되는데 앱에선 왜 안돼
이 글이 도움이 되었나요?
관련 글

"터미널에선 되는데 앱에선 왜 안돼" - 6시간 삽질 끝에 찾은 해답
PicGo 앱의 Unexpected field 오류부터 PicGo-Core의 PATH 문제까지, Obsidian에서 Cloudinary 이미지 자동 업로드를 구현하며 겪은 연쇄적인 문제 해결 과정을 공유합니다.

Day 1-2: 첫 삽질의 기록 - 옵시디언에서 웹사이트까지
프로젝트 초기화부터 첫 배포까지, 48시간 동안 일어난 일들. 위키링크 변환 삽질, 댓글 시스템 설계, 그리고 예상치 못한 Date 객체의 배신

코딩 모르는 의사가 5일 만에 블로그를 만든 이유
206개의 커밋, 66개의 PR, 5일간의 몰입. 네이버 블로그 셋방살이를 청산하고 나만의 집을 지은 이야기