<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>DongHo-Slog</title>
        <link>https://velog.io/</link>
        <description>블로그 이사 했습니다~ https://dongho-blog.vercel.app/</description>
        <lastBuildDate>Wed, 18 Feb 2026 15:09:08 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>DongHo-Slog</title>
            <url>https://velog.velcdn.com/images/dongho18/profile/144f21d9-69bb-412e-b838-ead86872b532/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. DongHo-Slog. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dongho18" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[네이버 부스트캠프 웹・모바일 10기 멤버십을 마치며]]></title>
            <link>https://velog.io/@dongho18/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B6%80%EC%8A%A4%ED%8A%B8%EC%BA%A0%ED%94%84-%EC%9B%B9%E3%83%BB%EB%AA%A8%EB%B0%94%EC%9D%BC-10%EA%B8%B0-%EB%A9%A4%EB%B2%84%EC%8B%AD%EC%9D%84-%EB%A7%88%EC%B9%98%EB%A9%B0</link>
            <guid>https://velog.io/@dongho18/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B6%80%EC%8A%A4%ED%8A%B8%EC%BA%A0%ED%94%84-%EC%9B%B9%E3%83%BB%EB%AA%A8%EB%B0%94%EC%9D%BC-10%EA%B8%B0-%EB%A9%A4%EB%B2%84%EC%8B%AD%EC%9D%84-%EB%A7%88%EC%B9%98%EB%A9%B0</guid>
            <pubDate>Wed, 18 Feb 2026 15:09:08 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요..!</p>
