<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>_roundtable.log</title>
        <link>https://velog.io/</link>
        <description>백엔드 주니어 주니어 개발자</description>
        <lastBuildDate>Wed, 08 Apr 2026 05:49:26 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>_roundtable.log</title>
            <url>https://velog.velcdn.com/images/_roundtable/profile/e0d152c8-83d5-40b5-8b01-c39285a49aa5/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. _roundtable.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/_roundtable" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[26-1분기 회고: 인턴과 소마, 오픈소스 이야기]]></title>
            <link>https://velog.io/@_roundtable/26-1%EB%B6%84%EA%B8%B0-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@_roundtable/26-1%EB%B6%84%EA%B8%B0-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Wed, 08 Apr 2026 05:49:26 GMT</pubDate>
            <description><![CDATA[<p><em>썸네일은 GPT로 생성되었습니다.</em></p>
<h2 id="겨울방학을-돌아보며">겨울방학을 돌아보며…</h2>
<p>겨울방학이 끝나고 새학기가 시작되었습니다.
이번 1분기를 바쁘게 보냈는데, 가볍게 돌아보며 블로그에 적어보려고 합니다!</p>
<h2 id="인턴">인턴</h2>
<ul>
<li>기간: 2025년 9월 - 2025년 12월</li>
</ul>
<p>사실 1분기 내용은 아니지만 기록을 못해서 슬쩍 껴두었습니다.
감사하게도 모션랩스에서 3개월 체험형 인턴을 경험할 수 있었습니다.
해당 경험에 대해서는 다른 아티클로 정리해보겠습니다!</p>
<h2 id="kuit-해커톤">KUIT 해커톤</h2>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/2f5e40ae-6ce4-4c0f-8c84-1ea85d4c8d63/image.jpeg" alt=""></p>
<ul>
<li>기간: 2026년 1월</li>
</ul>
<p>교내 IT 동아리 KUIT에서 해커톤 대상을 수상했습니다!
채팅 SNS 메신저인데, AI 재판관을 넣었습니다.
가끔 친구들끼리 카톡 채팅방에서 대화하다가 논쟁을 하는 경우가 있는데요,
누가 판결해주면 좋겠다..! 하는 아이디어에서 시작되었습니다.</p>
<p>AI 재판관이 대화를 보고 실시간으로 승률 퍼센트를 분석해주고,
마지막에 분석 결과를 리포트 형식으로 보여줍니다.
팀원들이랑 재밌게 만들었는데, 좋은 결과 있어서 보람찼습니다 ㅎㅎ</p>
<p><strong>GITHUB</strong>: <a href="https://github.com/RoundTable02/proj-objection">https://github.com/RoundTable02/proj-objection</a></p>
<h2 id="gdgoc-one-wave-해커톤">GDGoC ONE WAVE 해커톤</h2>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/538d3727-ad0d-4c53-a40a-a08871d513d4/image.jpeg" alt=""></p>
<ul>
<li>기간: 2026년 2월</li>
</ul>
<p>Google Developer on Campus 에서 주관하는 ONE WAVE 해커톤에 참여했습니다.</p>
<p>주제는 &quot;취업난&quot;이었습니다.
저희 조는 &quot;기업의 채용 비용을 줄이자!&quot; 쪽으로 집중해서
&quot;프론트엔드 구현과제 채점 자동화 서비스&quot;를 개발했습니다.</p>
<p>Google에서 지원하기 때문에, GCP를 이용했고 구현 쪽에서 점수를 받기 위해 아키텍처 쪽 고민도 했습니다.
과제 생성과 조회는 GCE 서버에서, Playwright를 이용한 과제 채점은 Serverless에서 처리해 메인 서버의 부담을 줄였습니다.</p>
<p>그러면서 두 개의 프로젝트를 하룻밤 사이에 만들어야 하는 문제가 생겼는데..
AI 덕분에 구현에 성공하고, 연동도 해낼 수 있었습니다!
프론트랑 연동 성공했을 때는 당일 초면이었던 프론트 친구랑 막 기뻐했던 기억이 납니다.</p>
<p>아쉽게 수상은 못했지만, 다같이 몰두해서 열정을 불태우는 해커톤은 언제나 즐거운 것 같습니다!</p>
<ul>
<li>BE: <a href="https://github.com/RoundTable02/gdgoc-onewave-be">https://github.com/RoundTable02/gdgoc-onewave-be</a></li>
<li>Serverless: <a href="https://github.com/RoundTable02/gdgoc-onewave-serverless">https://github.com/RoundTable02/gdgoc-onewave-serverless</a></li>
</ul>
<h2 id="aws-saa">AWS-SAA</h2>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/53580523-7ec4-415e-9646-7eee3a060d23/image.png" alt=""></p>
<ul>
<li>기간: 2026년 2월</li>
</ul>
<p>AWS SAA 자격증을 취득했습니다.
인턴을 경험하면서, 인프라 공부의 중요성을 깨달았습니다.</p>
<p>약 300여개의 병원에 발송되는 월간 리포트에 대하여
생성 및 발송 프로세스를 자동화하는 역할을 맡았습니다.
이 과정에서 단순히 코드 레벨을 수정하는 것으로는 성능 개선에 한계가 있다는 것을 알게 되었고,
서버 개발뿐만 아니라 인프라(아키텍처) 공부의 중요성을 느꼈습니다.</p>
<p>자격증 취득 과정은 아래의 아티클을 참고해주세요~
<a href="https://velog.io/@_roundtable/AWS-SAA-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0-%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C%EB%A1%9C-%EB%8D%A4%ED%94%84-%EC%97%86%EC%9D%B4-%EA%B3%B5%EB%B6%80%ED%95%98%EA%B8%B0">https://velog.io/@_roundtable/AWS-SAA-합격-후기-클로드-코드로-덤프-없이-공부하기</a></p>
<h2 id="tetrapod-데모데이">TETRAPOD 데모데이</h2>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/096afcc4-c589-413d-9430-78f090ce3678/image.jpeg" alt=""></p>
<ul>
<li>기간: 2026년 1월 - 2026년 3월</li>
</ul>
<p>TETRAPOD에 백엔드 개발자로 참여하고, 데모데이 대상을 수상했습니다.</p>
<p>TETRAPOD는 현직자, 대학생 등 다양한 개발자 분들이 참여하시는 연합동아리 성격의 장기 해커톤입니다.</p>
<p>기획자 1, 디자이너 3, 프론트 3, 백엔드 3의 좀 큰 단위의 팀으로 구성되며, 기획자 분들은 실제 법인의 대표로 계시거나 창업 경험이 있으신 분들이었습니다!</p>
<p>저는 만성신장질환 환자를 위한 AI 솔루션 &#39;신신당부&#39; 프로젝트에 참여했습니다.
프로젝트 &#39;말모&#39;를 통해 AI를 활용한 경험이 있어서 자연스럽게 백엔드 팀장을 맡게 되었습니다.</p>
<p>현직자 분들로부터 다양한 인사이트를 얻을 수 있었으며,
실제 프로덕트로 제공될 서비스를 빠른 속도로 개발하면서 실무적 경험을 쌓을 수 있는 좋은 기회였습니다!</p>
<p><strong>System Architecture</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/b7bc2766-11ba-4656-a256-f1de9959b243/image.png" alt=""></p>
<h2 id="ai-sw-마에스트로-합격">AI SW 마에스트로 합격</h2>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/77e9eb4a-0635-4cea-b71d-dfbe3b3008ee/image.png" alt=""></p>
<p><em>합격까지 험난한 과정...</em></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/69a777d2-dce7-467f-af91-62711db71b38/image.png" alt=""></p>
<ul>
<li>기간: 2026년 2월 - 2026년 3월</li>
</ul>
<p>처음에는 별 생각 없었는데 전형이 길어지니까 (한 달) 좀 기대하게 되더라구요..</p>
<p>사실 코테 준비하면서 AI랑 같이 공부를 하려고 했는데요,</p>
<blockquote>
<p>ME: &quot;<del>한 문제인데 이렇게 풀었거든? 힌트 주라&quot;
AI: &quot;아</del>ㅋㅋ 그거 그렇게 푸는 거 아니고 ~~하게 푸는 거야&quot;</p>
</blockquote>
<p>자꾸 아는 척을 하니 생각하는 힘을 못 키우는 문제가 있었습니다.</p>
<p>그래서 소크라테스 문답법을 이용해 절대 정답을 알려주지 않도록 하였고,
<img src="https://velog.velcdn.com/images/_roundtable/post/6fb27c19-8fa0-45a8-908d-eef7ed494ff1/image.png" alt=""></p>
<p>이런 식으로 아무리 매달려도 정답을 알려주지 않는 철학자 소크라테스로 만들 수 있었습니다.</p>
<p>이걸 스킬로 만들어보았는데 다양한 곳에서 활용해보시면 좋을 것 같습니다!
<strong>GITHUB</strong>: <a href="https://github.com/RoundTable02/socrates-skill">https://github.com/RoundTable02/socrates-skill</a></p>
<h2 id="오픈소스">오픈소스?</h2>
<p>어쩌다보니 오픈소스를 운영 비슷하게 하게 되었습니다.</p>
<h3 id="remote-opencode">remote-opencode</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/8794a5d4-f5b4-4110-8526-6a863be13c4e/image.png" alt=""></p>
<ul>
<li>기간: 2026년 2월 -</li>
</ul>
<p>경기도 통학러로서 인생 중 지하철에서 보내는 시간이 많은데, 이 시간이 좀 아까웠습니다.</p>
<p>OMO(Oh-My-Openagent) 저도 애용하고 있었고, OpenCode의 가볍고 커스텀 가능한 점을 좋아하지만 밖에서 원격 코딩을 쓰려면 설정이 굉장히 불편했습니다.</p>
<blockquote>
<p>디스코드에서 간단하게 명령을 보내고 원격으로 코딩해줄 수는 없을까? </p>
</blockquote>
<p>라는 아이디어에서 시작된 작은 프로젝트입니다.</p>
<p>2026.04.08 기준</p>
<ul>
<li>npm Downloads 2.6k+</li>
<li>Github Star 110+</li>
</ul>
<p><strong>GITHUB</strong>: <a href="https://github.com/RoundTable02/remote-opencode">https://github.com/RoundTable02/remote-opencode</a></p>
<h3 id="tutor-skills">tutor-skills</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/aa7a9134-aa2e-48a8-93ab-0e752fbd47a6/image.png" alt=""></p>
<ul>
<li>기간: 2026년 2월</li>
</ul>
<p>위에 <a href="https://velog.io/@_roundtable/AWS-SAA-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0-%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C%EB%A1%9C-%EB%8D%A4%ED%94%84-%EC%97%86%EC%9D%B4-%EA%B3%B5%EB%B6%80%ED%95%98%EA%B8%B0">AWS-SAA-합격-후기</a> 아티클에서 소개드렸던 클로드 스킬입니다!</p>
<p>docs, pdf, codebase 등 각종 자료를 obsidian 문서로 변환,
메타인지 기반 퀴즈를 제공하는 스킬입니다.</p>
<p>클로드뿐만 아니라 다양한 AI Agent를 통해 사용 가능합니다.</p>
<p>2026.04.08 기준</p>
<ul>
<li>Skills.sh Downloads 1.5k+</li>
<li>Github Star 600+</li>
</ul>
<p><a href="https://github.com/RoundTable02/tutor-skills">https://github.com/RoundTable02/tutor-skills</a></p>
<h2 id="회고-후기">회고 후기</h2>
<blockquote>
<p>쓰고 보니까 자꾸 세부 내용을 다음 아티클로 미루는 것 같네요..ㅎㅎ</p>
<p>2026년은 1분기부터 오픈 소스처럼 전에 해본 적도 없는 신기한 경험도 해보고, 프로젝트로 수상도 하는 감사한 경험을 할 수 있었습니다.</p>
<p>앞으로 학교도 다니고, 소마도 하고, 다양한 분들과 다양한 프로젝트를 하면서 시야를 넓힐 수 있는 기회가 될 것 같습니다.</p>
</blockquote>
<p>나름 바쁘게 열심히 살았구나! 싶다가도 주변에 어마어마하신 분들 보면 부족함을 느끼고, 또 자극 받는 것 같습니다.</p>
<blockquote>
<p>2분기에는 1분기보다 더 나은 사람이 되었으면 좋겠습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS SAA 합격 후기 + 클로드 코드로 덤프 없이 공부하기]]></title>
            <link>https://velog.io/@_roundtable/AWS-SAA-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0-%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C%EB%A1%9C-%EB%8D%A4%ED%94%84-%EC%97%86%EC%9D%B4-%EA%B3%B5%EB%B6%80%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@_roundtable/AWS-SAA-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0-%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C%EB%A1%9C-%EB%8D%A4%ED%94%84-%EC%97%86%EC%9D%B4-%EA%B3%B5%EB%B6%80%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 25 Feb 2026 09:00:01 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_roundtable/post/93602d69-1884-452b-b74d-7fc50f60384a/image.png" alt=""></p>
<p>2026.02.13 AWS SAA 자격증 취득</p>
<p>조금 늦게 올리는 후기 공유드립니다!</p>
<h1 id="기간">기간</h1>
<p>공부 기간은 약 2주 정도로 잡았는데,
그때가 25% 쿠폰이 만료되기 직전이었습니다 ㅎㅎ</p>
<p>돈을 아끼기 위해 간절하게 2주 안에 벼락치기가 필요했습니다.</p>
<h1 id="공부-방법">공부 방법</h1>
<p>학생이라 최대한 돈을 아껴야 했습니다.
이미 자격증 신청에만 <strong>20만 원</strong> 가까이 소비했기 때문에..</p>
<p>강의 + 클로드 코드를 이용해서 공부했습니다.
문제 덤프도 따로 구매하지 않았습니다!</p>
<p>앞서 적은 것과 같이 기간은 2주 정도였는데,
이 기간을 위해 10만 원이 넘는 문제 덤프를 구매할 순 없었습니다ㅠㅠ</p>
<p>강의로 개념 공부를 마친 후,
클로드 코드로 매일 30분 정도 추가 공부를 한 것 같습니다.</p>
<h2 id="강의">강의</h2>
<p>AWS SAA의 경우 Udemy 강의를 주로 수강하시는 분들이 많은 것 같습니다!
비용, 수강 시간 등의 이유로 고민하던 중에 기적과도 같이
JSCODE에서 새로운 강의가 등장했습니다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/b3ac3cd2-c8d8-48ec-8723-8bc5dc82cba8/image.png" alt=""></p>
<p><a href="https://www.inflearn.com/course/aws-saa-c03-%EC%9E%90%EA%B2%A9%EC%A6%9D-%EB%B2%BC%EB%9D%BD%EC%B9%98%EA%B8%B0?cid=340198">https://www.inflearn.com/course/aws-saa-c03-%EC%9E%90%EA%B2%A9%EC%A6%9D-%EB%B2%BC%EB%9D%BD%EC%B9%98%EA%B8%B0?cid=340198</a></p>
<p>당장 AWS 자격증을 폭풍 몰아치기 해야 하는 저에게 안성맞춤인 강의였습니다.</p>
<p>강의 수가 많지 않고, 핵심만 정리해둔 강의였습니다.
준비해두신 문제 양도 163문제로 많아서, 3회독 정도 하니 이걸로 개념을 확실히 잡을 수 있었습니다.</p>
<h2 id="클로드-코드로-공부하는-방법">클로드 코드로 공부하는 방법</h2>
<p>이후로는 클로드 코드로 공부했습니다.</p>
<p><strong>아래의 과정을 편하게 사용하실 수 있도록
Claude Code Skills로 생성하여 오픈소스로 등록해두었습니다!</strong>
pdf, md, 또는 코드베이스까지 어떠한 학습 자료도 사용 가능하니 단순하게 Skill 설치 후 공부하시는 것을 추천드립니다!
<a href="https://github.com/RoundTable02/tutor-skills">https://github.com/RoundTable02/tutor-skills</a></p>
<p>우선 클로드 코드에게 강의 자료를 체계적으로 옵시디언 Vault로 변환하도록 요청하였습니다.
<del>(체계적으로.. 라는 과정 안에 꽤나 시행착오가 있었습니다.)</del>
(skill: /tutor-setup)</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/c418a853-b1fa-4194-8091-6507d96454cf/image.png" alt=""></p>
<p>이쁘게 잘 만들어졌네요.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/a5ff5846-51d1-411c-821b-8f4fdde4854f/image.png" alt=""></p>
<p>그래프도 확인 가능합니다.</p>
<p>이제 클로드에게 &#39;나의 취약 지점&#39;에 대해 질문 생성을 요청합니다.</p>
<p>클로드에는 AskUserQuestion이라는 도구가 있는데,
Plan 모드에서 내가 미처 생각하지 못한 부분을 클로드가 질의할 때 사용하는 그 도구입니다.</p>
<p>저의 부족한 메타인지를 채워주는 도구라고 생각했는데,
이러한 공부 플로우에 적용하면 좋을 것 같아서 시도해보았습니다!</p>
<p>(skill: /tutor)</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/3fb43923-dff1-4afd-9fdb-57fe4df76b3f/image.png" alt=""></p>
<p>클로드가 옵시디언 파일들을 분석해서
이렇게 진단 평가를 하거나, 원하는 섹션 또는 취약 지점에 대한 질문을 생성합니다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/ab9f2865-81c4-4e40-abe7-00d67181daf7/image.png" alt=""></p>
<p>평가가 완료되면 각 섹션에 대해 대시보드를 업데이트하고,</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/46538428-d8fb-432f-a641-8336e52c593d/image.png" alt=""></p>
<p>각 섹션에 오답이 있다면 오답도 기록합니다.
이 오답은 이후 취약 지점 분석에서 다시 활용됩니다!</p>
<hr>
<p>이 스킬을 다른 영역에서도 사용 가능하도록 확장시켜서 오픈소스로 올리게 되었습니다.</p>
<p>다양한 유형의 자료뿐만 아니라,
새롭게 보는 코드에 대한 온보딩도 빠르게 가능하도록 생성된 Skill입니다.</p>
<p>틈새 홍보) 혹시 깃허브가 있으시다면.. Star 부탁드리겠습니다! 😊
<a href="https://github.com/RoundTable02/tutor-skills">https://github.com/RoundTable02/tutor-skills</a></p>
<hr>
<h1 id="결과">결과</h1>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/8d644e22-6bf8-4f9d-8c7f-36b632130234/image.png" alt=""></p>
<p>사실 떨어질까봐 조마조마 하고 있었는데
생각보다 100점 정도 넉넉하게 합격했습니다..!</p>
<h1 id="후기">후기</h1>
<p>우선 강의가 큰 도움이 되었습니다.
추가적으로 필요한 개념도 알려주셔서 확장식 공부가 가능했습니다!
소중한 강의 만들어주신 JSCODE님께 감사합니다..ㅠㅠ</p>
<p>클로드 코드로 공부하면서 반복되는 과정을 Skill로 만들고,
Skill에서 맘에 안드는 부분을 뜯어고치는 반복 과정을 경험을 할 수 있었습니다.</p>
<p>앞으로도 학교 시험 공부, 개발 등에서 자주 이 스킬을 쓸 것 같습니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[EventEmitter 메모리 누수 막아보기]]></title>
            <link>https://velog.io/@_roundtable/EventEmitter-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EB%88%84%EC%88%98-%EB%A7%89%EC%95%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@_roundtable/EventEmitter-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EB%88%84%EC%88%98-%EB%A7%89%EC%95%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Thu, 13 Nov 2025 07:26:00 GMT</pubDate>
            <description><![CDATA[<h2 id="고민-내용">고민 내용</h2>
<p>User에 대해 매일 7천 건 씩 bulljs를 통한 비동기 백그라운드 처리를 진행해야 했다.
관련 작업을 QA에 배포 후 우선 11건 정도만 처리하도록 더미 데이터를 넣었다.
그리고 모니터링 하던 중...</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/256df4fa-6f23-462d-b81f-49db9ac47a7b/image.png" alt=""></p>
<blockquote>
<p>MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 global:completed listeners added to [Queue]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit...</p>
</blockquote>
<p>요런 에러가 발생하게 되었다!
물론 이런 오류만 뜨고 로직은 정상적으로 수</p>
<blockquote>
<p>🤔 메모리가 새고 있을 수도 있다고..?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<p>에러 메시지를 조금 자세히 보자.</p>
<pre><code class="language-text">11 global:completed listeners added to [Queue]. MaxListeners is 10. </code></pre>
<p>global:completed 라는 이벤트 리스너가 11개라고 한다.
그리고 이벤트 하나 당 Node.js에서 기본으로 두는 리스너의 최대 수는 10이다.</p>
<p>난 이런 거 설정한 적 없는데?!</p>
<h3 id="원인-알아보기">원인 알아보기</h3>
<p>문제의 코드다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/7327f958-1cd4-47cf-863c-d58a5cb50db0/image.png" alt=""></p>
<p>bull 사용하면서 전체 처리 진행을 알기 위해서 사용한 코드다.
의도는, 각각의 작업을 추적해 모든 작업이 완료되면 진행 상황을 찍기 위해 작성되었다.</p>
<p>한 번 라이브러리의 속을 파보자.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/19704a52-6d99-49e7-ac36-e84d8b45b345/image.png" alt=""></p>
<p>우선 Redis pub/sub에서 global:completed, global:failed 메시지를 구독하는 것 부터 시작한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/f099c947-77b0-4fa1-99fe-1682b5f49421/image.png" alt=""></p>
<p>그리고 각각의 메시지가 발생했을 때 터질 콜백을 등록한다.
재밌는 점은 global 이벤트가 발생하면 그게 어떤 작업의 것인지 알 수 없기 때문에 콜백에서 jobId를 비교하고, 맞으면 콜백을 터뜨리고 아니면 패스하는 방식이다.</p>
<p>이때 콜백이 등록되는 건 this.queue.on을 통해서 등록되는데, 조금 더 자세히 보자</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/c56e3288-086b-4208-9982-44c8ff3a3e45/image.png" alt=""></p>
<p>여기서 사용되는 Queue는 사실 EventEmitter의 상속을 받고 있다.
그러니까 Bull의 Queue는 Node.js 내부의 이벤트 관리를 함과 동시에 Redis pubsub을 이용한 큐 로직을 수행하는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/e57e4996-97cc-4b33-9af2-9bc8a0d06abc/image.png" alt=""></p>
<p>on은 두 가지 일을 수행하는데,
첫 번째는 register를 통해 메시지를 구독하는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/ff44bc1c-d0f3-422d-bf21-8b73696fec4c/image.png" alt=""></p>
<p>여기서는 크게 볼 건 없지만, 앞선 코드에서 구독을 했는데 왜 또 하지..? 하는 생각에 정리해보았다.
요약하자면, 중복으로 하지 않도록 막는 코드가 존재한다.</p>
<pre><code class="language-js">this.registeredEvents[_eventName]</code></pre>
<p>그리고 node.js 내부에서 관리되는 이벤트 이름은 global을 떼고 저장된다.</p>
<p>두 번째 _on은 EventEmitter의 on이다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/a5c4f31c-f349-4f46-8ab8-fcf0f8f75f2c/image.png" alt=""></p>
<p>출처 : <a href="https://nodejs.org/en/learn/asynchronous-work/the-nodejs-event-emitter">https://nodejs.org/en/learn/asynchronous-work/the-nodejs-event-emitter</a></p>
<p>이건 Node.js 공식 문서에서도 확인할 수 있다.
EventEmitter를 통해 on을 호출하면, 특정 이벤트 발생 시 동작해야 하는 콜백을 등록할 수 있다.</p>
<p>다시 처음으로 돌아와서,</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/5fd368b4-d1ef-4d7b-bcce-bac475f33d78/image.png" alt=""></p>
<p>이 코드는 이제 실제 프로덕션에서는 7천 개의 global:completed 콜백과 global:failed 콜백을 등록한다.</p>
<p>그럼 등록된 콜백은, 런타임에서 알고 있어야 실행을 하기 때문에 JS Heap (메모리)에 저장시킨다.</p>
<p>이벤트가 발생할 경우 이 이벤트의 리스너(콜백) 리스트를 모두 순회한다. 동기적으로 돌기 때문에 이것도 비효율이지만,
의도치 않은 동작으로 이 리스너들이 해제되지 않으면, node.js 입장에서는 이 리스너들이 계속 사용된다고 판단하여 gc가 돌지 않고, <strong>메모리 누수</strong>가 발생할 수 있다!</p>
<p>참고: <a href="https://nodejs.org/api/events.html">https://nodejs.org/api/events.html</a></p>
<h3 id="해결해보기">해결해보기</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/40abea66-02a5-47f4-a135-2f11631935e4/image.png" alt=""></p>
<p>우리는 모든 작업에 대해서 추적할 필요가 없다.
모든 작업이 종료되었을 때, 성공한 작업의 총 개수만 알면 된다.
따라서 작업에 대해 이벤트 리스너를 걸 필요 없이,
그냥 작업 큐에 대해 전체의 리스너를 걸어두면 된다.</p>
<p>각각의 콜백은 성공, 실패의 개수를 카운팅한다.</p>
<p>이전에 리뷰를 받았던 부분이
&#39;Cron에 의해 작업이 중복 실행되는 경우, 이벤트 리스너의 의도가 보장 받을 수 없다&#39;라는 내용이 있었는데,
이를 보장하기 위해 jobId를 실행 시각을 섞어서 만들도록 하였고, 이 id들을 미리 리스트로 만들어 저장해두었다가 이벤트가 터질 때 리스트에 존재하는 id인 경우에만 콜백이 실행되도록 만들었다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/7508536c-2daa-4c3e-94e2-0894954bfcf1/image.png" alt=""></p>
<p>그리고 깔끔하게 finally에서 cleanup을 호출해 리스너들을 모두 해제해주자.</p>
<hr>
<h2 id="결과">결과</h2>
<p>로컬에서 실행
7,000 건의 User 데이터를 처리하는 경우</p>
<h3 id="수정-전">수정 전</h3>
<ul>
<li>작업 전</li>
</ul>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/e69d3364-61a9-4cdf-8164-3b536893f5a3/image.png" alt=""></p>
<ul>
<li>작업 시작 후</li>
</ul>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/c97119e9-74c5-4aaf-86d3-4f174ccfb67c/image.png" alt=""></p>
<p>작업 전 메모리 사용량 110MB → 작업 시작 후 184MB (+74MB)</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/59181f04-1912-40cd-93e5-d6e433140f4b/image.png" alt=""></p>
<p>실행 전 %MEM 0.7 → 1.3</p>
<h3 id="수정-후">수정 후</h3>
<ul>
<li>작업 전</li>
</ul>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/9423ed73-83d6-4492-b62c-82f13806dd66/image.png" alt=""></p>
<ul>
<li>작업 시작 후</li>
</ul>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/a9329b78-122c-4167-94b8-e1fc06809fc5/image.png" alt=""></p>
<p>작업 전 메모리 사용량 107MB → 작업 시작 122MB (+15MB)</p>
<p>작업이 계속 진행되어도, 초기 메모리 사용량에서 크게 변동되지 않았다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/0df90acc-eac6-40d9-aa71-0e11fbe42ce8/image.png" alt=""></p>
<p>%MEM 변동 없음</p>
<hr>
<h2 id="결론">결론</h2>
<p>🎉 작업 처리 로직 메모리 사용량 79.73% 개선!</p>
<p>사실 Bull 사용에 있어서는 아래 내용을 참고해보면 좋다.
<a href="https://blog.taskforce.sh/do-not-wait-for-your-jobs-to-complete/">https://blog.taskforce.sh/do-not-wait-for-your-jobs-to-complete/</a>
Bull은 비동기 백그라운드 처리를 위한 라이브러리이기 때문에, 기다리지 말라는 것이다.
사실 많은 IO 작업과 초당 작업 제한을 편리하게 관리할 수 있고, 여러 큐를 통한 확장성 때문에 장점이 있었지만,
전체 진행에 대한 모니터링이 필요했기 때문에 Bull 사용에 살짝 의문이 생겼던 부분이기도 하다.</p>
<p>+) Bull 개발자도 production에서 job.finished()는 사용하지 말라고 권고하고 있다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/890fa8dd-5fdc-40cd-976a-f2fdd8e26dfd/image.png" alt=""></p>
<blockquote>
<p><strong>결론</strong>
Node.js에서 이벤트를 사용한다면, 메모리 누수를 주의하자!</p>
</blockquote>
<hr>
<h2 id="번외-오픈소스-주석-수정해보기">번외: 오픈소스 주석 수정해보기</h2>
<p>코드 뜯어보다가 주석이 이상해서 작게나마 기여했다.. 하하</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/3f9cd29c-ca5e-4469-8df7-dd7387c19bda/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[말모] 트랜잭션 아웃박스 패턴 가용성 테스트 (AI 채팅 3-1)]]></title>
            <link>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%95%84%EC%9B%83%EB%B0%95%EC%8A%A4-%ED%8C%A8%ED%84%B4-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8-AI-%EC%B1%84%ED%8C%85-3-1</link>
            <guid>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%95%84%EC%9B%83%EB%B0%95%EC%8A%A4-%ED%8C%A8%ED%84%B4-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8-AI-%EC%B1%84%ED%8C%85-3-1</guid>
            <pubDate>Mon, 22 Sep 2025 19:49:08 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️
<strong>작성 날짜</strong> 2025.09.23</p>
<h2 id="고민-내용">고민 내용</h2>
<p>찾아본 이론을 바탕으로 적용해봤는데, 진짜 작동하는지 궁금하다!</p>
<blockquote>
<p>🤔 그래서 트랜잭션 아웃박스 패턴 쓰면 얼마나 좋은 건데?</p>
</blockquote>
<hr>
<h2 id="테스트-준비">테스트 준비</h2>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/ff767ed3-33b6-4430-8e57-71499a47186e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/1afc0924-584f-4945-8821-fee61cab82ee/image.png" alt=""></p>
<p>우선 Test 용 Mock 클래스를 만들어서 Profile 어노테이션을 달아주었다.
이렇게 하면 profile 환경 변수가 dev일 때는 Test~ 클래스가, dev가 아닐 때는 원래 클래스가 실행된다.
<del>이럴 때는 참 헥사고날이 좋다 ㅠㅠ</del></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/b962c7a4-bafd-4a81-a208-19d29b9fd0aa/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/75baed1f-ae65-4ebb-b654-294c7763be49/image.png" alt=""></p>
<p>Mock 클래스로 바꿔 준 이유는 몇 가지 상황을 설정하기 위해서이다.</p>
<p>일단 실제 OpenAI API를 사용하지 않도록 한다.
몇 천 건의 요청을 진짜 보낸다면.. 내 토큰...
그리고 OpenAI의 경우 40% 확률로 실패, Redis Stream 발행은 20%의 확률로 실패하는 상황을 만들었다.</p>
<h2 id="테스트-환경">테스트 환경</h2>
<p>이제 테스트를 해보자.
브랜치를 나눠서</p>
<blockquote>
<p>1) 아웃박스 패턴을 적용하기 전
2) 아웃박스 패턴을 적용</p>
</blockquote>
<p>으로 분리하였다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/31d3fbcc-ce31-4879-917a-f0704c866f55/image.png" alt=""></p>
<p>단위 테스트는 K6로 진행하였다.
많은 양의 API 부하테스트를 진행하기에 편리한 툴이라 종종 사용하고 있다!</p>
<p>20명의 유저가 3분 간 지속적으로 API 요청을 보내는 상황으로 설정.
이를 위해 트랜잭션 아웃박스 패턴에서 사용하는 Scheduler의 주기도 1분으로 단축하였다.</p>
<p>테스트 결과 지표는 다음과 같이 설정하였다.</p>
<blockquote>
<p><strong>메시지 처리 비율</strong> = (ASSISTANT의 메시지 수) / (USER + ASSISTANT의 메시지 수)</p>
</blockquote>
<p>처음에는 아웃박스 엔티티의 상태 비율로 하려고 했는데 그러면 아웃 박스 적용 전에선 결과를 구할 수 없다.
USER의 메시지는 API 요청이 오자마자 저장된다.
그리고 ASSISTANT의 메시지는 OpenAI API 요청이 성공해야만 저장된다.
따라서 이 두 가지의 지표로 처리 비율을 계산할 수 있다.</p>
<hr>
<h2 id="테스트-해보기">테스트 해보기</h2>
<p>각 테스트가 종료되면 스트림에 남아있는 Pending 메시지로 인하여 테스트 간 간섭이 발생하지 않도록 스트림을 제거 후 재생성해주었다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/b71410af-007f-40bc-9cd9-27413a38efea/image.png" alt=""></p>
<p>위에서 언급한 결과 비교 지표 확인을 위해 아래의 SQL 쿼리를 실행하였다.</p>
<pre><code class="language-sql">SELECT
    SUM(CASE WHEN cm.sender_type = &#39;USER&#39; THEN 1 ELSE 0 END) AS user_count,
    SUM(CASE WHEN cm.sender_type = &#39;ASSISTANT&#39; THEN 1 ELSE 0 END) AS assistant_count,
    ROUND(
            SUM(CASE WHEN cm.sender_type = &#39;ASSISTANT&#39; THEN 1 ELSE 0 END) /
            NULLIF(SUM(CASE WHEN cm.sender_type = &#39;USER&#39; THEN 1 ELSE 0 END), 0),
            2
    ) AS assistant_to_user_ratio
FROM chat_message_entity cm;
</code></pre>
<h2 id="테스트-결과-재시도-로직-제거">테스트 결과 (재시도 로직 제거)</h2>
<p>트랜잭션 아웃박스 패턴만의 효과를 확인하기 위해 우선 재시도 로직을 제거하여 테스트하였다.
이후 재시도 로직을 추가하여 실제 재시도 로직의 효과를 비교할 예정이다.</p>
<h3 id="트랜잭션-아웃박스-적용-전">트랜잭션 아웃박스 적용 전</h3>
<p><strong>K6 테스트 결과</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/ea706806-05c4-43d9-8915-ed099dec0b0f/image.png" alt=""></p>
<p>미리 설정해둔 20% 확률의 Stream 메시지 발행 실패를 확인할 수 있다.
OpenAI API의 실패는 Consumer가 비동기로 처리하기 때문에 REST API 요청 단계에서는 확인할 수 없다.</p>
<p><strong>SQL 쿼리 실행 결과</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/a19836f8-4fec-4155-8a62-f80871b69799/image.png" alt=""></p>
<p>이론값과 근사한 값이 나왔다.</p>
<p>Stream 발행 성공률 = 0.8
OpenAI 성공률 = 0.6</p>
<p>예상 최종 성공률 = 0.8 × 0.6 = 0.48 (48%)</p>
<p>이론 값과 오차율 1%p 정도</p>
<p>최종 실패율은 1 - 0.49 = 0.51, 즉 51%이다.</p>
<h3 id="트랜잭션-아웃박스-적용-후">트랜잭션 아웃박스 적용 후</h3>
<p>아웃 박스 메시지들을 실시간으로 확인해봤는데,</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/e539ea59-4cc5-49c1-9f2b-e11dcdd04003/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/5219478c-2a5d-4380-9758-55c48f8c51d8/image.png" alt=""></p>
<p>이렇게 점점 줄어든다. (스케줄러가 계속 확인해 처리해주기 때문)</p>
<p><strong>K6 테스트 결과</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/1f3b2b8f-bcac-4fdf-b644-9ed57d97b1b3/image.png" alt=""></p>
<p>여기서는 전체의 API 요청이 성공하였는데,
이는 트랜잭션 아웃박스 패턴을 적용하면서 내부 로직을 바꿨기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/908cec70-6e6d-4d91-ad9c-2eabbee311d0/image.png" alt=""></p>
<p>어차피 publish에서 예외가 터져도 Outbox는 이미 저장된 상태이기 때문에, Exception을 삼켜도 나중에 Scheduler에 의해 처리된다.
이는 클라이언트에서도 서버 내부의 일시적인 오류로 인한 불필요한 500 응답을 막아주는 역할도 할 수 있다.</p>
<p><strong>SQL 쿼리 실행 결과</strong></p>
<p>처음 종료 시</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/3b617ea5-992d-4bd2-850d-5b0efbb55210/image.png" alt=""></p>
<p>일정 시간 이후</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/0e5d88b5-fcec-45ce-80f2-901a1bcd53fa/image.png" alt=""></p>
<p>처음 종료 시에는 80% 대가 나오다가 이후 100%를 찍었다.</p>
<p>적용 전보다 살짝 전체 숫자가 적긴 한데 아웃박스 엔티티의 입출력 단계에서 발생하는 커넥션이나 스레드의 블로킹으로 예상된다.</p>
<p>최종 결과 성공률 100%, 실패율 0%</p>
<h2 id="테스트-결과-재시도-로직-포함">테스트 결과 (재시도 로직 포함)</h2>
<h3 id="트랜잭션-아웃박스-적용-전-1">트랜잭션 아웃박스 적용 전</h3>
<p><strong>K6 테스트 결과</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/8dab07ff-fb63-4662-9671-a1d90fec8e1c/image.png" alt=""></p>
<p><strong>SQL 쿼리 실행 결과</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/6653c14f-cc9c-4dee-89df-7aa3f7473902/image.png" alt=""></p>
<p>약 78%의 성공률을 확인할 수 있다.</p>
<p>이 역시 이론값과 유사하다.
재시도 로직은 3회 수행되기 때문에,</p>
<blockquote>
<p>P(OpenAI 최종 성공)=1−P(4번 모두 실패)=1−0.44=1−0.0256=0.9744≈97.44%</p>
</blockquote>
<p>따라서 스트림 발행 성공률인 80%와 곱했을 때,</p>
<blockquote>
<p>P(최종 성공)=P(Stream 성공)×P(OpenAI 최종 성공)=0.8×0.9744≈0.7795≈78%</p>
</blockquote>
<p>로 오차가 매우 적다.</p>
<p>따라서 최종 성공률 78%, 실패율 22%</p>
<h3 id="트랜잭션-아웃박스-적용-후-1">트랜잭션 아웃박스 적용 후</h3>
<p><strong>K6 테스트 결과</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/4c6324bb-ab86-490b-82bb-1ba074e3829a/image.png" alt=""></p>
<p><strong>SQL 쿼리 실행 결과</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/92bd7d7c-969f-4f72-bc11-d3f33b5340c3/image.png" alt=""></p>
<p>성공률 100%, 실패율 0%
다만 재시도 로직 적용 전 보다 결과를 더 빨리 확인할 수 있다는 장점이 있다.</p>
<hr>
<h2 id="결론">결론</h2>
<p>실패율 기준</p>
<p>| 시나리오          | 트랜잭션 아웃박스 도입 전 실패율 | 트랜잭션 아웃박스 도입 후 실패율 | 개선 정도   |                                                                   |
| ------------- | ------------------ | ------------------ | ------- |
| <strong>재시도 로직 제거</strong> | 51%                | 0%                 | 51%p 개선 |
| <strong>재시도 로직 추가</strong> | 22%                | 0%                 | 22%p 개선 |</p>
<p>처리 메시지 수 기준</p>
<table>
<thead>
<tr>
<th>시나리오</th>
<th>재시도</th>
<th>아웃박스 도입 전</th>
<th>아웃박스 도입 후</th>
<th>개선 메시지 수</th>
<th>개선률</th>
</tr>
</thead>
<tbody><tr>
<td>재시도 로직 제거</td>
<td>없음</td>
<td>1,720</td>
<td>3,491</td>
<td>1,771</td>
<td>103% ↑</td>
</tr>
<tr>
<td>재시도 로직 추가</td>
<td>있음</td>
<td>2,756</td>
<td>3,481</td>
<td>725</td>
<td>26% ↑</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>결론</strong>
트랜잭션 아웃박스 패턴과 재시도 로직으로 실패율 0%를 달성할 수 있었다!
머리 속의 이론을 활용하여 적용한 결과를 눈으로 확인할 수 있어서 시간을 헛 쓰진 않았구나 하는 안도감이 들었다..ㅎㅎ</p>
</blockquote>
<p> 그리고 코드 캡처할 때 인텔리제이 플러그인 Easy Code Screenshots 처음 써봤는데 캡처도 깔끔하고 너무 편하다... 진작 쓸걸</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[말모] 트랜잭션 아웃박스 패턴으로 메시지 발행 보장하기 (AI 채팅 3편)]]></title>
            <link>https://velog.io/@_roundtable/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%95%84%EC%9B%83%EB%B0%95%EC%8A%A4-%ED%8C%A8%ED%84%B4%EC%9C%BC%EB%A1%9C-%EB%A9%94%EC%8B%9C%EC%A7%80-%EB%B0%9C%ED%96%89-%EB%B3%B4%EC%9E%A5%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@_roundtable/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%95%84%EC%9B%83%EB%B0%95%EC%8A%A4-%ED%8C%A8%ED%84%B4%EC%9C%BC%EB%A1%9C-%EB%A9%94%EC%8B%9C%EC%A7%80-%EB%B0%9C%ED%96%89-%EB%B3%B4%EC%9E%A5%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 16 Sep 2025 19:05:33 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️⭐️⭐️
<strong>작성 날짜</strong> 2025.09.17</p>
<h2 id="고민-내용">고민 내용</h2>
<p>지난번 OpenAI API 요청을 Message Queue로 처리하여 다음과 같은 장점을 얻을 수 있었다.</p>
<ol>
<li>요청 실패 시 최대 3번의 자동 재요청</li>
<li>DLQ로 실패 요청 관리 가능</li>
<li>트래픽 완화</li>
<li>실시간성 보장</li>
<li>확장성</li>
</ol>
<p>그러나 아직 해결되지 못한 문제가 있다!</p>
<ol>
<li><p>Redis Stream 메시지 발행 시 오류가 발생한 경우 처리가 안된다
<img src="https://velog.velcdn.com/images/_roundtable/post/7b83d766-3e33-4c72-a6a6-9b086796c008/image.png" alt="">
실제로 오류가 발생한 경우,
<img src="https://velog.velcdn.com/images/_roundtable/post/dbb6c6c3-aa9d-4e2f-a2c9-9467cb1ec456/image.png" alt="">
DB는 커밋되었지만 예외가 터져서 500 응답이 발생한다.
이렇게 되면 사용자는 오류를 확인하고 비동기 요청도 발생하지 않지만,
DB는 커밋되는 바람에 이후의 조회에서도 오류가 발생할 수 있다.
또한 운영에서도 문제의 발생을 로그로만 확인할 수 있다.</p>
</li>
<li><p>컨슈머가 요청 처리 도중 죽어버린 경우</p>
<p>컨슈머가 요청을 처리하던 도중 오류가 발생한 경우에는 자동으로 큐에 메시지를 발급한다.
하지만 예외도 처리하기 전에 알 수 없는 이유로 서버가 죽으면,
메시지는 PEL에 Pending 상태로 남고 재처리 되지는 않는다.</p>
</li>
<li><p>DLQ에 박혀있는 실패 메시지들
DLQ에서 직접 확인해보면 실패한 메시지들에 대한 확인이 가능하지만,
이 메시지들을 하나하나 직접 재요청해야만 처리가 가능하다.</p>
</li>
</ol>
<blockquote>
<p>🤔 <em>실패한 메시지들에 대해 데이터 정합성을 지키면서 자동 재시도 처리할 수는 없을까?</em></p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<p>처음에는 실패한 메시지들을 어떻게 처리하지? 라는 의문이 생겼고,</p>
<blockquote>
<p>그럼 DLQ를 주기적으로 조회해서 실패 메시지를 재발행 하면 되는 거 아닌가?</p>
</blockquote>
<p>이렇게 생각했다.
하지만 메시지 발행조차 안되거나 컨슈머가 죽는 경우에는 메시지를 처리할 수가 없다. (처리할 메시지조차 없다ㅠㅠ)</p>
<p>그래서 찾아본 결과 메시지 발행을 보장하고 실패 메시지에 대한 일괄 처리를 보장하는 <strong>트랜잭션 아웃박스 패턴</strong>이라는 것을 알게 되었다.</p>
<hr>
<h3 id="트랜잭션-아웃박스-패턴-transactional-outbox-pattern">트랜잭션 아웃박스 패턴 (transactional outbox pattern)</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/89081e8d-1d5a-49ac-8b39-dd927020efaf/image.png" alt="">
<em>출처 : 대규모 트랜잭션을 처리하는 배민 주문시스템 규모에 따른 진화 (우아콘2023)</em></p>
<p>트랜잭션 아웃박스 패턴은 메시지 큐 환경에서 메시지 유실을 근본적으로 방지하는 전략이다.
비즈니스 데이터와 메시지를 하나의 트랜잭션으로 묶어 원자성을 확보하고,
별도의 안전한 경로를 통해 MQ로 발행함으로써 데이터 정합성을 보장한다.</p>
<p><strong>트랜잭션 아웃박스 패턴의 원리</strong></p>
<ol>
<li><p><strong>아웃박스 테이블 생성</strong>
비즈니스 데이터와 같은 DB에 <code>outbox</code> 테이블을 둔다.</p>
</li>
<li><p><strong>비즈니스 트랜잭션과 함께 메시지 저장</strong>
애플리케이션은 비즈니스 데이터를 저장할 때 동시에 아웃박스 테이블에 메시지를 기록한다.
이 과정은 하나의 DB 트랜잭션 안에서 처리되므로 원자성이 보장된다.</p>
</li>
<li><p><strong>별도 프로세스(Outbox Poller)에서 메시지 발행</strong>
아웃박스 테이블에 쌓인 메시지는 전용 프로세스(스케줄러)가 주기적으로 읽어 MQ(Kafka, RabbitMQ, Redis Stream 등)에 발행한다.</p>
</li>
<li><p><strong>발행 성공 시 상태 업데이트</strong>
MQ 발행이 성공하면 해당 메시지를 <code>SENT</code> 상태로 업데이트하거나 삭제한다.
실패하면 재시도 로직을 수행한다.</p>
</li>
</ol>
<p><strong>상태 관리 전략</strong></p>
<p>아웃박스 패턴에서는 메시지 상태를 관리해야 한다.
나의 경우에는 요런 식으로 하려고 한다.</p>
<ul>
<li><strong>PENDING</strong> : 발행 대기 상태</li>
<li><strong>SENT</strong> : MQ로 정상 발행 완료</li>
<li><strong>FAILED</strong> : 컨슈머가 메시지를 처리했지만, 실패한 상태</li>
<li><strong>DONE</strong> : 메시지 처리가 완전히 완료</li>
</ul>
<p>상태를 두는 이유는 메시지가 정상적으로 처리되었는지 추적하고, 장애 발생 시 재처리를 가능하게 하기 위함이다.</p>
<p>문제가 되는 상황은 PENDING 상태의 지속과 FAILED 상태</p>
<p>스케줄러는 두 가지 방식으로 체크한다.</p>
<ul>
<li><p>메시지 발행 보장(3초 마다 체크)
PENDING 상태가 지속된지 5초가 지난 경우 다시 publish를 시도
retry_count를 두고, 임곗값(3회) 초과 시 FAILED 처리</p>
<p>이 상황은 MQ에 문제가 있거나 알 수 없는 이유로 메시지가 발행되지 않은 경우이다. 
이 과정은 빠르게 처리되는 것이 정상이기 때문에, 5초 이상 처리되지 않는 경우 처리가 되지 않았음으로 가정, 최대 8초까지의 지연을 허용한다.</p>
</li>
<li><p>API 호출 실패 및 메시지 발행 관리(5분 마다 체크)
메시지가 FAILED 상태인 경우 다시 publish를 시도
(컨슈머 로직 실패 또는 메시지 발행 반복된 실패인 경우)</p>
<p>외부 API의 서버 문제 또는 메시지 큐의 문제상황으로 간주,
5분 간격으로 API의 상태를 health check하고 FAILED 상태인 메시지를 재시도 처리한다.</p>
</li>
</ul>
<p><strong>SENT 상태의 지속은 어떻게 처리할까?</strong>
SENT 상태: 브로커는 메시지를 받았지만, 컨슈머가 아직 로직을 처리하지 않은 상황</p>
<p>이 상황이 지속된다는 것은 컨슈머가 로직 처리를 못 했거나, 컨슈머는 로직 처리를 완료했으나 OUTBOX 상태를 DB에 반영을 못한 경우다.</p>
<p>이 상황에 대해서는 따로 처리하지 않기로 결정했는데, 이유는 다음과 같다.</p>
<ol>
<li><p>이 상황은 컨슈머의 문제이다.
트랜잭션 아웃박스 패턴은 Producer를 위한 패턴이다.
Producer의 트랜잭션은 커밋되었는데, 메시지는 처리되지 않은 경우를 위한 해결책이다.
FAILED 상황을 따로 처리하는 경우는 부가적으로 외부 API 요청의 안정성을 보강하기 위한 방안이다.</p>
</li>
<li><p>추가적인 처리를 할 경우 중복 메시지 문제가 발생할 수 있다.
컨슈머가 이미 처리했는데 DB에 반영만 안된 경우라면, 중복 메시지 발행으로 의도치 않은 데이터 처리가 발생한다.</p>
</li>
<li><p>2번의 문제를 피하려면 너무 상황이 복잡해진다.
정리해보면 이렇다.</p>
<blockquote>
<ul>
<li>PEL에 존재 → 컨슈머 로직 도중 조용히 실패 
=&gt; 메시지 재발급 필요</li>
<li>PEL에 존재 X &amp; DLQ에 존재 → 컨슈머 로직 도중 예외, DB 반영 실패 
=&gt; DONE으로 상태 변경</li>
<li>PEL에 존재 X &amp; DLQ에도 존재 X → 컨슈머가 메시지를 못 가져옴 
=&gt; 컨슈머의 상태 변경 필요 (재시작 등)</li>
</ul>
</blockquote>
</li>
</ol>
<p>따라서 이를 재시도 로직에 넣지 않고, DB를 모니터링하여 문제 발생 시 원인을 찾도록 할 예정이다.</p>
<hr>
<p><strong>장점</strong></p>
<ul>
<li><strong>메시지 유실 방지</strong>: DB와 MQ 발행 사이의 불일치를 제거한다.</li>
<li><strong>재시도 가능</strong>: 발행 실패 시 아웃박스 테이블을 기반으로 재시도할 수 있다.</li>
<li><strong>추적성 확보</strong>: 메시지의 상태와 이력을 관리할 수 있다.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li><strong>추가 테이블 관리 필요</strong>: 아웃박스 테이블이 커질 수 있어 아카이빙 전략이 필요하다.</li>
<li><strong>지연(latency)</strong>: 폴링 주기에 따라 메시지 발행이 지연될 수 있다.</li>
<li><strong>운영 복잡도 증가</strong>: 별도 메시지 발행 프로세스 관리가 필요하다.</li>
</ul>
<hr>
<p><strong>그럼 Redis Stream 이제 필요 없는 거 아닌가..?</strong></p>
<p>추가적인 인프라 비용이 발생하는 상황에서 굳이 메시지 큐가 필요한가?
그냥 스프링 애플리케이션 내부의 이벤트나 비동기 요청을 이용해서 처리하면 안 되나?
어차피 스케줄러가 재시도를 처리하는데, 3회 재시도 전략도 스케줄러를 통해 처리하면 되지 않을까?</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/c8c4b9f7-8d49-40b8-b238-067128b57dd8/image.png" alt=""></p>
<h3 id="그럼에도-redis-stream을-사용하는-이유">그럼에도 Redis Stream을 사용하는 이유</h3>
<ul>
<li><p><strong>일단, 비동기 처리가 필요하다</strong>
동기로 처리하면 클라이언트는 OpenAI의 응답이 완료될 때까지 요청을 기다려야 한다.
쓰레드 풀 고갈로 인한 응답 지연이 발생할 수 있다.</p>
</li>
<li><p><strong>비동기 처리, 외부 아키텍처가 필요한가?</strong>
AI 채팅은 서버에서 가장 부하가 많은 부분이다.
스프링 어플리케이션 내부에서 단순 비동기 요청하는 것은 작업 큐에 작업을 제출하는 방식인데,
해당 방식은 JVM 메모리 위에서 동작하기 때문에 요청이 몰려 서버가 죽는 경우 재부팅 시 모두 유실된다.</p>
<p>반면에 Redis Stream은 외부 인프라를 사용하기 때문에 스프링의 장애에서 분리되며, 재부팅 시에도 영속화 설정을 통해 유지 가능하다.</p>
<p>병렬 처리에 있어서도 장점이 있는데, MQ는 자동으로 여러 컨슈머로 작업을 뿌려주지만,
그냥 내부 비동기를 사용할 경우 여러 개의 스레드 풀을 등록하여 직접 분산시켜 주어야 여러 개의 작업 큐를 사용할 수 있다.
그렇지 않으면 그냥 하나의 작업 큐만 사용하니 병목 문제가 발생할 수 있다.
나아가 MQ에서는 컨슈머가 느리더라도 버퍼링을 통해 컨슈머 속도에 맞춰 처리가 가능하다.</p>
</li>
<li><p><strong>그리고 확장성...</strong>
컨슈머 그룹 기준으로 스케일 아웃이 가능하다.
요청이 많아지는 경우 IO 작업을 처리하는 컨슈머만 분리하여 여러 개로 증설할 수 있다.</p>
<p>현재도 논블로킹 IO 작업을 통한 효율적인 스레드 관리를 위해 WebClient를 사용 중이다.
이후 IO 작업이 많은 컨슈머 작업은 WebFlux로 변경할 수 있기 때문에 컨슈머를 분리하는 것은 확장성을 고려한 선택이다.</p>
</li>
</ul>
<p><strong>Redis Stream 컨슈머의 재시도 처리 vs 스케줄러의 재시도 처리</strong></p>
<blockquote>
<p>채팅은 실시간성이 보장되어야 하기 때문에 최대한 빠른 재시도가 필요하고,
3번 이상 실패한 경우에는 API 서버에 문제가 있다고 판단하여 나중에 일괄 재시도해야 한다.</p>
</blockquote>
<p>이 경우에는 너무 자주 DB를 조회해 부하가 발생하지 않도록 Scheduler의 시간을 일정 시간 이상 두어야 한다.</p>
<blockquote>
</blockquote>
<p>따라서 일정 주기로 재시도 하게 되며, 실시간성을 보장하지 않는다.</p>
<p>컨슈머가 실패 즉시 2회 재시도(총 3회 시도)하여 API의 성공 확률을 높이고,
스케줄러는 API 서버의 상태를 확인하고 10분 단위로 재시도하여 안정적인 메시지의 발행과 일괄적인 재시도를 담당한다.</p>
<blockquote>
<p>Outbox는 “DB와 이벤트 발행 사이 일관성 보장”
Redis Stream은 “실시간 분산 전달 및 처리”</p>
</blockquote>
<p>각각의 역할 분담 정도로 생각하면 좋을 것 같다.</p>
<hr>
<h3 id="아직-남은-메시지-중복-처리-문제">아직 남은 메시지 중복 처리 문제</h3>
<p>만약 beforeCommit에서 Outbox 메시지를 저장하고, afterCommit에서 Redis Stream을 발행했을 때</p>
<p>Producer가 메시지를 발행하는 것보다 Scheduler가 빠르게 아웃박스를 조회해 메시지를 발행하면 어떻게 될까?</p>
<p>스케줄러의 조회 시간을 충분하게 둔다고 하더라도 알 수 없는 이유로 메시지 발행이 지연된 경우에는 중복 메시지 문제가 발생할 수 있다.</p>
<p>해결 방법으로 생각해 본 것은 다음과 같다.</p>
<ol>
<li><p><strong>메시지 발행의 주체는 Only 스케줄러만</strong>
Producer의 역할은 아웃박스에 메시지를 저장하는 것 까지만.
스트림으로 발행하는 것은 Scheduler가 Pending 상태의 메시지를 넣어주는 것으로 책임을 완전히 분리한다.</p>
<p>이렇게 하면 책임이 명확해지고, 구조가 단순해진다는 장점이 있다.
그러나 스케줄러가 동작하는 주기에 의해 딜레이 되기 때문에 실시간성을 유지할 수 없다.</p>
</li>
<li><p><strong>CDC(Change Data Capture)</strong>
DB에서 데이터의 변화를 실시간으로 감지하고 Stream으로 발행한다.
<img src="https://velog.velcdn.com/images/_roundtable/post/2b5eb3ba-222c-4dcc-9963-edf0bfba4cd3/image.png" alt=""></p>
<p>실제 AWS에서 지원하는 DynamoDB의 추가 설정을 통해 구현이 가능하다.
DynamoDB에 변화를 캡쳐할 테이블을 설정하고, 변화가 감지되면 Stream으로 메시지를 발행하는 구조이다.</p>
<p>이렇게 하면 실시간성을 확보할 수 있다! 그러나 추가적인 인프라 구성에 대한 부담이 존재한다. 단일 인스턴스 환경에서 이러한
실제 MSA 구성에서는 이러한 방식이 도움이 될 것 같아서 일단 알아만 두고 넘어가려고 한다!</p>
</li>
<li><p><strong>beforeCommit에서 아웃박스에 저장, afterCommit에서 저장된 아웃박스를 활용</strong>
단순하게 메시지를 발행은 무조건 아웃박스를 거치는 방법이다.
만약 스케줄러가 메시지를 발행한 상태라면 메시지의 상태가 SENT로 변경되었을 것이며,
아웃박스 ID와 함께 PENDING 상태인 메시지를 조건으로 조회해 발행한다면 중복 메시지 문제를 피할 수 있을 것이다.</p>
</li>
</ol>
<p>이 방식 역시 완전히 스레드 경합 문제에서 자유롭지는 않다.</p>
<ul>
<li>스케줄러의 스레드에서 메시지 조회</li>
<li>Producer(afterCommit)의 메시지 조회</li>
<li>스케줄러의 메시지 발행</li>
<li>Producer의 메시지 발행</li>
</ul>
<p>이 순서의 문제가 존재하기 때문이다.
이 문제는 비관적 락으로 해결 가능할 것으로 보이지만,
트래픽이 많지 않은 현재 상황에서 적용 시 성능 저하로 인한 실시간성 문제가 생긴다는 트레이드 오프를 고려하여 적용하지 않기로 하였다.</p>
<p>3번 방식만 적용하더라도 그냥 발행부터 하던 과거 방식과는 달리 일차적인 검증 단계가 들어가기 때문에 어느 정도 이슈에서 자유로울 것으로 예상한다.</p>
<hr>
<h2 id="적용하기">적용하기</h2>
<h3 id="outboxentity">OutboxEntity</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/fc7d012a-e52e-4f5f-a7e8-4a3b0d5bb542/image.png" alt=""></p>
<p>아웃박스 패턴에서 메시지 저장을 위한 엔티티이다.
json 형태의 payload를 저장하는데, 마찬가지로 암호화 대상이다.(<a href="%22https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-DB-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%95%94%ED%98%B8%ED%99%94-%EC%A0%84%EB%9E%B5-feat.-AES-NI-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8%22">이전 포스팅</a> 참고)</p>
<p>PENDING 상태로 무한 재시도 하는 것을 막기 위해 retryCount를 추가했고, 해당 카운트가 임곗값을 넘으면 FAILED 상태로 변경한다.</p>
<p>상태값은 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/6b0b5dcf-e67c-4d25-b607-b60cd658ca7e/image.png" alt=""></p>
<h3 id="outboxhelper">OutboxHelper</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/edc97404-de4c-4771-8c4d-600b42bedbc8/image.png" alt=""></p>
<p>OpenAI API를 이용해야 하는 메서드들이 호출하는 publish 메서드이다.
Outbox를 통해 메시지를 저장하는 것까지만 진행하고,
책임과 트랜잭션의 완전 분리를 위해 ApplicationEvent를 발행한다.</p>
<h3 id="outboxmessagesavedeventlistener">OutboxMessageSavedEventListener</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/43d8f519-7d0d-439a-81d8-5ae72ab05be5/image.png" alt=""></p>
<p>OutboxMessageSavedEvent를 감지하는 Listener 클래스이다.
<a href="%22https://velog.velcdn.com/images/_roundtable/post/a51ced8d-ab81-4b87-a24a-374690643aa3/image.png%22">이전 포스팅</a> 확인했던 경합 문제를 해결하기 위해 트랜잭션 종료 후(AFTER_COMMIT) 상황에 실행되도록 하였고,
REQUIRES_NEW를 통해 새로운 트랜잭션으로 분리를 명시하였다.</p>
<h3 id="outboxservice-publish">OutboxService (publish)</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/6e334d3b-a56f-4037-8299-f5ccd380959e/image.png" alt=""></p>
<p>Outbox 테이블에서 앞서 저장한 Outbox의 ID를 통해 Outbox를 조회하고, payload 값을 담아 Stream에 실질적으로 발행한다.
이전과 다른 점은 Outbox의 ID 값을 메시지에 담는다는 것인데,
메시지를 처리하면 Outbox의 상태를 FAILED 또는 DONE으로 바꾸기 위한 장치이다.
messageId를 이용하지 않은 이유는 재시도(재발행) 로직으로 인해 메시지의 ID 값은 달라질 수 있지만, Outbox의 ID는 동일하기 때문이다.</p>
<h3 id="consumer">Consumer</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/f73a490f-5c3f-4d75-81cc-88cf258af703/image.png" alt=""></p>
<p>소비자 로직은 이전과 거의 동일하다.
메시지 처리 성공 시 Outbox 메시지를 조회해 DONE 상태로 마킹한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/eac9d616-855d-494a-94f0-ddf1ecc89e61/image.png" alt=""></p>
<p>만약 메시지 처리에 실패하면 DLQ에 넣으면서 Outbox 메시지를 FAILED 처리한다.
이렇게 마킹 처리된 메시지들은 Scheduler가 처리한다.</p>
<h3 id="outboxscheduler">OutboxScheduler</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/bb916c02-8fc1-4b63-a62e-6c68d859b477/image.png" alt=""></p>
<p>3초마다 5초 이상 PENDING이 지속되는 메시지를 조회하여 처리한다.
PENDING 상태에서 SENT가 되지 않은 메시지는, 초기 로직이 실행되어 DB에 반영되었으나, 발행되지 않은 경우로 Scheduler가 이를 처리한다.</p>
<p>5분마다 FAILED 상태인 메시지들을 발행 시도한다.
FAILED 상태는 지속적으로 메시지 발행에 실패하거나 API 호출에 실패한 메시지들로, Scheduler가 이를 재발행하여 처리한다.</p>
<h3 id="outboxservice-scheduled">OutboxService (scheduled)</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/208fb2cf-595a-45fe-8523-cfa63f7dceb0/image.png" alt=""></p>
<p>Scheduler가 호출하는 재시도 로직이다.
PENDING 상태이면서 해당 상태가 5초 이상 지속된 경우, 메시지를 스트림에 발행한다.</p>
<p>발행 성공한 메시지만 Outbox에 기록한다.
messageId를 등록하고 상태를 SENT로 변경한다.
만약 실패한 메시지의 처리 횟수가 3회 이상이 된 경우 FAILED 상태로 변경한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/c898b23c-28fe-4dda-ab72-ab864155a90f/image.png" alt=""></p>
<p>FAILED 상태를 가진 메시지를 처리한다.
FAILED 상태인 메시지들은 OpenAI API의 상태가 불안정한 경우일 수 있기 때문에 Health Check를 먼저 진행한다.</p>
<p>이전과 달리 지속 시간에 대한 조건이 없는 이유는 PENDING 상태인 경우 현재 Publishing이 진행 중일 수 있기 때문에 필요한 조건이었으며, 
여기에서는 API 상태에 문제가 생긴 경우이기 때문에 API가 회복되면 FAILED 상태인 메시지들을 모두 처리해야 한다.</p>
<p>너무 많은 메시지 처리로 인한 문제가 생길 경우에는 Chunk 단위로 나누어 처리할 필요도 있을 것 같다!</p>
<hr>
<h3 id="redisstreamadapter">RedisStreamAdapter</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/0d80422d-a06b-42b8-9e5b-6c7676673878/image.png" alt=""></p>
<p>많은 양의 Outbox를 Batch 처리하기 위한 메서드이다.
개념 상 List를 받아 publish 처리하는 메서드이기 때문에 <code>batch</code>라는 키워드를 사용하였지만, 실제 레디스 내부적으론 Stream으로 처리한다.</p>
<p>왜냐하면 Redis Stream의 <code>XADD</code>가 단일 메시지만 처리할 수 있기 때문이다ㅠㅠ
조금이라도 처리를 효율적으로 하기 위해 Pipeline을 이용하였다.</p>
<p>그냥 단순히 <code>XADD</code>를 N번 처리할 수도 있지만 이 방식은 네트워크 왕복 시간(RTT)도 N배가 된다.
Pipeline을 이용하면 <code>XADD</code> 명령을 명령어 큐에 차곡차곡 쌓아두고, 한 번에 전달하기 때문에 네트워크 왕복이 한 번만 발생한다.</p>
<hr>
<h2 id="컨슈머가-요청-처리-도중-죽어버린-경우-해결하기">컨슈머가 요청 처리 도중 죽어버린 경우 해결하기</h2>
<p>위의 코드로 거의 모든 조건을 만족했다.
그런데 2번인 &quot;컨슈머가 요청 처리 도중 죽어버린 경우&quot;를 아직 해결하지 못 했다.</p>
<p>계획 단계에서 SENT로 처리된 아웃박스 메시지를 처리하지 않기로 하면서 해당 부분을 어떻게 해결할 수 있을지 고민했는데, 다음과 같이 결론내렸다.</p>
<blockquote>
<p>PEL에 너무 오래 남아있으면서, 아웃박스 상태가 DONE이 아닌 메시지를 재처리하자.</p>
</blockquote>
<p>일단 이 컨슈머 그룹에서 PEL에 너무 오래 남아있다는 것은 컨슈머가 죽어서 제대로 처리되지 않았음을 의미한다.
그리고 이미 처리되었지만 ACK 처리되지 않아 PEL에 남아있는 경우를 재처리를 피하기 위해 DONE 상태 조건을 넣었다.</p>
<p>이걸 그냥 메시지 재발행하는 것은 PEL에 메시지가 쌓여 후에 메모리 이슈가 될 수 있으며, 메시지 중복에 대한 문제도 될 수 있다.</p>
<p>그래서 <code>XPENDING</code>과 <code>XCLAIM</code>을 사용하였다.
<code>XPENDING</code>을 통해서 해당 컨슈머 그룹의 PENDING 상태의 메시지를 조회할 수 있다.
그리고 <code>XCLAIM</code>은 컨슈머에게 할당 된 메시지를 다른 컨슈머에게 재할당하는 명령이다.</p>
<p>컨슈머가 죽어있는 상태이기 때문에, 컨슈머 그룹 내의 모든 PEL 메시지들을 별도의 Consumer로 옮기고, 해당 컨슈머를 이용해 ACK 처리!
하는 게 내 계획이다.</p>
<p>그리고 메시지에 담긴 outboxId를 조회해 해당 Outbox가 DONE 상태가 아니라면 FAILED 상태로 변경한다. (이건 이제 FAILED를 처리하는 스케줄러가 처리한다.)</p>
<h3 id="outboxscheduler-1">OutboxScheduler</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/afb56d8b-a143-43be-b906-3cf623fb4856/image.png" alt=""></p>
<p>10분마다 스케줄러가 job을 실행한다.</p>
<h3 id="retrypendingmessages">retryPendingMessages</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/9a68664e-c237-4555-92a3-170f8f08d422/image.png" alt=""></p>
<p>PENDING 상태에서 5분 이상 지난 모든 PEL 메시지를 조회한다.</p>
<p>해당 메시지의 outboxId를 조회해 DONE 상태가 아닌 메시지를 FAILED 처리하고, ACK 명령을 보내 PEL에서 제거한다.</p>
<h3 id="loadpendingmessages">loadPendingMessages</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/0f12223a-7aa0-4aa1-94a2-3b73b533276c/image.png" alt=""></p>
<p>컨슈머 그룹의 Pending 메시지를 모두 조회한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/f1d811a5-9be7-4329-9345-1dde3c1609e8/image.png" alt=""></p>
<p>이제 각 컨슈머의 메시지를 가져와서 minIdleTime (5분)에 해당하는 메시지가 있다면 Claim 명령을 보내어 별도의 컨슈머로 옮긴다.</p>
<p>그리고 allClaimedMessages로 모아서 한 번에 return 한다.</p>
<p>이제 PEL에 쌓인 메시지는 ACK 되어 제거되기 때문에 메모리를 지킬 수 있으며,
아웃박스에서 메시지는 FAILED 처리되어 스케줄러에 의해 재처리된다.</p>
<hr>
<h2 id="결론">결론</h2>
<p>&#39;데이터 정합성&#39;이라는 키워드에 꽂혀 결점이 없는 서버를 만들자는 목표로 트랜잭션 아웃박스 패턴을 도입해보았다.
스스로 정말 오랜 시간 고민하고, 또 고민한 문제였다.
단순하게 적용하지 않고 상황에 맞는 방식으로 도입하다 보니 설계에만 꽤 많은 시간을 쏟은 것 같다.</p>
<p>물론 이게 진짜 최선의 선택인지도 정확히 잘 모르겠고 시간도 오래 걸렸지만,
Trade-off를 고민해보며 다양한 관점으로 문제 해결을 위해 고민했던 과정이 큰 도움이 된 것 같다!</p>
<p>이후에 확장된다면, 이런 것들을 고려해볼 수 있을 것 같다.</p>
<ul>
<li><p>트래픽이 많아지는 경우 Outbox Chunk 단위로 조회
현재는 트래픽이 많지 않기 때문에 전체를 조회한다.</p>
</li>
<li><p>Outbox 상태 변경을 위한 효율적인 쿼리 도입
현재는 빠른 개발을 위해 JPA를 최대한 활용하고 있다.</p>
</li>
<li><p>분산 시스템으로 확장된 경우 CDC 도입</p>
</li>
</ul>
<blockquote>
<p><strong>결론</strong>
안정적인 서버 구축의 방법이 무궁무진하다는 것을 알게 되었다. 
항상 외부 시스템을 의심하자!</p>
</blockquote>
<hr>
<p><strong>참고한 자료들</strong></p>
<p>우아한테크 - 대규모 트랜잭션을 처리하는 배민 주문시스템 규모에 따른 진화
<a href="https://youtu.be/704qQs6KoUk?si=AJ3tFOeWbVihLYOu">https://youtu.be/704qQs6KoUk?si=AJ3tFOeWbVihLYOu</a></p>
<p>분산 시스템에서 메시지 안전하게 다루기 - 강남언니 공식블로그
<a href="https://blog.gangnamunni.com/post/transactional-outbox">https://blog.gangnamunni.com/post/transactional-outbox</a></p>
<p>트랜잭션 아웃박스 패턴 - AWS 권장 가이드
<a href="https://docs.aws.amazon.com/ko_kr/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html">https://docs.aws.amazon.com/ko_kr/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[말모] DB 데이터 암호화 전략 (feat. AES-NI 성능 테스트)]]></title>
            <link>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-DB-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%95%94%ED%98%B8%ED%99%94-%EC%A0%84%EB%9E%B5-feat.-AES-NI-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-DB-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%95%94%ED%98%B8%ED%99%94-%EC%A0%84%EB%9E%B5-feat.-AES-NI-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Fri, 12 Sep 2025 15:41:25 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️
<strong>작성 날짜</strong> 2025.08.13</p>
<h2 id="고민-내용">고민 내용</h2>
<p>동아리 내 사용해 보신 분들이 이런 질문을 하셨다.</p>
<blockquote>
<p>이거 채팅 내용 운영자 분들이 보실 수 있으신가요?
부끄러운데...</p>
</blockquote>
<p>아무래도 아는 사람들이 채팅 내용을 볼 수 있다는 점이
사용이 꺼려지시는 포인트인 것 같다.</p>
<p>기획 분께서도 해당 피드백을 받으시고
<strong>최대한 빨리 채팅 암호화!!</strong>
를 요청하셨다.</p>
<blockquote>
<p>🤔 채팅 메시지 데이터, 어떻게 암호화 할까?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<p>우리의 비즈니스 로직 상 지켜져야 하는 부분은 다음과 같다.</p>
<blockquote>
<ol>
<li>사용자의 채팅 메시지를 암호화 해야 한다.</li>
<li>AI 채팅 메시지를 암호화 해야 한다.</li>
<li>채팅 메시지는 사용자가 읽을 수 있어야 한다.</li>
<li>AI가 사용자의 메시지를 알 수 있어야 한다. (채팅 맥락 확인 용)</li>
</ol>
</blockquote>
<h3 id="대칭-키-비대칭-키">대칭 키? 비대칭 키?</h3>
<p><strong>대칭 키 (Symmetric Key)</strong>
대칭 키 암호화는 암호화와 복호화에 동일한 키를 사용하는 방식이다.
데이터를 보내는 사람과 받는 사람 모두가 같은 비밀 키를 공유해야 한다. 
이 방식은 하나의 문을 같은 열쇠로 열고 잠그는 것과 비유할 수 있다.</p>
<blockquote>
<p>작동 원리</p>
</blockquote>
<ol>
<li>송신자는 공유된 대칭 키를 사용하여 원본 데이터(평문)를 암호화한다.</li>
<li>암호화된 데이터(암호문)를 수신자에게 전송한다.</li>
<li>수신자는 미리 공유받은 동일한 대칭 키를 사용하여 암호문을 복호화하여 원본 데이터를 확인한다.</li>
</ol>
<p>장점</p>
<ul>
<li>암호화 및 복호화 과정이 단순하여 계산 속도가 매우 빠르다. 따라서 대용량 데이터를 암호화하는 데 적합하다.</li>
<li>조가 간단하여 구현이 비교적 쉽다.</li>
</ul>
<p>단점</p>
<ul>
<li>암호화와 복호화를 위해 양측이 동일한 키를 안전하게 공유해야 하는 어려움이 있다. 
키가 전송 과정에서 탈취되면 암호 전체가 무력화된다.</li>
<li>통신하는 사람의 수가 많아질수록 관리해야 할 키의 개수가 기하급수적으로 늘어난다. (N명이 통신할 경우, N(N−1)/2개의 키가 필요하다.)</li>
<li>새로운 사용자가 추가될 때마다 기존 사용자들과 각각의 비밀 키를 생성하고 공유해야 하므로 확장성이 떨어진다.</li>
</ul>
<p>알고리즘 종류: DES, 3DES, AES, SEED, ARIA</p>
<p><strong>비대칭 키 (Asymmetric Key)</strong>
비대칭 키 암호화는 암호화와 복호화에 서로 다른 키를 사용하는 방식이다. 
이 방식은 공개 키(Public Key)와 비밀 키(Private Key)라는 한 쌍의 키로 구성된다.</p>
<p>공개 키는 이름처럼 누구에게나 공개될 수 있지만, 비밀 키는 소유자만이 안전하게 보관해야 한다.</p>
<p>자물쇠와 열쇠에 비유할 수 있는데, 누구나 공개된 자물쇠(공개 키)로 상자를 잠글 수 있지만, 오직 상자 주인만이 가진 열쇠(비밀 키)로만 열 수 있다.</p>
<blockquote>
<p>작동 원리</p>
</blockquote>
<ol>
<li>수신자는 자신만의 공개 키와 비밀 키 쌍을 생성한다.</li>
<li>자신의 공개 키를 송신자를 포함한 다른 사람들에게 공개한다.</li>
<li>송신자는 수신자로부터 받은 공개 키를 사용하여 데이터를 암호화하고 전송한다.</li>
<li>수신자는 자신이 가진 비밀 키를 사용하여 암호문을 복호화한다. 이 암호문은 오직 해당 개인 키로만 열 수 있다.</li>
</ol>
<p>장점</p>
<ul>
<li>공개 키는 누구나 알아도 되므로 키를 배송하는 과정에서 보안 문제가 발생하지 않는다.</li>
<li>각 사용자는 자신의 키 쌍(공개 키, 개인 키)만 관리하면 되므로, 통신 상대방이 늘어나도 관리해야 할 키의 수가 늘어나지 않는다.</li>
<li>개인 키로 서명한 데이터는 해당 개인 키의 소유자만이 작성했음을 증명할 수 있어, 메시지 출처를 확인하고 데이터 전송 사실을 부인하는 것을 방지할 수 있다.</li>
</ul>
<p>단점</p>
<ul>
<li>대칭 키 방식에 비해 암호화 및 복호화 과정이 복잡하여 속도가 현저히 느리다.</li>
<li>수학적으로 복잡한 계산을 기반으로 한다.</li>
</ul>
<p>알고리즘 종류: RSA, DSA, ECC</p>
<p><strong>하이브리드 방식 (Hybrid Cryptosystem)</strong></p>
<p>실제 통신 환경에서는 대칭 키와 비대칭 키의 장점을 결합한 하이브리드 방식이 널리 사용된다.</p>
<p>대용량 데이터를 암호화하는 데는 속도가 빠른 대칭 키를 사용한다.</p>
<p>이때 사용된 대칭 키를 안전하게 전달하기 위해, 속도는 느리지만 보안성이 뛰어난 비대칭 키 방식으로 암호화하여 상대방에게 전송한다.</p>
<p>데이터를 받은 수신자는 자신의 개인 키로 암호화된 대칭 키를 복호화하고, 그 대칭 키를 사용하여 실제 데이터를 복호화한다.</p>
<p>이러한 하이브리드 방식은 우리가 일상적으로 사용하는 SSL/TLS 프로토콜(HTTPS)의 핵심 원리이며, 빠르고 안전한 데이터 통신을 가능하게 한다.</p>
<hr>
<h3 id="그래서-뭘-선택해야-할까">그래서 뭘 선택해야 할까?</h3>
<p><strong>1. 클라이언트-서버 간 암호화</strong>
이 방식은 애초에 클라이언트에서 메시지를 보낼 때 암호화를 해서 보내는 방식이다.</p>
<p>클라이언트가 보낸 암호화 된 메시지를 그대로 저장하면 되기 때문에 편리하다고 생각했다.</p>
<ul>
<li><p>클라이언트가 대칭 키를 갖는 경우
→ 서버가 복호화를 해서 메시지를 AI한테 줘야 되는데 그게 안 된다.</p>
</li>
<li><p>클라이언트가 비밀 키를 갖는 비대칭 키 방식
→ 메시지 암호화는 공개 키로 해야 한다. 비밀 키로 암호화 하면 누구나 복호화 할 수 있기 때문.
애초에 클라이언트가 키를 생성해서 갖는 방식은 유실 시 데이터를 버리는 것과 마찬가지기 때문에 불가능!</p>
</li>
<li><p>서버가 대칭 키를 갖는 경우
→ 암호화 된 텍스트를 클라이언트가 해석할 수 없다.</p>
</li>
<li><p>서버가 비밀 키를 갖는 비대칭 키 방식
→ 이미 HTTPS를 사용 중이라 중복된 보안이다.
그리고 비대칭 키 방식은 복잡한 알고리즘을 사용하기 때문에 느리다.</p>
</li>
</ul>
<p><strong>2. 서버-DB 간 암호화</strong>
클라이언트 - 서버 간 데이터 유실에 대한 걱정은 HTTPS가 해결해 주기로 하고,
그렇게 받아온 메시지를 DB에 넣을 때 암호화 하는 방식으로 결정했다.</p>
<p>메시지 암호화와 복호화의 주체 모두가 서버이기 때문에,
비대칭 키 방식은 의미가 없고 대칭 키 방식을 선택했다.</p>
<p>대칭 키 방식에는 DES, 3DES, AES, SEED, ARIA 등등이 있는데,
그중에서도 AES를 선택했다.</p>
<ul>
<li>AES: 128/192/256bit 키 지원 → 현재까지 알려진 공격에 안전하다.
특히 미국 NIST에서 표준으로 채택되어 전 세계적으로 가장 많이 사용된다.</li>
</ul>
<p>추가적인 장점은 속도이다.
요즘 CPU에는 AES-NI라는 것을 지원하는데, 이 기술은 암호화를 코어 레벨에서 할 수 있도록 지원하여 소프트웨어 레벨의 연산보다 8배의 암호화 속도를 보여준다고 한다.</p>
<h3 id="부록---jvm에서도-aes-ni는-동작하는가">부록 - JVM에서도 AES-NI는 동작하는가?</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/4ecdfee0-e1e5-4284-b93a-b1cb9832ce47/image.png" alt=""></p>
<p>이런 코드로 테스트 해보았다.</p>
<p>AES-NI를 활성화
<img src="https://velog.velcdn.com/images/_roundtable/post/9ed9d2e7-ebfc-4cc7-ba8a-f992a344140d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/505f7811-0ec4-41d4-894d-e6cdb8bf01ea/image.png" alt=""></p>
<p>AES-NI를 비활성화</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/6c4bf007-1dc1-4e5c-ad0d-92503cf0804c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/4c00256f-3ed4-4ff8-9805-9d16026edc45/image.png" alt=""></p>
<p>뭐야 똑같네</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/3dce4e30-053e-4da8-b9b1-a4a2e582f744/image.png" alt=""></p>
<p>알고보니까 M1 맥은 인텔과 달라서 AES-NI를 사용하지 않는다.
다만 CoreCrypto 모듈을 이용해 암호화를 처리한다는 것 같다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/ac5a6ae6-bc22-44b2-b814-8221be71add6/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/e5816a55-cae8-4284-96a1-64c602b26863/image.png" alt=""></p>
<p><a href="https://csrc.nist.gov/CSRC/media/projects/cryptographic-module-validation-program/documents/security-policies/140sp4854.pdf">https://csrc.nist.gov/CSRC/media/projects/cryptographic-module-validation-program/documents/security-policies/140sp4854.pdf</a></p>
<p>그럼 우분투에서는 어떨까?</p>
<p>ec2에 ssh 접속해서 java 코드를 vi로 작성했다.</p>
<pre><code class="language-java">import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import java.security.SecureRandom;

public class AESTest {

    public static void main(String[] args) throws Exception {
        System.out.println(&quot;Java version: &quot; + System.getProperty(&quot;java.version&quot;));
        System.out.println(&quot;OS: &quot; + System.getProperty(&quot;os.name&quot;) + &quot; &quot; + System.getProperty(&quot;os.arch&quot;));
        System.out.println(&quot;==============================================&quot;);

        // 1. AES-NI 비활성화 테스트
        System.out.println(&quot;### AES-NI 비활성화 테스트 (순수 Java) ###&quot;);
        System.setProperty(&quot;com.sun.crypto.provider.AESCipherNI&quot;, &quot;false&quot;);
        performAESTest();

        System.out.println(&quot;==============================================&quot;);

        // 2. AES-NI 활성화 테스트 (기본값)
        System.out.println(&quot;### AES-NI 활성화 테스트 (하드웨어 가속) ###&quot;);
        System.setProperty(&quot;com.sun.crypto.provider.AESCipherNI&quot;, &quot;true&quot;);
        // 혹은 System.clearProperty(&quot;com.sun.crypto.provider.AESCipherNI&quot;);
        performAESTest();
    }

    private static void performAESTest() throws Exception {
        KeyGenerator kg = KeyGenerator.getInstance(&quot;AES&quot;);
        kg.init(256);
        SecretKey key = kg.generateKey();

        Cipher cipher = Cipher.getInstance(&quot;AES/GCM/NoPadding&quot;);
        byte[] data = new byte[1024 * 1024]; // 1MB
        new SecureRandom().nextBytes(data);

        // Warm-up
        for (int i = 0; i &lt; 10; i++) {
            cipher.init(Cipher.ENCRYPT_MODE, key);
            cipher.doFinal(data);
        }

        // Measurement
        long start = System.nanoTime();
        for (int i = 0; i &lt; 1000; i++) {
            cipher.init(Cipher.ENCRYPT_MODE, key);
            cipher.doFinal(data);
        }
        long end = System.nanoTime();

        double seconds = (end - start) / 1e9;
        double mbPerSecond = (1000.0 * data.length) / (1024 * 1024) / seconds;

        System.out.println(&quot;실행 시간: &quot; + String.format(&quot;%.4f&quot;, seconds) + &quot; 초&quot;);
        System.out.println(&quot;처리율: &quot; + String.format(&quot;%.2f&quot;, mbPerSecond) + &quot; MB/s\n&quot;);
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/ea085111-1057-403b-a253-d0a9f9b0bc73/image.png" alt=""></p>
<pre><code class="language-bash">java -XX:+UnlockDiagnosticVMOptions -XX:+PrintIntrinsics AESTest | grep &quot;AES&quot;</code></pre>
<p>이걸로 실제 AES를 사용하는지 검사해봤다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/40f61f9d-21e1-448a-b407-3b9a3f3269f3/image.png" alt=""></p>
<p>성능 차이가 20배 난다.
비활성화 테스트에서 AES 사용이 찍히는 이유는 JIT 컴파일러 때문인데,
JVM은 인터프리터로 네이티브 코드를 처리하다가 몇 백 번 반복문이 실행되는 걸 확인하고 HOT하다고 판단하여 JIT 컴파일러로 최적화해버린다.
이 과정에서 현재 CPU가 AES-NI 명령어를 지원하는 것을 감지하고, AES-NI 네이티브 명령어로 교체하는 과정에서 AES가 찍힌 것으로 유추된다.</p>
<p>활성화 테스트에서는 이미 JIT로 최적화된 반복 메서드를 그대로 사용하기 때문에 엄청나게 빠르게 나오는 것이다!</p>
<p>JIT의 개입을 최소화하기 위해
코드를 performAESTest()만 실행하도록 하고, java 실행 시 설정 값으로 주입하도록 변경했다.</p>
<pre><code class="language-bash">java -XX:+UnlockDiagnosticVMOptions -XX:-UseAESIntrinsics AESNIBenchmark</code></pre>
<p>요렇게.</p>
<p>&lt;AES-NI 비활성화&gt;</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/91136692-5834-4dd7-be4e-fc82a8a19dc1/image.png" alt=""></p>
<p>&lt;AES-NI 활성화&gt;</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/1777dc76-313b-4d0e-a0d1-a244d6730a5d/image.png" alt=""></p>
<p>1.67배 가까이 되는 성능 차이를 확인할 수 있었다.</p>
<p>아까보다 AES-NI 활성화가 느려진 이유는 초반에 네이티브로 실행하다가 중간부터 바뀌기 때문이다. 
따라서 Full로 AES-NI를 사용하지는 않았지만, 비활성화의 경우보다 중간에 AES-NI를 사용한 것이 더 빠르다는 것을 알 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/f9538024-0f9d-410c-b139-802cc7dfa563/image.png" alt=""></p>
<p>실제 AES 호출도 설정 처리하지 않은 자바에서만 이루어진 모습</p>
<p>프리티어라 cpu 성능 차이도 좀 있었을 텐데 M1 맥 보다 처리율 자체도 높은 거 보면 AES-NI의 성능이 굉장히 좋은 것 같다.</p>
<p>혹시나 좀 더 많은 양으로 처리하면 확실한 차이가 보이지 않을까?</p>
<p>500만 회 비교</p>
<p>&lt;AES-NI 사용 하지 않은 경우&gt;
<img src="https://velog.velcdn.com/images/_roundtable/post/674a93c7-e0cf-4e2c-b3bb-81e9679037d0/image.png" alt=""></p>
<p>&lt;AES-NI 사용 한 경우&gt;</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/0be3e3b2-8ae8-47ca-bb4f-a0738db2ac5f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/3fc41cfa-b2c1-4551-b254-37b471d3f631/image.png" alt=""></p>
<p>이번에는 5.72 배의 소요 시간이 발생하였다.</p>
<hr>
<h3 id="스프링-jpa에서-aes-암호화복호화-적용하기">스프링 JPA에서 AES 암호화/복호화 적용하기</h3>
<p>컨버터 클래스를 만들어준다.</p>
<pre><code class="language-java">@Component
public class AESGCMConverter implements AttributeConverter&lt;String, String&gt; {

    private static final String ALGORITHM = &quot;AES/GCM/NoPadding&quot;;
    private static final int GCM_TAG_LENGTH = 128; // bits
    private static final int IV_LENGTH = 12;       // bytes (권장 12)

    private SecretKey secretKey;

    public AESGCMConverter(@Value(&quot;${encryption.aes.key}&quot;) String key) {
        this.secretKey = new SecretKeySpec(key.getBytes(), &quot;AES&quot;);
    }

    @Override
    public String convertToDatabaseColumn(String attribute) {
        if (attribute == null) return null;
        try {
            byte[] iv = new byte[IV_LENGTH];
            new SecureRandom().nextBytes(iv); // 매번 랜덤 IV 생성

            Cipher cipher = Cipher.getInstance(ALGORITHM);
            GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
            cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);

            byte[] encrypted = cipher.doFinal(attribute.getBytes());

            // IV + Ciphertext 합쳐서 Base64 저장
            byte[] result = new byte[iv.length + encrypted.length];
            System.arraycopy(iv, 0, result, 0, iv.length);
            System.arraycopy(encrypted, 0, result, iv.length, encrypted.length);

            return Base64.getEncoder().encodeToString(result);
        } catch (Exception e) {
            throw new RuntimeException(&quot;AES-GCM 암호화 실패&quot;, e);
        }
    }

    @Override
    public String convertToEntityAttribute(String dbData) {
        if (dbData == null) return null;
        try {
            byte[] decoded = Base64.getDecoder().decode(dbData);

            // 저장된 데이터에서 IV 분리
            byte[] iv = new byte[IV_LENGTH];
            byte[] encrypted = new byte[decoded.length - IV_LENGTH];
            System.arraycopy(decoded, 0, iv, 0, IV_LENGTH);
            System.arraycopy(decoded, IV_LENGTH, encrypted, 0, encrypted.length);

            Cipher cipher = Cipher.getInstance(ALGORITHM);
            GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
            cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);

            byte[] decrypted = cipher.doFinal(encrypted);
            return new String(decrypted);
        } catch (Exception e) {
            throw new RuntimeException(&quot;AES-GCM 복호화 실패&quot;, e);
        }
    }
}</code></pre>
<p>AES 중에서도 모드는 128-GCM을 선택했다.</p>
<ul>
<li>ECB: 같은 평문 → 같은 암호문 (패턴 노출 → 보안 취약)</li>
<li>CBC: IV 사용, 패턴은 막지만 위·변조 탐지 불가능 → 무결성 보장 X</li>
<li>GCM: IV + 카운터 기반, 인증 태그(Tag) 포함 → 암호화 + 무결성 보장 (AEAD: Authenticated Encryption with Associated Data)</li>
</ul>
<p>256이 더 안전하지만, 속도를 위해 128을 선택하였다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/8624a32b-3708-4ba5-9f8f-85d24f6396df/image.png" alt=""></p>
<p>GCM 방식은 같은 평문 + 같은 iv를 사용하면 이런 오류를 발생시키는데,
동일한 결과로 인해 KEY 값을 유추해 내는 방식이 가능할 수도 있어서 이런 방식을 강제한다.</p>
<p>무작위 문자열은 이런 식으로 만들어서 환경 변수로 넣어주었다.</p>
<pre><code class="language-java">public static void main(String[] args) throws Exception {
        KeyGenerator keyGen = KeyGenerator.getInstance(&quot;AES&quot;);
        keyGen.init(128); // 128비트 = 16바이트

        SecretKey secretKey = keyGen.generateKey();

        byte[] keyBytes = secretKey.getEncoded();
        String base64Key = Base64.getEncoder().encodeToString(keyBytes);

        System.out.println(&quot;AES-128 Key (16바이트, Base64): &quot; + base64Key);
        System.out.println(&quot;Key length (bytes): &quot; + keyBytes.length);
}</code></pre>
<p>주의해야 할 점은 KEY 값은 16, 24, 32 의 크기만 지원한다는 것이다.
아니면 이런 오류가 뜬다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/5654ed05-234c-49ad-8478-203ed316a90c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/9fad0fdd-7892-44a5-9939-de63d1ad45f5/image.png" alt=""></p>
<p>이제 원하는 필드에 Convert 어노테이션을 붙여 컨버터를 쉽게 적용할 수 있다.</p>
<p><strong>테스트 결과</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/b7852f72-bbeb-4ffc-a7aa-38e64baa7c45/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/9ccc20ee-ff29-4235-ab18-df5fe000de7c/image.png" alt=""></p>
<p>저장 시에도 암호화가 잘 된다.
그리고 복호화도 문제 없이 잘 되었다.</p>
<hr>
<h2 id="결론">결론</h2>
<blockquote>
<p><strong>결론</strong>
JPA 덕분에 DB 레벨의 암호화-복호화를 쉽게 처리할 수 있었다!
AES-NI 성능 테스트에서 JIT 컴파일러의 동작 방식도 알 수 있어서 유의미한 경험이었던 것 같다!</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링이 빈을 컨테이너에 등록하는 방법 뜯어보기]]></title>
            <link>https://velog.io/@_roundtable/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%B4-%EB%B9%88%EC%9D%84-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88%EC%97%90-%EB%93%B1%EB%A1%9D%ED%95%98%EB%8A%94-%EC%84%B8-%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@_roundtable/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%B4-%EB%B9%88%EC%9D%84-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88%EC%97%90-%EB%93%B1%EB%A1%9D%ED%95%98%EB%8A%94-%EC%84%B8-%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Wed, 10 Sep 2025 06:19:14 GMT</pubDate>
            <description><![CDATA[<h2 id="궁금한-점">궁금한 점</h2>
<p>횡단 관심사를 분리하여 처리하기 위해 스프링 AOP를 적용하다가, 문득 스프링이 이 과정을 어떻게 처리하는지에 대한 의문이 생겼다.</p>
<blockquote>
<p>🧐 <strong>스프링은 AOP를 어떻게 구현할 수 있었을까?</strong></p>
</blockquote>
<h2 id="해답">해답</h2>
<h3 id="스프링은-왜-컨테이너를-사용하는가">스프링은 왜 컨테이너를 사용하는가</h3>
<p><strong>IoC(제어의 역전, Inversion of Control)</strong></p>
<p>보통을 객체를 만들 때 개발자가 new 해서 객체를 직접 생성하고 연결한다.
하지만 스프링에서는 컨테이너가 객체를 대신 만들고 주입한다.</p>
<p>개발자는 &quot;어떤 객체가 필요하다&quot; 정도만 선언하면, 컨테이너가 알아서 연결해준다.</p>
<p>즉, 객체의 생성 책임과 의존성 연결 책임을 컨테이너로 역전한다는 것이다.</p>
<p>이렇게 하면 좋은 건 객체 간 느슨한 결합을 가져가기 때문에, 변경의 전파를 막을 수 있다.</p>
<p><strong>DI(의존성 주입, Dependency Injection)</strong></p>
<p>IoC의 구현 방식 중 연결(주입) 부분에 해당하는 원칙이다.</p>
<p>A 객체가 B 객체를 필요로 할 때, 컨테이너가 B를 찾아서 A에 주입해준다.</p>
<p>이 덕분에 개발자는 객체 생성 로직에 신경 안 쓰고, 인터페이스 기반으로 프로그래밍을 할 수 있다.</p>
<blockquote>
<p>스프링은 객체의 생명주기와 주입의 책임을 역전시키기 위한 역할로 &quot;컨테이너&quot;를 사용하였다.</p>
</blockquote>
<hr>
<h3 id="스프링은-왜-컨테이너에-빈을-싱글톤으로-등록하는가">스프링은 왜 컨테이너에 빈을 싱글톤으로 등록하는가</h3>
<p>스프링은 컨테이너에 자바 객체인 빈을 등록한다.
그런데 왜 싱글톤으로 등록할까?</p>
<p><strong>(1) 성능 최적화</strong></p>
<p>객체를 매번 new 하면 메모리, GC, 생성 비용이 발생한다.</p>
<p>대부분의 서비스 객체는 상태를 가지지 않는 stateless 객체인데 매번 만들면 메모리 낭비가 발생한다.</p>
<p>이런 객체는 여러 요청에서 공유해도 문제가 없으니, 하나만 만들어 두고 계속 쓰는 게 효율적</p>
<p><strong>(2) 일관된 의존성 관리</strong></p>
<p>컨테이너가 하나의 객체만 관리하면, 어디서 가져다 쓰든 항상 같은 객체를 보게 된다.</p>
<p>예를 들어 OrderService가 UserRepository를 주입받았을 때, 다른 서비스에서도 같은 UserRepository를 보장하므로 데이터 접근 일관성이 보장된다.</p>
<p><strong>(3) 객체 생명주기 제어</strong></p>
<p>스프링이 빈을 싱글톤으로 보장하면, 개발자가 객체 생성과 파괴를 직접 관리하지 않아도 된다.</p>
<p>컨테이너가 애플리케이션 시작 시 객체를 미리 만들어두고, 애플리케이션 종료 시 정리하므로 안정적인 자원 관리가 가능하다.</p>
<hr>
<h3 id="스프링은-어떻게-컨테이너에-빈을-등록할까">스프링은 어떻게 컨테이너에 빈을 등록할까?</h3>
<p>스프링이 빈을 컨테이너에 등록하게 하는 방법은 크게 세 가지가 있다.</p>
<p><strong>XML</strong>
xml 파일을 통해 빈을 등록하는 방법이 있다.</p>
<pre><code class="language-xml">&lt;!-- applicationContext.xml --&gt;
&lt;beans xmlns=&quot;http://www.springframework.org/schema/beans&quot;
       xmlns:xsi=&quot;http://www.w3.org/2001/XMLSchema-instance&quot;
       xsi:schemaLocation=&quot;http://www.springframework.org/schema/beans 
           http://www.springframework.org/schema/beans/spring-beans.xsd&quot;&gt;

    &lt;!-- MyService 빈 등록 --&gt;
    &lt;bean id=&quot;myService&quot; class=&quot;com.example.MyService&quot;/&gt;

    &lt;!-- OrderService 빈 등록, MyService 의존성 주입 --&gt;
    &lt;bean id=&quot;orderService&quot; class=&quot;com.example.OrderService&quot;&gt;
        &lt;constructor-arg ref=&quot;myService&quot;/&gt;
    &lt;/bean&gt;

&lt;/beans&gt;
</code></pre>
<p>자바 코드가 아니라서 언어 독립적이라는 장점은 있으나
가독성도 떨어지고 타입 안정성도 없어서 요즘은 거의 사용되지 않는다고 한다.</p>
<p><strong>@Component</strong></p>
<p>어노테이션이 클래스 단위로 붙는다.</p>
<p><code>@ComponentScan</code>에 의해 빈으로 자동 등록되며,
스프링이 알아서 new 처리를 해서 관리된다.</p>
<p><strong>@Bean</strong></p>
<p><code>@Configuration</code>이 선언된 클래스 내에서 메서드를 통해 명시적으로 등록된다.</p>
<p>개발자가 new 해서 반환한 객체를 스프링이 관리한다.</p>
<p>그런데 꼭 @Configuration 어노테이션이 붙은 클래스 내에서만 사용 가능할까?</p>
<p>그렇지 않다.
@Configuration은 내부적으로 CGLIB 프록시를 생성해서, @Bean 메서드를 호출할 때 싱글톤 보장을 해준다.</p>
<p>만약 @Configuration이 없다면, 빈은 등록되지만 Config 클래스 내부에서 다시 사용될 때 싱글톤이 아니라 호출될 때 마다 new를 하는 방식으로 반환된다.</p>
<pre><code class="language-java">@Component
public class AppConfig {
    @Bean
    public MyService myService() {
        return new MyService();
    }

    @Bean
    public OrderService orderService() {
        return new OrderService(myService());
    }
}</code></pre>
<p>얘를 들면 이 상황에서,
OrderService를 등록할 때 주입되는 myService()의 경우
위에서 컨테이너에 등록된 MyService를 가져오는 게 아니라 단순히 myService()를 호출해 new를 한 번 더 해서 가져온다는 것이다.</p>
<p>이것을 방지하기 위해 CGLIB이 AppConfig의 프록시 객체를 만드는 방법을 알아보자.</p>
<hr>
<h3 id="스프링은-어떻게-설정-클래스를-찾아내는가">스프링은 어떻게 설정 클래스를 찾아내는가</h3>
<p><strong>ConfigurationClassPostProcessor.java</strong>
<img src="https://velog.velcdn.com/images/_roundtable/post/308fa944-ce1f-4ee1-a837-8dc177f887d0/image.png" alt=""></p>
<p><code>postProcessBeanDefinitionRegistry()</code> 메서드이다.
여기에서는 스프링 컨테이너 초기화 과정에서 다른 빈들이 인스턴스화되기 전에 빈 정의를 추가하거나 수정할 수 있는 권한을 갖는다.</p>
<p>모든 설정 클래스 처리의 시작 부분이다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/0a375ec7-cf88-4385-ad05-b687000f360d/image.png" alt=""></p>
<p>클래스 내부의 <code>processConfigBeanDefinitions()</code> 메서드에서 
<code>ConfigurationClassParser</code>의 <code>parse()</code> 메서드를 호출한다.</p>
<p><strong>ConfigurationClassParser.java</strong></p>
<p>빈 대상인 클래스의 사전 정보를 분석한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/89995975-18b0-4cc8-bfb4-885d0d38b549/image.png" alt=""></p>
<p><code>parse()</code> 메서드를 보면 전달 받은 Config 클래스 후보들을 순회한다.</p>
<ol>
<li><p>AnnotatedBeanDefinition
클래스에 붙은 어노테이션 정보(AnnotationMetadata)를 이미 내부에 갖고 있는 경우이다.
컴포넌트 스캔으로 찾은 @Component, @Configuration 클래스들이 여기에 해당한다.
이 경우, 클래스를 또 로딩할 필요 없이 이미 준비된 어노테이션 메타데이터(annotatedBeanDef.getMetadata())를 직접 다음 parse 메서드로 넘긴다.</p>
</li>
<li><p>AbstractBeanDefinition
어노테이션 메타데이터는 없지만, BeanDefinition 내부에 이미 로딩된 Class 객체가 있는 경우이다.
이때는 getBeanClass()로 Class 객체를 꺼내서 다음 parse 메서드로 전달한다.
이 parse 메서드는 전달받은 Class 객체를 리플렉션을 통해 분석한다.</p>
</li>
<li><p>빈 이름만 있는 경우
xml 처럼 빈 이름만 있고 클래스 정보는 직접 가져와야 하는 경우이다.
parse()에서 클래스 로딩을 먼저 수행한 후 정보를 넘긴다.</p>
</li>
</ol>
<p>마지막으로 <code>DeferredImportSelectorHandler</code> 진행 시켜 <code>@Import</code>가 붙어있는, 추가적으로 빈으로 등록해야되는 클래스들을 처리한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/e499270d-2c00-4988-8164-2ec4a7b4d026/image.png" alt=""></p>
<p>앞선 parse 메서드가 호출하는 같은 클래스의 <code>processConfigurationClass()</code> 메서드이다.</p>
<p>이미 parse 처리된 적이 있는 클래스의 경우 우선 순위에 의해 갈아끼운다.</p>
<p><strong>우선순위 규칙</strong>
일반적으로 컴포넌트 스캔 등을 통해 직접 발견된 설정(non-imported)이 @Import를 통해 간접적으로 포함된 설정보다 우선순위가 높다.</p>
<p>이후 do-while 루프를 통해 doProcessConfigurationClass 메서드 작업을 마친 후, 자신이 파싱한 클래스의 부모 클래스를 반환한다.
while (sourceClass != null) 조건은 부모 클래스가 더 이상 없을 때까지(즉, java.lang.Object에 도달할 때까지) 루프를 계속 돌린다.</p>
<p>처리된 클래스는 configurationClasses 맵에 저장한다.</p>
<p><strong>doProcessConfigurationClass()</strong></p>
<p>특정 설정 클래스(sourceClass) 하나를 받아서, 
그 안에 선언된 각종 스프링 설정 관련 어노테이션들(@PropertySource, @ComponentScan, @Import, @Bean 등)을 하나씩 순서대로 찾아내어 파싱하고,
그 결과를 configClass 객체에 누적하는 역할을 한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/79899b97-0e52-4edb-af1a-cdcc48c9ec52/image.png" alt=""></p>
<p>클래스 내부에 정의된 중첩 클래스(nested class)나 내부 클래스(inner class)가 @Configuration 같은 설정 클래스일 수 있으므로, 이들을 먼저 재귀적으로 파싱한다.
@Component가 붙어있을 때까지만 호출하여
일반 자바 클래스의 중첩 클래스까지 모두 스캔하는 것을 방지한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/5f4ae164-e41f-4858-b364-57d105a9ee59/image.png" alt=""></p>
<p>@PropertySource 어노테이션을 찾아 .properties 파일의 위치를 알아낸다.
그리고 PropertySourceRegistry를 통해 해당 파일의 내용을 읽어와 스프링의 Environment 객체에 등록한다.
이를 통해 @Value(&quot;${...}&quot;) 구문으로 프로퍼티 값을 주입할 수 있게 된다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/7a0215f1-f423-4d5c-9ade-b1b3dd15d6dd/image.png" alt=""></p>
<p>@ComponentScan 어노테이션에 지정된 패키지 경로를 기반으로 컴포넌트 스캔을 실행한다.</p>
<p>componentScanParser.parse()를 호출하여 지정된 패키지 내의 @Component, @Service, @Repository 등을 찾아 BeanDefinition으로 만든다.</p>
<p>스캔을 통해 새로 발견된 클래스들 중에 @Configuration 같은 또 다른 설정 클래스가 있는지 확인한다.</p>
<p>만약 있다면, parse() 메서드(가장 처음 분석했던 진입점 메서드)를 재귀적으로 호출하여 새로운 설정 클래스의 파싱을 처음부터 다시 시작한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/c368e359-518c-4cb5-9ba8-7914631c8efe/image.png" alt=""></p>
<p>@Import: 다른 설정 클래스(@Configuration), ImportSelector, 또는 일반 클래스를 가져와 빈으로 등록하도록 처리한다.</p>
<p>@ImportResource: XML 설정 파일(applicationContext.xml 등)을 불러와 그 안에 정의된 빈들을 등록하도록 처리한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/d9cbfc83-cd78-4f69-90c5-8733e1ac885a/image.png" alt=""></p>
<p>retrieveBeanMethodMetadata를 통해 클래스에 정의된 모든 메서드를 스캔하여 @Bean 어노테이션이 붙은 메서드만 찾아낸다.</p>
<p>찾아낸 각 @Bean 메서드의 메타데이터(메서드 이름, 반환 타입 등)를 BeanMethod 객체로 감싸서, configClass 객체 내부의 beanMethods 리스트에 차곡차곡 쌓아둔다.
이 정보는 나중에 ConfigurationClassBeanDefinitionReader가 실제 BeanDefinition을 생성할 때 사용한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/618f5c00-2a11-4c56-acae-f98eb0440295/image.png" alt=""></p>
<p>processInterfaces: 클래스가 구현한 인터페이스에 @Bean이 붙은 default 메서드가 있을 수 있으므로, 이를 처리한다.</p>
<p>마지막으로, 클래스가 부모 클래스를 상속받았다면 부모 클래스의 SourceClass 객체를 반환한다.
이 반환 값은 이 메서드를 호출했던 processConfigurationClass의 do-while 루프의 조건이 되어 부모 클래스에 대한 파싱을 계속 이어나가게 만든다.</p>
<p>더 이상 거슬러 올라갈 부모 클래스가 없으면(java.lang.Object에 도달하면) null을 반환하여 do-while 루프를 종료시킨다.</p>
<hr>
<h3 id="스프링이-클래스를-빈으로-등록하는-방법">스프링이 클래스를 빈으로 등록하는 방법</h3>
<p>여기까지가 스프링이 설정 클래스를 찾아내고, 그 안에 빈이 될 클래스들(@Bean 메서드, @Import, @ComponentScan 등)을 찾아내는 과정이었다.
이제 찾아낸 설정 클래스들을 빈으로 등록하는 과정을 알아보자.</p>
<p><strong>ConfigurationClassPostProcessor.java</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/25a0f208-2a1c-4e21-b692-bf7a19091ac4/image.png" alt=""></p>
<p>다시 처음의 ConfigurationClassPostProcessor 클래스로 돌아가보자.
Parser의 parse 이후에는 ConfigurationClassBeanDefinitionReader라는 Reader가 등장한다.
여기서 Reader의 loadBeanDefinitions() 메서드를 호출하는데, 얘가 바로 Parser가 찾아온 클래스 정보를 바탕으로 빈을 만들어주는 부분이다.</p>
<p><strong>ConfigurationClassBeanDefinitionReader.java</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/0fd19171-37a5-425a-94fa-949c63969628/image.png" alt=""></p>
<p>@ConditionalOnBean과 같은 조건이 있을 수 있기 때문에 shouldSkip 메서드로 검증한다.</p>
<p>만약 이 설정 클래스가 @ComponentScan이 아닌 @Import 어노테이션을 통해 포함된 것이라면, 설정 클래스 그 자체도 하나의 빈으로 컨테이너에 등록한다.</p>
<p>왜냐하면 @Import된 설정 클래스도 내부에 의존성을 주입받는 등 스프링 컨테이너의 관리가 필요할 수 있기 때문. 
@ComponentScan으로 찾은 클래스는 이미 빈 정의가 등록된 상태이므로 이 로직이 필요 없다.</p>
<p>configClass 객체 안에 파서가 찾아두었던 모든 @Bean 메서드(BeanMethod) 목록을 순회한다.</p>
<p>각 BeanMethod에 대해 loadBeanDefinitionsForBeanMethod 메서드를 호출하여, 메서드 정보를 기반으로 BeanDefinition을 생성하고 레지스트리에 등록한다.</p>
<p>@ImportResource 어노테이션으로 지정된 XML 설정 파일이 있었다면, 해당 파일들을 읽어서 그 안에 bean 태그로 정의된 빈들을 모두 등록한다.</p>
<p>@Import를 통해 등록된 ImportBeanDefinitionRegistrar 구현체가 있다면 이를 실행한다.</p>
<p>ImportBeanDefinitionRegistrar는 사용자가 자바 코드를 통해 동적으로 빈 정의를 직접 등록할 수 있게 해주는 기능이라고 한다.
사용자가 작성한 해당 로직을 직접 실행시켜 주는 부분이 마지막에 등장한다.</p>
<p><strong>loadBeanDefinitionsForBeanMethod()</strong></p>
<p>굉장히 긴 메서드이지만, 중요한 부분만 뜯어보자.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/a316009b-89fb-4167-9fcf-8948e3cddda9/image.png" alt=""></p>
<p>@Bean(name = {&quot;beanA&quot;, &quot;aliasB&quot;}) 와 같이 이름이 명시적으로 주어졌는지 확인한다.</p>
<p>이름이 있으면 첫 번째 이름을 기본 beanName으로 사용한다.</p>
<p>이름이 없으면, 메서드 이름을 beanName으로 사용한다.</p>
<p>name 속성에 여러 이름이 주어진 경우, 첫 번째를 제외한 나머지 이름들은 모두 별칭으로 컨테이너에 등록한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/32b85ae1-0e29-4b3f-9cf8-f6bee1f8d6ca/image.png" alt=""></p>
<p>마침내 빈의 설계도인 BeanDefinition 객체를 생성하는 순간이다..!
이 객체는 어떤 클래스에서, 어떤 메서드를 통해 빈이 정의되었는지 등의 출처 정보를 담고 있다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/9eeb4559-7f5e-4a84-b5cf-7fffd49a27ab/image.png" alt=""></p>
<p>@Bean 메서드가 빈을 생성하는 &quot;팩토리&quot; 역할을 한다는 것을 명시하는 부분이다.</p>
<p>static 메서드인 경우: 팩토리는 클래스 자체. setBeanClass를 통해 클래스를 지정한다.</p>
<p>인스턴스 메서드인 경우: 팩토리는 설정 클래스의 인스턴스(객체). setFactoryBeanName을 통해 설정 클래스 빈의 이름을 지정해 나중에 스프링이 해당 빈의 인스턴스를 찾아 메서드를 호출하도록 한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/0b0e24bc-b5a4-4178-b51e-54169faff606/image.png" alt=""></p>
<p>@Bean 메서드에 함께 사용된 다른 중요한 어노테이션들의 속성을 BeanDefinition에 채워 넣는다.</p>
<p>processCommonDefinitionAnnotations: @Lazy, @Primary, @DependsOn 등 공통 어노테이션을 처리한다.</p>
<p>initMethod, destroyMethod: 빈의 생명주기 콜백 메서드 이름을 설정한다.</p>
<p>@Scope: @Scope(&quot;prototype&quot;)과 같이 스코프를 설정하고, 필요하다면 프록시(proxy) 객체를 생성하도록 처리한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/3fdd089b-8d92-4405-bc29-f6a36780b68d/image.png" alt=""></p>
<p>모든 설정이 완료된 BeanDefinition 객체를 BeanDefinitionRegistry에 최종적으로 등록한다.</p>
<p>이 호출이 성공적으로 끝나면, 스프링 컨테이너는 beanName을 가진 빈의 존재를 공식적으로 인지하게 되며, 애플리케이션의 다른 곳에서 이 빈을 주입받아 사용할 수 있게 된다.</p>
<p>여기서 빈이 등록되는 registry는,
<strong>DefaultListableBeanFactory</strong>이다.</p>
<p>이 팩토리 클래스는 내부적으로 빈들을 싱글톤으로 관리한다.
빈을 넣을 때 말고 빈을 가져오기 위한 getBean이 호출될 때 싱글톤 캐시를 조회하고 없으면 생성한다.</p>
<hr>
<h3 id="configuration이-붙은-클래스가-등록될-때-벌어지는-일">@Configuration이 붙은 클래스가 등록될 때 벌어지는 일</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/991c58ab-cfe0-48c6-b988-1440e1638a72/image.png" alt=""></p>
<p>ConfigurationClassPostProcessor에서 위의 과정을 모두 마치면
enhanceConfigurationClasses() 메서드를 실행한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/c55b4acc-d6eb-4a4c-9958-e45ebab6e1e3/image.png" alt=""></p>
<p>설정파일의 beanDefinition에서 CONFIGURATION_CLASS_ATTRIBUTE 정보를 가져온다.</p>
<p>이 상태는 두 가지로 나뉘는데,</p>
<ul>
<li>CONFIGURATION_CLASS_LITE</li>
<li>CONFIGURATION_CLASS_FULL</li>
</ul>
<p>이다.</p>
<p>FULL 모드인 경우, configBeanDefs에 현재 처리하는 빈을 넣고</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/dea1d03d-d2b7-4995-8137-ed68878e6c6f/image.png" alt=""></p>
<p>ConfigurationClassEnhancer라는 클래스를 통해 enhance 메서드를 실행한다.</p>
<p><strong>ConfigurationClassEnhancer.java</strong></p>
<p>@Configuration 어노테이션이 붙은 클래스의 프록시 객체 생성은
<code>ConfigurationClassEnhancer</code>가 관리한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/e7fea2a9-d26f-4610-8ab2-2cf2ce627707/image.png" alt=""></p>
<p><code>@Bean</code>이 붙은 메서드가 호출될 때 내부에서 등록된 <code>BeanMethodInterceptor</code>가 이를 가로채 실제 구현체를 생성한다.</p>
<p><strong>BeanMethodInterceptor.java</strong></p>
<p>내부가 살짝 복잡하긴 한데 가장 중요한 <code>intercept()</code> 메서드를 들여다보면, 분기가 세 개로 나뉘는 것을 알 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/7c57e56a-005f-42b8-9ff2-270cdd10fb9b/image.png" alt=""></p>
<p>팩토리 빈을 처리하는 부분이다.
현재 처리하는 메서드가 팩토리 빈을 처리하고 있다면, 내부의 빈을 프록시로 감싸 등록한다.</p>
<p><strong>팩토리 빈</strong>
팩토리 빈이란, 다른 빈을 생성하는 &#39;팩토리 클래스&#39;가 빈으로 등록된 경우이다.</p>
<p>보통 스프링이 빈을 생성하는 방법은 리플렉션을 이용하여 디폴트 생성자에 접근해 객체를 만들어 등록한다.</p>
<p>그런데 생성자가 private이거나 동적으로 프록시 되는 경우 스프링이 강제로 접근할 수 없다.</p>
<p>그래서 해당 클래스를 빈으로 만들어주기 위해서는 팩토리 클래스가 필요하다.
팩토리 클래스의 getObject() 메서드가 반환하는 클래스를 빈으로 등록해준다.</p>
<p><strong>ScopedProxy</strong>
코드 속 두 번째 if문을 보면, ScopedProxy 라는 개념이 나온다.
이건 빈과 빈 사이의 생명 주기가 다른 경우에도 싱글톤 객체를 주입하기 위해 속이 비어있는 가짜 객체를 넣어주는 상황이다.</p>
<p>즉, <code>ScopedProxyFactoryBean</code>은 현재 상황에서 결정할 수 없는 객체를 동적으로 생성하기 위한 팩토리 빈이므로, 
여기서 생성하는 빈은 등록 시점에 실제 클래스가 사용되지 않는다.
그런 상황에서 팩토리 빈은 사용 시점에 반환되는 클래스가 동적으로 지정되기 때문에, 컨테이너에 싱글톤으로 등록되지 않도록 if 문을 통해 싱글톤 프록시 처리를 무시한다.</p>
<p>갑작스러운 처음 보는 개념들의 등작으로 좀 정리해보았는데,
이제 다시 코드로 돌아오자...</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/d0c2df22-7f3c-4062-aeab-9ec4b6d07a8e/image.png" alt=""></p>
<p><code>isCurrentlyInvokedFactoryMethod</code>를 통해 현재 생성되어야 하는 빈인지를 확인하고, 맞다면 super (원본 Configuration 클래스의 @Bean 메서드)를 실행한다.</p>
<p>일반적인 빈 참조의 경우, <code>resolveBeanReference</code>를 실행하여 컨테이너에 있는 빈을 반환한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/27a7328f-16fe-4705-aa27-45b2247749a4/image.png" alt=""></p>
<p>잠시 <code>isCurrentlyInvokedFactoryMethod</code>를 알아보자.
스프링이 @Bean 메서드를 호출해서 빈을 생성할 때 어떤 메서드를 실행 중인지 ThreadLocal에 저장해 둔다.
그래서 해당 메서드 명이 &#39;지금&#39; 처리 중인 메서드 명과 동일한지 확인한다.</p>
<p>예를 들어 위의 예시에서 @Bean에 의해 myService()가 실행되는 경우 현재 처리 중인 메서드도 myService이기 때문에 true,
orderService()가 myService()를 실행한 경우, 현재 실행 중인 메서드와 처리 중인 메서드의 이름이 다르기 때문에 false를 반환한다.</p>
<p>이제 대망의 resolveBeanReference() 메서드를 보자.</p>
<p><strong>resolveBeanReference()</strong></p>
<p>빈의 순환 참조를 허용하기 위한 코드도 있지만, 메인이 되는 내용은 아니기 때문에 나중에 정리하겠다.</p>
<p>이 메서드는 위에서 보았듯이, 일반적인 빈 참조의 경우에 호출된 빈을 컨테이너에서 가져오기 위한 메서드이다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/26d52fd4-abed-48f9-81db-890a64602cb6/image.png" alt=""></p>
<p>대부분의 경우 @Bean 메서드 호출 시 인자(beanMethodArgs)가 없으므로 beanFactory.getBean(beanName)을 통해 이름으로 빈을 조회한다.</p>
<p>만약 프로토타입 빈처럼 @Bean 메서드에 인자를 전달하는 경우 인자를 사용하는 getBean 오버로딩 메서드를 사용한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/cfbcbaba-2768-4407-9933-c7a4680027c3/image.png" alt=""></p>
<p>이제 위에서 가져온 빈과, 현재 실행 중인 @Bean 메서드의 이름을 통해 의존 관계를 스프링 컨테이너에 공식적으로 등록한다.</p>
<hr>
<h2 id="최종-정리">최종 정리</h2>
<p>아직 AOP 근처에도 못 갔다..
스프링이 POJO로 설정 클래스들을 만드는 과정이 복잡하지만
자바의 정수를 보는 느낌이라 새로운 것 같다</p>
<p>다음 포스팅에서 계속...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[말모] 좀 더 안정적인 API 호출 with Redis Stream (AI 채팅 2편)]]></title>
            <link>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-%EC%A2%80-%EB%8D%94-%EC%95%88%EC%A0%95%EC%A0%81%EC%9D%B8-API-%ED%98%B8%EC%B6%9C-with-Redis-Stream</link>
            <guid>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-%EC%A2%80-%EB%8D%94-%EC%95%88%EC%A0%95%EC%A0%81%EC%9D%B8-API-%ED%98%B8%EC%B6%9C-with-Redis-Stream</guid>
            <pubDate>Fri, 05 Sep 2025 14:43:19 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️⭐️
<strong>작성 날짜</strong> 2025.08.09</p>
<h2 id="고민-내용">고민 내용</h2>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/bb3c3231-f5c6-4e97-ab99-03716fa39ce8/image.png" alt=""></p>
<p>말모는 OpenAI API를 이용한 AI 챗봇 기능을 제공한다.
사용자 채팅에 대한 응답 외애도 채팅 요약, 메타데이터 생성 등 다양한 기능에서 외부 API를 사용하는데,
생성형 AI를 API로 사용할 때의 문제가 바로 &#39;나의 예상대로 응답하지 않을 수 있다는 점&#39;이다!</p>
<p>실제로 OpenAI의 서버가 죽거나,
AI의 응답이 서버가 기대하는 규격과 달라 감지되는 오류가 종종 있었다.</p>
<p>그럼 외부 API의 응답에 문제가 발생한 경우 어떻게 처리해야 할까?</p>
<ul>
<li>요청하기, 실패하면 예외 터뜨리기 (현재 방식)</li>
</ul>
<p>일단 API 요청을 하고, 실패하면 내부 예외를 터뜨리는 방식이다.</p>
<p>그러나 이 방식이 경험해본 결과 어색한 흐름을 가져온다는 것을 알게 되었다.</p>
<p>채팅 자동 요약이나 대화의 경우에는 비동기로 처리되지만,
채팅 종료 프로세스의 경우 요약 생성을 동기로 처리한다.</p>
<p>동기 처리 과정에서 오류가 발생한 경우, 사용자는 의도하지 않은 오류 화면을 만날 수 있다.
예를 들어 채팅 종료 버튼을 눌러 채팅의 종료를 기대했는데, 요약이 생성되지 않는다는 이유로 에러 화면을 만나는 것이다.</p>
<p>비동기 처리 과정에서 오류가 발생한 경우, 서버에 오류 로그가 남는다.
그리고 의도된 동작은 처리되지 않는다.</p>
<p align=center>
<img width=300 margin=auto src='https://velog.velcdn.com/images/_roundtable/post/60aa4531-d03f-49f8-a855-91a733baa488/image.png' />
</p>

<p>요약이 생성되어야 하는데...
오류로 인하여 생성되지 않았고 결국 생성 전 상태로 남아있는, 이 상태처럼 말이다.</p>
<ul>
<li>그럼 될 때까지 무한 요청하자</li>
</ul>
<p>이건 현실적으로 불가능하다.
의도된 답변이 아니라는 이유로 API를 무한 요청하면, 의도하지 않은 무한 토큰 비용까지 발생할 수 있다...</p>
<p>서버의 관점에서도 재시도 횟수와 시간을 예상할 수 없기 때문에, 장시간 스레드를 점유하는 문제가 발생할 수 있다.</p>
<ul>
<li>API 요청 처리 실패 시 재시도, 일정 재시도 횟수를 초과하면 따로 분류</li>
</ul>
<p>앞선 두 방식의 절충안이다.
계속 재시도할 수는 없으니, 우리가 재시도 횟수를 예측할 수 있도록 임계값을 정해두고, 이 값을 초과하면 따로 분류하여 기록해두는 방식이다.
오류 기록은 운영 상 쉽게 확인할 수 있어야 하며, 일괄 재처리 될 수 있도록 해야 한다.</p>
<blockquote>
<p>🤔 재시도, 조금 똑똑하게 할 수는 없을까?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<h3 id="try-catch">try-catch</h3>
<p>재시도 처리는 어떻게 해야 할까?</p>
<p>물론 try-catch를 통해서도 가능하다.
무적의 반복문으로 이를 처리해 줄 수 있지만, 여기서 또 하나의 문제를 생각해볼 수 있다.</p>
<blockquote>
<p>재시도 처리 도중 서버가 죽는다면?</p>
</blockquote>
<p>try-catch는 자바 코드 위에서 실행되는 내용이기 때문에,
서버가 재부팅됐을 때 &#39;요기부터 다시해야지~&#39;라며 시도하지 않는다.</p>
<p>메모리 위에 작성된 재시도 횟수는 말 그대로 증발하는 셈이다.</p>
<p>이 문제를 해결하려면 외부 인프라를 활용해야 한다.</p>
<blockquote>
<p>다시 요구사항을 정리해보면,</p>
</blockquote>
<ol>
<li>재시도를 할 수 있어야 한다.</li>
<li>일정 재시도 횟수를 초과한 경우, 따로 분류한다.</li>
<li>외부 인프라를 사용해야 한다.</li>
</ol>
<p>Redis pub/sub을 생각해보았지만, 서버 재부팅 상황에서 마찬가지로 휘발성을 갖기 때문에 오류 상황을 핸들링해야 하는 현재의 문제 상황에서 적합하지 않다.
또한, 이벤트의 순서에 대해서도 보장하지 않기 때문에, 채팅 시스템에서 적절하지 않았다.</p>
<p>그래서 고려하게 된 것이 <strong>메시지 큐</strong></p>
<h3 id="생산자-소비자-패턴-producer-consumer-pattern">생산자-소비자 패턴 (Producer-Consumer Pattern)</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/4fcbc858-8abc-43c0-bb16-2a33d91ebeeb/image.png" alt=""></p>
<p>Producer에 의해 발행된 메시지가 순차적으로 큐에 도착하면, Consumer가 이를 소비하여 정해진 작업을 처리하는 구조이다.</p>
<p>이 패턴을 사용하면, 정의한 문제를 해결할 수 있을 것이라고 생각했다.
생산자가 &#39;API 호출을 해주세요&#39;라는 메시지를 받으면, 소비자가 이를 처리하고, 실패 시 다시 큐에 넣는다.
그럼 다시 소비자가 이를 소비하여 재시도한다.
이때 소비자는 메시지의 재시도 횟수를 판단하여 이를 처리할지, 분류할지를 결정한다.</p>
<p>이런 방식으로 진행하면, 자연스럽게 비동기 처리도 가능하며, 하나의 기능이 항상 똑같은 행동을 하도록 보장할 수 있다.</p>
<h3 id="메시지-브로커">메시지 브로커</h3>
<p>이때 생산자가 발행한 메시지의 순서를 보장하면서, 소비자 그룹이 적어도 한 번, 또는 전체에서 한 번 소비할 수 있도록 큐를 관리해주는 도구가 바로 메시지 브로커다.</p>
<p>메시지 브로커로 세 가지를 고려해 보았다.</p>
<ol>
<li>아파치 카프카 (Apache Kafka)</li>
</ol>
<blockquote>
<p><strong>장점</strong>
Exactly-once (정확히 한 번 전달)까지 보장할 수 있다.
가장 많은 데이터를 빠르게 처리할 수 있는 메시지 브로커이다.</p>
</blockquote>
<blockquote>
<p><strong>단점</strong>
Kafka 아키텍처 자체가 비용 구조를 높인다.
Kafka는 분산 로그 시스템을 전제로 설계되었기 때문에,
브로커(Broker): 메시지를 저장하고 제공
주키퍼(Zookeeper): 클러스터 메타데이터 관리
토픽/파티션: 데이터를 여러 노드에 나누어 저장</p>
</blockquote>
<p>즉, Kafka는 고가용성, 내구성, 확장성을 위해 여러 서버와 디스크, 메모리를 요구하기 때문에 단일 인스턴스 구조에서 좋은 선택은 아닌 것 같다.</p>
<ol start="2">
<li>RabbitMQ</li>
</ol>
<blockquote>
<p><strong>장점</strong>
설치 간단, 관리 UI 제공 → 운영 편리
다양한 라우팅 패턴 지원 (fanout, direct, topic, delayed queue 등)</p>
</blockquote>
<blockquote>
<p><strong>단점</strong>
고성능 처리량은 제한적
클러스터 확장은 Kafka보다 약하다.</p>
</blockquote>
<ol start="3">
<li>Redis Stream</li>
</ol>
<blockquote>
<p><strong>장점</strong>
Redis 하나로 캐시 + 세션 + 메시징 큐까지 해결 가능
Consumer Group 지원 → 메시지 재처리 가능
AOF/RDB를 사용하면 재시작 시에도 유실 방지가 가능하다.</p>
</blockquote>
<blockquote>
<p><strong>단점</strong>
Kafka만큼의 로그 영속성/처리량은 기대 어려움
메시지 라우팅 기능은 RabbitMQ보다 단순</p>
</blockquote>
<p>Kafka의 기능은 너무 훌륭하지만,
단일 인스턴스를 벗어날 수 없는 현재 프로젝트의 규모를 고려했을 때
Kafka는 선택에서 배제하였다.</p>
<p>RabbitMQ와 Redis Stream을 비교했을 때는 비슷한 것 같다.</p>
<p>고민 끝에 Redis Stream을 선택하였다!</p>
<ul>
<li>RabbitMQ는 사용해본 적이 없기 때문에 러닝 커브의 문제</li>
<li>앞으로 Redis를 이용한 캐싱을 적용할 부분이 있기 때문에 확장성을 고려</li>
<li>분산 시스템 환경에서 다른 서버로 메시지를 전달하는 용도가 아니라 하나의 서버에서 API 안정성을 위해 사용하기 때문에, 메시지 라우팅 기능을 크게 사용하지 않을 예정</li>
</ul>
<p>요런 이유로 Redis Stream을 적용해보기로 하였다.</p>
<hr>
<h2 id="적용해보기">적용해보기</h2>
<h3 id="redis-config">Redis Config</h3>
<pre><code class="language-java">@Bean
public RedisTemplate&lt;String, Object&gt; redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate&lt;String, Object&gt; template = new RedisTemplate&lt;&gt;();
        template.setConnectionFactory(connectionFactory);

        // 직렬화 설정
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new StringRedisSerializer());

        template.afterPropertiesSet();
        return template;
}</code></pre>
<p>Redis에 데이터를 보낼 때, 받을 때 직렬화 및 역직렬화 방법을 설정한다.
직렬화와 역직렬화 방법을 통일시켜서 소통의 오류가 없도록 설정한다.</p>
<pre><code class="language-java">@Bean
public StreamMessageListenerContainer&lt;String, MapRecord&lt;String, String, String&gt;&gt; streamContainer(
            RedisConnectionFactory connectionFactory,
            RedisStreamConsumer consumer
    ) {
        StreamMessageListenerContainer.StreamMessageListenerContainerOptions&lt;String, MapRecord&lt;String, String, String&gt;&gt; options =
                StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder()
                        .pollTimeout(Duration.ofSeconds(2))
                        .executor(Executors.newFixedThreadPool(8))
                        .build();

        StreamMessageListenerContainer&lt;String, MapRecord&lt;String, String, String&gt;&gt; container =
                StreamMessageListenerContainer.create(connectionFactory, options);

        // Consumer 그룹 내 여러 Consumer를 등록 → 병렬 처리
        for (int i = 0; i &lt; 4; i++) {
            container.receive(
                    Consumer.from(consumerGroup, &quot;malmo-consumer-&quot; + i),
                    StreamOffset.create(streamKey, ReadOffset.lastConsumed()),
                    consumer::onMessage
            );
        }

        container.start();
        return container;
}</code></pre>
<p>컨슈머를 실행하기 위한 코드이다.</p>
<p><strong>pollTimeout</strong>
Redis Stream에서 새 메시지를 가져오기 위해 블로킹 폴링할 때 최대 대기 시간이다.
2초 동안 새 메시지가 안 오면 타임아웃 → 다시 요청 (롱폴링 방식)</p>
<p><strong>executor</strong>
메시지를 처리할 스레드 풀.
여기서는 8개 스레드로 동시에 메시지를 처리 가능</p>
<p><strong>Consumer.from(consumerGroup, &quot;malmo-consumer-&quot; + i)</strong>
Redis Stream의 Consumer Group 기반으로 Consumer 등록한다.
Consumer Group을 쓰면 같은 그룹 내 여러 Consumer가 있을 때 메시지가 분산 처리</p>
<p>여기서는 &quot;malmo-consumer-0&quot; ~ &quot;malmo-consumer-3&quot; 까지 총 4개의 Consumer를 등록했다.</p>
<p><strong>StreamOffset.create(streamKey, ReadOffset.lastConsumed())</strong>
읽어올 Stream을 지정한다.
lastConsumed → 이전에 처리하지 않은 메시지부터 이어서 읽기</p>
<p><strong>consumer::onMessage</strong>
메시지가 들어왔을 때 실행할 콜백
RedisStreamConsumer 클래스의 onMessage 메서드가 호출되도록 설정했다.</p>
<p><strong>container.start()</strong>
start() 호출 시 컨테이너가 백그라운드에서 동작 시작
이후 Redis에 새 메시지가 오면 Consumer들이 알아서 가져가서 처리한다.</p>
<h3 id="컨슈머는-어떻게-새로운-메시지의-도착을-감지하는가">컨슈머는 어떻게 새로운 메시지의 도착을 감지하는가?</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/713705a9-7923-49d5-b209-a62fd1186449/image.png" alt=""></p>
<p><code>StreamMessageListenerContainer</code>가 빈으로 등록되면서 일어나는 일들을 정리해보자.</p>
<p>위의 코드를 기준으로, 4개의 리스너 스레드가 백그라운드에서 동작한다.
여기서 사용되는 스레드는 Executor의 스레드 풀이 아닌 스프링 프레임워크에서 관리하는 스레드 풀에서 가져온다.</p>
<p>실행된 리스너 스레드에서 레디스의 <code>XREADGROUP</code> 명령어를 <code>BLOCK</code> 옵션과 함께 실행하면서 메시지를 계속 기다린다.</p>
<p>BLOCK 옵션 덕분에 메시지가 없을 때는 불필요한 네트워크 트래픽 없이 효율적으로 대기 (pollTimeout 설정 시간만큼 대기)</p>
<p>리스너 스레드 자체는 블로킹되지만, 대기 상태이기 때문에 CPU에 크게 영향을 주지 않는다.</p>
<p>레디스 입장에서는 XREADGROUP을 외친 컨슈머를 순차적으로 대기열에 넣어 관리한다.
Producer가 메시지를 추가(XADD)하면,
대기열 처음에 있는 컨슈머부터 차례로 할당한다.</p>
<p>레디스 내부적으로는 각 컨슈머 당 하나의 PEL(Pending Entries List)을 갖는다.
PEL은 누가 어떤 메시지를 가져갔는데, 처리 중(Pending)임을 기록하는 리스트이다.
컨슈머에 메시지를 할당하면, PEL에 메시지를 추가한다.</p>
<p>리스너 스레드가 Redis Stream에 추가된 메시지를 감지하면,
ExecutorService의 작업 큐에 작업을 제출한다.</p>
<p>Executor 스레드 풀은 <code>Executors.newFixedThreadPool(8)</code>로 설정된 스레드 풀이다.
이 스레드 중, 작업이 할당되지 않은 스레드가 이를 가져가서 consumer::onMessage로 등록된 메서드를 실행한다.</p>
<p>서버에서 ACK 처리를 하면, 레디스에서는 해당 컨슈머의 PEL에서 제거한다.</p>
<p>+) <strong>그럼 그냥 연결해둔 채로 계속 메시지 받으면 될텐데, pollTimeout이 필요한 이유는 뭘까?</strong>
Redis와 서버 간 네트워크가 몰래 끊겼을 경우를 대비한다.
이렇게 하면 끊겼거나 안 끊겼거나, 타임아웃을 통해 재연결을 보장할 수 있다.</p>
<p>+) <strong>컨슈머는 왜 1개일 때보다 N개일 때 병목을 줄일 수 있을까?</strong>
리스너 스레드가 메시지를 감지하면, Redis Stream에서 메시지를 가져온다.
그리고 그 메시지를 ExecutorService의 작업 큐에 할당한다.
만약 컨슈머가 1개이면,
메시지를 가져오고, 작업 큐에 할당하는 과정 중에는 다른 메시지를 가져올 수 없기 때문에 병목이 발생할 수 있다.
또한 컨슈머(리스너 스레드)에서 장애가 발생하면, 1개의 컨슈머인 경우 모든 메시지를 처리할 수 없기 때문에, N개의 컨슈머를 설정하면 장애의 전파를 줄일 수 있다는 장점도 있다.</p>
<h3 id="producer">Producer</h3>
<pre><code class="language-java">@Slf4j
@Service
@RequiredArgsConstructor
public class RedisStreamAdapter implements PublishStreamMessagePort {

    @Value(&quot;${spring.data.redis.stream-key}&quot;)
    private String streamKey;

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;

    public void publish(StreamMessageType type, StreamMessage payload) {
        TransactionSynchronizationManager.registerSynchronization(
                new TransactionSynchronization() {
                    @Override
                    public void afterCommit() {
                        try {
                            Map&lt;String, String&gt; map = new HashMap&lt;&gt;();
                            map.put(&quot;type&quot;, type.name());
                            map.put(&quot;payload&quot;, objectMapper.writeValueAsString(payload));
                            map.put(&quot;retry&quot;, &quot;0&quot;);

                            StreamOperations&lt;String, String, String&gt; ops = redisTemplate.opsForStream();
                            RecordId id = ops.add(MapRecord.create(streamKey, map));

                            log.info(&quot;Published message to Redis Stream: type={}, payload={}, id={}&quot;, type, payload, id);
                        } catch (Exception e) {
                            throw new RuntimeException(&quot;Failed to serialize payload&quot;, e);
                        }
                    }
                }
        );
    }
}</code></pre>
<p>Producer가 Redis에 메시지를 보낼 때 사용하는 코드이다.
보면 afterCommit 안에서 메시지를 추가(XADD)하는데, 그 이유는 조금 길어져서 아래의 트러블 슈팅에 정리해두었다.</p>
<h3 id="consumer">Consumer</h3>
<p>생성된 메시지를 처리해 줄 컨슈머의 메서드이다.</p>
<pre><code class="language-java">@PostConstruct
public void init() {
        try {
            // Consumer Group 생성 (이미 존재하면 무시)
            try {
                redisTemplate.opsForStream().createGroup(streamKey, ReadOffset.from(&quot;0&quot;), consumerGroup);
                log.info(&quot;Created consumer group: {}&quot;, consumerGroup);
            } catch (Exception e) {
                log.debug(&quot;Consumer group already exists or stream doesn&#39;t exist yet&quot;);
            }
        } catch (Exception e) {
            log.error(&quot;Error creating consumer group&quot;, e);
        }
}</code></pre>
<p>처음에 컴포넌트를 등록할 때 실행되는 메서드이다.</p>
<p>Redis의 XGROUP CREATE 명령어를 실행하여 특정 스트림(streamKey)에 새로운 컨슈머 그룹을 생성한다.</p>
<p><code>ReadOffset.from(&quot;0&quot;)</code>
0은 스트림의 가장 처음부터 모든 메시지를 읽을 준비를 하라는 의미이다.
의도적으로 이전의 메시지를 무시하려는 경우, 변경 가능하지만 최초 생성의 상황이기 때문에 0으로 설정하였다.</p>
<p>XGROUP CREATE는 만약 그룹이 존재하는 경우 오류를 던지는데,
해당 오류는 운영 상 의미 없는 부분이므로 로그만 남기고 정상 흐름으로 바꾼다.</p>
<pre><code class="language-java">public void onMessage(MapRecord&lt;String, String, String&gt; record) {
        try {
            String type = record.getValue().get(&quot;type&quot;);
            String payloadJson = record.getValue().get(&quot;payload&quot;);
            JsonNode payloadNode = objectMapper.readTree(payloadJson);

            log.info(&quot;Processing record type={}, id={}&quot;, type, record.getId());

            CompletableFuture&lt;Void&gt; future;
            switch (StreamMessageType.valueOf(type)) {
                case REQUEST_CHAT_MESSAGE:
                    future = processChatMessage(payloadNode);
                    break;
                case REQUEST_SUMMARY:
                    future = processSummary(payloadNode);
                    break;
                case REQUEST_TOTAL_SUMMARY:
                    future = processTotalSummary(payloadNode);
                    break;
                case REQUEST_EXTRACT_METADATA:
                    future = processMetadata(payloadNode);
                    break;
                default:
                    log.warn(&quot;Unknown message type: {}&quot;, type);
                    // 알 수 없는 타입은 바로 ACK 처리
                    redisTemplate.opsForStream().acknowledge(streamKey, consumerGroup, record.getId());
                    return;
            }

            // 비동기 작업이 완료되었을 때의 처리
            future.whenComplete((result, throwable) -&gt; {
                if (throwable != null) {
                    // 비동기 작업 중 예외 발생 시
                    log.error(&quot;Error processing record {} asynchronously&quot;, record.getId(), throwable);
                    handleFailedMessage(record); // 실패 처리 로직 (DLQ 등)
                } else {
                    // 성공적으로 완료 시 ACK
                    redisTemplate.opsForStream().acknowledge(streamKey, consumerGroup, record.getId());
                    log.info(&quot;Successfully processed and acknowledged record id={}&quot;, record.getId());
                }
            });

        } catch (Exception e) {
            // onMessage 메서드 자체에서 동기적으로 에러 발생 시
            log.error(&quot;Error processing record {}&quot;, record.getId(), e);
            handleFailedMessage(record);
        }
}</code></pre>
<p>RedisConfig에서 설정했던, 컨슈머가 직접적으로 실행하는 onMessage 메서드이다.</p>
<p>메시지를 받고, 메시지 타입에 따라 다른 메서드를 실행한다.</p>
<p>CompletableFuture를 이용해 실제 작업은 비동기 처리한다.
해당 작업을 실행하던 워커 스레드는 콜백을 등록하고, 바로 스레드풀에 복귀하기 때문에 Block되지 않는다.
그리고 해당 작업이 성공적으로 처리된 경우, 이 작업을 처리하던 스레드가 메시지를 ACK 처리한다.</p>
<p>이 방식을 사용하면 Executor 스레드 풀의 스레드는 API 요청과 같이 긴 시간을 필요로 하는 작업을 처리하지 않고, 스레드 풀로 복귀할 수 있기 때문에 더 많은 Redis 메시지를 처리할 수 있다.</p>
<p>비동기 작업에서 예외가 발생하거나, 전체 코드에서 예외가 발생한 경우, <code>handleFailedMessage</code> 메서드를 실행한다.</p>
<pre><code class="language-java">private void handleFailedMessage(MapRecord&lt;String, String, String&gt; record) {
        try {
            // 현재 retry 횟수 확인
            Object retryCountObj = record.getValue().get(&quot;retry&quot;);
            int retryCount = retryCountObj != null ? Integer.parseInt(String.valueOf(retryCountObj)) : 0;

            // 최대 재시도 횟수 설정 (예: 3회)
            int maxRetries = 3;

            if (retryCount &lt; maxRetries) {
                // retry count 증가
                retryCount++;

                // 새로운 메시지 생성 (기존 데이터 + retry count 업데이트)
                ObjectRecord&lt;String, Map&lt;String, String&gt;&gt; retryRecord = StreamRecords.objectBacked(record.getValue())
                        .withStreamKey(streamKey);

                // retryCount 필드 추가/업데이트
                retryRecord.getValue().put(&quot;type&quot;, record.getValue().get(&quot;type&quot;));
                retryRecord.getValue().put(&quot;payload&quot;, record.getValue().get(&quot;payload&quot;));
                retryRecord.getValue().put(&quot;retry&quot;, String.valueOf(retryCount));
                retryRecord.getValue().put(&quot;originalId&quot;, record.getId().getValue());

                // Stream에 다시 publish
                redisTemplate.opsForStream().add(retryRecord);

                log.info(&quot;Retry message published - originalId: {}, retryCount: {}&quot;,
                        record.getId(), retryCount);
            } else {
                log.error(&quot;Maximum retry count exceeded for message: {}&quot;, record.getId());
                // DLQ에 실패한 메시지 추가
                String dlqKey = streamKey + &quot;:dlq&quot;;
                ObjectRecord&lt;String, Map&lt;String, String&gt;&gt; dlqRecord = StreamRecords.objectBacked(record.getValue())
                        .withStreamKey(dlqKey);
                dlqRecord.getValue().put(&quot;failedAt&quot;, String.valueOf(System.currentTimeMillis()));
                dlqRecord.getValue().put(&quot;originalId&quot;, record.getId().getValue());

                redisTemplate.opsForStream().add(dlqRecord);
            }

            // 기존 메시지는 ACK 처리해서 PEL에서 제거
            redisTemplate.opsForStream().acknowledge(streamKey, consumerGroup, record.getId());
        } catch (Exception e) {
            log.error(&quot;Error handling failed message: {}&quot;, record.getId(), e);
        }
}</code></pre>
<p>API 요청이나 소비 과정에서 오류가 발생한 경우에 대한 정책을 구현한 코드이다.
만약 실패 상황이 발생하면, retry 필드 값을 확인한다.
만약 retry 값이 3인 경우에는 이 메시지를 DLQ에 추가한다.
DLQ는 더이상 재시도 처리하지 않는 메시지들을 모아둔 메시지의 무덤이다.
이후 장애가 발생한 상황에서 DLQ를 직접 조회하고, 관리할 수 있다.</p>
<p><code>XRANGE my-stream:dlq - + COUNT 10</code>
요런 식으로 레디스 명령을 치면,</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/279d718d-fa52-4e73-b051-3e32b2a63a3b/image.png" alt=""></p>
<p>이런 식으로 실패한 메시지의 형태를 확인하고, 관리할 수 있다.</p>
<p>만약 retry 값이 3보다 작은 경우, 메시지를 다시 Stream에 produce한다.
이 과정에서 메시지의 retry 필드의 값을 하나 증가시킨다.</p>
<p>그리고 이렇게 추가된 메시지는, 다시 Consumer에 의해 소비된다.</p>
<p><strong>이런 방식으로 실패한 API 요청에 대한 재시도, 분류 처리가 가능했다!</strong></p>
<hr>
<h2 id="트러블-슈팅">트러블 슈팅</h2>
<h3 id="비동기-처리로-인한-동시성-이슈">비동기 처리로 인한 동시성 이슈</h3>
<p>분명히 COMPLETE로 바꾼 채팅방의 상태가 컨슈머의 처리 후 ALIVE로 둔갑하는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/1b248c08-f94a-4eeb-b2c5-2463b0816459/image.png" alt=""></p>
<p>Producer의 코드를 보면, chatRoom.expire()의 코드를 통해
chatRoom의 상태를 COMPLETE로 변경한다.
이후 saveChatRoom으로 상태를 DB에 반영한다. (정확히는 영속 상태)</p>
<p>Transactional이 메서드 단위에 붙어있기 때문에,
save 시점은 코드가 끝난 후, 즉 모든 처리가 끝나야 커밋 내역이 flush가 된다.</p>
<p>그런데 영속과 커밋 시점 사이에 Redis에 메시지를 발행한다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/ebd234e5-b6b6-4479-be90-ffc922f3227e/image.png" alt=""></p>
<p>Consumer의 코드를 보면,
chatRoom을 조회해 내부 로직을 처리한 후 변경사항을 저장하기 위해 마찬가지로 chatRoom을 저장한다.</p>
<p>문제는 여기서 발생하는데,
Consumer의 chatRoom 조회는 Producer의 코드에서 flush가 되기 전으로, 아직 상태가 ALIVE이다.
Consumer의 chatRoom이 update되는 시점은, Producer의 chatRoom이 flush된 이후에 처리되기 때문에 덮어씌워지는 것</p>
<p>생각치도 못한 문제가 발생했다..ㅎㅎ
리스너 스레드가 굉장히 빠르게 메시지를 가져와서 컨슈머에게 할당하는 것도 신기했다..!</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/3e4ae028-16d1-4516-80ce-37586d211d9b/image.png" alt=""></p>
<p>publish 메서드에 afterCommit을 달아 해결했다.
Callee는 Caller의 트랜잭션을 전파받는데, 
이 트랜잭션에 &quot;나는 당신들 처리 끝나면 실행하시오&quot; 하고 등록해두는 셈이다.</p>
<hr>
<h2 id="결론">결론</h2>
<h3 id="문제-해결">문제 해결?</h3>
<p>이전에 충분 조건을 확인해보자.</p>
<blockquote>
<ol>
<li>재시도를 할 수 있어야 한다.
→ API 요청 실패 시 재시도 메시지를 큐에 넣는다.</li>
<li>일정 재시도 횟수를 초과한 경우, 따로 분류한다.
→ DLQ에 저장</li>
<li>외부 인프라를 사용해야 한다.
→ 레디스 스트림 사용, 서버 재시작 시 큐에 있는 메시지를 처리하는 방법으로 사고 대응 가능</li>
</ol>
</blockquote>
<p>포스팅 도중 알게된 내용인데 추가적으로 처리할 부분이 남았다..ㅎㅎ</p>
<p>메시지 처리에 실패해서 큐에 재시도 메시지를 넣는 것까지는 구현하였으니
서버가 재시작되더라도 큐의 메시지를 Consume해서 다시 처리가 가능한 것은 맞다.</p>
<p>그러나 메시지가 Consume되어 처리되는 도중 서버에 장애가 발생하면,
메시지는 ACK되지 않고 Pending 상태로 남게 된다.
서버 재시작 시 PEL에 등록된 메시지를 직접 확인할 수 있긴 하지만
이 방법은 번거롭다.</p>
<p>아마 다음의 방법으로 확인할 수 있을 것 같다.</p>
<blockquote>
</blockquote>
<ol>
<li>재시작 시 XPENDING 명령을 사용해 현재 컨슈머에 할당된 처리 미완료 메시지가 있는지 확인</li>
<li>만약 Pending 메시지가 있다면, XCLAIM 명령으로 해당 메시지들의 소유권을 다시 현재 컨슈머로 가져오기</li>
</ol>
<p>근데 이 방법은 컨슈머가 두 대일 때 하나가 처리하고 있는 메시지를 재부팅된 컨슈머가 중복으로 처리할 수도 있지 않을까?</p>
<p>그건 유휴 시간(idle time)의 개념을 도입하면 될 것 같다.
MIN_IDLE_TIME을 초과한 메시지들만 XCLAIM하는 방식을 사용해
오랜시간 ACK없이 PEL에 존재하는 메시지들, 즉 뭔가 문제가 있다고 판단되는 메시지들만 옮기는 방법으로 해결할 수 있을 것 같다.</p>
<h3 id="확장하기">확장하기</h3>
<p>지금은 요청이 완전히 실패한 경우 DLQ에 넣어버리지만,
다른 큐에 넣고 다른 방식으로 처리해줄 컨슈머를 생성하는 방식으로 확장할 수 있다.</p>
<p>API 요청이 완전히 실패한 경우, dlq와 함께 슬랙 알림을 주는 방식으로 확장한다면, 운영 상 더 편리할 것으로 예상한다.</p>
<p>추가적으로, 현재 프로젝트의 규모에 적합할지는 조금 알아봐야겠지만,
OpenAI API 서버에 대한 지속적인 헬스체크를 통해 서버가 정상 상태일 때 실패 API를 재처리하는 로직 등 다양한 방식의 확장이 가능할 것 같다!</p>
<blockquote>
<p><strong>결론</strong>
이런 식의 확장 방식을 고려해볼 수 있는 것도 메시지 브로커 도입의 장점인 것 같다!
다음 포스팅에서 계속...</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[말모] SSE로 GPT 응답 스트리밍하기 (AI 채팅 1편)]]></title>
            <link>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-SSE%EB%A1%9C-GPT-%EC%9D%91%EB%8B%B5-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-SSE%EB%A1%9C-GPT-%EC%9D%91%EB%8B%B5-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 03 Sep 2025 13:12:00 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️⭐️⭐️
<strong>작성 날짜</strong> 2025.07.19</p>
<h2 id="고민-내용">고민 내용</h2>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/411c7270-e1b8-4eb9-bacd-9311d09e3fc8/image.png" alt=""></p>
<p>말모는 AI챗봇 모모가 사용자의 애착유형을 바탕으로 연애 고민을 상담해주는 어플리케이션이다.</p>
<p>가장 핵심이라고 할 수 있는 부분이 AI 상담을 위한 채팅 기능인데, 이 기능을 어떻게 구현해야 사용자 친화적일지를 PM님과 함께 고민했다.</p>
<p>우선 개발 경험이 있어서 그나마 친숙하였던 OpenAI API를 사용하기로 하였다.</p>
<p>GPT에게 사용자의 응답을 전송하면, GPT가 응답을 생성해서 받아온 값을 사용자에게 보여주어야 했는데,
이 과정에서 생각보다 많은 지연이 발생해 사용자 관점에서 &#39;느리다&#39;라는 느낌을 받을 수 있었다.</p>
<p>ChatGPT 처럼 GPT의 응답이 모두 완성될 때까지 기다리는 게 아니라, 생성되는 대로 <strong>와다다다</strong> 나올 수는 없을까?</p>
<p>다행히도 OpenAI API에서는 해당 기능을 제공하는데, 그것이 바로 &#39;Streaming&#39;이다.</p>
<p><a href="https://platform.openai.com/docs/guides/streaming-responses?api-mode=responses">https://platform.openai.com/docs/guides/streaming-responses?api-mode=responses</a></p>
<p>근데 우리... REST API 아닌가?</p>
<p>Spring MVC의 REST 컨트롤러(@RestController)의 동작 방식은 다음과 같다.</p>
<blockquote>
<p>클라이언트 → 서버에 요청 (HTTP Request)
서버 → 내부 로직 처리 후 최종 결과를 생성
서버 → 한 번에 Response Body를 내려주고 연결 종료</p>
</blockquote>
<p>즉, 한 번 응답을 완료하면 HTTP 연결이 닫히기 때문에 REST 통신 방식만으로는 스트리밍을 구현할 수 없다.</p>
<blockquote>
<p>🤔 REST API 통신 방식은 유지하면서, GPT의 스트리밍을 구현할 수는 없을까?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<p>결론부터 이야기 하자면, 우리 팀에서는 <strong>SSE</strong>를 선택하였다.</p>
<p>우리의 기획은 사용자가 다른 사용자와 채팅을 할 수도 없으면서,
채팅방은 사용자 당 하나이고,
사용자의 요청 → GPT의 응답의 1 대 1 대응이기 때문에</p>
<p>굳이 복잡한 상태관리가 필요한 WebSocket을 선택할 필요가 없었다.
사용자의 채팅은 REST로 받아두고, OpenAI의 응답에 대해서만 서버 → 클라이언트로 응답하면 되는 부분이기 때문에 서버 중심적인 통신 방식을 선택해도 문제가 없는 상황이었다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/f1273874-71f1-4762-a9f0-9569d850e0c1/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/bd8cdfd7-a097-49f5-a0c0-22a792e58326/image.png" alt=""></p>
<p>그리고 GPT 본인한테 물어보니까
OpenAI API → 서버로의 응답 과정도 SSE 방식이고,
ChatGPT도 SSE 통신 방식을 쓴다고 하니(확실하지 않음)
접근 방식 자체는 좋았던 것 같다.</p>
<hr>
<h2 id="sse는-어떤-통신-방식인가">SSE는 어떤 통신 방식인가?</h2>
<p>조금 더 구체적인 동작 방식을 알아보자.</p>
<blockquote>
<p><strong>클라이언트에서 서버로 SSE 연결을 요청하면 어떤 일이 벌어지는가?</strong></p>
<ol>
<li><p>스프링 서버에서는 SSE Emitter를 생성한다.</p>
</li>
<li><p>외부 네트워크 통신을 위해 소켓을 생성하는데, OS 레벨에서 TCP 소켓은 socket() 시스템 콜로 만들어지고, 이 소켓은 FD(파일 디스크립터) 로 관리된다.
REST와 다른 점은 REST는 이 소켓을 응답과 동시에 닫아버리지만, SSE는 타임아웃의 발생 전까지는 소켓을 유지한다는 점이다.</p>
</li>
</ol>
</blockquote>
<ol start="3">
<li>컨트롤러가 SseEmitter를 반환하면, DispatcherServlet은 이 요청을 비동기 모드로 전환한다.
즉, 초기에 연결 API를 통해 받은 요청을 처리하기 위한 스레드는 반납되지만, HTTP 연결은 OS의 네트워크 스택에 의해 유지되는 상태가 된다.
이때 Spring은 SseEmitter에 설정된 타임아웃 시간 후에 실행될 타임아웃 콜백 작업을 스케줄러(Task Scheduler)에 등록한다.</li>
</ol>
<blockquote>
<p><strong>데이터의 전송</strong></p>
</blockquote>
<p>생성된 SSE Emitter를 통해 데이터를 전송하면, 소켓을 통해 메시지가 작성된다.
열려있는 HTTP 연결 덕분에 TCP Handshake 과정을 생략하고, 클라이언트에게 메시지가 즉시 전달된다.</p>
<blockquote>
<p><strong>타임아웃 발생</strong></p>
</blockquote>
<ol>
<li>예약된 타임아웃 시간이 되면, 스케줄러가 서블릿 컨테이너에 비동기 디스패치(Async Dispatch)를 요청한다.</li>
<li>컨테이너는 이 요청을 받고 스레드 풀에서 스레드를 하나 할당한다.</li>
<li>할당된 스레드가 onTimeout() 콜백을 먼저 실행한다.
onTimeout() 실행 후 onCompletion() 콜백이 실행된다.
이 콜백에서 연결 종료, 리소스(소켓) 정리 등의 후속 작업을 수행한다.
이 과정에서 서버는 클라이언트와의 TCP 연결을 종료하는 신호를 전달한다.<blockquote>
</blockquote>
</li>
<li>onCompletion()까지 실행되어 모든 참조가 해제되면,
SseEmitter 객체는 더 이상 참조되지 않으므로 자바의 가비지 컬렉터(GC)에 의해 메모리에서 해제될 대상이 된다.</li>
</ol>
<hr>
<h2 id="적용해보기">적용해보기</h2>
<p>이제 직접 적용해보자.</p>
<h3 id="sse-연결">SSE 연결</h3>
<pre><code class="language-java">@Slf4j
@RequiredArgsConstructor
@Component
public class SseEmitterAdapter implements SendSseEventPort, ConnectSsePort, ValidateSsePort {
    private static final long TIMEOUT = 60 * 1000L; // 1분
    private static final int MAX_SIZE = 1000;
    public static final long RECONNECT_TIME_MILLIS = 3000L;

    private final Map&lt;Long, SseEmitter&gt; emitters = new ConcurrentHashMap&lt;&gt;();

    private final SseMetrics sseMetrics;

    @Override
    public SseEmitter connect(MemberId memberId) {
        if (emitters.size() &gt;= MAX_SIZE) {
            log.warn(&quot;Cannot connect SSE: Emitter map is full (size: {}).&quot;, emitters.size());
            throw new SseConnectionException(&quot;Maximum number of connections exceeded.&quot;);
        }

        sseMetrics.increment();

        Long memberIdValue = memberId.getValue();
        SseEmitter newEmitter = new SseEmitter(TIMEOUT);

        SseEmitter oldEmitter = emitters.put(memberIdValue, newEmitter);
        if (oldEmitter != null) {
            oldEmitter.complete();
        }

        newEmitter.onTimeout(() -&gt; {
            newEmitter.complete();
            log.info(&quot;SSE emitter timed out for member: {}&quot;, memberIdValue);
        });
        newEmitter.onError(e -&gt; {
            newEmitter.complete();
            log.error(&quot;SSE emitter error for member: {}&quot;, memberIdValue, e);
        });
        newEmitter.onCompletion(() -&gt; {
            log.info(&quot;SSE emitter completed for member: {}&quot;, memberIdValue);
            emitters.remove(memberIdValue, newEmitter);
            sseMetrics.decrement();
        });

        try {
            newEmitter.send(SseEmitter.event()
                    .id(String.valueOf(memberIdValue))
                    .name(&quot;connected&quot;)
                    .data(&quot;SSE connection established.&quot;)
                    .reconnectTime(RECONNECT_TIME_MILLIS));
        } catch (IOException e) {
            log.error(&quot;Failed to send initial SSE connection event for member: {}&quot;, memberIdValue, e);
            newEmitter.complete();
        }

        return newEmitter;
    }
}</code></pre>
<p>SSE Emitter를 ConcurrentHashMap으로 관리하였다.
사용자 별로 Emitter를 하나만 할당할 수 있다.
너무 많은 연결로 인한 리소스 문제의 발생을 막기 위해 MAX_SIZE를 설정하였고,
RECONNECT_TIME_MILLIS 또한 설정하였는데, 의도치 않은 연결 해제 시 브라우저(클라이언트)에게 자동 재연결을 할 수 있도록 부여하는 시간이다.</p>
<p>타임아웃에 대해 고민이 있었는데,</p>
<p>타임아웃을 길게 설정할 때</p>
<blockquote>
<p><strong>장점</strong>
클라이언트가 자주 재연결할 필요가 없어 네트워크 트래픽과 서버 부하가 줄어듦
실시간 알림 서비스 등에서 안정적인 연결 유지 가능</p>
<p><strong>단점</strong>
끊어진 연결(네트워크 단절 등)을 서버가 늦게 감지 → 리소스 낭비 (스레드/메모리 점유)
유휴 연결이 많아지면 서버 자원 관리가 어려워질 수 있음</p>
</blockquote>
<p>타임아웃을 짧게 설정할 때</p>
<blockquote>
<p><strong>장점</strong>
서버가 빠르게 끊어진 연결을 정리 가능 → 리소스 낭비 최소화
클라이언트가 주기적으로 재연결하면서 더 안정적인 연결 상태를 유지</p>
<p><strong>단점</strong>
클라이언트 재연결 요청이 잦아져 네트워크 부하 증가
이벤트 전달 시 순간적으로 끊김(재연결 지연)이 발생할 수 있음</p>
</blockquote>
<p>상대적으로 짧은 타임아웃(1분)을 선택하였다.
사용자가 앱을 이미 떠난 상황임에도 길게 SSE를 점유하고 있어
TCP 소켓(FD)을 정리하지 못하는 상황을 최대한 피하고 싶었다.</p>
<p>SseMetrics는 프로메테우스 매트릭 수집을 위해 추가한 부분이다.</p>
<p>조금 중요하다고 생각하는 부분은 이 부분인데,</p>
<pre><code class="language-java">newEmitter.onTimeout(() -&gt; {
            newEmitter.complete();
            log.info(&quot;SSE emitter timed out for member: {}&quot;, memberIdValue);
});
newEmitter.onError(e -&gt; {
            newEmitter.complete();
            log.error(&quot;SSE emitter error for member: {}&quot;, memberIdValue, e);
});
newEmitter.onCompletion(() -&gt; {
            log.info(&quot;SSE emitter completed for member: {}&quot;, memberIdValue);
            emitters.remove(memberIdValue, newEmitter);
            sseMetrics.decrement();
});</code></pre>
<p>SSE Emitter의 콜백을 관리하기 위한 코드이다.
각각 타임아웃, 전송 중 에러 발생, 완료 처리 시 비동기 후속 처리에 대한 코드이다.
타임아웃과 에러 상황에서도 <code>newEmitter.complete()</code>을 호출에 주어야 하는데, 그 이유에 대해서는 트러블 슈팅에서 정리해두었다.</p>
<p>complete()이 호출된 경우 onCompletion 속 코드가 실행된다.
HashMap에 저장된 SSE Emitter를 제거하여 자원을 관리한다.</p>
<p>sendToMember에서는 사용자의 ID를 바탕으로 Map에서 조회하여 SSE Emitter로 데이터를 흘려보낸다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/b8cbb462-5666-4275-81b5-22d932a38dc5/image.png" alt=""></p>
<p>connect() 메서드에서 생성한 SseEmitter를 컨트롤러에서 그대로 반환하면,
HTTP 연결이 열린 상태로 유지되게 된다.
또한, TEXT_EVENT_STREAM_VALUE 헤더를 설정해서 앞으로 SSE 통신이 이루어질 것임을 클라이언트에게 알린다.</p>
<p>이제 SSE Connect를 위한 API를 설정하였으니,
데이터를 흘려보내주어야 한다.</p>
<h3 id="openai-스트리밍">OpenAI 스트리밍</h3>
<pre><code class="language-java">private Map&lt;String, Object&gt; createStreamBody(List&lt;Map&lt;String, String&gt;&gt; messages) {
        return Map.of(
                &quot;model&quot;, GPT_VERSION,
                &quot;messages&quot;, messages,
                &quot;temperature&quot;, GPT_TEMPERATURE,
                &quot;stream&quot;, true
        );
}</code></pre>
<p>OpenAI API에 담아줄 Body 부분이다. 모델과 메시지, Temperature를 설정하는데,
가장 중요한 부분은 stream을 true로 설정하여 스트리밍으로 전달받도록 하는 것이다.</p>
<pre><code class="language-java">@Override
public void streamChat(List&lt;Map&lt;String, String&gt;&gt; messages,
                           Consumer&lt;String&gt; onData,
                           Consumer&lt;String&gt; onCompleteFullResponse,
                           Consumer&lt;String&gt; onError) {

        Map&lt;String, Object&gt; body = createStreamBody(messages);
        Request request = createStreamRequest(body, onError);

        client.newCall(request).enqueue(new Callback() {
            @Override
            public void onFailure(@NotNull Call call, @NotNull IOException e) {
                log.error(&quot;Failed to connect to OpenAI API&quot;, e);
                onError.accept(&quot;에러가 발생했습니다: 네트워크 연결에 실패했습니다.&quot;);
            }

            @Override
            public void onResponse(@NotNull Call call, @NotNull Response response) {
                StringBuilder fullResponse = new StringBuilder();
                try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body().byteStream()))) {
                    String line;
                    while ((line = reader.readLine()) != null) {
                        if (line.startsWith(&quot;data: &quot;)) {
                            String data = line.substring(6).trim();
                            if (data.equals(&quot;[DONE]&quot;)) break;

                            String content = extractStreamContent(data);

                            if (!content.isEmpty()) {
                                fullResponse.append(content);
                                onData.accept(content);
                            }
                        }
                    }

                    onCompleteFullResponse.accept(fullResponse.toString());
                }
                catch (Exception e) {
                    log.error(&quot;Error processing OpenAI API response&quot;, e);
                    onError.accept(&quot;에러가 발생했습니다: 응답 처리 중 문제가 발생했습니다.&quot;);
                }
            }
        });
}</code></pre>
<p><em>이 코드는 리팩토링 전의 코드입니다. 추후 포스팅을 통해 코드의 변경 과정을 보여드릴 예정입니다. (WebClient 기반 비동기 논블로킹)</em></p>
<p>이 코드에서 중심이 되는 내용을 몇 가지 소개하자면</p>
<ol>
<li>함수 인자의 Consumer</li>
</ol>
<p>OpenAI에서 우리 서버로 SSE를 통해 chunk 단위로 데이터를 전송한다.
이 데이터를 받아서 그대로 클라이언트에게 전달해야 하는데,
일반적인 코드처럼 API 요청을 하는 것으로 함수를 끝내버리면 response를 반환 받자마자 함수가 끝나버려 연속적인 처리가 불가능하다.</p>
<p>Consumer 인터페이스를 인자로 받으면,
스트리밍으로 데이터가 들어올 때 마다 해당 함수를 실행시켜주는 방식으로 구현할 수 있다.</p>
<p>이러한 방식을 콜백 패턴이라고 하는데,
실제 코드를 함수에 넣는 것과는 달리 실행 시점에 할 일을 외부에서 제어하면서 책임을 분리할 수 있다는 장점이 있다.</p>
<ol start="2">
<li>client.newCall(request).enqueue(new Callback() {...}</li>
</ol>
<p>OkHttp 라이브러리의 <code>enqueue()</code> 메서드를 사용하였다.
이 메서드는 <code>execute()</code>와는 달리, HTTP 요청을 비동기로 처리하기 위한 코드이다.</p>
<p><code>execute()</code>를 사용하여 동기 처리하는 경우, GPT의 응답 전문을 모두 받을 때까지 호출 스레드를 블로킹한다.
외부 API를 호출하기 때문에 이 시간이 길어질 수 있으며, 스레드 풀의 일시적 고갈로 이어질 수 있다.
이를 방지하기 위해 비동기 처리를 위한 <code>enqueue()</code> 메서드를 사용하였다.</p>
<p>또한, enqueue의 내부적으로도 Callback을 주입해 비동기 코드에서 해야 할 일을 미리 전달하여 데이터가 도착했을 때 어떤 일을 해야하는지 적어두었다.</p>
<h3 id="sse-데이터-전송">SSE 데이터 전송</h3>
<pre><code class="language-java">requestChatApiPort.streamChat(messages,
                //  데이터 stream 수신 시 SSE 이벤트 전송
                chunk -&gt; {
                    // ...생략
                    sendSseMessage(memberId, chunk);
                },
                // 응답 완료 시 전체 응답 저장
                fullAnswer -&gt; {
                    saveAiMessage(...);
                    //  ...생략
                },
                // 에러 발생 시 에러 메시지 전송
                errorMessage -&gt; sendSseErrorMessage(memberId, errorMessage)
);</code></pre>
<p>streamChat() 메서드의 Caller 부분만 가져왔다.
(관계 없는 내부 로직은 생략하였다.)
이제 chunk가 도착할 때 마다 sendSseMessage 메서드를 통해
SSE를 처리하는 Adapter의 sendToMember로 메시지를 전달하게 된다.</p>
<pre><code class="language-java">@Override
public void sendToMember(MemberId memberId, NotificationEvent event) {
        Long memberIdValue = memberId.getValue();
        SseEmitter emitter = emitters.get(memberIdValue);
        if (emitter == null) {
            log.debug(&quot;SSE emitter not found for member: {}&quot;, memberIdValue);
            return;
        }

        try {
            emitter.send(SseEmitter.event()
                    .id(memberIdValue + &quot;_&quot; + System.currentTimeMillis()) // 각 이벤트에 고유 ID 부여
                    .name(event.getEventType().getEventName())
                    .data(event.getData()));
        } catch (IOException | IllegalStateException e) {
            log.error(&quot;Failed to send SSE event to member: {}. Removing emitter.&quot;, memberIdValue, e);
            emitter.complete();
        }
}</code></pre>
<hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<h3 id="sse-타임아웃이-되면-에러-로그가-찍힌다">SSE 타임아웃이 되면 에러 로그가 찍힌다</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/bf5e1009-e7fd-4940-82c5-8add0f3d8fc0/image.png" alt=""></p>
<p>SSE Timeout 시 Access Denied 에러 로그 찍히는 문제 발생!</p>
<p>사용에는 문제 없지만, 너무 길어져 다른 로그를 보는 것에 방해가 된다.</p>
<p>원인은 Spring Security에 ASYNC 디스패처가 보호되어 있어서
AuthorizationFilter가 다시 권한 체크를 하다 AccessDenied를 터뜨리는 것이다.</p>
<p><strong>비동기 요청 완료를 위한 내부 디스패치(Async Dispatch)</strong></p>
<ul>
<li><code>SseEmitter</code>가 타임아웃되면, 스케줄러는 이 비동기 콜백을 처리하기 위해 <strong>내부적으로 디스패치(dispatch)를 실행</strong>한다.
즉, 최초에 들어왔던 요청을 마무리 짓기 위해 서버 내부에서 &quot;가상의 요청&quot;을 한번 더 실행하는 셈이다.</li>
<li>이때, 이 디스패치된 요청은 <strong>다시 Spring Security의 필터 체인을 통과</strong>한다.</li>
<li>문제는 SSE의 Timeout은 비동기 후속 작업으로 처리되기 때문에, 원래의 스레드와는 다른 스레드에서 진행된다.</li>
<li>따라서 원래 갖고 있던 SecurityContext를 잃어버리고, 인증 실패로 처리하여 사용자를 <code>Anonymous</code>로 만든다.</li>
<li>결국 <code>AuthorizationFilter</code>가 해당 엔드포인트에 대한 권한(예: <code>isAuthenticated()</code>)을 검사하다가, 익명 사용자의 접근이므로 <code>AccessDeniedException</code>을 발생시킨다.</li>
</ul>
<pre><code class="language-java">.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll()</code></pre>
<p>Spring Security에 한 줄 추가해서 해결</p>
<pre><code class="language-bash">org.springframework.web.context.request.async.AsyncRequestTimeoutException: null

at org.springframework.web.context.request.async.TimeoutDeferredResultProcessingInterceptor.handleTimeout(TimeoutDeferredResultProcessingInterceptor.java:42) ~[spring-web-6.1.14.jar:6.1.14]

at org.springframework.web.context.request.async.DeferredResultInterceptorChain.triggerAfterTimeout(DeferredResultInterceptorChain.java:81) ~[spring-web-6.1.14.jar:6.1.14]

at org.springframework.web.context.request.async.WebAsyncManager.lambda$startDeferredResultProcessing$5(WebAsyncManager.java:457) ~[spring-web-6.1.14.jar:6.1.14]

at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) ~[na:na]

at org.springframework.web.context.request.async.StandardServletAsyncWebRequest.onTimeout(StandardServletAsyncWebRequest.java:185) ~[spring-web-6.1.14.jar:6.1.14</code></pre>
<p>이런 오류가 또 떴는데 이건</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/6ac4d540-49e0-4166-881f-c59502021a7f/image.png" alt=""></p>
<p>타임아웃에 Complete 처리를 안해서 그렇다.</p>
<p><code>emitter.complete()</code>는 단순히 메서드를 하나 더 호출하는 것이 아니라, 
<strong>비동기 요청 처리의 제어권을 프레임워크로부터 받아와서 내 코드에서 책임지고 완료시키겠다는 의사 표현</strong>이기 때문에,
타임아웃 발생 시 Complete을 시키지 않으면 서블릿이 AsyncRequestTimeoutException을 발생시켜 요청이 프레임워크에 의해 강제로 종료됨을 알리는 것이다.
만약 이런 기능이 없으면 통신 객체를 더이상 사용하지 않음에도 소켓이 열려있는 문제가 발생하기 때문에 강제 종료 기능을 넣은 것으로 추측된다.</p>
<h3 id="sse-메시지가-가끔-안-와요">SSE 메시지가 가끔 안 와요</h3>
<p>이건 에러 로그도 없고 특정 시점이 아닌 간헐적으로 발생하는 문제라 디버깅이 어려웠다.</p>
<p>찾아보니 비슷한 문제를 겪고 있는 분들이 계셔서 참고했다.</p>
<p>요약하자면,</p>
<blockquote>
<p>SseEmitter의 타임아웃을 1분으로 설정하더라도, 중간 경유지인 공유기, 통신사, 웹 서버(Reverse Proxy) 등의 자체적인 유휴 타임아웃(Idle Timeout) 설정 때문에 메시지 전송이 없는 경우 연결이 먼저 끊어질 수 있다고 한다.</p>
</blockquote>
<p>우리 서버(Spring) → 클라이언트(React)로 요청, 응답하는 과정에는 상당히 많은 중간 장치를 거친다.</p>
<pre><code>클라이언트
→ 클라이언트 측 네트워크 장치 (라우터, ISP)
→ DNS 서버
→ AWS 네트워크 → [NACL → Security Group → (EC2 OS 방화벽)] 
→ Nginx 
→ Spring Boot</code></pre><p>우리 인스턴스에서 사용하는 웹 서버인 nginx에는 이미 </p>
<pre><code class="language-bash">proxy_read_timeout 1d;
proxy_send_timeout 1d;</code></pre>
<p>이런 식으로 충분히 긴 타임아웃 시간을 설정해두었다.
그러니 nginx의 문제는 아닐 것이다.</p>
<p>그러나 라우터, ISP와 같은 중간 장비들은 SSE 연결이 활성 상태인지, 아니면 단순히 아무 데이터도 전송되지 않는 유휴 상태인지 구분하지 못한다. 따라서 자신들의 규칙에 따라 유휴 상태라고 판단되면 연결을 종료시킨다고 한다.</p>
<p>해결 방법은 바로바로 <strong>하트비트 (Heart Beat)</strong></p>
<p>서버 측(SseEmitter)에서 아무런 전송할 이벤트가 없더라도 10초나 30초 등 SseEmitter 타임아웃 및 중간 장비의 타임아웃보다 짧은 간격으로 주석(comment)이나 의미 없는 작은 데이터를 클라이언트로 보내는 방식이다.</p>
<p>이 더미 데이터는 실제 데이터는 아니지만, 네트워크 상에서는 트래픽으로 인식된다. 따라서 중간 경유지들은 이 연결이 계속 활성 상태라고 판단하여 연결을 끊지 않게 된다고 한다.</p>
<pre><code class="language-java">@Scheduled(fixedRate = 15_000)
public void sendHeartbeat() {
    // 현재 연결된 모든 Emitter에 대해 반복
    emitters.forEach((memberId, emitter) -&gt; {
        try {
            emitter.send(SseEmitter.event()
                    .comment(&quot;sse heartbeat&quot;));
        } catch (IOException | IllegalStateException e) {
            // IO 에러 발생 시, 클라이언트 연결이 끊어진 것으로 간주하고 정리
            log.warn(&quot;Failed to send heartbeat to member: {}. Removing emitter.&quot;, memberId, e);
            emitter.complete();
        }
    });
}</code></pre>
<p>사실 눈에 보이는 해결책은 아니라 실제로 문제를 해결할 수 있을 지에 대한 의문이 있었는데,
이 방식을 적용한 이후로 연결 끊김이 많이 사라졌다는 프론트 팀원 분들의 만족 가득한 후기가 있었다.</p>
<h3 id="jdbcconnectionexception-발생">JDBCConnectionException 발생</h3>
<p>좀 뜬금 없는 이야기일 수 있는데, SSE를 구현하고 이런 오류가 발생했다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/7f5637cb-9ec9-4d17-b371-d70a6ea2edad/image.png" alt=""></p>
<p>좀 자세히 살펴보면,
JDBCConnectionException: Connection is not available, request timed out after 30000ms
이라는 메시지를 확인할 수 있다.</p>
<p>바로 DB와 관련된 API를 요청한 경우, JDBC 트랜잭션이 커넥션 풀에서 커넥션을 가져오려고 하는데 없어서 대기하다가 타임아웃이 발생하는 오류이다.</p>
<p>음? SSE 얘기하고 있는데 갑자기 JDBC?</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/b8cbb462-5666-4275-81b5-22d932a38dc5/image.png" alt=""></p>
<p>컨트롤러 코드를 보면, 클라이언트-서버 간 SSE 연결을 해주는 /connect API가 있다.
잘 보면 SseEmitter를 반환하고 있어서 Spring은 Content-Type: text/event-stream 으로 연결을 열어두고 닫지 않는다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/35d6a72d-9bfa-48a5-ba7f-653b8c743126/image.png" alt=""></p>
<p>SSE Emitter 생성을 관리하는 서비스 코드이다.
원인은 바로 <code>@CheckValidMember</code> 이놈에 있었다.</p>
<p>CheckValidMember는 커스텀 어노테이션으로, 서비스 코드가 실행되기 전 AOP로 사용자가 실제 멤버인지 DB를 통해 검증하는 역할을 가지고 있다.</p>
<p>그렇다. 이 어노테이션은 DB를 통해 엔티티를 조회한다...
그래서 커넥션을 빌려오고, 요청이 끝날 때까지 이 커넥션을 점유한다.</p>
<p>이런 일이 발생하는 이유는 Spring이 Open-In-View 설정을 true 값으로 자동 설정하기 때문인데, 작동 방식을 보면 알 수 있다.</p>
<p>Open-In-View는 실제로 DB에 접근하는 코드가 실행될 때 커넥션을 가져오며, 일단 가져온 커넥션은 웹 요청이 끝날 때까지 유지한다. Repository를 호출하는 등 DB 접근이 한 번이라도 일어나면 커넥션을 할당하고 계속 붙잡고 있게 된다.</p>
<p>Open-In-View의 핵심은 DB 커넥션의 생명주기를 웹 요청의 생명주기와 일치시키는 것이다.</p>
<ol>
<li><p>웹 요청 시작: 클라이언트로부터 HTTP 요청</p>
</li>
<li><p>OpenInViewInterceptor 동작: 인터셉터가 영속성 컨텍스트(EntityManager)를 생성하여 현재 스레드에 바인딩</p>
</li>
<li><p>첫 DB 접근 발생: 서비스나 컨트롤러에서 Repository의 메서드를 호출하는 등 실제로 데이터베이스에 쿼리를 보내야 하는 첫 번째 순간에, 영속성 컨텍스트는 커넥션 풀로부터 물리적인 DB 커넥션을 할당받음.</p>
</li>
<li><p>커넥션 유지: 일단 커넥션이 할당되면, Open-In-View 전략에 따라 이 커넥션은 웹 요청이 완전히 끝날 때까지 반납되지 않는다. (ThreadLocal에 보관)</p>
</li>
<li><p>웹 요청 종료: 클라이언트에게 최종 응답이 전송되고 요청이 마무리되면, OpenInViewInterceptor가 영속성 컨텍스트를 닫으면서 DB 커넥션을 커넥션 풀에 반납</p>
</li>
</ol>
<p>findById(1L)과 같은 코드를 단 한 줄이라도 실행했다면, 그 순간 커넥션이 할당되고 웹 요청(HTTP)이 끝날 때까지 유지된다.</p>
<p>이 경우에는 스레드는 반환되었지만, HTTP 요청은 종료되지 않았기 때문에 스레드의 ThreadLocal에 갇힌 커넥션은 반환되지 않는 것이다.</p>
<p>스프링이 이와 같은 작동 방식에 대한 기본 값을 true로 설정하는 이유는 지연 로딩 때문이다.</p>
<blockquote>
<p>Service나 Repository에서 커넥션을 이용해 엔티티를 조회한 경우,
해당 엔티티를 그대로 Caller인 컨트롤러 또는 타임리프와 같은 View 계층에 반환하는 경우가 있다.
이제 실제 JSON으로 변환하기 위해 Getter를 사용하려 하지만, 지연 로딩 설정되어 있는 경우 해당 시점에 DB에서 실제 엔티티를 조회해 와야 한다.
그럼 DB 커넥션이 필요하기 때문에 이 커넥션을 Callee에서 반환해버리지 말고, 편하게 View 단까지 끌어오자~</p>
</blockquote>
<p>라는 목적성을 갖고 있다고 보면 된다.</p>
<p>여튼, 이 문제를 해결하기 위해선
spring.jpa.open-in-view의 값을 false로 설정하거나,
DB 커넥션을 사용하는 메서드를 제거하는 방식으로 변경해야 한다.</p>
<p>현재 말모 프로젝트의 엔티티와 도메인은 분리되어 있어서 open-in-view의 값을 false로 설정하더라도 아마도 문제가 없을 것이다.
그러나 나의 경우에는 개발 기간이 빠듯해 이를 검증하지 못할 것 같아 후자를 선택하였다.
어차피 Security의 Access Token에서 검증 과정에서 어느정도 멤버가 검증되기도 하며,
여기서 걸러지지 않은 사용자가 토큰을 재활용하여 Emitter를 생성하더라도, 로직 상 Emitter 생성만 가능하고, 다른 API는 사용하지 못하기 때문에 문제가 없을 것이다.</p>
<p>따라서 <code>@CheckValidMember</code> 을 제거하는 방식으로 문제를 해결하였다. (추후 검증을 통해 전자로 변경 예정)</p>
<p><strong>가급적이면 SSE Emitter를 반환하는 API에는 DB Connection을 사용하지 말자!</strong></p>
<hr>
<h2 id="결론">결론</h2>
<blockquote>
<p><strong>결론</strong>
새롭게 사용하는 기술이라 여러모로 이슈가 많이 있었다.
기술의 동작 방식을 정리하면서 정확한 문제를 파악할 수 있었다.
기본을 확실히 알고 사용해야 한다는 것을 느꼈다.</p>
</blockquote>
<p>다음 포스팅에서 계속...</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[말모] nginx로 블루-그린 배포 흉내내기]]></title>
            <link>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-nginx%EB%A1%9C-%EB%B8%94%EB%A3%A8-%EA%B7%B8%EB%A6%B0-%EB%B0%B0%ED%8F%AC-%ED%9D%89%EB%82%B4%EB%82%B4%EA%B8%B0</link>
            <guid>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-nginx%EB%A1%9C-%EB%B8%94%EB%A3%A8-%EA%B7%B8%EB%A6%B0-%EB%B0%B0%ED%8F%AC-%ED%9D%89%EB%82%B4%EB%82%B4%EA%B8%B0</guid>
            <pubDate>Tue, 02 Sep 2025 18:42:09 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️
<strong>작성 날짜</strong> 2025.07.13</p>
<h2 id="고민-내용">고민 내용</h2>
<p>마감 날짜가 정해져 있어 MVP를 빠르게 개발해야 했기 때문에,
백엔드에서는 기능이 개발되는 대로 바로바로 서버에 배포하였다.</p>
<p>이 배포 과정에서 잠깐의 중단이 발생하는데,
프론트 팀원들에게 미리 공지하지 않으면 갑작스러운 에러 발생으로 당황해할 수 있다.
CI/CD 파이프라인으로 배포하고 있어서 Deploy Step이 언제 진행될지 몰라 미리 서버의 중단을 10분 정도로 공지하고, 배포하고 있었다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/4b84727c-43dc-4abc-922c-9227238a17c6/image.png" alt=""></p>
<p><em>프론트 개발자님과의 슬랙 내용 (새벽 1시 51분)</em>
새벽에도 열심히 개발하는 우리 팀원들... 덕분에 새벽 배포도 어렵다ㅠ</p>
<p>문제는 Deploy나 Dockerfile을 잘못 만지거나 의존성에서 문제가 생기거나 하는 경우에는 기존 서버가 stop &amp; rm된 상태로 새로운 서버가 올라가지 않는 경우도 있다.
이러면 기존에 10분으로 공지했던 내용을 20분, 30분... 이상으로 연기시켜야 했던 아찔한 경우가 있었다.</p>
<p>나아가 개발 마감 기한이 가까워져서 PM님과 디자이너님도 함께 QA에 들어가게 되면 팀 전체가 서버의 배포만을 기다리게 되는 끔찍한 경우가 생길지도 모른다..!</p>
<blockquote>
<p>🤔 일이 끔찍해지기 전에 막아보자</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<p>이왕 이렇게 된거 무중단 배포의 개념을 찾아 정리해보았다.</p>
<h3 id="롤링-배포-rolling-deployment">롤링 배포 (Rolling Deployment)</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/1b0126ec-5680-4b06-929a-0c05600c9ad2/image.png" alt=""></p>
<blockquote>
<p><strong>개념</strong>
전체 서버(또는 컨테이너)를 한 번에 교체하지 않고, 일부 인스턴스부터 새로운 버전을 점진적으로 교체하는 방식.
트래픽은 점차 새로운 버전으로 분산되며, 모든 인스턴스가 교체되면 배포가 완료됨.</p>
</blockquote>
<p>장점</p>
<ul>
<li>무중단 배포가 가능하다.</li>
<li>인프라 리소스 추가가 거의 필요 없다. (기존 서버 안에서 순차 교체)</li>
<li>문제가 생기면 롤백도 순차적으로 가능하다.</li>
</ul>
<p>단점</p>
<ul>
<li>배포 중에는 구버전과 신버전이 공존 → 호환성 문제가 생길 수 있음.</li>
<li>배포 완료까지 시간이 오래 걸릴 수 있다.</li>
<li>트래픽 분산 및 상태 관리가 복잡할 수 있다.</li>
</ul>
<h3 id="블루-그린-배포-blue-green-deployment">블루-그린 배포 (Blue-Green Deployment)</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/d4be9782-a093-4c04-bb27-8e7e6f38d44b/image.png" alt=""></p>
<blockquote>
<p><strong>개념</strong>
Blue(현재 운영 중인 버전)과 Green(새 버전) 두 개의 동일한 환경을 준비해두고, 트래픽을 한 번에 Blue → Green으로 전환하는 방식.
문제가 생기면 다시 Blue로 트래픽을 전환하여 빠르게 롤백 가능.</p>
</blockquote>
<p>장점</p>
<ul>
<li>전환 시점은 매우 짧아서 사실상 완벽한 무중단 배포가 가능하다.</li>
<li>롤백이 단순하고 빠르다.</li>
<li>Blue와 Green 환경을 비교하여 사전 검증이 가능하다.</li>
</ul>
<p>단점</p>
<ul>
<li>두 개의 환경을 동시에 유지해야 하므로 인프라 비용이 크다.</li>
<li>데이터베이스 스키마 변경과 같이 공유 리소스가 있으면 적용이 까다롭다.</li>
</ul>
<h3 id="카나리-배포-canary-deployment">카나리 배포 (Canary Deployment)</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/475d7d76-509d-4998-9b71-fe7b3a564eaa/image.png" alt=""></p>
<blockquote>
<p><strong>개념</strong>
새로운 버전을 일부 사용자(또는 트래픽)에게만 먼저 배포하고, 이상이 없으면 점차 확대하는 방식.
&quot;카나리 새&quot;에서 유래: 광산에 카나리를 먼저 들여보내 위험 여부를 확인했던 것처럼, 새로운 버전을 일부에 먼저 시험 적용.</p>
</blockquote>
<p>롤링 배포와 다른 점은 카나리는 검증을 위한 배포 방식이라는 점이다.
일부 사용자에게만 고정적으로 신버전을 제공하여 점차 신버전 사용자의 비율을 늘려가는 방식이다.</p>
<p>장점</p>
<ul>
<li>실제 사용자 트래픽으로 신버전의 안정성을 검증 가능하다.</li>
<li>문제 발생 시 영향 범위가 제한적이다.</li>
<li>롤백도 부분적으로 용이하다.</li>
</ul>
<p>단점</p>
<ul>
<li>트래픽 라우팅(누구에게 신버전을 보낼지) 설정이 필요해 운영이 복잡하다.</li>
<li>모니터링 및 장애 감지가 필수다.</li>
<li>배포 속도가 상대적으로 느리다.</li>
</ul>
<h3 id="그래서-뭘-선택해야-하는가">그래서 뭘 선택해야 하는가?</h3>
<p>상황에 따라 다르겠지만, 사실 내 경우에는 어떠한 전략도 선택할 수 없다.
개념을 엄밀하게 따지자면, 무중단 배포는 독립된 두 물리적인 자원 (인스턴스)가 필요하다.
우리 팀은 운영 비용을 최소화하기 위해 서버를 프리티어 인스턴스 위에서 돌리고 있다.
그리고 AWS는 EC2 인스턴스 한 대까지만 프리티어를 지원하기 때문에, 새로운 인스턴스를 생성하고 운용하는 것은 추가적인 비용에 대한 부담이 있었다.
그래서 위에서 언급한 배포 전략은 모두 적용하기 어렵다.</p>
<p>그러나 <strong>배포 전략의 아이디어</strong>만은 가져올 수 있다.
하나의 인스턴스에서 여러 개의 도커 컨테이너를 띄우면, 버전에 따른 논리적인 서버 분리가 이루어지는 것은 아닐까?</p>
<p>카나리 배포는 트래픽 분산을 위한 기술이 필요하며, 단순히 개발 과정에서의 편의를 위해 도입하기에는 과하다고 판단했다.
롤링 배포도 매력적이지만, 프리티어 인스턴스에서 메모리 관리를 위해 컨테이너 수를 최소화 할 수 있는 <strong>블루-그린 배포</strong>가 적절하다고 생각했다.</p>
<p>블루-그린 배포의 개념을 활용하면 단 두 개의 실행 중인 서버로도 무중단 배포를 할 수 있기 때문이다!</p>
<hr>
<h2 id="적용해보기">적용해보기</h2>
<h3 id="새로운-인스턴스-대신-새로운-컨테이너">새로운 인스턴스 대신 새로운 컨테이너</h3>
<p>우선 기존의 인스턴스에 새로운 도커 컨테이너를 띄워보자.
기존 스프링 컨테이너는 8080으로, 새로운 컨테이너는 8081에 띄워보자.</p>
<p>이 과정에서 살짝 고생했던 경험을 공유하자면,
현재 서버가 docker-compose를 이용해 스프링 서버의 컨테이너와 Redis 컨테이너를 같이 띄우고 있다.
그럼 새로운 컨테이너는 같은 compose로 실행된 컨테이너가 아니기 때문에 직접 Redis 컨테이너에 접근할 수 없다.</p>
<p>그럼 docker-compose를 사용하지 말고 레디스를 따로 컨테이너 띄우면 되지 않나?
라고 생각했지만 CI/CD의 코드를 통해 레디스의 설정을 관리할 수 있는 장점을 포기하고 싶지 않아서 패스.</p>
<p>그래서 우선 인스턴스에 접속해서 도커 네트워크를 생성해주었다.</p>
<pre><code class="language-bash">docker network create network-name</code></pre>
<p>그리고 docker-compose에서 컨테이너를 띄울 때</p>
<pre><code>networks:
- network-name

...

networks:
  network-name:
    external: true</code></pre><p>이런 식으로 설정하여 compose 외부의 네트워크 위에서 컨테이너가 돌아가게 하였다.</p>
<p>새롭게 띄우는 컨테이너도 해당 네트워크를 이용하도록 설정하면 끝!</p>
<hr>
<h3 id="로드밸런서-대신-nginx">로드밸런서 대신 nginx</h3>
<p>물리적으로 인스턴스를 분리하는 경우에는 AWS ELB와 같은 로드밸런서를 활용한다.
왜냐하면 사용자의 요청을 어느 인스턴스의 서버로 보낼지 결정해주는 가이드가 필요하기 때문이다.</p>
<p>나의 경우, 하나의 인스턴스에서 이를 실현하기 위해 nginx를 사용하였다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/9179cfd5-6678-490e-91b1-de6bb7287179/image.png" alt=""></p>
<pre><code class="language-bash">server {
    listen 80;
    server_name api.server.name.kr;
    return 301 https://$host$request_uri;
}

server {
        listen 443 ssl;
    server_name api.server.name.kr;

    location / {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

    }

    # 이하 내용 생략
}</code></pre>
<p>여기서 <a href="http://localhost:8080">http://localhost:8080</a> 부분을 8081로 바꾸거나 반대로 바꾸어주면,
api.server.name.kr로 요청이 들어왔을 때 새롭게 띄운 컨테어너로 연결이 가능하다.</p>
<p>스크립트를 이용해 배포 시 자동으로 nginx 설정까지 바꿔주는 방법도 있긴 하지만, 어차피 롤백과 같은 상황에서 관리하려면 nginx 설정을 직접 건드릴 필요가 있고 수정할 부분도 조금이라 해당 방법을 사용하지는 않았다.</p>
<pre><code class="language-bash">sudo nginx -t
sudo systemctl reload nginx</code></pre>
<p>이제 이 명령으로 nginx를 업데이트~!</p>
<p>한 가지 궁금했던 점은</p>
<blockquote>
<ol>
<li>현재 서버 도메인은 블루 컨테이너로 연결되어 있음 </li>
<li>사용자가 도메인으로 API 요청을 보냄 </li>
<li>요청이 완전히 처리되기 전 nginx의 설정을 변경하여 도메인의 연결을 그린 컨테이너로 변경 </li>
<li>블루 컨테이너의 API 요청이 처리</li>
</ol>
</blockquote>
<p>이 경우에 사용자는 API 요청에 대한 response를 받을 수 있을까?</p>
<p>정답은 Yes이다.
nginx는 설정을 reload하더라도 기존 연결을 끊지 않고 유지한다. (restart와 다르다!)
reload 명령 이후에 새롭게 들어온 요청만 새로운 설정 값으로 처리한다고 한다.</p>
<hr>
<h3 id="redis-stream-consumer가-둘로-불어났다">Redis Stream Consumer가 둘로 불어났다</h3>
<p>이 방식을 적용하여 테스트 하던 중,
Redis Stream을 사용하는 로직을 가진 API에 요청을 보내면, SSE 응답이 오지 않는 문제를 발견했다.</p>
<blockquote>
<p>문제의 발생 순서는 다음과 같다.</p>
</blockquote>
<ol>
<li>클라이언트가 SSE 연결 API 요청</li>
<li>SSE Emitter는 블루 컨테이너 서버의 HashMap에 저장</li>
<li>클라이언트가 Redis Stream을 이용하는 API 요청</li>
<li>블루 컨테이너가 Redis Stream에 메시지를 publish</li>
<li>그린 컨테이너의 Consumer가 이를 소비</li>
<li>그린 컨테이너에는 해당 클라이언트의 SSE Emitter가 존재하지 않으니 오류 처리</li>
</ol>
<p><strong>해결</strong></p>
<p>문제는 동일한 서버에서 생성된 메시지를 동일한 서버에서 처리해야 하는데 그렇지 않아서 발생한다.</p>
<p>기존에 하드코딩 해두었던 Stream Key와 Consumer Group을
yml을 통해 환경 변수로 주입받도록 변경한다.</p>
<pre><code class="language-yml">  data:
    redis:
      port: 6379
      host: redis
      stream-key: ${REDIS_STREAM_KEY}
      consumer-group: ${REDIS_CONSUMER_GROUP}
      repositories:
        enabled: false</code></pre>
<p>이 부분은 docker-compose(deploy.yml)에서 설정할 수 있으며, 블루 컨테이너와 그린 컨테이너 각각 다른 값을 넣어준다.</p>
<pre><code class="language-yml">environment:
- TZ=Asia/Seoul
- REDIS_STREAM_KEY=${{ secrets.REDIS_STREAM_KEY }}
- REDIS_CONSUMER_GROUP=${{ secrets.REDIS_CONSUMER_GROUP }}</code></pre>
<p>Producer(publisher)의 코드는 다음과 같다.</p>
<pre><code class="language-java">RecordId id = ops.add(MapRecord.create(streamKey, map));</code></pre>
<p>주입받은 streamKey를 payload와 함께 발행한다.</p>
<p>그리고 컨슈머의 코드를 보면</p>
<pre><code class="language-java">redisTemplate.opsForStream().createGroup(streamKey, ReadOffset.from(&quot;0&quot;), consumerGroup);</code></pre>
<p>해당 stream key에 해당하는 메시지만 Consume하도록 설정되어 있기 때문에 문제를 해결할 수 있다.</p>
<p>이와 별개로 SSE Emitter가 유실되는 문제는... 현재의 구조에서 완벽하게 해결하긴 어려운 것으로 결정지었다.
SSE Emitter의 타임아웃을 1분 정도로 짧게 지정해서, 재연결을 요청하는 경우나 새롭게 연결을 요청하는 경우 새로운 서버에서 Emitter를 생성할 수 있다.
1분 정도의 유실이 생기는 부분은 아쉽지만 구조 확장이 없이는 다른 방법이 크게 떠오르지 않는 것 같아서 마무리..!</p>
<hr>
<h2 id="결론">결론</h2>
<p>이를 통해 다음과 같은 결과를 얻을 수 있었다.</p>
<ol>
<li>중단되지 않는 빠른 배포</li>
<li>서버에 문제가 생긴 경우 기존 서버로 포트를 돌려 빠르게 롤백</li>
</ol>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/0c0e5791-2246-4d13-944b-b8d6cd0ff658/image.png" alt=""></p>
<p>런칭 이후인 지금은 그린 컨테이너를 테스트 서버로 사용하고 있다.
Redis 메시지는 Key와 Consumer를 분리해두어 간섭이 없고, DB도 분리해두었기 때문에 Prod 서버와는 문제 없이 병행 운용할 수 있다.</p>
<p>그리고 테스트가 완료되면 그린 컨테이너(테스트 서버)의 설정을 바꾼 후 nginx의 포트를 교차시키는 방식으로 무중단 배포를 활용하고 있다.</p>
<blockquote>
<p><strong>결론</strong>
더 나은 방법도 찾아보면 있을지도 모르겠지만, 비용을 최소화하면서 원하는 결과를 얻을 수 있어서 보람찼다!!</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[말모] 도메인 모델 생성자에 대한 고민]]></title>
            <link>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AA%A8%EB%8D%B8%EC%9D%98-%EC%83%9D%EC%84%B1%EC%9E%90%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EB%AF%BC</link>
            <guid>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AA%A8%EB%8D%B8%EC%9D%98-%EC%83%9D%EC%84%B1%EC%9E%90%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EB%AF%BC</guid>
            <pubDate>Tue, 02 Sep 2025 14:36:22 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️⭐️
<strong>작성 날짜</strong> 2025.07.12</p>
<h2 id="고민-내용">고민 내용</h2>
<pre><code class="language-java">@Getter
@SuperBuilder
@AllArgsConstructor
public class Member extends BaseTimeEntity { ... }</code></pre>
<p>기존의 도메인 모델의 코드를 보면, 
<code>@SuperBuilder</code>, <code>@AllArgsConstructor</code>를 이용하고 있다.
SuperBuilder를 쓴 이유는 BaseTimeEntity의 필드까지 접근가능하도록 하기 위해서이다.</p>
<p>그러나 이 방식은 도메인 모델의 생성자를 외부로 노출하는 문제가 있었다.
예를 들어, 멤버의 생성은 <code>createMember()</code>에서만 진행되어야 한다.
이외의 접근 방식을 이용할 수 없도록 강제하지 못한다면 의도와는 다른 생성이 이루어질 수 있다.</p>
<p>그런데 Builder 어노테이션과는 달리, SuperBuilder는 AccessLevel 설정이 안된다.
그리고 SuperBuilder를 제거하는 것은, Mapper 클래스의 엔티티 - 도메인간 변환을 할 수 없도록 만들기 때문에 불가능하다.
도메인 모델의 생성 구조를 변경해야 한다!</p>
<blockquote>
<p>🤔 모델 생성을 create 메서드의 접근으로만 강제할 순 없을까?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/2d903ebe-26f9-4f77-8782-d7a9871b4596/image.png" alt=""></p>
<p>고민했던 내용을 의식의 흐름대로 정리해보면...</p>
<ol>
<li>AllArgsConstructor를 Package 접근 레벨로 쓰는 것은 어떨까?
→ Mapper와 Entity가 다른 패키지에 있어서 접근 불가.
도메인-엔티티 간 매핑은 정적 팩토리 메서드에서만 가능하도록 해야 한다.</li>
</ol>
<hr>
<ol start="2">
<li>그냥 SuperBuilder 대신 Builder를 클래스 단위로 쓰는 것은?
→ 부모 클래스 필드에 접근 불가능하다.
왜냐면 Builder의 특성 때문.</li>
</ol>
<p>Builder 어노테이션의 경우 롬복이 컴파일 시점에 단일 클래스에 대해서만 정적 빌더 메서드를 생성한다.</p>
<pre><code class="language-java">// Parent
public class Parent {
    private String name;

    public static ParentBuilder builder() {
        return new ParentBuilder();
    }

    public static class ParentBuilder {
        private String name;

        public ParentBuilder name(String name) {
            this.name = name;
            return this;
        }

        public Parent build() {
            return new Parent(name);
        }
    }
}

// Child
public class Child extends Parent {
    private int age;

    public static ChildBuilder builder() {
        return new ChildBuilder();
    }

    public static class ChildBuilder {
        private int age;

        public ChildBuilder age(int age) {
            this.age = age;
            return this;
        }

        public Child build() {
            return new Child(age);
        }
    }
}</code></pre>
<p>반면에 SuperBuilder는 상속을 고려하여 빌더를 생성한다.</p>
<pre><code class="language-java">import lombok.Getter;

public class Parent {
    private String name;

    protected Parent(ParentBuilder&lt;?, ?&gt; b) {
        this.name = b.name;
    }

    public static ParentBuilder&lt;?, ?&gt; builder() {
        return new ParentBuilderImpl();
    }

    public abstract static class ParentBuilder&lt;C extends Parent, B extends ParentBuilder&lt;C, B&gt;&gt; {
        private String name;

        public B name(String name) {
            this.name = name;
            return self();
        }
        protected abstract B self();
        public abstract C build();
    }

    private static final class ParentBuilderImpl extends ParentBuilder&lt;Parent, ParentBuilderImpl&gt; {
        protected ParentBuilderImpl self() {
            return this;
        }

        public Parent build() {
            return new Parent(this);
        }
    }
}

public class Child extends Parent {
    private int age;

    protected Child(ChildBuilder&lt;?, ?&gt; b) {
        super(b); // 부모 생성자를 호출하여 부모 필드를 초기화
        this.age = b.age;
    }

    public static ChildBuilder&lt;?, ?&gt; builder() {
        return new ChildBuilderImpl();
    }

    public abstract static class ChildBuilder&lt;C extends Child, B extends ChildBuilder&lt;C, B&gt;&gt;
            extends Parent.ParentBuilder&lt;C, B&gt; { 
        private int age;

        public B age(int age) {
            this.age = age;
            return self();
        }
    }

    private static final class ChildBuilderImpl extends ChildBuilder&lt;Child, ChildBuilderImpl&gt; {
        protected ChildBuilderImpl self() {
            return this;
        }

        public Child build() {
            return new Child(this);
        }
    }
}
</code></pre>
<p>이 생성 과정을 보면 Lombok이 왜 SuperBuilder에 대한 AccessLevel을 지원하지 않는지 알 수 있다.</p>
<p>ChildBuilder의 선언 부분을 보면 ParentBuilder를 상속하고 있다.
이 상속 덕분에 ChildBuilder는 ParentBuilder의 모든 기능을 물려받는다.
즉, ChildBuilder의 인스턴스는 name() 메서드를 가지고 있다.</p>
<p>ParentBuilder에 정의된 name() 메서드를 보면, 접근 제어자가 public인 것을 알 수 있다.
ChildBuilder는 ParentBuilder를 상속했기 때문에 public 접근 제어자를 가진 name() 메서드를 그대로 물려받는다.
자바의 규칙에 따라, 상속받은 메서드를 오버라이딩할 때 접근 제어자를 부모보다 더 제한적인 protected나 private으로 바꿀 수 없다.</p>
<p>따라서 Child.builder().name(...)을 호출할 때 사용되는 name() 메서드는 ParentBuilder로부터 물려받은 public 메서드이며, 이 규칙 때문에 public으로 유지될 수밖에 없는 것이다.</p>
<p>이렇게 복잡하게 구현되는 이유는 메서드 체이닝 때문인데,</p>
<pre><code class="language-java">Child child = Child.builder()
                   .name(&quot;홍길동&quot;)  // 1. ParentBuilder의 메서드
                   .age(20)       // 2. ChildBuilder의 메서드
                   .build();</code></pre>
<p>.name(&quot;홍길동&quot;)을 호출하면 ParentBuilder에 정의된 name() 메서드가 실행된다. 이 메서드는 마지막에 self()를 반환한다.</p>
<p>ChildBuilder에서 self()는 ChildBuilder 자기 자신(this)을 반환하도록 구현되어 있다.</p>
<p>결과적으로 .name(&quot;홍길동&quot;)의 반환 값은 ParentBuilder가 아닌 ChildBuilder가 된다.</p>
<p>따라서 바로 이어서 ChildBuilder에만 있는 .age(20) 메서드를 호출할 수 있다.</p>
<p>롬복이 이러한 이유로 AccessLevel을 막아두었을 것으로 예측(?)한다.</p>
<hr>
<ol start="3">
<li>그럼 생성자를 직접 만들어서 Builder를 붙일까..?<pre><code class="language-java">@Builder(builderMethodName = &quot;createMemberBuilder&quot;, access = AccessLevel.PRIVATE)
 private Member(LocalDateTime createdAt, LocalDateTime modifiedAt, LocalDateTime deletedAt, Long id, Provider provider, String providerId, MemberRole memberRole, MemberState memberState, boolean isAlarmOn, String firebaseToken, String refreshToken, LoveTypeId loveTypeId, float avoidanceRate, float anxietyRate, String nickname, String email, InviteCodeValue inviteCode, LocalDate startLoveDate) {
     super(createdAt, modifiedAt, deletedAt);
     this.id = id;
     this.nickname = nickname;
     this.email = email;
     this.startLoveDate = startLoveDate;
     // ...생략
 }</code></pre>
</li>
</ol>
<p>이렇게 하면 만약에 필드가 추가될 때 Mapper 클래스까지 까먹지 않고 수정해야되는 부담이 생기고, 만약 빠지면 필드는 존재하는데 도메인이나 엔티티에서 값이 누락되는 문제가 생긴다. 이걸 강제할 수는 없을까?</p>
<hr>
<ol start="4">
<li>그냥 상속을 제거하고, SuperBuilder 대신 Builder를 사용하자.</li>
</ol>
<blockquote>
<p>제거의 근거는 다음과 같다.</p>
</blockquote>
<ol>
<li>어차피 부모 필드가 세 가지 밖에 안된다.</li>
<li>다형성을 활용하는 것도 아니다.</li>
<li>createdAt, modifiedAt은 JPA Auditing의 기능이기 때문에 엔티티의 관점에서 분리하는 것은 의미가 있으나, 도메인 레벨에선 굳이 필요 없다.</li>
</ol>
<pre><code class="language-java">@Getter
@Builder(access = AccessLevel.PRIVATE)
public class Member { ... }</code></pre>
<p>상속 없애면 정적 팩토리 메서드를 이용해 요렇게 바꿀 수 있다.</p>
<pre><code class="language-java">public static Member from(
            Long id,
            String nickname,
            String email,
            LocalDate startLoveDate,
            LocalDateTime createdAt,
            LocalDateTime modifiedAt,
            LocalDateTime deletedAt
            // ...생략
    ) {
        return Member.builder()
                .id(id)
                .nickname(nickname)
                .email(email)
                .startLoveDate(startLoveDate)
                .createdAt(createdAt)
                .modifiedAt(modifiedAt)
                .deletedAt(deletedAt)
                // ...생략
                .build();
    }</code></pre>
<p>상속 제거로 반복이 늘어난다는 단점이 있지만,
이제 두 가지 조건을 만족한다.</p>
<ol>
<li>Builder의 AccessLevel이 PRIVATE이기 때문에 외부에서 접근할 수 없다.</li>
<li>Mapper 클래스에서는 from을 통해서만 접근할 수 있으며, 모델의 필드가 변경되면, from의 인자 하나라도 누락된 경우 컴파일 에러가 발생해 문제를 미연에 방지할 수 있다.</li>
</ol>
<hr>
<h2 id="결론">결론</h2>
<blockquote>
<p><strong>결론</strong>
빌더가 이렇게 복잡한 구조인지는 몰랐다..
모든걸 다 해결해주는 슈퍼슈퍼빌더가 나왔으면 좋겠다</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[말모] 헥사고날의 선택, 그리고 후기]]></title>
            <link>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-%ED%97%A5%EC%82%AC%EA%B3%A0%EB%82%A0%EC%9D%98-%EC%84%A0%ED%83%9D-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@_roundtable/%EB%A7%90%EB%AA%A8-%ED%97%A5%EC%82%AC%EA%B3%A0%EB%82%A0%EC%9D%98-%EC%84%A0%ED%83%9D-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Tue, 02 Sep 2025 09:03:30 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️⭐️⭐️
<strong>작성 날짜</strong> 2025.06.26</p>
<p>해당 포스팅은 헥사고날 아키텍처를 선택하고 구현하면서 고민했던 부분, 그리고 개발 이후 느낀점에 대한 내용을 담고 있습니다.
구현 과정의 경우 부족한 저의 개인적인 판단이 많이 들어가 있으니 더 나은 방식에 대한 조언을 댓글로 남겨주시면 경청하겠습니다!</p>
<h2 id="고민-내용">고민 내용</h2>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/411c7270-e1b8-4eb9-bacd-9311d09e3fc8/image.png" alt=""></p>
<p>말모는 AI챗봇 모모가 사용자의 애착유형을 바탕으로 연애 고민을 상담해주는 어플리케이션이다.</p>
<p>처음 기획에 대해 들었을 때 개발 구현을 어떻게 해야할지를 먼저 고민했다.</p>
<p>가장 중점적으로 고민했던 내용을 정리하자면,</p>
<ol>
<li>두 달 안에 기능이 구현되어야 한다.</li>
<li>기획은 개발 중 변경 가능성이 있다.</li>
<li>외부 API에 대한 의존이 크다.</li>
<li>개발 이후 확장 가능성이 있다.</li>
<li>백엔드를 혼자 개발한다.</li>
</ol>
<blockquote>
<p>🤔 이 프로젝트에 가장 잘 맞는 구조는 뭘까?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<h3 id="layered-architecture">Layered Architecture</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/d44be021-df40-41e4-8ff1-e50f5da5b696/image.png" alt=""></p>
<p>내가 가장 익숙하다고 생각하는 아키텍처이다.
보통 컨트롤러, 서비스, 레포지토리 계층으로 나누어 코드를 작성한다.
많이 사용해본 아키텍처라 익숙하기도 하고,
패키지를 나누는 것에도 간단, 명확해서 빠른 개발이 가능하다는 장점이 있다.</p>
<p>하지만 그렇다보니 코드가 비대해지는 경향이 있다.
그말은 즉 하나의 계층에서 너무 많은 책임을 지는 경향이 있고,
유지 보수 시 서로 많이 얽혀 있어 변경과 확장에 어려움이 있다.</p>
<p>또, 테스트를 할 때 일부만 Mocking하기 어려워 이것도 단점이라고 말할 수 있을 것이다. 백엔드를 혼자 개발하기 때문에 디테일한 테스트 코드가 필요한 시점에서, 이 단점도 해당 아키텍처를 선택하지 않은 이유로 작동했다.</p>
<h3 id="hexagonal-architecture">Hexagonal Architecture</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/f99924f1-ff45-4e1c-b4ad-880ef93bf13a/image.png" alt=""></p>
<p>헥사고날 아키텍처의 다른 이름은 Port &amp; Adpater이다.
도메인 외부와의 커넥터는 Port(Interface)로 이루어지고, Adapter가 해당 포트에 결합(구현)되는 형태이다.
이런 방식의 장점은 계층 외부가 변경되더라도, 어댑터만 갈아끼면 해결이며, 도메인은 변경하지 않아도 된다.
도메인을 변경하지 않아도 되니 다른 기능으로의 변경 전파에서도 자유롭다.</p>
<p>레이어드 아키텍처의 투머치 책임 문제를 해결하고,
나의 고민이었던 변경에 크게 휘둘리지 않는다는 점이 매력적으로 보여 헥사고날 아키텍처를 선택하게 되었다!
<em><del>(그리고 이 결정은 이후 살짝 후회하게 된다.)</del></em></p>
<hr>
<h2 id="적용해보기">적용해보기</h2>
<p><em>이 파트는 헥사고날 아키텍처의 구현 과정을 담고 있습니다.
조금 길어지니까 후기만 궁금하신 분들은 다음 파트로 넘기셔도 좋습니다!</em></p>
<p>헥사고날 아키텍처는 기본적으로 IN과 OUT으로 구분된다.
가장 코어에는 도메인이 존재하는데,
도메인으로 들어오는 흐름(ex. 사용자 요청 -&gt; 도메인)인 경우 IN
도메인에서 나가는 흐름(ex. 도메인 -&gt; 외부 API)인 경우 OUT이다.</p>
<p>이에 따른 핵심 원칙은 다음과 같다.</p>
<ul>
<li>의존성은 항상 외부에서 내부(도메인)로 향한다.</li>
<li>도메인은 인프라에 대해 전혀 몰라야 한다.</li>
<li>인프라(어댑터)가 도메인 포트를 구현해서 연결된다.</li>
</ul>
<p>그럼 이 원칙을 잘 지키면서 간단한 디데이 업데이트 기능을 구현해보자.
실제 말모에서 적용했던 코드이다.</p>
<p><em>편한 코드 이해를 위해 구현 순서가 아닌 의존 흐름의 방향으로 내용을 작성한 점 유의 부탁드립니다!</em></p>
<h3 id="구현-요구사항">구현 요구사항</h3>
<p>구현 요구사항은 다음과 같다.</p>
<ul>
<li>말모는 커플 앱으로, 다른 사용자와 연동이 가능하다.</li>
<li>사용자는 해당 기능으로 연애 시작일을 업데이트 할 수 있다.</li>
<li>사용자가 연동하지 않은 경우, 자신의 연애 시작일을 업데이트 한다.</li>
<li>사용자가 연동된 경우, 사용자의 연애 시작일을 업데이트하고, 동시에 커플의 연애 시작일을 업데이트한다.</li>
</ul>
<h3 id="controller">Controller</h3>
<p>adaptor/in/.../MemberController.java</p>
<pre><code class="language-java">@PatchMapping(&quot;/start-love-date&quot;)
public BaseResponse&lt;UpdateStartLoveDateUseCase.UpdateStartLoveDateResponse&gt; updateStartLoveDate(
            @AuthenticationPrincipal User user,
            @Valid @RequestBody UpdateStartLoveDateRequestDto requestDto
) {
        UpdateStartLoveDateUseCase.UpdateStartLoveDateCommand command = UpdateStartLoveDateUseCase.UpdateStartLoveDateCommand.builder()
                .memberId(Long.valueOf(user.getUsername()))
                .startLoveDate(requestDto.getStartLoveDate())
                .build();

        return BaseResponse.success(updateStartLoveDateUseCase.updateStartLoveDate(command));
}</code></pre>
<p>컨트롤러는 외부에서 들어오는 흐름이므로 IN Adapter에 해당한다.
그리고 서비스를 직접 호출하면 각 계층 간의 결합도가 올라가기 때문에, UseCase라는 인터페이스를 통해 접근한다.</p>
<h3 id="usecase">UseCase</h3>
<p>application/port/in/.../UpdateStartLoveDateUseCase.java</p>
<pre><code class="language-java">public interface UpdateStartLoveDateUseCase {

    UpdateStartLoveDateResponse updateStartLoveDate(UpdateStartLoveDateCommand command);

    @Data
    @Builder
    class UpdateStartLoveDateCommand {
        private Long memberId;
        private LocalDate startLoveDate;
    }

    @Data
    @Builder
    class UpdateStartLoveDateResponse {
        private LocalDate startLoveDate;
    }
}</code></pre>
<p>컨트롤러와 서비스를 연결해 줄 UseCase 계층이다.
여기서 Command는 컨트롤러가 UseCase에게 주입하는 POJO 클래스이다.
Command를 사용하면, 서비스의 행동은 컨트롤러가 받아온 값에 의해 결정되어야 하지만, 서비스는 컨트롤러(View)의 실제 구현에 의존하지 않도록 하기 위해 DTO인 Command를 사용하는 것이다.</p>
<h3 id="service">Service</h3>
<p>application/service/.../MemberCommandService.java</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class MemberCommandService implements UpdateStartLoveDateUseCase {

    @Override
    @CheckValidMember
    @Transactional
    public UpdateStartLoveDateResponse updateStartLoveDate(UpdateStartLoveDateCommand command) {
        Member member = memberQueryHelper.getMemberByIdOrThrow(MemberId.of(command.getMemberId()));
        LocalDate startLoveDate = command.getStartLoveDate();

        member.updateStartLoveDate(startLoveDate);
        Member savedMember = memberCommandHelper.saveMember(member);

        if (member.isCoupleLinked()) {
            coupleQueryHelper.getCoupleById(member.getCoupleId())
                    .ifPresent(couple -&gt; {
                        couple.updateStartLoveDate(startLoveDate);
                        coupleCommandHelper.saveCouple(couple);
                    });
        }

        return UpdateStartLoveDateResponse.builder()
                .startLoveDate(savedMember.getStartLoveDate())
                .build();
    }
 }</code></pre>
<p>어플리케이션 서비스는 구현 요구사항을 만족시키기 위해 Port들을 지휘하는 역할을 맡아야 한다.
나의 경우에는 대부분의 Port 의존을 Helper 클래스를 통해 이루어지도록 했는데, 그 이유는 후술하겠다.
구체적인 구현은 다른 클래스에서 이루어지기 때문에, 어떤 흐름으로 진행되는지 명확하게 확인할 수 있다.
도메인과 엔티티는 명확하게 분리되어 있어, 수정은 더티체킹이 아닌 조회, 모델 변경, 저장의 순서로 이루어져 있다.</p>
<p>또한, 캡슐화를 지키기 위해 모델 내 필드 변경은 모델 내부의 메서드 호출, 또는 애그리거트 루트 도메인 서비스를 통해 진행하도록 한다. (DDD)</p>
<p>여기서 Member의 ID를 넘길 때, 단순한 Long이 아닌 MemberId 클래스를 이용해 넘기는 것을 볼 수 있다.
Long 타입을 활용했다면, 단순하게 같은 Long 타입이 들어와도 컴파일 에러가 발생하지 않는다.
이렇게 하면 Member의 ID를 담아야 할 자리에 Couple의 ID를 담는 것과 같은 사고를 미연에 방지할 수 있다.
그리고 행위의 의미가 명확해진다는 장점도 있다.</p>
<h3 id="helper">Helper</h3>
<p>application/helper/.../MemberQueryHelper.java</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class MemberQueryHelper {

    private final LoadMemberPort loadMemberPort;

    public Member getMemberByIdOrThrow(MemberId memberId) {
        return loadMemberPort.loadMemberById(MemberId.of(memberId.getValue()))
                .orElseThrow(MemberNotFoundException::new);
    }
}</code></pre>
<p>application/helper/.../MemberCommandHelper.java</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class MemberCommandHelper {

    private final SaveMemberPort saveMemberPort;

    public Member saveMember(Member member) {
        return saveMemberPort.saveMember(member);
    }
}</code></pre>
<p>멤버의 조회와 저장을 위한 Helper 클래스이다.
조회 메서드가 너무 비대해지는 것을 막기 위해 CQRS를 통해 책임을 분리했다.
성능보다는 역할 분담과 서비스에서의 명확한 사용을 위해 CQRS를 사용하였다.</p>
<p>Helper 클래스를 사용하면 여러 장점이 있다.</p>
<ul>
<li><p>코드 재활용이 편하다.
Member 모델을 조회하는 것이지만, Couple 관련 로직에서도 이를 사용할 수도 있고 그외 다른 로직에서도 Helper 클래스만 주입하면 사용할 수 있다.</p>
</li>
<li><p>서비스 코드가 깔끔해진다.
서비스는 외부에 의한 복잡한 예외 처리에 의존하지 않고, 필요한 흐름에만 집중해서 코드를 작성할 수 있다.</p>
</li>
<li><p>예외를 Helper로 집중시킬 수 있다.
개발을 하다보면 Optional로 조회해서 orElseThrow 처리하는 경우가 많은데, 그럴 때 마다 예외를 던지는 것은 불필요한 반복이고, 이 예외를 수정하려면 모든 것을 수정해야하는 문제가 있었다.
Helper에서 예외 처리를 집중시키면, DB 조회 오류를 한 곳에서 처리할 수 있었다.</p>
</li>
</ul>
<blockquote>
<p>그럼 Optional이 필요한 경우는?
<em>서비스에서 Optional이 필요한 경우에는 Optional을 반환하는 메서드를 만들었다. 
메서드명을 보면 알겠지만, getMemberByIdOrThrow가 있고, getMemberById 처럼 예외 처리가 없다는 것을 함수명에 명시하여 분리하였다..!</em></p>
</blockquote>
<blockquote>
<p>다른 예외가 필요한 경우는?
<em>getMember의 경우 DB에 멤버가 없는 경우 모두 같은 예외를 터뜨리면 되지만, 다른 비즈니스 로직에 의해 다른 예외를 터뜨려야 하는 경우, 다른 Helper에서 이를 구현하거나, valid를 위한 메서드를 Helper에 구현하여 처리하였다.</em></p>
</blockquote>
<p>도메인 서비스에서 이루어지면 되는 거 아닌가...? 라고 생각했었는데,
도메인 서비스는 외부의 의존 없이 순수한 자바 코드로 이루어져야 한다.
여기선 사용하지 않았지만, 도메인 서비스는 도메인 모델의 생성에 다른 모델이 필요한 복잡한 경우나 모델 내의 필드로 확인할 수 있는 검증 조건과 같은 경우 사용한다.</p>
<h3 id="port">Port</h3>
<p>application/port/out/.../LoadMemberPort.java</p>
<pre><code class="language-java">public interface LoadMemberPort {
    Optional&lt;Member&gt; loadMemberById(MemberId memberId);
}</code></pre>
<p>도메인을 지나 이제 외부를 호출하는 방향으로 흘러가기 때문에 OUT port에 해당한다.
포트는 어댑터의 역할을 단순히 규정만 하고, 서비스에서 구체적인 구현을 알 필요가 없게 도와준다.
이제 어댑터는 memberId를 받으면, Optional에 조회한 Member를 담아 넘기는 역할을 가지면 된다.</p>
<h3 id="adapter">Adapter</h3>
<p>adaptor/out/persistence/adapter/MemberPersistenceAdapter.java</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class MemberPersistenceAdapter implements
        LoadMemberPort, SaveMemberPort, LoadPartnerPort, LoadInviteCodePort, LoadChatRoomMetadataPort {

    private final MemberRepository memberRepository;
    private final MemberMapper memberMapper;

    @Override
    public Optional&lt;Member&gt; loadMemberById(MemberId memberId) {
        return memberRepository.findById(memberId.getValue())
                .map(memberMapper::toDomain);
    }

    @Override
    public Member saveMember(Member member) {
        MemberEntity memberEntity = memberMapper.toEntity(member);
        MemberEntity savedEntity = memberRepository.save(memberEntity);
        return memberMapper.toDomain(savedEntity);
    }
}</code></pre>
<p>이제 어댑터를 붙여주자.
Persistence 계층의 어댑터는 Repository를 통해 DB와 관련된 모든 행위를 명령할 책임을 갖는다.
Repository에서 가져온 엔티티를 서비스 계층에서 사용할 수 있도록 도메인으로 변경한다.
이때, 도메인과 엔티티 간의 변환은 Mapper 클래스라는 도구를 이용한다.</p>
<h3 id="mapper">Mapper</h3>
<p>adaptor/out/persistence/mapper/MemberMapper.java</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class MemberMapper {

    public Member toDomain(MemberEntity entity) {
        return Member.from(
                entity.getId(),
                entity.getNickname(),
                entity.getEmail(),
                entity.getStartLoveDate(),
                // ...생략
        );
    }

    public MemberEntity toEntity(Member domain) {
        return MemberEntity.builder()
                .id(domain.getId())
                .email(domain.getEmail())
                .nickname(domain.getNickname())
                .startLoveDate(domain.getStartLoveDate())
                // ...생략
                .build();
    }
}</code></pre>
<p>Mapper 클래스는 단순히 엔티티 - 도메인 간 변환을 도와준다.
이를 어댑터에서 직접 처리하지 않고 책임 분리하여 깔끔한 코드를 작성할 수 있다.</p>
<h3 id="repository">Repository</h3>
<p>adaptor/out/persistence/repository/member/MemberRepository.java</p>
<pre><code class="language-java">public interface MemberRepository extends JpaRepository&lt;MemberEntity, Long&gt;, MemberRepositoryCustom {

    @Query(&quot;select m from MemberEntity m where m.id = ?1 and m.memberState != &#39;DELETED&#39;&quot;)
    Optional&lt;MemberEntity&gt; findById(Long memberId);
}</code></pre>
<p>대망의 마지막 코드인 Repository이다.
Repository는 Persistence Adapter의 명령을 바탕으로 DB에 직접적인 쿼리를 던지는 역할을 갖고 있다.
좀 복잡한 쿼리가 필요한 경우 RepositoryCustom 인터페이스를 통해 구현된 Querydsl 코드를 이용하였다.</p>
<p>실제 구현 순서는 서비스에 필요한 로직을 먼저 작성하고, Port와 UseCase를 작성하는 내부 -&gt; 외부 방식으로 개발하였다.</p>
<hr>
<h2 id="결론">결론</h2>
<h3 id="후회">후회</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/0aeec1ac-d9ce-42b4-97e1-a01cee7710d0/image.gif" alt=""></p>
<p>MVP 개발을 마치고 돌아보자면, 결론적으론 헥사고날 아키텍처를 사용한 것을 후회한다..ㅎㅎ</p>
<h4 id="개발-속도가-너무-느리다">개발 속도가 너무 느리다</h4>
<p>위의 개발 내용만 봐도 알겠지만, 간단한 기능 하나를 추가하는 것에도 파일이 10 - 12개 추가되는 것을 알 수 있다.
아마 레이어드로 개발했다면, 파일 수가 5개 정도 추가되었을 것이니 단순 파일 수만 생각해도 200% 이상...</p>
<p>물론 파일 수 == 코드 수는 아니다.
그러나 새로운 파일을 생성하고, 책임을 분리하기 위한 고민을 하고, 적합한 패키지를 찾아 코드를 작성하기 위해 소요된 시간을 무시하긴 어려울 것이다. (이후에는 조금 익숙해져서 빨라지긴 했지만...)</p>
<p>심지어 도메인과 엔티티를 명확하게 분리했어야 하기 때문에, JPA의 편리성 중 하나인 더티체킹과 Lazy Loading은 포기해야 한다.</p>
<p>두 달 안에 개발해야 하는 상황에서 이 구조의 사용은 너무 많은 시간을 잡아먹는다는 문제가 있었다.</p>
<h4 id="팀을-고려한-선택이었을까">팀을 고려한 선택이었을까?</h4>
<p>너무 빠른 시기에 확장성을 고려했으니, 오버엔지니어링이라는 표현이 적합한 것 같다.
구현부터 빠르게 진행해야 했고, 유지보수를 고려한 코드의 퀄리티는 이후 리팩토링을 통해서 해결할 수 있는 부분이었다.
백엔드 내부적으로 이쁜 코드를 만드는 것도 중요하지만, 빠르게 MVP가 나와야 하는 시점에서 프론트엔드 팀은 백엔드의 API 구현을 기다린다.
휴학생이라 시간이 많아서 다행히 시간에 맞출 수는 있었지만 내가 좀더 익숙한 구조를 통해 빨리 개발해서 API부터 내놓고 이후 남는 시간에 확장을 고려했다면 어땠을까?
아마 익숙한 기술을 선택하는 것이 팀을 위한 선택이었을지도 모르겠다.</p>
<hr>
<h3 id="하지만">하지만...</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/b8301b63-5bf5-4667-bd26-1ea71f4801f8/image.png" alt=""></p>
<p>그렇다고 후회만 하였는가? 그건 또 아니다.
배우는 것이 정말 많았던 소중한 기회였다.
만약 헥사고날을 선택하지 않았다면 장단점 조차 느끼지 못했을 것이다.</p>
<h4 id="첫째-명확한-책임">첫째, 명확한 책임</h4>
<p>헥사고날 아키텍처를 구현하면서 가장 고민이 많았던 부분이 책임에 대한 분리였다.
헥사고날을 처음 사용해보아서 개발하면서 GPT랑 참 많은 이야기를 나누었다.
이 코드는 어느 패키지에 넣으면 될까? 하는 방식으로...</p>
<p>예를 들어, 레이어드를 구현할 때는 사용하지 않았던 Mapper 클래스가 있다.
도메인 - 엔티티 간 변환 방법은 Mapper에서만 제시한다.
그리고 이 Mapper를 Persistence Adapter 계층에서만 사용하여 도메인 - 엔티티 간 변환 책임을 몰아주었다.
Adapter는 Repository 인터페이스를 호출한다.
그리고 DB에 직접적인 쿼리를 던지는 책임은 Repository 계층에 집중하였다.
그리고 Adapter는 Port를 통해 어플리케이션 서비스에서 사용한다.
도메인 모델에 대해 도메인 서비스와 도메인 모델 자체만이 변경할 수 있는 책임을 갖는다.
그리고 어플리케이션 서비스는 다양한 Port와 도메인 서비스(모델)를 이용해 서비스의 흐름을 지휘(orchestration)하는 책임을 갖는다.</p>
<p>헥사고날 아키텍처에서 각 계층의 외부에 해당하는 의존은 인터페이스로만 접근하기 때문에, 애초에 책임 분리를 강제하여 코드를 작성하게 된다.</p>
<p>이런 식으로 작성된 코드는 계층 간의 역전이나 얽힘 없이 코드의 재사용성도 높일 수 있었다.</p>
<p>책임이 명확하니, 디버깅도 빠르게 가능했다.
어떤 문제가 생겼을 때 문제의 원인을 빠르게 찾아갈 수 있었으며, 복잡하게 얽혀있지 않다보니 해당 클래스만 수정해주면 문제가 해결되었다.</p>
<p>후술할 장점들도 크게 보면 이 장점에 속할 정도로 명확한 책임 분리는 헥사고날의 큰 장점에 속하는 것 같다.</p>
<h4 id="둘째-장기적인-시간-단축">둘째, 장기적인 시간 단축</h4>
<p>명확하게 분리해두었기 때문에, 외부 API에 대한 변경이 이루어질 경우 초기 구현과는 달리 오히려 시간적 비용이 절감된다.
실제로 구현 이후에 사용자의 탈퇴 처리를 위한 요구 사항이 하나 추가되었는데, 서비스 코드는 한 줄 정도로 크게 수정이 없었으며 카카오와 애플 API에 대한 코드만 추가하는 방식으로 해결할 수 있었다.
아마 현재 사용 중인 Redis Stream을 확장하여 이후 Kafka나 RabbitMQ로 변경한다면 헥사고날 아키텍처는 빛을 발할 것이다.</p>
<h4 id="셋째-편리한-테스트-mocking">셋째, 편리한 테스트 Mocking</h4>
<p>책임에 따라 잘게 나누어져 있기 때문에,
외부 의존 클래스에 대해서만 콕 집어서 모킹할 수 있다는 장점이 있었다.
개발하면서 Stream을 통해 Consumer가 API 요청을 제대로 보내는지 확인하고 싶었는데, 테스트 코드로 확인하기 어려운 부분이었다. (레디스를 사용하여 컨슈머의 동작을 확인해야 했기 때문)
그래서 <code>@Profile(&quot;dev&quot;)</code>를 통해 테스트만을 위한 클래스를 새롭게 만들고, 개발 환경에서 API 요청에 대한 부분만 가짜 객체를 이용하는 방식으로 기능을 검증할 수도 있었다.</p>
<h4 id="넷째-개인적인-성장">넷째, 개인적인 성장</h4>
<p>어떤 것이 이쁜 코드인가?에 대한 판단은 주관적인 부분인 것 같다.
그러나 헥사고날 아키텍처를 통해 &#39;이쁜 코드를 작성하는 것&#39;에 대해 강제로 고민할 수 있었던 부분이 나를 성장시켰을 것이라고 생각한다!
특히 내가 Java라는 언어에서 매력을 느끼는 SOLID 원칙과, 스프링의 핵심 기술을 극대화시켜 내가 사용하는 기술이 어떤 것인지 돌아볼 수 있었다.
앞으로 레이어드, 클린 등 다른 아키텍처를 사용하더라도 깔끔하고, 이쁜 코드를 작성하는 것에 큰 도움이 될 것 같다.</p>
<blockquote>
<p><strong>결론</strong>
절대 헥사고날 아키텍처를 비추하는 것은 아니다.
헥사고날을 경험하면서 정말 많은 것을 배웠고 이후의 확장에 있어서 해당 구조가 큰 도움이 될 것으로 생각한다.
구조, 기술, 방법론 등 무엇이든 상황에 맞는 선택이 가장 중요함을 알 수 있었던 좋은 기회였다!</p>
</blockquote>
<p>개발 이후 뒤늦게 보게된 내용인데 공감하는 부분이 많아 남겨둡니다!</p>
<div class="video-container">
    <iframe width="640" height="360" src="https://www.youtube.com/embed/Y8gX49FGLtw?si=JgcPMo4k2S2X-7E3" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
  </div>


<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[말모] 거의 모든 서비스 로직이 사용자를 검증한다 (AOP 적용기)]]></title>
            <link>https://velog.io/@_roundtable/%EA%B1%B0%EC%9D%98-%EB%AA%A8%EB%93%A0-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%A1%9C%EC%A7%81%EC%9D%B4-%EC%82%AC%EC%9A%A9%EC%9E%90%EB%A5%BC-%EA%B2%80%EC%A6%9D%ED%95%9C%EB%8B%A4-AOP-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@_roundtable/%EA%B1%B0%EC%9D%98-%EB%AA%A8%EB%93%A0-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%A1%9C%EC%A7%81%EC%9D%B4-%EC%82%AC%EC%9A%A9%EC%9E%90%EB%A5%BC-%EA%B2%80%EC%A6%9D%ED%95%9C%EB%8B%A4-AOP-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Mon, 01 Sep 2025 09:23:42 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️
<strong>작성 날짜</strong> 2025.07.06</p>
<h2 id="고민-내용">고민 내용</h2>
<p>말모는 커플 전용 앱이지만, 커플 연동이 아직인 사용자도 제한된 기능으로 접근 가능한 서비스이다.</p>
<p>그래서 크게 API 접근 권한을 세 가지 정도로 나눌 수 있는데,</p>
<ol>
<li>사용자가 아니어도 접근 가능 (ex. 로그인, 약관 조회 등)</li>
<li>일반 사용자가 접근 가능 (ex. 내 정보 조회 등)</li>
<li>커플인 사용자만 접근 가능 (ex. 커플 상대 정보 조회 등)</li>
</ol>
<p>1번은 오픈된 API이기 때문에 로직 상 크게 상관이 없다.
2번, 3번의 경우 JWT 토큰을 통해 DB 정보를 직접 조회하고, 비즈니스 로직에 맞춰 해당 유저가 권한이 있는지 검사해야 한다.</p>
<p>스프링 시큐리티가 있지만, 필터에서 토큰의 유효성 검사만 진행하지 실제 사용자의 정보를 참고하지는 않는다.
예를 들어 사용자가 탈퇴한 경우에도 서비스 입장에서는 토큰을 만료시킬 방법이 없기 때문에, 해당 토큰을 그대로 사용하더라도 필터를 통과하게 된다.</p>
<p>물론 <code>UserDetailsService</code>를 커스텀화해서, 조회하고 검증하는 방법도 있지만 요청 경로에 따라 2번 3번을 분리해야 하며, 비즈니스 관심사를 필터 단에서 처리해 유지보수에 어려움이 생길 수 있었다.</p>
<p>그래서 서비스 계층에서 JWT에 담긴 사용자 정보를 조회하고, 상황에 맞춰 2번과 3번의 형태를 검증하는 코드를 작성해야 했다.
그런데 거의 모든 서비스 로직이 사용자를 검증하다보니 반복의 문제도 생기고, 코드 레벨의 가독성도 떨어진다는 문제가 발생했다!</p>
<blockquote>
<p>🤔 늘어나는 반복, 해결 방법은 없을까?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<h3 id="횡단-관심사">횡단 관심사</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/8f900514-1bea-4bbe-9a0f-312a0da63032/image.png" alt=""></p>
<blockquote>
<p>객체 지향 소프트웨어 개발에서 횡단 관심사 또는 크로스커팅 관심사(cross-cutting concerns)는 다른 관심사에 영향을 미치는 프로그램의 애스펙트이다. 이 관심사들은 디자인과 구현 면에서 시스템의 나머지 부분으로부터 깨끗이 분해되지 못하는 경우가 있을 수 있으며 분산(코드 중복)되거나 얽히는(시스템 간의 상당한 의존성 존재) 일이 일어날 수 있다.
(출처 : 위키백과)</p>
</blockquote>
<p>말이 좀 복잡하게 적혀있는데, 현재 계층 또는 모듈에서 구현된 관심사가, 다른 부분에서도 요구하는 부분이기 때문에 코드가 중복되고, 쉽게 떼어내기 힘든 관심사를 의미한다.
이러한 상황을 &#39;횡단 관심사&#39;라고 한다.
하나의 요청의 플로우를 종단 관심사라고 볼 때, 이렇게 중복되는 부분은 공통적으로 관심을 갖는 부분이므로, 이 종단 관심사를 관통하는 횡단의 관심사라고 판단할 수 있다.</p>
<p>나의 경우에는, 멤버를 요청 상황에 맞추어 검증하는 부분이 그렇다.
모두 서비스 계층에서 검증이 이루어지지만, 다른 도메인(모듈)의 서비스 코드에서도 반복되고 있다.
이로 인해 코드가 중복되어 유지 보수도 어렵고, 하나의 서비스 메서드에서 이루어지더라도 서비스 계층 간의 의존이 우려되는 상황이다.</p>
<h3 id="스프링이-횡단-관심사를-해결하는-방법">스프링이 횡단 관심사를 해결하는 방법</h3>
<p>AOP (Aspect-Oriented Programming)
기존 도메인의 관점으로 진행되었던 코드 개발 방식과는 달리, 관심사(Aspect)를 기준으로 프로그래밍하여 중복되는 부분을 분리해낼 수 있는 개발방법론이다.</p>
<p>특히 스프링의 특징 중에 하나인 어노테이션을 이용한다면, 더 깔끔한 코드를 만들 수 있다.</p>
<h3 id="스프링-aop-적용해보기">스프링 AOP 적용해보기</h3>
<p>우선 기본적으로 검증을 위한 코드를 작성한다.</p>
<pre><code class="language-java">@Override
public boolean isCoupleMember(Long memberId) {
    return queryFactory
            .selectOne()
            .from(coupleEntity)
            .where(coupleEntity.coupleMembers.any().memberEntityId.value.eq(memberId))
            .fetchFirst() != null;
    }</code></pre>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class MemberValidationQueryHelper {

    private final ValidateMemberPort validateMemberPort;

    public void isMemberCouple(MemberId memberId) {
        boolean coupleMember = validateMemberPort.isCoupleMember(memberId);

        if (!coupleMember) {
            throw new NotCoupleMemberException(&quot;커플 등록 전인 사용자입니다. 커플 등록 후 이용해주세요.&quot;);
        }
    }
}</code></pre>
<p>이제 &#39;커플인 사용자만 접근 가능해요&#39;라는 것을 표시하면서, 해당 검증 기능을 실행시켜줄 수 있는 어노테이션을 만들어준다.</p>
<pre><code class="language-java">@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckCoupleMember {
}</code></pre>
<p>이제 aspectj의 어노테이션을 이용하여, 코드의 실행 시점을 정해주었다. 
<code>@Before(&quot;@annotation(CheckCoupleMember)&quot;)</code>
해당 코드는
&#39;어노테이션인 CheckCoupleMember를 실행하기 이전에 아래의 코드를 실행하라&#39;
이런 의미를 담고 있다.</p>
<pre><code class="language-java">@Aspect
@Component
@RequiredArgsConstructor
public class CoupleMemberValidationAspect {

    private final MemberDomainValidationService mmemberValidationService;

    @Before(&quot;@annotation(CheckCoupleMember)&quot;)
    public void checkCoupleMember() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || !(authentication.getPrincipal() instanceof User user)) {
            throw new AuthenticationCredentialsNotFoundException(&quot;인증된 사용자를 찾을 수 없습니다.&quot;);
        }

        Long memberId = Long.valueOf(user.getUsername());
        mmemberValidationService.isMemberCouple(MemberId.of(memberId));
    }
}</code></pre>
<p>사용자의 정보가 필요했기 때문에, 스프링 시큐리티가 ContextHolder에 저장해둔 User 객체를 통해 JWT 토큰에 저장되어 있었던 사용자의 ID를 가져올 수 있었다.</p>
<p>커플이 아닌 사용자에 대한 코드도 동일한 방법으로 작성하였다.</p>
<pre><code class="language-java">@Override
@CheckCoupleMember
public PartnerMemberResponseDto getPartnerInfo(PartnerInfoCommand command) {
      //... 코드 생략
}</code></pre>
<p>이제 커플인 멤버만 접근할 수 있는 서비스 코드는 <code>@CheckCoupleMember</code> 어노테이션을 붙이면 서비스 코드의 실행 이전에 검증 코드를 먼저 실행하여 미리 예외를 던질 수 있다.</p>
<hr>
<h2 id="결론">결론</h2>
<blockquote>
<p><strong>결론</strong>
내가 하는 고민은 다 선배 개발자들이 한 고민...
OOP를 위한 선배님들의 해결책을 잘 흡수하자</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[YOLO로 카톡 캡쳐 이미지에서 메시지 추출하기]]></title>
            <link>https://velog.io/@_roundtable/YOLO%EB%A1%9C-%EC%B9%B4%ED%86%A1-%EC%BA%A1%EC%B3%90-%EC%9D%B4%EB%AF%B8%EC%A7%80%EC%97%90%EC%84%9C-%EB%A9%94%EC%8B%9C%EC%A7%80-%EC%B6%94%EC%B6%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@_roundtable/YOLO%EB%A1%9C-%EC%B9%B4%ED%86%A1-%EC%BA%A1%EC%B3%90-%EC%9D%B4%EB%AF%B8%EC%A7%80%EC%97%90%EC%84%9C-%EB%A9%94%EC%8B%9C%EC%A7%80-%EC%B6%94%EC%B6%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 16 Jun 2025 06:22:15 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️⭐️
<strong>작성 날짜</strong> 2025.06.16</p>
<h2 id="고민-내용">고민 내용</h2>
<p>프로젝트를 진행하던 중, 카톡과 같은 SNS의 채팅방 채팅 메시지 캡처 사진을 상대방과 나로 구분하고 각 내용을 추출해야 하는 요구사항이 있었다.</p>
<blockquote>
<p>🤔  서버 개발만 공부해온 내가 이미지 처리 문제를 해결할 수 있을까..?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<h3 id="디지털-영상-처리-수업으로-해결하기">디지털 영상 처리 수업으로 해결하기</h3>
<p>생각해보니까 작년 초 학교에서 &lt;디지털 영상 처리&gt;라는 수업을 수강한 적이 있었다. 그곳에서 배운 몇 가지 기술과, 해당 과목의 프로젝트에서 사용했던 pytesseract를 이용하면 글자 추출이 가능하지 않을까?</p>
<p>전략은 이렇다.</p>
<ol>
<li>가우시안 블러를 이용해 이미지 전처리</li>
<li>적절한 Threshold를 설정해 이진화</li>
<li>외곽선을 찾아 일정 기준을 넘는 것들만 수집</li>
<li>왼쪽이면 상대방, 오른쪽이면 내 메시지로 판단</li>
<li>테서랙트로 텍스트 추출</li>
</ol>
<pre><code class="language-python">img = cv2.imread(&#39;./samples/test.png&#39;)

# 회색조 변환 + 블러
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (3, 3), 0)

# 이진화
thresh = cv2.adaptiveThreshold(blur, 255,
    cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
    cv2.THRESH_BINARY_INV, 11, 2)
# show_img(thresh)

# 외곽선 찾기
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

# 말풍선 필터링
balloons = []
for cnt in contours:
    x, y, w, h = cv2.boundingRect(cnt)
    if 100 &lt; x &lt; 120 or 720 &lt; x + w &lt; 750 :
        if 50 &lt; w &lt; 1000 and 50 &lt; h :
            balloons.append((x, y, w, h))

# y 기준 정렬
balloons.sort(key=lambda b: b[1])

# 말풍선 추출 및 OCR
for (x, y, w, h) in balloons:
    roi = img[y:y+h, x:x+w]

    gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

    # Otsu threshold
    _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    resized = cv2.resize(binary, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)

    text = pytesseract.image_to_string(resized, lang=&#39;kor+eng&#39;, config=&#39;--psm 6&#39;)
    print(text.strip())

    # 결과 확인 시 활성화
    # show_img(resized)
    # cv2.imwrite(&#39;resized&#39;+str(x)+&#39;x&#39;+str(y)+&#39;y&#39;+str(w)+&#39;w&#39;+str(h)+&#39;.jpg&#39;, resized)</code></pre>
<h3 id="이미지-처리-테스트">이미지 처리 테스트</h3>
<p>이제 실제 카톡 이미지로 테스트해보자!</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/ec95837a-5eb9-40fe-9292-1b16dc9275e0/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/e4954894-ae9e-4eb3-943d-c768d53bfd26/image.png" alt=""></p>
<p>망했다.
일단 &#39;메시지&#39;가 무엇인지 정의하기도 어렵고,
다양한 카톡 테마와 인스타 dm 등등 여러 메시지를 대응하는 것에도 어려움이 있었던 것이 문제도 존재했다.</p>
<p>좀더 고도화된 기술이 필요하다...</p>
<ul>
<li>내가 원하는 객체를 인식할 수 있어야 한다.</li>
<li>특정 색상, 위치에 의존하지 않아야 한다.</li>
</ul>
<h3 id="yolo">YOLO</h3>
<p>해결책은 딥 러닝.
그중에서도 실시간성을 가져 빠르게 서빙 가능하고, 경량화된 YOLO를 선택하였다.
또한, &#39;나&#39;와 &#39;상대방&#39;의 메시지를 분류하기 위한 Segmentation이 가능하면서 가벼운 YOLOv8이 필요하다고 생각하였다.</p>
<h3 id="데이터-셋을-찾아보자">데이터 셋을 찾아보자</h3>
<p>문제는 데이터 셋!</p>
<p>구글에서 &#39;카톡 짤&#39;을 검색해서 하나하나 저장하고 Label Studio로 라벨링 하다 현타가 와서 구글링을 시작</p>
<p>&#39;Text Message Object Detection&#39;으로 검색을 하니 데이터셋이 하나 딱 나오더라</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/715d50a6-4c03-48f5-9d2c-7a3ababb932c/image.png" alt=""></p>
<p><a href="https://universe.roboflow.com/text-message/text-message">https://universe.roboflow.com/text-message/text-message</a></p>
<p>요런 걸루
무려 라벨링된 데이터 2천 장!</p>
<p>근데 내가 원하는 건 상대의 톡과 내 톡이 구분되어야 한다.
이건 할 수 없이 수작업으로 진행해주었다.</p>
<pre><code class="language-bash">label-studio-converter import yolo \
  -i ./test \
  -o test_annotations.json \      
  --image-root-url &quot;/data/local-files/?d=test/images&quot; \                                  
  --to-name &quot;image&quot; \
  --from-name &quot;label&quot;</code></pre>
<p>Storage에 먼저 사진 업로드 (동기화)</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/f1b57f1d-b25b-488c-a411-72458c1f9a15/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/b71ef3e7-4f4c-439d-9898-53328133ec4b/image.png" alt=""></p>
<p>모든 데이터셋을 전부 수정하는 것은 시간이 부족해 238장만 수정했다.</p>
<p>Label Studio의 조금 마음에 안드는 점은 나는 내가 선택한 파일만 export 하고 싶은데 그냥 프로젝트 전체 다 받아야 한다는 점이다...</p>
<p>여튼 json으로 export 하면 모든 데이터셋이 나온다.
python 코드를 짜서 내가 수정한 id 값만 걸러주었다.</p>
<p><strong>filter_data.py</strong></p>
<pre><code class="language-python">import json

def filter_labeled_data(input_file, output_file, target_ids):
    &quot;&quot;&quot;
    Label Studio export 파일에서 특정 ID들만 필터링하여 새 파일로 저장
    &quot;&quot;&quot;
    with open(input_file, &#39;r&#39;, encoding=&#39;utf-8&#39;) as f:
        data = json.load(f)

    # ID가 target_ids 범위에 있는 항목들만 필터링
    filtered_data = []

    for item in data:
        item_id = item.get(&#39;id&#39;)
        if item_id and is_target_id(item_id, target_ids):
            filtered_data.append(item)

    # 필터링된 데이터를 새 파일로 저장
    with open(output_file, &#39;w&#39;, encoding=&#39;utf-8&#39;) as f:
        json.dump(filtered_data, f, ensure_ascii=False, indent=2)

    print(f&quot;Original: {len(data)} items&quot;)
    print(f&quot;Filtered: {len(filtered_data)} items&quot;)
    print(f&quot;Saved to: {output_file}&quot;)

def is_target_id(item_id, target_ranges):
    &quot;&quot;&quot;ID가 지정된 범위에 있는지 확인&quot;&quot;&quot;
    for start, end in target_ranges:
        if start &lt;= item_id &lt;= end:
            return True
    return False

# 사용 예시
if __name__ == &quot;__main__&quot;:
    input_file = &quot;project-1-at-2025-06-16-08-01-6fcb70fe.json&quot;
    output_file = &quot;filtered_labeled_data.json&quot;

    # 작업 완료한 ID 범위: 1~51, 8604~8823
    target_id_ranges = [
        (1, 51),
        (8604, 8823)
    ]

    filter_labeled_data(input_file, output_file, target_id_ranges)</code></pre>
<p>그 다음에는 json을 YOLO 형식의 txt 파일로 바꿔준다.
이미지 파일 명과 동일한 이름으로 맞춰준다.</p>
<p>마찬가지로 python 코드로 해결하였다.</p>
<p><strong>convert_to_yolo.py</strong></p>
<pre><code class="language-python">import json
import os
from pathlib import Path
from urllib.parse import urlparse, parse_qs

def convert_labelstudio_to_yolo(json_file, output_dir=&quot;yolo_labels&quot;):
    &quot;&quot;&quot;
    Label Studio export JSON을 YOLO 형식으로 변환
    &quot;&quot;&quot;
    # 출력 디렉토리 생성
    os.makedirs(output_dir, exist_ok=True)

    # 클래스 매핑 (Label Studio 라벨 -&gt; YOLO 클래스 ID)
    class_mapping = {
        &quot;MyMessage&quot;: 0,
        &quot;OtherMessage&quot;: 1
    }

    # JSON 파일 로드
    with open(json_file, &#39;r&#39;, encoding=&#39;utf-8&#39;) as f:
        data = json.load(f)

    converted_count = 0

    for item in data:
        # data.image 경로에서 실제 파일명 추출
        image_path = item.get(&#39;data&#39;, {}).get(&#39;image&#39;, &#39;&#39;)
        if not image_path:
            # fallback으로 file_upload 사용
            image_path = item.get(&#39;file_upload&#39;, &#39;&#39;)

        if not image_path:
            continue

        # URL 파라미터에서 파일명 추출
        filename = extract_filename_from_path(image_path)
        if not filename:
            continue

        # 확장자를 .txt로 변경
        txt_filename = Path(filename).stem + &#39;.txt&#39;
        txt_path = os.path.join(output_dir, txt_filename)

        # 어노테이션 정보 추출
        annotations = item.get(&#39;annotations&#39;, [])
        if not annotations:
            continue

        yolo_lines = []

        for annotation in annotations:
            results = annotation.get(&#39;result&#39;, [])

            for result in results:
                if result.get(&#39;type&#39;) != &#39;rectanglelabels&#39;:
                    continue

                # 원본 이미지 크기
                original_width = result.get(&#39;original_width&#39;)
                original_height = result.get(&#39;original_height&#39;)

                if not original_width or not original_height:
                    continue

                # 라벨 정보
                value = result.get(&#39;value&#39;, {})
                rectanglelabels = value.get(&#39;rectanglelabels&#39;, [])

                if not rectanglelabels:
                    continue

                label = rectanglelabels[0]
                if label not in class_mapping:
                    continue

                class_id = class_mapping[label]

                # Label Studio 좌표 (percentage) -&gt; YOLO 좌표 변환
                x_percent = value.get(&#39;x&#39;, 0)
                y_percent = value.get(&#39;y&#39;, 0)
                width_percent = value.get(&#39;width&#39;, 0)
                height_percent = value.get(&#39;height&#39;, 0)

                # YOLO 형식: center_x, center_y, width, height (모두 0-1 범위)
                center_x = (x_percent + width_percent / 2) / 100
                center_y = (y_percent + height_percent / 2) / 100
                width_norm = width_percent / 100
                height_norm = height_percent / 100

                # YOLO 라인 생성
                yolo_line = f&quot;{class_id} {center_x:.6f} {center_y:.6f} {width_norm:.6f} {height_norm:.6f}&quot;
                yolo_lines.append(yolo_line)

        # YOLO 텍스트 파일 저장
        if yolo_lines:
            with open(txt_path, &#39;w&#39;, encoding=&#39;utf-8&#39;) as f:
                f.write(&#39;\n&#39;.join(yolo_lines))
            converted_count += 1
            print(f&quot;Converted: {filename} -&gt; {txt_filename}&quot;)

    # 클래스 정보 파일 생성
    classes_path = os.path.join(output_dir, &#39;classes.txt&#39;)
    with open(classes_path, &#39;w&#39;, encoding=&#39;utf-8&#39;) as f:
        for label, class_id in sorted(class_mapping.items(), key=lambda x: x[1]):
            f.write(f&quot;{label}\n&quot;)

    print(f&quot;\n변환 완료!&quot;)
    print(f&quot;총 {converted_count}개 파일 변환됨&quot;)
    print(f&quot;출력 디렉토리: {output_dir}&quot;)
    print(f&quot;클래스 정보: {classes_path}&quot;)

def extract_filename_from_path(image_path):
    &quot;&quot;&quot;
    이미지 경로에서 실제 파일명 추출
    &quot;&quot;&quot;
    if not image_path:
        return None

    # URL 파라미터가 있는 경우 처리
    if &#39;?&#39; in image_path:
        # URL 파싱
        parsed_url = urlparse(image_path)
        query_params = parse_qs(parsed_url.query)

        # &#39;d&#39; 파라미터에서 파일 경로 추출
        if &#39;d&#39; in query_params:
            file_path = query_params[&#39;d&#39;][0]
            # 파일 경로에서 마지막 파일명만 추출
            filename = os.path.basename(file_path)
            return filename

    # 일반적인 경우: 경로에서 파일명 추출
    filename = os.path.basename(image_path)
    return filename

if __name__ == &quot;__main__&quot;:
    # 변환 실행
    convert_labelstudio_to_yolo(&quot;filtered_labeled_data.json&quot;, &quot;yolo_labels&quot;)
</code></pre>
<p>마지막으로, 사진 데이터가 학습을 위해 정확히 구성되어있는지 체크하는 데이터 정합성 검증 코드가 필요하다.
사진과 YOLO 파일이 이름이 맞지 않거나, 누락된 파일이 있거나 하는 문제를 찾아내야 한다.</p>
<p><strong>verify_yolo_conversion.py</strong></p>
<pre><code class="language-python">import os
import json
import shutil
from pathlib import Path
from urllib.parse import urlparse, parse_qs

def copy_matching_images(yolo_dir=&quot;yolo_labels&quot;, images_dir=&quot;test/images&quot;, output_dir=&quot;yolo_images&quot;):
    &quot;&quot;&quot;
    YOLO 라벨 파일과 일치하는 이미지 파일들을 복사
    &quot;&quot;&quot;
    print(&quot;=== 이미지 파일 복사 시작 ===\n&quot;)

    # 출력 디렉토리 생성
    os.makedirs(output_dir, exist_ok=True)

    # YOLO 라벨 파일 목록 가져오기 (classes.txt 제외)
    if not os.path.exists(yolo_dir):
        print(f&quot;❌ YOLO 디렉토리가 존재하지 않습니다: {yolo_dir}&quot;)
        return

    txt_files = [f for f in os.listdir(yolo_dir) if f.endswith(&#39;.txt&#39;) and f != &#39;classes.txt&#39;]
    print(f&quot;YOLO 라벨 파일 수: {len(txt_files)}&quot;)

    # 이미지 디렉토리 확인
    if not os.path.exists(images_dir):
        print(f&quot;❌ 이미지 디렉토리가 존재하지 않습니다: {images_dir}&quot;)
        return

    copied_count = 0
    missing_count = 0

    for txt_file in txt_files:
        # txt 파일명에서 확장자 제거
        base_name = Path(txt_file).stem

        # 이미지 파일 찾기 (jpg 우선, 없으면 png)
        image_filename = None
        source_path = None

        # JPG 파일 먼저 확인
        jpg_filename = base_name + &#39;.jpg&#39;
        jpg_path = os.path.join(images_dir, jpg_filename)

        if os.path.exists(jpg_path):
            image_filename = jpg_filename
            source_path = jpg_path
        else:
            # PNG 파일 확인
            png_filename = base_name + &#39;.png&#39;
            png_path = os.path.join(images_dir, png_filename)

            if os.path.exists(png_path):
                image_filename = png_filename
                source_path = png_path

        if source_path:
            # 대상 경로
            target_path = os.path.join(output_dir, image_filename)

            # 파일 복사
            shutil.copy2(source_path, target_path)
            copied_count += 1
            print(f&quot;✅ 복사됨: {image_filename}&quot;)
        else:
            missing_count += 1
            print(f&quot;⚠️  누락된 이미지: {base_name}.jpg 또는 {base_name}.png&quot;)

    print(f&quot;\n=== 복사 완료 ===&quot;)
    print(f&quot;복사된 이미지: {copied_count}개&quot;)
    print(f&quot;누락된 이미지: {missing_count}개&quot;)
    print(f&quot;출력 디렉토리: {output_dir}&quot;)

def verify_yolo_conversion(json_file, yolo_dir=&quot;yolo_labels&quot;):
    &quot;&quot;&quot;
    YOLO 변환 결과 검증
    &quot;&quot;&quot;
    # JSON 파일 로드
    with open(json_file, &#39;r&#39;, encoding=&#39;utf-8&#39;) as f:
        data = json.load(f)

    print(&quot;=== YOLO 변환 결과 검증 ===\n&quot;)

    # 통계 정보
    total_images = len(data)
    converted_files = 0
    total_annotations = 0
    class_counts = {&quot;MyMessage&quot;: 0, &quot;OtherMessage&quot;: 0}

    for item in data:
        # data.image 경로에서 실제 파일명 추출
        image_path = item.get(&#39;data&#39;, {}).get(&#39;image&#39;, &#39;&#39;)
        if not image_path:
            # fallback으로 file_upload 사용
            image_path = item.get(&#39;file_upload&#39;, &#39;&#39;)

        if not image_path:
            continue

        # URL 파라미터에서 파일명 추출
        filename = extract_filename_from_path(image_path)
        if not filename:
            continue

        txt_filename = Path(filename).stem + &#39;.txt&#39;
        txt_path = os.path.join(yolo_dir, txt_filename)

        # YOLO 파일 존재 확인
        if os.path.exists(txt_path):
            converted_files += 1

            # 라인 수 카운트
            with open(txt_path, &#39;r&#39;, encoding=&#39;utf-8&#39;) as f:
                lines = f.readlines()
                line_count = len([line for line in lines if line.strip()])
                total_annotations += line_count

            # 원본 JSON에서 클래스별 카운트
            annotations = item.get(&#39;annotations&#39;, [])
            for annotation in annotations:
                results = annotation.get(&#39;result&#39;, [])
                for result in results:
                    if result.get(&#39;type&#39;) == &#39;rectanglelabels&#39;:
                        rectanglelabels = result.get(&#39;value&#39;, {}).get(&#39;rectanglelabels&#39;, [])
                        for label in rectanglelabels:
                            if label in class_counts:
                                class_counts[label] += 1
        else:
            print(f&quot;⚠️  누락된 파일: {txt_filename} (원본: {filename})&quot;)

    print(f&quot;총 이미지 수: {total_images}&quot;)
    print(f&quot;변환된 파일 수: {converted_files}&quot;)
    print(f&quot;총 어노테이션 수: {total_annotations}&quot;)
    print(f&quot;MyMessage: {class_counts[&#39;MyMessage&#39;]}개&quot;)
    print(f&quot;OtherMessage: {class_counts[&#39;OtherMessage&#39;]}개&quot;)

    # 샘플 파일 내용 출력
    print(&quot;\n=== 샘플 YOLO 파일 내용 ===&quot;)
    yolo_files = [f for f in os.listdir(yolo_dir) if f.endswith(&#39;.txt&#39;) and f != &#39;classes.txt&#39;]
    if yolo_files:
        sample_file = yolo_files[0]
        sample_path = os.path.join(yolo_dir, sample_file)
        print(f&quot;\n파일: {sample_file}&quot;)
        with open(sample_path, &#39;r&#39;, encoding=&#39;utf-8&#39;) as f:
            content = f.read().strip()
            print(content[:200] + &quot;...&quot; if len(content) &gt; 200 else content)

def extract_filename_from_path(image_path):
    &quot;&quot;&quot;
    이미지 경로에서 실제 파일명 추출
    &quot;&quot;&quot;
    if not image_path:
        return None

    # URL 파라미터가 있는 경우 처리
    if &#39;?&#39; in image_path:
        # URL 파싱
        parsed_url = urlparse(image_path)
        query_params = parse_qs(parsed_url.query)

        # &#39;d&#39; 파라미터에서 파일 경로 추출
        if &#39;d&#39; in query_params:
            file_path = query_params[&#39;d&#39;][0]
            # 파일 경로에서 마지막 파일명만 추출
            filename = os.path.basename(file_path)
            return filename

    # 일반적인 경우: 경로에서 파일명 추출
    filename = os.path.basename(image_path)
    return filename

if __name__ == &quot;__main__&quot;:
    verify_yolo_conversion(&quot;filtered_labeled_data.json&quot;, &quot;yolo_labels&quot;)

    # 이미지 파일 복사 실행
    copy_matching_images(&quot;yolo_labels&quot;, &quot;test/images&quot;, &quot;yolo_images&quot;)
</code></pre>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/9358b10d-5b79-47a8-9a83-8c31e8528efd/image.png" alt=""></p>
<p>이제 하나의 폴더에 요런 구조로 정리해둔다.</p>
<p>이제 학습만 시키면 끝</p>
<h3 id="yolo-학습시키기">YOLO 학습시키기</h3>
<p><strong>train_yolo.py</strong></p>
<pre><code class="language-python">
from ultralytics import YOLO
import torch
import os

def verify_label_format():
    &quot;&quot;&quot;
    라벨 파일 형식 검증
    &quot;&quot;&quot;
    print(&quot;=== 라벨 형식 검증 ===&quot;)

    labels_dir = &quot;yolo_test/labels&quot;

    if not os.path.exists(labels_dir):
        print(f&quot;❌ 라벨 디렉토리가 없습니다: {labels_dir}&quot;)
        return False

    label_files = [f for f in os.listdir(labels_dir) if f.endswith(&#39;.txt&#39;) and f != &#39;classes.txt&#39;]

    if not label_files:
        print(&quot;❌ 라벨 파일이 없습니다.&quot;)
        return False

    # 처음 5개 라벨 파일 검사
    valid_files = 0
    empty_files = 0

    for i, label_file in enumerate(label_files[:5]):
        sample_file = os.path.join(labels_dir, label_file)
        print(f&quot;\n검사 중: {label_file}&quot;)

        try:
            with open(sample_file, &#39;r&#39;) as f:
                lines = f.readlines()

            if not lines or all(not line.strip() for line in lines):
                print(f&quot;  ❌ 빈 파일입니다.&quot;)
                empty_files += 1
                continue

            print(f&quot;  라벨 라인 수: {len([l for l in lines if l.strip()])}&quot;)

            # 첫 번째 라벨 라인 확인
            for line_num, line in enumerate(lines):
                line = line.strip()
                if not line:
                    continue

                parts = line.split()
                print(f&quot;  라인 {line_num+1}: {line}&quot;)

                if len(parts) != 5:
                    print(f&quot;    ❌ 잘못된 형식! {len(parts)}개 컬럼 (5개여야 함)&quot;)
                    return False

                try:
                    class_id = int(parts[0])
                    x, y, w, h = map(float, parts[1:5])

                    print(f&quot;    클래스: {class_id}&quot;)
                    print(f&quot;    좌표: x={x:.3f}, y={y:.3f}, w={w:.3f}, h={h:.3f}&quot;)

                    # 좌표 범위 확인
                    if not (0 &lt;= x &lt;= 1 and 0 &lt;= y &lt;= 1 and 0 &lt;= w &lt;= 1 and 0 &lt;= h &lt;= 1):
                        print(f&quot;    ❌ 좌표가 0-1 범위를 벗어남!&quot;)
                        return False

                    # 클래스 ID 확인
                    if class_id not in [0, 1]:
                        print(f&quot;    ❌ 잘못된 클래스 ID: {class_id} (0 또는 1이어야 함)&quot;)
                        return False

                except ValueError as e:
                    print(f&quot;    ❌ 숫자 변환 오류: {e}&quot;)
                    return False

                break  # 첫 번째 라인만 확인

            valid_files += 1

        except Exception as e:
            print(f&quot;  ❌ 파일 읽기 오류: {e}&quot;)
            return False

    print(f&quot;\n검증 결과:&quot;)
    print(f&quot;  유효한 파일: {valid_files}&quot;)
    print(f&quot;  빈 파일: {empty_files}&quot;)

    if empty_files &gt; 0:
        print(&quot;⚠️ 빈 라벨 파일이 있습니다. 이는 정상적일 수 있습니다.&quot;)

    return valid_files &gt; 0

def create_dataset_yaml():
    &quot;&quot;&quot;
    YOLO 데이터셋 설정 파일 생성 (yolo_test 구조 사용)
    &quot;&quot;&quot;
    # 절대 경로 사용
    project_path = os.path.abspath(&quot;.&quot;)

    yaml_content = f&quot;&quot;&quot;# Chat Message Detection Dataset
path: {project_path}/yolo_test
train: images
val: images

# Classes
nc: 2
names: [&#39;MyMessage&#39;, &#39;OtherMessage&#39;]
&quot;&quot;&quot;

    with open(&#39;chat_data.yaml&#39;, &#39;w&#39;, encoding=&#39;utf-8&#39;) as f:
        f.write(yaml_content)
    print(&quot;✅ chat_data.yaml 파일이 생성되었습니다.&quot;)
    print(f&quot;데이터셋 경로: {project_path}/yolo_test&quot;)

def clean_cache():
    &quot;&quot;&quot;
    잘못된 캐시 파일 삭제
    &quot;&quot;&quot;
    print(&quot;=== 캐시 파일 정리 ===&quot;)

    cache_files = [
        &quot;yolo_test/images.cache&quot;,
        &quot;yolo_test/labels.cache&quot;,
        &quot;yolo_images.cache&quot;,
        &quot;yolo_labels.cache&quot;,
        &quot;runs/train/yolov8n_chat_debug/train.cache&quot;,
        &quot;runs/train/yolov8n_chat_debug/val.cache&quot;,
        &quot;runs/train/yolov8n_chat_stable/train.cache&quot;,
        &quot;runs/train/yolov8n_chat_stable/val.cache&quot;
    ]

    for cache_file in cache_files:
        if os.path.exists(cache_file):
            os.remove(cache_file)
            print(f&quot;✅ 삭제됨: {cache_file}&quot;)

    print(&quot;캐시 파일 정리 완료&quot;)

def train():
    model = YOLO(&quot;yolov8n.pt&quot;)

    if not os.path.exists(&quot;chat_data.yaml&quot;):
        print(&quot;❌ chat_data.yaml 파일이 없습니다.&quot;)
        return

    model.train(
        data=&quot;chat_data.yaml&quot;,     
        epochs=100,                # 에폭 복구
        imgsz=640,                 
        batch=4,                   # 배치 크기 증가 (표준 구조로 안정화)
        name=&quot;yolov8n_chat_final&quot;, # 새로운 실험명
        project=&quot;runs/train&quot;,      
        exist_ok=True,             # 기존 폴더 덮어쓰기 허용

        # 학습률 설정
        lr0=0.001,                
        lrf=0.01,                 

        # 데이터 증강 (정상 구조이므로 일부 활성화)
        augment=True,             
        degrees=5.0,              # 약간의 회전
        translate=0.1,            # 약간의 이동
        scale=0.3,                # 약간의 스케일
        shear=0.0,                # 전단 비활성화 (텍스트 특성상)
        perspective=0.0,          # 원근 비활성화
        flipud=0.0,               # 상하 뒤집기 비활성화
        fliplr=0.5,               # 좌우 뒤집기 활성화
        mosaic=0.5,               # 모자이크 감소
        mixup=0.0,                # 믹스업 비활성화
        copy_paste=0.0,           # 복사-붙여넣기 비활성화

        # 검증 설정
        val=True,                 
        split=0.2,                # 20% 검증 데이터
        patience=30,              # patience 증가

        # 저장 설정
        save=True,                
        save_period=25,           # 25 에폭마다 저장
        plots=True,               
        verbose=True,             

        # 하드웨어
        device=&#39;0&#39; if torch.cuda.is_available() else &#39;cpu&#39;,
        workers=2,                # 워커 수 증가

        # 안정성
        amp=False,                # Mixed Precision 비활성화 (안정성)
        seed=42,                  

        # 추가 안정성 옵션
        rect=False,               # 직사각형 훈련 비활성화
        cache=False,              # 캐시 비활성화 (첫 실행 시)
    )

def verify_data_structure():
    &quot;&quot;&quot;
    데이터 구조 검증 (yolo_test 구조)
    &quot;&quot;&quot;
    print(&quot;=== 데이터 구조 검증 ===&quot;)

    images_dir = &quot;yolo_test/images&quot;
    labels_dir = &quot;yolo_test/labels&quot;

    if not os.path.exists(images_dir):
        print(f&quot;❌ 이미지 디렉토리가 없습니다: {images_dir}&quot;)
        return False

    if not os.path.exists(labels_dir):
        print(f&quot;❌ 라벨 디렉토리가 없습니다: {labels_dir}&quot;)
        return False

    image_files = [f for f in os.listdir(images_dir) if f.lower().endswith((&#39;.jpg&#39;, &#39;.jpeg&#39;, &#39;.png&#39;))]
    label_files = [f for f in os.listdir(labels_dir) if f.endswith(&#39;.txt&#39;) and f != &#39;classes.txt&#39;]

    print(f&quot;이미지 파일 수: {len(image_files)}&quot;)
    print(f&quot;라벨 파일 수: {len(label_files)}&quot;)

    # 매칭 확인
    matched = 0
    mismatched = []

    for img_file in image_files:
        img_name = os.path.splitext(img_file)[0]
        label_file = img_name + &#39;.txt&#39;
        if label_file in label_files:
            matched += 1
        else:
            mismatched.append(img_file)

    print(f&quot;매칭된 파일 쌍: {matched}&quot;)

    if mismatched:
        print(f&quot;매칭되지 않은 이미지 파일들 (처음 5개):&quot;)
        for miss in mismatched[:5]:
            print(f&quot;  - {miss}&quot;)

    if matched &lt; 200:
        print(&quot;⚠️  학습에 충분한 데이터가 부족할 수 있습니다.&quot;)

    return matched &gt; 0

if __name__ == &quot;__main__&quot;:
    print(f&quot;CUDA 사용 가능: {torch.cuda.is_available()}&quot;)
    print(f&quot;PyTorch 버전: {torch.__version__}&quot;)

    # 0. 캐시 파일 정리
    clean_cache()

    # 1. 데이터 구조 검증
    if not verify_data_structure():
        print(&quot;❌ 데이터 구조에 문제가 있습니다.&quot;)
        exit(1)

    # 2. 라벨 형식 검증
    if not verify_label_format():
        print(&quot;❌ 라벨 형식에 문제가 있습니다.&quot;)
        exit(1)

    # 3. 데이터셋 설정 파일 생성
    create_dataset_yaml()

    # 4. 학습 시작
    print(&quot;\n=== 최종 학습 시작 ===&quot;)
    train()

    print(&quot;\n=== 학습 완료 ===&quot;)
    print(&quot;모델 결과는 &#39;runs/train/yolov8n_chat_final&#39; 디렉토리에서 확인할 수 있습니다.&quot;)</code></pre>
<ol>
<li>CUDA/PyTorch 버전 확인</li>
<li>캐시 파일 삭제 → 깨끗한 시작</li>
<li>데이터 구조 확인 (폴더/파일 매칭 확인)</li>
<li>라벨 형식 확인 (YOLO txt 형식, 값 검증)</li>
<li>데이터셋 yaml 생성</li>
<li>YOLO 학습 실행</li>
<li>완료 메시지 출력</li>
</ol>
<p>계속 코드를 수정하면서 테스트를 돌렸는데, 이게 데이터 검증 문제로 중간에 학습이 끊기는 문제가 있어서 검증 파이프라인을 추가했다.
이로써 테스트 데이터셋의 변경에도 자동화 형태로 문제 없이 진행할 수 있었다.</p>
<h4 id="학습-결과">학습 결과</h4>
<pre><code class="language-bash">100 epochs completed in 1.672 hours.
Optimizer stripped from runs/train/yolov8n_chat_final/weights/last.pt, 6.2MB
Optimizer stripped from runs/train/yolov8n_chat_final/weights/best.pt, 6.2MB

Validating runs/train/yolov8n_chat_final/weights/best.pt...
Ultralytics 8.3.155 🚀 Python-3.12.0 torch-2.7.1 CPU (Apple M1 Pro)
Model summary (fused): 72 layers, 3,006,038 parameters, 0 gradients, 8.1 GFLOPs
                 Class     Images  Instances      Box(P          R      mAP50  mAP50-95): 100
                   all        238       1221      0.995      0.999      0.995      0.885
             MyMessage        219        599      0.996          1      0.995      0.893
          OtherMessage        225        622      0.993      0.998      0.995      0.876
Speed: 0.5ms preprocess, 119.1ms inference, 0.0ms loss, 0.5ms postprocess per image</code></pre>
<p>238개의 데이터 셋, Mac M1 로컬로 1.7시간 돌려서
mAP50: 99.499% 
mAP50-95: 89.953% 
Precision: 99.907% 
Recall: 99.99% 
좋은 수치의 모델을 뽑아냈다! (라고 GPT가 평가해줬다)</p>
<p>이제 테스트를 돌려보자</p>
<h3 id="yolo-테스트">YOLO 테스트</h3>
<p><strong>detect_yolo.py</strong></p>
<pre><code class="language-python">from ultralytics import YOLO
import cv2
import os
from pathlib import Path

def run_detection():
    &quot;&quot;&quot;
    학습된 YOLO 모델로 채팅 메시지 탐지 실행
    &quot;&quot;&quot;
    print(&quot;=== 채팅 메시지 탐지 시작 ===&quot;)

    # 모델 로드
    model_path = &quot;runs/train/yolov8n_chat_final/weights/best.pt&quot;
    if not os.path.exists(model_path):
        print(f&quot;❌ 모델 파일을 찾을 수 없습니다: {model_path}&quot;)
        return

    model = YOLO(model_path)
    print(f&quot;✅ 모델 로드 완료: {model_path}&quot;)

    # 테스트 이미지 폴더 확인
    test_dir = &quot;test_images&quot;
    if not os.path.exists(test_dir):
        print(f&quot;❌ 테스트 이미지 폴더를 찾을 수 없습니다: {test_dir}&quot;)
        print(&quot;📁 test_images/ 폴더를 생성하고 테스트할 이미지를 넣어주세요.&quot;)
        return

    # 이미지 파일 확인
    image_files = [f for f in os.listdir(test_dir) if f.lower().endswith((&#39;.jpg&#39;, &#39;.jpeg&#39;, &#39;.png&#39;, &#39;.bmp&#39;))]
    if not image_files:
        print(f&quot;❌ {test_dir} 폴더에 이미지 파일이 없습니다.&quot;)
        return

    print(f&quot;📸 발견된 이미지 파일: {len(image_files)}개&quot;)

    # 탐지 실행
    results = model.predict(
        source=test_dir,
        imgsz=640,
        conf=0.25,              # 신뢰도 임계값 (0.25 = 25%)
        iou=0.45,               # IoU 임계값
        save=True,              # 결과 이미지 저장
        save_txt=True,          # 감지 결과 텍스트 저장
        save_conf=True,         # 신뢰도도 텍스트에 저장
        show_labels=True,       # 라벨 표시
        show_conf=True,         # 신뢰도 표시
        verbose=True            # 상세 출력
    )

    # 결과 분석
    total_detections = 0
    my_messages = 0
    other_messages = 0

    for result in results:
        if result.boxes is not None:
            detections = len(result.boxes)
            total_detections += detections

            # 클래스별 카운트
            for box in result.boxes:
                class_id = int(box.cls[0])
                if class_id == 0:  # MyMessage
                    my_messages += 1
                elif class_id == 1:  # OtherMessage
                    other_messages += 1

    print(&quot;\n=== 탐지 결과 요약 ===&quot;)
    print(f&quot;📊 총 탐지된 메시지: {total_detections}개&quot;)
    print(f&quot;💬 내 메시지: {my_messages}개&quot;)
    print(f&quot;👥 상대방 메시지: {other_messages}개&quot;)
    print(f&quot;📁 결과 위치: runs/detect/predict/&quot;)
    print(&quot;✅ 탐지가 완료되었습니다!&quot;)

def test_single_image(image_path):
    &quot;&quot;&quot;
    단일 이미지 테스트 함수
    &quot;&quot;&quot;
    if not os.path.exists(image_path):
        print(f&quot;❌ 이미지 파일을 찾을 수 없습니다: {image_path}&quot;)
        return

    model_path = &quot;runs/train/yolov8n_chat_final/weights/best.pt&quot;
    model = YOLO(model_path)

    results = model.predict(
        source=image_path,
        imgsz=640,
        conf=0.25,
        save=True,
        show=True,              # 결과를 화면에 표시
        verbose=True
    )

    # 결과 출력
    for result in results:
        if result.boxes is not None:
            print(f&quot;📸 {image_path}에서 {len(result.boxes)}개의 메시지를 탐지했습니다.&quot;)
            for i, box in enumerate(result.boxes):
                class_id = int(box.cls[0])
                confidence = float(box.conf[0])
                class_name = &quot;MyMessage&quot; if class_id == 0 else &quot;OtherMessage&quot;
                print(f&quot;  {i+1}. {class_name} (신뢰도: {confidence:.2f})&quot;)
        else:
            print(f&quot;📸 {image_path}에서 메시지를 탐지하지 못했습니다.&quot;)

if __name__ == &quot;__main__&quot;:
    # 폴더 전체 테스트
    run_detection()

    # 단일 이미지 테스트 예시 (필요시 주석 해제)
    # test_single_image(&quot;test_images/sample.jpg&quot;)</code></pre>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/8d0b4443-5fe1-4542-872f-3a27e12cf9bf/image.jpg" alt="">
인터넷에서 구한 카톡 싸움 짤을 돌려보자</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/80ca172f-df65-421f-b33f-fcedd41c390c/image.jpg" alt=""></p>
<p>메시지 영역을 정확하게 판단하고
상대 메시지와 내 메시지를 구별해낸다!</p>
<hr>
<h2 id="결론">결론</h2>
<p>딥러닝 관련 기술을 사용해본 것이 처음이라, 비효율적인 접근 방식일지도 모르고, 더 나은 방법이 있을 것이라고 생각한다. 이건 사용해보면서 더 알아가야겠다.</p>
<blockquote>
<p><strong>결론</strong>
그래도 문제 해결을 위해 내가 아는 모든 방법을 동원하였고, 결국 해결 방법을 찾아냈다는 사실이 뿌듯하다. 앞으로도 단순히 스프링 개발자가 아니라 &#39;비즈니스 문제를 기술로 해결하는 사람&#39;이 되고 싶다!</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[JsonTypeIdResolver 어노테이션과 Visitor 패턴으로 복잡한 다형성 처리하기]]></title>
            <link>https://velog.io/@_roundtable/JsonTypeIdResolver-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98%EA%B3%BC-Visitor-%ED%8C%A8%ED%84%B4%EC%9C%BC%EB%A1%9C-%EB%B3%B5%EC%9E%A1%ED%95%9C-%EB%8B%A4%ED%98%95%EC%84%B1-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@_roundtable/JsonTypeIdResolver-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98%EA%B3%BC-Visitor-%ED%8C%A8%ED%84%B4%EC%9C%BC%EB%A1%9C-%EB%B3%B5%EC%9E%A1%ED%95%9C-%EB%8B%A4%ED%98%95%EC%84%B1-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 07 Jun 2025 08:51:46 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️⭐️
<strong>작성 날짜</strong> 2025.06.07</p>
<h2 id="고민-내용">고민 내용</h2>
<p>아이쿠는 MSA 구조로 이루어져 있다. 
FCM으로 알람을 보내는 책임을 별개의 서버로 분리하였고, 다른 서버에서 알람을 보내야 할 일이 생기면 kafka로 publish 하였다.</p>
<p>알람 서버는 kafka로 publish된 알람 정보를 받아와 FCM으로 전달하는 책임을 갖고 있다.</p>
<p>그런데 문제는...
알람 서버로 전달되는 메시지 객체가 모두 다르기 때문에,
해당 메시지를 캐스팅하는 것에 문제가 있다는 것이다!</p>
<pre><code class="language-java">@Getter
@NoArgsConstructor
@AllArgsConstructor
public class AlarmMessage {

    private List&lt;String&gt; alarmReceiverTokens;
    private AlarmMessageType alarmMessageType;

}</code></pre>
<p>이러한 클래스를 상속받아 메시지 클래스를 만든다.</p>
<pre><code class="language-java">@Getter
@NoArgsConstructor(access = AccessLevel.PACKAGE)
public class ArrivalAlarmMessage extends AlarmMessage {

    private long memberId;
    private long scheduleId;
    private String scheduleName;
    private LocalDateTime arrivalTime;
    private AlarmMemberInfo arriveMemberInfo;

    public ArrivalAlarmMessage(List&lt;String&gt; alarmReceiverTokens, AlarmMessageType alarmMessageType, long memberId, long scheduleId, String scheduleName, LocalDateTime arrivalTime, AlarmMemberInfo arriveMemberInfo) {
        super(alarmReceiverTokens, alarmMessageType);
        this.memberId = memberId;
        this.scheduleId = scheduleId;
        this.scheduleName = scheduleName;
        this.arrivalTime = arrivalTime;
        this.arriveMemberInfo = arriveMemberInfo;
    }

}</code></pre>
<p>이런 식으로...!
하지만 문제는 각기 다른 클래스를 kafka로 전달한다고 해서 alarm 서버에서 부모 클래스인 AlarmMessage로 캐스팅하면 안된다.</p>
<p>왜냐하면, kafka로 publish할 때 ObjectMapper를 이용해 JSON으로 변환이 되고, 해당 JSON을 변환할 때 AlarmMessage에 매핑해버리면 자식 클래스에 추가된 필드는 버려지기 때문이다.
이렇게 되면 이후에 자식 클래스로 다운 캐스팅이 불가능하다.</p>
<p>필자는 이 이유로 무지막지한 코드를 작성하고 만다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class AlarmMessageMapper {

    private final ObjectMapper objectMapper;

    public AlarmMessage mapToAlarmMessage(ConsumerRecord&lt;String, String&gt; data) {
        Pattern pattern = Pattern.compile(&quot;\&quot;alarmMessageType\&quot;:\&quot;(.*?)\&quot;&quot;);
        Matcher matcher = pattern.matcher(data.value());

        AlarmMessageType alarmMessageType;

        if (matcher.find()) {
            String extractedValue = matcher.group(1); // 첫 번째 그룹
            System.out.println(&quot;Extracted Value: &quot; + extractedValue);

            alarmMessageType = AlarmMessageType.valueOf(extractedValue);
        } else {
            throw new JsonParseException();
        }

        Class&lt;?&gt; clazz = switch (alarmMessageType) {
            case SCHEDULE_ADD, SCHEDULE_ENTER, SCHEDULE_EXIT, SCHEDULE_UPDATE, SCHEDULE_OWNER, SCHEDULE_OPEN, SCHEDULE_AUTO_CLOSE -&gt;
                data.value().contains(&quot;sourceMember&quot;) ? ScheduleMemberAlarmMessage.class : ScheduleAlarmMessage.class;
            case MEMBER_ARRIVAL -&gt; ArrivalAlarmMessage.class;
            case SCHEDULE_MAP_CLOSE -&gt; ScheduleClosedMessage.class;
            case EMOJI -&gt; EmojiMessage.class;
            case ASK_RACING -&gt; AskRacingMessage.class;
            case RACING_AUTO_DELETED -&gt; RacingAutoDeletedMessage.class;
            case RACING_DENIED -&gt; RacingDeniedMessage.class;
            case RACING_TERM -&gt; RacingTermMessage.class;
            case RACING_START -&gt; RacingStartMessage.class;
            case TITLE_GRANTED -&gt; TitleGrantedMessage.class;
            case PAYMENT_SUCCESS -&gt; PaymentSuccessMessage.class;
            case PAYMENT_FAILED -&gt; PaymentFailedMessage.class;
            case POINT_ERROR -&gt; PointErrorMessage.class;
            default -&gt; null;
        };

        try {
            return (AlarmMessage) objectMapper.readValue(data.value(), clazz);
        } catch (JsonProcessingException e) {
            throw new MessagingException(FAIL_TO_SEND_MESSAGE);
        }

    }
}</code></pre>
<p>JSON에서 alarmMessageType을 꺼내서 해당하는 enum을 찾고, 거기에 맞는 클래스를 반환하는 switch-case 문을 완성했다.
여기서 끝나면 팩토리 클래스 역할을 할 수 있었지만...</p>
<pre><code class="language-java">@NoArgsConstructor
@Component
public class AlarmMessageConverter {

    // Firebase 알림 전달 용 메시지 생성
    public Map&lt;String, String&gt; getMessage(AlarmMessage alarmMessage) {
        return ReflectionJsonUtil.getAllFieldValuesRecursive(alarmMessage);
    }

    // DB 알림 저장 용
    public String getSimpleAlarmInfo(AlarmMessage alarmMessage) {
        switch (alarmMessage.getAlarmMessageType()) {
            case SCHEDULE_ADD -&gt; {
                return getScheduleStatement(alarmMessage) + &quot; 가 추가되었습니다.&quot;;
            }
            case SCHEDULE_ENTER -&gt; {
                return getScheduleStatement(alarmMessage) + &quot; 에 멤버가 입장하였습니다.&quot;;
            }
            case SCHEDULE_EXIT -&gt; {
                return getScheduleStatement(alarmMessage) + &quot; 에서 퇴장하였습니다.&quot;;
            }
            case SCHEDULE_UPDATE -&gt; {
                return getScheduleStatement(alarmMessage) + &quot; 이 업데이트 되었습니다.&quot;;
            }
            case SCHEDULE_OWNER -&gt; {
                return getScheduleStatement(alarmMessage) + &quot; 의 스케줄 장이 변경되었습니다.&quot;;
            }
            // ...이하 생략
      }

}</code></pre>
<p>FCM에 들어갈 메시지와 알람 저장용 메시지 컨버터가 추가하면서 문제가 생겼다.</p>
<ul>
<li>새로운 알람 메시지 타입이 생성되는 경우 두 클래스 모두를 수정해야 하며 (OCP 위반)</li>
<li>타입 캐스팅 남발로 코드가 더러워졌다.</li>
</ul>
<p>각 메시지 클래스가 필드도 다 다르고 각 역할이 다 다르긴 하지만,
새로운 메시지를 만드는 경우 너무 신경써야 하는 부분이 많다...!</p>
<blockquote>
<p>🤔 같은 부모지만 너무 다른 자식들... 다형성을 더 잘 활용할 방법은 없을까?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<h3 id="해결책-생각해보기">해결책 생각해보기</h3>
<h4 id="1-factory">1. Factory</h4>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class AlarmMessageMapper {

    private final ObjectMapper objectMapper;

    private final Map&lt;AlarmMessageType, Class&lt;? extends AlarmMessage&gt;&gt; typeClassMap = Map.of(
        AlarmMessageType.MEMBER_ARRIVAL, ArrivalAlarmMessage.class,
        AlarmMessageType.SCHEDULE_MAP_CLOSE, ScheduleClosedMessage.class,
        AlarmMessageType.EMOJI, EmojiMessage.class,
        AlarmMessageType.ASK_RACING, AskRacingMessage.class,
        AlarmMessageType.RACING_AUTO_DELETED, RacingAutoDeletedMessage.class,
        AlarmMessageType.RACING_DENIED, RacingDeniedMessage.class,
        AlarmMessageType.RACING_TERM, RacingTermMessage.class,
        AlarmMessageType.RACING_START, RacingStartMessage.class,
        AlarmMessageType.TITLE_GRANTED, TitleGrantedMessage.class,
        AlarmMessageType.PAYMENT_SUCCESS, PaymentSuccessMessage.class,
        AlarmMessageType.PAYMENT_FAILED, PaymentFailedMessage.class,
        AlarmMessageType.POINT_ERROR, PointErrorMessage.class
        // Schedule 계열은 따로 처리
    );

    public AlarmMessage mapToAlarmMessage(ConsumerRecord&lt;String, String&gt; data) {
        String json = data.value();
        AlarmMessageType type = extractType(json);

        try {
            if (isScheduleType(type)) {
                Class&lt;?&gt; clazz = json.contains(&quot;sourceMember&quot;) ?
                        ScheduleMemberAlarmMessage.class : ScheduleAlarmMessage.class;
                return (AlarmMessage) objectMapper.readValue(json, clazz);
            }

            Class&lt;?&gt; clazz = typeClassMap.get(type);
            if (clazz == null) {
                throw new MessagingException(FAIL_TO_SEND_MESSAGE);
            }

            return (AlarmMessage) objectMapper.readValue(json, clazz);

        } catch (JsonProcessingException e) {
            throw new MessagingException(FAIL_TO_SEND_MESSAGE);
        }
    }

    private AlarmMessageType extractType(String json) {
        Pattern pattern = Pattern.compile(&quot;\&quot;alarmMessageType\&quot;:\&quot;(.*?)\&quot;&quot;);
        Matcher matcher = pattern.matcher(json);

        if (matcher.find()) {
            return AlarmMessageType.valueOf(matcher.group(1));
        }
        throw new JsonParseException();
    }

    private boolean isScheduleType(AlarmMessageType type) {
        return switch (type) {
            case SCHEDULE_ADD, SCHEDULE_ENTER, SCHEDULE_EXIT, SCHEDULE_UPDATE,
                    SCHEDULE_OWNER, SCHEDULE_OPEN, SCHEDULE_AUTO_CLOSE -&gt; true;
            default -&gt; false;
        };
    }
}
</code></pre>
<p>조건에 맞는 클래스를 찾을 때 Map으로 등록해 switch-case를 탈피할 수 있다는 장점이 있다.
그러나 DB 저장용 메시지 변환 메서드를 위해서는 추가적인 캐스팅 메서드가 필요하기 때문에 근본적인 해결이 어렵다.</p>
<h4 id="2-전략-패턴-strategy-pattern">2. 전략 패턴 (Strategy Pattern)</h4>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/703d2e3c-2b95-4694-91e3-0abe0adaaf80/image.png" alt=""></p>
<p><a href="https://velog.io/@_roundtable/Enum-%EC%B2%98%EB%A6%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-%EB%81%9D-%EC%97%86%EB%8A%94-switch%EB%AC%B8%EC%97%90-%EA%B4%80%ED%95%98%EC%97%AC-%EC%A0%84%EB%9E%B5-%ED%8C%A8%ED%84%B4-%EC%82%AC%EC%9A%A9%EA%B8%B0">Enum 처리를 위한 끝 없는 switch문에 관하여… (전략 패턴 사용기)</a></p>
<p>장점은 다음과 같다.</p>
<ul>
<li>Enum에 대해 switch-case 대신 Map으로 처리한다.</li>
<li>각 클래스 별로 다른 메서드 실행이 필요한 경우 각 클래스 별 하나의 전략으로 처리할 수 있다.</li>
</ul>
<p>치명적인 단점은 15개 이상 존재하는 모든 전략에 대해 클래스 구현이 필요하다는 점이다.
부모 클래스로 받아야 하기 때문에 메시지 변환의 복잡함은 여전히 존재할 것이다.</p>
<h4 id="3-jsontypeinfo--jsonsubtypes">3. @JsonTypeInfo + @JsonSubTypes</h4>
<pre><code class="language-java">@JsonTypeInfo(
  use = JsonTypeInfo.Id.NAME,
  include = JsonTypeInfo.As.PROPERTY,
  property = &quot;alarmMessageType&quot;
)
@JsonSubTypes({
  @JsonSubTypes.Type(value = ArrivalAlarmMessage.class, name = &quot;MEMBER_ARRIVAL&quot;),
  @JsonSubTypes.Type(value = EmojiMessage.class, name = &quot;EMOJI&quot;),
  // ... 모든 메시지 타입 등록
})
public abstract class AlarmMessage {
    // 공통 필드
}</code></pre>
<p>Jackson 어노테이션을 이용하는 방법이다.
이렇게 하면 따로 Mapper 클래스를 만들지 않고도 ObjectMapper 하나만으로 역직렬화가 가능하다.</p>
<h3 id="활용하기">활용하기</h3>
<pre><code class="language-java">public enum AlarmMessageType {
    SCHEDULE_ADD(ScheduleAlarmMessage.class),
    SCHEDULE_ENTER(ScheduleMemberAlarmMessage.class),
    SCHEDULE_EXIT(ScheduleMemberAlarmMessage.class),
    SCHEDULE_UPDATE(ScheduleAlarmMessage.class),
    SCHEDULE_OWNER(ScheduleAlarmMessage.class),
    SCHEDULE_OPEN(ScheduleAlarmMessage.class),
    SCHEDULE_AUTO_CLOSE(ScheduleAlarmMessage.class),

    MEMBER_ARRIVAL(ArrivalAlarmMessage.class),
    SCHEDULE_MAP_CLOSE(ScheduleClosedMessage.class),
    EMOJI(EmojiMessage.class),
    ASK_RACING(AskRacingMessage.class),
    RACING_AUTO_DELETED(RacingAutoDeletedMessage.class),
    RACING_DENIED(RacingDeniedMessage.class),
    RACING_TERM(RacingTermMessage.class),
    RACING_START(RacingStartMessage.class),
    TITLE_GRANTED(TitleGrantedMessage.class),
    PAYMENT_SUCCESS(PaymentSuccessMessage.class),
    PAYMENT_FAILED(PaymentFailedMessage.class),
    POINT_ERROR(PointErrorMessage.class);

    private final Class&lt;? extends AlarmMessage&gt; messageClass;

    AlarmMessageType(Class&lt;? extends AlarmMessage&gt; messageClass) {
        this.messageClass = messageClass;
    }

    public Class&lt;? extends AlarmMessage&gt; getMessageClass() {
        return messageClass;
    }

    public static AlarmMessageType fromName(String name) {
        try {
            return AlarmMessageType.valueOf(name);
        } catch (IllegalArgumentException e) {
            return null;
        }
    }
}</code></pre>
<p>우선 Enum을 바꿔주었다. Enum의 필드로 각 Message 클래스를 매핑하였고, 이 과정을 통해 자동 등록되도록 할 것이다.</p>
<pre><code class="language-java">public class AlarmMessageTypeIdResolver extends TypeIdResolverBase {

    @Override
    public JavaType typeFromId(DatabindContext context, String id) {
        AlarmMessageType type = AlarmMessageType.fromName(id);
        if (type == null) {
            throw new IllegalArgumentException(&quot;Unknown alarmMessageType: &quot; + id);
        }
        return context.constructType(type.getMessageClass());
    }

    @Override
    public String idFromValue(Object value) {
        return null;
    }

    @Override
    public String idFromValueAndType(Object value, Class&lt;?&gt; suggestedType) {
        return null;
    }

    @Override
    public JsonTypeInfo.Id getMechanism() {
        return JsonTypeInfo.Id.CUSTOM;
    }
}</code></pre>
<p><code>@JsonTypeIdResolver</code>을 통해 매핑 과정을 커스텀한다.
typeFromId를 통해 위에서 등록한 Enum을 확인하고, 자동으로 클래스를 매핑한다.</p>
<pre><code class="language-java">@JsonTypeInfo(
        use = JsonTypeInfo.Id.CUSTOM,
        include = JsonTypeInfo.As.PROPERTY,
        property = &quot;alarmMessageType&quot;,
        visible = true
)
@JsonTypeIdResolver(AlarmMessageTypeIdResolver.class)
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class AlarmMessage {

    private List&lt;String&gt; alarmReceiverTokens;
    private AlarmMessageType alarmMessageType;

}</code></pre>
<p>이제 AlarmMessage의 어노테이션을 다음과 같이 설정하면 끝
이렇게 하면 ObjectMapper로 AlarmMessage를 받을 때 해당하는 자식 클래스로 자동으로 매핑시켜준다!</p>
<pre><code class="language-java">@Test
void convert() throws JsonProcessingException {
    ObjectMapper objectMapper = new ObjectMapper();

    String json = &quot;&quot;&quot;
        {
          &quot;alarmMessageType&quot;: &quot;POINT_ERROR&quot;,
          &quot;alarmReceiverTokens&quot;: [&quot;token1&quot;, &quot;token2&quot;]
        }
       &quot;&quot;&quot;;

    AlarmMessage message = objectMapper.readValue(json, AlarmMessage.class);
    System.out.println(message.getClass()); // PointErrorMessage
}</code></pre>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/60072cc5-6351-4a94-b0e5-6841e7fb8e3a/image.png" alt=""></p>
<p>짜잔 잘 동작하는 것을 확인할 수 있다.</p>
<p>이 과정으로 다음의 조건을 달성했다.</p>
<ul>
<li>쉽게 자식 클래스로의 매핑이 가능할 것
매핑을 Jackson이 대신 처리해주기 때문에 편리하다</li>
<li>새로 클래스를 추가할 때 빠뜨리는 행위가 없을 것
Enum 생성 과정에서 클래스 등록을 강제하기 때문에 가능하다.</li>
</ul>
<p>하지만 한 가지 문제가 아직 존재했다.</p>
<ul>
<li>메시지 클래스의 DB 저장 &amp; FCM 메시지 컨버팅을 위한 메서드 필요</li>
</ul>
<p><em>🧐 그렇다면 Message 클래스 내에 메시지 컨버팅 내용을 직접 넣으면 되는 것 아닌가?</em>
그건 옳은 방향이 아닌 것 같다!
왜냐하면 Message 클래스는 모든 모듈에서 사용하는 클래스인데, 다른 모듈에서 알람과 이해 관계가 없는 작업자가 클래스를 생성하더라도 메시지 컨버팅 내용에 의존하게 된다.
따라서 alarm과 관련된 책임 응집도가 떨어지게 될 것이다.</p>
<p>어떻게 해결할 수 있을까?</p>
<h3 id="방문자-패턴-visitor-pattern">방문자 패턴 (Visitor Pattern)</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/fcd99cc9-6e1a-4111-bea6-f13e13a0bb9e/image.png" alt=""></p>
<p>Visitor 패턴은 알고리즘을 객체 구조에서 분리시켜주는 디자인 패턴이다.
각 자식 클래스가 처리해야 하는 복잡한 작업을 Visitor에 몰아 넣고,
실제 클래스는 Visitor를 받아 실행시킨다.</p>
<p>장점은 다음과 같다.</p>
<ul>
<li>알고리즘을 하나의 클래스에 몰아 넣어 응집도를 높인다.</li>
<li>코드의 수정이 적다.</li>
</ul>
<p>Visitor 패턴은 문제를 해결해 줄 수 있을 것 같다!</p>
<h3 id="방문자-패턴-적용하기">방문자 패턴 적용하기</h3>
<pre><code class="language-java">public interface AlarmMessageVisitor {
    String visit(ArrivalAlarmMessage message);
    String visit(EmojiMessage message);
    String visit(ScheduleAlarmMessage message);
}</code></pre>
<p>각 클래스에 맞는 행동을 실행하기 위한 방문자를 interface로 생성해준다.
각 메서드는 방문했을 때 방문자가 하는 행동이다.</p>
<pre><code class="language-java">public abstract String accept(AlarmMessageVisitor visitor);</code></pre>
<p>부모 클래스의 타입을 추상 클래스로 변경하고, accept() 메서드를 작성한다.</p>
<pre><code class="language-java">@Override
public String accept(AlarmMessageVisitor visitor) {
    return visitor.visit(this);
}</code></pre>
<p>이제 AlarmMessage의 자식 클래스에서 visitor의 행동을 실행시켜준다!</p>
<p>이렇게 하면 AlarmMessage와 그 상속 클래스들은 실제 구현이 어떻게 진행되는지 알 필요가 없기 때문에 관심사를 분리할 수 있다.</p>
<pre><code class="language-java">public class AlarmMessageConverter implements AlarmMessageVisitor {
    @Override
    public String visit(ArrivalAlarmMessage message) {
        return &quot;약속 : &quot; + message.getScheduleName() + &quot;에서 멤버 &quot; + message.getArriveMemberInfo().getNickname() + &quot;가 약속 장소에 도착하였습니다!&quot;;
    }

    @Override
    public String visit(EmojiMessage message) {
        return &quot;약속 : &quot; + message.getScheduleName() + &quot;에서 멤버 &quot; + message.getSenderInfo().getNickname() + &quot;가 &quot; + message.getEmojiType() + &quot; 이모지를 전달했습니다.&quot;;
    }

    @Override
    public String visit(ScheduleAlarmMessage message) {
        AlarmMessageType alarmMessageType = message.getAlarmMessageType();

        if (alarmMessageType.equals(AlarmMessageType.SCHEDULE_ADD))
            return &quot;약속 : &quot; + message.getScheduleName() + &quot; 가 추가되었습니다.&quot;;
        else if (alarmMessageType.equals(AlarmMessageType.SCHEDULE_UPDATE))
            return &quot;약속 : &quot; + message.getScheduleName() + &quot; 이 업데이트 되었습니다.&quot;;
        else if (alarmMessageType.equals(AlarmMessageType.SCHEDULE_OWNER))
            return &quot;약속 : &quot; + message.getScheduleName() + &quot; 의 스케줄 장이 변경되었습니다.&quot;;
        else if (alarmMessageType.equals(AlarmMessageType.SCHEDULE_OPEN))
            return &quot;약속 : &quot; + message.getScheduleName() + &quot; 맵이 생성되었습니다!&quot;;
        else if (alarmMessageType.equals(AlarmMessageType.SCHEDULE_AUTO_CLOSE))
            return &quot;약속 : &quot; + message.getScheduleName() + &quot; 이 자동 종료되었습니다.&quot;;
        else
            return &quot;&quot;;
    }
}
</code></pre>
<p>실제 Visitor 역할을 해줄 구현체이다.
부모 클래스가 아닌 실제 상속된 클래스를 사용하기 때문에 자식 클래스에서 추가된 필드도 사용 가능하다.</p>
<p>타입 별로 다른 메시지가 반환되어야 하는 복잡한 로직도 Visitor 클래스 하나에서 처리 가능하다.</p>
<p>Visitor 인터페이스는 common 모듈에서 작성했지만, 해당 클래스는 직접적인 Alarm 내용을 하드코딩하는 부분이기 때문에 책임 분리를 위해 alarm 모듈에서 작성하였다!</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/57c477a8-bd3c-45e4-b249-133b589b31e3/image.png" alt=""></p>
<p>만약 새로운 AlarmMessage 클래스를 생성하더라도 accept를 구현하지 않거나,
Visitor가 해당 메서드를 작성하지 않으면 컴파일 에러를 발생시키니 빼먹을 일도 없을 것 같다.</p>
<p><strong>추가적인 의문</strong></p>
<blockquote>
<p>accept(), visit()와 같이 중립적인 네이밍을 써야할까?
이러한 네이밍은 실제 함수가 어떤 역할을 하는지 보이지 않는다..!
toLogMessage()와 같은 직접적인 네이밍을 쓰면 안되나?</p>
</blockquote>
<p>그럼에도 중립적인 네이밍을 유지하는 이유는 다음과 같다.</p>
<p>방문자는 여러가지 역할을 가질 수 있다.
지금은 DB에 알림 리스트로 저장하는 방문자만 존재하지만, 이후에는 다른 역할을 하는 방문자가 추가될 수도 있다.</p>
<p>그럴 때마다 부모 클래스에 함수를 추가하고, 구현체를 수정하는 것은 비용이 크기 때문에 accept() 하나로 모든 방문자를 handle할 수 있어야 한다.</p>
<p>특히나 현재와 같이 다양한 AlarmMessage 자식 클래스가 존재할 때는 기능 추가에 특히나 열려있어야 할 것 같다.</p>
<p>어차피 accept() 함수의 매개변수에는 Visitor의 실제 구현체가 들어가게 된다.
따라서 해당 Visitor가 방문했을 때 어떤 역할을 하는지는 Caller가 이미 알고 있을 것이며,
함수 네이밍이 모호하더라도 Caller가 기대하는 함수 자체는 Visitor에서 실행되기 때문에 우려하는 일은 발생하지 않을 것 같다.</p>
<hr>
<h2 id="결론">결론</h2>
<p>과거의 코드를 볼 때 마다 왜 이렇게 짰지..? 싶은 무지막지한 코드가 많이 보인다.
이러한 코드를 짜게 되면 항상 의심부터 하자!!</p>
<blockquote>
<p><strong>결론</strong></p>
<ul>
<li>Jackson의 JsonTypeIdResolver를 사용하면 다형성을 이용한 매핑에서 효율을 챙길 수 있다.</li>
</ul>
</blockquote>
<ul>
<li>방문자 패턴을 통해 클래스의 알고리즘 구현 부담을 분리할 수 있다!</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[EventListener 잘 쓰는 거 어떻게 하는 건데]]></title>
            <link>https://velog.io/@_roundtable/EventListener-%EC%9E%98-%EC%93%B0%EB%8A%94-%EA%B1%B0-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%98%EB%8A%94-%EA%B1%B4%EB%8D%B0</link>
            <guid>https://velog.io/@_roundtable/EventListener-%EC%9E%98-%EC%93%B0%EB%8A%94-%EA%B1%B0-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%98%EB%8A%94-%EA%B1%B4%EB%8D%B0</guid>
            <pubDate>Sun, 25 May 2025 10:41:22 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️⭐️⭐️
<strong>작성 날짜</strong> 2025.05.25</p>
<h2 id="고민-내용">고민 내용</h2>
<p>아이쿠 프로젝트에서는 관심사를 분리하여 결합도를 낮출 수 있는 이벤트 주도 아키텍처의 개발을 진행하고 있다.</p>
<p>스프링에서는 ApplicationEventPublisher를 통해 이를 실현할 수 있다.</p>
<pre><code class="language-java">private final ApplicationEventPublisher eventPublisher;

// 실제 사용 부분
eventPublisher.publishEvent(new MyEvent(&quot;EntityA Created&quot;));</code></pre>
<p>이런 방식으로 간편하게 이벤트 Pub이 가능하다.</p>
<p>하지만...
가장 어려움을 겪는 부분은 발행된 이벤트를 처리하는 EventListener 부분이다.
문제도 이 부분에서 발생했다.</p>
<p>이번 고민거리는 복잡하기 때문에 예제를 통해 단순화하여 글을 작성하고자 한다.</p>
<pre><code class="language-java">@Transactional
public void createEntityA() {
     EntityA a = new EntityA();
     a.setName(&quot;Test A&quot;);
     entityARepository.save(a);

     eventPublisher.publishEvent(new MyEvent(&quot;EntityA Created&quot;));
}</code></pre>
<p>이벤트를 발행하는 부분이다. EntityA를 저장하기 위해 트랜잭션을 갖고 있으며, 저장한 이후 이벤트를 발행한다.</p>
<pre><code class="language-java">@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleEvent(MyEvent event) {
     myService.createEntityB();
}</code></pre>
<p>이벤트 발생 시 이벤트를 처리하기 위한 리스너이다.
발행하는 파트의 트랜잭션이 종료되어 EntityA가 저장된 이후 실행될 로직이기 때문에 <code>@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code>을 사용하였다.</p>
<pre><code class="language-java">@Transactional
public void createEntityB() {
     EntityB b = new EntityB();
     b.setDescription(&quot;Created in Event Listener&quot;);
     entityBRepository.save(b);
}</code></pre>
<p>리스너가 실행하는 메서드이다. 이번에는 EntityB를 저장하기 위해 트랜잭션 어노테이션을 달아주었다.</p>
<pre><code class="language-java">@Test
void 엔티티A저장후_이벤트리스너를통해_엔티티B도저장되는지확인() throws Exception {
     // when
     myService.createEntityA();

     // then (AFTER_COMMIT은 커밋 후 비동기처럼 동작하기 때문에 약간 대기 필요)
     Thread.sleep(1000);  // 이벤트 리스너 동작 시간 확보

     List&lt;EntityA&gt; listA = entityARepository.findAll();
     List&lt;EntityB&gt; listB = entityBRepository.findAll();

     assertThat(listA).hasSize(1);
     assertThat(listB).hasSize(1);
}</code></pre>
<p>우리가 기대하는 결과는 EntityA와 EntityB가 모두 저장되는 것이다.
과연 테스트는 통과할까?</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/475dfc6f-be4c-458d-82ac-6ded58c85c6a/image.png" alt=""></p>
<p>테스트는 실패한다!
EntityA는 잘 저장되었으나 EntityB는 저장되지 않았다..!</p>
<blockquote>
<p>🤔 EntityB는 왜 저장되지 않았을까?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<p><code>AbstractPlatformTransactionManager</code>의 <code>processCommit</code>에서 그 이유를 찾을 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/0d01cfbe-28b2-455e-b52b-0a01a532718b/image.png" alt=""></p>
<p>isNewTransaction이 true인 경우만 doCommit을 실행한다.</p>
<p>processCommit에 break point를 걸고 확인해보았다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/0a61e39d-2a71-434c-ae9e-4474bdd7dd94/image.png" alt=""></p>
<p>첫 번째 EntityA를 저장하는 부분
세션ID는 412903043, newTransaction은 true로 되어있어 doCommit이 실행된다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/c52d9555-1baa-4d31-abfc-e1bc3e3a37e9/image.png" alt=""></p>
<p>두 번째 EntityB를 저장하는 부분
세션ID는 412903043, newTransaction은 false</p>
<p>문제는 이벤트로 분리되었다고 생각했던 두 메서드가 같은 세션에서 실행되고 있었다는 것이다.
세션ID를 공유하고 있다는 것은, 같은 쓰레드 로컬의 EntityManager를 사용한다는 것이며 트랜잭션이 전파되었다고 유추할 수 있다.</p>
<p>이때 같이 묶이는 트랜잭션은 논리 트랜잭션인데, 물리 트랜잭션은 이미 커밋되었기 때문에 이후의 커밋에 대해 실행되지 않는 것이다. (newTransaction == false)</p>
<h2 id="해결-방법">해결 방법</h2>
<p>그렇다면 어떻게 해야될까?</p>
<p>코드 레벨에서 볼 때, 단순히 newTransaction을 true로 만들어주면 커밋이 실행되니 이를 만들어줄 방법을 생각해보자.</p>
<h3 id="1-async-이용하기">1. Async 이용하기</h3>
<p>아예 별개의 세션을 만들어서 새로운 트랜잭션을 사용하도록 해보자.</p>
<pre><code class="language-java">@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleEvent(MyEvent event) {
     System.out.println(&quot;AFTER_COMMIT 리스너 동작: &quot; + event.getMessage());
     myService.createEntityB();  // 메서드 C 호출
}</code></pre>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/6c511a40-3dd1-46e0-9c79-002a48265d11/image.png" alt=""></p>
<p>EntityA를 저장하는 부분</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/6d11eda5-a9e5-4de8-97f0-4a56fc08b84b/image.png" alt=""></p>
<p>EntityB를 저장하는 부분</p>
<p>SessionID도 다르고 newTransaction도 둘 다 true</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/9cc1b01e-fa84-4a62-bbb5-622f35d88fe1/image.png" alt=""></p>
<p>테스트도 성공!</p>
<h3 id="2-requires_new-이용하기">2. REQUIRES_NEW 이용하기</h3>
<p><code>@Transactional</code> 어노테이션의 옵션인 REQUIRES_NEW를 이용한다.</p>
<pre><code class="language-java">@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createEntityB() {
     EntityB b = new EntityB();
     b.setDescription(&quot;Created in Event Listener&quot;);
     entityBRepository.save(b);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/5d1f069d-cadd-4510-8924-2e4b5669569a/image.png" alt=""></p>
<p>EntityA를 저장하는 부분</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/49fe9613-a99a-4ca4-9e4a-c86ba320d93e/image.png" alt=""></p>
<p>EntityB를 저장하는 부분</p>
<p>마찬가지로 SessionID도 다르고 newTransaction도 둘 다 true</p>
<p>트랜잭션이 시작될 때 Spring은 새로운 EntityManager를 생성해서 현재 쓰레드에 바인딩한다.
세션ID는 EntityManager에서 꺼내오기 때문에 세션 ID 값은 다르게 나온다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/bf103468-d535-4bd3-b0ef-4ff22df6afba/image.png" alt=""></p>
<p>테스트도 성공!</p>
<blockquote>
<p>이 방식은 이전의 트랜잭션은 일시정지하고, REQUIRES_NEW 이후의 메서드를 위한 새로운 DB 커넥션을 풀에서 동시에 소비한다.
따라서 커넥션 풀이 고갈될 위험이 있다.
@Async를 사용하면 트랜잭션의 일시정지가 필요 없이 독립적으로 수행 가능하며, 반환을 대기할 수 있기 때문에 해당 문제에서 자유로울 수 있다.</p>
</blockquote>
<h3 id="3-커밋-순서가-중요하지-않다면">3. 커밋 순서가 중요하지 않다면</h3>
<h4 id="3-1-그냥-eventlistener-사용하기">3-1. 그냥 EventListener 사용하기</h4>
<p>단순 EventListener는 관심사 분리라는 장점을 제외하면 함수 호출과 동일하다.
publishEvent를 한 현재 스레드에서 EventListener가 붙어있는 함수들을 순차적으로 호출하기 때문이다.</p>
<p>따라서 트랜잭션은 자연스럽게 묶이며, 모든 함수 호출이 끝난 경우 트랜잭션은 종료되며 데이터베이스에 커밋된다.</p>
<h4 id="3-2-before_commit-사용하기">3-2. BEFORE_COMMIT 사용하기</h4>
<p><code>@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)</code></p>
<p>AFTER_COMMIT이 트랜잭션 커밋 이후 EventListener 로직을 실행하는 것이었다면,
BEFORE_COMMIT은 말 그대로 커밋 직전에 실행하는 것이다.</p>
<p>트랜잭션은 아직 커밋된 적이 없기 때문에, 이후의 EventListener 로직에서 영속된 값도 커밋이 가능하다.</p>
<h4 id="그럼-before_commit과-eventlistener의-차이는">그럼 BEFORE_COMMIT과 EventListener의 차이는?</h4>
<blockquote>
<p>@EventListener: 트랜잭션과 무관, 이벤트 발행하면 즉시 실행
@TransactionalEventListener: 트랜잭션 경계에 따라 정해진 시점에 실행</p>
</blockquote>
<p>예시는 publishEvent가 caller의 끝 부분에 나왔지만, 만약 publishEvent 뒤에도 코드가 있다고 가정해보자.</p>
<p>@EventListener라면, 뒤쪽의 코드를 block하여 Listener의 로직을 수행한 후 publishEvent 뒤의 코드를 실행한다.</p>
<p>@TransactionalEventListener의 BEFORE_COMMIT이라면, Caller 메서드의 끝에 도달하여 이제 커밋해볼까~ 하는 순간에 잠깐! 하고 Listener의 로직을 실행한다. (따라서 트랜잭션 있을 때만 사용 가능하다.)</p>
<p>트랜잭션 경계가 중요하다면 @TransactionalEventListener의 BEFORE_COMMIT를, Caller에 트랜잭션이 없거나 경계가 중요하지 않으면 @EventListener를 사용하면 될 것 같다.</p>
<hr>
<h2 id="결론">결론</h2>
<blockquote>
<p><strong>결론</strong>
Async, EventListener, TransactionalEventListener, Transactional 등등...
너무 많은 개념이 혼재해서 헷갈렸었는데 이번 기회에 정리하면서 머릿속이 좀 정돈된 것 같다.
이벤트를 이용해 개발하는 것이 장점이 크다고 생각하기 때문에 잘 써먹어봐야겠다!</p>
</blockquote>
<hr>
<p><strong>참고한 포스팅</strong></p>
<p><a href="https://00h0.tistory.com/102">https://00h0.tistory.com/102</a></p>
<p><a href="https://lenditkr.github.io/spring/transactional-event-listener/">https://lenditkr.github.io/spring/transactional-event-listener/</a></p>
<p><a href="https://hojun-dev.tistory.com/entry/JAVA-eventListener-transactionalEventListener-%EC%98%88%EC%99%B8-%EB%B0%8F-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%A0%84%ED%8C%8C-%EC%B4%9D%EC%A0%95%EB%A6%AC#hELLO">https://hojun-dev.tistory.com/entry/JAVA-eventListener-transactionalEventListener-%EC%98%88%EC%99%B8-%EB%B0%8F-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%A0%84%ED%8C%8C-%EC%B4%9D%EC%A0%95%EB%A6%AC#hELLO</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[비동기 코드를 테스트 하는 방법]]></title>
            <link>https://velog.io/@_roundtable/%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%BD%94%EB%93%9C%EB%A5%BC-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@_roundtable/%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%BD%94%EB%93%9C%EB%A5%BC-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Fri, 23 May 2025 12:45:30 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️
<strong>작성 날짜</strong> 2025.05.23</p>
<h2 id="고민-내용">고민 내용</h2>
<p>아이쿠 프로젝트에서는 포인트의 증감과 관련된 모든 메서드에서의 동일한 로직 호출로 발생하는 문제를 해결하기 위해 이를 이벤트 Pub/Sub으로 처리하고 있다.</p>
<pre><code class="language-java">@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void pointChangeEvent(PointChangeEvent event) {
        pointChangeFacade.makePointChange(event.getMemberId(), event.getSign(), event.getPointAmount(), event.getReason(), event.getReasonId());
}</code></pre>
<p>포인트 증감을 발생시키는 이벤트를 Listener로 구독하여 포인트 증감 이벤트 발생 시 해야하는 로직이 담긴 Facade 클래스의 메서드를 실행한다.</p>
<p>테스트 코드는 다음과 같다.</p>
<pre><code class="language-java">handler.pointChangeEvent(
          new PointChangeEvent(member.getId(), PointChangeType.PLUS, 100, PointChangeReason.EVENT, 11L)
);

assertThat(member.getPoint()).isEqualTo(100);</code></pre>
<p>그런데 여기서 문제가 발생했다!</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/1d21fb4d-8aa7-4ae6-88f2-a5e4e6f37da4/image.png" alt=""></p>
<p>알고보니 <code>@Async</code>의 문제였다.
호출되는 리스너에 달려있는 Async 어노테이션을 주석처리하니 테스트가 통과하였다.</p>
<p>테스트는 분리된 스레드 작업이 끝나기를 기다려주지 않기 때문에 포인트 증가 로직이 반영되기 전에 테스트를 진행해버린다!</p>
<blockquote>
<p>🤔 비동기는 테스트를 어떻게 해야될까?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<h3 id="1-테스트-환경설정-변경하기">1. 테스트 환경설정 변경하기</h3>
<p>테스트의 실행환경에서는 비동기가 필요 없다.
AsyncConfigurer를 통해 비동기를 처리하는 방식을 동기로 처리하도록 바꿔준다.</p>
<pre><code class="language-java">@Configuration
public class TestAsyncConfig implements AsyncConfigurer {
    @Override
    public Executor getAsyncExecutor() {
        return new SyncTaskExecutor();
    }
}</code></pre>
<pre><code class="language-java">// 테스트 클래스
@Import(TestAsyncConfig.class)</code></pre>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/346e3882-6844-468e-b50d-29d0e6f7b047/image.png" alt=""></p>
<p>잘 통과한다!</p>
<h3 id="2-async가-정말-필요한가">2. Async가 정말 필요한가?</h3>
<p>사실 1번 방법이 번거롭기도 해서, Async일 필요성을 다시 확인해보았다.</p>
<blockquote>
<ol>
<li>응답 시간을 줄이기 위한 목적</li>
</ol>
</blockquote>
<p>포인트 증가에서 비관적 락을 사용하기 때문에 약간의 딜레이가 있을 수 있지만, 성능에 크게 영향을 주지는 않는다.
로그를 남기는 부분도 마찬가지로 I/O 부하는 크게 없다.</p>
<blockquote>
<ol start="2">
<li>실패해도 주 로직에는 영향이 없어야 할 때</li>
</ol>
</blockquote>
<p>이 부분이 핵심 내용인 것 같다!
다른 서비스에서는 SAGA 패턴을 통해 롤백할 정도로 트랜잭션을 강제로 붙이고 있다.
다시 말해서, 포인트의 증감 로직은 이벤트를 발생시키는 Pub 로직에서도 Sub 로직의 영향을 받아야 한다.</p>
<p>+) 추가로 Async를 사용했을 때 테스트와 디버깅 비용이 더 발생한다는 문제도 있다.</p>
<blockquote>
<p>결론 : 현재 상황에선 Async가 필요하지 않다!</p>
</blockquote>
<hr>
<h2 id="결론">결론</h2>
<p>Async 때문에 발생한 테스트 문제 덕분에 Async의 필요성을 돌아보게 되는 계기가 되었다.</p>
<blockquote>
<p><strong>결론</strong>
기술을 쓰기 전에... 항상 의심하는 습관을 갖자!</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[SpringSecurity permitAll이 동작하지 않는다!]]></title>
            <link>https://velog.io/@_roundtable/SpringSecurity-permitAll%EC%9D%B4-%EB%8F%99%EC%9E%91%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94%EB%8B%A4</link>
            <guid>https://velog.io/@_roundtable/SpringSecurity-permitAll%EC%9D%B4-%EB%8F%99%EC%9E%91%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94%EB%8B%A4</guid>
            <pubDate>Fri, 09 May 2025 18:21:10 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️⭐️
<strong>작성 날짜</strong> 2025.05.10</p>
<h2 id="고민-내용">고민 내용</h2>
<p>현재 아이쿠는 Security 기반의 JWT Token을 사용하고 있다.
회원가입과 같은 경우 토큰 발급이 아직이기 때문에 요청 자체를 열어두어야 하고, 리프레시 토큰을 이용한 토큰 재발급의 경우에는 Access 토큰 자체가 만료되었을 테니 필터링하면 안된다.</p>
<p>그런데 <code>SecurityConfig</code>에서 <code>permitAll</code>로 설정한 경로들이 토큰 없이는 <strong>FORBIDDEN</strong> 오류가 뜨는 것이다!</p>
<blockquote>
<p>🤔
분명히 PermitAll 설정을 걸었는데 왜 검문을 할까?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<h3 id="문제-인식">문제 인식</h3>
<p>Security에서 필터를 거치는 과정에서 문제가 발생한 것 같다.
<code>permitAll</code>의 역할은 인증이 아닌 인가 과정에서 모든 사용자를 접근 가능하게 한다는 것인데,
이 부분을 인증 절차에서도 모두 접근 허용으로 착각하는 바람에 생긴 문제였다.
따라서 <code>permitAll</code>을 설정하더라도, 필터는 설정한 대로 넘어가 인증 과정을 거친다.</p>
<p>원래의 <code>JwtAuthenticationFilter</code>는 다음과 같다.</p>
<pre><code class="language-java">@Override
public Mono&lt;Void&gt; filter(ServerWebExchange exchange, WebFilterChain chain) {
        String token = resolveToken(exchange);

        if (token != null &amp;&amp; jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContext context = new SecurityContextImpl(authentication);

            ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
                    .header(&quot;Access-Member-Id&quot;, MDC.get(&quot;accessMemberId&quot;))
                    .build();

            ServerWebExchange mutatedExchange = exchange.mutate().request(mutatedRequest).build();
            return chain.filter(mutatedExchange)
                    .contextWrite(ReactiveSecurityContextHolder.withSecurityContext(Mono.just(context)));
        }

        return chain.filter(exchange);
}</code></pre>
<p>여기서 <code>resolveToken</code> 부분이 <code>AUTHORIZATION</code> 헤더를 가져오면서 생기는 1차 문제,
<code>validateToken</code>이 만료 기간을 검증하면서 생기는 2차 문제를 확인할 수 있다.</p>
<h3 id="해결책-알아보기">해결책 알아보기</h3>
<p>여기서 세 가지 선택지가 있는데,</p>
<ol>
<li>필터를 인가 이후로 보내 permitAll 경로는 필터를 우회하도록 한다.</li>
<li>shouldNotFilter를 이용한다.</li>
<li>필터 내부에서 경로에 따라 필터링 한다.</li>
</ol>
<p>1번은 의미상 가능하긴 하지만...
<code>JwtAuthenticationWebFilter</code>가 <code>SecurityContext</code>를 넣어주어야 뒤에 단계에서 <code>AuthorizationWebFilter</code>를 통해 인가 과정이 가능한데, 이것의 순서를 바꿔주는 과정이다 보니 문제가 생긴다.</p>
<p>=&gt; 따라서 탈락.</p>
<p>2번은 다른 블로그에서 많이 찾아볼 수 있었던 해결책이었다.
(참고 : <a href="https://velog.io/@choidongkuen/Spring-Security-SecurityConfig-%ED%81%B4%EB%9E%98%EC%8A%A4%EC%9D%98-permitAll-%EC%9D%B4-%EC%A0%81%EC%9A%A9%EB%90%98%EC%A7%80-%EC%95%8A%EC%95%98%EB%8D%98-%EC%9D%B4%EC%9C%A0">https://velog.io/@choidongkuen/Spring-Security-SecurityConfig-클래스의-permitAll-이-적용되지-않았던-이유</a>)</p>
<p>스프링 WebMVC에서는 <code>OncePerRequestFilter</code>를 상속해서 필터를 커스텀화한다.
그러나 우리는 MSA 환경에서 요청을 적절한 서비스로 전달하는 Spring Gateway에서 토큰을 검증하고 있었고, Webflux 환경이었기 때문에 <code>WebFilter</code>를 상속받고 있었다.
이 클래스는 <code>filter</code> 메소드만을 사용하기 때문에 <code>shouldNotFilter</code>라는 메소드가 없었다.</p>
<p>=&gt; 따라서 얘도 탈락.</p>
<p>남은 것은 3번</p>
<p>필터 내부에서 if문을 이용해 해당 패턴에 일치하면 필터 로직을 거치지 않고 바로 다음 필터로 넘기는 방식.</p>
<p>중복도 늘어날 것 같고 복잡해보여서 다른 방식을 찾아보고 싶었지만, 거의 유일한 방법인 듯해 최대한 효율적인 방법을 생각해서 작성해보기로 했다.</p>
<h3 id="해결책-적용해보기">해결책 적용해보기</h3>
<p>우선 걱정되었던 중복은 다음에서 발생한다.</p>
<ul>
<li>SecurityConfig에서 PermitAll하는 요청</li>
<li>필터에서 예외처리 해줄 요청</li>
</ul>
<p>두 요청은 같은 요청인데, 다른 곳에 숨겨져 있으면 나중에 열린 요청이 늘어나는 경우 누락의 위험이 있다.</p>
<pre><code class="language-java">public static final String[] ALL_METHOD_PERMIT_ALL_PATHS = {
            &quot;/login/sign-in/**&quot;,
            // 생략 ...
    };

public static final String[] POST_METHOD_PERMIT_ALL_PATHS = {
            &quot;/users&quot;,
            // 생략 ...
};</code></pre>
<p>상수로 빼주었다.
POST만 처리해 줄 경로도 있어서 따로 빼주었다.
SecurityConfig의 pathMatchers는 String[] 형식도 받아주기 때문에 이걸 그대로 넣어주면 된다.</p>
<pre><code class="language-java">.pathMatchers(JwtSecurityUtils.ALL_METHOD_PERMIT_ALL_PATHS).permitAll() 
.pathMatchers(HttpMethod.POST, JwtSecurityUtils.POST_METHOD_PERMIT_ALL_PATHS).permitAll()</code></pre>
<p>이런 느낌...
이전 코드와 크게 다르지 않지만 가독성은 더 올라간 것 같다.
SecurityConfig 쪽은 끝.</p>
<p>다음과 같은 Util 함수를 추가해주었다.</p>
<pre><code class="language-java">public static boolean isPermitAllPath(String path, HttpMethod method) {
        if (HttpMethod.POST.equals(method)) {
            return Arrays.stream(POST_METHOD_PERMIT_ALL_PATHS)
                    .anyMatch(pattern -&gt; pathMatcher.match(pattern, path));
        }

        return Arrays.stream(ALL_METHOD_PERMIT_ALL_PATHS)
                .anyMatch(pattern -&gt; pathMatcher.match(pattern, path));
}</code></pre>
<p>메소드와 경로를 통해 해당 Permit 조건에 부합하는지 확인한다.</p>
<pre><code class="language-java">@Override
public Mono&lt;Void&gt; filter(ServerWebExchange exchange, WebFilterChain chain) {
        String path = exchange.getRequest().getPath().toString();
        HttpMethod method = exchange.getRequest().getMethod();

        if (JwtSecurityUtils.isPermitAllPath(path, method)) {
            return chain.filter(exchange);
        }
        // 후략 ...</code></pre>
<p>그리고 이 Util 함수를 통해 필터 로직을 수행하기 전 앞 쪽에서 확인시켰다.
만약 부합하는 경우 필터 체인을 바로 넘긴다.</p>
<hr>
<h2 id="결론">결론</h2>
<p>이제 허용 경로가 늘어나는 경우에도 상수 처리한 리스트에 한 줄만 추가하면 된다.
더이상 <strong>FORBIDDEN</strong>도 발생하지 않으니 문제 해결!</p>
<blockquote>
<p><strong>결론</strong>
Security는 편리하지만 거대하고 어려운 것 같다. 
잘 공부하고 사용하자! 😭</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[OpenStreetMap에서 도보 최단경로 찾기]]></title>
            <link>https://velog.io/@_roundtable/OpenStreetMap%EC%97%90%EC%84%9C-%EB%8F%84%EB%B3%B4-%EC%B5%9C%EB%8B%A8%EA%B2%BD%EB%A1%9C-%EC%B0%BE%EA%B8%B0</link>
            <guid>https://velog.io/@_roundtable/OpenStreetMap%EC%97%90%EC%84%9C-%EB%8F%84%EB%B3%B4-%EC%B5%9C%EB%8B%A8%EA%B2%BD%EB%A1%9C-%EC%B0%BE%EA%B8%B0</guid>
            <pubDate>Thu, 08 May 2025 16:31:56 GMT</pubDate>
            <description><![CDATA[<p><strong>난이도</strong> ⭐️
<strong>작성 날짜</strong> 2025.05.08</p>
<h2 id="고민-내용">고민 내용</h2>
<p>프로젝트에서 도보 경로를 찾는 과제가 있었는데 API를 쓰기에는 보통 유료라 고민이다..</p>
<blockquote>
<p>🤔
무료로 도보 경로를 찾을 수는 없을까?</p>
</blockquote>
<hr>
<h2 id="찾아보기">찾아보기</h2>
<p><a href="https://download.geofabrik.de/asia/south-korea.html">https://download.geofabrik.de/asia/south-korea.html</a>
여기서 OSM에서 제공하는 대한민국 지도 정보를 다운로드 받을 수 있다.</p>
<p>.osm.pbf는 OpenStreetMap에서 큰 지도 데이터를 처리하기 위해 제공하는 압축된 파일이다.</p>
<p>파일을 다운받아 Resources에 넣어두었다.</p>
<p>다음은 osm pbf를 처리하기 위해 자바 코드로 넘어간다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/d63ad7fe-bc69-48fa-8844-4a6880bb6524/image.png" alt=""></p>
<p>도보 길찾기를 좀 편리하게 하기 위해 GraphHopper라는 라이브러리를 이용하였다.</p>
<pre><code>implementation &#39;com.graphhopper:graphhopper-core:8.0&#39;</code></pre><p>이렇게 의존을 추가해주고, 테스트를 해보았다.</p>
<pre><code class="language-java">public static void main(String[] args) {
        GraphHopper hopper = new GraphHopper();
        hopper.setOSMFile(&quot;src/main/resources/map/south-korea-latest.osm.pbf&quot;);
        hopper.setGraphHopperLocation(&quot;graph-cache&quot;);

        CustomModel customModel = new CustomModel();
        // road_access == DESTINATION 일 경우 우선순위를 0으로 곱해서 무시
        Statement avoidDestinationRoads = Statement.If(&quot;road_access == DESTINATION&quot;, Statement.Op.MULTIPLY, &quot;0&quot;);
        customModel.addToPriority(avoidDestinationRoads);

        Profile profile = new Profile(&quot;foot&quot;)
                .setVehicle(&quot;foot&quot;)
                .setWeighting(&quot;custom&quot;)
                .setCustomModel(customModel);

        hopper.setProfiles(profile);
        hopper.importOrLoad();

        GHRequest req = new GHRequest(37.5665, 126.9780, 37.5796, 126.9770)  // 서울시청 → 경복궁 예시
                .setProfile(&quot;foot&quot;)
                .setLocale(&quot;en&quot;);

        GHResponse res = hopper.route(req);
        if (res.hasErrors()) {
            System.out.println(res.getErrors());
            return;
        }

        PointList path = res.getBest().getPoints();
        path.forEach(p -&gt; System.out.println(p.getLat() + &quot;, &quot; + p.getLon()));
    }</code></pre>
<p>GraphHopper는 이것저것 설정할 것들이 좀 있었는데,
특히 CustomModel 설정 방법이 찾아봐도 잘 안나와서 애먹었다.</p>
<p>OSM PBF 파일을 불러와서 그래프를 만드는데,
처음에만 만들고 이후에는 만든 엣지, 노드 등의 가공된 정보를 활용하도록 setGraphHopperLocation로 설정한 폴더에 캐싱해두고 이후엔 importOrLoad로 캐싱된 그래프를 불러온다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/3bfa7885-3b3b-4664-b66c-5852b4980dd6/image.png" alt=""></p>
<p>요런 식으로 바이너리 파일이 저장된다.</p>
<p>서울시청 → 경복궁 예시를 넣어보았는데,
<img src="https://velog.velcdn.com/images/_roundtable/post/08fc146d-92ce-42e7-af9f-73c4429179b6/image.png" alt=""></p>
<p>이런 식으로 osm pbf 파일 처리를 진행하고,</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/44ecddab-a0df-4bbd-b3b3-74fd7b4064f0/image.png" alt=""></p>
<p>요렇게 결과를 보여준다.</p>
<p>처음 실행하면 그래프 생성 과정 때문에 무려 41초가 걸리지만,
두 번째부턴 1.108s로 확 줄어든다.</p>
<p>이제 요게 진짜 도보 경로가 맞는지 확인이 필요하다.</p>
<p>GPT에게 leaflet으로 지도 위에 경로를 그릴 수 있도록 html 작성을 부탁했다.</p>
<p>결과물로 나온 위치 정보를 JSON 형식으로 바꿔서 넣어줬더니...</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/fcee9e1c-6200-4d37-b193-e9e494742258/image.png" alt=""></p>
<p>조금 자세히 보면,</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/952b1d17-3c13-4102-a562-3e9a0f686463/image.png" alt=""></p>
<p>진짜 도보를 이용하는 것을 알 수 있다!
차도 건너는 부분도 찾아보니 실제 횡단보도였다.
<img src="https://velog.velcdn.com/images/_roundtable/post/b44e23d5-bb26-4b0a-a2f0-c6cc77029618/image.png" alt=""></p>
<pre><code class="language-java">res.getBest().getDistance() // 거리 (m)
res.getBest().getTime() // 시간 (ms)</code></pre>
<p>이 두 코드를 이용하면 거리와 시간도 구할 수 있다.</p>
<p><strong>자바</strong>
2.006148099905989 km
24 min</p>
<p><strong>실제 카카오 맵</strong>
1.8 km
28 min</p>
<p>경로 상의 차이가 있음을 감안했을 때, 단순 자바 라이브러리를 이용한 것 치고는 오차가 크지 않은 것 같다.</p>
<hr>
<h2 id="결론">결론</h2>
<p>setVehicle을 foot으로 설정해서 도보 경로로 뽑아보았는데, car로 설정하면 자동차 경로도 뽑을 수 있는 듯하다.</p>
<p>알고리즘도 CH 알고리즘, A*, Dijkstra 등 다양한 커스텀 알고리즘을 적용할 수 있는 것 같으니 활용도가 무궁무진 할 것 같다!</p>
<blockquote>
<p><strong>결론</strong>
자바 경로 찾기는 GraphHopper로!</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[토킹테일] WebFlux 파일 서버 예제와 k6로 MVC와 비교하여 성능 테스트하기 (1-2)]]></title>
            <link>https://velog.io/@_roundtable/%ED%86%A0%ED%82%B9%ED%85%8C%EC%9D%BC-Spring-WebFlux-%ED%8C%8C%EC%9D%BC-%EC%84%9C%EB%B2%84-%EC%98%88%EC%A0%9C%EB%A1%9C-%EC%97%B0%EC%8A%B5%ED%95%98%EA%B8%B0-1-2</link>
            <guid>https://velog.io/@_roundtable/%ED%86%A0%ED%82%B9%ED%85%8C%EC%9D%BC-Spring-WebFlux-%ED%8C%8C%EC%9D%BC-%EC%84%9C%EB%B2%84-%EC%98%88%EC%A0%9C%EB%A1%9C-%EC%97%B0%EC%8A%B5%ED%95%98%EA%B8%B0-1-2</guid>
            <pubDate>Mon, 28 Apr 2025 17:43:48 GMT</pubDate>
            <description><![CDATA[<h2 id="비바이빙---빠르게-개발하기">비바이빙 - 빠르게 개발하기</h2>
<p>AI 시대에 맞추어 2주에 한 프로덕트를 만들어내는, 작지만 빠른 개발을 지향하는 프로젝트입니다.</p>
<hr>
<h2 id="아직도-webflux-이해가-부족하다-문제-인식-단계">아직도 WebFlux 이해가 부족하다 (문제 인식 단계)</h2>
<p>이전에 채팅 예제로 공부했는데, 뭔가 실무에 바로 적용하기에 WebFlux의 장점을 완전히 흡수하진 못한 느낌이라 GPT한테 주제 하나만 알려달라고 부탁했다.</p>
<blockquote>
<p>&quot;리액티브 파일 업로드/다운로드 서버&quot; 만들기
클라이언트가 파일을 업로드하면, 서버가 스트리밍 방식으로 &quot;조각&quot;조각 받아서 저장해.
서버는 업로드 상태를 실시간으로 응답해줄 수도 있어.
다운로드할 때는, 서버가 파일 전체를 한 번에 읽지 않고, &quot;조각&quot; 조각 스트림으로 흘려보내.</p>
</blockquote>
<p>이러한 주제를 추천해줘서 한 번 만들어보면서 공부하기로 결심했다.</p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/908cc0a1-1763-4a72-be9b-d4e6853b6ce3/image.png" alt=""></p>
<p>아..응..그래..</p>
<hr>
<h2 id="공부-내용-정리">공부 내용 정리</h2>
<h3 id="파일-서버-만들기">파일 서버 만들기</h3>
<p>지난번엔 채팅 예제로 연습했는데, 이번에는 파일을 업로드하고, 다운로드 할 수 있는 서버를 만들어보겠다.</p>
<pre><code class="language-java">@Configuration
public class FileRouter {

    @Bean
    public RouterFunction&lt;ServerResponse&gt; fileRoutes(FileHandler fileHandler) {
        return route(POST(&quot;/upload&quot;), fileHandler::upload)
                .andRoute(GET(&quot;/download/{filename}&quot;), fileHandler::download);
    }
}</code></pre>
<p>이번엔 Controller 대신 완전한 비동기 처리를 지원하는 RouterFunction를 사용하였다.
어떤 HTTP Method와 URI로 오냐에 따라 실행될 함수를 매핑한다.</p>
<pre><code class="language-java">public Mono&lt;ServerResponse&gt; upload(ServerRequest request) {
        return request.body(BodyExtractors.toMultipartData())
                .flatMap(parts -&gt; {
                    var fileParts = parts.toSingleValueMap().values().stream()
                            .filter(part -&gt; part instanceof FilePart)
                            .map(part -&gt; (FilePart) part)
                            .toList();

                    if (fileParts.isEmpty()) {
                        return ServerResponse.badRequest().bodyValue(&quot;No file uploaded&quot;);
                    }

                    FilePart filePart = fileParts.get(0); // 하나만 저장
                    Path destination = Paths.get(UPLOAD_DIR).resolve(filePart.filename());

                    // 파일 스트리밍 저장
                    return DataBufferUtils.write(
                            filePart.content(), // Flux&lt;DataBuffer&gt;
                            destination,
                            StandardOpenOption.CREATE
                    ).then(
                            ServerResponse.ok().bodyValue(&quot;File uploaded: &quot; + filePart.filename())
                    );
                });
    }</code></pre>
<p>업로드를 위한 함수. 하나씩 파헤쳐보자.</p>
<p>map이 아닌 flatMap을 쓴 이유는,
파일 스트리밍 저장과 같은 또다른 비동기 (Mono) 작업이 필요하기 때문이다.
map도 비동기 처리가 가능하지만, map 안에서 새로운 Mono 스트림을 만들게 되면, Mono&lt;Mono&lt; T&gt;&gt; 이런 형식으로 반환되기 때문에 평탄화 작업이 필요하다.
그래서 flatMap을 쓰는 것</p>
<pre><code class="language-java">var fileParts = parts.toSingleValueMap().values().stream()
                     .filter(part -&gt; part instanceof FilePart)
                     .map(part -&gt; (FilePart) part)
                     .toList();</code></pre>
<p>toSingleValueMap으로 첫 번째 값만 가진 맵에서 values로 스트림을 생성한다.
FilePart에 해당하는 값만 형 변환해 리스트로 만든다.</p>
<pre><code class="language-java">return DataBufferUtils.write(
                     filePart.content(), // Flux&lt;DataBuffer&gt;
                     destination,
                     StandardOpenOption.CREATE
             ).then(
                     ServerResponse.ok().bodyValue(&quot;File uploaded: &quot; + filePart.filename())
             );</code></pre>
<p>filePart.content()는 Flux&lt; DataBuffer &gt;를 반환한다. 
파일 저장 버튼을 누르면 DataBuffer 단위로 나뉘어서 메모리에 잠시 저장되는데, filePart.content()가 이를 Flux로 연결한다.
전달된 Flux는 DataBufferUtils.write가 논블로킹으로 디스크에 전달.
.then은 앞 스트림이 성공하면 실행된다.</p>
<p>이런 식으로 논블로킹 업로드 완료</p>
<pre><code class="language-java">public Mono&lt;ServerResponse&gt; download(ServerRequest request) {
        String filename = request.pathVariable(&quot;filename&quot;);
        Path path = Paths.get(UPLOAD_DIR).resolve(filename);

        if (!path.toFile().exists()) {
            return ServerResponse.notFound().build();
        }

        Path file = path.resolve(filename);

        Flux&lt;DataBuffer&gt; fileStream = DataBufferUtils.read(
                file,
                request.exchange().getResponse().bufferFactory(),
                4096
        );


        return ServerResponse.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, &quot;attachment; filename=\&quot;&quot; + filename + &quot;\&quot;&quot;)
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .body(fileStream, DataBuffer.class);
    }</code></pre>
<p>다운로드 시 실행하는 메소드이다. 마찬가지로 하나씩 뜯어보자.</p>
<pre><code class="language-java">Flux&lt;DataBuffer&gt; fileStream = DataBufferUtils.read(
                file,
                request.exchange().getResponse().bufferFactory(),
                4096
        );</code></pre>
<p>마찬가지로 file에서 데이터를 읽어와 Flux&lt; DataBuffer &gt; 형태로 변환한다.
읽은 데이터를 저장할 버퍼를 생성하고,
한 번에 읽어올 데이터의 크기를 4096으로 지정</p>
<pre><code class="language-java">return ServerResponse.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, &quot;attachment; filename=\&quot;&quot; + filename + &quot;\&quot;&quot;)
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .body(fileStream, DataBuffer.class);</code></pre>
<p>바로 다운로드 할 수 있도록 헤더를 지정해주고,
바이너리 파일을 전달한다고 명시 (APPLICATION_OCTET_STREAM)
WebFlux가 내부적으로 fileStream 안에 있는 DataBuffer들을 하나씩 꺼내
클라이언트로 스트리밍 전송하게 만들기 위해서 DataBuffer.class로 전송한다.</p>
<p>하면서 알게된 건데,
서버가 Mono를 리턴하면 HTTP 응답을 1개 빠르게 만들어 클라이언트로 전달한다.
하지만 Flux를 리턴하면 HTTP 응답이 채워지지 않은 채로 보내지고, Response Body를 천천히 채워나간다 (스트리밍)</p>
<p>이 코드에선 ServerResponse를 통해 모노로 전달하지만,
body가 Flux이기 때문에 body를 채우기 위해 클라이언트는 기다린다.</p>
<img src="https://velog.velcdn.com/images/_roundtable/post/91b0e36c-3d62-46f0-a518-6ecc08cd622a/image.png" width=400/>

<p>요런 식으로 만들어서 업로드 / 다운로드 성공</p>
<hr>
<h3 id="mvc-버전으로-만들어보기">MVC 버전으로 만들어보기</h3>
<p>진짜 리액티브 프로그래밍은 좋을까..?
궁금하니까 비교해보기로 결심했다.</p>
<p>간단하게 파일 업로드/다운로드 로직을 작성했다.</p>
<pre><code class="language-java">@Controller
public class TestController {

    private static final String UPLOAD_DIR = &quot;tmp&quot;;

    @PostMapping(&quot;/upload&quot;)
    public ResponseEntity&lt;String&gt; upload(@RequestParam(&quot;file&quot;) MultipartFile file) {
        if (file.isEmpty()) {
            return ResponseEntity.badRequest().body(&quot;No file uploaded&quot;);
        }
        Path uploadDir = Paths.get(System.getProperty(&quot;user.dir&quot;), &quot;tmp&quot;);

        try {
            if (!Files.exists(uploadDir)) {
                Files.createDirectories(uploadDir); // tmp 디렉토리 없으면 생성
            }
            Path destination = uploadDir.resolve(file.getOriginalFilename());
            Files.createDirectories(destination.getParent());
            file.transferTo(destination.toFile());
            return ResponseEntity.ok(&quot;File uploaded: &quot; + file.getOriginalFilename());
        } catch (IOException e) {
            e.printStackTrace();
            return ResponseEntity.internalServerError().body(&quot;Upload failed&quot;);
        }
    }

    @GetMapping(&quot;/download/{filename}&quot;)
    public ResponseEntity&lt;byte[]&gt; download(@PathVariable String filename) {
        Path filePath = Paths.get(UPLOAD_DIR).resolve(filename);
        if (!Files.exists(filePath)) {
            return ResponseEntity.notFound().build();
        }

        try {
            byte[] fileBytes = Files.readAllBytes(filePath);
            return ResponseEntity.ok()
                    .header(HttpHeaders.CONTENT_DISPOSITION, &quot;attachment; filename=\&quot;&quot; + filename + &quot;\&quot;&quot;)
                    .contentType(MediaType.APPLICATION_OCTET_STREAM)
                    .body(fileBytes);
        } catch (IOException e) {
            e.printStackTrace();
            return ResponseEntity.internalServerError().build();
        }
    }
}
</code></pre>
<p>일단은 논블로킹의 장점을 확인하기 위해 블로킹 방식의 코드를 작성했다.</p>
<img src="https://velog.velcdn.com/images/_roundtable/post/cb0b5882-d8f0-412e-b9a9-ff29163d5282/image.png" width=400/>

<p>마찬가지로 잘 동작한다.
이제 비교를 해보자.</p>
<h3 id="grafana-k6로-성능-테스트">Grafana k6로 성능 테스트</h3>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/a4c008f7-8b71-4668-a13e-e2e1f0d1c62c/image.png" alt=""></p>
<p>Grafana k6는 오픈소스 부하 테스팅 툴이다.
찾아보니 성능 테스트 할 때 어렵지 않게 쓸 수 있는 것 같아
처음 사용해보는 툴이지만 사용해보기로 결심했다!</p>
<pre><code class="language-js">import http from &#39;k6/http&#39;;
import { sleep } from &#39;k6&#39;;

export const options = {
    vus: 50, // 50명 동시 접속자
    duration: &#39;20s&#39;, // 20초 동안 지속
};

export default function () {
    const res = http.get(&#39;http://localhost:8080/download/testfile.jpg&#39;); 
    if (res.status !== 200) {
        console.error(`Request failed with status ${res.status}`);
    }
    sleep(0.1);
}
</code></pre>
<p>k6 코드는 위와 같다.</p>
<p>참고로 테스트 용 파일은 랜덤 바이너리 데이터로 만든 jpg 파일이다.</p>
<pre><code class="language-bash">dd if=/dev/urandom of=testfile.jpg bs=500K count=1</code></pre>
<hr>
<p>k6를 이용한 성능 부하 테스트 1</p>
<blockquote>
<p><em>시나리오1</em>
50명이 500KB 파일을 20초 동안
/download 엔드포인트에 연속으로 던진다.</p>
</blockquote>
<p><strong>WebFlux</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/1d5831a2-0140-4324-911a-f4d620770664/image.png" alt=""></p>
<p><strong>MVC</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/24f77c28-755d-4907-b735-98462fced100/image.png" alt=""></p>
<p>테스트 대 실패
WebFlux의 avg가 훨씬 길게 나왔다.
이유는 너무 적은 요청을 보내서 그런 것 같다.
컨텍스트 스위칭 비용 때문이 아닐까
로컬로 진행하다 보니 무리갈까봐 겁먹어서 너무 적은 요청을 보낸 것 같다.</p>
<p>k6를 이용한 성능 부하 테스트 2</p>
<blockquote>
<p><em>시나리오2</em>
200명이 20.5MB 파일을 20초 동안
/download 엔드포인트에 연속으로 던진다.</p>
</blockquote>
<p><strong>WebFlux</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/10d0051d-e2e1-47d7-83de-356068e22280/image.png" alt=""></p>
<p><strong>MVC</strong></p>
<p><img src="https://velog.velcdn.com/images/_roundtable/post/49d34b4e-30ea-4efc-ae6c-0eee159f4f2f/image.png" alt=""></p>
<p>이번에는 유의미한 결과가 나왔다.
평균 응답 시간은 비슷했다. (사실 MVC가 살짝 빠르다)
하지만 원하는 응답에 대해서만 비교했을 때 (expected_response:true)
WebFlux 8.94, MVC 10.5로 조금 더 빨랐다.</p>
<p>더 확실히 비교 가능한 점은, 실패율 (http_req_failed)이었다.
WebFlux 9.68%, MVC 52.70%로 MVC의 실패율이 훨씬 높다.</p>
<p>로컬 노트북으로 하다 보니 더 많이는 못 해봤고 일단 성능은 비슷하게 나왔지만,
200명 동시 접속으로 엄청나게 많은 요청을 보내 보니</p>
<p>확실히 더 <strong>안정적인</strong> 서비스를 유지할 수 있는 것은 WebFlux인 것 같다.</p>
<hr>
<h2 id="후기">후기</h2>
<p>WebFlux는 높은 동시성과 안정성에서 강점을 보인다. 특히 에러율이 낮고, 부하가 증가해도 응답 시간이 비교적 일정하게 유지된다.</p>
<p>비동기 논블로킹 방식의 장점을 알아볼 수 있었던 좋은 기회였다!</p>
<blockquote>
<p>이제 얼추 리액티브 프로그래밍에 살짝 발 담근 정도는 된 것 같다.
이제 진짜 적용해보자..!!</p>
</blockquote>
]]></description>
        </item>
    </channel>
</rss>