<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>hyeok_</title>
        <link>https://velog.io/</link>
        <description>부담 없이 질문하고 싶은 개발자가 목표입니다.</description>
        <lastBuildDate>Sat, 25 Apr 2026 07:37:07 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>hyeok_</title>
            <url>https://velog.velcdn.com/images/hyeok_1212/profile/2f33adbb-552b-4fdc-83dc-eb70d22fe1a2/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. hyeok_. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/hyeok_1212" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Claude Code] 아니 그게 아니라...]]></title>
            <link>https://velog.io/@hyeok_1212/Claude-Code-%EC%95%84%EB%8B%88-%EA%B7%B8%EA%B2%8C-%EC%95%84%EB%8B%88%EB%9D%BC</link>
            <guid>https://velog.io/@hyeok_1212/Claude-Code-%EC%95%84%EB%8B%88-%EA%B7%B8%EA%B2%8C-%EC%95%84%EB%8B%88%EB%9D%BC</guid>
            <pubDate>Sat, 25 Apr 2026 07:37:07 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“아니 그게 아니라…”</p>
</blockquote>
<p>이 한 문장이 우리의 토큰을 계속 태우고 있어요. 대화가 길어질수록 비용이 늘어난다는 건 이미 알고 계실 것 같아요. 그런데 진짜 문제는 따로 있어요. 우리의 <code>틀린 시도</code>와 <code>애매한 지시</code>까지 그대로 남아서, 다음 결과까지 망칠 수 있어요.</p>
<h1 id="우리가-자주-하는-실수">우리가 자주 하는 실수</h1>
<p>Claude Code로 작업할 때 이런 흐름을 자주 반복해요.</p>
<ol>
<li>A안과 B안 중 A를 먼저 시도한다. (A vs B)</li>
<li>결과가 마음에 안 든다.</li>
<li>&quot;아니 그게 아니라 이렇게 해줘&quot;라고 추가로 지시한다.</li>
</ol>
<p>동작은 하겠지만, <strong>A의 모든 시행착오가 그대로 대화에 남아요.</strong> 이후 모든 응답에서 실패한 A안의 흔적이 함께 따라다니기 때문에 두 가지 비용이 발생하게 돼요.</p>
<ul>
<li><strong>매 턴 비용이 늘어나요.</strong> 대화가 길어질수록 모든 히스토리가 함께 전송돼서 더 많은 토큰을 사용해요. 
  <img src="https://velog.velcdn.com/images/hyeok_1212/post/c5de53bb-11f5-42d2-9378-013162022f96/image.png" alt=""><ul>
<li>LLM은 매 요청마다 <code>지금까지 대화 전체</code>를 다시 읽습니다.</li>
<li><a href="https://platform.claude.com/docs/en/build-with-claude/context-windows">출처: Claude API Docs - Context windows</a></li>
</ul>
</li>
<li><strong>응답이 느려지고 흐려져요.</strong> <code>읽어야 할 분량이 늘어나는 것</code>도 문제지만, 더 큰 문제는 Claude가 이미 한번 잡은 방향을 쉽게 못 놓는다는 거예요. B를 요청해도 A의 영향이 응답에 섞일 가능성이 있어요. (특히 코드처럼 산출물이 누적되는 작업에서 두드러져요.)</li>
</ul>
<h2 id="해결책은-되돌리기">해결책은 되돌리기</h2>
<p>Claude Code는 매 프롬프트마다 자동으로 <a href="https://code.claude.com/docs/en/checkpointing">체크포인트</a>를 만들어요.</p>
<p><code>Esc</code>를 두 번 누르거나 <code>/rewind</code> 명령으로 rewind 메뉴를 열 수 있어요. 메뉴에는 세션의 프롬프트들이 시점별로 나열돼요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/8d601948-e332-4cf9-92a9-3ce2de104ed5/image.png" alt=""></p>
<p>원하는 시점을 고르면 작업 옵션이 나타나요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/127b89ef-285c-4902-98d1-0d82d6cd5672/image.png" alt=""></p>
<blockquote>
<h3 id="선택-옵션">선택 옵션</h3>
<p>A vs B처럼 방향을 바꿔야 하는 상황이라면 <code>Restore code and conversation</code>을 선택하세요. 코드와 대화를 함께 되돌려서, A의 흔적 없이 깨끗한 상태에서 B를 시작할 수 있어요. 나머지 옵션은 상황에 따라 골라 쓰면 돼요.</p>
<ul>
<li><code>Restore conversation</code>: 코드는 유지하고 대화만 되돌리기</li>
</ul>
</blockquote>
<ul>
<li><code>Restore code</code>: 대화는 유지하고 파일 변경만 되돌리기</li>
<li><code>Summarize from here</code>: 이후 대화를 압축</li>
<li><code>Never mind</code>: 변경 없이 메뉴 닫기<blockquote>
<p>* 코드를 되돌리는 옵션은 실제로 코드가 변경된 경우에만 보여요.</p>
</blockquote>
</li>
</ul>
<p>Rewind는 “방향이 틀렸을 때” 쓰는 도구에요. 반대로, 단순한 표현 수정이나 결과 다듬기처럼 기존 맥락을 유지해도 되는 경우라면 굳이 되돌릴 필요 없이 이어서 지시해도 충분할 수 있어요.</p>
<h2 id="핵심은-페인포인트를-함께-전달하는-것">핵심은 페인포인트를 함께 전달하는 것</h2>
<p>그냥 되돌려서 B를 다시 요청하면 A에서 배운 걸 전부 잃어요. 되돌린 직후 B를 요청할 때 <strong>A에서 발견한 문제</strong>를 한두 줄로 덧붙여주세요.</p>
<blockquote>
<p>B안으로 가자. A를 먼저 해봤는데 X 부분이 너무 복잡해졌고 Y 케이스에서 동작이 애매했어. 이 점은 피해서 구현해줘.</p>
</blockquote>
<p>A의 시행착오를 학습으로 압축해 전달하는 셈이에요. 대화는 깨끗해지고, 인사이트는 살아남아요.</p>
<h2 id="학습-자체를-버리고-싶지-않다면">학습 자체를 버리고 싶지 않다면</h2>
<p>A 과정에서 의미 있는 탐색이 많았다면 <code>Summarize from here</code> 옵션을 골라보세요. 코드는 그대로 두고 대화만 압축해서 컨텍스트 공간을 확보해요. 통째 롤백과 그냥 두기 사이의 절충안이에요.</p>
<h2 id="언제-쓰면-좋은가">언제 쓰면 좋은가</h2>
<p>공식 문서는 다음 상황에 체크포인팅을 권장해요.</p>
<ul>
<li>여러 구현 방법을 비교할 때</li>
<li>도입한 변경이 버그를 만들었을 때</li>
<li>같은 기능을 변형해가며 실험할 때</li>
<li>디버깅 세션이 길어져 컨텍스트가 부족할 때</li>
</ul>
<h2 id="알아둘-한계">알아둘 한계</h2>
<ul>
<li>bash 명령(<code>rm</code>, <code>mv</code> 등)으로 바뀐 파일은 추적되지 않아요.</li>
<li>Claude Code 외부에서 직접 수정한 파일도 추적되지 않아요.</li>
<li>Git을 대체하지 않아요. <strong>체크포인트는 &quot;로컬 실행취소&quot;, Git은 &quot;영구 기록&quot;</strong> 이에요.</li>
</ul>
<blockquote>
<p><strong>참고</strong>: A와 B를 동시에 비교하고 원본도 보존하고 싶다면 <code>claude --continue --fork-session</code>으로 세션을 분기할 수 있어요. Rewind는 순차적 실험, Fork는 병렬 비교에 적합해요.</p>
</blockquote>
<h2 id="정리">정리</h2>
<blockquote>
<p><del>&quot;아니 그게 아니라..&quot;</del></p>
</blockquote>
<p>마음에 안 든다고 클로드에게 화내지 마세요.
버릴 건 버리고, 가져갈 것만 들고 되돌아가세요.</p>
<p><code>ESC</code> + <code>ESC</code> 또는 <code>/rewind</code> 한 번이면 돼요.</p>
<h3 id="참고-문서">참고 문서</h3>
<ul>
<li><a href="https://code.claude.com/docs/ko/checkpointing">Claude Code Docs - Checkpointing</a></li>
<li><a href="https://code.claude.com/docs/en/how-claude-code-works#resume-or-fork-sessions">Claude Code Docs - Resume or fork sessions</a></li>
<li><a href="https://platform.claude.com/docs/en/build-with-claude/context-windows">Claude API Docs - Context windows</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[우리 팀은 이렇게 쿼리를 개선했어요]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%9A%B0%EB%A6%AC-%ED%8C%80%EC%9D%80-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EC%BF%BC%EB%A6%AC%EB%A5%BC-%EA%B0%9C%EC%84%A0%ED%96%88%EC%96%B4%EC%9A%94</link>
            <guid>https://velog.io/@hyeok_1212/%EC%9A%B0%EB%A6%AC-%ED%8C%80%EC%9D%80-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EC%BF%BC%EB%A6%AC%EB%A5%BC-%EA%B0%9C%EC%84%A0%ED%96%88%EC%96%B4%EC%9A%94</guid>
            <pubDate>Wed, 01 Oct 2025 12:28:52 GMT</pubDate>
            <description><![CDATA[<h1 id="진행-중인-프로젝트">진행 중인 프로젝트</h1>
<blockquote>
<h3 id="보따리">보따리</h3>
<p>어떤 순간에도 빠짐없이 챙길 수 있도록 돕는 체크리스트 및 알림 서비스입니다. 
우아한테크코스에서 백엔드 4명, 안드로이드 3명으로 구성된 팀으로 서비스를 개발하고 있습니다.</p>
<p>플레이스토어에서 <a href="https://play.google.com/store/apps/details?id=com.bottari.bottari&amp;hl=ko">보따리</a>를 만날 수 있어요!</p>
</blockquote>
<h2 id="개요">개요</h2>
<p>이 글은 보따리 백엔드 팀이 <code>느린 쿼리</code>를 만나 데이터 기반으로 문제를 진단하고 개선한 경험을 정리한 글이에요.</p>
<p>혹시 아래와 같은 고민을 해보셨다면, 저희 팀의 경험이 좋은 참고자료가 될 수 있을 것 같아요.</p>
<ul>
<li>&quot;성능 테스트, 일단 데이터 100만 건 넣고 실행해보면 되는 거 아닐까?&quot;</li>
<li>&quot;수많은 쿼리 중 어떤 것부터 손대야 효율적일까?&quot;</li>
<li>&quot;그래서 DB 튜닝이 정말 백엔드 개발자의 핵심 역량일까?&quot;</li>
</ul>
<h2 id="가정-및-작업-시작-이유">가정 및 작업 시작 이유</h2>
<p>보따리는 현재 초기 서비스 단계지만, 성장을 고려한 준비가 필요했어요. 서비스가 성장하면서 쿼리 성능 저하는 사용자 경험에 직접적인 영향을 주기 때문이에요. </p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/ecdb7b1e-9e66-46a2-941e-0bac906c125a/image.png" alt=""></p>
<blockquote>
<p>페이지 로딩 시간이 1초에서 3초로 늘어나면 이탈 확률은 32% 증가합니다. - <a href="https://www.thinkwithgoogle.com/marketing-strategies/app-and-mobile/mobile-page-speed-new-industry-benchmarks/">자료 출처</a></p>
</blockquote>
<p>문제가 발생하기 전에 미리 성능을 점검하고 개선하기로 결정했어요.</p>
<h1 id="어떻게-시작할까">어떻게 시작할까?</h1>
<blockquote>
<p>개선(改善)은 무언가를 더 낫게 만드는 것을 뜻한다.</p>
</blockquote>
<p>보따리 팀에서 DB 개선을 하기 위해서는 2가지가 필요하다고 생각했어요.</p>
<ol>
<li>학습</li>
<li>현재 상태 측정</li>
</ol>
<p><code>학습</code> 측면에서는 팀원 모두 데이터베이스를 개선한 경험이 많지 않아서 어느 정도 학습 후 작업하면 더 나은 선택을 할 가능성이 높아질 것 같다고 생각했고, 당연히 현재 상태를 알아야 개선을 논할 수 있으며, 지금도 문제가 아닐 가능성이 있기 때문에 <code>측정</code>이 필요하다고 생각했어요.</p>
<h2 id="학습-넓게보다-깊게-필요한-부분만">학습: 넓게보다 깊게, 필요한 부분만</h2>
<p>성능 개선이라는 목표는 명확했지만, 막상 시작하려니 막막했어요. 인터넷이나 AI 친구들에겐 수많은 정보가 있었지만, 내용의 신뢰도나 깊이가 제각각이라 팀원 모두가 같은 지식을 공유하기 어려울 것 같았어요.</p>
<p>그래서 우리는 효율적인 학습과 논의를 위해 신뢰할 수 있는 단 하나의 기준을 세웠어요.  <code>모두가 인정하는 교과서를 정하고, 그 안에서 같은 내용을 학습하자.</code> 그래서 저희는 많은 개발자에게 <code>DB 바이블</code>로 여겨지는 <a href="https://product.kyobobook.co.kr/detail/S000001766482"><code>RealMySQL 8.0</code></a>을 길잡이로 삼기로 결정했어요.</p>
<h3 id="선택과-집중">선택과 집중</h3>
<p>1, 2권 합쳐서 1,200페이지가 넘는 책을 모두 읽기엔 주어진 시간이 너무 짧다고 생각했어요. 보따리 팀의 목표는 DB 박사가 아니라, 마주친 성능 문제를 해결하는 것이었으니까요.</p>
<p>그래서 저희는 책의 내용 중 지금 우리에게 가장 필요한 핵심만을 선별해서 읽었어요. AI에게 우리의 문제(DB 개선)를 전달하여, 필요한 챕터만 선별했어요.</p>
<ul>
<li>8장 인덱스: 느린 쿼리를 빠르게 만드는 인덱스의 원리와 활용</li>
<li>9장 옵티마이저와 힌트: 쿼리 해석과 실행 방식</li>
<li>10장 실행 계획: 쿼리 실행 과정 이해</li>
</ul>
<p>이런 <code>선택과 집중</code> 덕분에 저희는 한정된 시간 안에 팀원 모두가 같은 지식 수준에서 문제에 접근하고, 해결책을 논의할 수 있게 되었던 것 같아요.</p>
<h2 id="측정-감이-아닌-데이터로-말하기">측정: 감이 아닌 데이터로 말하기</h2>
<p><code>감으로 이 쿼리가 느릴 것 같다</code>가 아니라, <code>200만 건 데이터 기준, 이 API는 2.5초가 걸린다</code>처럼 정확한 데이터로 현재 상태를 측정하는 과정이 반드시 필요하다고 생각했어요.</p>
<p>측정을 하기 위해서는 2가지가 필요해요.</p>
<ol>
<li>더미 데이터셋</li>
<li>측정 및 문제 인식 기준</li>
</ol>
<p><code>더미 데이터셋</code>은 우리가 가정하는 환경을 만들기 위해 꼭 필요하고, 어떤 결과가 문제인지에 대한 측정 및 문제 인식 기준이 필요하다고 생각했어요.</p>
<h3 id="더미-데이터-만들기">더미 데이터 만들기</h3>
<p>더미 데이터는 양적인 측면뿐만 아니라 질적인 측면도 고려하여, 실제 운영 환경과 유사한 부하를 발생시키도록 구성했어요. (기간은 3년으로 설정)</p>
<ul>
<li>실제 사용률이 높은 데이터의 비율을 높여 생성</li>
<li>특정 값(단어, 외래키 등)에 쏠림이 없도록 분포를 고려</li>
<li>서비스 로직에 맞는, 논리적으로 유효한 데이터만 삽입</li>
</ul>
<p>예를 들어, 3년 동안 한 번도 팀에 속하지 않은 멤버도 있을 수 있고, 반대로 여러 팀에 속한 멤버도 있을 수 있겠죠.
또 검색어가 전부 &#39;여행&#39;이라면 쿼리 측정이 왜곡되기 때문에 다양한 키워드를 섞어 넣었어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/2e65de1d-2928-4dab-ba4c-78005f9d8b90/image.png" alt=""></p>
<p>데이터는 AI를 적극적으로 활용해 만들었어요. 비즈니스 규칙과 원하는 분포를 설명하면 CSV 파일을 생성하여 MySQL에 삽입했어요.</p>
<h3 id="측정-및-문제-인식-기준">측정 및 문제 인식 기준</h3>
<p>JPA를 사용했기에 쿼리 로그(또는 Hibernate SQL 로그)로 실제 실행된 SQL을 확보하고, DB에서 EXPLAIN / EXPLAIN ANALYZE로 실행 계획을 확인했어요.</p>
<p>이때 <code>1초 미만</code> 또는 <code>Full Table Scan만을 피하자</code>는 기준을 세웠어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/631f5d58-cf2d-4724-bbd2-503b91e3d9cf/image.png" alt=""></p>
<p>인덱스 용량 확인으로 공간적 부담을 확인해보기도 했어요.</p>
<blockquote>
<h3 id="tip">Tip.</h3>
<p>단순히 쿼리를 빠르게 만드는 게 최종 목표는 아니에요. 사용자에게 더 빠른 응답을 돌려주는 것이 진짜 목적이에요. (더 나은 사용자 경험) 따라서 쿼리 단위 성능만 볼 게 아니라, API 엔드포인트 전체(서비스 로직 포함) 응답 시간까지 함께 점검해야 해요.
예를 들어, 엔드포인트 전체가 1초 안에 끝나야 한다면, 쿼리 단계에서는 더 타이트한 기준을 적용하는 게 안전할 것 같아요.</p>
</blockquote>
<h1 id="개선">개선</h1>
<h2 id="측정-결과">측정 결과</h2>
<p>실제 쿼리, 실행 계획 등을 나열하고 팀원들과 함께 문제가 되는 쿼리를 식별했어요. <a href="https://github.com/woowacourse-teams/2025-bottari/wiki/20250919%E2%80%90%EC%A1%B0%ED%9A%8C-%EC%BF%BC%EB%A6%AC-%ED%98%84%ED%99%A9-%EB%AC%B8%EC%84%9C">정리한 문서</a></p>
<blockquote>
<h3 id="모든-쿼리를-정성껏-테스트할-필요가-있을까">모든 쿼리를 (정성껏) 테스트할 필요가 있을까?</h3>
<p>현재는 모든 쿼리를 나열하고 같은 기준으로 테스트를 진행했어요. 그러나 항상 모든 쿼리를 테스트해야 할까요? 데이터베이스 테이블은 엄청나게 쌓이지 않을 경우도 있어요. 예를 들어, 시,군,도 정보를 가지는 테이블이라면 한계치가 정해져 있을 것 같아요. 이 경우 대규모 데이터를 고려해야 할까요? 그렇지 않을 것 같아요. 도메인에 대한 이해가 더 빠르고 정확한 병목 지점 찾는 것에 도움이 될 것 같아요.</p>
</blockquote>
<h1 id="대표-개선-사례-검색-쿼리">대표 개선 사례 (검색 쿼리)</h1>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/0ef831f5-1492-4a3f-b2ea-141226c791cf/image.png" alt=""></p>
<p>모두의 보따리 기능에서는 사용자들이 만든 보따리를 검색하거나 가져갈 수 있어요.<br>해당 검색 쿼리를 점검하는 과정에서 성능 저하의 주요 원인을 발견했어요.</p>
<h2 id="기존-쿼리">기존 쿼리</h2>
<pre><code class="language-sql">-- 보따리 템플릿을 제목으로 검색하고 멤버 정보와 함께 조회하는 쿼리 
SELECT
    bt.id,
    bt.created_at,
    bt.deleted_at,
    m.id,
    m.deleted_at,
    m.name,
    m.ssaid,
    bt.taken_count,
    bt.title
FROM bottari_template AS bt
JOIN member AS m
      ON m.id = bt.member_id
     AND m.deleted_at IS NULL
WHERE bt.deleted_at IS NULL
  AND bt.title LIKE CONCAT(&#39;%&#39;, &#39;검색어&#39;, &#39;%&#39;) ESCAPE &#39;&#39;
ORDER BY
    bt.created_at DESC;</code></pre>
<p><strong>실행 계획</strong></p>
<table>
<thead>
<tr>
<th>id</th>
<th>select_type</th>
<th>table</th>
<th>partitions</th>
<th>type</th>
<th>possible_keys</th>
<th>key</th>
<th>key_len</th>
<th>ref</th>
<th>rows</th>
<th>filtered</th>
<th>Extra</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>SIMPLE</td>
<td>m1_0</td>
<td>null</td>
<td>ALL</td>
<td>PRIMARY</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>20096</td>
<td>10</td>
<td>Using where; Using temporary; Using filesort</td>
</tr>
<tr>
<td>1</td>
<td>SIMPLE</td>
<td>bt1_0</td>
<td>null</td>
<td>ref</td>
<td>FKektixs1dwkvv5sftpy5ldqlpw</td>
<td>FKektixs1dwkvv5sftpy5ldqlpw</td>
<td>9</td>
<td>bottari.m1_0.id</td>
<td>5</td>
<td>1.11</td>
<td>Using where</td>
</tr>
</tbody></table>
<pre><code>-&gt; Sort: bt1_0.created_at DESC  (actual time=1391..1391 rows=0 loops=1)
    -&gt; Stream results  (cost=7403 rows=117) (actual time=1391..1391 rows=0 loops=1)
        -&gt; Nested loop inner join  (cost=7403 rows=117) (actual time=1391..1391 rows=0 loops=1)
            -&gt; Filter: (m1_0.deleted_at is null)  (cost=2064 rows=2010) (actual time=0.0489..109 rows=18054 loops=1)
                -&gt; Table scan on m1_0  (cost=2064 rows=20096) (actual time=0.0481..107 rows=20001 loops=1)
            -&gt; Filter: ((bt1_0.deleted_at is null) and (bt1_0.title like &lt;cache&gt;(concat(&#39;%&#39;,&#39;검색어&#39;,&#39;%&#39;)) escape &#39;&#39;))  (cost=2.13 rows=0.0581) (actual time=0.0708..0.0708 rows=0 loops=18054)
                -&gt; Index lookup on bt1_0 using FKektixs1dwkvv5sftpy5ldqlpw (member_id = m1_0.id)  (cost=2.13 rows=5.23) (actual time=0.0551..0.0696 rows=5.01 loops=18054)</code></pre><ul>
<li>10만 건 데이터 기준 1.391초 소요</li>
<li>실행 계획에서 <code>ALL</code> (Full Table Scan) 발생</li>
<li>비효율적인 조인 순서 → 불필요한 <code>임시 테이블 생성(Using temporary)</code> 및 <code>파일 소트(Using filesort)</code> 발생</li>
</ul>
<p>처음 세운 기준(1초 미만 응답)에 맞지 않을 뿐만 아니라, 앞으로 데이터가 계속 늘어날 도메인이었기 때문에 반드시 개선해야 할 쿼리였어요.</p>
<h2 id="개선-1-커서-기반-페이징no-offset으로-불필요한-조회-제거">개선 1: 커서 기반 페이징(No-Offset)으로 불필요한 조회 제거</h2>
<blockquote>
<p>📌 개선 요약: 불필요한 데이터 조회 제거 → 실행 시간 <strong>77% 단축 (1.391초 → 0.319초)</strong></p>
</blockquote>
<p>기존 쿼리는 검색 조건에 맞는 모든 결과를 한 번에 가져오는 방식이었어요.<br>결과가 수만 건 이상일 경우 애플리케이션 메모리를 과도하게 점유하거나, 다음 페이지를 보지 않아도 데이터를 불러오는 문제가 있었죠. 특히 OFFSET 기반 페이징은 페이지가 뒤로 갈수록 성능이 급격히 떨어지는 한계가 있었어요.</p>
<p>이를 해결하기 위해 <code>커서 기반 페이징(No-Offset)</code>을 도입했어요. 마지막으로 조회한 데이터를 기준점(cursor)으로 삼아, 필요한 n건만 가져오도록 쿼리를 수정했어요.</p>
<pre><code class="language-sql">-- 커서 기반 + 최신순 쿼리
SELECT
    bt.id,
    bt.created_at,
    bt.deleted_at,
    m.id,
    m.deleted_at,
    m.name,
    m.ssaid,
    bt.taken_count,
    bt.title
FROM
    bottari_template AS bt
JOIN
    member AS m
    ON m.id = bt.member_id AND m.deleted_at IS NULL
WHERE
    bt.deleted_at IS NULL
    AND bt.title LIKE CONCAT(&#39;%&#39;, &#39;해외 여행&#39;, &#39;%&#39;) ESCAPE &#39;&#39;
    AND (
        bt.created_at &lt; &#39;2025-09-01 10:00:00&#39; -- 마지막으로 본 아이템의 생성 시간
        OR (
            bt.created_at = &#39;2025-09-01 10:00:00&#39;
            AND bt.id &lt; 10000 -- 생성 시간이 같다면 ID로 순서 보장
        )
    )
ORDER BY
    bt.created_at DESC,
    bt.id DESC
LIMIT
    10; -- 필요한 만큼만 조회</code></pre>
<p><strong>실행 계획</strong></p>
<table>
<thead>
<tr>
<th>id</th>
<th>select_type</th>
<th>table</th>
<th>partitions</th>
<th>type</th>
<th>possible_keys</th>
<th>key</th>
<th>key_len</th>
<th>ref</th>
<th>rows</th>
<th>filtered</th>
<th>Extra</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>SIMPLE</td>
<td>m1_0</td>
<td>null</td>
<td>ALL</td>
<td>PRIMARY</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>20095</td>
<td>10</td>
<td>Using where; Using temporary; Using filesort</td>
</tr>
<tr>
<td>1</td>
<td>SIMPLE</td>
<td>bt1_0</td>
<td>null</td>
<td>ref</td>
<td>PRIMARY,FKektixs1dwkvv5sftpy5ldqlpw</td>
<td>FKektixs1dwkvv5sftpy5ldqlpw</td>
<td>9</td>
<td>bottari.m1_0.id</td>
<td>5</td>
<td>0.93</td>
<td>Using where</td>
</tr>
</tbody></table>
<pre><code>-&gt; Limit: 10 row(s)  (actual time=319..319 rows=10 loops=1)
    -&gt; Sort: bt1_0.created_at DESC, bt1_0.id DESC, limit input to 10 row(s) per chunk  (actual time=319..319 rows=10 loops=1)
        -&gt; Stream results  (cost=5844 rows=100) (actual time=0.319..318 rows=2039 loops=1)
            -&gt; Nested loop inner join  (cost=5844 rows=100) (actual time=0.313..316 rows=2039 loops=1)
                -&gt; Filter: (m1_0.deleted_at is null)  (cost=2050 rows=2010) (actual time=0.0514..11.7 rows=18054 loops=1)
                    -&gt; Table scan on m1_0  (cost=2050 rows=20095) (actual time=0.0503..9.83 rows=20001 loops=1)
                -&gt; Filter: ((bt1_0.deleted_at is null) and (bt1_0.title like &lt;cache&gt;(concat(&#39;%&#39;,&#39;해외 여행&#39;,&#39;%&#39;)) escape &#39;&#39;) and ((bt1_0.created_at &lt; TIMESTAMP&#39;2025-09-01 10:00:00&#39;) or ((bt1_0.created_at = TIMESTAMP&#39;2025-09-01 10:00:00&#39;) and (bt1_0.id &lt; 10000))))  (cost=1.35 rows=0.05) (actual time=0.0162..0.0167 rows=0.113 loops=18054)
                    -&gt; Index lookup on bt1_0 using FKektixs1dwkvv5sftpy5ldqlpw (member_id = m1_0.id)  (cost=1.35 rows=5.39) (actual time=0.0119..0.0155 rows=5.01 loops=18054)</code></pre><p>페이징 처리는 애플리케이션의 안정성을 확보하고 불필요한 부하를 줄이는 필수적인 1차 개선이었어요.
filesort 및 temporary table의 대상이 전체 결과에서 10건으로 크게 줄어들어 실행 시간이 레코드 10만 건 기준 <code>1.391초 → 0.319초로 약 77% 개선</code>되었어요.</p>
<h2 id="개선-2-like-대신-full-text-index-도입">개선 2: LIKE 대신 Full-Text Index 도입</h2>
<blockquote>
<p>📌 개선 요약: LIKE Full Scan 제거, 인덱스 기반 검색으로 전환 → 실행 시간 <strong>95.4% 추가 개선 (0.319초 → 0.0147초)</strong></p>
</blockquote>
<p>커서 기반 페이징을 적용했음에도, 검색 결과가 적거나 없을 때는 여전히 테이블을 광범위하게 스캔해야 했어요. 100만 건 기준, 검색 결과가 없는 경우 최대 1.8초까지 걸렸어요.</p>
<pre><code># 100만 건 기준 검색 결과가 없는 경우의 실행 계획
-&gt; Limit: 10 row(s)  (cost=110599 rows=10) (actual time=1811..1811 rows=0 loops=1)
    -&gt; ....</code></pre><p>LIKE 검색의 구조적 한계를 극복하고자 <code>Full-Text Index</code>를 도입했어요. <code>N-gram 파서</code>는 형태소 분석기처럼 문법을 이해하지는 않지만, 글자를 N개 단위로 잘라 인덱싱하기 때문에 띄어쓰기나 단어 변형에 강해요. 덕분에 복잡한 설정 없이도 <code>LIKE &#39;%키워드%&#39;</code> 방식보다 성능과 검색 정확도를 개선할 수 있었어요.</p>
<p>특히 한글은 조사나 어미가 단어에 직접 붙어 형태가 자주 바뀌기 때문에(&#39;보따리&#39; vs &#39;보따리를&#39;), 어디까지가 한 단어인지 구분하기 까다로운 편이에요. N-gram은 이러한 문법적 고민 없이 모든 글자를 분해하므로, 사용자가 어떤 검색어를 입력하든 일관된 결과를 제공하는 실용적인 해결책이라고 판단했어요.</p>
<pre><code class="language-sql">-- Full-Text Index를 사용하도록 개선된 쿼리
SELECT
    bt.id,
    bt.created_at,
    bt.deleted_at,
    m.id,
    m.deleted_at,
    m.name,
    m.ssaid,
    bt.taken_count,
    bt.title
FROM
    bottari_template AS bt
JOIN
    member AS m
    ON m.id = bt.member_id AND m.deleted_at IS NULL
WHERE
    bt.deleted_at IS NULL
    AND MATCH(bt.title) AGAINST(&#39;+해외 +여행&#39; IN BOOLEAN MODE) -- 해외 and 여행
    AND (
        bt.taken_count &lt; 3000
        OR (
            bt.taken_count = 3000
            AND bt.id &lt; 10000
        )
    )
ORDER BY
    bt.taken_count DESC,
    bt.id DESC
LIMIT
    10;</code></pre>
<p><strong>실행 계획</strong></p>
<table>
<thead>
<tr>
<th>id</th>
<th>select_type</th>
<th>table</th>
<th>partitions</th>
<th>type</th>
<th>possible_keys</th>
<th>key</th>
<th>key_len</th>
<th>ref</th>
<th>rows</th>
<th>filtered</th>
<th>Extra</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>SIMPLE</td>
<td>bt1_0</td>
<td>null</td>
<td>fulltext</td>
<td>PRIMARY,FKektixs1dwkvv5sftpy5ldqlpw,idx_template_title</td>
<td>idx_template_title</td>
<td>0</td>
<td>const</td>
<td>1</td>
<td>5</td>
<td>Using where; Ft_hints: no_ranking; Using filesort</td>
</tr>
<tr>
<td>1</td>
<td>SIMPLE</td>
<td>m1_0</td>
<td>null</td>
<td>eq_ref</td>
<td>PRIMARY</td>
<td>PRIMARY</td>
<td>8</td>
<td>bottari.bt1_0.member_id</td>
<td>1</td>
<td>10</td>
<td>Using where</td>
</tr>
</tbody></table>
<pre><code>-&gt; Limit: 10 row(s)  (cost=0.368 rows=0.1) (actual time=14.6..14.7 rows=10 loops=1)
    -&gt; Nested loop inner join  (cost=0.368 rows=0.1) (actual time=14.6..14.7 rows=10 loops=1)
        -&gt; Sort row IDs: bt1_0.taken_count DESC, bt1_0.id DESC  (cost=0.255 rows=1) (actual time=14.6..14.7 rows=10 loops=1)
            -&gt; Filter: ((bt1_0.deleted_at is null) and (match bt1_0.title against (&#39;+해외 +여행&#39; in boolean mode)) and ((bt1_0.taken_count &lt; 3000) or ((bt1_0.taken_count = 3000) and (bt1_0.id &lt; 10000))) and (bt1_0.member_id is not null))  (cost=0.255 rows=1) (actual time=0.539..11.3 rows=2257 loops=1)
                -&gt; Full-text index search on bt1_0 using idx_template_title (title = &#39;+해외 +여행&#39;)  (cost=0.255 rows=1) (actual time=0.535..10.9 rows=2505 loops=1)
        -&gt; Filter: (m1_0.deleted_at is null)  (cost=0.45 rows=0.1) (actual time=0.00468..0.00478 rows=1 loops=10)
            -&gt; Single-row index lookup on m1_0 using PRIMARY (id = bt1_0.member_id)  (cost=0.45 rows=1) (actual time=0.00444..0.00447 rows=1 loops=10)</code></pre><p>LIKE 절의 Full Scan이 사라지고 인덱스 기반의 빠른 검색이 가능해졌어요. <code>0.319 -&gt; 0.0147초 (개선 1 대비) 약 95.4%</code> 또한 검색 결과 유무와 관계없이 일관되게 빠른 성능을 확보하게 되었어요.</p>
<h3 id="전문-검색-인덱스-추가-과정">전문 검색 인덱스 추가 과정</h3>
<pre><code class="language-sql">-- 전문 검색 인덱스 추가 방법
CREATE FULLTEXT INDEX idx_template_title
    ON bottari_template(title)
    WITH PARSER ngram;</code></pre>
<img width="800" height="42" alt="image" src="https://github.com/user-attachments/assets/06b197a0-bab5-4305-ba98-be7a56c533f4" />

<img width="800" height="42" alt="image" src="https://github.com/user-attachments/assets/0601b448-acd3-4dfe-8996-d5f3c261e9fa" />

<p>Full-Text Index를 추가할 경우 데이터 10만 건(8.5MB) 기준 인덱스 크기가 2.52 → 7.03 MB로 증가 약 2.79배 증가하지만, 현재 인프라에서 부담되는 정도가 아니며, 명확한 한계가 보이는 방법보다는 낫다고 판단했습니다.</p>
<h3 id="ngram--1">ngram = 1</h3>
<pre><code>-- mysql 설정값 변경 필요
ngram_token_size=1</code></pre><p>한 글자 검색까지 지원하여 사용자 경험을 극대화하기 위해 <code>ngram_token_size=1</code>로 설정했어요. title 컬럼의 최대 길이가 15자로 짧고, 조회(Read) 비율이 압도적으로 높다고 판단하여 인덱스 크기 증가나 쓰기 성능 저하의 부담보다 조회 성능 개선의 이점이 훨씬 크다고 판단했어요.</p>
<h2 id="개선-3-straight_join으로-옵티마이저-실행-계획-보정">개선 3: STRAIGHT_JOIN으로 옵티마이저 실행 계획 보정</h2>
<blockquote>
<p>📌 개선 요약: 검색어 유무와 관계없이 안정적인 실행 계획 확보 → 실행 시간 <strong>99.3% 추가 개선 (0.0147초 → 0.000106초)</strong></p>
</blockquote>
<p>전문 검색 인덱스를 도입하여 검색어 입력 시의 성능은 개선되었으나, 새로운 문제가 발생했어요.</p>
<p>검색어가 비어있을 경우 전체 템플릿 목록을 조회해야 하는데, MATCH(...) AGAINST(&#39;&#39;)는 결과를 반환하지 않아요.</p>
<p>그래서 아래와 같이 OR 조건을 추가했어요.</p>
<pre><code class="language-sql">WHERE ... AND (&#39;검색어&#39; = &#39;&#39; OR MATCH(bt1_0.title) AGAINST(&#39;검색어&#39; IN BOOLEAN MODE))</code></pre>
<p>하지만 이 OR 조건은 검색어가 비어있을 때, 옵티마이저는 &#39;&#39; = &#39;&#39; 조건이 항상 TRUE라는 이유로 Full-Text Index를 무시하고 member 테이블을 Full Scan하는 과거의 비효율적인 실행 계획을 선택하게 되었어요.</p>
<p><strong>실행 계획</strong></p>
<table>
<thead>
<tr>
<th align="left">id</th>
<th align="left">select_type</th>
<th align="left">table</th>
<th align="left">partitions</th>
<th align="left">type</th>
<th align="left">possible_keys</th>
<th align="left">key</th>
<th align="left">key_len</th>
<th align="left">ref</th>
<th align="left">rows</th>
<th align="left">filtered</th>
<th align="left">Extra</th>
</tr>
</thead>
<tbody><tr>
<td align="left">1</td>
<td align="left">SIMPLE</td>
<td align="left">bt1_0</td>
<td align="left">null</td>
<td align="left">ALL</td>
<td align="left">PRIMARY,FKektixs1dwkvv5sftpy5ldqlpw</td>
<td align="left">null</td>
<td align="left">null</td>
<td align="left">null</td>
<td align="left">99719</td>
<td align="left">13.55</td>
<td align="left">Using where; Using filesort</td>
</tr>
<tr>
<td align="left">1</td>
<td align="left">SIMPLE</td>
<td align="left">m1_0</td>
<td align="left">null</td>
<td align="left">eq_ref</td>
<td align="left">PRIMARY</td>
<td align="left">PRIMARY</td>
<td align="left">8</td>
<td align="left">bottari.bt1_0.member_id</td>
<td align="left">1</td>
<td align="left">10</td>
<td align="left">Using where</td>
</tr>
</tbody></table>
<pre><code>-&gt; Limit: 10 row(s)  (actual time=783..783 rows=10 loops=1)
...</code></pre><pre><code class="language-sql">CREATE INDEX idx_created_at_id ON bottari_template(created_at DESC, id DESC);
CREATE INDEX idx_taken_count_id on bottari_template(taken_count desc, id desc );</code></pre>
<pre><code class="language-sql">SELECT STRAIGHT_JOIN -- STRAIGHT_JOIN 추가
        bt1_0.id,
        bt1_0.created_at,
...</code></pre>
<p><code>idx_created_at_id</code>와 <code>idx_taken_count_id</code> 인덱스를 생성하고, 옵티마이저의 비합리적인 조인 순서를 조정하기 위해 <code>STRAIGHT_JOIN</code>을 사용했습니다.</p>
<p><code>STRAIGHT_JOIN</code>은 FROM 절 순서(bottari_template -&gt; member)대로 조인을 수행하게 하여, 옵티마이저의 비합리적인 선택을 피할 수 있었어요.</p>
<p><strong>실행 계획</strong></p>
<table>
<thead>
<tr>
<th align="left">id</th>
<th align="left">select_type</th>
<th align="left">table</th>
<th align="left">partitions</th>
<th align="left">type</th>
<th align="left">possible_keys</th>
<th align="left">key</th>
<th align="left">key_len</th>
<th align="left">ref</th>
<th align="left">rows</th>
<th align="left">filtered</th>
<th align="left">Extra</th>
</tr>
</thead>
<tbody><tr>
<td align="left">1</td>
<td align="left">SIMPLE</td>
<td align="left">bt1_0</td>
<td align="left">null</td>
<td align="left">index</td>
<td align="left">PRIMARY,FKektixs1dwkvv5sftpy5ldqlpw,idx_created_at_id</td>
<td align="left">idx_created_at_id</td>
<td align="left">17</td>
<td align="left">null</td>
<td align="left">99</td>
<td align="left">13.55</td>
<td align="left">Using where</td>
</tr>
<tr>
<td align="left">1</td>
<td align="left">SIMPLE</td>
<td align="left">m1_0</td>
<td align="left">null</td>
<td align="left">eq_ref</td>
<td align="left">PRIMARY</td>
<td align="left">PRIMARY</td>
<td align="left">8</td>
<td align="left">bottari.bt1_0.member_id</td>
<td align="left">1</td>
<td align="left">10</td>
<td align="left">Using where</td>
</tr>
</tbody></table>
<pre><code>-&gt; Limit: 10 row(s)  (cost=3334 rows=1.34) (actual time=0.0449..0.106 rows=10 loops=1)
...</code></pre><h3 id="straight_join-사용-시-주의사항">STRAIGHT_JOIN 사용 시 주의사항</h3>
<p><code>STRAIGHT_JOIN</code>은 강력하지만, 다음과 같은 상황에서는 오히려 성능이 악화될 수 있어요.</p>
<ul>
<li><strong>데이터 분포 변화</strong>: bottari_template의 데이터가 member보다 훨씬 많아지는 경우</li>
<li><strong>조회 패턴 변화</strong>: 특정 member_id에 데이터가 쏠리는 경우</li>
<li><strong>버전 변경</strong>: 특정 버전에서 달라지는 경우</li>
</ul>
<p>따라서 정기적으로 실행 계획을 확인해야 해요.</p>
<h2 id="개선-결과">개선 결과</h2>
<p>모두의 보따리 조회(검색) 기능을 10만 건 기준 실행 시간 1.391초 → 0.000106초 로 개선할 수 있었어요.</p>
<ul>
<li>페이징 도입으로 불필요한 데이터 조회 제거</li>
<li>Full-Text Index로 검색 성능 개선</li>
<li>필요한 인덱스 추가 및 STRAIGHT_JOIN 활용으로 검색어 유무에 따른 실행 계획 안정화</li>
<li>테스트 환경, 더미 데이터 구성 방법, 개선 근거 및 기준 등을 <a href="https://github.com/woowacourse-teams/2025-bottari/wiki/%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%A3%BC%EC%9A%94-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5-%EC%A0%90%EA%B2%80-%EB%B0%8F-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%A0%84%EB%9E%B5">문서</a>로 작성</li>
</ul>
<p><strong>개선 과정 요약</strong></p>
<table>
<thead>
<tr>
<th>단계</th>
<th>실행 시간</th>
<th>주요 개선 포인트</th>
</tr>
</thead>
<tbody><tr>
<td>기존</td>
<td>1.391초</td>
<td>Full Table Scan, Filesort</td>
</tr>
<tr>
<td>개선1</td>
<td>0.319초</td>
<td>커서 기반 페이징</td>
</tr>
<tr>
<td>개선2</td>
<td>0.0147초</td>
<td>Full-Text Index</td>
</tr>
<tr>
<td>개선3</td>
<td>0.000106초</td>
<td>STRAIGHT_JOIN, 인덱스 보정</td>
</tr>
</tbody></table>
<h1 id="과정-중-궁금했던-부분">과정 중 궁금했던 부분</h1>
<h2 id="테스트-환경은-프로덕션과-일치해야-할까">테스트 환경은 프로덕션과 일치해야 할까?</h2>
<p>이상적으로는 프로덕션과 동일한 환경에서 테스트하는 것이 가장 좋아요.<br>하지만 비용과 운영 복잡성을 고려하면, 완벽하게 복제하는 건 현실적으로 쉽지 않아요.  </p>
<p>그래도 아래 요소들을 맞춰주면 테스트 신뢰도를 크게 높일 수 있어요.</p>
<ul>
<li><strong>DB 버전 및 핵심 설정</strong>: MySQL 버전뿐만 아니라 <code>innodb_buffer_pool_size</code>, <code>optimizer_switch</code> 같은 성능 관련 설정은 반드시 일치시키는 게 좋아요.  </li>
<li><strong>데이터 분포와 양</strong>: 단순히 데이터 양뿐 아니라, 실제 서비스와 유사한 분포(Skew)를 갖춰야 옵티마이저가 비슷한 실행 계획을 선택해요.  </li>
<li><strong>인프라 사양의 비율</strong>: CPU, RAM, Disk를 동일하게 맞추는 게 이상적이에요.<br>어렵다면 최소한 로컬 PC처럼 <strong>프로덕션과 극단적으로 다른 환경</strong>은 피하고, 그 결과는 <code>경향성</code> 확인 용도로만 보는 게 좋아요.  </li>
</ul>
<h2 id="더미-데이터는-어떻게-구성할까">더미 데이터는 어떻게 구성할까?</h2>
<p>저희는 두 가지 방식을 고민했고, 최종적으로 <strong>AI를 활용한 CSV Bulk Insert</strong>를 선택했어요.</p>
<h3 id="1-ai-활용-csv--bulk-insert-선택한-방식">1. AI 활용 CSV + Bulk Insert (선택한 방식)</h3>
<ul>
<li>수백만 건의 데이터를 가장 빠르게 적재할 수 있었어요.  </li>
<li>AI가 생성한 다양한 텍스트 덕분에 실제 사용자처럼 보이는 데이터도 쉽게 확보할 수 있었어요.  </li>
</ul>
<p>단점은 애플리케이션 로직을 거치지 않다 보니, 비즈니스 규칙을 위반하는 &#39;더러운 데이터&#39;가 생길 수 있다는 점이에요.<br>(예: 이미 탈퇴한 회원이 팀 보따리에 소속되는 경우)</p>
<h3 id="2-서비스-로직api-호출로-적재">2. 서비스 로직(API) 호출로 적재</h3>
<ul>
<li>실제 서비스 API나 서비스 메서드를 호출하기 때문에, 항상 비즈니스적으로 유효한 데이터가 생성돼요.  </li>
<li>실제 사용자와 가장 유사한 방식으로 데이터가 쌓이기 때문에 테스트 결과의 신뢰도가 높아져요.  </li>
</ul>
<p>하지만 이 방식은 애플리케이션 로직과 DB 트랜잭션을 모두 거치기 때문에, 대량 데이터를 생성할 때는 시간이 매우 오래 걸린다는 단점이 있어요.</p>
<h2 id="서비스는-천천히-클-텐데-항상-미리-이렇게-튜닝해야-할까">서비스는 천천히 클 텐데, 항상 미리 이렇게 튜닝해야 할까?</h2>
<p>“섣부른 최적화는 팀의 생산성을 떨어뜨린다”는 말을 많이 들었어요.<br>그래서 고민도 많이 하고, 질문도 많이 해본 끝에 이렇게 정리했어요.  </p>
<p>즉, <strong>‘나중에 문제가 생겼을 때 수정 비용이 큰가?’</strong>를 기준으로 판단하면 된다고 생각해요.</p>
<h3 id="미리-최적화를-고려해야-하는-경우">미리 최적화를 고려해야 하는 경우</h3>
<ul>
<li><strong>서비스의 핵심 기능</strong>: 성패를 좌우하는 기능이라면 시간을 투자할 만해요.  </li>
<li><strong>트래픽 급증이 예상되는 경우</strong>: 대형 플랫폼에 출시되거나, 한번에 많은 사용자가 몰릴 수 있는 기능일 때 (예: 카카오 신규 앱, 스레드 출시 시점)</li>
</ul>
<h3 id="나중에-대응해도-괜찮은-경우">나중에 대응해도 괜찮은 경우</h3>
<ul>
<li>어드민 페이지나 사용 빈도가 낮은 비핵심 기능  </li>
<li>초기 <strong>MVP 단계</strong>에서 사용자 반응을 먼저 확인해야 하는 기능  </li>
</ul>
<h1 id="결론-도메인-이해의-중요성">결론: 도메인 이해의 중요성</h1>
<p>이번 DB 성능 개선을 통해 사용자에게 쾌적한 경험을 제공할 수 있게 되었어요.</p>
<p>그러나 이번 작업의 진짜 가치는 단순히 SQL 튜닝 기술을 배운 것이 아니라, <strong>&quot;어떤 쿼리부터 개선해야 하는가?&quot;</strong>라는 질문에 접근하는 방법을 배운 것 같아요.
인터넷에는 수많은 DB 최적화 기법들이 있지만, 어떤 기법을 언제 적용할지는 결국 서비스를 가장 잘 이해하고 있는 우리가 판단해야 했어요.</p>
<ul>
<li>어떤 기능이 사용자에게 핵심인가?</li>
<li>어떤 테이블의 데이터가 빠르게 증가하는가?</li>
<li>사용자들은 실제로 어떻게 검색하는가?</li>
</ul>
<p>백엔드 개발자로서 도메인을 깊이 이해할수록, 이런 질문들에 더 명확히 답할 수 있을 것이고, 수많은 쿼리 중에서 진짜 병목을 빠르게 찾고 적절한 해결책을 선택할 수 있을 것 같아요.</p>
<h2 id="남은-과제">남은 과제</h2>
<p>현재는 단일 요청 속도에 집중했지만, 실제 서비스에서는 다음 단계가 필요해요.</p>
<ul>
<li><strong>부하 테스트</strong>: 동시 사용자 환경에서의 처리량 검증</li>
<li><strong>동시성 제어</strong>: 락 경합, 데드락 등 실제 환경 이슈 해결</li>
<li><strong>지속적 모니터링</strong>: 데이터 증가에 따른 성능 변화 추적</li>
</ul>
<p>DB 성능 개선은 한 번에 끝나는 게 아니라, 
서비스와 함께 계속 발전시켜야 하는 여정이라는 걸 깨달았어요.</p>
<p>긴 글 읽어주셔서 감사합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[우아한테크코스 7기 레벨3 회고]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%EB%A0%88%EB%B2%A83-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%EB%A0%88%EB%B2%A83-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Sun, 31 Aug 2025 09:51:08 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>&quot;우아한테크코스에 입학한 지도 벌써 2달이 지났다.&quot; - <a href="https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%EB%A0%88%EB%B2%A81-%ED%9A%8C%EA%B3%A0">1레벨 회고</a></p>
</blockquote>
<p>라고 말한지 4달이 지났다. 시간 정말 빠르다.</p>
<p>3레벨에선 팀원들과 <a href="https://play.google.com/store/apps/details?id=com.bottari.bottari&amp;hl=ko">보따리</a>라는 앱을 출시했다. 이 프로젝트는 단순한 기술 성장을 넘어, 나를 오랫동안 괴롭혔던 <strong>완벽주의</strong>라는 고질병에서 벗어나게 해준 고마운 경험이었다.</p>
<p>이번 레벨은 함께 일하는 방법을 많이 시도해보고 배웠다. 매번 API를 완성하고 누군가의 작업을 하염없이 기다리는, 익숙한 분업에 지쳤다면 이 글이 좋은 인사이트가 될 수 있을 것 같다.</p>
<h1 id="완벽주의라는-착각-실체는-두려움">완벽주의라는 착각, 실체는 두려움</h1>
<p>완벽주의라 표현했지만, 실은 <strong>&#39;미완성인 내 결과물을 남에게 보여주는 것에 대한 두려움&#39;</strong> 이 더 정확한 표현이다. 이런 두려움 때문에 내 코드엔 버그가 없어야 했고, 누군가 지적하면 &quot;아, 그거 알고 있어요. 실수예요&quot;라며 둘러댄 적도 있었다. 당연히 결과물을 내놓기까지의 시간은 하염없이 길어졌다.</p>
<h2 id="완벽한-주제는-없었다">완벽한 주제는 없었다</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/761291aa-3e1c-4b83-898a-510447880e47/image.png" alt=""></p>
<p align="center">(위) 주제 어필의 흔적들</p>

<p>두 달 넘게 할 프로젝트이니 주제부터 완벽해야 했다. 사용자도 많아야 할 것 같았다. 내 아이디어가 채택되지 않을까 봐 발표 자료 준비에 엄청나게 공을 들였다.</p>
<p>하지만 팀원들의 생각은 저마다 조금씩 달랐다. 모두의 의견을 반영하다 보니 &quot;이런 서비스를 만들 수는 있나?&quot;라는 근본적인 의문이 들었다. 더군다나 우아한테크코스는 학습을 위한 환경인 만큼, 파트별 기술 도전 욕구도 무시할 수 없었기에 주제 선정에는 꽤 많은 시간이 걸렸다.</p>
<p>결국 깨달았다. 완벽한 주제란 없다.</p>
<p>우아한테크코스라는 공간에서 만큼은 <strong>각자 원하는 방향을 솔직하게 공유하고 합의점을 찾는 과정</strong>이 더 중요하다고 느꼈다. 그렇게 &#39;보따리&#39;가 탄생했다. 만약 그때도 완벽한 주제를 찾아 헤맸다면, 우린 아직도 기획만 하고 있었을 거다.</p>
<h1 id="분업과-협업-사이">분업과 협업 사이</h1>
<p><code>요약: 4인 페어 → 2인 페어 → API 협업</code></p>
<p>프로젝트 시작 전, 내가 팀에 제안 하나를 던졌다.</p>
<blockquote>
<p>&quot;우리, 4인 짝 프로그래밍으로 시작하면 어떨까?&quot;</p>
</blockquote>
<p>내가 제시한 이유는 아래와 같다.</p>
<pre><code>- 이전 레벨에서 경험한 짝 프로그래밍의 연장선, 이게 진짜 협업 아닐까?
- &#39;너는 게시판, 나는 로그인&#39; 이건 협업이 아니라 분업이다.
- 서로 다른 코드 컨벤션을 통일해서 일관성 있는 코드를 만들자.</code></pre><p>팀원 모두가 동의했고, 우린 1~2주간 4명이 한 화면을 보며 코드를 작성했다.
처음엔 한 화면을 보며 같이 코드를 짜는 게 어색했지만, 금방 웃으면서 의견을 주고받는 게 자연스러워졌다.</p>
<p>어느 날 코치와 면담 중 질문을 받았다.</p>
<blockquote>
<p>&quot;현재 작업은 어떻게 나누어 진행하고 있나요?&quot;</p>
</blockquote>
<p>우리는 자신만만하게 답했다. </p>
<blockquote>
<p>&quot;저희는 이런 이유로 4인 짝 프로그래밍을 하고 있습니다.&quot;</p>
</blockquote>
<p>하지만 돌아온 코치의 반응은 우려였다.</p>
<blockquote>
<p>&quot;그러면 작업 속도가 더디지 않나요?&quot;</p>
</blockquote>
<p>순간 모두가 멈칫하며 서로를 바라봤다... 그전까지는 전혀 문제라고 생각지 못했다. 4명이 같은 맥락을 공유하고 일관성 있는 코드를 작성하는 것이 멋지고 이상적인 협업이라 믿었다. 하지만 객관적인 결과물을 보니, 진행 속도가 생각보다 느리다는 것을 깨달았다.</p>
<p>그러면 작업을 나눠서 진행해야 할 것 같은데, 처음에는 의문이 먼저 들었다. &quot;작업을 나누면 순서 의존성 문제는 어떻게 해결하지?&quot; </p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/2b7820e0-374b-4c80-8923-869d532189d0/image.png" alt=""></p>
<p>예를 들어, 게시판 기능(A)이 완전히 완성되어야만 댓글이나 신고 기능(B)을 개발할 수 있다고 생각했다. 만약 두 짝이 A와 B를 동시에 작업하다 나중에 합칠 때 코드가 엉키거나 충돌이 나면, 그걸 해결하는 데 시간을 뺏겨 오히려 더 비효율적일 거라고 예상했기 때문이다.
(돌아보면 충돌이 나거나 코드가 엉망이 되는 것을 두려워했던 것 같다. 고치면 되는데..)</p>
<p>하지만 이건 우리의 착각이었다. <code>B 기능을 개발하는 데 필요한 것은 완성된 A 기능 전체가 아니었다.</code></p>
<p>단지 &#39;게시물이 존재한다&#39;는 약속, 즉 최소한의 데이터 구조만 있어도 기능을 구현할 수 있고, 충돌 역시 두려워할 대상이 아니었다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/2833d46e-d299-489b-babf-ca2a5010c01f/image.png" alt=""></p>
<p>이 깨달음을 바탕으로 우리는 2:2 짝으로 나뉘고, 코드 리뷰를 활성화하기로 했다. 서로 다른 작업을 하니, 코드 리뷰를 통한 동기화가 필수적이라 느꼈기 때문이다. 변화는 성공적이었다. 작업 속도는 눈에 띄게 빨라졌고, 코드 리뷰를 통해 배우는 점도 많아졌다. 이제 속도와 협업, 두 마리 토끼를 다 잡았다고 생각했다.</p>
<p>물론 모든 작업을 그렇게 진행하지 않았다. <strong>엔티티 설계처럼 충돌 가능성이 매우 높고 전체 구조에 영향을 미치는 작업은 다 함께 논의</strong>했고, 이후 구현 작업부터 병렬적으로 처리하여 유연하게 진행했다.</p>
<h2 id="여전히-분업이었다">여전히 분업이었다.</h2>
<p>하지만 진짜 문제는 다른 곳에 있었다. 어느 날 받은 피드백이 정곡을 찔렀다.</p>
<blockquote>
<p>&quot;혹시 백엔드는 API 다 만들고, 앱은 화면 다 만든 다음에 합치면서 서로 고생하고 있지 않나요?&quot;</p>
</blockquote>
<p>정확했다. 백엔드는 RESTful한 URI 구조를 위해 고민하는데, 앱에서는 &quot;그냥 API 하나로 처리해주면 안 돼요?&quot; 또는 &quot;이 API에 이것도 추가해주세요.&quot; 처럼 부딪히는 경험이 꽤 있었다. 각자 파트에서 최선이라 생각한 결과물이, 합치는 과정에서 수많은 수정 요청과 불필요한 비용을 발생시켰다.</p>
<blockquote>
<p>&quot;왜 일을 따로 하나요? 처음부터 같이 만들면 고칠 일이 없지 않을까요?&quot;</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/05b1b9d5-9c99-4a16-a34c-791223a40659/image.png" alt=""></p>
<p>지금까지는 팀이었지만 파트라는 이름으로 경계가 뚜렷했고, 서로의 과정보다는 연동 시점의 결과물만을 중시했다. 그 결과, 실제 연동 단계에 들어서야 서로의 생각 차이를 발견하게 되었고, 충돌과 수정이 반복되곤 했다.</p>
<h2 id="다른-협업-방법-도입">다른 협업 방법 도입</h2>
<p>문제를 개선하기 위해 다른 협업 방식을 고민했다.</p>
<h3 id="1-백엔드--안드로이드-짝을-이뤄-하나의-기능을-완성한다">1. 백엔드 + 안드로이드 짝을 이뤄 하나의 기능을 완성한다.</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/254871d4-3831-41fa-851e-8504779485d4/image.png" alt=""></p>
<ul>
<li>하나의 이슈(기능)를 서로 다른 파트원끼리 짝을 이룬다.</li>
<li>기능 구현에 대한 결정권은 해당 짝이 갖는다.</li>
<li>작업이 끝나면 각 파트 내에서 코드 리뷰를 진행한다.</li>
</ul>
<p>처음엔 다른 파트와 짝을 맺으니 낯설었지만, 내가 만든 API가 바로 눈앞 화면에 연결되는 걸 보며 성취감을 함께 느낄 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/0bcfb600-ec03-46c0-81d6-eee35aaaa8f0/image.png" alt=""></p>
<p align="center">(위) 서로 다른 파트 협업 이슈</p>

<p>화면이나 기능 단위로 작업을 나누어 할당하고, 내부적으로 작게 쪼개어 기능을 구현했다.</p>
<h3 id="2-서로의-작업이-끝나길-기다리지-않는다">2. 서로의 작업이 끝나길 기다리지 않는다.</h3>
<ul>
<li>서로의 로컬 환경을 동일하게 구성한다.</li>
<li>백엔드 코드가 코드 리뷰를 기다리고 있을 때, 안드로이드 팀원은 해당 브랜치를 가져와 로컬에서 작업한다.</li>
<li>안드로이드 기기가 없는 팀원은 단순히 기다리는 것이 아니라, 애뮬레이터로 앱을 사용한다.</li>
</ul>
<p>개발 서버에만 의존하지 않아도 된다는 점은 충분히 매력적이었다. 서버에 문제가 있거나 반영이 지연될 때도, 기다림 대신 그 시간을 적극적으로 활용할 수 있었기 때문이다.</p>
<h3 id="3-코드-충돌conflict을-두려워하지-않는다">3. 코드 충돌(Conflict)을 두려워하지 않는다.</h3>
<ul>
<li>&quot;충돌 나면 어떡하지?&quot; 대신 &quot;일단 만들고, 충돌 나면 고치자&quot; 는 마인드를 장착했다.</li>
</ul>
<p>자연스럽게 작업을 더 작게 나누는 연습이 된 것 같다.</p>
<h3 id="도입-후-결과">도입 후 결과</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/f1b85df4-8839-459d-9fd3-1a19a5e66ad0/image.png" alt=""></p>
<p align="center">(위) 서로 다른 파트 협업 도입 시점 전후의 완료 PR 개수 변화 (각 1개월)</p>

<p>PR의 크기는 서로 다르겠지만, 이 지표는 우리 팀이 훨씬 민첩해졌음을 명확히 보여준다. </p>
<p>잦은 충돌은 오히려 빠른 해결 능력을 길러주었던 것 같다. 추가로 자동 테스트 및 배포 파이프라인이 잘 구축되어 있어서 더 쉽게 충돌을 확인하거나 해결할 수 있었다.</p>
<h1 id="민첩함의-그림자-작업-공유의-중요성">민첩함의 그림자, 작업 공유의 중요성</h1>
<p>앞서 소개한 협업 방식은 팀의 생산성을 폭발적으로 높였지만, <a href="https://www.ibm.com/kr-ko/think/topics/data-silos">지식의 사일로화(Silo)</a>를 일으켰다.</p>
<p>팀원 각자가 맡은 기능에 깊게 몰입하다 보니, 다른 팀원이 진행한 작업의 세부 내용을 놓치는 일이 자연스럽게 발생한다. 예를 들어, A가 팀의 편의를 위해 멋진 배포 자동화 파이프라인을 구축했다. B는 그저 &#39;PR 머지만 하면 배포가 되니 편하다&#39;고 생각하며 사용할 뿐, 그 내부 원리나 구조, 문제가 생겼을 때의 대처법을 알기 어렵다.</p>
<p>단기적으로는 문제가 없다. 오히려 각자 작업에 집중하니 효율적이다. 하지만 이런 <strong>나만 아는 지식</strong>이 쌓이면 <strong>팀의 &#39;버스 팩터(Bus Factor)&#39;가 극도로 낮아진다.</strong></p>
<blockquote>
<h3 id="🚌-버스-팩터-bus-factor-란">🚌 버스 팩터 (Bus Factor) 란?</h3>
<p>팀의 핵심 멤버가 갑자기 버스에 치여 사라졌을 때(...) 팀원 중 예측 불가능한 이유로 업무 불능 상태가 되었을 때, 특정 업무가 중단되기까지 필요한 최소 인원수를 나타내는 지표입니다. 이 지수는 팀의 지식 집중도와 업무 위험도를 표현하며, 지수가 낮으면 소수의 인원에 대한 의존도가 높아 프로젝트 진행에 취약성을 가집니다.</p>
<p><code>N명이 팀을 구성한다 라고 하면 1과 N사이의 값</code>을 갖게 되는데, 만약 특정 업무를 진행하는데에 있어서 팀원들중 1명만이 맥락을 이해하고 있다면, 해당 1명이 사라지면 업무가 진행 될 수 없기 때문에, 이 경우 이 팀은 (이 업무는) 1이라는 버스 지수 값을 갖습니다. <a href="https://jhk0530.medium.com/%EB%B2%84%EC%8A%A4-%EC%A7%80%EC%88%98-53cb040f220b">출처</a></p>
</blockquote>
<p>만약 파이프라인을 만들었던 팀원이 휴가를 가거나 갑자기 아프기라도 하면 어떻게 될까? 배포 과정에 문제가 생겼을 때, 남은 팀원들은 그가 돌아올 때까지 기다리거나, 수많은 설정 파일과 코드를 역으로 파헤치며 귀한 시간을 낭비해야 한다. 우리가 애써 얻어낸 민첩함이 한순간에 무너진다고 생각한다.</p>
<h2 id="어떻게-개선할까">어떻게 개선할까?</h2>
<p>우리는 이 문제를 해결하기 위해 GitHub Wiki와 Notion을 단순한 기록 보관소가 아닌, 살아있는 지식 베이스로 활용할 수 있게 노력했다.</p>
<h3 id="결과가-아닌-과정을-기록">결과가 아닌 &#39;과정&#39;을 기록</h3>
<p>단순히 &#39;무엇을 만들었다&#39;가 아닌, &#39;왜 이런 기술적 결정을 했는지&#39;, &#39;어떤 문제를 해결하려 했는지&#39; 와 같은 의사결정 과정을 기록하자.</p>
<p>예를 들어, 특정 기술을 선택한 이유, API 설계 시 고려했던 점, 지금의 아키텍처가 나오기까지의 고민 등을 가볍게라도 남겨 히스토리를 공유하자.</p>
<ul>
<li><a href="https://github.com/woowacourse-teams/2025-bottari/wiki/%5BBE%5D-%EB%B2%84%EC%A0%84-%EB%B0%8F-%EA%B8%B0%EC%88%A0-%EC%8A%A4%ED%83%9D-%EC%84%A0%EC%A0%95">기술 스택 및 버전 선택 이유</a></li>
<li><a href="https://github.com/woowacourse-teams/2025-bottari/wiki/%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EB%8F%84%EA%B5%AC-%EC%84%A0%ED%83%9D-%EC%9D%B4%EC%9C%A0">모니터링 도구 선택 이유</a></li>
<li><a href="https://github.com/woowacourse-teams/2025-bottari/wiki/%EC%9A%B4%EC%98%81-%EC%84%9C%EB%B2%84-%EC%9D%B8%ED%94%84%EB%9D%BC-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98">운영 서버 인프라 아키텍처</a></li>
<li><a href="https://github.com/woowacourse-teams/2025-bottari/wiki/%ED%8C%80-%EB%B3%B4%EB%94%B0%EB%A6%AC-%EB%B3%80%EA%B2%BD-%EC%82%AC%ED%95%AD-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EB%B0%98%EC%98%81-%EA%B5%AC%ED%98%84-%EA%B2%80%ED%86%A0">팀 보따리 변경 사항 실시간 반영 구현 검토</a></li>
</ul>
<h3 id="암묵지를-형식지로-바꾸다">&#39;암묵지&#39;를 &#39;형식지&#39;로 바꾸다</h3>
<p>작업 내용 반영 방법, 배포 자동화 파이프라인, 로그 전략 및 로그 파일 저장 위치 관련 설정 등 특정 담당자만 알고 있던 <a href="https://www.kird.re.kr/newsletter/html/vol122/sub02.html">&#39;암묵적인 지식(암묵지)&#39;</a>을 누구나 따라 할 수 있는 &#39;형식적인 문서(형식지)&#39;로 만들자. </p>
<ul>
<li><a href="https://github.com/woowacourse-teams/2025-bottari/wiki/%EA%B0%9C%EB%B0%9C-%EC%84%9C%EB%B2%84-%EC%9E%91%EC%97%85-%EB%82%B4%EC%9A%A9-%EB%B0%98%EC%98%81-%EB%B0%A9%EB%B2%95">개발 서버 작업 내용 반영 방법</a></li>
<li><a href="https://github.com/woowacourse-teams/2025-bottari/wiki/%EA%B0%9C%EB%B0%9C-%EC%84%9C%EB%B2%84-%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8">개발 서버 배포 자동화 파이프라인</a></li>
<li><a href="https://github.com/woowacourse-teams/2025-bottari/wiki/%EB%A1%9C%EA%B7%B8-%EC%A0%84%EB%9E%B5-%EB%B0%8F-%EB%A1%9C%EA%B7%B8-%ED%8C%8C%EC%9D%BC-%EC%A0%80%EC%9E%A5-%EC%9C%84%EC%B9%98-%EA%B4%80%EB%A0%A8-%EC%84%A4%EC%A0%95">로그 전략 및 로그 파일 저장 위치 관련 설정</a></li>
</ul>
<p>지금 생각해보면 자주 발생하는 에러에 대한 트러블슈팅 가이드도 있다면 팀의 시간을 크게 절약해줄 것 같다.</p>
<h3 id="문서를-살아있게-유지하다">문서를 &#39;살아있게&#39; 유지하다</h3>
<p>어려운 규칙이다. </p>
<p>문서는 한 번 만들고 끝이 아니라 Pull Request를 올릴 때 관련 문서 링크를 첨부하거나, 기능 변경 시 관련 문서를 함께 수정해야 한다. 문서가 방치되는 것을 막고, 항상 최신 상태를 유지할 수 있어야 한다고 생각한다.</p>
<p>작업 공유는 단순히 <strong>인수인계를 위한 보험</strong>이 아니라고 느꼈다. 팀 전체의 기술적 체력을 함께 키우고, 누가 없어도 흔들리지 않는 회복탄력성 있는 팀을 만드는 중요한 과정이라고 생각한다.</p>
<h1 id="마무리">마무리</h1>
<ul>
<li><strong>완벽주의는 두려움의 다른 이름</strong>이었다. 미완성이라도 드러내고 피드백을 받아야 성장할 수 있었다.  </li>
<li><strong>협업은 단순한 분업이 아니다.</strong> 결과물이 아니라 과정을 공유해야 불필요한 충돌을 줄이고 진짜 협업이 된다.  </li>
<li><strong>속도와 협업은 대립하지 않는다.</strong> 방식을 바꾸니 더 빨라졌고, 더 많이 배울 수 있었다.  </li>
<li><strong>민첩함의 뒷면엔 지식 공유가 필요하다.</strong> 사일로를 깨고 과정을 기록해야 팀 전체의 체력이 유지된다. </li>
<li><strong>정답은 없지만, 더 나은 방식을 찾아가는 과정 자체가 성장</strong>이었다.  </li>
</ul>
<p>돌이켜보면 최근 블로그를 쓰지 못했던 이유도 완벽주의 때문이었다. 더 좋은 주제를 찾아야 한다는 강박이 글쓰기 자체를 막아버렸다. 항상 정답은 없겠지만, 현재 상황에서 더 나은 협업 방식을 찾는 과정에서 많이 배웠다. 이 모든 여정을 함께한 보따리 팀원들에게 감사를 전한다.</p>
<blockquote>
<p>여러분은 어떤 협업 방식을 시도하고 계신가요?</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[복잡해지는 요구사항 속 도메인 간 강결합]]></title>
            <link>https://velog.io/@hyeok_1212/%EB%B3%B5%EC%9E%A1%ED%95%B4%EC%A7%80%EB%8A%94-%EC%9A%94%EA%B5%AC%EC%82%AC%ED%95%AD-%EC%86%8D-%EB%8F%84%EB%A9%94%EC%9D%B8-%EA%B0%84-%EA%B0%95%EA%B2%B0%ED%95%A9</link>
            <guid>https://velog.io/@hyeok_1212/%EB%B3%B5%EC%9E%A1%ED%95%B4%EC%A7%80%EB%8A%94-%EC%9A%94%EA%B5%AC%EC%82%AC%ED%95%AD-%EC%86%8D-%EB%8F%84%EB%A9%94%EC%9D%B8-%EA%B0%84-%EA%B0%95%EA%B2%B0%ED%95%A9</guid>
            <pubDate>Mon, 26 May 2025 05:01:01 GMT</pubDate>
            <description><![CDATA[<p>소프트웨어의 요구사항은 시간이 지날수록 복잡해져요.</p>
<p>예전에는 많은 것을 예측하고 만드는 것이 잘 만들어진 소프트웨어라고 봤어요. 시장 흐름이 크게 복잡하지 않았고, 기술의 변화도 크지 않았기에 가능했던 것 같아요.</p>
<p>그러나 지금은 달라요. 사람들의 취향이나 트렌드는 하루만에 변하고, 기술은 끊임 없이 개발되어 우리를 놀라게 해요. AI 관련 기술만 보더라도 엄청난 속도를 체감할 수 있어요.</p>
<p>그래서 요즘은 예측도 중요하지만, <code>변화에 대응하는 것</code>을 더 중요하게 생각하는 것 같아요. 소프트웨어도 마찬가지로 읽기 좋은 코드, 확장 유연함 등을 중요하게 생각하면서 대응에 초점을 맞추는 것 같아요.</p>
<h1 id="요구사항">요구사항</h1>
<blockquote>
<h3 id="요구사항requirement">요구사항(requirement)</h3>
</blockquote>
<ol>
<li>사용자가 문제를 해결하거나 목표를 달성하기 위해 필요한 조건이나 능력<blockquote>
<p>   •    예: 사용자가 상품을 구매하기 위해 로그인 기능이 필요하다.</p>
</blockquote>
</li>
<li>시스템 또는 시스템 구성요소가 계약, 표준, 명세서 또는 기타 공식 문서를 만족하기 위해 갖추어야 할 조건이나 능력<blockquote>
<p>   •    예: “이 시스템은 하루 1,000건 이상의 요청을 처리할 수 있어야 한다”는 조건이 명세서에 명시되어 있다면, 그것이 요구사항이 된다.</p>
</blockquote>
</li>
<li>위 (1) 또는 (2)에 해당하는 조건이나 능력을 문서화한 것<blockquote>
<pre><code>•    즉, 사용자 요구사항이든 시스템 요구사항이든, 그것을 문서로 작성한 것이 요구사항 문서이다.</code></pre><p><a href="https://www.informatik.htw-dresden.de/~hauptman/SEI/IEEE_Standard_Glossary_of_Software_Engineering_Terminology%20.pdf">출처: IEEE Standard Glossary of
Software Engineering Terminology, p.62, requirement</a></p>
</blockquote>
</li>
</ol>
<p>소프트웨어의 요구사항을 정리하면 <code>충족해야 할 조건이나 기능</code>으로 정리할 수 있어요.</p>
<h2 id="빠르고-큰-변화">빠르고 큰 변화</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/6789d3e0-1e09-4c1f-9121-b9664d4a5b74/image.png" alt=""></p>
<p>앞서 말한 것처럼 요즘은 변화가 큰 폭으로 자주 일어나기 때문에 <code>충족해야 할 조건이나 기능</code>이 계속해서 바뀌는 것 같아요. 예를 들어, 초기에는 <code>단순 예약 및 취소</code>만 필요했던 방탈출 예약 시스템에 갑자기 <code>예약 취소 시 대기 자동 승인</code> 기능이 필요해지고, 곧이어 <code>대기자에게 카카오톡 알림</code>을 보내는 기능, <code>예약 보증금 결제</code> 기능까지 추가되는 상황을 상상해 보세요.</p>
<p>이러한 관점으로 바라본 도메인 간 강결합에 대해 알아보려고 해요.</p>
<h1 id="도메인-강결합">도메인 강결합?</h1>
<p>방탈출 예약 애플리케이션을 예시로 살펴볼게요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/5dfbd86d-8ba3-4c27-bf5d-cbba70426a55/image.png" alt=""></p>
<p>사용자는 날짜, 테마, 시간을 선택하여 방탈출을 예약할 수 있어요.
만약, 해당 슬롯이 이미 예약된 경우 대기를 요청할 수 있어요.</p>
<h2 id="자동-승인-기능-시나리오">자동 승인 기능 시나리오</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/ce5ec2cd-619e-43d8-a3d9-3e4b910a7051/image.png" alt=""></p>
<p>A 사용자가 예약을 하고, B 사용자가 동일한 슬롯에 대기를 요청했어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/6e9451c4-8350-4135-8f9b-d7409d7e5cb0/image.png" alt=""></p>
<p>이후 A 사용자가 예약을 취소하면, B 사용자의 대기(첫 번째 대기)를 자동으로 승인하여 예약으로 전환해야 하는 시나리오를 살펴볼게요.</p>
<h2 id="코드로-확인하기">코드로 확인하기</h2>
<pre><code class="language-java">// ReservationService
public void cancel(Long reservationId) {
    reservationRepository.deleteById(reservationId);
}</code></pre>
<p>식별자로 단순 예약 취소를 진행하던 코드에요. <code>자동 승인 기능</code>을 구현하기 위해 아래와 같이 작성할 수 있어요.</p>
<pre><code class="language-java">public void cancel(Long reservationId) {
    reservationRepository.deleteById(reservationId);
    if (waitingRepository.exists()) {
        // 가장 먼저 등록된 대기자를 예약으로 전환
    }
}</code></pre>
<p>이제 <code>자동 승인 기능</code>을 위해 예약 도메인이 대기 도메인을 알아야 해요...</p>
<p>예약이 대기를 알면 안 되는가? 아니요, 단순히 예약이 대기를 알면 안 된다는 건 아니에요.
다만, 저는 아래와 같은 관점에서 이 상황이 문제라고 인식될 수 있다고 생각했어요.</p>
<ul>
<li>방탈출 예약 애플리케이션에서 <code>예약</code>은 가장 중요한 핵심 도메인이에요.</li>
<li>반면 <code>대기</code>라는 도메인은 없어질 수 있거나, 기능의 변경 빈도가 높을 것이라고 추측했어요.<ul>
<li>온라인 대기를 없애고 현장 대기로만 운영될 수 있다.</li>
<li>특정 방탈출 슬롯은 대기를 지원하지 않을 수 있다.</li>
</ul>
</li>
</ul>
<p>이처럼 대기라는 도메인이 없어지거나 크게 변경되더라도 예약 기능에는 영향이 없어야 한다고 생각했어요. 정리하면 핵심 도메인이 변동성이 큰 도메인을 직접 알고 있다면, 변화에 대응하기 어려워질 것 같다는 의견이에요.</p>
<p>또한, 결제가 도입된다면, 또 다른 의존성이 추가될 수밖에 없어요.</p>
<pre><code class="language-java">public void cancel(Long reservationId) {
    reservationRepository.deleteById(reservationId);
    if (waitingRepository.exists()) {
        // 대기 자동 승인
    }
    payment.cancel(); // 결제 취소 처리, 결제 도메인 알게 됨
}</code></pre>
<p>결국 시간이 지나면, 예약 도메인이 다른 도메인(대기, 결제)과 강하게 결합돼요. <code>예약 취소 기능</code> 하나에 벌써 <code>자동 대기 승인</code>과 <code>결제 취소</code> 기능이 묶여있어요.</p>
<p>다른 관점으로는 새로운 기능이 추가될 때마다 기존 서비스에 변경이 일어나요. (OCP 위반 가능성) 결과적으로 기능 하나 하나가 비대해지고, 유지보수와 확장이 어려울 것 같다는 생각이 들었어요.</p>
<h2 id="어떻게-해결했는가">어떻게 해결했는가?</h2>
<p>저는 도메인 복잡도를 낮추기 위해 <code>수동 승인</code>으로 구현했어요. 관리자가 직접 승인하는 구조에요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/aee91d6b-5dad-4023-8978-7a92601c24c2/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/80437107-8dd6-41c0-bb3d-a48cdacc3b57/image.png" alt=""></p>
<ul>
<li>좌: <code>자동 승인</code>, 우: <code>수동 승인</code></li>
</ul>
<p>이 방식은 대기 도메인이 변경되거나 없어지더라도, 제가 중요하게 생각한 예약 도메인에는 영향이 가지 않는다는 장점이 있어요. 핵심 도메인의 안정성을 지키면서 도메인 간의 직접적인 결합을 피했기에, 변화에 대응하기 쉬워졌다고 할 수 있어요.</p>
<p>도메인 복잡도가 감소하여, 변화에 대응하기 쉬워졌다고 할 수 있어요.</p>
<h3 id="그러나">그러나...</h3>
<p>제가 관리자라면, <code>수동 승인</code>보다는 <code>자동 승인</code>을 더 선호할 것 같아요. 도메인 복잡도를 감소시켜 개발 편의성을 높였지만, 그로 인해 사용자와 관리자의 편의성이 저하될 수 있어요. 과연 괜찮을까요?</p>
<p>시간이 지나면서, 예약의 안정성을 지키기 위해 결제 취소도 관리자가 직접 처리해야 하고, 대기자 알림도 시간마다 확인해서 보내거나 수동으로 전송해야 하는 등 운영상의 부담이 커질 수 있어요.</p>
<h3 id="유연함을-위해서는-자동화를-포기해야-하는가">유연함을 위해서는 자동화를 포기해야 하는가?</h3>
<p><code>개발의 유연성</code>과 <code>사용자/운영의 편의성</code>을 모두 잡으려고 노력해야 해요.</p>
<p>기존 방식의 문제는 <code>도메인 간의 직접적인 의존성</code>에서 비롯되었어요. <code>ReservationService</code>가 <code>Waiting</code>과 <code>Payment</code> 등의 로직을 직접 호출하면서, <code>ReservationService</code>는 너무 많은 것을 알게 되고, 책임이 비대해졌죠. 마치 비행기가 관제탑 없이 서로 직접 통신하려 하는 것과 비슷했어요.</p>
<p>문제를 해결하기 위해, 저는 도메인 간의 직접적인 의존성을 끊고 간접적인 방식으로 소통하게 하는 방법들을 찾아봤어요. 그리고 그 해결책 중 하나로 이벤트 발행 및 수신에 대해 알게 되었어요.</p>
<h1 id="이벤트">이벤트?</h1>
<p>프로그램에 의해 감지되고 처리될 수 있는 동작이나 사건을 말해요. 예를 들어, 사용자가 버튼을 클릭하는 행위, 파일 다운로드가 완료된 사건, 데이터베이스에 새로운 정보가 추가된 사건 등이 모두 이벤트가 될 수 있어요.</p>
<h2 id="발행과-수신">발행과 수신</h2>
<p>이벤트 기반 아키텍처에서 핵심은 <code>이벤트 발행자(Publisher)</code>와 <code>이벤트 수신자(Subscriber)</code>에요.</p>
<ul>
<li><code>이벤트 발행자(Publisher)</code>: 어떤 사건이 발생했음을 알리는 주체에요. 이벤트를 발생시키고, 자신에게 어떤 수신자가 이 이벤트를 처리할지는 전혀 알지 못해요. 그저 &quot;나 이런 일 생겼어!&quot;라고 외칠 뿐이에요.</li>
<li><code>이벤트 수신자(Subscriber)</code>: 특정 이벤트에 관심이 있어 해당 이벤트를 수신하고 처리하는 주체에요. 발행자가 어떤 객체인지는 알 필요가 없어요. 그저 &quot;이런 이벤트가 발생하면 내가 처리해야지!&quot;라고 기다릴 뿐이에요.</li>
</ul>
<p>이벤트 발행자와 수신자는 서로에 대한 직접적인 참조나 의존성 없이 느슨하게 결합돼요. 마치 신문사(발행자)가 신문을 찍어내고, 독자(수신자)는 자신이 원하는 신문을 구독하는 것과 비슷해요. 신문사는 어떤 독자가 신문을 읽을지 모르고, 독자는 신문사가 어떤 방식으로 신문을 만드는지 모르는 것에 비유할 수 있어요.</p>
<h2 id="스프링에서의-이벤트">스프링에서의 이벤트</h2>
<p>스프링 프레임워크는 이벤트 기반 아키텍처를 쉽게 구현할 수 있게 도와줘요.</p>
<p><code>스프링 이벤트(Spring Event)</code>는 애플리케이션 내부에서 특정 사건이 발생했을 때, 그 사건에 관심 있는 다른 컴포넌트들이 이를 인지하고 각자의 로직을 수행하도록 돕는 역할을 해요. 복잡한 설정을 할 필요 없이, 간단한 어노테이션 <code>@EventListener</code>만으로 이벤트 발행자와 수신자를 연결할 수 있어요.</p>
<ul>
<li><code>이벤트 발행</code>: 특정 로직 수행 후, <code>ApplicationEventPublisher</code>를 통해 이벤트를 발행해요.</li>
<li><code>이벤트 수신</code>: 이벤트에 반응해야 하는 컴포넌트(서비스)는 발행된 이벤트를 받기 위해 <code>@EventListener</code> 어노테이션을 붙인 메서드를 정의해요. 스프링이 알아서 이벤트를 해당 메서드로 전달해줘요.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/fd132b45-abc9-4171-b646-6441f3cbca6b/image.png" alt=""></p>
<p><code>ReservationService</code>는 오직 예약을 취소하고 <code>취소 이벤트</code>만 발행하는 책임만 가지고, 다른 곳에서 이벤트를 수신받아 알아서 처리(자동 승인, 결제 취소 등)하면 돼요. 서로가 어떻게 구현되었는지 전혀 알 필요가 없어져요.</p>
<p>대기 자동 승인이나 결제 취소와 같은 기능이 추가되거나 수정될 때 예약 취소 기능에는 영향을 끼치지 않게 돼요.</p>
<h1 id="코드로-알아보기">코드로 알아보기</h1>
<p>이벤트 발행 구독을 활용하여 느슨한 결합을 만드는 것을 목표로 간단하게 <code>자동 승인 기능</code>을 구현해볼게요.</p>
<h2 id="발행할-이벤트">발행할 이벤트</h2>
<pre><code class="language-java">public record ReservationCancelEvent(
        LocalDate reservationDate,
        Long reservationTimeId,
        Long themeId
) {
}</code></pre>
<p>예약이 취소되는 경우 <code>날짜, 시간 id, 테마 id</code>를 담은 취소 이벤트를 발행하고 이를 통해 밖에서 로직을 수행해요. 간결하고 불변성을 보장하기 위해 record를 사용했어요.</p>
<blockquote>
<p>스프링 4.2 이전에는 반드시 이벤트 클래스가 <code>ApplicationEvent</code>를 상속받아야 했어요. 하지만 4.2부터는 해당 클래스를 상속받지 않고도(어떤 타입이라도, Object) 이벤트로 사용할 수 있게 되었어요. </p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/5d412787-a8d5-4d19-a1da-d9085ceb9ff0/image.png" alt=""></p>
<blockquote>
<p>덕분에 원하는 형태의 클래스를 정의하고 이벤트로 사용할 수 있어요.</p>
</blockquote>
<h2 id="예약-취소-기능-이벤트-발행">예약 취소 기능 (이벤트 발행)</h2>
<pre><code class="language-java">@Service
@Transactional
@RequiredArgsConstructor
public class ReservationService {

    private final ReservationRepository reservationRepository;
    private final ApplicationEventPublisher applicationEventPublisher;

    public void cancelById(Long reservationId) {
        Reservation reservation = getReservation(reservationId);
        reservationRepository.delete(reservation);
        applicationEventPublisher.publishEvent(
                new ReservationCancelEvent(
                    reservation.getDate(),
                    reservation.getTime().getId(),
                    reservation.getTheme().getId()
                )
        ));
    }
}</code></pre>
<p><code>ApplicationEventPublisher#publishEvent</code>를 사용하여 예약 취소라는 사건을 시스템에 알리는 이벤트를 발행해요. <code>ReservationService</code>는 이제 대기나 결제 로직에 대해 아무것도 알 필요가 없어요. 그저 &quot;예약이 취소되었다&quot;는 사실만 알리면 돼요.</p>
<h2 id="자동-승인-기능-이벤트-수신-및-로직">자동 승인 기능 (이벤트 수신 및 로직)</h2>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class DeleteReservationEventListener {

    private final AutoWaitingPromotionService autoWaitingPromotionService;

    // 이벤트 수신
    @EventListener
    public void handle(ReservationCancelEvent reservationCancelEvent) {
        // 이벤트에서 값을 꺼내 전달
        autoWaitingPromotionService.promote(
                reservationCancelEvent.reservationDate(),
                reservationCancelEvent.reservationTimeId(),
                reservationCancelEvent.themeId()
        );
    }
}

@Service
@Transactional
@RequiredArgsConstructor
public class AutoWaitingPromotionService {

    private final WaitingRepository waitingRepository;
    private final ReservationRepository reservationRepository;

    // 로직 수행
    public void promote(LocalDate reservationDate, Long reservationTimeId, Long themeId) {
        // 대기 존재 여부를 확인하고 존재한다면, 가장 첫 대기를 예약으로 승인
    }
}</code></pre>
<p><code>@EventListener</code> 어노테이션이 붙은 <code>handle()</code> 메서드는 <code>ReservationCancelEvent</code>가 발행되면 자동으로 호출돼요. <code>DeleteReservationEventListener</code>는 이벤트 발행자와 수신자 사이에서 중개자 역할을 하며, 실제 비즈니스 로직은 <code>AutoWaitingPromotionService</code>가 담당하고 있어요. </p>
<p>덕분에 <code>ReservationService</code>와 <code>AutoWaitingPromotionService</code>는 서로의 존재를 모르고 독립적으로 동작할 수 있게 돼요.</p>
<h2 id="만약-이벤트-수신이-많아지면">만약 이벤트 수신이 많아지면?</h2>
<p>이제 <code>@EventListener</code>를 사용하여 어디서든 <code>ReservationCancelEvent</code>를 수신하고 필요한 로직을 처리할 수 있게 되었어요. 덕분에 시스템의 결합도를 낮추고 유연성을 높일 수 있었어요.</p>
<p>하지만, 만약 취소 이벤트를 수신하고 처리하는 로직이 많아지거나, 그 로직 자체가 오래 걸리는 작업이라면 어떻게 될까요? 아래에서 이메일 전송 중 문제가 발생하여 30초간 기다리는 시나리오를 생각해 볼게요.</p>
<pre><code class="language-java">@EventListener
@Transactional
public void handle(ReservationCancelEvent reservationCancelEvent) {
    // 예시: 이메일 전송 중 문제가 발생하여 30초간 대기하는 시나리오
    // emailSender.send(...);
    // 30초 대기
    try {
        Thread.sleep(30_000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException(&quot;Thread was interrupted&quot;, e);
    }
}</code></pre>
<p>이때, 취소 이벤트를 발행한 메서드를 호출한 사용자는 30초 넘게 응답을 받지 못하게 돼요. 사용자에게 시스템이 느리거나 멈춘 것처럼 느껴지게 하여 불만족스러운 경험을 제공할 수 있어요.</p>
<h3 id="동기-vs-비동기-이벤트-처리">동기 vs 비동기 이벤트 처리</h3>
<p>기본적으로 스프링의 <code>@EventListener</code>는 이벤트를 발행한 곳과 같은 스레드에서 <code>동기(Synchronous)</code> 방식으로 처리해요.</p>
<p>예를 들어, A라는 서비스에서 이벤트를 발생시키면, 이 이벤트를 처리하는 <code>@EventListener</code> 메서드도 A 서비스의 스레드에서 바로 실행돼요. 이렇게 되면 모든 이벤트 리스너의 작업이 완료될 때까지 A 서비스의 응답이 지연돼요. (그래서 30초 넘게 응답을 받지 못했어요)</p>
<p>만약 이메일 발송처럼 당장 보내는 것을 기다리지 않아도 되는 부가적인 작업이라면, 비동기 방식을 사용할 수 있어요. 스프링은 <code>@Async</code> 어노테이션을 통해 이를 간단하게 지원하고 있어요.</p>
<p>먼저, 스프링 애플리케이션에 비동기 기능을 활성화 해줘야 해요. 보통 메인 애플리케이션 클래스나 별도의 설정 클래스에 <code>@EnableAsync</code> 어노테이션을 추가하는 방식으로 활성화 해요.</p>
<pre><code class="language-java">@EnableAsync // 비동기 기능 활성화
@SpringBootApplication
public class RoomEscapeApplication {
}</code></pre>
<pre><code class="language-java">@Async
@EventListener
@Transactional
public void handle(ReservationCancelEvent reservationCancelEvent) {
    // 취소 관련 이메일 전송 로직 수행
}</code></pre>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/230f6125-dff6-4eae-a5d6-6b78f7d7443b/image.png" alt=""></p>
<p>이렇게 비동기 처리하면, 예약 취소의 응답 속도가 빨라지는 장점이 있어요. 예약 취소 요청이 빠르게 처리되고, 부가적인 작업은 백그라운드에서 진행되니까요.</p>
<p>하지만, 만약 이벤트 처리를 다른 스레드에서 비동기적으로 하고 싶다면 주의해야 할 문제가 있어요. 새로운 스레드에서는 원래 스레드의 <code>ThreadLocal</code>이나 <code>MDC(Mapped Diagnostic Context)</code> 정보가 기본적으로 전달되지 않아요.</p>
<blockquote>
<h3 id="컨텍스트-전파context-propagation">컨텍스트 전파(Context Propagation)</h3>
<p>ThreadLocal에는 현재 로그인한 사용자 정보, 요청 ID, 트랜잭션 ID 등 중요한 컨텍스트 정보가 저장되는 경우가 많아요. MDC는 로깅에 사용되어 특정 요청에 대한 모든 로그를 추적할 수 있게 해주죠. 이벤트가 다른 스레드에서 처리될 때 이러한 컨텍스트 정보가 전파되지 않으면, 로그에 사용자 정보가 누락되거나, 특정 요청의 흐름을 추적하기 어려워지는 등의 문제가 발생할 수 있어요. 스프링은 이러한 문제를 해결하기 위한 기술(예: CompletableFuture, TaskDecorator 등을 활용한 컨텍스트 전파)을 제공하고 있어요.</p>
</blockquote>
<p><a href="https://docs.spring.io/spring-framework/reference/integration/observability.html#observability.application-events">Application Events and @EventListener, Spring Docs</a></p>
<h1 id="eventlistener는-publishevent가-수행되는-즉시-동작">@EventListener는 publishEvent가 수행되는 즉시 동작?</h1>
<p>또 다른 중요한 문제가 있어요. 현재 <code>@EventListener</code>는 <code>publishEvent()</code>가 호출되는 즉시 동작해요.</p>
<p>방탈출 예약 취소를 수행하고, 중간에 <code>publishEvent()</code>로 취소 이벤트를 발행하는 순간 <code>@EventListener</code>가 동작해요.</p>
<pre><code class="language-java">@Service
@Transactional
public class ReservationService {
    // ...
    public void cancelById(Long reservationId) {
        Reservation reservation = getReservation(reservationId);
        reservationRepository.delete(reservation); // (1) 예약 삭제 시도
        applicationEventPublisher.publishEvent(new ReservationCancelEvent(...)); // (2) 이벤트 발행
        // ... (3) 만약 여기서 예상치 못한 예외가 발생한다면?
    }
}</code></pre>
<p>만약 (3)번 위치에서 예상치 못한 예외가 발생하여 <code>ReservationService</code>의 트랜잭션이 롤백되었다면 어떻게 될까요? </p>
<p>예약 삭제 작업은 롤백되지만, 리스너는 이미 (2)번 시점에서 동작했기 때문에 첫 번째 대기를 승인하려고 할 거예요. 예약은 취소되지 않았는데 대기는 승인되어버리는 상황이기 때문에 심각한 데이터 불일치를 야기할 수 있어요.</p>
<h2 id="transactionaleventlistener">@TransactionalEventListener</h2>
<p>이러한 데이터 불일치 문제를 해결하기 위해 스프링에서는 트랜잭션의 완료 시점에 따라 이벤트가 동작하도록 특별히 설계된 <code>@TransactionalEventListener</code>를 제공해요.</p>
<p><code>@TransactionalEventListener</code>는 이름 그대로 트랜잭션에 묶여 동작하는 이벤트 리스너예요. 일반 <code>@EventListener</code>와 달리, 이벤트 발행 시 바로 실행되지 않고, 이벤트가 발행된 트랜잭션의 특정 단계(Phase)에서만 실행되도록 설정할 수 있어요.</p>
<h3 id="phase-속성">phase 속성</h3>
<ul>
<li><code>AFTER_COMMIT</code> (기본값): 트랜잭션이 성공적으로 커밋된 후에 이벤트가 실행돼요. 가장 일반적이고 중요한 설정으로, ReservationService의 트랜잭션이 성공해야만 대기 자동 승인 로직이 동작하도록 할 수 있어요.</li>
<li><code>BEFORE_COMMIT</code>: 트랜잭션이 커밋되기 직전에 실행돼요.</li>
<li><code>AFTER_ROLLBACK</code>: 트랜잭션이 롤백된 후에 실행돼요. (예: 특정 작업 실패 시 로그 기록, 보상 트랜잭션 등)</li>
<li><code>AFTER_COMPLETION</code>: 트랜잭션이 커밋되든 롤백되든 완료된 후에 실행돼요.</li>
</ul>
<blockquote>
<p>트랜잭션이 없는 경우, 즉 이벤트를 발행한 스레드에서 트랜잭션이 실행 중이 아니라면 <code>@TransactionalEventListener</code>는 기본적으로 호출되지 않아요. 이는 트랜잭션과 연동되어야 하는 리스너의 본래 목적 때문이에요. 만약 트랜잭션이 없더라도 리스너가 동작해야 한다면 <code>fallbackExecution = true</code> 속성을 사용할 수 있지만, 이 경우 트랜잭션 연동의 이점은 사라지므로 신중하게 선택해야 해요.</p>
<p><a href="https://docs.spring.io/spring-framework/reference/data-access/transaction/event.html">Transaction-bound Events, Spring Docs</a></p>
</blockquote>
<h2 id="예약-취소가-정말-되었을-때만-대기를-승인하자-최종-개선">예약 취소가 정말 되었을 때만 대기를 승인하자 (최종 개선)</h2>
<pre><code class="language-java">@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void promoteWaitingAfterReservationCanceled(ReservationCancelEvent reservationCancelEvent) {
    LocalDate date = reservationCancelEvent.reservationDate();
    Long reservationTimeId = reservationCancelEvent.reservationTimeId();
    Long themeId = reservationCancelEvent.themeId();
    autoWaitingPromotionService.promote(date, reservationTimeId, themeId);
}</code></pre>
<p>이제 이 리스너는 예약 취소 트랜잭션이 성공적으로 커밋된 이후에만 동작해요.</p>
<h3 id="질문-1-transactionaleventlistener를-async와-함께-사용할-수-있나요">질문 1: @TransactionalEventListener를 @Async와 함께 사용할 수 있나요?</h3>
<p>네, <code>@TransactionalEventListener</code>와 <code>@Async</code>는 함께 사용할 수 있으며, 실제로 많이 권장되는 조합이라고 해요.</p>
<p>두 어노테이션은 서로 다른 목적을 가지고 상호 보완적으로 작동해요.</p>
<p><code>@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code>은 <strong>언제</strong> 이벤트 리스너가 실행될지를 제어해요. 즉, 이벤트 발행 트랜잭션(여기서는 예약 취소 트랜잭션)이 성공적으로 커밋된 후에만 이 리스너가 동작하도록 보장해요.</p>
<p><code>@Async</code>는 <strong>어떻게</strong> 이벤트 리스너가 실행될지를 제어해요. 리스너의 로직을 이벤트를 발행한 스레드가 아닌, 별도의 스레드에서 비동기적으로 실행되도록 만들어요. 덕분에 오래 걸릴 수 있는 작업이 메인 스레드를 블로킹하지 않아 애플리케이션의 응답 속도를 개선할 수 있어요.</p>
<p>두 어노테이션을 함께 사용하면, <strong>예약 취소 트랜잭션이 확정된 후 (데이터 정합성 보장), 부가적인 대기 승인 로직이 별도의 스레드에서 빠르게 처리(성능 향상)</strong>되는 시나리오를 구현할 수 있어요.</p>
<blockquote>
<p><code>BEFORE_COMMIT</code>는 발행 트랜잭션과 동일한 스레드에서 실행되므로, <code>@Async</code>와 함께 사용할 수 없어요. <code>@Async</code>를 사용하면 별도의 스레드가 생성되어 트랜잭션 컨텍스트가 분리되기 때문이에요.</p>
</blockquote>
<h3 id="질문-2-트랜잭션은-하나의-스레드에서만-보장되는-것-아니었나요">질문 2: 트랜잭션은 하나의 스레드에서만 보장되는 것 아니었나요?</h3>
<p>예리한 질문이에요. 스프링의 기본 트랜잭션(JPA, JDBC 등)은 기본적으로 하나의 스레드에 묶여 동작해요. 그러나 잘 살펴보면 이벤트 리스너가 트랜잭션이 <code>종료된 후</code>에 동작한다는 부분을 알 수 이어요.</p>
<ol>
<li><p><code>예약 취소 트랜잭션 (메인 스레드)</code>: ReservationService에서 예약 취소 로직이 실행될 때 트랜잭션이 시작되고, 이 트랜잭션은 해당 요청을 처리하는 스레드에 묶여요. 이 스레드에서 <code>ReservationCancelEvent</code>가 발행돼요.</p>
</li>
<li><p><code>리스너의 대기</code>:
<code>@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code>가 붙은 리스너는 이 이벤트를 즉시 처리하지 않아요. 대신, 이벤트가 발행된 트랜잭션이 성공적으로 커밋될 때까지 대기하고 있어요.</p>
</li>
<li><p><code>트랜잭션 커밋 후 비동기 실행</code>:
ReservationService의 트랜잭션이 성공적으로 커밋되면, 예약 삭제와 같은 모든 핵심 변경 사항이 데이터베이스에 영구적으로 반영된 상태에요. 이때 리스너의 실행 조건이 충족되고, 리스너 메서드에 <code>@Async</code>가 붙어 있으므로, 이 로직은 메인 스레드와는 별개의 새로운 스레드에서 비동기적으로 시작돼요.</p>
</li>
</ol>
<h2 id="단점">단점</h2>
<p>되게 좋은 것 같지만, (예상되는) 단점도 꽤나 명확한 것 같아요.</p>
<h3 id="이벤트-추적-및-관리-어려움">이벤트 추적 및 관리 어려움</h3>
<p>시간이 지날수록 어떤 이벤트가 발행되고 수신되고 있는지 파악하기 어려워질 것 같아요.</p>
<blockquote>
<p>GPT: 분산 트레이싱(Distributed Tracing) 도구(예: Zipkin, Jaeger)나 APM(Application Performance Management) 툴을 활용하여 이벤트 흐름을 추적하고 가시성을 확보할 수 있습니다.</p>
</blockquote>
<h3 id="트랜잭션-관리-어려움">트랜잭션 관리 어려움</h3>
<p>의존성을 과하게 분리하거나 비동기 작업이 많아진다면, 트랜잭션을 관리하는 것이 어려워질 것 같아요. </p>
<blockquote>
<p>GPT: @TransactionalEventListener와 리스너 메서드에 @Transactional을 함께 사용하는 것은 이러한 독립적인 비동기 트랜잭션을 효과적으로 관리하는 방법 중 하나입니다. 각 트랜잭션의 범위를 명확히 인지하고 설계해야 합니다.</p>
</blockquote>
<h3 id="순서-보장이-필요하다면">순서 보장이 필요하다면?</h3>
<p>순서 보장이 필요하지 않은 작업만 이 구조를 가져가면 좋을 것 같다는 생각을 했어요. 만약 순서 보장이 필요한 경우에 이벤트 발행-수신 구조를 가져간다면, 얻는 이점 대비 복잡성이 더 늘 것 같아요.</p>
<p>정리하면 <code>복잡도 증가</code>인 것 같아요. 개발 블로그의 마무리에 꽃인 상황에 따른 적절한 선택이 필요할 것 같아요.</p>
<blockquote>
<p>GPT: 이 경우 메시지 큐의 순서 보장 기능(예: 카프카의 파티션 내 순서)을 활용하거나, 단일 소비자 패턴을 고려해야 합니다.</p>
</blockquote>
<h2 id="마무리-유연성과-편의성을-모두-잡는-구조">마무리: 유연성과 편의성을 모두 잡는 구조</h2>
<p>예측보다는 변화에 대응하는 것이 중요해진 현대 소프트웨어 개발 환경에서, 도메인 간의 강결합 문제를 알아봤어요. 그리고 이를 해결하기 위해 <code>이벤트 발행-수신</code> 구조를 살펴보며, 특히 스프링 이벤트가 이러한 문제를 어떻게 해결해 줄 수 있는지 코드로 직접 확인해봤어요.</p>
<p>핵심은 <code>느슨한 결합으로 유연한 대처 가능</code>, <code>비동기 처리로 응답 속도 개선</code>, <code>트랜잭션 연동으로 데이터 정합성 보장</code>이었어요.</p>
<p>결론적으로, 개발 유연성이라는 목표 아래 도메인 간의 직접적인 의존성을 끊어내고, 이벤트 기반의 비동기 트랜잭션 연동 방식으로 소통하게 함으로써, 사용자와 관리자의 편의성을 동시에 잡는 아키텍처를 구축할 수 있음을 살펴봤어요.</p>
<p>처음 접하는 방식이라 재밌었어요. 어렵긴 하더라구요 ㅎㅎ.. 더 학습해보겠습니다!</p>
<p>긴 글 읽어주셔서 감사합니다.</p>
<h3 id="더-궁금한-내용">더 궁금한 내용</h3>
<ul>
<li>서버 간 이벤트 발행?</li>
<li>이벤트 발행-수신 구조에서 실패 복구 전략</li>
<li>느슨한 결합을 위한 다른 구조</li>
<li>성능 성능 성능</li>
<li>이벤트 테스트 전략</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[우아한테크코스 7기 레벨1 회고]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%EB%A0%88%EB%B2%A81-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%EB%A0%88%EB%B2%A81-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Mon, 14 Apr 2025 14:53:42 GMT</pubDate>
            <description><![CDATA[<p>우아한테크코스에 입학한 지도 벌써 2달이 지났다. (글을 작성하는 지금은 레벨1 방학 마지막 날이다.)
좋았던 부분은 더 나은 방향으로 발전시킬 수 있도록, 부족했던 부분은 다음엔 더 잘할 수 있도록 기억하고자 한다.</p>
<p>기술적인 회고보다는 내가 우아한테크코스에서 활동하며 변화된 마음을 중점적으로 작성해보려고 한다.</p>
<h1 id="🎬-연극">🎬 연극</h1>
<p>우아한테크코스를 검색하면 어렵지 않게 <code>연극을 한다.</code> 라는 정보를 찾아볼 수 있다. 이번에는 다를 수 있지 않을까? 라는 기대가 있었지만, 정확히 첫날 OT에서 그 기대는 무너졌다.</p>
<p>내향적인 나는 감히 (해보지 않았지만) 모든 미션보다 연극이 가장 힘들 것으로 생각했다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/d3f1a70a-f7bf-4591-a576-9bf96815974b/image.png" alt=""></p>
<p>우리 조가 연극하기 바로 직전에 나의 빨대 상태이다. (많이 불안했다.)</p>
<h2 id="🤝-함께하기">🤝 함께하기</h2>
<p>연극을 떠올리면 분명 쉽지 않았던 경험이었다. 하지만 같은 문제(연극)를 함께 해결해야 하는 크루들이 있었기에 준비 과정은 오히려 즐겁게 느껴졌다. 더 재미있는 연극을 만들기 위해 각자의 부끄러움을 내려놓고, 대사와 동선을 수없이 바꾸며, 시간을 재고 맞춰가며 계속해서 연습했다. 그 과정에서 일종의 프로페셔널리즘을 느낄 수 있었다. (가콩, 모다, 하루, 슬링키 최고!)</p>
<p>연극을 준비하면서는 다른 조를 의식하지 않으려고 노력했다. 경쟁 구도가 아니라, 모든 크루가 함께 하나의 연극이라는 문제를 해결해 나가는 과정이라고 생각하려 했다. 이런 마음가짐 덕분에 연습도 훨씬 잘할 수 있었던 것 같다. (연극을 보면서는 다들 준비성이 철저하다고 느끼긴 했다.)</p>
<p>아울러 우아한테크코스는 같은 커리큘럼을 각자의 속도와 방식으로 풀어나가는 구조다. <code>자연스럽게 비교가 생기기 쉬운 환경</code>인 셈이다. 각자의 미션 결과물은 늘 다른 형태이다. 서로의 코드를 구경하며 감탄할 때도 많았다. 연극을 준비할 때처럼, 앞으로도 남을 지나치게 의식하기보단 <code>나만의 템포를 지키며 꾸준히 나아가면</code> 좋을 것 같다.</p>
<h2 id="🔥-아쉬웠던-점">🔥 아쉬웠던 점</h2>
<p>연극 도중에 부끄러움을 넘어선 반응인 웃음을 참지 못했다. 이 웃음이 관람하는 크루들에게 전해져서 좋게 넘어갈 수 있었으나, 같이 연극을 하는 크루에게도 전해졌다. 웃음을 참지 못해 무대가 잠시 멈췄던 점은 아쉬움으로 남았다. <code>개인적인 부끄러움이 팀의 결과물에 영향을 주었기에 더 신중할 필요</code>가 있었다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/2fcd6b9d-bd1f-4ce8-abc9-08e557b034fe/image.png" alt=""></p>
<p>연극이 끝난 후 단순히 이건 연극이니까 작업이나 미션과는 다르다고 회피하려는 생각도 했었다. 그러나 결국 함께 문제를 해결하는 과정이므로 일과 크게 다를 게 없다고 생각한다. <code>개인적인 부분을 더 내려두고 팀 결과물에 퀄리티를 위해 더욱 노력</code>해야겠다는 마음이 들었다.</p>
<h1 id="🧑🤝🧑-페어-프로그래밍">🧑‍🤝‍🧑 페어 프로그래밍</h1>
<p>미션의 반은 다른 크루와 페어 프로그래밍으로 진행된다. 더 정확히는 초기 설계 및 구현을 페어 프로그래밍으로 진행하고 리팩토링이나 추가되는 새로운 기능 구현을 혼자서 하게 된다.</p>
<p>페어 프로그래밍은 언제나 설레고 두려웠지만, 연극을 마친 후라 그런지 무엇이든 할 수 있을 것 같았다.</p>
<h2 id="🐟-물-만난-물고기">🐟 물 만난 물고기</h2>
<p>우아한테크코스에 오기 전부터 개발 경험이 있었고, <code>알고 있는 지식을 설명하는 걸 좋아했다.</code> 그래서 페어 프로그래밍을 할 때는 내가 먼저 토론 주제를 꺼내는 경우가 많았다.</p>
<p><code>설명하는 과정에서 나 또한 더 나은 방식을 발견하곤 했다.</code> 이런 태도는 페어에게도 긍정적으로 전달된 것 같다.</p>
<pre><code>페어로 만났던 크루들의 피드백

- 서로의 의견을 존중하며 더 나은 결론에 도달하도록 이끌어준 것이 좋았습니다. 또한 자신이 어떠한 의견을 가졌는지, 이에 대해 어떻게 생각하는지 등등 고집없이 어떠한 피드백도 받아들이겠다는 마음가짐으로 페어프로그랭에 임해주셔서 좋았습니다.
- 자신이 생각한 의견에 대해서 설명하고 상대의 의견을 들은 후 그냥 넘어가지 않고 각각의 장단점을 비교하는 모습이 좋았습니다. 이 과정을 통해 선택이 좀 더 수월해질 수 있었던 것 같습니다. 자신이 사용하는 방식에 대해 구체적인 자신만의 기준과 이유가 있는 것이 강점인 것 같습니다.
- 협업 과정에서 페어가 적극적으로 고민되는 지점에 대해서 물어보며 생각의 흐름을 같이 맞춰나가려는 소통 방식이 기억에 남습니다. 현재 진행하고 있는 방향에 대한 가지고 있던 모호한 의문에 대한 근거를 되물으며 의문을 구체화했던 점이 인상 깊었습니다.</code></pre><p>의견을 명확히 설명하면서도 상대방의 관점을 묻고, 장단점을 비교해보는 방식이 긍정적으로 작용한 것 같다. 이런 방식이 효과적이라고 느껴져서, 이후 미션에서도 의식적으로 같은 접근을 계속 시도하고 있다. </p>
<p>앞으로도 이 방향을 유지하며 더 나은 협업을 시도해보고 싶다.</p>
<h2 id="🔥-아쉬웠던-점-1">🔥 아쉬웠던 점</h2>
<p>주도적으로 대화를 이끌다 보니, 가끔 이야기의 길이가 길어지는 경우가 많았다. 너무 작은 부분까지 깊이 파고들어 전체 속도를 늦추는 순간이 생기기도 했다.</p>
<pre><code>페어로 만났던 크루들의 피드백

- 몇 안되지만 한번씩 다른 길로 깊이 빠질 때가 있었습니다. 요구사항에 관한 부분이 아닌 다른 것에 대해 너무 깊이 생각하고 이러한 것들로 시간을 빼앗길때가 있었습니다.
- 개발할 때 먼 미래를 보고 설계하는 것도 좋지만, 아직 일어나지 않은 일을 너무 걱정하기보다 지금 당장 적절한 코드를 작성하는 것도 좋을 것 같습니다.
- 자신의 의견을 말할 때 상대가 이해할 수 있도록 요점을 정리해서 다시 말하거나, 상대방이 이해할 수 있도록 기다려 준다면 더 원활한 협업을 할 수 있을 것 같습니다.</code></pre><p>전반적으로 <code>지나치게 깊은 고민으로 인해 시간 관리가 어려울 수 있다</code>는 피드백이었다. 나 역시 공감했다. </p>
<p>예를 들어, 블랙잭 미션에서 ACE 카드의 점수를 1 또는 11로 처리하는 방식에 대해 꽤 오래 고민했다. 나는 코드에서 이 부분을 명확히 표현해야 한다고 생각했지만, 페어는 우선 조건문으로 간단히 구현하고 나중에 리팩터링하자고 제안했다. 지금 돌이켜보면, 마감기한을 고려했을 때 페어의 제안이 훨씬 현실적이었다.</p>
<p>토론에서 에너지를 소모한 후 구현 단계에서 집중력이 떨어진 경험도 있었다. 당장 구현에 영향을 주지 않는 고민은 우선순위를 낮추고, 노션에 정리해두었다가 리팩토링 단계에서 다시 다루는 방식이 더 효율적일 것 같다는 생각을 하게 되었다. 다음 레벨에서는 이 전략을 적극적으로 실험해보려 한다.</p>
<h1 id="🗣️-코드-리뷰">🗣️ 코드 리뷰</h1>
<p>우아한테크코스에서 가장 기대했던 것 중 하나가 코드 리뷰였다.
현업자의 시선을 통해 내 코드를 평가받을 수 있다는 점이 엄청나게 귀하게 느껴졌다.</p>
<p>하지만 <code>리뷰를 받는다</code>는 생각이 어느 순간 <code>정답이 있고, 평가받는 것이다</code>라는 압박감으로 바뀌었다. 정답은 없다고 말하면서도, 내가 맞았는지 틀렸는지를 찾으려는 마음이 들었다. 그러다 보니 리뷰를 요청하는 글을 쓸 때도 매우 조심스러워졌다. <code>이게 맞을까?</code>, <code>이걸 질문해도 될까?</code>, <code>다른 것을 더 물어보는 게 낫지 않을까?</code> 고민하며 코멘트를 남기는 데 너무 많은 시간이 들었다. 특히 내 질문 자체를 스스로 평가하는 데에 시간을 많이 썼다.</p>
<p>레벨1이 끝나고 우아한테크코스에서 제공해준 코드리뷰 가이드 중 문구 하나에 크게 공감했다.</p>
<blockquote>
<h3 id="리뷰어의-역할은-코드-경찰이-아니랍니다">리뷰어의 역할은, 코드 경찰이 아니랍니다</h3>
<p>코드 리뷰어는 과제를 검사하고 채점하는 조교가 아니다. 채점하듯이 코드리뷰에서 잘못된 점, 아쉬운 부분을 모두 찾아내는 것이 코드리뷰의 목적이 아니다. 코드 리뷰어가 테스트를 담당하는 QA도 아니다. 코드리뷰를 요청하기 전에 전체적인 테스트를 실행해보는 것은 개발자가 기본적으로 해야 할 일이다. 리뷰어는 필수로 지켜져야 하는 사항이 지켜졌는지 크로스 체크하고, 좀 더 나은 코드가 될 수 있게 코드 작성자를 지원하는 역할이다. 코드 작성자가 내가 쓴 코드에 대해 1차적인 오너십을 가진다.</p>
</blockquote>
<p>문구를 보며 마음이 조금은 놓였던 것 같다. <code>틀리지 않기 위한 글</code>이 아니라 <code>이 방향이 왜 맞다고 생각했는지 설명하는 글</code>을 쓰기 위해 노력할 것이다.</p>
<h2 id="🌤️-받았던-피드백">🌤️ 받았던 피드백</h2>
<pre><code>리뷰어 피드백

- 본인의 주장을 근거와 같이 달아주는 점이 좋았습니다.
- 질문을 텍스트로 잘 정리해주셔서 이해하기 수월했습니다. 피드백에 대한 본인의 생각을 코멘트로 모두 남겨주어서 어떤 생각을 가졌는지 알 수 있어 좋았습니다.
- PR description 및 comment를 통해 스스로 느낀 아쉬운 점과 궁금증, 고민, 그리고 변경 사항 등을 잘 정리하여 공유해준 덕분에, 코드 리뷰 과정에 큰 도움이 되었습니다. 리뷰를 반영하는 과정에서 이뤄진 의식 흐름을 코멘트로 남겨준 덕분에, 리뷰가 의도대로 반영되었는지 확인하기 좋았으며 리뷰이에게 부족한 부분이 무엇인지 파악하여 학습을 유도하기 좋았습니다.</code></pre><p>비록 생각을 정리하는 데 (정말!) 오랜 시간이 걸렸지만, 리뷰어 분들이 남겨준 피드백을 보며 보람을 느낄 수 있었다. 내가 고심 끝에 쓴 문장들이, 내 의도를 전달하는 데에 도움이 되었다는 것이 좋았다.</p>
<p>하지만 한편으로는 이런 생각도 들었다.</p>
<blockquote>
<p>&quot;실제로 현업에서는 이렇게까지 모든 과정을 기록하며 개발할 수 있을까?”</p>
</blockquote>
<p>비슷한 고민을 코치에게도 드렸던 적이 있다. 
코치는 오히려 지금처럼 <code>의도를 잘 전달하기 위한 훈련</code>을 잘하고 있다고 말씀해주셨고, 숙련되면 자연스럽게 속도가 붙거나 적절한 타협점을 찾을 수 있을 테니 조급해하지 않아도 된다고 말씀해주셨다.</p>
<h2 id="🔥-아쉬웠던-점-2">🔥 아쉬웠던 점</h2>
<p>지금 돌이켜보면, 정답이 없다는 말에 수긍하면서도, 
어딘가엔 <code>정답처럼 느껴지는 그럴싸한 답</code>을 찾으려 했던 것 같다.
그래서 나의 근거가 있음에도 자신 있게 이야기하지 못했던 순간들이 아쉽게 남는다.</p>
<p>리뷰어의 질문에 그럴싸한 답을 찾아야만 답을 할 수 있었고,
그 과정에서 의도를 잘 파악하지 못했음에도 나만의 그럴싸한 답을 찾아 답변하던 순간이 있었다.</p>
<p>다양한 망설임이 때론 내 의도를 흐리게 만들기도 했고, 스스로도 부족한 답변이라는 걸 느끼며 더 아쉬움이 남았다. 의도를 잘 파악하지 못한 것 같다면, 직접 물어보는 것이 더 나은 자세인 것 같다.</p>
<h1 id="♻️-유강스">♻️ 유강스</h1>
<p>유강스는 유연성 강화 스터디를 말한다. 따로 <a href="https://github.com/YehyeokBang/woowa-writing/blob/step1/.github/level1_writing.md">작성했던 유강스 회고</a>가 있어서 남겨봤다.</p>
<ul>
<li>소프트스킬에 관한 유연성을 의미한다.</li>
<li>레벨1 과정에서 꽤 큰 비중을 차지한다고 느꼈다.</li>
<li>다른 사람에게도 추천하고 함께하고 싶다고 느낀 활동이었다.</li>
</ul>
<h1 id="🏖️-방학">🏖️ 방학</h1>
<p>방학은 약 열흘 정도였다.</p>
<p>공부하지 말라는 말에 끝내주게 놀았다.</p>
<p>여행을 두 번이나 다녀왔다.</p>
<p>후회는 전혀 없지만, 뭔가 군인 휴가 복귀하는 마음이 든다..</p>
<h1 id="🎉-마무리">🎉 마무리</h1>
<p>우아한테크코스에서의 두 달은 나의 태도와 생각을 변화시켰다.<br>앞으로의 시간도, 함께 성장하며 더 나은 나를 만들어가고 싶다.</p>
<h2 id="🎯-작은-목표">🎯 작은 목표</h2>
<p>실패하더라도 <code>우아한테크코스의 학습 목표 이외의 공부</code>를 해보고 싶다. 지금은 우아한테크코스가 제시한 학습 과정에 거의 모든 시간을 쏟았다. 자율적으로 나만의 공부 시간도 가지며 살아보면 좋을 것 같다고 느꼈다.</p>
<h2 id="🎸-기타">🎸 기타</h2>
<ul>
<li>매일 점심을 밖에서 사 먹으니 뭔가 건강이 안 좋아지는 기분이 든다. 모든 직장인분들 대단하다.</li>
<li>너무 몰입해서 밤늦게까지 하던 경우가 꽤 있었다. 비율을 적당히 조절해보면 좋겠다는 생각이 든다.</li>
<li>레벨2도 화이팅이다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[2024 정산하기 (우테코 7기 BE 합격)]]></title>
            <link>https://velog.io/@hyeok_1212/2024-%EC%A0%95%EC%82%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hyeok_1212/2024-%EC%A0%95%EC%82%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 29 Dec 2024 09:57:47 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요. 단기적으로는 백엔드 개발자를, 장기적으로는 부담 없이 질문할 수 있는 사람이 되고 싶은 방예혁입니다. 2025년을 위해 올해를 돌아보며 스스로 피드백을 해보려고 합니다. 스스로 돌아보는 것이기 때문에 말은 편하게 작성했습니다!</p>
<h1 id="회고">회고?</h1>
<p>나는 지금까지 프로젝트를 마무리하거나 1년이 지날 때마다 회고를 진행했었다. 처음에는 남들이 하길래 했었고, 하는 김에 나에게 도움 되는 방법으로 해보자 해서 유명한 회고 방법(KPT 등)을 활용한 적도 있었다. </p>
<p>그러나 회고가 주는 이점 즉, 회고해야 하는 이유에 대해서 고민해 본 적이 없었던 것 같다. 그래서 누군가 회고가 무엇이냐고 질문하면, 사전적인 의미인 <code>돌아보는 것</code>과 <code>더 나은 무언가를 위해</code>라는 문장을 생각나는 대로 조합해서 답을 했던 것 같다.</p>
<p>누군가와 겹칠 수도 있고, 다를 수도 있지만 먼저 회고에 대한 내 생각을 정리하고 회고를 작성해 보려고 한다. 다양한 기술 선택도 근거를 가지고 선택하는 것처럼 회고도 왜 해야 하는지에 대해 고민해 보고 싶었다.</p>
<h1 id="회고는-왜-해요">회고는 왜 해요?</h1>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/6f81bb62-43c2-4117-9381-5fd90fe9bb95/image.png" alt=""></p>
<p>요즘은 구글 검색을 하면 <a href="https://support.google.com/websearch/answer/14901683?hl=ko&amp;visit_id=638709601444192670-4107433102&amp;p=ai_overviews&amp;rd=1"><code>AI 개요</code></a>라는 이름으로 검색 결과를 AI로 요약해준다. </p>
<p>회고를 하면서 얻는 이점이 결국 회고를 하는 이유가 될 것이다. 이점에 대해 생각나는 대로 작성해 보려고 한다.</p>
<h2 id="이점-1-문제-인식">이점 1. 문제 인식</h2>
<p>문제를 인식하는 과정은 문제 해결의 첫걸음이며, 흔히 <code>무엇(what)</code>을 결정하는 단계로 설명된다. 이는 단순히 현상을 관찰하는 것을 넘어, 그 현상을 문제로 정의할지 여부를 판단하는 과정이라고 볼 수 있다. 회고는 이 문제 인식을 효과적으로 할 수 있게 도와준다고 볼 수 있다.</p>
<p>우선, 문제 인식이란 지금 무슨 일이 일어나고 있는지, 구체적인 증상이 무엇인지에 대해 스스로 질문하고 답을 찾는 과정을 포함한다고 생각한다. 이를 통해 단순히 드러난 현상이 아닌, 그 현상이 발생한 원인을 고민하게 된다. 여기서 중요한 점은, <code>일어나는 현상 자체가 반드시 문제가 되는 것은 아니라는 것이다.</code> 문제로 인식할지 말지는 개인이나 조직의 관점과 판단에 따라 달라질 수 있다.</p>
<h3 id="문제-인식의-예시-공장과-수술">문제 인식의 예시: 공장과 수술</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/e80fec13-90a7-4299-9867-a8559984b455/image.png" alt=""></p>
<p>예를 들어, 한 공장의 생산 라인에서 불량률이 1%라고 가정해 보자. 100개를 생산했을 때 평균적으로 1개가 불량품일 가능성이 있다는 사실은 <code>단지 현상일 뿐이다.</code> 이것이 문제인지 아닌지는 상황에 따라 달라진다. 만약 공정 특성상 1%의 불량률이 허용 가능한 범위 내에 있으며, 이를 줄이기 위해 추가적인 검증 절차를 도입하는 것이 오히려 생산 효율을 떨어뜨린다면, 이 현상은 큰 문제로 인식되지 않을 확률이 높을 것이다.</p>
<p>반대로, 특정 수술의 생존 확률이 1%라는 예시를 든다면, 낮은 생존 확률은 단순한 현상이 아니라 심각한 문제로 인식될 가능성이 높을 것이다. 같은 1%라는 수치라도 그 맥락과 중요도에 따라 완전히 다른 판단이 내려질 수 있는 것이다.</p>
<p>앞서 말한 것처럼 회고는 이러한 <code>문제 인식</code>을 도와준다. 단순히 과거를 되돌아보는 활동이 아니라, 현재 상황을 명확히 이해하고, 문제로 인식해야 할 지점을 판단하며, 해결책을 모색하기 위한 출발점을 제공한다. 우리는 그 현상이 문제가 되는 이유를 정의하며, 구체적인 개선 방향을 설정할 수 있을 것이다.</p>
<h2 id="이점-2-최고의-플레이">이점 2. 최고의 플레이</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/8d58bd67-4366-4682-bc07-add192599087/image.png" alt=""></p>
<p>문제를 찾고 그것을 해결하는 과정도 중요하지만, 그 과정에서 열심히 노력한 나 자신(혹은 팀)도 중요할 것이다. </p>
<p>즉, 더 나아지기 위해 개선할 부분을 찾는 것만큼이나 중요한 것이 무엇을 잘했는지 돌아보는 것이라고 생각한다.</p>
<p>회고는 <code>자기 자신(혹은 팀)에 대한 긍정적인 시각</code>을 키울 기회를 제공한다고 볼 수 있다. 노력의 흔적을 발견하고, 성공적으로 해결한 과정을 떠올리며 자긍심과 자신감을 얻을 수 있을 것이다. 문제 해결 과정만 강조하다 보면 잘했던 순간들을 지나치기 쉽다. 하지만 회고에서 잘한 점을 찾아내고, 그것을 인정하며 스스로를 칭찬하는 것은 그 자체로 강력한 동기부여가 된다고 생각한다.</p>
<p>예를 들어, 프로젝트 중 특정 문제를 창의적으로 해결했거나, 팀 내 불화 없이 프로젝트를 끝내는 등 그 순간들을 기록하고 다시 떠올리는 것만으로도 뿌듯함을 느낄 수 있다. 더 나아가 단순한 칭찬에서 <code>우리가 잘했던 방법을 지속할 수 있는 방향성</code>을 제시해 준다고 볼 수 있을 것 같다.</p>
<p>내가 잘한 일에 대해 자부심을 가지는 과정은 지속해서 성장하기 위한 원동력이 될 것이다. 또한 자신감으로 더 훌륭한 일을 해낼 수도 있을 것이다.</p>
<h2 id="이점-3-기록">이점 3. 기록</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/d3b56e01-9863-484c-9080-f9ea3a99093d/image.png" alt=""></p>
<p>팀원과 대화로 회고를 진행할 수도 있겠지만, 글로 남기는 방법도 있다.</p>
<p>단순히 졸업 앨범처럼 추억으로 볼 수도 있지만, 내가 작성했던 회고를 보며 과거의 나에게 배울 점을 찾을 수 있다. 당시의 상황과 문제를 해결하려는 고민이나 목표, 다짐 등을 글로 남겨두면, 시간이 지난 후 다시 돌아봤을 때 새로운 통찰을 얻을 수 있다는 관점이다.</p>
<p>예를 들어, 한 프로젝트에서 마주했던 어려움을 극복하기 위해 세웠던 전략이 효과적이었다면, 미래의 나에게 교훈이 될 수 있다. 반대로, 결과적으로 잘못된 선택이었다면 기록을 통해 다시 살펴볼 수 있으며, 같은 실수를 반복하지 않을 수 있다.</p>
<h2 id="결론-더-나은-나">결론, 더 나은 나</h2>
<p>회고는 스스로 잘한 부분은 칭찬하고, 아쉬웠던 부분은 보완하기 위해 노력하여 더 나은 내가 되기 위해 사용하는 도구 중 하나라고 볼 수 있다. </p>
<p>직접 피드백 주기를 가져가며 잘한 부분에 대한 칭찬은 자신감을 높여주고, 아쉬운 점에 대한 보완은 꾸준히 나아질 수 있는 원동력이 될 것이다.</p>
<h1 id="2024-돌아보기">2024 돌아보기</h1>
<p>2024년은 걱정이 많았던 해였다. <del>여전히 많긴 하다.</del></p>
<h2 id="마음-가짐">마음 가짐</h2>
<pre><code>- 대학교 4학년 (25년 2월 졸업 예정)
- 졸업하면 뭐 해?
- 취업하기 힘들다는 이야기
- 직접 맛본 탈락</code></pre><p><img src="https://velog.velcdn.com/images/hyeok_1212/post/c6f4fc68-03b2-4f85-8cde-9e53ada86ce7/image.png" alt=""></p>
<p>나만 특별히 힘들다고 생각해 본 적은 없다.</p>
<p>그러나 주변 사람이나 인터넷을 통해 아래와 같은 내용을 접할 때마다 정체 모를 걱정이 쏟아졌던 것 같다.</p>
<pre><code>- 개발자 신입은 언어 1개만 잘해도 돼.
- 취업을 위한 대용량 트래픽, 동시성 문제, MSA, AWS, ...
- 중소, SI 기업가면 큰일 난다.
- 요즘 경제가 어려워~ 현금이 없어~ 채용 시장이 얼었어~
- 부트캠프는 별로다. vs 부트캠프라도 나와야 한다.
- 취업을 위한 준비를 위한 그 준비를 위한...
- ...</code></pre><p>취업이라는 것이 게임 레벨처럼 일정 경험치를 쌓으면 되는 것이 아니라, 나를 세일즈(함께 일하고 싶은 개발자) 해야 한다는 말을 자주 들었기 때문에 공부해도 지금 이게 잘하고 있는 건가?에 대한 생각이 들면서 설명할 수 없는 고민이 많이 생겼던 것 같다. (졸업이 다가올수록 커져만 갔다.)</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/01138c02-e8df-4eff-947d-5c71bf162faf/image.png" alt=""></p>
<h2 id="스토아-철학">스토아 철학</h2>
<p>우연히 유튜브에서 내가 봐야 할 것 같은 영상을 보게 되었다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/9c230166-0da3-4c3b-a534-fb4fd5c648cf/image.png" alt=""></p>
<p><a href="https://www.youtube.com/watch?v=0yaXm0sjK3A&amp;ab_channel=%EC%B6%A9%EC%BD%94%EC%9D%98%EC%B2%A0%ED%95%99Chungco">불안과 걱정으로 마음이 괴로울 때 필요한 철학, 스토아 학파 - 충코의 철학</a></p>
<p>내가 통제할 수 없는 외부 요인에 대한 걱정보다는 자기 내면을 강화하고, 통제할 수 있는 부분에 집중하는 것이 중요하며, 이것을 행복과 연관 지어 보는 것이 핵심이라고 생각한다. 나는 내가 통제하기 어려운 부분에 대해서도 무리하게 걱정한다고 느꼈다. 그렇게 인식해 보기로 했다. 덕분에 집중해야 할 부분을 찾고, 마음은 조금 나아진 것 같다. </p>
<p>물론 성격상 철학을 절대적인 정답으로 여기며 따르진 않을 것 같지만, 다른 사람의 관점으로 고민해 보면서 새로운 생각이 탄생할 수 있는 것 같아서 좋은 경험인 것 같다.</p>
<h2 id="배운-것을-공유하기">배운 것을 공유하기</h2>
<p>2년의 GDSC 활동과 2번의 교내 멘토링 프로그램 참여로 <code>배운 것을 공유하는 것</code>이 효율적인 학습 방법 중 하나라는 것을 깨달았었다. 그래서 나는 더욱 적극적으로 그 방법을 활용하기로 했다.</p>
<p>우선 올해 나의 고민과 생각 흐름을 최대한 많이 담으려고 노력한 글을 20개 정도 업로드했다. 좋아요를 위해 썸네일에 대한 지나친 고민을 경험한 적도 있었지만, 누군가 눌러준 좋아요가 내 고민에 대한 작은 인정이라고 생각하니, 뿌듯하기도 하고 계속 배우려고 노력하면서 원동력이 된 것 같다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/72408824-9b62-46f6-a93e-b03fc41fae2b/image.png" alt=""></p>
<p>블로그를 작성하며 완벽하지 않은 나의 모습을 보여주는 것에 익숙해졌고, 피드백은 좋은 기회이며 두려워하지 않아도 된다는 마음을 가지게 되고 나서 <a href="https://velog.io/@hyeok_1212/%EC%8A%A4%ED%84%B0%EB%94%94-%EC%A7%84%ED%96%89-%ED%9A%8C%EA%B3%A0">HTTP와 친해지기 스터디</a>를 기획하고 진행했다. HTTP 이론에 대해 강의를 듣고 이를 이해하기 쉬운 형태로 재구성하여 동아리 멤버에게 공유하는 형태였다.</p>
<p>또한, GDSC나 <a href="https://cho-log.github.io/">초록 스터디</a>, 우아한테크코스 프리코스를 진행하면서, 코드 리뷰도 많이 진행했다. 코드 리뷰는 배운 것을 공유할 수 있는 좋은 환경이었다. 가끔 내가 아는 부분이 나오면 신나게 리뷰를 작성했던 기억이 있다. 그러나 돌아보면 그것이 정말 그 사람을 위한 행동인가를 생각하면 아닌 부분도 많은 것 같다. 누군가에게 나는 <code>이거 모르네 왜 이렇게 안해?</code>라고 말한 사람이 되었을 수도 있겠다는 걱정을 하게 되었다.</p>
<h3 id="ㄴ-칭찬">ㄴ 칭찬</h3>
<ul>
<li>꾸준하게 배운 것을 공유하며, 의식적으로 피드백을 받을 수 있는 환경으로 나아간 것</li>
<li>단순히 기술을 나열한 것이 아니라, 고민이나 생각 흐름을 담으려고 노력한 것</li>
</ul>
<h3 id="ㄴ-개선-필요">ㄴ 개선 필요</h3>
<ul>
<li>가끔 포스팅의 제목이나 썸네일을 지나치게 많이 고민하는 것</li>
<li>더 나은 코드를 작성할 수 있도록 코드 리뷰를 더욱 친절하고, 상세하게 작성하는 것</li>
</ul>
<h3 id="ㄴ-다음-목표">ㄴ 다음 목표</h3>
<ul>
<li>지속하는 것</li>
<li>익숙치 않은 새로운 사람들과 공유해 보는 것</li>
</ul>
<h2 id="교내-it-경진대회-금상-수상">교내 IT 경진대회 금상 수상</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/78ec7ce7-19d2-4177-9e38-a0add8fba34c/image.jpeg" alt=""></p>
<p>4명으로 구성된 팀에서 <code>AI 자세 추정 기술</code>을 활용한 앉은 자세 분석 및 실시간 알림 기능을 제공하는 앱 서비스 <a href="https://github.com/ProjectBARO/BARO-Server">BARO</a>라는 앱 서비스를 제작해 교내 IT 경진대회에서 금상을 수상했다.</p>
<p><a href="https://velog.io/@hyeok_1212/GDSC-Solution-Challenge-2024-%ED%9A%8C%EA%B3%A0">GDSC-Solution-Challenge-2024</a>에도 제출했던 경험이 있지만, 사용성, 성능 등을 개선하여 제출했다.</p>
<p>우리 팀은 각각 Server, App, AI, PM으로 분야가 모두 달랐다. 소규모 팀이기 때문에 모두가 기획에 참여하여 모두가 같은 컨텍스트를 가질 수 있도록 노력했다. 하지만 파트 별로 한 명씩이었기 때문에 일정이 있거나, 많은 부분을 수정해야 할 때, 다른 팀원이 기다려야 하는 상황이 자주 발생했다.</p>
<p>처음에는 작업이 완료되길 기다리기만 했으나, 문제를 해결하기 위해 나는 AI 파트 팀원에게 클라우드 배포 방법을 정리해 공유하고, 플러터를 간단히 배워 API 사용 예시를 제공하며 팀원의 작업을 도왔다. 덕분에 팀원들 사이에 서로 배려하는 분위기가 형성됐고(다른 파트가 어떻게 해야 하는지 상세히 기술하는 등), 결과적으로 기간 내 프로젝트를 완수할 수 있었던 것 같다.</p>
<p>백엔드 파트였던 나는 여러 기술 블로그를 보고 gRPC 기술을 도입해 성능을 개선하려 했으나, 팀원들은 익숙하고 잘 작동하는 REST 대신 사용해야 하는 이유를 크게 공감하지 못했고, 남아있는 작업으로 큰 부담을 느꼈던 상황이 있었다.</p>
<p>나는 그래서 gRPC와 REST 방식을 모두 사용할 수 있도록 구성하여 도입 순서를 뒤로 미뤘고, 직접 플러터로 gRPC 사용 예제를 만들어서 도입할 시기에 쉽게 작업할 수 있도록 준비했다. 최고의 선택은 아니었지만, 팀원 모두 솔직하게 의견을 나누었기 때문에 갈등을 원활하게 해결했던 것 같다.</p>
<p>서로 다른 분야의 사람들이 모여 각자의 역할에 주인의식을 갖고 프로젝트를 완성하고 성과를 낸 경험은 큰 동기부여가 되었다.</p>
<h3 id="ㄴ-칭찬-1">ㄴ 칭찬</h3>
<ul>
<li>내게 작업이 할당될 때까지 기다리지 않고 적극적으로 프로젝트에 기여한 것</li>
<li>다른 파트를 경험하고 이해하려 노력한 것</li>
</ul>
<h3 id="ㄴ-개선-필요-1">ㄴ 개선 필요</h3>
<ul>
<li>경청! 경청! 경청!<ul>
<li>같은 내용이지만, 표현 방식이나 단어의 이해 정도에 따라 다르게 이해하여 회의가 길어지는 경우도 있었다.</li>
</ul>
</li>
<li>미래(나와 팀)를 생각한 코드를 작성하기<ul>
<li>새로 배운 go 언어로 진행하여 어떻게든 작동하게 만든 코드였다.</li>
</ul>
</li>
</ul>
<h3 id="ㄴ-다음-목표-1">ㄴ 다음 목표</h3>
<ul>
<li>같은 파트가 여러 명인 프로젝트에 기여하는 것</li>
<li>내가 봐도 이것보다 좋은 코드로 프로젝트에 기여하는 것</li>
</ul>
<h2 id="책-읽기">책 읽기</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/d18e8301-5925-499d-884e-df1f7069045f/image.png" alt=""></p>
<p>책 읽기는 분명한 개선이 필요하다. <del>위 다짐은 작년인 2023년에 작성했다.</del></p>
<blockquote>
<p>책은 시간 측면에서 가장 효율적인 학습 방법이라고 생각해요.
작가는 책을 쓰기까지 많은 책을 보았고, 작가가 봤던 책들의 작가도 수많은 책과 시간을 보냈을 것이라 생각해요.</p>
<p><del>이런 지식과 지혜를 몇 시간 만에 얻을 수 있다? 혜자다.</del></p>
</blockquote>
<p>책을 자주 읽는 선배가 해주신 말이다. </p>
<p>개발을 잘해야 해. 잘하기 위해서는 많이 알아야 해. 라는 생각으로 책을 읽을 시간은 없어 프로젝트 해보거나 새로운 기술을 써보자. 라는 생각이 많았던 것 같다.</p>
<p>변명을 해보자면 책은 읽었을 때 눈에 띄는 효과가 나지 않고, 새로운 기술을 써보는 것은 눈에 바로 보였기 때문인 것 같다. 그러나 지금 돌아보면 책을 읽고 나서 시간이 지날 때마다 시야나 생각에 차이가 꽤나 크게 차이 난다고 느꼈다.</p>
<p>동기부여를 위해 읽은 책이나 강의도 <a href="https://github.com/YehyeokBang/TIL">TIL</a>에 작성하려고 노력했다.</p>
<p>선배가 이번 나의 생일 선물로 문학책을 선물해 주셨다.(감사합니다.) 개발 책뿐만 아니라 문학을 읽는 것이 내게 어떤 변화를 줄지 기대가 된다.</p>
<p>누군가에게 영향을 받아 좋은 경험을 하게 되었다. 다른 사람도 경험할 수 있게 읽었던 책을 추천하기도 했다. 주변에 책을 읽는 사람이 많아진다면 더 재밌는 토론을 할 수 있을 것 같아서 이 부분도 기대가 된다. 우선 나부터 꾸준히 읽으려고 한다.</p>
<h3 id="ㄴ-칭찬-2">ㄴ 칭찬</h3>
<ul>
<li>책을 읽기는 한 것</li>
<li>좋았던 경험을 공유하고 주변 환경을 개선해 보려고 한 것</li>
</ul>
<h3 id="ㄴ-개선-필요-2">ㄴ 개선 필요</h3>
<ul>
<li>시간이 없다는 것은 핑계이다.</li>
<li>책을 온전히 이해하고 따르는 것도 좋을 수 있지만, 읽으면서 필요한 것을 판단하고 체화하는 것이 중요해 보인다.</li>
</ul>
<h3 id="다음-목표">다음 목표</h3>
<ul>
<li>다양하게 읽어보자.</li>
<li>누군가와 같은 책을 함께 읽어보는 것을 해보고 싶다.</li>
</ul>
<h2 id="우아한테크코스-7기">우아한테크코스 7기</h2>
<p>우아한테크코스 7기에 지원했다. 이번 기수에서는 <code>메타인지</code> 키워드를 메인으로, 성장과 회고에 중점을 두었다고 느꼈다.</p>
<p>지원한 가장 큰 이유는 <a href="https://www.woowacourse.io/intro">홈페이지 인트로</a>에 작성된 내용과 소개된 과정들이 욕심났기 때문이다. 그래서 나는 대부분의 시간을 지원 과정에 투자했고, 지원서나 회고는 정말 솔직하게 작성했다. 해당 과정에 욕심이 난다는 마음, 이전엔 그랬으나 지금은 그렇게 생각하지 않는 것 같다. 등을 예시로 들 수 있을 것 같다.</p>
<p>프리코스 과정에서 배운 것을 공유하기도 했다. 프리코스 과정 때문에 작성했다기보단, 프리코스 과정 덕분에 더 깊게 고민하고 학습 내용을 공유할 수 있었다고 생각한다.</p>
<ul>
<li><a href="https://velog.io/@hyeok_1212/posts?tag=%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4">우아한테크코스 프리코스 과정 회고록</a></li>
<li><a href="https://velog.io/@hyeok_1212/Java-Record-%EC%82%AC%EC%9A%A9%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94">[Java] Record 사용하시나요?</a></li>
<li><a href="https://velog.io/@hyeok_1212/Java-enum-%EC%82%AC%EC%9A%A9%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94">[Java] Enum 사용하시나요?</a></li>
</ul>
<p>우아한테크코스에 지원하고, 더 나은 코드를 작성하고, 리뷰하며 더 나은 방법을 찾는 것들은 내가 통제할 수 있는 부분이었다. 그러나 우아한테크코스에 합격하는 것은 내가 통제할 수 없는 부분이었다. 그래서 나는 통제할 수 있는 부분에 집중하고 노력했던 것이 심리적으로 많은 도움이 되었다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/e9dbaa23-1fa3-4798-9eef-5c9a9274a94f/image.jpeg" alt=""></p>
<p>최종 코딩 테스트는 클린 코드, 테스트 코드, 확장성 등을 고민하며 이상을 바라보다가 모두 구현하지 못한 채로 집에 돌아갔다. 그래도 현실에서는 항상 이상적이고 아름다운 코드만을 작성할 수 없을 때가 있다는 가르침이라고 생각했다.</p>
<p>이후 나는 최종 결과 발표 당일 메일 발송이 3시간이나 지연되어 심장 아픈 3시간을 보냈었다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/f150fac5-f6c9-4e42-b0c3-470456640739/image.png" alt=""></p>
<p>운이 좋게 우아한테크코스 7기 백엔드 파트로 활동할 수 있게 되었다.</p>
<h3 id="ㄴ-칭찬-3">ㄴ 칭찬</h3>
<ul>
<li>솔직하게 임한 것</li>
<li>포기하지 않고 끝까지 노력한 것</li>
</ul>
<h3 id="ㄴ-개선-필요-3">ㄴ 개선 필요</h3>
<ul>
<li>목표를 위해 밤을 새운 적이 많았다. 장기적으로 바라보며 건강 관리를 해보는 것은 어떨까?</li>
</ul>
<h3 id="ㄴ-다음-목표-2">ㄴ 다음 목표</h3>
<ul>
<li>우아한테크코스의 인재상에 부합할 수 있게 노력하며 나만의 주관을 뚜렷하게 가지는 것</li>
</ul>
<h1 id="마무리">마무리</h1>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/cb0e905d-8983-44a5-9059-c37222c4d6ae/image.png" alt=""></p>
<p>2024년은 걱정과 도전이 공존한 해였지만, 이를 통해 내면의 힘을 키우고 성장의 기반을 마련하려고 했다. 2025년에는 더 큰 자신감을 가지고 도전에 나서며, 배운 것을 나누고 더 나은 개발자가 될 것이다.</p>
<p>함께 학습하거나 도와준 모든 분들께 감사의 말씀을 전합니다. </p>
<p>감사합니다. 행복한 연말 되세요.</p>
<h2 id="피드백-정리">피드백 정리</h2>
<table>
<thead>
<tr>
<th align="center">항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><strong>칭찬</strong></td>
<td>꾸준하게 배운 것을 공유하며, 의식적으로 피드백을 받을 수 있는 환경으로 나아간 것</td>
</tr>
<tr>
<td align="center"></td>
<td>블로그 작성 시 단순히 기술을 나열한 것이 아니라, 고민이나 생각 흐름을 담으려고 노력한 것</td>
</tr>
<tr>
<td align="center"></td>
<td>걱정과 불안을 철학적 관점으로 바라보고, 집중해야 할 부분을 명확히 인식하려고 노력한 것</td>
</tr>
<tr>
<td align="center"></td>
<td>내게 작업이 할당될 때까지 기다리지 않고 적극적으로 프로젝트에 기여한 것</td>
</tr>
<tr>
<td align="center"></td>
<td>다른 파트를 경험하고 이해하려 노력한 것</td>
</tr>
<tr>
<td align="center"></td>
<td>책을 읽고 기록하며, 이 경험을 나누려고 시도한 것</td>
</tr>
<tr>
<td align="center"></td>
<td>우아한테크코스 선발 과정에 솔직하게 임하고 끝까지 포기하지 않은 것</td>
</tr>
<tr>
<td align="center"><strong>개선 필요</strong></td>
<td>걱정과 불안에 매몰되지 않고 더 명확한(구체적인) 액션 플랜을 세울 것</td>
</tr>
<tr>
<td align="center"></td>
<td>가끔 포스팅의 제목이나 썸네일을 지나치게 많이 고민하는 것</td>
</tr>
<tr>
<td align="center"></td>
<td>더 나은 결과를 위해 코드 리뷰(소통)를 더욱 친절하고, 상세하게 하는 것</td>
</tr>
<tr>
<td align="center"></td>
<td>경청! 경청! 경청!</td>
</tr>
<tr>
<td align="center"></td>
<td>시간이 없다는 것은 핑계이다.</td>
</tr>
<tr>
<td align="center"></td>
<td>누군가의 의견을 맹신하는 것이 아니라, 읽으면서 필요한 것을 판단하고 체화하는 것</td>
</tr>
<tr>
<td align="center"></td>
<td>목표를 위해 밤을 새운 적이 많았다. 장기적으로 바라보며 건강 관리할 것</td>
</tr>
<tr>
<td align="center"><strong>다음 목표</strong></td>
<td>아래의 목표를 더욱 구체화하고 지속적으로 목표를 수정해 나가는 것</td>
</tr>
<tr>
<td align="center"></td>
<td>조금은 더 친절한 사람이 되는 것</td>
</tr>
<tr>
<td align="center"></td>
<td>새로운 사람들과 지식을 공유해 보는 것</td>
</tr>
<tr>
<td align="center"></td>
<td>배운 내용을 더 다양한 방식으로 공유하여 학습 효과 극대화하는 것</td>
</tr>
<tr>
<td align="center"></td>
<td>같은 파트가 여러 명인 프로젝트에 기여하고 싶다.</td>
</tr>
<tr>
<td align="center"></td>
<td>내가 봐도 지금보다 좋은 코드로 프로젝트에 기여하는 것</td>
</tr>
<tr>
<td align="center"></td>
<td>다양한 주제나 형태로 책을 읽어보고 싶다.</td>
</tr>
<tr>
<td align="center"></td>
<td>누군가와 같은 책을 함께 읽어보는 것을 해보고 싶다.</td>
</tr>
<tr>
<td align="center"></td>
<td>우아한테크코스의 인재상에 부합할 수 있게 노력하며 나만의 주관을 뚜렷하게 가질 것이다.</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[조회수 기능 구현 (동시성 이슈)]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%A1%B0%ED%9A%8C%EC%88%98-%EC%A0%95%ED%95%A9%EC%84%B1</link>
            <guid>https://velog.io/@hyeok_1212/%EC%A1%B0%ED%9A%8C%EC%88%98-%EC%A0%95%ED%95%A9%EC%84%B1</guid>
            <pubDate>Wed, 27 Nov 2024 19:07:50 GMT</pubDate>
            <description><![CDATA[<p>조회수는 사용자가 콘텐츠에 대한 관심을 가장 직관적으로 확인할 수 있는 지표에요. 서비스의 품질과 사용자 경험에 긍정적인 영향을 주는 기능이지만, 단순한 구현은 신뢰도를 떨어뜨릴 위험이 있어요. 따라서 조회수가 제공해야 하는 가치와 신뢰할 수 있는 지표의 역할을 어떻게 설계할지 알아보려고 해요.</p>
<h1 id="조회수의-가치">조회수의 가치</h1>
<h2 id="조회수는-무엇을-위한-지표인가">조회수는 무엇을 위한 지표인가?</h2>
<blockquote>
<p>제가 만들 조회수는 피드(게시글)의 인기를 나타내는 지표로 사용될 예정이에요.</p>
</blockquote>
<p>단순 게시판 형태를 가진 서비스에서 조회수는 사용자가 서비스 내에서 주목받는 콘텐츠를 쉽게 식별하도록 돕는 역할을 해요. 예를 들어, 사용자가 오늘의 화제나 트렌드를 알고 싶을 때, 조회수가 높은 피드를 통해 즐거움을 느낄 수 있어요.</p>
<p>이처럼 조회수는 콘텐츠를 주목받게 하고 사용자들에게 탐색의 동기를 제공해요. 하지만 부정확하거나 신뢰를 떨어뜨리는 조회수는 오히려 사용자 경험에 부정적인 영향을 미칠 수 있다고 생각해요.</p>
<h2 id="다른-곳에서의-조회수">다른 곳에서의 조회수?</h2>
<ul>
<li><p><code>유튜브의 인기 급상승 동영상</code> : 조회수 외의 더 많은 요소가 영향을 주겠지만, 사용자의 호기심을 자극하거나 재미를 주는 요소임은 분명해요. (인기 급상승 동영상에 자신의 영상이 게시되었다고 감사하다는 말을 남기는 유튜버도 있을 정도예요.)</p>
</li>
<li><p><code>여러 블로그의 개설 이후 총조회수</code> : 꾸준히 전달된 콘텐츠의 가치를 나타내며, 커뮤니티 규모를 파악하는 데 도움을 받을 수 있어요.</p>
</li>
<li><p><code>이 상품 몇 명이 보고 있어요</code> : 커머스 앱을 사용하다 보면 UI나 알림으로 n명이 보고 있다는 정보를 보게 되는 경우가 있어요. 이는 빼앗기는 것을 좋아하지 않는 사용자의 구매 심리를 자극해요. 이러한 기능은 <code>실시간성</code>과 <code>중복 제거</code>를 위해 더 복잡한 구현이 필요할 것 같아요.</p>
</li>
<li><p><code>인터넷 방송 플랫폼의 시청자 수</code> : 실시간으로 방송을 시청하고 있는 사용자의 수를 나타내요. 실시간 시청자 수를 집계하여 순위 정보를 제공하는 서비스도 있는 만큼 인터넷 방송 생태계에서는 중요하게 작용한다고 이해할 수 있어요.</p>
</li>
</ul>
<h3 id="피드-조회수에서의-차별점">피드 조회수에서의 차별점</h3>
<p>제가 구현하려는 피드 조회수 기능은 위 사례들과는 달리 조회수가 <code>작성자의 직접적인 수익으로 연결되지 않아요.</code> 하지만 서비스 품질과 신뢰도를 위해서는 직관적이고 유효한 정보를 제공해야 하는 점은 동일한 것 같아요.</p>
<p>예를 들어, 오늘의 인기 피드가 조회수로 추천되었음에도 불구하고 부적절한 콘텐츠(광고, 불쾌한 내용 등)라면, 이는 사용자 경험에 부정적 영향을 미칠 수 있어요.</p>
<p>따라서 단순히 조회수를 증가시키는 구현이 아니라, 정확하고 신뢰할 수 있는 조회수 기능을 통해 서비스 품질을 위한 설계가 필요할 것 같아요.</p>
<h3 id="참고">참고</h3>
<p>반면, 서비스의 유의미한 발전을 위해 수집되거나 Velog의 조회수처럼, <code>내부 인원만 볼 수 있는</code> 지표도 있어요.</p>
<ul>
<li><p><code>데이터 분석 및 활용</code> : 사용자 행동 데이터를 기반으로 서비스를 개선하거나, 맞춤형 콘텐츠를 추천하는 데 활용할 수 있어요.</p>
</li>
<li><p><code>작성자의 만족</code> : 글을 작성한 사용자가 자신의 글이 얼마나 읽혔는지 확인하면서 개인적인 성취감이나 동기 부여를 느낄 수 있어요. (추천 지표로 사용될 수 있기도 해요)</p>
</li>
</ul>
<h1 id="구현">구현</h1>
<p>다양한 사례를 살펴보니, 조회수 기능은 서비스마다 <code>제공하려는 가치</code>가 다르게 설정된다는 점을 알 수 있었어요. 따라서 각 팀이나 프로젝트에서 조회수를 통해 제공하고자 하는 가치를 명확히 정의하고, 이를 기반으로 기획 및 기술적 결정을 내린다면 더 나은 결과를 얻을 수 있을 것 같아요.</p>
<blockquote>
<p>Spring Boot, Spring Data JPA, MySQL을 사용해요.</p>
</blockquote>
<h2 id="가장-간단한-방법">가장 간단한 방법</h2>
<pre><code class="language-java">@Entity
@Getter
@Table(name = &quot;feeds&quot;)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Feed {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String content;

    private long viewCount = 0;

    public Feed(String title, String content) {
        this.title = title;
        this.content = content;
    }

    public void addViewCount() {
        this.viewCount++;
    }
}

...

// Service 계층 메서드
@Transactional
public FeedResponse view(Long feedId) { // 특정 피드 조회
    return feedRepository.findById(feedId)
            .map(feed -&gt; {
                feed.addViewCount(); // 조회수 필드 +1
                return FeedResponse.from(feed);
            })
            .orElseThrow();
}</code></pre>
<p>가장 간단한 방법은 피드에 조회수 필드를 추가하고, 이를 조회할 때마다 <code>viewCount</code> 필드를 증가시키는 것이에요. </p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/50d66d4b-4511-433e-9ec1-62a6d1a1412b/image.png" alt=""></p>
<p>피드를 조회하기 때문에 당연히 SELECT 쿼리가 실행돼요. 이후 조회수 증가를 위해 엔티티를 변경했기 때문에 메서드 실행이 완료되고 트랜잭션이 커밋되기 전에 영속성 컨텍스트에서 가지고 있던 스냅샷과 비교하여 변경 사항을 적용하기 위한 UPDATE 쿼리를 생성하고 커밋 시점에 이를 실행해요.</p>
<p>즉, 조회가 발생할 때마다 Feed 엔티티를 조회하는 <code>SELECT</code> 쿼리와 조회수 필드를 변경하는 <code>UPDATE</code> 쿼리가 실행된다는 말이에요.</p>
<h3 id="참고-왜-조회수-필드만-변경했는데-모든-컬럼이-수정되나요">(참고) 왜 조회수 필드만 변경했는데 모든 컬럼이 수정되나요?</h3>
<p>JPA의 기본 전략이기 때문이에요. </p>
<p>그러면 모든 필드를 데이터베이스에 전송해야 하기 때문에 전송량이 증가하지 않나요?</p>
<p>네. 그러나 아래와 같은 장점으로 모든 필드를 업데이트한다고 해요.</p>
<ul>
<li>모든 필드를 수정한다면, 수정 쿼리는 항상 같아요.</li>
<li>데이터베이스에 동일한 쿼리를 보내면 데이터베이스는 이전에 한 번 파싱된 쿼리를 재사용할 수 있어요.</li>
</ul>
<p>필드가 많거나 저장되는 내용이 너무 크다면 수정된 데이터만 사용해서 동적으로 UPDATE 쿼리를 생성할 수 있는 방법이 있어요. 아래와 같이 <code>@DynamicUpdate</code> 를 사용하면 돼요.</p>
<pre><code class="language-java">@Entity
@Getter
@DynamicUpdate // 추가하기
@Table(name = &quot;feeds&quot;)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Feed { ... }</code></pre>
<blockquote>
<p>상황에 따라 다르지만 컬럼이 2~30개가 넘어가지 않는 이상 대부분 기본 전략이 효율적이라고 해요. 따라서 기본 전략을 사용하다가 더욱 타이트한 최적화가 필요한 경우 고려하면 좋아보여요. (인덱스가 많거나, 특정 컬럼의 데이터가 너무 큰 경우 등)</p>
</blockquote>
<h3 id="정확하지-않은-조회수">정확하지 않은 조회수</h3>
<p>위 코드의 구현은 간단하지만, 동시에 여러 요청이 들어오는 상황에서는 정합성 문제가 발생할 수 있어요.</p>
<pre><code class="language-java">public static void main(String[] args) {
    int taskCount = 100; // 실행 흐름의 수
    ExecutorService executorService = Executors.newFixedThreadPool(100); 
    for (int i = 0; i &lt; taskCount; i++) {
        // FeedReadTask는 특정 Feed를 조회하는 API를 호출하는 작업을 수행해요.
        executorService.submit(new FeedReadTask(&quot;http://localhost:8080/feeds/1&quot;));
    }
    executorService.shutdown();
}</code></pre>
<p>100개의 실행 흐름으로 같은 피드를 조회했지만, 테스트 결과 평균적으로 조회수가 <code>10.9</code> 정도로 기록되었어요. (테스트 10회, 단순 조회 작업이라 속도 및 결과에 편차가 크지 않다고 판단했어요.)</p>
<p>100번 호출했으니 조회수는 100이 되어야 하지만, 결과는 터무니없이 적은 값이 나왔어요.</p>
<h3 id="처음에는">처음에는...</h3>
<p>처음에는 조회수의 정확도는 크게 중요하지 않다고 생각했어요. 인스타그램의 좋아요 수나 유튜브 구독자 수처럼, 숫자가 커질수록 대략적인 부피감만 보여주는 방식이 일반적이기 때문이에요. 사용자 입장에서도 <code>몇 명이 좋아했는지</code>와 같은 상세한 수치보다 얼마나 인기가 많은가를 비교할 수만 있다면 괜찮은 접근이라고 생각했어요.</p>
<h3 id="다시-생각해보니">다시 생각해보니...</h3>
<p>그러나 <code>결과적으로 최종 데이터는 올바르게 반영되어야 하는게 맞지 않나?</code> 라는 의문이 들었어요. 커다란 부피감과는 별개로 서로 다른 2명이 동시에 피드를 조회했을 때 그 순간에는 아직 나 혼자 봤네라고 느낄 수 있지만, 언젠가는 조회수가 2로 반영되어야 올바른 데이터라고 말할 수 있다고 생각했어요.</p>
<h3 id="그럼-왜-이런-문제가-발생할까">그럼 왜 이런 문제가 발생할까?</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/a4880afa-8356-4dfc-950a-4b401f28e0bb/image.png" alt=""></p>
<p>현재의 구현에서는 조회수의 데이터를 가져와 값을 1 증가시키고 저장해요. 하지만 동시에 여러 요청이 들어오는 경우, 각 요청은 서로의 작업을 고려하지 않고 독립적으로 처리돼요. 여러 사용자가 동시에 같은 피드(조회수 0)를 조회하고 조회수를 1 증가시키려고 할 때, <code>각 요청은 자신이 조회한 데이터를 기반</code>으로 viewCount를 증가시키고, 이를 DB에 저장해요.</p>
<p>두 사용자가 동시에 조회수를 1 증가시키려 할 때</p>
<ul>
<li>첫 번째 사용자는 viewCount가 0인 데이터를 읽고 1로 증가시켜요.</li>
<li>(첫 번째 사용자의 조회수 반영이 끝나기 전에) 두 번째 사용자도 같은 데이터를 읽고, 역시 0에서 1로 증가시켜요.</li>
<li>두 사용자는 각각 UPDATE 쿼리를 실행하지만, 첫 번째 사용자가 업데이트한 값은 두 번째 사용자의 업데이트로 덮어씌워져요.</li>
</ul>
<p>결국, 두 명의 사용자가 각각 1씩 증가시키려고 했지만, 최종적으로 반영되는 viewCount는 1로만 기록되고, 첫 번째 사용자의 변경은 누락되었어요. (Race Condition)</p>
<p>문제가 발생한 이유는 이러한 <code>동시성 이슈</code>를 고려하지 않았기 때문이에요.</p>
<h1 id="동시성-문제를-해결하는-방법">동시성 문제를 해결하는 방법</h1>
<p>동시성 문제를 해결하기 위해 여러 가지 접근 방식을 찾아봤어요. 적절한 방법을 선택하기 위해 간단한 테스트를 진행했어요.</p>
<h2 id="synchronized-키워드-사용">synchronized 키워드 사용</h2>
<p>조회수를 변경하는 메서드에 synchronized 키워드를 사용하여 하나의 쓰레드만 접근하여 작업할 수 있게 바꾸면 어떨까요?</p>
<pre><code class="language-java">// Feed 엔티티 내부
public synchronized void addViewCount() {
    this.viewCount++;
}</code></pre>
<p><code>addViewCount()</code> 메서드에 <code>synchronized</code> 키워드를 추가하면, 하나의 스레드만 이 메서드에 접근할 수 있기 때문에 동시성 문제가 해결될 것처럼 보이지만, 결과는 이전과 비슷했어요.</p>
<h3 id="왜-synchronized로-해결되지-않았을까요">왜 synchronized로 해결되지 않았을까요?</h3>
<p>이는 JPA의 동작 방식과 관련이 있어요.</p>
<ul>
<li>엔티티 조회 : <code>findById()</code>로 엔티티를 조회하면 영속성 컨텍스트에 엔티티가 저장돼요.</li>
<li>엔티티 수정 : <code>addViewCount()</code>로 필드를 변경하면 영속성 컨텍스트에서 변경된 엔티티를 관리해요.</li>
<li>트랜잭션 커밋 : 트랜잭션이 끝나면 영속성 컨텍스트에서 변경 감지를 수행하고, 필요한 경우 UPDATE 쿼리를 실행해요.</li>
</ul>
<p>문제는 <code>동일한 Feed 엔티티</code>를 동시에 조회하고 수정하는 요청이 들어올 경우, 각 요청이 서로 독립적으로 SELECT 쿼리를 실행하고 <code>같은 초기 상태의 데이터를 기반으로 수정한다</code>는 점이에요.</p>
<ul>
<li><code>첫 번째 트랜잭션</code> : viewCount = 0 -&gt; viewCount = 1 -&gt; UPDATE</li>
<li><code>두 번째 트랜잭션</code> : viewCount = 0 -&gt; viewCount = 1 -&gt; UPDATE (첫 번째 트랜잭션 변경 사항 덮어씀)</li>
</ul>
<p>결국, synchronized 키워드는 메서드 수준에서만 동기화를 보장하며, <code>데이터베이스 레벨</code>에서의 동시성 제어는 하지 않아요.</p>
<blockquote>
<p>데이터에 동시에 하나의 스레드만 접근이 가능하다는 조건은 하나의 프로세스에서만 보장돼요.</p>
</blockquote>
<p>예를 들어, Scale-out을 진행하여 서버가 여러 대일 때 동시성이 보장되지 않는다는 말이에요.</p>
<h2 id="비관적-락pessimistic-lock">비관적 락(Pessimistic Lock)</h2>
<p>동일한 데이터에 대해 동시에 여러 작업이 수행되지 않도록 데이터에 잠금을 거는 방식이에요. JPA에서 <code>@Lock</code> 어노테이션을 사용하여 구현할 수 있어요.</p>
<pre><code class="language-java">// Repository 계층..
public interface FeedRepository extends JpaRepository&lt;Feed, Long&gt; {

    @Lock(LockModeType.PESSIMISTIC_WRITE) // 읽기 쓰기 잠금
    @Query(&quot;SELECT f FROM Feed f WHERE f.id = :feedId&quot;)
    Optional&lt;Feed&gt; findByIdForUpdate(@Param(&quot;feedId&quot;) Long feedId);
}

// Service 계층..
@Transactional
public FeedResponse view(Long feedId) {
    return feedRepository.findByIdForUpdate(feedId) // 변경 
            .map(feed -&gt; {
                feed.addViewCount();
                return FeedResponse.from(feed);
            })
            .orElseThrow();
}</code></pre>
<p>위와 같이 설정하면, <code>SELECT ... FOR UPDATE</code> 쿼리가 실행되어 다른 트랜잭션이 해당 데이터를 수정하거나 읽을 수 없도록 <code>읽기 쓰기 잠금</code>을 설정해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/d8a15958-0aa0-41a1-bf47-ab25ca2ecc81/image.png" alt=""></p>
<p><code>FOR UPDATE</code> 키워드가 사용되면, 트랜잭션이 해당 데이터를 읽는 순간, 데이터에 잠금이 걸려서 읽거나 수정할 수 없게 돼요.</p>
<ul>
<li>잠금이 걸린 데이터는 다른 트랜잭션이 수정할 수 없어요.</li>
<li>다른 트랜잭션이 동일한 데이터를 읽으려고 시도하면, 잠금을 해제할 때까지 대기해요.</li>
<li>잠금은 트랜잭션 범위 내에서만 유효하며, 트랜잭션이 커밋되거나 롤백되면 해제돼요.</li>
</ul>
<p>각 요청의 트랜잭션이 시작될 때 잠그고 커밋(반영)될 때 해제하기 때문에 두 트랜잭션이 동시에 동일 데이터를 수정하려 할 경우, 한 트랜잭션이 완료될 때까지 다른 트랜잭션이 대기하기 때문에 동시성 문제가 확실히 해결돼요.</p>
<p>그러나 트랜잭션을 완전히 기다리기 때문에 대기 시간이 길어지고 높은 트래픽 환경에서는 성능 저하를 초래할 가능성이 높은 편이에요.</p>
<p>비관적 락은 데이터 정합성이 매우 중요하거나, 충돌 가능성이 높은 경우에 적합해 보여요. 예를 들어, 쇼핑몰에서 한정 상품의 재고를 감소를 처리하거나 은행 계좌의 잔액을 수정할 때 사용할 수 있을 것 같아요.</p>
<h2 id="낙관적-락optimistic-lock">낙관적 락(Optimistic Lock)</h2>
<p>낙관적 락은 동시성 충돌을 허용하지만, 충돌이 발생하면 이를 감지하고 처리하는 방식이에요. 일반적으로 <code>@Version</code> 어노테이션을 사용해 구현하며, 데이터를 수정할 때 버전 정보를 기반으로 변경 충돌을 감지해요.</p>
<pre><code class="language-java">// Repository 계층..
public interface FeedRepository extends JpaRepository&lt;Feed, Long&gt; {

    @Lock(LockModeType.OPTIMISTIC) // 
    @Query(&quot;SELECT f FROM Feed f WHERE f.id = :feedId&quot;)
    Optional&lt;Feed&gt; findByIdWithOptimisticLock(@Param(&quot;feedId&quot;) Long feedId);
}

// Feed 엔티티
@Entity
@Getter
@Table(name = &quot;feeds&quot;)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Feed {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String content;

    private long viewCount = 0;

    @Version // 낙관적 락을 위한 버전 필드 추가
    private Integer version;

    ...
}</code></pre>
<p>낙관적 락은 트랜잭션이 시작될 때 잠금을 걸지 않고, 트랜잭션이 커밋될 때 버전 정보를 비교하여 충돌 여부를 확인해요. 만약 버전 정보가 일치한다면 트랜잭션을 커밋하고 (버전값 증가 후 저장), 그렇지 않으면 충돌이 발생한 것으로 간주하고 예외가 발생해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/3f91903d-8792-4283-82a1-5cd9af5e0f63/image.png" alt=""></p>
<p>위와 같이 <code>@Version</code> 어노테이션을 추가한 필드인 version은 트랜잭션이 커밋될 때 자동으로 검증되고, 충돌이 발생하면 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/orm/ObjectOptimisticLockingFailureException.html"><code>ObjectOptimisticLockingFailureException</code></a> 예외를 던져요.</p>
<blockquote>
<p><code>ObjectOptimisticLockingFailureException</code>: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [dev.bang.viewtest.entity.Feed#1]]</p>
</blockquote>
<h3 id="그러나-우리는">그러나 우리는..</h3>
<p>그러나 이렇게 예외가 발생하여 프로그램이 종료되거나 무시되지 않고, 다시 <code>정확한 데이터</code>를 위해 수정 쿼리가 실행되는 것을 원해요.</p>
<p>그렇게 하기 위해서는 <code>ObjectOptimisticLockingFailureException</code> 예외가 발생했을 때 처리해야 하는 로직을 직접 구현해야 해요. </p>
<p>해당 요청을 다시 시도하거나 사용자에게 이를 알리는 방법으로 구현할 수 있을 것 같아요. 하지만 <code>조회수가 집계되지 않았습니다.</code> 라고 사용자에게 전달할 정도는 아니기 때문에 다시 시도하는 방법이 좋을 것 같아요.</p>
<pre><code class="language-java">// 재시도 로직 처리 예시
@Transactional
public FeedResponse view(Long feedId) throws InterruptedException {
    int retryCount = 0;
    while (retryCount &lt; 3) {  // 최대 3번 재시도
        try {
            return feedRepository.findByIdWithOptimisticLock(feedId)
                    .map(feed -&gt; {
                        feed.addViewCount();
                        return FeedResponse.from(feed);
                    })
                    .orElseThrow(() -&gt; new EntityNotFoundException(&quot;Feed not found&quot;));
        } catch (ObjectOptimisticLockingFailureException e) {
            retryCount++;
            if (retryCount &gt;= 3) {
                // 예외를 던지거나 추가 로직 구현
            }
            // 예외 발생 시 잠시 대기 (재시도 전에 잠시 대기)
            Thread.sleep(5000); // 예시: 5초 대기 후 재시도
        }
    }
    // 재시도 실패 후 예외 처리 등
}</code></pre>
<p>예시를 작성해 봤어요. 재시도 횟수를 설정하여 과도한 재시도를 방지하거나 상황에 맞게 지연시간을 설정해야 할 것 같았어요. 대기 큐를 사용하는 방법도 있을 것 같아요.</p>
<p>낙관적 락은 동시성 문제가 발생할 수 있는 환경에서 충돌이 발생할 경우 이를 감지하고, 예외를 처리하여 데이터를 정확하게 수정할 수 있도록 도와줘요. </p>
<p>비관적 락보다 잠금 시간이 짧기 때문에 성능이 더 나을 수 있지만, 재시도 로직으로 인해 더 많은 시간이 걸릴 수도 있을 것 같아요. 프로젝트 상황에 맞는 처리 로직을 구현하여 시스템의 일관성을 유지하면서도 사용자 경험을 방해하지 않도록 해야 해요.</p>
<p>데이터 충돌이 자주 일어나지 않을 것이라고 예상할 수  있고, 조회 성능이 중요한 경우에는 괜찮은 방법이라고 생각해요.</p>
<h2 id="직접-수정-쿼리를-작성하기">직접 수정 쿼리를 작성하기</h2>
<p>하나의 트랜잭션 단위 내에서 <code>@Modifying</code>과 <code>@Query</code>를 이용한 업데이트 쿼리 방식은 명시적인 업데이트 쿼리를 통해 트랜잭션 내에서 즉시 수정하는 방법도 있어요.</p>
<pre><code class="language-java">// Repository 계층..
public interface FeedRepository extends JpaRepository&lt;Feed, Long&gt; {

    @Modifying
    @Query(&quot;UPDATE Feed f SET f.viewCount = f.viewCount + 1 WHERE f.id = :feedId&quot;)
    void incrementViewCount(@Param(&quot;feedId&quot;) Long feedId);
}

// Service 계층..
@Transactional
public FeedResponse view(Long feedId) {
    feedRepository.incrementViewCount(feedId);
    return feedRepository.findById(feedId)
            .map(FeedResponse::from)
            .orElseThrow();
}</code></pre>
<p><code>incrementViewCount()</code> 메서드는 JPQL을 통해 단일 UPDATE 쿼리로 실행되므로, 1000개 스레드 환경에서도 동시성 문제 없이 안전했어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/5ecb811c-f40a-4ab2-ab83-ebfae8d067b8/image.png" alt=""></p>
<p>어떻게 동시성 문제를 잘 처리할 수 있었을까요?</p>
<blockquote>
<p>이전에는 애플리케이션 수준에서 값을 읽고 증가시키고 반영했어요. 예를 들어, 두 명의 사용자가 동시에 조회수가 0인 피드를 조회했다면 모두 해당 피드의 조회수 필드를 1로 UPDATE 하는 쿼리를 실행할 수 있어요.</p>
</blockquote>
<p>그러나 이 방법은 데이터베이스 수준에서 값을 증가시켜요. 조회수 필드에 +1 연산을 수행하라고 UPDATE 쿼리를 실행해요. 이때 데이터베이스는 트랜잭션 직렬화 메커니즘을 활용하여 동시 접근 시 순차적 처리를 보장해요. 즉 모든 연산이 순차적으로 실행되는 것을 보장하기 때문에 동시성 이슈가 발생하지 않아요. (트랜잭션 격리 수준 구성이나 롤백 여부에 따라 달라질 수 있어요.)</p>
<ul>
<li><p><code>데이터베이스의 원자적 증가 연산</code> : UPDATE 문에서 <code>f.viewCount = f.viewCount + 1</code>는 데이터베이스 레벨에서 원자적으로 수행되므로, 동시성 문제가 발생하지 않아요. </p>
<p>이는 애플리케이션 레벨에서 값을 읽고 수정하는 기존 방식(findById → 증가 → 저장)과 다르게, DB 내부에서 연산하므로 두 트랜잭션이 동시에 동일 값을 업데이트하더라도 최종적으로 모든 연산이 반영돼요. 따라서 동시 실행 환경에서도 조회수 증가 연산이 안전해요.</p>
</li>
<li><p><code>트랜잭션 격리 수준의 보장</code> : 대부분의 데이터베이스에서 기본 격리 수준인 <code>READ_COMMITTED</code> 이상에서는 트랜잭션이 다른 트랜잭션이 진행 중인 데이터 변경사항을 읽지 못하도록 보장해요. UPDATE 쿼리는 락(Lock)을 동반하여 실행되며, 동일 데이터에 대한 충돌을 방지하기 위해 순차적으로 실행돼요. 따라서 동시성 문제를 피할 수 있어요.</p>
</li>
</ul>
<p>트랜잭션 범위와 격리 수준을 잘 확인하고 사용해야 해요.</p>
<blockquote>
<p><a href="https://www.youtube.com/watch?v=bLLarZTrebU&amp;ab_channel=%EC%89%AC%EC%9A%B4%EC%BD%94%EB%93%9C">transaction isolation level 설명합니다! - 쉬운 코드</a> 제가 본 유튜브 영상이 괜찮아서 추천드려요.</p>
</blockquote>
<h2 id="redis-사용하기">Redis 사용하기</h2>
<p>지금까지의 방법은 모두 피드 <code>조회</code>와 조회수 컬럼 <code>수정</code> 쿼리가 모두 발생했어요. 테스트와는 다르게 엄청나게 많은 사용자가 특정 피드를 조회하는 경우 성능에 영향을 미칠 수 있어요. 특히, 수정 작업에서의 정합성을 맞추기 위해 더 많은 시간이 걸릴 것으로 예상돼요.</p>
<p>그래서 이번에는 Redis를 사용한 방법을 알아보려고 해요.</p>
<p>우선 피드를 조회하기 위한 <code>SELECT</code> 쿼리만 수행하도록 해요. 이때 조회했다는 것을 Redis에 저장해요. 이후 특정 시간이 지나면 Redis에 쌓인 값을 확인하고 UPDATE 쿼리를 실행하는 방법이에요.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class FeedService {

    private final FeedRepository feedRepository;
    private final RedisTemplate&lt;String, Long&gt; redisTemplate;

    @Transactional(readOnly = true)
    public FeedResponse view(Long feedId) {
        addViewCount(feedId); // Redis에 조회수 증가를 알림
        return feedRepository.findById(feedId) // 일반적인 조회 및 DTO 변환
                .map(FeedResponse::from)
                .orElseThrow();
    }

    private void addViewCount(Long feedId) {
        String redisKey = FEED_VIEW_COUNT_PREFIX + feedId;
        redisTemplate.opsForValue()
                .increment(redisKey, 1L);
    }
}

@Service
@RequiredArgsConstructor
public class FeedSyncService {

    private final FeedRepository feedRepository;
    private final RedisTemplate&lt;String, Long&gt; redisTemplate;

    // 1분마다 작업을 수행
    @Scheduled(cron = &quot;0 * * * * *&quot;)
    @Transactional
    public void syncFeedViewsToDb() {
        Set&lt;String&gt; keys = redisTemplate.keys(FEED_VIEW_COUNT_PREFIX + &quot;*&quot;);
        if (keys.isEmpty()) { // Redis에 담긴 변경사항이 없는 경우 작업 종료
            return;
        }

        // Redis에 담긴 값을 순회하며 쌓인 조회수를 UPDATE 쿼리로 반영 (동기화)
        keys.forEach(redisKey -&gt; {
            Long feedId = Long.parseLong(redisKey.replace(FEED_VIEW_COUNT_PREFIX, &quot;&quot;));
            long redisViewCount = Optional.ofNullable(redisTemplate.opsForValue().get(redisKey))
                    .orElse(0L);
            if (redisViewCount &gt; 0) { // 0 이상의 조회수가 쌓인 경우 동기화
                syncViewCount(redisKey, feedId, redisViewCount);
            }
        });
    }

    private void syncViewCount(String redisKey, Long feedId, long redisViewCount) {
        feedRepository.incrementViewCount(feedId, redisViewCount); // DB에 조회수 증가
        redisTemplate.delete(redisKey); // Redis에서 해당 키 삭제
    }
}</code></pre>
<p>아래는 Redis와 Scheduling 관련 구성이에요.</p>
<pre><code class="language-java">// Redis
@Configuration
public class RedisConfig {

    public static final String FEED_VIEW_COUNT_PREFIX = &quot;feed:view:&quot;;

    @Bean
    LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory();
    }

    @Bean
    public RedisTemplate&lt;String, Long&gt; redisTemplate(LettuceConnectionFactory connectionFactory) {
        RedisTemplate&lt;String, Long&gt; template = new RedisTemplate&lt;&gt;();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericToStringSerializer&lt;&gt;(Long.class)); // 조회수가 Long 타입
        return template;
    }
}

// 스케줄링
@Configuration
@EnableScheduling
public class SchedulingConfig {

}</code></pre>
<p>즉, 피드만 실시간으로 가져와서 조회하고 조회수는 추후에 반영하는 방법이에요. (특정 시간마다 동기화를 진행하거나, 특정 값을 넘기면 동기화를 진행하거나 여러 방법이 있을 수 있어요.)</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/632cc16a-ae46-435c-bb65-c483dc0b1ea9/image.png" alt=""></p>
<p>피드를 조회할 때 조회수 추가를 위한 UPDATE 쿼리가 발생하지 않는다는 것이 장점이에요. (특히 UPDATE 쿼리의 정합성을 위해 비교적 많은 시간이 필요했기 때문이에요.)</p>
<p>그러나 이 방법에도 단점이 있어요.</p>
<ul>
<li><p><code>실시간성 부족</code> : 만약 코드 예시처럼 1분마다 동기화를 진행한다면, 글을 작성하자마자 1,000명의 사용자가 글을 조회하는 경우 해당 사용자들은 모두 조회수를 0(또는 클라이언트 상에서 +1을 시켜주는)으로 보게 돼요. 조회수 자체가 사용성에 크게 민감하지 않다면 괜찮을 수 있어요.</p>
<p>사용성을 위해 더 짧은 주기로 동기화를 수행하거나, 일정 수의 조회수가 누적되었을 때 동기화를 트리거하는 방법도 가능할 것 같아요.</p>
</li>
<li><p><code>캐시 서버에 문제가 생긴 경우 정합성</code> : 데이터베이스에서 조회할 때마다 Redis에 흔적을 남겨둬요. 이때 만약 Redis 서버에 문제가 생겨 종료된다면 그동안 쌓인 (동기화 되지 못한) 조회수들은 사라져요.</p>
<p>Redis 서버에 장애가 발생했을 때 동기화를 일시적으로 중단하고 조회수 증가가 DB에 반영되도록 구현하고, 복구된 후 동기화 작업을 다시 수행하게 하는 방법으로 해결할 수 있을 것 같아요.</p>
</li>
<li><p><code>복잡성 증가</code> : Redis 서버를 별도로 구축하거나 스케줄링, 관련 구현 작업이 필요해요. 현재 상황에서 조회 성능 이슈(속도 등)가 발생하지 않는다면 굳이 복잡성을 늘릴 필요가 있을까요?</p>
</li>
</ul>
<h1 id="성능-확인">성능 확인</h1>
<p>살펴본 방식들을 정합성, 속도 측면에서 확인해 보려고 해요.</p>
<p><code>k6</code>를 사용했고, 간단한 스크립트를 작성했어요. <a href="https://velog.io/@dongvelop/%EC%84%B1%EB%8A%A5%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%88%B4-%EC%86%8C%EA%B0%9C">사용 방법 참고 블로그</a></p>
<pre><code class="language-js">import http from &quot;k6/http&quot;;
import { sleep } from &quot;k6&quot;;

export let options = {
    vus: 1000,          // 1,000명의 가상 유저
    duration: &quot;1m&quot;,      // 테스트 진행 시간 1분
};

export default function () {
    let getUrl = &quot;http://localhost:8080/feeds/1&quot;; // 요청할 URL

    // GET 요청을 보냄
    http.get(getUrl);

    // 1초 동안 기다림 (이 시간 동안 테스트가 계속 진행됨)
    sleep(1);
}</code></pre>
<blockquote>
<p>테스트 환경의 차이도 무시할 수 없기 때문에 가볍게 비교만 해보는 느낌으로 테스트를 진행해봤어요. 최대한 같은 환경에서 시도하려고 노력했어요.</p>
<ul>
<li>데이터 정합성은 (실행된 요청 - 실패한 요청)과 데이터베이스의 조회수 필드의 값을 비교했어요.</li>
</ul>
</blockquote>
<h2 id="가장-간단한-방식">가장 간단한 방식</h2>
<p>JPA의 <code>findById()</code> 메서드를 사용하여 조회하고 단순히 조회수 필드를 증가시키는 방식이에요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/8921ff5f-26b4-47ba-b115-010fb20f1649/image.png" alt=""></p>
<p>동시성 문제를 신경쓰지 않기 때문에 요청 속도가 빠른 편이에요. 다만 정합성 문제가 크기 때문에 선택하기 어려워요.</p>
<h2 id="비관적-락">비관적 락</h2>
<p><code>FOR UPDATE</code>를 사용하여 읽기 쓰기 잠금을 사용하는 방식이에요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/734ccc11-e5c5-4882-b842-8adac4959e01/image.png" alt=""></p>
<p>기존 방식보다 속도가 확실히 느려진 것을 볼 수 있어요. 다른 방식들과 비교하면 데이터 정합성에 올인한 케이스라고 볼 수 있어요.</p>
<h2 id="낙관적-락">낙관적 락</h2>
<p><code>@Version</code>을 사용하여 UPDATE 쿼리가 실행될 때 충돌을 확인하고, 충돌인 경우 재시도하는 방식이에요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/afabbac1-cc81-431d-bfff-96705d377c35/image.png" alt=""></p>
<p>실패율이 매우 높은 것을 볼 수 있는데, 버전 차이로 예외가 발생했을 때 처리하는 로직에 문제인 것 같았어요. 그래도 확실히 읽기 쓰기 잠금을 거는 비관적 락보다는 빨라진 것을 볼 수 있어요.</p>
<h2 id="직접-수정-쿼리를-작성하는-방식">직접 수정 쿼리를 작성하는 방식</h2>
<p><code>@Modifying</code>과 <code>JPQL</code>을 사용하여 원자적인 수정 쿼리를 실행하고 조회하는 방식이에요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/a53e888e-35c6-4bcd-81b2-684b367d095b/image.png" alt=""></p>
<p>비관적 락과 낙관적 락의 중간 정도의 속도를 보여줬어요. 또한 실패율이 매우 낮은 편이에요. 추가로 데이터 정합성은 100% 보장된 것을 확인할 수 있었어요.</p>
<h2 id="redis-방식">Redis 방식</h2>
<p>일반적인 조회 쿼리만 실행하고 Redis에 조회수 정보를 남겨두는 방식이에요. 이후 일정 시간(또는 이벤트)마다 데이터베이스에 동기화해서 정합성을 맞추도록 구현해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/3dffd6e9-6223-4aa1-a72c-ffc34f9e72a0/image.png" alt=""></p>
<p>평균 요청 시간이 압도적으로 빠른 것을 볼 수 있어요. 아무래도 수정 쿼리나 수정 쿼리 시 정합성을 위한 작업을 진행하지 않기 때문인 것 같아요. 그러나 어떤 이유인지는 모르겠으나 테스트 환경에서 데이터 정합성이 완벽히 보장되지 않았어요.</p>
<hr>
<h3 id="작성-후-추가-내용-테스트-시-redis-정합성-문제가-발생한-이유를-살펴보려고-해요">(작성 후 추가 내용) 테스트 시 Redis 정합성 문제가 발생한 이유를 살펴보려고 해요.</h3>
<p>Reids 방식 과정은 아래와 같아요.</p>
<ol>
<li>Redis에서 조회수 증가 : 사용자가 페이지를 조회하면 Redis에 조회수를 저장(증가)해요.</li>
<li>데이터베이스 수정 : 주기적으로 Redis 데이터를 데이터베이스에 동기화해요.</li>
<li>Redis 초기화 : 동기화 후 Redis 데이터를 초기화(삭제)해요.</li>
</ol>
<p>테스트에서 사용한 간격인 1분이 되어 데이터베이스 동기화 작업 중 Redis 조회수가 추가되면, 해당 조회수는 (동기화 이후에 키를 삭제하기 때문)삭제되면서 데이터 정합성이 깨질 수 있었어요. 또한 테스트 시간도 1분이고 스케줄링이 1분이다 보니까 정확한 시간에 맞춰 끝내지 않는 이상 문제가 발생했던 것 같아요.</p>
<h3 id="그렇다면-어떻게-해결할-수-있을까요">그렇다면 어떻게 해결할 수 있을까요?</h3>
<p>데이터베이스를 동기화하고 키를 제거하는 방법 대신 조회수만큼 차감하는 방식이라면 정합성을 지킬 수 있어요.</p>
<p>초기 Redis 조회수를 = 10이라고 가정할게요.</p>
<ol>
<li>데이터베이스 수정 작업을 시작해요.</li>
<li>수정 작업 중 Redis에서 추가 조회가 발생한다고 가정해요.(+3 → Redis 조회수 = 13)</li>
<li>수정 완료 후 데이터베이스에 10을 저장(Update)하고 Redis에서 -10을 차감해요.(Redis 조회수 = 3).</li>
</ol>
<p>결과적으로 추가된 조회수(+3)는 Redis에 살아있어요.</p>
<pre><code class="language-java">private void syncViewCount(String redisKey, Long feedId, long redisViewCount) {
    // 1. 데이터베이스에 Redis 조회수만큼 증가
    feedRepository.incrementViewCount(feedId, redisViewCount);

    // 2. Redis에서 증가된 조회수 차감
    redisTemplate.opsForValue()
            .decrement(redisKey, redisViewCount);
}</code></pre>
<p>코드를 변경하고 동일한 환경에서 여러 번 테스트를 진행했을 때 데이터 정합성을 지킬 수 있었어요. 조회 성능이 가장 좋기 때문에 이 방법을 다시 고려하게 되었어요.</p>
<h2 id="정리">정리</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/87c3ccbc-a42c-404a-b20d-65d6b59b7daa/image.png" alt=""></p>
<p>기본 방식은 동시성 문제를 전혀 제어하지 않기 때문에 사용성을 생각해서 사용하지 않을 것 같아요.</p>
<p>비관적 락은 동시성 문제를 매우 강력하게 처리하지만 그만큼 요청 속도가 줄어들기 때문에 피드 구현 정도라면 저는 사용하지 않을 것 같아요. (충분히 정합성을 지킬 수 있는 방식이 있었기 때문이에요.)</p>
<p>낙관적 락은 조회 성능도 준수하고, 프로젝트 규모나 특성상 동시에 피드를 조회하는 경우가 적다면 충분히 좋은 전략이 될 것 같아요. 물론 저는 높은 실패율을 보였지만, 낙관적 락 방식에서 버전 차이로 수정 작업 시 발생한 예외를 처리하는 로직을 잘 구현한다면 좋은 방식이 될 것 같아요.</p>
<p>직접 수정 쿼리를 작성한 방식은 비교적 무난한 방식 같아요. 매우 쉽게 구현할 수 있고, 현재 규모에서 속도도 느리지 않고, 테스트 환경에서 정합성을 100% 보장했기 때문이에요.</p>
<p>현재 상황에서 조회 성능의 향상이 중요하다면 Redis 방식을 사용하는 것이 좋을 것 같아요. 물론 Redis 서버의 장애가 생길 수 있기 때문에 안정성을 위해 이에 대한 구현도 필요해요. (데이터 정합성 부분도 체크가 필요해요.) 또한, 복잡도가 상승한다는 것도 충분히 고려가 필요할 것 같아요. (장애 포인트가 늘어난다는 것은 할 일이 늘어나기 때문이에요. 해당 정보가 프로젝트에 중요한지 검토가 필요할 것 같아요.)</p>
<p>저희 팀은 우선 직접 수정 쿼리를 작성해서 실행하는 방식으로 사용하기로 했어요.</p>
<h3 id="왜-선택했나요">왜 선택했나요?</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/1842d949-ff95-421d-8bfa-113fd4fd5964/image.png" alt=""></p>
<p>이미 사용자의 세션 정보를 위해 Redis 서버를 사용하고 있어요. 조회수를 위해 다른 Redis 서버를 사용하려면 추가적인 비용 문제가 발생하고, <del>실제 사용자가 없기 때문이에요.</del> 추가로 사용자 세션을 담고 있는 Redis 서버에 조회수 필드도 함께 저장하는 방식도 고민해 봤지만, 더 빠른 응답 속도가 필요하다고 느낀 상황이 아니라 복잡성을 낮추고 적당한 조회 성능을 선택했어요.</p>
<p>여러 개념이나 적용 방법, 테스트 방식 등이 사실과 다를 수 있어요. 혹시 발견하신다면 댓글 부탁드려요.</p>
<h2 id="추가">추가</h2>
<p>현재는 교내 LMS처럼 게시글을 조회할 때마다 조회수가 증가해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/d1e47375-19d8-432c-bf1c-a9bcd7d3b417/image.gif" alt=""></p>
<p>이 방식은 부정확한 인기 지표를 나타낼 가능성이 있으며, 사용성에 영향을 줄 수 있어요. 이를 개선하기 위해 쿠키와 만료 시간을 활용하여 12시간(혹은 하루) 동안 동일 사용자의 반복 조회가 조회수 증가에 영향을 주지 않도록 구현해 볼 예정이에요.</p>
<p>긴 글 읽어주셔서 감사합니다.</p>
<h2 id="참고-1">참고</h2>
<ul>
<li><a href="https://www.yes24.com/Product/Goods/19040233">자바 ORM 표준 JPA 프로그래밍</a></li>
<li><a href="https://ksh-coding.tistory.com/125#%E2%80%BB%20Synchronized%EC%9D%98%20%EB%98%90%20%EB%8B%A4%EB%A5%B8%20%EB%AC%B8%EC%A0%9C%EC%A0%90-1">[Spring] 스프링 동시성 처리 방법(feat. 비관적 락, 낙관적 락, 네임드 락) - BE_성하</a></li>
<li><a href="https://www.youtube.com/watch?v=bLLarZTrebU&amp;ab_channel=%EC%89%AC%EC%9A%B4%EC%BD%94%EB%93%9C">transaction isolation level 설명합니다! - 쉬운 코드</a></li>
<li><a href="https://velog.io/@dongvelop/%EC%84%B1%EB%8A%A5%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%88%B4-%EC%86%8C%EA%B0%9C">성능테스트 툴 소개 - 이동엽</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] OSIV 설정하시나요?]]></title>
            <link>https://velog.io/@hyeok_1212/osiv-%EC%84%A4%EC%A0%95%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94</link>
            <guid>https://velog.io/@hyeok_1212/osiv-%EC%84%A4%EC%A0%95%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94</guid>
            <pubDate>Fri, 22 Nov 2024 18:22:42 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/50d7c42f-161e-499c-9bd9-3398e5877c79/image.png" alt=""></p>
<p>Spring Boot 애플리케이션을 실행시키면 볼 수 있는 흔한 화면이에요. 대부분은 <code>INFO</code> 레벨의 로그이지만, <code>WARN</code> 레벨의 로그가 출력된 것을 볼 수 있어요.</p>
<p>사실 개발을 할 때 이상한 예외 TRACE가 출력되지 않고 아래와 같이 톰캣과 애플리케이션이 실행되었다는 것만 보고 기능을 테스트해 볼 때가 많았어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/92ef9391-e3d0-4eb1-913d-c0b4c21b207c/image.png" alt=""></p>
<p>이처럼 별 생각 없이 넘겼던 부분을 인식하고 학습해 보려고 해요.</p>
<blockquote>
<p>혹시 시작할 때 예외 TRACE 로그가 아닌 다른 로그에 관심을 가져본 적이 있으신가요?</p>
</blockquote>
<ul>
<li>Spring Boot, Spring Data JPA를 사용해요.</li>
</ul>
<h1 id="문제-상황">문제 상황</h1>
<blockquote>
<h4 id="warn-레벨-로그의-정체">WARN 레벨 로그의 정체</h4>
<p>JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning</p>
<h4 id="번역">번역</h4>
<p><code>spring.jpa.open-in-view</code>는 기본적으로 활성화됩니다. 따라서 뷰 렌더링 중에 데이터베이스 쿼리가 수행될 수 있습니다. 이 경고를 비활성화하려면 <code>spring.jpa.open-in-view</code>를 명시적으로 구성하십시오.</p>
</blockquote>
<p>로그를 바탕으로 검색하면 아주 오래된 <a href="https://stackoverflow.com/questions/30549489/what-is-this-spring-jpa-open-in-view-true-property-in-spring-boot">StackOverFlow에 게시된 질문</a>을 볼 수 있어요.</p>
<h2 id="해결-방법">해결 방법</h2>
<pre><code class="language-properties"># application.properties
spring.jpa.open-in-view=false</code></pre>
<pre><code class="language-yml"># application.yml
spring:
  jpa.open-in-view: false</code></pre>
<p>로그에서 나온 해결 방법대로 위와 같은 설정을 추가하면 <code>WARN</code> 로그는 출력되지 않는 것을 볼 수 있어요. (OSIV를 활성화하려면 true로 작성하면 돼요.)</p>
<h2 id="간단한데">간단한데...?</h2>
<p>이렇게 간단하게 해결할 수 있는데, 왜 <code>WARN</code> 로그가 출력될까요?</p>
<p>Spring Boot는 개발 초기 단계에서 편리함을 위해 OSIV를 활성화하지만, 이 설정을 그대로 사용하는 것이 <code>항상</code> 좋은 선택은 아닐 수 있어요. 이 패턴을 통해 데이터베이스 연결이 더 오래 유지되면서 예기치 않은 데이터베이스 쿼리가 발생하거나, 많은 양의 연결 리소스가 묶여서 병목을 일으킬 수 있어요.</p>
<p>OSIV가 안티 패턴인지 아닌지에 대한 논의도 치열해요. <a href="https://github.com/spring-projects/spring-boot/issues/7107">Spring Boot Issue #7107</a>에서도 확인할 수 있어요.</p>
<p>잘 모른 상태로 끄고 켜면 언젠가 큰 문제가 발생할 수도 있겠다는 느낌이 들어요. 따라서 이번에는 <code>OSIV(or OEIV)</code>에 대해서 알아보려고 해요.</p>
<blockquote>
<p>JPA에서는 <code>OEIV(Open EntityManager In View)</code>, 하이버네이트에선 <code>OSIV(Open Session In View)</code>라고 표현해요.</p>
</blockquote>
<p>관례상 둘 다 OSIV로 부르지만, Spring Boot GitHub Repository의 Issue나 PR을 확인하면 <code>O(S|E)IV</code>로 부르는 사람도 있어요.</p>
<h1 id="osivopen-session-in-view">OSIV(Open Session In View)</h1>
<p><strong>OSIV(Open Session In View)</strong>는 HTTP 요청이 처리되는 동안 영속성 컨텍스트를 유지하는 기능이에요. </p>
<p>Spring에서는 이를 지원하기 위해 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/orm/jpa/support/OpenEntityManagerInViewInterceptor.html">OpenEntityManagerInViewInterceptor</a>를 제공해요.</p>
<blockquote>
<h4 id="openentitymanagerinviewinterceptor-opensessioninviewinterceptor">OpenEntityManagerInViewInterceptor? OpenSessionInViewInterceptor?</h4>
<p>대부분의 Spring JPA 애플리케이션에서는 JPA 표준을 따르는 OpenEntityManagerInViewInterceptor를 사용해요. Hibernate를 직접 사용하는 특정 상황에서만 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/orm/hibernate5/support/OpenSessionInViewInterceptor.html">OpenSessionInViewInterceptor</a>가 고려될 수 있다고 해요.</p>
</blockquote>
<p>이 기능을 통해 서비스 계층의 트랜잭션이 종료된 이후에도 영속성 컨텍스트가 유지되므로, 컨트롤러 및 뷰 계층에서 지연 로딩(Lazy Loading) 데이터를 조회할 수 있게 돼요.</p>
<p>동작 방식부터 이해해 보려고 해요.</p>
<h2 id="osiv의-동작-방식">OSIV의 동작 방식</h2>
<p>OSIV가 활성화된 환경에서 Spring 애플리케이션이 요청을 처리하는 과정을 살펴볼게요.</p>
<blockquote>
<p>아래에 예시 코드와 그림도 준비되어 있으니 함께 읽는다면 더욱 이해하기 쉬울 거에요.</p>
</blockquote>
<h3 id="1-hibernate-세션-생성">1. Hibernate 세션 생성</h3>
<p>클라이언트 요청이 들어오면 <code>OpenEntityManagerInViewInterceptor</code>가 동작하여 <code>Hibernate 세션(영속성 컨텍스트)</code>을 생성해요.</p>
<p>이 단계에서는 아직 트랜잭션이 시작되지 않은 상태에요.</p>
<h3 id="2-서비스-계층에서-트랜잭션-처리">2. 서비스 계층에서 트랜잭션 처리</h3>
<p>서비스 계층의 <code>@Transactional</code> 어노테이션이 작성된 메서드가 호출되면 트랜잭션이 시작되고, 영속성 컨텍스트가 트랜잭션과 연결돼요.</p>
<p>트랜잭션 범위 내에서 엔티티를 조회하거나 변경할 수 있으며, 트랜잭션 종료 시 변경 사항은 데이터베이스에 반영돼요. (Dirty Checking)</p>
<h3 id="3-영속성-컨텍스트-유지">3. 영속성 컨텍스트 유지</h3>
<p>OSIV가 활성화된 경우, 트랜잭션 종료 후에도 영속성 컨텍스트는 요청 종료 시점까지 유지돼요.</p>
<p>즉, 컨트롤러 및 뷰 계층에서도 엔티티의 <code>지연 로딩</code> 속성(필드)을 조회할 수 있어요.</p>
<h3 id="4-세션-종료">4. 세션 종료</h3>
<p>요청이 완전히 처리되면, <code>OpenEntityManagerInViewInterceptor</code>가 영속성 컨텍스트를 종료해요.</p>
<p><strong>이 과정에서 <code>em.flush()</code>는 호출되지 않고, 영속성 컨텍스트만 종료돼요.</strong></p>
<h2 id="코드-예시로-살펴보기">코드 예시로 살펴보기</h2>
<p>더욱 이해하기 쉽게 코드로 살펴볼게요.</p>
<p>다음은 사용자와 사용자가 작성한 게시글(아티클)이 양방향 일대다 관계를 가지는 엔티티 설계에요.</p>
<pre><code class="language-java">@Entity
@Getter
@Table(name = &quot;users&quot;)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private int age;

    @OneToMany(mappedBy = &quot;author&quot;, fetch = FetchType.LAZY)
    private List&lt;Article&gt; articles = new ArrayList&lt;&gt;();

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

@Entity
@Getter
@Table(name = &quot;articles&quot;)
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String contents;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;author_id&quot;)
    private User author;

    public Article(String title, String contents, User author) {
        this.title = title;
        this.contents = contents;
        this.author = author;
    }
}</code></pre>
<p>여기서, <code>User</code>와 <code>Article</code>의 관계는 <code>지연 로딩</code>으로 설정되어 있어요. 따라서, User 엔티티에서 articles를 접근할 때 데이터베이스 쿼리가 발생해요.</p>
<p><strong>이때, 영속성 컨텍스트가 열려 있어야 가능해요.</strong></p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    @Transactional(readOnly = true)
    public User findOne(String name) {
        return userRepository.findByUsername(name)
                .orElseThrow(() -&gt; new IllegalArgumentException(&quot;해당 이름의 사용자가 없습니다.&quot;));

    }
}</code></pre>
<p><code>@Transactional</code> 어노테이션은 서비스 계층에서 트랜잭션을 생성하며, 그 경계 안에서 <code>영속성 컨텍스트</code>가 유지돼요.</p>
<p>직관적으로는 트랜잭션이 종료될 때 영속성 컨텐스트도 종료될 것으로 예상하지만, 앞서 말한 것처럼 OSIV가 활성화된 경우에는 HTTP 요청-응답이 종료될 때까지 영속성 컨텍스트가 유지돼요.</p>
<pre><code class="language-java">@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;users&quot;)
public class UserController {

    private final UserService userService;

    @GetMapping(&quot;{username}&quot;)
    public ResponseEntity&lt;UserResponse&gt; findUser(@PathVariable String username) {
        User user = userService.findByUsername(username);
        System.out.println(&quot;Service 빠져나옴&quot;); // 편의를 위해 콘솔 출력을 사용했어요.
        return ResponseEntity.ok(toUserDetailResponse(user));
    }

    // UserResponse는 Record로 작성한 DTO에요.
    private UserResponse toUserDetailResponse(User user) {
        return new UserResponse(
                user.getId(),
                user.getName(),
                user.getAge(),
                user.getArticles().size() // 지연 로딩이 발생해요.
        );
    }
}</code></pre>
<p>아직 영속성 컨텍스트가 존재하기 때문에 <code>user.getArticles().size()</code>를 호출하면서 지연 로딩이 발생해요. (추가 쿼리가 발생하여 정상적으로 데이터를 가져올 수 있어요.)</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/c013b42a-6384-4d2c-97a4-2a397c9c53b5/image.png" alt=""></p>
<p>만약 OSIV가 비활성화된 상태라면, 트랜잭션이 종료된 후 영속성 컨텍스트가 닫히기 때문에, (예시에서는 컨트롤러 계층에서) 지연 로딩 시도 시 <code>LazyInitializationException</code>이 발생해요.</p>
<h3 id="lazyinitializationexception">LazyInitializationException?</h3>
<p>예시 프로젝트에서 <code>jpa.open-in-view</code> 옵션을 false로 바꾼 후 똑같이 시도하면 아래와 같이 예외가 발생해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/6c9513f3-8cff-418a-a15a-c1500661c1b8/image.png" alt=""></p>
<blockquote>
<h4 id="예외-로그">예외 로그</h4>
<p>org.hibernate.LazyInitializationException : failed to lazily initialize a collection of role: dev.bang.osivtest.entity.User.articles: could not initialize proxy - no Session</p>
</blockquote>
<h4 id="번역-1">번역</h4>
<p>컬렉션을 지연 초기화하지 못했습니다, User.articles 프록시를 초기화할 수 없습니다, 세션이 없습니다.</p>
<p>하이버네이트 공식 문서에서도 이와 같은 상황을 예시로 예외를 설명해요.</p>
<blockquote>
<p>...예를 들어 세션이 닫힌 후 초기화되지 않은 프록시 또는 컬렉션에 액세스하면 이 예외가 발생합니다. </p>
</blockquote>
<ul>
<li><a href="https://docs.jboss.org/hibernate/orm/6.0/javadocs/org/hibernate/LazyInitializationException.html">Hibernate Docs - LazyInitializationException</a></li>
</ul>
<h2 id="뭐가-언제-열려-닫혀">뭐가 언제? 열려? 닫혀?</h2>
<pre><code class="language-yml">logging.level:
  org.hibernate.SQL: trace
  org.hibernate.engine.spi: trace
  org.hibernate.event.internal: trace
  org.hibernate.event.spi: trace
  org.hibernate.internal: trace</code></pre>
<p><code>application.yml</code>에 로그 옵션을 추가하여 더 자세하게 살펴볼게요. 직접 코드를 작성하여 실행하고 살펴보는 것도 좋은 방법이 될 것 같아요.</p>
<p>(실제 배포되는 애플리케이션에서는 성능과, 로그의 수집 가치를 따져보고 추가해야 해요.)</p>
<h3 id="osiv-활성화-로그">OSIV 활성화 로그</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/78b27e5c-9d96-430c-8b2b-8fe780aa7cc2/image.png" alt=""></p>
<pre><code>2024-11-23T01:09:32.434+09:00 TRACE ... Opening Hibernate Session.  tenant=null
2024-11-23T01:09:32.434+09:00 TRACE ... Opened Session [b7a297d6-1503-4e44-ab57-e58686e58c7b] at timestamp: 1732291772434</code></pre><ul>
<li>HTTP 요청(GET - users/bang)이 시작되어 서비스 계층에서 데이터베이스에 접근하기 위해 Hibernate가 세션(영속성 컨텍스트)을 생성해요.</li>
</ul>
<pre><code>2024-11-23T01:09:32.476+09:00 DEBUG ... org.hibernate.SQL:
    select
        u1_0.id,
        u1_0.age,
        u1_0.name 
    from
        users u1_0 
    where
        u1_0.name=?</code></pre><ul>
<li>데이터베이스에서 사용자를 조회하기 위한 SQL 쿼리가 실행돼요. (이름으로 조회해요.)</li>
</ul>
<pre><code>2024-11-23T01:09:32.478+09:00 TRACE ... SessionImpl#beforeTransactionCompletion()
2024-11-23T01:09:32.478+09:00 TRACE ... SessionImpl#afterTransactionCompletion(successful=true, delayed=false)</code></pre><p>트랜잭션이 정상적으로 처리되었으며, 데이터베이스 작업이 커밋되었음을 의미해요. (successful=true)</p>
<ul>
<li><code>beforeTransactionCompletion</code>은 트랜잭션이 완료되기 전에 호출돼요.</li>
<li><code>afterTransactionCompletion</code>은 트랜잭션이 성공적으로 끝난 것을 나타내요.</li>
</ul>
<pre><code>2024-11-23T01:09:32.479+09:00 TRACE ... DefaultInitializeCollectionEventListener : Initializing collection [dev.bang.osivtest.entity.User.articles#1]
2024-11-23T01:09:32.479+09:00 TRACE ... Collection not cached
2024-11-23T01:09:32.479+09:00 DEBUG ... org.hibernate.SQL:
    select
        a1_0.author_id,
        a1_0.id,
        a1_0.price,
        a1_0.name 
    from
        articles a1_0 
    where
        a1_0.author_id=?
2024-11-23T01:09:32.480+09:00 TRACE ... Collection initialized</code></pre><p>컨트롤러 계층에서 <code>user.getArticles().size()</code> 메서드 호출이 발생하여 articles 컬렉션을 초기화했다는 내용이에요. </p>
<ul>
<li><p>User 엔티티의 articles 컬렉션이 초기화되었어요.<br>이 컬렉션은 Lazy Loading으로 설정되어 처음에는 데이터베이스에서 가져오지 않다가, 실제 접근 시점에 SQL 쿼리를 실행하여 초기화했어요.  </p>
<p>또한, Hibernate는 articles의 데이터를 캐시에서 찾으려고 했으나, 데이터가 캐시되지 않아 select 쿼리를 실행한 것을 볼 수 있어요.</p>
</li>
</ul>
<p>트랜잭션이 종료된 후 컨트롤러 계층에서 지연 로딩이 동작했음을 알 수 있고, 영속성 컨텍스트가 살아있음을 알 수 있어요.</p>
<blockquote>
<p>트랜잭션이 종료되었기 때문에 DirtyChecking이 일어나지 않아요. 추가로 요청-응답 주기가 종료되어 영속성 컨텍스트가 종료되는 순간에도 flush()를 호출하지 않아요. </p>
</blockquote>
<p>예시 컨트롤러에서 User 엔티티를 변경하도록 코드를 작성해도 update 쿼리가 발생하지 않는 것을 볼 수 있어요. (물론 예시에서는 readOnly로 조회한 엔티티이지만...)</p>
<pre><code>2024-11-23T01:09:32.500+09:00 TRACE ... Closing session [b7a297d6-1503-4e44-ab57-e58686e58c7b]</code></pre><p>마지막으로 HTTP 요청의 끝에서 세션이 닫혔어요.</p>
<h3 id="osiv-비활성화-로그">OSIV 비활성화 로그</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/46d5cf31-258d-4afd-8b22-a6d9bf4ef777/image.png" alt=""></p>
<p>트랜잭션이 종료되면서 영속성 컨텍스트도 종료되었어요.</p>
<p>그렇다는 것은 컨트롤러 계층에서 (영속성 컨텍스트 없이) 지연 로딩을 시도하는 것이기 때문에 예외가 발생하게 돼요.</p>
<h2 id="그림으로-확인하기">그림으로 확인하기</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/76372ca9-593f-4c42-832b-704897c714b2/image.png" alt=""></p>
<p>영속성 컨텍스트와 지연 로딩, 트랜잭션의 범위를 생각하며 흐름을 따라가다 보면 그림을 이해할 수 있을 거에요.</p>
<h2 id="osiv-활성화-springjpaopen-in-viewtrue">OSIV 활성화 (spring.jpa.open-in-view=true)</h2>
<p>직접 설정을 추가하지 않는 이상 기본으로 적용되는 구성이에요.</p>
<p>컨트롤러와 뷰 계층에서도 지연 로딩을 통한 추가적인 작업이 가능해지기 때문에 개발 편의성 측면에서는 도움을 받을 수 있어요. </p>
<p>그러나...</p>
<ul>
<li><code>예상하지 못한 쿼리 발생</code> : 예상하지 못한(서비스 계층이 아닌) 곳에서 다수의 쿼리가 발생할 수 있어요. 만약 개발자가 이를 알아차리지 못한 경우(의도하지 않은 경우)라면 또 다른 문제가 발생할 수도 있어요. (개발자가 확인해야 하는 영역이 넓어져요.)</li>
<li><code>유한한 자원 점유</code> : 영속성 컨텍스트는 <strong>데이터베이스 커넥션</strong>을 유지하고 있어요. 여러 개의 요청이 끝날 때까지 데이터베이스 커넥션을 점유하고 있다는 부분은 큰 장애가 발생할 수 있다는 것을 암시해요.</li>
</ul>
<h3 id="유한한-자원-점유">유한한 자원 점유</h3>
<p>OSIV 활성화 시 영속성 컨텍스트가 <code>요청-응답 주기</code> 동안 열려 있는 상태로 유지돼요. 이로 인해 <strong>영속성 컨텍스트는 데이터베이스 커넥션을 점유</strong>하고 있게 되며, 여러 요청이 동시에 처리되는 동안 리소스 점유 문제가 발생할 수 있어요.</p>
<p><code>@Transactional</code> 어노테이션의 사용도 비슷한 관점으로 바라볼 수 있을 것 같아요.</p>
<pre><code class="language-java">@Transactional
public ResponseDto order(RequestDto dto) {
    // 사용자 정보 가져오기

    // 쿠폰 및 할인 정보 가져오기

    // 가게 사장님에게 푸시 알림 보내기...?

    // 가격 계산 후 데이터베이스 저장
}</code></pre>
<p>예시에서는 하나의 트랜잭션 단위 안에 여러 작업이 실행돼요. 상황에 따라 <code>여러</code> 작업이 실행되는 것을 문제로 볼 수도 있겠지만, 저는 <code>푸시 알림 보내기</code> 부분이 문제가 될 수 있을 것 같아요.</p>
<p>만약 푸시 알림 서버에 문제가 생긴다면 어떻게 될까요?</p>
<p>Timeout을 지정해 두었다면 (대부분은 기본 구성이 있으니) 그 시간만큼 대기하게 돼요. 즉, 데이터베이스 커넥션이 필요 없는 작업 때문에 하나의 트랜잭션이 아무것도 하지 못한 채 대기하게 돼요.</p>
<p>요청이 하나라면 큰 문제가 없을 수 있지만, 1,000만 명이 축구 경기를 보기 위해 치킨을 주문하던 시기였다면 어떻게 될까요?</p>
<p>반대로 사용자 수가 정해져 있거나 적은 경우에는 큰 문제가 없을 수 있을 것 같아요. (예시: 관리자 페이지 등)</p>
<h2 id="osiv-비활성화-springjpaopen-in-viewfalse">OSIV 비활성화 (spring.jpa.open-in-view=false)</h2>
<p>OSIV를 비활성화하면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션도 반환해요. 따라서 커넥션 리소스를 낭비하지 않게 돼요.</p>
<p>그러나...</p>
<ul>
<li><code>필요한 데이터는 미리</code> : 필요한 모든 지연 로딩을 트랜잭션 단위 내부에서 처리해야 해요. (트랜잭션 단위 내 코드 복잡성 증가) 만약 예시처럼 컨트롤러에서 지연 로딩을 하려는 순간 <code>LazyInitializationException</code>이 발생해요.</li>
</ul>
<h3 id="lazyinitializationexception-해결-방법은">LazyInitializationException 해결 방법은?</h3>
<p>트랜잭션이 유지되는 서비스 계층에서 필요한 데이터(사용할 연관 관계)를 모두 조회하면, 상위 계층에서 데이터를 가져오지 않아도 되기 때문에 문제를 방지할 수 있어요.</p>
<p>대부분 사용을 피하는 전략이지만, 패치 전략을 Eager로 사용하거나 <code>fetch join</code>, <code>@EntityGraph</code>를 활용할 수도 있고, <code>Projections</code>을 활용할 수도 있을 것 같아요. 그 부분은 또 구조와 상황을 판단하고 선택하면 될 것 같아요.</p>
<h3 id="기타">기타</h3>
<ol>
<li>OSIV 활성화, 트랜잭션이 종료된 이후 (아직 영속성 컨텍스트는 열려 있을 때)</li>
</ol>
<pre><code class="language-java">// UserController
@GetMapping(&quot;{username}&quot;)
public ResponseEntity&lt;UserResponse&gt; findUser(@PathVariable String username) {
    User user = userService.findOne(username);
    System.out.println(&quot;Service 빠져나옴&quot;);
    user.updateName(&quot;newName&quot;); // 엔티티 변경 후
    em.flush(); // 강제 flush() 호출
    return ResponseEntity.ok(toUserDetailResponse(user));
}</code></pre>
<p>이렇게 하면 이미 트랜잭션은 종료되었기 때문에 예외가 발생해요. </p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/e4d9a46f-deb9-4f69-9074-4b79c92f576c/image.png" alt=""></p>
<p>하지만, 다른 비즈니스 메서드를 호출하여 사용할 때 Dirty Checking이 동작하여 update 쿼리가 발생해요.</p>
<pre><code class="language-java">// UserController
@GetMapping(&quot;{username}&quot;)
public ResponseEntity&lt;UserResponse&gt; findUser(@PathVariable String username) {
    User user = userService.findOne(username);
    System.out.println(&quot;Service 빠져나옴&quot;);
    user.updateName(&quot;newName&quot;);
    userService.biz(username); // @Transactional이 작성된 메서드 호출
    // Dirty Checking이 동작하여 update 쿼리가 발생
    return ResponseEntity.ok(toUserDetailResponse(user));
}</code></pre>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/8857d40e-612e-41eb-8bc8-5bb738c3163f/image.png" alt=""></p>
<p>따라서 비즈니스에 의해 엔티티를 트랜잭션이 종료된 후 변경해야 한다면 제일 마지막에 하는 것이 좋을 것 같아요.</p>
<ol start="2">
<li>로그 옵션</li>
</ol>
<pre><code class="language-yml"># application.yml
logging.level:
  org.hibernate.SQL: trace # Hibernate가 실행하는 모든 SQL 쿼리
  org.hibernate.engine.spi: trace # Hibernate 세션 및 영속성 컨텍스트의 내부 엔진 동작
  org.hibernate.event.internal: trace # Hibernate 내부 이벤트 리스너의 구현체 동작
  org.hibernate.event.spi: trace # Hibernate 이벤트 처리의 SPI(Service Provider Interface) 레벨
  org.hibernate.internal: trace # Hibernate의 내부 구현 세부사항 추적</code></pre>
<ul>
<li>학습 간 사용했던 옵션이에요. (채찍피티가 알려준..)</li>
</ul>
<h2 id="결론">결론</h2>
<p>OSIV는 개발 편의성과 성능 사이의 트레이드오프를 고려해야 하는 중요한 설정인 것 같아요. 각 프로젝트의 특성에 맞게 신중하게 선택해야 해요. (정답은 없다...!)</p>
<ul>
<li>애플리케이션의 트래픽 규모</li>
<li>데이터베이스 커넥션 관리 전략</li>
<li>지연 로딩 사용 패턴</li>
<li>성능 요구사항</li>
</ul>
<blockquote>
<p>고객이 직접 경험하게 되는 서비스의 실시간 API는 OSIV를 끄고, 관리자 페이지처럼 커넥션을 많이 사용하지 않는 곳에서는 OSIV를 키는 전략을 사용하는 곳도 있다고 하네요.</p>
</blockquote>
<p>저도 사용자가 경험하는 API인 경우에는 OSIV 비활성화를 주로 선택할 것 같아요.</p>
<p>긴 글 읽어주셔서 감사합니다.</p>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://www.baeldung.com/spring-open-session-in-view">Spring OSIV - Baeldung</a></li>
<li><a href="https://stackoverflow.com/questions/30549489/what-is-this-spring-jpa-open-in-view-true-property-in-spring-boot">What is this spring.jpa.open-in-view=true property in Spring Boot? - stackoverflow</a></li>
<li><a href="https://github.com/spring-projects/spring-boot/issues/7107">Spring Boot Issue #7107</a></li>
<li><a href="https://ykh6242.tistory.com/entry/JPA-OSIVOpen-Session-In-View%EC%99%80-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94">JPA - OSIV(Open Session In View) 정리 - 유경호</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[우아한테크코스 7기 프리코스 4주 차 [BE]]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-4%EC%A3%BC-%EC%B0%A8-BE</link>
            <guid>https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-4%EC%A3%BC-%EC%B0%A8-BE</guid>
            <pubDate>Thu, 14 Nov 2024 06:44:39 GMT</pubDate>
            <description><![CDATA[<p>1~3주 차 회고에서는 미션을 수행하며 중요하게 생각한 부분과 다양한 사람과 주고받은 피드백을 중점으로 작성했습니다. 이번에는 약 4주 간 재밌게 몰입한 나에게 칭찬하며, 프리코스 과정이 제게 준 영향을 중점으로 돌아보려고 합니다.</p>
<h1 id="4주-차-미션">4주 차 미션</h1>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/69fca20e-d07d-4479-9143-e2e194fa8055/image.png" alt=""></p>
<p>어려웠습니다.</p>
<p>돌아가는 쓰레기를 먼저 만들고 개선하는 것이 아니라. 설계에 많은 시간을 투자하다가 마감 기간 때문에 돌아가는 쓰레기를 만들어버렸습니다...</p>
<h1 id="지원-동기">지원 동기</h1>
<p>저는 백엔드 개발자를 목표로 공부 중인 대학생입니다. 처음 전공과 개발 공부를 시작했을 때는, 모든 개념을 처음부터 끝까지 외우는 방식으로 학습했습니다. 하지만 학년이 올라가면서 점점 더 많은 시간이 소요되었고, 효율적인 시간 관리가 필요하다고 느꼈습니다. 그래서 다른 사람들은 어떻게 공부하고 시간을 관리하는지 궁금해졌고, 교내에서 운영하는 GDGoC(전 Google Developer Student Clubs)에 지원해 활동을 시작했습니다.</p>
<p>GDGoC 활동으로 깨달은 점은 <code>누군가에게 배운 지식을 공유하는 경험</code>이 학습에 큰 도움이 된다는 것이었습니다. 지식을 정리하고 공유하는 과정에서 제 자신도 다시 한 번 배울 수 있었습니다. 이 방식을 이어가기 위해 교내 멘토링 프로그램에 1년 동안 멘토로 참여하게 되었고, 지식을 나누고 피드백을 받는 과정이 즐겁다는 것을 깨달았습니다. 그래서 GDGoC의 서버 파트 운영진(코어)로 지원하여, 강의를 진행하거나 스터디를 진행했습니다.</p>
<p>대부분의 시간을 만족스럽게 보냈지만, 가끔 저에 대한 솔직한 피드백이 부족하다는 생각이 들었습니다. 제가 운영진이나 멘토 역할을 맡게 된 것은 단지 먼저 배울 기회가 있었기 때문이지 특별히 잘나서가 아니라고 생각했기 때문에, 저에 대한 날카로운 피드백을 원했습니다. 하지만 강의에 참여하는 분들 입장에서는 모르는 분야를 강의하는 운영진이나 멘토에게 솔직하게 피드백하는 것이 어려울 수 있다고 생각했습니다.</p>
<p>저는 <code>제 자신이 제대로 성장하고 있는지에 대한 객관적인 피드백이 필요하다</code>는 것을 깨달았고, 다양한 사람과 의견을 나누며 함께 성장할 수 있는 우아한테크코스에 큰 관심을 가지게 되었습니다.</p>
<h1 id="프리코스-목표">프리코스 목표</h1>
<blockquote>
<p>저는 다양한 의견을 듣고 배울 기회를 소중히 여기며, 함께 성장하고 싶습니다.</p>
</blockquote>
<h2 id="목표-설정-및-달성">목표 설정 및 달성</h2>
<p>처음 프리코스에 지원하면서 <code>다양한 의견을 듣고 배울 기회를 소중히 여기며 함께 성장할 것</code>이라는 목표를 세웠습니다. 지금 생각해 보면 꽤 추상적인 목표였다고 생각합니다. 그럼에도 불구하고, 나아갈 방향만큼은 크게 다르지 않았고, 덕분에 대부분의 목표를 달성했다고 생각합니다.</p>
<h3 id="상호-코드-리뷰">상호 코드 리뷰</h3>
<p>상호 코드 리뷰는 제 목표를 이루기 위해 가장 효과적인 방법이라고 생각했습니다. 특히 같은 문제를 다른 방법으로 해결하고, 그 이유를 듣는 것은 더 넓게 바라보고 비교할 수 있는 힘이 생기며, 누군가에게 도움을 주는 것뿐만 아니라 더 나은 답변을 작성하기 위해 학습하는 과정으로 함께 성장할 수 있다는 것을 체감했습니다.</p>
<p>저는 그중에서 검증 로직의 위치를 고민하면서 겪은 순간들이 기억에 남습니다. <a href="https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-3%EC%A3%BC-%EC%B0%A8-BE">3주 차 회고</a>에서 각 미션마다 변화된 생각을 작성해두었습니다.</p>
<p>또한, 저와 리뷰를 나눈 분이 아니더라도 제출 PR을 확인하며 여러 사람의 의견을 보고 배우는 것은 매우 큰 도움이 되었습니다. 이처럼 <code>제가 남긴 답변도 누군가에게 도움이 되지 않을까?</code> 라는 생각으로 제 의도를 명확히 전달하고 더 나은 방향을 찾을 수 있도록 고민하고 답변을 남기려고 노력했습니다.</p>
<p>마지막으로 다양한 사람의 의견 덕분에 여러 방법을 경험할 수 있었으며, 이야기를 나누는 과정에서 저만의 이유가 생긴 것 같아서 뿌듯하고 재밌었습니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/e6c45f2c-cb57-469e-acb2-d9945418dcc8/image.png" alt=""></p>
<p>이런 생각을 가지고 있었습니다!</p>
<h3 id="배운-것을-공유하기">배운 것을 공유하기</h3>
<p>부끄러움을 내려두고 함께 성장하기 위해 <a href="https://velog.io/@hyeok_1212/Java-Record-%EC%82%AC%EC%9A%A9%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94">Java Record</a>와 <a href="https://velog.io/@hyeok_1212/Java-enum-%EC%82%AC%EC%9A%A9%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94">Enum</a>에 대해 공부한 내용을 공유했습니다. 공식 문서를 꼼꼼히 읽고 다른 분들이 이해하기 쉽게 정리하는 과정이 쉽지는 않았지만, 제가 이해한 내용을 더 깊이 생각할 수 있었고, 생각보다 블로그에 반응이 좋아서 학습과 나눔이 주는 기쁨을 경험하게 되었습니다.</p>
<h3 id="좋은-경험이었던-대면-토론">좋은 경험이었던 대면 토론</h3>
<p>같은 학교 학우와의 대면으로 모여 마감된 미션을 수행하며 중요하게 생각한 부분(설계, 요구 사항 분석, 학습할 키워드 등)에 대해 이야기하는 시간을 가졌습니다. 상호 코드 리뷰는 온라인 특성상 피드백 주기가 길고, 상대방의 컨텍스트를 완벽히 이해하기 힘들다고 느꼈습니다. 그래서 직접 만나서 코드를 작성할 때의 고민과 의도를 듣고 실시간으로 궁금한 부분을 물어볼 수 있었기 때문에 매주 만날 수 있었던 것 같습니다.</p>
<p>우아한테크코스의 프리코스를 경험하며 &#39;함께 성장한다&#39;는 것이 단순히 지식을 나누는 것 이상임을 다시 한번 깨달았습니다. 서로의 코드를 읽고, 피드백을 나누고, 때로는 가르치고 배우는 과정 자체가 우리 모두를 성장시키는 <code>원동력</code>이었던 것 같습니다. 덕분에 처음에는 추상적으로 느껴졌던 목표가 이제는 구체적인 실천 방법들로 채워져 있음을 느꼈고, 대부분 달성했다고 생각합니다.</p>
<h2 id="목표를-조정하기-생성형-ai-활용에-대한-관점-변화">목표를 조정하기, 생성형 AI 활용에 대한 관점 변화</h2>
<p>처음에는 &quot;AI 사용을 지양하자&quot;라는 단순한 생각을 가졌었는데, 중간 회고 이후 이 관점이 바뀌었습니다. 토론 채널에서 다양한 의견을 접하면서, AI를 무조건 피하는 것이 아니라 &#39;어떻게 하면 학습에 도움이 되는 방향으로 활용할 수 있을까&#39;를 고민하게 되었습니다.</p>
<p>이러한 관점의 변화로 시간을 효율적으로 사용할 수 있었던 것 같습니다. 예를 들어, 새로운 개념을 처음 접했을 때 AI를 통해 간단한 예시를 요청하고, 공식 문서로 검증하는 방식으로 활용했습니다. 이렇게 하니 학습 시간은 단축되면서도, 정작 중요한 코드 설계와 구현에 더 많은 시간을 투자할 수 있었습니다.</p>
<p>무엇보다 &quot;이런 상황에서 AI를 사용하면 왜 좋고 나쁜지&quot;를 구체적으로 체감할 수 있었고, 도구는 결국 어떻게 사용하느냐가 중요하다는 것을 배웠던 과정이었습니다.</p>
<p>추가로 미션 단위의 작은 목표 중 하나인 구현 기능 목록을 살아있는 문서로 관리하라는 공통 피드백처럼 목표도 단순히 정해두고 달려가는 것이 아니라 지속해서 고치면 더 좋은 방향으로 성장할 수 있겠다고 생각했습니다.</p>
<h2 id="미션-수행-전략과-그-효과">미션 수행 전략과 그 효과</h2>
<p>미션을 수행할 때 사용한 전략에 대해 돌아보려고 합니다.</p>
<h3 id="공통-피드백을-통한-학습">공통 피드백을 통한 학습</h3>
<p>저는 매주 공통 피드백에서 언급된 키워드들을 먼저 학습하는 전략을 세웠습니다. 이 피드백들이 <code>더 나은 코드를 위한 방향성</code>을 제시한다고 생각했기 때문입니다. 실제로 각 키워드의 장단점을 이해하고 나면, 어떤 상황에서 활용해야 할지가 자연스럽게 떠올랐던 것 같습니다.</p>
<h3 id="반례-중심의-설계-접근">반례 중심의 설계 접근</h3>
<p>문제를 읽을 때마다 <code>어떤 경우에 이 프로그램이 실패할 수 있을까?</code>를 먼저 고민했습니다. 이런 접근은 검증 로직의 위치나 객체의 책임 범위를 결정하는 데 큰 도움이 되었습니다. 단순히 예시 테스트만 통과하는 것이 아니라, 실제 현장에서 발생할 수 있는 다양한 상황에서도 문제가 덜 발생하는 견고한 프로그램을 만들 수 있었습니다.</p>
<h3 id="아쉬움과-깨달음">아쉬움과 깨달음</h3>
<p>하지만, 이런 접근이 때로는 양날의 검이 되기도 했습니다. 미션의 난이도가 올라가면서 고려해야 할 사항도 많아졌고, 다른 분들의 의견을 들으며 새로운 반례와 구조적 단점들이 계속 떠올라 설계에 지나치게 많은 시간을 투자한 적도 있었습니다.</p>
<p>특히 TDD와 같은 새로운 방법론을 시도하지 못한 것이 아쉽습니다. 문제가 복잡해질수록 새로운 방식을 도입하는 것이 부담스러워졌지만, 지금 돌이켜보면 오히려 그런 상황에서 공통 피드백에 영상자료로 학습할 수 있던 TDD가 더 도움이 되지 않았을까 하는 생각이 들었습니다.</p>
<p>이번 프리코스를 통해 <code>완벽한 설계</code>보다는 <code>점진적인 개선</code>이 때로는 더 현실적인 접근일 수 있다는 것을 배웠습니다. 앞으로는 새로운 도전을 두려워하지 않고, 실패를 통해서도 배울 수 있다는 마음가짐으로 임하고 싶습니다. TDD와 같은 새로운 방법론도 쉬운 것부터 시도해 보면서 점차 시야를 확장해 나가는 것이 중요할 것 같습니다.</p>
<h2 id="몰입이란">몰입이란?</h2>
<p>프리코스를 진행하면서 경험한 가장 큰 변화는 문제를 해결하는 과정과 이를 위해 학습하고 나누는 과정이 즐거워졌다는 것입니다. 프리코스의 각 미션 자체가 하나의 도전 과제였고, 그 안에서 로직을 구현하거나 오류를 해결하는 과정들이 모두 작은 도전이었습니다.</p>
<p>이전에도 블로그 글을 작성하거나 동아리에서 강의를 진행한 경험이 있었지만, 그때는 상황에 따라 <code>해야만 하는 일</code>이라는 생각으로 임했습니다. 지금 생각해 보면 즉각적인 피드백을 받을 수 없어서 지쳤던 것 같습니다.</p>
<p>그러나 프리코스는 달랐습니다. 같은 목표를 가진 사람들과 일정 주기로 문제를 해결하고, 그에 대한 피드백을 쉽게 받을 수 있는 환경이었기 때문입니다. 자신만의 이유를 가지고 문제를 해결하고, 함께 성장하기 위해 피드백을 주고받는 과정은 매우 만족스러웠습니다.</p>
<p>또한, 이번 프리코스를 통해 앞으로 어떻게 학습하고 성장해야 할지에 대한 방향을 잡을 수 있었습니다. 앞으로도 프리코스의 구현 기능 목록 문서처럼 살아있는 목표를 가지고, 다양한 사람들과 함께 성장하고 서로 부담 없이 질문하고 도움을 주고받을 수 있는 개발자가 되도록 노력할 예정입니다.</p>
<h2 id="4주-차-미션-1">4주 차 미션</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/25a9ec6e-7f3a-4e79-bd67-0b73232d2df6/image.png" alt=""></p>
<p>이번 과제를 수행하면서 가장 크게 배운 점은 <code>완벽한 설계보다 진척이 중요한 순간도 있다</code>는 것이었습니다. 처음에는 요구사항을 완벽하게 이해하지 못한 채로 설계에만 과도하게 집중했습니다. 흔히 말하는 <code>클린 코드</code>, <code>객체지향 원칙</code>과 같은 이상적인 목표에 사로잡혀 있다 보니, 정작 기본적인 기능 구현에 충분한 시간을 할애하지 못했던 것 같습니다.</p>
<p>또한, <code>돌아가는 쓰레기가 낫다</code>는 조언을 들었지만, 저는 먼저 돌아가는 코드를 만들고 이를 개선하는 방식이 아니라, 완벽한 설계를 고민하다가 시간이 부족해져서 급하게 구현한 것 같아서 많이 아쉽습니다.</p>
<p>이 경험으로, 설계보다 요구사항을 꼼꼼히 파악하는 것이 우선이며, 시간은 한정적이므로 프로그램 규모에 맞게 우선 돌아가는 코드를 작성한 후 점진적으로 개선하는 것이 더 효율적일 수 있음을 배웠습니다. 특히, 설계에 과도하게 집중하면 오히려 유연한 변경이 어려워진다는 것도 큰 배움이었습니다.</p>
<h3 id="마지막">마지막</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/b5c8261a-56f0-4b40-be26-ce7acff8f2ac/image.png" alt=""></p>
<p>듣고 배웠던 내용을 적용하여 다시 미션을 풀어보려고 합니다.</p>
<p>긴 글 읽어주셔서 감사합니다.</p>
<ul>
<li><a href="https://github.com/YehyeokBang/java-convenience-store-7-YehyeokBang">4주 차 미션</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[우아한테크코스 7기 프리코스 3주 차 [BE]]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-3%EC%A3%BC-%EC%B0%A8-BE</link>
            <guid>https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-3%EC%A3%BC-%EC%B0%A8-BE</guid>
            <pubDate>Wed, 06 Nov 2024 14:43:13 GMT</pubDate>
            <description><![CDATA[<p>약 3주 간 재밌게 몰입한 나에게 칭찬하며, 회고를 작성하려고 합니다.</p>
<h1 id="로또">로또</h1>
<p>저는 오늘 하루가 지루하다고 느끼면 <del>1등이 되면 집부터 사고...</del> 이런 상상을 하면서 종종 로또를 구매했습니다.</p>
<p>이번 미션이 로또인 것을 핑계로 저번 주에도 구매를 했어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/afaed102-1561-495b-9309-0107dcd7e359/image.png" alt=""></p>
<p>...</p>
<p>아쉽지만, 그래도 저는 현실 로또 구매 과정에서 객체 분리에 대한 힌트를 얻었습니다.</p>
<h1 id="객체와의-협력">객체와의 협력</h1>
<p><a href="https://techblog.woowahan.com/2502/">생각하라, 객체지향처럼 - 김승영</a> 포스팅에서는 <a href="https://search.shopping.naver.com/book/catalog/32482589668">객체지향의 사실과 오해</a> 부분 중에서 ’07장. 함께 모으기’ 중 커피 전문점 도메인 설계 및 구현 예제에 대한 내용을 설명 하듯이 작성하신 것을 볼 수 있어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/44eef81d-bb85-4d50-95aa-1579806bf08c/image.png" alt=""></p>
<blockquote>
<p>여기서 손님, 메뉴판, 메뉴 항목들(4가지), 바리스타, 커피(4가지)가 각각 하나의 객체가 될 수 있습니다. (저는 처음에 혼자 생각해볼 때 4가지의 메뉴 항목들까지 객체로 생각하지는 못 했습니다. 근데 사실 아직도 어색하긴 합니다..ㅋㅋ)</p>
</blockquote>
<ul>
<li><a href="https://techblog.woowahan.com/2502/">생각하라, 객체지향처럼 - 김승영</a></li>
</ul>
<p>이런 관점으로 저도 로또 판매점에 들어가서 로또를 구매하기 위한 행동을 적어봤어요.</p>
<ol>
<li>로또 판매점 들어가기
1.1 (선택) 번호를 선택하기</li>
<li>직원에게 돈을 전달하며 원하는 개수를 말하기</li>
<li>개수에 맞게 발급 하신 로또를 받기</li>
</ol>
<p>여기서 저는 <code>로또 판매점</code>, <code>판매 직원</code>, <code>로또 발급기</code>를 객체로 만들 수 있겠다고 생각했습니다.</p>
<ul>
<li><code>로또 판매점</code> : 판매 직원이 배치되어 있으며, 이 직원에게 손님이 접근할 수 있습니다.</li>
<li><code>판매 직원</code> : 로또 발급기를 가지고 있으며, 손님에게 받은 금액을 확인한 후 발급기에 원하는 개수를 입력합니다.</li>
<li><code>로또 발급기</code> : 직원이 입력한 개수만큼 로또를 랜덤하게 발급합니다.</li>
</ul>
<p>덕분에 각 객체가 하나의 책임만 가지도록 설계하여 기능을 분리했고, 각 기능을 독립적으로 테스트할 수 있었습니다.</p>
<p>다만 아쉬운 점은 <code>1.1 (선택 사항) 번호를 직접 선택하기</code> 부분처럼, 랜덤으로 생성되는 로또가 아니라 사용자가 번호를 직접 선택하는 기능(현실 로또의 수동)을 추가할 때 확장성을 고려하지 못한 점입니다. 이를 개선하기 위해 전략 패턴을 활용하여 번호 생성 로직을 유연하게 변경할 수 있었다면 더 나은 설계가 되었을 것 같다고 생각합니다.</p>
<p>이외에도 당첨 번호(보너스 번호)와 결과 평가자, 규칙 등을 분리하고 단일 책임 원칙을 의식적으로 지키기 위해 노력했습니다.</p>
<p>로또를 구매한 토요일에 위와 같은 구조가 떠올라서 바로 추가했습니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/737855fd-94c2-4c7f-bade-c38e1ee1d6d0/image.png" alt=""></p>
<p> <del>4주 차 미션인 편의점도 다녀오면 좋을 것 같아서 고민하고 있습니다.</del></p>
<h1 id="검증-책임은-어디에">검증 책임은 어디에?</h1>
<h2 id="객체-내부에-1주-차">객체 내부에 (1주 차)</h2>
<pre><code class="language-java">public record Separator(String regex) {

    public Separator {
        validateRegex(regex);
    }

    private void validateRegex(String regex) {
        if (regex == null || regex.isEmpty()) {
            throw new IllegalArgumentException(&quot;구분자를 찾을 수 없어요. 입력하신 커스텀 구분자를 확인해주세요.&quot;);
        }
        if (regex.length() != 1) {
            throw new IllegalArgumentException(&quot;구분자는 한 글자여야 해요. 다른 구분자를 사용해주세요.&quot;);
        }
        if (regex.matches(Constants.POSITIVE_NUMBER_REGEX)) {
            throw new IllegalArgumentException(&quot;숫자는 커스텀 구분자로 사용할 수 없어요. 다른 구분자를 사용해주세요.&quot;);
        }
        if (UnsupportedSeparatorType.isUnsupported(regex)) {
            String reason = UnsupportedSeparatorType.getReason(regex);
            String message = String.format(&quot;해당 구분자(%s)는 커스텀 구분자로 사용할 수 없어요. (사유: %s) 다른 구분자를 사용해주세요.&quot;, regex, reason);
            throw new IllegalArgumentException(message);
        }
    }
}</code></pre>
<p>1주 차 미션에서는 각 객체가 자체적으로 검증 로직을 가지도록 구현했습니다. 객체가 직접 자신의 상태를 검증하게 하여, 각 객체가 자신의 책임을 지도록 만든 것입니다. 하지만 이 방식으로 구현하다 보니, 클래스 파일에서 검증 로직이 전체 코드의 절반 이상을 차지하는 경우가 생겼습니다. 결과적으로 해당 클래스가 원래 해야 하는 역할이 검증 로직에 가려져서, 클래스의 책임을 한눈에 파악하기가 어려웠습니다.</p>
<blockquote>
<p>여러 방법을 직접 경험하고 더 좋다고 판단되는 것을 고를 수 있는 개발자가 되고 싶었습니다. 그래서 2주 차 미션에서는 검증 로직을 한 곳으로 모아서 책임지는 객체를 만들어보기로 했습니다.</p>
</blockquote>
<ul>
<li><a href="https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-2%EC%A3%BC-%EC%B0%A8-BE">2주 차 회고</a></li>
</ul>
<h2 id="외부에-2주-차">외부에 (2주 차)</h2>
<pre><code class="language-java">public class RegistrationValidator {

    // 상수들

    private RegistrationValidator() {
    }

    // 검증 메서드들
}

// 위 메서드들은 신청 폼을 책임지는 객체에서만 사용합니다.</code></pre>
<p>2주 차 미션은 자동차 경주를 주제로 했는데, 저는 자동차 경주에 참가 신청하는 과정을 코드로 표현하고자 했습니다. 이를 위해 등록(신청 폼) 검증 클래스를 별도로 만들고, 사용자가 입력한 데이터를 바탕으로 신청 폼을 생성할 때 이 클래스에서 검증이 이루어지도록 했습니다.</p>
<p><code>모든 검증 로직이 한 곳에 모여</code> 있어서, 어느 부분에서 어떤 검증이 수행되는지 한눈에 파악할 수 있었고, 여러 클래스에서 공통으로 사용되는 검증 로직(예: 문자열을 숫자로 변환하는 작업 등)을 손쉽게 재사용할 수 있어 코드의 중복을 줄일 수 있었습니다.</p>
<p>그러나 이 방식에도 단점이 있었습니다. 모든 검증 로직이 외부의 검증 클래스로 이동하면서, 개별 객체에는 검증 로직이 남아있지 않게 되었습니다. 이로 인해 런타임에 항상 올바른(요구사항에 맞는) 객체가 생성될 것이라는 보장이 없었습니다. 예를 들어, Car 클래스는 자동차 이름의 길이가 5자 이하라는 규칙을 지켜야 하는데, 검증이 외부로 이동하면서 Car 객체 내부에서는 이 규칙을 강제할 수 없게 된 것입니다.</p>
<pre><code class="language-java">// 예시
public class Car {

    private final String name;

    public Car(String name) {
        this.name = name;
    }
    ...
}</code></pre>
<p>이 방식은 제가 코드를 작성할 때는 큰 문제가 없을 수 있습니다. 저는 제가 작성했기 때문에 검증 클래스가 필요성을 잘 이해하고 이를 일관되게 사용할 수 있기 때문입니다. 하지만 다른 개발자가 이 코드를 사용할 경우, 검증 클래스를 생략하고 바로 <code>new Car(&quot;나는이름이정말길어요&quot;);</code>처럼 규칙을 벗어난 객체를 생성할 가능성이 있습니다. 이렇게 되면 프로그램 전체의 일관성이 깨질 수 있습니다.</p>
<p>또한, 테스트 코드도 작성하기 어려웠습니다. 규칙에 어긋나는 이름으로 자동차 객체를 만들 수 있기 때문에 올바른 테스트인가? 라는 고민이 많이 들었습니다.</p>
<h2 id="합치기-3주-차">합치기 (3주 차)</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/0949e661-9859-40b1-a797-f5ee8c59fd1b/image.png" alt=""></p>
<p>그래서 저는 두 방식의 장점을 섞으면 좋을 것 같다고 생각했습니다. 검증 로직을 한 곳에 모아 재사용성을 높이면서도, 개별 객체가 스스로의 일관성을 보장할 수 있도록 만드는 것이 이번 미션의 목표였습니다.</p>
<pre><code class="language-java">public class Validator {

    private Validator() {
    }

    // 많은 검증 로직들
}

...
// 로또 판매 직원 객체 메서드
public LottoTickets exchangeMoneyForTickets(int purchaseAmount) {
    validatePurchaseAmount(purchaseAmount); // 외부에 있는 검증 메서드를 사용
    int quantity = calculateLottoQuantity(purchaseAmount);
    return lottoMachine.issueTicket(quantity);
}

private void validatePurchaseAmount(int purchaseAmount) {
    // Validator의 검증 메서드를 호출하여 검증 수행
    Validator.checkAboveBaseAmount(purchaseAmount);
    Validator.checkPurchaseAmountUnit(purchaseAmount);
}</code></pre>
<p>검증 로직을 독립된 <code>Validator</code> 유틸리티 클래스로 분리하고, 각 객체가 필요할 때 검증 로직을 <code>스스로 호출</code>하여 자신의 일관성을 유지하도록 설계했습니다.</p>
<p>공통 검증 로직을 중앙 집중화함으로써, 코드 중복 없이 여러 곳에서 재사용할 수 있게 되었고, 검증 규칙이 바뀌어도 <code>Validator</code> 클래스만 수정하면 전체 코드에 반영할 수 있는 것도 큰 장점이라고 생각했습니다.</p>
<p>각 객체는 <code>Validator</code>의 검증 메서드를 스스로 호출하여, 자신의 상태가 유효한지 확인하고 일관성을 유지합니다. 이로써 객체는 자신이 필요로 하는 검증이 잘 이루어졌음을 보장할 수 있습니다. 예를 들어, <code>exchangeMoneyForTickets()</code> 메서드는 로또 구매 금액이 유효한지 검증한 후 티켓 발급을 진행하기 때문에, 올바르지 않은 상태의 객체가 생성되거나 사용되는 위협을 줄일 수 있습니다.</p>
<p>하지만 이 방식은 여전히 <code>Validator</code>와 객체 간의 강한 결합을 가지고 있으며, 각 객체가 매번 Validator의 메서드를 호출해야 한다는 단점이 있는 것 같습니다. (검증은 언제나 있어야 하니까 괜찮은 걸까? 라는 고민도 했습니다.)</p>
<h2 id="검증-세분화">검증 세분화</h2>
<p>저는 입력 검증과 모델 검증(로또 규칙 검증)을 분리하여 관리했습니다.</p>
<p>예를 들어, 문자열을 금액(숫자)으로 바꿀 수 있는지 확인하는 로직은 입력 검증, 로또에 중복된 숫자가 포함되어 있는지 확인하는 로직은 모델 검증으로 분류하여 관리했습니다.</p>
<p>그렇게 생각하게 된 이유는 다음과 같습니다.</p>
<p>현재는 <code>콘솔</code>을 통해 사용자로부터 문자열 형태의 값을 입력받아 이를 검증하고 사용하고 있습니다. 그러나, 만약 콘솔이 아닌 다른 입력 방식(예시: API)으로 전환하게 된다면, 기존 검증 로직(모든 검증 로직이 모여있는 경우)의 많은 부분을 수정해야 할 가능성이 있습니다.</p>
<p>예를 들어, API에서 JSON 형태로 입력받을 경우, 콘솔에 의존한 검증을 동일하게 사용하기 어렵다고 생각했습니다. (물론, 메서드 분리를 잘 해뒀다면, 큰 변화는 없을 수도 있겠다는 생각도 했습니다.)</p>
<p>따라서, 입력 검증(예시: 입력이 비어있는 값인지 확인하거나 문자열을 정수로 변환 가능한지 확인)과 모델 검증(예시: 로또 번호가 중복되지 않는지 또는 지정된 범위 내에 있는지 확인)을 분리하였습니다. 이를 통해 입력 형태와 관계없이 검증 로직만 조정할 수 있다고 생각했습니다.</p>
<p>앞서 말한 것처럼 검증에 사용하는 메서드는 모두 정적으로 선언하여 한 곳에 모아두고 사용하도록 했습니다.</p>
<h1 id="재시도-로직">재시도 로직</h1>
<p>사용자의 입력값이 잘못된 경우 그 부분부터 다시 입력받는 것이 요구사항이었습니다.</p>
<pre><code class="language-java">private int 구입금액_입력받기() {
    try {
        String rawInputPurchaseAmount = inputView.requestPurchaseAmount();
        validatePurchaseAmount(rawInputPurchaseAmount);
        return parsePurchaseAmount(rawInputPurchaseAmount);
    } catch (IllegalArgumentException exception) {
        System.out.println(exception.getMessage());
        return readPurchaseAmount();
    }
}
...</code></pre>
<p>처음에는 각 메서드에서 반복문과 <code>try-catch</code> 문을 사용하여 입력과 예외 처리를 했습니다. 예를 들어, 사용자로부터 로또 구매 금액을 입력받고, 입력 값이 유효하지 않으면 다시 입력받는 방식이었습니다. 그러나 이런 로직이 여러 메서드에 반복적으로 작성되었고, 코드가 복잡해지는 문제가 있었습니다.</p>
<p>문제를 해결하기 위해 <code>예외 발생 시 자동으로 재시도</code>하는 로직을 분리했습니다. 이렇게 하면 개별 메서드는 입력에만 집중할 수 있고, 예외가 발생했을 때 재입력하는 로직은 공통으로 처리하도록 했습니다.</p>
<pre><code class="language-java">@FunctionalInterface
public interface SupplierWithException&lt;T&gt; {

    T get() throws IllegalArgumentException;
}

public class RetryHandler {

    private RetryHandler() {
    }

    public static &lt;T&gt; T retryIfError(SupplierWithException&lt;T&gt; method) {
        while (true) {
            try {
                return method.get();
            } catch (IllegalArgumentException e) {
                System.out.println(e.getMessage());
            }
        }
    }
}</code></pre>
<p><code>retryIfError()</code> 메서드는 예외가 발생할 수 있는 메서드(함수형 인터페이스)를 파라미터로 받습니다. 예외가 발생하면 해당 메서드를 다시 호출하고, 예외가 발생하지 않을 때까지 반복합니다.</p>
<p>Java의 표준 <code>Supplier</code> 인터페이스는 예외를 처리할 수 없기 때문에, 예외를 던질 수 있는 커스텀 함수형 인터페이스 <code>SupplierWithException</code>을 추가로 정의해서 사용했습니다.</p>
<p>스스로 생각해낸 방법은 아니고, 분리하는 방법을 고민하다가, 다른 언어인 자바스크립트의 함수를 인자로 전달하는 것처럼 자바에서도 <code>재시도_메서드(입력_메서드);</code> 형태로 구현하면 좋겠다는 생각으로 검색하다가 <code>행위</code>를 쉽게 다룰 수 있는 함수형 인터페이스에 대해 알게 되었고 학습 후 적용하게 되었습니다.</p>
<pre><code class="language-java">RetryHandler.retryIfError(this::구입금액_입력받기());</code></pre>
<p>이런식으로 전달할 수 있게 되었고, 재시도 로직과 입력 로직을 분리하여 사용할 수 있게 되었습니다.</p>
<h3 id="함수와-메서드">함수와 메서드?</h3>
<p><code>retryIfError()</code> 메서드를 구현하면서 <code>함수</code>와 <code>메서드</code>의 차이에 대해서도 고민하게 되었습니다. 자바에서는 메서드를 함수형 인터페이스에 전달하는 방식으로 함수형 프로그래밍의 일부 개념을 도입할 수 있는데, 이를 이해하려면 <code>함수</code>와 <code>메서드</code>의 차이를 명확히 아는 것이 중요할 것 같아서 학습하게 되었습니다.</p>
<ul>
<li><p><code>메서드(Method)</code> : 메서드는 클래스에 소속된 함수로, 객체의 상태에 접근하거나 조작하는 역할을 수행합니다. 따라서 메서드는 객체의 속성과 결합되어 있으며, 객체 지향 프로그래밍(OOP)의 중요한 구성 요소입니다. 메서드는 <code>객체의 상태</code>에 따라 반환 값이 달라질 수 있으며, 주로 객체의 행동을 정의한다고 합니다.</p>
</li>
<li><p><code>함수(Function)</code> : 함수는 <code>독립적으로</code> 수행되는 작업의 단위로, 특정 객체에 종속되지 않고 입력값을 받아 결과를 반환하는 역할을 합니다. 함수는 외부 상태에 의존하지 않고 전달받은 파라미터에 의해서만 결과가 결정되며, 이를 순수 함수라고도 합니다. Java에서는 함수형 프로그래밍이 도입되면서 람다 표현식과 함수형 인터페이스 등을 통해 특정 객체와 무관하게 동작하는 함수처럼 사용할 수 있는 방법이 제공되었다고 합니다.</p>
</li>
</ul>
<pre><code class="language-java">// 함수처럼 독립적으로 동작하는 예시
public int sum(int a, int b) {
    return a + b; // 입력값만으로 결과가 결정됨
}

// 메서드 예시 - 특정 객체의 상태에 따라 동작이 달라짐
public class Car {

    private String name;

    // 생성자
    public Car(String name) {
        this.name = name;
    }

    public String getName() {
        return name; // 객체 상태에 따라 결과가 달라질 수 있음
    }

    public void setName(String name) {
        this.name = name; // 객체의 상태를 변경
    }
}</code></pre>
<p><code>함수</code>는 전달받은 입력값에 의해서만 결과가 만들어지고 외부 상태에 영향을 받지 않습니다. 함수형 프로그래밍에서는 이러한 함수들을 사용해 <strong>프로그램의 상태 변화와 부작용을 줄이는 것을 목표로 합니다.</strong> </p>
<p><code>메서드</code>는 객체의 상태를 읽거나 변경할 수 있기 때문에, 같은 메서드라도 객체의 내부 상태에 따라 결과가 달라질 수 있습니다.</p>
<h1 id="작은-부분부터-테스트하기">작은 부분부터 테스트하기</h1>
<p>코드가 의도대로 작동하는지 확인하기 위해 각 객체의 역할을 중심으로 단위 테스트를 작성했습니다. 덕분에 기능별로 빠르게 피드백을 받아 수정할 수 있었고, 테스트 코드를 작성하면서 객체의 책임과 역할을 자연스럽게 점검하게 되었습니다. 이 과정이 메서드나 객체가 작은 단위로 명확한 역할을 수행하도록 도움을 준다는 것을 느꼈습니다. </p>
<p><strong>특히, 테스트 코드를 작성할 때 깊은 부분까지 확인하기 힘들거나 많은 경우의 수를 확인해야 하는 경우 이것이 객체의 책임 범위를 점검하는 기준이 된다고 느꼈습니다.</strong></p>
<p>물론 테스트 코드 작성에는 꽤 많은 시간이 소요되지만, 빠른 피드백을 통해 실수를 줄이고 코드의 품질을 높일 수 있을 것 같습니다. </p>
<h1 id="학습한-내용을-공유하기">학습한 내용을 공유하기</h1>
<p>최근 <a href="https://velog.io/@hyeok_1212/Java-Record-%EC%82%AC%EC%9A%A9%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94">Java Record 포스팅</a>에 이어, 이번에는 <a href="https://velog.io/@hyeok_1212/Java-enum-%EC%82%AC%EC%9A%A9%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94">Java Enum</a>에 대해 학습하고 내용을 공유했습니다.</p>
<p>처음에는 Enum을 단순히 상수들을 모아놓은 형태로만 이해하고 있었지만, Java의 Enum은 생각보다 훨씬 강력한 기능들을 제공한다는 것을 알게 되었습니다. 특히, Enum은 단순히 값의 집합 이상으로 각 상수에 상태와 행위를 추가할 수 있으며, 각 Enum의 상수가 객체처럼 작동할 수 있다는 부분이 인상 깊었습니다.</p>
<p>저처럼 Enum을 가볍게 생각하던 분들이 이 글을 통해 Java Enum의 강력한 기능을 이해하고 활용할 수 있었으면 좋겠다고 생각해서 학습한 내용을 좀 더 쉽게 전달하려고 노력했고, 특히 Java를 만든 분들이 Enum을 어떤 의도로 만들었는지, 공식 문서에서 강조하는 핵심 요소들을 반영하여 설명했습니다. 쉽게 설명하기 위해 학습하고 정리하는 과정으로 많은 부분을 고려할 수 있게 되었습니다.</p>
<h1 id="마무리">마무리</h1>
<p>이제 프리코스의 마지막 미션만 남았습니다. 약 3주 간의 과정으로 다양한 관점을 깨닫고, 많은 것을 배워서 우아한테크코스의 교육 과정에 욕심이 생기는 것 같습니다. 특히 주기적으로 회고를 작성하는 것이 의식적으로 배운 것을 사용하게 만들고, 더 나은 코드를 위해 학습하게 만드는 원동력이 되는 것 같습니다. 함께 성장하기 위해 모인 지원자 분들을 모두 응원합니다. 마지막 미션도 화이팅입니다!</p>
<p>감사합니다.</p>
<h2 id="수행한-미션">수행한 미션</h2>
<p><a href="https://github.com/woowacourse-precourse/java-lotto-7/pull/565">프리코스 3주 차 로또 미션 PR</a></p>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://techblog.woowahan.com/2502/">생각하라, 객체지향처럼 - 김승영</a></li>
<li><a href="https://search.shopping.naver.com/book/catalog/32482589668">객체지향의 사실과 오해</a> </li>
<li><a href="https://inpa.tistory.com/entry/%E2%98%95-%ED%95%A8%EC%88%98%ED%98%95-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-API">함수형 인터페이스 표준 API 총정리 - Inpa</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] Enum 사용하시나요?]]></title>
            <link>https://velog.io/@hyeok_1212/Java-enum-%EC%82%AC%EC%9A%A9%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94</link>
            <guid>https://velog.io/@hyeok_1212/Java-enum-%EC%82%AC%EC%9A%A9%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94</guid>
            <pubDate>Mon, 04 Nov 2024 18:29:31 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/b3e3030d-d958-4a77-b623-1aab4db60563/image.png" alt=""></p>
<p>우아한테크코스의 프리코스 3주 차 프로그래밍 요구 사항이에요. Enum 없이도 원하는 기능을 만들 수는 있었기 때문에 학습할 필요를 느끼기 힘들었는데, 이번 기회에 Enum에 대해 학습하고 이유를 알아보려고 해요.</p>
<p>구글에 검색할 때 가장 먼저 보이는 것은 이동욱 님의 <a href="https://techblog.woowahan.com/2527/">Java Enum 활용기 포스팅</a>이에요. 간단하게 Enum을 소개하고 실제 적용한 예시를 보여주면서 enum의 장점을 체감할 수 있게 해주는 블로그라서 Enum을 처음 접하시는 경우 읽어보시면 도움이 될 것 같아요.</p>
<blockquote>
<ul>
<li>Enum을 통해 확실한 부분과 불확실한 부분을 분리할 수 있었습니다. </li>
<li>특히 가장 실감했던 장점은 문맥(Context)을 담는다는 것이었습니다.<ul>
<li>Java Enum 활용기 포스팅 中</li>
</ul>
</li>
</ul>
</blockquote>
<p>저는 학습을 시작할 때 블로그 글로 간단한 개념과 예시로 장단점을 익히고 공식 문서를 확인하는 편이에요. 영어로 작성되어 있기 때문에 다소 시간이 걸리지만, 직관적인 설명과 링크로 이어진 방대한 자료로 더 넓게 이해할 수 있는 것 같아요. 이번에도 공식 문서를 위주로 학습해 보려고 해요.</p>
<h1 id="enum">Enum?</h1>
<blockquote>
<p>Enum은 2004년 9월 Java 5(1.5)에 추가되었어요.<br>이때 부터 1.x 표기가 아닌 x로 표기하기 시작했어요. <a href="https://docs.oracle.com/javase/1.5.0/docs/relnotes/version-5.0.html">Version 1.5.0 vs 5.0?</a></p>
</blockquote>
<p>열거형(Enumeration), 줄여서 <code>Enum</code>은 개발자가 미리 정의된 상수 집합을 변수로 정의할 수 있게 해주는 Java의 특별한 데이터 타입이에요. </p>
<p>Enum이 새롭고 화려한 개념이고, 언제나 꼭 사용해야 하는 것이다! 보다는 코드의 신뢰성과 가독성을 높여주는 개선 도구라고 이해하면 좋을 것 같아요.</p>
<p>먼저 Enum 추가 이전의 상수 사용 방식을 먼저 확인하고, Enum의 필요성에 대해 알아보려고 해요.</p>
<h2 id="이전에는">이전에는</h2>
<pre><code class="language-java">// int 타입 열거형 예시
public static final int SEASON_WINTER = 0;
public static final int SEASON_SPRING = 1;
public static final int SEASON_SUMMER = 2;
public static final int SEASON_FALL   = 3;</code></pre>
<p>이는 Enum 추가 전에 final 키워드를 이용해 변수를 상수화 하여 사계절을 구분짓는 일반적인 패턴이었어요. 아래와 같이 상수를 사용할 수 있어요.</p>
<pre><code class="language-java">public static void main(String[] args) {
    int currentSeason = SEASON_FALL; // 가을로 지정, 사실은 3이에요.

    switch (currentSeason) {
        case SEASON_WINTER:
            System.out.println(&quot;겨울이다!&quot;);
            break;
        case SEASON_SPRING:
            System.out.println(&quot;봄이다!&quot;);
            break;
        case SEASON_SUMMER:
            System.out.println(&quot;덥다..&quot;);
            break;
        case SEASON_FALL:
            System.out.println(&quot;가을이다!&quot;);
            break;
        default:
            System.out.println(&quot;알 수 없는 계절이에요.&quot;);
    }
}</code></pre>
<p>이런 방식에는 문제가 있어요.</p>
<ul>
<li><p><code>타입 안전성 부족</code> : 계절이 단순히 정수(int)로 표현되기 때문에 계절이 필요한 곳에 다른 정수를 전달하거나 두 계절을 더하는 등의 잘못된 사용이 가능해요. (다양한 사람이 함께 작업하는 환경에서 의도와 잘못된 사용이 가능하다는 것은 큰 문제가 될 수 있어요.)</p>
</li>
<li><p><code>이름 지정(?) 부족</code> : 정수형 열거형의 상수는 다른 정수형 열거형 타입과의 충돌을 피하기 위해 문자열(SEASON_)로 접두사를 붙이는 작업이 필요해요.</p>
</li>
<li><p><code>취약성</code> : 정수형 열거형은 <code>컴파일 타임 상수</code>이기 때문에 이를 사용하는 클라이언트에 컴파일되어 포함돼요. 만약 기존 상수 사이에 새로운 상수가 추가되거나 순서가 변경되면, 클라이언트는 다시 컴파일해야 해요. (안해도 실행은 가능하지만, 원하는대로 동작되지 않을 수 있어요.)</p>
</li>
<li><p><code>깡통 출력값</code> : 단순히 정숫값이기 때문에 출력할 경우 숫자만 나타나며, 그 숫자가 무엇을 나타내는지, 어떤 유형인지에 대한 정보는 전혀 제공되지 않아요.</p>
</li>
</ul>
<p>이러한 문제를 해결하기 위해 <code>Type-Safe-Enum Pattern</code>을 사용할 수 있지만, 이 패턴은 코드가 너무 길어지는 문제가 있고, 특히 이 패턴으로 만든 상수는 <code>switch 문</code>에서 사용할 수 없다는 단점이 있어요.</p>
<h2 id="그래서">그래서!</h2>
<p>Java 프로그래밍 언어는 5 버전에서 열거형 타입에 대한 언어적 지원을 추가했어요. 그것이 Enum이에요.</p>
<pre><code class="language-java">enum Season { WINTER, SPRING, SUMMER, FALL }</code></pre>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/729265a9-fe85-4149-8210-34e4f2d619e1/image.png" alt=""></p>
<p>가장 간단한 형태의 Enum을 보면 다른 언어(C, C++, C#)와 유사하게 보여요. 그러나 <a href="https://docs.oracle.com/javase/1.5.0/docs/guide/language/enums.html">공식 문서</a>에서는 Java의 Enum은 다른 언어의 열거형보다 훨씬 강력하다고 소개해요.</p>
<h2 id="강력한-java의-enum">강력한 Java의 Enum</h2>
<p>다른 언어의 열거형은 단순히 나열된 정수에 불과하지만, Java의 Enum은 완전한 기능을 갖춘 클래스에요. 위에서 확인한 모든 문제를 해결하면서도 아래의 이점을 누릴 수 있어요.</p>
<ul>
<li><p><code>완전한 클래스!</code> : Java에서 Enum을 정의하면, 단순한 값의 목록이 아니라 <code>기능이 있는 클래스</code>를 만들게 됩니다. 이 클래스는 메서드와 필드를 가질 수 있어, 다양한 동작을 수행할 수 있어요.</p>
</li>
<li><p><code>임의의 메서드와 필드 추가 가능!</code> : Enum에 원하는 메서드와 변수를 추가할 수 있어요. 예를 들어, 계절(Enum Season)에 대한 메서드를 추가하여 각 계절의 특징을 설명할 수 있게 만들 수 있어요.</p>
</li>
<li><p><code>인터페이스 구현 가능!</code> : Enum은 다른 클래스와 마찬가지로 인터페이스를 구현할 수 있어요. 이를 통해 Enum의 기능을 더욱 확장하고 유연하게 사용할 수 있어요.</p>
</li>
<li><p><code>Object 메서드를 Enum에 맞게!</code> : Java의 Enum은 <code>equals()</code>, <code>hashCode()</code>, <code>toString()</code>과 같은 Object 클래스의 메서드를 더 Enum 특성에 맞게 만들어줘요. Enum 값들을 비교하거나 출력할 때 편하게 사용할 수 있어요.</p>
</li>
<li><p><code>Comparable과 Serializable</code> : Java의 Enum은 <code>Comparable</code>과 <code>Serializable</code> 인터페이스를 구현하여, Enum 값들을 쉽게 비교하고 저장할 수 있어요.</p>
</li>
</ul>
<h3 id="채찍피티의-비교">채찍피티의 비교</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/4c9dd4e5-b3c4-4a89-9354-cbfb5ff21c8e/image.png" alt=""></p>
<h3 id="추가적인-장점">추가적인 장점</h3>
<ul>
<li><code>switch 문</code>에서도 사용할 수 있어요.</li>
<li>IDE의 지원이 좋아요. (자동완성, 오타검증, 텍스트 리팩토링 등)</li>
<li>리팩토링시 변경 범위가 줄어들어요. Enum에 정의하고 다른 곳에서 사용하기 때문에 Enum에만 변경이 일어나요.</li>
</ul>
<h1 id="enum-구조">Enum 구조</h1>
<p>Enum은 <code>enum</code> 키워드를 사용해 선언되며, Enum 선언은 아래와 같은 구조를 가져요.</p>
<pre><code>{ClassModifier} enum TypeIdentifier [ClassImplements] EnumBody</code></pre><ul>
<li><code>ClassModifier</code> : 클래스의 접근 제어자 및 기타 수정자를 지정해요. (예: public, private 등)</li>
<li><code>TypeIdentifier</code> : Enum의 이름이에요.</li>
<li><code>ClassImplements</code> : 필요한 경우 구현할 인터페이스를 지정할 수 있어요.</li>
<li><code>EnumBody</code> : 열거 상수 및 추가 메서드, 필드를 정의할 수 있어요.</li>
</ul>
<blockquote>
<h3 id="enum-관례">Enum 관례</h3>
<ul>
<li>Enum 명은 클래스처럼 첫 문자를 대문자로하고 나머지는 소문자로 구성해요.</li>
<li>열거 상수는 모두 대문자로 작성하며, 여러 단어로 구성된 경우 단어 사이에 언더바(_)를 사용해요.</li>
</ul>
</blockquote>
<p><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Enum.html">공식 문서</a>에서 각 메서드에 대한 자세한 구현 내용을 확인할 수 있어요.</p>
<p>이제 예제 코드를 보며 Enum의 구조와 사용 예시를 살펴볼게요.</p>
<h2 id="예시">예시</h2>
<pre><code class="language-java">public enum Season { WINTER, SPRING, SUMMER, FALL }</code></pre>
<ul>
<li>사계절을 구성하는 Season Enum이며, 각 상수(WINTER ~ FALL)는 Season Enum의 <code>인스턴스</code>에요.</li>
<li>Enum 상수는 고정된 값을 가지며, 외부에서 변경할 수 없어요.</li>
<li>Enum 클래스는 기본적으로 <code>values()</code>와 <code>valueOf(String name)</code> 메서드가 제공돼요.<ul>
<li><code>values()</code> : Enum의 모든 상수를 배열로 반환해요.</li>
<li><code>valueOf(String name)</code> : 해당 이름을 가진 Enum 상수를 반환해요.</li>
</ul>
</li>
</ul>
<pre><code class="language-java">public static void main(String[] args) {
    Season season = Season.FALL; // Enum 타입도 객체!

    switch (season) {
        case WINTER:
            System.out.println(&quot;겨울이다!&quot;);
            break;
        case SPRING:
            System.out.println(&quot;봄이다!&quot;);
            break;
        case SUMMER:
            System.out.println(&quot;덥다..&quot;);
            break;
        case FALL:
            System.out.println(&quot;가을이다!&quot;);
            break;
    }
}</code></pre>
<p>사계절 예제를 Enum을 사용하여 바꾸면 위와 같이 작성할 수 있어요. 기존에는 정수 상수를 사용하여 계절을 표현했지만, 잘못된 값(예: 4)이 입력될 위험이 있었어요. Enum을 사용하면 이러한 위험 없이 정의된 값만 사용하게 되어, 안전성과 가독성을 동시에 확보할 수 있어요.</p>
<p>또한, Enum은 정의된 상수 이외의 값이 입력되면 컴파일 시 에러를 발생시켜, 오류를 초기에 잡아낼 수 있도록 해줘요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/7b2d7753-4c32-4c7e-a9ce-8b8c1d0843c7/image.png" alt=""></p>
<p>다만, 이렇게 끝나면 안전성과 가독성 외에는 기존 상수 선언 방법과 크게 다른 부분이 없어보일 수 있어요.</p>
<h2 id="고급-사용">고급(?) 사용</h2>
<pre><code class="language-java">public enum Season {
    WINTER(&quot;겨울&quot;),
    SPRING(&quot;봄&quot;),
    SUMMER(&quot;여름&quot;),
    FALL(&quot;가을&quot;);

    private final String koreanName;

    Season(String koreanName) {
        this.koreanName = koreanName;
    }

    @Override
    public String toString() {
        return koreanName;
    }
}</code></pre>
<p>이 예제에서 <code>Season</code> 열거형(Enum)은 각 계절을 나타내는 상수뿐 아니라, 각 계절에 대응하는 한글 이름(koreanName) 필드도 가지고 있어요. 이처럼 Enum에 필드를 추가해 각 인스턴스가 고유한 값을 가질 수 있으며, 이를 활용해 보다 더욱 구체적으로 데이터를 표현할 수 있어요.</p>
<pre><code class="language-java">public static void main(String[] args) {
    for (Season season : Season.values()) {
        System.out.println(season);  // 각 계절의 한글 이름이 출력돼요.
    }
}</code></pre>
<p><code>Season</code> Enum의 각 인스턴스는 생성자에서 한글 이름을 전달받아 <code>koreanName</code> 필드에 저장해요. 이렇게 생성된 Enum 인스턴스는 <code>toString()</code> 메서드를 재정의하여, 기본적인 <code>name()</code> 대신 <code>koreanName</code>을 반환하도록 하고 있어요.</p>
<p>즉, <code>Season.WINTER</code>를 출력할 때 &quot;겨울&quot;이라는 한글 이름이 출력돼요. 즉, Enum은 일반 클래스처럼 동작하면서도 안전한 방식으로 데이터를 관리할 수 있어요.</p>
<p>추가적으로 익명 클래스 형태로 구현될 수 있기 때문에 각 상수가 다른 메서드를 가질 수 있도록 설계할 수도 있어요.</p>
<pre><code class="language-java">//  ADD와 SUBTRACT 상수는 각각 고유의 apply 메서드를 구현하여 연산을 다르게 수행해요.
public enum Operation {
    ADD {
        public int apply(int x, int y) { return x + y; }
    },
    SUBTRACT {
        public int apply(int x, int y) { return x - y; }
    };

    public abstract int apply(int x, int y);
}</code></pre>
<p>각 Enum 상수가 서로 다른 연산을 수행할 수 있도록 익명 클래스 형태로 <code>apply</code> 메서드를 구현해요. 이를 통해 상수별로 고유한 로직을 정의할 수 있으며, 추상 메서드를 이용해 상수마다 다른 동작을 할 수 있는 유연한 Enum 구조를 만들 수 있어요.</p>
<p>Java Enum은 상수만을 나열하는 데서 끝나지 않고, 상태나 동작을 담아 좀 더 풍부하게 데이터와 로직을 표현할 수 있게 되어 단순 열거형 이상의 강력한 도구로 사용할 수 있어요.</p>
<h2 id="또-다른-예제">또 다른 예제</h2>
<p>어떤 개념에 대한 다양한 예제를 보면 자신에게 좋은 방법을 찾을 수도 있어요. 다른 예제도 확인해 보면 좋을 것 같아요.</p>
<pre><code class="language-java">public class Card {
    // 트럼프 카드에서 카드 숫자(순위)를 정의하는 열거형이에요.
    public enum Rank {
        DEUCE, THREE, FOUR, FIVE, SIX,
        SEVEN, EIGHT, NINE, TEN,
        JACK, QUEEN, KING, ACE
    }

    // 트럼프 카드에서 카드 문양을 정의하는 열거형이에요.
    public enum Suit {
        CLUBS, DIAMONDS, HEARTS, SPADES
    }

    // 카드 객체는 숫자(rank)와 문양(suit)으로 구성되어 있어요.
    private final Rank rank;  // 카드의 숫자 (예: ACE, KING 등)
    private final Suit suit;   // 카드의 문양 (예: HEARTS, SPADES 등)

    // 생성자
    private Card(Rank rank, Suit suit) {
        this.rank = rank;
        this.suit = suit;
    }

    public Rank rank() {
        return rank;
    }

    public Suit suit() {
        return suit;
    }

    // 카드의 정보를 문자열로 반환하는 메서드에요.
    public String toString() {
        return rank + &quot; of &quot; + suit;  // &quot;숫자 of 문양&quot; 형식
    }

    // 초기 카드 덱을 저장할 리스트입니다.
    private static final List&lt;Card&gt; protoDeck = new ArrayList&lt;&gt;();

    // 정적 블록을 사용하여 초기 카드 덱을 생성해요.
    static {
        // 각 문양에 대해
        for (Suit suit : Suit.values()) {
            // 각 숫자를 반복하여 카드 객체를 생성합니다.
            for (Rank rank : Rank.values()) {
                protoDeck.add(new Card(rank, suit));  // 새로운 카드 객체를 덱에 추가
            }
        }
    }

    // 초기 카드 뭉치를 반환하는 정적 메서드에요.
    public static ArrayList&lt;Card&gt; newDeck() {
        return new ArrayList&lt;&gt;(protoDeck);
    }
}</code></pre>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) {
        // Card 클래스의 정적 메서드로 초기 카드 뭉치를 가져온 후 출력해요.
        ArrayList&lt;Card&gt; cards = Card.newDeck();
        for (Card card : cards) {
            System.out.println(&quot;card.toString() = &quot; + card.toString());
        }
    }
}

/* 출력
DEUCE of CLUBS
THREE of CLUBS
FOUR of CLUBS
...
ACE of SPADES
*/</code></pre>
<p>여러 개의 Enum을 사용해 카드 게임을 위한 Card 클래스를 쉽고 안전하게 만들 수 있어요.</p>
<p>관련된 상수들을 그룹화하여 구조화된 데이터를 정의할 수 있어요. <code>Rank.THREE</code>나 <code>Suit.HEARTS</code>는 각각 카드에서 특정 숫자와 문양을 의미하기 때문에 직관적이고, 선언된 상수 중에서 선택해야 하기 때문에(그렇지 않은 경우 컴파일 에러) 안전성도 높아져요.</p>
<p>또한, Rank 비교를 위한 메서드를 구현하는 등 메서드를 추가하여 더욱 객체지향에 어울리는 코드를 작성할 수 있어요.</p>
<h1 id="enum-특징">Enum 특징</h1>
<p>예제를 보며 살펴본 Enum의 특징을 정리해보려고 해요.</p>
<h2 id="enum-상수는-reference-타입">Enum 상수는 reference 타입</h2>
<p>앞서 말한 것처럼 Java의 Enum 상수들은 일반적인 상수가 아닌 <code>reference</code> 타입이에요. 이는 각 Enum 상수가 고유한 <code>인스턴스</code>이므로, 각각의 상수가 서로 다른 인스턴스처럼 작동할 수 있음을 의미해요.</p>
<h2 id="싱글톤-패턴-적용">싱글톤 패턴 적용</h2>
<pre><code class="language-java">public enum Season {
    SPRING, SUMMER, FALL, WINTER
}

public class Main {
    public static void main(String[] args) {
        Season season1 = Season.SPRING;
        Season season2 = Season.SPRING;

        System.out.println(season1 == season2); // 결과는 true, 같은 인스턴스를 참조해요.
    }
}</code></pre>
<p>Enum은 그 자체로 <code>싱글톤 패턴</code>이 적용되어 있어요. 즉, Enum의 각 상수는 애플리케이션 내에서 단 하나의 인스턴스만 생성돼요. Enum은 자바의 ClassLoader에 의해 클래스 로드 시점에 초기화되므로, 상수별 인스턴스는 정적이고 불변성을 가지게 돼요.</p>
<p>특정 Enum 상수인 <code>Season.SPRING</code>을 여러 번 호출하더라도 동일한 인스턴스를 반환해요.</p>
<h3 id="싱글톤-어떻게-보장하는데">싱글톤 어떻게 보장하는데?</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/47eaeed6-65ab-4640-bd54-4c3bdfc8de70/image.png" alt=""></p>
<p><a href="https://docs.oracle.com/javase/specs/jls/se21/html/jls-8.html#jls-8.9">공식 문서</a>에서 만약 Enum 클래스를 명시적으로 인스턴스화하려고 하면 컴파일 오류가 발생한다고 설명하고 있어요.</p>
<blockquote>
<p>Java에서 Enum은 상속이 제한되어 있어 익명 클래스의 슈퍼클래스로 사용할 수 없어요. 또한, Enum은 항상 final로 정의되어 새로운 하위 클래스를 만들 수 없어요. 이 때문에 Enum은 인스턴스 생성 시 &quot;freely extensible&quot;한 클래스나 인터페이스로 간주되지 않으며, 이를 슈퍼클래스로 사용할 수 없다는 <code>컴파일 제한</code>이 존재해요. - <a href="https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html#jls-15.9.1">출처</a></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/99b45707-0803-4084-8121-aeb051464161/image.png" alt=""></p>
<ul>
<li>Enum의 clone 메서드는 <strong>파이널(final)</strong>로 설정되어 있어, enum 상수를 절대 복제할 수 없어요.</li>
<li>리플렉션을 사용한 enum 클래스의 인스턴스화가 금지되어 있습니다.</li>
<li>직렬화(Serialization) 시에도 특별한 처리가 적용되어, 직렬화와 역직렬화 과정에서 enum 상수의 복제본이 결코 생성되지 않습니다.</li>
</ul>
<p>즉, 인스턴스화를 시도하면 컴파일 오류를 발생시키며, 위와 같은 방법들을 함께 사용하여 하나의 인스턴스만 존재할 수 있도록 보장한다고 해요.</p>
<h2 id="분리된-네임-스페이스">분리된 네임 스페이스</h2>
<p>Enum을 사용할 때, 각 상수는 서로 독립적인 네임 스페이스를 가져요. Enum 내부의 상수들이 서로 독립적이며 충돌이 일어나지 않는다는 이야기에요.</p>
<pre><code class="language-java">public enum Direction {
    NORTH, SOUTH, EAST, WEST
}

public enum Status {
    NORTH, SOUTH, RUNNING, STOPPED
}</code></pre>
<p>서로 다른 Enum인 <code>Direction</code>과 <code>Status</code>에서 각각 NORTH와 SOUTH라는 상수를 정의하였지만, 서로 다른 Enum이므로 충돌이 일어나지 않아요. 이를 통해 코드의 모듈화를 높이고 충돌 가능성을 줄일 수 있어요.</p>
<h2 id="상속된-메서드들">상속된 메서드들</h2>
<p>Enum은 자바의 <code>java.lang.Enum</code> 클래스를 상속받아 여러 메서드를 상속받아요.</p>
<h3 id="name">name()</h3>
<p>Enum 상수의 이름을 정확히 반환하는 메서드에요. name() 메서드는 상수 이름을 코드에 작성된 그대로 반환하므로, 상수 이름이 변경되지 않는 한 일관된 결과를 제공해요.</p>
<p>일반적으로는 <code>toString()</code> 메서드를 사용하는 것이 권장되지만, name() 메서드는 정확한 상수 이름을 가져와야 하는 경우 사용할 수 있어요. toString() 메서드는 각 상수의 사용자 친화적인 이름을 반환할 수 있도록 재정의할 수 있지만, name() 메서드는 오버라이드할 수 없고 항상 상수의 정확한 이름을 반환해요.</p>
<pre><code class="language-java">public enum Color {
    RED, GREEN, BLUE;

    @Override
    public String toString() {
        return &quot;Color: &quot; + name().toLowerCase();
    }
}

public class Main {
    public static void main(String[] args) {
        Color color = Color.RED;

        System.out.println(&quot;name(): &quot; + color.name());         // &quot;RED&quot;
        System.out.println(&quot;toString(): &quot; + color.toString()); // &quot;Color: red&quot;
    }
}</code></pre>
<h3 id="values">values()</h3>
<p>Enum에 정의된 모든 상수를 배열로 반환하는 메서드에요.</p>
<pre><code class="language-java">public enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

public class Main {
    public static void main(String[] args) {
        for (Day day : Day.values()) {
            System.out.println(day);
        }
    }
}</code></pre>
<h3 id="valueofstring-name">valueOf(String name)</h3>
<p>문자열로 Enum 상수를 찾을 때 사용하는 메서드에요. 일치하는 상수를 반환하고, 일치하는 상수가 없으면 <code>IllegalArgumentException</code>을 발생시켜요.</p>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) {
        Day day = Day.valueOf(&quot;MONDAY&quot;);
        System.out.println(day); // MONDAY
    }
}</code></pre>
<p>Enum의 name() 반환값과 같은 문자열을 넣어야 상수를 가질 수 있어요.</p>
<h3 id="ordinal">ordinal()</h3>
<p>각 상수의 <strong>순서(0부터 시작)</strong>를 반환하는 메서드에요. Enum 상수 선언 순서에 따라 인덱스 값을 가지며, 이 값은 고정적이에요.</p>
<pre><code class="language-java">public enum Command {
    GO, EXIT, RUN, COPY
}

public class Main {
    public static void main(String[] args) {
        System.out.println(Command.GO.ordinal());   // 0
        System.out.println(Command.RUN.ordinal());  // 2
    }
}</code></pre>
<p>사용이 단순하기 때문에 어디선가 조건으로 사용할 수 있을 것 같지만, 제 생각에는 명확히 Enum을 조건으로 사용하는 것이 좋을 것 같아요. 예를 들어, <code>EXIT</code>와 <code>RUN</code> 상수 사이에 <code>WRITE</code>라는 명령어가 새롭게 추가된다면, 순서에 의존하던 부분에서 변경이 필요할 수 있기 때문이에요.</p>
<p>따라서 순서에 의존하는 코드보다는 Enum 상수 자체에 의존하는 코드로 작성하는 편이 유지보수하기에 좋을 것 같아요.</p>
<h1 id="마무리">마무리</h1>
<p>공식 문서를 참고하면서 직접 Enum을 사용해 본다면, Java 공식 문서에서 말하는 강력한 기능을 더욱 잘 다루게 될 것 같아요. 단순히 장단점을 비교하는 데 그치지 않고, Enum이 제공하는 기능들을 이해하고 적용해 보면서 더 나은 코드를 작성하는 데에 도움이 되셨으면 좋을 것 같아요.</p>
<p>학습 후 Enum을 효과적으로 적용한 블로그를 찾아보시면 더 깊이 이해하고 체화할 수 있을 것 같아요.</p>
<p>감사합니다.</p>
<h2 id="필요에-따라-학습하면-좋을-것-같은">필요에 따라 학습하면 좋을 것 같은...</h2>
<ul>
<li>열거형 클래스는 모두 직렬화 가능하며 직렬화 메커니즘에 의해 특별한 처리를 받습니다. 열거형 상수에 사용되는 직렬화된 표현은 사용자 정의할 수 없어요. <a href="https://docs.oracle.com/en/java/javase/21/docs/specs/serialization/serial-arch.html#serialization-of-enum-constants">공식 문서</a></li>
<li>Enum을 지원하기 위한 <a href="https://docs.oracle.com/en/java/javase/15/docs/api/java.base/java/util/EnumSet.html">EnumSet</a>과 <a href="https://docs.oracle.com/en/java/javase/15/docs/api/java.base/java/util/EnumMap.html">EnumMap</a>이 있어요.</li>
<li>Enum의 인스턴스 생성은 <code>thread-safe</code>할까?</li>
<li>Enum에는 무제한으로 상수를 생성할 수 있을까? (feat. 메모리)</li>
</ul>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://docs.oracle.com/javase/specs/jls/se21/html/jls-8.html#jls-8.9">Enum Classes Spec - Oracle Docs</a></li>
<li><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Enum.html">Enum - Oracle Docs</a></li>
<li><a href="https://docs.oracle.com/javase/1.5.0/docs/guide/language/enums.html">Java 5 Enums Guide - Oracle Docs</a></li>
<li><a href="https://blogs.oracle.com/javamagazine/post/how-to-make-the-most-of-java-enums">How to make the most of Java enums - Michael Kölling</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[우아한테크코스 7기 프리코스 2주 차 [BE]]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-2%EC%A3%BC-%EC%B0%A8-BE</link>
            <guid>https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-2%EC%A3%BC-%EC%B0%A8-BE</guid>
            <pubDate>Wed, 30 Oct 2024 03:25:33 GMT</pubDate>
            <description><![CDATA[<h1 id="2주-차-공통-피드백">2주 차 공통 피드백</h1>
<blockquote>
<p>메타인지를 위한 최고의 도구 중 하나는 회고입니다. 회고를 통해 우리는 학습과 경험을 그냥 지나치지 않고 반성하고 개선할 수 있습니다. - 프리코스 2주 차 공통 피드백 中</p>
</blockquote>
<p>벌써 프리코스의 절반이 지나갔습니다. 미션이 마감되고 제출된 많은 PR을 구경하다 보면 새로운 방법이 떠오르기도 하고, 걱정이 많아지기도 합니다.</p>
<p>그래도 주도적으로 부족한 부분을 학습하는 시간이 늘었고, 다양한 지원자분들과 관점을 공유하거나 제가 작성한 코드를 어떻게 이해했는지 리뷰를 남겨주셔서 많은 부분을 돌아보게 된 것 같습니다.</p>
<h1 id="2주-차-미션-중요하게-생각한-부분">2주 차 미션 중요하게 생각한 부분</h1>
<blockquote>
<p>저는 다양한 의견을 듣고 배울 기회를 소중히 여기며, 함께 성장하고 싶습니다.</p>
</blockquote>
<p>프리코스 2주 차에는 공감되는 피드백을 학습하고 생각해 보는 것을 가장 중요하게 생각했습니다. 1주 차 미션의 목표가 <code>함께 성장할 준비</code>였고, 피드백을 주고받으며 이를 제 것으로 흡수하는 과정에서 많은 성장을 할 것으로 기대했기 때문입니다.</p>
<h2 id="코드-리뷰-돌아보기">코드 리뷰 돌아보기</h2>
<p>코드 리뷰를 정리해 보고 공감되는 부분을 찾아 학습하고 이를 적용하려고 했습니다.</p>
<h3 id="구분자-관련">구분자 관련</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/8bf17e8f-be09-40f3-b23c-d00c60e5b107/image.png" alt=""></p>
<p>1주 차 미션에서 구분자를 객체로 다루고 기본 구분자와 사용할 수 없는 구분자를 enum으로 관리하도록 구현했습니다. 덕분에 구분자 규칙을 명확하게 확인할 수 있었고, 큰 변경 없이 새로운 구분자를 추가할 수 있게 되었습니다. </p>
<p>많은 분이 제 의도를 알아주어서 많이 좋았습니다. 앞으로도 더 명확하게 코드의 의도를 전달해야겠다고 생각했습니다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/1fe1c00d-d63c-426c-8024-09a2acff11a6/image.png" alt=""></p>
<p>그래서 2주 차 미션에서 자동차의 움직임 여부를 결정하는 로직을 하나의 패턴으로 분리했습니다. 움직임 전략 인터페이스에는 움직일 수 있는지 확인하는 하나의 메서드만 존재하고 만들려는 규칙에 맞게 움직임 전략 구현체를 만들어 사용하도록 설계했습니다. 덕분에 기본 규칙에 따라 <code>무작위 값을 기준으로 자동차가 움직이는가?</code>를 테스트하는 것이 아니라 <code>특정 전략에 맞춰서 자동차가 움직이는가?</code>를 테스트할 수 있게 되어 더 깊은 부분까지 테스트 코드를 작성해볼 수 있었습니다. </p>
<p>다른 사람이 보더라도 의도를 쉽게 파악할 수 있게 작성하려고 하고, 그 부분이 전달되는 것을 기대하고 있습니다. (네이밍과 코드 컨벤션 등)</p>
<hr>
<h3 id="검증-책임-위치">검증 책임 위치</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/54484757-2d57-45f6-b48e-1c51514531a9/image.png" alt=""></p>
<p>지금까지 저는 해당 객체가 스스로 검증하고 생성되도록 설계하는 것을 좋아했습니다. 런타임 시점에 생성된 모든 객체가 모두 검증된 상태임을 보장하고, 코드가 안정적으로 동작할 수 있다고 생각했습니다.</p>
<p>저와 같은 의견을 가지신 분도 있지만, 약간의 우려를 말씀해 주신 분도 계셨습니다. 특히, 검증 로직이 많아질수록 객체의 역할이 묻히는 느낌을 받게 되고, 검증 로직이 여러 곳에 흩어져서 한눈에 파악하기 어렵다는 것이 문제가 될 수 있다는 것을 인지하게 되었습니다.</p>
<hr>
<p>여러 방법을 직접 경험하고 더 좋다고 판단되는 것을 고를 수 있는 개발자가 되고 싶었습니다. 그래서 2주 차 미션에서는 검증 로직을 한 곳으로 모아서 책임지는 객체를 만들어보기로 했습니다.</p>
<p>2주 차 미션에서 자동차 경주를 위해 신청서를 작성하는 것처럼 신청폼이라는 개념을 코드에 도입하려고 했습니다. 덕분에 한번에 검증 로직을 파악할 수 있었고, 검증 로직이 한 곳으로 모여있으니 테스트 코드에서도 각 객체의 역할과 책임을 잘 표현할 수 있었습니다. </p>
<p>다만, 검증되지 않은 객체가 런타임 동안에 생성되는 것을 물리적으로 막을 수는 없기 때문에 이 부분에 대해서 고민이 필요한 것 같습니다. (검증 로직 중복을 막기 위해 입력값 검증 이후 각 객체는 별도의 검증 없이 검증된 입력값을 사용하는 방식으로 구현했습니다.)</p>
<hr>
<h3 id="단일-책임-원칙">단일 책임 원칙</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/6f7ab105-a076-433a-acff-e93b401dbf1c/image.png" alt=""></p>
<p>위 질문에 대한 답변입니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/9c19e5e6-9541-4285-99a9-4d8a35b3d407/image.png" alt=""></p>
<p>서비스를 왜 만들게되었는지는 설명할 수 있었으나, 피드백 이후 다시 읽어보니 관리하는 책임이 많다고 생각했습니다.</p>
<hr>
<p>그래서 이번에는 단일 책임 원칙을 의식하며 지키려고 노력했습니다. 이 과정에서 테스트 코드를 작성하는 것이 가장 많은 도움이 되었습니다. 테스트 케이스를 고민하다가 필드에 따라 너무 많은 경우의 수가 필요하거나, 중간 과정이 아닌 큰 메서드 하나의 결과만 확인할 수 있는 경우에 여러 개의 책임을 가진 것은 아닌지 생각할 수 있는 중요한 지표였습니다.</p>
<p>초기 자동차 경주는 라운드 진행(시도한 횟수만큼), 움직임 전략에 따라 이동 명령(라운드마다), 경주 기록 반환 등 다양한 역할을 모두 수행하고 있었습니다. 그래서 객체의 필드(상태)가 많아지게 되었고, 메서드 분리만으로는 복잡성을 줄이기 쉽지 않았습니다.</p>
<blockquote>
<h3 id="필드상태가-많을-때-발생하는-문제-학습-내용-요약">필드(상태)가 많을 때 발생하는 문제 (학습 내용 요약)</h3>
<ul>
<li>필드가 많으면 코드의 복잡도가 증가하고 이해하기 어려워집니다.</li>
<li>필드 값 조합을 모두 고려해야 하므로 테스트가 어려워집니다.</li>
<li>여러 필드가 얽혀 있어 단일 책임을 구현하기 어렵고, 특정 필드와 관련된 기능만 수정하려 해도 다른 필드와의 연관성 때문에 쉽게 수정하기 힘듭니다.</li>
</ul>
</blockquote>
<p>이 문제를 해결하고자, 라운드 관리와 경주 기록 반환의 책임을 가진 객체로 분리했습니다. 이렇게 구조를 나누면서 필요한 부분만 수정하거나 테스트하기가 한층 수월해졌습니다. 예를 들어, 경주 기록에 시간 정보를 추가해야 할 경우, 경주 기록을 담당하는 객체와 기록 객체(DTO)만 수정하면 구현할 수 있습니다.</p>
<p>이 문제를 해결하기 위해 <code>라운드 관리</code>와 <code>경주 기록 반환</code>의 책임을 각각 독립된 객체로 분리했습니다. 이렇게 구조를 나누면서 특정 부분만 수정하거나 테스트하기가 훨씬 편해졌습니다. 예를 들어, 라운드 관리 객체에서는 라운드를 정해진 횟수만큼 정확히 진행할 수 있는지에 집중하여 테스트할 수 있었습니다. (이전 구조에서는 자동차와 경주 전체를 생성하고, 라운드 진행과 경주 기록 반환을 한 번에 테스트해야 했던 번거로움이 있었습니다.)</p>
<hr>
<h3 id="놓쳤던-부분">놓쳤던 부분</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/63954b26-f775-4033-b46b-17db355fe275/image.png" alt=""></p>
<p>마지막으로 생각하지 못했거나 놓쳤던 부분에 대한 피드백입니다. 다른 지원자분의 꼼꼼한 리뷰로 알 수 있게 되었습니다. 문제가 될 수 있는 부분을 보완하고, 제출 전 요구 사항을 다시 한번 검토하는 과정으로 실수를 줄이려고 했습니다.</p>
<h2 id="공통-피드백">공통 피드백</h2>
<p>저는 그동안 <code>구현할</code> 기능 목록을 작성할 때 미리 모든 상황을 꼼꼼히 고려해 설계한 후 그대로 따르는 방식을 선호했습니다. 그러나 예상보다 시간이 많이 소요되었고, 정해둔 틀에 갇혀 자유롭게 수정하기가 어렵다는 느낌을 받았습니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/dc8bdf23-d614-4fd4-8772-741f23e3d1c5/image.png" alt=""></p>
<p>2주 차 공통 피드백에서 구현할 기능 목록을 죽은 문서가 아닌 살아있는 문서로 유지하도록 지속적인 업데이트가 필요하다는 피드백에 큰 공감을 했습니다.</p>
<p>더 나은 프로그램을 만들기 위해 주기적으로 기능 목록 문서를 확인하고 필요한 경우 변경하려고 합니다.</p>
<h1 id="돌아보기">돌아보기</h1>
<p>2주 차 미션에서는 1주 차 미션에서 받은 피드백을 분석하고 이를 학습하거나 적용하려고 했습니다.</p>
<h2 id="코드-리뷰">코드 리뷰</h2>
<p>프리코스 커뮤니티를 활용해 상호 코드 리뷰를 진행했습니다. 미션을 수행하면서 놓쳤던 부분을 알게 되면서 부끄럽다는 생각도 했지만, 한편으로는 먼저 알게되고 다음 미션을 수행하기 전에 미리 생각해 볼 수 있게 되어 다행이라고 생각했습니다.</p>
<p>다양한 의견을 접하는 것은 장점이지만, 리뷰 요청이 제한 없이 쌓일 때는 리뷰의 질이 떨어진다는 점도 알게 되었습니다. 제한된 시간 내에 성장에 집중하려면 피드백하는 시간을 더 체계적으로 관리하는 것이 필요하다고 생각했습니다. 그래서 코드 리뷰 인원의 제한을 두거나 중점적으로 봐주면 좋을 부분을 어필한다면 더욱 도움 되는 의견을 많이 받을 수 있을 것 같습니다.</p>
<p>추가적으로 단순히 해결 방법을 제시하기보다 문제의 원인을 꼼꼼하게 설명해 주는 것이 서로에게 더욱 의미 있는 학습 기회를 제공한다는 사실을 다시 한번 느꼈습니다.</p>
<h2 id="테스트-코드">테스트 코드</h2>
<p>1주 차 미션에서는 단순히 &quot;이런 입력이 들어오면 이러한 결과가 나온다.&quot;, &quot;특정 입력이 주어졌을 때 예외가 발생한다.&quot;와 같이 프로그램의 실행과 결과만을 테스트했습니다.</p>
<p>이후 프리코스 1주 차 공통 피드백을 참고하여 테스트 방법을 학습했고, 각 객체가 역할을 잘 수행하고 있는지를 확인하는 테스트를 작성해 봤습니다. 만약 테스트하기가 너무 어렵다거나 디테일한 테스트를 작성할 수 없는 경우 분리를 고민했던 것 같습니다.</p>
<p>이를 통해 느낀 테스트 코드 작성의 큰 장점은, 객체가 너무 많은 책임을 가지고 있는지 아닌지를 파악할 수 있는 좋은 기준이 되었다는 점입니다.</p>
<h1 id="마무리">마무리</h1>
<p>앞으로도 다양한 사람과 피드백을 주고받으며 더 나은 선택을 할 수 있도록 학습하고 적용할 예정입니다.</p>
<p>감사합니다.</p>
<p><a href="https://github.com/woowacourse-precourse/java-racingcar-7/pull/1294">2주 차 미션</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[우아한테크코스 7기 프리코스 1주 차 [BE]]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-1%EC%A3%BC-%EC%B0%A8-BE</link>
            <guid>https://velog.io/@hyeok_1212/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-7%EA%B8%B0-%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-1%EC%A3%BC-%EC%B0%A8-BE</guid>
            <pubDate>Thu, 24 Oct 2024 08:24:17 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>미션을 수행할 때 어떤 접근 방법으로 무엇을 중요하게 생각하며 해결했는지 스스로 돌이켜보는 시간을 가질 계획입니다.</p>
</blockquote>
<h1 id="어떻게-생각했는가">어떻게 생각했는가</h1>
<p>미션을 수행할 때 어떤 접근 방법으로 무엇을 중요하게 생각했는지 작성하려고 합니다. 순서의 의미는 없고 키워드별로 나열해보려고 합니다.</p>
<h2 id="경쟁자랑-함께-성장하라고요">경쟁자랑 함께 성장하라고요?</h2>
<p>프리코스 과정은 <a href="https://story.baemin.com/6193/"><code>소프트웨어 생태계에 선한 영향력을</code></a> 이란 비전을 가진 우아한테크코스에 들어가기 전 성장 과정을 경험하는 공간이기 때문에 많은 것을 경험하고 나누고 싶었습니다. 또한, 부담 없이 질문할 수 있는 프로그래머라는 목표를 가진 저에게는 큰 어려움 없이 비전에 공감했습니다.</p>
<ul>
<li>토론하기, 함께 나누기, 코드 리뷰 등 적극적으로 확인하고 가능하면 참여하려고 했습니다.</li>
</ul>
<h2 id="규칙-정하기">규칙 정하기</h2>
<blockquote>
<h3 id="프리코스-진행-방식">프리코스 진행 방식</h3>
</blockquote>
<ul>
<li>기능 요구 사항에 기재되지 않은 내용은 스스로 판단하여 구현한다.</li>
</ul>
<p>위 요구 사항을 보고, 커스텀 구분자에 대한 규칙을 미리 정해두는 것이 추후 변경을 줄이는 데 도움이 될 것이라 판단했습니다. 여러 경우의 수를 고려하여 다음과 같은 규칙을 세웠습니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/99a1dd1f-3d23-4e82-8cfd-ef612d3eea7c/image.png" alt=""></p>
<p>구분자는 단일 문자만 사용할 수 있으며, 수학적 기호는 제외하도록 했습니다. </p>
<p>수학적 기호를 배제한 이유는 문자열 덧셈 계산기라는 특성 때문입니다. 예를 들어, <code>&quot;5-2&quot;</code>라는 문자열을 입력하면 뺄셈을 기대할 수 있지만, 실제 계산 결과는 7이 나와 혼란을 줄 수 있습니다. 이러한 혼동을 방지하기 위해 널리 사용되는 수학적 기호들은 커스텀 구분자에서 제외하는 규칙을 세웠습니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/9a02b71a-662a-4ab9-b17a-e63303b8293d/image.png" alt=""></p>
<p>구분자는 단일 문자로 타입이 같고, 카테고리컬하게 묶을 수 있는 특성을 활용하기 위해 enum으로 관리하도록 했습니다.</p>
<ul>
<li>기본 구분자 유형 : 기본으로 사용할 수 있는 구분자 목록</li>
<li>지원되지 않는 구분자 유형 : 사용할 수 없는 구분자 목록 (+ 사유)</li>
</ul>
<h2 id="프로젝트-구조">프로젝트 구조</h2>
<p>1주 차 <code>문자열 덧셈 계산기</code> 미션을 진행하며, 저는 &quot;객체지향은 실제 사물의 동작을 모방하는 것&quot;이라고 생각했습니다. 그래서 일상에서 볼 수 있는 <code>진짜 계산기</code>를 참고하여 구현했습니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/402bcb49-658f-44bf-8359-d2500f35c87c/image.png" alt=""></p>
<pre><code class="language-bash">project/
├── service/ # 사용자가 볼 수 없는 부분
│   ├── command/ # 사용자 입력과 관련된 패키지
│   ├── expression/ # 계산할 식과 관련된 패키지
│   └── separator/ # 구분자와 관련된 패키지
├── view/ # 사용자가 볼 수 있는 부분
├── util/ # 현재는 상수를 관리
└── Application.java # 계산기 어플리케이션</code></pre>
<p>각 패키지마다 가져야 할 역할과 책임을 쉽게 이해할 수 있었습니다.</p>
<h2 id="객체로-다뤄보기">객체로 다뤄보기</h2>
<p>이번 미션에서 사용자 입력은 단순한 <code>문자열</code> 형태로 주어졌습니다. 처음에는 이 문자열을 검증하거나 필요한 정보를 추출하는 과정이 반복되면서 코드에 중복이 발생했습니다. 검증 로직이 곳곳에 흩어지다 보니 클래스의 책임이 불명확해지고, 기능을 수정할 때마다 여러 부분에서 이미 수행된 검증을 고려해야 하는 번거로움이 있었습니다. </p>
<blockquote>
<p>특히, 문자열은 자체적으로 특별한 메서드를 가지지 않기 때문에, 원하는 값을 얻기 위해서 외부에서 작업해줘야 합니다. 이 때문에 사용자의 입력값인 문자열은 수동적인 존재로 다뤄집니다.</p>
</blockquote>
<p>이 문제를 해결하기 위해, 저는 사용자 입력을 객체로 다루기로 방향을 잡았습니다. 사용자 입력을 Command 객체로 만들고, 구분자(Separator)와 계산할 식(Expression)을 객체로 분리하여 다루기 시작했습니다.</p>
<p>예를 들어, 입력 문자열이 <code>“//$\n1$2$3”</code>일 경우, 빈 문자열 검증, 커스텀 구분자 확인, 기본 구분자와의 구분 등 다양한 규칙을 문자열 기반으로 일일이 처리해야 했습니다. 그런데 이를 구분자 객체로 만들면서 생성자에서 유효성 검증을 하도록 했습니다. 즉, 생성된 구분자(인스턴스)는 언제나 유효한 구분자임을 보장했습니다. 또한, 구분자 객체 스스로가 자신의 역할을 수행할 수 있도록 여러 메서드를 제공함으로써 코드가 훨씬 명확해졌습니다.</p>
<p>결과적으로, 여러 개념을 객체로 분리한 덕분에 검증과 추출 로직의 중복이 줄어들었고, 다른 객체로 데이터를 전달할 때 검증된 값만을 제공할 수 있었습니다.</p>
<h2 id="깃-활용하기">깃 활용하기</h2>
<p>깃 커밋 메시지도 다른 사람에게 의도를 전달할 수 있는 좋은 방법이라고 생각했습니다. 컨벤션을 미리 지정하여 각 순간마다 어떤 변경이 일어났는지 파악하기 쉽게 전달하려고 노력했습니다.</p>
<p>또한, 브랜치를 활용하여 여러 구현 방법을 시도해보고 적용하거나 롤백(<del>아닌 것 같으면 폐기</del>)하는 방식으로 도전적이지만, 코드는 안전하게 관리하려고 했습니다.</p>
<h2 id="배운-것을-공유하기">배운 것을 공유하기</h2>
<p><a href="https://velog.io/@hyeok_1212/Java-Record-%EC%82%AC%EC%9A%A9%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94">Java record 포스팅</a> 미션을 수행하며 학습한 record에 대해서 학습한 내용을 공유했습니다.</p>
<h2 id="생성형-ai-토론">생성형 AI 토론</h2>
<blockquote>
<p>저는 프리코스 미션을 진행할 때 생성형 AI의 사용을 지양하려고 합니다. 평소에 생성형 AI는 빠르고 편리하게 해결책을 제시하지만, 문제를 해결하면서 직접 고민하고 선택하는 과정이 없어진다는 느낌을 받았습니다.</p>
</blockquote>
<p>위 문장은 제가 학습 목표로 작성했던 내용 중 일부입니다.</p>
<p>그러나, 프리코스 커뮤니티에서 <code>GPT, 어디까지 활용해야 할까요?</code> 토론 채널에서 생성형 AI 사용한 공부 방식에 관한 다양한 사람의 생각을 볼 수 있었습니다. 저와 같은 맥락의 생각을 가진 분들도 있었고, 구글 검색을 하는 키워드 위주로 적극적인 사용을 하시는 분들도 있었습니다. 저는 단지, 생성형 AI는 빠른 시간안에 구현과 문제 해결을 도와주기 때문에 지양하려는 마음이 있었지만, 그것을 해치지 않으면서, 시간을 아끼는 효율적으로 학습에 사용할 수도 있겠다고 느꼈습니다.</p>
<p>문제가 발생한 부분에서 방향을 빠르게 잡는 용도로 사용하거나, 모르는 문법 등을 빠르게 학습하기 위해 사용하는 것은 성장에 도움이 될 수 있겠다고 생각했습니다. 물론 최종 테스트에서는 사용할 수 없으니, 그냥 안된다고 사용하는 것이 아니라 이것 또한 근거를 가지고 도구로 활용한다면, 의존도를 줄이고, 더욱 효율적인 성장을 할 수 있겠다고 생각했고, 근거를 가지고 다음 미션을 위한 학습에 사용해 보려고 합니다.</p>
<h2 id="매일-기록">매일 기록</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/31ac6437-612c-400a-aa77-b3dee1a2869c/image.png" alt=""></p>
<p>매일 학습 내용을 기록하는 이유는 두 가지입니다. </p>
<ul>
<li>스스로 잘하고 있는지 확인하기 위해서</li>
<li>왜 내가 이렇게 구현했는지를 명확하게 기억해두기 위해서</li>
</ul>
<p>단순히 학습한 내용을 기록하는 것이 아니라, 어떤 이유로 그 선택을 했는지를 함께 적어두고, 시간이 지나도 그 의도와 과정을 떠올릴 수 있게 하려고 했습니다.</p>
<h2 id="가독성-및-피드백">가독성 및 피드백</h2>
<blockquote>
<p>저는 다양한 의견을 듣고 배울 기회를 소중히 여기며, 함께 성장하고 싶습니다.</p>
</blockquote>
<p>이러한 목표를 가진 저에게는 더 나은 피드백을 받는 것이 중요했습니다. <code>가는 말이 고와야 오는 말이 곱다</code>는 속담처럼, 제가 작성한 코드의 의도를 명확히 전달해야 더 깊고 유의미한 피드백을 받을 수 있을 것이라 생각했습니다. 이를 위해 PR 페이지에 의도를 작성하거나, 메서드명을 명확하고 통일성 있게 작성하려고 노력했습니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/8eb41b47-4650-4a8b-a11c-38b24315df2d/image.png" alt=""></p>
<p>이후 프리코스 커뮤니티의 <code>서로 리뷰하기</code> 채널에 홍보하고, 깊고 의미있는 피드백을 주고받을 수 있었습니다.</p>
<ul>
<li>서비스의 역할(객체의 역할), 메서드 분리, 검증 로직의 위치, 테스트 코드, 예외 메시지 등 다양한 지원자의 의견을 듣고 배울 수 있어서 좋았습니다.</li>
</ul>
<h1 id="돌아보기">돌아보기</h1>
<p>좋은 리뷰를 작성하는 일은 생각보다 어렵다는 것을 느꼈습니다. </p>
<p>같은 문제를 다루더라도 지원자마다 접근 방식이나 해결 방법이 달라지기 때문에, 리뷰어가 그 의도를 온전히 파악하지 못할 때도 있는 것 같습니다. 리뷰를 잘 받기 위해서는, 제가 작성한 코드의 의도를 더욱 명확히 표현하는 것이 중요하다는 점을 깨달았습니다. 코드에 대한 설명을 조금 더 신경 써서 남기면, 의도에 대한 깊이 있는 피드백을 받을 가능성이 높아지고, 그로 인해 더 나은 코드로 개선할 기회를 얻을 수 있을 것이라 기대하고 있습니다.</p>
<p>또한, 제가 다른 지원자의 코드를 리뷰할 때도 마찬가지로, 피드백을 명확하고 도움이 되도록 남기는 것이 매우 중요하다는 것을 느꼈습니다. 다른 사람의 PR을 구경하다 보면 &quot;나도 이렇게 배려 넘치는 리뷰를 남기고 싶다&quot;는 생각이 많이 들었습니다. 이를 위해서는 단순히 코드의 오류를 지적하는 데 그치지 않고, 해당 코드의 의도와 설계 방식에 대해 고민하고, 지원자 스스로 학습할 수 있는 환경을 조성하려는 노력이 필요하다는 것을 알게 되었습니다.</p>
<p>특히, 다른 사람의 코드 리뷰를 작성할 때는 코드의 맥락을 충분히 이해하고, 그 코드가 어떤 문제를 해결하려는지, 그리고 왜 그렇게 구현했는지를 고려하여 일관적인 피드백을 제공하는 것이 중요하다고 생각합니다. 그 과정에서 저 역시 새로운 시각을 배우고 성장할 기회가 있다는 점이 있기 때문입니다. 앞으로는 더욱 주도적으로 리뷰에 참여하여, 서로의 발전에 기여할 수 있는 유의미한 피드백을 남기는 것이 목표입니다. 다만, 너무 많은 지원자들과 코드 리뷰를 주고받으면 시간이 꽤 많이 소요되기 때문에 인원 수에 제한을 두는 방법도 좋을 것 같습니다.</p>
<p>결국, 좋은 리뷰를 남기기 위해서는 단순한 코드 확인을 넘어서서 문제 해결 과정과 의도를 이해하는 것이 필수적이며, 더 나은 피드백을 주고받는 경험이 지원자 모두의 성장에 큰 도움이 될 것이라 생각합니다.</p>
<p>함께 리뷰를 주고받은 분들께 감사합니다!</p>
<p><a href="https://github.com/woowacourse-precourse/java-calculator-7/pull/1103">문자열 덧셈 계산기 PR</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] Record 사용하시나요?]]></title>
            <link>https://velog.io/@hyeok_1212/Java-Record-%EC%82%AC%EC%9A%A9%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94</link>
            <guid>https://velog.io/@hyeok_1212/Java-Record-%EC%82%AC%EC%9A%A9%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94</guid>
            <pubDate>Sat, 19 Oct 2024 10:46:30 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/e60d22fc-affd-43ef-82ca-61f020494b81/image.png" alt=""></p>
<p><code>IntelliJ IDEA</code>에서 Java 클래스를 작성하다 보면, 특정 조건에 해당할 때 클래스를 <strong>레코드(record)</strong>로 변경하라는 제안을 받을 때가 있어요. 이 제안을 수락하면 기존의 클래스가 간단한 record로 변경되며, 코드가 더 깔끔해지는 것을 볼 수 있어요.</p>
<p>특히 데이터를 의미 있게 전달하는 목적으로 작성된 클래스일수록, 이 제안을 많이 받아요. 저는 IntelliJ의 제안을 대부분 수락하는 편이긴 하지만, record에 대해 아는 정보가 없었기에 어떤 효과가 있을지 예상하기 어려워서 제안을 무시하는 경우가 많았어요.</p>
<p>이번 글에서는 미루고 있던 Record에 대해서 알아보려고 해요.</p>
<h2 id="record-개요">Record 개요</h2>
<p>Java 14에서 <a href="https://openjdk.org/jeps/359">프리뷰</a>로 처음 소개된 <strong>레코드(Record)</strong>는, 데이터를 저장하고 이를 쉽게 접근할 수 있는 메서드를 자동으로 생성해주는 특별한 클래스에요.</p>
<p><a href="(https://openjdk.org/jeps/395)">Java 16</a>부터는 정식 기능으로 추가되었어요.</p>
<p>레코드를 검색해보면, <code>불변(immutable)</code>, <code>데이터 클래스</code>, <code>간결함</code> 같은 키워드를 쉽게 찾을 수 있어요. 레코드는 데이터를 간단하고 효율적으로 다룰 수 있게 설계된 특징을 가지고 있다고 해요.</p>
<blockquote>
<h3 id="불변-객체의-장점">불변 객체의 장점</h3>
</blockquote>
<p>불변 객체는 상태 변경이 불가능하여 멀티스레드 환경에서의 안전성을 높이고, 데이터 무결성을 유지하며, 코드의 가독성과 유지보수성을 향상시키는 장점이 있어요.</p>
<h2 id="탄생-배경">탄생 배경</h2>
<blockquote>
<p>레코드가 왜 필요하게 되었는지를 이해하면, 더 깊이 있고 목적에 맞게 활용할 수 있을 거예요.</p>
</blockquote>
<p>레코드의 탄생 배경을 알고 싶다면 <a href="https://openjdk.org/projects/amber/design-notes/records-and-sealed-classes">Data Classes and Sealed Types for Java 문서</a>를 보면 좋을 것 같아요. 여기서 레코드 같은 데이터 클래스가 왜 필요하게 되었는지를 알 수 있어요.</p>
<p>아래와 같이 배경을 요약할 수 있어요.</p>
<h3 id="java의-장황함-boilerplate-코드">Java의 장황함, Boilerplate 코드</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/5aabb0b9-f4a1-46c0-80c4-2c94355cc1ff/image.png" alt=""></p>
<p>Java는 &quot;너무 장황하다&quot;는 비판을 자주 받아요. 특히 단순히 데이터를 전달하기 위한 클래스에서 이러한 문제가 더 부각돼요. 단순한 데이터 클래스를 작성할 때도 생성자(constructor), 접근자(getter), equals(), hashCode(), toString() 같은 메서드를 반복해서 작성해야 해요.</p>
<pre><code class="language-java">final class Point {
    public final int x;
    public final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    // 생성자, 접근자, equals(), hashCode() 등 반복적인 코드 작성 필요
}</code></pre>
<p>이런 반복 작업은 실수를 유발하기 쉬운 부분이기도 해서, 개발자들이 종종 생략하거나 실수하는 경우가 많아요. 그 결과 예기치 않은 동작이 발생하거나 디버깅이 복잡해질 수 있어요.</p>
<blockquote>
<h3 id="boilerplate-코드">Boilerplate 코드?</h3>
</blockquote>
<p>주로 반복되는, 그 자체로는 비즈니스 로직이나 핵심 기능을 나타내지 않고, 프레임워크, 라이브러리, 언어 등의 특정 규약을 따르기 위한 코드를 말해요. 예를 들어, 생성자, getter, setter, toString() 등이 있어요. 이러한 코드는 귀찮고, 예외가 발생하기 쉬운 곳이에요.</p>
<h3 id="데이터-클래스의-필요성-나만-없어">데이터 클래스의 필요성, 나만 없어..</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/242be329-af4b-453e-917c-396a06537d1d/image.png" alt=""></p>
<p>다른 객체지향 언어들(예: Scala의 case class, C#의 record class 등)은 데이터 지향 클래스를 더 간결하게 표현할 수 있는 문법을 도입했어요. 이러한 클래스들은 클래스 헤더에 상태를 간단하게 정의할 수 있고, 이에 따라 생성자나 접근자, equals(), hashCode() 같은 메서드들이 자동으로 제공돼요.</p>
<pre><code class="language-java">record Point(int x, int y) { }
// 생성자, 접근자, equals(), hashCode() 등 자동으로 생성</code></pre>
<p>이 코드만 봐도 객체가 두 개의 정수 필드(x, y)를 가진다는 것을 바로 알 수 있어요. 또한, 필수적인 Object 메서드들이 자동으로 올바르게 구현되니, 장황한 boilerplate 코드를 작성할 필요도 없어요.</p>
<blockquote>
<p>요약하면, 레코드는 객체의 의도를 더 명확히 드러내고, 불필요한 코드 작성을 줄여 가독성을 높여줘요. 객체 지향 철학에 맞춰 데이터를 간결하게 표현하는 방식을 제공하면서, 개발자가 불변 데이터를 모델링하는 데 집중할 수 있게 돕는 것이에요.</p>
</blockquote>
<h2 id="record-구조">Record 구조</h2>
<p>레코드는 <code>record</code> 키워드로 선언하며, 클래스와 유사한 구조를 가져요.</p>
<pre><code class="language-java">{ClassModifier} record TypeIdentifier [TypeParameters] RecordHeader [ClassImplements] RecordBody</code></pre>
<ul>
<li><code>ClassModifier</code> : 클래스의 접근 제어자 및 기타 수정자(예: public, private 등)</li>
<li><code>TypeIdentifier</code> : 레코드의 이름으로, 대문자로 시작하는 것이 관례에요.</li>
<li><code>TypeParameters</code> : 제네릭 타입 매개변수를 사용할 경우 선언해요.</li>
<li><code>RecordHeader</code> : 레코드에 포함될 필드들을 선언해요.</li>
<li><code>ClassImplements</code> : 필요한 경우 구현할 인터페이스를 지정해요.</li>
<li><code>RecordBody</code> : 레코드의 메서드나 기타 구성 요소를 작성할 수 있어요.</li>
</ul>
<h3 id="예시">예시</h3>
<pre><code class="language-java">public record Point(int x, int y) { }</code></pre>
<p>x와 y라는 두 개의 필드를 가진 Point 레코드를 선언했어요. 이 레코드는 <code>불변(immutable)</code>이며, 생성자, 접근자, equals(), hashCode(), toString() 같은 메서드들이 자동으로 생성돼요. (일반 클래스처럼 <code>RecordBody</code>에서 재정의할 수 있어요.)</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/fd4591e6-dd29-476a-8b70-3d526276e9f4/image.png" alt=""></p>
<p>자동으로 생성되는 메서드들의 세부 구현 정보는 <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Record.html">공식 문서</a>에서 확인할 수 있어요.</p>
<h3 id="자동-생성-눈으로-확인하기">자동 생성 눈으로 확인하기</h3>
<p>직접 작성하지 않아도 자동으로 생성되는 메서드를 사용할 수 있지만, 이 메서드들은 코드에서 직접 보이지 않아요. 이를 확인하기 위해 javap 명령어를 사용할 수 있어요.</p>
<p>아래의 명령어를 실행하면 컴파일된 <code>.class 파일</code>에서 자동 생성된 메서드들의 시그니처를 확인할 수 있어요.</p>
<pre><code class="language-bash">$ javap -p build/classes/java/main/recordtest/Point.class</code></pre>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/1aba21d8-de46-486c-b04f-e3696a88f569/image.png" alt=""></p>
<p>어떤 메서드가 자동으로 생성되었는지 눈으로 확인할 수 있어요.</p>
<h2 id="레코드의-특징">레코드의 특징</h2>
<ul>
<li><p>레코드 클래스는 암묵적으로 <code>final</code>로 선언되며, 상속이 불가능해요. 이는 레코드가 값 집합을 간단하게 표현하는 데 초점을 맞추고 있음을 의미해요. 따라서<code>abstract</code>, <code>sealed</code>, <code>non-sealed</code>와 같은 수식어는 사용할 수 없어요.</p>
</li>
<li><p>다른 클래스를 상속 받을 수 없지만, 인터페이스 구현은 가능해요.</p>
</li>
<li><p><code>RecordHeader</code>에 선언된 필드는 final로 정의되어 있으며, 레코드의 생성자에서만 초기화할 수 있어요. 즉, setter를 통해 필드 값을 변경할 수 없어요.</p>
</li>
</ul>
<h2 id="레코드-생성자">레코드 생성자</h2>
<p>레코드 클래스는 <code>기본 생성자</code>를 제공하지 않아요. 대신, 모든 필드(컴포넌트 필드)를 초기화하는 <strong>정식 생성자(Canonical Constructor)</strong>를 암묵적 또는 명시적으로 선언해야 해요. 정식 생성자는 <code>Normal Canonical Constructors</code>와 <code>Compact Canonical Constructors</code>로 나뉘어요.</p>
<h3 id="기본-생성자">기본 생성자?</h3>
<pre><code class="language-java">// 기본 생성자 예시
class Person {
    // 필드가 있지만 생성자를 정의하지 않음
    String name;
    int age;

    // 컴파일러가 자동으로 아래와 같은 기본 생성자를 추가함
    // Person() { }
}

// Person 객체 생성
Person p = new Person();  // 기본 생성자 호출 가능</code></pre>
<p>자바에서 클래스를 선언할 때 생성자를 정의하지 않으면 컴파일러가 자동으로 기본 생성자를 추가해요. 하지만 레코드 클래스는 기본 생성자를 제공하지 않아요.</p>
<h3 id="normal-canonical-constructors-정식-생성자">Normal Canonical Constructors (정식 생성자)</h3>
<pre><code class="language-java">record Point(int x, int y) { }

record Point(int x, int y) {
    // 정식 생성자
    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

Point p = new Point(10, 20);</code></pre>
<p>레코드 클래스는 <code>정식 생성자</code>를 제공해요. 정식 생성자는 레코드의 모든 필드를 초기화하는 생성자로, 레코드 객체를 생성할 때 사용돼요. </p>
<p>레코드는 데이터 클래스로서 불변성을 유지하기 위해 모든 필드를 초기화하는 생성자를 제공한다고 볼 수 있어요. (나중에 추가 및 변경이 불가능해요.)</p>
<h3 id="compact-canonical-constructors-컴팩트-생성자">Compact Canonical Constructors (컴팩트 생성자)</h3>
<pre><code class="language-java">record Point(int x, int y) {
    // 컴팩트 생성자, 필드를 나열하지 않음
    Point {
        validate(x, y);
    }

    static void validate(int x, int y) {
        if (x &lt; 0 || y &lt; 0) {
            throw new IllegalArgumentException(&quot;x와 y는 0보다 작을 수 없습니다.&quot;);
        }
    }
}

Point p = new Point(-1, 1); // 검증 메서드로 예외가 발생함</code></pre>
<p><code>컴팩트 생성자</code>는 더 간결한 형태의 생성자 선언 방식이에요. 필드를 별도로 나열하지 않고, 레코드의 필드가 암묵적으로 선언돼요. 이 생성자는 주로 매개변수를 검증하거나 값을 정규화하는 로직만을 포함하고, 나머지 초기화는 컴파일러에 의해 자동으로 처리돼요.</p>
<ul>
<li>두 생성자 방식 중 하나만 사용할 수 있으며, 둘 다 명시적으로 정의할 경우 컴파일 오류가 발생해요.</li>
<li>생성자의 접근 제어자는 레코드 클래스의 접근 제어자와 일치해야 하며, 생성자를 명시하지 않으면 컴파일러가 자동으로 정식 생성자를 추가해요.</li>
</ul>
<blockquote>
<p>생성자 방식만 보더라도, 레코드가 데이터 클래스로서 불변성을 유지하고자 하는 의도를 느낄 수 있어요.</p>
</blockquote>
<h2 id="레코드의-필드-사용">레코드의 필드 사용</h2>
<p>레코드의 필드 값은 한 번 설정되면 변경할 수 없어요.</p>
<p>레코드의 필드는 자동으로 생성된 <code>접근자 메서드</code>를 통해 접근할 수 있으며, 이 메서드는 전통적인 getter 메서드 명명 규칙인 getX() 대신에 <code>필드명을 메서드 이름으로 사용</code>해요. </p>
<p>모든 레코드 필드는 <code>final</code>로 선언되어 있어, setter를 통해 값을 변경할 수 없어요. 즉, 레코드는 선언과 동시에 해당 필드들의 불변성을 보장해요.</p>
<pre><code class="language-java">record Point(int x, int y) { }

public class Main {
    public static void main(String[] args) {
        // 레코드 인스턴스 생성
        Point p = new Point(10, 20);

        // 자동 생성된 접근자 메서드를 통해 필드에 접근
        System.out.println(p.x());  // 10
        System.out.println(p.y());  // 20

        // 필드가 final이므로 값을 변경할 수 없음 (setter가 없음)
        // p.x = 30;  // 컴파일 에러 발생
    }
}</code></pre>
<p>예시에서 볼 수 있듯이, 레코드를 사용하면 필드의 불변성을 유지하면서도 간편하게 데이터에 접근할 수 있어요.</p>
<h2 id="레코드-사용-시-주의사항">레코드 사용 시 주의사항</h2>
<h3 id="1-상속-불가">1. 상속 불가</h3>
<p>레코드는 <code>final</code>로 선언되어 있어 상속이 불가능해요. 즉, 레코드를 기반으로 하는 서브 클래스를 만들 수 없어요. 상속이 필요한 경우, 일반 클래스를 사용하는 것이 바람직해요.</p>
<h3 id="2-비즈니스-로직-포함에-대한-경고">2. 비즈니스 로직 포함에 대한 경고</h3>
<p>레코드는 기본적으로 <code>불변성</code>을 유지해야 하는 데이터 객체에요. 이로 인해 비즈니스 로직을 레코드 내부에 포함시키는 것은 권장되지 않는다고 해요. 비즈니스 로직을 레코드에 포함시키면 객체의 불변성이 깨질 수 있으며, 예상치 못한 부작용을 초래할 수 있어요.</p>
<p>예를 들어, Spring Data JPA를 사용할 때 Entity를 record로 선언하는 것이 좋을까?를 고민해보면 좋을 것 같아요.</p>
<h3 id="3-자동-생성된-메서드의-사용">3. 자동 생성된 메서드의 사용</h3>
<p>레코드는 equals(), hashCode() 등 필요한 메서드를 자동으로 생성해요. 이러한 메서드들이 필드의 값을 기반으로 생성되기 때문에 필드가 적절히 정의되어 있어야 하며, 각 메서드들이 어떻게 구현되어 있는지 이해하고 있는 것이 중요해요. 만약 제대로 이해하지 않고 사용하면, 예상치 못한 결과가 발생할 수 있어요.</p>
<p>예를 들어, HashSet에 레코드를 추가할 때 hashCode()와 equals()의 동작을 잘못 이해하면 중복된 객체가 삽입되거나, 특정 값만을 기준으로 비교하여 중복으로 처리되는 등의 원치 않는 동작이 발생할 수 있어요. 따라서 레코드를 사용할 때는 이러한 자동 생성 메서드의 동작 원리를 충분히 이해하고 활용하는 것이 중요해요.</p>
<h3 id="4-java-버전-호환">4. Java 버전 호환</h3>
<p>레코드는 Java 14에서 프리뷰 기능으로 도입되었고, Java 16부터는 정식 기능으로 포함되었어요. 이전 버전의 Java를 사용하고 있다면 레코드 기능을 사용할 수 없으므로, 레코드를 도입하기 전 해당 애플리케이션이 어떤 Java 버전을 사용하는지 확인해야 해요.</p>
<h2 id="마무리">마무리</h2>
<p>레코드는 주의사항을 고려하여 의도에 맞게 사용한다면 많은 장점을 누릴 수 있어요. 특히 가독성과 불변성을 제공하여, 코드를 더욱 명확하고 안정적으로 만들어요.</p>
<p>제가 느낀 가장 큰 장점 중 하나는 다른 개발자에게 <code>불변 데이터를 담기 위한 객체</code>라는 의도를 명확하게 전달할 수 있다는 점이에요.</p>
<p>레코드를 사용하기 전에는 사용 여부를 신중하게 검토하고, 관련된 주의사항을 충분히 이해하는 것이 중요하며, 이러한 과정을 거친다면 레코드를 효과적으로 사용할 수 있을 것 같아요.</p>
<p>추가적으로 <a href="https://www.baeldung.com/java-record-vs-lombok">Java Record vs Lombok 포스팅</a>도 읽어보면 좋을 것 같아요!</p>
<p>긴 글 읽어주셔서 감사합니다!</p>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://openjdk.org/projects/amber/design-notes/records-and-sealed-classes">Data Classes and Sealed Types for Java - Brian Goetz</a></li>
<li><a href="https://openjdk.org/jeps/359">Record Java 14 Preview</a></li>
<li><a href="(https://openjdk.org/jeps/395)">Record Java 16</a></li>
<li><a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Record.html">Record - Oracle Docs</a></li>
<li><a href="https://www.baeldung.com/java-record-keyword">Java Record Keyword - Baeldung</a></li>
<li><a href="https://www.baeldung.com/java-record-vs-lombok">Java Record vs Lombok - Baeldung</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[서버 모니터링(감시)하기]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%84%9C%EB%B2%84-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81%EA%B0%90%EC%8B%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hyeok_1212/%EC%84%9C%EB%B2%84-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81%EA%B0%90%EC%8B%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 15 Oct 2024 17:58:22 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 모니터링 환경을 구성해보려고 해요.</p>
<h1 id="자꾸-서버가-꺼져요">자꾸 서버가 꺼져요</h1>
<p>이전에 진행했던 <a href="https://github.com/ProjectBARO/BARO-Server">BARO 프로젝트</a>에서 사용자의 자세를 측정하기 위한 <code>AI 분석 서버</code>를 따로 배포한 적이 있었어요. 당시에는 배포 주기도 다르고, 실행 특성(분석이 오래 걸려요)도 달랐기에 따로 배포했어요. 그러나, 분석 API를 여러 번 사용하면 AI 서버가 계속 종료되었어요. AI 모델의 연산은 많은 자원을 요구하는데, 당시 사용하던 서버의 사양이 부족한가? 라고 생각해서 <code>free -m</code> 명령어로 메모리 사용량을 확인했었고, 단순히 메모리가 부족하다고 판단해 메모리가 더 큰 서버로 바꿨던 경험이 있어요. 원활하게 실행 가능한 성능 기준 없이 일부 정보에만 의존해 자원을 늘렸다고 볼 수 있을 것 같아요.</p>
<p>이처럼 명확한 기준 없이 서버의 성능을 결정한다면, 아무리 좋은 코드라도, 자원 부족으로 실행에 어려움이 생기거나 사용하지 않는 자원에 불필요한 비용을 지불해야 할 위험이 있어요. 이러한 문제를 방지하기 위해서는 모니터링이 필요할 수 있어요.</p>
<h1 id="모니터링">모니터링?</h1>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/93374af7-be64-4bcb-a332-99c9c4b7b477/image.png" alt=""></p>
<blockquote>
<p>서울지방경찰청은 모니터링 강화로 CCTV 감시를 통한 범인 검거가 지난해보다 3배 가까이 늘었다고 14일 밝혔다. 서울시내 25개 CCTV 관제센터 근무자를 전문성이 있는 경찰관으로 교체하는 등 3월부터 관제센터 기능을 &#39;범죄억제&#39;에서 &#39;예방·검거&#39;로 전환한 데 따른 것으로 분석된다. - <a href="https://www.yna.co.kr/view/AKR20150514084000004">출처</a></p>
</blockquote>
<p><code>모니터링</code>이라는 단어는 일상생활에서도 자주 접할 수 있어요. 사용되는 분야마다 의미는 조금씩 다르지만, 대부분의 경우 <code>잠재적인 문제를 감시</code>하고 <code>발생하는 상황을 관찰</code>하는 역할을 수행해요.</p>
<p>애플리케이션 모니터링의 주 목적은 서비스의 안정성을 향상시키는 것이에요. 사용자가 원활한 서비스를 제공받기 위해 시스템은 항상 안정적으로 운영되어야 해요. 모니터링을 통해 애플리케이션에서 발생하는 다양한 동작을 기록하고 성능을 분석하여 최적화할 수 있으며, 실시간으로 발생하는 문제를 신속하게 파악하고 해결할 수 있어요.</p>
<p><a href="https://youtu.be/75X_eBW0mog?si=ULshPufNZG9IK-Lu">Go lang 도입, 그리고 4년 간의 기록 - 변규현, 당근마켓 | GopherCon Korea 2023 (6분 52초)</a> 컨테이너 환경에서 원치 않는 CPU Throttling을 겪고 이를 해결한 이야기를 들은 적도 있어요. 만약 모니터링이 없었다면, 이처럼 빠르게 문제로 인식하고 해결 방안을 찾을 수 없었을 거에요.</p>
<p>또한, 최근에 아래와 같은 포스팅들을 읽으며 모니터링의 중요성을 체감할 수 있었어요.</p>
<ul>
<li><a href="https://velog.io/@koomin1227/%EC%82%AC%EC%9A%A9%EC%9E%90-1000%EB%AA%85%EC%97%90-DB%EC%9D%98-CPU-%EC%82%AC%EC%9A%A9%EB%A5%A0%EC%9D%B4-90%ED%8D%BC%EA%B0%80-%EB%84%98%EB%8A%94%EB%8B%A4%EA%B3%A0">사용자 1000명에 DB의 CPU 사용률이 90퍼가 넘는다고? - koomin</a></li>
<li><a href="https://velog.io/@qjvk2880/Temp-Title">이상 행동 사용자의 데이터 시각화하기 - 우기</a> </li>
</ul>
<h2 id="메트릭">메트릭</h2>
<p>메트릭(metric)은 측정 가능한 데이터를 나타내는 지표를 의미해요. 메트릭을 잘 수집하면 시스템의 현재 상태를 손쉽게 파악할 수 있으며, 성능을 분석하거나 개선점을 찾을 때 용이해요. 메트릭의 종류는 매우 다양하며, 상황에 따라 필요한 메트릭을 선택적으로 수집할 수 있어요.</p>
<p>다음은 일반적으로 수집하는 메트릭의 예시에요.</p>
<ul>
<li><code>호스트 단위 메트릭</code> : CPU 사용률, 메모리 사용량, 디스크 I/O 등</li>
<li><code>종합 메트릭</code> : 데이터베이스 성능, 캐시 성능 등 시스템의 특정 계층에 대한 성능 지표</li>
<li><code>핵심 비즈니스 메트릭</code> : 일별 활성 사용자 수, 수익, 재방문율 등 비즈니스 성과와 직결되는 지표</li>
</ul>
<p>모니터링을 잘 구축해두면, 자원 부족 문제나 성능 저하를 사전에 파악하고 해결할 수 있어, 더 나은 서비스 운영이 가능해요.</p>
<h3 id="맥북-활성-상태-보기">맥북 활성 상태 보기</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/5e6496a4-b0e3-44de-b6cf-71d823824cd5/image.png" alt=""></p>
<p>맥북의 <code>활성 상태 보기</code>도 모니터링 도구의 일종이며, CPU 사용량, 메모리 사용량, 디스크 활동 등을 실시간으로 확인할 수 있어요. 이런 데이터가 <code>메트릭(metric)</code>이에요.</p>
<h2 id="prometheus">Prometheus</h2>
<p><a href="https://prometheus.io/">Prometheus</a>는 오픈 소스 모니터링 시스템으로, 서버, 애플리케이션 등 다양한 서비스의 메트릭을 수집하고 분석하는 데 사용돼요.</p>
<p>특히, Prometheus는 주기적으로 모니터링 대상(서버)에게 HTTP 요청을 보내서 메트릭을 가져오는 <code>Pull 방식</code>을 사용해요. 이는 Prometheus가 언제 데이터를 수집할지 통제할 수 있다는 장점을 가지고 있어요. Prometheus는 메트릭 수집과 저장뿐만 아니라 이를 기반으로 한 알람, 시각화, 쿼리 기능도 제공한다고 해요. </p>
<p>지원되는 메트릭 유형은 <a href="https://prometheus.io/docs/concepts/metric_types/">공식 문서</a>에서 확인할 수 있어요.</p>
<blockquote>
<h3 id="pull-vs-push-방식">Pull vs Push 방식</h3>
<p>모니터링 시스템에는 Pull 방식과 Push 방식이라는 두 가지 방식이 존재해요.</p>
</blockquote>
<ul>
<li><code>Push 방식</code> : 메트릭이 발생하는 대상이 직접 메트릭 수집 시스템으로 데이터를 보내요. 애플리케이션이 자체적으로 발생한 메트릭을 모니터링 시스템에 전달하는 방식이에요.</li>
<li><code>Pull 방식</code> : 메트릭 수집 시스템이 주기적으로 메트릭을 수집해요. Prometheus는 주기적으로 서버나 애플리케이션에 HTTP 요청을 보내 데이터를 수집해요.</li>
</ul>
<h2 id="actuator">Actuator</h2>
<p>Actuator는 Spring Boot 애플리케이션의 상태를 모니터링하고 관리할 수 있는 다양한 기능을 제공하는 모듈이에요. 애플리케이션의 메트릭, 상태 확인, 환경 정보 등을 손쉽게 노출할 수 있는 엔드포인트를 제공해요.</p>
<p>특히, Prometheus와 같은 모니터링 시스템과 통합할 수 있게 메트릭 엔드포인트를 제공해요. 이 엔드포인트를 통해 애플리케이션 내부에서 발생하는 다양한 메트릭(예: CPU 사용량, 메모리 상태, HTTP 요청 처리 시간 등)을 Prometheus가 주기적으로 가져갈 수 있게 설정할 수 있어요.</p>
<p>Spring Boot에서 Actuator를 활성화하려면, <code>spring-boot-starter-actuator</code> 의존성을 추가하고, 메트릭 엔드포인트를 노출시키는 설정이 필요해요.</p>
<h2 id="grafana">Grafana</h2>
<p>Grafana는 Prometheus와 함께 자주 사용되는 오픈 소스 데이터 시각화 도구예요. Grafana는 Prometheus에서 수집한 메트릭 데이터를 시각화하여 대시보드 형태로 제공해요.</p>
<p>Grafana는 다양한 데이터 소스를 지원하며, Prometheus는 그중 하나예요. Grafana를 사용하면 실시간 데이터를 기반으로 한 대시보드를 생성하여 서버 성능, 애플리케이션 상태, 비즈니스 메트릭 등을 한 곳에서 모니터링할 수 있어요.</p>
<h3 id="주요-기능">주요 기능</h3>
<ul>
<li><code>대시보드 생성</code> : 다양한 차트, 그래프, 패널을 이용해 사용자 정의 대시보드를 만들 수 있어요.</li>
<li><code>알람 설정</code> : 특정 조건에 맞는 알람을 설정하여, 시스템 이상 발생 시 즉각적으로 알림을 받을 수 있어요.</li>
<li><code>다양한 데이터 소스 지원</code> : Prometheus 외에도 MySQL, Elasticsearch, AWS CloudWatch 등 여러 소스에서 데이터를 가져와 시각화할 수 있어요.</li>
</ul>
<p>Grafana와 Prometheus를 연동하면, Prometheus에서 수집한 메트릭을 Grafana의 직관적인 대시보드를 통해 시각적으로 확인할 수 있어 모니터링의 효율성을 크게 높일 수 있어요.</p>
<h1 id="실험">실험</h1>
<p><a href="https://velog.io/@hyeok_1212/%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%B4%EC%9A%94">이전 글</a>에서는 개발 서버 배포 자동화를 구축했어요. 이 서버에 모니터링을 적용할 수 있도록 유사하게 테스트해보려고 해요.</p>
<h2 id="메트릭-구성">메트릭 구성</h2>
<p>저는 <a href="https://woo-chang.tistory.com/78">포스팅</a>을 참고하여 테스트를 진행했어요.</p>
<pre><code class="language-java">implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
implementation &#39;org.springframework.boot:spring-boot-starter-actuator&#39;
implementation &#39;io.micrometer:micrometer-registry-prometheus&#39;</code></pre>
<p>스프링 부트 애플리케이션에 웹 의존성, 메트릭 노출을 위한 Actuator와 메트릭 수집을 위한 Micrometer 의존성을 추가해요.</p>
<pre><code class="language-yml"># application.yml
spring:
  application:
    name: test

management:
  endpoints:
    web:
      exposure:
        include: prometheus, health, info
  metrics:
    tags:
      application: ${spring.application.name}</code></pre>
<p>application.yml에 위와 같은 설정을 추가해요.</p>
<p><code>management.endpoints.web.exposure.include</code> : 특정 엔드포인트(prometheus, health, info)를 외부로 노출하는 설정이에요.
<code>metrics.tags</code> : 메트릭에 태그를 추가하여 추가적인 정보를 기록하고, 해당 태그로 메트릭을 구분할 수 있어요. 여기서는 application이라는 태그를 추가하고 spring.application.name(test)를 값으로 사용하도록 했어요.</p>
<p>지원되는 엔드포인트는 <a href="https://docs.spring.io/spring-boot/reference/actuator/endpoints.html#actuator.endpoints">공식 문서</a>에서 확인할 수 있어요.</p>
<blockquote>
<p>스프링 부트 애플리케이션은 8080번 포트에서 실행되지만, 클라이언트는 Traefik에 의해 80번 포트를 통해 접근할 수 있어요.</p>
</blockquote>
<p>애플리케이션 실행 후 <code>http://localhost/actuator</code>에 접속하면 현재 액추에이터가 제공하는 엔드포인트 목록을 확인할 수 있어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/7d306321-5eaf-405d-a0b4-89a52e6601c0/image.png" alt=""></p>
<p>원하는 엔드포인트에 접속함으로 원하는 메트릭 데이터를 확인할 수 있어요. 프로메테우스에 메트릭을 제공하기 위한 엔드포인트인 <code>http://localhost/actuator/prometheus</code> 도 확인할 수 있어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/513caad0-f8a7-449d-80ca-f051425cc5aa/image.png" alt=""></p>
<h2 id="prometheus-설정">Prometheus 설정</h2>
<pre><code class="language-yml"># prometheus.yml
global:
  scrape_interval: 15s # Prometheus가 메트릭을 수집하는 주기 (기본 값은 1분)
  scrape_timeout: 15s # 메트릭 수집 요청이 타임아웃되기까지의 최대 시간 (기본 값은 10초)
  evaluation_interval: 2m # AlertRule과 같은 규칙을 검증하는 주기 (기본 값은 1분)

  external_labels: # 메트릭에 추가할 외부 레이블 정의
    monitor: &#39;system-monitor&#39; # 모든 메트릭에 monitor: &#39;system-monitor&#39;라는 레이블이 추가
  query_log_file: query_log_file.log # Prometheus의 쿼리 로그를 저장할 파일의 이름

rule_files:
  - &quot;rule.yml&quot; # 적용할 규칙 파일의 경로

scrape_configs: # 여러 개의 스크랩 구성 정의 가능
  - job_name: &quot;prometheus&quot; # 스크랩할 작업의 이름
    static_configs:
      - targets: # Prometheus 메트릭을 수집할 대상
          - &quot;prometheus:9090&quot; # Prometheus 서버의 주소 prometheus:9090
  - job_name: &quot;springboot&quot; # 스크랩할 작업의 이름
    metrics_path: &quot;/actuator/prometheus&quot; # 메트릭을 수집하기 위해 Prometheus가 요청할 엔드포인트 경로
    scheme: &#39;http&#39;
    scrape_interval: 5s # 메트릭을 수집하는 주기
    static_configs:
      - targets:
          - &quot;{도커 컨테이너 서비스 이름}:8080&quot; # Spring Boot 애플리케이션이 실행되고 있는 Docker 컨테이너의 서비스</code></pre>
<p>실제로 아래와 같이 로그 파일이 생성돼요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/5925f664-fbe7-4e2b-9b79-4b5a112f9a4f/image.png" alt=""></p>
<pre><code class="language-yml"># rule.yml
groups: # 알림 규칙들을 묶는 그룹, 각 그룹은 여러 개의 규칙을 포함 가능
  - name: system-monitor
    rules:
      # InstanceDown 알림은 인스턴스가 5분 이상 다운된 경우(up == 0) 트리거되며,
      # 심각도는 page로 설정되고, 요약과 설명에는 다운된 인스턴스와 관련된 세부 정보가 포함
      - alert: InstanceDown
        expr: up == 0
        for: 5m
        labels:
          severity: page
        annotations:
          summary: &quot;Instance {{ $labels.instance }} down&quot;
          description: &quot;{{ $labels.instance }} of job {{ $labels.job }} has been down for more than 5 minutes.&quot;

      # APIHighRequestLatency 알림은 
      # API 요청의 중앙값 지연 시간이 1초를 초과하는 상태가 10분간 계속될 경우 트리거되며,
      # 요약과 설명에는 해당 인스턴스의 지연 시간 정보를 동적으로 제공
      - alert: APIHighRequestLatency
        expr: api_http_request_latencies_second{quantile=&quot;0.5&quot;} &gt; 1
        for: 10m
        annotations:
          summary: &quot;High request latency on {{ $labels.instance }}&quot;
          description: &quot;{{ $labels.instance }} has a median request latency above 1s (current value: {{ $value }}s)&quot;</code></pre>
<p>이 설정 파일은 Prometheus가 두 가지 조건에 대해 알림을 발생시키도록 구성되어 있어요.</p>
<ul>
<li><code>InstanceDown</code> : 인스턴스가 5분 이상 다운된 경우 발생해요.</li>
<li><code>APIHighRequestLatency</code> : API 요청의 중앙값 지연 시간이 1초를 초과하는 상태가 10분간 계속될 경우 발생해요.</li>
</ul>
<h2 id="컨테이너-실행">컨테이너 실행</h2>
<blockquote>
<p><a href="https://velog.io/@hyeok_1212/%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%B4%EC%9A%94">이전 글</a>에서 사용한 Traefik과 함께 구성했어요.</p>
</blockquote>
<pre><code class="language-yml"># docker-compose.yml

version: &#39;3.9&#39;

services:
  reverse-proxy:
    image: traefik:v3.1
    command:
      - &quot;--api.insecure=true&quot; # Traefik 대시보드 활성화
      - &quot;--providers.docker=true&quot; # Docker를 프로바이더로 사용
      - &quot;--entrypoints.web.address=:80&quot; # HTTP 트래픽을 처리하는 엔트리포인트
      - &quot;--entrypoints.traefik.address=:8080&quot; # Traefik 대시보드 포트
      - &quot;--api.dashboard=true&quot;
      - &quot;--log.level=INFO&quot;
      - &quot;--accesslog=true&quot;
    ports:
      - &quot;80:80&quot; # HTTP 트래픽 포트
      - &quot;8080:8080&quot;  # Traefik 대시보드 포트
    volumes:
      - &quot;/var/run/docker.sock:/var/run/docker.sock&quot;  # Docker 소켓을 Traefik에 연결
    networks:
      - traefik-test

  my-app:
    image: &quot;사용할 스프링 부트 애플리케이션 이미지&quot;
    networks:
      - traefik-test
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.my-app.rule=Host(`localhost`) &amp;&amp; (PathPrefix(`/api/v1`) || PathPrefix(`/actuator`))&quot;
      - &quot;traefik.http.services.my-app.loadbalancer.server.port=8080&quot; # my-app 내부 포트 8080으로 라우팅

  prometheus:
    image: prom/prometheus:latest
    container_name: prometheus
    volumes:
      - ./prometheus/config:/etc/prometheus
      - ./prometheus/volume:/prometheus
    ports:
      - &quot;9090:9090&quot; # Prometheus 기본 웹 포트
    command:
      - &#39;--web.enable-lifecycle&#39;
      - &#39;--config.file=/etc/prometheus/prometheus.yml&#39;
      - &#39;--web.console.libraries=/etc/prometheus/console_libraries&#39;
      - &#39;--web.console.templates=/etc/prometheus/consoles&#39;
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.prometheus.rule=Host(`localhost`) &amp;&amp; PathPrefix(`/prometheus`)&quot; # Prometheus에 대한 Traefik 라우팅 규칙
      - &quot;traefik.http.services.prometheus.loadbalancer.server.port=9090&quot; # Prometheus 서비스 내부 포트
    restart: always
    networks:
      - traefik-test

  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    ports:
      - &quot;3000:3000&quot; # Grafana 기본 포트
    volumes:
      - ./grafana/volume:/var/lib/grafana
    restart: always
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.grafana.rule=Host(`localhost`) &amp;&amp; PathPrefix(`/grafana`)&quot; # Grafana에 대한 Traefik 라우팅 규칙
      - &quot;traefik.http.services.grafana.loadbalancer.server.port=3000&quot; # Grafana 서비스 내부 포트
    networks:
      - traefik-test

networks:
  traefik-test:
    driver: bridge</code></pre>
<p>application, prometheus, grafana 컨테이너를 하나의 네트워크로 동작시키기 위한 <code>docker-compose.yml</code> 파일이에요.</p>
<h2 id="prometheus-컨테이너">Prometheus 컨테이너</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/7797687d-5c8e-490b-947c-60a124ccc888/image.png" alt=""></p>
<p>정상적으로 실행되었다면, <code>http://localhost:9090</code> 에 접속하여 위와 같은 화면을 볼 수 있어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/61368d71-a067-4825-9507-793a9210fe84/image.png" alt=""></p>
<p><code>http_server_requests_seconds_count</code>로 HTTP 요청의 총 처리 횟수를 알 수 있어요. 다른 메트릭 정보는 검색을 통해 금방 찾을 수 있어요!</p>
<p>이처럼 Prometheus에서도 메트릭 데이터를 그래프로 시각화할 수 있지만, Grafana를 통해 더욱 효과적으로 시각화할 수 있어요.</p>
<h2 id="grafana-컨테이너">Grafana 컨테이너</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/adbee9aa-3ce9-41e4-8bbd-ab0738d9ec9a/image.png" alt=""></p>
<p><code>http://localhost:3000</code>에서 동작하고 있는 그라파나에 접속하면 로그인 화면을 볼 수 있어요. 초기 아이디와 비밀번호는 <code>admin</code>으로 설정되어 있어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/eb6dcbfe-19fc-46e9-871b-eb1e54a6a755/image.png" alt=""></p>
<p>로그인 하면 위와 같은 화면을 볼 수 있는데, 우리가 원하는 메트릭 시각화를 위해 두 가지 설정이 필요해요.</p>
<ul>
<li>데이터 소스 추가</li>
<li>대시보드 생성</li>
</ul>
<h3 id="데이터-소스-추가">데이터 소스 추가</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/1b0396cb-fd7f-4cbf-a21a-0cc19c2ba7a9/image.png" alt=""></p>
<p>URL을 보면 <code>host.docker.internal</code>이라는 호스트명을 사용하고 있어요. 이는 컨테이너 내부에서 호스트 머신의 IP 주소를 가리키는 특별한 호스트명이에요. 컨테이너 내부에서 <code>host.docker.internal</code>을 사용하면 호스트 머신의 IP 주소로 변환해줘요.</p>
<h3 id="짤막한-도커-지식">짤막한 도커 지식</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/990df09a-02a1-4e49-937a-7c2c030f0a69/image.png" alt=""></p>
<p>도커 컨테이너를 실행하고 있는 <code>내 컴퓨터 입장</code>에서 Prometheus는 localhost:9090, Grafana는 localhost:3000에서 실행되고 있어요. 하지만, <code>Grafana 컨테이너 입장</code>에서는 localhost가 컨테이너 내부(자기 자신)가 되기 때문에 localhost:9090으로 외부에 있는 Prometheus 컨테이너에 접근할 수 없어요. 따라서 컨테이너 내부에서 호스트에 접근하기 위해 <code>host.docker.internal</code>을 사용해요.</p>
<p>추가적으로 <code>http://prometheus:9090</code>와 같이 Prometheus 컨테이너의 이름을 통해서도 컨테이너 내부에서 외부 컨테이너에 접근이 가능해요.</p>
<h3 id="대시보드-생성">대시보드 생성</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/552e8cd9-ea88-4b68-bd0f-c01d2290c08e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/805366c8-e8da-4edc-b297-69ff00de23ca/image.png" alt=""></p>
<p>대시보드를 직접 만들어서 커스텀하는 방법도 있지만, 누군가 잘 만들어둔 대시보드를 Import 하는 방법도 있어요.</p>
<p><code>4701</code>은 스프링 부트 메트릭을 보여주는 유명한 <code>대시보드의 ID</code>에요. 대시보드를 통해 I/O, JVM Memory, CPU, GC, Thread 등의 메트릭 데이터를 시각화해서 볼 수 있어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/1513cbee-adc0-4007-884a-a7aa3f6bf4dd/image.png" alt=""></p>
<h2 id="마무리">마무리</h2>
<p>이번 글에서는 Prometheus와 Grafana를 활용하여 모니터링 환경을 구성하는 방법을 알아봤어요. (근데 이제 Traefik을 곁들인...)</p>
<p>보기 좋은 모니터링 환경을 구성하는 것보다 각 메트릭의 의미와 상관관계를 이해하고 우선순위 및 대응 프로세스를 정해두는 것이 더 중요할 것 같아요. 단순히 메트릭 정보를 알고 있다고 해서 좋은 서비스가 되는 것은 아니기 때문이에요.</p>
<p>아래는 더 고민해 볼 내용이에요.</p>
<ul>
<li>수집된 메트릭을 어떻게 보관할 것인가?
계속 쌓아두다 보면 유의미한 정보로 발전할 수 있지 않을까요?
예를 들어, 주기적으로 데이터베이스에 저장하거나 로그 파일을 다른 곳에 백업해 두는 것도 도움이 될 것 같아요.</li>
<li>모니터링 구성을 위한 추가적인 인스턴스? 
같은 인스턴스에서 실행될 경우, 해당 인스턴스에 문제가 발생하면 모니터링 서버도 함께 종료될 수 있으며, 이는 신속한 대응이 어려워질 수 있을 것 같아요.</li>
<li>신속한 대응을 위해 알림 설정도 가능해요.
예를 들어, Slack과 같은 툴을 활용해 중요한 이벤트를 즉시 받아볼 수 있어요.</li>
</ul>
<p>긴 글 읽어주셔서 감사합니다.</p>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://woo-chang.tistory.com/78">[Spring Boot] 프로메테우스, 그라파나를 이용한 스프링 부트 모니터링 - woo&#39;^&#39;chang</a></li>
<li><a href="https://youtu.be/75X_eBW0mog?si=ULshPufNZG9IK-Lu">Go lang 도입, 그리고 4년 간의 기록 - 변규현, 당근마켓 | GopherCon Korea 2023</a></li>
<li><a href="https://velog.io/@koomin1227/%EC%82%AC%EC%9A%A9%EC%9E%90-1000%EB%AA%85%EC%97%90-DB%EC%9D%98-CPU-%EC%82%AC%EC%9A%A9%EB%A5%A0%EC%9D%B4-90%ED%8D%BC%EA%B0%80-%EB%84%98%EB%8A%94%EB%8B%A4%EA%B3%A0">사용자 1000명에 DB의 CPU 사용률이 90퍼가 넘는다고? - koomin</a></li>
<li><a href="https://velog.io/@qjvk2880/Temp-Title">이상 행동 사용자의 데이터 시각화하기 - 우기</a></li>
<li><a href="https://docs.spring.io/spring-boot/reference/actuator/endpoints.html#actuator.endpoints">Actuator endpoints - Spring Docs</a></li>
<li><a href="https://prometheus.io/docs/introduction/overview/">Prometheus Docs</a></li>
<li><a href="https://grafana.com/docs/grafana/latest/">Grafana Docs</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[배포 자동화가 필요해요]]></title>
            <link>https://velog.io/@hyeok_1212/%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%B4%EC%9A%94</link>
            <guid>https://velog.io/@hyeok_1212/%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%B4%EC%9A%94</guid>
            <pubDate>Fri, 20 Sep 2024 19:10:13 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 상황에 맞게 선택한 자동 배포 파이프라인에 대해 기록하려고 해요.</p>
<h1 id="왜-배포할까">왜 배포할까?</h1>
<p>아무리 좋은 프로그램을 만들어도 사용자들이 그것을 접할 수 없다면 의미가 없어요. 따라서 배포는 단순히 코드를 실행 가능한 상태로 변환하는 것을 넘어, <code>언제 어디서나 안정적으로</code> 접속할 수 있는 컴퓨터(서버)를 준비하고, 애플리케이션을 그 서버에서 실행시켜 <code>사용자들이 접근할 수 있도록</code> 하는 과정이에요.</p>
<h2 id="그럼-자동-배포는">그럼 자동 배포는?</h2>
<p>자동 배포가 필요한 가장 큰 이유는 <code>개발 생산성을 향상 시키는 것</code>이에요. </p>
<p>수동으로 배포를 진행하는 경우 매번 코드 수정 후 빌드, 테스트, 배포 과정을 반복해야 해요. 이는 시간이 많이 소요되고, 사람이 개입하는 과정에서 <code>휴먼 에러가 발생할 가능성도 높아져요.</code></p>
<p>자동 배포 파이프라인을 구축하면 귀찮은 작업과 위험을 크게 줄일 수 있어요. 코드가 변경될 때마다 자동으로 빌드되고, 배포까지 이루어지기 때문에 개발자는 애플리케이션 <code>로직 구현에 집중</code>할 수 있게 돼요.</p>
<p>이러한 장점으로 프로젝트 시작과 동시에 자동 배포 파이프라인을 구축한다면, 구현에 집중하여 빠르게 개발할 수 있게 돼요. </p>
<h2 id="이제-필요해요">이제 필요해요</h2>
<p>하지만, 대부분의 배포 작업은 <code>비용</code>이 발생하기 때문에 저희 팀은 조금이라도 절약하고자 배포 시기를 미뤘어요. </p>
<p>로그인 기능이 완성된 지금, 프론트엔드 팀원들도 API를 테스트하며 작업하는 환경을 원하셔서 배포가 필요하다는 것을 느꼈고, 개발 환경 배포 파이프라인을 구축하기로 했어요.</p>
<hr>
<h1 id="배포-파이프라인">배포 파이프라인</h1>
<p>완성된 배포 파이프라인 흐름은 아래와 같아요. 왜 이런 구조를 선택하게 되었는지 알아볼게요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/5f7ff321-898e-406e-9e97-1111f77e8110/image.png" alt=""></p>
<h2 id="흐름">흐름</h2>
<ol>
<li><code>develop</code> 브랜치에 새로운 작업 결과가 <code>push</code> 돼요.</li>
<li>이를 감지하여 <code>워크 플로우(cd-dev)</code>가 실행돼요. (GitHub Actions)</li>
<li>GitHub 자체 호스팅 서버(러너)로 Docker image <code>build and push</code>가 진행돼요. (Docker Hub)</li>
<li>이후 <code>self-hosted-runner</code>를 통해 배포 서버에서 나머지 작업이 수행돼요.</li>
<li>나머지 작업은 변경사항 반영 및 서버 재시작이 포함되어 있어요.</li>
</ol>
<hr>
<h2 id="브랜치-상황">브랜치 상황</h2>
<pre><code>└── main (절대 안정, 실제 릴리즈)
     └── develop (개발 환경, 새로운 기능 적용 및 테스트)
           ├── {키워드}/#이슈번호
           ├── {키워드}/#이슈번호
           ├── {키워드}/#이슈번호
           ├── {키워드}/#이슈번호
                   ...
           └── {키워드}/#이슈번호</code></pre><p>우리 팀의 브랜치 상황이에요. <code>{키워드}</code>는 feat, fix와 같이 커밋 메시지 컨벤션을 검색하면 쉽게 볼 수 있는 키워드를 의미하고, <code>#이슈번호</code>는 깃허브의 이슈 번호를 의미해요. (브랜치명 예시: <code>feat/#4</code>)</p>
<p>모든 기능 개발(및 수정) 브랜치는 <code>develop</code> 에서 시작되며, 완성되면 <code>develop</code>으로 머지된 후 완성 단계라고 생각되면 <code>main</code> 브랜치로 병합하는 흐름을 가져요.</p>
<p>우선 프론트엔드 팀원들이 빠르게 새로운 기능을 테스트하며 개발할 수 있도록 <code>develop</code> 브랜치를 기준으로 배포하기로 했어요. 이후 <code>main</code> 브랜치의 배포 파이프라인을 따로 구성하여 실제 사용자들이 사용하게 되는 서버를 구축할 예정이에요.</p>
<h2 id="간단한-요구사항">간단한 요구사항</h2>
<ul>
<li>비용이 적을 수록 좋아요.</li>
<li>자동으로 배포가 진행되어야 해요.</li>
<li>HTTPS 적용이 필요해요.</li>
</ul>
<hr>
<h1 id="어디에-배포할까">어디에 배포할까</h1>
<blockquote>
<p>배포는 <code>언제 어디서나 안정적으로</code> 접속할 수 있는 컴퓨터(서버)를 준비하고, 애플리케이션을 그 서버에서 실행시켜 <code>사용자들이 접근할 수 있도록</code> 하는 과정이에요.</p>
</blockquote>
<p>애플리케이션을 배포하기 위해서는 적절한 서버를 선택해야 해요. <del>집에는 보통 서버를 둘 수 없기 때문에</del>, 클라우드 서비스를 활용하는 것이 일반적이에요. </p>
<p>여기서는 <code>서버 vs 서버리스</code>, <code>클라우드 회사</code> 등 여러 부분에서 고민해봤어요.</p>
<h2 id="서버-vs-서버리스">서버 vs 서버리스</h2>
<p>애플리케이션을 배포할 때, 크게 <code>서버 기반 배포</code>와 <code>서버리스 배포</code>로 나눌 수 있어요.</p>
<h3 id="서버-기반-배포server-based-deployment">서버 기반 배포(Server-based Deployment)</h3>
<ul>
<li><p>서버 기반 배포는 물리적 또는 가상 서버에서 애플리케이션을 실행하는 방식이에요. <code>서버는 항상 켜져 있으며</code>, 사용자의 요청이 있을 때마다 응답할 준비가 되어 있어요.</p>
</li>
<li><p>서버는 항상 실행되어야 하므로, 대부분 일정한 비용이 발생해요.</p>
</li>
<li><p>서버 기반 배포는 애플리케이션의 구성과 환경을 세밀하게 제어할 수 있는 유연성을 제공해요. 하지만, 세밀하게 제어할 수 있는 만큼 직접 관리해야 하는 항목도 많아져요. (네트워크 설정, 보안 패치 등)</p>
</li>
<li><p>AWS의 EC2, GCP의 VM Instance, ...</p>
</li>
</ul>
<h3 id="서버리스-배포serverless-deployment">서버리스 배포(Serverless Deployment)</h3>
<ul>
<li><p>서버리스 배포는 클라우드 제공자가 서버 관리를 해주며, 필요에 따라 자동으로 확장해요.</p>
</li>
<li><p>사용한 만큼만 비용을 지불하며, 트래픽이 적을 때는 비용이 절감돼요.</p>
</li>
<li><p>비교적 <code>서버 관리에 대한 부담이 줄어들고</code>, 개발자가 애플리케이션 개발에 집중할 수 있어요. 하지만, 특정 실행 시간 제한이나 제약된 환경 설정 등의 한계가 있을 수 있어요. <code>클라우드 제공자에 대한 높은 의존성</code></p>
</li>
<li><p>AWS의 Lambda, GCP의 Cloud Functions, ...</p>
</li>
</ul>
<h3 id="선택하기">선택하기</h3>
<blockquote>
<p>아래와 같은 고민으로 <strong>서버 기반 배포</strong>를 선택했어요.</p>
<p>개발 환경에서는 우리가 고민했던 <code>비용</code>에서 큰 차이를 느끼기 어려웠고, 그렇다면 우리가 더 알아보고 싶은 <code>서버 기반 배포</code>를 선택하자!</p>
</blockquote>
<p>처음에는 개발 생산성 향상과 효율성을 위해 서버리스 배포를 고려했어요. 서버리스는 서버 관리 부담을 덜어주어 개발자들이 애플리케이션 로직에 집중할 수 있으며, 사용한 만큼만 비용을 지불하는 장점이 있어요.</p>
<p>그러나 아래와 같은 고민으로 마음을 바꾸게 되었어요.</p>
<ul>
<li><p><code>콜드 스타팅</code> : 완전 관리형 서버리스 컨테이너 서비스인 GCP의 Cloud Run을 사용한 경험이 있는데, 개발 및 테스트 과정에서 콜드 스타팅으로 인해 초기 응답 시간이 지연되는 문제가 발생했어요. 이를 해결하기 위해 최소 인스턴스 개수를 1개 이상으로 설정할 수 있었지만, 이는 결국 서버리스의 비용 효율성을 떨어뜨린다고 생각했어요. (자주 호출하여 인스턴스가 꺼지지 않게 하는 방법도 위와 동일하다고 생각했어요.)</p>
</li>
<li><p><code>크게 차이나지 않는 비용</code> : 사용한 만큼 지불하는 메리트가 있지만, 클라우드 제공자의 초기 할인 정책(AWS 프리티어, GCP의 $300 무료 크레딧 등)을 활용하면, 개발 단계에서는 두 방식 모두 무료로 구축할 수 있어요. 오히려 서버리스 배포는 클라우드 제공자가 관리를 도와주기 때문에 최소 비용은 더 높아질 수 있어요.</p>
</li>
<li><p><code>구조로 발생한 제약</code> : 특정 시간에 작업 완료 알림을 보내는 기능도 구상하고 있었기에 서버리스 구조가 제약이 될 것 같은 걱정도 있었어요.</p>
</li>
</ul>
<p>서버 기반 배포는 직접 관리할 수 있는 항목이 많아져요. 따라서 배포 환경에서 발생한 문제를 해결하는 것은 저희 팀에게 <code>좋은 성장의 기회</code>가 될 수 있다고 생각했어요. 또한, 팀원들이 <code>리눅스 환경에 익숙</code>해지며, 직접 관리하는 경험을 원했어요.</p>
<p>또한, 서버 기반 배포는 대부분 일정한 비용이 발생하므로, 예산 계획이 용이하고. 앞서 말한 것처럼 클라우드 제공자의 할인 정책을 활용하면 거의 <code>무료로 사용</code>할 수 있어요.</p>
<h2 id="클라우드-제공자">클라우드 제공자</h2>
<blockquote>
<p>우리 팀은 <strong>AWS</strong>를 선택했어요.</p>
</blockquote>
<p>대표적인 클라우드 제공자로는 <code>AWS(Amazon Web Services)</code>, <code>GCP(Google Cloud Platform)</code>, <code>OCI(Oracle Cloud Infrastructure)</code> 가 있어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/627ccc92-4a8c-4452-8a78-86777d6889d0/image.png" alt=""></p>
<p>구글 트렌드 검색 결과, <code>AWS</code>의 검색량이 다른 제공자들보다 압도적으로 높았어요. 검색량이 많다는 것은 그만큼 많은 관심을 받고 있으며, 레퍼런스도 풍부할 것이라고 생각했어요.</p>
<p>세 업체 모두 소규모 인스턴스 사용에 있어 무료 사용량이 제공되어 비용 부담은 없어요. 그러나 <code>AWS</code>는 12개월 동안 프리티어로 여러 추가 서비스(RDS 등)를 제공해요. (GCP의 무료 크레딧은 3개월만 유효해요.) 그래서 결국 <code>AWS</code>를 선택하자고 했어요. <del>OCI는 상대적으로 관심도가 적어서 포기했어요.</del></p>
<hr>
<h1 id="실행-환경-고민하기">실행 환경 고민하기</h1>
<p>이제 컴퓨터(AWS의 EC2 인스턴스 생성)를 구했으니 프로그램을 실행시켜야 해요. Spring Boot 애플리케이션을 실행시키는 방법은 간단해요. JAR 파일을 직접 실행하거나 (도커) 이미지를 통한 컨테이너를 띄우는 방식이 있어요.</p>
<blockquote>
<p>우리 팀은 <strong>컨테이너화 방식</strong>을 선택했어요.</p>
<p>팀원들은 모두 <code>Docker</code> 및 <code>docker-compose</code> 사용 경험이 있었기 때문에 러닝 커브에 대한 우려가 적은 편이었고, 모두 다른 개발 환경에서 개발하는 우리에게 <code>일관된 환경으로 실행 가능</code>의 장점이 크게 느껴졌어요. </p>
<p><del>미래에 더 좋고 무료인 서버를 사용할 수 있으면 바로 이사갈 마음을 가지고 있기 때문에</del></p>
</blockquote>
<h2 id="jar-파일-실행-방식">JAR 파일 실행 방식</h2>
<pre><code class="language-bash">java -jar application.jar</code></pre>
<p>Java 런타임만 준비되어 있다면, JAR 파일을 쉽게 실행할 수 있어요. <code>추가적인 학습이 필요하지 않고</code> 바로 배포할 수 있다는 것이 장점이에요. 하지만, Java 버전과 서버 설정 등 <code>환경에 따라</code> 애플리케이션이 제대로 작동하지 않을 가능성이 있어요.</p>
<h2 id="컨테이너화-방식">컨테이너화 방식</h2>
<p>컨테이너화는 애플리케이션과 그 의존성을 하나의 패키지로 묶어, <code>일관된 환경에서 실행할 수 있도록 하는 기술</code>이에요. Docker는 가장 널리 사용되는 컨테이너화 도구 중 하나에요.</p>
<p>컨테이너는 <code>여러 환경에서 동일하게 동작</code>하므로, 환경 차이로 인한 문제를 줄일 수 있어요. 또한, 각 컨테이너는 독립적으로 실행되므로, 애플리케이션 간의 충돌을 방지할 수 있어요.</p>
<p><code>Docker Compose</code> 와 같은 오케스트레이션 도구를 사용하여 손쉽게 여러 컨테이너를 효율적으로 관리할 수 있어요.</p>
<hr>
<h1 id="자동-배포-파이프라인">자동 배포 파이프라인</h1>
<p>이제 애플리케이션을 어디에, 어떻게 배포할 것인지 결정했기 때문에 그 과정을 자동화해야 해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/fdd33f7a-e227-4f5a-8eb8-9a95a2e961f8/image.png" alt=""></p>
<p>구글에 자동 배포를 검색하면 <code>Jenkins</code>, <code>GitHub Actions</code> 이 두개를 많이 활용한다는 것을 알 수 있어요. 직접 다 써보고 비교하기엔 시간이 오래 걸리기 때문에 ChatGPT를 활용하여 빠르게 비교해봤어요.</p>
<h2 id="gpt의-비교-및-제안">GPT의 비교 및 제안</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/ed16c722-c79f-416a-aba6-ffe860419eb8/image.png" alt=""></p>
<blockquote>
<h3 id="chatgpt-답변">ChatGPT 답변</h3>
<p>소규모 팀과 프로젝트의 경우, <strong><code>GitHub Actions가 Jenkins보다 더 적합한 선택일 가능성이 큽니다.</code></strong> GitHub Actions는 <code>설정과 관리가 간편</code>하고, <code>GitHub과의 통합</code>이 자연스러워 초기 설정 시간을 단축시킬 수 있습니다. 또한, 비용 효율성 측면에서도 소규모 프로젝트에 적합한 프리티어를 제공하므로, 팀의 리소스를 효율적으로 사용할 수 있습니다.</p>
<p>반면, Jenkins는 고유한 빌드 요구사항이나 높은 커스터마이징이 필요한 경우, 또는 자체 서버 관리에 대한 경험이 있는 팀에게 더 적합할 수 있습니다. 하지만, 소규모 팀에서는 관리 부담과 초기 설정의 복잡성을 고려할 때 GitHub Actions가 더 효율적일 것입니다.</p>
</blockquote>
<p>이미 GitHub를 활용하여 코드 관리를 하고 있기 때문에, <code>GitHub Actions</code>를 사용하여 구성 및 통합하기 편하고 설정 방법이 직관적이에요. 또한 <code>Jenkins</code> 자체는 무료지만 자체 서버 비용이 추가되며, <code>GitHub Actions</code>은 Public Repository라면 무료로 시작할 수 있어요.</p>
<blockquote>
<p>그래서 우리 팀은 <strong>GitHub Actions</strong>를 사용하기로 했어요.</p>
</blockquote>
<h2 id="github-actions">GitHub Actions</h2>
<p>구성을 위해 레퍼런스를 찾아보니 거의 대부분이 아래와 같은 흐름을 가지고 있었어요. (JAR 파일 실행 방식인 경우에는 Code Deploy와 S3를 활용한 방식이 많았어요.)</p>
<pre><code class="language-yml">name: `Actions 구분을 위한 이름`

on:
  push:
    branches: `대상 브랜치`

jobs:
  build:
    `우분투 환경에서 GitHub에 업로드한 소스 코드 사용하여 이미지 build 및 Docker Hub에 push`

  deploy:
    `ssh로 인스턴스에 접속하여 도커 이미지 pull 및 재시작 명령어 실행`</code></pre>
<p>이런 흐름을 그냥 따라가려고 했지만, 마음에 들지 않았던 두 가지 부분이 있었어요.</p>
<ol>
<li>빌드 과정에서 실행에 필요한 환경변수를 모두 넣고 이미지를 만드는 방식 (가끔 이렇게 구현하신 분이 계셨어요.)</li>
<li>GitHub Actions를 통해 인스턴스를 접근하는 방식 (인스턴스 호스트, 비밀 키 등을 GitHub Secrets에 넣어요.)</li>
</ol>
<h3 id="1-환경변수를-모두-넣고-이미지를-만드는-방식">1. 환경변수를 모두 넣고 이미지를 만드는 방식</h3>
<p>빌드 단계에서 환경변수를 Docker 이미지에 포함시키는 방식은 위험할 수 있어요. 예를 들어, 특정 서비스의 API Key가 이미지에 하드코딩된 상태로 이미지가 외부에 유출될 경우 문제가 발생할 수 있어요. </p>
<p>최근에 읽은 <a href="https://velog.io/@yeseong0412/%EB%AA%85%EB%A0%B9%EC%96%B4-%EB%AA%87%EC%A4%84%EB%A1%9C-%EB%8F%84%EC%BB%A4-%ED%95%B4%ED%82%B9%ED%95%98%EA%B8%B0">도커 이미지로 AWS 계정이 털린다? - 양예성</a> 포스팅에서도 이러한 위험성을 언급하고 있어요. 해당 포스팅을 읽고 그럴 수도 있겠다... 라는 마음으로 첫 번째 방식이 마음에 들지 않았어요.</p>
<p>하지만, 이 문제는 <code>런타임에 환경변수를 주입</code>하거나, 애플리케이션이 시작될 때 외부에서 설정을 불러올 수 있도록 <a href="https://docs.spring.io/spring-cloud-config/docs/current/reference/html/"><code>Spring Cloud Config Server</code></a>를 도입하는 방식으로 해결할 수 있어요.</p>
<h3 id="2-github-actions를-통해-인스턴스를-접근하는-방식">2. GitHub Actions를 통해 인스턴스를 접근하는 방식</h3>
<p><strong>GitHub Secrets을 믿지 못하겠다는 이야기는 아니에요.</strong></p>
<p>만약 <code>GitHub 계정이 털리거나 레포지토리가 노출될 경우</code>, 인스턴스에 대한 접근 권한이 유출될 수 있다고 생각했어요. 또한, GitHub Actions 서버가 우리 인스턴스에 접근하기 위해 <code>보호막(방화벽 규칙)을 풀어야 하는 것</code>도 보안상의 취약점이 될 수 있다고 생각해요. </p>
<p>물론 가볍게 보면 유난으로 보일 수 있지만, AWS 자격 증명 유출로 악의적인 사용자에 의해(또는 실수로) 서버 비용이 많이 나와 선처를 구하는 블로그(AWS 환불 후기 등) 글이 많은 것을 보면 중요한 문제라고 생각해요.</p>
<p>우리 팀은 이러한 보안 위협을 줄이고자 <code>Self-Hosted Runner</code>를 도입했어요.</p>
<h2 id="self-hosted-runner">Self-Hosted-Runner</h2>
<p><code>Self-Hosted Runner</code>는 GitHub Actions에서 제공하는 기본 클라우드 호스팅 환경 대신, 직접 관리하는 서버에서 워크플로우를 실행할 수 있게 해주는 기능이에요. 이를 통해 배포 환경을 더 세부적으로 제어하고, 보안을 강화할 수 있어요.</p>
<p>GitHub Repository에서 Settings &rarr; Actions &rarr; Runner 순서로 들어가서 <code>New self-hosted-runner</code> 버튼을 누르면 Runner를 생성할 수 있고, 구성 방법을 쉽고 자세하게 알려줘요. 더 자세한 내용은 <a href="https://docs.github.com/ko/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners">공식 문서</a>에서 확인할 수 있어요.</p>
<pre><code class="language-yml">name: cd-dev

on:
  push:
    branches: develop

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: &quot;17&quot;
          distribution: &quot;temurin&quot;

      - name: Sign in Dockerhub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build the Docker image
        run: docker build -t ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:latest .

      - name: Push the Docker image
        run: docker push ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:latest

  deploy:
    needs: build
    runs-on: self-hosted

    steps:
      - name: Docker Image pull
        run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:latest

      - name: Docker Compose Restart
        run: |
          cd ~/app
          sudo docker-compose down
          sudo docker-compose up -d</code></pre>
<ul>
<li><p><code>build (호스팅된 GitHub Actions 서버에서 실행)</code> :
이 과정에서는 GitHub에 업로드한 소스 코드로 Docker 이미지를 빌드하여 Docker Hub에 push하는 과정이에요.</p>
</li>
<li><p><code>deploy (Self-Hosted Runner에서 실행)</code> : 
미리 설정한 인스턴스에서 실행될 작업이에요. Docker Hub에서 최신 이미지를 pull 받고, 인스턴스의 app 디렉터리로 이동하여 미리 작성해둔 docker-compose.yml을 통해 컨테이너를 재시작 하는 과정이에요.</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/6b85eba0-f1ca-4ac2-aadc-67a49bac8185/image.png" alt=""></p>
<p>이러한 워크 플로우를 통해 빌드 및 이미지 푸시 작업을 GitHub의 호스팅 서버에서 처리하고, 배포는 <code>Self-Hosted Runner</code>를 사용해 인스턴스 자체에서 안전하게 실행돼요. </p>
<p>이제 AWS 자격 증명을 <code>GitHub Secrets에 저장할 필요가 없기 때문에</code>, 자격 증명이 외부에 노출될 확률이 낮아져요. 또한, 이전 방식에선 호스팅된 GitHub Actions 서버(외부)에서 인스턴스에 접근하기 때문에 외부에서 인스턴스로 진입할 수 있는 입구가 하나 생긴다는 단점이 있지만, <code>Self-Hosted Runner</code>는 <code>내부 네트워크에서 실행</code>되므로 문제가 생길 확률이 줄어들어요.</p>
<p>추가로 외부에서 인스턴스에 접속할 수 없도록 IP 제한이 걸린 경우 이 방식이 유용할 것 같아요. (회사 내부 IP에서만 인스턴스에 접속할 수 있는 경우..?)</p>
<blockquote>
<h3 id="주의-사항">주의 사항</h3>
<p>Self-Hosted Runner는 자체 서버에서 실행되며, Runner가 idle 상태여야 하기 때문에 서버의 리소스(CPU, 메모리 사용량 등)를 잘 확인해야 해요. 저는 우선 swap 메모리를 설정했어요.</p>
<p>Runner가 항상 원활하게 작동하도록 유지하는 것(가용성)을 위해 시스템 서비스로 등록하거나 모니터링 및 알림 설정을 할 수 있을 것 같아요.</p>
</blockquote>
<p>물론, 이렇게 하더라도 GitHub 계정에 문제가 발생한 경우에는 막기 어려울 수 있으니 계정 관리도 잘해야 해요. 아니면 외부 트리거를 설정하여 진행하거나, private 이미지 저장소를 사용하는 방법이 나은 선택이 될 수 있을 것 같아요. (비용이 발생할 수 있어요. 예시: AWS Code Pipeline, GCP Cloud Build)</p>
<h1 id="리버스-프록시">리버스 프록시</h1>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/3d0f6862-2c8d-4db3-96bf-d72a7af770a7/image.png" alt=""></p>
<p>우리 팀은 <code>traefik</code>을 사용하여 리버스 프록시를 구축했어요. </p>
<p>저는 <a href="https://github.com/traefik/traefik/">깃허브</a>를 구경하다가 우연히 알게 되었는데. 좀 찾아보니 괜찮은 도구라고 생각해서 도입하자고 제안했어요.</p>
<h2 id="træfik">træfik?</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/ddd17e1d-4ede-4fdb-8bf6-13bffc53ecf9/image.png" alt=""></p>
<p>Traefik은 오픈 소스 애플리케이션 프록시로, 서비스를 쉽게 운영할 수 있도록 돕는 도구에요. 무엇보다도 설정 과정이 복잡하지 않고 자동화되어 있어 <code>마이크로서비스 환경</code>이나 <code>컨테이너 기반 시스템</code>에서 많은 개발자들에게 사랑받고 있다고 해요.</p>
<p>Traefik을 사용하면 복잡한 프록시 설정 작업을 줄일 수 있어, 개발자는 애플리케이션 개발에 더 집중할 수 있다고 강조해요. 또한, 다양한 클러스터 환경에서 유연하게 동작하며, 특히 Docker 및 Kubernetes와 같은 환경에서 서비스 배포를 간소화해준다고 해요.</p>
<p>Traefik의 가장 큰 장점 중 하나는 <code>자동 구성 기능</code>이에요. Traefik은 인프라를 스캔하여 각 서비스가 어떤 요청을 처리해야 하는지 자동으로 파악하고, 별도의 수작업 없이 적절한 라우팅을 설정해주기 때문에 각 서비스에 맞는 라우팅 규칙을 수동으로 작성할 필요 없이, Traefik이 알아서 모든 작업을 처리해요. Traefik은 설정이 실시간으로 반영되기 때문에, 서비스를 중단하거나 재시작할 필요가 없다고 해요.</p>
<p>추가로 <code>Let&#39;s Encrypt</code>와의 통합을 통해 <code>SSL 인증서를 자동으로 발급 및 갱신</code>해 주며, 별도의 작업 없이 안전한 HTTPS 트래픽을 관리할 수 있어요.</p>
<h3 id="자동-구성-테스트">자동 구성 테스트</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/095cd8f3-6524-4021-9cac-0b3811ba722f/image.png" alt=""></p>
<p>Traefik 컨테이너를 먼저 실행시키고, 추가로 2개의 컨테이너를 실행시켰어요. Traefik 컨테이너를 재시작하지 않아도 자동으로 구성에 추가된 것을 볼 수 있어요.</p>
<blockquote>
<p>특히, 저는 <code>Docker와의 통합</code>과 <code>SSL 인증서 자동 발급 및 관리</code>가 너무 편했어요.</p>
</blockquote>
<h2 id="어떻게-사용해">어떻게 사용해</h2>
<p>처음에는 <a href="https://doc.traefik.io/traefik/getting-started/quick-start/">Quick Start with Docker - traefik docs</a>를 참고하여 직접 써보면서 학습했고 나머지는 공식 문서를 통해 학습했어요. v2 레퍼런스는 꽤 있었지만, v3 레퍼런스는 거의 없었고 공식 문서가 학습하기 더 편했어요.</p>
<h3 id="테스트">테스트</h3>
<p>아래는 제가 <code>GET - api/v1/health</code> API만 제공하는 스프링 부트 애플리케이션을 이미지로 만들어서 테스트에 사용했던 <code>docker-compose.yml</code> 이에요.</p>
<pre><code class="language-yml">version: &#39;3.9&#39;

services:
  reverse-proxy:
    image: traefik:v3.1
    command:
      # Traefik API를 비밀번호 없이 접근 가능하게 설정
      - &quot;--api.insecure=true&quot;
       # Docker에서 컨테이너의 정보를 가져오기 위해 설정
      - &quot;--providers.docker=true&quot; 
       # HTTP 요청을 처리할 포트 80 설정
      - &quot;--entrypoints.web.address=:80&quot;
      # Traefik 대시보드에 접근할 포트 8080 설정
      - &quot;--entrypoints.traefik.address=:8080&quot;
      - &quot;--api.dashboard=true&quot;
      - &quot;--log.level=INFO&quot;
      - &quot;--accesslog=true&quot;
    ports:
      # {호스트의 포트} : {컨테이너의 포트} 연결
      - &quot;80:80&quot;
      - &quot;8080:8080&quot;
    volumes:
      # Docker 소켓을 마운트하여 Traefik이 Docker 컨테이너를 관리하도록 허용
      - &quot;/var/run/docker.sock:/var/run/docker.sock&quot;
    networks:
      - traefik-test

  my-app:
    # 배포할 스프링 부트 애플리케이션의 Docker 이미지
    image: `사용할 이미지`
    deploy:
      # 애플리케이션의 복제본 수 설정, 3개의 컨테이너가 실행
      replicas: 3
    labels:
      # Traefik이 이 서비스를 인식하도록 설정
      - &quot;traefik.enable=true&quot;
      # 특정 호스트와 경로에 대한 라우팅 규칙 설정
      - &quot;traefik.http.routers.my-app.rule=Host(`localhost`) &amp;&amp; PathPrefix(`/api/v1`)&quot;
      # 로드밸런서가 서비스의 8080 포트로 요청을 전달하도록 설정
      - &quot;traefik.http.services.my-app.loadbalancer.server.port=8080&quot;
    networks:
      - traefik-test

networks:
  traefik-test:
    driver: bridge</code></pre>
<h3 id="설정-요약">설정 요약</h3>
<ul>
<li><code>Traefik</code> : 리버스 프록시로서 애플리케이션의 요청을 처리하고, 대시보드를 통해 상태를 모니터링할 수 있어요.</li>
<li><code>my-app</code> : Docker 이미지를 기반으로 한 스프링 부트 애플리케이션으로, Traefik에 의해 로드밸런싱 돼요.</li>
<li><code>로드밸런싱</code> : replicas: 3 설정으로 애플리케이션의 인스턴스가 3개 실행되며, 자동으로 요청을 나눠줘요.</li>
<li><code>라우팅 규칙</code> : Host와 PathPrefix를 기반으로 요청을 라우팅하여, /api/v1 경로로 시작하는 요청이 my-app으로 전달돼요.</li>
</ul>
<h3 id="요청-테스트">요청 테스트</h3>
<p>스프링 부트 애플리케이션에 요청을 보내려면 다음과 같은 URL을 사용하면 돼요.
<code>GET - http://localhost/api/v1/health</code> (OK 문자열만 응답하게 구성했어요.)</p>
<p>요청을 보내면 Traefik이 요청을 받아 3개의 my-app 서비스로 균형있게 전달해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/3618f0ff-fa70-46a4-b554-7999c2838b88/image.png" alt=""></p>
<p>아래의 reverse-proxy(traefik 컨테이너 이름) 로그를 보면 같은 요청이지만, 다른 호스트로 전달하고 있는 것을 볼 수 있어요. (모두 스프링 부트 애플리케이션으로 전달돼요.)</p>
<h3 id="대시보드">대시보드</h3>
<p>위 구성에서는 브라우저에 <code>http://localhost:8080/dashboard/</code> 를 입력하면 대시보드를 확인할 수 있어요. Traefik이 제공해주는 기능이에요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/8e2dfe5e-d001-4eee-90e6-3da094148d1d/image.png" alt=""></p>
<p>Services 탭에 들어가면 아래와 같이 구성된 서비스들의 정보를 확인할 수 있어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/fe8dacb3-3c45-42f9-b5c5-7145c895a9ec/image.png" alt=""></p>
<p>앞서 설정한 것처럼 3개의 복제본이 실행된 상태이기 때문에 서브넷 내의 IP 주소가 3개인 것을 볼 수 있고, 앞에서 본 reverse-proxy 로그에 나왔던 주소와 똑같은 것을 볼 수 있어요.</p>
<p>아래에는 설정했던 라우팅 규칙이 명시되어 있어요.</p>
<blockquote>
<p>SSL 인증서 자동 발급 및 관리 설정은 공식 문서에서 <a href="https://doc.traefik.io/traefik/user-guides/docker-compose/acme-tls/">문서 1</a>과 <a href="https://doc.traefik.io/traefik/https/acme/">문서 2</a>를 보면 쉽게 구현할 수 있을 거에요.</p>
</blockquote>
<h1 id="기타-내용">기타 내용</h1>
<ul>
<li>AWS의 <code>RDS</code>를 활용하여 MySQL 데이터베이스를 사용할 수 있게 추가했습니다.</li>
<li><a href="https://velog.io/@hyeok_1212/%EC%96%B4%EB%94%94%EC%97%90-%EC%84%B8%EC%85%98%EC%9D%84-%EB%B3%B4%EA%B4%80%ED%95%A0%EA%B9%8C">로그인 세션 관리</a>를 위해 <code>Amazon ElastiCache</code>를 사용해보려고 해요.</li>
<li>환경 변수가 포함된 docker-compose.yml 파일은 인스턴스 내부에 존재해요.</li>
</ul>
<h2 id="마무리">마무리</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/5f7ff321-898e-406e-9e97-1111f77e8110/image.png" alt=""></p>
<p>이러한 고민과 선택으로 개발 환경 배포 파이프라인을 구성했어요. 아래는 구성하면서 생긴 궁금증이에요.</p>
<ul>
<li>이처럼 한 인스턴스 내부에 복제하여 실행한 환경이 과연 가용성 향상에 도움이 될까요?</li>
<li>만약 Runner가 종료되면 어떻게 감지할까요?</li>
<li>현재는 재배포가 시작되면 들어오던 요청도 무시될텐데 어떡할까요? Graceful Shutdown?</li>
<li>또한 배포 사이에 공백이 존재해요. 무중단 배포?</li>
</ul>
<p>긴 글 읽어주셔서 감사합니다. </p>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://sjh9708.tistory.com/237">[AWS &amp; Github Actions] CI/CD 파이프라인 구축 (Spring + Docker) - 데굴데굴 개발자의 기록</a></li>
<li><a href="https://thalals.tistory.com/470">Jenkins → GitHub Action 이전기 - 민돌v</a></li>
<li><a href="https://velog.io/@yeseong0412/%EB%AA%85%EB%A0%B9%EC%96%B4-%EB%AA%87%EC%A4%84%EB%A1%9C-%EB%8F%84%EC%BB%A4-%ED%95%B4%ED%82%B9%ED%95%98%EA%B8%B0">도커 이미지로 AWS 계정이 털린다? - 양예성</a></li>
<li><a href="https://docs.spring.io/spring-cloud-config/docs/current/reference/html/">Spring Cloud Config Server - Spring Docs</a></li>
<li><a href="https://docs.github.com/ko/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners">Self-Hosted-Runner - GitHub Docs</a></li>
<li><a href="https://amaran-th.github.io/%EC%9D%B8%ED%94%84%EB%9D%BC/%5BCICD%5D%20Self-hosted%20Runner%EB%A1%9C%20%EC%84%9C%EB%B2%84%20%EB%B0%B0%ED%8F%AC%20%EC%9E%90%EB%8F%99%ED%99%94%ED%95%98%EA%B8%B0/">Self-hosted Runner로 서버 배포 자동화하기 - Amaranth</a></li>
<li><a href="https://doc.traefik.io/traefik/">Traefik Docs</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[어디에 세션을 보관할까?]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%96%B4%EB%94%94%EC%97%90-%EC%84%B8%EC%85%98%EC%9D%84-%EB%B3%B4%EA%B4%80%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@hyeok_1212/%EC%96%B4%EB%94%94%EC%97%90-%EC%84%B8%EC%85%98%EC%9D%84-%EB%B3%B4%EA%B4%80%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Thu, 12 Sep 2024 11:39:03 GMT</pubDate>
            <description><![CDATA[<p>많은 서비스에 구현된 로그인 기능을 어떻게 구현하면 좋을 지 고민하며 작성하는 글이에요. 또한, 왜 그렇게 하고 싶은가?를 포함하여 프로젝트 팀원에게 제안하기 위한 글이에요.</p>
<p><a href="https://velog.io/@hyeok_1212/%EC%96%B4%EB%96%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B0%A9%EC%8B%9D%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C">이전 글</a>에서 세션 쿠키 방식으로 인증을 구현하기로 결정했어요. 사용자가 원활하게 서비스를 이용하기 위해서는 세션을 잘 보관하는 것은 중요해요. 만약 서비스 중 저장된 세션이 지워진다면, 사용자는 다시 로그인을 해야 해요. 추가로 보관을 잘못하여 세션 정보가 이상하다면 다른 사람의 계정으로 활동하게 될 수도 있어요.</p>
<p>이번 글에서는 세션 정보를 어디에 어떻게 보관할 것인지를 중점적으로 고민해보려고 해요.</p>
<h1 id="상황">상황</h1>
<p>우선 프로젝트에서 만드는 Spring Boot 애플리케이션을 기준으로 설명해요. </p>
<blockquote>
<p>현재 세션 로그인 기능은 구현했지만, 별도의 설정을 하지 않아 톰캣 서버 내에 세션을 저장하고 서버를 끄면 사라져요. 세션 관리 방법에 대해 살펴보고 최선의 선택을 하려고 해요.</p>
</blockquote>
<p>추가로 일반적인 기능과 채팅 기능을 분리해서 개발한 후 따로 배포할 수도 있어요. 또한 <del>사용자가 많아지거나</del> 공부 목적으로 <code>Scale Out</code> 전략을 사용할 수도 있어요. 즉, 배포할 때 여러 개의 서버로 구성될 가능성이 있다는 말이에요.</p>
<blockquote>
<h3 id="scale-up-scale-out">Scale Up? Scale Out?</h3>
<p><code>Scale Up</code>이란 서버의 사양을 높이는 것을 말해요. 기존 서버에 자원(CPU, 메모리 등)을 추가하거나 서버 자체를 높은 사양의 서버로 교체하여 서버의 성능을 향상시켜요. 즉, 서버 한 대의 성능을 높여요.</p>
<p><code>Scale Out</code>이란 서버의 수를 늘리는 것을 말해요. 기존 서버와 비슷한 사양의 새로운 서버를 추가하여 트래픽을 분산시켜 응답 속도(성능)를 개선해요.</p>
<p>참고: <a href="https://chagokx2.tistory.com/92">Scale Out vs Scale Up 포스팅 - Liiot</a></p>
</blockquote>
<h1 id="세션-저장소-유형">세션 저장소 유형</h1>
<p>세션 저장소는 사용자가 로그인한 후 유지해야 할 정보를 저장하는 공간입니다. 어떤 저장소를 선택하느냐에 따라 시스템의 복잡성, 확장성 등에 영향을 미치기 때문에 신중하게 고민해보려고 해요.</p>
<ul>
<li><code>톰캣 세션 사용하기</code> : 가장 기본적인 방식으로, 아무런 설정을 하지 않으면 톰캣 서버 내에서 세션이 관리돼요.</li>
<li><code>데이터베이스 사용하기</code> : MySQL, PostgreSQL와 같은 데이터베이스에 세션을 저장하고 관리해요.</li>
<li><code>인-메모리 데이터베이스 사용하기</code> : Redis와 같은 인-메모리 데이터베이스를 사용하여 세션을 저장하고 관리해요.</li>
</ul>
<h2 id="톰캣-세션-사용하기">톰캣 세션 사용하기</h2>
<p>일반적으로 별다른 설정을 하지 않았을 때, 기본적으로 적용되는 방식이에요.</p>
<p>초기 프로젝트에서는 톰캣 세션을 사용하는 것이 간단하고 빨라요. 저도 초기 개발 단계에서는 톰캣 세션을 사용했어요. 하지만 서버 재시작 시 세션 손실, 여러 서버 간 세션 동기화 등 고려할 사항이 많아요.</p>
<h3 id="장점">장점</h3>
<ul>
<li>별도의 외부 저장소나 복잡한 설정 없이도 바로 사용할 수 있기 때문에 초기 구현에 유리해요.</li>
<li>세션 정보를 서버 메모리에 저장하기 때문에 속도가 빨라요.</li>
</ul>
<h3 id="단점">단점</h3>
<ul>
<li>서버가 재시작하거나 장애가 발생하면 세션 정보가 손실될 수 있어요.</li>
<li>서버가 여러 대일 경우 각 서버의 세션이 독립적이에요. 즉, 세션 동기화를 위한 추가적인 구현이 필요해요.</li>
<li>많은 사용자가 동시에 접속하면 서버의 메모리 용량을 초과할 수 있어요.</li>
</ul>
<h2 id="데이터베이스-사용하기">데이터베이스 사용하기</h2>
<p>세션을 MySQL, PostgreSQL 등의 관계형 데이터베이스에 저장하는 방식이에요. 각 서버가 같은 데이터베이스를 참조하기 때문에 서버가 여러 대일 때도 동일한 세션 정보를 공유할 수 있어요.</p>
<h3 id="장점-1">장점</h3>
<ul>
<li>서버를 재시작해도 세션이 유지돼요.</li>
<li>여러 서버가 세션을 공유할 수 있기 때문에 확장성이 좋아요.</li>
<li>데이터베이스에 저장하기 때문에 세션 정보가 영구적으로 저장돼요.</li>
</ul>
<h3 id="단점-1">단점</h3>
<ul>
<li>세션 조회 및 업데이트가 데이터베이스 쿼리를 통해 이루어지므로, 데이터베이스의 부하가 커질 수 있고, 성능 또한 비교적 느려요.</li>
<li>데이터베이스 스키마 설계 및 관리가 필요하며, 세션 만료 시 삭제되는 로직도 별도로 구현해야 해요.</li>
</ul>
<h3 id="테스트">테스트</h3>
<pre><code class="language-java">implementation &#39;org.springframework.session:spring-session-jdbc&#39;</code></pre>
<pre><code class="language-yml">spring:
  session:
    jdbc:
      initialize-schema: always # 세션 및 세션 속성 테이블을 자동으로 생성해줘요. 
      # 추가로 jdbc.table-name을 통해 테이블 이름도 변경할 수 있다.</code></pre>
<blockquote>
<p>참고: <a href="https://docs.spring.io/spring-session/reference/configuration/jdbc.html">Spring Session JDBC - Spring Docs</a></p>
</blockquote>
<p>의존성을 추가하고 아래의 설정을 추가하면 바로 테스트를 진행할 수 있어요. 간단히 만든 로그인 기능을 실행하고 저장된 세션 정보를 살펴보면 아래와 같아요. (MySQL을 사용했어요.)</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/37f8c8e3-b754-49d9-8a9f-99db8b500651/image.png" alt=""></p>
<p>세션 저장을 위한 테이블이 자동으로 생성되어 저장되는 것을 볼 수 있어요.</p>
<p>생각보다 구성은 어렵지 않은 편이지만, 하드 디스크에 저장하는 데이터베이스 특성상 메모리에 저장하는 방식들보단 시간이 오래 걸려요. 정확한 지표가 될 순 없겠지만, 실제로 여러 번 요청을 테스트해본 결과에요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/d2234c23-cb48-4f56-8dac-b649482a35a1/image.png" alt=""></p>
<p>로그인 과정 마지막에 생성된 세션을 저장소에 저장하는 코드가 있는데, 톰캣 서버에 세션을 저장하는 방식은 메모리 기반이기 때문에 비교적 빨리 write 되어 응답 속도에 차이가 있어요.</p>
<h2 id="인-메모리-데이터베이스-사용하기">인-메모리 데이터베이스 사용하기</h2>
<p>Redis와 같은 인-메모리 데이터베이스에 세션을 저장하는 방식이에요. 서버의 메모리가 아닌 외부의 고성능 인-메모리 데이터베이스를 사용하여 세션을 빠르게 관리하며, 데이터베이스 사용 방식과 동일하게 서버 간 세션을 공유할 수 있어요.</p>
<h3 id="장점-2">장점</h3>
<ul>
<li>메모리 기반 데이터베이스이므로 빠른 응답 속도를 보장해요.</li>
<li>여러 서버 간 세션을 공유할 수 있으며, 서버 수를 늘리거나 클러스터를 구성하여 확장할 수 있어요.</li>
<li>Redis의 경우 기본적으로 인-메모리 데이터베이스지만, 디스크에 백업할 수 있기 때문에 안정성을 챙길 수 있어요.</li>
</ul>
<h3 id="단점-2">단점</h3>
<ul>
<li>인-메모리 서버를 추가적으로 운영해야 하며, 세션 만료 및 데이터 정리를 위한 정책을 관리해야 해요.</li>
<li>클라우드에서 Redis와 같은 인-메모리 데이터베이스를 사용하면 추가 비용이 발생할 수 있어요.</li>
</ul>
<h3 id="테스트-1">테스트</h3>
<p>원활한 테스트를 위해 레퍼런스가 가장 많았던 Redis를 활용해봤어요.</p>
<pre><code class="language-java">// Redis와의 통신 및 데이터 처리를 위한 라이브러리
implementation &#39;org.springframework.boot:spring-boot-starter-data-redis&#39;
// Redis를 세션 저장소로 사용하기 위한 Spring Session 통합 라이브러리
implementation &#39;org.springframework.session:spring-session-data-redis&#39;</code></pre>
<pre><code class="language-yml"># Spring Boot 애플리케이션이 Redis 서버와 통신할 수 있도록 설정
spring:
  data:
    redis:
      host: localhost
      password: 1234
      port: 6379
  # Redis에서 세션 데이터를 저장할 때 사용할 네임스페이스를 설정
  session:
    redis:
      # Redis의 키를 session-test:sessions로 접두어를 붙여 세션을 저장
      namespace: session-test:sessions

# 세션 쿠키의 이름을 설정 (아래 이미지로 확인)
server:
  servlet:
    session:
      cookie:
        name: GOODSESSIONID</code></pre>
<blockquote>
<p>참고: <a href="https://docs.spring.io/spring-session/reference/guides/boot-redis.html">Spring Session Redis - Spring Docs</a></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/98de45e0-8e62-4e4f-8eb0-04872aed5817/image.png" alt=""></p>
<p>의존성을 변경하고 <code>application.yml</code> 설정 값을 추가해주면, 바로 사용할 수 있어요. (Redis는 직접 구동해야 해요.)</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/5bd4b352-efe6-4407-b1df-f2f013c52a4a/image.png" alt=""></p>
<p>데이터베이스 저장 방식보다 확실히 평균 응답 속도가 빨라진 것을 볼 수 있어요.</p>
<h1 id="분산-서버-환경에서-발생하는-데이터-불일치">분산 서버 환경에서 발생하는 데이터 불일치</h1>
<blockquote>
<h3 id="앞서-말한-프로젝트의-상황">앞서 말한 프로젝트의 상황</h3>
<p>일반적인 기능과 채팅 기능을 분리해서 개발한 후 따로 배포할 수도 있어요. 또한 <del>사용자가 많아지거나</del> 공부 목적으로 <code>Scale Out</code> 전략을 사용할 수도 있어요.</p>
</blockquote>
<p>우리 프로젝트는 일반적인 기능과 채팅 기능을 분리하여 배포하거나 공부 목적으로 <code>Scale Out</code> 전략을 사용할 수도 있어요. 여러 대의 서버가 배포될 가능성이 있다는 말이에요.</p>
<p>(특별히 설정이나 구현을 하지 않은) 톰캣 서버에 저장하는 방식의 경우 각 서버마다 세션 저장소에 저장된 세션 정보들이 다른 서버와 공유되지 않아요. 즉, 세션 정보 저장 방식에 따라 데이터 불일치 문제가 발생할 수 있어요.</p>
<h2 id="데이터-불일치-상황">데이터 불일치 상황</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/aa20af05-3951-4faa-a7b0-660a7f657608/image.png" alt=""></p>
<p>각 서버마다 세션 저장소가 존재하는 경우 위와 같은 상황이 발생할 수 있어요. (편의를 위해 간단하게 그렸어요.)</p>
<ol>
<li>클라이언트의 로그인 요청은 리버스 프록시에 의해 <code>Server 1</code> 에 전달되고, 인증 과정을 거쳐 세션 ID를 발급 받아요. (해당 서버에만 세션 정보가 저장돼요.)</li>
<li>클라이언트의 조회 요청이 이번에는 <code>Server 2</code> 에 전달돼요. 하지만 <code>Server 2</code> 의 세션 저장소에는 해당 사용자의 세션 정보가 없기 때문에(인증을 하지 못해요.) 조회를 실패해요.</li>
</ol>
<p>따라서 사용자는 다시 로그인을 요청해야 해요. 하지만 그렇다고 문제가 해결되진 않아요. 분산 서버 환경인 경우 특별한 설정 없이는 사용자의 요청이 세션 정보가 저장된 서버로 전송된다는 보장이 없기 때문이에요.</p>
<p>이처럼 여러 대의 서버가 존재하는 경우 일관성있게 세션 정보를 관리하는 것이 필요해요.</p>
<blockquote>
<h3 id="reverse-proxy-리버스-프록시">Reverse Proxy (리버스 프록시)</h3>
<p>인터넷-서버 사이에 존재하는 프록시를 말해요. 여기서 리버스 프록시는 사용자의 요청을 여러 서버 중 하나로 전달해주는 중간 서버 역할을 해요. 사용자는 리버스 프록시에 직접 요청을 보내고, 리버스 프록시는 해당 요청을 기준에 따라 적절한 서버로 전달해요. 이 과정에서 리버스 프록시는 클라이언트에게 서버의 세부 정보를 숨기고, 클라이언트가 요청하는 데이터를 서버에게 받아 클라이언트로 전달하는 역할도 해요. </p>
<p>참고: 로드 밸런싱, 캐싱, SSL Termination, 장애 대응으로 가용성 높이기 등 여러 이점을 누릴 수 있어요.</p>
</blockquote>
<hr>
<h3 id="sticky-session">Sticky Session</h3>
<p>먼저, 데이터 불일치 상황을 해결하기 위해 <code>Sticky Session</code> 방법이 있어요. 이는 사용자의 세션을 처음 생성된 서버에 바인딩하여 이후 동일한 사용자로부터 들어오는 모든 요청을 처음 바인딩 된 서버로 보내는 방법이에요.</p>
<p>위에서 살펴본 상황처럼 <code>Server 1</code> 에서 로그인하고 <code>Server 2</code> 에서 다른 요청을 보내면, 세션 정보가 없어 인증에 실패할테니 그냥 처음 정해진 서버인 <code>Server 1</code> 로 계속 요청을 보내는 것을 말해요. (너의 세션은 저기 저장되어 있으니 저기로 가라. <code>반복</code> )</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/aa0bd600-d5c9-4013-ba7a-d8905c7fcdd8/image.png" alt=""></p>
<p>각 클라이언트가 보낸 요청들을 리버스 프록시가 받으면, 요청을 보낸 사용자의 IP 주소나 쿠키로부터 어떤 서버에 고정되어 있는지 확인한 후, 해당 요청을 지정된 서버로 보내요. 따라서, 사용자는 세션이 유지되는 동안에는 같은 서버로 요청을 주고 받기 때문에 데이터 불일치가 발생하지 않아요.</p>
<p>이처럼 데이터 불일치 문제는 해결했지만, 이 방법은 아래의 단점을 가져요.</p>
<ul>
<li><p><code>트래픽 불균형</code> : 처음에는 리버스 프록시(로드 밸런서)가 사용자 요청을 서버에 고르게 분배해요. 하지만 시간이 지나면서 일부 사용자가 이탈하게 되면, 세션이 바인딩된 서버에 남아있는 사용자가 몰릴 수 있어요. 예를 들어, 처음 10만 명의 사용자가 5만 명씩 두 서버에 나뉘었다가, 한쪽 서버의 사용자가 이탈하면 남은 5만 명은 계속 한쪽 서버에만 접속하게 돼요. 이로 인해 한 서버에 몰리면서 과부하가 발생할 수 있어요.</p>
</li>
<li><p><code>세션 정보 사라짐</code> : 한 서버가 갑작스럽게 종료되면, 해당 서버에 저장된 세션 정보도 함께 사라져요. 세션 정보가 없으면 사용자는 다시 로그인해야 하는 불편을 겪게 되며, 사용자 경험에 영향을 미칠 수 있어요.</p>
</li>
</ul>
<hr>
<h3 id="session-clustering">Session Clustering</h3>
<p><code>Session Clustering</code>이란 여러 서버에서 생성된 세션을 하나의 그룹으로 묶어, 각 서버가 동일한 세션 정보를 공유할 수 있도록 만드는 기술을 말해요. 일반적으로 여러 서버가 동시에 실행되고 있는 환경에서 사용자의 요청이 어느 서버로 전달되더라도 일관된 세션 정보에 접근할 수 있어야 해요.</p>
<p>세션 클러스터링에서 중요한 점은 <strong>세션 복제(Session Replication)</strong>에요. 여러 대의 서버가 서로 세션 정보를 공유하려면, 한 서버에서 생성된 세션이 다른 서버에도 동일하게 존재해야 해요. 이를 위해, 서버들은 서로 세션 정보를 복제하여 일관성있는 세션 정보를 유지해요.</p>
<p>톰캣은 각 서버에서 생성된 모든 세션 정보들을 클러스터로 묶인 모든 서버에 복제하는 <code>all-to-all 세션 복제 방식</code>을 사용하고 있어요. 이 방식을 기준으로 한번 살펴볼게요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/b086e0f1-28bc-468a-887a-7733b574f766/image.png" alt=""></p>
<p>사용자가 로그인을 하면 새로운 세션을 생성하고 응답해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/011c6922-2700-4bfb-8819-d823f1a3269d/image.png" alt=""></p>
<p>이처럼 새로 세션이 생성되었거나 세션 정보가 변경될 때마다 모든 서버에 복제해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/59f4b978-e2eb-4b8c-ad5a-0e97d3daa5f0/image.png" alt=""></p>
<p>따라서 모든 서버가 일관성있는 세션 정보를 가지고 있을 수 있어요. 그렇다는 것은 다른 서버로 요청이 전송되더라도 로그인을 다시 할 필요없이 계속해서 서비스를 이용할 수 있다는 말이에요.</p>
<p><code>Sticky Session</code> 방식의 한계였던, 트래픽 불균형과 세션 정보 안전성을 어느 정도 해결했다고 볼 수 있어요.</p>
<p>그러나 세션 복제 방식 자체에는 피할 수 없는 단점이 있어요. 바로 서비스를 이용하는 사용자가 늘어날수록 세션 복제도 늘어나요. (복제할 세션 정보도 많아져요.)</p>
<blockquote>
<p>This works great for smaller clusters, but we don&#39;t recommend it for larger clusters — more than 4 nodes or so. Also, when using the DeltaManager, Tomcat will replicate sessions to all nodes, even nodes that don&#39;t have the application deployed. 
<a href="https://tomcat.apache.org/tomcat-9.0-doc/cluster-howto.html">Clustering/Session Replication How-To - Tomcat docs</a></p>
</blockquote>
<p>톰캣 공식 문서에서는 이 방식이 소규모 클러스터에는 적합하지만 노드가 4개 이상인 대규모 클러스터에는 권장되지 않는다고 해요. 또한, 세션 복제 과정은 클러스터에 묶인 <code>모든 서버</code>에서 수행되기 때문에 웹 애플리케이션이 운영되고 있지 않은 서버에도 세션 데이터를 복제한다고 해요.</p>
<p>이처럼 <code>Session Clustering</code> 방식은 세션 정보가 변경될 때마다 세션을 복제해야 하기 때문에 사용자 수가 늘어난다면 성능 부분에서 문제가 발생할 수 있어요.</p>
<ul>
<li><code>성능 이슈</code> : 세션이 변경될 때마다 모든 서버에 세션을 복제하는 작업을 수행하기 때문에 대규모 클러스터에는 권장되지 않는 방식이에요. 또한, 서버에 따라 사용되지 않을 수 있는 세션 정보를 항상 가지고 있어야 해요.</li>
</ul>
<p>이런 문제를 해결하기 위해 세션 저장소를 분리하는 방법을 고려할 수 있어요. 세션 저장소를 분리하면, 모든 서버가 동일한 세션 정보를 조회할 수 있기 때문에 서버 간 세션 불일치 문제를 해결할 수 있으며, 서버를 재시작하더라도 세션 정보가 사라지지 않아요.</p>
<hr>
<h3 id="세션-저장소-분리">세션 저장소 분리</h3>
<p>각 서버 메모리에 세션을 저장하는 톰캣 기본 방식과 달리 세션 저장소를 분리하는 방식이에요. 앞에서 세션 저장소 유형에서 살펴봤던 데이터베이스나 인-메모리 데이터베이스를 사용하는 것이 세션 저장소를 분리했다고 말할 수 있어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/d1c0f564-60b8-411d-84c7-7d1ff2bba4c7/image.png" alt=""></p>
<p><code>Sticky Session</code> 방법처럼 특정 세션이 서버에 바인딩 되지 않으면서 데이터 불일치 문제를 해결할 수 있어요. 또한, 데이터 정합성을 위해 <code>Session Clustering</code> 방법처럼 세션을 복제할 필요가 없어요. (모두가 같은 곳에서 세션을 조회하기 때문이에요.)</p>
<p>보라색 영역에 존재하는 서버들은 하나의 세션 저장소만 접근하기 때문에, 모두 일관성 있는 세션 정보를 조회할 수 있어요. 덕분에 서버를 추가하거나 제거하는 것이 쉬워져요.</p>
<p>그러나 그림에서는 세션 저장소가 하나이기 때문에 세션 저장소에 문제가 생긴다면 서비스 전체에 문제가 발생해요. 그렇기 때문에 세션 저장소에 대한 클러스터링도 별도로 진행하면 좋다고 해요.</p>
<hr>
<h1 id="제안">제안</h1>
<blockquote>
<p>저는 서버 외부에 인-메모리 데이터베이스(Redis)를 사용하여 세션 저장소를 만들면 좋을 것 같아요.</p>
</blockquote>
<p>개발 단계에서는 톰캣 서버 자체에 세션을 저장하는 방식으로 빠르게 개발하는 것도 좋을 것 같아요. 미리 다른 설정을 하는 경우 모든 팀원이 같은 환경을 구축해야 하기 때문에 개발 편의성이 조금 떨어질 수 있을 것 같아요. 아니면 로컬 환경에서는 그대로 사용하고 배포 및 테스트 환경에서는 따로 설정하는 것도 좋을 것 같다고 생각해요.</p>
<p>여러 대의 서버가 존재할 경우 발생할 수 있는 데이터 불일치 문제는 세션 저장소를 분리하는 것이 가장 효과적인 방법이라고 생각해요. <code>Sticky Session</code> 방식은 트래픽 불균형을 초래할 수 있으며, 여전히 서버가 종료되는 경우 세션 정보가 사라질 수 있어요. 또한 <code>인증을 위한 세션</code>과 <code>세션 일관성을 위한 정보</code> 등 서버의 상태가 늘어나는 것은 서버의 부담 및 잠재적인 위험을 키울 수 있는 요소이며, HTTP의 Stateless와도 어울리지 않다고 생각해요.</p>
<p>이를 해결하기 위해 <code>Session Clustering</code> 방식을 사용할 수도 있지만, 사용되지 않을 수 있는 세션 정보들을 복제하여 가지고 있는 것은 낭비이며, 서버의 복잡성을 늘리는 요소라고 생각해요. <code>세션 저장소를 분리</code>하는 것이 훨씬 간단하고 부담이 적은 방법인 것 같아요.</p>
<h2 id="왜-인-메모리-데이터베이스">왜 인-메모리 데이터베이스?</h2>
<ul>
<li><p><code>빠른 응답 시간</code> : 인-메모리 데이터베이스는 메모리에서 직접 데이터를 처리하기 때문에 디스크 기반인 데이터베이스보다 훨씬 빠른 응답 시간을 제공해요. 따라서 세션 정보처럼 자주 읽고 쓰는 데이터를 다루는 데 매우 적합하다고 생각해요.</p>
</li>
<li><p><code>확장성</code> : Redis와 같은 인-메모리 데이터베이스는 클러스터링과 샤딩을 통해 여러 서버에 데이터를 분산하여 저장할 수 있어요. 이를 통해 트래픽이 증가해도 성능 저하 없이 세션 데이터를 처리할 수 있으며, 서버 확장성 문제도 효과적으로 해결할 수 있다고 해요. (직접 사용해본 경험은 없어요.)</p>
</li>
<li><p><code>관리의 분리</code> : 비즈니스 데이터(사용자 정보, 게시글 등)와 세션 데이터는 성격이 다르다고 생각해요. 비즈니스 데이터는 장기적으로 유지되어야 하지만, 세션 데이터는 일시적으로 유지돼요. 인-메모리 데이터베이스를 통해 세션 데이터를 분리하여 저장하면, 데이터베이스 부하를 줄이고 관심사를 적절하게 나눌 수 있을 것 같아요.</p>
</li>
</ul>
<h2 id="왜-redis">왜 Redis?</h2>
<p><a href="https://chagokx2.tistory.com/95">세션 스토리지로 어떤 것이 더 적합한가? Redis VS Memcached - Liiot</a> 
저는 위 포스팅을 읽고 공감가서 Redis가 좋다고 생각했어요. 이거에 대해서는 함께 이야기해보면 더 좋을 것 같아요!</p>
<h2 id="마무리">마무리</h2>
<p>이번 글에서는 여러 대의 서버가 존재할 수 있는 프로젝트에서 세션 정보를 안전하고 일관성 있게 관리할 수 있는 방법을 찾기 위해 여러 방법을 살펴봤어요. 항상 정답은 없겠지만, 그 상황에서 최선의 선택을 하기 위해 학습하려고 해요. 다소 시간이 걸리는 단점이 있지만, 여러 가능성을 살펴보고 고민하는 것이 재밌는 것 같아요.</p>
<p>잘못된 부분이나 궁금한 점이 있으시면 말씀 부탁드립니다. 긴 글 읽어주셔서 감사합니다!</p>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://chagokx2.tistory.com/92">Scale Out vs Scale Up - Liiot</a></li>
<li><a href="https://chagokx2.tistory.com/93">여러 대의 서버에 흩어져 있는 세션을 어떻게 관리할 수 있을까? - Liiot</a></li>
<li><a href="https://chagokx2.tistory.com/95">세션 스토리지로 어떤 것이 더 적합한가? Redis VS Memcached - Liiot</a> </li>
<li><a href="https://docs.spring.io/spring-session/reference/configuration/jdbc.html">Spring Session JDBC - Spring Docs</a></li>
<li><a href="https://docs.spring.io/spring-session/reference/guides/boot-redis.html">Spring Session Redis - Spring Docs</a></li>
<li><a href="https://tomcat.apache.org/tomcat-9.0-doc/cluster-howto.html">Clustering/Session Replication How-To - Tomcat docs</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[이렇게 본인 인증을 해볼까?]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%9D%B4%EB%A0%87%EA%B2%8C-%EB%B3%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D%EC%9D%84-%ED%95%B4%EB%B3%BC%EA%B9%8C</link>
            <guid>https://velog.io/@hyeok_1212/%EC%9D%B4%EB%A0%87%EA%B2%8C-%EB%B3%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D%EC%9D%84-%ED%95%B4%EB%B3%BC%EA%B9%8C</guid>
            <pubDate>Sat, 31 Aug 2024 16:47:45 GMT</pubDate>
            <description><![CDATA[<p>많은 서비스에 구현된 로그인 기능을 어떻게 구현하면 좋을 지 고민하며 작성하는 글이에요. 또한, 왜 그렇게 하고 싶은가?를 포함하여 프로젝트 팀원에게 제안하기 위한 글이에요.</p>
<p><a href="https://velog.io/@hyeok_1212/%EC%96%B4%EB%96%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B0%A9%EC%8B%9D%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C-2">이전 글</a>에서는 어떻게 로그인 기능을 구현하고, 어떻게 회원가입을 시킬 것인가에 대해 고민했어요. 이번에는 회원가입 중 필요한 <code>본인 인증 과정</code>에 대해서 이야기해보려고 해요. </p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/a74f6f55-fd80-4b90-8bea-66f7dd45de09/image.png" alt=""></p>
<p>최근 팀원들과 로그인 및 회원가입 기능에 대해 논의하면서, 사용자 인증 방식에 대해 여러 가지 의견이 나왔어요. 그중에서 가장 많은 관심을 받은 것은 <code>휴대폰 인증</code>이었어요. 이번 글에서는 왜 휴대폰 인증이 나오게 되었는지, 그리고 이를 도입할 때 고려해야 할 점들은 무엇이 있는지 알아보려고 해요.</p>
<h1 id="왜-휴대폰-인증을-고려하게-되었는가">왜 휴대폰 인증을 고려하게 되었는가?</h1>
<p>로그인 및 회원가입 과정에서 사용자의 신원을 명확히 하고, 사용자가 여러 개의 계정을 만드는 것을 방지하는 것은 서비스의 품질을 높일 수 있어요. 여러 개의 계정을 만드는 것이 쉽다면 아래와 같은 부작용이 발생할 수 있어요.</p>
<ul>
<li><p>사용자가 여러 개의 계정을 만들어 서비스를 악용할 가능성이 있어요. 예를 들어, 가짜 리뷰를 남기거나, 커뮤니티 형태의 서비스인 경우 특정 분위기를 조성하는 등의 문제를 일으킬 수 있어요.</p>
</li>
<li><p>모임 생성 및 가입 기능이 있는 서비스의 경우, 사용자가 책임감을 가지는 것이 중요할 수 있어요. 만약 한 사용자가 여러 개의 계정을 생성하지 못한다면 조금 더 신중하게 서비스를 이용할 것이라고 기대할 수 있어요.</p>
</li>
</ul>
<p>이러한 문제들을 해결하기 위해 팀원들은 휴대폰 번호를 통한 본인 인증이 효과적이라고 생각했어요. 대부분의 사용자는 하나의 휴대폰 번호만 가지고 있기 때문에 다중 계정 생성이 어렵기 때문이에요.</p>
<h2 id="휴대폰-인증의-장점">휴대폰 인증의 장점</h2>
<ul>
<li><p><code>식별성</code> : 휴대폰 번호는 일반적으로 하나씩만 소유하는 경우가 많기 때문에 사용자를 식별하기 쉬운 편에 속해요.</p>
</li>
<li><p><code>책임감</code> : 결국 여러 개의 계정을 만들기 어렵기 때문에 각 사용자에게 책임감 있는 행동을 기대할 수 있어요.</p>
</li>
<li><p><code>익숙함</code> : 거의 모든 사용자가 휴대폰을 가지고 있으며, 휴대폰 인증 절차에 익숙해요.</p>
</li>
</ul>
<h2 id="휴대폰-인증의-단점과-고려-사항">휴대폰 인증의 단점과 고려 사항</h2>
<p>하지만, 늘 그렇듯 단점 하나 없는 만능 방법은 없는 것 같아요. 우선 휴대폰 인증이 만능은 아니에요. 도입 전에 몇 가지 단점을 인지해야 해요.</p>
<ul>
<li><p><code>비용</code> : SMS를 통한 인증 과정은 비용이 발생해요. 특히, 서비스가 성장함에 따라 인증 요청이 늘어나면, 이 비용도 함께 증가해요.</p>
</li>
<li><p><code>번호 변경</code> : 사용자가 휴대폰 번호를 변경하는 경우, 재인증이 필요하게 돼요. 또한 이전 번호 사용자가 서비스를 이용한 경험이 있는 경우 현재 사용자는 계정 생성에 불편함을 느낄 수 있어요.</p>
</li>
<li><p><code>사용자 이탈</code> : 본인 인증 과정이 번거롭다고 느껴질 경우, 일부 사용자는 회원가입 자체를 포기할 수 있어요.</p>
</li>
</ul>
<h3 id="이메일-인증은요">이메일 인증은요?</h3>
<p><code>본인 인증</code> 과정은 사용자 이탈의 요소가 될 수 있어요. 그래서 쉽게 이 과정을 넘길 수 있는 <code>소셜 로그인</code>과 이메일을 기준으로 통합 계정을 제공하자는 의견도 나왔어요. 하지만 사용자가 각 SNS 계정에 설정한 이메일이 다를 경우(카카오는 네이버 이메일, 페이스북은 구글 이메일로 설정 등) 같은 사용자로 분류하기 어렵고, 이메일도 사실 쉽게 만들 수 있는 요소이기 때문에 팀 내부에서 만족하는 방법이 되진 못했어요.</p>
<h1 id="휴대폰-인증">휴대폰 인증</h1>
<p>대부분의 사용자는 휴대폰 인증 과정이 익숙하다고 생각해요. 제 메시지 수신함에 쌓인 인증번호만 봐도 수많은 서비스에서 사용하고 있다는 것을 알 수 있었어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/478215a1-c6ce-412c-9d25-1e64171eb2cf/image.png" alt=""></p>
<p>휴대폰 인증을 도입할 경우, 사용자는 아래와 같은 과정을 경험해요.</p>
<ol>
<li>사용자가 회원가입 시 휴대폰 번호를 입력해요.</li>
<li>서버는 입력된 번호로 SMS 인증번호를 전송해요.</li>
<li>사용자는 받은 인증번호를 웹 사이트에 입력해요.</li>
<li>서버는 인증번호를 확인한 후, 사용자를 인증하고 회원가입 절차를 완료해요.</li>
</ol>
<p>사실 이 글을 읽는 분들은 이 과정을 이미 알고 있는 경우도 많을 것 같아요. 우리에게 익숙하고 더 만족스러운 인증 과정을 진행할 수 있기 때문에 팀원 분들이 제안했을 것 같아요. </p>
<p>팀 내에서 휴대폰 인증을 선택하고 싶은 이유와 장단점, 흐름을 간단하게 알아봤어요. 이제 발생할 수 있는 문제들을 알아보려고 해요.</p>
<h2 id="비용">비용</h2>
<p>우선 SMS를 통한 인증 과정은 비용이 발생해요. 도입 이후 개발 단계에서 테스트만 하더라도 요금이 발생해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/14e201a6-f798-405a-8a61-b8d432939cce/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/a583f82f-8cd0-40be-aed6-694c4c451b53/image.png" alt=""></p>
<p>위 사진들은 각각 <a href="https://imweb.me/appstore?app=adult_phone">본인 인증 과정까지 포함된 서비스</a>와 <a href="https://coolsms.zendesk.com/hc/ko/articles/115001071551-%EB%AC%B8%EC%9E%90%ED%83%80%EC%9E%85%EB%B3%84-%EB%B9%84%EC%9A%A9">SMS 발송 API</a>의 요금 표에요. 직접 인증 과정을 구현하더라도 SMS 발송 건당 요금이 부가되는 것을 볼 수 있어요. 찾아보면 약간의 무료 크레딧을 제공하는 업체도 있지만, 인증 과정 테스트만 하더라도 모두 사용될 양을 제공해요. </p>
<p>팀원 모두 학생이기 때문에 비용이 발생한다는 것은 큰 단점 중 하나에요.</p>
<h2 id="번호-변경">번호 변경</h2>
<p>사용자의 휴대폰 번호가 변경되는 경우를 대비하지 않는다면, 예전에 <code>A</code>라는 번호를 사용하던 사용자의 계정과 (번호 변경 후 ) 나중에 똑같은 <code>A</code>라는 번호를 사용하게 될 사용자의 계정이 잘못 통합될 수 있어요.</p>
<p>이는 개인정보 보호 측면에서도 큰 문제이기 때문에 대비해야 해요.</p>
<p>다른 서비스는 어떻게 이 문제를 대처하고 있는지 알아봤어요.</p>
<ul>
<li><a href="https://www.daangn.com/wv/faqs/167">&quot;가입한 적 없는데 계정이 있다고 나와요&quot; - 당근</a></li>
<li><a href="https://www.daangn.com/wv/faqs/3">&quot;휴대전화번호를 변경하고 싶어요. 어떻게 변경하나요?&quot; - 당근</a></li>
</ul>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/c3028d7b-0d68-4598-b7f3-4ee5ce2378c5/image.png" alt=""></p>
<p>휴대폰 번호 기반으로 가입할 수 있는 당근은 가입 이후에 휴대폰 번호가 변경된 경우 설정 탭 또는 문의를 통해 계정에 반영할 수 있도록 하며, 만약 새로운 번호로 처음 가입하려는데 이미 가입된 계정이 있는 경우에는 이미 존재하는 계정을 삭제하는 방식을 선택한 것 같아요.</p>
<p>휴대폰 번호는 겹칠 수 없고, 변경되는 경우도 많지 않기 때문에 당근과 같은 프로세스를 채택한다면 이 문제를 해결할 수 있을 것 같아요.</p>
<h1 id="그러면-좋은거-아니야">그러면 좋은거 아니야?</h1>
<p>사실 팀원 모두가 대부분의 관점에서 휴대폰 인증 방식이 최선의 선택이라고 생각했어요. 하지만 여전히 해결되지 않은 문제가 있어요. 바로 <strong>비용</strong> 문제에요. </p>
<pre><code>팀원 A: 어느 정도의 비용은 괜찮지 않을까요?
팀원 B: 테스트를 위한 계정은 미리 만들어두고 휴대폰 인증 테스트는 초반에만 진행하고 안쓰는 방향은 어때요?
팀원 C: 테스트 과정에선 절약할 수 있지만, 배포 이후에는 저희가 컨트롤하기 힘들지 않을까요?
...</code></pre><p>조금 멀리서 보면 <strong>&quot;아직 안해봤잖아 너네들&quot;</strong>, <strong>&quot;해보고 말해&quot;</strong> 라는 생각이 들 수 있어요. </p>
<p>어떻게 보면 맞는 말이에요. 배포 이후에 팀원들의 지인 100명이 가입하는 경우 (건당 20원 기준) <strong>2,000원</strong>이 부과돼요. (생각보다 작은 비용일 수 있어요.) 또한, 포인트 충전 형식으로 결제하는 경우 포인트를 모두 소진했을 때 발송되지 않도록 설정할 수도 있어요.</p>
<p>하지만, 귀한 사용자 한분이 포인트 소진으로 가입하지 못하는 경우도 두렵고, 악의적인 사용자가 휴대폰 인증만 반복적으로 요청하여 많은 비용이 발생될까 두렵기도 해요.</p>
<p>추가로 저는 개발자가 <strong>&quot;모든 사용자가 우리가 원하는 대로 사용할 것 같은데?&quot;</strong> 와 같은 안일한 태도를 가진다면 언젠가 큰 문제가 발생할 수 있다고 생각했기 때문에 모든 걱정들이 공감이 됐어요.</p>
<h2 id="반대로-해볼까">반대로 해볼까?</h2>
<p>남들과는 다른 방향의 인증 방식을 사용하는 쏘카의 기기인증 방식이 있다는 것을 알게 되었어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/c5f23b13-6fca-48a5-bd37-a3841d4c44f2/image.png" alt=""></p>
<p>인증 메시지 보내기를 누르면 자동으로 받는 사람과 보낼 내용이 자동으로 입력되며 그대로 인증 메시지를 보내면 인증되는 방식이에요. 추가로 SMS 인증번호 발송을 통한 인증도 함께 진행하는 것 같아요.</p>
<blockquote>
<p> iOS에서는 SMS MO(Mobile Oriented) 인증 시스템을 통해 기기 소유 여부를 확인하고 안드로이드에서는 유심(USIM, 범용가입자식별모듈) 조회 및 인증을 거쳐야합니다. <a href="https://blog.socar.kr/10301">쏘카 블로그</a></p>
</blockquote>
<p>쏘카는 해당 방식을 활용하여 기기 본인 소유의 휴대폰으로만 쏘카를 이용할 수 있는 인증 시스템을 도입했어요. 가입부터 서비스 이용까지 모든 과정을 <code>본인 소유의 기기</code>를 통해서만 가능하도록 구현한 것이에요. </p>
<p>저는 이 글을 보고 인증번호가 담긴 SMS를 사용자에게 보내는 것이 아니라, 사용자가 직접 보내게끔 구현한다면 거의 무료로 휴대폰 인증을 할 수 있지 않을까? 라는 생각이 들어서 여러 가지 테스트를 진행했어요.</p>
<p>각 통신사의 저렴한 요금제를 사용하더라도 통화와 문자는 기본 제공(무제한)인 시대이기 때문에 큰 무리가 되진 않을 것 같다고 생각했어요.</p>
<h2 id="예상-흐름">예상 흐름</h2>
<p>사용자가 직접 메시지를 전송하게 만드는 방법으로 휴대폰 인증을 할 수 있지 않을까? 라는 생각으로 빠르게 생각해본 시나리오에요.</p>
<ol>
<li>사용자가 휴대폰 번호를 입력하고 인증 메시지 보내기 버튼을 누른다.</li>
<li>휴대폰 번호는 서버에 전송되고 서버는 해당 휴대폰 번호를 위한 무작위 값(인증 번호)을 생성하여 저장 및 응답한다.</li>
<li>사용자는 완성된 문자 템플릿 그대로 문자를 전송한다. 문자 내용에는 무작위 값(인증 번호)가 포함된다.</li>
<li>서버는 문자를 전송한 곳(사용자의 핸드폰 번호)과 인증 번호를 비교하여 결과를 응답한다.</li>
</ol>
<p>위 과정에서 중요하다고 생각하는 요소는 <code>지정된 문자를 보내게 만들기</code>, <code>사용자가 보낼 곳</code>, <code>인증 번호 생성 및 비교</code>에요.</p>
<h3 id="지정된-문자를-보내게-만들기">지정된 문자를 보내게 만들기</h3>
<p>모바일 기기에서 브라우저를 통해 서비스를 사용하는 것을 생각하고 있기 때문에 그것을 기준으로 찾아봤어요. (추후 앱으로 만들 수도 있어요.)</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;테스트&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;a href=&quot;sms:{보낼 곳}?body=인증번호입니다.&quot;&gt;인증 메시지 보내기&lt;/a&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
<p>이 코드는 HTML에서 SMS 링크를 만드는 방법이에요. <code>sms:</code>는 모바일 기기에서 특정 번호로 SMS(문자 메시지)를 보낼 수 있는 링크를 생성하는 프로토콜이에요.</p>
<p><strong>href 속성</strong>에 <code>sms:</code>를 사용하여 링크를 클릭하면 SMS(문자 메시지)를 보낼 수 있도록 설정해요. <code>sms:</code> 뒤에는 보낼 곳을 지정하고, <code>?body=</code> 뒤에는 SMS 메시지 본문에 포함될 내용을 지정해요.</p>
<p>링크를 클릭하면 사용자의 기본 SMS 앱이 열리고, 지정된 보낼 곳과 메시지 본문이 미리 채워져요. 사용자는 이를 전송하기만 하면 돼요.</p>
<blockquote>
<p>실제로 브라우저 주소창에 <code>sms:보낼 곳?body=내용</code>을 입력하면 각 환경에 맞게 기본 SMS 앱이 열리는 것을 확인할 수 있어요.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/a0375ed4-8c31-4587-ba47-1ba75dc71cd0/image.gif" alt=""></p>
<h3 id="어디로">어디로?</h3>
<p>원래는 <code>sms:</code> 뒤에는 휴대폰 번호가 오는 것이 일반적이에요. 하지만 요즘 스마트폰의 기본 메신저 앱은 이메일 전송까지 가능해서 이메일 주소를 사용해도 큰 문제가 없어요. 다만, 이메일을 받는 사람이 휴대폰 전화로 답장을 보내는 것을 불가능해요.</p>
<p>저는 이런 인증 방법을 위해 테스트를 하고 있는 이유가 <strong>비용</strong>이기 때문에 인증을 위한 전화(유지 비용이 발생해요.)를 두는 것은 의미가 없다고 생각했고, 기본 메신저 앱으로 이메일 전송까지 가능하다는 점에서 이메일을 하나 만들어서 <code>인증 메시지 수신함</code>으로 사용하는 것이 더 괜찮다고 생각했어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/773b5814-e1f7-47ab-91db-34d5d54618db/image.png" alt=""></p>
<p>실제로 전송하면 <code>010xxxxxxxx(사용자의 휴대폰 번호)@이메일.com</code>와 비슷한 형태의 이메일 주소로 메시지가 전송되는 것을 확인할 수 있어요. 인증 번호를 전송한 사용자의 전화번호와 인증 번호 모두를 확인할 수 있고, 서비스를 위해 필요한 휴대폰 인증 요청 비용이 무료에 가깝기 때문에 좋다고 생각했어요.</p>
<h3 id="무엇을">무엇을?</h3>
<p><code>?body=</code>를 사용하여 미리 보내게 만들 내용을 지정할 수 있어요. 저는 서버에서 생성한 인증 번호를 내용으로 지정하면 될 것 같다고 생각했어요. 그림으로 표현하면 아래와 같아요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/77f1b37c-6e4d-4622-8ab2-f1e98937c25e/image.png" alt=""></p>
<p>사용자가 메시지를 전송하면 휴대폰 번호와 서버에서 만들어준 인증 번호까지 함께 확인할 수 있기 때문에 가능한 인증 방식일 것 같았어요.</p>
<h3 id="수신-확인하기">수신 확인하기</h3>
<p>그림을 살펴보면 메일함의 수신 내역을 확인하는 로직은 매우 중요해요. 사용자가 아무리 인증 메시지를 전송하더라도 메시지를 수신할 수 없다면, 인증할 수 없기 때문이에요. 간단하게 테스트가 목적이었기 때문에 Python을 사용하여 수신된 메일을 확인할 수 있는지 구현해봤어요.</p>
<blockquote>
<h3 id="코드를-보기-전-알면-좋아요">코드를 보기 전 알면 좋아요.</h3>
</blockquote>
<ul>
<li><code>IMAP (Internet Message Access Protocol)</code> : 이메일 서버에서 메시지를 가져오거나 관리할 수 있게 해주는 프로토콜이에요. 이 코드에서는 Gmail의 IMAP 서버를 사용해요.</li>
<li><code>imaplib 라이브러리</code> : Python에서 IMAP을 사용해 이메일을 처리할 수 있는 표준 라이브러리에요.</li>
<li><code>email 라이브러리</code> : Python 표준 라이브러리로, 이메일 메시지를 파싱하고 분석할 때 사용해요.</li>
</ul>
<pre><code class="language-python">import imaplib
import email
from email.header import decode_header

# Gmail 서버 정보
IMAP_SERVER = &quot;imap.gmail.com&quot;
IMAP_PORT = 993

# 로그인 정보
EMAIL_ACCOUNT = &quot;사용할 이메일 주소&quot;
PASSWORD = &quot;앱 비밀번호&quot;

def clean(text):
    # IMAP에서 사용하는 폴더 이름을 처리하기 위한 간단한 문자열 정리 함수
    return &quot;&quot;.join(c if c.isalnum() else &quot;_&quot; for c in text)

def main():
    # IMAP 서버에 연결하고 로그인
    mail = imaplib.IMAP4_SSL(IMAP_SERVER)
    mail.login(EMAIL_ACCOUNT, PASSWORD)
    mail.select(&quot;inbox&quot;)
    status, messages = mail.search(None, &#39;ALL&#39;)

    mail_ids = messages[0].split()

    # 가장 최근의 10개의 이메일만 가져오기
    for i in mail_ids[-10:]:
        status, msg_data = mail.fetch(i, &quot;(RFC822)&quot;)
        for response_part in msg_data:
            if isinstance(response_part, tuple):
                msg = email.message_from_bytes(response_part[1])

                # 이메일의 보낸 사람 정보 추출
                sender = decode_header(msg[&quot;From&quot;])[0][0]
                if isinstance(sender, bytes):
                    sender = sender.decode()

                print(f&quot;From: {sender}&quot;)

                # 이메일의 내용 추출
                if msg.is_multipart():
                    for part in msg.walk():
                        content_type = part.get_content_type()
                        content_disposition = str(part.get(&quot;Content-Disposition&quot;))

                        if &quot;attachment&quot; not in content_disposition:
                            try:
                                body = part.get_payload(decode=True).decode()
                                print(f&quot;Message snippet: {body[:100]}&quot;)
                            except:
                                pass
                else:
                    content_type = msg.get_content_type()
                    if content_type == &quot;text/plain&quot; or content_type == &quot;text/html&quot;:
                        body = msg.get_payload(decode=True).decode()
                        print(f&quot;Message snippet: {body[:100]}&quot;)

    # 연결 종료
    mail.close()
    mail.logout()

if __name__ == &quot;__main__&quot;:
    main()
</code></pre>
<p>이 코드는 Gmail의 받은 편지함에서 최근 10개의 이메일을 가져와서 보낸 사람과 이메일의 일부 내용을 출력하는 코드에요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/5a062f6a-0220-49f4-9e35-395619fa7d0e/image.png" alt=""></p>
<p>실제 실행하여 결과를 보면 위에서 직접 전송했던 메시지를 확인할 수 있었어요. (From: 부분은 제 휴대폰 번호라서 가렸어요.)</p>
<h2 id="마무리">마무리</h2>
<p>회원가입 및 인증 방식을 구현하기 전, 팀의 상황을 기반으로 최선의 선택을 하고 싶어서 많은 고민을 했던 것 같아요. 특히 <strong>비용</strong> 문제를 해결하기 위해 사용자가 직접 메시지를 전송하게 만드는 인증 방식을 적용해보려고 해요. 가능성을 확인하기 위해 우선 간단한 단위 테스트를 진행했고, 실제로 개발을 하면서 보안 문제, 사용성 문제 등 우려되는 부분이 있다면 내용을 추가해보려고 해요.</p>
<p>긴 글 읽어주셔서 감사합니다.</p>
<h2 id="더-고민하면-좋을-부분">더 고민하면 좋을 부분</h2>
<ul>
<li><p>간단하게 인증 번호를 서버에서 전송하고 이를 입력하게 만드는 방법보다 구현이 어려운 편이에요. 또한 얼마나 이점이 있는지 아직까지 확신할 수 없어요. 즉, <code>언제라도 다시 변경될 수 있기 때문에 그 부분을 염두하여 개발</code>해야 할 것 같아요.</p>
</li>
<li><p>기존 인증 방식보다는 생소하기 때문에 사용자의 이탈을 막을 수 있게 <code>가이드라인</code>을 이해하기 쉽게 제공하는 것이 중요할 것 같아요.</p>
</li>
<li><p>실제 구현이 가능한지를 확인하기 위해 간단한 html과 python을 사용했어요. 실제 우리가 개발할 때는 어떤 기술로 만들지, 가능한 로직인지는 직접 해봐야 알 것 같아요.</p>
</li>
<li><p>메일을 생성할 때 <code>010xxxxxxxx</code>의 형태로 만들 수 있는가? 구글 이메일은 불가능하다고 해요.</p>
</li>
<li><p>보안 문제가 발생할 시나리오를 생각해보고 회의를 통해 좋은 인증 방식인지 고민하는 시간이 필요할 것 같아요.</p>
</li>
</ul>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://www.daangn.com/wv/faqs/167">&quot;가입한 적 없는데 계정이 있다고 나와요&quot; - 당근</a></li>
<li><a href="https://www.daangn.com/wv/faqs/3">&quot;휴대전화번호를 변경하고 싶어요. 어떻게 변경하나요?&quot; - 당근</a></li>
<li><a href="https://blog.socar.kr/10301">쏘카가 본인 인증 시스템을 강화합니다 - 쏘카</a></li>
<li><a href="https://stackoverflow.com/questions/31342336/opening-sms-app-from-feature-phones-mobile-browser">Opening SMS app from feature phone&#39;s mobile browser? - stackoverflow</a></li>
<li><a href="https://imweb.me/appstore?app=adult_phone">휴대폰 본인인증 기능 제공 서비스 - imweb</a></li>
<li><a href="https://coolsms.zendesk.com/hc/ko/articles/115001071551-%EB%AC%B8%EC%9E%90%ED%83%80%EC%9E%85%EB%B3%84-%EB%B9%84%EC%9A%A9">SMS 발송 API - coolsms</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[어떻게 회원가입(+로그인) 하게 만들까?]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%96%B4%EB%96%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B0%A9%EC%8B%9D%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C-2</link>
            <guid>https://velog.io/@hyeok_1212/%EC%96%B4%EB%96%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B0%A9%EC%8B%9D%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C-2</guid>
            <pubDate>Tue, 27 Aug 2024 16:17:29 GMT</pubDate>
            <description><![CDATA[<p>많은 서비스에 구현된 로그인 기능을 어떻게 구현하면 좋을 지 고민하며 작성하는 글이에요. 또한, 왜 그렇게 하고 싶은가?를 포함하여 프로젝트 팀원에게 제안하기 위한 글이에요.</p>
<p><a href="https://velog.io/@hyeok_1212/%EC%96%B4%EB%96%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B0%A9%EC%8B%9D%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C">저번 글</a>에서는 서버의 로그인 기능을 어떻게 구현할 것인가에 대해 살펴봤어요. (JWT, session, cookie 등) 이번 글에서는 회원가입 및 로그인 방식에 대해 알아보고 사용자 경험에 대해 살펴보려고 해요. 이후 팀원들과 함께 제일 적합한 방식은 무엇인지에 대해 이야기할 예정이에요.</p>
<h1 id="회원가입">회원가입</h1>
<p>요즘 대부분의 웹 서비스를 원활하게 사용하기 위해서는 해당 서비스의 회원이 되어야 해요. 물론 회원이 아닌 경우에도 서비스를 이용할 수 있는 경우도 있지만, 서비스 이용 내역이나 개인 맞춤화 데이터들을 저장하고 활용하기 위해서는 회원가입이 필요해요.</p>
<p>회원가입을 진행하는 과정은 크게 2가지로 분류돼요.</p>
<ul>
<li>자사 서비스 회원가입</li>
<li>SNS 계정을 통한 회원가입</li>
</ul>
<p>가끔 공공기관에서 금융 인증서를 통한 로그인 방법도 존재하지만, 가장 많이 사용되는 2가지 방식을 알아볼게요.</p>
<h2 id="자사-서비스-회원가입">자사 서비스 회원가입</h2>
<p>직접 아이디와 비밀번호, 선택적으로 입력 가능한 개인정보 등을 입력하여 회원으로 등록한 후 가입된 계정으로 로그인하여 서비스를 이용하는 방법이에요. 어디서나 쉽게 볼 수 있었던 가입 방법이에요. 아래는 네이버 회원 가입 과정을 캡쳐한 것이에요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/35818bb8-9016-45a8-ae61-5b8f72f3aab0/image.png" alt=""></p>
<p>사진에서 볼 수 있듯이 회원가입을 진행하면 약관에 동의해야 하며, 휴대전화 인증 등의 번거로운 과정을 거쳐야 해요. 추가적으로 보안성 문제를 이유로 비밀번호를 어렵게 만들기를 권장하고 있어요. 덕분에 로그인 할 때 비밀번호를 까먹고 재설정하는 경험을 겪어본 적이 있을 거에요. 또한 사용자들은 우리 서비스 외에도 다양한 서비스에 회원으로 가입되어 있기 때문에 이를 기억하기는 더더욱 쉽지 않아요.</p>
<blockquote>
<p> Anrain이라는 소셜 미디어 ROI 솔루션 기업 2013년의 자료에 따르면, 약 80% 이상의 인터넷 이용자들이 각각 다른 패스워드를 기억하고 관리하고 있으며, 92% 이상의 인터넷 이용자들이 로그인 아이디나 비밀번호를 잊어 이를 복구하거나 재설정하기보다 아예 해당 서비스를 사용하는 것을 포기한다고 조사되었다. 이와 같은 비밀번호 피로(Password fatigue)로 인한 고객이탈 혹은 고객유입 저해 현상은 기업 측면에서 잠재적 위험 요소로 간주할 수 있다. 
<a href="https://www.dbpia.co.kr/journal/articleDetail?nodeId=NODE07996658">구소연, 고준 (2018). &quot;소셜 로그인 서비스 태도에 영향을 미치는 요인: 개인 혁신성의 조절효과&quot;</a></p>
</blockquote>
<p>자료에서 볼 수 있듯이 이러한 회원가입 방식은 신규 사용자의 유입을 막거나 비밀번호를 까먹는 것처럼 사용자에게 부정적인 경험을 제공할 수 있어요.</p>
<h3 id="그런데-왜">그런데 왜?</h3>
<p>단순하게 신규 유입, 사용자 경험을 생각하면 자사 서비스의 회원가입 기능보다 SNS 계정으로 회원가입하는 것이 좋아보여요. 그렇다면 언제, 왜? 회원가입 기능을 직접 구현하면 좋을까요?</p>
<ul>
<li><p><code>독립성 유지</code> : SNS 회원가입을 제공할 경우, <code>특정 플랫폼에 의존</code>하게 돼요. 이는 해당 SNS 서비스가 중단되거나 정책이 변경될 경우, 사용자 로그인 방식에 큰 영향을 미칠 수 있어요. 일반 로그인은 이러한 제약에서 벗어나 <code>독립적인 사용자 관리</code>가 가능해요. 직접 회원가입을 통해 수집된 데이터라면 동의 여부에 따라 직접 관리할 수 있어요. (명확한 데이터 소유권을 주장할 수 있어요!)</p>
</li>
<li><p><code>UX/UI, 디자인 유지</code> : 사용자에게 회원가입과 로그인까지 모든 과정에서 우리 서비스의 UX/UI 디자인과 기능을 온전히 제공할 수 있어요. 이는 사용자 경험을 최적화하고, 브랜드 일관성을 유지하는 데 중요한 역할을 해요. 예를 들어, 화려한 색상의 동그란 디자인을 채택한 서비스에서 SNS 로그인을 위해 단색의 각진 Google 로그인이 나온다면, 어색하다고 느낄 수 있어요.</p>
</li>
<li><p><code>다양한 선택지 제공</code> : 모든 방식의 회원가입을 제공해요. 일부 사용자들은 개인정보 보호나 개인적인 이유로 SNS 회원가입을 선호하지 않을 수 있어요. 그래서 일반 회원가입도 제공하면서 다양한 선택지를 제공해요.</p>
</li>
</ul>
<p>이외에도 SNS를 제공하는 기업보다 더 강력한 보안이 필요한 경우 직접 고객의 개인정보를 관리해야 하는 경우 사용할 수 있을 것 같아요.</p>
<h2 id="sns-계정으로-회원가입">SNS 계정으로 회원가입</h2>
<p>SNS 계정을 통한 회원가입 및 로그인은 사용자 입장에서 반복적인 등록을 피하게 하여 사용자 편의성을 높여서 서비스 제공자 입장에서는 신규 회원을 빠르게 확보, 참여와 재방문율을 높일 수 있는 중요한 장점을 제공해요. 또한 소셜 로그인의 방식을 이용할 때 개인정보를 많이 제공하지 않아도 된다는 것도 큰 장점이에요.</p>
<p>저도 SNS 계정을 통해 서비스를 이용하는 빈도가 늘어났어요. 이유는 번거로운 회원가입 과정과 계정을 직접 기억하고 있어야 되기 때문이에요. 물론 기기 내부에 계정을 저장해두고 인증(Face ID, 지문 인식 등)을 통해 쉽게 로그인할 수 있지만, 자주 사용하는 계정(구글, 카카오 등)으로 쉽게 로그인 하는 과정이 더욱 간편하기 때문에 많이 사용했던 것 같아요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/6f5f953c-209a-430e-a036-aed6cfeb586b/image.png" alt=""></p>
<p>위 이미지는 대한항공 사이트의 로그인 화면이에요. 이처럼 요즘은 다양한 곳에서 SNS 계정을 통한 회원가입 및 로그인을 제공하고 있어요.</p>
<h3 id="소셜-로그인-도입-결과-분석">소셜 로그인 도입 결과 분석</h3>
<blockquote>
<p>Yelp는 2009년 7월에, TripAdvisor는 2010년 12월에 페이스북 로그인 기능을 도입했기 때문에, 기간 전후 12개월의 리뷰를 비교하여 어떤 변화가 있었는지 보았습니다. 분석 결과, 소셜 로그인을 통한 새로운 가입자가 증가하고 기존 가입자도 더 많은 리뷰를 남기게 되어 리뷰의 양은 증가했습니다. 그리고 연구 전 예상했던 대로 리뷰에서 더 감정에 관련된 단어가 더 많이 등장하고, 분석적이거나 또는 논리적인 문장 구조를 만드는 단어는 더 줄어들었습니다.</p>
<p>이와 더불어 부정적인 단어를 덜 사용하게 되는 것으로 나타났습니다. 사용자가 직접 작성하는 리뷰가 서비스의 가치를 만든다는 점에서 양이 증가한다는 점은 주목할 만합니다. 하지만 이전 연구들을 돌이켜 보았을 때, 실질적으로 다른 사용자들에게 도움이 되는 리뷰는 덜 감정적이고 부정적인 리뷰라는 점에서 리뷰의 질이 떨어지게 되지는 않는지 우려할 부분이 있습니다. 따라서 소셜 로그인은 양날의 칼이라고 볼 수 있으며, 서비스 운영자들 입장에서는 이 점을 유의하여 시스템을 디자인할 필요가 있겠습니다. <a href="https://blog.naver.com/kcbpr/221193912467">카이스트MBA 추천 해외 논문, 소셜 로그인 서비스의 양면성- KCB Insights</a></p>
</blockquote>
<p>이런 측면에서도 한번 살펴보면 좋을 것 같아서 블로그 글을 인용했어요.</p>
<h3 id="그럼-당연히-이거-아냐">그럼 당연히 이거 아냐?</h3>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/58705521-cda4-46c4-8ace-d9431ae09c3e/image.png" alt=""></p>
<ul>
<li><a href="https://cuk.or.kr/information/01_view.asp?no=872">자료 출처</a></li>
</ul>
<p>이제 막 시작하는 서비스라면 신뢰가 없고, 비교적 보안이 좋지 않을 수 있다는 인식이 있기 때문에 신규 유입, 사용자 경험을 생각하면 SNS 계정을 통한 회원가입 및 로그인 기능을 사용하는 것이 좋아보여요. 그러면 무조건 SNS 계정을 통한 회원가입 기능을 제공해야 할까요? (사실 소셜 로그인을 사용하는 것이 좋아보이는데, 단점을 알아야 생길 문제를 예방하거나 대처하기 쉬울 것 같다고 생각했어요.)</p>
<ul>
<li><p><code>SNS 종속성</code> : 서비스 제공자는 SNS 플랫폼의 정책 변화나 서비스 중단에 종속돼요. 예를 들어, SNS 로그인을 진행할 때마다 10원씩 납부하라고 한다면 서비스 이용에 차질이 생기거나 추가적인 비용 문제도 발생할 수 있어요.</p>
</li>
<li><p><code>보안 문제</code> : 자주 사용하던 SNS 계정에 문제(탈취, 삭제 등)가 생긴 경우 여러 서비스 이용에 어려움을 겪을 수 있어요.</p>
</li>
<li><p><code>어떤 계정으로 했더라..</code> : 소셜 로그인을 제공하는 대부분의 서비스는 여러 개의 SNS 계정을 통해 가입할 수 있도록 지원해요. 어떤 SNS 계정으로 로그인하더라도 각각 누구인지 구분할 수 있다면, 통합하여 제공할 수 있어요. 하지만 모든 서비스가 그렇게 구현되어 있지는 않아요. 어떤 계정으로 서비스를 이용했는지 몰라서 여러 개의 계정으로 가입되는 불편한 경험을 겪을 수 있어요. (이에 대한 좋은 해결책은 <a href="https://brunch.co.kr/@toqha7822/7">관련 포스팅</a>를 읽어보면 좋을 것 같아요.)</p>
</li>
</ul>
<p>사실 SNS 계정을 통한 소셜 로그인 기능에 가장 큰 단점은 계정 하나로 연결된 모든 서비스를 쉽게 이용할 수 있다는 것이에요. 해당 서비스의 간편결제까지 연결된 경우라면 SNS 계정 탈취 하나로 큰 문제가 발생할 수 있어요.</p>
<h3 id="연결-관리-참고">연결 관리 (참고)</h3>
<p>카카오 계정을 예시로 가져왔어요. 계정 설정에서 <code>연결된 서비스 관리</code> 탭에 들어가면 제가 카카오 계정을 통해 가입한 서비스들을 볼 수 있어요. 보안을 위해 가끔씩 확인하시면 좋을 것 같아요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/84e0b671-95de-40f7-8909-5ff8a8659ef7/image.jpeg" alt=""></p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/01bd9e60-ab08-4280-b1e8-c86c171bfe86/image.jpeg" alt=""></p>
<p>저도 확인해보니 기억에 없는 서비스들에 카카오 계정을 통해 가입되어 있었어요. 이처럼 카카오 계정 하나로 많은 서비스에 접근할 수 있게 되기 때문에 소셜 로그인을 주로 사용하시는 분들은 SNS 계정을 잘 관리해야 해요. 이러한 것도 사용자에게 부담이 될 수 있을 것 같아요.</p>
<h2 id="정리하면">정리하면</h2>
<p>자체적인 회원가입의 경우 회원가입 및 로그인 화면까지 서비스에 맞게 구현할 수 있기 때문에 언제나 <code>동일한 UX/UI 경험</code>을 제공할 수 있고, 개인적인 이유로 SNS 계정을 사용하고 싶지 않은 경우 <code>대체 선택지</code>가 될 수 있어요. 추가적으로 SNS 플랫폼에 종속되지 않기 때문에 SNS 계정을 탈퇴하거나 보안 문제가 발생한 경우 서비스에는 영향을 끼치지 않을 수 있어요. 하지만 사용자 인증(이메일, 휴대전화 인증 등)을 위한 시스템을 <code>직접 구축</code>해야 하며, 가입을 진행하다가 많은 개인정보 입력 요구에 <code>사용자가 이탈</code>하게될 가능성이 높아요.</p>
<p>SNS 계정을 통한 회원가입의 경우 사용자가 직접 계정을 기억하고 있지 않아도 되며, 평소 사용하던 SNS 계정을 통해 <code>아주 쉽게 서비스에 가입</code>할 수 있고, 민감한 정보를 직접 작성하지 않기 때문에 가입 시 부담을 줄일 수 있어요. 즉, <code>새로운 사용자가 쉽게 가입할 수 있고</code> 이후에도 SNS 계정으로 쉽게 로그인할 수 있기 때문에 사용자 입장에서 서비스 이용을 위한 <code>계정 관리 부담이 줄어들어요</code>. 하지만, SNS 플랫폼에 종속되어 생길 수 있는 문제 때문에 이를 분리하고 싶은 사용자도 나타날 수 있어요.</p>
<p>저는 <code>서비스 접근성</code> 하나 만으로 소셜 로그인을 도입할 이유가 충분하다고 생각해요. 하지만, 앞서 살펴본 것처럼 SNS 플랫폼 종속성 관련 문제가 발생하더라도 쉽게 대처할 수 있게 만들면 좋을 것 같아요. (예를 들어, SNS 계정으로 가입했지만 일반 계정으로 변경할 수 있다거나 등)</p>
<h1 id="소셜-로그인">소셜 로그인?</h1>
<p>지금까지 말한 SNS 계정을 통한 회원가입이 소셜 로그인이라고 할 수 있어요. 소셜 로그인 구현이라고 구글에 검색하면 <code>OAuth</code> 라는 키워드를 아주 많이 볼 수 있어요.</p>
<blockquote>
<p>구글, 페이스북, 트위터와 같은 다양한 플랫폼의 특정한 사용자 데이터에 접근하기 위해 제3자 클라이언트(우리의 서비스)가 사용자의 접근 권한을 위임(Delegated Authorization)받을 수 있는 표준 프로토콜이다.</p>
<p>쉽게 말하자면, 우리의 서비스가 우리 서비스를 이용하는 유저의 타사 플랫폼 정보에 접근하기 위해서 권한을 타사 플랫폼으로부터 위임 받는 것 이다. 
<a href="https://hudi.blog/oauth-2.0/">OAuth 2.0 포스팅</a></p>
</blockquote>
<p>제가 작성한 내용은 아니지만 개념, 동작 메커니즘 등 이해하기 쉽게 설명되어 있어요. 만약 OAuth에 대한 개념을 잘 모르시는 경우 먼저 읽으시면 도움이 될 것 같아요!</p>
<h2 id="주의사항">주의사항</h2>
<p>소셜 로그인은 만능이 아니에요. SNS 계정을 통해 인증을 진행하고, 해당 SNS 계정 주인의 정보(이메일, 이름, 나이 등)를 가져오는 것까지가 소셜 로그인의 역할이라고 생각하시면 편할 것 같아요. 즉, 사용자가 SNS 계정으로 로그인하도록 구현하고 이후 로그인한 사용자의 정보를 SNS 플랫폼으로부터 제공받는 것이에요.</p>
<p>서버는 제공받은 사용자의 정보를 토대로 가입 여부를 확인하여 로그인 또는 회원가입 과정을 진행해야 해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/70c1d228-a7e8-4edb-b39e-1ac0b73b6a43/image.png" alt=""></p>
<h2 id="흐름">흐름</h2>
<p>사용자 입장에서 바라본 소셜 로그인 과정이에요. SNS 계정을 통한 가입 이후에 별도의 추가 로직(닉네임을 추가로 입력받는 등)이 없는 경우 매우 간단하게 로그인 또는 회원가입을 처리해요. 사용자는 미리 준비된 로그인 화면에서 SNS 계정으로 로그인하면 가입 또는 로그인 처리가 이루어져요. </p>
<p>즉, 서비스를 개발한다면 미리 준비된 로그인 화면, SNS 플랫폼과 협동할 서버, DB, 별도 서비스 로그인 방법(JWT, 세션 쿠키 등) 등을 직접 구현해야 해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/67f2d66e-604a-4e90-b402-de335d54f081/image.png" alt=""></p>
<h2 id="사용자-정보-저장-방법">사용자 정보 저장 방법</h2>
<p>사용자가 SNS 계정으로 로그인하면 SNS 플랫폼으로부터 <code>동의 여부</code>에 따라 사용자의 이름, 이메일, 나이 등을 제공해요. </p>
<p>만약 일반적인 회원가입으로 가입한 사용자와 SNS 계정으로 가입한 사용자를 통합시켜 관리하려면 <code>이메일</code>을 기반으로 같은 사용자인지 확인할 수 있을 것 같아요. 반대로 분리하여 관리하고 싶다면 테이블을 분리하거나 가입 경로를 저장하는 방법도 있을 것 같아요. </p>
<p>이처럼 주어진 정보를 어떻게 활용하고 저장하느냐에 따라 사용자 경험이 달라질 수 있어요. 이번에는 생각할 수 있는 시나리오를 살펴보고 비교해보려고 해요.</p>
<h3 id="통합-회원-관리">통합 회원 관리</h3>
<p>통합 회원 관리 방식은 사용자가 다양한 경로(일반 회원가입, 카카오톡, 구글 등)로 서비스를 이용할 때, 하나의 통합된 계정처럼 느끼게 제공하는 것이에요. 사용자가 여러 경로로 회원가입을 하더라도 하나의 계정으로 모든 서비스를 이용할 수 있다면, 중복 가입이나 별도의 계정 관리 부담을 덜 수 있어요.</p>
<p>서버 측면에서는 각 경로로 들어온 사용자의 정보를 기반으로 동일한 사용자임을 식별하고, 이를 바탕으로 통합된 계정으로 관리해야 해요. 이를 구현하기 위해 서버는 아래의 부분을 고려해야 할 것 같아요.</p>
<ul>
<li><p><code>고유 정보 수집 및 관리</code> : 통합 계정을 구현하려면, 각 회원가입 경로에서 <code>고유한 식별 정보</code>를 수집해야 해요. 주로 <code>이메일 주소</code>를 사용할 것 같아요. 이메일은 대부분의 회원가입 과정에서 필수적으로 입력되는 편이에요.
그러나 모든 SNS 계정에 동일한 이메일이 사용되지 않을 수 있어요. 저도 카카오톡은 네이버 이메일을 사용하고, 인스타는 구글 이메일을 등록하여 사용하고 있어요. 이를 동일한 사용자로 인식하는 것은 불가능하기 때문에 통합 회원 관리의 범위가 제한될 수 있어요.</p>
</li>
<li><p><code>복합 키를 통한 사용자 구분</code> : 동일한 사용자임을 파악하기 위해 이메일 대신 이름, 성별, 생년월일 등을 <code>복합 키</code>처럼 사용하여 사용하여 사용자를 구분할 수 있을 것 같아요. 그러나 이 경우 중복 가능성이 있기 때문에, 동일 정보를 가진 다른 사용자의 계정과 잘못된 통합이 발생할 수 있어요.
이런 문제를 방지하기 위해, <code>복합 키</code>를 사용할 때는 추가적인 확인 절차를 도입하는 것이 필요한데, 추가 구현에 대한 부담이 발생해요.</p>
</li>
<li><p><code>부가 서비스 통합</code> : SNS 계정을 통해 가입한 사용자는 해당 SNS의 부가적인 서비스도 사용할 수 있습니다. 예를 들어, 네이버 로그인 사용자는 네이버 캘린더, 카페 등의 부가 서비스에 접근할 수 있어요. 통합 회원 관리가 이루어진다면, 이러한 부가 서비스에 대한 권한 관리도 필요할 것 같아요. 즉, 부가 서비스를 사용하는 경우 통합 관리의 복잡성을 높일 수 있을 것 같아요.</p>
</li>
</ul>
<h3 id="각-가입-경로마다-분리">각 가입 경로마다 분리</h3>
<p>통합 회원 관리 방식과는 반대로, 각 경로로 가입한 사용자를 서로 다른 사용자로 관리할 수 있어요.</p>
<ul>
<li><p><code>독립적인 사용자 관리</code> : 각 경로로 들어온 사용자 계정을 독립적으로 관리함으로써, 통합 계정 관리에서 발생할 수 있는 중복, 잘못된 계정 통합 등의 문제를 방지할 수 있어요. 하지만 다수의 계정을 쉽게 만들 수 있는 환경이 조성되기 때문에 예를 들어, SNS 서비스인 경우 여러 사용자가 다른 사람인척 특정 분위기를 조성할 수 있으니 주의가 필요해요. (이런 부분이 중요한 서비스라면, 휴대폰 인증 등을 추가할 수 있을 것 같아요.) </p>
</li>
<li><p><code>사용자 선택의 폭</code> : 사용자가 특정 경로를 선호하는 경우, 해당 경로로만 서비스에 접근하고, 다른 경로는 사용하지 않는 선택을 할 수 있게 돼요. 개인정보 보호를 중요하게 생각하는 사용자에게 좋은 경험이 될 수 있어요.
그러나 이 방식은 사용자가 각 경로로 만들어진 다수의 계정을 관리해야 한다는 단점이 있어요. (이 서비스에는 구글 계정이었나...?)</p>
</li>
<li><p><code>서비스 제공자의 부담 감소</code> : 통합 계정 관리에 비해 구현이 상대적으로 간단하기 때문에 구현 부담이 줄어들어요. 각 경로로 들어온 사용자끼리 쉽게 그룹핑하여 관리할 수 있다는 것도 장점이 될 수 있을 것 같아요.</p>
</li>
</ul>
<p>각 가입 경로마다 별도로 관리하는 경우 사용자가 어떤 SNS 계정을 사용했는지 기억해야 하는 단점이 있어요. 하지만 이는 <a href="https://brunch.co.kr/@toqha7822/7">관련 포스팅</a>에서 다룬 방식을 활용하면 사용성이 더 좋아질 것 같아요. 추가로 인프런에서는 최근에 로그인 시 사용한 SNS 계정을 표시하여 알려주기도 해요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/6f131dbb-1c60-4c33-b3a1-8b4cf04f23b4/image.png" alt=""></p>
<h3 id="일반-로그인-기반에-소셜-인증">일반 로그인 기반에 소셜 인증</h3>
<p>일반 로그인 기반에 소셜 인증을 추가하는 방식을 말해요. 기본적으로 자사 서비스의 회원가입을 통해 사용자 계정을 생성하고, 이후에 소셜 인증을 통해 추가적인 보안이나 편의성을 제공해요.</p>
<ul>
<li><code>편의와 선택을 동시에</code> : 사용자는 기본적으로 자사 서비스의 회원가입을 통해 계정을 생성하고, 이 계정으로 로그인해요. 이후에 사용자가 원할 경우, SNS 계정을 연결하여 추가적인 인증 수단으로 활용할 수 있어요. (SNS 계정을 통한 빠른 로그인 사용 가능) 이때, 개인적인 이유로 SNS 계정 연동을 취소하고 싶다면 기본 서비스 계정이 존재하기 때문에 비교적 쉽게 연결을 취소할 수 있어요.</li>
<li><code>추가적인 보안</code> : 금융 서비스나 중요한 개인정보를 다루는 서비스에서 유용하게 사용될 수 있을 것 같아요. SNS 계정 인증을 통해 서비스 로그인 시 추가적인 보안 확인 절차를 거칠 수 있어요.</li>
</ul>
<h2 id="마무리">마무리</h2>
<blockquote>
<p><strong>직접 구현하는 회원가입</strong> vs <strong>SNS 계정을 통한 회원가입</strong> vs <strong>퓨전..?</strong></p>
</blockquote>
<p>각 구현 방식마다 <code>장단점</code>이 있어요. (다 좋은 것은 찾기 힘들어요..) 중요한 것은 만드려는 서비스를 이해하고, 사용자의 입장을 바탕으로 요구사항을 분석하는 것이 중요한 것 같아요. 분석에 시간을 아끼려면 <code>비슷한 서비스</code>에서 <code>어떤 이유로 어떤 방식을 택했는지</code> 찾아보는 것이 도움이 될 것 같아요.</p>
<p>우리 서비스는 모임 생성 및 가입 기능이 추가될 예정이에요. 따라서 한 명의 사용자가 다수의 계정을 만드는 것을 억제하여 계정에 책임감을 갖게 하고, 각 계정마다 신뢰도를 높이는 것이 중요하다고 생각해요. </p>
<p>다양한 가입 경로를 제공하는 것은 많은 사용자에게 편의를 제공하지만, 빠르게 개발해야 하는 경우 구현 및 사용자 관리에 큰 부담이 될 수 있어요. 따라서 SNS 계정 가입 경로를 2개 이하로 설정하고 많은 사람들이 사용하는 카카오와 네이버 또는 구글 중에서 선택하는 것이 좋을 것 같아요.</p>
<p>빠르게 만들고 확인해볼 서비스에서 회원가입을 직접 구현하는 것은 구현 부담, 보안 문제, 사용자 이탈 관점에서 관심이 크게 가진 않는 것 같아요.</p>
<p>팀원 분들의 생각이 궁금해요. 방식이 결정되면 구현 코드에 대해서도 함께 이야기하면 좋을 것 같아요.</p>
<p>긴 글 읽어주셔서 감사합니다. <a href="https://velog.io/@hyeok_1212/%EC%9D%B4%EB%A0%87%EA%B2%8C-%EB%B3%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D%EC%9D%84-%ED%95%B4%EB%B3%BC%EA%B9%8C">본인 인증(SMS 인증)에 대한 글</a>도 추가됐어요.</p>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://www.dbpia.co.kr/journal/articleDetail?nodeId=NODE07996658">구소연, 고준 (2018). &quot;소셜 로그인 서비스 태도에 영향을 미치는 요인: 개인 혁신성의 조절효과&quot;</a></li>
<li><a href="https://blog.naver.com/kcbpr/221193912467">카이스트MBA 추천 해외 논문, 소셜 로그인 서비스의 양면성- KCB Insights</a></li>
<li><a href="https://brunch.co.kr/@toqha7822/7">왜 꼭 소셜 로그인일까 - 이새봄</a></li>
<li><a href="https://cuk.or.kr/information/01_view.asp?no=872">네이버&gt;카카오&gt;페이스북&gt;구글 순 소셜로그인 통해 신규앱 이용 - 한국소비자연맹</a></li>
<li><a href="https://hudi.blog/oauth-2.0/">OAuth 2.0 개념과 동작원리 - Hudi</a></li>
<li><a href="https://developers.kakao.com/docs/latest/ko/kakaologin/common">Kakao Developers, 카카오 로그인 공식 문서</a></li>
<li><a href="https://www.reddit.com/r/memes/comments/zbvpaf/password_cant_be_the_same_as_your_old_password/">대표 이미지 출처</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[어떤 로그인 방식을 선택해야 할까?]]></title>
            <link>https://velog.io/@hyeok_1212/%EC%96%B4%EB%96%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B0%A9%EC%8B%9D%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@hyeok_1212/%EC%96%B4%EB%96%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B0%A9%EC%8B%9D%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Fri, 23 Aug 2024 16:01:05 GMT</pubDate>
            <description><![CDATA[<p>많은 서비스에 구현된 로그인 기능을 어떻게 구현하면 좋을 지 고민하며 작성하는 글이에요. 또한, <code>왜 그렇게 하고 싶은가?</code>를 포함하여 프로젝트 팀원에게 제안하기 위한 글이에요. </p>
<p>많은 서비스에서 제공하는 로그인 기능은 사용자에게 맞춤형 경험을 제공하는 등 다양한 비즈니스 요구사항을 충족시키는 핵심 요소에요. 많은 서비스에 구현되어 있는 만큼 간단한 검색만으로도 다양한 구현 방법이 있다는 사실을 알 수 있어요. 다방면으로 고민하고 비교하며 최선의 선택을 하려고 해요.</p>
<p>간단한 결정이라도 계속되는 고민으로 인해 의식의 흐름대로 진행될 수도 있다는 점 이해해주시면 좋을 것 같아요.</p>
<h1 id="로그인이란">로그인이란?</h1>
<p>로그인은 중요한 데이터와 리소스를 보호하기 위해 컴퓨터 시스템과 앱에서 사용되는 일반적인 보안 수단이에요.</p>
<p>로그인은 사용자가 특정 시스템(애플리케이션, 웹사이트 등)에 접근할 수 있도록 인증하는 절차라고 말할 수 있어요. 로그인 과정에서 사용자는 일반적으로 사용자 이름(또는 이메일 주소)과 비밀번호 같은 <code>자격 증명(credentials)</code>을 입력해요. 이 정보를 기반으로 시스템은 사용자가 등록된 사용자임을 확인하고, 해당 사용자가 시스템에 접근할 수 있는 권한을 부여해요.</p>
<p>로그인 과정에서 중요한 요소는 다음과 같아요.</p>
<ul>
<li>자격 증명 제출 (나를 증명하도록 하지!!)</li>
<li>인증 (우리 사용자가 맞다!!)</li>
<li>인증 상태 관리 (이미 인증된 사용자다!!)</li>
<li>접근 권한 (이 사용자는 이 리소스를 수정할 수 없다!!)</li>
</ul>
<blockquote>
<h3 id="인증-authentication">인증 (Authentication)</h3>
<p>유저가 누구인지 확인하는 절차, 회원가입하고 로그인 하는 것.</p>
<h3 id="인가-authorization">인가 (Authorization)</h3>
<p>유저에 대한 권한을 허락하는 것. <a href="https://velog.io/@aaronddy/%EC%9D%B8%EC%A6%9DAuthentication%EA%B3%BC-%EC%9D%B8%EA%B0%80Authorization">출처</a></p>
</blockquote>
<h1 id="요구사항">요구사항</h1>
<p>백엔드 파트인 제 입장에서 전달받은 프론트엔드 파트의 요구사항에 대해 설명할게요.</p>
<blockquote>
<p><code>서비스가 정말 필요한가?</code>에 대해 빠르게 알아보기 위해 빠른 개발도 큰 목적 중에 하나였어요. 그래서 백엔드 파트에서는 모두 경험이 있는 JWT 방식을 구현하고 로컬 스토리지에 저장하는 방식으로 구현하려고 했어요. 이러한 결정에 대해 프론트엔드 파트가 전달한 요구사항임을 알립니다!</p>
</blockquote>
<h2 id="프론트엔드-파트-요구사항">프론트엔드 파트 요구사항</h2>
<ul>
<li><code>SSR</code> 환경에서 <code>로컬 스토리지</code>에 접근할 수 없어요. (NextJS를 사용해요.)</li>
<li>또한, <code>로컬 스토리지</code>에 토큰이 있으면 js에서 접근할 수 있다보니 보안에 취약할 수 있어요.</li>
<li><code>MSA-모노레포 환경</code>을 고려하고 있어요. 서브 도메인끼리 로컬 스토리지, 세션 스토리지를 공유하지 않아요.</li>
</ul>
<h1 id="요구사항-분석">요구사항 분석</h1>
<p>우리가 구현하려는 로그인 기능은 여러 가지 비즈니스 및 기술적 요구사항을 충족시켜야 해요. 특히, 함께 작업하는 프론트엔드와 백엔드 사이의 상호작용을 이해해야 해요.</p>
<p>우선 <code>SSR</code> 환경에서 <code>로컬 스토리지</code>에 접근할 수 없다는 말에 대한 이해가 필요했어요.</p>
<p><a href="https://www.youtube.com/watch?v=YuqB8D6eCKE">[10분 테코톡] 🎨 신세한탄의 CSR&amp;SSR</a> 영상을 통해 기본적인 지식을 습득했어요.</p>
<blockquote>
<p>CSR(Client-Side Rendering)
클라이언트 측에서 자바스크립트를 통해 HTML을 동적으로 생성하고 렌더링하는 방식이에요. 초기 로드 시간은 길지만, 페이지 간 이동이 빠르고 사용자 경험이 부드럽다고 해요.</p>
<p>SSR(Server-Side Rendering)
서버에서 미리 렌더링된 HTML을 생성하여 클라이언트에 전달하는 방식이에요. 초기 로드 시간이 짧아 <code>SEO</code>에 유리하며, 콘텐츠가 빠르게 표시되지만, 서버에 더 많은 부하가 발생할 수 있어요.</p>
</blockquote>
<h2 id="ssr-환경에서의-로컬-스토리지-접근-문제">SSR 환경에서의 로컬 스토리지 접근 문제</h2>
<p>SSR 환경에서는 서버에서 HTML을 렌더링하여 클라이언트에 전달하기 때문에, 클라이언트에서만 접근 가능한 로컬 스토리지에 접근할 수 없어요. 즉, 로그인 토큰을 로컬 스토리지에 저장하고 사용해야 하는 경우, 초기 페이지 렌더링 시 문제가 발생할 수 있다는 것을 의미해요.</p>
<p>사용자 유형에 따라 다른 화면을 제공해야 하는데, 로컬 스토리지에서 토큰을 관리하다 보니 인증 및 인가가 필요한 API 호출이 지연되어 사용성이 저하될 수 있어요.</p>
<p>위 내용을 바탕으로 제가 이해한 내용을 그림으로 표현해봤어요.</p>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/31672c8f-a5a7-47f2-ab7a-e93f6153e590/image.png" alt=""></p>
<details>
<summary>상세 흐름</summary>
<div markdown="1">

<p>메인 페이지에 {사용자 이름}님 어서오세요. 가 있는 경우를 예시로 설정했어요.</p>
<ul>
<li><p><code>1</code> :  메인 페이지 접근 요청
사용자가 로그인 페이지를 통해 자격 증명을 서버로 전송해요. 서버는 데이터베이스에서 해당 사용자 정보를 조회하고, 제공된 자격 증명이 올바른지 확인해요.</p>
</li>
<li><p><code>2</code> : 요청한 페이지의 HTML을 서버에서 렌더링
SSR 방식은 서버에서 HTML을 미리 렌더링해요.</p>
<p>예를 들어, &quot;홍길동님 어서오세요&quot;라는 메시지를 보여주려면, 서버는 클라이언트의 로그인 상태를 확인하고, 사용자 이름을 가져와 HTML에 포함시켜야 해요.</p>
<p>이때, 서버는 클라이언트가 보유하고 있는 로그인 토큰을 사용하여 사용자의 정보를 조회하려고 시도해요.</p>
</li>
<li><p><code>3</code> : 서버에서는 클라이언트의 로컬 스토리지 접근 불가
서버가 사용자의 로그인 토큰을 확인하려고 할 때, 클라이언트의 로컬 스토리지에 접근해야 해요. 그러나 로컬 스토리지는 클라이언트(브라우저)에서만 접근할 수 있는 자원이에요.</p>
</li>
<li><p><code>4</code> : 일단 그거 빼고 나머지 렌더링 완료 후 전달
서버는 클라이언트의 로컬 스토리지에 접근할 수 없기 때문에, 로그인 토큰을 읽어올 수 없어요. 이는 서버에서 사용자 정보를 가져오지 못하게 만들고, 결과적으로 클라이언트에 완전한 정보를 포함한 HTML을 렌더링할 수 없게 만들어요.
이러한 이유로 서버는 사용자의 로그인 상태를 알지 못한 채, 기본 HTML만 렌더링해서 클라이언트에 전송해요.</p>
</li>
<li><p><code>5</code> : 브라우저가 HTML 표시 및 JS 실행
클라이언트(브라우저)가 서버에서 전달받은 HTML을 받아서 화면에 표시해요. 이 시점에서 클라이언트 측 JavaScript가 실행되고, 클라이언트는 이제 window 객체와 localStorage에 접근할 수 있어요.</p>
<p>클라이언트는 로컬 스토리지에서 로그인 토큰을 가져와서, 해당 토큰을 이용해 사용자의 정보를 확인하기 위한 API 요청을 다시 서버로 보내요.</p>
</li>
<li><p><code>6</code> : 다시 API 호출
클라이언트는 로컬 스토리지에 저장된 토큰을 기반으로 사용자 정보를 가져오기 위해 API 호출을 수행해요.
API 호출에 대한 응답을 받고 클라이언트는 화면을 갱신하여 &quot;홍길동님 어서오세요&quot;와 같은 메시지가 보이게 해요. 이 과정에서 화면이 한 번 깜빡이거나, 정보 로딩 시간이 지연될 수 있어요. 이는 초기 HTML 렌더링 시 사용자의 정보를 확인하지 못해 발생하는 지연이에요.</p>
</li>
</ul>
</div>
</details>

<h3 id="해결책">해결책</h3>
<blockquote>
<p>CSR (Client-Side Rendering) 작업에 익숙한 경우 서버에서 localStorage에 액세스할 수 없는 경우가 발생할 수 있다. 이는 localStorage가 <code>window</code> 객체에 정의되어 있지 않고 Next.js가 클라이언트 사이드 렌더 전에 서버 사이드 렌더를 수행하기 때문이다. <a href="https://brick-house.tistory.com/18">출처</a></p>
</blockquote>
<p>위 포스팅처럼 window 객체의 존재 유무를 살피고 window 함수에 접근하거나, useEffect를 통해 window 객체에 접근하는 방식을 사용할 수 있을 것 같아요. 하지만 이를 위해 작성하는 <code>불필요하게 반복되는 코드</code>와 <code>사용성</code> 측면에서 봤을 때 완전 좋은 방식이라고 느껴지진 않았어요. 그래서 로컬 스토리지 저장 대신에 쿠키를 사용하는 방식에 대해 살펴봤어요.</p>
<p>SSR 환경에서 쿠키는 알 수 있어요. 따라서 로그인 토큰을 서버가 클라이언트에 전달할 때, 로컬 스토리지 대신 <code>쿠키</code>에 저장하는 방식을 사용할 수 있어요. 쿠키는 HTTP 요청 시마다 서버로 <code>자동으로 전송</code>되므로, 클라이언트에서 직접 접근하지 않아도 인증 상태를 유지할 수 있어요. 이를 통해 SSR에서도 인증 처리를 구현할 수 있을 것 같아요.</p>
<h2 id="로컬-스토리지-보안-문제">로컬 스토리지 보안 문제</h2>
<p>클라이언트 측에서 로컬 스토리지에 토큰을 저장하면, JavaScript를 통해 해당 토큰에 접근할 수 있기 때문에, 악의적인 스크립트가 토큰을 탈취할 위험이 있어요.</p>
<h3 id="해결책-1">해결책</h3>
<p>보안 강화를 위해 토큰을 <code>Secure</code>, <code>HttpOnly</code>, <code>SameSite</code> 속성을 설정한 쿠키에 저장하는 방식을 사용해요.</p>
<ul>
<li><code>Secure</code> : HTTPS 연결에서만 쿠키가 전송되도록 해요.</li>
<li><code>HttpOnly</code> : JavaScript에서 쿠키에 접근할 수 없게 만들어요.</li>
<li><code>SameSite</code> : CSRF(Cross-Site Request Forgery) 공격을 방지해요. </li>
</ul>
<p>위와 같은 헤더 속성들을 통해, 클라이언트 측에서의 보안 취약점을 줄일 수 있을 것 같아요.</p>
<h2 id="msa-모노레포-환경에서의-인증-문제">MSA-모노레포 환경에서의 인증 문제</h2>
<p>MSA(Microservices Architecture) 및 모노레포 환경에서는 서브 도메인 간의 로컬 스토리지나 세션 스토리지를 공유할 수 없다고 해요. 이로 인해, 사용자가 서브 도메인을 이동할 때마다 인증 과정이 반복되어야 하는 불편함이 발생한다고 설명했어요. MSA-모노레포에 대한 내용은 프론트엔드 파트에게 간단하게 질문할 예정이에요.</p>
<h3 id="해결책-2">해결책</h3>
<p>이 문제를 해결하기 위해, 앞에서 본 문제처럼 (서브 도메인 간에 공유할 수 있는) <code>쿠키</code>를 사용해 인증 상태를 관리하는 방안을 고려해야 할 것 같아요. 예를 들어, <code>domain=.example.com</code>과 같은 형식으로 쿠키의 Domain 속성을 설정하면, <code>sub1.example.com</code>과 <code>sub2.example.com</code> 같은 서브 도메인 간에 쿠키를 공유할 수 있어요. 이를 통해 불필요한 인증 요청을 줄이고, 원활한 사용자 경험을 제공할 수 있을 것 같아요.</p>
<h2 id="분석-결과">분석 결과</h2>
<p>현재 프론트엔드 파트의 요구 사항을 분석해보니 응답 본문으로 토큰을 전달하고 이를 로컬 스토리지에 저장하는 방식으로 구현하는 경우 여러 보안 위험과 사용성 저하가 발생할 우려가 있다고 생각해요.</p>
<p>따라서, 프론트엔드 파트의 원활한 <code>SSR</code> 및 <code>MSA-모노레포</code> 환경 구축을 위해 <code>쿠키</code> 방식으로 토큰을 전달하는 방향으로 구현해야 할 것 같아요.</p>
<h1 id="쿠키로-무엇을-전송할까">쿠키로 무엇을 전송할까?</h1>
<p>지금까지 로그인 토큰을 전달한다고 생각하며 알아봤어요. 여기서 토큰은 JWT를 말해요.</p>
<h2 id="jwt">JWT?</h2>
<p>JWT는 Json Web Token의 약자로 json 객체를 이용해서 토큰 자체의 정보를 저장하고 있는 웹 토큰이에요. <a href="https://datatracker.ietf.org/doc/html/rfc7519">RFC-7519</a></p>
<blockquote>
<p>JWT는 당사자 간의 비밀을 제공하기 위해 암호화될 수 있지만, <code>서명된 토큰에 초점을 맞출 것입니다.</code> 서명된 토큰은 그 안에 포함된 클레임의 무결성을 확인할 수 있는 반면, 암호화된 토큰은 다른 당사자에게 해당 클레임을 숨깁니다. 토큰이 공개/비공개 키 쌍을 사용하여 서명되는 경우, 서명은 또한 개인 키를 보유한 당사자만이 서명한 사람임을 증명합니다. - <a href="https://jwt.io/introduction">jwt.io</a></p>
</blockquote>
<pre><code>eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c</code></pre><p>위와 같이 JWT는 암호화되어 매우 복잡하고 사람이 읽을 수 없는 문자열 형태로 구성되어 있어요.</p>
<p>JWT는 모든 팀원이 사용해본 경험이 있기 때문에 자세한 설명 대신 간단한 특징을 알아보고, 사용 시나리오를 설정하여 문제가 될만한 상황을 찾아보려고 해요. JWT에 대해 더 학습이 필요한 경우 <a href="https://jwt.io/introduction">공식문서</a>를 참고하면 좋을 것 같아요.</p>
<h2 id="흐름">흐름</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/326f8fa5-30ec-4318-95fa-3c768972a1ff/image.png" alt=""></p>
<ul>
<li><p><code>1</code> : 사용자가 로그인 요청을 보내요.
사용자가 로그인 페이지를 통해 자격 증명을 서버로 전송해요. 서버는 데이터베이스에서 해당 사용자 정보를 조회하고, 제공된 자격 증명이 올바른지 확인해요.</p>
</li>
<li><p><code>2~4</code> : 서버가 토큰을 생성해요
로그인 자격 증명이 확인되면, 서버는 해당 사용자 정보를 기반으로 토큰을 생성해요. 이는 수정하거나 삭제할 수 없으며, 서명 알고리즘을 통해 위변조를 확인해요.</p>
</li>
<li><p><code>5</code> : 클라이언트에게 <code>토큰</code>을 전달해요.
서버는 이 토큰을 쿠키나 HTTP 응답 본문을 통해 클라이언트로 전달해요. (AccessToken만 살펴봤어요. RefreshToken은 따로 찾아보시면 더 좋을 것 같아요.)</p>
</li>
</ul>
<hr>
<ul>
<li><p><code>1~2</code> : 서버가 토큰을 확인해요.
쿠키나 헤더를 통해 전달받은 AccessToken을 검증해요. 위변조 여부, 만료 기한 등을 확인해요.</p>
<p>만약 토큰이 만료되었다면, 다시 인증을 진행하거나 RefreshToken을 활용하여 AccessToken을 재발급받을 수 있어요.</p>
</li>
<li><p><code>3~5</code> : 요청에 따라 서버가 응답해요
토큰이 유효한 경우, 서버는 요청에 따라 적절한 응답을 클라이언트에게 전송해요. 사용자는 추가적인 로그인 과정 없이도 계속해서 애플리케이션을 사용할 수 있어요.</p>
</li>
<li><p><code>기타</code> : 로그아웃, 무효화
사용자가 로그아웃을 요청하더라도, 별도의 구현 없이는 AccessToken의 유효성이 사라지지 않아요. 만료 기한이 지나기 전까지는 특정 토큰을 무력화시킬 방법이 없어요. 그래서 로그아웃을 위해 블랙 리스트를 구현하기도 해요.</p>
</li>
</ul>
<h3 id="jwt-기반-인증의-장점">JWT 기반 인증의 장점</h3>
<ul>
<li><code>위변조 방지</code> : JWT는 <code>서명(Signature)</code>을 포함하고 있어, 발급된 토큰이 중간에 변조되지 않았음을 확인할 수 있어요.</li>
<li><code>무상태(StateLess) 유지</code> : JWT는 <code>토큰 자체에 모든 인증 정보를 포함</code>하고 있기 때문에, 서버 측에서 별도의 세션 저장소나 데이터베이스 조회 없이도 인증이 가능해요.</li>
<li><code>모바일 앱 호환성</code> : JWT는 클라이언트와 서버 간의 통신이 매우 가벼워 모바일 환경에서도 잘 동작해요. 세션 방식과 달리, 별도의 서버 상태를 유지하지 않기 때문에 모바일 기기에서도 효율적으로 사용할 수 있어요.</li>
</ul>
<h3 id="jwt-기반-인증의-단점">JWT 기반 인증의 단점</h3>
<ul>
<li><code>토큰 크기</code> : JWT는 서명 및 페이로드를 포함하고 있어, 토큰의 크기가 비교적 커질 수 있어요. 매번 전송되는 경우가 많기 때문에 네트워크 대역폭이 낭비되거나 성능에 영향을 미칠 수 있어요.</li>
<li><code>탈취 위험</code> : JWT는 클라이언트 측에 저장되기 때문에 탈취될 위험이 있어요. 특히, 만료 기한이 긴 토큰이 탈취되면, 해당 토큰을 악용한 공격이 장기간 발생할 수 있어요. (JWT는 생성과 동시에 수정 및 삭제가 불가능하기 때문에 잘 관리해야 해요.)</li>
<li><code>토큰 무효화 어려움</code> : JWT는 상태가 없기 때문에, 발급된 토큰을 서버에서 무효화하기 어려워요. 예를 들어, 사용자가 로그아웃하거나 계정 정보를 변경해도, 이미 발급된 JWT는 만료될 때까지 유효해요. 블랙 리스트를 구현하는 방법으로 보완할 수 있지만, 추가적인 구현을 요구해요.</li>
</ul>
<h2 id="시나리오-1">시나리오 1</h2>
<blockquote>
<p>AccessToken만 사용하기</p>
</blockquote>
<p>AccessToken만 사용할 경우 크게 2가지 경우로 볼 수 있어요.</p>
<h3 id="만료기한이-존재하지-않는-경우">만료기한이 존재하지 않는 경우</h3>
<p>AccessToken이 평생 만료되지 않기 때문에, 사용자는 로그아웃하지 않는 이상 계속 인증 상태를 유지할 수 있어요. 하지만, AccessToken이 탈취되는 경우 탈취된 AccessToken을 무력화할 수 있는 방법은 존재하지 않기 때문에 매우 위험해요.</p>
<h3 id="만료기한이-존재하는-경우">만료기한이 존재하는 경우</h3>
<p>AccessToken이 만료되는 경우 사용자는 다시 인증해야 해요. 이러한 경험을 줄이기 위해 AccessToken의 만료 기한을 길게 설정한다면, 보안에 취약해져요(위와 같은 맥락). 반대로 만료 기한을 짧게 가져가는 경우 계속된 재인증 요구로 사용성이 저하돼요.</p>
<h2 id="시나리오-2">시나리오 2</h2>
<blockquote>
<p>AccessToken과 RefreshToken을 함께 사용하기</p>
</blockquote>
<p>AccessToken의 만료기한은 짧게 설정하고 RefreshToken을 통해 사용성을 올리는 방법이에요.</p>
<h3 id="보안을-위해">보안을 위해</h3>
<ul>
<li>RefreshToken까지 만료된 경우에는 다시 인증을 요구해요.</li>
<li>AccessToken과 RefreshToken을 따로 저장하여 관리할 수 있어요.</li>
<li>실제 인증이 필요한 API를 사용할 때는 AccessToken만 사용하여 RefreshToken은 최대한 감추고 AccessToken이 만료된 경우에만 RefreshToken을 전송해요.</li>
</ul>
<p>HTTPS로 암호화를 진행하지만, 혹시 모르니 매 요청마다 RefreshToken을 보낼 필요는 없어요. 어떤 요청의 내용을 알아내더라도 대부분 만료 기한이 짧은 AccessToken만 있을 것을 기대해요. RefreshToken을 모르면 재발급받을 수 없어요.</p>
<h3 id="우리는-쿠키를-사용할-예정">우리는 쿠키를 사용할 예정</h3>
<p>하지만, 프론트엔드 요구사항에 맞게 백엔드 파트는 쿠키를 통해 토큰을 전송할 예정이에요.</p>
<p>쿠키를 한번 설정하면 만료되기 전까지 모든 HTTP 요청에 쿠키가 함께 전송돼요. 즉, 로그인을 통해 쿠키로 전달된 AccessToken과 RefreshToken이 항상 모든 요청에 함께 전송된다는 말이에요. </p>
<p>항상 모든 토큰이 전송되니 탈취 가능성이 비슷해져요. RefreshToken을 통해 AccessToken을 다시 발급 받을 수 있기 때문에 AccessToken과 RefreshToken을 사용한다고 하더라도 보안 문제를 해결하지 못할 수도 있을 것 같아요.</p>
<p>위와 같이 쿠키와 토큰 방식을 살펴봤지만, 걱정 없는 방법이 떠오르지 않아서 다른 로그인 구현 방식을 찾아봤어요.</p>
<h1 id="세션-기반-인증-방식">세션 기반 인증 방식</h1>
<p><code>세션(Session)</code> 기반 인증은 사용자 로그인 시 서버에서 세션을 생성하고, 클라이언트에게는 <code>세션 ID</code>를 쿠키에 담아 전송하는 방식이에요. 이후 클라이언트의 모든 요청에는 <code>세션 ID</code>가 포함되어 전송되며, 서버는 이 <code>세션 ID</code>를 바탕으로 사용자의 인증 상태와 관련된 데이터를 관리하게 돼요.</p>
<h2 id="흐름-1">흐름</h2>
<p><img src="https://velog.velcdn.com/images/hyeok_1212/post/72022665-61f1-47f5-9958-06552eeab0f2/image.png" alt=""></p>
<ul>
<li><p><code>1</code> : 사용자가 로그인 요청을 보내요.
사용자가 로그인 페이지를 통해 자격 증명을 서버로 전송해요. 서버는 데이터베이스에서 해당 사용자 정보를 조회하고, 제공된 자격 증명이 올바른지 확인해요.</p>
</li>
<li><p><code>2~4</code> : 서버가 세션을 생성해요
로그인 자격 증명이 확인되면, 서버는 해당 사용자에 대해 <strong>세션(Session)</strong>을 생성해요. 세션은 서버 메모리, 데이터베이스, 또는 인메모리 저장소(Redis 같은)와 같은 서버 쪽에 저장되는 데이터 구조에요.</p>
<p>세션에는 추가적인 정보(사용자 ID, 세션 생성/만료 시간, 사용자의 권한 등)가 포함될 수 있어요.</p>
</li>
<li><p><code>5</code> : 세션 ID를 생성해요
서버는 세션을 생성한 후, 이 세션을 식별할 수 있는 <code>세션 ID</code>를 생성해요. 세션 ID는 클라이언트와 서버 간에 교환되는 유일한 데이터에요. 이 ID는 매우 긴 무작위 문자열로, 다른 사람에게 추측되기 어렵도록 설계되어 있어요.</p>
</li>
<li><p><code>6~7</code> : 클라이언트에게 <code>세션 ID</code>를 전달해요
서버는 이 세션 ID를 클라이언트에게 <strong>쿠키(Cookie)</strong>로 전송해요. 쿠키는 클라이언트 측에 저장되어 이후의 모든 요청에 자동으로 포함돼요.</p>
</li>
</ul>
<hr>
<ul>
<li><p><code>1~3</code> : 서버가 세션을 확인해요
서버는 요청을 받을 때마다 <code>세션 ID</code>를 기반으로 세션 저장소에서 해당 세션을 조회해요. 세션이 유효하면, 서버는 세션에 저장된 데이터를 기반으로 사용자가 누구인지, 어떤 권한이 있는지 등을 확인해요.</p>
<p>만약 세션이 만료되었거나 무효화되었다면, 서버는 클라이언트에게 재인증(로그인)을 요구할 수 있어요.</p>
</li>
<li><p><code>4~7</code> : 요청에 따라 서버가 응답해요
세션이 유효한 경우, 서버는 요청에 따라 적절한 응답을 클라이언트에게 전송해요. 이 과정에서 세션 ID가 계속 유지되며, 사용자는 추가적인 로그인 과정 없이도 계속해서 애플리케이션을 사용할 수 있어요.</p>
</li>
<li><p><code>기타</code> : 로그아웃 및 세션 무효화
사용자가 로그아웃을 요청하면, 서버는 해당 사용자의 세션을 삭제하거나 무효화해요. 이후 이 세션 ID는 더 이상 유효하지 않으며, 동일한 세션 ID를 포함한 요청은 인증되지 않아요. 로그아웃 후 다시 접근하려면 사용자는 다시 로그인해야 해요.</p>
</li>
</ul>
<h3 id="세션-기반-인증의-장점">세션 기반 인증의 장점</h3>
<ul>
<li><code>안전한 전송</code> : 세션 기반 인증에서는 클라이언트와 서버 간에 오가는 것은 <code>세션 ID</code>뿐이에요. 사용자 정보나 민감한 데이터는 세션 ID를 통해 <code>서버에서만 관리</code>되므로, 클라이언트 측에서 데이터 유출의 위험이 줄어들어요. 또한 세션 ID는 별도의 사용자 정보 없이 고유한 식별자로만 사용되기 때문에 비교적 안전해요.</li>
<li><code>인증 상태 관리 가능</code> : 사용자가 로그아웃하거나, 일정 시간이 지나 세션이 만료되었을 때, 서버에서 세션을 즉시 무효화할 수 있어요. 즉, 서버에서 인증 상태를 관리할 수 있다는 말이에요. (계정당 하나의 기기에서 로그인, 탈취된 세션 ID를 즉시 차단하기 등이 가능해져요.)</li>
</ul>
<h3 id="세션-기반-인증의-단점">세션 기반 인증의 단점</h3>
<ul>
<li><code>서버 부담</code> : 서버에서 세션을 관리하기 때문에, 사용자가 늘어나면 서버의 메모리 사용량이 증가해요.</li>
<li><code>확장성 부담</code> : 서버에 세션 저장소를 두기 때문에, 여러 대의 서버를 구축하는 등 확장할 때 고려할 문제가 증가돼요.</li>
</ul>
<h1 id="정리">정리</h1>
<blockquote>
<p>저는 아래와 같은 이유로 <code>세션, 쿠키</code>를 이용한 로그인 구현 방식을 제안하려고 해요.</p>
</blockquote>
<ul>
<li>프론트엔드 파트의 구현 방식(SSR, MSA 등)과 사용성 측면에서 로컬 스토리지 저장 방식 대신에 쿠키를 사용하는 것이 나은 방법 같아요.</li>
<li>JWT 방식은 한번 생성되면 수정할 수 없기 때문에 탈취된 경우에 해당 토큰을 무력화할 수 있는 방법이 없어요.</li>
<li>물론 RefreshToken이나 블랙 리스트 개념을 도입하는 경우 탈취에 대한 대비가 어느 정도 가능하지만, 그것을 구현하는 것과 세션 기반 로그인을 위한 구현하는 것이 초반 개발 단계에서 난이도 차이가 크지 않을 것 같아요. </li>
<li>추가로 세션 방식의 경우에는 탈취되어도 의미 없는 유니크한 값이기 때문에 개인 정보가 유출되지 않아요. (서버에서 개인 정보를 다뤄요.)</li>
<li>쿠키로 AccessToken과 RefreshToken을 모두 전송하게되면 매 요청마다 모든 토큰이 네트워크에 노출될 수밖에 없어요.</li>
<li>많은 사용자가 발생한 경우 세션 관리, 여러 개의 서버가 있는 경우 세션 공유 등 여러 챌린지한 주제에 대해 고민하고 도전할 수 있는 기회라고 생각해요.</li>
<li>부가적인 비즈니스 기능들을 구현할 수 있어요. (계정당 하나의 기기에서만 로그인 가능, 원격 로그아웃 등)</li>
</ul>
<p>제가 바라보고 있지 못하는 부분이나 틀린 부분이 있을 수 있으니 함께 이야기 해보면 좋을 것 같아요.</p>
<p>읽어주셔서 감사합니다. </p>
<p>*<a href="https://velog.io/@hyeok_1212/%EC%96%B4%EB%96%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B0%A9%EC%8B%9D%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C-2">다음 글</a>도 추가됐습니다!</p>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://velog.io/@aaronddy/%EC%9D%B8%EC%A6%9DAuthentication%EA%B3%BC-%EC%9D%B8%EA%B0%80Authorization">인증(Authentication)과 인가(Authorization), Daye Kang</a></li>
<li><a href="https://medium.com/@hong009319/jwt%EC%97%90%EC%84%9C-%EC%84%B8%EC%85%98-%EC%BF%A0%ED%82%A4-%EC%9D%B8%EC%A6%9D%EC%9C%BC%EB%A1%9C-%EC%A0%84%ED%99%98-%EC%9D%B8%EC%A6%9D-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%B3%80%EA%B2%BD-32cef9467213">JWT에서 세션 쿠키 인증으로 전환: 인증 시스템 변경, dev-redo</a></li>
<li><a href="https://hudi.blog/session-based-auth-vs-token-based-auth/">세션 기반 인증과 토큰 기반 인증 (feat. 인증과 인가), Hudi</a></li>
<li><a href="https://www.youtube.com/watch?v=YuqB8D6eCKE">[10분 테코톡] 🎨 신세한탄의 CSR&amp;SSR</a></li>
<li><a href="https://brick-house.tistory.com/18">Next js에서 sessionStorage is not defined 문제 해결 방법, 개형이</a></li>
<li><a href="https://jwt.io/introduction">jwt.io</a></li>
<li><a href="https://x.com/TechSpot/status/1099081091900686338">대표 이미지 출처</a></li>
</ul>
]]></description>
        </item>
    </channel>
</rss>