<p>길고 길었던 부스트캠프에서의 여정을 마무리 하게 되었습니다 🌱</p>
<p>오늘은 잘 쓰려 하기보다, <strong>날 것 그대로</strong> 제 이야기를 담아보려고 해요.</p>
<hr>
<h2 id="나는-왜-자신-있게-설명하지-못했을까"><strong>나는 왜 자신 있게 설명하지 못했을까</strong></h2>
<p>작년 2월에 지거국 컴퓨터공학과를 졸업하고 상반기 동안 취업 준비를 했어요.</p>
<p>프로젝트 경험도 많고 기능 구현에도 익숙하다고 생각했기에, 스스로 준비가 잘 됐다고 믿었습니다.</p>
<p>그런데 면접에 가면 늘 같은 질문에서 막혔어요. 🥲</p>
<p><strong>“왜 그렇게 설계했나요?”</strong></p>
<p><strong>“다른 선택지는 없었나요?”</strong></p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/f83ef67f-f0cc-4337-9cbc-ed77011f115e/image.png" alt=""></p>
<p>머릿속으로는 알고 있다고 생각했지만, 막상 설명하려 하면 말이 막혔어요.</p>
<p>그때 처음 깨달았어요.</p>
<p><strong>‘아는 것’과 ‘설명할 수 있는 것’은 전혀 다른 문제라는 걸요.</strong></p>
<p>확신이 없으니 점점 면접용 스크립트를 만들어 외우기 시작했어요.</p>
<p>논리로 설득하기보다 준비된 답을 말하려 했고, 스스로도 그게 어색하다는 걸 알고 있었어요.</p>
<p>그렇게 몇 번의 면접을 지나고 나니 이런 생각이 들었어요.</p>
<p><strong>“이건 내가 되고 싶은 모습이 아닌데.”</strong></p>
<p>준비된 답을 말하는 사람이 아니라, 제 선택을 스스로 납득하고 설명할 수 있는 사람이 되고 싶었어요.</p>
<p>그래서 어떻게 하면 그런 사람이 될 수 있을지 그 당시에 고민이 정말 많았어요.</p>
<hr>
<h2 id="네이버-부스트캠프에-입과하다">네이버 부스트캠프에 입과하다</h2>
<p>그때 우연히 <a href="https://blog.naver.com/boostcamp_official/223845032364">네이버 부스트캠프의 학습 철학</a>을 보게 됐어요.</p>
<blockquote>
</blockquote>
<p>부스트캠프에서는 실전과 유사한 조건에서 복잡하고 비구조화된 문제를 <strong>해결하며 스스로 학습</strong>하고 <strong>동료와의 소통</strong>을 반복하며 문제 해결 경험을 쌓습니다.</p>
<blockquote>
</blockquote>
<p>이 문장을 읽는 순간, 제가 부족하다고 느꼈던 부분을 정확히 짚어준 느낌이었어요.</p>
<p>기능 구현은 익숙했지만, 정답이 없는 문제를 정의하고 선택의 이유를 설명하는 훈련은 충분히 해보지 못했거든요.</p>
<p>면접에서 저를 당황하게 만들었던 질문들도 결국 정답이 없는 문제에 대해 <strong>제 사고 과정을 묻는 질문</strong>이었고요.</p>
<p>‘내가 찾던 곳이 바로 여기다’ 싶어서 망설임 없이 지원했고, </p>
<p>베이직과 챌린지를 거쳐 멤버십까지 함께하게 되었습니다. 😀</p>
<hr>
<h2 id="매일-왜-앞에서-말문이-막혔다"><strong>매일 “왜?” 앞에서 말문이 막혔다</strong></h2>
<p>8개월간의 훈련 끝에 이제는, 말하는 감자에서 <strong>조금은 생각을 말로 꺼낼 수 있는 사람</strong>이 된 것 같아요.</p>
<p>다만 처음부터 쉬웠던 건 아니었어요. 부스트캠프에서는 모든 활동에 “왜?”가 따라붙었거든요.</p>
<ul>
<li>아침에는 10시에 동료들과 함께 문제를 해결한 과정을 말로 설명해야 했고</li>
<li>오후에는 개발하면서 어떤 선택을 했는지 이유를 생각해둬야 했고</li>
<li>하루를 마무리할 때는 GitHub PR에 오늘의 판단을 동료들이 이해하기 쉽게 정리해야 했어요</li>
</ul>
<p>코드만 짜도 벅찼는데 생각까지 정리해 말해야 한다는 게 어려웠어요.</p>
<p>“왜 그렇게 했냐”는 질문을 들으면 괜히 틀린 것 같고, 설명이 부족하면 실력이 없어 보일까 봐 말을 줄인 적도 있었고요. 🥲</p>
<hr>
<h2 id="말이-어렵다면-먼저-그려보자"><strong>말이 어렵다면, 먼저 그려보자</strong></h2>
<p><img src="https://velog.velcdn.com/images/dongho18/post/038c1f2f-5cf4-4330-9610-a8f627a6ddc2/image.png" alt=""></p>
<p>그러다 이런 생각이 들었어요.</p>
<p><strong>말이 어려우면, 글과 그림으로 먼저 정리해보자.</strong></p>
<p>그래서 챌린지 기간 동안은 말을 잘하기 전에, 먼저 제 생각부터 머릿 속에서 끄집어내려고 했어요.</p>
<ul>
<li>복잡한 요구사항을 그림으로 구조화하고</li>
<li>코드 흐름을 다이어그램으로 그리고</li>
<li>클래스 다이어그램을 그리기도 했어요.</li>
</ul>
<p>신기했던 건, <strong>그림은 거짓말을 못 한다</strong>는 거였어요.</p>
<p>흐름도를 그리다 보면 제가 모호하게 생각했던 부분이 그대로 드러났고, 설계의 빈틈도 보이기 시작했어요.</p>
<p>그 부분을 다시 코드로 돌아가 고치거나, 설계를 다시 고민해보는 일이 반복됐어요.</p>
<p>몇 번 반복하다 보니 동료들에게 설명하는 일이 점점 덜 두려워졌어요. </p>
<p>특히 &quot;이해가 잘 된다&quot;는 피드백을 받기 시작하면서부터는 자신감도 생겼고요.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/3c6b4439-e9ab-4c46-bb30-bef89ebd0c10/image.png" alt=""></p>
<p>누가 써주신 피드백인지는 모르겠지만 넘 감동이였습니다 !! 🥹</p>
<hr>
<h2 id="설명하려고-하니-생각이-깊어졌다">설명하려고 하니, 생각이 깊어졌다</h2>
<p>예전의 저는 일단 구현부터 하고, 돌아가면 다음으로 넘어갔어요.</p>
<p>하지만 지금은 조금 달라졌어요.</p>
<ul>
<li>이 선택을 설명할 수 있을까?</li>
<li>PR에 이 결정을 어떻게 적을까?</li>
<li>누군가 반박한다면 나는 뭐라고 말할 수 있을까?</li>
</ul>
<p>설명을 전제로 생각하기 시작하면서 제 사고가 한 단계 더 깊어지는 느낌을 받았어요.</p>
<p>돌이켜보면 저는 말을 잘하게 된 게 아니라, <strong>생각을 정리하는 방법을 배운 것 같아요.</strong></p>
<p>글로 쓰고, 그림으로 그리고, 입으로 말해보고.</p>
<p>그 과정을 반복하다 보니 말은 자연스럽게 따라왔어요.</p>
<hr>
<h2 id="그룹-프로젝트에서-배운-건-함께-일하는-방식이었다"><strong>그룹 프로젝트에서 배운 건 ‘함께 일하는 방식’이었다</strong></h2>
<p><img src="https://velog.velcdn.com/images/dongho18/post/34e79aed-4a49-421c-9879-ba7e236ab138/image.png" alt=""></p>
<p>멤버십 그룹 프로젝트에서 Web01 팀은 <a href="https://boostus.site/">boostus</a> 라는 서비스를 만들었어요.</p>
<p>네이버 부스트캠프에는 좋은 학습 활동이 정말 많지만, 입과 전에는 그 경험들을 한 곳에서 접하기가 쉽지 않았어요. 캠퍼들의 프로젝트나 회고를 보려면 직접 찾아다녀야 했고, 기수가 끝나면 기록들이 체계적으로 보존되지 못하는 문제도 있었어요.</p>
<p><strong>boostus는 소중한 경험들이 사라지지 않고 계속 축적될 수 있도록, 부스트캠퍼들의 프로젝트, 회고들을 한 곳에 모아 연결하는 아카이빙 플랫폼이에요.</strong></p>
<p>프로젝트의 기술적인 내용은 <a href="https://github.com/boostcampwm2025/web01-BoostUs/wiki">GitHub Wiki</a>에 정리해두었으니, 궁금하시면 참고해 주세요!</p>
<p>이번 그룹 프로젝트를 통해 가장 크게 배운 건, 기술보다는 <strong>함께 일하는 방식</strong>이었어요.</p>
<h3 id="1-심리적-안전감이-팀을-바꾼다">1. 심리적 안전감이 팀을 바꾼다</h3>
<p><img src="https://velog.velcdn.com/images/dongho18/post/6b832061-89a9-4735-a8c7-2e98dad3fe1f/image.png" alt=""></p>
<p>1주차에는 각자의 목표와 강점, 약점을 솔직하게 공유하고 어떤 방식으로 협업할지 이야기하는 팀 캔버스 작성 시간을 가졌어요.</p>
<p>가장 기억에 남는 건 약점을 나누던 순간이었어요. 저는 논쟁이 생기면 분위기가 어색해질까 봐 피하는 편이고, 다른 생각이 있어도 굳이 표현하지 않고 넘기는 경우가 많다고 털어놓았는데요. 막상 꺼내고 보니 비슷한 고민을 가진 팀원이 생각보다 많았어요.</p>
<p>그래서 팀 그라운드 룰 중 하나로 <strong>&quot;반대 의견이 있으면 숨기지 말고 건설적으로 표현하기&quot;</strong> 를 정했어요. 팀원 모두가 각자의 약점을 인정하며 함께 만들어낸 룰이었기에 실제로도 잘 작동했어요. 의견이 엇갈리는 순간에도 조금 더 용기를 낼 수 있었던 건, 이런 팀 빌딩 과정이 있었기 때문이라고 생각해요.</p>
<h3 id="2-기록은-남기기-위한-게-아니라-이해하기-위한-것이다"><strong>2. 기록은 남기기 위한 게 아니라, 이해하기 위한 것이다</strong></h3>
<p><img src="https://velog.velcdn.com/images/dongho18/post/da436836-9040-49fa-9ecc-677c9eabf9d9/image.png" alt=""></p>
<p>팀 프로젝트를 하면서 문서화의 중요성도 크게 느꼈어요.</p>
<p>저희 팀은 매일 개발 일지를 작성했고, PR에도 변경 이유와 고민 과정을 최대한 자세하게 남기려고 했어요.</p>
<p>데모 시간에 질문을 받았을 때 “그 부분은 제가 안 했어요”가 아니라, <strong>누구든지 답변할 수 있기를 바랐거든요.</strong></p>
<p>덕분에 팀원들이 어떤 작업을 하는지, 왜 그런 선택을 했는지 팀 전체가 이해할 수 있었고, 개선 사항이 생겼을 때도 기록을 보며 자연스럽게 흐름을 따라갈 수 있었어요.</p>
<p>무엇보다 좋았던 건, 기록을 하면서 제 생각이 한 번 더 정리된다는 점이었어요.</p>
<p>그냥 구현하고 끝내는 게 아니라 “왜 이렇게 했지?”를 스스로에게 다시 묻게 되면서, 조금 더 책임감 있는 코드를 작성할 수 있었어요.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/4271c192-0045-4b58-9397-e79881871973/image.png" alt=""></p>
<p>여기서 한 걸음 더 나아가 회의록 관리 방식도 바꿔보고 싶었어요.</p>
<p>기존에는 회의가 끝나면 노션에 정리된 회의록만 남는 구조였는데, 시간이 지나면 “이거 왜 이렇게 하기로 했지?”라는 질문이 나왔고 결국 노션을 다시 뒤지거나 기억에 의존해야 하는 경우가 많았어요.</p>
<p>기록은 남아 있었지만 <strong>맥락은 남아 있지 않았던</strong> 셈이었어요.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/a1aaaabd-fcf8-4faf-af7c-e414ea3136e0/image.png" alt=""></p>
<p>그래서 Slack 허들 스크립트를 NotebookLM에 소스로 추가해 자연어 기반으로 검색할 수 있는 시스템을 만들었어요.</p>
<p>덕분에 “이 기능을 왜 하기로 했지?”, “이때 반대 의견은 뭐였지?”처럼 잊기 쉬운 질문에도 빠르게 답을 얻을 수 있었어요.</p>
<h3 id="3-기술적인-도전보다-완성도를-택하다">3. <strong>기술적인 도전보다 완성도를 택하다</strong></h3>
<p><img src="https://velog.velcdn.com/images/dongho18/post/1e831f19-7d88-4901-818d-a447bda8057f/image.png" alt=""></p>
<p>프로젝트를 하면서 팀 내에서 이런 고민이 있었어요. 실제 사용자의 문제를 해결하는 서비스를 만드는 것과, 취업을 위한 포트폴리오를 만드는 것 사이에서요.</p>
<p>처음에는 기술적인 도전보다 팀원 모두가 공감하는 문제를 해결하는 방향으로 잡았어요. 그런데 시니어 리뷰어님께서 &quot;문제 의식은 명확하지만, 커뮤니티 서비스 특성상 단순 CRUD 수준에서 끝날 가능성이 있어 보인다&quot;는 피드백을 주시면서 고민이 깊어졌어요.</p>
<p>피드백 이후 프로젝트 주제를 엎을지 말지 의견이 오갔는데, 주제를 바꾸기에는 리스크가 너무 크다고 판단해, 방향은 유지하되 기술적인 도전 요소를 찾아보기로 했어요. 마침 2주간의 인터미션 기간이 있었고, 그 시간에 프론트엔드/백엔드/DevOps 관점에서 가능한 도전들을 최대한 발산해봤어요.</p>
<p>하지만 막상 구현 단계에 들어가니 핵심 사용자 시나리오 기능을 완성하는 것만으로도 시간이 빠듯했어요.</p>
<p>결국 어설프게 도전 요소를 넣는 것보다 <strong>우리가 만들고자 한 기능을 제대로 완성하는 게 더 중요하다</strong>고 판단했어요.</p>
<p>포트폴리오보다 사용자를 먼저 생각한 선택이었고, 돌이켜보면 기술적인 도전은 의도적으로 만드는 게 아니라 사용자 경험을 고민하다 보면 자연스럽게 따라온다는 것을 배운 시간이었어요.</p>
<hr>
<h2 id="부스트캠프-덕분에-생긴-습관들"><strong>부스트캠프 덕분에 생긴 습관들</strong></h2>
<h3 id="1-추상적인-생각-대신-구체적으로-생각하기">1. 추상적인 생각 대신, 구체적으로 생각하기</h3>
<p>부스트캠프 과정을 거치면서 가장 달라진 점은 추상적으로 생각하지 않게 되었다는 것이에요. </p>
<p>예전에는 &quot;기능을 구현하자&quot; 정도로만 생각했다면, 이제는 문제를 작은 단위로 나누어 구체적으로 정의하려고 해요. </p>
<p>AI에게 작업을 요청할 때도 단순히 &quot;깃허브 소셜 로그인을 구현해줘&quot;라고 말하기보다, 구현 의도와 흐름을 함께 담아 요청하게 됐어요.</p>
<blockquote>
</blockquote>
<p>NestJS 기반의 백엔드 서버에서 OAuth2를 사용해서 깃허브 소셜 로그인을 구현하고 싶어.
로그인 성공 시에는 우리 서비스의 JWT를 발급하고, 기존 회원이면 바로 로그인 처리, 신규 회원이면 추가 정보를 입력 받는 플로우로 구성해줘… (중략)</p>
<blockquote>
</blockquote>
<h3 id="2-항상-why를-먼저-묻기">2. 항상 Why를 먼저 묻기</h3>
<p>부스트캠프를 하면서 자연스럽게 생긴 또 하나의 습관은 결정을 내리기 전에 항상 &quot;왜?&quot;를 먼저 묻는 것이에요. </p>
<p>기술 선택을 할 때에도 근거를 설명할 수 있는지를 스스로에게 묻게 되었고, 덕분에 선택의 이유가 더 분명해졌어요.</p>
<blockquote>
</blockquote>
<p>&quot;JWT를 쓰자&quot; → 왜 세션이 아니라 JWT인가? → Stateless 구조가 우리 서비스에 정말 필요한가? → 토큰 탈취 리스크는 어떻게 감당할 것인가? → 현재 규모에서는 세션으로도 충분하지만, 향후 확장성을 고려했을 때 JWT가 더 적합하다고 판단했다.</p>
<blockquote>
</blockquote>
<h3 id="3-필요할-땐-도움을-요청하기">3. 필요할 땐 도움을 요청하기</h3>
<p>저는 부스트캠프에 입과하기 전까지 혼자 공부하는 것에 익숙했어요. 문제가 생기면 혼자 해결하려 했고, 며칠을 붙잡고 있어도 동료에게 먼저 물어보는 일이 거의 없었어요.</p>
<p>그런데 한 번은 Next.js에서 이미지가 엑스박스로 뜨는 문제를 혼자 끙끙대다가, 처음으로 슬랙에 먼저 물어봤어요. 그랬더니 한 팀원분께서 바로 해결책을 알려주셔서 몇 분 만에 해결됐어요.</p>
<p>혼자였다면 몇 시간은 걸렸을 문제였는데, 그때부터 “혼자 오래 고민하는 것”이 능력이 아닐 수도 있다는 걸 느꼈어요.</p>
<p>이제는 혼자 오래 붙잡기보다, 일정 시간 고민해도 해결이 안 되면 먼저 물어보는 것을 선택하게 되었어요.</p>
<h3 id="4-생각의-외주화를-막기">4. 생각의 외주화를 막기</h3>
<p>저는 매일 자기 전에 PR을 작성하는 루틴이 있었는데, </p>
<p>개발을 마치고 나면 너무 지쳐서 가끔 AI에게 PR 작성을 맡긴 적이 있었어요.</p>
<p>나중에 그 PR을 다시 보면 내용은 그럴듯했지만, 정작 제가 그날 어떤 고민을 했는지는 남아있지 않았어요.</p>
<p>그때 <strong>생각하는 과정까지 AI에게 맡기고 있었다</strong>는 걸 스스로 자각하게 됐어요.</p>
<p>그래서 점점 방식을 바꾸게 되었는데요.</p>
<p>먼저 스스로 충분히 고민하고, 가설을 세우고, 문서로 정리한 뒤에 질문하는 방식으로요.</p>
<p>그 과정에서 AI는 ‘대신 생각해주는 존재’가 아니라, <strong>제 사고를 확장해주는 도구</strong>라는 것을 배우게 되었어요.</p>
<hr>
<h2 id="앞으로의-계획은">앞으로의 계획은?</h2>
<p>올해 상반기에는 본격적으로 이력서를 돌리고 면접을 준비할 계획이에요.</p>
<p>부스트캠프를 하며 CS 기초(네트워크/운영체제/자료구조)가 개발 중 발목을 잡는 순간들이 있었기에, </p>
<p><strong>CS 스터디를 통해 단골 질문들에 근거 있게 답할 수 있는 수준까지 끌어올리는 것</strong>이 단기 목표예요.</p>
<p>단순히 암기해서 답하는 게 아니라, <strong>“왜 그렇게 동작하는지”를 제 언어로 설명할 수 있을 만큼</strong> 이해하고 싶어요.</p>
<p>장기적으로는 주어진 기능을 구현하는 것을 넘어, 사용자 입장에서 왜 이 기능이 필요한지를 먼저 고민하는 <strong>프로덕트 엔지니어</strong>로 성장하고 싶어요.</p>
<hr>
<h2 id="마무리하며">마무리하며…</h2>
<p>부스트캠프는 개발자로서 문제를 바라보는 태도와 동료와 협업하는 방식까지 배울 수 있었던 소중한 시간이었어요. 무엇보다 좋은 동료들을 많이 만난 게 가장 큰 행운이었고, 그 덕분에 혼자였다면 불가능했을 성장을 할 수 있었어요.</p>
<p>부스트캠프를 시작하기 전에는 제 자신을 너무 몰랐는데, 부족한 부분들을 하나씩 채울 수 있는 환경을 만들어주신 운영진분들과 마스터님께 진심으로 감사드려요. 함께 달려온 동료들에게도 고마운 마음을 전하고 싶어요.</p>
<p>비록 부스트캠프는 끝났지만, 앞으로도 쭉 근황 나누면서 함께 성장해나갔으면 좋겠어요! 😀</p>
<p>긴 글 읽어주셔서 감사합니다!</p>
<blockquote>
<ul>
<li>글 쓰기 소요시간: 10시간 + @</li>
</ul>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[boostus RSS 기반 크롤러 구현기]]></title>
            <link>https://velog.io/@dongho18/boostus-RSS-%EA%B8%B0%EB%B0%98-%ED%81%AC%EB%A1%A4%EB%9F%AC-%EA%B5%AC%ED%98%84%EA%B8%B0</link>
            <guid>https://velog.io/@dongho18/boostus-RSS-%EA%B8%B0%EB%B0%98-%ED%81%AC%EB%A1%A4%EB%9F%AC-%EA%B5%AC%ED%98%84%EA%B8%B0</guid>
            <pubDate>Tue, 10 Feb 2026 08:32:54 GMT</pubDate>
            <description><![CDATA[<h2 id="rss-really-simple-syndication-이란">RSS (Really Simple Syndication) 이란?</h2>
<p>RSS는 웹사이트의 최신 콘텐츠를 기계가 읽기 쉬운 형태인 XML로 제공하는 표준 포맷입니다.</p>
<p>RSS의 등장 배경은 “사람이 직접 사이트를 방문하지 않아도 새로운 글을 받아볼 수 있게 하자”는 요구가 많아지며 생긴 기술입니다. 과거에는 사용자가 여러 사이트를 일일이 방문해야 했지만, RSS를 사용하면 프로그램이 피드를 구독해 새 글을 자동으로 수집할 수 있습니다.</p>
<h3 id="rss가-없다면-글을-어떻게-수집할까">RSS가 없다면 글을 어떻게 수집할까?</h3>
<p>RSS가 없는 시절에는 개발자가 직접 HTML 구조를 분석해 원하는 데이터를 추출해야 했습니다.</p>
<p>이렇게 되면 사이트마다 구조가 제각각이기 때문에, 파싱 로직을 내가 추출하고자 하는 사이트와 1대1로 대응시켜 작성할 수밖에 없습니다.</p>
<p>결국 사이트가 10개면 10개의 크롤링 코드가 필요했고, 사이트의 HTML 구조가 조금만 바뀌어도 로직 전체를 수정해야 했습니다.</p>
<h3 id="rss의-등장">RSS의 등장</h3>
<p>이러한 불편함을 해결하기 위해 등장한 것이 RSS입니다. RSS는 콘텐츠 제공자가 직접 정해진 규격에 맞춰 글 정보를 노출하도록 만들어졌습니다. 덕분에 콘텐츠를 수집하는 쪽(프로그램)에서는 더 이상 사이트별로 HTML 구조를 분석할 필요 없이, 표준화된 XML 형식만 처리하면 되었습니다.</p>
<p>RSS가 도입되면서 콘텐츠 수집 방식은 크게 달라졌습니다.</p>
<ul>
<li>HTML 구조 의존 → 표준 데이터 의존</li>
<li>사이트별 전용 크롤러 → 공통 파서 사용</li>
<li>잦은 깨짐 → 비교적 안정적인 수집</li>
</ul>
<h3 id="rss의-구조">RSS의 구조</h3>
<p>위에서 RSS는 콘텐츠 제공자가 정해진 규격에 맞춰 글 정보를 노출하도록 만들어졌다고 했는데요.</p>
<p>그렇다면 이 정해진 규격은 어떻게 생겼을까요?</p>
<p>RSS는 기본적으로 XML 기반 문서이며, 크게 두 영역으로 구성됩니다.</p>
<ul>
<li><strong><code>channel</code></strong>: 피드 전체에 대한 정보</li>
<li><strong><code>item</code></strong>: 개별 게시글 정보</li>
</ul>
<p>예를 들어 <a href="https://www.rssboard.org/rss-specification">RSS 2.0</a>을 기준으로 <code>item</code>은 다음과 가은 형태를 가집니다.</p>
<pre><code class="language-xml">&lt;item&gt;
  &lt;title&gt;게시글 제목&lt;/title&gt;
  &lt;link&gt;https://...&lt;/link&gt;
  &lt;pubDate&gt;...&lt;/pubDate&gt;
  &lt;description&gt;...&lt;/description&gt;
&lt;/item&gt;</code></pre>
<h3 id="rss는-누가-제공하는걸까">RSS는 누가 제공하는걸까?</h3>
<p>RSS는 콘텐츠를 만드는 쪽에서 직접 제공합니다. </p>
<p>대부분의 국내 블로그 플랫폼이나 뉴스 사이트는 자체적으로 RSS 피드를 생성해 공개하고 있습니다.</p>
<p>예를 들면 이런 곳들이죠.</p>
<ul>
<li>기술 블로그 플랫폼(<a href="https://dongho-dev.tistory.com/rss">Tistory</a>, <a href="https://v2.velog.io/rss/dongho18">Velog</a>, Medium 등)</li>
<li>뉴스 미디어</li>
</ul>
<p>이들은 새로운 글이 발행될 때마다 RSS 문서를 함께 갱신합니다.</p>
<p>따라서 수집 프로그램은 사이트의 화면을 긁어오는 대신, 공식적으로 제공되는 피드 주소만 구독하면 됩니다.</p>
<p>이 점이 RSS의 가장 중요한 특징입니다.</p>
<p>HTML 크롤링은 “외부에서 몰래 긁어오는 방식”에 가깝다면, RSS는 콘텐츠 제공자가 허용한 공식 창구를 이용하는 방식입니다.</p>
<p>그 덕분에 비교적 안정적이고 예의 바른 데이터 수집이 가능합니다.</p>
<blockquote>
<p><strong>엥? 그러면 콘텐츠 제공자가 RSS 피드를 제공해주지 않을 수도 있겠네요?</strong></p>
</blockquote>
<p>맞습니다.. 모든 사이트가 RSS 피드를 제공해주면 좋겠지만 그렇지 않은 사이트들도 있습니다.</p>
<p>사실 RSS 피드는 콘텐츠 제공자의 입장에서 신경 써야 할 또 하나의 골칫거리가 됩니다.</p>
<p>글을 작성할 때마다 피드가 정상적으로 갱신되는지 관리해야 하고, 어떤 정보를 어디까지 공개할지도 고민해야합니다.</p>
<p>그리고 콘텐츠 제공자는 광고 노출이나 페이지 체류 시간을 통해 수익을 얻는 경우가 많은데, RSS를 지원하게 되면 제공자의 입장에서는 손해이겠죠?</p>
<p>그래서 일부 플랫폼은 그래서 RSS에 본문 전체가 아닌 요약만 제공하거나, 아예 RSS 기능을 제공하지 않습니다.</p>
<h2 id="boostus-요구사항">boostus 요구사항</h2>
<p><img src="https://velog.velcdn.com/images/dongho18/post/0b72994a-2f2c-4a76-95f0-7111bb4ff50f/image.png" alt=""></p>
<p>RSS에 대해 어느 정도 알아보았으니, 이제 이 기술을 boostus에서 어떤 문제를 해결하는 데 활용하려 했는지 정리해보겠습니다.</p>
<p>boostus의 목표는 여러 캠퍼들의 블로그에 흩어져 있는 개발 글을 한곳에서 모아 보여주는 것이었습니다.</p>
<p>이렇게 모인 글들이 캠퍼들의 성장 기록이 되고, 부스트캠프에 관심 있는 예비 지원자들에게는 좋은 학습 자료가 되길 바랐습니다.</p>
<h3 id="블로그-플랫폼-조사">블로그 플랫폼 조사</h3>
<p>개발에 들어가기 앞서 캠퍼들이 개발 글을 주로 어디에 작성하고 있는지부터 살펴보았습니다.</p>
<p>대표적으로 다음과 같은 공간들이 사용되고 있었습니다.</p>
<ul>
<li>Velog</li>
<li>Tistory</li>
<li>GitHub Pages</li>
<li>개인 블로그 (React, Next.js 등으로 자체 제작)</li>
</ul>
<h3 id="rss-지원-현황">RSS 지원 현황</h3>
<table>
<thead>
<tr>
<th>블로그명</th>
<th>RSS URL</th>
<th>지원 여부</th>
</tr>
</thead>
<tbody><tr>
<td>Tistory</td>
<td><a href="https://dongho-dev.tistory.com/rss">https://dongho-dev.tistory.com/rss</a></td>
<td>✅ (rss 2.0)</td>
</tr>
<tr>
<td>Velog</td>
<td><a href="https://v2.velog.io/rss/dongho18">https://v2.velog.io/rss/dongho18</a></td>
<td>✅ (rss 2.0)</td>
</tr>
<tr>
<td>Github Pages (jekyll)</td>
<td><a href="https://jangdongho.github.io/feed.xml">https://jangdongho.github.io/feed.xml</a></td>
<td>✅ (Atom)</td>
</tr>
<tr>
<td>개인 블로그 (자체 제작)</td>
<td>-</td>
<td>⚠️ optional</td>
</tr>
</tbody></table>
<p>캠퍼들이 사용하는 블로그 플랫폼에 따라 RSS 제공 방식이 완전히 달랐습니다.</p>
<p>어떤 곳은 RSS를 기본적으로 지원 했고, 아예 RSS가 없는 경우도 있었습니다.</p>
<h3 id="플랫폼별-rss-구조-분석">플랫폼별 RSS 구조 분석</h3>
<p>Tistory와 Velog의 RSS item을 분석해보니, 구조가 비슷하면서도 세부 포맷에는 차이가 있었습니다.</p>
<p><a href="https://dongho-dev.tistory.com/rss"><strong>Tistory RSS items</strong></a></p>
<table>
<thead>
<tr>
<th>필드</th>
<th>설명</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><code>&lt;title&gt;</code></td>
<td>게시글 또는 항목의 제목</td>
<td>[Troubleshooting] Jenkins에서 docker-compose 명령 수행 시 Permission denied 에러</td>
</tr>
<tr>
<td><code>&lt;link&gt;</code></td>
<td>게시글/항목 URL</td>
<td><a href="https://dongho-dev.tistory.com/58">https://dongho-dev.tistory.com/58</a></td>
</tr>
<tr>
<td><code>&lt;description&gt;</code></td>
<td>게시글/항목 상세 내용, HTML 가능</td>
<td>Jenkins 컨테이너에서 docker-compose 사용 시 Permission denied 발생</td>
</tr>
<tr>
<td><code>&lt;category&gt;</code></td>
<td>항목이 속한 카테고리, 여러 개 가능</td>
<td>docker-compose</td>
</tr>
<tr>
<td><code>&lt;author&gt;</code></td>
<td>작성자</td>
<td>dongho_dev</td>
</tr>
<tr>
<td><code>&lt;guid&gt;</code></td>
<td>항목 고유 ID, 영구 링크 여부 표시 가능</td>
<td><a href="https://dongho-dev.tistory.com/58">https://dongho-dev.tistory.com/58</a></td>
</tr>
<tr>
<td><code>&lt;comments&gt;</code></td>
<td>댓글 URL</td>
<td><a href="https://dongho-dev.tistory.com/58#entry58comment">https://dongho-dev.tistory.com/58#entry58comment</a></td>
</tr>
<tr>
<td><code>&lt;pubDate&gt;</code></td>
<td>작성일/게시일</td>
<td>Fri, 12 Apr 2024 17:31:44 +0900</td>
</tr>
</tbody></table>
<p>먼저 Tistory RSS item을 살펴보면, 제목, URL, 상세 내용, 카테고리, 작성자, 고유 ID, 댓글 URL, 작성일 등 필요한 데이터를 알차게 담아서 보내주고 있었습니다.</p>
<p><a href="https://v2.velog.io/rss/dongho18"><strong>Velog RSS items</strong></a></p>
<table>
<thead>
<tr>
<th>필드</th>
<th>설명</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><code>&lt;title&gt;</code></td>
<td>게시글 또는 항목의 제목</td>
<td>[트러블슈팅] Supabase Max client connections reached</td>
</tr>
<tr>
<td><code>&lt;link&gt;</code></td>
<td>게시글/항목 URL</td>
<td><a href="https://velog.io/@dongho18/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-Supabase-Max-client-connections-reached">https://velog.io/@dongho18/트러블슈팅-Supabase-Max-client-connections-reached</a></td>
</tr>
<tr>
<td><code>&lt;guid&gt;</code></td>
<td>항목 고유 ID, 영구 링크 여부 가능</td>
<td><a href="https://velog.io/@dongho18/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-Supabase-Max-client-connections-reached">https://velog.io/@dongho18/트러블슈팅-Supabase-Max-client-connections-reached</a></td>
</tr>
<tr>
<td><code>&lt;description&gt;</code></td>
<td>게시글/항목 상세 내용, HTML 및 CDATA 가능</td>
<td>Supabase Max client connections reached 오류 발생 원인과 해결 방법, DB Connection Pool과 HikariCP 관련 설명 및 설정 방법</td>
</tr>
<tr>
<td><code>&lt;pubDate&gt;</code></td>
<td>게시일/작성일</td>
<td>Fri, 24 May 2024 15:19:20 GMT</td>
</tr>
</tbody></table>
<p>반면에 Velog의 경우, 제공하는 필드가 Tistory보다 단순했습니다. </p>
<p>주요 차이점은 다음과 같습니다.</p>
<ul>
<li><code>category</code>와 <code>author</code> 필드가 없음</li>
<li><code>description</code>에 CDATA가 포함되어 HTML과 특수문자가 섞여 있음</li>
<li>작성일(pubDate) 표준이 Tistory는 KST(+0900)인 반면, Velog는 GMT 기준</li>
</ul>
<h3 id="플랫폼-선정-기준">플랫폼 선정 기준</h3>
<p>Github Pages (jekyll)도 분석을 진행했었지만, RSS 대신 Atom 피드를 제공하고 있었기 때문에 boostus에서는 우선 <strong>Tistory와 Velog RSS만을 대상</strong>으로 수집을 진행하기로 결정했습니다.</p>
<p>선정 이유는 다음과 같습니다.</p>
<ol>
<li>Atom과 RSS는 구조가 비슷하지만, 파싱 로직을 단순화하기 위해 RSS에 집중</li>
<li>모든 플랫폼을 한 번에 대응하기보다는, RSS 구조가 표준에 가깝고 사용자 비중이 높은 곳부터 안정적으로 처리하는 것이 먼저라고 판단</li>
</ol>
<h2 id="rss-공통-모델-설계">RSS 공통 모델 설계</h2>
<p>Velog와 Tistory는 둘 다 RSS 표준을 따르지만, 세부 필드 구성과 형식에는 차이가 있음을 확인했습니다.</p>
<p>그래서 boostus에서 사용할 RSS 공통 모델을 설계했습니다.</p>
<p>설계 기준은 다음과 같습니다.</p>
<ol>
<li><strong>Tistory와 Velog에서 모두 제공하는 필드인가?</strong></li>
<li><strong>우리 서비스에 꼭 필요한 필드인가?</strong></li>
</ol>
<p>최종적으로 선정된 공통 필드는 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th><strong>필드</strong></th>
<th><strong>설명</strong></th>
<th><strong>예시</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>guid</code></td>
<td>글 고유 ID</td>
<td><a href="https://dongho-dev.tistory.com/58">https://dongho-dev.tistory.com/58</a></td>
</tr>
<tr>
<td><code>title</code></td>
<td>게시글 제목</td>
<td>글 제목입니다!!</td>
</tr>
<tr>
<td><code>link</code></td>
<td>게시글 URL</td>
<td><a href="https://dongho-dev.tistory.com/58">https://dongho-dev.tistory.com/58</a></td>
</tr>
<tr>
<td><code>description</code></td>
<td>게시글 상세 내용</td>
<td><p>글 내용은 블라블라블라 입니다.</p></td>
</tr>
<tr>
<td><code>pubDate</code></td>
<td>게시일</td>
<td>Fri, 12 Apr 2024 17:31:44 +0900</td>
</tr>
</tbody></table>
<p>각 필드별 특징과 처리 방향은 다음과 같습니다.</p>
<p><strong>guid (글 고유 ID)</strong></p>
<ul>
<li>글마다 유일하게 부여된 ID로, 중복 글을 걸러낼 때 사용됩니다.</li>
<li>Tistory와 Velog는 이 <code>guid</code>가 <code>link</code>와 동일했지만, 다른 블로그 플랫폼들은 항상 그렇지 않을 수 있어 별도 필드로 관리합니다.</li>
</ul>
<p><strong>description (게시글 상세 내용)</strong></p>
<ul>
<li>Tistory와 Velog는 블로그 글 내용 전문을 HTML(CDATA) 형태로 제공합니다.</li>
<li>이 HTML 본문에서 썸네일 이미지와 요약 텍스트를 추출해야 합니다.</li>
</ul>
<p><strong>pubDate (게시일)</strong></p>
<ul>
<li>Tistory는 KST(+0900), Velog는 GMT를 기준으로 제공되기 때문에 통일된 시간대 변환이 필요합니다.</li>
<li>데이터 수집 단계에서 UTC 기준으로 변환한 뒤, 서비스에서 글을 보여줄 때는 KST로 다시 변환하여 표시합니다.</li>
</ul>
<h2 id="엔티티-설계">엔티티 설계</h2>
<p>이렇게 정의한 RSS 공통 모델을 기반으로, 이제 실제 데이터베이스에 저장할 엔티티를 설계해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/185c7fe3-3c6f-4d18-841f-197bee0a1627/image.png" alt=""></p>
<p>설명을 위해 ERD를 간소화했습니다.</p>
<h3 id="feed-엔티티-설계">Feed 엔티티 설계</h3>
<p>먼저 어떤 블로그에서 글을 수집할 것인가를 정의하는 Feed 엔티티부터 살펴보겠습니다.</p>
<p>Feed 엔티티는 회원과 RSS 피드 URL을 연결하는 역할을 담당합니다.</p>
<p>각 회원이 자신의 블로그 RSS URL을 등록하면, 크롤러는 이 Feed 정보를 기반으로 주기적으로 새 글을 수집합니다.</p>
<p><strong>Feed 엔티티 구조</strong></p>
<pre><code class="language-tsx">class Feed {
  id: bigint;              // DB에서 자동 생성되는 고유 ID
  memberId: string;        // 회원 ID (Member 엔티티와 1:1 관계)
  feedUrl: string;         // RSS 피드 URL
}</code></pre>
<p><strong>주요 필드 설명</strong></p>
<ol>
<li><strong>memberId</strong><ul>
<li>Feed와 Member는 1:1 관계입니다.</li>
<li>유니크 제약 조건으로 한 회원당 하나의 피드만 등록 가능하도록 하기 위함입니다.</li>
<li>한 회원이 여러 블로그를 운영하는 경우는 초기 요구사항에 없어 단순하게 설계했습니다.</li>
</ul>
</li>
<li><strong>feedUrl (RSS 피드 URL)</strong><ul>
<li>RSS 피드의 실제 URL입니다. (예: <code>https://dongho-dev.tistory.com/rss</code>)</li>
<li>유니크 제약 조건으로 동일한 RSS URL이 중복 등록되는 것을 방지합니다.</li>
</ul>
</li>
</ol>
<h3 id="story-엔티티-설계">Story 엔티티 설계</h3>
<p>RSS 공통 모델에서 정의한 필드들을 바탕으로, 실제 서비스에서 사용할 Story 엔티티를 설계했습니다.</p>
<p>Story 엔티티는 단순히 RSS 데이터를 그대로 저장하는 것이 아니라, 서비스에서 필요한 추가 정보를 함께 담고 있습니다.</p>
<p><strong>Story 엔티티 구조</strong></p>
<pre><code class="language-tsx">class Story {
  id: bigint;              // DB에서 자동 생성되는 고유 ID
  feedId: string;          // 어느 피드에서 수집했는지 (Feed 엔티티와 관계)

  // RSS 공통 모델에서 가져온 필드들
  guid: string;            // RSS 아이템 고유 식별자
  title: string;           // 글 제목
  contents: string;        // 본문 HTML (description에서 가져옴)
  originalUrl: string;     // 원문 링크 (link에서 가져옴)
  publishedAt: Date;       // 발행일 (pubDate를 UTC로 변환)

  // 서비스를 위해 추가 가공한 필드들
  summary: string;         // 요약 (contents에서 slice해서 추출)
  thumbnailUrl?: string;   // 썸네일 이미지 URL (contents에서 추출)
}</code></pre>
<p><strong>주요 필드 설명</strong></p>
<ol>
<li><strong>feedId</strong><ul>
<li>Feed와 Story는 1:N 관계로, 하나의 Feed에서 여러 Story가 수집됩니다.</li>
<li>Story는 Feed를 참조하여 “이 글이 어느 블로그에서 수집되었는지” 추적합니다.</li>
</ul>
</li>
<li><strong>guid (글 고유 ID)</strong><ul>
<li>RSS 피드에서 제공하는 각 글의 고유 식별자입니다.</li>
<li>중복 수집을 방지하는 데 사용됩니다. 같은 <code>guid</code>를 가진 글은 이미 수집된 것으로 판단합니다.</li>
<li>Tistory와 Velog는 <code>guid</code>가 글 URL과 동일하지만, 다른 플랫폼은 다를 수 있어 별도 필드로 관리합니다.</li>
</ul>
</li>
<li><strong>title (글 제목)</strong><ul>
<li>RSS의 <title> 태그에서 가져온 글 제목입니다.</li>
<li>HTML 엔티티(<code>&amp;amp;</code>, <code>&amp;quot;</code> 등)를 디코딩하여 저장합니다.</li>
</ul>
</li>
<li><strong>contents (본문 HTML)</strong><ul>
<li>RSS의 <code>&lt;description&gt;</code> 태그에서 가져온 글의 전체 내용입니다.</li>
<li>HTML 형식으로 저장됩니다.</li>
<li>이 필드에서 썸네일과 요약을 추출합니다.</li>
</ul>
</li>
<li><strong>originalUrl (원문 링크)</strong><ul>
<li>RSS의 <code>&lt;link&gt;</code> 태그에서 가져온 글의 원본 URL 입니다.</li>
<li>사용자가 글 상세를 보려 할 때 원본 블로그로 이동하는 링크로 사용됩니다.</li>
</ul>
</li>
<li><strong>publishedAt (발행일)</strong><ul>
<li>RSS의 <code>&lt;pubDate&gt;</code> 태그에서 가져온 글의 작성 시각입니다.</li>
<li>블로그마다 시간대가 다르므로(KST, GTM 등) UTC로 통일하여 저장합니다.</li>
<li>글 목록을 최신순으로 정렬할 때 사용됩니다.</li>
</ul>
</li>
<li><strong>summary (요약)</strong><ul>
<li>RSS에서 직접 제공하지 않는 필드로, <code>contents</code>에서 추출하여 생성합니다.</li>
<li>HTMl 태그를 제거하고 본문 앞부분의 텍스트만 추출합니다. (150~200자)</li>
<li>글 목록 화면에서 미리보기 텍스트로 사용됩니다.</li>
</ul>
</li>
<li><strong>thumbnailUrl (썸네일 이미지 URL)</strong><ul>
<li>RSS에서 직접 제공하지 않는 필드로, <code>contents</code>의 HTML에서 첫 번째 <code>&lt;img&gt;</code> 태그를 찾아 추출합니다.</li>
<li>이미지가 없는 글은 <code>null</code> 값을 가집니다.</li>
<li>글 목록 화면에서 썸네일로 표시됩니다.</li>
</ul>
</li>
</ol>
<h2 id="createstoryrequest-dto-설계">CreateStoryRequest DTO 설계</h2>
<p>Story 엔티티를 설계했으니, 이제 RSS 데이터를 Story로 변환하기 위한 중간 계층인 CreateStoryRequest DTO를 살펴보겠습니다.</p>
<p><strong>DTO 구조</strong></p>
<pre><code class="language-tsx">interface CreateStoryRequest {
  feedId: string;           // 어느 피드에서 수집했는지
  guid: string;             // RSS 아이템 고유 식별자
  title: string;            // 글 제목
  summary: string;          // 요약 (HTML 태그 제거 후 생성)
  contents: string;         // 본문 HTML
  thumbnailUrl?: string;    // 썸네일 이미지 URL (선택)
  originalUrl: string;     // 원문 링크
  publishedAt: string;      // 발행일 (ISO 8601)
}</code></pre>
<p>이제 이 DTO를 활용해 실제 RSS 수집 파이프라인이 어떻게 동작하는지 살펴보겠습니다.</p>
<h2 id="rss-수집-파이프라인">RSS 수집 파이프라인</h2>
<h3 id="파이프-라인-구조">파이프 라인 구조</h3>
<p>수집 파이프라인은 크게 3단계로 구성되어 있습니다.</p>
<ol>
<li>RSS 피드 다운로드(Download)</li>
<li>RSS 파싱(Parse)</li>
<li>DB 저장(Store)</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dongho18/post/f7d31bda-67a4-4228-8cd6-dde7c1d7b7bc/image.png" alt=""></p>
<p>그리고 각 파이프라인 단계에 맞춰 컴포넌트를 분리했습니다.</p>
<ul>
<li><strong>피드 다운로더</strong>: 캠퍼 블로그의 RSS URL에 HTTP 요청을 보내 최신 글 데이터(XML)를 가져오는 역할</li>
<li><strong>피드 파서</strong>: 다운로드한 RSS XML을 <code>&lt;item&gt;</code> 단위로 파싱하고 CreateStoryRequest DTO로 변환하는 역할</li>
<li><strong>피드 매니저</strong>: 다운로드와 파서의 중간 다리 역할</li>
</ul>
<p>크롤러의 동작 과정을 정리하면 다음과 같습니다.</p>
<ol>
<li>피드 테이블에서 피드 URL들을 조회합니다.</li>
<li>피드 URL들을 순회하며 피드 XML을 다운로드합니다.</li>
<li>다운로드 한 XML을 <code>&lt;item&gt;</code> 단위로 파싱하고, CreateStoryRequest DTO로 정규화합니다.</li>
<li>DTO를 Story 엔티티로 변환하여 DB에 저장합니다.</li>
<li>정해진 시간마다 1~4번 과정을 반복합니다.</li>
</ol>
<p>위 구조를 바탕으로, 이제 각 컴포넌트가 어떤 책임을 가지고 있고 어떤 기준으로 분리되었는지 하나씩 살펴보겠습니다.</p>
<h3 id="피드-매니저-feed-manager">피드 매니저 (Feed Manager)</h3>
<p>피드 매니저는 수집 파이프라인의 <strong>오케스트레이터 역할</strong>을 담당하는 컴포넌트입니다.</p>
<p>다운로드와 파서를 직접 호출하며, 전체 수집 흐름을 제어하는 역할을 합니다.</p>
<p><strong>동작 과정</strong></p>
<p>피드 매니저는 다음과 같은 흐름으로 동작합니다.</p>
<ol>
<li>DB에서 활성화된 피드 URL 목록을 조회</li>
<li>각 URL을 다운로더에 전달해 XML 데이터 다운로드</li>
<li>파서에게 XML을 전달해 CreateStoryRequest DTO로 변환</li>
<li>DTO를 Story 엔티티로 변환하고 저장소에 전달하여 저장</li>
</ol>
<pre><code class="language-tsx">for (const feed of feeds) {
  try {
    const xml = await downloader.download(feed.feedUrl);
    const stories = await parser.parse(xml, feed.id);
    await this.createStories(stories);
  } catch {
    continue;
  }
}</code></pre>
<p>위 스니펫은 피드 매니저의 핵심 흐름만 표현한 코드입니다.</p>
<p>다운로더 → 파서 → 저장으로 이어지는 파이프라인을 순차적으로 실행하며,</p>
<p>개별 피드에서 발생한 예외가 전체 수집을 중단하지 않도록 했습니다.</p>
<h3 id="피드-다운로더-feed-donwloader">피드 다운로더 (Feed Donwloader)</h3>
<p>피드 다운로더는 RSS URL로 HTTP 요청을 보내 원본 XML 데이터를 안정적으로 가져오는 역할을 담당합니다.</p>
<p><strong>동작 과정</strong></p>
<p>피드 다운로더는 다음과 같은 흐름으로 동작합니다.</p>
<ol>
<li>전달받은 피드 URL로 HTTP GET 요청을 보냅니다.</li>
<li>User-Agent 헤더를 명시해 크롤러 요청임을 알립니다. (예의 바른 크롤러)</li>
<li>30초 타임아웃을 설정해 무한 대기 상태를 방지합니다.</li>
<li>응답 상태 코드를 검증해 정상 응답(200)인지 확인합니다.</li>
<li>문제가 없다면 XML 원문을 반환합니다.</li>
</ol>
<pre><code class="language-tsx">async download(feedUrl: string): Promise&lt;string&gt; {
  const response = await axios.get(feedUrl, {
    headers: { &#39;User-Agent&#39;: &#39;BoostUs-RSS-Crawler&#39; },
    timeout: 30000,
  });

  if (response.status !== 200) {
    throw new Error(`HTTP ${response.status}`);
  }

  return response.data;
}</code></pre>
<p>이 과정에서 다운로더는 네트워크 통신에만 집중하고, </p>
<p>파싱이나 저장과 같은 이후 단계의 로직은 담당하지 않도록 분리했습니다.</p>
<h3 id="피드-파서-feed-parser">피드 파서 (Feed Parser)</h3>
<p>피드 파서는 다운로드된 XML 데이터를 분석해 CreateStoryRequest DTO로 변환하는 역할을 담당합니다.</p>
<p>초기에는 직접 XML을 파싱하는 방식을 고려했지만, 파싱 로직을 직접 구현하기에는 시간이 부족하다고 판단해서 <a href="https://www.npmjs.com/package/rss-parser">rss-parser</a> 라이브러리를 활용하는 방식을 선택했습니다.</p>
<p><strong>선택 이유</strong></p>
<ol>
<li><strong>RSS 표준의 다양성</strong><ul>
<li>RSS 2.0, Atom 등 여러 변형 존재</li>
<li>블로그마다 태그 구조가 미묘하게 다름</li>
<li>직접 구현 시 예외 케이스 처리 비용이 큼</li>
</ul>
</li>
<li><strong>안정성과 유지보수</strong><ul>
<li>이미 검증된 파서 로직 재사용</li>
</ul>
</li>
<li><strong>핵심 로직 집중</strong><ul>
<li>“XML 해석”보다 “데이터 정규화”에 집중</li>
<li>서비스에 필요한 필드 가공 로직을 중심으로 구현</li>
</ul>
</li>
</ol>
<p><strong>동작 과정</strong></p>
<p>피드 다운로더는 다음과 같은 흐름으로 동작합니다.</p>
<ol>
<li>rss-parser를 이용해 XML 문자열을 Feed 객체로 변환합니다.</li>
<li>Feed 객체의 <code>items</code> 배열에서 개별 글 정보를 순회합니다.</li>
<li>각 RSS Item을 CreateStoryRequest DTO 형식으로 변환합니다.</li>
<li>유효한 DTO 목록을 반환합니다.</li>
</ol>
<pre><code class="language-tsx">async parse(xmlContent: string, feedId: string) {
  const feed = await this.parser.parseString(xmlContent);

  return feed.items
    .map(item =&gt; this.convertToStory(item, feedId))
    .filter(story =&gt; story !== null);
}</code></pre>
<p>전체 흐름을 파악했으니, <code>convertToStory</code> 메소드를 자세히 살펴보겠습니다.</p>
<h3 id="dto-변환-로직">DTO 변환 로직</h3>
<p><code>convertToStory</code> 메소드는 RSS 파서가 읽어 온 원본 Item을 CreateStoryRequest DTO로 변환하는 단계입니다.</p>
<pre><code class="language-tsx">private convertToStory(
  item: RssItem,
  feedId: string
): CreateStoryRequest | null {

  // 1. 필수 필드 검증
  if (!item.guid || !item.title) return null;

  // 2. 본문 확보
  const contents = item.content ?? &#39;&#39;;
  if (!contents) return null;

  // 3. 데이터 정제 및 표준화
  const summary = this.extractSummary(contents);
  const publishedAt = item.pubDate
    ? new Date(item.pubDate).toISOString()
    : new Date().toISOString();

  // 4. 내부 DTO로 변환
  return {
    feedId,
    guid: item.guid,
    title: this.decodeHtmlEntities(item.title),
    summary,
    contents,
    thumbnailUrl: this.extractImageUrl(contents),
    originalUrl: item.link,
    publishedAt,
  };
}</code></pre>
<p>변환 과정을 단계별로 살펴보겠습니다.</p>
<ol>
<li><strong>필수 필드 검증</strong>: <ul>
<li><code>guid</code>, <code>title</code>, <code>contents</code>는 CreateStoryRequest를 구성하기 위한 최소 조건입니다.</li>
<li>세 값 중 하나라도 누락된 데이터는 저장 가치가 없다고 판단해 변환 대상에서 제외합니다.</li>
</ul>
</li>
<li><strong>요약 추출</strong>:<ul>
<li>원문 HTML(<code>contents</code>)에서 불필요한 태그를 제거한 뒤, 본문 초반의 의미 있는 텍스트를 요약으로 생성합니다.</li>
<li>이 요약은 서비스에서 글 목록 화면에서 사용됩니다.</li>
</ul>
</li>
<li><strong>발행일 표준화(UTC)</strong>:<ul>
<li>RSS 피드의 <code>pubDate</code>는 블로그마다 형식이 제각각이어서 그대로 사용하기 어렵습니다.</li>
<li>파싱 후 ISO 형식의 UTC 시간으로 변환해 저장함으로써 일관성을 확보했습니다.</li>
<li>발행일이 없는 경우에는 수집 시점을 기준으로 대체합니다.</li>
</ul>
</li>
<li><strong>CreateStoryRequest DTO로 변환</strong>:<ul>
<li>검증과 가공이 끝난 데이터는 CreateStoryRequest DTO 형태로 변환됩니다.</li>
<li>이 DTO는 이후 Story 엔티티로 변환되어 DB에 저장됩니다.</li>
</ul>
</li>
</ol>
<h2 id="rss-크롤러에서-데이터베이스-접근은-어떻게-할까">RSS 크롤러에서 데이터베이스 접근은 어떻게 할까?</h2>
<p>이제 남은 것은 변환된 <code>CreateStoryRequest DTO</code>를 <code>Story</code> 엔티티로 바꾸고 데이터베이스에 저장하는 과정이었습니다.</p>
<p>여기서 한 가지 고민이 생겼습니다.</p>
<p><strong>“RSS 크롤러가 데이터베이스에 어떻게 접근하는 게 맞을까?”</strong></p>
<p>boostus에서는 원래 API 서버에서만 데이터베이스 접근을 담당하고 있었고,</p>
<p>크롤러는 단순히 RSS를 수집하는 별도 모듈로 분리되어 있었습니다.</p>
<p>처음에는 크롤러 안에서 바로 <code>Story</code> 엔티티를 만들어 DB에 넣는 방식도 생각했습니다.</p>
<p>하지만 그렇게 하면 DB에 접근하는 코드가 두 곳으로 늘어나면서 구조가 복잡해질 것 같았습니다.</p>
<ul>
<li>중복 글 체크 로직이 두 군데에 생길 수 있고</li>
<li>저장 규칙이 달라질 위험도 있고</li>
<li>나중에 구조를 바꿀 때 수정 범위가 커질 것 같았습니다.</li>
</ul>
<p>그래서 저는 <strong>크롤러는 저장까지 책임지지 않는 게 맞다</strong>고 판단했습니다.</p>
<h3 id="데이터-접근-방식은">데이터 접근 방식은?</h3>
<p><img src="https://velog.velcdn.com/images/dongho18/post/4249bbec-5bdd-4f14-b079-be72bb5238fb/image.png" alt=""></p>
<p>크롤러는 DB를 직접 건드리지 않고, 이미 존재하는 API 서버의 기능을 그대로 사용하기 위해</p>
<p>BE REST API를 통한 통신 방식을 선택했습니다.</p>
<ul>
<li><code>GET /api/feeds</code> : 수집 대상 피드 조회</li>
<li><code>POST /api/stories</code> : 수집된 스토리 저장</li>
</ul>
<p>구체적으로는 크롤러 쪽에 피드 API 클라이언트를 추가해서,</p>
<ol>
<li>크롤러는 RSS를 파싱해 <code>CreateStoryRequest DTO</code> 까지만 만든다.</li>
<li>그 DTO를 POST /api/stories로 API 서버로 보낸다.</li>
<li>실제 검증과 DB 저장은 기존 StoryService가 담당한다.</li>
</ol>
<p>이렇게 하니 DB 스키마도 한 곳에서 관리할 수 있어서 구조가 깔끔해졌습니다.</p>
<h2 id="중복-데이터는-어떻게-방지할까">중복 데이터는 어떻게 방지할까?</h2>
<p>RSS 크롤러를 만들면서 생겼던 또 다른 고민은 중복 수집을 어떻게 막을지 였습니다.</p>
<p>RSS는 최신 글을 계속해서 다시 내려주는 구조라, 같은 글이 여러 번 데이터베이스에 저장될 가능성이 높았습니다.</p>
<p>그래서 “어떤 기준으로 같은 글이라고 판단할 것인가?”를 정의해야 했습니다.</p>
<h3 id="1-guid-기준-upsert">1. guid 기준 upsert</h3>
<p>Tistory와 Velog의 RSS에는 글을 식별할 수 있는 <code>guid</code> 필드가 있었습니다.</p>
<p>이 값은 바로 블로그 글의 원문 URL이었습니다.</p>
<table>
<thead>
<tr>
<th>플랫폼명</th>
<th>guid 예제</th>
</tr>
</thead>
<tbody><tr>
<td>Tistory</td>
<td><a href="https://dongho-dev.tistory.com/58">https://dongho-dev.tistory.com/58</a></td>
</tr>
<tr>
<td>Velog</td>
<td><a href="https://velog.io/@dongho18/%EA%B8%80-%EC%A0%9C%EB%AA%A9">https://velog.io/@dongho18/글-제목</a></td>
</tr>
</tbody></table>
<p>그래서 동일한 guid가 있으면 기존 글을 업데이트하고, 존재하지 않으면 새로운 글을 생성하는 upsert 전략을 적용했습니다.</p>
<p>하지만 설계를 하면서 한 가지 의문이 들었습니다.</p>
<p><strong>“정말 <code>guid</code> 값만으로 모든 경우를 구분할 수 있을까?”</strong></p>
<p><code>guid</code> 필드는 같은 플랫폼 내에서는 유일하게 식별이 될 수 있지만, 다른 블로그 플랫폼으로 확장이 될 경우 전역적으로 유일하다고 보장할 수 없었습니다.</p>
<h3 id="2-feedid--guid-기준-upsert">2. feedId + guid 기준 upsert</h3>
<p><img src="https://velog.velcdn.com/images/dongho18/post/9a4c4491-299a-4103-ae5a-d22585865b78/image.png" alt=""></p>
<p>설명을 위해 ERD를 간소화했습니다.</p>
<p>그래서 <code>guid</code> 하나만으로는 안전하지 않다고 판단해서, <code>feedId</code>를 추가해서 upsert를 하는 전략으로 변경했습니다.</p>
<p>이렇게 하면 동일한 피드 안에서는 guid로 글을 구분하고, 서로 다른 피드의 동일 guid는 다른 글로 취급할 수 있습니다.</p>
<p>그리고 DB에는 <code>feedId + guid</code>에 UNIQUE 제약 조건을 걸어 데이터 무결성을 보장했습니다.</p>
<h2 id="전체-흐름-정리">전체 흐름 정리</h2>
<p><img src="https://velog.velcdn.com/images/dongho18/post/a731124c-0d0a-4dca-a538-975e5bb404f9/image.png" alt=""></p>
<p>내용이 길었는데 전체 흐름을 정리해보겠습니다.</p>
<ol>
<li><strong>BE API로부터 수집 대상 피드 목록 조회</strong><ul>
<li>크롤러는 DB를 직접 보지 않고, <code>GET /api/feeds</code> API를 통해 어떤 블로그를 수집해야 하는지 받아옵니다.</li>
</ul>
</li>
<li><strong>각 피드 URL에서 RSS 다운로드</strong><ul>
<li>받아온 URL에 HTTP GET 요청을 보내 RSS XML을 가져옵니다.</li>
</ul>
</li>
<li><strong>RSS 파싱 및 도메인 변환</strong><ul>
<li>RSS 2.0 형식을 기준으로 XML을 파싱하고,</li>
<li>각 <code>&lt;item&gt;</code>을 <code>CreateStoryRequest DTO</code>로 변환해 Story 배열을 생성합니다.</li>
</ul>
</li>
<li><strong>BE API로 저장 요청</strong><ul>
<li>변환된 Story 배열을 순회하며 <code>POST /api/stories</code>로 전송합니다.</li>
<li>이때 <code>feedId + guid</code> 기준 upsert 전략으로 중복 저장을 방지합니다.</li>
</ul>
</li>
</ol>
<h2 id="남은-도전-과제">남은 도전 과제</h2>
<p>기본 기능은 동작하지만, 실제 운영을 생각하면 보완할 점이 많이 남아 있습니다.</p>
<h3 id="1-무례하지-않은-크롤러-만들기"><strong>1. 무례하지 않은 크롤러 만들기</strong></h3>
<p>지금은 하나의 블로그에 연속된 요청을 보내는 구조라, 블로그 서버에 부담을 줄 가능성이 있습니다.</p>
<p>캠퍼들의 블로그 게시 주기에 따라서 우선순위를 다르게 설정해서, </p>
<p>자주 글을 올리는 블로그는 조금 더 자주 확인하고 활동이 적은 블로그는 수집 주기를 길게 가져가는 방식도 고려할 수 있습니다.</p>
<h3 id="2-불필요한-업데이트-줄이기">2. 불필요한 업데이트 줄이기</h3>
<p>현재 upsert 방식은 <code>feedId + guid</code>만 같으면 실제 변경이 없어도 업데이트가 발생해, DB 자원을 불필요하게 사용한다는 문제가 있습니다.</p>
<p>이를 개선하기 위해, 변경 감지 처리 방식을 고려해볼 수 있을 것 같습니다.</p>
<p>글의 제목이나 본문 내용을 해시 값으로 만들어 저장하고, 이전 해시와 다를 떄만 업데이트를 진행하는 식으로 개선해볼 수 있을 것 같습니다.</p>
<h3 id="3-재시도-전략">3. 재시도 전략</h3>
<p>네트워크는 언제든 실패할 수 있기 때문에 안정적인 수집 구조가 필요합니다.</p>
<p>지금은 하나의 크롤러에서 파이프라인이 순차적으로 동작하다 보니,</p>
<p>중간 단계에서 오류가 발생한 실패한 피드는 다시 처음 단계부터 수집을 해야한다는 비효율이 존재합니다.</p>
<p>이를 개선하기 위해, exponential backoff와 재시도 횟수 제한을 둬서 일시적 네트워크 장애에 대응하고,</p>
<p>각 파이프라인 과정 사이에 메시지큐를 둬서 단계별로 분리된 구조를 고려할 수 있습니다.</p>
<h3 id="4-로깅-및-모니터링">4. 로깅 및 모니터링</h3>
<p>문제가 생겼을 때 원인을 빠르게 찾기 위한 장치도 부족합니다.</p>
<p>현재는 각 파이프라인의 성공/실패 과정을 <code>console.log</code> 혹은 <code>console.error</code> 로만 남기고 있어서,</p>
<p>서버가 재시작 되면 이전 실행 기록이 모두 사라진다는 문제가 있습니다.</p>
<p>이를 개선하기 위해, 피드 단위로 수집 이력을 저장하고 문제가 발생했을 때 어느 단계에서 어떤 이유로 실패했는지 바로 확인할 수 있는 구조를 고려하고 있습니다.</p>
<h3 id="5-플랫폼-확장">5. 플랫폼 확장</h3>
<p>마지막으로 Tistory와 Velog 외에 Github Pages나 자체 제작 블로그까지 지원 범위를 넓히는 것도 고려하고 있습니다.</p>
<p>하지만 Github Pages나 자체 제작 블로그들은 개발자가 RSS 표준을 따르지 않아 RSS 필드 구성이 제각각일 가능성이 높습니다.</p>
<p>따라서 RSS 피드 URL을 등록하기 전에, 우리 서비스가 요구하는 공통 스펙을 만족하는지 검증하는 단계가 필요하다고 판단했습니다.</p>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://www.rssboard.org/rss-specification">https://www.rssboard.org/rss-specification</a></li>
<li><a href="https://github.com/boostcampwm2025/web01-BoostUs/tree/dev/Crawler">https://github.com/boostcampwm2025/web01-BoostUs/tree/dev/Crawler</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[boostus 무한 스크롤 개발기]]></title>
            <link>https://velog.io/@dongho18/boostus-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B0%9C%EB%B0%9C%EA%B8%B0</link>
            <guid>https://velog.io/@dongho18/boostus-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B0%9C%EB%B0%9C%EA%B8%B0</guid>
            <pubDate>Tue, 03 Feb 2026 12:29:37 GMT</pubDate>
            <description><![CDATA[<p>부스트캠프 커뮤니티 서비스 boostus에는 ‘캠퍼들의 이야기’라는 공간이 있습니다.</p>
<p>캠퍼들이 각자 운영하는 블로그 글을 모아 한 곳에 볼 수 있게 만든 피드인데
처음엔 그냥 글 목록 하나 뿌려주면 끝날 줄 알았습니다.</p>
<p>그런데 글이 쌓이고, 정렬 조건이 붙고, 무한 스크롤까지 지원하면서
단순한 조회 API가 점점 복잡한 문제로 변하기 시작했습니다. 🤯</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/4702f51e-3097-43df-9f3f-98bddeed1a69/image.gif" alt=""></p>
<hr>
<h2 id="그냥-다-가져오면-안-되나요">그냥 다 가져오면 안 되나요?</h2>
<p>커뮤니티 서비스에 올라오는 글들을 불러오는 가장 쉬운 방법은 뭘까요?</p>
<pre><code class="language-sql">SELECT * FROM posts</code></pre>
<p>맞습니다. 그냥 전부 가져오면 됩니다.</p>
<p>개발 초반에는 이 방식이 가장 단순하고 빠릅니다. 데이터가 많지 않을 때는 문제도 거의 없습니다.</p>
<p>하지만 캠퍼들의 글이 늘어나면서 문제가 생기기 시작합니다.</p>
<ul>
<li>게시물이 늘어날수록 한 번의 요청에 불러오는 데이터 양 증가</li>
<li>사용자가 보지도 않을 데이터까지 전송되는 네트워크 낭비</li>
<li>프론트 목록 페이지 로딩 속도 저하</li>
<li>서버 메모리 부하 증가</li>
</ul>
<p>그래서 데이터를 ‘나눠서’ 가져오는 페이지네이션이 필요했습니다.</p>
<hr>
<h2 id="어떤-방식으로-나눠서-가져올까"><strong>어떤 방식으로 나눠서 가져올까?</strong></h2>
<p>데이터를 나눠서 가져온다는 어떤 의미일까요?</p>
<p>전체 게시물이 10,000개라고 해서, 사용자에게 한 번에 10,000개를 모두 보여줄 필요는 없습니다.</p>
<p>대부분의 사용자는 첫 화면에서 10~20개의 글만 보고, 필요할 때 조금 더 내려서 다음 글을 확인합니다.</p>
<ul>
<li><strong>“지금 사용자에게 필요한 만큼만 가져오자”</strong></li>
</ul>
<p>이를 구현하기 위해 필요한 개념이 바로 <strong>페이지네이션(Pagination)</strong> 입니다.</p>
<p>페이지네이션은 크게 두 가지 방식으로 나눌 수 있습니다.</p>
<ol>
<li>몇 번째 페이지인지 기준으로 가져오는 방식</li>
<li>마지막으로 본 데이터 기준으로 가져오는 방식</li>
</ol>
<p>저희 팀은 이 두 방식을 두고 어떤 방법이 더 적합할지 고민했습니다.</p>
<hr>
<h2 id="offset-기반-페이지네이션">Offset 기반 페이지네이션</h2>
<p>페이지네이션을 구현하는 가장 간단한 방법은 Offset 방식입니다.</p>
<p>Offset 방식의 핵심은 앞의 N개를 건너뛰고 그 다음부터 읽는 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/cb9dd610-6cc5-4c1e-8021-1969563cb321/image.png" alt=""></p>
<p>이를 MySQL 기준으로 쿼리문을 작성해보면 다음과 같습니다.</p>
<pre><code class="language-sql">SELECT *
FROM posts
ORDER BY published_at DESC
LIMIT 4 
OFFSET 3;</code></pre>
<ul>
<li>위 쿼리문은 3개의 행을 건너뛰고 그 다음부터 4개의 게시글을 가져온다 라는 의미입니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dongho18/post/1661c583-88dc-4dc1-8ec5-d08d7ba19ca2/image.png" alt=""></p>
<p>이를 조금 응용해서 하나의 페이지에 20개의 글을 보여주고, 6번째 페이지의 글을 보여주고 싶다면 아래와 같이 쿼리문을 작성할 수 있습니다.</p>
<pre><code class="language-sql">SELECT *
FROM posts
ORDER BY published_at DESC
LIMIT 20
OFFSET 100;</code></pre>
<ul>
<li>OFFSET = (페이지 번호 - 1) x 페이지 크기</li>
</ul>
<p>Offset 방식은 단순하고 직관적이지만, 서비스가 커질수록 몇 가지 문제점들을 만나게 됩니다.</p>
<h3 id="문제점-1-페이지가-뒤로-갈수록-느려진다"><strong>문제점 1) 페이지가 뒤로 갈수록 느려진다</strong></h3>
<pre><code class="language-sql">SELECT *
FROM posts
ORDER BY published_at DESC
LIMIT 20 
OFFSET 100000;</code></pre>
<p>위 쿼리문은 겉보기에는 100,000개의 데이터를 건너뛰고 20개만 가져오는 효율적인 작업처럼 보입니다.</p>
<p>하지만 데이터베이스는 내부적으로 다음과 같이 동작합니다.</p>
<ol>
<li>앞의 100,000개 행을 하나하나 확인한다</li>
<li>100,000번째 행에 도달했을 때 20개의 데이터를 읽는다</li>
<li>그 20개만 결과로 반환한다</li>
</ol>
<p>즉, OFFSET의 값이 커질수록 DB가 실제로 읽어야 하는 데이터 양도 함께 증가합니다.</p>
<p>게시물이 수십만 건, 수백만 건으로 늘어나면 응답 속도가 급격히 느려질 수 밖에 없는 구조입니다.</p>
<p>그렇다면 데이터베이스는 왜 이런 방식으로 동작할 수 밖에 없을까요?</p>
<p>이유는 OFFSET이 “몇 번째 행인지”를 기준으로 동작하기 때문입니다.</p>
<p>데이터베이스 입장에서는 100,000번째 행이 어디인지 바로 알 수 있는 방법이 없습니다.</p>
<p>책으로 비유하면 페이지 번호가 없는 책에서 특정 페이지를 펴달라고 요청하는 것과 비슷합니다.</p>
<p>어떤 사람에게 두꺼운 책을 주고 정확히 “987번째 페이지를 펼쳐서 보여주세요”라고 하면,</p>
<p>한 장 한 장 넘기며 세어볼 수밖에 없겠죠. 🥲</p>
<h3 id="문제점-2-데이터-중복-또는-누락이-발생할-수-있다"><strong>문제점 2) 데이터 중복 또는 누락이 발생할 수 있다</strong></h3>
<p><img src="https://velog.velcdn.com/images/dongho18/post/875f9c37-e4bf-4abd-a7fb-43d31530116e/image.png" alt=""></p>
<p>Offset 방식의 또 다른 치명적인 단점은 페이지를 이동하는 사이에 데이터가 변하면 결과가 꼬일 수 있다는 점입니다.</p>
<p>예를 들어 이런 상황이 있을 수 있습니다.</p>
<ol>
<li>사용자가 1페이지를 조회한다. → (<code>1, 2, 3, 4</code>)</li>
<li>그 사이에 <code>0</code>번 게시글이 새로 추가된다.</li>
<li>사용자가 2페이지를 조회한다. → (<code>4, 5, 6, 7</code>)</li>
</ol>
<p>우리가 원하는 2페이지의 결과값은 (<code>5, 6, 7, 8</code>) 인데, 실제로는 (<code>4, 5, 6, 7</code>) 이 반환됩니다.</p>
<p>즉, 4번 게시글은 1페이지에서도 보고 2페이지에서도 또 보게 되는 중복 현상이 발생합니다.</p>
<p>그리고 8번 게시글은 원래 2페이지에 있어야 하는데 사라져서 보지 못하는 누락 현상이 발생합니다.</p>
<h2 id="cursor-기반-페이지네이션">Cursor 기반 페이지네이션</h2>
<p><img src="https://velog.velcdn.com/images/dongho18/post/54729341-7d76-4d59-9d31-5f1ba5315ea1/image.png" alt=""></p>
<p>이 문제를 해결하기 위해서는 “몇 번째 행”이 아니라 “마지막으로 본 행”을 기준으로 그 다음 데이터를 가져오는 방식이 필요합니다.</p>
<p>이 개념이 바로 Cursor 기반 페이지네이션 입니다.</p>
<p>그렇다면 어떻게 “마지막으로 본 행”을 정확하게 특정할 수 있을까요?</p>
<p>바로 데이터베이스의 기본키(PK) 또는 유니크(Unique)한 값을 사용하는 것입니다.</p>
<p>예를 들어 id를 기준으로 최신순을 정렬하고 싶다면, 마지막으로 본 게시글의 id 값을 기억해두었다가 이렇게 조회할 수 있습니다.</p>
<pre><code class="language-sql">SELECT *
FROM posts 
WHERE id &lt; 20
ORDER BY id DESC
LIMIT 10;</code></pre>
<h3 id="만약-그-사이에-새로운-글이-추가된다면">만약 그 사이에 새로운 글이 추가된다면?</h3>
<p>Offset 방식에서는 전체 행 번호가 밀려 중복이나 누락이 발생했지만,</p>
<p>Cursor 방식에서는 기준이 “위치”가 아니라 “값”이기 때문에 새로운 글이 올라와도 영향을 전혀 받지 않습니다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/ff1e3586-41ed-49d3-94cd-3804aaf99630/image.png" alt=""></p>
<p>새 글이 추가되어도 <code>id &lt; 20</code> 조건은 그대로이고, 중간 글이 삭제되어도 다음 조회 범위는 변하지 않습니다.</p>
<h3 id="페이지가-뒤로-갈수록-느려질까"><strong>페이지가 뒤로 갈수록 느려질까?</strong></h3>
<p>그렇다면 Cursor 방식에서는 뒤쪽 페이지가 갈수록 성능이 느려지는 문제가 없을까요?</p>
<pre><code class="language-sql">SELECT *
FROM posts 
WHERE id &lt; 1000
ORDER BY id DESC
LIMIT 10;</code></pre>
<p><img src="https://velog.velcdn.com/images/dongho18/post/858b269d-8260-40b8-9394-c07052c16231/image.png" alt=""></p>
<p>id가 PK라면 대부분의 RDBMS에서는 자동으로 인덱스가 생성되어 있습니다.</p>
<p>이 경우 데이터베이스는 테이블 전체를 훑는 것이 아니라 B-Tree 인덱스에서 조건에 맞는 위치를 바로 탐색합니다.</p>
<p>동작 과정을 풀어서 설명해보겠습니다.</p>
<ol>
<li>인덱스에서 <code>id = 1000</code> 이전 위치를 찾는다.</li>
<li>그 지점부터 정렬된 순서대로 10개만 읽는다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dongho18/post/a2774fc1-1470-4d8a-b7ae-cd8dbfab5f75/image.png" alt=""></p>
<p>어떻게 id가 1000인 위치를 바로 찾을 수 있을까요?</p>
<p>인덱스는 단순히 값들을 쭉 나열해 둔 배열이 아니라, 책의 목차처럼 계층적인 구조로 정렬되어 있습니다.</p>
<p>그래서 전체 데이터를 처음부터 끝까지 보는 대신, 이진 탐색과 비슷한 방식으로 빠르게 위치를 찾아갈 수 있습니다.</p>
<p>그래서 데이터가 100만 개이든, 1000만 개이든, 1억 개이든 id가 1000인 값을 O(log N)의 시간만에 찾을 수 있습니다.</p>
<h2 id="boostus-데이터-구조">boostus 데이터 구조</h2>
<p>이제 Cursor 기반 페이지네이션에 대해 어느 정도 알아보았으니,</p>
<p>실제로 boostus에 페이지네이션을 구현하면서 만났던 문제들을 정리해보겠습니다.</p>
<p>처음에는 “커서 = id 하나면 끝 아닌가?” 라고 생각했습니다.</p>
<p>근데 요구사항을 보니 전혀 아니었습니다. 🫠</p>
<p>게시글 테이블은 대략 다음과 같은 구조였습니다.</p>
<p>PK id는 auto increment를 사용했고, 등록일(<code>published_at</code>), 조회수(<code>view_count</code>), 좋아요(<code>like_count</code>)가 포함되어 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/fb2d5a49-8ee7-456f-9071-062661d4a6a6/image.png" alt=""></p>
<p>그리고 지원해야 하는 정렬 조건은 크게 세 가지 였습니다.</p>
<ul>
<li>최신순</li>
<li>조회수순</li>
<li>좋어요순</li>
</ul>
<h2 id="최신순-정렬">최신순 정렬</h2>
<p>우선 최신순으로 정렬을 하기 위해 아래와 같이 쿼리문을 작성했습니다.</p>
<pre><code class="language-sql">WHERE id &lt; ?
ORDER BY id DESC
LIMIT 10;</code></pre>
<p>하지만 문제가 생겼습니다. </p>
<p><code>id</code>가 최신순을 보장하지 않는 구조였기 때문입니다.</p>
<p>boostus의 ‘캠퍼들의 이야기’는 다른 도메인과 다르게,</p>
<p>캠퍼들의 블로그(외부)에서 데이터를 수집해오는 구조였기에 과거 글이 뒤늦게 추가되는 경우도 많았습니다.</p>
<p>즉, 글이 생성되는 시점(<code>id</code> 생성 시점)과 실제로 사용자가 글을 작성한 날짜(<code>published_at</code>)이 항상 일치하지 않았죠.</p>
<p>그래서 최신순의 기준을 <code>id</code>가 아닌 <code>published_at</code> 으로 잡기로 하고 쿼리문을 수정했습니다.</p>
<pre><code class="language-sql">WHERE published_at &lt; ?
ORDER BY published_at DESC
LIMIT 10;</code></pre>
<h3 id="새로운-문제-published_at-가-같을-때">새로운 문제: <code>published_at</code> 가 같을 때</h3>
<p><code>published_at</code> 기준으로 바꾸고 나니 또 다른 문제가 보였습니다.</p>
<p>같은 시각에 발행된 글들이 존재할 수 있다는 점이었습니다.</p>
<p>예를 들어 아래와 같은 데이터가 있다고 가정하고 사용자가 1페이지에서 <code>id=30</code>을 마지막으로 봤다고 한다면</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/240a2a4e-8c33-4372-bf13-376465e2996e/image.png" alt=""></p>
<p>우리가 원하는 다음 데이터는 29이지만, 같은 시각의 행들은 전부 건너뛰어 버리고 28번 글부터 가져오게 됩니다.</p>
<pre><code class="language-sql">WHERE published_at &lt; &#39;2024-03-02 14:00:00&#39;
ORDER BY published_at DESC</code></pre>
<h3 id="해결-보조-키로-id를-함께-사용">해결: 보조 키로 id를 함께 사용</h3>
<p>그래서 정렬 기준에 <code>id</code>를 보조 키로 추가했습니다.</p>
<pre><code class="language-sql">WHERE 
  published_at &lt; ?
  OR (published_at = ? AND id &lt; ?)
ORDER BY published_at DESC, id DESC
LIMIT 10;</code></pre>
<p>이렇게 하면</p>
<ul>
<li><code>published_at</code>이 더 이전인 글</li>
<li>같은 <code>published_at</code>이면 id가 더 작은 글</li>
</ul>
<p>순서로 정확하게 다음 페이지 기준을 잡을 수 있습니다.</p>
<h2 id="조회수좋아요순-정렬">조회수/좋아요순 정렬</h2>
<p>최신순은 그래도 <code>published_at + id</code> 조합으로 정리가 됐는데,</p>
<p>조회수순과 좋아요순은 한 단계 더 까다로웠습니다.</p>
<p>조회수와 좋아요순은 정렬 기준을 아래와 같이 잡았습니다.</p>
<ol>
<li><code>view_count</code> (혹은 <code>like_count</code>)</li>
<li><code>published_at</code></li>
<li><code>id</code></li>
</ol>
<p>그리고 커서 조건을 이런 구조로 만들었습니다. (조회수 예시)</p>
<pre><code class="language-sql">WHERE 
  view_count &lt; :cursorViewCount
  OR (
    view_count = :cursorViewCount 
    AND published_at &lt; :cursorPublishedAt
  )
  OR (
    view_count = :cursorViewCount 
    AND published_at = :cursorPublishedAt
    AND id &lt; :cursorId
  )
ORDER BY view_count DESC, published_at DESC, id DESC
LIMIT 10;</code></pre>
<p>처음엔 조건이 너무 복잡해져서 과한 거 아닌가 싶었는데</p>
<p>조회수가 같고, 같은 시간에 발행된 글이 여러개여도 항상 다음 글 하나를 특정할 수 있었습니다.</p>
<h2 id="새로운-문제-데이터-full-scan">새로운 문제: 데이터 Full Scan</h2>
<p>커서 조건까지 정리하고 나니 이제 다 끝난 줄 알았는데요..</p>
<p>로컬에서 테스트할 때도 잘 동작하고, 페이지 넘길 때 중복과 누락도 발생하지 않았습니다.</p>
<p>하지만 갑자기 문득 이런 생각이 들었습니다.</p>
<p>“이렇게 복잡한 WHERE 조건이면 DB가 인덱스를 안 타고 다 뒤지는 거 아닐까?”</p>
<p>그래서 바로 실행 계획을 찍어보았습니다.</p>
<pre><code class="language-sql">EXPLAIN
SELECT *
FROM stories
WHERE
  view_count &lt; 10
  OR (
    view_count = 10
    AND published_at &lt; &#39;2026-02-03&#39;
  )
  OR (
    view_count = 10
    AND published_at = &#39;2026-02-03&#39;
    AND id &lt; 200
  )
ORDER BY view_count DESC, published_at DESC, id DESC
LIMIT 10;</code></pre>
<p><strong>[실행 결과]</strong></p>
<table>
<thead>
<tr>
<th>id</th>
<th>type</th>
<th>possible_keys</th>
<th>key</th>
<th>rows</th>
<th>filtered</th>
<th>Extra</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>ALL</td>
<td>PRIMARY</td>
<td>NULL</td>
<td>121</td>
<td>35.57</td>
<td>Using where; Using filesort</td>
</tr>
</tbody></table>
<ul>
<li><code>possible_keys: PRIMARY</code><ul>
<li>옵티마이저는 PRIMARY 인덱스를 사용할 수 있다는 걸 알고 있습니다.</li>
<li>PRIMARY는 이 쿼리에서 사용할 수 있는 인덱스 후보입니다.</li>
</ul>
</li>
<li><code>key: NULL</code><ul>
<li>그럼에도 불구하고 옵티마이저는 인덱스를 사용하지 않기로 결정했습니다.</li>
</ul>
</li>
<li><code>type: ALL</code><ul>
<li>결국 옵티마이저는 풀 테이블 스캔을 선택했습니다.</li>
</ul>
</li>
<li><code>filtered</code><ul>
<li>옵티마이저는 이 쿼리가 전체 데이터(121건)의 약 36%, 즉 44건 정도를 반환할 것이라고 예측했습니다.</li>
</ul>
</li>
<li><code>Extra: Using where; Using filesort</code><ul>
<li>WHERE로 필터링하고 정렬은 메모리로 처리한다는 뜻입니다.</li>
<li>즉, 데이터가 많아지면 정렬 단계에서 CPU와 메모리를 꽤 잡아먹을 가능성이 높습니다.</li>
</ul>
</li>
</ul>
<h3 id="문제-해결-복합-커서-인덱스-추가">문제 해결: 복합 커서 인덱스 추가</h3>
<p>그래서 인덱스를 추가해서 문제를 해결했습니다.</p>
<p>지금 조회수순 정렬 기준이 <code>viewCount</code> → <code>publishedAt</code> → <code>id</code> 순서였는데,</p>
<p>테이블에는 이 순서대로 만들어진 인덱스가 없었습니다.</p>
<p>그래서 아래처럼 복합 인덱스를 추가했습니다.</p>
<pre><code class="language-sql">CREATE INDEX stories_view_count_published_at_id_idx
ON stories (view_count DESC, published_at DESC, id DESC);</code></pre>
<p>그리고 똑같은 쿼리문에 대해서 실행 계획을 돌려보았습니다.</p>
<p><strong>[실행 결과]</strong></p>
<table>
<thead>
<tr>
<th>id</th>
<th>type</th>
<th>possible_keys</th>
<th>key</th>
<th>rows</th>
<th>filtered</th>
<th>Extra</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>range</td>
<td>PRIMARY, stories_view_count_published_at_id_idx</td>
<td>stories_view_count_published_at_id_idx</td>
<td>137</td>
<td>100</td>
<td>Using index condition; Backward index scan</td>
</tr>
</tbody></table>
<ul>
<li><code>type: range</code><ul>
<li>옵티마이저는 <code>stories_view_count_published_at_id_idx</code> 인덱스를 사용해 특정 범위만 스캔했습니다.</li>
</ul>
</li>
<li><code>filtered: 100</code><ul>
<li>옵티마이저는 인덱스로 걸러낸 행들이 사실상 WHERE 조건을 거의 그대로 만족할 거라고 판단했습니다.</li>
</ul>
</li>
<li><code>Extra: Using index condition; Backward index scan</code><ul>
<li>조건 비교가 테이블이 아니라 인덱스 레벨에서 처리되었습니다.</li>
<li>정렬도 <code>view_count DESC, publishedAt DESC, id DESC</code> 순서 그대로 인덱스를 뒤에서부터 읽는 방식이라 별도의 <code>filesort</code>가 발생하지 않았습니다.</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dongho18/post/c44a5baa-0339-47be-aa79-075fd3904fa6/image.png" alt=""></p>
<p>이해를 돕기 위해 인덱스를 그림으로 시각화 해보았습니다.</p>
<p>만약 마지막으로 본 데이터가 <code>id=63</code> 이라면 DB는 다음과 같이 동작합니다.</p>
<ol>
<li>인덱스에서 <code>view_count = 5, published_at = ‘2024-03-03’, id = 63</code> ****위치를 먼저 찾습니다.</li>
<li>그 지점부터 정렬 기준(<code>view_count DESC → published_at DESC → id DESC</code>) 방향으로</li>
<li>딱 10개만 순서대로 읽습니다. (<code>limit 10</code>)</li>
</ol>
<p>여기서 중요한 점은 index는 이미 우리가 원하는 정렬 순서대로 정돈되어 있다는 것입니다.</p>
<p>그래서 DB는 데이터를 전부 꺼내서 다시 정렬할 필요가 없기 때문에 데이터가 아무리 많아져도 일정한 성능을 유지할 수 있게 됩니다.</p>
<h2 id="마무리하며">마무리하며</h2>
<p>boostus 무한 스크롤을 구현하면서 DB 복습을 제대로한 것 같네요.</p>
<p>커서 기반 페이지네이션은 개념만 보면 단순한데,
실제 서비스 요구사항에 맞추려니 생각할 게 정말 많았네요.</p>
<p>아직 완벽한 구조라고는 못 하겠지만,
적어도 왜 이렇게 설계했는지는 설명할 수 있는 상태가 됐다는 점에서 뿌듯합니다.</p>
<p>그러면 이만 글을 마치겠습니다!
도움이 되셨다면 댓글, 좋아요 부탁드릴게요~ 👍🏻</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블슈팅] Supabase Max client connections reached]]></title>
            <link>https://velog.io/@dongho18/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-Supabase-Max-client-connections-reached</link>
            <guid>https://velog.io/@dongho18/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-Supabase-Max-client-connections-reached</guid>
            <pubDate>Fri, 24 May 2024 15:19:20 GMT</pubDate>
            <description><![CDATA[<p>오늘은 일주일 간 나를 골머리 아프게 했던 Max client connections reached 에러 해결 방법에 대해 기록하고자 한다.
이 문제를 해결하면서 데이터베이스의 연결 관리와 최적화에 대해 많은 것을 배울 수 있었다.</p>
<h1 id="1-문제-상황">1. 문제 상황</h1>
<ol>
<li>개발자 두명이 동시에 스프링부트 서버로 작업을 하고 있는 상황이다.<ul>
<li>이 서버는 한 개의 PostgreSQL DB를 바라보고 있다.</li>
</ul>
</li>
<li>이 상태에서 새롭게 DB에 조회를 시도하려고 하면 <code>[XX000] FATAL: Max client connections reached</code> 오류가 발생한다.</li>
<li>스프링부트 서버를 종료하면 오류가 사라졌다.</li>
</ol>
<h1 id="2-원인-분석">2. 원인 분석</h1>
<h2 id="supabase란">Supabase란?</h2>
<p>Supabase는 <strong>PostgreSQL</strong>을 기반으로 하며, 실시간 웹 소켓 기능과 REST API를 제공하여 개발자들이 애플리케이션을 구축하고 데이터를 관리할 수 있도록 다양한 기능을 제공하는 오픈 소스 서버리스 클라우드 데이터베이스이다.</p>
<h2 id="supabase-log에서는-무슨-일이">Supabase Log에서는 무슨 일이..?</h2>
<p>Supabase의 가장 큰 장점은 이러한 문제가 생겼을 때 에러 로그를 잘 시각화해서 보여준다.
<img src="https://velog.velcdn.com/images/dongho18/post/c5c15a92-68b8-47f1-8482-38767bbce20a/image.png" alt="">
원인 분석을 위해 <code>내 프로젝트 &gt; Logs &gt; Pooler</code>로 들어가보았다.
사진에는 나오지 않았지만 오류가 발생할 당시에 <code>ClientHandler: Max Client Connections Reached</code> 로그가 1분에 한 번꼴로 발생하고 있었다. (멘붕)
이 로그는 현재 <code>Pooler</code> 단에서 발생하기 때문에 <code>DB Connection Pool</code>에 문제가 있을 것이라고 가설을 세웠다.</p>
<h1 id="3-가설-검증">3. 가설 검증</h1>
<h2 id="db-connection-pool">DB Connection Pool</h2>
<blockquote>
<p><a href="https://youtu.be/zowzVqx3MQ4?si=Gu_iGSOYYv89ZL1B">DBCP (DB connection pool)의 개념부터 설정 방법까지!</a></p>
</blockquote>
<p>혹시라도 DB Connection Pool를 처음 들어보았다면 위의 영상부터 본 뒤에 블로그 글을 이어서 보는걸 추천한다.
이 영상을 보고 난 다음에 문제를 해결하는데 큰 도움이 되었기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/1876edcc-e48c-472f-80e1-4092d9602d52/image.png" alt=""></p>
<p>Supabase는 <a href="https://github.com/supabase/supavisor">Supavisor</a>라고 하는 Postgres 연결 풀러를 사용한다. 이 풀러는 기존의 PgBouncer의 단점을 개선한 것이다.</p>
<p>문제는 이 Supavisor에서 발생했다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/527c0e83-5ccf-4ddb-a68a-fc42fc17b8a7/image.png" alt=""></p>
<p>Supabase 데이터베이스 크기 별 기본 설정값을 보자.</p>
<ul>
<li><code>default_pool_size</code>: Supavisor에서 데이터베이스로의 연결 수(변경 가능)</li>
<li><code>max_connections</code>: Postgres가 허용하도록 구성된 최대 직접 연결 수(변경 가능)</li>
<li><code>default_max_clients</code> : Supavisor에 연결할 수 있는 최대 클라이언트 수(변경 불가능)</li>
</ul>
<p>우리 프로젝트는 Supabase <code>micro</code> size 데이터베이스(무료 플랜)를 사용하고 있었는데, <code>default_pool_size</code>가 15로 작게 설정돼있어 문제가 됐다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/3c6162ac-efec-459e-be00-4ff46f646e2e/image.png" alt=""></p>
<p>상황을 그림으로 정리하자면 다음과 같다.
우리는 스프링 서버를 두 개를 같이 켜면 하나는 되고 나머지 하나에서는 문제가 발생한다.
왜 그럴까?</p>
<h2 id="jdbc-connection-pool">JDBC Connection Pool</h2>
<p>우리는 DB 서버의 Connection Pool 사이즈 기본 값이 15인걸 알았다.
하지만, 스프링 서버에도 Connection Pool이 있다는 사실을 알아야 한다.</p>
<p>Spring Boot 2.0 이후부터는 이 커넥션 풀을 관리하기 위해 <a href="https://github.com/brettwooldridge/HikariCP">HikariCP</a> 라고 하는 가벼운 용량과 빠른 속도를 가지는 우수한 성능의 JDBC Connection Pool 프레임워크를 사용한다.</p>
<p>이 HikariCP의 <code>minimumIdle</code> 과 <code>maximumPoolSize</code> 의 기본 값은 10이다.
즉, 아무 요청도 안 보내고 서버를 켜두기만 해도 10개의 커넥션을 DB랑 미리 연결해두겠다는 말이다.</p>
<p>두 개의 스프링부트 서버에서 각각 10개의 커넥션을 요청하면 총합 20개로, Supavisor의 default pool size인 15를 초과하면서 <code>Max Client Connections Reached</code> 에러를 내뱉는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/2b1db4ad-7e7a-47c1-93f6-97c3c7e13bce/image.png" alt=""></p>
<p>이제 이 문제를 해결하기 위해 두 가지 방법 중 한가지를 택할 수 있다.</p>
<ol>
<li>DB Connection Pool Size(15)를 늘린다.</li>
<li>JDBC Maximum Pool Size(10)을 줄인다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dongho18/post/29aaf1d8-7076-4487-a82b-af0f67a31d02/image.png" alt=""></p>
<p>내가 생각한 가장 이상적인 방법은 운영 환경의 서버에서는 pool size를 디폴트 값인 10으로 두고, 나머지 개발 환경의 서버에서는 값을 1~2로 줄여버리는 방법을 생각했다.</p>
<p>하지만, 일단 임시 방편으로 pool size를 30으로 늘리는 것으로 조치를 취해보겠다.
Supabase 웹페이지에서 <code>Project Settings &gt; Database &gt; Connection pooling configuration</code> 를 살펴보자.
사진에 있는 Pool Size가 처음 기본값으로 15로 설정이 돼있을텐데, 이 값을 30으로 바꿔준다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/0dbd9e4f-48ed-4409-a2fa-d423b6c8ea25/image.png" alt=""></p>
<p>한번 실제로 연결이 잘 됐는지 살펴보자.
DB 쿼리문에 다음과 같이 입력한다.</p>
<pre><code>SELECT * FROM pg_stat_activity WHERE application_name = &#39;PostgreSQL JDBC Driver&#39;;</code></pre><p>스프링부트 서버를 한개만 켰을 때는 예상한대로 10개의 커넥션이 idle 상태로 연결된다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/59065424-8f1b-4f30-850f-abf3c13d6e7f/image.png" alt=""></p>
<p>스프링부트 서버를 한개 더 켜본 다음에 쿼리를 다시 입력해보자.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/5229074f-9ae7-49f5-81d7-3f6634e2f5c6/image.png" alt=""></p>
<p>문제 없이 20개 모두 커넥션이 연결되었다.</p>
<h1 id="결론">결론</h1>
<p>이 문제는 이론보다는 실무에서 경험을 통해 더 많이 배우게 되는 사례 중 하나라고 생각한다. 실제로 프로젝트를 진행하다 보면, 특히 여러 명의 개발자가 동시에 작업하는 환경에서는 다양한 문제가 발생할 수 있으며, 그 중 하나가 바로 이 DB 커넥션 풀 관련 문제인 것 같다.</p>
<h2 id="깨달은-점">깨달은 점</h2>
<h3 id="자원-관리의-중요성">자원 관리의 중요성</h3>
<p>데이터베이스 연결은 제한된 자원이다. 각 어플리케이션이 너무 많은 연결을 사용하게 되면, 데이터베이스는 새로운 연결 요청을 처리할 수 없게 되고, 결국 &quot;Max client connections reached&quot;와 같은 오류가 발생한다.</p>
<h3 id="설정-조정-및-최적화">설정 조정 및 최적화</h3>
<p>각 환경에 맞는 적절한 설정을 적용하는 것이 중요하다는 것을 깨달았다. 운영 환경에서는 안정성과 성능을 최우선으로 하여 설정을 조정하고, 개발 환경에서는 자원의 효율적인 사용을 위해 설정을 조정해야 한다.</p>
<h3 id="모니터링과-문제-해결">모니터링과 문제 해결</h3>
<p>Supabase와 같은 서비스는 로그와 모니터링 도구를 통해 문제의 원인을 파악하는 데 큰 도움이 됐다. 이러한 도구들을 적극적으로 활용하여 문제를 신속하게 파악하고 해결하는 능력을 길러야겠다.</p>
<h1 id="참고-문서">참고 문서</h1>
<p><a href="https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pooler">Connecting to your database | Supabase Docs</a>
<a href="https://jiwondev.tistory.com/291">JDBC Connection 에 대한 이해, HikariCP 설정 팁</a>
<a href="https://www.youtube.com/watch?v=zowzVqx3MQ4&amp;t=1657s&amp;ab_channel=%EC%89%AC%EC%9A%B4%EC%BD%94%EB%93%9C">DBCP (DB connection pool)의 개념부터 설정 방법까지! hikariCP와 MySQL을 예제로 설명합니다! 이거 잘 모르면 힘들..</a>
<a href="https://supabase.github.io/supavisor/configuration/pool_modes/">Pool Modes - supavisor</a>
<a href="https://github.com/supabase/supavisor">https://github.com/supabase/supavisor</a>
<a href="https://velog.io/@dongvelop/Spring-Boot-Hikari-CP-%EC%BB%A4%EC%8A%A4%ED%85%80%EC%9C%BC%EB%A1%9C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0">[Spring Boot] Hikari CP 커스텀으로 성능 최적화하기</a>
<a href="https://techblog.woowahan.com/2664/">HikariCP Dead lock에서 벗어나기 (이론편) | 우아한형제들 기술블로그</a>
<a href="https://jgrammer.tistory.com/entry/Spring-Boot-Hikari-Connection-Pool-%EC%97%90%EB%9F%AC-%ED%95%B8%EB%93%A4%EB%A7%81">Spring Boot Hikari Connection Pool 에러 핸들링</a>
<a href="https://jojoldu.tistory.com/634">NodeJS 와 PostgreSQL Connection Pool</a>
<a href="https://supabase.com/blog/supavisor-postgres-connection-pooler">Supavisor 1.0: a scalable connection pooler for Postgres</a>
<a href="https://supabase.com/docs/guides/api">REST API | Supabase Docs</a>
<a href="https://steady-coding.tistory.com/564">[데이터베이스] Connection Pool이란?</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[이커머스 도메인 개체명 인식기 개발하기]]></title>
            <link>https://velog.io/@dongho18/%EC%9D%B4%EC%BB%A4%EB%A8%B8%EC%8A%A4-%EB%8F%84%EB%A9%94%EC%9D%B8-%EA%B0%9C%EC%B2%B4%EB%AA%85-%EC%9D%B8%EC%8B%9D%EA%B8%B0-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dongho18/%EC%9D%B4%EC%BB%A4%EB%A8%B8%EC%8A%A4-%EB%8F%84%EB%A9%94%EC%9D%B8-%EA%B0%9C%EC%B2%B4%EB%AA%85-%EC%9D%B8%EC%8B%9D%EA%B8%B0-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 17 May 2024 05:35:12 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>본 게시물은 <a href="https://velog.io/@dongho18/%EC%8B%9C%EA%B0%81%EC%9E%A5%EC%95%A0%EC%9D%B8%EC%9D%84-%EC%9C%84%ED%95%9C-%EC%9D%BD%EC%96%B4%EC%A3%BC%EB%8A%94-%EC%87%BC%ED%95%91-%EB%8C%80%ED%99%94%ED%98%95-AI-%EC%86%8C%EB%8B%B4-%EA%B0%9C%EB%B0%9C-%ED%9A%8C%EA%B3%A0">&quot;시각장애인을 위한 읽어주는 쇼핑 대화형 AI &#39;소담&#39; 개발 회고&quot;</a> 와 내용이 이어집니다.</p>
</blockquote>
<p>오늘은 이커머스 도메인 개체명 인식기 개발을 주제로 글을 써보려고 한다.</p>
<h1 id="개체명-인식이란">개체명 인식이란?</h1>
<p>개체명 인식(Named Entity Recognition)이란 말 그대로 이름을 가진 개체(named entity)를 인식하겠다는 것을 의미한다. 좀 더 쉽게 설명하면, 어떤 이름을 의미하는 단어를 보고는 그 단어가 어떤 유형인지를 인식하는 것을 말한다.</p>
<p>예를 들어 <strong>동호는 2025년에 토스에 입사했다.</strong> 라는 문장이 있을 때, 사람(person), 조직(organization), 시간(time)에 대해 개체명 인식을 수행하는 모델이라면 다음과 같은 결과를 보여준다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/f021dca4-24b9-48e9-9b5d-87094b00c768/image.png" alt=""></p>
<h1 id="개발-배경">개발 배경</h1>
<p>이전에 학교 팀 프로젝트를 진행하면서 <strong>상품 추천 알고리즘</strong> 개발을 맡은 적이 있었다.
Word2Vec 를 사용해 사용자의 발화문과 DB에 저장돼있는 제품명을 비교해서 코사인 유사도 값을 계산한 뒤에 유사도 값이 가장 큰 상품을 추천해주는 방식으로 간단하게 구현을 진행했었다.</p>
<p><a href="https://velog.io/@dongho18/%EC%8B%9C%EA%B0%81%EC%9E%A5%EC%95%A0%EC%9D%B8%EC%9D%84-%EC%9C%84%ED%95%9C-%EC%9D%BD%EC%96%B4%EC%A3%BC%EB%8A%94-%EC%87%BC%ED%95%91-%EB%8C%80%ED%99%94%ED%98%95-AI-%EC%86%8C%EB%8B%B4-%EA%B0%9C%EB%B0%9C-%ED%9A%8C%EA%B3%A0">구현 방법</a></p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/7cfc68dc-2703-4b98-98c3-a30b713ab729/image.png" alt=""></p>
<p>이 방법은 간단하면서도 나름 성능이 괜찮았지만, 한 가지 문제가 존재했다.
예를 들어 &quot;다이슨 청소기 추천해줘&quot;와 같은 문장 전체를 모두 벡터화시키다 보면, 중요하지 않은 정보들도 함께 벡터화가 되어 노이즈가 생긴다. 
즉, 문장이 길어지면 길어질수록 추천 성능이 떨어진다. 
그래서, 문장에서 중요한 키워드만 뽑아서 벡터화를 시키는 방법은 없을까를 고민하게 됐고 그에 대한 해결 방법으로 개체명 인식기 개발 방법에 대해 공부하게 된다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/f3e0f549-58bd-433f-ac7a-9dcbb3f3f529/image.png" alt=""></p>
<h1 id="선행-연구">선행 연구</h1>
<p>개체명 인식에 대한 선행 연구를 조사해본 결과, 다양한 접근 방식이 존재했다. 주로 사용되는 방법으로는 규칙 기반 접근, 머신러닝 기반 접근, 그리고 최근에는 딥러닝 기반 접근이 있다. 본 글에서는 딥러닝 기반 방식을 사용했다.</p>
<h2 id="딥러닝-기반-접근">딥러닝 기반 접근</h2>
<p>최근에는 딥러닝을 활용한 개체명 인식 모델들이 주로 연구되고 있다. 딥러닝 모델은 대량의 데이터로부터 자동으로 특징을 추출하고 학습할 수 있다는 특징이 있다. 대표적인 딥러닝 모델로는 RNN, LSTM, 그리고 Transformer 기반 모델(BERT, GPT) 등이 있다.</p>
<p>딥러닝 모델 중 특히 Transformer 기반 모델들은 문맥을 잘 이해하고, 긴 문장에서도 높은 성능을 발휘하는 것으로 알려져 있다. 예를 들어, BERT 모델이나 ELECTRA 모델은 문장의 양방향 문맥 정보를 활용하여 높은 정확도의 개체명 인식을 가능하게 한다. 이러한 모델은 사전 학습(pre-trained)된 상태로 제공되어, 특정 도메인에 맞게 미세 조정(fine-tuning)하는 방식으로 쉽게 적용할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/671a6771-d9e8-49b6-99d2-f42d29e81437/image.png" alt=""></p>
<h2 id="b-i-o-태깅">B-I-O 태깅</h2>
<p>개체명 인식 모델을 학습하는 과정은 지도 학습의 일부분으로, 레이블링된 데이터인 B-I-O 태깅이 된 문장을 사용하여 모델이 텍스트에서 개체명을 인식하고 분류하는 방법을 학습한다.</p>
<p>B-I-O 태깅에서 B-태그는 개체명의 시작을, I-태그는 개체명의 내부를 나타낸다. 이 두 태그를 구분하는 이유는 &quot;김치 냉장고&quot;와 같이 두 단어 이상으로 이루어진 하나의 개체명을 식별하기 위해서이다. 마지막으로 O-태그는 개체명과 관련이 없는 일반적인 토큰을 나타낸다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/dab04ffe-1a3b-4d93-bd92-f6abce81edd4/image.png" alt=""></p>
<h1 id="제안하는-방법">제안하는 방법</h1>
<p>선행 연구에서 살펴본 Transformer 기반 모델들은 위키피디아나 뉴스 등의 대규모 텍스트 데이터를 기반으로 학습이 되며, 이러한 데이터에서 언어의 패턴이나 구조를 파악하도록 훈련된다.</p>
<p>하지만, 나의 경우 기본적으로 입력 데이터로 문어체가 아닌 대화체가 들어오기 때문에 일반적인 ELECTRA 모델이 아닌 <a href="https://github.com/SKplanet/Dialog-KoELECTRA">Skplanet의 Dialog-KoELECTRA 모델</a>을 사용하여 학습을 진행하기로 결정하였다. Dialog-KoELECTRA 모델은 22GB의 대화체 및 문어체 한글 텍스트 데이터로 훈련되었다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/c8309dcd-ae42-4325-b507-accf04740e16/image.png" alt=""></p>
<h1 id="실험">실험</h1>
<h2 id="데이터셋">데이터셋</h2>
<p>앞서 Transformer 기반의 모델을 학습하기 위해서는 B-I-O 태깅이 된 문장들이 필요하다고 했다. 하지만, 이러한 데이터들은 보통 이커머스 회사들의 귀중한 자산이기에 구하기가 쉽지 않았다.</p>
<p>나 같은 경우에는 <a href="https://aihub.or.kr/aihubdata/data/view.do?currMenu=115&amp;topMenu=100&amp;aihubDataSe=realm&amp;dataSetSn=102">AI-Hub 소상공인 고객 주문 질의-응답 텍스트 데이터셋</a>을 사용하여 진행했다. 이 데이터셋은 콜센터에서 녹취된 질의-응답 음성 파일을 수집하고, 음성 데이터를 텍스트로 가공한 데이터셋으로 약 400만 건의 대화 데이터를 포함하고 있다.</p>
<p>개체명은 크게 6가지로 나뉘어져있고, 예시는 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/f27e728e-35ac-4bcb-abe8-9f0dada6055d/image.png" alt=""></p>
<h2 id="데이터-전처리">데이터 전처리</h2>
<p>실험에서는 데이터셋의 양이 너무 많아 디지털/가전 카테고리의 데이터로 범위를 제한했다. 또한, 데이터 품질 문제로 인해 태그가 전혀 없거나 매우 부족한 일부 문장을 모두 사용하는 대신, 최소한 2개 이상의 태그가 지정된 문장만을 선택하여 사용했다. (약 50,000개)</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/012eeb8c-d550-4c3b-a188-f762076c2da1/image.png" alt=""></p>
<h2 id="실험-설계">실험 설계</h2>
<p>실험 설계는 크게 세 가지로 구성된다.</p>
<ol>
<li>문어체 기반 모델과 대화체 기반 모델을 모두 학습시켜 성능을 비교한다.</li>
<li>학습 데이터를 모두 사용한 경우와 일부만 사용한 경우의 성능 차이를 분석한다.</li>
<li>혼동행렬 및 케이스 분석을 통해 모델이 어느 부분에서 강점과 약점을 보이는지 파악한다.</li>
</ol>
<h2 id="평가-지표">평가 지표</h2>
<p>평가 지표로는 정밀도, 재현율, F1-Score를 사용하여 모델이 태그를 얼마나 잘 예측했는지 판단한다. 또한, 이런 모델의 경우에는 보통 큰 의미를 갖지 않는 레이블 정보 즉 &#39;O&#39; 태그가 다른 태그보다 압도적으로 많기 때문에 모델이 &#39;O&#39; 태그를 예측하는 데에 편향되어 정확도가 뻥튀기 되어 모델의 성능을 오해할 수 있다는 문제가 있다. 이러한 문제를 막기 위해 seqeval 라이브러리를 사용하여 모델이 해당 개체명을 이루는 모든 단어를 올바르게 예측하였을 경우에만 평가 점수를 계산하도록 하였다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/5c1e2208-43ef-4849-a10c-d7cc0e317b0d/image.png" alt=""></p>
<h2 id="실험-결과">실험 결과</h2>
<p>실험에서 사용된 모델은 크게 3가지 이다.</p>
<p>문어체를 기반으로 사전 학습된 KoELECTRA-Small 모델과 KoELECTRA-Base 모델이 있고, 대화체를 기반으로한 사전 학습된 Dialog-KoELECTRA-Small 모델이 있다.</p>
<p>Small 모델과  Base 모델의 차이는 파라미터 수의 차이에 있다. 일반적으로 Small 모델은 자원 제약이 있는 상황이나 빠른 추론이 필요한 경우에 적합하고, Base 모델은 성능을 우선시하는 경우에 더 적합하다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/2c1c00e4-a424-4ffe-b686-630007767ab7/image.png" alt=""></p>
<h3 id="실험-1---모델-별-성능-비교">실험 1 - 모델 별 성능 비교</h3>
<p>문어체 기반 모델과 대화체 기반 모델로 학습했을 때 실제로 더 좋은 성능이 나왔는지 확인해보기 위해 3가지 모델을 모두 비교해보았다. Full Dataset(N=50,000)을 기준으로 Small 모델끼리 비교했을 때는 약 2%의 성능 개선이 있었지만, Dialog-KoELECTRA-Base 모델을 비교했을 때는 많이 뒤쳐지는 모습을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/5a4866e3-a967-47d6-a136-f1ad4de62da0/image.png" alt=""></p>
<h3 id="실험-2---학습-데이터-크기-별-성능-비교">실험 2 - 학습 데이터 크기 별 성능 비교</h3>
<p>흥미로웠던 점은 학습 데이터 수가 적어지면 적어질수록 Dialog-KoELECTRA-Small 모델의 성능이 향상되었다. 실험에서는 Full Dataset의 크기의 약 0.01%인 500개의 학습 데이터로 KoELECTRA-Small 모델을 학습했을 때 F1-Score가 7.5%, Dialog-KoELECTRA-Small 모델은 41.6%로 약 6배 가까이 성능이 개선된 것을 확인할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/d8419ec4-dc93-46c5-bcd2-bd5161f56f0a/image.png" alt=""></p>
<h3 id="실험-3---혼동-행렬-및-케이스-분석">실험 3 - 혼동 행렬 및 케이스 분석</h3>
<p>&#39;Dialog-KoELECTRA-Small&#39; 모델의 Full Dataset을 기준으로 혼동 행렬을 분석한 결과, 모델은 &#39;O&#39; 태그에서 가장 많이 혼동하는 경향을 보였다. 그 외 개체명은 잘 분류한다는 것이 오차 행렬의 주대각선에 잘 나타난다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/7d321cec-21f8-4153-b637-98573b583343/image.png" alt=""></p>
<p>높은 손실을 내는 시퀀스들을 분석해본 결과 원본 데이터의 저품질 현상이 주 원인이라는 것을 알게 되었다. 대표적인 예로 &quot;100인치 해상도로 시청 가능&quot; 이라는 문장에서 &quot;100인치&quot; 라는 단어를 모델은 &quot;크기&quot; 라벨로 정확하게 예측했지만 실제로 원본 라벨에서는 &#39;O&#39; 태그가 부착되어 있어 성능이 크게 감소했다.</p>
<p>뿐만 아니라 일부 제품명이 모델의 학습에 혼동을 주고 있는 것으로 확인되었다. 예시에서는 &quot;도로 시&quot;라는 제품명이 모델에게 혼란을 야기하고 있는 것으로 나타났다. <a href="https://www.google.com/search?sca_esv=0eb7a465d1046084&amp;sca_upv=1&amp;rlz=1C1IBEF_koKR1021KR1021&amp;q=%EB%8F%84%EB%A1%9C%EC%8B%9C&amp;tbm=isch&amp;source=lnms&amp;sa=X&amp;ved=2ahUKEwizjpfM95OGAxUwQPUHHT1QDuIQ0pQJegQIDRAB&amp;biw=1858&amp;bih=993&amp;dpr=1">도로시가 뭔지 궁금해서 구글에 검색해보니...</a></p>
<p>아무튼 이러한 일부 단어들 때문에 &#39;O&#39; 태그로 예측해야 하는 단어들까지 제품명 라벨로 인식하는 문제까지 발생하고 있었다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/df91dea5-12bd-4dc0-b2e1-73d659e6d55b/image.png" alt=""></p>
<h1 id="결론">결론</h1>
<p>이번 프로젝트에서는 이커머스 도메인에서 개체명 인식을 수행하는 모델을 개발하는 과정을 다루었다. 이를 통해 다음과 같은 결론을 도출할 수 있었다.</p>
<ol>
<li>Dialog-KoELECTRA 모델은 문어체 기반의 KoELECTRA 모델보다 높은 성능을 보였다. 이는 특히 적은 양의 데이터로 학습할 때 더욱 두드러졌다.</li>
<li>모델의 성능은 학습 데이터의 품질에 크게 좌우되었다. 특히 B-I-O 태깅의 정확도가 중요한데, 저품질 데이터는 모델의 혼동을 초래하고 성능 저하를 유발했다. 따라서, 고품질의 레이블링 데이터셋 확보가 중요하며, 이를 위해 데이터 전처리와 라벨링 과정에서의 엄격한 품질 관리가 필요하다.</li>
<li>개체명 인식 모델을 실제 프로덕트에 적용한 결과, 추천 성능이 향상되었다.</li>
</ol>
<p>향후 연구에서는 레이블링이 부정확하게 된 문장들을 제외 시킨 뒤에 다시 모델을 학습시켜볼 예정이다. 만약 이 과정에서 모델의 성능이 더 향상된다면, 데이터 라벨링 과정에서의 품질 관리가 모델 성능 향상에 큰 영향을 미친다는 것을 보다 명확히 입증할 수 있을 것이다.</p>
<h1 id="참고-논문">참고 논문</h1>
<ol>
<li>Li, J., Sun, A., Han, J., &amp; Li, C. (2020). A survey on deep learning for named entity recognition. IEEE Transactions on Knowledge and Data Engineering, 34(1), 50-70.</li>
<li>Nadeau, D., &amp; Sekine, S. (2007). A survey of named entity recognition and classification. Lingvisticae Investigationes, 30(1), 3-26.</li>
<li>Ngo, Q. H., Kechadi, T., &amp; Le-Khac, N. A. (2021). Domain specific entity recognition with semantic-based deep learning approach. IEEE Access, 9, 152892-152902.</li>
<li>Ishikawa, T., Yakoh, T., &amp; Urushihara, H. (2022). An NLP-inspired data augmentation method for adverse event prediction using an imbalanced healthcare dataset. IEEE Access, 10, 81166-81176.</li>
<li>Zeng, X., Lin, S., &amp; Liu, C. (2021). Multi-view deep learning framework for predicting patient expenditure in healthcare. IEEE Open Journal of the `Computer Society, 2, 62-71.</li>
<li>Abonizio, H. Q., Paraiso, E. C., &amp; Barbon, S. (2021). Toward text data augmentation for sentiment analysis. IEEE Transactions on Artificial Intelligence, 3(5), 657-668.</li>
<li>Wei, J., &amp; Zou, K. (2019). Eda: Easy data augmentation techniques for boosting performance on text classification tasks. arXiv preprint arXiv:1901.11196.</li>
<li>Cho, Gyeong Seon, and Kim, Sung Bum. (2022). Korean Named Entity Recognition Using Data Augmentation Techniques. Journal of the Korean Institute of Industrial Engineers, 48(2), 176-184.</li>
<li>Edunov, S., Ott, M., Auli, M., &amp; Grangier, D. (2018). Understanding back-translation at scale. arXiv preprint arXiv:1808.09381.</li>
<li>Clark, K., Luong, M. T., Le, Q. V., &amp; Manning, C. D. (2020). Electra: Pre-training text encoders as discriminators rather than generators. arXiv preprint arXiv:2003.10555.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[시각장애인을 위한 읽어주는 쇼핑 대화형 AI '소담' 개발 회고]]></title>
            <link>https://velog.io/@dongho18/%EC%8B%9C%EA%B0%81%EC%9E%A5%EC%95%A0%EC%9D%B8%EC%9D%84-%EC%9C%84%ED%95%9C-%EC%9D%BD%EC%96%B4%EC%A3%BC%EB%8A%94-%EC%87%BC%ED%95%91-%EB%8C%80%ED%99%94%ED%98%95-AI-%EC%86%8C%EB%8B%B4-%EA%B0%9C%EB%B0%9C-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@dongho18/%EC%8B%9C%EA%B0%81%EC%9E%A5%EC%95%A0%EC%9D%B8%EC%9D%84-%EC%9C%84%ED%95%9C-%EC%9D%BD%EC%96%B4%EC%A3%BC%EB%8A%94-%EC%87%BC%ED%95%91-%EB%8C%80%ED%99%94%ED%98%95-AI-%EC%86%8C%EB%8B%B4-%EA%B0%9C%EB%B0%9C-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Thu, 16 May 2024 09:04:24 GMT</pubDate>
            <description><![CDATA[<p>오늘은 많은 학과 교수님들의 관심과 대회에서 큰 상을 받았던 팀 프로젝트를 진행한 경험을 회고하고자 한다. 이 프로젝트를 통해 나는 인공지능에 대한 큰 흥미를 가지게 되었고, 그것이 가진 잠재력을 몸소 깨닫게 되었다.</p>
<p><a href="https://github.com/AlgoThreeReading-Team">깃허브 리포지토리</a></p>
<h1 id="대화형-ai-소담이란">대화형 AI 소담이란?</h1>
<p>대화형 AI 소담은 눈이 잘 보이지 않는 시각장애인분들을 대상으로 상품에 대한 정보를 소리로 설명해주는 서비스이다. 사용자는 아래 사진과 같이 특정 상품을 추천받을 수도 있고 원한다면 상품 정보에 대한 답변도 얻을 수 있다.</p>
<p>스크린리더를 통해 상품의 정보를 들으면서 이해하는 시각장애인들의 입장에서 복잡한 쇼핑몰 UI는 큰 독이다.
가령 사용자가 사과 하나를 사기 위해 쇼핑몰에 들어간다고 가정한다면, 사용자는 사과와 관련 없는 많은 정보들을 소리를 통해 듣고 있어야 한다.</p>
<p>만약 인공지능 점원이 사용자가 원하는 상품을 빠르게 추천해주고, 설명해준다면 어떨까? 그게 가능하다면 사용자는 짧은 시간 안에 상품을 파악하고 구매하는데 큰 도움을 받을 것이다.</p>
<p><a href="https://drive.google.com/file/d/1irIDroCX0r1bx-w5nkVEEh7senVA5E_b/view?usp=sharing">시연 영상</a></p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/2b8fd843-4281-460b-b3df-0d148a33ff4e/image.png" alt=""></p>
<h1 id="어떻게-만들건데">어떻게 만들건데?</h1>
<p>이를 구현하기 위해 필요한건 크게 두 가지이다.</p>
<p>1) 사용자에게 상품을 추천해준다.
2) 사용자에게 상품을 설명해준다.</p>
<p>심플해보이지만 이 프로젝트를 처음 기획할 당시에는 어떤 식으로 설계를 할지 고민이 많았다.
내가 선택한 방법은 다음과 같다.</p>
<h2 id="데이터셋-준비">데이터셋 준비</h2>
<p>실험 대상은 &#39;쿠팡&#39;으로 정했다.
쿠팡에서 랜덤한 상품 데이터를 30개 정도 골라 스크래핑 하여 데이터를 정형화 시켰다.
이 정형화 된 상품 정보들을 이용해 사용자에게 상품을 추천해주고 설명해주는 로직을 짜보기로 했다.
혹시라도 이 데이터가 필요한 사람들을 위해 <a href="https://github.com/AlgoThreeReading-Team/SodamSodam-Chatbot/blob/main/recommend/product.csv">깃허브 리포지토리</a> 를 공유한다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/b168851c-ff02-4e16-bf92-376bbfa88057/image.png" alt=""></p>
<h2 id="상품-추천검색">상품 추천(검색)</h2>
<p>상품을 사용자에게 어떻게 하면 간단한 방법으로 추천해줄 수 있을까?
ChatGPT에게 부탁하자니 이 친구는 생성형 모델이지 추천 모델이 아니였기에 제외시켰다.
다른 전통적인 방식인 사용자 기반 추천과 아이템 기반 추천으로 상품을 추천하자니, 사용자의 구매 및 탐색 이력이 필요한데 나 같은 대학생에게 그러한 정보는 없었다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/45971b87-d2d7-4407-83d6-b80bead59dd0/image.png" alt=""></p>
<p>이가 없으면 잇몸으로 개발해보자.
나는 아주 간단한 방법으로 상품 DB 안에 있는 제품명과 사용자의 발화문을 각각 벡터화 시켜서 코사인 유사도 값을 계산한 뒤, 유사도 값이 가장 큰 상품을 추천해주는 방식으로 개발을 진행했다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/02d8298a-7d2e-483d-b8db-fd49f2ebe672/image.png" alt=""></p>
<p>먼저 문장을 벡터화 시켜 주기 위해 한국어 사전 학습 모델인 <code>jhgan/ko-sroberta-multitask</code> 을 불러와준다.
상품 DB의 제품명들을 불러온 model을 이용해 벡터화 시킨다. 이렇게 제품명을 벡터화 해주면 나중에 들어올 사용자의 발화문과 얼마나 유사한지를 수학적으로 계산할 수 있게 된다.</p>
<pre><code class="language-python">from sentence_transformers import SentenceTransformer, util
import torch
import pandas as pd

model = SentenceTransformer(&quot;jhgan/ko-sroberta-multitask&quot;)
df = pd.read_csv(&quot;product.csv&quot;)
df[&quot;벡터화된 제품명&quot;] = df[&quot;제품명&quot;].apply(lambda x: model.encode(x))</code></pre>
<p>그런 다음 유사도를 계산해서 유사성이 높은 상품들을 추출해내는 함수를 작성한다.
이 함수는 사용자의 발화문을 나타내는 <code>query</code> 변수와, 반환할 상위 결과의 개수를 나타내는 <code>top_k</code> 변수를 인자로 받는다.</p>
<p>함수의 동작 방식을 설명하자면 다음과 같다.</p>
<p>1) 사용자의 발화문을 모델을 통해 벡터화한다.
2) 데이터프레임의 &quot;벡터화된 제품명&quot;열과 발화문 간의 코사인 유사도를 계산한다.
3) 계산된 유사도 중에서 상위 K개의 결과를 선택한다.
4) 선택된 결과 중 코사인 유사도가 0.3 이상인 상품들에 대한 정보를 추출하고, 이 정보들을 포함한 리스트를 생성한다.
5) 생성된 리스트를 반환한다.</p>
<p>참고로 여기서 코사인 유사도는 0~1 사이의 값이 나오게 되는데, 유사도가 높을 수록 값이 1에 가까워진다.
나 같은 경우는 조금 널널하게 문턱값을 <code>0.3</code> 으로 잡았는데 이 값은 여러 가지 실험을 통해서 개발자가 임의로 정하면 된다.</p>
<pre><code class="language-python">def get_query_sim_top_k(query, top_k):
    query_encode = model.encode(query)
    cos_scores = util.pytorch_cos_sim(query_encode, df[&quot;벡터화된 제품명&quot;])[0]
    top_results = torch.topk(cos_scores, k=top_k)

    top_indices = top_results.indices.tolist()  # 상위 상품 인덱스 리스트

    result_list = []

    for index in top_indices:
        if cos_scores[index] &gt;= 0.3:
            product_info = {
                &quot;id&quot;: df.iloc[index][&quot;index&quot;],
                &quot;title&quot;: df.iloc[index][&quot;title&quot;],
                # ...(중략)
            }
            result_list.append(product_info)
    return result_list

query = &quot;맛있는 고구마 추천해줘&quot;
product = get_query_sim_top_k(query, 1)
print(product)</code></pre>
<p>이렇게 하면 &quot;맛있는 고구마 추천해줘&quot; 라는 사용자의 발화문과 DB 안의 상품명들과 하나하나 비교해서 그럴싸한 상품 하나를 추천해주게 된다. 물론 나중에 알았지만, 이 방식은 결점이 많아 크게 권장하지는 않는다. 대학교 팀 프로젝트나 개인적으로 진행하는 사이드 프로젝트에만 사용하길 바란다.</p>
<h2 id="상품-설명">상품 설명</h2>
<p>상품 설명을 진행하기 앞서 한 가지 의문이 들 수도 있을 것이다.
&quot;사용자가 상품을 추천해달라는 것인지, 설명해달라는 것인지 어떻게 구분할 수 있어?&quot;
이를 해결하기 위해서는 사용자의 발화문이 입력 값으로 들어왔을 때 그 문장이 &#39;상품 추천&#39; 카테고리에 해당하는지, 아니면 &#39;상품 설명&#39;에 해당하는지 분류해주는 인공지능 모델이 필요했다.
하지만, 이 당시 나는 인공지능에 &#39;인&#39;자도 모르는 학부생이였기에 ChatGPT API를 이용하여 해결했다.</p>
<h3 id="사용자-의도-파악">사용자 의도 파악</h3>
<p><img src="https://velog.velcdn.com/images/dongho18/post/5f8e1343-874d-4a9f-bf0b-38fddb4876ee/image.png" alt=""></p>
<p>사용자가 어떠한 질문을 했을 때 ChatGPT가 &#39;추천&#39;인지 &#39;설명&#39;인지 분류를 하기 위해서는 약간의 프롬프트 엔지니어링이 필요했다.</p>
<p>분류할 의도를 <code>allowed_intents</code> 리스트에 집어 넣고, GPT에게 <code>사용자의 발화문이 어느 범주에 속하는지 반드시 리스트에 해당하는 하나의 범주로만 대답해라</code> 고 유도한다.</p>
<p>만약 GPT가 예측한 의도가 허용된 의도 목록에 있는지 확인하고, 있으면 해당 의도를 반환하고, 없으면 &quot;미분류&quot;로 반환한다.</p>
<pre><code class="language-python">from openai import OpenAI
client = OpenAI(api_key=os.environ.get(&quot;OPENAI_API_KEY&quot;))

def get_user_intent(query):
    allowed_intents = [
        &quot;상품 설명&quot;,
        &quot;상품 추천&quot;,
        &quot;리뷰 요약&quot;,
        &quot;미분류&quot;,
    ]
    messages = [
        {
            &quot;role&quot;: &quot;system&quot;,
            &quot;content&quot;: &quot;당신은 사용자의 질문 의도를 이해하는 유용한 보조자입니다.&quot;,
        },
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: f&quot;아래 문장은 어느 범주에 속합니까: {&#39; | &#39;.join(allowed_intents)}? 반드시 하나의 토큰으로만 응답하세요. \n{query} \nA:&quot;,
        },
    ]

    response = client.chat.completions.create(model=&quot;gpt-3.5-turbo&quot;, messages=messages)
    chatbot_response = response.choices[0].message.content

    return chatbot_response if chatbot_response in allowed_intents else &quot;미분류&quot;</code></pre>
<p>그렇다면 상품 설명은 어떤 방식으로 이루어질까?
앞서 사용자가 상품 하나를 추천 받게 되면 클라이언트는 추천 받은 상품에 대한 id 값을 기억하고 있는다.
사용자가 상품 하나를 추천 받은 뒤에 그 상품에 대한 설명을 요청하면 서버로 상품 id에 대한 값이 같이 넘어오게 된다.</p>
<p>예를 들어, 1번 id 값을 가지는 상품을 추천받았다면 사용자가 그 다음에 하는 질문들과 함께 1번 id 값이 같이 넘어가게 된다. 그런 다음 이 질문의 의도가 &quot;상품 설명&quot; 이라면 상품 id 값을 이용해 db에서 1번 상품에 대한 정보들을 가져온다.</p>
<p><img src="https://velog.velcdn.com/images/dongho18/post/b3682edc-b6fa-4e23-8e5c-492dc2a1f4d8/image.png" alt=""></p>
<p>그렇게 가져온 상품 정보들을 GPT에게 문자열 형태로 넘긴 다음 요약을 하라고 시킨다.</p>
<pre><code class="language-python">def get_description_answer(product_info, query):
    messages = [
        {
            &quot;role&quot;: &quot;system&quot;,
            &quot;content&quot;: &quot;당신은 사용자에게 상품에 대해서 설명해주는 점원입니다. 손님은 항상 바쁘기 때문에 답변을 간결하게 하려고 노력해주세요.&quot;,
        },
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: f&quot;질문: {query}\n상품 정보: {product_info}\n이 상품에 대해서 설명해주는 멘트만 짧게 작성해주세요. \nA:&quot;,
        },
    ]

    response = client.chat.completions.create(
        model=&quot;gpt-3.5-turbo&quot;, messages=messages, temperature=0.5
    )
    chatbot_response = response.choices[0].message.content

    return chatbot_response</code></pre>
<h1 id="맺음말">맺음말</h1>
<p>인공지능 기술이 빠르게 발전하고 있다. 이제는 나같은 컴퓨터 공학 학부생들도 ChatGPT API와 같은 도구를 이용하여 현실적이고 유용한 제품을 개발할 수 있게 되었다. 빠른 발전에 도태되지 않기 위해 계속해서 인공지능 분야에서 학습하고 발전하는 것이 중요한 것 같다. </p>
<p>이 글을 보는 다른 분들도 ChatGPT 시대에서 자신의 역량을 어떻게 발휘할 수 있을지 같이 고민해보고 또 도움이 되었으면 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CSS] Flexbox]]></title>
            <link>https://velog.io/@dongho18/CSS-Flexbox</link>
            <guid>https://velog.io/@dongho18/CSS-Flexbox</guid>
            <pubDate>Wed, 23 Feb 2022 01:14:28 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 flexbox를 다뤄보겠다.
flexbox는 행과 열 형태로 항목을 배치하는 <strong>일차원 레이아웃 메서드</strong>이다.</p>
<h1 id="왜-flexbox를-써야해">왜 flexbox를 써야해?</h1>
<p>flexbox가 없던 시절에 우리는 floats와 position을 가지고 CSS 레이아웃을 대부분 작성했다. 이 둘은 잘 작동했지만, 어떤 면에서는 너무 불편했다. 그래서 등장한 것이 바로 flexbox! flexbox는 많은 레이아웃 작업을 훠어얼씬 더 쉽게 만들어준다.</p>
<h1 id="간단한-예제">간단한 예제</h1>
<p>아래 예제를 살펴보자. <code>&lt;header&gt;</code> 요소와 세 개의 <code>&lt;article&gt;</code>를 포함한 <code>&lt;section&gt;</code> 요소가 있다.</p>
<p>!codepen[jangdongho/embed/zYPaKVQ?default-tab=html%2Cresult]</p>
<h2 id="flexbox-지정하기">flexbox 지정하기</h2>
<p>먼저 어떤 요소들을 flexbox로 레이아웃 할지 생각해봐야 한다. 그리고 영향을 주고 싶은 요소의 <strong>부모 요소</strong>에 <code>display: flex;</code> 값을 준다. 여기서 주의할 점은 반드시 내가 flexbox를 만들고 싶은 요소의 <strong>부모 요소</strong>에 flex를 주어야 한다. 예제의 경우 우리는 <code>&lt;article&gt;</code> 요소를 레이아웃하길 원하므로 그의 부모 요소인 <code>&lt;section&gt;</code>에 flex 속성값을 지정해야 한다.</p>
<pre><code class="language-css">section {
  display: flex;
}</code></pre>
<p>!codepen[jangdongho/embed/wvPXowp?default-tab=html%2Cresult]</p>
<p>놀랍지않나? 딱 한 줄의 코드만으로 단의 크기가 동일한 다단 레이아웃을 갖게 되었고, 단의 높이도 다 같아졌다!</p>
<h1 id="꼭-알아야-할-용어들">꼭 알아야 할 용어들</h1>
<p>요소들이 flexbox로 레이아웃될 때 그 상자들은 두 개의 축을 따라 배치된다.</p>
<p><img src="https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Flexbox/flex_terms.png" alt=""></p>
<p>아래의 용어들은 flexbox를 이해하기 위해서 반드시 알아두어야 할 용어들이다.</p>
<ul>
<li><strong>주축</strong>(main axis)은 flex item이 배치되고 있는 방향으로 진행하는 축이다.<ul>
<li>주축의 시작과 끝을 일컬어 <strong>main start</strong>와 <strong>main end</strong>라고 한다.</li>
</ul>
</li>
<li><strong>교차축</strong>(cross axis)은 배치되고 있는 방향에 직각을 이루는 축이다.<ul>
<li>교차축의 시작과 끝을 일컬어 <strong>cross start</strong>와 <strong>cross end</strong>라고 한다.</li>
</ul>
</li>
</ul>
<h2 id="배치되고-있는-방향이-뭐시여">배치되고 있는 방향이 뭐시여?</h2>
<p>flexbox는 주축이 진행되는 방향이 있다. 기본값은 <code>row</code>로 설정되어 행 레이아웃(좌측에서 우측)으로 배치된다.</p>
<p>또한 이것의 방향을 바꿀 수도 있는데, <code>flex-direction</code> 속성을 이용하면 된다. 다음 선언문을 <code>&lt;section&gt;</code>에 추가해보자.</p>
<pre><code class="language-css">flex-direction: column;</code></pre>
<p>!codepen[jangdongho/embed/wvPXoeX?default-tab=html%2Cresult]</p>
<p>이로써 항목들을 열 레이아웃(위에서 아래)으로 설정했다. 마치 flexbox 레이아웃을 사용하기 전으로 되돌아간 것 같은 느낌이 든다.</p>
<p>참고로, <code>row-reverse</code>와 <code>column-reverse</code> 속성값을 사용하면 역방향으로 배치할 수도 있다!</p>
<ul>
<li><code>display: flex</code>가 설정된 부모 요소를 일컬어 <strong>flex container</strong>라고 한다.</li>
<li>flex container 내부에 flexbox로 레이아웃 되는 항목을 일컬어 <strong>flex item</strong>이라고 한다.</li>
</ul>
<h1 id="flex-item이-너무-많아졌어">flex item이 너무 많아졌어!</h1>
<p>flex item이 적을 때는 상관없지만 요소가 많아지기 시작하면 레이아웃 너비 또는 높이가 고정 크기를 갖고 있어 결국 요소가 컨테이너에서 이탈하면서 레이아웃이 깨지게 된다.</p>
<p>!codepen[jangdongho/embed/XWzYNzJ?default-tab=html%2Cresult]</p>
<p>그것을 해결해줄 만한 속성들이 있다. </p>
<pre><code class="language-css">flex: 200px;</code></pre>
<pre><code class="language-css">flex-wrap: wrap;</code></pre>
<p><code>flex: 200px</code> 의 의미는 각 요소에 적어도 200px 너비가 지정되었다는 의미이다. 만약 최소 너비로 행을 채우는데 대형이탈된 요소가 생겼다면 그 요소는 <code>flex-wrap: wrap</code> 에 의해 다음 행으로 넘어간다. 그리고 모든 요소들은 다시 알맞게 크기가 조정되어 분배가 된다. 얼마나 좋은가?</p>
<p>!codepen[jangdongho/embed/RwjJoQj?default-tab=html%2Cresult]</p>
<h2 id="flex-flow">flex-flow</h2>
<pre><code class="language-css">flex-direction: row;
flex-wrap: wrap;</code></pre>
<p>꿀팁! 위 두 줄의 코드를 하나로 합칠 수 있다.</p>
<pre><code class="language-css">flex-flow: row wrap;</code></pre>
<h1 id="flex-item의-비율-조절">flex item의 비율 조절</h1>
<pre><code class="language-css">article {
    flex: 1;
}</code></pre>
<p>위 코드는 각 flex item이 기본 축을 따라 남은 공간을 어느 정도나 점유할지를 결정하는 단위가 없는 비율 값이다. 그 말은 즉슨 패당과 여백이 지정된 이후 남은 여분의 공간을 모두 <strong>동등한 크기로 점유하게 된다는 의미</strong>이다.</p>
<p>아래의 예제에서는 3개의 flex item들이 1:1:1 비율로 배분되었다.</p>
<p>!codepen[jangdongho/embed/NWwzbQm?default-tab=html%2Cresult]</p>
<p>만약 세 번째 요소에 <code>flex: 2</code> 를 넣으면 어떻게 될까? 그럼 flex item들은 1:1:2 비율로 배분될 것이다.</p>
<p>!codepen[jangdongho/embed/mdqKRbj?default-tab=html%2Cresult]</p>
<p>또한 flex 값 내에서 최소 크기 값을 지정할 수 있다.</p>
<pre><code class="language-css">article {
  flex: 1 200px;
}

article:nth-of-type(3) {
  flex: 2 200px;
}</code></pre>
<p>!codepen[jangdongho/embed/xxPzgKd?default-tab=html%2Cresult]</p>
<p>이것은 기본적으로 아래의 순서대로 공간이 배분된다.</p>
<ol>
<li>각 flex item은 먼저 사용 가능한 공간에서 최소 공간인 200px를 부여 받는다.</li>
<li>나머지 사용 가능한 공간은 비례 단위에 따라 분배된다.</li>
</ol>
<h1 id="flexbox의-꽃-수평-및-수직-정렬">flexbox의 꽃, 수평 및 수직 정렬</h1>
<p>!codepen[jangdongho/embed/oNoMNwp?default-tab=html%2Cresult]</p>
<p>위의 예제는 일반적인 flex 레이아웃을 취하고 있다. 이제 새로운 속성을 통해서 버튼들의 위치를 수평 및 수직으로 정렬해보겠다.</p>
<pre><code class="language-css">div {
  display: flex;
  align-items: center;
  justify-content: space-around;
}</code></pre>
<p>!codepen[jangdongho/embed/NWwBWgZ?default-tab=html%2Cresult]</p>
<p>와우! 버튼들의 위치를 보니 마음이 편안해진다. 새로운 두 가지 속성에 대해서 자세히 알아보자.</p>
<ol>
<li><p><code>justify-content</code> 는 flex item이 <strong>주축</strong> 상 어디에 놓이는지를 결정한다.</p>
</li>
<li><p><code>align-items</code> 는 flex item이 <strong>교차축</strong> 상 어디에 놓일 지를 제어한다.</p>
</li>
</ol>
<p>만약 flex item이 배치되고 있는 방향이 <code>row</code> 이고, <code>justify-content</code> 가 <code>center</code> 라면 주축(<strong>좌측에서 우측</strong>)에서 중앙에 배치될 것이다.</p>
<p>만약 flex item이 배치되고 있는 방향이 <code>column</code> 이고, <code>justify-content</code> 가 <code>center</code> 라면 주축(<strong>윗쪽에서 아랫쪽</strong>)에서 중앙에 배치될 것이다.</p>
<p>이 두 가지의 차이를 잘 이해해야 한다.</p>
<p>이제 조금 더 깊게 들어가보자.</p>
<h2 id="justify-content-주축">justify-content (주축)</h2>
<ul>
<li><code>flex-start</code> : 기본값이며, 모든 항목이 주축의 시작 부분에 놓인다.</li>
<li><code>flex-end</code> : 모든 항목이 주축의 끝 부분에 놓인다.</li>
<li><code>center</code> : 항목들이 주축의 중심에 놓인다.</li>
<li><code>space-around</code> : 모든 항목을 주축을 따라 고르게 분배하고 양쪽 끝에 약간의 공간도 남겨둔다.</li>
<li><code>space-between</code> : space-aroumd 와 유사하나, 양쪽 끝에 공간은 남기지 않는다.</li>
</ul>
<h2 id="align-items-교차축">align-items (교차축)</h2>
<ul>
<li><code>stretch</code> : 기본값이며, 교차축 방향으로 부모 요소를 채우기 위해 <strong>모든 flex item을 연장</strong>한다. (이 방식 때문에 모든 항목이 같은 높이를 가지게 되는 것이다.)</li>
<li><code>center</code> : 모든 항목이 <strong>자기들 고유의 면적을 유지</strong>하는 동시에 교차축의 중심에 놓인다.</li>
<li><code>flex-start</code> : 교차축의 시작 부분에 놓인다.</li>
<li><code>flex-end</code> : 교차축의 끝 부분에 놓인다.</li>
</ul>
<blockquote>
<p><code>align-self</code> 속성을 이용하면 flex item 개별 항목을 배치할 수 있다!</p>
</blockquote>
<h1 id="flex-item-순서-정하기">flex item 순서 정하기</h1>
<p>flexbox에는 소스 순서에 영향을 미치지 않고 flex item의 레이아웃 순서를 변경하는 기능도 있다.</p>
<pre><code class="language-css">button:first-child {
  order: 1;
}</code></pre>
<p>!codepen[jangdongho/embed/VwrBwVG?default-tab=html%2Cresult]</p>
<p>본래 첫번째에 자리 잡고있던 &#39;Smile&#39; 단추가 주축의 끝으로 이동했다. 왜 이렇게 됐는지 살펴보자.</p>
<ul>
<li>기본값으로 모든 flex item들은 <code>order</code> 값이 0이다.</li>
<li>순위값이 높은 flex item들은 순위값이 낮은 항목들보다 나중에 나타난다.</li>
<li>&#39;Smile&#39; 버튼이 <code>order</code> 값이 가장 높기 때문에 제일 뒤로 이동했다.</li>
</ul>
<p>그렇다면 &#39;Smile&#39; 버튼을 맨 앞으로 보내려면 어떻게 해야할까? &#39;Smile&#39; 버튼의 <code>order</code> 값을 0보다 작게 해주면 된다.</p>
<h1 id="최종-복습">최종 복습</h1>
<p>!codepen[jangdongho/embed/PoOBogM?default-tab=html%2Cresult]</p>
<p>위의 예제를 하나하나 뜯어보면서 복습해보자. 예제의 HTML의 구조는 다음과 같다. 세 개의 <code>&lt;article&gt;</code> 를 포함하는 <code>&lt;section&gt;</code> 요소가 있다. 세 번째 <code>&lt;article&gt;</code> 은 세 개의 <code>&lt;div&gt;</code> 를 포함하고 있다.</p>
<p>먼저 <code>&lt;section&gt;</code> 의 자식들을 flexbox로 취급해 배치하였다.</p>
<pre><code class="language-css">section {
  display: flex;
}</code></pre>
<p>다음으로 <code>&lt;article&gt;</code> 들에게 각각 200px를 부여하고 남은 빈 공간은 1:1:3만큼 채워넣는다. 그리고 세 번째 <code>&lt;article&gt;</code> 요소의 자식들을 flexbox로 배치하는데 열 방향으로 배치한다.</p>
<pre><code class="language-css">article {
  flex: 1 200px;
}

article:nth-of-type(3) {
  flex: 3 200px;
  display: flex;
  flex-flow: column;
}</code></pre>
<p>다음으로 세 번째 <code>&lt;article&gt;</code> 요소의 첫 번째 <code>&lt;div&gt;</code> 를 선택해서 100px의 최소 높이를 주기 위해 <code>flex:1 100px;</code> 을 사용한다. 그리고 그것의 자식들을(<code>&lt;button&gt;</code> 요소들) flexbox로 배치했다. 이 요소들은 행 방향으로 배치되며, 줄 바꿈을 하고 중심에 정렬한다.</p>
<pre><code class="language-css">article:nth-of-type(3) div:first-child {
  flex:1 100px;
  display: flex;
  flex-flow: row wrap;
  align-items: center;
  justify-content: space-around;
}</code></pre>
<p>마지막으로, 우리는 버튼에 1 auto라는 flex 값을 부여한다. 조금 익숙치 않을텐데, 브라우저 창의 폭을 한번 조정해 보면 이해가 된다. 버튼은 최대한의 공간을 차지하려 하고 동일 선상에 가능한 많은 요소를 놓으려고 한다. 그러나 해당 요소들이 더 이상 동일 선상에 안착할 수 없을 경우 새로운 줄로 밀려난다.</p>
<pre><code class="language-css">button {
  flex: 1 auto;
  margin: 5px;
  font-size: 18px;
  line-height: 1.5;
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[대학가 주변 맛집 소개 <eatGNU> 제작 회고]]></title>
            <link>https://velog.io/@dongho18/eatGNU-%EC%A0%9C%EC%9E%91-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@dongho18/eatGNU-%EC%A0%9C%EC%9E%91-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Mon, 21 Feb 2022 14:05:46 GMT</pubDate>
            <description><![CDATA[<h1 id="eatgnu">eatGNU</h1>
<p><a href="http://eatgnu.kro.kr/">eatGNU 구경하기</a>
<a href="https://github.com/JangDongHo/eatGNU">깃 레포로 이동하기</a></p>
<h2 id="개요">개요</h2>
<p>내가 사이드 프로젝트를 하기로 마음 먹은건 군대에서부터 시작됐다. 다른 선배 개발자분들께서 항상 하는 말이 &#39;이론을 배우고 나서는 항상 무언가를 직접 만들어봐야 실력이 는다&quot; 였다. 나도 이에 적극적으로 동의한다. 예전에 파이썬을 가지고 디스코드 봇을 만들어본 적이 있었는데 이때 무언가 내가 성장하는 느낌을 받았었는데, 마찬가지로 이번에도 그 느낌을 다시 받고싶었다. 그래서 노마드코더 코코아톡 클론코딩 강의(HTML+CSS)를 다  듣고나서 내가 얼마나 이해하고 뭐가 부족한지 점검하고자 사이드 프로젝트를 기획하게 되었다.</p>
<h2 id="아이디어">아이디어</h2>
<p>사이드 프로젝트 아이디어를 짜면서 중점적으로 생각한건 아래와 같다.</p>
<ul>
<li>수요가 있는가? 누군가에게 도움이 되는가?</li>
<li>내가 학습한 것을 온전히 적용시킬 수 있는가?</li>
<li>내가 감당할 수 있는 규모인가?</li>
<li>수익을 창출할 수 있는가?</li>
</ul>
<p>먼저, 처음 떠올린 아이디어는 <code>주식 도우미 웹사이트</code> 였는데, 사실 이건 HTML, CSS를 점검하기 보다는 백엔드쪽이 주를 이루겠다 싶어서 제외했다.</p>
<p>그리고 고심하다 떠올린 것이 <code>맛집 소개 웹사이트</code> 이다. 예전에 부산대에 다니던 친구가 웹사이트에서 먹을만한 식당을 찾고 있길래 뭔가해서 봤더니, 부산대생이 만든 <a href="http://eateatpnu.com/">eatEatPNU</a> 를 보고 있었다. &#39;우리 학교에는 이런걸 만든 사람이 있을까?&#39; 하고 찾아봤는데 없는 것이었다! 마침, 개강을 앞둔 시즌이라 분명히 수요가 있을거라고 생각하고 바로 개발에 착수했다.</p>
<ul>
<li>수요가 있는가? 누군가에게 도움이 되는가? =&gt; <code>대학생 새내기들</code></li>
<li>내가 학습한 것을 온전히 적용시킬 수 있는가? =&gt; <code>단순한 정보 제공 웹사이트이므로, 백엔드보다는 프론트엔드에 집중할 수 있음</code></li>
<li>내가 감당할 수 있는 규모인가? =&gt; <code>크게 부담되지 않는 수준일 것 같았음</code></li>
<li>수익을 창출할 수 있는가? =&gt; <code>광고를 넣을 수도 있지만, 아주 작은 사이드 프로젝트였기에 수익 창출은 과감히 포기하고 깔끔한 웹사이트로 서비스 하기로 마음 먹음</code></li>
</ul>
<h2 id="디자인">디자인</h2>
<p><img src="https://images.velog.io/images/dongho18/post/7026bdad-8ee0-41b7-9cdb-cfb2e2a13498/ezgif.com-gif-maker%20(5).gif" alt="">
디자인은 아무래도 노마드코더 강의를 많이 듣다보니, 노마드코더 홈페이지 UI와 유사한 형태로 가버렸다. <del>(죄송합니다 ㅠㅠ)</del>
중점사항으로는 적은 코드로 어느 기기든 다 콘텐츠가 잘 보이도록 Flexbox를 중점적으로 사용했다.</p>
<p>음식 사진이 차지하는 비중을 제일 크게 하여 시각적인 요소를 부각하였으며, 그 뒤로 가게 이름의 폰트 사이즈를 크게하여 어떤 가게인지 잘 인지할 수 있도록 하였다. 그 외에는, 식당의 부가설명과 태그를 작게 만들어 박스 안에 넣어두었다.</p>
<h2 id="기능">기능</h2>
<p>사이드 프로젝트를 하면서 뼈저리게 느낀 점은 HTML, CSS만으로는 나와 웹사이트를 이용할 사용자들 모두 만족할 수 없겠다는걸 깨달았다. 내가 웹사이트를 첫 배포를 하기 전에 넣고싶었던 필수적인 기능들은 <code>음식 태그 필터링</code> 과 <code>식당 위치를 알려주는 지도</code> 이 두개였는데, 막상 이 두개를 적용하려면 자바스크립트의 이해가 필요해보였다. 그래서, 노마드코더 바닐라JS 강의를 병행하며 이론을 공부했고 그 외에 이해가 안되는 것들은 구글링하며 기능 완성에 집중했다.</p>
<h3 id="음식-종류-필터링">음식 종류 필터링</h3>
<p><img src="https://images.velog.io/images/dongho18/post/76e2358f-6536-4a8c-9ff2-5d2260b95c09/ezgif.com-gif-maker%20(6).gif" alt=""></p>
<p>하나의 html에서 모든 음식을 다 보여주는 구조다 보니, 최소한 음식 종류별로 따로 식당들을 보여줄 수 있게 필터링하는 기능이 없으면 안되겠다고 생각해서 만든 기능이다.</p>
<pre><code class="language-js">const tagBtnContainer = document.querySelector(&#39;.tag-filter__tags&#39;);
const restaurantContainer = document.querySelector(&#39;.restaurant-lists&#39;);
const restaurants = document.querySelectorAll(&#39;.restaurant-list&#39;);
tagBtnContainer.addEventListener(&#39;click&#39;, () =&gt; {
  const filter = 
  event.target.dataset.filter || event.target.parentNode.dataset.filter;
  if (filter == null) {
    return;
  }
  restaurantContainer.classList.add(&#39;anim-out&#39;);
  setTimeout(() =&gt; {
    restaurants.forEach((restaurant) =&gt; {
      if (filter === &#39;*&#39; || filter === restaurant.dataset.type) {
        restaurant.classList.remove(&#39;invisible&#39;);
      } else {
        restaurant.classList.add(&#39;invisible&#39;);
      }
    });
    restaurantContainer.classList.remove(&#39;anim-out&#39;);
  }, 280);
});</code></pre>
<p>사실, 이 코드는 내 것이 아니다. 왜냐하면, 이 기능을 넣을 당시에 자바스크립트 강의를 듣기 전이었고, 무지성으로 구글링하여 억지로 코드를 이해하며 넣은 코드기 때문이다. 지금이야, 바닐라JS 강의를 모두 다 들은 상태라 코드를 보고 이해가 가능하지만, 이 당시에는 지금은 HTML, CSS에 집중하는 사이드 프로젝트니 배포를 마무리 한 후 내 손으로 직접 짜보자고 생각하고 넘어갔다. 그래서, 이 회고록을 작성하고 태그 필터링 부분은 다 지우고 내 방식으로 짜볼 생각이다.</p>
<h3 id="식당-위치-지도로-보여주기-feat-네이버-지도-api">식당 위치 지도로 보여주기 (Feat. 네이버 지도 API)</h3>
<p align="center">
  <img src="https://images.velog.io/images/dongho18/post/a64ae4fc-953f-4a39-aaee-cf5c84c43fee/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202022-02-21%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%209.21.25.png" width="600" height="500"/>
</p>

<p>위에서도 언급했지만, 예전에 디스코드 봇을 잠깐 만든적이 있었는데 그때 API를 사용해본 경험이 있어서 그런지 이 부분을 만드는데 몇 번의 삽질은 있었지만 크게 어렵지 않았다. 네이버 지도 API를 적용시키면서 가장 좋았던건 <a href="https://navermaps.github.io/maps.js/docs/tutorial-digest.example.html">예제</a>가 잘 나와있어서 하나하나 기본 예제부터 실행시켜보며 어떤 식으로 작동하는지 알아보기 편했다.</p>
<h4 id="버튼-실행-함수">버튼 실행 함수</h4>
<pre><code class="language-js">const buttons = document.querySelectorAll(&quot;.restaurant-list&quot;);
const queryMap = document.querySelector(&quot;#map-container&quot;);
const mapQuitBtn = document.querySelector(&quot;#map-quit&quot;);

for (const button of buttons) {
  button.addEventListener(&quot;click&quot;, function(event) {
    const restaurantName = event.path[2].childNodes[5].childNodes[1].innerText;
    const restaurantAddress = event.path[2].attributes[1].value;
    document.body.style.overflow = &quot;hidden&quot;;
    queryMap.classList.remove(&quot;invisible&quot;);
    geocoding(restaurantName, restaurantAddress);
  })
}</code></pre>
<p>먼저, for문을 사용해서 식당 리스트 하나하나에 클릭 이벤트를 부여하여 어떤 식당 버튼을 눌렀는지 정보를 주도록 만들었다. 지금 보니 좀 부끄러운 코드인데, 구글링 안하고 console.dir로 무지성으로 경로를 찾아가면서 만든거라 기괴한 코드가 나와버렸다. <del>(저것보다 더 간단해질 수 있는데..)</del> 이 부분은 나중에 리팩토링 할 때 고쳐야겠다.</p>
<h4 id="식당-좌표값을-어떻게-따올까">식당 좌표값을 어떻게 따올까?</h4>
<p>식당 위치를 지도에 띄우기 위해서는 식당 좌표값이 필요했다. 내가 생각한 방법은 두 가지였다.</p>
<ul>
<li>주먹구구식으로 HTML 태그에 식당 좌표값을 하나씩 다 따와서 넣자!</li>
<li>식당 이름만 넣으면 자동으로 식당 좌표값을 반환하는 함수를 만들어보자!</li>
</ul>
<p>전자로 하기에는... 엄두가 안났다.. 초기 식당 개수만 50개 가까이 됐고, 앞으로 꾸준히 식당 리스트를 업데이트 한다고 생각했을 때 그건 정말 미친 짓이었다! 그래서, 재빠르게 네이버 지도 API 예제 중에 식당 이름을 입력하면 식당 좌표값을 반환해주는 예제가 없는지 찾아보았다. 아쉽게도, 식당 이름만 입력하면 좌표 값을 주는 예제는 없었고 그 대신에 <a href="https://navermaps.github.io/maps.js/docs/tutorial-3-geocoder-geocoding.example.html">&#39;도로명 주소나 지번 주소를 입력하면 좌표로 변환해주는 API 예제&#39;</a>가 있었다. 그래서, 조금 번거롭지만 도로명 주소(지번 주소)까지는 HTML 태그에 직접 찾아서 넣는걸로 타협했다.</p>
<h5 id="좌표-값을-따오자">좌표 값을 따오자!</h5>
<pre><code class="language-js">function geocoding(name, address) {
    var Addr_val = address;

    // 도로명 주소를 좌표 값으로 변환(API)
    naver.maps.Service.geocode({
        query: Addr_val
    }, function(status, response) {
        if (status !== naver.maps.Service.Status.OK) {
            return alert(&#39;Something wrong!&#39;);
        }

        var result = response.v2, // 검색 결과의 컨테이너
            items = result.addresses; // 검색 결과의 배열

        // 리턴 받은 좌표 값을 변수에 저장
        let x = parseFloat(items[0].x);
        let y = parseFloat(items[0].y);
          mapQuitBtn.addEventListener(&quot;click&quot;, quitMap);
        mapGenerator(name, String(y), String(x));
    })
}</code></pre>
<p>식당 좌표 값을 따오는 코드이다. Addr_val 변수에 도로명 주소(지번 주소)를 넣으면 지도 API에서 좌표값을 반환해준다. <del>(대단해!)</del> 사실 여기서 정말 이상한 걸로 큰 애를 먹었는데.. 코드에는 문제가 없다고 확신했는데.. 자꾸 오류가 터지는 것이다. 그래서 엄청난 삽질 끝에 원인을 발견했는데 <code>Web Dynamic Map</code> 서비스만 신청하고 <code>Geocoding</code> 서비스를 신청을 안해서 터진 오류였다.. 멍청이! 그래도 삽질 끝에 해결할 때 느끼는 기쁜 감정으로 개발하는거 아니겠는가? <del>(라고 합리화 해보았다.)</del></p>
<h5 id="지도를-생성하자">지도를 생성하자!</h5>
<pre><code class="language-js">function isMobile() {
    return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}

function mapGenerator(name, la,lo){
  var HOME_PATH = window.HOME_PATH || &#39;.&#39;;
  var location = new naver.maps.LatLng(la,lo),
      map = new naver.maps.Map(&#39;map&#39;, {
          center: location,
          zoom: 19
      }),
      marker = new naver.maps.Marker({
          map: map,
          position: location
      });
  if (isMobile()) {
    var contentString = [
        &#39;&lt;div class=&quot;&quot;&gt;&#39;,
        &#39;   &lt;h5&gt;&#39;+name+&#39;&lt;/h5&gt;&#39;,
        &#39;&lt;/div&gt;&#39;
    ].join(&#39;&#39;);
  }
  else {
    var contentString = [
        &#39;&lt;div class=&quot;&quot;&gt;&#39;,
        &#39;   &lt;h5&gt;&#39;+name+&#39;&lt;/h5&gt;&#39;,
        &#39;   &lt;p&gt;&lt;br&gt;&#39;,
        &#39;       &lt;a target=&quot;_blank&quot; href=&quot;http://map.naver.com/search/가좌동&#39;+name+&#39;&quot; &gt;네이버 지도 바로 가기&lt;/a&gt;&#39;,
        &#39;   &lt;/p&gt;&#39;,
        &#39;&lt;/div&gt;&#39;
    ].join(&#39;&#39;);
  }</code></pre>
<p>좌표값을 따온 뒤에 지도를 생성하는 함수를 넣었다. 아래에 contentString 변수는 지도 마커를 클릭했을 시 띄우는 식당 정보 창인데 식당 이름과 식당의 네이버 지도 주소 링크를 포함하고 있다. 네이버 지도 링크는 네이버 지도의 url의 검색 부분을 변수를 입혀서 자동으로 링크가 생성되게 만들었다. 근데 이 방법은 PC 지도에서만 작동이 되길래 모바일은 아쉽지만 이 기능을 빼버리는걸로 결정했다 (ㅠㅠ)</p>
<h5 id="식당-정보-창을-띄우자">식당 정보 창을 띄우자!</h5>
<pre><code class="language-js">  var infowindow = new naver.maps.InfoWindow({
      content: contentString,
      maxWidth: 300,
      backgroundColor: &quot;#eee&quot;,
      borderColor: &quot;#A4A4A4&quot;,
      borderRadius:&quot;30px&quot;,
      borderWidth: 2,
      disableAnchor:true,
      anchorColor: &quot;#A4A4A4&quot;,
      pixelOffset: new naver.maps.Point(10, -10)
  });

  naver.maps.Event.addListener(marker, &quot;click&quot;, function(e) {
      if (infowindow.getMap()) {
        infowindow.close();
      } else {
        infowindow.open(map, marker);
      }
  });
  setTimeout(function() {
      window.dispatchEvent(new Event(&#39;resize&#39;));
  }, 600);
}</code></pre>
<p>지도에서 마커를 클릭했을 때 뜨는 식당 정보 창을 만드는 object와 감지하고 띄우는 eventlistener 함수이다.</p>
<h2 id="웹-호스팅--도메인-준비">웹 호스팅 &amp; 도메인 준비</h2>
<p>배포를 하면서 생각한 요소는 운영 비용이었는데, 아무래도 이 프로젝트로 수익을 창출할 생각이 없었기 때문에 비용 없이 서비스를 운영하는 쪽으로 생각하게 되었다.</p>
<p>웹 호스팅은 노마드코더 강의에서 접한 <code>github pages</code>를 통해 무료로 웹사이트를 돌릴 수 있었다. 이런 서비스를 무료로 제공해주는 깃허브에게 너무 감사하다.</p>
<p>그런데, 문제는 기본 url이 너무 안 이뻐서 구글링을 해보니 다행히 url을 자체 도메인으로 변경할 수 있었다. 그래서 무료 도메인을 알아봤는데 중고딩 시절에 마인크래프트 서버를 운영할 때 사용했던 <code>내도메인.한국</code>이 아직까지 살아있길래 바로 eatGNU.kro.kr 도메인을 만들어 적용했다. 이 과정은 구글링으로 크게 어렵지 않게 적용시켰다.</p>
<h2 id="성공적인-배포">성공적인 배포</h2>
<p align="center">
  <img src="https://images.velog.io/images/dongho18/post/b73c1f06-2994-4649-a394-7bb7b536055b/KakaoTalk_Photo_2022-02-21-22-29-19.jpeg" width="450" height="600"/>
</p>
이 프로젝트를 기획할 때부터 배포는 에브리타임 새내기게시판에 할 예정이었다. 왜냐하면, 식당들을 잘 알고있는 재학생들 보다는 새내기들에게 더 수요가 있을 것 같다고 생각했기 때문이다.

<p>그렇게 2022년 2월 18일에 내 손으로 만든 첫 웹사이트가 배포되었다. 일명 &#39;핫게&#39;라고 불리는 곳에 들어가려면 추천 수 10개가 필요해 글이 묻히기 전에 내 친구들을 동원해서 추천 수를 빠르게 늘렸다. 핫게에 들어가면 그 뒤로는 저절로 홍보가 될 것이라고 생각했다. 내 생각이 맞았다. 내 글은 5분 정도 후에 핫게에 들어갔고 그 뒤로는 에브리타임 메인 홈에 뜨면서 노출 수가 증가했다. 내가 예상했던 것 보다 더 큰 반응이었다. 첫 날에는 추천수 50개와 스크랩 100개를 받았는데, 두 번째 날에는 실시간 인기글 1위까지 들어가며 추천 수와 스크랩 수가 폭발적으로 늘어났다. 그렇게 2월 21일 기준으로 추천수 123개와 스크랩 수 305개를 받는 기염을 토했다.</p>
<p>금요일에 배포를 했었는데, 주말 동안 이것 때문에 너무 행복했다. 비록 수익이 없더라도, 내 손으로 만든 서비스를 누군가 필요해서 봐주는 것 만으로도 개발자에게는 큰 힘이 되었다. 내 웹페이지가 공개되고 축하와 응원을 해준 내 친구들과 관심을 가져준 재학생들에게 감사하다.</p>
<h2 id="깨달은-점">깨달은 점</h2>
<p>사이드 프로젝트를 진행하면서 내가 생각했던 것보다 많은 것을 깨달았다. 왜 사람들이 사이드 프로젝트를 그렇게 강조하는지 알겠다. 내가 배웠던 이론들을 점검하며 부족한 부분을 알 수 있었고, 필요한 기능을 구현하기 위해서 직접 구글링 해가며 새로운 것들을 학습했다. 그리고, 제일 중요한 것은 <strong>자신감</strong>을 얻었다는 것이다. 사이드 프로젝트를 만들기 전에는 &#39;내가 과연 이걸 만들 수 있을까?&#39; 지레 겁이 났다. 또, &#39;만든다 한들 실패하면 어떡하지? 아무도 관심을 가져주지 않으면 어떡하지?&#39; 라는 생각이 앞섰었다. 그러나, 나는 개발과 배포 모두 성공했고 사용자들에게 좋은 반응을 얻었다. 그러나, 비록 실패하더라도 그 과정에서 많은 것을 깨달을 수 있을 것이라는 생각이 들었다. 그것만으로 사이드 프로젝트는 큰 나의 성장 원동력이 되는 것이다.</p>
<h2 id="아쉬웠던-점">아쉬웠던 점</h2>
<h3 id="아쉬운-코드">아쉬운 코드</h3>
<p>아무래도 강의를 들은 직후에 따로 더 깊게 공부하지 않고 바로 사이드 프로젝트를 진행한거라, 코드가 온전한 내 것이라고 느껴지지 않는다. 다음 사이드 프로젝트를 진행하기 전까지 부족한 부분을 더 공부해서 보완해야겠다.</p>
<h3 id="무지성-개발-과정">무지성 개발 과정</h3>
<p>다음 사이드 프로젝트부터는 다른 개발자들처럼 체계화된 개발을 하고 싶다. 예를 들면 UI/UX 요소를 미리 종이에 그려보든 피그마를 통해 구현해본다던지, 빠르게 프로토타입을 만들어 먼저 소수의 사용자들에게 배포해 반응을 살펴본다든지, 노션을 통해 오늘의 할당량을 정해서 개발해보고 싶다.</p>
<h2 id="마무리하며">마무리하며</h2>
<p>비록 개발 과정이 순탄치는 않았지만 많은 것을 느낄 수 있었던 첫 사이드 프로젝트였다.
다음 사이드 프로젝트에서는 더 성장한 나를 기대하며 글을 마무리한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CSS] 보통 흐름 (normal flow)]]></title>
            <link>https://velog.io/@dongho18/CSS-%EB%B3%B4%ED%86%B5-%ED%9D%90%EB%A6%84-normal-flow</link>
            <guid>https://velog.io/@dongho18/CSS-%EB%B3%B4%ED%86%B5-%ED%9D%90%EB%A6%84-normal-flow</guid>
            <pubDate>Mon, 21 Feb 2022 00:59:09 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 normal flow, 즉 내가 요소의 레이아웃을 변경하지 않았을 시 웹페이지 요소가 자기 자신을 스스로(?) 배치하는 방법에 관해 알아볼 것이다!</p>
<p><img src="https://t1.daumcdn.net/cfile/blog/21048946563B2EC405" alt="">
<del>(대충 스폰지밥이 보통흐름을 설명 하는 짤로 시작하겠다.)</del></p>
<p>보통 흐름은 웹 페이지의 &#39;순정&#39; 이라고 생각하면 된다. 즉, CSS를 적용하지 않은 경우 웹 페이지의 요소는 normal flow로 배치된다. 개발자가 따로 &quot;넌 이렇게 배치돼라!&quot; 라고 하지 않아도, 자기만의 규칙들을 가지고 알아서 배치된다는 것이다!</p>
<h1 id="블록-레벨-요소-vs-인라인-요소">블록 레벨 요소 vs 인라인 요소</h1>
<p>HTML(Hypertext Markup Language)의 요소는 역사적으로 &quot;블록 레벨&quot; 요소와 &quot;인라인&quot; 요소로 분류됐다.</p>
<p>블록 레벨 요소는 부모 요소의 <strong>전체 공간</strong>을 차지하여 <strong>블록</strong>을 만든다. 쉽게 설명하면 이 친구는 욕심이 그득해서 자기가 위치한 곳 한 줄을 다 차지해버린다. 아래 예제를 보면 이해가 쉬울 것이다.</p>
<blockquote>
<p>블록 레벨 요소는 언제나 새로운 줄에서 시작하고, 좌우 양쪽으로 최대한! 늘어나 가능한 모든 너비를 차지한다.</p>
</blockquote>
<p>!codepen[jangdongho/embed/zYPWJEV?default-tab=html%2Cresult]</p>
<p>자, 그럼 인라인 요소는 무엇일까? 인라인 요소는 콘텐츠의 흐름을 끊지 않고, 요소를 구성하는 태그에 할당된 공간만 차지한다. 쉽게 설명하면 이 친구는 욕심이 없고 자기 분수를 알아서 자기 사이즈만큼만 너비를 차지한다.</p>
<blockquote>
<p>인라인 요소는 새로운 줄을 만들지 않으며 필요한 너비만 차지한다.</p>
</blockquote>
<p>!codepen[jangdongho/embed/qBVoMpd?default-tab=html%2Cresult]</p>
<p>블룩 수준 요소의 내용물은 자기 부모 요소의 너비 100%와 자체 내용물의 최대 높이가 되며, 인라인 요소는 자체 내용물의 최대 높이를 취하는 동시에 최대 너비를 취한다. 당연하게도 인라인 요소는 너비와 높이를 설정할 수 없다!</p>
<p>인라인 요소의 크기를 제어하고 싶다면 그것을 <code>display: block;</code> 속성값이나 양쪽의 성격이 혼합된 <code>display: inline-block;</code>을 가지고 블럭 수준 요소처럼 행동하도록 설정할 필요가 있다.</p>
<p>!codepen[jangdongho/embed/JjOLavd?default-tab=html%2Cresult]</p>
<h1 id="마진-축소-margin-collapsing">마진 축소 (margin collapsing)</h1>
<p>두 개의 인접 요소가 모두 자체 여백이 지정되어 있다면 두 여백은 접촉하고 그중 큰 여백만 남게 되고, 작은 여백은 사라지는 이상한 규칙이 있는데, 이를 마진 축소(margin collapsing)라고 한다. 이 내용은 추후에 따로 글을 쓸 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CSS] 레이아웃에는 어떤 것들이 있나?]]></title>
            <link>https://velog.io/@dongho18/CSS-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EC%9E%85%EB%AC%B8%EC%84%9C</link>
            <guid>https://velog.io/@dongho18/CSS-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EC%9E%85%EB%AC%B8%EC%84%9C</guid>
            <pubDate>Thu, 03 Feb 2022 05:43:24 GMT</pubDate>
            <description><![CDATA[<p>CSS 레이아웃 기술은 저마다 용도가 있고, 장단점이 있으며, 어떤 기술도 독립적인 용도를 갖추도록 설계되지 않았다. 각 메서드가 어떤 용도로 마련된 것인지 이해한다면 해당 작업에 가장 적합한 도구가 어떤 것인지 파악하는데 유리해진다!</p>
<h1 id="보통-흐름normal-flow">보통 흐름(normal flow)</h1>
<p>먼저 내가 페이지 레이아웃을 1도 안건드렸을 때, 브라우저가 기본값으로 HTML 페이지를 배치하는 방법을 보통 흐름이라고 한다.</p>
<p>!codepen[jangdongho/embed/podLOgV?default-tab=html%2Cresult]</p>
<p>보시다시피 소스 코드 위에서 아래로 HTML 요소가 그대로 표시된다. 요소 집합이 상대 요소 바로 아래 나타나는 것을 <strong>block 요소</strong>라고 한다. (반대 개념은 inline 요소)</p>
<p>CSS를 사용해서 레이아웃을 만들 경우 이 보통 흐름에서 벗어나도록 하는 것이다. 그러니 잘 구조화된 HTML 문서에서 시작해서 CSS 기술을 잘 써서 코드랑 싸우는게 아니라 협력해서 작업할 수 있도록 만들어보자...</p>
<h1 id="display">display</h1>
<p>디스플레이 속성은 요소가 표시되는 기본값 변경을 허용한다. 모든 요소는 고유의 display 속성값을 가지고 있다. 대표적으로 p 태그는 block 요소이고, span 태그는 inline 요소이다.</p>
<p>당신은 전지전능하기 때문에 어느 요소라도 display 속성값을 변경할 수 있다. 한 항목을 block에서 inline으로 변경하거나, 그 반대로도 바꿀 수 있다. 요소가 보여지는 방식을 변경할 수 있다는 의미다!</p>
<p>근데 저 두개만 가지고 웹사이트 절대 못만들꺼다. 그래서 확대된 형태의 레이아웃 메서드가 2개 있다. 바로바로 <code>display: flex</code>와 <code>display: gird</code>이다.</p>
<h1 id="flexbox">Flexbox</h1>
<p><img src="https://res.cloudinary.com/practicaldev/image/fetch/s---3gDSFf1--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/i/fsln7je4ax7ft3er28hh.png" alt="">
flexbox는 일차원 상에 사물을 배치할 경우 써먹으면 편하다. 내가 진열하고 싶은 요소들의 <strong>부모 요소</strong>에 <code>display: flex</code>를 적용하고나면 그 자식들이 플렉스 항목이 된다. 이해를 돕기 위해 예시를 보자.</p>
<p>!codepen[jangdongho/embed/rNYdZOQ?default-tab=html%2Cresult]</p>
<p>이렇게 부모 요소 wrapper에 flex를 부여하면 세개 항목이 자체적으로 단으로 배열된다. 왜나하면 이들이 _가변 항목_이 되었을 뿐만 아니라 flexbox가 그들 요소에 부여한 일부 초기값을 사용했기 때문이다. 그 중 하나가 바로 <code>flex-direction: row</code>인데 우리가 지정하지는 않아도 저 속성은 기본값이므로 요소들이 행으로 표시됐다.</p>
<p>더 놀라운건 위치를 변경하는 것 외에도 항목을 <strong>변형</strong>시킬수도 있는데, 위의 예제에서는 박스 오른편에 빈 공간이 있지 않는가? 이걸 빈공간 없이 다 채워버릴 수도 있다.</p>
<p>!codepen[jangdongho/embed/xxPWaZN?default-tab=html%2Cresult]</p>
<p>간단한 예로 자식 항목 전체에 대한 flex 속성에 대해 속성값 1을 부여한다면, 컨테이너 말미에 공간을 남기지 않고 항목 무리 전체가 확대되거나 채워지도록 만들어 버린다! 신기하지 않은가? 그 요소가 뭐가됐건 동일한 공간 점유를 위해 크기가 강제로 조종된다!</p>
<h1 id="그리드-레이아웃">그리드 레이아웃</h1>
<p><img src="https://blog.kakaocdn.net/dn/BLy2n/btqDg6u92Aj/SFkzML8yCqmkTs63zBelk1/img.png" alt="">
flexbox가 일차원 레이아웃을 위해 마련됐다면, 그리드 레이아웃은 이차원 레이아웃을 위해 마련됐다!</p>
<p><code>display: gird</code>로 그리드 레이아웃으로 전환할 수 있다.</p>
<p><code>gird-template-rows</code>와 <code>gird-template-columns</code>라는 개별 속성을 이용해서 부모 요소를 상대로 행과 열 궤도를 정의한다. 아래의 예제는 <code>1fr</code> 값이 지정된 3열과 <code>100px</code>이 지정된 2행을 정의했다. 쉽게 설명하자면 가로 길이는 1:1:1로 3열을, 세로 길이는 100px로 2행을 만들라는 얘기다.</p>
<p>!codepen[jangdongho/embed/QWOmVNa?default-tab=html%2Cresult]</p>
<p>항목 무리를 이렇게 자동 배치하는게 아니라 원하는대로 위치도 지정해줄 수 있다.</p>
<p>!codepen[jangdongho/embed/KKyMqWB?default-tab=html%2Cresult]</p>
<p>어떤가? 신기하지 않는가? 너무 편리하다!!</p>
<p>중요한 레이아웃 요소 두개는 배웠고 이제 나머지 부분은 페이지의 주요 레이아웃 구조로 보기엔 덜 중요하지만 특정 작업을 수행하는 데 여전히 도움이 될 수 있는 다른 레이아웃 방법들이 있다.</p>
<h1 id="floats">Floats</h1>
<p>요소를 부동시키면 요소는 왼쪽 또는 오른쪽으로 이동하고 보통 흐름(normal flow)에서 벗어나게되며 주변 콘텐츠는 부유된 항목 주위로 떠다닌다.</p>
<p>이 <code>float</code> 속성은 네 가지 값을 가진다.</p>
<table>
<thead>
<tr>
<th>값</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>left</td>
<td>요소를 왼쪽에 띄운다.</td>
</tr>
<tr>
<td>right</td>
<td>요소를 오른쪽에 띄운다.</td>
</tr>
<tr>
<td>none</td>
<td>부동 여부 지정 X (기본값)</td>
</tr>
<tr>
<td>inherit</td>
<td>부동 속성의 값이 요소의 부모 요소에서 상속된다.</td>
</tr>
</tbody></table>
<p>!codepen[jangdongho/embed/zYPWJqb?default-tab=html%2Cresult]</p>
<p>원래라면 박스 아래에 와야할 글자들이 박스 주변을 감싸고 있는 형태로 변했다!</p>
<h1 id="position">Position</h1>
<p>포지셔닝을 통해 보통 흐름(normal flow)속에 있는 요소를 기존의 배치 위치에서 벗어나 다른 위치로 이동시킬 수 있다. 포지셔닝은 보통 웹페이지의 큰 레이아웃을 조정할 때 쓰는게 아닌, 특정 항목을 미세하게 조정할 때 보통 많이 사용한다.</p>
<p>우리가 알야아 할 다섯 가지 포지셔닝 유형은 아래와 같다.</p>
<table>
<thead>
<tr>
<th>유형</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>정적 포지셔닝</td>
<td>레이아웃을 특별히 건들지 않겠다라는 의미</td>
</tr>
<tr>
<td>상대 포지셔닝</td>
<td>보통 흐름(normal flow)를 따라가며, 자기 자신의 위치를 기준으로 움직인다.</td>
</tr>
<tr>
<td>절대 포지셔닝</td>
<td>보통 흐름(normal flow)를 무시하며, 상대 포지셔닝을 가지고 있는 부모 요소를 기준으로 움직인다. (없다면 body를 기준으로 움직인다.)</td>
</tr>
<tr>
<td>고정 포지셔닝</td>
<td>다른 요소가 아닌 브라우저 뷰포트 기준으로 움직인다. 요소를 특정한 위치에 고정시키고 싶을 때 유용하게 쓰인다.</td>
</tr>
<tr>
<td>스티키 포지셔닝</td>
<td>평소에는 정적 포지셔닝을 가지고 있다가 설정한 위치에 다다르면 고정 포지셔닝을 가지는 새로운 포지셔닝 메서드이다.</td>
</tr>
</tbody></table>
<h2 id="정적-포지셔닝">정적 포지셔닝</h2>
<p>!codepen[jangdongho/embed/PoORdzp?default-tab=html%2Cresult]</p>
<h2 id="상대-포지셔닝">상대 포지셔닝</h2>
<p>!codepen[jangdongho/embed/JjOLaKg?default-tab=html%2Cresult]</p>
<p>위의 예제를 보면 두 번째 블럭의 위치가 <code>top: 30px</code> 와 <code>left 30px</code> 때문에 <strong>자기 자신의 기존 위치에서</strong> 아래로 30px, 오른쪽으로 30px 이동한 것을 볼 수 있다. 그 방향으로 가는게 아니라, 지정한 방향으로 부터 밀린다는 개념으로 생각하면 편할 것 같다.</p>
<h2 id="절대-포지셔닝">절대 포지셔닝</h2>
<p>절대 포지셔닝은 보통 흐름(normal flow)에서 요소를 완전히 제거하고 부모 요소의 가장자리로부터 움직인다. 여기서 부모 요소는 상대 포지셔닝이어야 하며, 만약 없다면 body를 부모 요소로 삼는다.</p>
<p>!codepen[jangdongho/embed/KKyoxNR?default-tab=html%2Cresult]</p>
<p>위의 예제는 기존의 보통 흐름을 무시한 채 자신의 부모 요소(body)의 좌측 최상단을 기준으로 위에서부터 30px, 왼쪽에서부터 30px만큼 움직였다.</p>
<h2 id="고정-포지셔닝">고정 포지셔닝</h2>
<p>고정 포지셔닝도 절대 포지셔닝과 같은 방식으로 보통 흐름을 무시한다. 그러나, 다른 점은 부모 요소가 기준이 아닌 뷰포트(화면)을 기준으로 적용된다. 항상 스크린을 기준으로 움직이기 때문에 페이지를 스크롤 해도 고정이 되는 것이다!</p>
<p>!codepen[jangdongho/embed/LYOdJRY?default-tab=html%2Cresult]</p>
<h2 id="흡착-포지셔닝">흡착 포지셔닝</h2>
<p>흡착 포지셔닝은 정적 포지셔닝과 고정 포지셔닝을 합친 것으로 보면 된다. 우리 지정해둔 위치까지 도달하기 전까지는 보통 흐름으로 있다가, 지점에 도달하면 <code>position: fixed</code>가 적용된 것처럼 &quot;철썩&quot; 달라 붙게 된다. 아래의 예제를 참고하자.</p>
<p>!codepen[jangdongho/embed/KKyoxgR?default-tab=html%2Cresult]</p>
<h1 id="테이블-레이아웃">테이블 레이아웃</h1>
<p>HTML 테이블은 표로 나타낸 데이터를 표시하기에는 무난했다. 그러나 웹 개발자들은 머리글, 바닥글, 서로 다른 단 등을 여러가지 테이블 행과 열에 집어넣어 전체 레이아웃을 짜는데 테이블을 사용하기도 했다.</p>
<h1 id="다단-레이아웃">다단 레이아웃</h1>
<p>다단 레이아웃은 텍스트가 신문지상에 나열되는 방식과 비슷하게 내용을 단 형태로 레이아웃할 수 있는 방법을 제공한다.</p>
<p>한 블록을 다단 컨테이너 속으로 들여넣으려면 <code>column-count</code> 속성을 사용하여 브라우저에게 우리가 몇 단으로 나누길 원하는지 밝히거나 <code>column-width</code> 속성을 사용하여 브라우저에게 몇 단이 됐건 최소 해당 너비만한 단으로 컨테이너를 채우라고 말하면 된다.</p>
<p>!codepen[jangdongho/embed/qBVoMqW?default-tab=html%2Cresult]</p>
<h1 id="마무리하며">마무리하며</h1>
<p>CSS 레이아웃은 파면 팔수록 정말 많이 있는 것 같고 연구를 많이해야 할 것 같다. 그래서 다음 글들은 위에서 다뤘던 레이아웃들을 개별적으로 하나씩 웹문서를 참고하며 정리해볼 생각이다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Slog] #1 나만의 Task 배분 방법]]></title>
            <link>https://velog.io/@dongho18/Slog1</link>
            <guid>https://velog.io/@dongho18/Slog1</guid>
            <pubDate>Wed, 15 Dec 2021 14:08:44 GMT</pubDate>
            <description><![CDATA[<h2 id="최대한-변화시키지-말자">최대한 변화시키지 말자!</h2>
<p>군대에서 끊임없이 고민한 것이 있다. </p>
<blockquote>
<p>&#39;내가 전역 하고나서 개발자가 되기 위해서 해야만 하는 수많은 공부들을 어떻게하면 번아웃 없이 해낼 수 있을까?&#39;</p>
</blockquote>
<p>일단 내가 떠올린 방법 중 하나는 내 몸은 군대에 익숙해져있으니, 사회에 나가서도 최대한 비슷한 환경으로 사는 것이다. 왜냐하면, 내 몸이 큰 변화를 느끼면 두려움이 생기게 되고 이는 꿈을 이루기 위한 걸림돌이 될 것이다. 그래서 뇌가 변화를 느끼지 못하도록 내 생활패턴을 군대와 최대한 비슷하게 맞출 것이다. 그리고 필요하다면, 아주 천천히 계획을 수정해 나갈 것이다.</p>
<hr>
<h2 id="시간-계획">시간 계획</h2>
<h3 id="평일">평일</h3>
<table>
<thead>
<tr>
<th align="center">활동시간</th>
<th align="center">To Do</th>
<th align="center">세부</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><strong>0630</strong></td>
<td align="center">기상</td>
<td align="center">군대 기상시간과 동일</td>
</tr>
<tr>
<td align="center"><strong>0630~0830</strong></td>
<td align="center">추가 자유시간</td>
<td align="center">환기실시, 도수체조, 미라클모닝 루틴, 아침식사, 세면세족</td>
</tr>
<tr>
<td align="center"><strong>0830~1130</strong></td>
<td align="center">오전 업무</td>
<td align="center">아주 죵요한 일부터 처리</td>
</tr>
<tr>
<td align="center"><strong>1130~1300</strong></td>
<td align="center">점심식사</td>
<td align="center">점심식사 후 낮잠 혹은 잡일 처리</td>
</tr>
<tr>
<td align="center"><strong>1300~1600</strong></td>
<td align="center">오후 업무</td>
<td align="center">그 다음으로 중요한 일 처리</td>
</tr>
<tr>
<td align="center"><strong>1600~1700</strong></td>
<td align="center">운동</td>
<td align="center">맨몸운동 (팔굽, 윗몸, 턱걸이, 싸이클 혹은 러닝)</td>
</tr>
<tr>
<td align="center"><strong>1700~21:00</strong></td>
<td align="center">자유시간</td>
<td align="center"></td>
</tr>
<tr>
<td align="center"><strong>21:00~24:00</strong></td>
<td align="center">자율 업무</td>
<td align="center">일일결산, Velog 글 작성, 자기계발 위주</td>
</tr>
<tr>
<td align="center">### 주말</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">⭐ 주말은 평일동안 쌓였던 피로들을 푸는 날로, 빡빡한 일정은 절대 잡지 않는다.</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">⭐ 친구들과의 약속은 대개 주말에 잡는다.</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">Why? 평일에 무리하게 약속을 잡으면 루틴 흐름이 깨지기 때문!</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">⭐ 친구들과의 약속이 없는 날이면 낮잠을 자거나 혼자 맛집을 탐방해보거나, 문화생활을 할 것이다.</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">___</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">## 태스크(Task) 배분</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">매일 밤 9시 일일결산 시간에 그 다음날 내가 무엇을 해야하는지 한곳에 가감없이 적어본다. 어제 하지 못한 일, 머릿 속에 떠오르는 일, 회사 일, 개인적인 일 모두 다 상관없다.</td>
<td align="center"></td>
<td align="center"></td>
</tr>
</tbody></table>
<h3 id="시간투자-매트릭스">시간투자 매트릭스</h3>
<p>내가 적은 업무들을 하나하나 들여다보며, 우선순위를 정한다.</p>
<blockquote>
<p>A. 긴급하지만 중요한 일 (소비의 시간)
B. 긴급하진 않지만 중요한 일 (투자의 시간)
C. 긴급하지만 중요하지 않은 일 (낭비의 시간)
D. 긴급하지도 중요하지도 않은 일 (허비의 시간)</p>
</blockquote>
<p>이 네가지 중에서 C랑 D는 가차없이 해야 할 Task에서 배제시킨다.
A는 가장 중요한 일이니 오전 업무 시간에 해치우고,  B는 그 다음으로 중요한 일이니 오후 업무 시간에 해치운다.</p>
<h3 id="나만의-기준">나만의 기준</h3>
<p>우선순위를 정할 때 참고하는 나만의 기준이 한가지 더 있는데, 아래의 다섯가지다.</p>
<blockquote>
</blockquote>
<h3 id="⚽-1순위---건강">⚽ 1순위 - 건강</h3>
<p>활기차고 체력이 좋아 성장하는데 제약이 없는 상태</p>
<h3 id="💻-2순위---전공">💻 2순위 - 전공</h3>
<p>탄탄한 전공과정 이수로 내가 바라는 개발자의 삶을 향해 다가가고 있는 상태</p>
<h3 id="📚-3순위---자기계발">📚 3순위 - 자기계발</h3>
<p>매사 긍정적인 사고를 유지하며, 어려움이 닥쳐오면 지혜롭게 해결할 수 있게 대비가 된 상태</p>
<h3 id="💸-4순위---재정">💸 4순위 - 재정</h3>
<p>경제적인 제약 없이 하고 싶은 것을 원하는 때에 할 수 있는 상태</p>
<h3 id="🤼-5순위---관계">🤼‍ 5순위 - 관계</h3>
<p>주변 사람들에게 선한 영향력을 끼치는 매력적인 사람</p>
<p>우선순위가 &#39;A&#39;로 동일하더라도, &#39;관계&#39;에 관련된 Task는 절대 &#39;건강&#39;과 관련된 Task를 거스를 수 없다.</p>
<hr>
<h2 id="정리하고-실천에-옮기자">정리하고 실천에 옮기자</h2>
<p>아무리 계획을 주구장창 짠다한들 실천으로 옮기지 않는다면 쓸모없다. 그래서 Task 배분 단계가 끝나면 정리 단계로 돌입한다. 처음에는 캘린더 같은 디지털 앱으로 정리했으나, 교보문고에서 김유진님의 [일어나라, 삶이 바뀐다 0430 TIME TO PLAN]을 접한 이후로는 플래너에 정리중이다. 캘린더는 먼 계획이나 중요한 일정들을 기록해두고 까먹지 않게 리마인드 시키는 용도로 사용중이다.</p>
<p>플래너가 정리됐으면, 다음날 아침부터는 플래너를 보고 그대로 실천에 옮긴다. 그리고 또 밤이 되면 그 날 태스크를 잘 정리했는지 스스로 평가하고, 다음 날에 무엇을 할지 생각하는 것으로 하루를 마무리한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Slog] #0 나의 삶은 군대에서 바뀌었다]]></title>
            <link>https://velog.io/@dongho18/Slog0</link>
            <guid>https://velog.io/@dongho18/Slog0</guid>
            <pubDate>Sun, 21 Nov 2021 02:35:03 GMT</pubDate>
            <description><![CDATA[<Blockquote><h5><span style="color:gray">'20/7~'22/1 까지 군대에서 병생활을 하며 깨달은 생각들을 글로 남기고 싶어서 작성한 글입니다. <br>이 글을 막연하게 "나는 ~가 될꺼야!"라고만 생각하고 그 꿈을 이루기 위해 구체적인 계획을 세우지않고 허송세월 대학생활만 하다가 군 입대를 앞두고 있는 대학생들에게 바칩니다.</span></h5></Blockquote>

<h2>군 입대 전</h2>
거의 모든 대학생들이 그렇듯 나도 입대를 앞두고 엄청난 회의감과 무기력함에 빠져 코딩을 몇 달간 놓게 되고, 그 당시 유행했던 롤토체스라는 게임을 매일 5~6시간씩 했다.<del>(얼마나 많이 했던지 그랜드마스터를 달았다는건 안비밀..)</del>

<hr>
<h2>군생활 초기</h2>
나의 군생활 초기는 말 그대로 매우 '소극적'이였다. 신교대에 입소해 어영부영 군인의 생활에 적응하고 통신병으로 '징집'되어 자대에 배치되었다. 근데 자대에 도착하자마자 만난 선임분들이 하나같이 하는 소리가 있다.
  <Blockquote><em>"여기 통신 일은 좀 빡셀꺼야" , "OOO 중사 만나면 피해"</em></Blockquote>
처음에는 무슨 소리인지 이해가 안갔지만, 차차 자대 생활을 하다보니 이해가 되었다. 생전 처음 들어보는 터무니 없는 인신공격을 이병~일병때 다 들어본 것 같다.(마음의 편지는 소용 없었다.) 너무 힘들었다. 조그마한 실수를 해도 욕을 하며 소리를 질렀고, 어느 날은 너무 서러워 혼자 끙끙대며 눈물을 훔치기도 했다. 일과 시간 내내 욕을 먹지 않기 위해 긴장을 했고, 새벽 간 근무를 서고 근무취침을 받는 날이면 욕을 먹지 않아도 돼서 좋다고 생각할 정도였다. <br><br>그렇게 시간이 지나고 그 간부는, 내가 일병 말쯤 되던 날에 전출을 갔다.

<hr>
<h2>인생 몸무게를 찍다</h2>
<img src="https://images.velog.io/images/dongho18/post/0530bd2b-4bef-4fa1-9c6c-0a887eea65b0/image.png" width="90%" height="30">
군 생활을 하면서 받은 스트레스는 주로 '폭식'으로 풀었다. 군필들이라면 모두 알만한 PX 냉동 식품들을 한번씩은 다 먹어본 것 같다. 체력단련은 '코로나' 시대라는 이유로 자율적으로 시행했다. 매일 같이 라면, 냉동을 먹고 체력단련도 하지 않으니 내 몸무게는 어느 새 귀신같이 불어나있었다.

<p>정말 이렇게는 안되겠다 싶어서 다이어트를 결심하고, 무작정 냅다 뛰기 시작했다. 3km를 한번도 안 쉬고 뛰려고 하니 죽을 맛이었다. 처음에는 뛰다가 계속 중간중간 멈추기도 하고, 너무 힘들어 다 뛰지도 못하고 포기했다. 그래도 &#39;체중감량&#39;이라는 목표와 &#39;멈추더라도 매일 뛰려는 노력을 하자&#39;라는 생각으로 열심히 뛰었다. 비가 오는 날을 제외하고는 거의 매일 같이 뛰었고, 이는 <strong>습관</strong>으로 자리 잡았다. 하늘도 나의 뜻을 알아주었는지, 한달쯤을 그렇게 지내고 나니 어느 새 (느리지만) 3km를 한번도 쉬지 않고 뛰어서 완주하는데 성공했다. 이때 아마.. 18분 30초가 나왔던 것 같다. 형편없다고 생각하나? 그래도 나는 완주를 했다는 것에 너무 기뻤다!<br><Blockquote>나는 3km 뜀걸음 완주를 통해 <b>&#39;성취감&#39;</b>을 배웠다.<br>그리고, <b>&#39;습관&#39;</b>의 중요성을 깨달았다.</Blockquote></p>
<hr>
<h2>코딩을 하다 징계를 받다</h2>
<img src="https://images.velog.io/images/dongho18/post/2e365ac3-cf70-461f-9251-1ea673ed3582/image.png" width="90%">
나는 부대에서 상황병 근무를 섰었는데, 근무 간에는 상황병이 쓸 수 있는 개인 PC가 있었다. 그 당시 우리 부대의 다른 근무들과는 다르게 상황병들은 짜잘한 업무들이 많아서 업무 인수인계철을 통해 업무를 배웠는데, 만들어진지 오래 됐는지 실제 업무들과 하는게 조금 달랐다. 그래서, 나는 나만의 '체크리스트'를 한글 파일로 하나 만들어서 새로운 업무가 추가되거나 없어질 때마다 최신화를 하곤 했다.

<p>그런데 그 체크리스트가 쓰기에 좋았는지, 상황병 대부분이 내 체크리스트를 보면서 근무를 섰다. 자잘한 업무가 많다보니 간간히 몇개를 까먹고 안한 적이 많았는데, 체크리스트를 보면서 하면 빼먹지 않고 다 처리할 수 있어서 좋았다. 사람들이 많이 이용해주니 괜히 별거아닌데 뿌듯하고 좋았다. <b>그런데.. 여기까지만 했으면 좋았을텐데.. 넘으면 안되는 선을 넘어버렸다.</b></p>
<p>어느 날, 나는 업무 인수인계철을 한글 파일 말고 html, css를 사용해서 만들어보면 어떨까? 라는 엉뚱한 생각을 하게 됐다. 이 생각을 하게 된 계기가, 상황병들에게 더 나은 업무환경을 만들어주기 위함도 있었고 특히 내 스스로 공부도 되니 일석이조라고 생각했다. 그리고, 상황병 PC는 몇년간 대대손손 물려져와서 전역자들이 남기고 간 파일들이 여럿 있었는데 그 중에는 html, css를 활용한 전역일 계산기 웹사이트도 있었다. 그래서 보안에 위배되지 않는 선에서 html을 활용해 개인적인 웹사이트를 만드는건 크게 문제가 안될 것 같다고 생각해 바로 책을 사서 개발에 착수했다.</p>
<p>그렇게 며칠이 지났을까, 갑자기 육군본부 사이버방호실에서 상황병 PC에서 바이러스가 검출이 됐다고 전화가 왔다. 대대적인 조사가 시작됐지만, 바이러스의 근본적인 원인은 찾지 못했지만 조사 과정에서 내가 만든 웹사이트가 발견됐고 보안 규정에 위배되는 행위이니 부대에서 자체적으로 징계를 하라고 지시했다. 다행히, 웹사이트를 제작한 목적이 악의적이지 않았고 지휘관님을 비롯한 담당 간부님들이 나를 좋게봐주셔서 큰 처벌은 면할 수 있었다.<br></p>
<blockquote>나는 <b>'계획적인 사람'</b>이라는 것을 깨달았다.<br>그리고 힘든 일이 생겼을 때, 나를 믿고 도와주는 간부님들과 위로해주는 동기들을 보며 <b>'관계'</b>의 중요성도 알게 됐다.</blockquote>

<hr>
<img src="https://images.velog.io/images/dongho18/post/d809db57-2f6c-4a34-acbb-0406a121be31/image.png" width="90%">
<h2>책 하나 안 읽던 내가 변했다</h2>

<p>나는 군대에 가기 전에 책 한권 읽지 않던 사람이었다. 남들이 많이 읽은 책들을 읽었지만 머리에 내용도 안들어와 따분하고, 읽어도 내 삶에서 무언가 바뀌지 않으니 유익하지 않다고 생각했다. 그러던 어느 날 <strong>&#39;그럼 내가 읽고 싶은 책을 찾아서 봐볼까?&#39;</strong>라는 생각을 했다. 그래서 부대 북카페에서 재밌어보이는 책을 찾아다녔다.</p>
<p>뭔가 성장하고 싶다는 생각이 있었는데 책장 구석에 있던 &#39;하루 5분 나를 성장시키는 메모 습관의 힘&#39;이라는 책을 꺼내들었다. <strong>나의 첫 자기계발 책이었다.</strong> 그 책은 메모를 하면 삶이 달라진다는 내용이었는데, 인상깊은 내용이 많아 바로 다음 날 볼펜과 개인 노트를 사서 책의 내용을 필사하기 시작했다. 뭔가 깨닫는 것도 많고 머리에 쏙쏙 들어오니 독서가 재밌어지기 시작했다. <strong>그렇게 그 책을 시작으로 나의 삶은 완전히 달라졌다.</strong></p>
<p>이후 나는 남들이 다 읽는 책이 아닌 내가 필요하고, 읽고 싶은 책을 쭉 읽었다. 내가 읽고 싶은 책을 읽으니 일단 재미가 있었고, 완독하려고 노력했다. 완독은 큰 성취감을 주고, 그것이 원동력이 되어 다른 책을 또 완독하게 만들었다.
중요하거나 흥미로운 내용은 독서 노트에 필사하고, 그 문장을 보며 떠오르는 생각을 추가로 적고, 내 삶에 도움이 될 것 같으면 실천했다.</p>
<hr>
<h2>그 밖에 내가 얻은 것들</h2>

<blockquote>
<ol>
<li><strong>소중한 사람을 얻었다.</strong>
내가 군대에서 만난 동기들은 나에게 큰 영향을 끼쳤다. 군생활 내내 부모님 다음으로 큰 버팀목이 되어주었고, 동기들을 통해 &#39;나&#39; 자신이 어떤 사람인지 알 수 있었다. 군대에서 좋은 사람들을 만났다면, 꼭 전역할 때까지 싸우지말고 내 곁에 둬라. 큰 힘이 될 것이다.</li>
<li><strong>긍정적인 사고방식을 얻었다.</strong>
군생활 초기에는 누군가 무언가를 시키거나, 예상치 못한 일들이 있을 때 큰 스트레스를 받았는데 이는 부정적인 생각에서 시작되는 것 같다. 이를 예측하고, 통제하고, 긍정적으로 생각한다면 이는 큰 문제가 되지 않는다는걸 깨달았다.</li>
<li><strong>과거에 얽매이지 않기로 했다.</strong>
내가 과거에 무슨 사람이었든 간에 그것은 중요하지 않다고 생각한다. 중요한 것은 현재의 자신이다. 내가 지금 이 순간에 무엇을 하느냐에 따라 미래가 결정된다는 것을 깨달았다.</li>
</ol>
</blockquote>
<hr>
<h2>이제 시작이다</h2>
이제 전역이 얼마 남지 않았다. 나에게 전역은 '새로운 기회의 시작'이다. 군대에서 정말 많은 것을 깨달았고, 얻었다. 그에 대한 보답으로, 달라진 나를 만들겠다. 끊임없이 공부하여 '개발자'로서 성공할 것이며, 경제적인 자유를 찾을 것이며, 좋은 관계를 꾸려나갈 것이다. 그 과정을 velog를 통해 공유할 것이며, 나와 비슷한 길을 걸어가는 사람들에게 조금이나마 도움이 됐으면 좋겠다. 혹여나, 현역 개발자분들이 이 글을 본다면 나에게 따끔한 충고도 아끼지 말아달라!]]></description>
        </item>
    </channel>
</rss>