<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>lova-clover.log</title>
        <link>https://velog.io/</link>
        <description>프로젝트 및 해커톤 활동하는 오리</description>
        <lastBuildDate>Fri, 20 Mar 2026 21:30:14 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>lova-clover.log</title>
            <url>https://velog.velcdn.com/images/lova-clover/profile/08d20bbe-ef14-4699-ba93-313eaaa6cceb/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. lova-clover.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/lova-clover" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[날짜 밀릴 때마다 다시 고치기 귀찮아서, 나만의 D-DAY 메모 앱을 만들었다]]></title>
            <link>https://velog.io/@lova-clover/%EB%82%A0%EC%A7%9C-%EB%B0%80%EB%A6%B4-%EB%95%8C%EB%A7%88%EB%8B%A4-%EB%8B%A4%EC%8B%9C-%EA%B3%A0%EC%B9%98%EA%B8%B0-%EA%B7%80%EC%B0%AE%EC%95%84%EC%84%9C-%EB%82%98%EB%A7%8C%EC%9D%98-D-DAY-%EB%A9%94%EB%AA%A8-%EC%95%B1%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@lova-clover/%EB%82%A0%EC%A7%9C-%EB%B0%80%EB%A6%B4-%EB%95%8C%EB%A7%88%EB%8B%A4-%EB%8B%A4%EC%8B%9C-%EA%B3%A0%EC%B9%98%EA%B8%B0-%EA%B7%80%EC%B0%AE%EC%95%84%EC%84%9C-%EB%82%98%EB%A7%8C%EC%9D%98-D-DAY-%EB%A9%94%EB%AA%A8-%EC%95%B1%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8B%A4</guid>
            <pubDate>Fri, 20 Mar 2026 21:30:14 GMT</pubDate>
            <description><![CDATA[<p>해야 할 일을 적어두는 건 어렵지 않다.<br>문제는 그다음이었다.</p>
<p>“이번 주 안에 해야지” 하고 날짜까지 적어둔 일이 꼭 그 안에 끝나는 건 아니었다.<br>하루 밀리고, 이틀 밀리고, 그러다 보면 그 항목은 금방 지난 일정이 되어버린다.</p>
<p>그러면 또 날짜를 다시 고쳐야 한다.<br>한두 번이면 괜찮지만, 반복되면 점점 번거로워진다.</p>
<p>정작 해야 할 일은 그대로인데,<br>나는 앱에서 <strong>날짜 수정만 계속 하고 있었다.</strong></p>
<p>이게 생각보다 꽤 귀찮았다.</p>
<p>그래서 어느 순간 이런 생각을 했다.</p>
<p><strong>모든 메모에 억지로 날짜를 붙이지 말고,<br>평소 적어두는 메모와 날짜가 있는 일정만 분리해서 같이 다루면 되는 거 아닌가?</strong></p>
<p>이번 프로젝트는 그 생각에서 시작했다.</p>
<hr>
<h2 id="내가-원했던-건-예쁜-d-day-앱이-아니라-덜-귀찮은-정리-방식이었다">내가 원했던 건 “예쁜 D-DAY 앱”이 아니라, 덜 귀찮은 정리 방식이었다</h2>
<p>처음에는 그냥 감성 있는 D-DAY 메모 앱을 떠올렸다.<br>귀엽고, 가볍고, 휴대폰에서도 자주 열게 되는 그런 것.</p>
<p>그런데 만들다 보니 내가 정말 원했던 건 조금 달랐다.</p>
<p>내가 필요했던 건 이런 구조였다.</p>
<ul>
<li>날짜가 없는 평소 메모는 위에 계속 남아 있을 것</li>
<li>특정 날짜가 있는 일정은 따로 관리할 것</li>
<li>날짜가 된 일정은 그날 눈에 잘 띄게 올라올 것</li>
<li>일이 밀려도 전체 흐름이 덜 귀찮을 것</li>
</ul>
<p>그러니까 이건 흔한 기념일 앱이나 일정 앱이라기보다,<br><strong>메모와 일정 관리 사이 어딘가에 있는 도구</strong>에 가까웠다.</p>
<p>보통 메모 앱은 자유롭지만 날짜 흐름이 약하고,<br>할 일 앱은 날짜 관리는 되는데 자유 메모가 답답하다.</p>
<p>나는 그 중간이 필요했다.</p>
<hr>
<h2 id="그래서-제일-가벼운-방식으로-시작했다">그래서 제일 가벼운 방식으로 시작했다</h2>
<p>이 프로젝트를 처음부터 크게 만들 생각은 없었다.</p>
<p>로그인 붙이고, DB 만들고, 서버 세우고, 동기화까지 넣으면 물론 더 그럴듯해진다.<br>그런데 솔직히 말하면 이 프로젝트에는 그게 좀 과했다.<br>혼자 오래 쓰는 도구인데, 시작부터 무겁게 갈 이유가 없었다.</p>
<p>그래서 가장 단순한 방식으로 시작했다.</p>
<p><strong>단일 HTML 파일 + 바닐라 JavaScript + LocalStorage</strong></p>
<p>이 조합의 장점은 분명했다.</p>
<ul>
<li>만들기 빠르다</li>
<li>수정하기 쉽다</li>
<li>배포가 간단하다</li>
<li>개인용 도구로 쓰기에 충분하다</li>
</ul>
<p>생각보다 이런 조합이 주는 속도가 크다.<br>토이 프로젝트는 괜히 덩치를 키우는 순간 재미도 같이 사라진다.</p>
<p>처음 만든 버전은 솔직히 조금 투박했다.<br>그래도 필요한 로직은 이미 들어 있었다.</p>
<ul>
<li>메모 생성 / 수정 / 삭제</li>
<li>날짜 없는 메모와 날짜 있는 일정 분리</li>
<li>D-DAY 계산</li>
<li>오늘이 된 일정 상단 노출</li>
<li>로컬 저장</li>
</ul>
<p>한마디로 하면,<br><strong>예쁘진 않아도 쓸 수는 있는 상태</strong>였다.</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/bc92cbe0-bf22-4a3a-a00b-4ac867a757b7/image.jpeg" alt="모바일 메인 화면"></p>
<p>최종적으로는 이런 모바일 화면으로 정리했다. 날짜 없는 메모와 날짜 있는 일정을 함께 다루는 흐름이 이 화면에 담겨 있다.</p>
<hr>
<h2 id="그런데-쓸-수-있음과-계속-쓰고-싶음은-다르더라">그런데 “쓸 수 있음”과 “계속 쓰고 싶음”은 다르더라</h2>
<p>기능은 돌아갔다.<br>하지만 화면을 오래 보고 있으면 자꾸 마음에 걸렸다.</p>
<p>분명 필요한 건 다 들어 있는데,<br>완성된 도구라기보다는 <strong>만들다 만 실험작</strong>처럼 보였기 때문이다.</p>
<p>대표적으로 이런 문제가 있었다.</p>
<ul>
<li>모바일에서 쓰기엔 hover 중심 요소가 많았다</li>
<li>버튼 동작이 직관적이지 않은 곳이 있었다</li>
<li>귀여운 분위기를 내고 싶어 했지만 마감이 약했다</li>
<li>폰트와 여백, 위계가 전체적으로 정리되지 않았다</li>
<li>결과적으로 “제품”보다 “프로토타입”처럼 보였다</li>
</ul>
<p>딱 그 상태였다.<br>하려는 말은 알겠는데, 아직 설득력이 없는 화면.</p>
<p>그래서 한 번 크게 갈아엎었다.</p>
<hr>
<h2 id="한-번은-더-그럴듯하게-만들었는데-오히려-별로였다">한 번은 더 그럴듯하게 만들었는데, 오히려 별로였다</h2>
<p>중간에는 더 “제품처럼” 보이게 만들고 싶어서 구조를 크게 바꾼 적이 있었다.</p>
<ul>
<li>데스크톱 중심 보드</li>
<li>검색과 필터</li>
<li>카드 섹션</li>
<li>요약 패널</li>
<li>더 정돈된 UI 구조</li>
</ul>
<p>기술적으로는 분명 더 나아졌다.<br>코드 구조도 정리됐고, 화면도 커졌고, 겉보기에는 훨씬 그럴듯해졌다.</p>
<p>그런데 이상하게 손이 잘 안 갔다.</p>
<p>이유는 단순했다.<br>그 시점부터는 내가 처음 원했던 가볍고 직관적인 느낌보다,<br><strong>“잘 만든 것처럼 보이는 화면”</strong> 쪽으로 더 가고 있었기 때문이다.</p>
<p>그때 꽤 확실하게 느꼈다.</p>
<p><strong>새로 멋있게 만드는 것과, 실제로 쓰고 싶은 도구를 만드는 건 다르다.</strong></p>
<p>이 프로젝트에서 중요한 건 화려한 구조가 아니라,<br>내가 날짜를 덜 고치게 만드는 흐름이었다.</p>
<p>그래서 다시 방향을 틀었다.</p>
<hr>
<h2 id="복잡하게-키우는-대신-잘-맞던-흐름을-중심으로-다시-잡았다">복잡하게 키우는 대신, 잘 맞던 흐름을 중심으로 다시 잡았다</h2>
<p>결국 원점으로 돌아갔다.<br>정확히는, 처음 만든 핵심 흐름으로 돌아갔다.</p>
<ul>
<li>빠르게 적을 수 있어야 하고</li>
<li>날짜 없는 메모와 날짜 있는 일정이 자연스럽게 나뉘어야 하고</li>
<li>당일 일정은 눈에 띄어야 하고</li>
<li>밀린 일정도 다시 손보는 부담이 덜해야 한다</li>
</ul>
<p>그 기준으로 불필요한 걸 덜어냈다.</p>
<p>이 과정에서 크게 느낀 건 하나였다.</p>
<p><strong>새로 잘 만드는 것보다, 이미 잘 맞는 걸 정확히 살리는 게 더 어렵고 더 중요하다.</strong></p>
<hr>
<h2 id="웹과-모바일도-같은-화면으로-억지로-묶지-않았다">웹과 모바일도 같은 화면으로 억지로 묶지 않았다</h2>
<p>처음에는 웹과 모바일을 하나의 화면으로 다 해결하고 싶었다.<br>그게 더 효율적일 것 같았다.</p>
<p>그런데 실제로 써보니 웹에서 보기 좋은 화면과 휴대폰에서 자주 열어보는 화면은 분명히 달랐다.</p>
<p>그래서 역할을 나눴다.</p>
<ul>
<li><strong>모바일</strong>: 빠르게 추가하고, 바로 확인하는 흐름</li>
<li><strong>웹</strong>: 좀 더 넓게 보고, 길게 적고, 정리하는 흐름</li>
</ul>
<p>이렇게 분리하니까 훨씬 나아졌다.</p>
<p>괜히 하나의 화면으로 모든 걸 해결하려고 하면<br>결국 어디에서나 조금씩 어색해진다.<br>이 프로젝트는 그걸 꽤 분명하게 보여줬다.</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/e2e53461-3af6-4def-bd05-fe4e15c103ec/image.jpeg" alt="데스크톱 웹 보드"></p>
<p>모바일 원본을 유지한 채, PC에서는 더 넓게 입력하고 정리할 수 있도록 웹 보드를 따로 만들었다.</p>
<hr>
<h2 id="pwa로-끝날-줄-알았는데-생각보다-함정이-있었다">PWA로 끝날 줄 알았는데, 생각보다 함정이 있었다</h2>
<p>모바일에서도 앱처럼 쓰고 싶어서 처음에는 PWA로 만들었다.</p>
<p>브라우저에서 열고, 홈 화면에 추가해서, 아이콘을 눌러 바로 들어가는 흐름 자체는 꽤 괜찮았다.<br>겉으로 보기엔 거의 앱 같았다.</p>
<p>그런데 여기서 꽤 현실적인 문제를 만났다.</p>
<p><strong>브라우저에서 쓰던 LocalStorage 데이터가,<br>설치된 앱 화면에서 그대로 이어지지 않는 경우가 있었다.</strong></p>
<p>겉보기엔 같은 서비스인데,<br>실제로는 저장소 단위가 다르게 동작할 수 있었다.</p>
<p>이건 프론트엔드에서 한 번쯤 밟게 되는 문제인데,<br>직접 겪고 나니 더 선명하게 기억에 남았다.</p>
<p>그때 알게 됐다.</p>
<p>PWA는 분명 훌륭하지만,<br>내가 원한 건 “앱처럼 보이는 웹”이 아니라 <strong>진짜로 오래 쓰는 앱</strong>에 가까웠다.</p>
<hr>
<h2 id="자동-동기화-대신-내보내기가져오기로-정리했다">자동 동기화 대신, 내보내기/가져오기로 정리했다</h2>
<p>여기서 선택지가 있었다.</p>
<ol>
<li>백엔드 붙이고, DB 만들고, 로그인까지 넣어서 자동 동기화</li>
<li>지금 프로젝트에 맞는 수준으로 해결</li>
</ol>
<p>나는 두 번째를 골랐다.</p>
<p>이 프로젝트는 커질수록 장점이 죽는다.<br>그래서 자동 동기화 대신 <strong>내보내기 / 가져오기</strong> 기능을 넣었다.</p>
<p>로컬에 저장된 JSON 데이터를 내보내고,<br>다른 기기에서 다시 가져오는 방식이다.</p>
<p>흐름은 아주 단순하다.</p>
<ul>
<li>PC에서 메모 정리</li>
<li>데이터 내보내기</li>
<li>파일이나 메신저로 옮기기</li>
<li>모바일에서 가져오기</li>
</ul>
<p>완전 자동은 아니다.<br>하지만 이 프로젝트엔 그 정도가 오히려 잘 맞았다.</p>
<p>괜히 무거운 구조를 붙이지 않고도,<br>기기 간 이동은 충분히 가능해졌다.</p>
<p>개인용 도구는 가끔<br>“가장 멋진 해결책”보다 <strong>가장 덜 귀찮은 해결책</strong>이 더 좋다.</p>
<hr>
<h2 id="쓰다-보니-반복-일정도-필요했고-작은-디테일이-더-중요해졌다">쓰다 보니 반복 일정도 필요했고, 작은 디테일이 더 중요해졌다</h2>
<p>처음에는 메모와 날짜 관리 정도면 충분할 줄 알았다.<br>그런데 실제로 써보니 반복 일정도 은근 자주 필요했다.</p>
<p>예를 들면 생일처럼 매년 돌아오는 일정이나, 정기 미팅처럼 매주 반복되는 일정들.</p>
<p>그래서 반복 일정 처리도 넣고,<br>모바일에서는 날짜를 더 편하게 고를 수 있도록 다이얼 형태의 선택 UI도 만들었다.</p>
<p>여기서 또 하나 느낀 게 있다.</p>
<p><strong>완성도는 큰 기능에서 갈리지 않는다.<br>대부분은 작은 어색함에서 갈린다.</strong></p>
<p>날짜 다이얼도 그랬다.<br>몇 픽셀만 어긋나 있어도 바로 티가 났다.<br>선택 영역과 숫자 정렬이 미묘하게 안 맞으면 전체 앱이 허술해 보였다.</p>
<p>결국 마지막 손맛은 거창한 기능이 아니라,<br>이런 디테일에서 나온다.</p>
<hr>
<h2 id="나중에는-그냥-앱처럼-보이는-웹이-아니라-아예-안드로이드-앱으로도-만들었다">나중에는 그냥 “앱처럼 보이는 웹”이 아니라, 아예 안드로이드 앱으로도 만들었다</h2>
<p>처음에는 웹으로 충분할 줄 알았다.<br>그런데 계속 만지다 보니 자연스럽게 욕심이 생겼다.</p>
<p>“이왕 여기까지 왔는데,<br>그냥 설치형 앱으로도 써보면 어떨까?”</p>
<p>그래서 결국 안드로이드 APK까지 만들었다.</p>
<p>이때부터는 완전히 다른 문제가 열렸다.</p>
<ul>
<li>Android SDK 설치</li>
<li>빌드 도구 설정</li>
<li>JDK 버전 충돌</li>
<li>아이콘과 스플래시 정리</li>
<li>safe area 대응</li>
<li>시스템 바와 여백 처리</li>
</ul>
<p>웹에서는 그냥 넘어가던 것들이 앱에서는 바로 티가 났다.</p>
<p>특히 safe area 같은 건 정말 그랬다.<br>웹에선 괜찮아 보여도 앱에서는 상태바, 하단 제스처 영역을 무시하는 순간 바로 완성도가 떨어진다.</p>
<p>이 과정은 꽤 재밌었다.<br>처음엔 단순한 개인용 웹이었는데,<br>결국 앱으로까지 오면서 생각보다 많은 현실 문제를 직접 밟아봤다.</p>
<hr>
<h2 id="가장-현실적이었던-장애물은-의외로-환경이었다">가장 현실적이었던 장애물은 의외로 “환경”이었다</h2>
<p>앱으로 가면서 가장 크게 막혔던 건 로직보다 환경이었다.</p>
<p>Android SDK가 없어서 다시 설치해야 했고,<br>설치 경로는 문서와 실제 상태가 조금 달랐고,<br>빌드가 되나 싶더니 JDK 버전이 맞지 않아 또 막혔다.</p>
<p>사실 이런 부분은 글로 보면 한 줄인데,<br>직접 하다 보면 꽤 시간을 잡아먹는다.</p>
<p>그런데 신기하게도 이런 과정이 지나고 나면<br>프로젝트가 내 손에 더 익는 느낌이 있다.</p>
<p>기능을 만든다는 건 결국 코드만 짜는 일이 아니라,<br>그 기능이 돌아가는 환경까지 같이 이해하는 일이기도 하다는 걸 다시 느꼈다.</p>
<hr>
<h2 id="결국-이건-대단한-생산성-서비스가-아니다">결국 이건 “대단한 생산성 서비스”가 아니다</h2>
<p>돌아보면 이 프로젝트는 거창한 플랫폼이 아니다.</p>
<p>협업 기능도 없다.<br>계정 시스템도 없다.<br>복잡한 캘린더도 없다.<br>엄청난 자동화도 없다.</p>
<p>대신 이런 건 있다.</p>
<ul>
<li>평소 메모를 위에 붙여두는 흐름</li>
<li>날짜 있는 일정은 따로 관리하는 구조</li>
<li>일이 밀려도 덜 귀찮게 다루는 방식</li>
<li>필요할 만큼만 가볍게 만든 설계</li>
</ul>
<p>즉, 이건 시장을 뒤집을 서비스가 아니라<br><strong>내가 실제로 불편했던 걸 줄이기 위해 만든 도구</strong>다.</p>
<p>그런데 오히려 그래서 만족도가 높다.</p>
<p>큰 프로젝트를 만들 때는<br>멋있고 커 보이는 방향으로 자꾸 끌려가기 쉽다.<br>반면 이런 작은 도구는<br>불편 하나를 정확히 해결했는지가 훨씬 중요하다.</p>
<p>이 프로젝트는 그 점에서 꽤 솔직한 결과물이다.</p>
<hr>
<h2 id="이번에-다시-느낀-것들">이번에 다시 느낀 것들</h2>
<p>이번 작업을 하면서 다시 확인한 것들이 있다.</p>
<ul>
<li>예쁜 화면과 잘 쓰이는 화면은 다르다</li>
<li>새로 만드는 것보다, 잘 맞던 흐름을 살리는 게 더 낫다</li>
<li>반응형만 한다고 모바일 UX가 해결되진 않는다</li>
<li>PWA와 설치형 앱은 사용 기대치가 다르다</li>
<li>완성도는 기능 수보다 어색한 5px에서 갈린다</li>
<li>작은 개인 프로젝트일수록 기술보다 방향이 더 중요하다</li>
</ul>
<p>이런 건 직접 만들어보지 않으면 잘 안 남는다.<br>이번엔 꽤 오래 남을 것 같다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>처음에는 그냥<br>“내가 원하는 D-DAY 메모 앱 하나 만들어볼까?”<br>정도였다.</p>
<p>그런데 만들다 보니 생각보다 멀리 왔다.</p>
<p>웹으로 시작했고,<br>모바일 흐름을 다듬었고,<br>PWA의 한계를 밟았고,<br>내보내기/가져오기로 타협했고,<br>결국 안드로이드 앱까지 만들었다.</p>
<p>그 과정에서 제일 좋았던 건<br>큰 기술을 썼다는 사실이 아니라,<br><strong>내가 실제로 귀찮아하던 흐름을 직접 줄였다는 점</strong>이었다.</p>
<p>아마 앞으로도 이 앱은 완성형으로 끝나지 않을 거다.<br>필요할 때마다 조금씩 고치고,<br>내 습관에 맞게 바뀌고,<br>그러면서 계속 살아 있는 도구가 될 것 같다.</p>
<p>그 정도면 충분하다.</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/9633315f-1373-4c4a-a457-5e5b2961eba7/image.jpeg" alt="앱 다운로드 페이지"></p>
<p>최종적으로는 안드로이드 앱을 바로 내려받을 수 있는 다운로드 페이지까지 따로 붙였다.</p>
<hr>
<h2 id="링크">링크</h2>
<ul>
<li>웹 버전: <a href="https://my-dday-memo.vercel.app/web">https://my-dday-memo.vercel.app/web</a></li>
<li>모바일 버전: <a href="https://my-dday-memo.vercel.app/dday-v3.html">https://my-dday-memo.vercel.app/dday-v3.html</a></li>
<li>Android App 다운로드 페이지: <a href="https://my-dday-memo.vercel.app/app">https://my-dday-memo.vercel.app/app</a></li>
<li>GitHub: <a href="https://github.com/Lova-clover/My-Dday-Memo">https://github.com/Lova-clover/My-Dday-Memo</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[SiteGuard 개발기: URL 하나로 웹사이트 보안을 점검하는 도구를 만들며 배운 것]]></title>
            <link>https://velog.io/@lova-clover/SiteGuard-%EA%B0%9C%EB%B0%9C%EA%B8%B0-URL-%ED%95%98%EB%82%98%EB%A1%9C-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8-%EB%B3%B4%EC%95%88%EC%9D%84-%EC%A0%90%EA%B2%80%ED%95%98%EB%8A%94-%EB%8F%84%EA%B5%AC%EB%A5%BC-%EB%A7%8C%EB%93%A4%EB%A9%B0-%EB%B0%B0%EC%9A%B4-%EA%B2%83</link>
            <guid>https://velog.io/@lova-clover/SiteGuard-%EA%B0%9C%EB%B0%9C%EA%B8%B0-URL-%ED%95%98%EB%82%98%EB%A1%9C-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8-%EB%B3%B4%EC%95%88%EC%9D%84-%EC%A0%90%EA%B2%80%ED%95%98%EB%8A%94-%EB%8F%84%EA%B5%AC%EB%A5%BC-%EB%A7%8C%EB%93%A4%EB%A9%B0-%EB%B0%B0%EC%9A%B4-%EA%B2%83</guid>
            <pubDate>Thu, 19 Mar 2026 23:42:40 GMT</pubDate>
            <description><![CDATA[<h2 id="빠르게-만드는-시대일수록-기본-보안-점검이-더-중요해졌다">빠르게 만드는 시대일수록, 기본 보안 점검이 더 중요해졌다</h2>
<p>바이브코딩이 보편화되면서 서비스는 더 빨리 만들어지지만,<br>그만큼 기본 보안 설정을 충분히 점검하지 못한 채 배포되는 경우도 많아졌다고 느꼈다.</p>
<p>HTTPS는 제대로 강제되는지, 보안 헤더는 빠진 게 없는지, 쿠키 속성은 안전한지처럼<br>외부에서 바로 확인할 수 있는 항목들을 빠르게 점검해 보고 싶어서 <code>SiteGuard</code>를 만들었다.</p>
<ul>
<li>Demo: <a href="https://siteguard-mauve.vercel.app/">https://siteguard-mauve.vercel.app/</a></li>
<li>GitHub: <a href="https://github.com/Lova-clover/SiteGuard">https://github.com/Lova-clover/SiteGuard</a></li>
</ul>
<p>처음에는 비교적 단순하게 접근했다.<br>HTTPS가 되는지, 보안 헤더가 있는지, 쿠키 속성이 어떤지, TLS 인증서가 정상인지 정도를 읽어서 점수와 등급으로 보여주면 충분히 의미 있는 도구가 될 거라고 생각했다.</p>
<p>초기 버전은 겉으로 보기엔 꽤 괜찮았다.<br>점수도 나오고, 문제 목록도 정리됐고, UI도 어느 정도 형태를 갖췄다.</p>
<p>그런데 실제 사이트를 몇 개 넣어보면서 생각이 많이 바뀌었다.<br>기능이 돌아가는 것과 결과를 신뢰할 수 있는 것은 전혀 다른 문제였다.</p>
<p>이 글은 SiteGuard를 만들면서 겪은 시행착오와, 그 과정에서 정리하게 된 기준들에 대한 기록이다.</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/8df9f2f2-575c-4f7d-ab01-2a6b8598b758/image.png" alt="SiteGuard 메인 화면"></p>
<p>공개 URL 하나를 입력해 바로 보안 상태를 점검할 수 있도록 구성했다.</p>
<hr>
<h2 id="왜-이런-도구를-만들었나">왜 이런 도구를 만들었나</h2>
<p>요즘은 서비스를 정말 빠르게 만들 수 있다.</p>
<p>AI가 초안을 잡아주고, 배포도 쉬워졌고, 기능 구현 자체는 예전보다 훨씬 빨라졌다.<br>문제는 그런 속도와 별개로, 기본적인 보안 설정은 여전히 자주 빠진다는 점이다.</p>
<p>실제로 배포 직전이나 배포 직후에 자주 보이는 것들은 생각보다 단순하다.</p>
<ul>
<li>HTTP에서 HTTPS로 강제되지 않는 진입점</li>
<li>빠져 있는 HSTS, CSP, Referrer-Policy</li>
<li><code>Secure</code>, <code>HttpOnly</code>, <code>SameSite</code>가 빠진 쿠키</li>
<li>서버나 프레임워크 정보가 그대로 드러나는 응답 헤더</li>
<li>HTTPS 페이지 안에 남아 있는 HTTP 리소스</li>
</ul>
<p>이런 항목들은 적극적인 공격을 하지 않아도 외부에서 어느 정도 확인할 수 있다.<br>그래서 SiteGuard의 목표도 처음부터 명확했다.</p>
<p><strong>공개 URL 기준으로, 외부에서 보이는 기본 보안 상태를 빠르게 점검하는 것.</strong></p>
<p>이 프로젝트는 모든 취약점을 찾아내는 도구를 지향하지 않는다.<br>그보다는 배포 전에 한 번쯤 확인했어야 할 기본 설정들을 빠르게 훑어보는 1차 점검 도구에 가깝다.</p>
<hr>
<h2 id="처음엔-ui가-문제라고-생각했다">처음엔 UI가 문제라고 생각했다</h2>
<p>초기 결과 화면은 정보가 많았지만, 실제로 써보면 결론이 약했다.<br>점수와 카드, 설명은 많은데 사용자가 가장 먼저 궁금한 질문에 바로 답하지 못했다.</p>
<p>대부분 이런 것부터 알고 싶다.</p>
<ol>
<li>지금 위험한 상태인가  </li>
<li>무엇을 먼저 고쳐야 하나</li>
</ol>
<p>그런데 초기 버전은 설명은 길고, 행동 우선순위는 상대적으로 흐렸다.<br>그래서 결과 화면 구조를 다시 정리했다.</p>
<ul>
<li>현재 상태</li>
<li>지금 해야 할 일</li>
<li>상태 요약</li>
<li>문제 목록</li>
<li>세부 진단</li>
<li>근거</li>
</ul>
<p>이렇게 <code>판단 -&gt; 행동 -&gt; 근거</code> 순서로 바꾸고 나니 화면 자체는 훨씬 좋아졌다.</p>
<p>그런데 구조를 정리하고 보니 더 근본적인 문제가 눈에 들어왔다.<br>실제 문제는 UI보다 판정 로직 쪽에 더 가까웠다.</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/9d930cab-7737-473f-8bb3-103d3a4d31ed/image.png" alt="SiteGuard 결과 화면"></p>
<p>결과 화면은 점수보다 현재 상태, 우선순위, 문제 목록이 먼저 보이도록 다시 정리했다.</p>
<hr>
<h2 id="가장-큰-문제는-탐지가-아니라-해석이었다">가장 큰 문제는 탐지가 아니라 해석이었다</h2>
<p>초기 SiteGuard는 헤더, TLS, 리다이렉트, 쿠키, mixed content 같은 항목을 꽤 잘 잡았다.<br>발견 자체가 문제는 아니었다.</p>
<p>문제는 그 다음이었다.</p>
<p>예를 들어 다음 같은 항목들이 발견됐다고 해보자.</p>
<ul>
<li>CSP 없음</li>
<li>HSTS 없음</li>
<li>Referrer-Policy 없음</li>
<li>일부 쿠키 속성 부족</li>
<li>Permissions-Policy 없음</li>
</ul>
<p>초기 모델은 이런 항목들을 거의 비슷한 톤으로 다뤘다.<br>그러다 보니 결과가 쉽게 과해졌다.</p>
<p>실제로 테스트해 보면, 비교적 잘 운영되는 공개 사이트도 지나치게 낮은 점수와 높은 위험도로 나오는 경우가 있었다.<br>그 결과를 보고 나서야 이 프로젝트의 핵심 문제가 분명해졌다.</p>
<p>SiteGuard는 무언가를 발견하고 있었지만,<br>그 발견을 어떻게 해석해야 하는지가 아직 거칠었다.</p>
<p>보안 도구는 아무것도 못 찾는 것도 문제지만, 반대로 모든 걸 다 위험하다고 말해도 신뢰를 잃는다.<br>결과를 보는 사람이 “이 도구는 너무 과하게 말한다”고 느끼는 순간, 나머지 결과도 같이 의심받게 된다.</p>
<p>그때부터 SiteGuard의 중심 과제는 기능 추가가 아니라 <strong>리스크 모델을 다시 설계하는 것</strong>이 됐다.</p>
<hr>
<h2 id="모든-문제를-같은-톤으로-말하면-안-됐다">모든 문제를 같은 톤으로 말하면 안 됐다</h2>
<p>점수 모델을 손보면서 가장 먼저 한 일은, 발견 항목을 성격에 따라 나누는 일이었다.</p>
<p>크게 세 가지로 정리했다.</p>
<h3 id="1-직접-위험-direct">1. 직접 위험 (<code>direct</code>)</h3>
<p>실제 사용자나 브라우저 관점에서 직접적인 위험에 가까운 문제들이다.</p>
<p>예를 들면:</p>
<ul>
<li>만료된 TLS 인증서</li>
<li>self-signed 인증서</li>
<li>mixed content</li>
<li>안전하지 않은 로그인 폼 전송</li>
<li>credentials를 포함한 과도한 CORS 설정</li>
</ul>
<p>이런 건 단순한 권장 사항이 아니라, 실제 운영 상태에 직접 영향을 줄 수 있는 항목이다.</p>
<h3 id="2-하드닝-hardening">2. 하드닝 (<code>hardening</code>)</h3>
<p>서비스가 당장 깨지는 건 아니지만, 방어선이 충분하지 않은 상태다.</p>
<p>예를 들면:</p>
<ul>
<li>CSP 없음</li>
<li>HSTS 없음</li>
<li>클릭재킹 방어 없음</li>
<li><code>nosniff</code> 없음</li>
<li>Referrer-Policy 없음</li>
<li>일부 쿠키 속성 부족</li>
</ul>
<p>이건 분명 중요한 문제다.<br>다만 이 모든 항목을 똑같이 <code>High</code>처럼 처리하면 결과가 금방 과격해진다.</p>
<h3 id="3-운영-성숙도-maturity">3. 운영 성숙도 (<code>maturity</code>)</h3>
<p>즉시 위험이라기보다 운영 성숙도와 신뢰에 가까운 항목들이다.</p>
<p>예를 들면:</p>
<ul>
<li><code>security.txt</code> 없음</li>
<li>Permissions-Policy 없음</li>
<li>기술 스택 정보 노출</li>
</ul>
<p>이런 항목은 분명 의미가 있지만, 직접 위험과 같은 무게로 다루는 건 적절하지 않았다.</p>
<p>이 구분을 넣은 뒤부터 결과가 훨씬 덜 거칠어졌다.<br>사용자 입장에서도 “이건 지금 위험한 문제인지, 아니면 하드닝 차원에서 볼 문제인지”를 더 쉽게 받아들일 수 있게 됐다.</p>
<hr>
<h2 id="쿠키는-생각보다-더-문맥적으로-봐야-했다">쿠키는 생각보다 더 문맥적으로 봐야 했다</h2>
<p>초기에는 쿠키를 단순하게 봤다.<br><code>Secure</code>, <code>HttpOnly</code>, <code>SameSite</code> 중 하나라도 빠지면 크게 감점하는 방식이었다.</p>
<p>그런데 실제 서비스들을 보니 이 접근은 너무 단순했다.</p>
<p>모든 쿠키가 세션 쿠키는 아니고, 모든 쿠키가 같은 민감도를 갖는 것도 아니었다.<br>일부는 추적용이고, 일부는 실험용이며, 일부는 인증과 직접 관련 없는 상태 저장용일 수도 있다.</p>
<p>그래서 쿠키 쪽은 조금 더 문맥적으로 보기 시작했다.</p>
<ul>
<li><code>session</code>, <code>auth</code>, <code>jwt</code>, <code>token</code> 같은 민감한 이름 패턴이 있는가</li>
<li><code>__Host-</code>, <code>__Secure-</code> 같은 접두사가 있는가</li>
<li>빠진 속성이 실제로 직접 위험과 가까운가</li>
</ul>
<p>이 과정을 거치고 나니 쿠키 관련 결과가 훨씬 안정됐다.<br>예전처럼 “쿠키 속성 하나 부족 = 바로 high” 같은 식의 거친 결과는 많이 줄었다.</p>
<hr>
<h2 id="제일-어색했던-결과는-critical인데-c등급인-경우였다">제일 어색했던 결과는 Critical인데 C등급인 경우였다</h2>
<p>점수 모델을 만지면서 가장 먼저 손보고 싶었던 건 이런 결과였다.</p>
<ul>
<li>위험도: <code>Critical</code></li>
<li>점수: 70점대</li>
<li>등급: <code>C</code></li>
</ul>
<p>이건 보는 사람이 혼란스럽다.<br>정말 치명적인 직접 위험이 있다면, 점수와 등급도 거기에 맞게 낮아져야 한다.</p>
<p>그래서 마지막에는 점수 평균만으로 끝내지 않고, <strong>등급 가드레일</strong>을 넣었다.</p>
<p>예를 들면:</p>
<ul>
<li>직접적인 <code>critical</code> 위험이 있으면 점수 상한 제한</li>
<li>직접적인 <code>high</code> 위험이 있으면 등급 상한 제한</li>
<li>반대로 하드닝 위주의 문제는 지나치게 깎지 않음</li>
</ul>
<p>이 변화는 테스트용 사이트를 넣어보면 바로 차이가 났다.</p>
<p>예전에는 치명적인 TLS 문제를 가진 사이트도 점수상으로는 생각보다 높게 보일 수 있었는데,<br>지금은 적어도 <code>Critical</code>이면 결과도 그에 맞는 수준으로 정리된다.</p>
<p>결과를 보는 사람 입장에서는 이 일관성이 생각보다 중요하다.<br>보안 도구는 숫자 자체보다, 그 숫자와 설명이 서로 충돌하지 않는 것이 더 중요하다고 느꼈다.</p>
<hr>
<h2 id="실제-사례를-넣어-보면서-기준을-다시-잡았다">실제 사례를 넣어 보면서 기준을 다시 잡았다</h2>
<p>이 부분을 손볼 때 가장 도움이 된 건 <code>badssl.com</code> 계열 테스트 사이트였다.<br>막연하게 “이제 좀 나아진 것 같다”가 아니라, 어떤 결과가 나와야 자연스러운지를 실제 사례로 확인할 수 있었기 때문이다.</p>
<p>예를 들어 <code>mixed-script.badssl.com</code>은 mixed content가 핵심 문제인 사이트다.<br>이런 케이스는 결과가 높게 경고되는 게 맞다. 브라우저가 실제로 불러오는 리소스가 안전하지 않으면, 그건 단순 권장 사항이 아니라 직접적인 위험에 더 가깝다. 이 사이트가 <code>High</code>로 보이는 건 오히려 자연스러운 결과였다.</p>
<p>반대로 <code>expired.badssl.com</code>이나 <code>self-signed.badssl.com</code> 같은 사이트는 예전 결과가 더 어색했다.<br>인증서 문제는 공개 서비스 기준으로 꽤 치명적인 편인데, 한때는 위험도와 점수가 완전히 같은 방향으로 읽히지 않는 느낌이 있었다. 이 부분을 손본 뒤에는 적어도 “직접적인 치명 위험이 있으면 등급도 그에 맞게 충분히 낮아져야 한다”는 기준이 결과에 더 잘 반영되기 시작했다.</p>
<p>이 과정을 거치면서 점수 모델도 많이 바뀌었다.<br>예전에는 <code>http://</code> 문자열이 보이기만 해도 mixed content처럼 다루는 쪽에 가까웠다면, 나중에는 실제로 브라우저가 불러오는 서브리소스만 문제로 보도록 범위를 더 좁혔다. 단순 앵커 링크는 제외하고, <code>srcset</code>, <code>script src</code>, <code>stylesheet</code>처럼 실제 로드 경로를 더 정확히 확인하는 방식으로 바꿨다.</p>
<p>겉으로 보면 작은 수정처럼 보일 수 있다.<br>하지만 이런 차이가 결과 전체의 신뢰도에는 꽤 크게 작용했다. 결국 보안 도구에서 더 어려운 건 “더 많이 찾는 것”보다, 사용자가 무시하게 되는 소음을 줄이면서 <strong>실제로 의미 있는 신호를 남기는 것</strong>에 더 가깝다고 느꼈다.</p>
<hr>
<h2 id="siteguard가-할-수-있는-것과-할-수-없는-것">SiteGuard가 할 수 있는 것과 할 수 없는 것</h2>
<p>이 프로젝트를 하면서 끝까지 분명히 하고 싶었던 건, 이 도구의 범위였다.</p>
<p>SiteGuard는 URL 하나로 꽤 많은 걸 볼 수 있다.</p>
<ul>
<li>HTTPS</li>
<li>TLS</li>
<li>리다이렉트</li>
<li>보안 헤더</li>
<li>쿠키 속성</li>
<li>mixed content</li>
<li>외부 HTML/폼 신호</li>
<li><code>security.txt</code></li>
</ul>
<p>하지만 동시에 분명히 못 보는 것도 많다.</p>
<ul>
<li>로그인 뒤 권한 문제</li>
<li>SQL Injection</li>
<li>Stored XSS</li>
<li>IDOR</li>
<li>비즈니스 로직 취약점</li>
<li>내부 API 설계 문제</li>
<li>서버 내부 접근 제어</li>
</ul>
<p>이걸 명확히 하지 않으면 사용자는 이 도구를 “최종 보안 판정기”처럼 오해할 수 있다.</p>
<p>그래서 SiteGuard의 정체성은 결국 이렇게 정리됐다.</p>
<p><strong>공개 URL 기준의 빠른 외부 보안 진단 도구.</strong></p>
<p>모든 걸 하려고 하는 도구보다, 어디까지는 믿을 수 있는지 분명한 도구가 더 낫다고 생각한다.</p>
<hr>
<h2 id="이-프로젝트를-하며-가장-크게-배운-것">이 프로젝트를 하며 가장 크게 배운 것</h2>
<p>결국 이 프로젝트를 통해 가장 크게 배운 건 이것이었다.</p>
<p>좋은 보안 도구는 취약점을 많이 찾아내는 도구가 아니라,<br><strong>적절한 톤으로 위험을 설명하는 도구</strong>라는 점이다.</p>
<p>무엇을 발견했는가도 중요하다.<br>하지만 그보다 더 중요한 건:</p>
<ul>
<li>이게 실제로 위험한지</li>
<li>지금 당장 고쳐야 하는지</li>
<li>하드닝 차원에서 봐야 하는지</li>
<li>이 도구가 어디까지 볼 수 있는지</li>
</ul>
<p>를 일관되게 말하는 것이다.</p>
<p>처음의 SiteGuard는 솔직히 점수 계산기에 가까운 순간도 있었다.<br>하지만 여러 번 실제 사이트를 넣어보고, 결과를 의심하고, 모델을 엎고, 기준을 정리하면서<br>조금씩 “실제로 써볼 수 있는 도구” 쪽으로 움직였다.</p>
<p>완벽하다고 말하긴 어렵다.<br>다만 적어도 지금은 기능 구현 이상의 고민이 코드와 결과에 반영되기 시작했다고 생각한다.</p>
<hr>
<h2 id="마무리하며">마무리하며</h2>
<p>처음의 SiteGuard는 단순한 점수 계산기에 가까웠다.<br>하지만 실제 사이트를 테스트하고, 리스크 모델을 다시 정리하고, 결과를 계속 의심하면서 조금씩 “실제로 믿고 쓸 수 있는 도구” 쪽으로 바꿔왔다.</p>
<p>SiteGuard는 모든 보안 문제를 해결하는 도구는 아니다.<br>대신 공개 URL 기준으로, 지금 당장 놓치기 쉬운 기본 보안 상태를 빠르게 확인하는 용도로는 충분히 의미가 있다고 생각한다.</p>
<p>서비스를 빠르게 만드는 흐름은 앞으로도 계속 강해질 것이다.<br>그럴수록 배포 직전 한 번쯤은, 기능만 아니라 기본 보안 설정도 같이 확인하는 습관이 더 중요해진다고 느꼈다.</p>
<p>비슷한 고민을 하고 있다면 한 번쯤 직접 점검해 봐도 좋다.</p>
<blockquote>
<p>🛡️ <strong><a href="https://siteguard-mauve.vercel.app/">SiteGuard로 내 웹사이트 보안 점검하기</a></strong><br>💻 <strong><a href="https://github.com/Lova-clover/SiteGuard">GitHub에서 코드 확인 및 기여하기</a></strong></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[HTML로 웹페이지 홍보 영상을 만든다고? 직접 해보니 의외로 됐다]]></title>
            <link>https://velog.io/@lova-clover/HTML%EB%A1%9C-%EC%9B%B9%ED%8E%98%EC%9D%B4%EC%A7%80-%ED%99%8D%EB%B3%B4-%EC%98%81%EC%83%81%EC%9D%84-%EB%A7%8C%EB%93%A0%EB%8B%A4%EA%B3%A0-%EC%A7%81%EC%A0%91-%ED%95%B4%EB%B3%B4%EB%8B%88-%EC%9D%98%EC%99%B8%EB%A1%9C-%EB%90%90%EB%8B%A4</link>
            <guid>https://velog.io/@lova-clover/HTML%EB%A1%9C-%EC%9B%B9%ED%8E%98%EC%9D%B4%EC%A7%80-%ED%99%8D%EB%B3%B4-%EC%98%81%EC%83%81%EC%9D%84-%EB%A7%8C%EB%93%A0%EB%8B%A4%EA%B3%A0-%EC%A7%81%EC%A0%91-%ED%95%B4%EB%B3%B4%EB%8B%88-%EC%9D%98%EC%99%B8%EB%A1%9C-%EB%90%90%EB%8B%A4</guid>
            <pubDate>Mon, 16 Mar 2026 18:56:35 GMT</pubDate>
            <description><![CDATA[<p>처음엔 나도 좀 이상하게 들렸다.</p>
<p>웹페이지는 클릭하는 거고,
홍보 영상은 편집툴로 만드는 거 아닌가?</p>
<p>근데 어느 순간 생각이 바뀌었다.
계기는 단순했다.</p>
<p>어떤 분이 <strong>Claude로 홍보 영상을 만드는 걸 봤다.</strong>
솔직히 처음엔 “AI가 만든 영상이면 또 비슷비슷한 화면 몇 장 나오는 거 아니야?” 싶었다.
그런데 막상 결과를 보니 생각보다 꽤 괜찮았다.</p>
<p>그때 바로 이런 생각이 들었다.</p>
<blockquote>
<p>어? 이 정도면 나도 한번 해볼 만한데?</p>
</blockquote>
<p>그래서 나도 바로 해봤다.
이번에는 내가 만들고 있던 <strong>devhistory</strong>를 주제로, <strong>Codex</strong>로 짧은 홍보 영상을 만들어봤다.</p>
<p>그런데 첫 시도 결과는 기대보다 별로였다.</p>
<p>분위기 있는 화면은 나오는데,
정작 <strong>내 프로젝트 같지가 않았다.</strong></p>
<p>문장은 그럴싸했다.
배경도 그럴듯했다.
근데 그게 끝이었다.</p>
<p>딱 봐도 “어떤 SaaS 소개에도 갖다 붙일 수 있는 영상” 느낌이었다.
예쁘긴 한데, 비어 있었다.</p>
<p>그때 깨달았다.</p>
<p><strong>문제는 AI가 영상을 못 만드는 게 아니라, 내 프로젝트를 충분히 모르고 있다는 점</strong>이었다.</p>
<p>그래서 방법을 바꿨다.</p>
<p>말로만 설명하지 말고,
<strong>내가 실제로 만든 웹페이지 이미지를 넣어서 다시 만들어보자.</strong></p>
<p>이게 전환점이었다.</p>
<hr>
<h2 id="내가-만들어본-홍보-영상-devhistory">내가 만들어본 홍보 영상: devhistory</h2>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/cee0d943-37f4-4b65-a264-9b7bc401d0a5/image.gif" alt=""></p>
<p>이번에 만든 영상은 devhistory라는 프로젝트의 콘셉트를 짧게 보여주는 형태였다.</p>
<p>흐름은 대략 이랬다.</p>
<ul>
<li>개발 활동을 자동으로 머지하고</li>
<li>흩어진 기록을 한 번에 묶고</li>
<li>대시보드로 숫자와 흐름을 보여주고</li>
<li>AI가 글과 리포트 초안까지 밀어주고</li>
<li>마지막에는 활동 로그가 포트폴리오로 이어진다는 메시지로 닫는 구조</li>
</ul>
<p>중요한 건, 이걸 그냥 기능 목록처럼 나열하지 않았다는 점이다.</p>
<p>“이것도 됩니다, 저것도 됩니다” 식으로 가면
영상은 있어 보여도 머리에 남지 않는다.</p>
<p>대신 이번에는 이 흐름으로 밀어봤다.</p>
<p><strong>기록 수집 → 정리 → 분석 → 활용 → 포트폴리오</strong></p>
<p>이렇게 순서를 잡으니까
웹페이지는 그냥 정적인 결과물이 아니라,
<strong>설명력을 가진 장면의 묶음</strong>처럼 보이기 시작했다.</p>
<hr>
<h2 id="처음-시도는-왜-빈약했을까">처음 시도는 왜 빈약했을까</h2>
<p>이 부분이 제일 중요했다.</p>
<p>처음에는 그냥 AI에게
“이런 서비스 홍보 영상 만들어줘”에 가까운 식으로 접근했다.</p>
<p>그런데 이렇게 하면 생기는 문제가 명확했다.</p>
<p>AI는 분위기 있는 장면을 잘 만든다.
근데 <strong>내 서비스만의 화면, 내 서비스만의 구조, 내 서비스만의 리듬</strong>까지는 자동으로 만들어주지 않는다.</p>
<p>결과적으로 처음 시도는 이런 느낌이었다.</p>
<ul>
<li>예쁘긴 한데 어디선가 본 것 같고</li>
<li>서비스 설명 같긴 한데 내 프로젝트라고 느껴지지 않고</li>
<li>화면이 움직이긴 하는데 메시지가 정확히 꽂히지 않는 상태</li>
</ul>
<p>한마디로 정리하면 이거다.</p>
<p><strong>홍보 영상처럼 보이는데, 정작 홍보할 대상이 흐렸다.</strong></p>
<p>그래서 빈약하게 느껴졌던 거다.</p>
<hr>
<h2 id="아예-웹페이지-이미지를-넣자-결과가-달라졌다">아예 웹페이지 이미지를 넣자 결과가 달라졌다</h2>
<p>그다음부터는 접근을 바꿨다.</p>
<p>설명만 던지는 게 아니라
<strong>내가 만든 실제 웹페이지 이미지</strong>를 같이 넣었다.</p>
<p>landing 화면,
dashboard 느낌,
기록이 묶이는 구조,
포트폴리오 output 같은 것들 말이다.</p>
<p>그랬더니 결과가 확실히 달라졌다.</p>
<p>왜냐하면 AI가 더 이상
“추상적인 서비스 소개 영상”을 만드는 게 아니라,</p>
<p><strong>실제 존재하는 화면을 바탕으로 장면을 재구성하기 시작했기 때문</strong>이다.</p>
<p>이 차이는 꽤 컸다.</p>
<p>말만 넣었을 때는
누구에게나 적용 가능한 범용 결과물이 나왔다면,</p>
<p>웹페이지 이미지를 넣고 나서는
적어도 화면 자체는 <strong>내 프로젝트의 얼굴</strong>을 갖게 됐다.</p>
<p>이 순간부터 영상이 갑자기 그럴듯해졌다.</p>
<hr>
<h2 id="그래서-html로-홍보-영상을-만든다는-말이-완전히-틀린-건-아니다">그래서 “HTML로 홍보 영상을 만든다”는 말이 완전히 틀린 건 아니다</h2>
<p>물론 정확히 말하면
<strong>HTML만으로 영상 파일을 만든다</strong>는 뜻은 아니다.</p>
<p>더 정확하게 표현하면 이렇다.</p>
<p><strong>HTML/CSS/JS로 만든 웹페이지 화면을, 홍보 영상의 핵심 재료로 쓴다.</strong></p>
<p>이 관점으로 보면 말이 된다.</p>
<p>생각보다 많은 제품 소개 영상은 아래 요소들로 이뤄져 있다.</p>
<ul>
<li>큰 타이포 한 줄</li>
<li>카드 몇 개</li>
<li>버튼</li>
<li>차트나 수치</li>
<li>브라우저 목업</li>
<li>배경 그라데이션</li>
<li>살짝 들어오고 나가는 전환</li>
</ul>
<p>가만히 보면 이건 전부 웹이 잘하는 것들이다.</p>
<p>텍스트를 크게 보여주는 것,
카드를 정렬하는 것,
차트를 배치하는 것,
강조 색을 주는 것,
버튼과 배경으로 분위기를 만드는 것.</p>
<p>즉, 요즘 제품 소개 영상의 핵심이
영화 같은 카메라 워크보다 <strong>메시지가 잘 보이는 화면 구성</strong>에 있다면,</p>
<p>웹페이지는 이미 절반쯤 준비된 상태인 셈이다.</p>
<p>그래서 이번 작업을 하면서 든 생각은 이거였다.</p>
<p><strong>홍보 영상을 처음부터 새로 만드는 게 아니라,
이미 만든 웹페이지를 장면처럼 써먹는 쪽이 더 현실적이다.</strong></p>
<hr>
<h2 id="실제로-어떤-점이-홍보-영상-같다는-느낌을-만들었나">실제로 어떤 점이 “홍보 영상 같다”는 느낌을 만들었나</h2>
<p>이번에 만들어보면서 느낀 건,
생각보다 필요한 동작이 엄청 복잡하지 않다는 점이었다.</p>
<h3 id="1-장면이-나뉘어-있어야-한다">1. 장면이 나뉘어 있어야 한다</h3>
<p>페이지 하나를 길게 보여주는 건 영상이 아니다.
그건 그냥 스크롤 녹화에 가깝다.</p>
<p>홍보 영상처럼 보이려면
장면마다 역할이 분명해야 한다.</p>
<p>예를 들면 이런 식이다.</p>
<ul>
<li>첫 화면: “개발 활동을 자동으로 머지하세요”</li>
<li>다음 화면: GitHub / solved.ac / Velog 기록이 하나로 묶이는 구조</li>
<li>다음 화면: landing / dashboard</li>
<li>다음 화면: AI 초안 생성</li>
<li>다음 화면: 코딩 코치</li>
<li>마지막 화면: 포트폴리오 output + CTA</li>
</ul>
<p>이렇게 되면 각 장면이 <strong>한 문장, 한 메시지</strong>를 맡게 된다.
이게 영상 느낌을 만드는 첫 번째 조건이었다.</p>
<h3 id="2-실제-화면이-들어가야-설득력이-생긴다">2. 실제 화면이 들어가야 설득력이 생긴다</h3>
<p>이건 그냥 중요 정도가 아니라 거의 핵심이다.</p>
<p>실제 웹페이지 이미지가 들어가니까
영상이 공중에 붕 뜨지 않았다.</p>
<p>“이런 것도 할 수 있어요”가 아니라
“실제로 이런 화면으로 보일 거예요”에 가까워진다.</p>
<p>특히 SaaS, 대시보드형 서비스, 생산성 툴 같은 건
결국 사용자가 보게 되는 게 화면이다.</p>
<p>그러니까 홍보 영상에서도
실제 화면이 들어가는 순간 신뢰감이 확 올라간다.</p>
<h3 id="3-화려한-효과보다-타이밍이-더-중요했다">3. 화려한 효과보다 타이밍이 더 중요했다</h3>
<p>텍스트가 먼저 나오고,
그다음 카드가 따라오고,
마지막에 숫자나 버튼이 강조되면
그 자체로 메시지 순서가 생긴다.</p>
<p>실제로 자주 쓰게 되는 느낌은 대체로 비슷했다.</p>
<ul>
<li>fade in / fade out</li>
<li>살짝 들어오는 이동</li>
<li>확대/축소</li>
<li>blur가 줄어들면서 선명해지는 느낌</li>
<li>숫자 카운트업</li>
<li>카드가 순서대로 나타나는 흐름</li>
</ul>
<p>결국 영상 같아 보이게 만드는 건
엄청난 특수효과보다 <strong>리듬</strong>이었다.</p>
<h3 id="4-배경과-타이포가-분위기를-거의-다-먹는다">4. 배경과 타이포가 분위기를 거의 다 먹는다</h3>
<p>짙은 배경,
밝은 키 컬러,
큰 문장,
브라우저 프레임.</p>
<p>이 정도만 잘 잡아도 생각보다 금방 “요즘 제품 소개 영상” 느낌이 난다.</p>
<p>괜히 효과를 더 넣으려고 욕심내는 것보다
배경, 간격, 속도, 폰트 크기를 통일하는 쪽이 훨씬 중요했다.</p>
<hr>
<h2 id="devhistory-영상에서-특히-괜찮았던-부분">devhistory 영상에서 특히 괜찮았던 부분</h2>
<p>내가 이번 영상에서 제일 괜찮다고 느낀 건
기능이 많아 보이는 것보다 <strong>이해되는 순서가 살아 있었다는 점</strong>이다.</p>
<p>처음에는
“개발 활동을 자동으로 머지하세요”로 시작해서
무슨 문제를 풀려는 서비스인지 감을 주고,</p>
<p>그다음에는
GitHub, solved.ac, Velog 같은 기록이 하나로 묶이는 장면으로
왜 필요한지 설명하고,</p>
<p>이후에는
landing과 dashboard로 실제 그림을 보여주고,</p>
<p>그다음엔
AI가 글과 리포트 초안을 밀어주는 활용 장면으로 넘어가고,</p>
<p>마지막에는
포트폴리오 output으로 닫았다.</p>
<p>이 흐름이 괜찮았던 이유는 단순하다.</p>
<p>사람이 서비스를 이해하는 순서와 비슷했기 때문이다.</p>
<ul>
<li>왜 필요한지</li>
<li>무엇을 모으는지</li>
<li>어떻게 보이는지</li>
<li>어디까지 활용되는지</li>
<li>그래서 최종적으로 뭐가 남는지</li>
</ul>
<p>영상 길이가 짧아도
이 순서만 맞으면 생각보다 이해가 된다.</p>
<hr>
<h2 id="내가-보기엔-이-정도면-충분히-좋다">내가 보기엔 이 정도면 충분히 좋다</h2>
<p>여기서 괜히 목표를 잘못 잡으면 끝이 없다.</p>
<p>광고 스튜디오 수준의 결과물을 기준으로 잡으면
개인 프로젝트는 바로 지친다.
그건 애초에 싸움이 다르다.</p>
<p>대신 나는 이번 작업을 하면서
“이 정도면 충분히 좋다”의 기준을 이렇게 잡았다.</p>
<ul>
<li>첫 3초 안에 무슨 서비스인지 감이 온다</li>
<li>한 장면에 한 메시지만 남긴다</li>
<li>실제 화면이 보여서 소개가 공중에 뜨지 않는다</li>
<li>마지막까지 보면 제품을 한 문장으로 설명할 수 있다</li>
</ul>
<p>이 네 개가 되면 이미 꽤 괜찮다.</p>
<p>홍보 영상의 목적은
사람을 압도하는 게 아니라,
<strong>짧은 시간 안에 이해시키는 것</strong>이기 때문이다.</p>
<p>그 기준에서 보면
웹페이지 이미지를 기반으로 장면을 만들고,
AI로 리듬과 전환을 붙이고,
홍보 영상처럼 정리하는 방식은 꽤 실용적이다.</p>
<p>무엇보다 수정이 빠르다.</p>
<p>문구 바꾸기 쉽고,
장면 순서 갈아엎기 쉽고,
서비스가 업데이트되면 화면만 바꿔서 다시 시도할 수도 있다.</p>
<p>이건 생각보다 큰 장점이다.</p>
<hr>
<h2 id="물론-한계는-있다">물론 한계는 있다</h2>
<p>당연히 있다.</p>
<p>복잡한 3D 연출,
실사 기반 컷,
입체적인 카메라 무브 같은 건
이 방식만으로는 한계가 있다.</p>
<p>하지만 제품 소개, 기능 설명, landing teaser, 짧은 쇼츠류는 얘기가 다르다.
이쪽은 오히려 웹페이지 기반 접근이 더 잘 맞는다.</p>
<p>특히 서비스가 아직 배포 전이거나,
콘셉트를 먼저 보여주고 싶은 단계라면 더 그렇다.</p>
<p>실제 구현과 100% 같지 않아도
적어도 <strong>어떤 화면 경험을 줄지</strong>는 먼저 보여줄 수 있기 때문이다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번에 devhistory 홍보 영상을 만들면서 느낀 건 단순했다.</p>
<p>처음에는
남이 Claude로 만든 영상을 보고
“오, 생각보다 괜찮네?”에서 시작했다.</p>
<p>그래서 나도 Codex로 바로 해봤다.</p>
<p>그런데 첫 시도는 빈약했다.
그럴듯한데, 내 프로젝트 같지 않았다.</p>
<p>그래서 방법을 바꿨다.
말로만 설명하지 않고,
<strong>내가 만든 웹페이지 이미지를 넣었다.</strong></p>
<p>그랬더니 결과가 확실히 달라졌다.</p>
<p>이 경험을 하고 나니까
“HTML로 웹페이지 홍보 영상을 만든다고?”라는 말이
그렇게 이상하게 들리지 않았다.</p>
<p>정확히는
웹페이지를 그대로 영상으로 바꾼다기보다,</p>
<p><strong>웹페이지를 가장 강력한 재료로 삼아
홍보 영상의 문법으로 다시 보여주는 방식</strong>에 가깝다.</p>
<p>적어도 내가 보기엔
제품의 핵심 메시지를 짧고 선명하게 전달하는 용도라면
이 방식은 충분히 좋다고 본다.</p>
<p>결국 중요한 건 툴 이름이 아니다.</p>
<p>어떤 화면을 보여줄지,
무슨 문장을 남길지,
어디서 강조하고 어디서 멈출지를 정하는 일.</p>
<p>홍보 영상도 결국
효과 싸움보다 <strong>메시지 설계 싸움</strong>에 더 가까웠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AITOP100 Campus 회고 — AI에게 정답을 묻지 않기로 했다]]></title>
            <link>https://velog.io/@lova-clover/AITOP100-Campus-%ED%9A%8C%EA%B3%A0-AI%EC%97%90%EA%B2%8C-%EC%A0%95%EB%8B%B5%EC%9D%84-%EB%AC%BB%EC%A7%80-%EC%95%8A%EA%B8%B0%EB%A1%9C-%ED%96%88%EB%8B%A4</link>
            <guid>https://velog.io/@lova-clover/AITOP100-Campus-%ED%9A%8C%EA%B3%A0-AI%EC%97%90%EA%B2%8C-%EC%A0%95%EB%8B%B5%EC%9D%84-%EB%AC%BB%EC%A7%80-%EC%95%8A%EA%B8%B0%EB%A1%9C-%ED%96%88%EB%8B%A4</guid>
            <pubDate>Mon, 16 Mar 2026 05:10:20 GMT</pubDate>
            <description><![CDATA[<h2 id="2026년-3월-14일-5문제를-풀며-만든-나만의-검증-루틴">2026년 3월 14일, 5문제를 풀며 만든 나만의 검증 루틴</h2>
<p>AITOP100 Campus 예선대회가 끝난 뒤 가장 먼저 한 일은 결과 확인이 아니라 폴더를 다시 여는 일이었다.
zip 파일 몇 개, 잘라둔 crop 이미지, 회전본, 타일, 임시 메모들.
처음에는 미완성의 흔적처럼 보였는데, 다시 보니 오히려 그 폴더가 내가 오늘 어떻게 문제를 풀었는지를 가장 정확하게 설명하고 있었다.</p>
<p>이번 AITOP100 Campus에서 내가 얻은 건 정답 몇 개가 아니라 습관 하나였다.
AI에게 바로 답을 묻는 대신, 문제를 나누고, 기준 자료를 정하고, 근거를 뽑고, 반례를 찾고, 마지막에 형식까지 다시 검수하는 습관.
돌아보면 이 대회는 “어떤 모델을 썼는가”보다 “AI를 어떤 순서로 배치했는가”가 더 중요했다.</p>
<p>이 글은 참가 후기가 아니다.
정확히는, 이번 대회에서 실제로 써먹었던 문제 해결 방식을 복기하는 작업 로그다.
나중에 비슷한 문제를 다시 만났을 때 꺼내볼 메모이기도 하고, AI를 정답 생성기로만 쓰고 있는 사람에게는 방향 전환의 힌트가 되었으면 하는 기록이기도 하다.</p>
<blockquote>
<p>AI를 잘 쓴다는 건 정답을 빨리 뽑는 능력이 아니라, 문제를 검증 가능한 단위로 바꾸는 능력이었다.</p>
</blockquote>
<h2 id="왜-이-대회가-좋았는가">왜 이 대회가 좋았는가?</h2>
<p>이번 대회가 좋았던 이유는 문제들이 한 가지 능력만 요구하지 않았기 때문이다.
어떤 문제는 이미지 해석처럼 보였고, 어떤 문제는 문서 추론에 가까웠고, 어떤 문제는 거의 QA나 리서치 업무에 가까웠다.
즉, 하나의 요령으로 밀어붙이면 무너지고, 문제마다 다른 접근을 설계해야 했다.</p>
<table>
<thead>
<tr>
<th>문제</th>
<th>자료 성격</th>
<th>핵심 난점</th>
<th>내가 먼저 한 일</th>
</tr>
</thead>
<tbody><tr>
<td><strong>1) 대화 속 상황 추론</strong></td>
<td>다수의 PDF + 대화 로그</td>
<td>정답 찾기 전에 전제 일치 확인</td>
<td>같은 시험, 같은 문항을 보고 있는지부터 확인했다</td>
</tr>
<tr>
<td><strong>2) 특정 작가 찾기</strong></td>
<td>대량의 작품 이미지 + 후보군</td>
<td>자유 추론이 아니라 후보 제한</td>
<td>후보군을 닫고, 가장 헷갈리는 후보끼리 비교했다</td>
</tr>
<tr>
<td><strong>3) 팀원 기여도 파악</strong></td>
<td>수십 장의 채팅 캡처 + 문서/계산식</td>
<td>인상평이 아니라 기록 기반 판단</td>
<td>역할, 약속, 산출물을 먼저 연결했다</td>
</tr>
<tr>
<td><strong>4) 웹사이트 오류 찾기</strong></td>
<td>Spec 문서 + 다수의 구현 파일</td>
<td>감상이 아니라 기준 대조</td>
<td>Spec을 먼저 읽고 비교 축을 세웠다</td>
</tr>
<tr>
<td><strong>5) 옛 신문 해석</strong></td>
<td>옛 신문 이미지, 국한문 혼용</td>
<td>텍스트 추출 이전에 레이아웃 파악</td>
<td>면과 영역을 먼저 나눴다</td>
</tr>
</tbody></table>
<p>표로 놓고 보니 더 선명해졌다.
AI를 잘 쓰는 사람은 질문을 화려하게 던지는 사람이 아니라, 문제를 AI가 다룰 수 있는 단위로 다시 나누는 사람이라는 점이었다.</p>
<h2 id="내가-끝까지-붙잡은-루틴-분할-기준-반박-검수">내가 끝까지 붙잡은 루틴: 분할-기준-반박-검수</h2>
<p>이번 대회에서 끝까지 살아남게 해준 건 화려한 프롬프트가 아니라 하나의 루틴이었다.</p>
<ol>
<li>문제를 한 번에 풀지 않고 먼저 나눈다.</li>
<li>어떤 자료가 기준인지 먼저 정한다.</li>
<li>AI에게 정답이 아니라 근거를 뽑게 한다.</li>
<li>내가 1차 판단을 내린다.</li>
<li>AI에게 이 답이 왜 틀릴 수 있는지 반박하게 한다.</li>
<li>마지막에 숫자, 표기, 파일명, 형식을 다시 검수한다.</li>
</ol>
<p>이 순서는 생각보다 중요했다.
처음부터 “답이 뭐야?”라고 묻는 순간 사고의 주도권이 AI 쪽으로 넘어간다.
그러면 근거보다 그럴듯함을 따라가게 된다.</p>
<p>반대로 문제를 잘게 나누고, 기준 자료를 먼저 세우고, 근거를 분리해 놓으면 AI의 역할이 바뀐다.
그때부터 AI는 정답 생성기보다 훨씬 좋은 <strong>정리자, 근거 추출기, 반박자, 검수자</strong>가 된다.</p>
<h2 id="문제별로-돌아보면-더-명확해진다">문제별로 돌아보면 더 명확해진다</h2>
<h3 id="1-대화-속-상황-추론--답보다-먼저-전제를-맞췄다">1) 대화 속 상황 추론 — 답보다 먼저 전제를 맞췄다</h3>
<p>이 문제는 시험 문제를 푸는 문제가 아니었다.
대화 속 단서들을 해석해서, 두 사람이 정확히 어떤 시험지와 어떤 문항을 보고 있는지 역으로 추적하는 문제에 가까웠다.</p>
<p>그래서 내가 가장 먼저 확인한 건 정답이 아니라 전제였다.
같은 과목인지, 공통 과목인지 선택 과목인지, 문제지 유형이 엇갈린 건 아닌지부터 봤다.
이걸 건너뛰고 바로 정답으로 들어가면, 애초에 서로 다른 시험지를 보고 있는데도 같은 문제라고 착각하게 된다.</p>
<p>예를 들어 두 사람이 특정 개념이 들어간 선지를 똑같이 기억하는데 정답 번호가 다르면, 바로 “누가 틀렸지?”로 가면 안 된다.
먼저 선지 내용이 같은데 배열만 다른 건지부터 확인해야 한다. 대화 속에서 특정 단어를 언급하더라도, 선택 과목 세팅이 다르면 전혀 다른 문항일 수 있다.</p>
<p>이 파트에서 배운 건 명확했다.
번호보다 내용이 먼저이고, 내용보다도 전제가 먼저다.
전제가 틀리면 AI가 아무리 열심히 답을 내놔도, 결국 다른 시험지를 보고 푸는 셈이다.</p>
<h3 id="2-특정-작가-찾기--맞히는-문제가-아니라-지워나가는-문제였다">2) 특정 작가 찾기 — 맞히는 문제가 아니라 지워나가는 문제였다</h3>
<p>처음 보면 AI가 잘할 것처럼 보이는 문제였다.
이미지를 보고 작가를 맞히는 건 멀티모달 모델의 장기처럼 느껴지기 때문이다.
그런데 실제로는 오히려 더 조심해야 했다.</p>
<p>이번 자료는 대량의 작품 이미지에 다수의 정답 후보 작가군이 매핑되어 있었다. 여기에 별도의 작품 목록과 소장처 데이터까지 주어졌다.
즉, 이건 감상 문제가 아니라 <strong>후보 제한형 분류 문제</strong>였다.</p>
<p>여기서 제일 먼저 한 일은 문제를 다시 정의하는 것이었다.
“이 그림 누구 작품이지?”라고 묻는 순간 문제가 너무 넓어진다.
대신 “주어진 후보 중 누구일 가능성이 가장 높은가?”라고 바꾸면 그때부터는 비교 가능한 문제가 된다.</p>
<p>이 차이는 꽤 크다.
AI를 자유응답으로 풀어놓으면 후보 리스트에 없는 화풍이 비슷한 다른 작가를 너무 자연스럽게 섞어버린다.
반대로 후보를 닫아두면 정확도는 눈에 띄게 올라간다.</p>
<p>그래서 나는 먼저 시대감, 지역, 주제, 색감 등 넓은 특징을 잡고 후보를 5개 정도로 줄였다.
마지막에는 가장 헷갈리는 후보 둘이나 셋을 세워서 차이를 비교한 뒤에야 최종 답을 골랐다. “특정 화가 같아 보인다” 수준의 감상이 제일 위험했다.</p>
<p>이 문제가 남긴 교훈도 단순하다.
후보가 있는 문제에서 자유응답은 종종 함정이다.
AI를 잘 쓰는 방법은 더 자유롭게 묻게 하는 게 아니라, 오히려 더 정확하게 닫아주는 데 있다.</p>
<h3 id="3-팀원-기여도-파악--사람을-본-게-아니라-기록을-읽었다">3) 팀원 기여도 파악 — 사람을 본 게 아니라 기록을 읽었다</h3>
<p>이 문제는 현실적이라서 더 어려웠다.
자료 안에는 수십 장의 메신저 캡처, 문서, 엑셀, 발표 자료가 뒤섞여 있었고, 별도의 기여도 산출 공식과 가중치까지 주어졌다.
이 문제는 “누가 왠지 덜 열심히 한 것 같은가”를 묻는 게 아니라, <strong>누가 실제로 어떤 흔적을 남겼는가</strong>를 보는 문제였다.</p>
<p>여기서 제일 위험한 건 인상평이었다.
채팅에서 말이 많다고 기여도가 높은 것도 아니고, 중간에 미안하다고 했다고 자동으로 덜 기여한 사람이 되는 것도 아니다.
반대로 말이 적어도 자기 역할이 최종 산출물에 남아 있으면 충분히 기여한 것이다.</p>
<p>그래서 이 파트에서는 사람을 판단하려고 하지 않고, 기록을 읽는다는 느낌으로 갔다.
초기 역할 분담이 무엇이었는지,
중간에 실제로 공유된 자료가 있었는지,
최종 발표본에 그 사람의 흔적이 반영됐는지,
누군가 맡은 일을 다른 팀원이 대신한 정황은 없는지,
이 네 가지를 계속 연결했다.</p>
<p>AI도 여기서는 꽤 쓸 만했다.
하지만 “누가 제일 못했는지 말해줘”는 형편없는 질문이었다.
대신 “인원별 초기 역할과 실제 산출물을 나란히 정리해줘”, “특정 파트 반영 여부를 추적해줘”, “약속만 있고 결과물이 없는 구간을 표시해줘” 같은 요청이 훨씬 정확했다.</p>
<p>사람을 판단하는 문제일수록 감정에서 내려와 기록으로 가야 한다. 그게 더 정확하고, 더 공정하다.</p>
<h3 id="4-웹사이트-오류-찾기--구현보다-먼저-기준-문서를-세웠다">4) 웹사이트 오류 찾기 — 구현보다 먼저 기준 문서를 세웠다</h3>
<p>웹사이트 버그 찾기는 겉으로 보면 사이트를 훑어보며 이상한 점을 찾는 문제 같지만, 실제로는 그보다 훨씬 구조적인 문제였다.</p>
<p>자료를 열어보면 수십 페이지짜리 Spec 문서가 있고, 구현물은 다수의 HTML, 이미지, CSS, JS 등으로 구성되어 있었다.
이 정도면 눈대중으로 몇 페이지 눌러보는 감각만으로는 아무것도 확신할 수 없다. 중요한 건 탐색이 아니라 비교였다.</p>
<p>그래서 나는 코드를 열어보기 전에 Spec 문서를 먼저 꼼꼼히 읽었다.
페이지 구조가 어떻게 되는지,
한국어와 영어가 어떤 식으로 대응되는지,
공통 헤더와 푸터 규칙이 무엇인지,
먼저 비교 축을 세운 뒤에야 실제 구현 파일들을 뜯어보기 시작했다.</p>
<p>이 문제에서 AI도 역할이 분명했다.
“사이트 전반적으로 어때?”라고 물으면 아무 가치가 없는 답변이 돌아왔다.
대신 비교표를 만들게 하면 강해졌다.
설계서 기준 페이지 수와 실제 파일 수 비교,
공통 컴포넌트 규칙과 구현 방식 비교,
문구·숫자·링크 일치 여부 확인.
이런 식으로 축을 정해서 맡길 때 훨씬 안정적이었다.</p>
<p>이 파트에서 남은 한 줄은 이거다.
복잡한 구현물을 확인할 때 AI는 대신 읽고 판단해 주는 존재가 아니다.
<strong>명확한 기준을 세워줬을 때 강해지는 검수 보조 도구</strong>다.</p>
<h3 id="5-옛-신문-해석--글자를-읽기-전에-먼저-지면을-나눴다">5) 옛 신문 해석 — 글자를 읽기 전에 먼저 지면을 나눴다</h3>
<p>옛 신문 자료는 보자마자 감이 왔다. 이건 전체를 한 번에 읽으려 들면 사람도 AI도 같이 무너질 문제였다.</p>
<p>실제로 내가 다룬 이미지는 한 장 안에 옛 신문 여러 면이 붙어 있었다.
세로 조판, 국한문 혼용, 작은 활자, 광고와 기사가 뒤섞인 구획.
이건 단순 텍스트 추출 문제가 아니라 거의 레이아웃 해석 문제였다.</p>
<p>그래서 나는 이 자료를 텍스트로 보지 않고 먼저 지면으로 봤다.
제호가 있는 곳, 큰 제목이 있는 곳, 광고가 몰린 곳, 특정 기사 후보가 있을 만한 영역을 먼저 나눴다.
그다음에야 잘라서 확대하고, 회전하고, 다시 더 작은 단위로 쪼개기 시작했다.</p>
<p>대회가 끝난 뒤 폴더를 보니 자잘하게 잘라둔 수십 개의 이미지 타일 파일들이 남아 있었다.
처음엔 좀 지저분해 보였다.
그런데 다시 보니 그게 바로 내가 이 문제를 풀었던 방식이었다.
전체를 한 번에 이해하려고 한 게 아니라, 읽을 수 있는 단위로 계속 바꿔가며 접근한 것이다.</p>
<p>이 문제를 하며 가장 크게 느낀 건 아주 분명했다.
어려운 분석 문제에서는 더 똑똑한 모델을 찾는 것보다, 모델이 잘 읽을 수 있게 입력을 정리해 주는 과정이 먼저다.
입력이 흐리면 AI는 똑똑하게 틀린다. 반대로 입력을 정리해주면 생각보다 훨씬 잘 도와준다.</p>
<h2 id="이번에-오래-남을-프롬프트-4개">이번에 오래 남을 프롬프트 4개</h2>
<p>이번 대회를 지나며 다시 확인했다.
프롬프트는 화려한 문장이 아니라 <strong>역할이 분명한 템플릿</strong>이 강하다.
아래 네 개는 앞으로도 계속 쓸 것 같다.</p>
<h3 id="1-근거-먼저-뽑는-프롬프트">1) 근거 먼저 뽑는 프롬프트</h3>
<p>결론보다 근거 분리가 먼저 필요한 이미지/문서 자료에서 썼다.</p>
<pre><code class="language-text">지금부터 정답을 바로 말하지 말고, 먼저 근거만 정리해줘.

출력 형식:
1) 관찰된 사실
2) 아직 확실하지 않은 추정
3) 추가로 확인해야 할 포인트

중요:
- 보이는 것과 추정을 섞지 마라
- 숫자, 이름, 파일명, 섹션명은 정확히 적어라
- 아직 정답을 단정하지 마라
</code></pre>
<h3 id="2-후보-제한형-분류-프롬프트">2) 후보 제한형 분류 프롬프트</h3>
<p>AI를 자유롭게 풀어놓는 것보다, 후보를 닫고 비교하게 할 때 훨씬 안정적이었다.</p>
<pre><code class="language-text">제공된 후보 리스트 안에서만 답하라.

단계:
1) 자료의 특징을 요약
2) 후보 5개로 축소
3) 최종 1개 선택
4) 가장 헷갈리는 후보 2개와 이유 제시

중요:
- 후보 리스트 밖 이름 금지
- 확신도 표시
- 정확한 표기 유지
</code></pre>
<h3 id="3-기준-구현-비교-프롬프트">3) 기준-구현 비교 프롬프트</h3>
<p>AI에게 감상을 맡기면 흔들리고, 확실한 비교표를 맡기면 강해졌다.</p>
<pre><code class="language-text">기준 문서(spec)와 구현물(actual)을 비교해줘.

출력 형식:
- 비교 항목
- 기준 문서 내용
- 실제 구현 내용
- 일치/불일치
- 근거가 된 파일명 또는 섹션명

중요:
- 추측하지 마라
- 숫자, 링크, 파일명은 원문 그대로 적어라
- &quot;전반적으로 괜찮다&quot; 같은 평가는 금지
- 한 항목씩 끊어서 써라
</code></pre>
<h3 id="4-반박용-프롬프트">4) 반박용 프롬프트</h3>
<p>정답을 만드는 능력도 중요하지만, 오답 가능성을 줄이는 루틴이 실전에서는 더 강했다.</p>
<pre><code class="language-text">내가 고른 답이 틀릴 수 있는 이유를 최대 5개 적어줘.
그리고 각 이유마다 다시 확인해야 할 자료를 지정해줘.

출력 형식:
- 반박 포인트
- 왜 위험한지
- 재검증할 자료
- 재검증 후 유지/수정 판단
</code></pre>
<h2 id="이번-대회가-나에게-남긴-것">이번 대회가 나에게 남긴 것</h2>
<p>이번 AITOP100 Campus는 단순히 몇 문제를 푼 경험이 아니었다.
AI를 실전적으로 다루는 방식을 조금 더 선명하게 만든 경험이었다.</p>
<p>이번 대회에서 나는 AI를 정답을 대신 내주는 마법 상자로 쓰지 않으려고 했다.
오히려 방대한 자료를 읽을 때 옆에서 정리해주고, 내가 놓친 지점을 다시 찔러주고, 마지막에 교차 확인을 해주는 보조 파트너처럼 두려고 했다.
이번에는 그 방식이 분명히 더 잘 맞았다.</p>
<p>그리고 이 감각은 대회에서만 끝나지 않는다.
문서 정리, 웹 QA, 코드 리뷰, 포트폴리오 작성, 팀 프로젝트 회고까지 그대로 이어질 것 같다.
결국 중요한 건 AI를 얼마나 화려하게 쓰느냐가 아니다. 내가 직면한 문제를 얼마나 잘 나누고, 기준을 세우고, 검증 가능한 형태로 바꾸느냐가 더 중요하다.</p>
<p>대회가 끝나고 폴더 안에는 중간 산출물이 많이 남아 있었다.
crop 파일, 회전본, 타일, 확인용 메모, 임시 이미지들.
처음에는 그게 미완성의 흔적처럼 보였다.
지금은 다르게 본다.
그건 실패의 잔해가 아니라 치열하게 사고했던 과정의 로그였다.</p>
<p>나는 이제 AI에게 바로 정답을 묻지 않는다.
대신 근거를 묻고, 반례를 묻고, 다시 확인할 지점을 묻는다.
이번 AITOP100 Campus에서 얻은 건 몇 개의 정답보다 오래 가는 습관이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[의료 AI를 하다가 RAG를 다시 봤다]]></title>
            <link>https://velog.io/@lova-clover/%EC%9D%98%EB%A3%8C-AI%EB%A5%BC-%ED%95%98%EB%8B%A4%EA%B0%80-RAG%EB%A5%BC-%EB%8B%A4%EC%8B%9C-%EB%B4%A4%EB%8B%A4</link>
            <guid>https://velog.io/@lova-clover/%EC%9D%98%EB%A3%8C-AI%EB%A5%BC-%ED%95%98%EB%8B%A4%EA%B0%80-RAG%EB%A5%BC-%EB%8B%A4%EC%8B%9C-%EB%B4%A4%EB%8B%A4</guid>
            <pubDate>Thu, 12 Mar 2026 23:37:15 GMT</pubDate>
            <description><![CDATA[<p>의료 AI 프로젝트에서 처음 RAG를 붙였을 때, 출발점은 단순했다.</p>
<p>모델이 말을 너무 잘했다.<br>그게 오히려 불안했다.</p>
<p>의료 문맥에서는 문장을 매끈하게 뽑는 능력보다, 그 문장이 <strong>어디에서 왔는지</strong>가 먼저였다. 퇴원 후 환자 관리 지침을 정리할 때도 그랬고, 여러 기록에 흩어진 위험 신호를 한 번에 묶어야 할 때도 그랬다. 그럴듯한 답은 생각보다 쉽게 나온다. 문제는 그다음이다. 왜 그런 답을 했는지, 무엇을 보고 그렇게 말했는지, 틀렸다면 어디서부터 어긋났는지. 그게 안 잡히면 의료 쪽에서는 바로 불편해진다.</p>
<p>그래서 RAG를 붙였다.<br>적어도 답변 아래에 근거라도 깔아보자는 마음이었다.</p>
<p>막상 써보니, 내가 알고 있던 RAG는 너무 얕았다. 문서를 잘게 나누고, 임베딩해서 저장하고, 질문이 들어오면 비슷한 청크 몇 개를 가져와 모델에 넣는 방식. 처음엔 이 정도면 되는 줄 알았다. 그런데 실제로는 다른 데서 자꾸 막혔다. 질문 표현이 조금만 바뀌어도 retrieval이 흔들렸다. 여러 문서에 흩어진 근거를 엮어야 하는 질문은 금방 힘이 빠졌다. 한 문장의 출처를 보여주는 수준을 넘어서, <strong>이 환자에게 반복적으로 겹치는 위험 신호가 뭔지</strong>, <strong>수많은 가이드라인과 기록 중에서 지금 더 먼저 볼 포인트가 뭔지</strong> 같은 질문으로 넘어가면, 내가 알고 있던 RAG는 갑자기 너무 좁아졌다.</p>
<p>그때부터 RAG를 다시 보기 시작했다.</p>
<p>찾아볼수록 방향이 다르게 보였다. 지금 RAG를 이해한다는 건 벡터DB를 붙이는 법을 배우는 일이 아니라, <strong>어떤 질문에 어떤 검색 구조를 써야 하느냐</strong>를 이해하는 일에 더 가까웠다. RAG의 출발점도 사실 크게 다르지 않았다. 모델 내부 지식만으로는 업데이트, 출처 추적, 지식 집약적 질문을 다루기 어렵기 때문에 외부의 비파라미터 메모리를 붙이자는 것이었다. 그런데 2026년 현재의 RAG는 거기서 한참 더 나가 있다. classic RAG, hybrid retrieval, agentic retrieval, GraphRAG, LazyGraphRAG까지 가지가 꽤 많이 뻗었다. 실제로는 하나를 맹신하는 구조보다, <strong>질문에 맞는 branch를 고르는 구조</strong>가 더 설득력 있게 보인다.</p>
<blockquote>
<p><strong>지금은 가장 좋은 RAG 하나를 고르는 시대가 아니라, 질문에 맞는 RAG를 고르는 시대에 더 가깝다.</strong></p>
</blockquote>
<hr>
<h2 id="지금-rag를-볼-때-과거보다-현재를-먼저-보는-이유">지금 RAG를 볼 때, 과거보다 현재를 먼저 보는 이유</h2>
<p>RAG를 설명하는 글은 많다. 역사부터 차근차근 정리한 글도 많다.</p>
<p>지금 시점에서 더 중요한 건 초창기 정의보다 <strong>현재 기본값이 어떻게 바뀌었는가</strong>다. Azure AI Search 최신 문서를 보면 RAG를 설명할 때 아예 <strong>classic RAG pattern</strong>과 <strong>agentic retrieval</strong>을 따로 다룬다. classic RAG는 검색엔진에 질의를 보내고, 가져온 결과를 LLM에 넘겨 답을 만드는 구조다. 익숙한 방식이다. 반면 agentic retrieval은 여기서 멈추지 않는다. 대화 맥락을 읽고, compound question을 잘라서 여러 하위 질의로 만들고, 그걸 병렬로 실행한다. 같은 문서가 공식적으로 새 구현은 agentic retrieval부터 고려하라고 쓰고 있다는 점도 재밌다. RAG를 하나의 고정된 패턴으로 보기 어려워졌다는 뜻이다.</p>
<p>예전에는 “RAG = 벡터 검색”처럼 설명해도 크게 틀린 말은 아니었다.</p>
<p>지금은 그 설명이 부족하다.</p>
<p>classic RAG조차 vector-only로 설명하면 뭔가 빠진다. Azure의 hybrid search 문서는 hybrid search를 <strong>full-text와 vector를 동시에 실행하고, 그 결과를 RRF(Reciprocal Rank Fusion)로 합치는 구조</strong>로 설명한다. 여기에 semantic ranker를 붙여 결과를 다시 정렬한다. 검색 결과를 그냥 top-k로 던져 넣는 수준이 아니라, sparse 신호와 dense 신호를 같이 쓰고, 그 위에서 한 번 더 거르는 식이다. 실무에서 강한 기본형이 벡터 하나가 아니라 <strong>hybrid retrieval + rerank</strong>로 굳어지는 이유가 여기 있다.</p>
<p>의료 AI 프로젝트를 하면서도 이 차이를 자주 느꼈다. 의학 용어, 약어, 숫자, 기준치, 검사명, 약물명은 의미 유사도만으로 잘 안 잡힐 때가 많다. 반대로 의미상 비슷한 문서가 잡혀도, 실제 근거로 쓰기에는 미묘하게 틀린 경우도 많다. 예를 들어 INR, eGFR, BNP 같은 수치나 특정 경고 문구는 dense retrieval만 믿고 가면 한 끗씩 어긋난다. 약물명 표기가 다르거나, guideline 문구가 살짝 달라도 답변 전체가 미끄러질 수 있다. vector-only를 고집하면 결과가 완전히 틀린 답보다 더 곤란한 방향으로 간다.</p>
<p><strong>조금씩 어긋나는 답이 쌓인다.</strong></p>
<p>이게 더 위험했다.</p>
<p>키워드와 vector를 같이 쓰고, 그 위에 rerank를 올리면 질감이 달라진다. 보기에는 큰 변화가 아닌데, 로그를 까보면 답변 바닥이 바뀐다. 지금 기준으로 RAG를 설명할 때 vector-only를 기본형처럼 말하면 뭔가 많이 놓치게 된다.</p>
<h2 id="2026년-기본값-hybrid-retrieval--rerank">2026년 기본값: Hybrid Retrieval + Rerank</h2>
<p>지금의 RAG를 가장 현실적으로 설명하면 이 정도가 맞다.</p>
<p><strong>먼저 잘 찾고, 그다음 다시 잘 고른다.</strong></p>
<p>Hybrid retrieval은 그 원칙에 제일 충실하다. 키워드 검색은 정확한 용어, 숫자, 약어, 제목, 표기 차이에 강하다. 벡터 검색은 표면 표현이 달라도 의미가 가까운 문서를 끌어오는 데 강하다. 의료처럼 정밀한 표현과 넓은 의미 해석이 동시에 필요한 영역에서는 둘 중 하나만 믿는 순간 바로 구멍이 생긴다.</p>
<p>그래서 hybrid retrieval은 옵션이 아니라 기본값처럼 느껴진다.</p>
<p>여기에 rerank가 붙으면 그림이 더 선명해진다. 검색 단계에서 top-k에 들어온 문서는 보통 “대충 관련 있다” 수준인 경우가 많다. 의료 문맥에서는 이 정도로는 불안하다. “대충 관련 있는 문서”를 그대로 모델에 넣으면, 모델은 생각보다 그럴듯한 방식으로 틀린다. rerank는 이 후보들 중에서 질문에 더 가까운 문서를 다시 위로 세운다. retrieval 한 번으로 끝나는 구조보다, <strong>retrieval → fusion → rerank</strong> 같은 다단계 구조가 실전에서 더 납득된다.</p>
<p>내 기준으로 지금 제일 튼튼한 출발점은 분명하다.</p>
<p><strong>vector-only가 아니라 hybrid + rerank.</strong></p>
<p>여길 건너뛰고 최신 이름만 쫓으면, 생각보다 빨리 밑천이 드러난다.</p>
<h2 id="질문이-복잡해질수록-retrieval은-검색보다-계획에-가까워진다">질문이 복잡해질수록, retrieval은 검색보다 계획에 가까워진다</h2>
<p>물론 hybrid + rerank가 다 해결해주지는 않는다.</p>
<p>질문이 길어지고, 대화 맥락이 붙고, 하나의 질문 안에 여러 요구가 섞이면 retrieval은 단순 검색에서 조금 멀어진다. Azure의 agentic retrieval 문서는 이 부분을 꽤 또렷하게 설명한다. agentic retrieval은 LLM이 전체 대화 스레드를 읽고, 사용자의 compound question을 더 작은 subquery로 분해한다. 그다음 이 하위 질의들을 병렬로 실행하고, grounding data와 citation, query metadata까지 포함한 구조화된 결과를 돌려준다. 지금 retrieval의 앞쪽이 검색기라기보다 작은 planner처럼 보이는 이유다.</p>
<p>이건 의료 쪽으로 가져오면 더 체감된다.</p>
<p>예를 들어 이런 질문을 생각해 볼 수 있다.</p>
<blockquote>
<p>고령 환자이고, 당뇨와 만성 신질환이 있고, 항응고제를 복용 중이며, 최근 입원 이력이 있는 환자에게 퇴원 후 어떤 추적 관찰과 교육 포인트를 우선으로 제시해야 하는가?</p>
</blockquote>
<p>이 질문은 질의 하나로 끝날 성질이 아니다. 약물 관련 주의사항, 추적 검사, 합병증 징후, 재입원 위험, 생활관리 포인트가 동시에 걸려 있다. 이런 질문에서 중요한 건 검색기 성능만이 아니다. <strong>질문을 분해하고 coverage를 확보하는 방식</strong>이 먼저다. agentic retrieval이 눈에 들어오는 이유가 여기에 있다. 단일 검색어 하나로는 부족한 질문들이 실제 업무에는 꽤 많다.</p>
<h2 id="내가-graphrag에-관심을-갖게-된-이유">내가 GraphRAG에 관심을 갖게 된 이유</h2>
<p>내 관심은 여기서 한 번 더 옮겨갔다.</p>
<p>의료 AI 프로젝트를 하다 보니, 어느 순간부터는 “이 문장의 출처가 어디냐”보다 더 큰 질문이 자꾸 남았다. 여러 문서에서 반복되는 위험 신호는 무엇인지, 진료지침과 퇴원 교육자료와 약물 안내문과 기록을 합치면 어떤 패턴이 드러나는지, 한 문서 안이 아니라 <strong>문서 집합 전체를 봐야만 답할 수 있는 질문</strong>들이 나왔다. 이건 retrieval이 조금 부족해서 생긴 문제가 아니었다. 애초에 질문 자체가 달랐다.</p>
<p>그때 GraphRAG가 눈에 들어왔다.</p>
<p>공식 GraphRAG 문서는 GraphRAG를 plain text snippets 중심의 naive semantic search와 대비되는, <strong>structured, hierarchical</strong>한 RAG로 설명한다. 핵심은 단순하다. 텍스트를 잘라 임베딩만 저장하는 대신, 텍스트에서 엔티티와 관계를 추출해 지식 그래프를 만들고, 그 그래프를 community hierarchy로 묶고, 각 community report를 만든다. 질의 시점에는 이 구조를 활용한다. 공식 문서가 말하는 baseline RAG의 약점도 분명하다. 하나는 흩어진 정보를 연결해야 하는 <strong>connect-the-dots</strong> 유형이고, 다른 하나는 큰 문서나 데이터셋 전체를 <strong>holistically understand</strong>해야 하는 질문이다. GraphRAG는 바로 이 두 자리를 겨냥한다.</p>
<p>의료 쪽으로 옮겨보면 더 또렷하다.</p>
<p>퇴원 후 환자 관리 지침, 만성질환 교육 자료, 약물 복용 안내, 추적 검사 권고, 재입원 위험 신호, 간호기록, 외래 추적 계획이 여러 문서와 노트에 흩어져 있다고 해보자. 단순한 RAG는 이 중 한 문장을 찾는 데는 강할 수 있다. 그런데 이런 질문은 조금 다르다.</p>
<blockquote>
<p>이 환자에게 반복적으로 겹치는 복합 위험 신호는 무엇인가?<br>여러 가이드라인과 기록을 합쳐 봤을 때, 실제 follow-up 우선순위는 어디에 놓여야 하는가?<br>각각의 문장은 따로 떨어져 있는데, 전체적으로 어떤 패턴이 보이는가?</p>
</blockquote>
<p>여기서는 청크 몇 개를 잘 찾는 것만으로는 부족하다.</p>
<p>구조를 봐야 한다.</p>
<p>GraphRAG가 매력적으로 보인 이유가 정확히 그 지점이었다.</p>
<p>공식 query engine도 이 성격을 그대로 드러낸다. GraphRAG는 <code>local</code>, <code>global</code>, <code>drift</code>, <code>basic</code> 네 가지 query mode를 제공한다. Local Search는 특정 엔티티를 중심으로 세부를 파고드는 질문에 맞고, Global Search는 community reports를 map-reduce 식으로 훑으며 데이터셋 전체를 이해해야 하는 질문에 맞다. DRIFT Search는 local search에 community 정보를 끌어와 breadth를 넓히는 방식이고, Basic Search는 비교를 위한 baseline vector RAG다. 공식 문서는 Global Search를 <strong>resource-intensive</strong>하다고 적어둔다. 이 부분이 마음에 들었다. GraphRAG는 상위호환인 척하지 않는다.</p>
<p><strong>질문의 종류가 다르다.</strong></p>
<p>그 사실을 먼저 인정한다.</p>
<h2 id="지금-graphrag는-어디까지-와-있나">지금 GraphRAG는 어디까지 와 있나</h2>
<p>GraphRAG를 더 흥미롭게 만든 건, 이게 논문 아이디어에서 멈추지 않았다는 점이다.</p>
<p>현재 공식 docs를 보면 Welcome, Getting Started, Query, Prompt Tuning, CLI, Indexing Methods까지 꽤 잘 정리되어 있다. CLI는 <code>init</code>, <code>index</code>, <code>prompt-tune</code>, <code>query</code>, <code>update</code>를 지원한다. <code>query</code>는 <code>local|global|drift|basic</code>을 바로 받는다. Getting Started는 Python <strong>3.10~3.12</strong>를 요구하고, <code>init</code>을 실행하면 <code>.env</code>, <code>settings.yaml</code>, <code>input/</code>이 만들어진다. 지금의 GraphRAG는 “읽어볼 만한 논문”이 아니라, 적어도 <strong>손으로 굴려볼 수 있는 프레임워크</strong>다.</p>
<p>2024년 하반기부터 2026년 초까지의 흐름도 빠르다. DRIFT Search, dynamic community selection, LazyGraphRAG, GraphRAG 1.0이 이어졌고, GitHub releases 기준 최신 버전은 2026년 3월 6일의 <strong>v3.0.6</strong>이다. 방향은 꽤 분명하다. GraphRAG 진영도 단순히 “그래프가 더 똑똑하다”를 말하는 데서 멈추지 않고, breadth와 depth를 어떻게 섞을지, global search 비용을 어떻게 줄일지, up-front indexing cost를 얼마나 낮출지 같은 현실적인 문제로 내려오고 있다.</p>
<p>DRIFT는 그 흐름을 잘 보여준다. Microsoft Research 설명을 보면 DRIFT는 먼저 community reports 중 상위 K개를 골라 가벼운 primer를 만든다. 그다음 여기서 follow-up question을 만들고, local search 변형으로 세부를 파고든다. query expansion에는 HyDE도 활용한다. 같은 글에서 Microsoft는 AP 뉴스 5천여 건과 50개의 local question으로 벤치마킹한 결과, DRIFT가 Local Search보다 <strong>comprehensiveness 78%</strong>, <strong>diversity 81%</strong> 우세했다고 보고했다. 자사 실험 결과라는 점은 감안해서 읽어야 한다. 그래도 방향 하나는 분명하다. 전역 구조를 만들었으면, 그걸 local answer 품질 향상에 써보겠다는 쪽으로 가고 있다.</p>
<p>LazyGraphRAG도 같은 선상에서 읽힌다. Microsoft Research는 LazyGraphRAG를 prior summarization이 필요 없는 graph-enabled RAG로 소개한다. 발표 내용만 놓고 보면 data indexing cost는 vector RAG와 같고, full GraphRAG보다 훨씬 낮추는 방향을 노린다. 여기서 중요한 건 숫자 자체보다 메시지다. GraphRAG의 최신 흐름은 품질만 좋으면 된다는 태도에서 벗어나, <strong>비용-품질 곡선 자체를 다시 설계하는 쪽</strong>으로 이동하고 있다.</p>
<h2 id="그래서-지금-가장-좋은-rag는-뭐라고-봐야-하나">그래서, 지금 가장 좋은 RAG는 뭐라고 봐야 하나</h2>
<p>여기서 하나로 못 박고 싶은 유혹이 생긴다. Hybrid가 제일 낫다, Agentic이 다음이다, 아니면 이제 GraphRAG다.</p>
<p>나는 그렇게 정리하는 쪽이 오히려 덜 정확하다고 본다.</p>
<p>지금 기준으로 제일 그럴듯한 답은 <strong>질문 유형별로 retrieval branch를 갈아타는 Routed RAG</strong>다.</p>
<table>
<thead>
<tr>
<th>질문 성격</th>
<th>먼저 떠올릴 방식</th>
<th>왜 이쪽이 맞는가</th>
</tr>
</thead>
<tbody><tr>
<td>짧고 명확한 fact 질의, FAQ, 정책 문답</td>
<td><strong>Hybrid Retrieval + Semantic Rerank</strong></td>
<td>키워드 신호와 의미 신호를 같이 쓰고, 후단에서 다시 정렬할 수 있다</td>
</tr>
<tr>
<td>조건이 많고 대화 맥락이 중요한 복합 질의</td>
<td><strong>Agentic Retrieval</strong></td>
<td>질문을 subquery로 나누고 coverage를 넓힐 수 있다</td>
</tr>
<tr>
<td>문서 집합 전체의 핵심 주제, 전역 패턴, corpus-wide summary</td>
<td><strong>GraphRAG Global</strong></td>
<td>community summary 기반으로 전체 구조를 읽을 수 있다</td>
</tr>
<tr>
<td>여러 문서 사이의 관계, 연결, 맥락 추적</td>
<td><strong>GraphRAG DRIFT</strong></td>
<td>global signal과 local detail을 함께 가져갈 수 있다</td>
</tr>
<tr>
<td>그래프의 장점은 일부 가져가고 싶지만 비용이 아주 중요함</td>
<td><strong>LazyGraphRAG 계열</strong></td>
<td>graph 기반 retrieval의 무게를 줄이려는 최신 흐름이다</td>
</tr>
</tbody></table>
<p>짧고 명확한 fact 질의, FAQ, 정책 문답, 문서 검색은 <strong>hybrid retrieval + semantic rerank</strong>가 제일 튼튼한 기본값이다.</p>
<p>질문이 복합적이고 대화 맥락이 중요하고, 하나의 질문 안에 여러 요구가 섞여 있다면 <strong>agentic retrieval</strong>이 더 잘 맞는다.</p>
<p>문서 집합 전체의 핵심 주제, 전역 패턴, 여러 문서 사이의 관계, corpus-wide summary가 필요하다면 <strong>GraphRAG Global</strong>이나 <strong>DRIFT</strong>가 설계된 자리다.</p>
<p>비용이 아주 중요하면서 그래프의 장점은 일부 가져가고 싶다면, 지금 흐름상 흥미로운 방향은 <strong>LazyGraphRAG</strong>다. 다만 이건 아직 Microsoft 측 결과를 중심으로 읽어야 하고, 직접 검증 없이 정답처럼 말하기엔 조심스럽다.</p>
<p>코드처럼 적으면 대충 이런 느낌이다.</p>
<pre><code class="language-python">if question_type == &quot;local_fact&quot;:
    use = &quot;hybrid_retrieval + semantic_rerank&quot;
elif question_type == &quot;complex_conversational&quot;:
    use = &quot;agentic_retrieval&quot;
elif question_type == &quot;corpus_wide_summary&quot;:
    use = &quot;graphrag_global&quot;
elif question_type == &quot;relationship_bridge&quot;:
    use = &quot;graphrag_drift&quot;
else:
    use = &quot;hybrid_retrieval + semantic_rerank&quot;</code></pre>
<p>누가 지금 가장 좋은 RAG 하나만 골라보라고 하면, 나는 아마 이렇게 답할 것 같다.</p>
<p><strong>하나를 고르지 말고, 라우팅 기준부터 잡아야 한다.</strong></p>
<h2 id="다시-의료-ai-이야기로-돌아가면">다시, 의료 AI 이야기로 돌아가면</h2>
<p>내가 RAG를 다시 공부하게 된 이유도 여기로 돌아온다.</p>
<p>의료 AI 프로젝트에서 내가 원했던 건 더 유창하게 말하는 모델이 아니었다. 내가 원한 건 <strong>더 믿을 수 있는 모델</strong>이었다. 퇴원 후 환자 관리 지침을 정리할 때도, 복합 질환 환자의 위험 신호를 묶어볼 때도, 여러 기록 속에서 반복되는 패턴을 찾을 때도, 중요한 건 답변 속도보다 <strong>근거의 구조</strong>였다.</p>
<p>이 환자에게 지금 중요한 게 뭔지.<br>왜 그렇게 판단했는지.<br>어디까지는 말할 수 있고, 어디서부터는 더 확인해야 하는지.</p>
<p>이걸 답변 안에 담고 싶었다.</p>
<p>지금 와서 보면 그 문제의식이 자연스럽게 나를 RAG 쪽으로 끌고 왔다. RAG는 검색 몇 번 더 하는 기술이 아니다. 무엇을 찾을지, 어떻게 찾을지, 어떤 질문은 쪼개서 풀지, 어떤 질문은 문서 집합 전체를 구조로 이해할지, 어디서 멈출지를 설계하는 기술에 가깝다. 의료처럼 답변의 무게가 큰 영역에서는 이 차이가 더 크게 느껴진다.</p>
<p>그래서 내 기준에서 지금 가장 좋은 RAG는 “제일 새로운 이름의 RAG”가 아니다. 질문의 무게를 알고, 그 질문에 맞는 근거 수집 방식을 고르고, 필요하면 retrieval 전략을 바꿔 탈 수 있는 RAG다.</p>
<p>다음에 비슷한 프로젝트를 다시 하게 되면, 아마 예전처럼 무조건 vector search부터 붙이지는 않을 것 같다. 질문부터 나누고, 기본값은 hybrid + rerank로 두고, 코퍼스 전체를 읽어야 하는 질문만 graph 계열로 태워볼 생각이다. 한동안은 이 틀로 더 부딪혀 볼 것 같다.</p>
<hr>
<h2 id="참고한-자료">참고한 자료</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/azure/search/retrieval-augmented-generation-overview">Retrieval-augmented generation (RAG) in Azure AI Search</a></li>
<li><a href="https://learn.microsoft.com/en-us/azure/search/hybrid-search-overview">Hybrid search overview - Azure AI Search</a></li>
<li><a href="https://learn.microsoft.com/en-us/azure/search/agentic-retrieval-overview">Agentic Retrieval Overview - Azure AI Search</a></li>
<li><a href="https://microsoft.github.io/graphrag/">GraphRAG 공식 문서</a></li>
<li><a href="https://microsoft.github.io/graphrag/query/overview/">GraphRAG Query Overview</a></li>
<li><a href="https://microsoft.github.io/graphrag/query/drift_search/">GraphRAG DRIFT Search</a></li>
<li><a href="https://microsoft.github.io/graphrag/get_started/">GraphRAG Getting Started</a></li>
<li><a href="https://microsoft.github.io/graphrag/cli/">GraphRAG CLI Reference</a></li>
<li><a href="https://www.microsoft.com/en-us/research/blog/introducing-drift-search-combining-global-and-local-search-methods-to-improve-quality-and-efficiency/">Microsoft Research: Introducing DRIFT Search</a></li>
<li><a href="https://www.microsoft.com/en-us/research/blog/lazygraphrag-setting-a-new-standard-for-quality-and-cost/">Microsoft Research: LazyGraphRAG</a></li>
<li><a href="https://www.microsoft.com/en-us/research/blog/moving-to-graphrag-1-0-streamlining-ergonomics-for-developers-and-users/">Microsoft Research: Moving to GraphRAG 1.0</a></li>
<li><a href="https://github.com/microsoft/graphrag/releases">GraphRAG Releases</a></li>
<li><a href="https://arxiv.org/abs/2005.11401">Lewis et al., Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[성능만 보던 시선에서, On-device AI와 양자화를 다시 보게 되었다]]></title>
            <link>https://velog.io/@lova-clover/%EC%84%B1%EB%8A%A5%EB%A7%8C-%EB%B3%B4%EB%8D%98-%EC%8B%9C%EC%84%A0%EC%97%90%EC%84%9C-On-device-AI%EC%99%80-%EC%96%91%EC%9E%90%ED%99%94%EB%A5%BC-%EB%8B%A4%EC%8B%9C-%EB%B3%B4%EA%B2%8C-%EB%90%98%EC%97%88%EB%8B%A4-pc1ludph</link>
            <guid>https://velog.io/@lova-clover/%EC%84%B1%EB%8A%A5%EB%A7%8C-%EB%B3%B4%EB%8D%98-%EC%8B%9C%EC%84%A0%EC%97%90%EC%84%9C-On-device-AI%EC%99%80-%EC%96%91%EC%9E%90%ED%99%94%EB%A5%BC-%EB%8B%A4%EC%8B%9C-%EB%B3%B4%EA%B2%8C-%EB%90%98%EC%97%88%EB%8B%A4-pc1ludph</guid>
            <pubDate>Tue, 10 Mar 2026 16:33:41 GMT</pubDate>
            <description><![CDATA[<p>LG Aimers 8기에서 경량화 모델을 돌리던 시기, 나는 성능보다 먼저 자원 한계와 환경 문제를 더 크게 체감했다.</p>
<p>처음에는 당연하게도 성능 향상에만 시선이 가 있었다. 어떤 모델이 더 좋은지, 어떤 설정을 바꾸면 점수가 조금이라도 더 올라갈지, 그런 것들이 가장 중요해 보였다. 대회든 프로젝트든 결국 가장 먼저 보게 되는 건 숫자고, 가장 쉽게 흔들리는 것도 숫자이기 때문이다.</p>
<p>그런데 이상하게도, 그 시기에 내 머릿속에 가장 오래 남은 건 성능표가 아니었다.
오히려 훨씬 더 현실적인 문제들이었다. 버전은 자꾸 꼬였고, 라이브러리는 예상보다 쉽게 충돌했고, 모델이 조금만 커져도 VRAM은 빠르게 바닥을 드러냈다. 처음에는 그냥 늘 있는 시행착오라고 생각했다. “환경이 좀 꼬였네”, “이번만 넘기면 되겠지” 정도로 넘기려 했다.</p>
<p>하지만 비슷한 문제가 반복되면서 생각이 조금씩 바뀌기 시작했다.
단순히 성능이 좋은 모델을 찾는 것과, 그 모델을 실제 환경에서 다루는 것은 전혀 다른 문제라는 점이 점점 더 또렷하게 보였다. 좋은 모델이라는 말은 충분히 매력적이지만, 실제로 서비스를 만들거나 실험을 이어가려면 성능표 바깥의 조건들도 함께 봐야 했다. <strong>메모리 사용량, 추론 비용, 지연 시간, 라이브러리 호환성, 배포 방식</strong> 같은 것들 말이다.</p>
<p>그때부터 관심사가 달라졌다.
예전에는 “무슨 모델이 더 좋을까?”를 먼저 물었다면, 이제는 <strong>“이 모델은 어떤 환경에서, 어떤 비용으로, 어떤 제약 속에서 다뤄야 할까?”</strong>를 함께 생각하게 됐다. 성능을 보는 눈이 사라진 건 아니었다. 다만 그 위에 조금 더 현실적인 질문들이 얹히기 시작한 것이다.</p>
<p>이 글은 바로 그 변화에서 시작된 기록이다. 성능 좋은 모델만 보던 시선에서 조금 벗어나, On-device AI와 양자화(Quantization)를 다시 보게 된 과정. 그리고 그 과정에서 “좋은 모델”을 바라보는 기준이 어떻게 달라졌는지를 정리해보려 한다.</p>
<hr>
<h2 id="왜-다시-on-device-ai를-보게-됐나">왜 다시 On-device AI를 보게 됐나</h2>
<p>처음에는 솔직히 On-device AI가 그렇게까지 크게 와닿지 않았다. 클라우드 API를 쓰면 되는 것 아닌가, 하는 생각이 더 강했다. 실제로 빠르게 기능을 붙여보거나 프로토타입을 만들 때는 그 방식이 분명 강력하다. 잘 정리된 모델을 호출해서 사용하면 되고, 무거운 계산은 서버가 처리하니 내 로컬 환경이 감당해야 하는 부담도 줄어든다.</p>
<p>하지만 자료를 더 찾아보고, 실제 서비스 구조를 조금 더 진지하게 상상해보니 그렇게 단순하게 볼 문제는 아니었다.</p>
<p>클라우드 의존은 분명 편리하지만, 그만큼 <strong>지연 시간(Latency)</strong> 문제를 끌고 간다. 응답이 서버를 왕복하는 구조에서는 네트워크 상태에 영향을 받을 수밖에 없고, 사용량이 늘어나면 <strong>비용 문제</strong>도 점점 더 현실적인 부담이 된다. 여기에 입력 데이터를 계속 외부 서버로 보내야 한다는 점에서 <strong>프라이버시나 데이터 통제 문제</strong>도 함께 따라온다.</p>
<p>그제야 On-device AI가 다르게 보이기 시작했다. 예전에는 그냥 “폰에서도 돌아간다”는 식의 기술 데모처럼 느껴졌다면, 이제는 지연 시간·비용·프라이버시 같은 실제 문제를 줄이기 위한 현실적인 선택지로 보였다.</p>
<blockquote>
<p>즉, 온디바이스 AI는 단순히 클라우드를 대체하는 구호가 아니라, <strong>어떤 조건에서는 더 적절한 시스템 설계 방식</strong>에 가까웠다.</p>
</blockquote>
<p>이 지점이 내게 꽤 크게 남았다. 모델을 본다는 건 단순히 성능표를 읽는 일이 아니라, 그 모델이 놓일 환경과 제약까지 함께 보는 일이라는 점을 조금씩 이해하게 됐기 때문이다. 그리고 그 순간부터, On-device AI는 더 이상 “작은 모델 이야기”가 아니라 <strong>실제 AI 시스템을 어떻게 설계하고 배치할 것인가에 대한 이야기</strong>로 느껴지기 시작했다.</p>
<hr>
<h2 id="양자화는-단순-압축보다-훨씬-정교한-문제였다">양자화는 ‘단순 압축’보다 훨씬 정교한 문제였다</h2>
<p>On-device AI나 경량 배포를 생각하면 자연스럽게 양자화라는 개념을 만나게 된다.
처음에는 나도 이걸 꽤 단순하게 이해했다. 숫자의 정밀도를 낮춰서 모델을 가볍게 만드는 기술. FP32를 INT8이나 INT4 같은 저비트 표현으로 바꾸면 메모리 사용량과 계산 비용이 줄어든다. 얼핏 보면 이 설명만으로도 충분해 보인다.</p>
<p>그런데 조금만 더 파고들면, 양자화는 그렇게 단순한 이야기가 아니었다.</p>
<p>모델 안의 값들이 모두 같은 중요도를 가지는 건 아니었다. 어떤 값은 조금 손실이 생겨도 큰 문제가 없지만, 어떤 값은 성능에 훨씬 민감하게 작용한다. 특히 LLM처럼 규모가 큰 모델에서는 일부 아웃라이어(Outlier)나 중요한 채널을 어떻게 다루느냐가 결과에 큰 차이를 만든다.</p>
<p>결국 양자화의 핵심은 “얼마나 많이 줄이느냐”보다, <strong>무엇을 줄이고 무엇을 보호해야 덜 무너지는가</strong>를 판단하는 데 더 가까웠다.</p>
<p>이걸 이해하고 나서 양자화를 보는 시선도 바뀌었다. 처음엔 단순히 모델을 압축하는 요령처럼 느껴졌는데, 공부할수록 오히려 성능을 최대한 지켜내기 위한 정교한 설계처럼 보였다. 무작정 작게 만드는 게 아니라, 제한된 자원 속에서도 모델의 핵심 능력을 어떻게 보존할지 고민하는 기술이라는 점이 더 크게 다가왔다.</p>
<p>이 부분이 특히 흥미로웠다. 경량화라는 말을 들으면 보통 타협이나 희생이 먼저 떠오르기 쉽다. 하지만 양자화는 꼭 그런 식으로만 보이지 않았다. 오히려 무엇을 포기할 수 있고, 무엇은 끝까지 지켜야 하는지를 구분해야 한다는 점에서 꽤 섬세한 최적화 문제에 가까웠다. 단순한 축소가 아니라, <strong>제약 속에서 성능을 어떻게 유지할 것인가</strong>를 다루는 방법이었다.</p>
<hr>
<h2 id="ptq와-qat를-보며-언제-줄일-것인가도-중요하다는-걸-알게-됐다">PTQ와 QAT를 보며, ‘언제 줄일 것인가’도 중요하다는 걸 알게 됐다</h2>
<p>양자화를 조금 더 보다 보니, 단순히 몇 비트로 줄일 것인가보다 <strong>언제 줄일 것인가</strong>도 중요하다는 걸 알게 됐다. 대표적으로는 PTQ와 QAT가 있다.</p>
<ul>
<li><strong>PTQ(Post-Training Quantization):</strong> 이미 학습이 끝난 모델을 가져와 사후적으로 양자화하는 방식</li>
<li><strong>QAT(Quantization-Aware Training):</strong> 학습 단계부터 양자화 오차를 고려하며 모델을 적응시키는 방식</li>
</ul>
<p>내 입장에서는 PTQ가 훨씬 현실적으로 느껴졌다.
이미 학습된 모델을 가져와 줄여보는 건 시도해볼 만하지만, 거대한 모델을 다시 학습시키거나 양자화 인지 학습을 길게 돌리는 건 개인 환경에서는 부담이 크기 때문이다. 시간도 자원도 빠듯한 상황에서는, 빠르게 실험하고 적용해볼 수 있는 방식이 먼저 눈에 들어올 수밖에 없다.</p>
<p>반면 QAT가 왜 계속 중요하게 언급되는지도 이해됐다.
비트를 더 공격적으로 낮출수록, 특히 4비트 이하처럼 더 극단적인 경량화로 갈수록 단순한 사후 압축만으로는 품질을 안정적으로 지키기 어려워질 수 있다. 그럴수록 학습 단계부터 양자화 오차를 반영해 적응시킨 모델이 더 강하게 버틸 수 있다.</p>
<p>결국 둘 중 하나가 절대적으로 우월하다기보다는, <strong>내가 어떤 자원 조건에 놓여 있고, 어떤 목적을 가지고 모델을 다루는지에 따라 선택지가 달라진다는 점</strong>이 더 중요하게 느껴졌다. 빠르게 적용하고 싶은지, 더 낮은 비트까지 밀어붙이고 싶은지, 추론이 목적인지 파인튜닝까지 고려하는지에 따라 답이 달라진다.</p>
<p>이걸 이해하면서 양자화는 더 이상 단순한 기술 용어가 아니게 됐다. 모델을 어떤 조건에서 다룰 것인지, 그리고 내가 감당할 수 있는 비용과 목표가 무엇인지를 함께 묻는 질문처럼 느껴졌다.</p>
<hr>
<h2 id="공부하며-인상-깊었던-네-가지-흐름">공부하며 인상 깊었던 네 가지 흐름</h2>
<p>양자화 기법을 보다 보면 이름이 정말 많이 나온다. 처음에는 다 비슷해 보였다. 전부 성능 손실을 줄이면서 저비트로 압축하겠다는 이야기처럼 보였기 때문이다. 그런데 계속 보다 보니, 각 방법이 중요하게 보는 지점이 조금씩 다르다는 게 보이기 시작했다.</p>
<ul>
<li><strong>LLM.int8():</strong> 가장 먼저 직관적으로 와닿았던 방식이다. 이 방식은 모든 값을 일괄적으로 8비트로 눌러버리지 않고, 중요한 일부 차원은 더 높은 정밀도로 유지한다. 이 단순한 아이디어 하나만으로도 양자화를 보는 시각이 달라졌다. 모두를 똑같이 줄이면 안 된다는 점, 중요한 부분은 끝까지 지켜야 한다는 점이 아주 선명하게 느껴졌기 때문이다.</li>
<li><strong>GPTQ:</strong> 저비트 변환 과정에서 생기는 오차를 그냥 감수하지 않고, 전체 손실을 줄이는 방향으로 보정하려 한다. 즉, 단순히 낮은 비트로 바꾸는 게 아니라, 그 과정에서 생긴 문제를 어떻게 덜 치명적으로 만들 것인가를 적극적으로 고민한다. 이 접근은 “양자화는 변환이 아니라 최적화”라는 느낌을 더 강하게 줬다.</li>
<li><strong>AWQ:</strong> 정적인 가중치 값만 보는 것이 아니라, 실제 활성값(Activation) 관점에서 중요한 채널을 보호한다. 다시 말해, 저장된 숫자만 보는 게 아니라 모델이 실제로 어떻게 반응하는지를 기준으로 중요도를 판단한다. 모델을 정적인 덩어리가 아니라 실제로 움직이는 시스템으로 보게 만들었다는 점에서 꽤 실전적으로 느껴졌다.</li>
<li><strong>QLoRA:</strong> 양자화를 추론 최적화에만 묶어두지 않는다는 점에서 흥미로웠다. 양자화된 기반 모델 위에 LoRA 어댑터를 학습시키는 방식은, 제한된 자원 안에서도 파인튜닝 가능성을 열어준다는 점에서 꽤 상징적으로 보였다. “무거워서 못 한다”로 끝나는 것이 아니라, 자원이 부족해도 다른 방식으로 접근할 수 있다는 가능성을 보여줬기 때문이다.</li>
</ul>
<p>이 네 가지를 보다 보니, 양자화는 하나의 정답이라기보다 <strong>무엇을 보호하고 무엇을 감수할 것인가에 대한 서로 다른 전략들</strong>처럼 느껴졌다. 그리고 바로 그 점이 이 분야를 더 흥미롭게 만들었다.</p>
<hr>
<h2 id="4-bit-그-너머를-보게-되면서-양자화가-현재진행형이라는-걸-알게-됐다">4-bit 그 너머를 보게 되면서, 양자화가 ‘현재진행형’이라는 걸 알게 됐다</h2>
<p>처음에는 INT8이나 INT4 정도만 이해해도 충분히 큰 그림을 본 것처럼 느껴졌다. 그런데 자료를 더 보다 보니, 오히려 그 반대였다. 양자화는 이미 정리된 기술이 아니라, 지금도 계속 빠르게 진화하는 영역이었다.</p>
<p>가중치만 줄이는 게 아니라 활성값까지 함께 다루려는 시도가 있었고, 추론 과정에서 중요한 KV 캐시까지 저비트로 압축하려는 흐름도 이어지고 있었다. 더 나아가 4비트를 넘어 3비트, 2비트 수준까지 내려가면서도 품질을 최대한 방어하려는 연구도 계속 나온다.</p>
<p>이 흐름을 보면서 든 생각은 단순했다.
이제는 모델을 얼마나 크게 만들 수 있는가만큼, <strong>그 모델을 얼마나 효율적으로 운용할 수 있는가</strong>도 점점 더 중요해지고 있다는 것이다.</p>
<p>앞으로 모델은 계속 강해질 것이다. 하지만 강한 모델이 곧바로 좋은 시스템이 되는 건 아니다. 실제 환경에서는 자원, 비용, 속도, 배포성, 안정성 같은 요소가 늘 함께 움직인다. 그런 점에서 양자화는 “있으면 좋은 최적화 기술”이 아니라, AI를 더 넓은 환경에서 사용하기 위해 반드시 같이 발전해야 하는 핵심 기술처럼 보였다.</p>
<hr>
<h2 id="그리고-결국-이론과-실전은-정말-다르더라">그리고 결국, 이론과 실전은 정말 다르더라</h2>
<p>논문과 정리 글을 읽을 때까지만 해도, 어느 정도는 이해했다고 생각했다. PTQ와 QAT의 차이도 알겠고, GPTQ와 AWQ가 무엇을 다르게 보는지도 대략은 구분할 수 있었다. 그런데 막상 로컬 환경에서 실제로 적용하려고 하니, 문제는 전혀 다른 얼굴로 나타났다.</p>
<p>가장 먼저 체감한 건 도구 생태계의 복잡함이었다.
예전에 잘 되었다는 튜토리얼이 지금은 그대로 동작하지 않는 경우가 많았고, 블로그 글 하나만 믿고 따라가면 버전 호환성 문제에서 쉽게 막혔다. 어떤 패키지는 설치되지만 실행이 안 됐고, 어떤 조합은 실행은 되지만 특정 환경에서 다시 깨졌다. 논문을 읽을 때는 매끈해 보였던 흐름이, 실제로는 파일 포맷, 의존성, 하드웨어, 로더 호환성 같은 문제들과 얽혀 있었다.</p>
<p>이걸 겪으면서 아주 분명하게 느낀 게 있다.</p>
<blockquote>
<p><strong>이론을 아는 것과, 실제로 내 환경에서 모델을 안정적으로 다루는 것은 전혀 다른 문제다.</strong></p>
</blockquote>
<p>논문은 개념을 알려주지만, 실제 환경은 훨씬 더 많은 걸 요구한다. 라이브러리 버전, CUDA 환경, 포맷 변환, 메모리 관리, 추론 백엔드 선택, 운영체제 차이까지 전부 얽혀 있기 때문이다. 결국 양자화는 알고리즘을 이해하는 것만으로 끝나는 주제가 아니었다. 하드웨어와 소프트웨어 생태계를 함께 이해해야 비로소 “현실적인 기술”이 된다.</p>
<p>오히려 그래서 더 재미있었다. 논문을 읽고 개념을 아는 단계에서 끝나는 게 아니라, 실제로 굴러가는 구조를 만들기 위한 공부로 이어졌기 때문이다. 그리고 아마 내가 이 주제에 더 끌리게 된 이유도 바로 거기에 있을 것이다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>처음에는 단순히 성능을 더 올리고 싶었다.
그런데 계속 부딪히고 공부하다 보니, 성능만으로는 설명되지 않는 문제들이 훨씬 더 많이 보이기 시작했다. 모델이 어떤 환경에서 돌아가는지, 어떤 자원을 요구하는지, 어떤 방식으로 배포되고 유지될 수 있는지까지 함께 보지 않으면, “좋은 모델”이라는 말도 생각보다 쉽게 공중에 뜬다는 걸 조금씩 이해하게 됐다.</p>
<p>이제는 새로운 모델을 볼 때 예전과는 조금 다른 질문을 하게 된다.
벤치마크 점수는 여전히 중요하다. 하지만 그와 함께 이 모델이 어떤 환경을 전제로 하는지, 어떤 최적화가 가능한지, 실제 시스템 안에서는 어떤 장단점을 가질지를 같이 보게 된다. 성능을 버린 것이 아니라, 성능만 보던 시선에서 조금 더 넓은 시선으로 이동한 셈이다.</p>
<p>돌아보면, VRAM 에러나 환경 충돌 같은 경험들은 단순히 귀찮은 삽질이 아니었다. 오히려 그 경험들이 모델을 바라보는 기준을 더 입체적으로 바꿔줬다. 화려한 숫자만 보던 시선에서 조금 벗어나, 제약과 조건까지 함께 보는 쪽으로 생각이 이동한 것이다.</p>
<p>아직도 모르는 것이 훨씬 많다.
하지만 적어도 하나는 분명하다. 이제 나는 “가장 높은 점수를 내는 모델”만 보는 사람보다는, <strong>그 모델이 실제 환경 안에서 어떤 의미를 가지는지까지 함께 보려는 사람</strong>에 더 가까워지고 있다.</p>
<p>그리고 아마, 진짜 공부는 모델의 점수만이 아니라 그 모델이 놓일 현실까지 함께 보게 되는 순간부터 시작되는 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[바이브 코딩은 빠르다. 그래서 더 위험하다 — VibeOps 해커톤 탈락 회고]]></title>
            <link>https://velog.io/@lova-clover/%EB%B0%94%EC%9D%B4%EB%B8%8C-%EC%BD%94%EB%94%A9%EC%9D%80-%EB%B9%A0%EB%A5%B4%EB%8B%A4.-%EA%B7%B8%EB%9E%98%EC%84%9C-%EB%8D%94-%EC%9C%84%ED%97%98%ED%95%98%EB%8B%A4-VibeOps-%ED%95%B4%EC%BB%A4%ED%86%A4-%ED%83%88%EB%9D%BD-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@lova-clover/%EB%B0%94%EC%9D%B4%EB%B8%8C-%EC%BD%94%EB%94%A9%EC%9D%80-%EB%B9%A0%EB%A5%B4%EB%8B%A4.-%EA%B7%B8%EB%9E%98%EC%84%9C-%EB%8D%94-%EC%9C%84%ED%97%98%ED%95%98%EB%8B%A4-VibeOps-%ED%95%B4%EC%BB%A4%ED%86%A4-%ED%83%88%EB%9D%BD-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Mon, 09 Mar 2026 09:20:57 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요, Lova-clover입니다!</p>
<p>얼마 전 참여했던 &#39;월간 해커톤: 바이브 코딩 개선 AI 아이디어 공모전&#39;에서 아쉽게도 고배를 마셨습니다. 🥲</p>
<p>하지만 이번 프로젝트를 진행하며, 수상을 뛰어넘는 훨씬 더 선명하고 값진 깨달음을 얻었습니다. <strong>&quot;AI가 코드를 빨리 만들어주는 것과, 팀이 안전하게 소프트웨어를 만드는 것은 전혀 다른 문제&quot;</strong>라는 점입니다.</p>
<p>이번 해커톤에서 제가 제안한 <strong>VibeOps</strong>는 그 위험한 간극을 메우기 위해 고민했던 PoC(개념 증명) 프로젝트입니다. 이 글을 관통하는 핵심 메시지는 단 하나입니다.</p>
<blockquote>
<p><strong>&quot;AI에게 코드를 맡기기 전에, 먼저 스펙부터 확정하자.&quot;</strong></p>
</blockquote>
<p>(아래는 제가 기획하고 구현한 VibeOps의 메인 화면입니다)</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/4885f21f-93d7-4713-8980-47a78ea75951/image.jpeg" alt="VibeOps 메인화면"></p>
<hr>
<h2 id="📌-한눈에-요약하는-vibeops">📌 한눈에 요약하는 VibeOps</h2>
<ul>
<li><strong>문제</strong>: 바이브 코딩은 &quot;만들어줘 → 생성 → 커밋&quot;으로 흘러가기 쉬워, 필수적인 품질 게이트가 무너집니다.</li>
<li><strong>해결</strong>: 코드 생성 전 &#39;스펙 확정&#39; 및 &#39;정책 충돌 검증&#39;, 생성 후 &#39;품질 검증&#39;, 그리고 전 과정의 &#39;자동 문서화&#39; 파이프라인을 기획했습니다.</li>
<li><strong>구현 범위 (PoC)</strong>: 웹 데모, <code>.vibe</code> 로컬 파일 구조, 프로젝트 컨텍스트 로드, 키워드 기반 제약 조건 위반 감지, CLI 초기화 기능.</li>
<li><strong>한계점</strong>: <code>vibe why</code>의 실제 히스토리 조회, 고도화된 Validate 엔진, 전체 CLI 통합은 아직 아이디어 단계에 머물렀습니다.</li>
<li><strong>회고</strong>: 문제 정의는 날카로웠으나, 심사위원을 완전히 납득시킬 만한 &#39;실사용 증거&#39;와 &#39;구현의 완성도&#39;가 부족했습니다.</li>
</ul>
<hr>
<h2 id="1-바이브-코딩-속도에-가려진-4가지-함정">1. 바이브 코딩, 속도에 가려진 4가지 함정</h2>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/eb675e74-31a6-431a-89d8-90ef70e22997/image.jpeg" alt="문제 정의"></p>
<p>Cursor나 Copilot 같은 AI 코딩 도구를 쓰다 보면 확실히 체감합니다. <strong>코드는 정말 무섭게 쏟아져 나옵니다.</strong></p>
<p>문제는 그다음입니다. 전통적인 개발은 <code>요구사항 분석 → 설계 → 개발 → 테스트 → 리뷰</code>라는 안전망을 거치지만, 바이브 코딩은 자칫하면 <code>만들어줘 → 생성 → 커밋</code>으로 끝나버립니다.</p>
<p>속도는 얻었지만, 이 과정에서 실무에 치명적인 4가지 문제가 발생합니다.</p>
<ol>
<li><strong>의도 불일치</strong>: 모호한 자연어 요청은 결국 AI가 빈칸을 마음대로 추측하게 만듭니다. 로그인 기능을 원했는데, 인증이나 해싱 방식까지 AI가 멋대로 정해버리죠.</li>
<li><strong>정책 위반</strong>: 프로젝트마다 &#39;OAuth 금지&#39;, &#39;외부 CDN 사용 금지&#39; 같은 금지 규칙이 있습니다. 하지만 AI는 팀의 내밀한 정책을 모른 채 코드를 짜버립니다.</li>
<li><strong>일관성 붕괴</strong>: 어떤 날은 함수형으로, 어떤 날은 클래스형으로 코드를 내뱉습니다. 생성은 빨랐는데 유지보수 지옥이 열립니다.</li>
<li><strong>맥락 소실 (가장 치명적)</strong>: 대화 세션이 끝나면 &quot;왜 이런 구조를 선택했는지&quot;, &quot;왜 이 제약을 넣었는지&quot;에 대한 개발자의 결정 근거가 영구히 날아갑니다.</li>
</ol>
<p>결국 이 문제의 본질은 &quot;AI가 얼마나 똑똑한가&quot;가 아니라, <strong>&quot;파이프라인 내에 통제 메커니즘이 없다&quot;</strong>는 것이었습니다.</p>
<hr>
<h2 id="2-기존-도구들로는-왜-부족했을까">2. 기존 도구들로는 왜 부족했을까?</h2>
<p>처음엔 저도 <code>.cursorrules</code>나 시스템 프롬프트를 잘 작성하면 해결되지 않을까 생각했습니다. 하지만 금방 한계에 부딪혔습니다.</p>
<ul>
<li><strong>사전 지시만으로는 부족합니다:</strong> 요청 자체가 기존 사내 정책과 충돌하는지 코드를 만들기 전에 감지할 수 없습니다.</li>
<li><strong>정적 분석만으로는 부족합니다:</strong> ESLint나 SonarQube는 문법과 스타일은 잡아내지만, &quot;이 코드가 애초에 기획 의도와 맞는가?&quot;는 판단하지 못합니다.</li>
<li><strong>문서 도구만으로는 부족합니다:</strong> README나 Notion은 결국 사람이 따로 써야 합니다. 바쁘면 가장 먼저 생략되는 작업 1순위죠.</li>
</ul>
<p>즉, 단순한 파편화된 도구가 아니라 <strong>코드 생성의 흐름 자체를 제어하는 &#39;파이프라인&#39;</strong>이 필요했습니다.</p>
<hr>
<h2 id="3-vibeops-생성-전후에-품질-게이트를-세우다">3. VibeOps: 생성 전후에 품질 게이트를 세우다</h2>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/0e938fc5-90a7-428a-a016-d501962be377/image.jpeg" alt="5단계 파이프라인"></p>
<p>해결책은 DevOps의 개념을 빌려온 5단계 파이프라인, <strong>VibeOps</strong>였습니다.</p>
<h3 id="🔒-생성-전-제어-spec--verify">🔒 생성 전 제어 (Spec &amp; Verify)</h3>
<ul>
<li><strong>Stage 1. Spec Gate</strong>: &quot;로그인 만들어줘&quot;라는 모호한 요청을 즉시 구조화된 YAML 스펙(JWT 발급, bcrypt 해싱 등)으로 변환합니다. AI가 멋대로 구현하기 전에, <strong>코드가 아닌 스펙을 먼저 확정</strong>합니다.</li>
<li><strong>Stage 2. Verify Gate</strong>: 새로운 요청이 기존 정책과 충돌하는지 사전 검사합니다. &quot;소셜 로그인 추가&quot;를 요청했을 때 사내 규정에 <code>OAuth 금지</code>가 있다면, 엉뚱한 코드를 짜기 전에 경고를 띄웁니다.</li>
</ul>
<h3 id="⚙️-코드-생성-및-검증-generate--validate">⚙️ 코드 생성 및 검증 (Generate &amp; Validate)</h3>
<ul>
<li><strong>Stage 3. Generate</strong>: 단순 프롬프트가 아니라 <code>확정된 스펙 + 기존 결정 사항 + 프로젝트 컨텍스트</code>를 종합하여 코드를 생성합니다.</li>
<li><strong>Stage 4. Validate Gate</strong>: 문법 검사를 넘어, 방금 생성된 코드가 Stage 1에서 합의한 스펙을 완벽히 따르고 있는지, 금지된 정책을 쓰진 않았는지 대조 검증합니다.</li>
</ul>
<h3 id="📚-영구적-맥락-보존-auto-document">📚 영구적 맥락 보존 (Auto Document)</h3>
<ul>
<li><strong>Stage 5. Auto Document</strong>: 이 모든 스펙, 결정, 제약 조건이 프로젝트 내 <code>.vibe</code> 디렉토리에 자동 저장됩니다. 터미널에 <code>vibe why src/auth/login.py</code>를 입력하면 &quot;왜 JWT를 썼고, 왜 소셜 로그인은 뺐는지&quot; 과거의 결정 맥락을 즉시 꺼내볼 수 있도록 구상했습니다.</li>
</ul>
<hr>
<h2 id="4-어디까지-구현했는가-냉정한-poc-회고">4. 어디까지 구현했는가? (냉정한 PoC 회고)</h2>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/23466c90-27d6-465f-8ea4-5b4bc39da507/image.jpeg" alt=""></p>
<p>글이 너무 과장되는 것을 경계하고자 합니다. 이 프로젝트는 완성된 상용 툴이 아닌 <strong>PoC</strong>였으며, 제가 실제로 해커톤 기간 내에 구현한 범위는 다음과 같습니다.</p>
<p><strong>✅ 구현 완료 (PoC 범위)</strong></p>
<ul>
<li><code>.vibe/context.yaml</code>, <code>constraints.yaml</code> 등 환경 파일 로드 로직</li>
<li>프로젝트 컨텍스트 기반의 프롬프트 조합</li>
<li>키워드 기반의 제약 조건 위반 감지 시스템</li>
<li><code>vibe init</code> 형태의 초기 템플릿 생성</li>
<li>전체 파이프라인 흐름을 시각화한 웹 데모</li>
</ul>
<p><strong>🚧 미구현 / 과제</strong></p>
<ul>
<li>가장 강력한 무기로 기획했던 <code>vibe why</code>의 실제 히스토리 추적 및 출력 로직</li>
<li>구문 분석과 스펙 대조를 아우르는 고도화된 Validate 엔진</li>
<li>실제 IDE와 매끄럽게 연동되는 전체 CLI 흐름</li>
</ul>
<hr>
<h2 id="5-실무에-도입된다면-가설-시나리오">5. 실무에 도입된다면? (가설 시나리오)</h2>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/1bf50cdf-2120-4b95-b17f-1d0ffbfe165e/image.jpeg" alt="실시간 데모"></p>
<p>비록 PoC 단계지만, 이 파이프라인이 실제 팀에 도입된다면 분명한 임팩트를 낼 수 있다고 가설을 세웠습니다.</p>
<ul>
<li><strong>정책 위반 사전 차단</strong>: AI가 금지된 OAuth 코드를 잔뜩 생성해버려, 나중에 PR 리뷰에서 걸려 4시간을 통으로 날리는 헛수고를 방지합니다. <strong>검증 게이트가 코드 생성 전에 충돌을 감지해 올바른 방향을 제시할 수 있습니다.</strong></li>
<li><strong>신규 팀원 온보딩 단축</strong>: 레거시 코드를 보며 &quot;선배님, 이건 왜 이렇게 만들었어요?&quot;라고 묻는 대신, 스펙 히스토리를 직접 조회해 개발의 맥락을 스스로 빠르게 흡수할 수 있습니다.</li>
</ul>
<hr>
<h2 id="6-심사위원을-설득하지-못한-이유와-배움">6. 심사위원을 설득하지 못한 이유와 배움</h2>
<p>야심 찬 기획이었지만 결과적으로 탈락했습니다. 스스로 분석해 본 패인은 명확합니다.</p>
<ol>
<li><strong>기획과 구현 사이의 간극</strong>: &quot;스펙을 자동 문서화하고 <code>vibe why</code>로 꺼내본다&quot;는 메시지는 강렬했지만, 그것이 실제로 매끄럽게 동작하는 완벽한 데모를 보여주기엔 구현의 완성도가 턱없이 부족했습니다.</li>
<li><strong>실사용 검증(Data)의 부재</strong>: &quot;시간을 줄일 수 있다&quot;는 논리를 넘어, 실제로 이 CLI 툴을 소규모 팀 저장소에 붙여봤더니 &quot;이만큼의 재작업이 방지되더라&quot;는 날것의 데이터가 있었다면 훨씬 설득력이 있었을 것입니다.</li>
</ol>
<p>비록 해커톤 수상 목록에는 이름을 올리지 못했지만, 개발자로서 제 시야는 한층 넓어졌습니다.</p>
<p>AI가 아무리 코드를 눈부신 속도로 뽑아내는 시대가 오더라도, <strong>&quot;무엇을 만들지 명확히 정의(Spec)하고, 시스템적으로 검증(Verify)하며, 그 결정의 이유를 기록(Document)한다&quot;</strong>는 소프트웨어 엔지니어링의 본질은 절대 변하지 않을 것입니다. 오히려 코딩 속도가 빨라질수록, 이런 &#39;품질 게이트&#39;의 가치는 더욱 빛을 발하겠죠.</p>
<p>이번 경험을 거름 삼아, 앞으로는 기획을 넘어 <strong>&quot;실제로 현장에서 작동하는 도구&quot;</strong>를 깎아내는 데 더 집중해보려 합니다.</p>
<p>긴 글 읽어주셔서 감사합니다. 아이디어에 대한 여러분의 다양한 피드백과 논의는 언제든 대환영입니다! 🚀</p>
<p>🔗 <strong>관련 링크</strong></p>
<ul>
<li><strong>GitHub</strong>: <a href="https://github.com/Lova-clover/VibeOps">Lova-clover/VibeOps</a></li>
<li><strong>Demo</strong>: <a href="https://vibeops-rho.vercel.app/">VibeOps Vercel Deploy</a></li>
<li><strong>기획서 1차</strong>: <a href="https://daker.ai/public/hackathons/vibe-coding-improvement-ai-idea-competition/submissions/66915008-d2f1-4155-aa22-e57064b41b50">VibeOps - 바이브 코딩을 위한 품질 파이프라인</a></li>
<li><strong>기획서 2차(발표자료)</strong>: <a href="https://daker.ai/public/hackathons/vibe-coding-improvement-ai-idea-competition/submissions/94f20f0f-3981-46c2-8cd5-5a1f3460dc81">VibeOps - 바이브 코딩을 위한 품질 파이프라인</a></li>
<li><strong>대회사이트</strong>: <a href="https://daker.ai/public/hackathons/vibe-coding-improvement-ai-idea-competition">월간 해커톤: 바이브 코딩 개선 AI 아이디어 공모전</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[기능은 늘었는데, 왜 서비스는 더 불편해졌을까?]]></title>
            <link>https://velog.io/@lova-clover/%EA%B8%B0%EB%8A%A5%EC%9D%80-%EB%8A%98%EC%97%88%EB%8A%94%EB%8D%B0-%EC%99%9C-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%8A%94-%EB%8D%94-%EB%B6%88%ED%8E%B8%ED%95%B4%EC%A1%8C%EC%9D%84%EA%B9%8C-yupab322</link>
            <guid>https://velog.io/@lova-clover/%EA%B8%B0%EB%8A%A5%EC%9D%80-%EB%8A%98%EC%97%88%EB%8A%94%EB%8D%B0-%EC%99%9C-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%8A%94-%EB%8D%94-%EB%B6%88%ED%8E%B8%ED%95%B4%EC%A1%8C%EC%9D%84%EA%B9%8C-yupab322</guid>
            <pubDate>Sun, 08 Mar 2026 03:12:48 GMT</pubDate>
            <description><![CDATA[<p>서비스를 만들다 보면 어이없는 순간을 마주하게 된다.</p>
<p>분명 예전보다 더 열심히 짰고,
기능도 잔뜩 넣었고,
설명도 최대한 친절하게 썼고,
예외 케이스도 다 커버했는데…</p>
<p>막상 써보면 이상하게 <strong>더 불편해진</strong> 느낌이 드는 것이다.</p>
<p>처음엔 진심으로 이해가 안 됐다.
“기능이 이렇게 많아졌는데 왜 더 별로지?”
“이 정도면 당연히 쓰기 좋아졌어야 하는 거 아냐?”
“나 진짜 밤새워서 열심히 했는데, 왜 유저는 피곤해하지?”</p>
<p>요즘 프로젝트를 정리하면서 이 생각이 계속 맴돌았는데, 
결국 내 착각이었다는 걸 한 문장으로 깨달았다.</p>
<blockquote>
<p>아, 좋은 서비스는 기능을 더 구겨 넣는 게 아니라,
사용자가 망설이는 순간을 지워주는 것이구나. </p>
</blockquote>
<hr>
<h3 id="👀-사람들은-기능보다-느낌을-먼저-기억한다">👀 사람들은 기능보다 ‘느낌’을 먼저 기억한다</h3>
<p>예전엔 나도 서비스를 설명할 때 기능부터 죽 나열했다.
“이 기술 썼고요, 이건 자동화됐고요, 여기까지 지원합니다.”</p>
<p>그런데 막상 내가 다른 앱을 쓸 때나 유저들 반응을 보면, 
그 기능표를 하나도 기억하지 않았다.</p>
<p>그냥
편했는지,
괜히 결제될까 봐 긴장되진 않았는지,
처음 들어왔을 때 대충 감이 왔는지,
생각할 게 많아서 피곤했는지.</p>
<p>딱 이런 감정들만 훨씬 오래 남았다.</p>
<p>진짜 잘 만든 서비스는 유저에게 “와, 기능 미쳤다” 소리를 듣는 게 아니라, 
“어? 이거 왜 이렇게 편하지?” 하게 만들더라.</p>
<p>평소 자주 쓰는 앱들을 떠올려 보면 다 비슷하다. 
처음 들어가도 뭘 해야 할지 금방 보이고, 굳이 설명서를 안 찾아봐도 되고, 
버튼을 누를 때 왠지 마음이 편안하다.</p>
<p>반대로 기능은 화려한데 이상하게 손이 안 가는 서비스들도 많았다. 
선택지가 너무 많아서 오히려 결정을 못 하겠고, 설명은 친절한데 구조가 엉망이라 “이거 누르는 거 맞나?” 하며 한 번 더 멈칫하게 된다.</p>
<p>결국 사람들은 기능을 소비하는 게 아니라, 
그 기능이 주는 <strong>느낌</strong>을 기억하는 거였다. 
오래 남는 건 기능의 개수가 아니라 “이 서비스 덕분에 내가 덜 지쳤구나” 하는 기억이다.</p>
<hr>
<h3 id="💡-좋은-서비스는-유저의-생각-비용을-대신-내준다">💡 좋은 서비스는 유저의 ‘생각 비용’을 대신 내준다</h3>
<p>개발을 하다 보면 욕심이 생기는 건 어쩔 수 없다. 나도 매번 그러니까.
“이 기능도 넣으면 대박이겠다”, “여기까지 온 김에 저것도…” 이러면서 말이다.</p>
<p>그런데 기능을 늘릴수록 서비스가 좋아지는 건 절대 아니었다. 오히려 덕지덕지 붙어서 더 무거워지는 경우가 훨씬 많았다.</p>
<p>그래서 요즘은 코드 한 줄을 더 짜기 전에 꼭 스스로에게 묻는다.</p>
<blockquote>
<p>이거 진짜 유저를 편하게 해주는 걸까?
아니면 그냥 내 눈에 그럴듯해 보이려고, 
유저한테 생각할 거리 하나 더 던져주는 걸까?</p>
</blockquote>
<p>솔직히 많은 기능들이 “있으면 멋져 보이는” 수준에서 끝났다. 
그런데 진짜 오래, 자주 쓰이는 핵심 기능들은 완전 다른 쪽에 있었다.</p>
<p>지금 당장 필요한 정보가 제일 먼저 눈에 띄게 해주고, 다음에 뭘 해야 할지 고민 안 하게 길을 터주고, 실수할까 봐 불안한 마음을 덜어주고, 잘못 눌러도 “아, 괜찮아” 하며 원래대로 돌아올 수 있게 해주는 것.</p>
<p>좋은 서비스는 튜토리얼을 빵빵하게 넣어서 유저를 똑똑하게 훈련시키려 들지 않는다. 그냥 유저가 <strong>덜 피곤하게, 더 자연스럽게</strong> 움직일 수 있도록 보이지 않는 곳에서 대신 짐을 들어줄 뿐이다.</p>
<hr>
<h3 id="🏄♂️-사람들은-기능을-하나하나-쓰는-게-아니라-흐름을-탄다">🏄‍♂️ 사람들은 기능을 하나하나 쓰는 게 아니라 흐름을 탄다</h3>
<p><strong>예전엔 나도 기능을 예쁘게 잘 나열해두면 유저가 알아서 쏙쏙 뽑아 쓸 줄 알았다.</strong></p>
<p>입력 → 처리 → 출력.
클릭 → 결과 → 다음.</p>
<p>개발자 머릿속에선 이 구조가 제일 직관적이고 깔끔하니까. 그런데 아니었다. 사람들은 내 생각처럼 기능을 하나하나 분석하면서 쓰지 않았다. 그냥 전체적인 &#39;흐름&#39;이라는 물결 위에 스르륵 올라탈 뿐이었다.</p>
<p>처음 화면에서 “아 이거 누르면 되는구나” 바로 감이 오는지, 중간에 턱턱 막히는 방지턱은 없는지, 다음 행동이 물 흐르듯 이어지는지.</p>
<p>그걸 깨닫고 나니까 서비스를 바라보는 시선이 완전히 바뀌었다.
<strong>“이 기능 좋은데?” 보다 “화면 넘어가는 흐름이 부드러운가?”를 먼저 보게 되더라.</strong></p>
<p>이 눈으로 다시 보니까 예전엔 미처 못 봤던 게 보였다. 겉보기엔 예뻤던 화면이 갑자기 되게 답답해 보이고, 엄청 친절하게 썼다고 자부했던 설명문이 사실은 앱 구조가 너무 약해서 구구절절 변명하는 거였다는 걸 깨달았다.</p>
<p>진짜 좋은 서비스 흐름은 사람을 시험하지 않는다. 그냥 유저가 화면이 넘어갔는지 의식조차 못한 채 끝까지 가게 만들어버린다. 난 그게 제일 무서운 실력이라고 느꼈다.</p>
<hr>
<h3 id="🧹-좋은-화면은-꽉-채운-게-아니라-싹-덜어낸-것이다">🧹 좋은 화면은 꽉 채운 게 아니라 싹 덜어낸 것이다</h3>
<p>프로젝트를 진행하면서 제일 많이, 끝까지 수정하는 게 결국 화면이다. 
기능이 아무리 완벽해도 유저가 만나는 최전선은 화면이니까.</p>
<p>예전의 나는 “최대한 더 잘 설명해야지!” 하는 마음이 컸다. 정보도 빈틈없이 꽉꽉 채워주고, 밤새워 만든 기능 하나라도 놓칠세라 툴팁을 띄워주고, 최대한 친절하게 만들려 했다.</p>
<p>그런데 내가 만든 걸 유저들이 쓰는 걸 지켜보면서 뼈저리게 깨달았다. 유저들은 내 생각보다 훨씬 빨리 훑어보고, 훨씬 빨리 피곤해하고, 아주 조금만 애매해도 그냥 멈춰버렸다.</p>
<p>그래서 이제 화면을 짤 때 제일 먼저 드는 생각은 “여기에 뭘 더 넣을까?”가 아니라 <strong>“뭐부터 치워버릴까?”</strong>가 됐다.</p>
<p>화면에 뭐가 많고 화려한 게 중요한 게 아니었다. 딱 지금 이 순간 유저가 알아야 할 것만 남기고, 굳이 지금 안 해도 되는 고민은 싹 다 가려주는 화면.</p>
<p>처음 봤을 때 대충 감이 오고, 쓸데없는 긴장감을 주지 않는 화면. 과한 설명보다 아예 생각할 거리를 없애주는 게 유저를 위한 진짜 배려라는 걸 알게 됐다.</p>
<hr>
<h3 id="🤝-화려한-기술보다-끝까지-살아남는-건-신뢰다">🤝 화려한 기술보다 끝까지 살아남는 건 ‘신뢰’다</h3>
<p>요즘은 기술 발전이 워낙 빨라서 맘만 먹으면 그럴듯한 걸 뚝딱 만들 수 있다.</p>
<p>그런데 <strong>&#39;똑똑해 보이는 것&#39;이랑 &#39;내어주고 싶을 만큼 믿음이 가는 것&#39;은 완전히 다른 문제</strong>였다.</p>
<p>결과물이 아무리 화려하게 나와도 “이게 대체 어떻게 처리된 거지?” 하고 감이 안 오거나, 에러가 났을 때 대처가 엉망이어서 자꾸 사람을 당황시키면 절대 두 번은 켜지 않게 된다.</p>
<p>신뢰라는 게 되게 거창한 데서 오는 줄 알았는데 아니었다. 말투가 차분한지, 화면 넘어가는 흐름이 일관적인지, 갑자기 이상한 곳을 눌러도 앱이 내 통제 안에 있다는 안도감을 주는지.</p>
<p>이런 아주 사소한 디테일들이 겹겹이 쌓여서 “아, 여기 괜찮네. 믿고 써도 되겠다” 하는 묵직한 감정을 만든다.</p>
<hr>
<h3 id="😅-결국-메이커의-만족은-더하기에-있었고-서비스의-완성은-덜어내기에-있었다">😅 결국 메이커의 만족은 &#39;더하기&#39;에 있었고, 서비스의 완성은 &#39;덜어내기&#39;에 있었다</h3>
<p>서비스를 계속 만들어보면서 점점 확신이 생기는 게 하나 있다.</p>
<p>진짜 잘 만든 프로덕트는 무언가를 끝없이 덧칠해서 완성되는 게 아니라, 
필요 없는 걸 뼈 깎는 심정으로 덜어내면서 확 선명해진다는 것이다.</p>
<p>솔직히 더 넣는 건 진짜 쉬웠다. 
그런데 빼는 건 너무 어려웠다. 
내가 고생해서 짠 코드를 포기해야 하고, 진짜 애착 가졌던 기발한 요소도 뒤로 미뤄야 하니까.</p>
<p>그런데 눈 딱 감고 그 과정을 넘기고 나면, 서비스가 놀라울 정도로 가벼워지고 단단해지는 걸 느꼈다. 과감하게 빼냈다는 건 곧 “이 서비스에서 진짜 뾰족하게 가져가야 할 게 뭔지 완벽하게 안다”는 증거이기도 하다.</p>
<p>모든 걸 다 보여주려는 욕심을 버리고, 딱 필요한 순간에 필요한 것만 조용히 내밀어서 유저가 쓸데없는 에너지를 안 쓰게 지켜주는 것. 나는 이제 이게 메이커의 진짜 실력이라고 믿는다.</p>
<hr>
<h3 id="✅-내가-지금도-매번-체크하는-5가지">✅ 내가 지금도 매번 체크하는 5가지</h3>
<p>요즘은 기획서나 화면을 볼 때 딱 이 다섯 개부터 스스로 묻는다.</p>
<ol>
<li>첫 화면 3초 안에 “아, 여기서 이거 해야 하는구나” 감이 오는가?</li>
<li>다음에 할 행동이 직관적으로 보이는가, 아니면 선택지가 너무 많아서 멈칫하게 되는가?</li>
<li>설명이 길다면 이게 진짜 친절한 건가, 아니면 앱 구조가 엉망이라 말이 길어진 건가?</li>
<li>완전 잘못 눌렀을 때도 유저가 당황하지 않고 원래대로 돌아올 수 있는가?</li>
<li>이 기능이 진짜 유저를 편하게 해주는가, 아니면 생각할 거리 하나를 더 얹어주는가?</li>
</ol>
<hr>
<h3 id="🌙-마무리하며">🌙 마무리하며</h3>
<p>이번 프로젝트를 거치면서 또 한 번 뼛속 깊이 확인한 건 진짜 단순했다.</p>
<p>사람들은 우리가 짠 기능 자체를 사랑하는 게 아니었다. 유저들이 진심으로 좋아하고 오래 곁에 두는 건 결국 이해하기 쉽고, 믿음이 가고, 쓸 때 덜 피곤한 경험이었다.</p>
<p>그래서 진짜 좋은 서비스는 &#39;더 많은 걸 할 수 있게 뽐내는 곳&#39;이 아니라,
<strong>&#39;유저의 망설임을 조용히 지워주는 곳&#39;</strong>이라고 확신하게 됐다.</p>
<p>아마 앞으로도 내가 무언가를 새로 만들 때 제일 먼저 들이대는 잣대는 비슷할 것 같다. 내 기능이 얼마나 대단해 보이냐가 아니라, 이 전체적인 흐름이 사람을 얼마나 편안하게 만들어 주냐일 것이다.</p>
<p>결국 끝까지 살아남고 내 폰에 오래 남는 건, 
똑똑해 보여서 피곤한 앱보다 왠지 모르게 손이 가는 편안한 서비스니까. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[회고] 지혜나눔터, 기획·MVP까지 했는데 공모전에서 떨어진 이유 🤔]]></title>
            <link>https://velog.io/@lova-clover/%ED%9A%8C%EA%B3%A0-%EC%A7%80%ED%98%9C%EB%82%98%EB%88%94%ED%84%B0-%EA%B8%B0%ED%9A%8DMVP%EA%B9%8C%EC%A7%80-%ED%96%88%EB%8A%94%EB%8D%B0-%EA%B3%B5%EB%AA%A8%EC%A0%84%EC%97%90%EC%84%9C-%EB%96%A8%EC%96%B4%EC%A7%84-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@lova-clover/%ED%9A%8C%EA%B3%A0-%EC%A7%80%ED%98%9C%EB%82%98%EB%88%94%ED%84%B0-%EA%B8%B0%ED%9A%8DMVP%EA%B9%8C%EC%A7%80-%ED%96%88%EB%8A%94%EB%8D%B0-%EA%B3%B5%EB%AA%A8%EC%A0%84%EC%97%90%EC%84%9C-%EB%96%A8%EC%96%B4%EC%A7%84-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Tue, 03 Mar 2026 03:42:48 GMT</pubDate>
            <description><![CDATA[<p>2026년 1월 25일,<br>저는 <strong>G-AFC 고령친화 아이디어 공모전</strong>에 <code>지혜나눔터</code>를 제출했습니다.</p>
<p>결과는 아쉽게도 <strong>탈락</strong>.  
그래도 이번 경험은 실패라기보다, 다음 시도를 위한 데이터에 더 가까웠습니다.<br>오늘은 그 과정을 솔직하게 남겨보려 합니다. 🙂</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/4636bbc4-951b-4ee9-92cb-1d9bf8e9a34a/image.png" alt="지혜나눔터 공모전 제출 표지"></p>
<hr>
<h2 id="1-왜-이-아이디어였나-🧓👩🎓">1. 왜 이 아이디어였나 🧓👩‍🎓</h2>
<p>출발점은 단순했습니다.</p>
<blockquote>
<p>“어르신은 도움을 받는 대상”이라는 익숙한 프레임을<br>“어르신은 지혜를 전하는 선생님”으로 바꿔보자.</p>
</blockquote>
<p><code>지혜나눔터</code>는 안동·예천 지역 어르신의 삶의 기술(전통음식, 예절·다도, 텃밭농사, 공예)을 대학생에게 연결하는 세대교류 프로그램입니다.</p>
<p>핵심은 봉사가 아니라 <strong>역할 전환</strong>이었습니다.</p>
<ul>
<li>어르신: 수혜자 → 멘토</li>
<li>대학생: 봉사자 → 학습자</li>
<li>지역사회: 단절 → 전승</li>
</ul>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/411e5bca-c0a0-455b-b21b-2ea4996acf97/image.jpeg" alt="관점의 전환"></p>
<hr>
<h2 id="2-실제로-어디까지-만들었나-💻">2. 실제로 어디까지 만들었나 💻</h2>
<p>아이디어 문서로 끝내지 않고, MVP까지 구현했습니다.</p>
<p>기술 스택:</p>
<ul>
<li>FastAPI</li>
<li>SQLAlchemy</li>
<li>SQLite</li>
<li>Jinja2</li>
<li>Tailwind CSS</li>
</ul>
<p>구현 기능:</p>
<ul>
<li>홈</li>
<li>프로그램 목록/상세</li>
<li>멘토 목록/상세</li>
<li>멘토 등록</li>
<li>수강 신청(중복 신청, 정원 마감 처리)</li>
<li>관리자 대시보드</li>
<li>관리자 수강 승인/수료 처리</li>
</ul>
<p>시연용 데이터:</p>
<ul>
<li>멘토 5명</li>
<li>프로그램 6개</li>
<li>학생 4명</li>
<li>신청 5건</li>
</ul>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/dce2ee4a-4a6f-41e8-a610-d5ad01ae9340/image.jpeg" alt="homepage"></p>
<ul>
<li>홈페이지</li>
</ul>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/d7fc67f8-3762-4673-bff7-1c844fda9cf0/image.jpeg" alt="program"></p>
<ul>
<li>교육 프로그램 페이지</li>
</ul>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/49355ce2-9e2b-40c3-b5c3-575b92c8a9a5/image.jpeg" alt="mentor"></p>
<ul>
<li>멘토 페이지</li>
</ul>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/e292f973-b96d-43c2-969d-86f5a5872c8c/image.jpeg" alt="mentor_register"></p>
<ul>
<li>멘토 등록 페이지</li>
</ul>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/9869e860-dd32-40ae-a0ac-847bbf1e83ea/image.jpeg" alt="admin_dashboard"></p>
<ul>
<li>G-AFC 관리자 대시보드</li>
</ul>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/fa96c06f-9853-4b7d-96fd-34454bf1de89/image.jpeg" alt="course_registration_management"></p>
<ul>
<li>수강 신청 관리 페이지</li>
</ul>
<hr>
<h2 id="3-그런데-왜-떨어졌을까-제-해석-🤔">3. 그런데 왜 떨어졌을까? (제 해석) 🤔</h2>
<p>심사 피드백을 모두 받은 건 아니지만, 제출물을 다시 보면 아쉬움은 명확했습니다.</p>
<ul>
<li><strong>실증 데이터 부족</strong>: 사용자 인터뷰, 파일럿 운영 결과, 재참여율 같은 근거가 약했습니다.</li>
<li><strong>실행 설계의 밀도 부족</strong>: 운영 계획은 있었지만 리스크 대응과 지속 가능성 설계가 더 필요했습니다.</li>
<li><strong>평가 언어의 부족</strong>: “잘 만들었다”보다 “왜 효과가 나는지”를 더 분명하게 증명했어야 했습니다.</li>
</ul>
<p>정리하면,<br><strong>만드는 힘은 보여줬지만 증명하는 힘은 부족했다</strong>는 결론입니다.</p>
<hr>
<h2 id="4-이번에-확실히-배운-것-📌">4. 이번에 확실히 배운 것 📌</h2>
<ul>
<li>공모전은 좋은 생각보다 <strong>검증된 가설</strong>이 강하다.</li>
<li>MVP는 끝이 아니라 시작이다. <strong>사용자 반응 데이터</strong>가 본게임이다.</li>
<li>감동적인 스토리와 냉정한 수치는 같이 가야 한다.</li>
<li>“얼마나 만들었는가”보다 “왜 효과가 나는가”를 먼저 보여줘야 한다.</li>
</ul>
<hr>
<h2 id="5-다음-도전에서-바꿀-것-🚀">5. 다음 도전에서 바꿀 것 🚀</h2>
<ul>
<li>최소 10명 이상 사용자 인터뷰 선행</li>
<li>4~8주 소규모 파일럿 운영</li>
<li>전/후 변화 지표 설계(만족도, 재참여율, 관계 형성)</li>
<li>심사 기준 역산형 문서 구성</li>
<li>데모 + 데이터 + 운영 시나리오를 한 세트로 제출</li>
</ul>
<hr>
<h2 id="마무리-🙏">마무리 🙏</h2>
<p>떨어진 건 사실이지만,<br>이번 프로젝트를 통해 확신한 건 분명합니다.</p>
<p><strong>어르신의 경험은 복지의 대상이 아니라 사회의 자산</strong>이라는 것.<br>그리고 그 자산을 연결하는 방식은 꼭 필요하다는 것.</p>
<p>지혜나눔터는 여기서 끝내지 않겠습니다.<br>다음엔 더 단단한 근거와 더 좋은 실행으로 다시 도전하겠습니다.</p>
<p>읽어주셔서 감사합니다. 🙌</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DevHistory] 토이에서 서비스로, 배포 직전 트러블슈팅 정리]]></title>
            <link>https://velog.io/@lova-clover/DevHistory-%ED%86%A0%EC%9D%B4%EC%97%90%EC%84%9C-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%A1%9C-%EB%B0%B0%ED%8F%AC-%EC%A7%81%EC%A0%84-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@lova-clover/DevHistory-%ED%86%A0%EC%9D%B4%EC%97%90%EC%84%9C-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%A1%9C-%EB%B0%B0%ED%8F%AC-%EC%A7%81%EC%A0%84-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Sun, 01 Mar 2026 09:26:31 GMT</pubDate>
            <description><![CDATA[<p>지난 글(2025년 11월 30일)에서는 DevHistory의 기본 흐름을 만들었습니다.</p>
<p>GitHub, solved.ac, Velog 데이터를 모아서 대시보드로 보고, 블로그 초안 만들고, 포트폴리오까지 뽑아내는 구조였습니다.</p>
<p>그때는 솔직히 이렇게 생각했습니다.</p>
<p><strong>“기능은 다 돌아가네. 이제 서버에 올리고 배포만 하면 되겠다.”</strong></p>
<p>하지만 완벽한 착각이었습니다. 실제로 계속 돌려보니, 개발 시간보다 더 많이 든 건 전혀 다른 쪽이었습니다.</p>
<p>새로운 기능을 화려하게 추가하는 일보다, 이미 있는 기능이 <strong>실제 환경에서 끝까지 동작하는지</strong> 확인하고, 실패했을 때 사용자에게 <strong>현재 상태를 납득시키는 작업</strong>이 훨씬 어렵고 오래 걸렸습니다.</p>
<p>이번 글은 토이 프로젝트를 실제 서비스로 다듬어가는 안정화 과정의 기록입니다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/7a4912f6-d8e1-4628-9ae3-960ef91385c6/image.png" alt="DevHistory 홈페이지"></p>
<blockquote>
<p><strong>서비스 첫 진입 화면</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/ceacfb63-66f3-4899-b835-7ce8d63fbd72/image.png" alt="DevHistory 대시보드"></p>
<blockquote>
<p><strong>수집된 데이터를 한눈에 확인하는 대시보드</strong></p>
</blockquote>
<hr>
<h2 id="1-2025-11-30-기준-이미-되던-것들">1) 2025-11-30 기준, 이미 되던 것들</h2>
<p>당시에는 아래 파이프라인이 동작했습니다.</p>
<ul>
<li>GitHub OAuth 로그인</li>
<li>GitHub / solved.ac / Velog 데이터 동기화</li>
<li>대시보드 지표/차트 렌더링</li>
<li>레포 기반 블로그 초안 생성</li>
<li>포트폴리오 페이지 + PDF 내보내기</li>
</ul>
<p>즉, <code>수집 → 집계 → 생성 → 출력</code>의 큰 뼈대는 완성돼 있었습니다.</p>
<p>이후의 과제는 <strong>“왜 가끔 안 되는지”, “안 될 때 사용자에게 어떻게 보이는지”</strong>의 구멍을 메우는 것이었습니다.</p>
<hr>
<h2 id="2-이번-사이클에서-크게-바뀐-점">2) 이번 사이클에서 크게 바뀐 점</h2>
<h3 id="2-1-생성-ux-비동기-흐름-정리">2-1. 생성 UX: 비동기 흐름 정리</h3>
<p>코딩 코치(퀴즈 생성)나 AI 글쓰기 기능에서 아주 치명적인 UX 문제가 있었습니다.</p>
<ul>
<li>생성 버튼 클릭</li>
<li>한참을 돌다가 <strong>실패 메시지</strong> 팝업</li>
<li>그런데 결과 목록을 새로고침해보면 <strong>이미 생성되어 있음</strong></li>
</ul>
<p>원인은 백엔드 처리 완료 시점과 프론트엔드의 타임아웃 기준이 엇갈렸기 때문입니다. 백엔드는 일을 끝냈는데 프론트가 먼저 지쳐버린 거죠.</p>
<p>그래서 무작정 기다리는 동기 방식을 버리고, 비동기 폴링(Polling)으로 흐름을 바꿨습니다.</p>
<ol>
<li>초기 요청 시 대기하지 않고 <code>task_id</code>만 즉시 반환</li>
<li>프론트에서 폴링으로 백그라운드 상태 확인</li>
<li>완료되면 즉시 화면에 렌더링</li>
</ol>
<pre><code class="language-text">❌ [Before] 타임아웃 지옥
요청 전송 → 프론트 타임아웃(실패 팝업) → 백엔드 작업 뒤늦게 완료

✅ [After] 비동기 UX 안정화
task_id 반환 → 상태 폴링 → 완료 시 정상 반영
</code></pre>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/6010348b-017a-42d7-96ff-b581f4dd5df1/image.png" alt="코딩 코치 After"></p>
<hr>
<h3 id="2-2-인증보안-로그인-성공보다-운영-신뢰">2-2. 인증/보안: 로그인 &quot;성공&quot;보다 운영 &quot;신뢰&quot;</h3>
<p>초기에는 &quot;소셜 로그인이 뚫렸다&quot;는 사실 자체에 만족했습니다. 하지만 배포를 앞두고 기준을 엄격하게 올렸습니다.</p>
<ul>
<li><code>httpOnly</code> 쿠키 중심의 안전한 인증 흐름</li>
<li>로그인/로그아웃 세션 동선 명확화</li>
<li>사용자별 BYO LLM API Key 저장/검증/테스트/삭제</li>
<li>API Key 암호화 저장 보완</li>
</ul>
<p>사용자가 자신의 API 키를 입력해야 하는 서비스 특성상, <strong>내 데이터와 키가 안전하다</strong>는 전제가 없으면 아무리 좋은 기능도 무용지물이기 때문입니다.</p>
<hr>
<h3 id="2-3-동기화-성공률보다-실패-가시성">2-3. 동기화: 성공률보다 실패 가시성</h3>
<p>데이터 동기화 버튼은 겉보기엔 단순하지만, 실패 시나리오를 어떻게 다루느냐에서 신뢰가 갈렸습니다.</p>
<ul>
<li>GitHub 소유 레포 기준(<code>owner</code>) 필터 검증 추가</li>
<li>로그인 직후 텅 빈 화면을 막기 위한 동기화 자동 큐잉</li>
<li>동기화 상태 및 <strong>실패 상황을 사용자 기준으로 확인 가능하게</strong> 개선</li>
</ul>
<p>성공 횟수보다 중요한 건, <strong>“실패했다면 왜 안 되는지 명확히 보여주는 것”</strong>이었습니다.</p>
<hr>
<h3 id="2-4-포트폴리오-개인-화면에서-공유-결과물로">2-4. 포트폴리오: 개인 화면에서 공유 결과물로</h3>
<p>포트폴리오는 혼자 뿌듯해하는 화면이 아니라, 남에게 전달되는 결과물입니다.</p>
<ul>
<li>공개 URL(<code>slug</code>) 및 비공개 토큰 링크 공유 기능</li>
<li>토큰 재발급/만료 관리 로직</li>
<li>이메일 등 민감 정보 공개 제어</li>
</ul>
<blockquote>
<p>특히 고민이 많았던 건 PDF 출력입니다. 
브라우저 환경에 따라 폰트나 레이아웃이 깨지는 변수를 통제하기 위해, 현재는 가장 안정적인 <strong>화면 캡처 방식의 PDF 저장</strong>을 우선 적용해 두었습니다. 당장의 &#39;실사용 안정성&#39;을 위해 타협한 부분이며, 향후 텍스트 보존성이 높은 네이티브 방식으로 고도화해 나갈 계획입니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/0cb6f2aa-732c-4569-8b7f-5e09984ec6b2/image.png" alt="포트폴리오 화면"></p>
<hr>
<h3 id="2-5-배포-준비-코드보다-운영-비중-증가">2-5. 배포 준비: 코드보다 운영 비중 증가</h3>
<p>이 시점부터는 IDE에서 코드를 치는 시간보다 인프라 세팅 비중이 훨씬 커졌습니다.</p>
<ul>
<li>프로덕션용 Docker Compose 작성</li>
<li>Caddy 기반 HTTPS 라우팅 및 인증서 설정</li>
<li>마이그레이션/운영/보안 문서 정리</li>
<li>환경변수/쿠키 도메인/OAuth 콜백 최종 점검</li>
</ul>
<p>직접 겪어보니 배포는 &quot;코드가 실행된다&quot;의 문제가 아니라, <strong>&quot;장애 발생 시 어떻게 확인하고 복구할 것인가&quot;</strong>를 준비하는 과정이었습니다.</p>
<hr>
<h3 id="2-6-현실적인-고민-devhistorykr--oracle-free-tier">2-6. 현실적인 고민: <code>devhistory.kr</code> + Oracle Free Tier</h3>
<p>로컬 개발이 끝나고 운영 현실의 벽과도 마주했습니다.</p>
<ul>
<li><code>devhistory.kr</code> 도메인 도입 검토</li>
<li>서버 비용 방어를 위한 Oracle Cloud Free Tier 등록 시도</li>
</ul>
<blockquote>
<p>악명 높은 오라클 가입 단계에서 막히는 케이스가 계속 반복됐습니다. 
그래서 무작정 &#39;무료&#39;에 시간을 쏟기보다는, 약간의 비용을 감수하더라도 예측 가능하고 바로 구축할 수 있는 다른 인프라 대안도 함께 검토하고 있습니다.</p>
</blockquote>
<hr>
<h2 id="3-이번-구간에서-배운-것-5가지">3) 이번 구간에서 배운 것 5가지</h2>
<ol>
<li>새로운 기능 추가보다 <strong>기존 기능의 실패 경험 감소</strong>가 우선이다.</li>
<li>비동기 처리는 백엔드 로직뿐 아니라 <strong>프론트 UX를 함께 설계</strong>해야 한다.</li>
<li>지표는 계산하는 것보다 <strong>정의를 통일</strong>하는 것이 먼저다.</li>
<li>PDF 출력은 단순 UI 기능이 아니라 <strong>렌더링 엔지니어링</strong> 이슈에 가깝다.</li>
<li>문서화는 개발 속도를 늦추지 않는다. 오히려 <strong>장애 복구 속도를 비약적으로 높인다.</strong></li>
</ol>
<hr>
<h2 id="4-현재-상태와-다음-단계">4) 현재 상태와 다음 단계</h2>
<p>현재 DevHistory는 아래 단계까지 완료되었습니다.</p>
<ul>
<li>수집 파이프라인 실사용 동작 검증</li>
<li>생성 기능 비동기 흐름 전면 개편</li>
<li>포트폴리오 공유/출력 품질 보강</li>
<li>운영 체크리스트 기반 배포 준비</li>
</ul>
<p>다음 단계는 실제 운영 환경에서 아래 항목들을 테스트하는 것입니다.</p>
<ul>
<li>도메인/DNS/HTTPS 최종 연결</li>
<li>OAuth 콜백/쿠키 도메인 정합성 확인</li>
<li>실사용 트래픽 기준 병목 확인</li>
</ul>
<hr>
<p>다음 글에서는 배포를 완료하고, 실제 트래픽을 받으며 겪은 <strong>운영 트러블슈팅 후기</strong>를 정리해 가져오겠습니다 🙌</p>
<p>혹시 사이드 프로젝트 배포하실 때 비슷한 고민을 해보셨거나, 추천하시는 인프라 조합이 있으시다면 댓글로 알려주세요! (오라클 프리티어와 사투 중입니다 😥)</p>
<p>배포 직전의 시행착오가 누군가에게 작은 참고가 되었으면 좋겠습니다.<br>재밌게 읽으셨다면 공감(💚) 부탁드립니다.</p>
<hr>
<h3 id="참고">참고</h3>
<ul>
<li>이전 글: <a href="https://velog.io/@lova-clover/DevHistory-%EA%B0%9C%EB%B0%9C-%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%EC%9E%90%EB%8F%99%ED%99%94-%ED%94%8C%EB%9E%AB%ED%8F%BC-%EB%A7%8C%EB%93%A4%EA%B8%B0">DevHistory - 개발 포트폴리오 자동화 플랫폼 만들기</a></li>
<li>GitHub: <a href="https://github.com/Lova-clover/DevHistory">DevHistory</a>
```</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[회고] 데이콘 피싱·스캠 예방 대회 예선 45위: 작동하는 MVP 'PhishShield' 개발기]]></title>
            <link>https://velog.io/@lova-clover/%ED%9A%8C%EA%B3%A0-%EB%8D%B0%EC%9D%B4%EC%BD%98-%ED%94%BC%EC%8B%B1%EC%8A%A4%EC%BA%A0-%EC%98%88%EB%B0%A9-%EB%8C%80%ED%9A%8C-%EC%98%88%EC%84%A0-45%EC%9C%84-%EC%9E%91%EB%8F%99%ED%95%98%EB%8A%94-MVP-PhishShield-%EA%B0%9C%EB%B0%9C%EA%B8%B0</link>
            <guid>https://velog.io/@lova-clover/%ED%9A%8C%EA%B3%A0-%EB%8D%B0%EC%9D%B4%EC%BD%98-%ED%94%BC%EC%8B%B1%EC%8A%A4%EC%BA%A0-%EC%98%88%EB%B0%A9-%EB%8C%80%ED%9A%8C-%EC%98%88%EC%84%A0-45%EC%9C%84-%EC%9E%91%EB%8F%99%ED%95%98%EB%8A%94-MVP-PhishShield-%EA%B0%9C%EB%B0%9C%EA%B8%B0</guid>
            <pubDate>Fri, 20 Feb 2026 16:19:08 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요. 데이콘에서 주최한 <strong>피싱·스캠 예방 서비스 개발 경진대회</strong>에 개인 참가자로 도전했던 경험을 공유합니다. 최종 성적은 104팀 중 45위로 본선 진출에는 실패했지만, <strong>기획부터 AI 모델링, 프론트엔드 구현, 그리고 Vercel에 배포까지 직접 완주한 MVP</strong>를 만들었습니다. 프로젝트 이름은 <strong>PhishShield</strong>로, 실제로 사용할 수 있는 보이스피싱 예방 서비스를 목표로 했습니다.</p>
<h2 id="1-문제-정의-피싱은-분류보다-행동의-문제">1. 문제 정의: 피싱은 ‘분류’보다 ‘행동’의 문제</h2>
<p>대회를 준비하면서 가장 먼저 떠올랐던 질문은 <strong>“피싱을 막는다는 게 단순히 메시지를 분류하는 일인가?”</strong>였습니다. 피싱 범죄의 핵심은 텍스트 자체보다 <strong>사람의 심리와 행동을 조작하는 구조</strong>입니다. 실제 사기 사례를 분석해 보면 다음과 같은 패턴이 결합돼 있습니다.</p>
<ul>
<li><strong>긴급성</strong>: “지금 바로 조치하지 않으면 큰일 난다”는 긴장감을 유발합니다.</li>
<li><strong>권위 사칭</strong>: 검찰·금감원·은행·가족을 사칭해 신뢰를 얻습니다.</li>
<li><strong>공포와 금전 요구</strong>: 계좌가 동결된다거나 가족이 다쳤다는 등 공포를 유도하고 송금이나 앱 설치를 요구합니다.</li>
<li><strong>즉각적인 행동 유도</strong>: 피해자가 이성적으로 판단할 시간을 주지 않고 링크 클릭, 앱 설치, 송금 등을 하도록 압박합니다.</li>
</ul>
<p>이처럼 피싱은 <strong>심리적 압박과 행동 유도</strong>가 맞물린 범죄라서, 단순히 “위험 확률”을 보여주는 것만으로는 실제 피해를 막기 어렵다고 판단했습니다. 따라서 저는 <strong>탐지 → 설명(왜 위험한지) → 행동 가이드</strong>로 이어지는 흐름을 MVP 목표로 설정했습니다.</p>
<h2 id="2-phishshield의-구성과-핵심-기능">2. PhishShield의 구성과 핵심 기능</h2>
<p>프로젝트의 목표는 위험 메시지를 감지하고, <strong>사용자가 바로 상황을 파악한 뒤 안전하게 대처할 수 있도록 돕는 서비스</strong>를 만드는 것이었습니다. 이를 위해 다음 네 가지 기능을 중심으로 설계했습니다.</p>
<h3 id="21-피싱-dna-12요소-분석">2.1 피싱 DNA 12요소 분석</h3>
<p>피싱 메시지에서 반복적으로 나타나는 패턴을 <strong>12가지 DNA 요소</strong>로 분류했습니다. 긴급성 압박, 권위 사칭, 금전 요구, 의심스러운 링크 포함 등 각 요소에 키워드와 설명을 부여했습니다. AI 모델은 텍스트를 전처리한 후 <strong>TF‑IDF</strong>로 벡터화하고, 각 메시지에서 이 패턴을 얼마나 충족하는지를 계산합니다. 이렇게 하면 “위험도 85%”와 같은 숫자만 보여 주는 대신, <strong>어떤 요소 때문에 의심되는지 구체적인 근거</strong>를 사용자에게 제시할 수 있습니다.</p>
<h3 id="22-패밀리-세이프워드family-safeword">2.2 패밀리 세이프워드(Family Safe‑Word)</h3>
<p>딥페이크 기술로 가족의 얼굴과 목소리까지 위조되는 시대에 ‘앱 안에서 비밀번호를 묻고 답하는 방식’은 오히려 위험합니다. 그래서 <strong>세이프워드는 데이터베이스에 저장하지 않고</strong>, 가족끼리만 아는 질문·답변을 오프라인에서 확인하도록 설계했습니다. 앱은 세이프워드 등록·리마인드만 도와주고, 의심 상황에서는 <strong>“지금 바로 가족에게 전화를 걸어 확인하세요”</strong>라는 안내를 제공합니다. 이는 README에서도 강조한 차별점 중 하나입니다.</p>
<h3 id="23-게임화된-대화-훈련">2.3 게임화된 대화 훈련</h3>
<p>피싱 위험을 알고 있어도 실제 상황에서는 쉽게 당황합니다. 그래서 <strong>실제 사기 사례를 바탕으로 20개의 시나리오</strong>를 만들고, 사용자가 메신저 대화 형식으로 직접 체험해 보는 훈련 모드를 도입했습니다. 각 시나리오는 보이스피싱, 메신저 피싱, 스미싱 등 다양한 유형을 다루며, <strong>올바른 대응을 선택하면 추가 정보를 제공하고 잘못된 행동을 하면 즉시 경고</strong>합니다. 이를 통해 <strong>어떤 패턴에 취약한지 스스로 알아갈 수 있도록</strong> 설계했습니다.</p>
<h3 id="24-시니어-모드">2.4 시니어 모드</h3>
<p>피싱 범죄의 주요 피해자는 고령층이 많습니다. AI 알고리즘이 아무리 좋아도 대상이 <strong>직접 사용할 수 없는 인터페이스</strong>라면 의미가 없다고 생각했습니다. 그래서 <strong>큰 글씨, 단순한 UI, 버튼 최소화</strong> 등 접근성을 최우선으로 한 <strong>시니어 모드</strong>를 제공했습니다. 이는 단순하지만 실제 사용자에게는 매우 중요한 요소입니다.</p>
<h2 id="3-기술적-접근-설명-가능한-ai-베이스라인">3. 기술적 접근: 설명 가능한 AI 베이스라인</h2>
<p>대회에서는 큰 딥러닝 모델을 사용하면 성능 점수가 올라갈 수 있었지만, 제가 혼자서 개발한 MVP에서는 다음 세 가지를 우선순위에 두었습니다.</p>
<ol>
<li><strong>실시간 처리 속도</strong>: 모바일에서 바로 판단해야 하므로 복잡한 모델은 지연을 초래할 수 있습니다.</li>
<li><strong>설명 가능성</strong>: 사용자가 왜 위험한지 이해해야 경고를 믿고 행동을 멈춥니다.</li>
<li><strong>데모 안정성</strong>: 발표와 심사에서 오류 없이 돌아가는 것이 가장 중요했습니다.</li>
</ol>
<h3 id="31-텍스트-전처리와-tfidf">3.1 텍스트 전처리와 TF‑IDF</h3>
<ul>
<li><strong>토큰화</strong>: 한국어 메시지에서 한글/영문/숫자만 남기고 나머지 기호를 제거했습니다. </li>
<li><strong>불용어 제거와 N‑그램</strong>: “입니다”, “에게” 같은 의미 없는 토큰을 제거하고, 2gram·3gram 형태로 구문을 만들어 패턴을 더 잘 포착했습니다.</li>
<li><strong>TF‑IDF 벡터화</strong>: 각 단어의 등장 빈도와 전체 말뭉치에서의 희소성을 고려해 벡터를 만들었습니다. 이는 문서 간 유사성을 계산하기 위해 기본이 되는 표현입니다.</li>
</ul>
<h3 id="32-유사도-계산과-패턴-스캔">3.2 유사도 계산과 패턴 스캔</h3>
<ul>
<li><strong>코사인 유사도</strong>: 피싱 메시지 코퍼스와 안전 메시지 코퍼스에 대해 각각 TF‑IDF 벡터를 만들어 메시지와의 유사도를 계산했습니다. 피싱 코퍼스와 가까울수록 위험 점수가 올라갑니다.</li>
<li><strong>DNA 패턴 매칭</strong>: 앞서 정의한 12개 DNA 요소에 대해 <strong>키워드 가중치와 조건</strong>을 부여하고, 메시지에 몇 개가 포함되는지 스캔했습니다. 패턴이 겹칠수록 위험을 더 높게 평가합니다.</li>
<li><strong>URL 분석과 구조 분석</strong>: 메시지에 포함된 URL을 추출해 <strong>위험한 도메인이나 짧은 링크, 유사 도메인</strong> 여부를 검사했습니다. 또한 문장이 특정 구조(긴급 문구 + 은행 계좌 등)로 나타나는지 확인해 추가 신호로 사용했습니다.</li>
<li><strong>개인화</strong>: 시니어 모드처럼 사용자 프로필을 고려해, 고령자에게 더 강한 경고를 보여주거나 은행 업무 경험이 적은 사용자에게 상세한 설명을 추가했습니다.</li>
</ul>
<h3 id="33-점수-합산과-위험-분류">3.3 점수 합산과 위험 분류</h3>
<p>최종 위험 점수는 <strong>여러 신호를 합산</strong>해 계산했습니다. 예를 들어 DNA 패턴 점수, NLP 유사도 점수, URL 점수, N‑그램/구조 점수를 <strong>가중치로 조합</strong>했습니다. 또, 특정 패턴이 동시에 나타날 때 가중치를 높이는 <strong>휴리스틱을 설계</strong>하고, false positive를 줄이기 위한 보정 규칙을 추가했습니다. 마지막으로 전체 점수를 기준으로 <strong>안전(safe)–낮음(low)–중간(medium)–높음(high)</strong> 네 단계로 분류했습니다. 이런 다층 구조 덕분에 모델은 딥러닝만큼 복잡하지 않지만, 사용자에게 <strong>이해하기 쉬운 근거와 함께 신뢰할 만한 판단</strong>을 제공할 수 있었습니다.</p>
<h2 id="4-개발-과정에서의-고민과-트레이드오프">4. 개발 과정에서의 고민과 트레이드오프</h2>
<p>프로젝트를 혼자 진행하다 보니 모든 결정을 스스로 내려야 했습니다. 그 과정에서 다음과 같은 고민이 컸습니다.</p>
<ul>
<li><strong>경고 빈도와 사용자 피로감</strong>: 경고가 너무 잦으면 사용자가 무시합니다. 반대로 문턱을 높이면 위험 메시지를 놓칠 수 있습니다. 그래서 경고를 단순 팝업이 아니라 <strong>근거 2~3줄과 행동 가이드</strong>가 포함된 리포트 형태로 제공했습니다.</li>
<li><strong>세이프워드의 보안 딜레마</strong>: 처음에는 OTP처럼 서버에서 검증하려고 했지만, 중간에서 탈취될 위험이 있었습니다. 그래서 결국 <strong>세이프워드를 오프라인 규칙</strong>으로 두는 설계로 회귀했습니다.</li>
<li><strong>10초 룰</strong>: 사용자가 앱을 켜고 <strong>10초 안에 이해하지 못하면</strong> 바로 이탈한다는 사실을 반영해 인터페이스를 최대한 단순화했습니다. 기능을 더 추가하려는 욕심을 버리고 <strong>동선과 설명 텍스트를 끊임없이 수정</strong>했습니다.</li>
</ul>
<h2 id="5-마무리하며-남은-과제와-배운-점">5. 마무리하며: 남은 과제와 배운 점</h2>
<p>본선 진출은 못 했지만, 다음과 같은 실질적인 교훈을 얻었습니다.</p>
<ol>
<li><strong>기획부터 MVP까지</strong>: 간단한 기획으로 시작하여, 작동하는 MVP까지 만들어보면서 데모가 기획서만 있는 것 보다 훨씬 설득력 있다는 것을 깨달았습니다.</li>
<li><strong>XAI와 UX의 중요성</strong>: 보안·헬스케어 같은 도메인에서 AI를 적용할 때는 높은 정확도보다 <strong>설명 가능성과 행동 유도</strong>가 더 중요합니다.</li>
<li><strong>개발과 일정 관리</strong>: 혼자서 모든 걸 진행하려면 <strong>역할 분담과 일정 관리</strong>가 더 어려워집니다. 기능 욕심을 버리고 마감 전에 사양을 동결하는 용기도 필요했습니다.</li>
</ol>
<p>향후에는 <strong>DNA 규칙을 더 정교하게 다듬고</strong>, 시스템 안정성을 해치지 않는 범위에서 <strong>경량화된 언어 모델</strong>을 결합해 “왜 위험한지 한 줄로 요약해 주는 기능”을 추가해보고 싶습니다. </p>
<p>끝까지 읽어주셔서 감사합니다. 프로젝트에 대한 의견이나 조언이 있다면 언제든 환영합니다.</p>
<h2 id="🔗-관련-링크">🔗 관련 링크</h2>
<ul>
<li><strong>DACON 코드 공유</strong>: <a href="https://dacon.io/competitions/official/236666/codeshare/13833">https://dacon.io/competitions/official/236666/codeshare/13833</a></li>
<li><strong>PhishShield 라이브 데모 (Vercel)</strong>: <a href="https://phish-shield-phi.vercel.app">https://phish-shield-phi.vercel.app</a></li>
<li><strong>Github 코드:</strong> : <a href="https://github.com/Lova-clover/PhishShield">https://github.com/Lova-clover/PhishShield</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[🏆 DACON K리그 AI 경진대회 15등(장려상) – 모멘텀 그래프·스토리 카드 자동화 K-MOMENTO AI 후기]]></title>
            <link>https://velog.io/@lova-clover/DACON-K%EB%A6%AC%EA%B7%B8-AI-%EA%B2%BD%EC%A7%84%EB%8C%80%ED%9A%8C-15%EB%93%B1%EC%9E%A5%EB%A0%A4%EC%83%81-%EB%AA%A8%EB%A9%98%ED%85%80-%EA%B7%B8%EB%9E%98%ED%94%84%EC%8A%A4%ED%86%A0%EB%A6%AC-%EC%B9%B4%EB%93%9C-%EC%9E%90%EB%8F%99%ED%99%94-K-MOMENTO-AI-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@lova-clover/DACON-K%EB%A6%AC%EA%B7%B8-AI-%EA%B2%BD%EC%A7%84%EB%8C%80%ED%9A%8C-15%EB%93%B1%EC%9E%A5%EB%A0%A4%EC%83%81-%EB%AA%A8%EB%A9%98%ED%85%80-%EA%B7%B8%EB%9E%98%ED%94%84%EC%8A%A4%ED%86%A0%EB%A6%AC-%EC%B9%B4%EB%93%9C-%EC%9E%90%EB%8F%99%ED%99%94-K-MOMENTO-AI-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Tue, 10 Feb 2026 17:49:46 GMT</pubDate>
            <description><![CDATA[<h2 id="한-줄-소개">한 줄 소개</h2>
<p><strong>90분의 열광을 18초 만에 하이라이트로 바꾸는 AI</strong><br>K리그 경기 데이터를 xG/xT 기반으로 분석해 모멘텀 그래프와 스토리 카드를 자동 생성하는 시스템을 만들었습니다.</p>
<hr>
<h2 id="🎯-대회-개요">🎯 대회 개요</h2>
<p><strong>DACON Track2: K리그-서울시립대 공개 AI 경진대회 (아이디어 개발 부문)</strong></p>
<ul>
<li><strong>기간</strong>: 2025.12.01 ~ 2026.01.12</li>
<li><strong>참가팀</strong>: 38팀</li>
<li><strong>결과</strong>: <strong>15등 장려상 수상</strong></li>
<li><strong>팀명</strong>: MomentoLab</li>
<li><strong>대회 링크</strong>: <a href="https://dacon.io/competitions/official/236648">DACON 대회 페이지</a></li>
<li><strong>제출 코드</strong>: <a href="https://dacon.io/competitions/official/236648/codeshare/13760">수상작 코드</a></li>
</ul>
<hr>
<h2 id="💡-아이디어-배경">💡 아이디어 배경</h2>
<h3 id="문제-인식">문제 인식</h3>
<p>K리그 경기를 보고 나면 항상 아쉬웠던 점:</p>
<ol>
<li><strong>&quot;저 장면이 진짜 중요했을까?&quot;</strong> – 경기 흐름 파악이 어려움</li>
<li><strong>하이라이트 편집에 5시간+</strong> – 영상 크리에이터의 수작업 고통</li>
<li><strong>전문가 해설 의존</strong> – 일반 팬들은 전술 이해 어려움</li>
<li><strong>K리그 낮은 시청률</strong> – 맞춤형 콘텐츠 부족</li>
</ol>
<h3 id="솔루션-k-momento-ai">솔루션: K-MOMENTO AI</h3>
<blockquote>
<p>&quot;경기가 끝나면 AI가 자동으로 핵심 전환점을 찾아내고, 전술 해설이 담긴 스토리 카드를 만들어줍니다.&quot;</p>
</blockquote>
<hr>
<h2 id="🏗️-시스템-구조">🏗️ 시스템 구조</h2>
<h3 id="기술-스택">기술 스택</h3>
<table>
<thead>
<tr>
<th>분야</th>
<th>기술</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Backend</strong></td>
<td>FastAPI (Python 3.11), Pandas, NumPy</td>
</tr>
<tr>
<td><strong>AI/ML</strong></td>
<td>Scikit-learn (xG), Markov Chain (xT), SciPy Signal</td>
</tr>
<tr>
<td><strong>Visualization</strong></td>
<td>Matplotlib, Pillow</td>
</tr>
<tr>
<td><strong>Frontend</strong></td>
<td>Next.js 14, TypeScript, Tailwind CSS</td>
</tr>
<tr>
<td><strong>AI 해설</strong></td>
<td>Rule-based + GPT-4</td>
</tr>
</tbody></table>
<h3 id="데이터">데이터</h3>
<ul>
<li><strong>raw_data.csv</strong>: 579,306개 이벤트 (패스, 슛, 캐리 등)</li>
<li><strong>match_info.csv</strong>: 198경기 메타데이터</li>
</ul>
<hr>
<h2 id="⚙️-핵심-알고리즘">⚙️ 핵심 알고리즘</h2>
<h3 id="1-xg-expected-goals--슈팅-골-확률-예측">1. xG (Expected Goals) – 슈팅 골 확률 예측</h3>
<pre><code class="language-python"># Logistic Regression 기반
features = [
    &#39;distance_to_goal&#39;,    # 골대까지 거리
    &#39;angle&#39;,               # 슈팅 각도
    &#39;body_part&#39;,          # 발/머리
    &#39;assist_type&#39;         # 크로스/패스
]

xg = 1 / (1 + exp(-logit))  # 0~1 확률값</code></pre>
<p><strong>배운 점:</strong></p>
<ul>
<li>합성 데이터 생성으로 데이터 부족 해결</li>
<li>중앙 위치 여부(<code>y_central</code>) 피처가 정확도를 15% 향상</li>
</ul>
<h3 id="2-xt-expected-threat--공격-위협도-계산">2. xT (Expected Threat) – 공격 위협도 계산</h3>
<pre><code class="language-python"># Markov Chain 전이 확률
grid = create_grid(12x8)  # 필드를 96칸으로 분할
xt[i,j] = P(goal | position(i,j))

# 재귀적 계산
xt[i,j] = Σ P(move) * (reward + xt[next])</code></pre>
<p><strong>어려웠던 점:</strong></p>
<ul>
<li>16x12 그리드로 시작했다가 계산 복잡도 과다 → 12x8로 축소</li>
<li>반복 계산 수렴 조건 설정 (20회 vs 50회 비교 실험)</li>
</ul>
<h3 id="3-momentum-calculation--경기-흐름-수치화">3. Momentum Calculation – 경기 흐름 수치화</h3>
<pre><code class="language-python"># 점수 계산
Score(t) = ΔxT + xG + GoalBonus

# 스무딩
momentum_smoothed = rolling_window(momentum, window=5)

# 전환점 탐지 (SciPy)
from scipy.signal import find_peaks

peaks, _ = find_peaks(
    momentum,
    prominence=0.3,      # 최소 변화량
    distance=300,        # 최소 간격 (초)
)</code></pre>
<p><strong>시행착오:</strong></p>
<ul>
<li>처음엔 prominence=0.5로 설정 → 전환점이 1개만 잡힘</li>
<li><code>distance=180</code>으로 했다가 3분 내 중복 이벤트 발생 → 300초로 조정</li>
</ul>
<hr>
<h2 id="🎨-시각화-결과">🎨 시각화 결과</h2>
<h3 id="1-모멘텀-그래프">1. 모멘텀 그래프</h3>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/bcd8c6c9-5a4f-49b2-8a6d-f55018374a58/image.png" alt="모멘텀 그래프 예시"></p>
<p><strong>실제 분석 예시: 전북 현대 모터스 vs 대전 하나 시티즌 (2024.03.01)</strong></p>
<ul>
<li>10분: 대전 선제골 </li>
<li>40분: 전북 득점골 </li>
<li>3개 전환점 자동 탐지 완료 ✅</li>
</ul>
<p><strong>특징:</strong></p>
<ul>
<li>양 팀 색상 구분 (빨강/파랑)</li>
<li>골 이벤트 점선 표시</li>
<li>전환점 Top 3 강조</li>
<li><strong>라벨 겹침 방지 알고리즘</strong> (4개 존으로 분할 배치)</li>
</ul>
<p><strong>개선 이력:</strong></p>
<ul>
<li>V1: 라벨 전부 겹침 😭</li>
<li>V2: 상하 번갈아 배치 → 여전히 겹침</li>
<li>V3: 좌우 지그재그 → 겹침</li>
<li><strong>V4 (최종)</strong>: 4개 존(Zone) 기반 충돌 회피 ✅</li>
</ul>
<h3 id="2-스토리-카드">2. 스토리 카드</h3>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/b0085960-42d3-4bed-bdae-fa0f4bfba6df/image.png" alt="스토리 카드 예시"></p>
<p><strong>울산 vs 포항 경기 #2 전환점 (35분 57초)</strong></p>
<ul>
<li>아라비제 슛, 아타루 패스, 루빅손 패스, 황인재 패스 연계 플레이</li>
<li>AI가 자동으로 주요 이벤트 3개 선정</li>
</ul>
<p><strong>포함 내용:</strong></p>
<ul>
<li>전환점 순위 (#1, #2, #3)</li>
<li>시간대 및 모멘텀 변화량</li>
<li>주요 이벤트 3개 (시간, 선수, xG/xT)</li>
<li>GPT-4 전술 해설</li>
</ul>
<p><strong>디자인 시행착오:</strong></p>
<ul>
<li>처음엔 그라데이션·그림자 남발 → 너무 복잡한 느낌</li>
<li><strong>최종</strong>: 기본 폰트 + 심플 색상 (Red/Blue/Orange) → 가독성 상승</li>
</ul>
<hr>
<h2 id="🤖-gpt-4-ai-해설-생성">🤖 GPT-4 AI 해설 생성</h2>
<h3 id="프롬프트-설계">프롬프트 설계</h3>
<pre><code class="language-python">prompt = f&quot;&quot;&quot;
당신은 K리그 전문 해설가입니다.
다음 경기 데이터를 보고 전환점을 설명하세요:

- 경기: {home_team} vs {away_team}
- 전환점 시간: {time_seconds}초
- 모멘텀 변화: {momentum_delta}
- 주요 이벤트:
  {event1}: {player_name} ({xg_value})
  {event2}: {player_name} ({xt_value})

팬들이 이해하기 쉽게 2문장으로 설명해주세요.
&quot;&quot;&quot;</code></pre>
<p><strong>예시 출력:</strong></p>
<blockquote>
<p>&quot;85분, 울산의 역습이 xT 0.42로 측정되며 포항 진영을 압박했습니다. 이 플레이가 결국 결승골로 이어지며 경기의 흐름이 완전히 뒤집혔습니다.&quot;</p>
</blockquote>
<hr>
<h2 id="📈-성과">📈 성과</h2>
<table>
<thead>
<tr>
<th>지표</th>
<th>수치</th>
</tr>
</thead>
<tbody><tr>
<td>xG 예측 정확도</td>
<td>87.3%</td>
</tr>
<tr>
<td>xT 계산 속도</td>
<td>0.15초/경기</td>
</tr>
<tr>
<td>전환점 탐지 재현율</td>
<td>92.1%</td>
</tr>
<tr>
<td><strong>전체 분석 시간</strong></td>
<td><strong>18초/경기</strong></td>
</tr>
<tr>
<td>API 응답 시간</td>
<td>&lt;500ms</td>
</tr>
</tbody></table>
<hr>
<h2 id="🚧-어려웠던-점들">🚧 어려웠던 점들</h2>
<h3 id="1-한글-폰트-문제-□□□-깨짐">1. 한글 폰트 문제 (<code>□□□</code> 깨짐)</h3>
<pre><code class="language-python"># Windows vs Linux 폰트 경로 차이
FONT_PATHS = [
    &quot;C:\\Windows\\Fonts\\malgun.ttf&quot;,      # Windows
    &quot;/usr/share/fonts/truetype/nanum/&quot;,    # Linux
]</code></pre>
<p><strong>해결</strong>: OS별 폰트 경로 리스트로 자동 탐색</p>
<h3 id="2-샘플-데이터-vs-전체-데이터">2. 샘플 데이터 vs 전체 데이터</h3>
<ul>
<li><strong>전체 데이터</strong>: 579K 행 → 분석 시간 2분+</li>
<li><strong>샘플 데이터</strong>: 58K 행 (20경기) → 분석 시간 18초</li>
</ul>
<p><strong>배포 전략</strong>: 샘플 데이터로 빠른 응답 → 추후 Redis 캐싱으로 전체 데이터 지원</p>
<h3 id="3-모델-재학습-vs-캐싱">3. 모델 재학습 vs 캐싱</h3>
<pre><code class="language-python"># xG/xT 모델 캐싱
if os.path.exists(&#39;backend/outputs/cache/xg_model.pkl&#39;):
    xg_model = pickle.load(open(&#39;cache/xg_model.pkl&#39;, &#39;rb&#39;))
else:
    xg_model = train_xg_model()
    pickle.dump(xg_model, open(&#39;cache/xg_model.pkl&#39;, &#39;wb&#39;))</code></pre>
<p><strong>결과</strong>: 모델 로딩 시간 15초 → 0.2초</p>
<hr>
<h2 id="💭-회고">💭 회고</h2>
<h3 id="잘한-점-✅">잘한 점 ✅</h3>
<ol>
<li><strong>실용성 우선</strong>: &quot;멋진 기술&quot;보다 &quot;실제로 쓸 수 있는 기능&quot;에 집중</li>
<li><strong>빠른 프로토타입</strong>: MVP를 3일 만에 완성 → 피드백 반영 시간 확보</li>
<li><strong>문서화</strong>: README.md, ARCHITECTURE.md로 코드 설명 충실</li>
</ol>
<h3 id="아쉬운-점-😅">아쉬운 점 😅</h3>
<ol>
<li><strong>배포 포기</strong>: Render.com 배포 시도했으나 무료 티어 메모리 부족 → 로컬 실행 영상 제출</li>
<li><strong>실시간 분석 미구현</strong>: 경기 중 실시간 모멘텀 업데이트 기능 못 만듦</li>
<li><strong>테스트 코드 부족</strong>: 급하게 개발하다 보니 Unit Test 생략</li>
<li><strong>시간 분배 실패</strong>: PPT 완벽하게 만들지 못함 ㅠ</li>
</ol>
<h3 id="배운-것-📚">배운 것 📚</h3>
<p><strong>기술적:</strong></p>
<ul>
<li>Markov Chain을 실전에 적용하는 법</li>
<li>SciPy Signal Processing (Peak Detection)</li>
<li>GPT-4 API 프롬프트 엔지니어링</li>
</ul>
<p><strong>비기술적:</strong></p>
<ul>
<li>&quot;완벽한 코드&quot;보다 &quot;작동하는 MVP&quot;가 우선</li>
<li>사용자 피드백 = 개발 방향의 나침반</li>
</ul>
<hr>
<h2 id="🙏-마치며">🙏 마치며</h2>
<p><strong>15등이라는 결과보다 더 값진 것:</strong></p>
<ol>
<li>xG/xT를 “읽기”에서 끝내지 않고 실제로 제품 형태로 옮긴 경험</li>
<li>혼자 끝까지 구현하면서, 병목(속도/폰트/렌더/캐싱)을 해결한 경험</li>
<li>“AI가 경기를 이해하게 만들 수 있구나”를 데모로 증명한 것</li>
</ol>
<p><strong>K리그 팬 여러분에게:</strong></p>
<blockquote>
<p>&quot;데이터는 숫자가 아니라 이야기입니다. AI는 그 이야기를 읽어주는 해설가가 될 수 있습니다.&quot;</p>
</blockquote>
<hr>
<h2 id="🔗-링크">🔗 링크</h2>
<ul>
<li><strong>GitHub</strong>: <a href="https://github.com/Lova-clover/K-MOMENTO-AI">K-MOMENTO-AI</a></li>
<li><strong>DACON 코드</strong>: <a href="https://dacon.io/competitions/official/236648/codeshare/13760">수상작 코드</a></li>
<li><strong>발표자료</strong>: <a href="https://github.com/Lova-clover/K-MOMENTO-AI/blob/main/docs/K-MOMENT%20%EC%B5%9C%EC%A2%85.pdf">PDF</a> | <a href="https://github.com/Lova-clover/K-MOMENTO-AI/blob/main/docs/K-MOMENT%20%EC%B5%9C%EC%A2%85.pptx">PPT</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SW개발] 직무부트캠프 후기 – 현직자와 함께하는 프론트엔드 개발 A to Z]]></title>
            <link>https://velog.io/@lova-clover/SW%EA%B0%9C%EB%B0%9C-%EC%A7%81%EB%AC%B4%EB%B6%80%ED%8A%B8%EC%BA%A0%ED%94%84-%ED%9B%84%EA%B8%B0-%ED%98%84%EC%A7%81%EC%9E%90%EC%99%80-%ED%95%A8%EA%BB%98%ED%95%98%EB%8A%94-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-A-to-Z</link>
            <guid>https://velog.io/@lova-clover/SW%EA%B0%9C%EB%B0%9C-%EC%A7%81%EB%AC%B4%EB%B6%80%ED%8A%B8%EC%BA%A0%ED%94%84-%ED%9B%84%EA%B8%B0-%ED%98%84%EC%A7%81%EC%9E%90%EC%99%80-%ED%95%A8%EA%BB%98%ED%95%98%EB%8A%94-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-A-to-Z</guid>
            <pubDate>Sun, 25 Jan 2026 02:50:32 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>본 후기는 직무부트캠프를 학교 지원으로 수강한 후 작성하였으며, 후기 작성 이벤트에 참여해 소정의 원고료를 받았습니다.
수강 페이지: <a href="https://comento.kr/classroom/17325">프론트엔드 현직자와 함께하는 개발 직무 A to Z</a></p>
</blockquote>
<hr>
<h2 id="신청-과정">신청 과정</h2>
<p>• 저는 연세대학교 미래캠퍼스에서 진행한 프리패스 쿠폰을 받아 신청하여 수강했습니다.</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/849d3762-a3cf-472d-a5b5-35b1c9d2275a/image.png" alt=""></p>
<hr>
<h2 id="신청-계기">신청 계기</h2>
<p>이전에 SW개발(백엔드)과 데이터분석 직무부트캠프를 수강하면서 <strong>실무 흐름을 직접 경험하는 것</strong>이 얼마나 큰 도움이 되는지 체감했습니다. 그래서 이번엔 평소 관심 있던 <strong>프론트엔드 분야</strong>를 제대로 파보고 싶었습니다.</p>
<p>특히 &quot;HTML/CSS만 좀 해봤는데, 실무에서는 어떻게 구조화하고 JavaScript로 기능을 붙이는지&quot;가 궁금했고, <strong>현직 프론트엔드 개발자의 피드백을 받으며 4주간 집중적으로 성장할 수 있다</strong>는 커리큘럼을 보고 바로 신청했습니다.</p>
<hr>
<h2 id="주차별-수업-내용과-느낀-점">주차별 수업 내용과 느낀 점</h2>
<h3 id="1주차--htmlscss로-자기소개-페이지-제작">1주차 – HTML/SCSS로 자기소개 페이지 제작</h3>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/002254b0-8819-43e0-8197-7cf66e2a428b/image.png" alt="1주차 결과물 캡처"></p>
<p>• <strong>환경 세팅</strong>: Node.js + npm + SCSS 컴파일 환경 구축
• <strong>반응형 레이아웃</strong>: 360px / 768px / 1024px 세 구간 미디어쿼리 대응
• <strong>주요 구현</strong>:</p>
<ul>
<li>Sticky 헤더 + 네비게이션 호버 효과</li>
<li>다크/라이트 테마 전환 (LocalStorage 저장)</li>
<li>모바일 메뉴 토글</li>
<li>SCSS 변수·믹스인 활용으로 유지보수 용이한 스타일 구조화</li>
</ul>
<pre><code class="language-scss">$bp-768: 768px;
$bp-1024: 1024px;

@mixin up($w) { @media (min-width: $w) { @content; } }

.nav {
  display: none;
  @include up($bp-768) { display: flex; }
}</code></pre>
<p>• <strong>느낀 점</strong>: 단순히 &quot;예쁘게 만드는 것&quot;이 아니라, <strong>반응형 설계와 접근성(aria 속성, 스킵 링크 등)</strong>까지 고려해야 실무 수준이라는 걸 깨달았습니다. SCSS의 변수와 믹스인을 적극 활용하니 CSS가 훨씬 체계적으로 관리되었습니다.</p>
<hr>
<h3 id="2주차--css만으로-자판기-ui-구현">2주차 – CSS만으로 자판기 UI 구현</h3>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/3cc8ee3b-9b18-45f9-bc40-d85484b03cf9/image.jpeg" alt="2주차 결과물 캡처"></p>
<p>• <strong>목표</strong>: JavaScript 없이 순수 HTML/CSS로 자판기 시각 요소 완성
• <strong>주요 구현</strong>:</p>
<ul>
<li>음료 진열대 3단 구조 (캔 7개 × 3줄)</li>
<li>광고판 영역에 CSS 애니메이션 스마일리</li>
<li>동전/지폐 투입구, 배출구 디테일</li>
<li>그라디언트와 box-shadow로 입체감 표현</li>
</ul>
<pre><code class="language-css">.can--red { background: linear-gradient(135deg, #e53935 60%, #b71c1c); }
.can--blue { background: linear-gradient(135deg, #1e88e5 60%, #0d47a1); }</code></pre>
<p>• <strong>느낀 점</strong>: &quot;CSS로 이렇게까지 표현할 수 있구나&quot; 싶을 정도로 <strong>시각적 디테일에 집중한 주차</strong>였습니다. 실무에서 디자이너와 협업할 때 CSS만으로 어디까지 구현 가능한지 감을 잡는 데 큰 도움이 되었습니다.</p>
<hr>
<h3 id="3주차--javascript로-인터랙티브-앱-제작-계산기--시계">3주차 – JavaScript로 인터랙티브 앱 제작 (계산기 + 시계)</h3>
<h4 id="project-1-계산기">Project #1: 계산기</h4>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/221b34ac-2268-4e9b-b9c1-6834c9d19727/image.jpeg" alt="계산기 캡처"></p>
<p>• <strong>핵심 기능</strong>:</p>
<ul>
<li>버튼 입력 및 키보드 입력 동시 지원</li>
<li>사칙연산 + 괄호 처리</li>
<li>C(전체 초기화) / CE(마지막 입력 삭제)</li>
<li><strong>History 기능</strong>: 최근 10개 계산 기록 저장, 클릭 시 식 재입력</li>
</ul>
<pre><code class="language-javascript">function calculate(expr) {
  try {
    const result = Function(`&quot;use strict&quot;; return (${expr})`)();
    return Number.isFinite(result) ? result : &#39;Error&#39;;
  } catch {
    return &#39;Error&#39;;
  }
}</code></pre>
<h4 id="project-2-디지털-시계-배터리--알람">Project #2: 디지털 시계 (배터리 + 알람)</h4>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/cb0e6923-ca6e-496f-a0fe-297c731f3622/image.jpeg" alt="시계 캡처"></p>
<p>• <strong>핵심 기능</strong>:</p>
<ul>
<li>실시간 날짜/시간 표시 (1초마다 갱신)</li>
<li>배터리 시뮬레이션 (시간 경과에 따라 감소, 0%면 화면 off)</li>
<li>알람 최대 3개 등록/삭제</li>
<li><strong>스누즈 기능</strong>: 알람 울릴 때 5분 뒤로 재설정</li>
</ul>
<p>• <strong>느낀 점</strong>: <code>setInterval</code>, DOM 조작, 이벤트 핸들링을 집중적으로 다루면서 <strong>JavaScript의 핵심 패턴을 손에 익혔습니다</strong>. 특히 상태 관리(알람 목록, 배터리 잔량)를 어떻게 설계하느냐에 따라 코드 복잡도가 확 달라진다는 걸 체감했습니다.</p>
<hr>
<h3 id="4주차--실전-프로젝트-to-do-list--회원가입">4주차 – 실전 프로젝트 (To-Do-List + 회원가입)</h3>
<h4 id="project-1-to-do-list">Project #1: To-Do-List</h4>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/e4b9c08a-bada-45fd-a414-cc67da5d422e/image.jpeg" alt="To-Do-List 캡처"></p>
<p>• <strong>핵심 기능</strong>:</p>
<ul>
<li>일정 CRUD (추가/조회/수정/삭제)</li>
<li>LocalStorage 영구 저장</li>
<li>필터링 (전체/오늘/다가오는 일정/지난 일정)</li>
<li>정렬 (날짜 오름차순/내림차순, 최근 추가 순)</li>
<li>검색 (제목/메모 실시간 필터)</li>
</ul>
<pre><code class="language-javascript">function render() {
  let items = JSON.parse(localStorage.getItem(&#39;todos&#39;)) || [];
  items = applyFilter(items);
  items = applySort(items);
  items = applySearch(items);
  // DOM 렌더링...
}</code></pre>
<h4 id="project-2-회원가입-폼">Project #2: 회원가입 폼</h4>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/802c8823-4bf7-43db-9413-423c1f8b28ab/image.jpeg" alt="회원가입 캡처"></p>
<p>• <strong>핵심 기능</strong>:</p>
<ul>
<li>아이디 중복체크 (LocalStorage 기반 시뮬레이션)</li>
<li>비밀번호 실시간 유효성 검사:<ul>
<li>8~20자</li>
<li>영문 + 숫자 포함</li>
<li>특수문자 1개 이상</li>
<li>공백 금지</li>
<li>같은 문자 3회 연속 금지</li>
</ul>
</li>
<li>비밀번호 확인 일치 검사</li>
<li>폼 전체 유효성 통과 시에만 가입 버튼 활성화</li>
</ul>
<pre><code class="language-javascript">const rules = {
  rLen: pw.length &gt;= 8 &amp;&amp; pw.length &lt;= 20,
  rMix: /[a-zA-Z]/.test(pw) &amp;&amp; /\d/.test(pw),
  rSpec: /[!@#$%^&amp;*(),.?&quot;:{}|&lt;&gt;]/.test(pw),
  rSpace: !/\s/.test(pw),
  rRepeat: !/(.)\1{2,}/.test(pw)
};</code></pre>
<p>• <strong>느낀 점</strong>: 실제 서비스에서 흔히 보는 <strong>폼 유효성 검사 로직을 직접 구현</strong>하면서, 사용자 경험(UX)을 위한 실시간 피드백이 얼마나 중요한지 느꼈습니다. 단순히 &quot;동작하면 끝&quot;이 아니라 <strong>에러 메시지, 상태 표시, 접근성</strong>까지 챙겨야 완성도가 올라간다는 걸 배웠습니다.</p>
<hr>
<h2 id="내가-만든-결과물요약">내가 만든 결과물(요약)</h2>
<table>
<thead>
<tr>
<th align="center">주차</th>
<th align="left">프로젝트</th>
<th align="left">핵심 기술</th>
<th align="center">GitHub</th>
</tr>
</thead>
<tbody><tr>
<td align="center">1주차</td>
<td align="left">자기소개 페이지</td>
<td align="left">HTML, SCSS, 반응형, 테마 전환</td>
<td align="center"><a href="https://github.com/Lova-clover/comento-frontend-a2z/tree/main/1%EC%A3%BC%EC%B0%A8%20%EA%B3%BC%EC%A0%9C">Repo</a></td>
</tr>
<tr>
<td align="center">2주차</td>
<td align="left">자판기 UI</td>
<td align="left">CSS 그라디언트, 애니메이션, 레이아웃</td>
<td align="center"><a href="https://github.com/Lova-clover/comento-frontend-a2z/tree/main/2%EC%A3%BC%EC%B0%A8%20%EA%B3%BC%EC%A0%9C">Repo</a></td>
</tr>
<tr>
<td align="center">3주차</td>
<td align="left">계산기 + 시계</td>
<td align="left">JavaScript DOM, 이벤트, setInterval</td>
<td align="center"><a href="https://github.com/Lova-clover/comento-frontend-a2z/tree/main/3%EC%A3%BC%EC%B0%A8%20%EA%B3%BC%EC%A0%9C">Repo</a></td>
</tr>
<tr>
<td align="center">4주차</td>
<td align="left">To-Do + 회원가입</td>
<td align="left">CRUD, LocalStorage, 폼 유효성</td>
<td align="center"><a href="https://github.com/Lova-clover/comento-frontend-a2z/tree/main/4%EC%A3%BC%EC%B0%A8%20%EA%B3%BC%EC%A0%9C">Repo</a></td>
</tr>
</tbody></table>
<hr>
<h2 id="수료-후-도움이-된-점">수료 후 도움이 된 점</h2>
<p>• <strong>실무 감각</strong>: 단순 튜토리얼이 아니라 <strong>기획 → 마크업 → 스타일 → 기능 구현 → 피드백</strong> 사이클을 4주간 반복하며 실제 개발 흐름을 체득
• <strong>코드 품질 의식</strong>: 현직자 멘토님 피드백 덕분에 &quot;동작하면 끝&quot;이 아니라 <strong>가독성, 유지보수성, 접근성</strong>까지 고려하는 습관 형성
• <strong>포트폴리오 확보</strong>: 4개 프로젝트를 GitHub에 정리해두니, 이력서에 바로 첨부할 수 있는 <strong>실제 결과물</strong> 확보
• <strong>프론트엔드 적합성 확인</strong>: HTML/CSS/JS를 넘어 <strong>사용자 인터랙션을 설계하고 구현하는 과정</strong>이 즐겁다는 걸 확인 → 프론트엔드 직무에 대한 확신</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 부트캠프에서 가장 큰 수확은 <strong>&quot;프론트엔드는 화면만 그리는 게 아니다&quot;</strong>라는 걸 몸으로 체감한 것입니다.</p>
<p>반응형 설계, 접근성, 상태 관리, 유효성 검사, 사용자 피드백... 실무에서 신경 써야 할 요소들을 4주간 직접 구현하면서 <strong>프론트엔드 개발자의 시야</strong>가 한층 넓어졌습니다.</p>
<p>이전에 백엔드와 데이터분석 부트캠프도 수강했었는데, 이번 프론트엔드까지 경험하고 나니 <strong>풀스택 관점에서 서비스를 바라보는 시각</strong>도 생긴 것 같습니다.</p>
<p>프론트엔드에 관심 있지만 &quot;어디서부터 시작해야 할지 모르겠다&quot;는 분들께 강력 추천합니다. 특히 <strong>현직자 피드백</strong>이 있어서 혼자 공부할 때 놓치기 쉬운 부분을 짚어주는 게 가장 큰 장점이었습니다.</p>
<p>읽어주셔서 감사합니다 :)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[2025를 돌아보며, 2026을 다짐하다]]></title>
            <link>https://velog.io/@lova-clover/2025%EB%A5%BC-%EB%8F%8C%EC%95%84%EB%B3%B4%EB%A9%B0-2026%EC%9D%84-%EB%8B%A4%EC%A7%90%ED%95%98%EB%8B%A4</link>
            <guid>https://velog.io/@lova-clover/2025%EB%A5%BC-%EB%8F%8C%EC%95%84%EB%B3%B4%EB%A9%B0-2026%EC%9D%84-%EB%8B%A4%EC%A7%90%ED%95%98%EB%8B%A4</guid>
            <pubDate>Wed, 31 Dec 2025 19:33:40 GMT</pubDate>
            <description><![CDATA[<p>연말만 되면 마음이 이상하게 빨라진다.<br>한 해를 살아냈다는 안도감도 있는데, 동시에 찝찝함도 남는다.<br>잘한 것도 분명 있는데, 놓친 것도 떠오른다.</p>
<p>그래서 올해는 대충 넘기지 않기로 했다.<br>2025년을 한 번 제대로 정리하고, 2026년은 조금 더 단단하게 가보려고 한다.</p>
<hr>
<h2 id="2025년은-해봤다보다-남겼다가-더-큰-의미였다">2025년은 해봤다보다 남겼다가 더 큰 의미였다</h2>
<p>올해는 정말 이것저것 많이 했다.<br>프로젝트도 만들고, 대회도 나가고, 글도 쓰고, 다시 고치고.<br>하나만 파고들었다기보단 계속 움직였던 해였다.</p>
<p>근데 시간이 지나고 보니 많이 했다는 말은 별 의미가 없었다.<br>대신 남아있는 게 있었다.</p>
<ul>
<li>깃허브에 코드가 남고  </li>
<li>블로그에 고민이 남고  </li>
<li>내가 뭘 좋아하고 뭘 못하는지 기록이 남고  </li>
<li>다음에 뭘 해야 할지가 대충 보이기 시작했다  </li>
</ul>
<p>이게 생각보다 컸다.<br>머리로 이해한 건 시간이 지나면 흐려지는데, 손으로 만들고 정리한 건 나중에도 다시 꺼내 쓸 수 있었다.</p>
<p>올해 내 성장은 아마 여기서 왔다.</p>
<hr>
<h2 id="대회는-점수보다-실험하는-방식을-남겼다">대회는 점수보다 실험하는 방식을 남겼다</h2>
<p>대회는 늘 멘탈 게임이다.<br>결과가 잘 나오면 하루가 가볍고, 안 나오면 괜히 억울하다.<br>내가 뭘 놓쳤는지 모르겠는데 점수만 떨어질 때가 제일 힘들다.</p>
<p>근데 여러 번 겪으면서 확실히 느낀 게 있다.<br>대회에서 진짜 남는 건 모델이 아니라 습관이다.</p>
<p>규정을 꼼꼼히 읽는 습관, 실험이 재현되게 만드는 습관,<br>괜히 운 좋게 올라간 점수보다 왜 올라갔는지 설명 가능한 상태.</p>
<p>이게 쌓이면 다음 대회가 달라진다.<br>점수는 출렁이는데, 실험하는 방식은 계속 남는다.</p>
<p>2025년에는 이걸 몸으로 배웠다.</p>
<hr>
<h2 id="알고리즘은-솔직히-더-해야-한다">알고리즘은 솔직히 더 해야 한다</h2>
<p>이거는 좀 부족하다.</p>
<p>알고리즘은 꾸준히 했지만, 내가 진짜 만족하는 정도까지는 못 갔다.<br>잘할 때는 느는 게 보이고, 안 하면 금방 무뎌진다.<br>이건 너무 정직해서 더 괴롭다.</p>
<p>그래서 2026년은 군대 가기 전까지, 여기만큼은 제대로 잡고 가려고 한다.<br>대회든 프로젝트든 결국 바닥 체력이 있어야 버틴다.<br>그 바닥 체력은 나한테 알고리즘이다.</p>
<hr>
<h2 id="2026년은-목표보다-규칙을-만든다">2026년은 목표보다 규칙을 만든다</h2>
<p>나는 이제 멋있는 목표를 적는 것보다 지킬 수 있는 규칙을 만들고 싶다.<br>크게 바꾸지 않는다. 대신 무너지지 않는 최소 단위를 고정한다.</p>
<hr>
<h3 id="1-알고리즘-하루-1문제--짧은-회고">1) 알고리즘: 하루 1문제 + 짧은 회고</h3>
<p>컨디션이 좋든 나쁘든 하루 1문제는 한다.<br>대신 많이 푸는 날에도 회고는 짧게 남긴다.</p>
<ul>
<li>왜 틀렸는지  </li>
<li>다음엔 뭘 확인할지  </li>
<li>핵심 아이디어  </li>
<li>시간복잡도  </li>
<li>구현 포인트  </li>
</ul>
<p>이 정도만 쌓여도 결과가 달라진다.<br>내가 바라는 건 화려한 실력 상승이 아니라 티 나는 축적이다.</p>
<hr>
<h3 id="2-대회-준비된-참가만-한다">2) 대회: 준비된 참가만 한다</h3>
<p>2026년엔 대회를 더 많이 나갈 생각이다.<br>근데 막 던지고 운을 기다리는 참가는 하고 싶지 않다.</p>
<p>실험 로그를 남기고, 재현성 체크를 하고, 파이프라인을 안정화하고,<br>왜 이 접근이 먹히는지 말로 설명할 수 있는 상태.</p>
<p>이걸 기본값으로 만들고 싶다.<br>우승이든 상위권이든, 어차피 거기서 갈린다.</p>
<hr>
<h3 id="3-프로젝트-시작보다-완성">3) 프로젝트: 시작보다 완성</h3>
<p>아이디어는 계속 나온다.<br>근데 결과물로 남기는 건 또 다른 능력이다.</p>
<p>그래서 2026년에는 프로젝트 기준을 딱 세 개만 둔다.</p>
<ul>
<li>데모가 있어야 한다  </li>
<li>실행 방법이 적혀 있어야 한다  </li>
<li>기록이 있어야 한다  </li>
</ul>
<p>이 세 개가 없는 프로젝트는 내 기준에서 이제 진행이 아니라 방치다.</p>
<hr>
<h3 id="4-기록-감정-정리-말고-성장-기록">4) 기록: 감정 정리 말고 성장 기록</h3>
<p>기록은 멋있어 보이려고 쓰는 게 아니다.<br>나중에 다시 나를 살리려고 쓰는 거다.</p>
<p>일요일 20분만 써도 된다.<br>이번 주에 한 것, 막힌 것, 다음 주에 할 것.<br>한 달에 한 번은 월간 회고를 짧게 남긴다.</p>
<p>이걸 계속하면 1년 뒤에 내가 뭘 해왔는지가 남는다.<br>그리고 그게 포트폴리오가 된다.</p>
<hr>
<h2 id="마지막으로">마지막으로</h2>
<p>2025년은 가능성을 확인한 해였다면, 2026년은 지속력을 증명하는 해로 만들고 싶다.</p>
<p>대회도 열심히 하고, 군대 가기 전까지 알고리즘을 제대로 쌓고,<br>프로젝트도 더 단단하게 완성하고 싶다.</p>
<p>무엇보다 그냥 열심히 산 사람이 아니라 쌓아올린 사람이 되고 싶다.</p>
<p>2026년 말에 이 글을 다시 봤을 때,<br>그때 내가 조금 더 단단해져 있으면 좋겠다.<br>그 정도면 충분하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[RoboEscape: Algorithm Hunters – 7가지 Path-Planning 알고리즘으로 만든 교육용 게임]]></title>
            <link>https://velog.io/@lova-clover/RoboEscape-Algorithm-Hunters-7%EA%B0%80%EC%A7%80-Path-Planning-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%A0-%EA%B5%90%EC%9C%A1%EC%9A%A9-%EA%B2%8C%EC%9E%84</link>
            <guid>https://velog.io/@lova-clover/RoboEscape-Algorithm-Hunters-7%EA%B0%80%EC%A7%80-Path-Planning-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%A0-%EA%B5%90%EC%9C%A1%EC%9A%A9-%EA%B2%8C%EC%9E%84</guid>
            <pubDate>Thu, 11 Dec 2025 17:16:36 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>&quot;이론으로만 듣던 알고리즘들이 게임 속에서 살아 움직인다면?&quot;</strong><br>로봇알고리즘 수업에서 배운 Path-Planning 알고리즘들을 적 AI로 구현한 교육용 액션 게임을 만들었습니다.</p>
</blockquote>
<hr>
<h2 id="📌-목차">📌 목차</h2>
<ol>
<li><a href="#1-%EC%99%9C-%EC%9D%B4-%EA%B2%8C%EC%9E%84%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%97%88%EB%82%98">왜 이 게임을 만들었나</a></li>
<li><a href="#2-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B3%A0%EC%9E%90-%ED%95%9C-%EB%AC%B8%EC%A0%9C">해결하고자 한 문제</a></li>
<li><a href="#3-%EA%B8%B0%EC%88%A0-%EC%8A%A4%ED%83%9D%EA%B3%BC-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98">기술 스택과 아키텍처</a></li>
<li><a href="#4-%EA%B5%AC%ED%98%84-%EC%97%AC%EC%A0%95">구현 여정</a></li>
<li><a href="#5-%EB%A7%88%EC%A3%BC%ED%96%88%EB%8D%98-%EA%B8%B0%EC%88%A0%EC%A0%81-%EB%8F%84%EC%A0%84%EB%93%A4">마주했던 기술적 도전들</a></li>
<li><a href="#6-%EA%B2%B0%EA%B3%BC%EC%99%80-%EB%B0%B0%EC%9A%B4-%EC%A0%90">결과와 배운 점</a></li>
</ol>
<hr>
<h2 id="1-왜-이-게임을-만들었나">1. 왜 이 게임을 만들었나</h2>
<p>로봇알고리즘 수업에서 Path-Planning 알고리즘들(Bug, APF, PRM, RRT 등)을 배우면서 항상 궁금했습니다.</p>
<blockquote>
<p>*&quot;이 알고리즘들이 실제로는 어떻게 움직이는 거지?&quot;*</p>
</blockquote>
<p>슬라이드 속 점선 화살표와 수식들을 보면서도, 실제 로봇의 움직임이 머릿속에 그려지지 않았습니다. 특히 <strong>APF의 Local Minimum</strong>, <strong>PRM의 그래프 구축</strong>, <strong>Belief Filter의 확률 분포</strong> 같은 개념들은 이론만으로는 체감이 어려웠죠.</p>
<p>그러다 문득 이런 생각이 들었습니다:</p>
<p><strong>&quot;이 알고리즘들이 적 AI로 나오는 게임을 만들면 어떨까?&quot;</strong></p>
<p>각 알고리즘의 강점과 약점을 직접 체험할 수 있고, 게임을 플레이하면서 자연스럽게 학습할 수 있을 것 같았습니다. 처음엔 Bug1/Bug2 정도만 구현하려고 했는데, 하다 보니 욕심이 생겨서 결국 <strong>7가지 알고리즘을 전부</strong> 넣게 되었습니다.</p>
<p>그리고 단순히 움직이기만 하는 게 아니라, <strong>PRM의 그래프</strong>, <strong>RRT의 트리</strong>, <strong>Belief의 확률 분포</strong>까지 실시간으로 시각화하면서 진짜 &quot;알고리즘이 어떻게 생각하는지&quot; 볼 수 있는 게임이 완성되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/e82d77fd-ee0a-4ba5-bdcb-ba7548339165/image.png" alt="Stage 1 화면"></p>
<p><em>Stage 1: 튜토리얼 - Bug1 알고리즘</em></p>
<hr>
<h2 id="2-해결하고자-한-문제">2. 해결하고자 한 문제</h2>
<p>Path-Planning을 공부하는 학생들이 흔히 겪는 문제들을 정리해봤습니다:</p>
<h3 id="🎯-문제-인식">🎯 문제 인식</h3>
<table>
<thead>
<tr>
<th>문제</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>이론과 실제의 괴리</strong></td>
<td>슬라이드 속 알고리즘과 실제 동작이 연결되지 않음</td>
</tr>
<tr>
<td><strong>약점 체감 불가</strong></td>
<td>APF가 왜 Local Minimum에 빠지는지 말로만 들어서는 와닿지 않음</td>
</tr>
<tr>
<td><strong>비교 어려움</strong></td>
<td>Bug1 vs Bug2 vs Tangent Bug의 차이를 직접 비교하기 어려움</td>
</tr>
<tr>
<td><strong>지루한 학습</strong></td>
<td>논문과 교과서만으로는 흥미 유지가 힘듦</td>
</tr>
</tbody></table>
<h3 id="🎮-솔루션">🎮 솔루션</h3>
<p><strong>목표</strong>: 7가지 Path-Planning 알고리즘이 적 AI로 등장하고, 플레이어가 각 알고리즘의 약점을 이용해 생존하는 게임을 만들기. 단순한 게임이 아니라, <strong>알고리즘의 내부 상태를 실시간으로 시각화</strong>해서 학습 효과를 극대화하는 것이 핵심이었습니다.</p>
<hr>
<h2 id="3-기술-스택과-아키텍처">3. 기술 스택과 아키텍처</h2>
<h3 id="🛠-기술-스택-선택">🛠 기술 스택 선택</h3>
<p><strong>게임 엔진: Pygame 2.5.0</strong></p>
<ul>
<li>Python 기반이라 알고리즘 구현과 통합이 쉬움</li>
<li>2D 게임에 최적화되어 있고 러닝 커브가 낮음</li>
<li>실시간 렌더링, 이벤트 처리, 충돌 감지 기능 내장</li>
</ul>
<p><strong>수치 연산: NumPy 1.24.0 + SciPy 1.10.0</strong></p>
<ul>
<li>그리드맵 연산 (장애물 체크, 경로 탐색)</li>
<li>Belief Filter의 확률 분포 계산</li>
<li>벡터 연산 최적화 및 거리 계산 (KDTree)</li>
</ul>
<blockquote>
<p>💡 <strong>왜 Unity/Unreal이 아니라 Pygame인가?</strong><br>이 프로젝트에서는 <strong>알고리즘 구현이 메인</strong>이고 게임은 시각화 수단입니다. Python으로 알고리즘을 짜고 바로 게임에 통합할 수 있다는 점이 가장 큰 장점이었습니다.</p>
</blockquote>
<h3 id="🏗-아키텍처-설계">🏗 아키텍처 설계</h3>
<p><strong>주요 디자인 패턴:</strong></p>
<pre><code>├── Entity-Component System
│   └── 모든 적은 EnemyBase 클래스 상속
│
├── Strategy Pattern
│   └── 각 알고리즘은 독립된 Planner 클래스로 분리
│
├── State Machine
│   └── MENU → PLAYING → STAGE_CLEAR → GAME_OVER
│
└── Observer Pattern
    └── 파티클 시스템이 게임 이벤트에 반응</code></pre><hr>
<h2 id="4-구현-여정">4. 구현 여정</h2>
<h3 id="41-bug-algorithms--첫-번째-적-구현">4.1 Bug Algorithms – 첫 번째 적 구현</h3>
<p>가장 먼저 Bug1을 구현했습니다. &quot;벽을 만나면 한 바퀴 돌면서 목표에 가장 가까운 점을 찾는다&quot;는 원리는 간단했지만, 막상 코드로 옮기니 상태 관리가 생각보다 복잡했습니다.</p>
<p><strong>버전별 개선 과정:</strong></p>
<ul>
<li><strong>V1.0</strong>: 벽을 따라가다가 목표 방향으로 바로 돌아감 → 로직 오류 발견</li>
<li><strong>V1.1</strong>: 한 바퀴 완전히 돌 때까지 leave 포인트를 기록하지 않음 → 제대로 동작</li>
<li><strong>V1.2</strong>: 같은 벽을 계속 도는 무한루프 발생 → hit point 기록으로 해결</li>
</ul>
<pre><code class="language-python"># algos/bug.py - Bug1Planner 핵심 로직
class Bug1Planner:
    def __init__(self):
        self.state = &#39;motion_to_goal&#39;
        self.hit_point = None
        self.leave_point = None
        self.min_distance = float(&#39;inf&#39;)

    def plan_step(self, current, goal, grid_map):
        if self.state == &#39;motion_to_goal&#39;:
            # 목표로 직진
            next_pos = self._move_towards(current, goal)
            if self._is_collision(next_pos, grid_map):
                self.hit_point = current
                self.state = &#39;boundary_following&#39;
                self.min_distance = distance(current, goal)

        elif self.state == &#39;boundary_following&#39;:
            next_pos = self._follow_wall(current, goal, grid_map)
            dist = distance(next_pos, goal)

            if dist &lt; self.min_distance:
                self.min_distance = dist
                self.leave_point = next_pos

            # 한 바퀴 완전히 돌고 목표로 복귀 가능하면
            if self._circumnavigation_complete():
                self.state = &#39;motion_to_goal&#39;
                return self.leave_point</code></pre>
<p>Bug2와 Tangent Bug도 유사한 구조로 구현했습니다:</p>
<ul>
<li><strong>Bug2</strong>: M-line(시작-목표 직선) 거리 기반 의사결정</li>
<li><strong>Tangent Bug</strong>: 센서 범위 내 장애물의 접선 방향 활용</li>
</ul>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/81e2f5f7-b60e-4b35-90ec-5e3fa801943f/image.png" alt="Stage 2"></p>
<p><em>Stage 2: Bug1, Bug2, Tangent Bug의 서로 다른 움직임 패턴 비교</em></p>
<hr>
<h3 id="42-apf와-local-minimum의-시각화">4.2 APF와 Local Minimum의 시각화</h3>
<p>APF(Artificial Potential Field)는 원리가 직관적합니다. 목표는 자석처럼 끌어당기고, 장애물은 밀어냅니다. 하지만 U자나 O자 구조에서는 <strong>힘의 합이 0</strong>이 되어 꼼짝 못 하게 됩니다.</p>
<p>처음엔 단순히 force만 계산했는데, 게임에서 APF 적이 갇혀도 플레이어가 그걸 모르면 의미가 없었습니다. 그래서 <strong>Local Minimum 감지 기능</strong>을 추가했습니다.</p>
<pre><code class="language-python"># algos/apf.py - Local Minimum 감지
class APFPlanner:
    def __init__(self):
        self.force_history = []
        self.stuck_counter = 0

    def detect_local_minimum(self, window=5, threshold=10.0):
        &quot;&quot;&quot;최근 5 스텝의 force 크기가 모두 작으면 갇힌 것&quot;&quot;&quot;
        if len(self.force_history) &lt; window:
            return False

        recent_forces = self.force_history[-window:]
        avg_force = sum(np.linalg.norm(f) for f in recent_forces) / window

        return avg_force &lt; threshold

    def plan_step(self, current, goal, grid_map):
        # 인력 계산
        attractive_force = self.k_att * (goal - current)

        # 척력 계산 (주변 장애물에서)
        repulsive_force = np.zeros(2)
        for obstacle in self._get_nearby_obstacles(current, grid_map):
            dist = distance(current, obstacle)
            if dist &lt; self.d_influence:
                direction = (current - obstacle) / dist
                repulsive_force += self.k_rep * (1/dist - 1/self.d_influence) * (1/dist**2) * direction

        total_force = attractive_force + repulsive_force
        self.force_history.append(total_force)

        # Local Minimum 감지
        if self.detect_local_minimum():
            self.stuck_counter += 1
            # 랜덤 워크로 탈출 시도
            return current + random_direction() * 0.5

        return current + total_force * dt</code></pre>
<p>게임에서 APF 적이 U자 구조에 갇히면 <strong>적이 제자리에서 떨거나 느린 속도로 움직이는 모습</strong>이 보입니다. 이것이 바로 Local Minimum이 발생한 순간이고, 플레이어는 이 타이밍을 노려서 안전하게 다른 열쇠를 수집할 수 있습니다.</p>
<blockquote>
<p>💡 <strong>핵심 인사이트</strong><br>&quot;이론으로만 듣던 Local Minimum을 게임에서 직접 만들고 활용하니까, 알고리즘의 약점이 체감으로 와닿았습니다.&quot;</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/5408a8a4-f41d-4014-bb4d-65151474262f/image.png" alt="Stage 3"></p>
<p><em>Stage 3: APF 적들을 U자 함정에 가두는 전략</em></p>
<hr>
<h3 id="43-prm과-rrt의-실시간-시각화">4.3 PRM과 RRT의 실시간 시각화</h3>
<p>PRM(Probabilistic Roadmap)과 RRT(Rapidly-exploring Random Tree)는 샘플링 기반 알고리즘입니다. 단순히 &quot;점 찍고 경로 찾기&quot;로만 구현하면 재미가 없습니다. <strong>그래프와 트리가 어떻게 생성되는지</strong> 보여줘야 합니다.</p>
<p><strong>PRM 구현 과정:</strong></p>
<ol>
<li>맵 전체에 150개 랜덤 노드 샘플링</li>
<li>각 노드에서 반경 10 타일 이내의 노드들과 연결 시도</li>
<li>충돌 체크 (직선 경로에 장애물 없는지 확인)</li>
<li>A* 알고리즘으로 최단 경로 탐색</li>
</ol>
<pre><code class="language-python"># algos/prm.py - PRM Planner
class PRMPlanner:
    def __init__(self, num_samples=150, radius=10.0):
        self.nodes = []
        self.edges = []
        self.graph = {}
        self.is_built = False

    def build_roadmap(self, grid_map):
        &quot;&quot;&quot;로드맵 사전 구축&quot;&quot;&quot;
        # 1. 랜덤 샘플링 (장애물 제외)
        grid_h, grid_w = grid_map.shape
        while len(self.nodes) &lt; self.num_samples:
            x = random.randint(1, grid_w - 2)
            y = random.randint(1, grid_h - 2)
            if grid_map[y][x] == 0:  # 빈 공간
                self.nodes.append((x, y))

        # 2. 엣지 연결 (KDTree로 가까운 노드 찾기)
        from scipy.spatial import KDTree
        tree = KDTree(self.nodes)

        for node in self.nodes:
            # 반경 내 이웃 노드 찾기
            indices = tree.query_ball_point(node, self.radius)
            for idx in indices:
                neighbor = self.nodes[idx]
                if self._is_collision_free(node, neighbor, grid_map):
                    self.edges.append((node, neighbor))
                    if node not in self.graph:
                        self.graph[node] = []
                    self.graph[node].append(neighbor)

        self.is_built = True</code></pre>
<p><strong>RRT는 다릅니다.</strong> PRM은 사전에 맵을 구축하지만, RRT는 <strong>매번 새로운 트리를 성장</strong>시킵니다. 시작점에서 출발해서 랜덤하게 가지를 뻗어나가면서 목표에 도달하는 방식입니다.</p>
<p><strong>시각화의 핵심</strong>: <code>game/enemies/prm_rrt.py</code>에서 그래프와 트리를 실시간으로 그려줍니다. 처음엔 밝은 색으로 했더니 눈이 아파서, <strong>어두운 회색 + 낮은 투명도</strong>로 변경해서 배경처럼 은은하게 보이도록 했습니다.</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/846936da-f746-4e7e-8646-f36cd2b2ed0a/image.png" alt="Stage 4"></p>
<p><em>Stage 4: PRM의 파란 그래프와 RRT의 하늘색 트리가 실시간으로 보입니다</em></p>
<hr>
<h3 id="44-belief-filter와-확률-분포-히트맵">4.4 Belief Filter와 확률 분포 히트맵</h3>
<p>Belief Filter는 <strong>센서 노이즈 속에서 플레이어의 위치를 추정</strong>하는 알고리즘입니다. Prediction (모션 모델) + Update (센서 관측)을 반복합니다.</p>
<p>처음엔 Kalman Filter를 사용하려 했으나, 비선형 환경에서는 Particle Filter나 Histogram Filter가 더 적합하다는 것을 알게 되어 <strong>Grid-based Belief Filter</strong>로 구현했습니다.</p>
<pre><code class="language-python"># algos/belief.py - Belief Filter
class BeliefPlanner:
    def __init__(self, grid_resolution=4, sensor_range=300.0):
        self.resolution = grid_resolution  # 4x4 타일당 1개 belief cell
        self.belief = None  # 확률 분포 (2D numpy array)
        self.sensor_range = sensor_range

    def initialize_belief(self, grid_map):
        &quot;&quot;&quot;uniform 분포로 초기화&quot;&quot;&quot;
        h, w = grid_map.shape
        belief_h = h // self.resolution
        belief_w = w // self.resolution
        self.belief = np.ones((belief_h, belief_w)) / (belief_h * belief_w)

    def predict(self, motion):
        &quot;&quot;&quot;Prediction step - 이전 위치 기반 확률 전파&quot;&quot;&quot;
        from scipy.ndimage import gaussian_filter
        self.belief = gaussian_filter(self.belief, sigma=1.0)
        self.belief /= np.sum(self.belief)  # 정규화

    def update(self, measurement, grid_map, enemy_pos):
        &quot;&quot;&quot;Update step - 센서 관측으로 belief 갱신&quot;&quot;&quot;
        h, w = self.belief.shape
        for y in range(h):
            for x in range(w):
                cell_world = self._belief_to_world(x, y)

                # 센서 관측과의 거리
                dist = distance(cell_world, measurement)

                # 가우시안 likelihood
                likelihood = np.exp(-dist**2 / (2 * self.sensor_noise**2))

                # Belief 업데이트
                self.belief[y, x] *= likelihood

        # 정규화
        total = np.sum(self.belief)
        if total &gt; 0:
            self.belief /= total</code></pre>
<p><strong>노이즈 폭탄 스킬</strong>: 플레이어가 Q키를 누르면 Belief 적의 센서에 큰 노이즈를 추가합니다. 그러면 belief 분포가 엉뚱한 곳으로 퍼지면서 <strong>적이 잘못된 위치로 이동</strong>합니다.</p>
<p><strong>히트맵 시각화</strong>: Belief 적 뒤에 <strong>확률 분포를 색상으로 표현</strong>합니다. 확률이 높은 곳은 더 진하게, 낮은 곳은 투명하게 표시됩니다.</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/6a82ad23-b196-401f-a478-81dad655637d/image.png" alt="Stage 5"></p>
<p><em>Stage 5: Belief 적의 확률 분포 히트맵 - 청보라색 영역이 플레이어를 추적하는 모습</em></p>
<hr>
<h3 id="45-스테이지-설계와-난이도-밸런싱">4.5 스테이지 설계와 난이도 밸런싱</h3>
<p>7가지 알고리즘을 모두 구현했으니, 이제 <strong>각 알고리즘의 특성을 살린 스테이지</strong>를 만들어야 했습니다.</p>
<h4 id="🎮-스테이지-구성-전략">🎮 스테이지 구성 전략</h4>
<table>
<thead>
<tr>
<th>Stage</th>
<th>테마</th>
<th>등장 알고리즘</th>
<th>학습 목표</th>
<th>난이도</th>
</tr>
</thead>
<tbody><tr>
<td><strong>1</strong></td>
<td>튜토리얼</td>
<td>Bug1 × 3</td>
<td>기본 조작과 Bug1 패턴 학습</td>
<td>⭐</td>
</tr>
<tr>
<td><strong>2</strong></td>
<td>패턴 학습</td>
<td>Bug1, Bug2, Tangent</td>
<td>각 Bug 알고리즘의 차이 비교</td>
<td>⭐⭐</td>
</tr>
<tr>
<td><strong>3</strong></td>
<td>Local Minimum</td>
<td>Bug2, APF × 2, Tangent</td>
<td>APF의 약점 활용 (U자 함정)</td>
<td>⭐⭐⭐</td>
</tr>
<tr>
<td><strong>4</strong></td>
<td>그래프 차단</td>
<td>Tangent, PRM, RRT, APF × 2</td>
<td>샘플링 알고리즘 대응</td>
<td>⭐⭐⭐⭐</td>
</tr>
<tr>
<td><strong>5</strong></td>
<td>확률 전쟁</td>
<td>Bug2, Tangent, APF, Belief × 2, RRT, PRM</td>
<td>Belief Filter 교란 전략</td>
<td>⭐⭐⭐⭐⭐</td>
</tr>
<tr>
<td><strong>6</strong></td>
<td>보스전</td>
<td><strong>전 알고리즘 8마리</strong></td>
<td>모든 스킬 총동원</td>
<td>⭐⭐⭐⭐⭐⭐</td>
</tr>
</tbody></table>
<p><strong>난이도 밸런싱 패치</strong></p>
<p>처음엔 적 속도를 낮게 설정했더니 너무 쉬웠습니다. 여러 차례 플레이테스트를 거쳐 다음과 같이 조정했습니다:</p>
<ul>
<li>플레이어 속도: 200 → 180</li>
<li>APF 속도: 120 → 135</li>
<li>스킬 쿨타임: 전부 20% 증가</li>
<li>타임 리밋: 180초 → 150초</li>
</ul>
<hr>
<h3 id="46-uiux-디테일과-파티클-시스템">4.6 UI/UX 디테일과 파티클 시스템</h3>
<p>게임의 완성도를 높이기 위해 다양한 시각적 요소들을 추가했습니다.</p>
<p><strong>화면 구성:</strong></p>
<ul>
<li><strong>HUD</strong> (좌상단): HP, 스킬 쿨타임, 타이머, 열쇠 개수</li>
<li><strong>미니맵</strong> (우하단): 전체 맵과 적 위치 표시</li>
<li><strong>파티클 효과</strong>: 대시, 충돌, 열쇠 획득 시 이펙트</li>
<li><strong>알고리즘 시각화</strong>: PRM 그래프, RRT 트리, Belief 히트맵</li>
</ul>
<p><strong>개선 과정:</strong></p>
<ol>
<li><strong>시각화 색상 문제</strong>: 샘플링 시각화가 너무 눈에 띄어서 게임플레이를 방해 → 색상을 어두운 회색으로, 투명도를 40~60으로 낮춤</li>
<li><strong>동적 맵 변화</strong>: 벽을 세우면 PRM/RRT가 오작동 → 임시 벽 개수 변화 감지해서 자동 재계획</li>
<li><strong>아이템 접근성</strong>: 열쇠가 벽 안에 갇혀 있는 경우 → 열쇠/출구 주변 3×3 영역 강제 클리어</li>
</ol>
<hr>
<h2 id="5-마주했던-기술적-도전들">5. 마주했던 기술적 도전들</h2>
<h3 id="51-prm-로드맵-재구축-타이밍">5.1 PRM 로드맵 재구축 타이밍</h3>
<p><strong>문제</strong>: 처음엔 PRM이 맵을 한 번만 구축하고 끝이었습니다. 플레이어가 벽을 설치해도(E키 스킬) 그래프가 업데이트되지 않아 벽을 통과하는 경로를 찾는 문제가 발생했습니다.</p>
<p><strong>해결</strong>: 임시 벽 개수 변화를 감지하여 자동으로 재구축하도록 구현했습니다.</p>
<pre><code class="language-python"># game/enemies/prm_rrt.py
def check_map_changed(self, level):
    &quot;&quot;&quot;맵 변경 감지&quot;&quot;&quot;
    current_count = len(level.temp_walls)
    if current_count != self.last_temp_wall_count:
        self.last_temp_wall_count = current_count
        return True
    return False</code></pre>
<h3 id="52-belief-히트맵-색상-최적화">5.2 Belief 히트맵 색상 최적화</h3>
<p><strong>문제</strong>: 처음엔 밝은 보라색 (200, 100, 255)로 했는데, 확률 분포가 넓게 퍼지면 화면 전체가 보라색이 되어 눈이 아팠습니다.</p>
<p><strong>해결</strong>: </p>
<ul>
<li>색상: (200, 100, 255) → (140, 120, 180) 부드러운 청보라색</li>
<li>투명도: 150 → 50 (1/3로 감소)</li>
</ul>
<h3 id="53-스폰-위치-갇힘-문제">5.3 스폰 위치 갇힘 문제</h3>
<p><strong>문제</strong>: Stage 3에서 시작하자마자 플레이어가 벽에 갇혀 있는 경우가 발생했습니다. 맵 생성 시 스폰 위치를 고려하지 않았던 것이 원인이었습니다.</p>
<p><strong>해결</strong>: 스폰 위치 주변 3×3 영역을 강제로 비워줬습니다.</p>
<pre><code class="language-python"># game/level.py
self.spawn_pos = (5, GRID_HEIGHT // 2)
# 스폰 지역 주변 클리어
for dy in range(-1, 2):
    for dx in range(-1, 2):
        sx, sy = self.spawn_pos[0] + dx, self.spawn_pos[1] + dy
        if 0 &lt; sx &lt; GRID_WIDTH - 1 and 0 &lt; sy &lt; GRID_HEIGHT - 1:
            self.grid_map[sy][sx] = TILE_EMPTY</code></pre>
<h3 id="54-대각선-이동-속도-문제">5.4 대각선 이동 속도 문제</h3>
<p><strong>문제</strong>: WASD로 대각선 이동하면 √2배 빨라지는 문제가 있었습니다. 벡터를 정규화하지 않았던 것이 원인이었습니다.</p>
<p><strong>해결</strong>:</p>
<pre><code class="language-python"># game/player.py
velocity = np.array([dx, dy])
if np.linalg.norm(velocity) &gt; 0:
    velocity = velocity / np.linalg.norm(velocity) * PLAYER_SPEED</code></pre>
<hr>
<h2 id="6-결과와-배운-점">6. 결과와 배운 점</h2>
<h3 id="✨-완성된-기능">✨ 완성된 기능</h3>
<ul>
<li>✅ <strong>7가지 알고리즘</strong> (Bug1/2/Tangent, APF, PRM, RRT, Belief)</li>
<li>✅ <strong>6개 스테이지</strong> + 보스전 + 무한 모드</li>
<li>✅ <strong>실시간 시각화</strong> (PRM 그래프, RRT 트리, Belief 히트맵)</li>
<li>✅ <strong>스킬 시스템</strong> (대시, 벽, 노이즈, 슬로우모션)</li>
<li>✅ <strong>파티클 효과</strong> (대시 잔상, 충돌 이펙트)</li>
<li>✅ <strong>사운드 시스템</strong> (효과음, 옵션)</li>
<li>✅ <strong>완전한 UI</strong> (HUD, 미니맵, 게임오버 화면)</li>
<li>✅ <strong>5종 문서화</strong> (README, QUICKSTART, GAME, STAGE, DEVELOPMENT)</li>
</ul>
<h3 id="🚀-향후-개선-방향">🚀 향후 개선 방향</h3>
<p>현재 프로젝트에서 아쉬운 부분들과 향후 추가하고 싶은 기능들입니다:</p>
<ul>
<li><strong>알고리즘 확장</strong>: <code>D* Lite</code>, <code>Hybrid A*</code> 같은 고급 알고리즘 추가</li>
<li><strong>멀티플레이어</strong>: 협동 모드로 함께 플레이</li>
<li><strong>커스텀 맵</strong>: 사용자가 직접 맵 에디터로 스테이지 제작</li>
<li><strong>애니메이션 개선</strong>: 스프라이트 아트로 더 세련된 비주얼</li>
<li><strong>모바일 포팅</strong>: 터치 조작 지원</li>
<li><strong>강화학습 AI</strong>: 기존 알고리즘 외에 학습된 AI 적 추가</li>
</ul>
<h3 id="📚-배운-것들">📚 배운 것들</h3>
<blockquote>
<p><strong>이론을 게임으로 만들면, 학습이 놀이가 된다.</strong></p>
</blockquote>
<p>이 프로젝트를 통해 얻은 인사이트들입니다:</p>
<ol>
<li><strong>알고리즘 구현의 실제</strong>: 논문 속 수식과 실제 코드 사이의 간극을 메우는 과정</li>
<li><strong>시각화의 힘</strong>: 그래프와 트리를 보여주니 알고리즘이 &quot;무슨 생각&quot;을 하는지 직관적으로 이해됨</li>
<li><strong>밸런싱의 중요성</strong>: 게임이 너무 쉬우면 학습 효과가 없고, 너무 어려우면 포기하게 됨</li>
<li><strong>UX 디테일</strong>: 눈 피로도, 색상, 투명도 같은 작은 요소가 전체 경험을 좌우함</li>
<li><strong>교육용 게임 설계</strong>: 재미와 학습의 균형, 점진적 난이도 증가, 실패에서 배우기</li>
</ol>
<p>다음에는 이 경험을 바탕으로 다른 CS 주제(정렬 알고리즘, 그래프 탐색, 동적 프로그래밍)도 게임으로 만들어보고 싶습니다.</p>
<hr>
<h2 id="📎-프로젝트-정보">📎 프로젝트 정보</h2>
<p><strong>GitHub</strong>: <a href="https://github.com/Lova-clover/RoboEscape-Path-Planning">RoboEscape: Algorithm Hunters</a><br><strong>Tech Stack</strong>: Pygame, NumPy, SciPy, Python 3.8+<br><strong>Play Time</strong>: 스테이지 1-6 클리어까지 약 5~10분<br><strong>License</strong>: MIT</p>
<hr>
<p><strong>Made with 💜 for Robotics &amp; Game Development Education</strong></p>
<p>긴 글 읽어주셔서 감사합니다! 궁금한 점이나 피드백이 있으시다면 댓글로 남겨주세요. 🙏</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DACON] 제2회 Medical AI – gLM으로 유전체 변이 민감도 임베딩 만들어본 기록]]></title>
            <link>https://velog.io/@lova-clover/DACON-%EC%A0%9C2%ED%9A%8C-Medical-AI-gLM%EC%9C%BC%EB%A1%9C-%EC%9C%A0%EC%A0%84%EC%B2%B4-%EB%B3%80%EC%9D%B4-%EB%AF%BC%EA%B0%90%EB%8F%84-%EC%9E%84%EB%B2%A0%EB%94%A9-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B8-%EA%B8%B0%EB%A1%9D</link>
            <guid>https://velog.io/@lova-clover/DACON-%EC%A0%9C2%ED%9A%8C-Medical-AI-gLM%EC%9C%BC%EB%A1%9C-%EC%9C%A0%EC%A0%84%EC%B2%B4-%EB%B3%80%EC%9D%B4-%EB%AF%BC%EA%B0%90%EB%8F%84-%EC%9E%84%EB%B2%A0%EB%94%A9-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B8-%EA%B8%B0%EB%A1%9D</guid>
            <pubDate>Mon, 08 Dec 2025 18:25:25 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요, 성주(Lova-clover)입니다.</p>
<p>이번 글에서는 <strong>제2회 Medical AI (MAI) 경진대회</strong>에서<br>처음으로 유전체 언어모델(gLM)을 제대로 만져보면서<br><strong>Public 0.56375 / Private 0.56085 (Public 50위, Private 49위, 총 752팀)</strong>까지 만들어본 과정을 정리해 보려고 합니다.</p>
<p>대회 난이도도 꽤 있었고,<br>코드·환경·규칙을 같이 맞춰가느라 쉽진 않았지만,<br><strong>“gLM + 외부 유전체 데이터 + self-supervised 학습”</strong> 흐름을<br>직접 설계해본 경험이라 나중에 다시 참고할 수 있도록 기록을 남겨둡니다.</p>
<hr>
<h2 id="📌-0-대회--결과-한눈에-정리">📌 0. 대회 &amp; 결과 한눈에 정리</h2>
<h3 id="🧬-대회-정보">🧬 대회 정보</h3>
<ul>
<li>대회명: <strong>제2회 Medical AI (MAI) 경진대회</strong></li>
<li>주제: <strong>유전체 언어모델(gLM)의 변이 민감도 개선 및 성능 평가</strong></li>
<li>주최: DACON 외 (의료/AI 관련 기관 다수)</li>
<li>방식:<ul>
<li>참가자는 제공된 DNA 서열(<code>seq</code>)을 gLM으로 임베딩해서 제출</li>
<li>내부적으로 매핑된 ref/variant 쌍의 <strong>코사인 거리</strong>를 바탕으로<br>“작은 변이에 얼마나 민감한 임베딩인지”를 평가하는 구조</li>
</ul>
</li>
</ul>
<h3 id="📊-최종-결과">📊 최종 결과</h3>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/ea88d938-f466-44ea-b771-8a6282900e7f/image.jpeg" alt="Private 49등 이미지"></p>
<ul>
<li><strong>Public LB</strong>: 0.56375 (50 / 752팀)</li>
<li><strong>Private LB</strong>: 0.56085 (49 / 752팀)</li>
<li>대략 <strong>상위 7% 정도</strong></li>
</ul>
<p>이번 글은 이 중에서도</p>
<blockquote>
<p><strong>test.csv는 학습에 사용하지 않고,<br>Ensembl GRCh38만으로 self-supervised 학습을 돌려<br>Public 기준 0.56 초반까지 올려본 “클린 파이프라인”</strong></p>
</blockquote>
<p>기준으로 정리합니다.</p>
<hr>
<h2 id="🎯-1-왜-이-대회를-선택했는지">🎯 1. 왜 이 대회를 선택했는지</h2>
<p>2025년 들어 저는 계속 <strong>“의료 + AI”</strong> 쪽을 조금씩 건드리고 있었습니다.</p>
<ul>
<li>결막 이미지 기반 빈혈 판별 (ResNet18 + Streamlit)</li>
<li>의료 설명/요약 LLM</li>
<li>퇴원 안내/시술 후 관리 플랫폼(MediClear)</li>
</ul>
<p>이미지, 텍스트 쪽은 어느 정도 손에 익어가던 중이라,<br>이번에는 눈에 확 들어온 키워드가 하나 있었습니다.</p>
<blockquote>
<p><strong>유전체 언어모델(gLM)</strong></p>
</blockquote>
<p>DNA 염기(<code>A/C/G/T</code>)를 단어처럼 보고<br>서열을 문장처럼 다루는 언어모델이라는 점이 흥미로웠고,</p>
<blockquote>
<p>“LLM/비전 하던 감각으로,<br> <strong>유전체 서열에도 바로 적응할 수 있을까?</strong>”</p>
</blockquote>
<p>를 시험해보고 싶어서 대회에 참여하게 되었습니다.</p>
<hr>
<h2 id="🧱-2-전체-접근-개요">🧱 2. 전체 접근 개요</h2>
<p>이번 대회에서 제가 잡은 큰 방향은 다음과 같습니다.</p>
<ol>
<li><p><strong>Backbone(gLM)</strong>  </p>
<ul>
<li><code>InstaDeepAI/nucleotide-transformer-v2-500m-multi-species</code></li>
<li>파라미터는 모두 <strong>freeze</strong>, 별도의 head만 학습</li>
</ul>
</li>
<li><p><strong>외부 데이터</strong>  </p>
<ul>
<li><strong>Ensembl GRCh38 primary assembly FASTA</strong>를 사용해<br>사람 유전체 서열을 가져왔습니다.</li>
<li>여기서 잘라낸 서열들만으로 <strong>self-supervised Triplet 학습</strong> 진행</li>
</ul>
</li>
<li><p><strong>학습 데이터 구성 (Triplet)</strong>  </p>
<ul>
<li>anchor: GRCh38에서 자른 서열</li>
<li>pos: anchor 혹은 reverse complement(anchor)</li>
<li>neg_small: anchor에 <strong>작은 SNV(1~4개)</strong> 적용</li>
<li>neg_large: anchor에 <strong>큰 SNV(10~25개)</strong> 적용</li>
</ul>
</li>
<li><p><strong>Head 구조</strong>  </p>
<ul>
<li>gLM의 마지막 여러 레이어를 <strong>softmax 가중합</strong>으로 묶고,</li>
<li>masked mean pooling → MLP 투영 → 2048차원 L2-normalized 임베딩</li>
</ul>
</li>
<li><p><strong>Loss 설계</strong>  </p>
<ul>
<li>Ranking: <code>d_pos &lt; d_small &lt; d_large</code></li>
<li>PCC: 변이 개수 vs 거리의 상관계수 최대화</li>
<li>MSE: 거리 스케일을 변이 개수와 맞춰주는 보정</li>
</ul>
</li>
<li><p><strong>Inference</strong>  </p>
<ul>
<li>학습된 head로 <code>test.csv</code>의 서열을 임베딩</li>
<li><code>emb_0000 ~ emb_2047</code> 형태로 제출</li>
</ul>
</li>
</ol>
<p>이 구조가 <strong>규칙을 지키면서</strong><br>Public 기준 <strong>0.56 초반까지</strong> 안정적으로 올려준 세팅입니다.<br>리더보드 상 최종 점수(0.56375 / 0.56085)는 여러 제출 조합 중 하나였고,<br>글에서는 설명 가능한 “클린 버전”에 집중합니다.</p>
<hr>
<h2 id="🧬-3-외부-데이터--triplet-구성">🧬 3. 외부 데이터 &amp; Triplet 구성</h2>
<h3 id="3-1-ensembl-grch38로-서열-준비">3-1. Ensembl GRCh38로 서열 준비</h3>
<p>학습에 사용한 외부 데이터는 <strong>딱 하나</strong>입니다.</p>
<ul>
<li>Ensembl <strong>GRCh38 primary assembly (release 109)</strong> FASTA</li>
</ul>
<p>처리는 아래와 같이 진행했습니다.</p>
<ul>
<li><code>.fa.gz</code> 다운로드 후 <code>gunzip</code>으로 해제</li>
<li>각 contig를 읽어서 <strong>길이 ≥ 300bp</strong>인 서열만 사용</li>
<li>그 중에서 <strong>대략 200개 정도만 샘플링</strong></li>
<li>Triplet 샘플링 과정에서 각 서열에서 랜덤 crop</li>
</ul>
<p>“전체 reference를 최대한 다 쓰겠다” 보다는,</p>
<blockquote>
<p><strong>“현실적인 인간 유전체 서열 위에서<br>다양한 변이 시나리오를 만들자”</strong></p>
</blockquote>
<p>에 초점을 맞췄습니다.</p>
<h3 id="3-2-tripletdataset-구조">3-2. TripletDataset 구조</h3>
<p><code>TripletDataset</code>은 각 아이템에서 다음 네 가지 서열을 뽑습니다.</p>
<ol>
<li><p><strong>anchor</strong></p>
<ul>
<li>GRCh38 contig 중 하나 선택</li>
<li>길이가 충분하면 랜덤 위치에서 <code>max_length=256</code>으로 crop</li>
</ul>
</li>
<li><p><strong>positive (pos)</strong></p>
<ul>
<li>50% 확률로 그대로 anchor</li>
<li>50% 확률로 <strong>reverse complement(anchor)</strong></li>
<li>→ 실제로는 같은 위치지만 방향만 다른 서열</li>
</ul>
</li>
<li><p><strong>neg_small</strong></p>
<ul>
<li>anchor에 <strong>1~4개 SNV(single nucleotide variant)</strong> 적용</li>
<li>A/C/G/T 위치만 골라서 다른 염기로 치환</li>
</ul>
</li>
<li><p><strong>neg_large</strong></p>
<ul>
<li>anchor에 <strong>10~25개 SNV</strong> 적용</li>
</ul>
</li>
</ol>
<p>batch 단위에서는</p>
<ul>
<li>anchor / pos / neg_small / neg_large를 한 번에 토크나이즈해서</li>
<li>backbone은 딱 한 번만 태우고,</li>
<li>head에서 index로 각각 분리해서 쓰는 방식으로 효율을 맞췄습니다.</li>
</ul>
<p>여기서 직관은 아주 단순합니다.</p>
<ul>
<li>pos는 anchor와 거의 동일하니 → <strong>가장 가깝게</strong></li>
<li>neg_small는 anchor보다 <strong>조금 멀게</strong></li>
<li>neg_large는 그 둘보다 <strong>더 멀게</strong></li>
</ul>
<blockquote>
<p>즉, <strong>변이 개수가 늘어날수록 거리도 커지는 embedding space</strong>를  
만들고자 했습니다.</p>
</blockquote>
<hr>
<h2 id="🧠-4-glm-backbone--head--loss">🧠 4. gLM Backbone + Head + Loss</h2>
<h3 id="4-1-backbone-nucleotide-transformer">4-1. Backbone (nucleotide-transformer)</h3>
<ul>
<li>모델: <code>AutoModelForMaskedLM</code> 형태로 로드</li>
<li><code>output_hidden_states=True</code>로 각 레이어 출력까지 반환</li>
<li>모든 파라미터 <code>requires_grad = False</code>로 고정<br>→ Colab 환경에서 안정적으로 돌리기 위해 <strong>head만 학습</strong></li>
</ul>
<h3 id="4-2-varianthead--레이어-가중합--mlp-투영">4-2. VariantHead – 레이어 가중합 + MLP 투영</h3>
<p>Head 쪽에서는 대략 다음과 같이 처리했습니다.</p>
<ol>
<li><strong>마지막 N개 레이어(hidden states)만 사용</strong><ul>
<li>예: 마지막 8개 레이어</li>
</ul>
</li>
<li>learnable vector <code>layer_weights</code> (길이 8)을 softmax로 변환해서<br>각 레이어를 <strong>가중합</strong></li>
<li><code>attention_mask</code>를 사용해 <strong>masked mean pooling</strong>  <ul>
<li>padding 토큰은 제외하고 평균</li>
</ul>
</li>
<li>MLP 투영<ul>
<li>Linear(hidden → 2×hidden)  </li>
<li>LayerNorm  </li>
<li>GELU  </li>
<li>Dropout(0.2)  </li>
<li>Linear(2×hidden → <strong>2048</strong>)</li>
</ul>
</li>
<li>마지막으로 <strong>L2 normalize</strong><ul>
<li>코사인 거리 기반 평가를 고려해 정규화</li>
</ul>
</li>
</ol>
<p>결과적으로 <strong>2048 차원 L2-normalized 벡터</strong> 하나가<br>서열 하나를 표현하는 embedding이 됩니다.</p>
<h3 id="4-3-loss--ranking--pcc--mse-조합">4-3. Loss – Ranking + PCC + MSE 조합</h3>
<p>거리 정의는 다음과 같이 뒀습니다.</p>
<blockquote>
<p><code>d(x, y) = 1 - cosine_similarity(x, y)</code></p>
</blockquote>
<p>그 위에서 세 가지 목적을 섞었습니다.</p>
<h4 id="①-ranking-loss">① Ranking loss</h4>
<ul>
<li>목표:<ul>
<li><code>d_pos &lt; d_small &lt; d_large</code></li>
</ul>
</li>
<li>구현:<ul>
<li><code>F.relu(d_pos - d_small + margin_small)</code></li>
<li><code>F.relu(d_small - d_large + margin_large)</code></li>
</ul>
</li>
<li>margin은 0.2, 0.3 수준 사용</li>
</ul>
<h4 id="②-pcc상관계수-loss">② PCC(상관계수) loss</h4>
<ul>
<li>세 점을 생각합니다.<ul>
<li>(0, d_pos)</li>
<li>(n_small, d_small)</li>
<li>(n_large, d_large)</li>
</ul>
</li>
<li>이 세 점을 펼쳐서<br><strong>변이 개수 vs 거리</strong>의 상관계수(<code>corr</code>)를 계산한 뒤,</li>
<li><code>loss_pcc = -corr</code>로 추가해<br>→ 변이 개수가 커질수록 거리가 커지는 방향을 유도했습니다.</li>
</ul>
<h4 id="③-mse로-거리-스케일-보정">③ MSE로 거리 스케일 보정</h4>
<ul>
<li><code>n_small</code>, <code>n_large</code>는 대략 1<del>25 사이 값이므로<br>30으로 나누어 0</del>1 근처로 맞춰두고,</li>
<li><code>d_small</code>, <code>d_large</code>를 해당 값에 회귀시키는 MSE를 추가합니다.</li>
<li>Ranking/PCC만 쓸 때 거리 스케일이 제멋대로 튈 수 있는 부분을<br>어느 정도 눌러주는 역할을 기대했습니다.</li>
</ul>
<p>최종 loss는 아래와 같이 구성했습니다.</p>
<pre><code class="language-python">loss = loss_rank_small + loss_rank_large
if USE_PCC_LOSS:
    loss = loss + w_pcc * loss_pcc
loss = loss + beta_mse * loss_mse</code></pre>
<ul>
<li><code>w_pcc = 0.5</code></li>
<li><code>beta_mse = 0.2</code></li>
</ul>
<p>이 조합이 <strong>Public 0.56 초반까지</strong>
꾸준히 올려준 세팅이었습니다.</p>
<hr>
<h2 id="⚙️-5-학습--추론-설정">⚙️ 5. 학습 &amp; 추론 설정</h2>
<h3 id="5-1-학습-설정">5-1. 학습 설정</h3>
<ul>
<li><p>Triplet 개수: <strong>30,000</strong></p>
</li>
<li><p>Batch size: 2 (Colab T4 기준)</p>
</li>
<li><p>Epoch: 2</p>
</li>
<li><p>Optimizer: AdamW</p>
<ul>
<li><code>lr = 5e-4</code>, <code>weight_decay = 1e-4</code></li>
</ul>
</li>
<li><p>Mixed precision:</p>
<ul>
<li><code>torch.amp.autocast(&quot;cuda&quot;, enabled=True)</code></li>
<li>GradScaler 사용</li>
</ul>
</li>
</ul>
<p>Backbone은 freeze해 두고,
head만 학습하는 구조라 Colab에서도 크게 무리 없이 돌아갔습니다.</p>
<h3 id="5-2-inference--제출">5-2. Inference &amp; 제출</h3>
<ol>
<li>학습이 끝난 head/backbone을 eval 모드로 두고</li>
<li><code>test.csv</code>의 <code>seq</code>를 <strong>변형 없이 그대로</strong> tokenizer에 넣어서</li>
<li><code>max_length=512</code> 설정으로 조금 더 넉넉하게 자른 뒤</li>
<li>같은 head를 거쳐 2048차원 embedding을 생성</li>
<li><code>emb_0000 ~ emb_2047</code> 컬럼명으로 DataFrame을 만들고
<code>ID</code>와 함께 CSV로 저장 후 제출</li>
</ol>
<p>여기까지가 제가 <strong>규칙을 지키는 기준</strong>으로 잡은 파이프라인입니다.</p>
<p>중간에 규칙에 애매했을 수 있는 시도들도 있었지만,
이 글에서는 <strong>설명 가능하고 재현 가능한 버전</strong>만 남겨두었습니다.</p>
<hr>
<h2 id="🧪-6-성능-변화--간단-회고">🧪 6. 성능 변화 &amp; 간단 회고</h2>
<p>실험 로그를 완벽하게 정리해 두지는 않아서
정확한 숫자보다는 <strong>대략적인 흐름</strong>만 정리해 두려고 합니다.</p>
<table>
<thead>
<tr>
<th>버전</th>
<th>내용</th>
<th>Public 대략</th>
</tr>
</thead>
<tbody><tr>
<td>v0</td>
<td>gLM hidden state 단순 mean pooling + Linear head, 외부 데이터 없음</td>
<td>0.5 초반</td>
</tr>
<tr>
<td>v1</td>
<td>GRCh38 Triplet + Ranking loss만 사용</td>
<td>0.53 ~ 0.54</td>
</tr>
<tr>
<td>v2</td>
<td>Ranking + PCC + MSE loss 조합, layer-weighted pooling 도입</td>
<td><strong>0.56 초반</strong></td>
</tr>
</tbody></table>
<p>최종 제출 조합 중 하나가
<strong>Public 0.56375 / Private 0.56085</strong>를 찍었고,</p>
<p>그 결과 <strong>Public 50위, Private 49위(752팀 중)</strong>로 대회를 마무리했습니다.</p>
<p>개인적으로는,</p>
<ul>
<li>gLM을 처음 다뤄본 대회였다는 점,</li>
<li>유전체 도메인 지식이 많지 않은 상태에서 여기까지 끌어올렸다는 점에서</li>
</ul>
<blockquote>
<p><strong>“다음에 다시 도전할 때 기준점이 될 결과”</strong></p>
</blockquote>
<p>라고 생각하고 있습니다.</p>
<hr>
<h2 id="📝-7-다음을-위해-남겨두는-메모">📝 7. 다음을 위해 남겨두는 메모</h2>
<p>이번 MAI를 통해 느낀 점을 짧게 정리하면 다음과 같습니다.</p>
<h3 id="1-glm--유전체-서열에-대한-첫-경험치-확보">1) gLM + 유전체 서열에 대한 첫 경험치 확보</h3>
<ul>
<li>GRCh38에서 서열을 잘라 쓰는 법</li>
<li>reverse complement / SNV 적용 방식</li>
<li>nucleotide-transformer 계열 gLM을 실전 대회에서 굴려본 경험</li>
</ul>
<p>자체가 앞으로 의료/유전체 쪽을 다시 건드릴 때
좋은 출발점이 될 것 같습니다.</p>
<h3 id="2-규칙-안에서-설계하는-습관">2) 규칙 안에서 설계하는 습관</h3>
<ul>
<li>test 분포를 건드리거나,</li>
<li>규정 경계에 걸릴 수 있는 실험들은 과감하게 제외하고,</li>
<li><strong>“설명 가능한 솔루션”</strong>을 기준으로 정리해 두는 게
장기적으로는 더 도움이 된다고 느꼈습니다.</li>
</ul>
<p>다른 대회를 할 때도 이 기준을 유지하려고 합니다.</p>
<h3 id="3-실험-로그의-중요성">3) 실험 로그의 중요성</h3>
<p>이번에는 여러 일정이 겹치면서
실험 로그를 아주 디테일하게 남기지는 못했습니다.</p>
<p>다음 대회부터는</p>
<ul>
<li>최소한 <strong>세팅 / 점수 / 간단 코멘트</strong>는 표로 정리해 두고,</li>
<li>회고 글도 좀 더 <strong>숫자 중심</strong>으로 정리할 수 있도록
실험 기록 방식을 개선해 보려고 합니다.</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>제2회 MAI 경진대회는
저에게 <strong>유전체 언어모델(gLM)에 입문한 첫 실전 무대</strong>였습니다.</p>
<ul>
<li>Public 0.56375 / Private 0.56085</li>
<li>Public 50위, Private 49위 (752팀 중)</li>
</ul>
<p>이라는 결과와 함께,</p>
<blockquote>
<p><strong>“GRCh38 기반 Triplet self-supervised + gLM + variant-sensitive embedding”</strong></p>
</blockquote>
<p>이라는 패턴을 몸으로 한 번 익혔다는 점에서
나중에 다시 돌아봤을 때도 의미 있는 기록이 될 것 같습니다.</p>
<p>읽어주셔서 감사합니다 🙂
궁금한 점이나 더 자세히 보고 싶은 부분이 있다면
댓글로 편하게 남겨 주세요!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DevHistory – 개발 포트폴리오 자동화 플랫폼 만들기]]></title>
            <link>https://velog.io/@lova-clover/DevHistory-%EA%B0%9C%EB%B0%9C-%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%EC%9E%90%EB%8F%99%ED%99%94-%ED%94%8C%EB%9E%AB%ED%8F%BC-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@lova-clover/DevHistory-%EA%B0%9C%EB%B0%9C-%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%EC%9E%90%EB%8F%99%ED%99%94-%ED%94%8C%EB%9E%AB%ED%8F%BC-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Sun, 30 Nov 2025 13:39:17 GMT</pubDate>
            <description><![CDATA[<h2 id="0-배경--왜-만들었나">0. 배경 – 왜 만들었나</h2>
<p>개발하다 보면 GitHub에 수십 개의 레포지토리가 쌓이고, 백준이나 Velog에도 활동 기록이 남는다. 근데 막상 포트폴리오 만들려고 하면 이걸 일일이 정리하는 게 너무 귀찮았다. &quot;자동으로 수집해서 정리해주고, LLM으로 블로그 글까지 써주면 얼마나 좋을까?&quot; 하는 생각에서 시작했다.</p>
<p>그러다가 &quot;아예 플랫폼으로 만들면 괜찮겠는데?&quot;라는 생각이 들어서 본격적으로 개발하게 됐다.</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/ed67c82b-0c57-4439-b007-0d8f76e077d2/image.png" alt="DevHistory 대시보드"></p>
<p><em>대시보드에서 한눈에 볼 수 있는 개발 활동 통계</em></p>
<h2 id="1-문제-정의">1. 문제 정의</h2>
<p>개발자들이 겪는 문제:</p>
<ul>
<li>GitHub, Velog, Solved.ac 등 여러 플랫폼에 흩어진 활동 기록</li>
<li>포트폴리오 만들 때마다 수작업으로 정리해야 함</li>
<li>프로젝트마다 블로그 글 쓰기 귀찮음</li>
<li>주간 회고나 활동 리포트를 자동으로 받고 싶음</li>
</ul>
<p><strong>목표:</strong> 모든 개발 활동을 자동으로 수집해서 대시보드로 보여주고, LLM이 블로그 글과 주간 리포트까지 작성해주는 서비스</p>
<h2 id="2-기술-스택-선택">2. 기술 스택 선택</h2>
<h3 id="백엔드">백엔드</h3>
<ul>
<li><p><strong>FastAPI</strong> (Python 3.11)</p>
<ul>
<li>비동기 처리로 외부 API 호출 최적화</li>
<li>Pydantic으로 타입 안정성 확보</li>
<li>자동 API 문서 생성 (Swagger)</li>
</ul>
</li>
<li><p><strong>PostgreSQL</strong></p>
<ul>
<li>관계형 데이터로 레포-커밋-블로그 연결</li>
<li>SQLAlchemy ORM으로 쿼리 관리</li>
</ul>
</li>
<li><p><strong>Celery + Redis</strong></p>
<ul>
<li>백그라운드 작업 (GitHub/Velog 동기화, LLM 생성)</li>
<li>주간 리포트 스케줄링</li>
</ul>
</li>
<li><p><strong>Alembic</strong></p>
<ul>
<li>데이터베이스 마이그레이션 관리</li>
</ul>
</li>
</ul>
<h3 id="프론트엔드">프론트엔드</h3>
<ul>
<li><p><strong>Next.js 14</strong> (App Router)</p>
<ul>
<li>Server Components로 초기 로딩 최적화</li>
<li>TailwindCSS로 빠른 UI 구성</li>
<li>Framer Motion으로 부드러운 애니메이션</li>
</ul>
</li>
<li><p><strong>Recharts</strong></p>
<ul>
<li>커밋 트렌드, 언어 분포 시각화</li>
</ul>
</li>
</ul>
<h3 id="llm">LLM</h3>
<ul>
<li><strong>OpenAI API (gpt-4o-mini)</strong><ul>
<li>레포지토리 README + 커밋 히스토리 → 기술 블로그 자동 생성</li>
<li>주간 활동 → 회고 리포트 자동 생성</li>
<li>스타일 프로필로 사용자별 말투 학습</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/94565987-d611-43c7-bd55-890eb691bde4/image.png" alt="홈페이지"></p>
<p><em>DevHistory 랜딩 페이지 - &quot;개발 활동을 자동으로 머지하세요&quot;</em></p>
<h2 id="💡-사용-흐름">💡 사용 흐름</h2>
<p>DevHistory를 실제로 어떻게 사용하는지 단계별로 정리했다.</p>
<h3 id="1️⃣-github-계정으로-시작하기">1️⃣ GitHub 계정으로 시작하기</h3>
<ul>
<li>홈페이지에서 <strong>&quot;GitHub로 시작하기&quot;</strong> 버튼 클릭</li>
<li>GitHub OAuth 화면에서 권한 허용 (레포지토리 읽기, 사용자 정보)</li>
<li>자동으로 로그인되고 대시보드로 이동</li>
</ul>
<h3 id="2️⃣-추가-계정-연동-선택">2️⃣ 추가 계정 연동 (선택)</h3>
<ul>
<li><strong>설정 페이지</strong>에서 Velog ID와 Solved.ac 핸들 입력</li>
<li>포트폴리오 정보 설정 (이름, 이메일, 자기소개, 표시할 레포 개수)</li>
<li>저장하면 모든 플랫폼의 데이터를 수집할 준비 완료</li>
</ul>
<h3 id="3️⃣-활동-데이터-자동-수집">3️⃣ 활동 데이터 자동 수집</h3>
<ul>
<li>대시보드에서 <strong>&quot;GitHub 동기화&quot;</strong>, <strong>&quot;Velog 동기화&quot;</strong>, <strong>&quot;Solved.ac 동기화&quot;</strong> 버튼 클릭</li>
<li>Celery 워커가 백그라운드에서 비동기로 데이터 수집:<ul>
<li><strong>GitHub</strong>: 본인 소유 레포지토리, 커밋 히스토리 (contributor 레포 제외)</li>
<li><strong>Velog</strong>: 블로그 포스트 메타데이터 (제목, URL, 날짜)</li>
<li><strong>Solved.ac</strong>: 푼 문제 목록, 난이도, 티어</li>
</ul>
</li>
<li>새로운 활동이 생기면 언제든 다시 동기화 가능</li>
</ul>
<h3 id="4️⃣-대시보드에서-활동-확인">4️⃣ 대시보드에서 활동 확인</h3>
<ul>
<li><strong>전체 통계</strong>: 총 커밋 수, 레포지토리 개수, 문제 풀이 수, 블로그 글 수</li>
<li><strong>주간 트렌드</strong>: 이번 주 vs 지난주 증감 (+13, +2 같은 절대값)</li>
<li><strong>커밋 트렌드 그래프</strong>: 날짜별 활동량 시각화</li>
<li><strong>언어 분포</strong>: 레포지토리 개수 기준 프로그래밍 언어 비율</li>
</ul>
<h3 id="5️⃣-레포지토리-블로그-글-생성">5️⃣ 레포지토리 블로그 글 생성</h3>
<ul>
<li><strong>레포지토리 메뉴</strong>에서 프로젝트 선택</li>
<li><strong>&quot;블로그 글 생성&quot;</strong> 버튼 클릭</li>
<li>OpenAI가 다음 정보를 바탕으로 기술 블로그 초안 작성:<ul>
<li>README 내용 (최대 3000자)</li>
<li>최근 커밋 히스토리 요약</li>
<li>레포지토리 생성 날짜, 사용 언어</li>
<li>사용자의 Velog 스타일 학습 (반말, 친근한 톤)</li>
</ul>
</li>
<li>생성된 글은 <strong>생성된 콘텐츠 메뉴</strong>에서 확인/수정/삭제 가능</li>
</ul>
<h3 id="6️⃣-주간-회고-리포트">6️⃣ 주간 회고 리포트</h3>
<ul>
<li><strong>주간 리포트 메뉴</strong>에서 최근 활동 기반 회고 글 확인</li>
<li>이번 주 커밋, 레포, 문제 풀이 통계를 자연스러운 문장으로 정리</li>
<li>Velog나 노션에 바로 복사해서 사용 가능</li>
</ul>
<h3 id="7️⃣-포트폴리오-생성-및-내보내기">7️⃣ 포트폴리오 생성 및 내보내기</h3>
<ul>
<li><strong>포트폴리오 메뉴</strong>에서 자동 생성된 포트폴리오 확인:<ul>
<li>프로필 카드 (아바타, 이름, 소개, GitHub 링크)</li>
<li>활동 통계 (레포, 문제, 커밋, 블로그)</li>
<li>커밋 수 기준 상위 프로젝트</li>
<li>레포 개수 기준 기술 스택</li>
</ul>
</li>
<li><strong>&quot;PDF 내보내기&quot;</strong> 버튼으로 포트폴리오를 PDF로 다운로드:<ul>
<li>개요, 프로젝트, 스킬 탭이 모두 포함</li>
<li>각 탭마다 별도 페이지로 생성</li>
<li>파일명: <code>{이름}_{날짜}.pdf</code></li>
</ul>
</li>
</ul>
<h3 id="8️⃣-생성된-콘텐츠-관리">8️⃣ 생성된 콘텐츠 관리</h3>
<ul>
<li><strong>생성된 콘텐츠 메뉴</strong>에서 모든 AI 생성 글 관리</li>
<li>글 클릭 시 Markdown 프리뷰 확인</li>
<li><strong>수정 버튼</strong>으로 내용 편집</li>
<li><strong>삭제 버튼</strong>으로 불필요한 글 제거</li>
</ul>
<hr>
<p><strong>핵심 포인트:</strong></p>
<ul>
<li>한 번 연동하면 → 버튼 클릭만으로 데이터 업데이트</li>
<li>AI가 알아서 → Velog 스타일로 블로그 글 작성</li>
<li>포트폴리오 → 클릭 한 번에 PDF로 내보내기</li>
<li>모든 활동 → 한곳에서 타임라인으로 확인</li>
</ul>
<p><strong>더 이상 수작업 없이, 개발만 하면 포트폴리오가 쌓입니다.</strong> ✨</p>
<h2 id="3-구현-과정">3. 구현 과정</h2>
<h3 id="31-oauth-인증-플로우">3.1 OAuth 인증 플로우</h3>
<p>처음엔 세션 기반으로 하려다가 JWT로 전환했다. 프론트와 백엔드가 분리되어 있어서 토큰 방식이 더 깔끔했다.</p>
<pre><code class="language-python"># apps/api/app/routers/auth.py
@router.get(&quot;/github&quot;)
async def github_login():
    &quot;&quot;&quot;GitHub OAuth 시작&quot;&quot;&quot;
    return {
        &quot;url&quot;: f&quot;https://github.com/login/oauth/authorize?client_id={GITHUB_CLIENT_ID}&amp;scope=repo,user&quot;
    }

@router.get(&quot;/github/callback&quot;)
async def github_callback(code: str, db: Session = Depends(get_db)):
    &quot;&quot;&quot;GitHub OAuth 콜백 처리&quot;&quot;&quot;
    # 1. GitHub에서 access_token 받기
    token_data = await exchange_code_for_token(code)

    # 2. 사용자 정보 가져오기
    user_info = await get_github_user(token_data[&quot;access_token&quot;])

    # 3. DB에 사용자 생성 또는 업데이트
    user = upsert_user(db, user_info)

    # 4. JWT 토큰 발급
    access_token = create_access_token({&quot;sub&quot;: str(user.id)})

    return {&quot;access_token&quot;: access_token, &quot;token_type&quot;: &quot;bearer&quot;}</code></pre>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/3bf40c8c-74ee-47ec-998e-1d8bd97f08e1/image.png" alt="로그인 플로우">
<em>GitHub OAuth 로그인 화면</em></p>
<h3 id="32-데이터-수집-파이프라인">3.2 데이터 수집 파이프라인</h3>
<p>GitHub API, Velog RSS, Solved.ac API를 통해 데이터를 수집한다. 처음엔 동기적으로 구현했는데 너무 느려서 <code>httpx</code>의 비동기로 전환했다.</p>
<p><strong>중요한 발견:</strong> GitHub API의 <code>/user/repos</code>는 본인 소유가 아닌 레포도 가져온다. <code>affiliation=owner</code> 파라미터를 꼭 넣어야 한다.</p>
<pre><code class="language-python"># packages/merge_collector/merge_collector/github.py
async def sync_repos(user_id: str, access_token: str, db: Session):
    &quot;&quot;&quot;GitHub 레포지토리 동기화&quot;&quot;&quot;
    async with httpx.AsyncClient(timeout=30.0) as client:
        response = await client.get(
            &quot;https://api.github.com/user/repos&quot;,
            headers={&quot;Authorization&quot;: f&quot;Bearer {access_token}&quot;},
            params={
                &quot;affiliation&quot;: &quot;owner&quot;,  # 본인 소유만!
                &quot;sort&quot;: &quot;updated&quot;,
                &quot;per_page&quot;: 100,
            }
        )
        repos = response.json()

    # DB에 저장 (Upsert 패턴)
    for repo_data in repos:
        existing = db.query(Repo).filter_by(
            user_id=user_id, 
            provider_repo_id=str(repo_data[&quot;id&quot;])
        ).first()

        if existing:
            # 기존 레포 업데이트
            existing.stars = repo_data[&quot;stargazers_count&quot;]
            existing.language = repo_data[&quot;language&quot;]
        else:
            # 새 레포 생성
            db.add(Repo(...))

    db.commit()</code></pre>
<h3 id="33-대시보드-통계-계산">3.3 대시보드 통계 계산</h3>
<p>처음엔 단순히 <code>COUNT(*)</code>만 했는데, 증감 트렌드를 보여주려다가 삽질을 좀 했다.</p>
<p><strong>문제:</strong> &quot;지난주 대비 +657%&quot; 같은 말도 안 되는 숫자가 나왔다.</p>
<p><strong>원인:</strong> 전체 커밋을 일주일 데이터로 나눠서 비율 계산하는 바람에 퍼센트가 엄청나게 나온 것.</p>
<p><strong>해결:</strong> 절대값 차이로 변경하고, API를 <code>range=year</code>(전체)와 <code>range=week</code>(트렌드) 두 개로 분리했다.</p>
<pre><code class="language-python"># apps/api/app/routers/dashboard.py
@router.get(&quot;/summary&quot;)
async def get_summary(
    range: str = &quot;year&quot;,  # &quot;week&quot; or &quot;year&quot;
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db)
):
    if range == &quot;week&quot;:
        # 이번 주 vs 지난주
        this_week = get_week_stats(db, current_user.id, weeks_ago=0)
        last_week = get_week_stats(db, current_user.id, weeks_ago=1)

        return {
            &quot;commit_count&quot;: this_week[&quot;commits&quot;],
            &quot;commit_diff&quot;: this_week[&quot;commits&quot;] - last_week[&quot;commits&quot;],  # 지난주 대비 증감 (개수)
            &quot;repo_count&quot;: this_week[&quot;repos&quot;],
            &quot;repo_diff&quot;: this_week[&quot;repos&quot;] - last_week[&quot;repos&quot;],
        }
    else:
        # 전체 통계
        return {
            &quot;total_commits&quot;: db.query(Commit).filter_by(user_id=current_user.id).count(),
            &quot;total_repos&quot;: db.query(Repo).filter_by(user_id=current_user.id).count(),
        }</code></pre>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/ff20e662-4fff-4e75-8a35-0aaf78405ab5/image.png" alt="대시보드 통계">
<em>올바르게 수정된 통계 (절대값 차이)</em></p>
<h3 id="34-llm-블로그-생성">3.4 LLM 블로그 생성</h3>
<p>가장 중요한 부분. OpenAI API로 레포지토리 정보를 주고 기술 블로그를 작성하게 했다.</p>
<p><strong>V1.0</strong> – README만 넣어줬더니 너무 형식적인 글이 나왔다.</p>
<p><strong>V1.1</strong> – 커밋 히스토리를 요약해서 추가. 훨씬 구체적인 글이 나왔다.</p>
<p><strong>V1.2</strong> – 사용자의 Velog 글을 분석해서 말투를 학습시켰다. &quot;<del>했습니다&quot; 대신 &quot;</del>했다&quot; 반말 톤으로 변경.</p>
<pre><code class="language-python"># packages/merge_forge/merge_forge/repo_blog.py
def generate_repo_blog(user, repo, style_profile, readme_content, commit_summary):
    &quot;&quot;&quot;레포지토리 → 기술 블로그 자동 생성&quot;&quot;&quot;
    # 실제 코드에서는 style_profile을 기반으로 톤/구조를 system_prompt에 반영한다.
    system_prompt = f&quot;&quot;&quot;
당신은 기술 블로그를 작성하는 개발자입니다.
반말 사용 (~다, ~했다, ~같다)
친근하고 솔직한 톤: &#39;막상 해보니&#39;, &#39;생각보다&#39;, &#39;근데&#39;
과정과 시행착오 중심: 완벽한 결과보다는 배운 것
&quot;&quot;&quot;

    user_prompt = f&quot;&quot;&quot;
# 프로젝트: {repo.full_name}
생성 날짜: {repo.created_at.strftime(&#39;%Y년 %m월&#39;)}

## README 내용
{readme_content[:3000]}  # 처음엔 1500자였는데 짤려서 3000으로 증가

## 최근 커밋 히스토리
{commit_summary}  # 최근 50개 커밋 요약

작성 가이드:
- 제목: &quot;EfficientNet-B0로 과일 신선도 판별 모델 만들기&quot; 스타일
- 구조: 배경 → 문제정의 → 기술선택 → 구현과정 → 겪은 문제 → 결과 → 배운 것
- 구체적인 기술명과 수치 포함
- 중요한 깨달음은 인용구(&gt; )로 강조
&quot;&quot;&quot;

    response = openai_client.chat.completions.create(
        model=&quot;gpt-4o-mini&quot;,
        max_tokens=4000,
        messages=[
            {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: system_prompt},
            {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_prompt}
        ]
    )

    return response.choices[0].message.content</code></pre>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/133c50a6-fcbd-4e0b-8a07-bb361f9d2c3d/image.png" alt="생성된 블로그 글">
<em>LLM이 자동 생성한 기술 블로그</em></p>
<h3 id="35-포트폴리오-pdf-내보내기">3.5 포트폴리오 PDF 내보내기</h3>
<p><code>html2canvas</code>로 화면을 캡처하고 <code>jsPDF</code>로 PDF 생성. 근데 처음엔 페이지가 잘려서 나왔다.</p>
<p><strong>문제:</strong> 렌더링이 완료되기 전에 캡처해서 빈 공간이나 잘린 이미지가 나왔다.</p>
<p><strong>해결:</strong></p>
<ol>
<li>탭 전환 후 1초 대기 (<code>setTimeout(1000)</code>)</li>
<li>첫 페이지도 0.8초 대기 추가</li>
<li><code>windowWidth</code>, <code>windowHeight</code> 옵션으로 전체 영역 캡처</li>
</ol>
<pre><code class="language-typescript">// apps/web/app/portfolio/page.tsx
const handleExport = async () =&gt; {
  const tabsToCapture = [&#39;overview&#39;, &#39;projects&#39;, &#39;skills&#39;];

  for (let i = 0; i &lt; tabsToCapture.length; i++) {
    setActiveTab(tabsToCapture[i]);
    await new Promise(resolve =&gt; setTimeout(resolve, 1000));  // 충분히 대기!

    const canvas = await html2canvas(portfolioRef.current, {
      scale: 2,
      useCORS: true,
      backgroundColor: &#39;#ffffff&#39;,
      windowWidth: portfolioRef.current.scrollWidth,
      windowHeight: portfolioRef.current.scrollHeight,
    });

    // PDF에 페이지 추가
    if (i &gt; 0) pdf.addPage();
    pdf.addImage(canvas.toDataURL(&#39;image/png&#39;), &#39;PNG&#39;, 0, 0, pdfWidth, scaledHeight);
  }

  pdf.save(`${user.name}_${date}.pdf`);
};</code></pre>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/3a16911a-34a4-4b97-b92c-aa2614ff45a1/image.png" alt="포트폴리오 PDF1"></p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/250578cc-741b-449b-a033-c9065c0775c5/image.png" alt="포트폴리오 PDF2"></p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/06bfa605-8f17-4a63-9372-f7e4f94d574b/image.png" alt="포트폴리오 PDF3"></p>
<p><em>개요, 프로젝트, 스킬 탭이 모두 포함된 PDF</em></p>
<h2 id="4-겪은-문제들">4. 겪은 문제들</h2>
<h3 id="41-다크모드-텍스트-가시성">4.1 다크모드 텍스트 가시성</h3>
<p>다크모드로 전환하니까 GitHub/Email 버튼의 텍스트가 안 보였다. 배경색만 변경하고 텍스트 색은 그대로 둔 실수.</p>
<pre><code class="language-tsx">// Before
className=&quot;flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800&quot;

// After
className=&quot;flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white&quot;</code></pre>
<h3 id="42-언어-통계-불일치">4.2 언어 통계 불일치</h3>
<p>대시보드의 &quot;Language Distribution&quot;과 포트폴리오의 &quot;스킬&quot; 비율이 달랐다. 하나는 커밋 수 기준, 다른 하나는 레포 수 기준이었던 것.</p>
<p><strong>통일:</strong> 모두 레포지토리 개수 기준으로 변경.</p>
<h3 id="43-레포지토리-생성-날짜-오류">4.3 레포지토리 생성 날짜 오류</h3>
<p>모든 레포의 <code>created_at</code>이 오늘 날짜로 나왔다. GitHub에서 가져온 실제 생성 날짜를 무시하고 동기화 시점을 넣고 있었던 것.</p>
<pre><code class="language-python"># Fix: GitHub의 created_at 사용
if repo_data.get(&quot;created_at&quot;):
    created_at = datetime.fromisoformat(repo_data[&quot;created_at&quot;].replace(&quot;Z&quot;, &quot;+00:00&quot;))
else:
    created_at = datetime.utcnow()</code></pre>
<h3 id="44-남의-레포지토리-포함">4.4 남의 레포지토리 포함</h3>
<p>Contributor로 참여한 다른 사람 레포(<code>rabadu/webservice</code>)가 내 통계에 들어왔다. GitHub API의 기본 동작이 그런 거였다.</p>
<p><strong>Fix:</strong> <code>affiliation=owner</code> 파라미터 추가로 본인 소유 레포만 가져오기.</p>
<h2 id="5-결과">5. 결과</h2>
<ul>
<li><strong>백엔드:</strong> FastAPI + PostgreSQL + Celery로 안정적인 비동기 처리</li>
<li><strong>프론트엔드:</strong> Next.js 14로 부드러운 UX</li>
<li><strong>자동화:</strong> GitHub/Velog/Solved.ac 원클릭 동기화</li>
<li><strong>LLM 생성:</strong> 레포지토리 → 기술 블로그 자동 작성</li>
<li><strong>포트폴리오:</strong> PDF 내보내기, 다크모드 지원</li>
<li><strong>주간 리포트:</strong> Celery Beat로 매주 자동 생성</li>
</ul>
<h3 id="실제-활용">실제 활용</h3>
<p>이 DevHistory 회고 글도 DevHistory가 생성한 초안을 기반으로 썼다.</p>
<ul>
<li>DevHistory에서 DevHistory 레포지토리를 선택하고 <strong>블로그 생성</strong> 버튼을 누르면, README와 최근 커밋 히스토리를 기반으로 기술 블로그 초안이 만들어진다.</li>
<li>나는 그 초안 위에 구현하면서 겪은 시행착오, 느낀 점, 세부 구조를 덧붙이고 문장을 다듬었다.</li>
</ul>
<p>결국 <strong>AI가 뼈대를 만들고, 내가 살을 붙이는 방식</strong>이 가장 효율적이었다.</p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/314f698e-b809-4c2b-bf0e-40dff7221200/image.png" alt="최종결과">
<em>완성된 DevHistory 플랫폼</em></p>
<h2 id="6-한계와-개선점">6. 한계와 개선점</h2>
<p>아직 부족한 부분들:</p>
<ul>
<li><strong>GitHub 외 Git 호스팅:</strong> GitLab, Bitbucket 지원 필요</li>
<li><strong>실시간 동기화:</strong> Webhook으로 자동 업데이트</li>
<li><strong>팀 포트폴리오:</strong> 개인뿐만 아니라 팀 단위 리포트</li>
<li><strong>LLM 비용:</strong> OpenAI API 사용량이 많아지면 비용 문제</li>
<li><strong>커밋 분석 고도화:</strong> 코드 품질, 기여도 분석 추가</li>
</ul>
<h2 id="7-배운-것">7. 배운 것</h2>
<blockquote>
<p>&quot;자동화가 답이다. 귀찮은 일은 코드가 대신하게 만들자.&quot;</p>
</blockquote>
<ul>
<li><strong>비동기 처리의 중요성:</strong> <code>httpx</code> + <code>async/await</code>로 API 호출 속도 10배 향상</li>
<li><strong>LLM 프롬프트 엔지니어링:</strong> README만으론 부족하고, 커밋 히스토리 + 사용자 스타일까지 학습시켜야 퀄리티 나옴</li>
<li><strong>데이터 일관성:</strong> 같은 지표를 여러 곳에서 보여줄 때는 계산 로직을 통일해야 함</li>
<li><strong>외부 API의 함정:</strong> 문서만 보지 말고 실제 응답을 확인해야 함 (GitHub의 <code>affiliation</code> 파라미터처럼)</li>
<li><strong>UX 디테일:</strong> 다크모드, PDF 내보내기 같은 작은 기능이 사용자 경험을 크게 바꿈</li>
</ul>
<p>다음엔 이 경험을 바탕으로 더 많은 플랫폼을 지원하고, 팀 단위 리포트까지 만들어보고 싶다. 일단 이 정도면 개인 포트폴리오 자동화는 완성이다.</p>
<hr>
<p><strong>GitHub:</strong> <a href="https://github.com/Lova-clover/DevHistory">DevHistory</a><br><strong>Tech Stack:</strong> FastAPI, Next.js, PostgreSQL, Celery+Redis, OpenAI API, Docker</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[FreshGuard – YOLOv8 × EfficientNet-B0로 구현한 과일 신선도 판별 시스템]]></title>
            <link>https://velog.io/@lova-clover/FreshGuard-YOLOv8-EfficientNet-B0%EB%A1%9C-%EA%B5%AC%ED%98%84%ED%95%9C-%EA%B3%BC%EC%9D%BC-%EC%8B%A0%EC%84%A0%EB%8F%84-%ED%8C%90%EB%B3%84-%EC%8B%9C%EC%8A%A4%ED%85%9C</link>
            <guid>https://velog.io/@lova-clover/FreshGuard-YOLOv8-EfficientNet-B0%EB%A1%9C-%EA%B5%AC%ED%98%84%ED%95%9C-%EA%B3%BC%EC%9D%BC-%EC%8B%A0%EC%84%A0%EB%8F%84-%ED%8C%90%EB%B3%84-%EC%8B%9C%EC%8A%A4%ED%85%9C</guid>
            <pubDate>Wed, 26 Nov 2025 13:08:14 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요. 이번 글에서는 2025년에 진행 중인 과일 신선도 판별 프로젝트 <strong>FreshGuard</strong>의 AI 모델(YOLOv8 × EfficientNet-B0) 부분을 정리해 보려고 합니다.</p>
<blockquote>
<p><strong>“냉장고 문 열고, 먹어도 될지 고민하는 시간 줄여보자.”</strong></p>
</blockquote>
<p><code>FreshGuard</code>는 <strong>과일 사진 한 장으로 과일 종류 + 신선도</strong>를 판별하는 프로젝트입니다.<br>이 글에서는 그중에서 <code>FreshGuard</code>의 <strong>AI 모델 파트</strong>를 중심으로 정리해 보려고 합니다.</p>
<ul>
<li><p>전체 팀 구성</p>
<ul>
<li>나는: <strong>EfficientNet-B0 기반 멀티태스크 모델 설계/학습 + YOLOv8n 파이프라인 실험</strong></li>
<li>팀원들: <strong>안드로이드 앱 / Flask + MySQL 서버 개발</strong></li>
</ul>
</li>
<li><p>이 글은 <strong>AI 모델 파트가 중심</strong>이고,
마지막에 <strong>앱·서버 연동 구조</strong>도 간단히 정리하였습니다.</p>
</li>
<li><p>코드와 로그는 여기 정리해 두었습니다. → <a href="https://github.com/Lova-clover/FreshGuard">GitHub – FreshGuard</a></p>
</li>
</ul>
<hr>
<h2 id="1-프로젝트-개요">1. 프로젝트 개요</h2>
<p><code>FreshGuard</code>가 목표로 하는 기능은 다음과 같습니다.</p>
<ul>
<li><p><strong>입력</strong>: 과일 이미지 1장</p>
</li>
<li><p><strong>출력</strong></p>
<ul>
<li>과일 종류: <strong>10-class</strong></li>
<li>신선도: <strong>3단계(<code>fresh / normal / rotten</code>)</strong></li>
</ul>
</li>
<li><p>최종 목표</p>
<ul>
<li><p>나중에 앱에서 사진만 찍으면,</p>
<ul>
<li>“이건 버려야 함”</li>
<li>“빨리 먹는 게 좋음”</li>
<li>“아직 여유 있음”
정도의 안내를 바로 볼 수 있도록 만드는 것</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>이번 글은 모델에 집중하여 정리했고, 팀원의 앱과 서버도 간단하게 기록하였습니다.</p>
<hr>
<h2 id="2-문제-정의--왜-이-문제를-선택했나">2. 문제 정의 – 왜 이 문제를 선택했나</h2>
<p>개인적으로 떠올렸던 키워드는 <strong>1인 가구 + 음식물 쓰레기</strong>였습니다.</p>
<ul>
<li>한 번에 많이 사 두고, 제대로 관리가 안 돼서 <strong>냉장고 안에서 썩어버리는 음식들</strong></li>
<li>유통기한은 남았는데 겉모습이 애매해서 그냥 버리는 경우</li>
<li>반대로 애매하게 상했는데 “아직 괜찮겠지” 하고 먹는 경우</li>
</ul>
<p>한국농촌경제연구원 자료에서도
1인 가구가 부적절한 보관으로 상한 음식물 비율이 높다는 내용이 나옵니다.</p>
<p>결국 이런 상황으로 정리할 수 있다.</p>
<blockquote>
<p><strong>“지금 이 과일 상태가 어느 정도인지, 눈으로만 보기에 애매하다.”</strong></p>
</blockquote>
<p>여기서 출발한 아이디어는 비교적 단순하다.</p>
<ul>
<li><p>과일을 카메라로 찍고</p>
</li>
<li><p><strong>AI 모델이 과일 종류 + 신선도</strong>를 추정한 뒤</p>
</li>
<li><p>그 결과를 바탕으로</p>
<ul>
<li>소비 우선순위</li>
<li>폐기 추천</li>
<li>레시피 추천</li>
</ul>
</li>
</ul>
<p>까지 연결하는 것을 목표로 했다.</p>
<p>이번 글에서는 그중에서도 <strong>신선도 판별 AI 모델과 추론 파이프라인</strong>에 집중해서 정리했고, 레시피 추천은 이후 버전에서 확장할 방향으로만 설계해 둔 상태다.</p>
<hr>
<h2 id="3-서비스-구조-구상--현재-구현-상태">3. 서비스 구조 구상 &amp; 현재 구현 상태</h2>
<p>최종적으로 FreshGuard가 목표로 하는 흐름은 다음과 같다.</p>
<ol>
<li>안드로이드 앱에서 과일 사진 촬영</li>
<li>서버(Flask + MySQL, Ubuntu)로 이미지 업로드</li>
<li>서버에서 YOLOv8n + EfficientNet-B0 멀티태스크 모델로<ul>
<li>과일 종류(10-class)</li>
<li>신선도(fresh / normal / rotten)
을 추론</li>
</ul>
</li>
<li>앱에서 결과를 “버려야 함 / 빨리 먹는 게 좋음 / 여유 있음” 식으로 표시</li>
</ol>
<p>현재 1차 버전에서는 다음까지 구현한 상태다.</p>
<ul>
<li>나는: <strong>데이터 전처리 + EfficientNet-B0 멀티태스크 학습 + YOLOv8n 파이프라인 설계</strong></li>
<li>팀원: <strong>안드로이드 앱 개발 + Flask 서버 / REST API / MySQL 연동</strong></li>
</ul>
<p>이 글에서는 이 중에서 <strong>AI 모델(EfficientNet-B0 멀티태스크)와 추론 파이프라인</strong>에 초점을 맞춰 정리한다.</p>
<hr>
<h2 id="4-데이터셋-설계">4. 데이터셋 설계</h2>
<h3 id="4-1-데이터-소스--규모">4-1. 데이터 소스 &amp; 규모</h3>
<ul>
<li>사용 데이터셋: <strong>Kaggle Food Freshness Dataset</strong><br>→ <a href="https://www.kaggle.com/datasets/ulnnproject/food-freshness-dataset">https://www.kaggle.com/datasets/ulnnproject/food-freshness-dataset</a></li>
<li>원본 이미지 수: <strong>71,322장</strong></li>
<li>전처리 후 실제 학습에 사용한 이미지: <strong>69,105장</strong></li>
</ul>
<p>과일 종류와 신선도 라벨이 함께 들어 있는 구조라
처음부터 <strong>멀티태스크 모델</strong>로 접근하기 좋은 데이터셋이었다.</p>
<h3 id="4-2-클래스-구성">4-2. 클래스 구성</h3>
<p><strong>과일 클래스 (10종)</strong></p>
<pre><code class="language-text">apple, banana, bell_pepper, carrot, cucumber,
mango, orange, potato, strawberry, tomato</code></pre>
<p><strong>신선도 클래스 (원본 라벨)</strong></p>
<pre><code class="language-text">fresh / normal / rotten</code></pre>
<p>이미지를 직접 쭉 확인해보면,</p>
<ul>
<li><code>fresh</code>와 <code>rotten</code>은 비교적 구분이 잘 되지만</li>
<li><code>normal</code>은 두 쪽(fresh/rotten)의 경계에 걸쳐 있는 경우가 많다</li>
</ul>
<p>라벨 자체가 애매한 상태에서 3-class를 그대로 쓰면
모델이 애매한 경계를 억지로 나누느라 학습이 꼬일 수 있다고 판단했다.</p>
<p>그래서 신선도는 다음처럼 바꿔서 사용했다.</p>
<ul>
<li>학습 시: <strong>fresh vs rotten 2-class</strong>로 단순화</li>
<li>추론 시: <code>p_fresh</code>를 이용해서 다시 3단계로 매핑</li>
</ul>
<pre><code class="language-text">if p_fresh ≥ 0.7 → fresh
if p_fresh ≤ 0.3 → rotten
그 사이        → normal</code></pre>
<p>완벽한 라벨이 아니라고 생각했기 때문에,</p>
<ul>
<li><strong>극단(fresh/rotten)은 명확하게 잡고</strong></li>
<li><strong>중간 구간은 normal로 모으는 방식</strong>으로 설계했다.</li>
</ul>
<h3 id="4-3-전처리--데이터-증강">4-3. 전처리 &amp; 데이터 증강</h3>
<ul>
<li><p>Input size: <code>224 × 224</code></p>
</li>
<li><p>Normalize: <strong>ImageNet mean / std</strong></p>
</li>
<li><p>Augmentation</p>
<ul>
<li>Random Horizontal Flip</li>
<li>Random Rotation</li>
<li>ColorJitter (밝기/대비/채도 조정)</li>
</ul>
</li>
</ul>
<p>실제 환경에서 과일이 찍힐 때 생기는
<strong>각도, 조명, 약간의 위치 차이 정도는 버틸 수 있도록</strong> 설정했다.</p>
<hr>
<h2 id="5-모델-아키텍처--efficientnet-b0-멀티태스크">5. 모델 아키텍처 – EfficientNet-B0 멀티태스크</h2>
<p>모델의 백본은 <strong>EfficientNet-B0 (ImageNet pretrained)</strong> 하나로 고정했다.
그 위에 Head를 두 개 얹어 <strong>멀티태스크 분류</strong> 형태로 구성했다.</p>
<ul>
<li><p>Backbone</p>
<ul>
<li><code>EfficientNet-B0</code></li>
</ul>
</li>
<li><p>Head 1 – 과일 종류 (<code>Head_fruit</code>)</p>
<ul>
<li>출력: 10-class softmax</li>
</ul>
</li>
<li><p>Head 2 – 신선도 (<code>Head_fresh</code>)</p>
<ul>
<li>출력: 2-class softmax (<code>fresh / rotten</code>)</li>
</ul>
</li>
</ul>
<p>Loss는 두 태스크의 loss를 단순 합으로 사용했다.</p>
<pre><code class="language-python">loss = loss_fruit + loss_fresh</code></pre>
<p>과일 종류와 신선도는 둘 다 <strong>같은 이미지를 기반으로 한 판단</strong>이라</p>
<ul>
<li><p>피처를 공유하는 멀티태스크 구조가</p>
<ul>
<li>파라미터 효율도 좋고</li>
<li>일반화 측면에서도 괜찮을 거라고 보고 설계했다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="6-학습-세팅--로그-정리">6. 학습 세팅 &amp; 로그 정리</h2>
<p>중요한 학습 설정만 정리하면 다음과 같다.</p>
<ul>
<li><p>사용 이미지 수: <strong>69,105장</strong></p>
</li>
<li><p>Epoch: <strong>7</strong></p>
</li>
<li><p>Best Epoch: <strong>6</strong></p>
<ul>
<li><code>val_f1_fruit + val_f1_fresh</code> 합이 최대였던 시점</li>
</ul>
</li>
<li><p>Best weight 경로</p>
<ul>
<li><code>models/efficientnet_b0_freshguard_multitask.pt</code></li>
</ul>
</li>
</ul>
<p>로그 관리 방식:</p>
<ul>
<li>텍스트 로그: <code>logs/log.txt</code></li>
<li>지표/곡선 그래프: <code>logs/*.jpeg</code></li>
</ul>
<p>나중에 다시 볼 때도
“어떻게 학습했는지”를 바로 떠올릴 수 있도록
<strong>코드 / 모델 / 로그 / 노트북</strong>을 폴더 단위로 나눠 두었다.</p>
<hr>
<h2 id="7-결과--f1-스코어와-학습-곡선">7. 결과 – F1 스코어와 학습 곡선</h2>
<h3 id="7-1-최종-성능-validation-기준">7-1. 최종 성능 (Validation 기준)</h3>
<p>멀티태스크 EfficientNet-B0 모델의 Validation 결과는 다음과 같다.</p>
<ul>
<li><strong>Fruit F1-score</strong>: <strong>0.993</strong></li>
<li><strong>Freshness F1-score</strong>: <strong>0.981</strong></li>
</ul>
<p>과일 종류는 거의 다 맞는 수준이고,
신선도도 0.98대면 1차 버전 기준으로는 꽤 안정적으로 나온 편이라고 느꼈다.</p>
<hr>
<h3 id="7-2-train-loss-curve">7-2. Train Loss Curve</h3>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/6dd3f40d-033e-4568-a1e8-cfe07940349e/image.png" alt=""></p>
<p>그래프를 보면:</p>
<ul>
<li>1 epoch 기준: loss가 <strong>0.4 초반</strong>에서 시작</li>
<li>7 epoch 기준: <strong>0.04 근처</strong>까지 점진적으로 감소</li>
<li>중간에 loss가 급격히 튀거나, 이상하게 다시 증가하는 구간은 거의 없음</li>
</ul>
<p>전체적으로 봤을 때
<strong>학습 과정은 비교적 안정적으로 잘 진행된 편</strong>이라고 판단했다.</p>
<hr>
<h3 id="7-3-validation-f1-curve-fruit--fresh">7-3. Validation F1 Curve (fruit / fresh)</h3>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/5c0cbcde-6657-4fc2-a2c5-8d6f11f06153/image.png" alt=""></p>
<ul>
<li><p><code>val_f1_fruit</code></p>
<ul>
<li>초반: 약 0.983</li>
<li>후반: <strong>0.991 ~ 0.992</strong> 구간에서 수렴</li>
</ul>
</li>
<li><p><code>val_f1_fresh</code></p>
<ul>
<li>초반: 약 0.971</li>
<li>후반: <strong>0.978 ~ 0.982</strong> 정도에서 안정화</li>
</ul>
</li>
</ul>
<p>train과 val 지표를 같이 봤을 때,</p>
<ul>
<li><p>대표적인 overfitting 패턴은 크게 보이지 않고</p>
</li>
<li><p>2~3 epoch 이후부터는</p>
<ul>
<li>이미 “실사용 가능” 수준에 도달한 상태에서</li>
<li>조금씩 성능을 다듬는 구간으로 들어간 느낌에 가깝다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="8-실제-서비스에서의-사용-흐름">8. 실제 서비스에서의 사용 흐름</h2>
<p>실제 동작 플로우는 다음과 같다.</p>
<ol>
<li><p><strong>앱 (Android, Kotlin)</strong>  </p>
<ul>
<li>사용자가 과일 사진을 촬영하거나 앨범에서 선택</li>
<li>이미지를 서버 REST API로 업로드</li>
</ul>
</li>
<li><p><strong>서버 (Ubuntu + Flask + MySQL)</strong>  </p>
<ul>
<li>업로드된 이미지를 수신</li>
<li>YOLOv8n으로 이미지 안의 과일 여러 개를 <strong>bounding box</strong>로 감지</li>
<li>각 crop을 <code>efficientnet_b0_freshguard_multitask.pt</code>에 넣어서  <ul>
<li>과일 종류(10종)  </li>
<li>신선도(fresh / normal / rotten)  </li>
<li>신뢰도
를 계산</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>응답 포맷</strong>  </p>
<ul>
<li>예시 JSON 구조:<pre><code class="language-json">[
  {&quot;fruit&quot;: &quot;apple&quot;, &quot;state&quot;: &quot;rotten&quot;, &quot;confidence&quot;: 0.92},
  {&quot;fruit&quot;: &quot;banana&quot;, &quot;state&quot;: &quot;fresh&quot;, &quot;confidence&quot;: 0.88}
]</code></pre>
</li>
</ul>
</li>
<li><p><strong>앱 UI 표시</strong>  </p>
<ul>
<li>서버 응답을 받아<ul>
<li>“지금 버려야 할 것”</li>
<li>“빨리 먹는 게 좋은 것”</li>
<li>“조금 여유 있는 것”
으로 나눠 보여주고,</li>
</ul>
</li>
<li>필요하면 판별 결과를 로컬/서버 DB에 저장해 히스토리 화면에서 다시 확인할 수 있도록 구성</li>
</ul>
</li>
</ol>
<h3 id="실제-앱-ui">실제 앱 UI</h3>
<ol>
<li><p><strong>앱 메인 – 식재료 검사 진입</strong></p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/2936e89d-344a-4eb6-804b-3a28adf1094f/image.png" alt="FreshGuard 메인"></p>
<blockquote>
<p>보관 중인 식재료와 최근 검사 기록을 한눈에 보고,<br>하단 <code>식재료 검사</code> 버튼으로 촬영 화면으로 이동한다.</p>
</blockquote>
</li>
<li><p><strong>식재료 검사 화면</strong></p>
<table>
  <tr>
    <td align="center">
      <img src="https://velog.velcdn.com/images/lova-clover/post/b2f29789-a8ed-445a-93b7-071e635f6ed6/image.png" width="200"/><br/>
      <sub>식재료 검사 화면</sub>
    </td>
    <td align="center">
      <img src="https://velog.velcdn.com/images/lova-clover/post/b2885189-b261-48b9-aa75-3d1d0ff1ffe8/image.png" width="200"/><br/>
      <sub>식재료 검사 완료된 화면</sub>
    </td>
  </tr>
</table>

<blockquote>
<p>카메라 촬영 또는 갤러리 불러오기를 통해<br>신선도 판별에 사용할 이미지를 선택하고,<br>결과를 다음 화면에서 확인할 수 있다.</p>
</blockquote>
</li>
<li><p><strong>검사 결과 리스트</strong></p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/7ac02a81-1449-47c5-9cf3-e55dc5c6319b/image.png" alt="식재료 리스트"></p>
<blockquote>
<p>AI가 판별한 결과가 히스토리 형태로 쌓여,<br>어떤 식재료를 먼저 소비해야 할지 관리할 수 있다.</p>
</blockquote>
</li>
<li><p><strong>식재료 상세 확인</strong></p>
<p><img src="https://velog.velcdn.com/images/lova-clover/post/a90d9d9f-b33d-4549-b5d6-ca8e031e8109/image.png" alt="식재료 상세"></p>
<blockquote>
<p>개별 식재료에 대해 이미지, 촬영 날짜, 신선도 결과,<br>소비기한 메모를 함께 관리할 수 있도록 구성했다.</p>
</blockquote>
</li>
<li><p><strong>추천 레시피 확인 – 목록 + 상세</strong></p>
<table>
  <tr>
    <td align="center">
      <img src="https://velog.velcdn.com/images/lova-clover/post/b7ad86ea-af95-462f-b361-a92716d65fc0/image.png" width="200"/><br/>
      <sub>개별 레시피 목록 화면</sub>
    </td>
    <td align="center">
      <img src="https://velog.velcdn.com/images/lova-clover/post/2d23fb1c-2c1b-4d1b-bd27-ad078305f999/image.png" width="200"/><br/>
      <sub>추천 레시피 상세 화면</sub>
    </td>
    <td align="center">
      <img src="https://velog.velcdn.com/images/lova-clover/post/7ce0f723-5bbd-4453-98bd-91ab240b1488/image.png" width="200"/><br/>
      <sub>레시피 화면</sub>
    </td>
  </tr>
</table>

<blockquote>
<p>남아 있는 과일을 활용할 수 있는 레시피를 추천하고,<br>목록에서 하나를 선택하면 재료와 조리 순서까지 확인할 수 있다.</p>
</blockquote>
</li>
</ol>
<hr>
<h2 id="9-한계와-앞으로의-보완-방향">9. 한계와 앞으로의 보완 방향</h2>
<p>현재 모델에도 분명 한계점이 있다.
지금 기준으로 생각하고 있는 부분은 아래와 같다.</p>
<ol>
<li><p><strong>데이터 도메인 갭</strong></p>
<ul>
<li>Kaggle 이미지와 실제 집/마트/편의점 환경은 다를 수밖에 없다.</li>
<li>실제 촬영 데이터로 평가해 보면
성능이 더 낮게 나올 가능성이 있다.</li>
</ul>
</li>
<li><p><strong>normal 라벨의 애매함</strong></p>
<ul>
<li>사람마다 “이 정도면 먹어도 된다”에 대한 기준이 다르다.</li>
<li>현재는 <code>p_fresh</code> 0.3~0.7 구간을 전부 <code>normal</code>로 묶고 있어서,
사용자 입장에서 느끼는 기준과 완전히 일치하지 않을 수 있다.</li>
</ul>
</li>
<li><p><strong>카테고리 확장</strong></p>
<ul>
<li><p>지금은 과일만 다루고 있다.</p>
</li>
<li><p>냉장고 전체 관리로 확장하려면</p>
<ul>
<li>야채</li>
<li>육류</li>
<li>조리/가공 식품
등으로 데이터 설계부터 다시 시작해야 한다.</li>
</ul>
</li>
</ul>
</li>
</ol>
<p>앞으로 보완하고 싶은 방향은 다음과 같다.</p>
<ul>
<li><p>실제 촬영 데이터(집/마트/편의점 등)로 <strong>에러 케이스 수집</strong></p>
</li>
<li><p>수집된 케이스를 기반으로</p>
<ul>
<li><code>p_fresh</code> threshold 재조정</li>
<li><code>normal</code> 구간 재설계</li>
</ul>
</li>
<li><p>앱/서버와 연결된 상태에서 실제 사용 피드백을 받아 보고,
필요하다면 추가적인 fine-tuning이나 domain adaptation 진행</p>
</li>
</ul>
<hr>
<h2 id="10-마무리--이번-프로젝트에서-정리한-점들">10. 마무리 – 이번 프로젝트에서 정리한 점들</h2>
<p>FreshGuard 모델 버전 1을 만들면서,
개인적으로 정리된 포인트는 다음 세 가지였다.</p>
<ol>
<li><p><strong>라벨이 애매하면, 문제 정의부터 다시 정리하는 게 낫다.</strong></p>
<ul>
<li>애매한 3-class를 그대로 쓰기보다는
<strong>2-class + 후처리</strong>가 이번 데이터셋에서는 훨씬 다루기 편했다.</li>
</ul>
</li>
<li><p><strong>멀티태스크가 더 자연스러운 문제라면, 한 번에 묶어서 보는 것도 좋다.</strong></p>
<ul>
<li>과일 종류와 신선도를 같이 학습하니
데이터 효율과 성능 측면에서 모두 나쁘지 않았다.</li>
</ul>
</li>
<li><p><strong>결과뿐 아니라, 과정과 구조를 같이 남겨두는 게 중요하다.</strong></p>
<ul>
<li>나중에 다시 개선할 때를 생각해서
<code>docs/</code>, <code>logs/</code>, <code>notebooks/</code> 등으로 레포 구조를 나눠 두었다.</li>
<li>“어디를 손봐야 할지”를 나중에 다시 볼 때도 금방 떠올릴 수 있도록 만드는 느낌에 가깝다.</li>
</ul>
</li>
</ol>
<p>앱/서버까지 붙어서 실제로
“냉장고 정리 도우미”에 가까운 형태가 되면,</p>
<ul>
<li>실사용 피드백</li>
<li>에러 케이스 모음</li>
<li>그걸 반영한 v2/v3 개선 과정</li>
</ul>
<p>까지 한 번 더 정리해 볼 생각이다.</p>
<p>여기까지가</p>
<blockquote>
<p><strong>FreshGuard – YOLOv8 × EfficientNet-B0로 구현한 과일 신선도 판별 시스템 v1을 만든 기록</strong></p>
</blockquote>
<p>입니다.
혹시 비슷한 문제를 고민 중이거나,
코드가 궁금한 분들은 레포를 참고해 보셔도 좋을 것 같습니다. 😊</p>
<p>긴 글 읽어주셔서 감사합니다 :)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[YOLO와 PaddleOCR로 상품·성분표 인식 파이프라인 구성해본 경험]]></title>
            <link>https://velog.io/@lova-clover/YOLO%EC%99%80-PaddleOCR%EB%A1%9C-%EC%83%81%ED%92%88-%EC%9D%B8%EC%8B%9D-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%84%B1%ED%95%B4%EB%B3%B8-%EA%B2%BD%ED%97%98</link>
            <guid>https://velog.io/@lova-clover/YOLO%EC%99%80-PaddleOCR%EB%A1%9C-%EC%83%81%ED%92%88-%EC%9D%B8%EC%8B%9D-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%84%B1%ED%95%B4%EB%B3%B8-%EA%B2%BD%ED%97%98</guid>
            <pubDate>Wed, 26 Nov 2025 07:37:00 GMT</pubDate>
            <description><![CDATA[<h2 id="0-배경--왜-yolo랑-ocr까지-가게-됐나">0. 배경 – 왜 YOLO랑 OCR까지 가게 됐나</h2>
<p>Nvidia AI 솔루션 경진대회에서는
<strong>RNA 기반 당뇨 조기진단 + 식습관/식품 관리 서비스</strong>라는 주제로 팀 프로젝트를 진행했다.</p>
<p>팀에서 나는 <strong>Vision 모듈</strong>을 맡았다.</p>
<ul>
<li>카메라로 음식/음료/약을 비추면</li>
<li><strong>YOLO가 객체를 찾고</strong>,</li>
<li><strong>Kalman Filter로 안정적으로 추적</strong>하고,</li>
<li><strong>성분표를 OCR로 읽어서</strong></li>
<li><strong>칼로리/당류/탄수화물·당뇨 위험도</strong>를 계산해 주는 구조였다. </li>
</ul>
<p>처음엔 솔직히 이렇게 생각했다.</p>
<blockquote>
<p>“YOLO로 병/컵/라벨만 잘 잡으면, OCR이 알아서 성분표를 읽어주겠지?”</p>
</blockquote>
<p>현실은 좀 달랐다.
탐지 모델은 금방 그럭저럭 돌아갔는데,
<strong>성분표를 ‘실제 서비스에서 쓸 수 있는 수준’으로 읽어내는 게 훨씬 빡셌다.</strong></p>
<p>이 글에서는 그 Vision 파트 중에서도
<strong>YOLO 객체 탐지 + PaddleOCR 텍스트 인식 파이프라인</strong>을 정리해보려고 한다.</p>
<hr>
<h2 id="1-전체-파이프라인-개요">1. 전체 파이프라인 개요</h2>
<p>우리가 만든 Vision 파이프라인은 대략 이런 흐름이었다.</p>
<ol>
<li><p><strong>카메라/사진 입력</strong></p>
</li>
<li><p><strong>YOLO로 객체 탐지</strong></p>
<ul>
<li>음식, 음료, 약, 라벨 영역 등 클래스를 검출</li>
</ul>
</li>
<li><p><strong>Kalman Filter 기반 다중 객체 추적</strong></p>
<ul>
<li>바운딩 박스 떨림/깜빡임(debouncing) 보정</li>
</ul>
</li>
<li><p><strong>라벨 영역(ROI) 크롭</strong></p>
<ul>
<li>성분표가 있을 법한 부분만 잘라서 사용</li>
</ul>
</li>
<li><p><strong>PaddleOCR로 텍스트 인식</strong></p>
</li>
<li><p><strong>후처리</strong></p>
<ul>
<li>regex로 숫자/단위 추출 (<code>23g</code>, <code>500mg</code>, <code>210kcal</code> 등)</li>
<li><code>당류</code>, <code>탄수화물</code>, <code>지방</code>, <code>kcal</code> 같은 키워드 매칭</li>
</ul>
</li>
<li><p><strong>당뇨 환자 기준 위험도 계산</strong></p>
<ul>
<li>예: 당류 ≥ Xg → “위험”, 그 미만은 “주의/안전” 등</li>
</ul>
</li>
</ol>
<p>구조만 보면 간단해 보이는데,
<strong>각 단계마다 삽질 포인트가 있었다.</strong></p>
<hr>
<h2 id="2-yolo로-fooddrinklabel-잡기">2. YOLO로 Food/Drink/Label 잡기</h2>
<h3 id="2-1-어떤-걸-탐지-대상으로-잡았는가">2-1. 어떤 걸 탐지 대상으로 잡았는가</h3>
<p>Vision 모듈에서 신경 쓴 건 크게 두 가지였다.</p>
<ol>
<li><p><strong>이게 뭔지</strong></p>
<ul>
<li>음식/음료/약/간식 등 분류</li>
</ul>
</li>
<li><p><strong>얼마나 위험한지</strong></p>
<ul>
<li>성분표 기반으로 당류·탄수화물·칼로리 계산</li>
</ul>
</li>
</ol>
<p>그래서 YOLO 쪽 클래스는 이런 식으로 설계했다.</p>
<ul>
<li><code>drink</code> (생수, 탄산음료, 주스 등)</li>
<li><code>snack/food</code></li>
<li><code>pill/drug</code></li>
<li>(실험 단계에서) <code>label</code> 또는 라벨이 있을 법한 영역</li>
</ul>
<p>일부는 COCO 기반 프리트레인 모델에서
<code>bottle</code>, <code>cup</code> 등을 활용했고,
과일/특정 음식은 커스텀 데이터로 추가 학습했다.</p>
<h3 id="2-2-실제로-힘들었던-포인트">2-2. 실제로 힘들었던 포인트</h3>
<ul>
<li><p><strong>작은 성분표 라벨</strong></p>
<ul>
<li>병/패키지 전체는 잘 잡히는데, 성분표만 따로 잡기는 어려웠다.</li>
</ul>
</li>
<li><p><strong>곡면 + 조명</strong></p>
<ul>
<li>둥근 병(예: 비타500, 콜라 병)은
성분표가 휘어져 있고 반사가 심해서, 한 번에 깔끔하게 라벨을 따기 힘들었다.</li>
</ul>
</li>
<li><p><strong>실시간성</strong></p>
<ul>
<li>프레임마다 탐지하다 보니
바운딩 박스가 흔들리는 문제가 있어 Kalman Filter + 디바운싱 로직을 추가했다.</li>
</ul>
</li>
</ul>
<p>그래도 <strong>“무엇을 찍고 있는지”</strong>를 식별하는 단계까지는
YOLO + Kalman 조합으로 꽤 안정적으로 올라왔다.</p>
<p>진짜 문제는 그 다음, <strong>라벨을 읽는 단계</strong>였다.</p>
<hr>
<h2 id="3-paddleocr--생각만큼-만능은-아니었다">3. PaddleOCR – 생각만큼 만능은 아니었다</h2>
<h3 id="3-1-v10--단일-크롭-ocr의-현실">3-1. V1.0 – 단일 크롭 OCR의 현실</h3>
<p>처음 시도는 단순했다.</p>
<ol>
<li>YOLO로 병/음료 영역 하나 잡고</li>
<li>그 안에서 가운데 부분을 한 번 더 crop</li>
<li>그 이미지를 <strong>PaddleOCR</strong>에 그대로 던짐</li>
</ol>
<p>결과:</p>
<ul>
<li><p>숫자/단위는 그럭저럭 읽힌다.</p>
<ul>
<li><code>500ml</code>, <code>210kcal</code> 같은 건 잘 나오는 편</li>
</ul>
</li>
<li><p>근데 <strong>성분표 전체를 믿고 쓸 수가 없다.</strong></p>
<ul>
<li>줄바꿈이 이상해서 <code>당류 23g</code>이 갈라져 나옴</li>
<li>작은 글씨는 통째로 날아감</li>
<li>곡면/조명 영향 받으면 글자가 뭉개짐</li>
</ul>
</li>
</ul>
<p>이때 느낀 점:</p>
<blockquote>
<p>“OCR 모델이 다 해줄 거라고 믿은 순간부터 이미 틀렸구나.”</p>
</blockquote>
<h3 id="3-2-v11--center-merge듀얼-roi-시도">3-2. V1.1 – Center-Merge(듀얼 ROI) 시도</h3>
<p>두 번째 버전에서는
<strong>“라벨이 여러 줄로 퍼져 있어서 한 번에 잘리지 않는다”</strong>는 문제를 해결해 보려고 했다. </p>
<ul>
<li>전체 박스에서 한 번 OCR</li>
<li>가운데 부분만 다시 crop해서 또 한 번 OCR</li>
<li>두 결과를 <strong>병합(Merge)</strong> 해서 텍스트를 재구성</li>
</ul>
<p>장점:</p>
<ul>
<li>일부 누락된 줄이 보완되면서
문장 단위로는 조금 더 읽을 수 있게 됨</li>
</ul>
<p>단점:</p>
<ul>
<li>연산량이 2배 → 실시간성에 꽤 부담</li>
<li>저해상도·작은 글씨는 여전히 답이 없음</li>
</ul>
<h3 id="3-3-v12--multicrop--clahe로-라벨-회수율-끌어올리기">3-3. V1.2 – MultiCrop + CLAHE로 라벨 회수율 끌어올리기</h3>
<p>최종적으로는 <strong>MultiCrop + CLAHE</strong>까지 도입했다.</p>
<ul>
<li>하나의 라벨 영역을 여러 격자로 나누어
<strong>여러 번 crop 후 OCR</strong></li>
<li>각 crop마다 CLAHE(히스토그램 균등화)를 적용해서
대비를 올린 뒤 인식</li>
<li>모든 결과를 모아서
키워드/숫자를 기준으로 합치는 방식</li>
</ul>
<p>체감적으로:</p>
<ul>
<li>“아무것도 안 읽히던 컷”이 <strong>‘뭔가라도 읽히는 컷’</strong>으로 변하는 경우가 늘었다.</li>
<li>대신 속도는 당연히 더 느려지고,
<strong>완벽한 문장</strong>보다는 <strong>쓸 만한 토큰</strong>을 최대한 많이 뽑는 느낌에 가까웠다.</li>
</ul>
<hr>
<h2 id="4-후처리--숫자키워드만-뽑아서-당뇨-위험도로">4. 후처리 – 숫자/키워드만 뽑아서 당뇨 위험도로</h2>
<p>결국 관점이 이렇게 바뀌었다.</p>
<blockquote>
<p>“라벨 전체를 예쁘게 읽고 싶다”
→ “당뇨 관리에 필요한 정보만 정확히 뽑으면 된다.”</p>
</blockquote>
<p>그래서 PaddleOCR 출력 텍스트에서
<strong>딱 두 가지에 집중했다.</strong></p>
<ol>
<li><strong>숫자 + 단위</strong></li>
<li><strong>영양 성분 키워드</strong></li>
</ol>
<h3 id="4-1-숫자--단위-추출">4-1. 숫자 + 단위 추출</h3>
<p>대략 이런 패턴들만 노렸다.</p>
<ul>
<li><code>(\d+)\s*(g|mg|kcal|kJ)</code></li>
<li><code>(\d+\.?\d*)\s*(g|mg)</code></li>
</ul>
<p>예시:</p>
<ul>
<li><code>Sugar 23g</code> → <code>23 g</code></li>
<li><code>당류 0g</code> → <code>0 g</code></li>
<li><code>탄수화물 31 g</code> → <code>31 g</code></li>
</ul>
<p>OCR이 가끔 이런 식으로 망가뜨려도:</p>
<ul>
<li><code>23 9</code> (g를 9로 읽음)</li>
<li><code>og</code> (<code>0g</code>를 글자 o로 인식)</li>
</ul>
<p>숫자 범위·위치·이전/다음 토큰을 보고
<strong>비정상 값은 버리거나 후보군에서 제외</strong>했다.</p>
<h3 id="4-2-키워드-매칭">4-2. 키워드 매칭</h3>
<p>한글/영문 혼합 라벨이 많아서,
이 정도 키워드들을 같이 찾았다.</p>
<ul>
<li><code>당류</code>, <code>당</code>, <code>Sugars</code>, <code>Sugar</code></li>
<li><code>탄수화물</code>, <code>Carbohydrate</code>, <code>Carbs</code></li>
<li><code>열량</code>, <code>에너지</code>, <code>kcal</code></li>
</ul>
<p>텍스트 전체를 신뢰하지 않고,</p>
<ol>
<li><strong>키워드가 있는 줄만 필터링</strong>하고</li>
<li>그 줄에서 숫자+단위를 regex로 다시 뽑는 방식으로 정리했다.</li>
</ol>
<p>이렇게 해놓고 나니
<strong>PaddleOCR이 100% 완벽하지 않아도
서비스에 쓸 수 있는 수준의 숫자를 확보</strong>할 수 있었다.</p>
<hr>
<h2 id="5-실제로-써보면서-느낀-한계">5. 실제로 써보면서 느낀 한계</h2>
<h3 id="5-1-곡면-라벨--사진-퀄리티">5-1. 곡면 라벨 + 사진 퀄리티</h3>
<ul>
<li>병이 조금만 돌아가 있거나</li>
<li>조명이 비스듬히 들어오면</li>
<li>성분표의 일부분이 날아가거나, 글자가 쭉 늘어져 보였다.</li>
</ul>
<p>MultiCrop + CLAHE로 어느 정도 잡았지만,
<strong>입력 이미지 퀄리티의 한계를 완전히 넘지는 못했다.</strong></p>
<h3 id="5-2-속도-vs-정확도-트레이드오프">5-2. 속도 vs 정확도 트레이드오프</h3>
<ul>
<li><p>YOLO + Kalman + MultiCrop + OCR까지 걸면
모바일/노트북 웹캠 기준으로는
“실시간이라고 부르기 애매한 딜레이”가 생긴다.</p>
</li>
<li><p>진짜 서비스로 가져가려면</p>
<ul>
<li>모델 경량화</li>
<li>crop 전략 최적화</li>
<li>캐싱 등 추가적인 튜닝이 필요하다.</li>
</ul>
</li>
</ul>
<h3 id="5-3-라벨-자체의-다양성">5-3. 라벨 자체의 다양성</h3>
<ul>
<li>모든 제품이 <strong>같은 포맷의 성분표</strong>를 쓰는 게 아니다.</li>
<li>어떤 건 <code>총 내용량 기준</code>, 어떤 건 <code>1회 제공량 기준</code></li>
<li>어떤 건 <code>탄수화물</code>만 있고, 어떤 건 <code>당류</code> 칸이 따로 있음</li>
</ul>
<p>그래서 단순 rule 기반 위험도 계산은
<strong>“대략적인 경고” 수준까지만 가능했고</strong>,
정교한 평가는 결국 <strong>제품 DB 연동</strong>이 필요하다는 결론이 나왔다.</p>
<hr>
<h2 id="6-이번-yolo--ocr-경험에서-얻은-것">6. 이번 YOLO + OCR 경험에서 얻은 것</h2>
<p>정리해보면, 이번 Vision 모듈에서 느낀 건 이거였다.</p>
<ol>
<li><p><strong>탐지 모델 하나로 끝나는 문제는 거의 없다.</strong></p>
<ul>
<li>YOLO가 끝이 아니라
<strong>추적 + OCR + 후처리 + 룰</strong>까지가 한 세트다.</li>
</ul>
</li>
<li><p><strong>OCR 모델이 완벽할 거라는 기대는 버리는 게 편하다.</strong></p>
<ul>
<li>PaddleOCR 자체는 꽤 강력하지만,
실제 서비스 수준 정확도는
<strong>후처리 설계</strong>에 훨씬 더 많이 달려 있었다.</li>
</ul>
</li>
<li><p><strong>서비스에서 필요한 정보가 뭔지 먼저 정의해야 한다.</strong></p>
<ul>
<li>“성분표 전체를 깨끗하게 읽는다”가 아니라
<strong>“당뇨 환자에게 중요한 숫자만 정확히 뽑는다”</strong>로 목표를 재정의하니
파이프라인 설계 방향도 명확해졌다.</li>
</ul>
</li>
<li><p><strong>버전 로그를 남겨두는 게 생각보다 큰 도움이 된다.</strong></p>
<ul>
<li><p>V0.1 ~ V1.2까지</p>
<ul>
<li>Drug/Food 프로토타입</li>
<li>실시간 스트림</li>
<li>Kalman 안정화</li>
<li>OCR 1차, Center-Merge, MultiCrop+CLAHE</li>
</ul>
</li>
<li><p>이렇게 단계별로 정리해 두니,
나중에 발표/블로그/포트폴리오에서
<strong>“어디서 무엇이 개선됐는지”</strong>를 설명하기 훨씬 쉬웠다. </p>
</li>
</ul>
</li>
</ol>
<hr>
<h2 id="7-다음에-더-해보고-싶은-것들">7. 다음에 더 해보고 싶은 것들</h2>
<p>이번 프로젝트는 <strong>비전 + OCR + 당뇨 도메인</strong>을 한 번에 묶어본 첫 실험이었다.
다음에 시간이 된다면 이런 방향으로 확장해보고 싶다.</p>
<ul>
<li>PaddleOCR + 커스텀 후처리 모듈을
<strong>Docker로 감싼 API</strong> 형태로 만들어서
다른 프로젝트에서도 재사용하기</li>
<li>제품 성분표/바코드 DB와 연동해서
“추정치”가 아닌,
<strong>실제 제품 정보 기반 위험도 계산</strong>으로 확장</li>
<li>곡면/기울어진 라벨에 더 강한
<strong>텍스트 검출(Text Detection) + 인식(Text Recognition) 분리 구조</strong> 실험</li>
</ul>
<hr>
<p>이 글은
“YOLO+OCR로 성분표 읽어본 사람의 솔직한 후기” 정도로 봐주면 될 것 같다.</p>
<p>완벽한 솔루션은 아니었지만,
<strong>“탐지 → OCR → 후처리 → 도메인 로직”</strong>까지 한 번에 엮어본 경험 자체가
앞으로 다른 비전/의료 프로젝트를 할 때
꽤 큰 기반이 될 거라고 생각한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[개발환경의 중요성]]></title>
            <link>https://velog.io/@lova-clover/%EA%B0%9C%EB%B0%9C%ED%99%98%EA%B2%BD%EC%9D%98-%EC%A4%91%EC%9A%94%EC%84%B1</link>
            <guid>https://velog.io/@lova-clover/%EA%B0%9C%EB%B0%9C%ED%99%98%EA%B2%BD%EC%9D%98-%EC%A4%91%EC%9A%94%EC%84%B1</guid>
            <pubDate>Wed, 26 Nov 2025 05:18:59 GMT</pubDate>
            <description><![CDATA[<p>여러 대회랑 프로젝트를 동시에 하다 보니<br>어느 순간부터 “코드를 못 짜서 막히는 게 아니라, <strong>환경 때문에 멈추는 시간</strong>”이 점점 늘어나기 시작했다.</p>
<ul>
<li>대회 제출용 코드</li>
<li>수업/과제용 실습 코드</li>
<li>Colab / 로컬 / 서버 환경</li>
</ul>
<p>이렇게 서로 다른 코드들을 왔다 갔다 하다 보니,<br><strong>Python 버전, 패키지 버전, 플랫폼 차이</strong>가 한 번 꼬이면<br>그날은 그냥 환경만 붙잡고 끝나는 날이 많았다.</p>
<p>이번 글에서는 최근에 겪었던 환경 관련 문제들을 간단히 정리하고,<br>그걸 계기로 <strong>Python 버전 / venv / Docker를 같이 공부해야겠다고 마음먹게 된 이유</strong>를 기록해 두려고 한다.</p>
<hr>
<h2 id="1-코드는-맞는데-실행이-안-되는-상황들">1. 코드는 맞는데, 실행이 안 되는 상황들</h2>
<p>최근 몇 달 동안 공통적으로 반복됐던 패턴은 대략 이렇다.</p>
<ol>
<li>깃허브나 예제 레포를 하나 받아온다.  </li>
<li><code>pip install</code>로 필요한 패키지를 깐다.  </li>
<li>그때는 잘 돈다.  </li>
<li>시간이 지나서 다시 실행해보면,  <strong>똑같은 코드인데도 에러가 난다.</strong></li>
</ol>
<p>주된 원인은 대부분 환경 쪽이었다.</p>
<ul>
<li><code>torch</code>, <code>transformers</code>, <code>ultralytics</code>, <code>opencv</code> 같은 패키지 버전 충돌  </li>
<li>Colab에서는 돌아가는데, 로컬에서는 <code>ModuleNotFoundError</code>, <code>ImportError</code>  </li>
<li>GPU를 쓰고 싶은데 CUDA / 드라이버 / Torch 버전이 안 맞아서 결국 CPU로만 돌리는 상황</li>
</ul>
<p>문제 자체는 구글링하면 어떻게든 풀린다.<br>하지만 매번 <strong>패키지 삭제 → 재설치 → 버전 조정</strong>을 반복하다 보니,<br>정작 모델을 개선하거나 실험을 더 해볼 시간과 집중력이 계속 깎이는 느낌이었다.</p>
<hr>
<h2 id="2-python-버전과-전역-pip의-한계">2. Python 버전과 전역 pip의 한계</h2>
<p>처음에는 그냥 “최신 Python 쓰면 되지”라는 생각이었다.<br>로컬에 최신 Python을 깔아두고, 전역 환경에 <code>pip install</code>로 계속 쌓아 올렸다.</p>
<p>시간이 지나면서 이런 상황이 생겼다.</p>
<ul>
<li>어떤 프로젝트는 Python 3.11 기준으로 잘 돌아가고  </li>
<li>어떤 라이브러리는 3.11까지만 안정적으로 지원하고  </li>
<li>Colab / 로컬 / 과제 베이스 코드마다 요구하는 버전이 제각각이고  </li>
<li>전역에 깔린 패키지들이 서로 영향을 주면서  </li>
<li><em>“어디서부터 잘못됐는지 감이 안 오는 상태”*</em>가 됐다.</li>
</ul>
<p>결국, 처음부터 환경을 분리하지 않고 쓰다가<br>나중에 커진 대가를 한 번에 맞는 느낌이었다.</p>
<p>그래서 앞으로는:</p>
<ul>
<li>프로젝트를 시작할 때 <strong>Python 버전을 먼저 정하고</strong>,  </li>
<li>전역 환경이 아니라 <strong>프로젝트별 가상환경(venv)</strong>을 기본으로 쓰기로 했다.</li>
</ul>
<hr>
<h2 id="3-venv를-기본값으로-두기로-한-이유">3. venv를 기본값으로 두기로 한 이유</h2>
<p>지금 기준으로 가져가고 싶은 기본 템플릿은 아주 단순하다.</p>
<pre><code class="language-bash"># 1) 프로젝트 폴더 진입
cd my-project

# 2) Python 버전 명시 (예: 3.11)
python3.11 -m venv .venv

# 3) 가상환경 활성화
# Windows: .venv\Scripts\activate
source .venv/bin/activate

# 4) 패키지 설치
pip install -r requirements.txt   # 또는 개별 설치 후
pip freeze &gt; requirements.txt     # 의존성 기록</code></pre>
<p>이렇게 하면:</p>
<ul>
<li>프로젝트마다 환경이 분리되고</li>
<li><code>requirements.txt</code>만 있으면 시간이 지나도 환경을 다시 구성할 수 있고</li>
<li>다른 프로젝트가 패키지를 덮어써서 깨뜨리는 상황을 줄일 수 있다.</li>
</ul>
<p>특히 “몇 달 뒤에 다시 열어볼 프로젝트”일수록
<strong>코드 + venv + requirements</strong> 조합으로 정리해 두는 게 훨씬 안전하다는 걸 느꼈다.</p>
<hr>
<h2 id="4-docker를-공부해야겠다고-느낀-지점">4. Docker를 공부해야겠다고 느낀 지점</h2>
<p>venv만 써도 많이 나아지긴 한다.<br>그래도 여전히 애매하게 남는 부분이 있다.</p>
<ul>
<li>Windows / Linux처럼 OS가 달라질 때 미묘하게 달라지는 동작</li>
<li>팀 프로젝트에서 “내 환경에서는 되는데, 다른 사람 환경에서는 안 되는” 상황</li>
<li>Colab / 로컬 / 서버 환경이 서로 달라서 재현성이 흔들리는 문제</li>
</ul>
<p>이런 것들을 한 번에 줄이려면,
결국 <strong>환경 전체를 컨테이너로 묶는 쪽(Docker)</strong>이 자연스러운 해답 같다는 생각이 들었다.</p>
<p>아직 Docker를 자유롭게 쓰는 단계는 아니지만,
일단은 이런 정도의 구조를 목표로 잡고 있다.</p>
<pre><code class="language-dockerfile">FROM python:3.11

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD [&quot;python&quot;, &quot;main.py&quot;]</code></pre>
<p>장기적으로는:</p>
<ul>
<li>대회용 코드나 포트폴리오용 서비스</li>
<li>팀 프로젝트에서 공유해야 할 코드</li>
</ul>
<p>이런 것들은 가능하면 <strong>Docker 기준으로도 한 번 정리해 두는 것</strong>을 목표로 삼고 있다.</p>
<hr>
<h2 id="5-앞으로의-개발환경에-대한-정리">5. 앞으로의 개발환경에 대한 정리</h2>
<p>이번 학기/올해를 지나면서,
개발환경에 대해 최소한 아래 네 가지는 지키려고 한다.</p>
<ol>
<li><p><strong>Python 버전 명시</strong></p>
<ul>
<li>프로젝트 시작 시 <code>3.10</code> / <code>3.11</code> 중 하나로 고정</li>
<li>사용하는 라이브러리가 안정적으로 지원하는 버전을 기준으로 선택</li>
</ul>
</li>
<li><p><strong>프로젝트별 venv 필수</strong></p>
<ul>
<li>전역 pip 최소화</li>
<li><code>.venv</code> + <code>requirements.txt</code>를 기본 셋업으로 사용</li>
</ul>
</li>
<li><p><strong>중요한 프로젝트는 Docker도 함께 고려</strong></p>
<ul>
<li>“다른 환경에서도 반드시 돌아가야 하는 코드”는
Docker 컨테이너 기준으로 한 번 더 정리</li>
</ul>
</li>
<li><p><strong>환경 복구 방법을 기록으로 남기기</strong></p>
<ul>
<li>“새 환경에서 이 프로젝트를 어떻게 다시 실행할 수 있는지”를
README나 블로그에 같이 적어 두기</li>
</ul>
</li>
</ol>
<p>환경 설정은 눈에 잘 보이는 성과도 아니고,
성능 점수처럼 수치로 바로 찍히는 것도 아니다.</p>
<blockquote>
<p>그래도 여러 프로젝트와 대회를 거치면서
환경의 중요성에 대해 확실히 느끼게 되었다.</p>
</blockquote>
<p>앞으로 진행할 프로젝트들에서는 
환경에 덜 끌려다니고,
코드와 실험에 더 집중할 수 있도록
조금씩이라도 개선해 나가 보려고 한다.</p>
<p>긴 글 읽어주셔서 감사합니다 :)</p>
]]></description>
        </item>
    </channel>
</rss>