<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>scientia est potentia</title>
        <link>https://velog.io/</link>
        <description>https://kimd0ngjun.tistory.com 로 이사감</description>
        <lastBuildDate>Mon, 19 Jan 2026 11:41:17 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>scientia est potentia</title>
            <url>https://velog.velcdn.com/images/kim00ngjun_0112/profile/2ae17640-9535-4c08-a974-168f0f95b36a/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. scientia est potentia. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/kim00ngjun_0112" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[31살 비전공자의 토스뱅크 서버 개발자 신입 합격 후기(feat. YIL 2025)]]></title>
            <link>https://velog.io/@kim00ngjun_0112/31%EC%82%B4-%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90%EC%9D%98-%ED%86%A0%EC%8A%A4%EB%B1%85%ED%81%AC-%EC%84%9C%EB%B2%84-%EA%B0%9C%EB%B0%9C%EC%9E%90-%EC%8B%A0%EC%9E%85-%ED%95%A9%EA%B2%A9%EA%B8%B0feat.-YIL-2025</link>
            <guid>https://velog.io/@kim00ngjun_0112/31%EC%82%B4-%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90%EC%9D%98-%ED%86%A0%EC%8A%A4%EB%B1%85%ED%81%AC-%EC%84%9C%EB%B2%84-%EA%B0%9C%EB%B0%9C%EC%9E%90-%EC%8B%A0%EC%9E%85-%ED%95%A9%EA%B2%A9%EA%B8%B0feat.-YIL-2025</guid>
            <pubDate>Mon, 19 Jan 2026 11:41:17 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><em>정말 열심히 달렸고, 달린 길의 끝에는 새로운 출발선이 있었다
겸손해지기 위해 이제까지 달렸던 길을 다시 회고하기 위한 마지막 벨로그 글</em></p>
</blockquote>
<h2 id="2025년-이전의-나는">2025년 이전의 나는...</h2>
<p>전공은 건축이요, 직무는 교육업계였던 나는 
사실 컴퓨터는 게임용 도구로만 알던 사람이었다
속내는 뭔가 치열하지 않고 인정만 좇던 그런 사람이었다</p>
<p>그런 내가 프론트엔드 부트캠프를 우연히 접하며 개발에 진입했고
개발자가 되려는 계기를 지닌 경험을 이어 백엔드 개발자를 꿈꿨고
몇 번의 좌절 끝에 다시 재시도하고 이제야 시작에 다다르게 됐다</p>
<p>비전공자인지라 전공자 분들을 비롯해 먼저 시작하신 분들의 개발자가 되기 위한 고민과 시간의 깊이는 내가 감히 따라가지 못할 것이라 생각해서 언제나 경청하고 배우는 자세로 임했던 시간이었다</p>
<p>그렇게 백엔드 부트캠프를 마무리하고, 학습을 이어가며 2025년을 마주했다.</p>
<h2 id="차가운-취업시장-속-무기-갖추기">차가운 취업시장 속 무기 갖추기</h2>
<p>당시 사이드 프로젝트 경험은 많이 갖췄지만, 그 외의 실무에 준하는 경험이나 자격증과 같이 서류에 적었을 때 객관적으로 입증이 가능한 내용은 없었다. 연속되는 서류 탈락의 주요 원인을 곰곰히 생각했고 이내 아래와 같이 결론내렸다. </p>
<blockquote>
<p><strong>나만의 특출난 무기가 없구나</strong></p>
</blockquote>
<p>꽤나 힘들겠다고 생각했고, 이를 보완하기 위한 달리기를 준비했다.</p>
<h3 id="1-정보처리기사-자격증-취득">1) 정보처리기사 자격증 취득</h3>
<p>흔히 SI 같은 곳에서 정처기 자격증을 중요시하지 스타트업이나 자사 서비스업 기업들은 자격증을 안본다라는 말을 많이 들었다. 하지만 사실 이 의견에 크게 공감되지 않았다. 특히 비전공자라서 더더욱 그랬다.</p>
<p>왜냐하면 나는 비전공자인데, 전공자 분들은 대부분 정처기 자격증을 취득한 상태로 취준을 하시는데 내가 뭐가 잘났다고 자격증 없이도 되겠다고 생각할 수 있을까. 더군다나 없는 것보다 낫다고 생각했다. 마침 다양한 프로젝트를 경험해보며 <strong>기술이 아닌 원론에 대한 부족함</strong>을 느끼던 참이었다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/a339e08e-9a83-48dc-a656-b6dd02e93e28/image.png" alt=""></p>
<p>그렇게 올해 2025년 2월, 정보처리기사 필기를 합격했고 재수 끝에 2025년 9월 정보처리기사 실기를 합격, 최종적으로 2025년 9월에 정보처리기사 자격증을 취득했다. 단순히 자격증을 위한 암기가 아니라, 내가 개발을 학습하며 어렴풋이 접했던 내용과 공통점이 뭐였는지 고민해보는 시간이기도 했다.</p>
<h3 id="2-학점은행제-신청">2) 학점은행제 신청</h3>
<p>한창 더워지기 시작하던 늦은 5월, 정보처리기사 실기를 준비하던 와중에 대학 친구로부터 학점은행제를 알게 됐다. 친구는 전문직 자격증 응시조건을 위해 경영 전공을 신청한 경험을 공유해줬고, 거기서 관련 자격증이 이미 있으면 4년제 졸업자는 요구학점 절반 가까이는 충당이 가능하다고 들었다.</p>
<p>마침 원론적인 개발 지식에 대한 보충 필요성을 느끼기도 했고, 당시의 나는 정처기 곧바로 따겠지(...)라는 착각에 <strong>학점은행제 컴퓨터공학 전공(학사 타전공)</strong>를 덜컥 신청해버렸다. 결과적으로 9월에 자격증을 취득해 학점에 추가할 수는 있었다.</p>
<p>물론 학점은행제를 수강하고 수료까지 간다고 해서 내가 전공자가 된다는 오만한 생각은 결코 아녔다. <strong>원론 지식들(운영체제, 네트워크, 데이터베이스, 알고리즘 등등...)에 대한 대학 과정의 커리큘럼을 확인해보며 내가 추가로 더 공부할 방향을 진단</strong>하기 위한 과정이었다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/5f67425a-6395-4b82-bb80-ede24e4ae36b/image.png" alt=""></p>
<p>현재는 전공과목 12학점을 듣고 학점인정을 대기하며 다음 학기에 어떤 과목을 수강할지 고민중에 있다. 후술할 프로젝트가 특히 운영체제와 밀접하게 관련이 있어서 운영체제 관련 과목을 들을까 싶다.</p>
<h3 id="3-공모전-프로젝트--수상">3) 공모전 프로젝트 &amp; 수상</h3>
<p>개발자가 되기 위해 가진 나의 가치관은 <strong>기여</strong>였다. 내가 구현한 서비스가 헛도는 코드 덩어리가 아닌 진정 누군가에게 기여할 수 있기 위해 고민하고 또 고민해온 시간이 많았다. 이를 실현할 수 있는 좋은 방법이 공모전이라 생각했다.</p>
<p>마침 새만금개발청에서 공공데이터 활용을 주제로 한 공모전 개최 소식을 접했고, 외주 프로젝트 당시 알게된 UX/UI 디자이너 분과 기획을 해보며 프론트엔드 개발자 인력을 구인했다.</p>
<p>사실 프로젝트 과정이 쉽지 않았다...ㅎ 중간에 인원이 탈주하질 않나, 생각보다 수집된 데이터는 분석 가치가 없질 않나 등등... 그럼에도 내가 팀 구성부터 기획까지 전부 관여하는 첫 프로젝트였기에 끝까지 책임을 지며 나를 믿고 합류해준 팀원들에게 성과를 안겨주고 싶었다. 다행히 스터디 팀원분의 도움으로 프로젝트를 잘 마무리했고 예선과 발표를 거치며 장려상이라는 수상 성과를 얻을 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/bf28f287-0a6f-4e25-9c7c-45c99a01d046/image.png" alt=""></p>
<p>개인적으로 가장 기억에 남는 프로젝트이기도 한데, <strong>백엔드 트러블 슈팅을 기술 도입이 아닌 원론 지식(특히 운영체제)의 범주 내에서 코드 리팩토링만으로 퍼포먼스를 끌어냈고, 모든 과정에 관여하며 힘들었지만 끝까지 책임지고 성과로 이어진 경험</strong>이었기 때문이다.</p>
<h3 id="4-전국단위-it-연합-동아리-참여">4) 전국단위 IT 연합 동아리 참여</h3>
<p>인턴도 금턴인 시즌(ㅠㅠ)이라 특히 실무 경험에 대해 목이 말랐다. 그래서 인턴에 그나마 준하는 동아리 활동을 탐색해봤다. 유튜브와 포털 검색을 통해 디프만, 넥스티즈, 메쉬업, YAPP 등의 다양한 동아리를 알게 됐고 그중 신청 시즌이 가장 가까웠던 <strong>DND</strong>에 지원 서류를 넣었다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/ac81bd6e-e819-4ae9-9a84-45a0e3020d47/image.png" alt=""></p>
<p>유달리 추운 12월이 되고, 동아리 합격 메일을 받아서 DND 14기에 합류하였다. 그렇게 만난 현재 팀원들과 열심히 우리 서비스를 위한 기획과 기능 정의 중이다. 아쉽게도 서류에 작성하진 못했지만 회의를 시작해보며 내가 개발자가 된 계기를 다시 되돌아보면서 열정을 자극할 수 있었고, 앞으로 좋은 기억으로 남을 듯하다.</p>
<h2 id="토스뱅크-채용-프로세스">토스뱅크 채용 프로세스</h2>
<p>위의 경험들 외에도 많은 경험들을 쌓으며, 정말로 의미있는 개발자가 되기 위한 밑바탕을 쌓고 이를 서류에 녹여내기 위해 수많은 고민과 회고를 반복했다. 정말 거짓말 안하고 이력서는 50번 넘게 갈아엎었고 포트폴리오도 지원 회사별로 전부 따로 만들어 PDF로 저장하는 등... 경험 갖추기보다 이를 문서화하는 작업이 더 힘들었다. 덕분에 <strong>개발일지의 중요성</strong>에 대해 깨닫기도 했다.</p>
<p>그렇게 한파가 일찍 다다른 2025년 11월, <strong>토스뱅크 여신사후관리 서버 개발자 파트</strong>에 지원하였다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/458a546c-7ccc-403b-81c7-e53e23d5dfc6/image.png" alt=""></p>
<h3 id="1-서류-전형">1) 서류 전형</h3>
<p>지원하기 전, 내가 지원하는 파트와 직무에 대해 곰곰히 생각해봤다. <strong>내가 여기서 뭘 하고 싶은건지, 어떻게 기여할 수 있는지, 그 기여하고자 하는 역량과 경험을 비롯한 나의 의사표현이 최대한 가독성 있게 내 서류에 잘 녹여져 있는지.</strong></p>
<p>그렇게 지원 서류를 넣었고, 약간의 기다림 끝에 추위가 심해지는 2025년 12월 서류합격 메일을 받으며 본격적인 면접 전형으로 진입하게 됐다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/94b0e3d9-13ee-46e4-8e78-fafcd791bcde/image.png" alt=""></p>
<h3 id="2-사전인터뷰--직무인터뷰">2) 사전인터뷰 &amp; 직무인터뷰</h3>
<p>면접은 총 3번 진행됐는데 그중 1, 2차가 기술직무와 관련된 면접이었다.</p>
<p>정확히는 나의 서류에 대한 이해도를 확인하는 <strong>사전인터뷰</strong>와 내가 진행한 프로젝트 및 기술적 역량에 대한 이해도를 검증하는 <strong>직무인터뷰</strong>를 진행하였다. 진행 프로세스는 토스에서 공개하는 프로세스와 차이점이 없었다.</p>
<p>먼저 사전인터뷰가 짧게 진행됐으며, 결과는 곧바로 다음날 메일과 유선으로 안내되면서 본격적인 직무인터뷰 단계가 진행됐다. 사실 토스에 도전하는 것이 이번이 처음은 아니었다. 대부분은 서류 탈락이었고 최고로 높게 간 것도 직무인터뷰였어서, 해당 단계의 어려움과 악명(?)에 대해서는 이미 경험한 바가 있었다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/a72c7625-8283-4c31-b575-6644b3987d04/image.png" alt=""></p>
<p>직무인터뷰 일정 선택 옵션은 상당히 넓었다. 12월 말부터 1월 중순까지 있었는데, 알고보니 토스는 12월 말이 오프윅 기간이라 이 기간에는 채용 프로세스가 중단돼서 그러했다. 처음에는 기간을 길게 잡고 준비를 꼼꼼하게 할까... 라는 생각에 가장 늦은 날짜를 선택하려 했지만, 이내 <strong>직무인터뷰는 공부를 해서 임하는 면접이 아니라 평소의 기술의사결정능력이 밑바탕이 되는 면접</strong>이라 생각해 최대한 빠른 날짜로 선택하면서 12월 말로 직무인터뷰 일정이 정해졌다.</p>
<p>길다면 길고 짧다면 짧은 준비기간이었지만 다시 내 이력서와 포트폴리오를 철저히 분석하고 직접 펜으로 그어가면서 내가 미숙한 개념들을 보완해나갔다. 그러면서 내가 당시 결정했던 기술전략을 곰곰히 회고하며 뭐가 옳았고 뭐가 아쉬웠는지 정리해갔다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/d4ad6042-2555-4509-9366-3d940c452aa2/image.png" alt=""></p>
<p>그렇게 대망의 면접날, 3명의 면접관님들이 들어오셔서 화상으로 인터뷰를 진행했고 정말 혼신을 다해서 면접관님들의 날카로운 질문들을 대답하고, 또한 역으로 내 생각을 어필하고 그에 대한 근거를 제시하며 약 2시간 가량을 진행하였다.</p>
<p>개인적으로 모든 프로세스 중에서 가장 난이도가 높은 단계가 아니었나 싶다. 단순히 암기처럼 생각한 기술이론에 대해 점검을 넘어서 그 사고과정까지 낱낱이 드러내게 하는 면접관님들의 높은 질문수준에 당황과 감탄을 같이 하며 꼭 토스뱅크에 합류하고 싶다는 의지를 다시 다짐하는 순간이기도 했다.</p>
<p>2시간을 꽉 채운 면접을 마무리하고 너무 탈진해서 곧바로 잠들 정도로 정말 열심히 임했다. 면접이 끝나고 <strong>내 모든 걸 쏟아냈다</strong>라는 느낌이 들었다. 시간이 지나며 아쉬움이 느껴지는 부분도 있었지만, 적어도 모르는 걸 모르는 것에 그치지 않고 내가 아는 한에서 최대한 정답이 아니어도 최선에 가까워지는 전략을 도출하려 했다. 그래서 아쉬움보다는 보완 의지가 더 크게 느껴졌던 단계였다.</p>
<p>그렇게 면접을 보고 이틀 후, 유선으로 직무인터뷰 합격 문자가 왔고 곧이어 문화적합성 인터뷰 안내 메일이 도착하면서 토스뱅크 합류의 마지막 단계를 앞두게 됐다.</p>
<h3 id="3-문화적합성-인터뷰">3) 문화적합성 인터뷰</h3>
<p>사실 토스뱅크 말고 다른 기업들의 면접은 기술직무와 컬쳐핏이 같이 합쳐진 경우가 많았다. 그래서 컬쳐핏만을 다루는 면접은 익숙하지 않았다. 직무인터뷰가 난이도가 가장 높은 인터뷰였다면 문화적합성 인터뷰는 나에게 가장 낯선 인터뷰였다.</p>
<p>그래서 우선은 토스의 문화적합성 인터뷰 후기들을 찾아보며 어떤 경험인지를 파악하려 했으나 합격 사례나 불합격 사례나 전부 공통적으로 <strong>자신의 경험에 솔직하라</strong>는 교훈적 후기들만 있었다. 매우 당황스럽기 그지없었다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/e664196c-16a3-4404-8786-b3d42af42e8c/image.png" alt=""></p>
<p>면접 일정은 2026년 1월 중순으로 잡게 됐고, 오프윅 기간이 겹치며 생각보다 꽤 긴 준비기간을 확보했지만 오히려 마음이 편치 않았다. <strong>평생을 학습에 익숙하게 살아온 내가 나 자신을 되돌아본다는 것이 무언가 부끄럽기도 하고 자꾸만 편집하려 하는 게 스스로 느껴졌기 때문</strong>이었다.</p>
<p>또한 자꾸만 나태해질까 겁이 났다. 뚫지 못할 거라 막연히 생각한 직무인터뷰를 통과했다는 사실 자체가 나한테 감격스러웠기 때문에 나도 모르게 안주하려는 것이 느껴졌다. 하지만 정상을 찍는 것과 중턱을 찍는 것은 다른 법. 이내 다시 채찍질을 하고 귀를 닫아 나 자신에게 집중해보며 재야의 종소리 없는 새해를 맞이하였다.</p>
<p>하지만 혼자서 나의 경험과 장단점 등, 나 자신을 내가 진단하려 하는 것이 뭔가 객관적이지 못하다는 생각으로 이어졌다. 그래서 같이 스터디를 진행한 팀원 분, 개발자 커뮤니티에서 만난 분, 링크드인을 통해 토스뱅크에서 근무하시는 분 등등 <strong>많은 분들께 나 자신을 공유하고 솔직하게 여쭤보며 메타인지의 도움</strong>을 얻었다.</p>
<p>그와 동시에 경험 정리는 말 그대로 경험에만 그쳤고, 이를 추상화해 나의 가치관과 개발자로서 이루고 싶은 점의 일관성과 공통점에 대해 진지하게 고민하기를 반복했다. 이와 더불어 내가 꼭 면접관님께 전하고 싶은 나의 생각들도 정리해보며 면접 준비를 마무리하고 대망의 면접일이 다가왔다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/073dacf4-c95d-40ee-a7ae-18f2f3200ded/image.png" alt=""></p>
<p>문화적합성 면접은 약 1시간 반 가량 동안 일대일로 진행됐다. 중간중간 면접관님이 던지신 아이스브레이킹 식의 질문도 곁들여졌지만, 본격적인 질문들 하나하나가 곧바로 대답을 하지 못하게 하는 질문이었다. 내가 스크립트마냥 준비한 내용들도 정말 진심일까를 다시 고민하게 하는 질문들도 많았고, 내가 중요하게 여기지 않은 경험에 대해 파고드는 질문들도 많았다.</p>
<p>그럼에도 나의 생각과 개발자로서의 가치관, 그리고 이를 어떻게 토스뱅크 여신사후관리 파트에서 활용하고 기여할 수 있는지를 최대한 질문에 대한 대답으로 녹여 전달하려고 노력했다. <strong>단순히 말뿐만이 아닌, 관련된 경험을 곁들이며 내 진정성을 면접관님께 전달</strong>드렸다.</p>
<p>그렇게 1시간 30분을 살짝 넘기며 면접이 끝났다. 스터디카페에서 나서며 드는 생각은, 메타인지의 중요성이었다. 단순히 누군가에게 나를 소개하기 위함이 아닌, 나 스스로의 반성에 대한 기준 확립의 시작이 곧 나 자신에 대한 인지와 인정이라는 생각과 아쉬움을 동시에 느끼며 토스뱅크 지원 및 면접 프로세스를 마무리했다.</p>
<h3 id="4-토스뱅크-최종-합격">4) 토스뱅크 최종 합격</h3>
<p>면접 후 하루가 넘게 지나도 연락이 없어서 처음에는 의연한 척하며 떨어졌다 생각하고 애써 신경쓰지 않으려 했지만 모든 일에 집중이 되지 않았다. 그만큼 진심이었고, 정말 모든 것을 쏟아낸 과정이었다.</p>
<p>그렇게 이틀 후 점심, 채용담당자님께 유선으로 최종합격 연락을 받았다. 처음에는 불합격 안내로 알고 덤덤하게 보려고 했는데 최종합격 안내라는 사실에 몇 번이고 재확인하며 직접 전화로 확인까지 받고 나서야 실감이 났다. 곧바로 나를 위해 매번 기도해주시고 걱정해주신 부모님께 연락드렸다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/60042547-4e58-47d1-8511-74488bbef6a4/image.png" alt=""></p>
<p>구글 검색을 하면 나오는 합격자들의 <strong>Ready for Toss</strong> 문구의 메일을 나도 받았지만, 아직은 얼떨떨하고 꿈인듯 하다. 과분한 선물이자 나 자신을 다듬기 위해 새롭게 주어진 기회라고 생각한다.</p>
<p>나는 신입이기 때문에 별도의 레퍼런스 체크 없이 처우협의 단계로 넘어가 입사일 확정 이후 최종합격 안내를 메일로 받았다. 그렇게 현재 글을 쓰고 있는 2026년 1월, 나는 토스뱅크 입사를 약 2주 가량 앞두고 있다.</p>
<h2 id="2026년의-나는">2026년의 나는...</h2>
<p>누군가는 노력에 대한 보답이라 말하고, 누군가는 어쩌면 당연한 결과였다고 말해주지만 나는 운이 좋았다고 생각한다. 취업은 운칠기삼이라는 말이 있듯이, 분명 운이 많이 따라줬다고 생각해서 의도적으로 겸손해지려고 한다. 여전히 전공자 분들과 먼저 개발을 시작하신 분들에 비해 나는 아직도 부족하다고 여기며 스스로 상기하려 한다.</p>
<p>그래서 2026년의 나는 여전히 노력하고 부족한 점을 보완하는 개발자가 되려고 한다. 토스뱅크에 합류하면 분명 많은 것들을 익히고 배워야겠지만 내가 갖춘 역량으로 어떻게 기여할지도 끊임없이 고민하는 시기가 될 것이다. 이를 위해 입사 전부터 어떤 것들을 갖출지 정리해보려 한다.</p>
<h3 id="1-코틀린-학습하기">1) 코틀린 학습하기</h3>
<p>직무 내용에 MSA 마이그레이션이 있다. 자바 레거시를 코틀린 기반으로 이전하기 때문에 코틀린의 심도 있는 학습이 요구된다. 코틀린은 문법은 간단히 다뤄봤지만 자바와의 매커니즘적 차이점, 바이트코드 변환 시의 취급 등에 대해서 깊게 학습해야겠다는 필요성을 느꼈다.</p>
<h3 id="2-영어-공부하기">2) 영어 공부하기</h3>
<p>단순히 어학 실력을 기르는 것이 아닌, 공식 문서 리딩과 개발자와의 의사소통 수단 확장의 목적으로 영어를 공부하려고 한다. 리딩은 어느 정도 되지만 여전히 번역기의 도움을 받고 있고, 스피킹과 리스닝이 무척 부족해서 이 점을 집중적으로 보완하며 영어 공부를 병행하려 한다.</p>
<h3 id="3-원론과-문제-정의-역량-기르기">3) 원론과 문제 정의 역량 기르기</h3>
<p>여전히 개발자에게 중요한 능력은 문제 정의와 이를 위한 해결 역량을 다양하게 갖추는 것이라고 생각한다. 그래서 컴퓨터의 기본 원론에 가까운 지식들에 대해 공부하는 시간을 가지려 한다. 문제 정의의 시작은 기술 도입이 아닌 원론 지식 범위에서의 인과관계 진단이 우선이므로(특히 공모전 프로젝트를 하면서 더욱 느꼈다) 이를 중요시하려 한다.</p>
<h2 id="마치며">마치며</h2>
<p>이 글은 나를 되돌아보기 위한 후기 겸 회고글이다.
모든 것이 어렵고 불확실한 시기고 나 역시 그러했고 어쩌면 앞으로도 그럴 것이다.
그래서 공부를 할 때, 더욱 절박하게 조급하게 공부를 해오면서 나 자신을 돌보지 않은 것 같다.</p>
<p>어쩌면 나같은, 아니면 나보다 더 절박한 분들도 있을 것이다.
그런 분들께, <strong>당신은 생각보다 더 뛰어난 사람이고 더 잘될 것임이 당연하다</strong>는 얘기를 해주고 싶다.
나를 자랑하려는 것이 아닌, 나 역시 겪어봤기에 내 경험이 조금이나마 도움이 되고픈 마음이다.</p>
<p>사실 저 말은 과거의 나에게 하고 싶은 말이기도 하다.
<strong>쉬어도 되지만, 안주하진 말고. 언제나 겸손하지만, 자책하지 말자고.</strong></p>
<hr>
<p>벨로그는 이 글을 마지막으로 포스팅을 마무리하려 한다.
앞으로는 <a href="https://kimd0ngjun.tistory.com/">티스토리</a>에서 블로깅과 포스팅을 진행해보려 한다.
물론 아직 만들어두기만 하고 꾸미질 않아서... 포스트가 언제 업로드 될지는 모르겠다.</p>
<p>수고 많으셨습니다 다들:)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[백준 + 프로그래머스 + 리트코드, 깃허브로 관리하기
(feat. Github Actions)]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EC%BD%94%ED%85%8C-%EB%AC%B8%EC%A0%9C%ED%92%80%EC%9D%B4-%EA%B9%83%ED%97%99-%EB%A0%88%ED%8F%AC%EC%97%90-%EC%A0%95%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EC%BD%94%ED%85%8C-%EB%AC%B8%EC%A0%9C%ED%92%80%EC%9D%B4-%EA%B9%83%ED%97%99-%EB%A0%88%ED%8F%AC%EC%97%90-%EC%A0%95%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 21 Jul 2025 09:34:44 GMT</pubDate>
            <description><![CDATA[<p><em>급한 분은 <a href="#5-%EA%B2%B0%EB%A1%A0">여기</a>를 눌러서 따라하시면 됩니다</em></p>
<h1 id="0-사실은-leethub-이슈">0. 사실은 LeetHub 이슈</h1>
<p><strong>백준허브</strong>는 깃헙과 연동해 백준, 프로그래머스에서 푼 코딩테스트 문제풀이를 지정한 깃헙 레포의 메인 브랜치에 자동 푸시해주면서 풀이 과정을 쉽게 관리하는 익스텐션이다. 이름과 다르게 프로그래머스 풀이도 같이 관리해주며, 단순히 풀이 파일만 올리지 않고 난이도별로 디렉토리를 개별 설정해서 깔끔하게 보관해주는 아주 유용한 확장 프로그램이다.</p>
<p>백준, 프로그래머스 외에도 LeetCode도 많이 애용되는 코테 풀이 플랫폼인데, 얘도 백준허브처럼 깃헙 레포에 풀이를 푸시하고 관리하는 <strong>LeetHub</strong>라는 익스텐션도 존재한다. 근데 얘는 백준허브랑 다르게 그냥 레포에 풀이 파일 디렉토리만 그대로 푸시한다.</p>
<p>둘을 별개의 깃헙 레포들로 나눠서 관리한다면 큰 문제는 아니겠지만, 알고리즘 풀이라는 동일 도메인(?)의 관점에서 하나의 레포로 관리하는 게 보기도 편하고 관리도 쉽겠다는 생각이 들어서, 백준허브와 리트허브를 연동할 레포를 한 곳으로 통일 지정할 수는 있다. 다만 그렇게 되면...</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/891b4f07-ad96-4229-a7d2-9117b93e5098/image.png" alt=""></p>
<p>위처럼 <code>0066-plus-one</code> 이라는 패키지는 리트코드에서 푼 문제풀이인데 <strong>플랫폼별 + 난이도별 디렉토리를 세팅해 푸시해주는 백준, 프로그래머스와는 다르게 루트 디렉토리에 바로 풀이를 넣어</strong>버려서 관리가 상당히 지저분해진다.</p>
<p>그래서 이 둘의 푸시 방식을 통일해서 깃헙 레포를 깔끔하게 관리하기 위해 LeetHub 관련 커밋을 추적해 디렉토리를 정리하는 <strong>Github Actions 스크립트</strong>를 작성하며 문제를 해결해보고자 한다.</p>
<blockquote>
<h4 id="목표-정리">목표 정리</h4>
</blockquote>
<ol>
<li>백준(프로그래머스) 풀이와 리트코드 풀이를 한 곳에서 같이 관리하고 싶다</li>
<li>백준허브의 난이도별 디렉토리 생성 관리 방식을 리트허브에도 적용하고 싶다</li>
</ol>
<p><del><em>공모전 후기도 작성해야 되는데...</em></del></p>
<h1 id="1-leethub의-커밋-추적">1. LeetHub의 커밋 추적</h1>
<h2 id="1-기존-leethub-관리-이슈-해결책">1) 기존 LeetHub 관리 이슈 해결책</h2>
<p>사실 예전에 이 문제를 맞닥뜨리고 레퍼런스 찾아가며 해결한 적이 있었다. 다만 그때는 리트허브 자체의 소스코드를 건드려서 해결했고, 수정된 소스코드를 임의로 확장자에 적용해서 <strong>버전 업데이트에 취약하고, 리트코드 UI가 구 버전이어야 가능</strong>하다는 단점이 있었다. 실제로 현재 리트허브 소스코드 수정본 적용이 막혀서 내 나름의 해결책을 생각한 것.</p>
<p><em><a href="https://sozerodev.tistory.com/197">리트허브 연동 에러와 관련된 포스팅</a></em></p>
<p>어찌됐든 소스코드를 건드리는 것보단 그냥 받아오는 내 측에서 관리하되, 귀찮은 검수 과정을 자동화시키는 것을 해결 방향으로 잡았다.</p>
<h2 id="2-리트허브-커밋-메세지-추적하기">2) 리트허브 커밋 메세지 추적하기</h2>
<p>이미 백준허브와 리트허브를 하나의 레포에 연동시켰을 것이다. 혹시 그걸 아직 못했으면 아래의 포스팅들 참고</p>
<p><em><a href="https://velog.io/@kupulau/%EB%B0%B1%EC%A4%80-%ED%97%88%EB%B8%8C%EC%99%80-LeetHub">백준허브와 리트허브 연동하기</a></em></p>
<p>일단 백준허브는 관리가 의도대로 잘 동작하니, 백준허브의 커밋 푸시는 우리의 자동화 구축 대상에서 제외해야 된다. 즉, 리트허브와 백준허브의 커밋 푸시를 선별해야 한다. 그래서 커밋 로그를 뒤져봤다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/83483eb8-7ad1-4dc6-b75a-7e4b6890cab2/image.png" alt=""></p>
<p>위의 로그는 프로그래머스 풀이를 푸시한 백준허브의 커밋 로그고, 밑의 2개 로그는 리트코드 풀이를 푸시한 리트허브의 커밋 로그다. <strong>커밋 메세지를 보면 마지막에 어디서 푸시했는지 기재되어 있다.</strong> 이걸 통해서, 커밋 메세지의 마지막을 서브스트링하여 백준허브 푸시와 리트허브 푸시를 분별할 수 있다!</p>
<h2 id="3-github-actions-스크립트-세팅">3) Github Actions 스크립트 세팅</h2>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/6a281874-d1d9-41d5-a251-c6c0045c0e7e/image.png" alt=""></p>
<p>일단 깃헙액션 스크립트를 세팅했다. 세팅 방법은 깃헙 레포의 Actions 탭으로 접속하거나, 아니면 직접 메인 브랜치의 루트 디렉토리에서 Add file을 누르고 <code>/.github/workflows/&lt;원하는 파일명&gt;.yml</code>을 작성하면 자동으로 스크립트 작성 준비가 끝난다.</p>
<p>jobs와 steps를 배치하고 최근 커밋의 메세지 로그를 추적하는 스텝을 작성하고 문제를 풀어 푸시해보았다.</p>
<pre><code class="language-yml">- name: Check if LeetHub Commit
  id: check_leethub
  run: |
    COMMIT_MSG=$(git log -1 --pretty=%B | tail -n 1)
    echo &quot;Latest commit message: $COMMIT_MSG&quot;

    # 와일드카드 + &quot;- LeetHub&quot; 검증
    if [[ &quot;$COMMIT_MSG&quot; == *&quot;- LeetHub&quot; ]]; then
      echo &quot;LeetHub commit detected ✅&quot;
    else
      echo &quot;Not a LeetHub commit ❌&quot;
    fi</code></pre>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/c69c55bb-100d-463d-8418-f0342c3f1090/image.png" alt=""></p>
<p>메세지 문자열을 잘 읽어오는 것이 확인된다!</p>
<h1 id="2-leetcode-풀이-디렉토리-정리">2. LeetCode 풀이 디렉토리 정리</h1>
<h2 id="1-폴더-디렉토리-옮기기">1) 폴더 디렉토리 옮기기</h2>
<p>깃헙 액션의 최우선 목적은 리트허브의 푸시를 확인하면 이를 루트 패키지에서 <code>/LeetCode</code>라는 내가 생성한 폴더로 옮겨야 한다. 일단 메인 브랜치에서 <code>/LeetCode</code>라는 이름으로 디렉토리를 하나 생성해서 안에 마크다운 파일 아무거나 생성해서 푸시해두자.</p>
<p>새로운 리트코드 문제 풀이가 푸시되면 상황은 다음과 같다.</p>
<blockquote>
<ol>
<li>루트 디렉토리에 원래 <code>/백준</code>,<code>/프로그래머스</code>, <code>/LeetCode</code>, <code>/.github</code>가 있었음</li>
<li>문제 풀이된 새로운 디렉토리가 확인됨</li>
<li>이 새로운 디렉토리를 <code>/LeetCode</code>로 옮겨야 함</li>
</ol>
</blockquote>
<p>이 과정이 이뤄지려면 스크립트에게 <strong>옮겨야 할 디렉토리명</strong>을 알려주고 옮기도록 해야 한다. 새로 추가된 디렉토리가 리트허브로부터 추가됐는지 확인하는 건, 원래 존재하던 디렉토리의 명칭과 다른 것인지 확인하면 된다.</p>
<pre><code class="language-yml">- name: Detect New Solve Package
  id: detect_package
  if: steps.check_leethub.outputs.should_run == &#39;true&#39;
  run: |
    EXCLUDE_DIRS=(&quot;LeetCode&quot; &quot;백준&quot; &quot;프로그래머스&quot; &quot;.github&quot;)
    NEW_DIRS=$(git diff --name-only HEAD~1 HEAD | awk -F/ &#39;NF==2 {print $1}&#39; | sort -u)

    for DIR in $NEW_DIRS; do
      SKIP=false
      for EX in &quot;${EXCLUDE_DIRS[@]}&quot;; do
        if [[ &quot;$DIR&quot; == &quot;$EX&quot; ]]; then
          SKIP=true
          break
        fi
      done

      if [[ &quot;$SKIP&quot; == false ]]; then
        echo &quot;새로 추가된 문제풀이 패키지명: $DIR&quot;
        echo &quot;PACKAGE_NAME=$DIR&quot; &gt;&gt; $GITHUB_OUTPUT
        break
      fi
    done</code></pre>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/f6da1da9-b979-4eee-8c6f-8f04a79793c4/image.png" alt=""></p>
<p>새로 추가된 디렉토리 패키지명을 추출해오는 것이 확인된다. 이걸 바탕으로 LINUX 명령어를 활용해 <code>/LeetCode</code> 경로로 풀이 패키지를 이동한다.</p>
<pre><code class="language-yml">jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      should_run: ${{ steps.check_leethub.outputs.should_run }}
      package_name: ${{ steps.detect_package.outputs.PACKAGE_NAME }}
      commit_msg: ${{ steps.check_leethub.outputs.commit_msg }}

    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          fetch-depth: 2 # 이전 로그 2개까지 해서 변경점 비교(디폴트는 1)

      # ...

      - name: Detect New Solve Package
        id: detect_package
        if: steps.check_leethub.outputs.should_run == &#39;true&#39;
        run: |
          EXCLUDE_DIRS=(&quot;LeetCode&quot; &quot;백준&quot; &quot;프로그래머스&quot; &quot;.github&quot;)
          NEW_DIRS=$(git diff --name-only HEAD~1 HEAD | awk -F/ &#39;NF==2 {print $1}&#39; | sort -u)

          # ...

  move:
    needs: detect
    if: ${{ needs.detect.outputs.should_run == &#39;true&#39; &amp;&amp; needs.detect.outputs.package_name != &#39;&#39; }}
    runs-on: ubuntu-latest

    steps:
      - name: Move Directory
        run: |
          PACKAGE_NAME=&quot;${{ needs.detect.outputs.PACKAGE_NAME }}&quot;
          COMMIT_MSG=&quot;${{ needs.detect.outputs.commit_msg }}&quot;
          echo &quot;옮겨야 할 패키지 확인: $PACKAGE_NAME&quot;

          # 실제 이동
          git mv &quot;$PACKAGE_NAME&quot; &quot;LeetCode/$PACKAGE_NAME&quot;

      - name: Commit and Push

      # ...</code></pre>
<h2 id="2-git-커밋--푸시-추가">2) Git 커밋 &amp; 푸시 추가</h2>
<p>깃헙 저장소니까 폴더를 옮기면 다시 깃을 커밋하고 푸시해줘야 최종 결과가 반영된다. 이 단계까지가 MVP에서 깃헙 액션이 담당하는 부분이다. 깃헙 액션이 우리의 깃 레포를 조작하려면 일단 <strong>권한을 개방</strong>하고 시크릿 변수에서 기본 제공하는 <strong>깃헙 토큰</strong>을 적용해야 한다.</p>
<pre><code class="language-yml">  steps:
    - name: Checkout
      uses: actions/checkout@v3
      with:
        token: ${{ secrets.GITHUB_TOKEN }}

    - name: Move Directory
      run: |
        PACKAGE_NAME=&quot;${{ needs.detect.outputs.PACKAGE_NAME }}&quot;
        echo &quot;옮겨야 할 패키지 확인: $PACKAGE_NAME&quot;

        # 실제 이동
        git mv &quot;$PACKAGE_NAME&quot; &quot;LeetCode/$PACKAGE_NAME&quot;

    - name: Commit and Push
      run: |
        COMMIT_MSG=&quot;${{ needs.detect.outputs.commit_msg }}&quot;
        echo &quot;커밋 메세지 확인: $COMMIG_MSG&quot;

        git config --global user.name &quot;&lt;사용자 깃허브 닉네임&gt;&quot;
        git config --global user.email &quot;&lt;사용자 깃허브 가입 이메일&gt;&quot;

        git add .
        git commit -m &quot;$COMMIT_MSG&quot; || echo &quot;No changes to commit&quot;
        git push</code></pre>
<p>커밋 메세지는 이전 커밋 분석에서 커밋 메세지를 추출해 변수로 저장해 여기서 활용했고, 깃 조작을 위한 사용자 닉네임과 가입 이메일을 기입해주면 깃 조작(add, commit, push)가 가능해진다.</p>
<p>여기까지 완료했으면 리트코드 풀이를 별도 디렉토리에서 관리한다는 우리의 MVP는 아래처럼 달성된다. 여기까지만 해도 백준허브 관리와 동일해져서 보기가 깔끔해진다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/b6a99dab-39fd-4473-b296-268492486573/image.png" alt=""></p>
<h1 id="3-추가-케이스---문제-다시-풀기">3. 추가 케이스 - 문제 다시 풀기</h1>
<p>백준이나 프로그래머스, 리트코드 전부 풀었던 문제를 다시 풀 수도 있고 언어를 다르게 해서 언어별 풀이를 추가할 수도 있다. 이 상황에서 MVP로 완성한 깃헙 액션 스크립트를 적용한다면?</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/30c0fe2b-c8e6-4a40-84c7-e72bbb354bb5/image.png" alt=""></p>
<p>간단한 회문(펠린드롬) 문제의 최적화 코드를 한 줄 추가한 풀이 업데이트를 수행하고 리트허브로 푸시했다.</p>
<h2 id="1-문제-상황---폴더가-중복으로-생성">1) 문제 상황 - 폴더가 중복으로 생성</h2>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/3b5c16b1-c5ed-4bd3-b38a-009529b98a2f/image.png" alt=""></p>
<p>보다시피 풀이 디렉토리가 또 추가돼서 기존 디렉토리 안에 생성된 것이 보인다. </p>
<p>디렉토리 이동에 쓰인 LINUX 명령어 <code>mv</code>는 디렉토리에 있어서는 덮어쓰기가 적용되지 않기 때문에 현재 깃헙에서처럼 재귀 생성이 발생하거나 오류가 발생할 수 있다. 즉, <strong>이미 존재하는 풀이 디렉토리인지를 먼저 확인하고 존재한다면 파일만 옮겨야 한다.</strong></p>
<p>다행히도 파일에 대한 <code>mv</code> 명령어 적용은 파일 덮어쓰기가 적용되므로 디렉토리 존재 여부만 파악하면 된다.</p>
<h2 id="2-디렉토리-검증-로직-추가">2) 디렉토리 검증 로직 추가</h2>
<pre><code class="language-yml">- name: Move Directory
  run: |
    PACKAGE_NAME=&quot;${{ needs.detect.outputs.PACKAGE_NAME }}&quot;
    DEST_DIR=&quot;LeetCode/$PACKAGE_NAME&quot;
    echo &quot;옮겨야 할 패키지 확인: $PACKAGE_NAME&quot;

    if [ -d &quot;$DEST_DIR&quot; ]; then
      echo &quot;$DEST_DIR 디렉토리가 이미 존재, 내부 파일만 이동&quot;
      # 내부 파일만 이동
      mv &quot;$PACKAGE_NAME&quot;/* &quot;$DEST_DIR&quot;/
      rm -r &quot;$PACKAGE_NAME&quot;
    else
      echo &quot;$DEST_DIR 디렉토리가 존재하지 않음(처음 푼 문제), 디렉토리 전체 이동&quot;
      mv &quot;$PACKAGE_NAME&quot; &quot;LeetCode/&quot;
    fi</code></pre>
<p>파일만 옮기게 되면 루트 위치에 기존 디렉토리가 남게 되므로 해당 디렉토리를 삭제해준다. 내부 파일이 없으면 깃 적용 대상이 아니긴 해도 예외를 대비하기 위한 <code>rm</code> 명령어를 추가한다.</p>
<h2 id="3-테스트-확인">3) 테스트 확인</h2>
<h3 id="1-기존-풀이-업데이트">(1) 기존 풀이 업데이트</h3>
<table>
<thead>
<tr>
<th>기존 풀이<img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/550adabb-ae13-4472-9065-d105cebfdda7/image.png" alt=""></th>
<th>→</th>
<th>새로운 풀이 업데이트<img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/1b31bd3a-fce0-4e71-9670-27d902547761/image.png" alt=""></th>
</tr>
</thead>
</table>
<p>기존 문제 풀이에서 코드를 추가하여 새로운 풀이를 적용해보니 파일이 덮어씌워지면서 잘 적용된다.</p>
<h3 id="2-새로운-언어-풀이-추가">(2) 새로운 언어 풀이 추가</h3>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/56d92f89-2f12-484f-b613-5e950e226d9d/image.png" alt=""></p>
<p>테스트를 위해 회문 문제를 이번엔 자바로 풀어서 파일을 추가하는 경우를 테스트해본다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/8d615c68-3022-4b10-8b3a-9d6244269552/image.png" alt=""></p>
<p>자바 파일이 파이썬 풀이와 동일한 디렉토리 위치에 잘 추가됐다.</p>
<h1 id="4-추가-케이스---난이도별-디렉토리-추가">4. 추가 케이스 - 난이도별 디렉토리 추가</h1>
<h2 id="1-문제별-난이도-추적">1) 문제별 난이도 추적</h2>
<p>백준, 프로그래머스처럼 리트코드도 문제별 난이도를 분류해두고 있다. 그래서 리트허브가 푸시할 때 아마 문제 관련 정보도 반영해서 커밋할 것으로 생각했고, 실제로 문제 설명과 관련된 <strong>마크다운 리드미</strong>를 같이 디렉토리에 추가해서 커밋한다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/5327452c-3b7f-4919-9de2-47301599cfc5/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/100193c0-8904-49db-9b21-d6fdf461b971/image.png" alt=""></th>
</tr>
</thead>
</table>
<p>해당 리드미에 난이도 레벨이 기재되어 있고, 마크다운을 뜯어보니 첫 번째 라인의 h3 태그로 감싸져있는 것을 확인했다. 즉, 이 난이도 정보를 활용해서 난이도별 디렉토리를 추가 구축할 수 있을 것이다.</p>
<h2 id="2-커밋--푸시-방식-분석">2) 커밋 &amp; 푸시 방식 분석</h2>
<p>리트허브의 푸시는 백준허브처럼 문제 타이틀을 패키지명 삼아, 내부에 <strong>풀이 파일, 마크다운 파일 최대 2개</strong>를 동봉한다. 근데, 이 푸시 방식이 백준허브와는 좀 다르다.</p>
<p>백준허브는 문제풀이와 마크다운을 한 번에 푸시하기 때문에 커밋이 1번만 이뤄진다. 반면, 리트허브는 문제풀이 따로 커밋하고 마크다운 따로 커밋하기 때문에 푸시를 확인해보면 커밋이 여러번 수행되는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/3c0c92cd-75bc-4d66-9757-bae39d0535ac/image.png" alt=""></p>
<p>즉, 커밋 시점에 차이가 있고 이것이 일괄적으로 관리 레포에 푸시되는 매커니즘을 가진다. 그렇기 때문에 우리가 기존 MVP 과정에서 추적한 커밋 시점에 리드미가 존재하는지를 보장해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/9802eeee-3435-406e-b8af-0885a5546db7/image.png" alt=""></p>
<p>현재 리트허브 버전에서의 커밋 로그는 전부 리드미 관련 커밋이 먼저 이뤄지고 풀이 커밋이 이뤄지는 순서를 가지고 있다. 즉, 커밋 로그를 깃헙 액션에서 추적할 시점에 이미 리드미를 갖고 있을 것이므로 난이도 레벨을 추출하는 데에는 문제가 없다고 판단했다.</p>
<h2 id="3-난이도-텍스트-추출--디렉토리-추가">3) 난이도 텍스트 추출 &amp; 디렉토리 추가</h2>
<pre><code class="language-yml">- name: Extract Level from README
  id: extract_level
  if: steps.check_leethub.outputs.should_run == &#39;true&#39; &amp;&amp; steps.detect_package.outputs.PACKAGE_NAME != &#39;&#39;
  run: |
    PACKAGE_NAME=&quot;${{ steps.detect_package.outputs.PACKAGE_NAME }}&quot;
    README_PATH=&quot;$PACKAGE_NAME/README.md&quot;

    if [ ! -f &quot;$README_PATH&quot; ]; then
      echo &quot;README.md 파일 없음 ❌&quot;
      echo &quot;leetcode_level=Unknown&quot; &gt;&gt; $GITHUB_OUTPUT
      exit 0
    fi

    # h3 태그 안에서 난이도 추출
    LEVEL=$(grep -oP &#39;(?&lt;=&lt;h3&gt;).*?(?=&lt;/h3&gt;)&#39; &quot;$README_PATH&quot; | head -n 1)

    # 없으면 fallback
    if [ -z &quot;$LEVEL&quot; ]; then
      LEVEL=&quot;Unknown&quot;
    fi

    echo &quot;LeetCode 문제 난이도: $LEVEL&quot;
    echo &quot;leetcode_level=$LEVEL&quot; &gt;&gt; $GITHUB_OUTPUT</code></pre>
<p>혹시 리드미를 확인하지 못할 경우를 대비해 <strong>&quot;Unknown&quot;</strong> 이라는 폴백 텍스트를 하나 추가해준다. 텍스트를 추출해 깃헙 액션의 변수에 담아 폴더 이동 job에서 활용할 수 있게 한다.</p>
<pre><code class="language-bash"># 기존 위치로 이동
if [ -n &quot;$FOUND_EXISTING_PATH&quot; ]; then
  DEST=&quot;$FOUND_EXISTING_PATH/$PACKAGE_NAME&quot;
  echo &quot;→ 기존 디렉토리로 병합 이동: $DEST&quot;
  mv &quot;$PACKAGE_NAME&quot;/* &quot;$DEST&quot;/
  rmdir &quot;$PACKAGE_NAME&quot; || true

# 새로 이동해야 할 경우
else
  DEST=&quot;$ROOT_DIR/$LEVEL_DIRECTORY/$PACKAGE_NAME&quot;
  echo &quot;→ 새 디렉토리로 이동: $DEST&quot;
  mkdir -p &quot;$ROOT_DIR/$LEVEL_DIRECTORY&quot;
  mv &quot;$PACKAGE_NAME&quot; &quot;$ROOT_DIR/$LEVEL_DIRECTORY/&quot;
fi</code></pre>
<h4 id="고려-요소---문제를-다시-풀-경우">고려 요소 - 문제를 다시 풀 경우</h4>
<p>스크립트를 작성하는 과정에서 알게 됐는데, 리트허브의 별개 커밋 정책 때문에 기존 문제풀이 업데이트에서는 리드미가 생성되지 않고 풀이만 추가된다. 즉, 해당 문제의 레벨을 업데이트 단계에서는 추적할 수 없는 것이다.</p>
<p>이렇게 되면 기존 문제 풀이와 새로운 문제 풀이가 각각 <code>/Unknown</code> 패키지와 <code>/Easy</code>(혹은 지정된 레벨) 패키지에 중복으로 생성될 것이다. 그래서 기존 디렉토리를 탐색하는 것을 아예 전체 순회 방식으로 수정했다.</p>
<pre><code class="language-bash"># 먼저 기존에 동일한 디렉토리 위치가 있는지 탐색
FOUND_EXISTING_PATH=&quot;&quot;
for dir in &quot;$ROOT_DIR&quot;/*; do
  if [ -d &quot;$dir/$PACKAGE_NAME&quot; ]; then
    FOUND_EXISTING_PATH=&quot;$dir&quot;
    echo &quot;기존 디렉토리 발견: $dir/$PACKAGE_NAME&quot;
    break
  fi
done</code></pre>
<p>해당 분기를 폴더 추가 혹은 이동 단계 이전에 배치한다.</p>
<h2 id="4-테스트-확인">4) 테스트 확인</h2>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/fd371000-2c8e-4fb5-8a67-2d60130c869e/image.png" alt=""></p>
<p>난이도에 맞춰 해당 난이도 디렉토리에 풀이 패키지가 추가되고, 풀이 업데이트도 정상적으로 잘 반영되는 것을 확인했다.</p>
<h1 id="5-결론">5. 결론</h1>
<blockquote>
<ol start="0">
<li><em><a href="https://velog.io/@kupulau/%EB%B0%B1%EC%A4%80-%ED%97%88%EB%B8%8C%EC%99%80-LeetHub">백준허브와 리트허브 하나의 레포에 같이 연동하기</a></em></li>
</ol>
</blockquote>
<ol>
<li>연동 레포의 루트 디렉토리에 <code>/LeetCode</code>를 추가한다.</li>
<li>깃헙 액션 스크립트를 세팅한다.(<code>/.github/workflows/&lt;원하는 파일명&gt;.yml</code>)</li>
<li>아래의 yml 스크립트를 적용한다. 마지막 스텝에서 깃헙 닉네임과 가입 이메일로 수정한다.(<del>벨로그 왜 토글 적용 안 됨??</del>)<pre><code class="language-yml">name: LeetHub Setting
&gt;
on:
push:
 branches: [ &quot;main&quot; ]
&gt;
permissions:
contents: write
&gt;
jobs:
detect:
 runs-on: ubuntu-latest
 outputs:
   should_run: ${{ steps.check_leethub.outputs.should_run }}
   package_name: ${{ steps.detect_package.outputs.PACKAGE_NAME }}
   commit_msg: ${{ steps.check_leethub.outputs.commit_msg }}
   level: ${{ steps.extract_level.outputs.leetcode_level }}
&gt;
 steps:
   - name: Checkout
     uses: actions/checkout@v3
     with:
       fetch-depth: 2 # 이전 로그 2개까지 해서 변경점 비교(디폴트는 1)
&gt;
   - name: Check if LeetHub Commit
     id: check_leethub
     run: |
       COMMIT_MSG=$(git log -1 --pretty=%B | tail -n 1)
       echo &quot;Latest commit message: $COMMIT_MSG&quot;
&gt;
       if [[ &quot;$COMMIT_MSG&quot; == Time:* &amp;&amp; &quot;$COMMIT_MSG&quot; == *&quot;- LeetHub&quot; ]]; then
         echo &quot;should_run=true&quot; &gt;&gt; $GITHUB_OUTPUT
         echo &quot;commit_msg=${COMMIT_MSG}&quot; &gt;&gt; $GITHUB_OUTPUT
         echo &quot;LeetHub 커밋 확인, 패키지 이사 작업 준비 ✅&quot;
       else
         echo &quot;should_run=false&quot; &gt;&gt; $GITHUB_OUTPUT
         echo &quot;commit_msg=&quot; &gt;&gt; $GITHUB_OUTPUT
         echo &quot;작업 없이 패스하면 됩니다 ❌&quot;
       fi
&gt;
   - name: Detect New Solve Package
     id: detect_package
     if: steps.check_leethub.outputs.should_run == &#39;true&#39;
     run: |
       EXCLUDE_DIRS=(&quot;LeetCode&quot; &quot;백준&quot; &quot;프로그래머스&quot; &quot;.github&quot;)
       NEW_DIRS=$(git diff --name-only HEAD~1 HEAD | awk -F/ &#39;NF==2 {print $1}&#39; | sort -u)
&gt;
       for DIR in $NEW_DIRS; do
         SKIP=false
         for EX in &quot;${EXCLUDE_DIRS[@]}&quot;; do
           if [[ &quot;$DIR&quot; == &quot;$EX&quot; ]]; then
             SKIP=true
             break
           fi
         done
&gt;
         if [[ &quot;$SKIP&quot; == false ]]; then
           echo &quot;새로 추가된 문제풀이 패키지명: $DIR&quot;
           echo &quot;PACKAGE_NAME=$DIR&quot; &gt;&gt; $GITHUB_OUTPUT
           break
         fi
       done
&gt;
   - name: Extract Level from README
     id: extract_level
     if: steps.check_leethub.outputs.should_run == &#39;true&#39; &amp;&amp; steps.detect_package.outputs.PACKAGE_NAME != &#39;&#39;
     run: |
       PACKAGE_NAME=&quot;${{ steps.detect_package.outputs.PACKAGE_NAME }}&quot;
       README_PATH=&quot;$PACKAGE_NAME/README.md&quot;
&gt;          
       if [ ! -f &quot;$README_PATH&quot; ]; then
         echo &quot;README.md 파일 없음 ❌&quot;
         echo &quot;leetcode_level=Unknown&quot; &gt;&gt; $GITHUB_OUTPUT
         exit 0
       fi
&gt;
       # h3 태그 안에서 난이도 추출
       LEVEL=$(grep -oP &#39;(?&lt;=&lt;h3&gt;).*?(?=&lt;/h3&gt;)&#39; &quot;$README_PATH&quot; | head -n 1)
&gt;
       # 없으면 fallback
       if [ -z &quot;$LEVEL&quot; ]; then
         LEVEL=&quot;Unknown&quot;
       fi
&gt;
       echo &quot;LeetCode 문제 난이도: $LEVEL&quot;
       echo &quot;leetcode_level=$LEVEL&quot; &gt;&gt; $GITHUB_OUTPUT
&gt;
move:
 needs: detect
 if: ${{ needs.detect.outputs.should_run == &#39;true&#39; &amp;&amp; needs.detect.outputs.package_name != &#39;&#39; }}
 runs-on: ubuntu-latest
&gt;
 steps:
   - name: Checkout
     uses: actions/checkout@v3
     with:
       token: ${{ secrets.GITHUB_TOKEN }}
&gt;          
   - name: Move Directory
     run: |
       PACKAGE_NAME=&quot;${{ needs.detect.outputs.PACKAGE_NAME }}&quot;
       LEVEL_DIRECTORY=&quot;${{ needs.detect.outputs.level }}&quot;
       ROOT_DIR=&quot;LeetCode&quot;
&gt;      
       echo &quot;옮겨야 할 패키지 확인: $PACKAGE_NAME&quot;
       echo &quot;감지된 레벨: $LEVEL_DIRECTORY&quot;
&gt;      
       # 먼저 기존에 동일한 디렉토리 위치가 있는지 탐색
       FOUND_EXISTING_PATH=&quot;&quot;
       for dir in &quot;$ROOT_DIR&quot;/*; do
         if [ -d &quot;$dir/$PACKAGE_NAME&quot; ]; then
           FOUND_EXISTING_PATH=&quot;$dir&quot;
           echo &quot;기존 디렉토리 발견: $dir/$PACKAGE_NAME&quot;
           break
         fi
       done
&gt;      
       # 기존 위치로 이동
       if [ -n &quot;$FOUND_EXISTING_PATH&quot; ]; then
         DEST=&quot;$FOUND_EXISTING_PATH/$PACKAGE_NAME&quot;
         echo &quot;→ 기존 디렉토리로 병합 이동: $DEST&quot;
         mv &quot;$PACKAGE_NAME&quot;/* &quot;$DEST&quot;/
         rmdir &quot;$PACKAGE_NAME&quot; || true
&gt;      
       # 새로 이동해야 할 경우
       else
&gt;            DEST=&quot;$ROOT_DIR/$LEVEL_DIRECTORY/$PACKAGE_NAME&quot;
         echo &quot;→ 새 디렉토리로 이동: $DEST&quot;
         mkdir -p &quot;$ROOT_DIR/$LEVEL_DIRECTORY&quot;
         mv &quot;$PACKAGE_NAME&quot; &quot;$ROOT_DIR/$LEVEL_DIRECTORY/&quot;
       fi
&gt;
   - name: Commit and Push
     run: |
       COMMIT_MSG=&quot;${{ needs.detect.outputs.commit_msg }}&quot;
       echo &quot;커밋 메세지 확인: $COMMIT_MSG&quot;
&gt;        
       git config --global user.name &lt;당신의 깃헙 닉네임&gt;
       git config --global user.email &lt;당신의 깃헙 가입 이메일&gt;
&gt;
       git add .
       git commit -m &quot;$COMMIT_MSG&quot; || echo &quot;No changes to commit&quot;
       git push</code></pre>
</li>
</ol>
<p>어쨌든 이 자동화 작업 때문에 리트코드 풀이 관리가 한결 편해지긴 했다...</p>
<p>아직 내가 생각하지 못한 고려 케이스가 있을 수 있는데... 지금 생각나는 개선 사항은 리트코드 풀이 패키지명이 문제 번호가 기입되어 있어서 <strong>이진 탐색</strong>으로 기존 디렉토리 탐색 작업을 효율적으로 수행할 수 있지 않을까 싶다.</p>
<p>깃헙 UI 상에는 폴더가 번호 순으로 나열됐기는 한데, 실제로 이진 탐색을 작성한 파이썬 파일을 기입해서 bash로 적용이 가능할지 조금 더 알아봐야겠다.</p>
<p><em>혹시 문제점이 있다면 말씀해주세요</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[면접 회고일지(1)]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EB%A9%B4%EC%A0%91-%ED%9A%8C%EA%B3%A0%EC%9D%BC%EC%A7%801</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EB%A9%B4%EC%A0%91-%ED%9A%8C%EA%B3%A0%EC%9D%BC%EC%A7%801</guid>
            <pubDate>Thu, 24 Apr 2025 18:36:37 GMT</pubDate>
            <description><![CDATA[<h1 id="망한-면접-회고">(망한) 면접 회고</h1>
<p>어쩌다 운이 좋아서 대기업 면접을 보게 됐다.
(어딘지는 밝히진 않지만 네카라쿠배 중 한 곳이다... 지금 생각해도 왜 서류합격했는지 노이해...)</p>
<p>회사 규모별로 개발 철학이나 요구사항은 전부 다르기 때문이 하나의 기준으로 전부 판단해서는 안되겠지만, 왜 대기업이 대기업인지 알게 되는 계기였다. <strong>면접은 훌륭하게 조졌고 질문 하나하나에 나를 반성하고 회고하게 만드는 경험이었다.</strong></p>
<p>요번주는 유독 바빴다. 20일에 정처기 실기 시험(난이도 너무한거 아니오...) 치자마자 곧바로 면접 일정이 잡혀서 2주 전부터 지금까지 밤을 새고... 오늘 면접 끝나고 내리 8시간 넘게 자다 방금 일어났다.</p>
<hr>
<h2 id="1-면접-전">1. 면접 전</h2>
<p>내가 지원했던 직무는 풀스택을 요했다.</p>
<blockquote>
<h3 id="요구사항">요구사항</h3>
</blockquote>
<ul>
<li>스프링, 코틀린을 다룰줄 알 것</li>
<li>리액트 기반 antd, shadcn를 다룰줄 알 것<blockquote>
<h3 id="우대사항">우대사항</h3>
</blockquote>
</li>
<li>어드민 서비스 개발 경험</li>
<li>SQL 실력자</li>
<li>배움의 의지</li>
</ul>
<h3 id="1-서류합격-연락을-받았다">1) 서류합격 연락을 받았다</h3>
<p>면접 일정은 18일이었나 17일에 전달받았다. 진짜 처음엔 스팸인가... 싶을 정도로 의심했었다. <strong>25%의 기대감과 50%의 걱정, 25%의 정처기혐오감</strong>을 갖고 그 당시에 이렇게 생각했다.</p>
<blockquote>
<p>정처기 끝나고 면접 준비해야지~</p>
</blockquote>
<p>나중에 말하겠지만, 정말 안일하기 그지없는 생각이었다.
기업의 면접 난이도나 우선순위 때문에 이 말을 하는 게 아니다. 정처기 시험도 물론 중요하지만 <strong>면접은 시험 이상으로 나를 온전하게 드러내야 하는 곳</strong>임을 망각하고 있었다.</p>
<p>사실 면접 준비를 꾸준하게 하지 않았다. 열정이 조금 식은 것도 있었고 정처기 시험에 시달린 것도 있었고... 라지만 핑계라고 생각한다. 지금이라도 면접 연습 열심히 해야지...</p>
<h3 id="2-정처기-시험-끝나고-3일간-밤샘준비">2) 정처기 시험 끝나고 3일간 밤샘준비</h3>
<p>시험 끝나고 면접 후기들을 뒤져보면서 내가 당시 제출했던 이력서와 포트폴리오를 다시 쑥 훑어보고 어떤 질문이 나올지 예상했었다. 사실 서류지원조차 까먹고 있었는데 서류 합격 연락을 받았다. 지금 생각해보면 이것도 잘못됐다고 생각한다. <strong><em>나 이러이러하니 뽑아주세요~</em> 하고 제출한 서류인데, <em>내가 뭐 때문에 뽑혔더라?</em> 하고 고민하는 건 모순이다.</strong></p>
<p>당시 준비할 때는 백엔드 기술면접... 프론트엔드 기술면접... 이런 것들 계속 찾아보며 달달달달 암기했다. 미리 말하지만, 진짜 하나도 안 나왔다. 이건 회바회인 것 같긴 한데 적어도 내가 면접 본 곳은 하나도 묻지 않았다. 그게 제일 충격이었다.</p>
<h2 id="2-면접">2. 면접</h2>
<p>정처기 실기를 준비한 덕에 네트워크나 운영체제 같은 부분의 기술면접 준비는 수월했고... 내 포트폴리오 서류도 보면서 어떤 질문 나올까 고민했다. 사실 내 포폴이 정말 잡탕 그 자체다. Kafka나 GraphQL도 경험해보고 MSA도 수행해보고 스프링 배치도 써먹어보고... 순수하게 기술 경험이 재밌어서 이것저것 해보다가 들어가게 됐는데, 아마 면접관들 입장에서는 물어뜯기 딱 좋은 먹잇감이 아닐까 하는 생각이 준비하면서 들었다.</p>
<p>여하튼 대망의 면접이 다가왔다.</p>
<h3 id="1-3대-1은-생각도-못했다">1) 3대 1은 생각도 못했다</h3>
<p>ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
아니 3명은 너무한 거 아니오...</p>
<p>각각 <strong>팀장님(직책명이 정확히 기억은 안 난다), 백엔드 담당자님, 프론트엔드 담당자님</strong>들께서 면접을 봐주셨다. 처음엔 내게 인성과 내 배경에 대해 여쭈셨고 그 다음에 기술 관련 질문을 해주셨다.</p>
<p>솔직히 1대 1도 무서운데 3대 1 하니까 외웠던 것들은 아득해지고... 머리는 하얘지고... 여튼 멘붕의 시작이었다.</p>
<h3 id="2-인성-소프트-관련-질문">2) 인성, 소프트 관련 질문</h3>
<p>처음 자기소개부터 간신히 정신을 부여잡고 했다. 대충 <strong>내가 설계부터 배포까지 했던 프로젝트 경험, 거기서 느낀 불편함으로 요즘 공부하고 있는 것, 내가 소통에 강점이 있음</strong>을 어필하는 자기소개를 수행했지만 다들 시큰둥하게(어쩌면 내 착각임) 바라보시며 대답하셨다.</p>
<p>나는 최대한 서류에 드러나지 않았던 나의 자기소개를 하고 싶었다. 여기서 추측되는 첫 실수인 것 같은데, <strong>서류에 없는 나의 소개는 결국 근거가 있어야 했고 그 근거가 빈약해서 추가 질문 폭격이 들어온 게 아닌가 싶다.</strong></p>
<blockquote>
<ol>
<li><strong>채팅 앱을 주제로 설계부터 배포까지 하며 MSA 구조까지 경험했다.</strong><ul>
<li>관심 x</li>
</ul>
</li>
<li><strong>함수형 패러다임을 자바로 챙기기 어려워서 코틀린에 관심이 있다.</strong><ul>
<li>여기서 백엔드 담당자분이 코틀린의 어떤 점 때문에? 관련해서 질문을 와다다다 하셨다. 아마 신입 중에 자바와 코틀린 둘 다 익숙한 사람은 흔치 않아서 그러신듯 + 요구사항에 코틀린도 있었다</li>
<li>사실 함수형 패러다임에 관심이 있음을 강조하고 싶었는데(심지어 난 코틀린은 깨작 몇 번 적어보고 문법 훑어본 게 끝인 초짜였다...) 내 의도가 엇나간 듯하다.</li>
</ul>
</li>
<li><strong>소통에 강점이 있다</strong><ul>
<li>관심 x</li>
</ul>
</li>
</ol>
</blockquote>
<p>자기소개를 듣고 나서 팀장님(<del>다시 말하지만 직책이 정확히 기억이 안 난다. 지송...</del>)이 내가 공부를 상당히 오래 한 것이 흥미로웠는지 그에 대해 여쭈시면서 백엔드와 프론트엔드 중 어디에 익숙한지 물으셨다.</p>
<blockquote>
<ol>
<li><strong>공부 기간이 상당히 오래된 것 같다. why? how?</strong><ul>
<li><del>경제가 어려워서요.. 라고 말하고 싶은 걸 꾹 참았다.</del></li>
<li>근데 솔직하게... 개발 재밌음... 다양한 기술을 접했던 것도 개발이 재밌었고, 진로 선택할 때도 문제를 구조화해서 다양한 해결책 도출하는 진로가 좋았는데 개발이 내맘쏙듦이었다(물론 이렇게 말하진 않고 정돈된 표현을 썼다... 요약하면 이렇단 거...)</li>
</ul>
</li>
<li><strong>프론트엔드랑 백엔드, 어디에 더 탁월하냐</strong><ul>
<li>결과가 눈으로 바로 확인 가능한 점이 매력적이라 프론트로 진입했지만, 전체 웹 개발의 흐름이 궁금해서 백엔드 공부했고, 백엔드가 더 적성에 맞다... 하지만 프론트엔드를 저버리는 건 아니고 지금도 필요하면 간단한 웹 뷰를 리액트로 구현할 정도로 실력을 유지하고 있다... 정도</li>
</ul>
</li>
</ol>
</blockquote>
<h3 id="3-얻어터진ㅎㅎ-기술-질문">3) 얻어터진(...ㅎㅎ) 기술 질문</h3>
<p>앞서 말했듯이, 달달달 외웠던 기술면접 스크립트는 하나도 안 나왔다.
애시당초 면접관님들이 기술의 기 조차도 꺼내시질 않으셨다.
<strong>순수하게 내 포트폴리오에 대해 꼬치꼬치 캐물었고, 내 개발 철학(?) 등에 대해만 관심을 가졌다.</strong> 당연히 아니겠지만, 당시엔 역시 대기업(...)하면서 질문을 받았다.</p>
<h4 id="1-기술지식은-면접의-메인이-아니다">(1) 기술지식은 면접의 메인이 아니다</h4>
<p>회고하면서 깨달은 건데, <span style='background-color:#dcffe4'><strong>기술면접 스크립트</strong>가 면접 준비의 메인이 돼서는 안 된다.</span></p>
<p>사실 이건 구글링해서 나오는 내용들 달달달 외우면 되고, 물론 이걸 묻는 이유는 기초가 다져져있는지를 확인하는 쉬운 수단이어서 물을 것이다.
하지만 결국 나를 완벽하게 표현할 수는 없다. <span style='background-color:#dcffe4'>핵심은 내가 제출한 서류(<strong>이력서, 포트폴리오</strong>)가 메인</span>이어야 했다. 기술지식은 <strong>영단어</strong>와 같다. 그렇지만 영단어 외웠다고 영어 수능 100점 맞는 게 아니지 않는가. 그렇기에 <span style='background-color:#dcffe4'>평소에 꾸준히 해야되고 면접 전날에 외우는 게 아니다.</span></p>
<h4 id="2-내가-내-포폴에-대해-진심이어야-함">(2) 내가 내 포폴에 대해 진심이어야 함</h4>
<p>면접관님들은 내 포폴을 중심적으로 꼬치꼬치 캐물으셨다. 내 포폴은 백엔드 포폴만 있었고 프론트엔드 포폴은 없었는데, 그래서 프론트엔드 면접관님은 내 개발 철학이나 시나리오별 대응? 이런 걸 위주로 물으셨다.</p>
<p><span style='background-color:#dcffe4'>내가 강점으로 내세웠던 것들은 <strong>나는 이렇게 많은 경험을 갖고 있다</strong>였는데, 거기서 면접관님들은 그럼 <strong>그 중에 본인들이 요구하는 사항에 대해서 얼마나 깊게 이해하고 있나</strong>를 물었다.</span></p>
<p>깊게 이해한다는 건, 프로젝트를 단순히 포폴용으로만 하고 그친 건지 아니면 내 경험과 역량을 늘이기 위한 과정이었는지를 분별하는 거라고 생각한다. 그 기준대로라면 <span style='background-color:#dcffe4'>나는 역량을 늘이기 위해서 프로젝트를 했지만 내심은 포폴용에 그쳤던 것 같다.</span></p>
<blockquote>
<ol>
<li><strong>배치 다뤄봤다는데, 진짜 천만 단위의 데이터도 다뤄봤니?</strong></li>
<li><strong>SQL 튜닝할 줄 안다매 그럼 데이터베이스 성능 고려도 했겠네?</strong></li>
</ol>
</blockquote>
<p>팀장님이 주로 SQL 관련해서 물었다. 사실 SQL을 정말 잘 다루는 편이 아니어서 어버버... 하면서 대답은 했지만 시원하진 않았다. 대답을 들은 면접관님들 표정도 그런 듯했고(ㅠㅠㅠ)</p>
<p>기술지식 질문은 없었는데, 굳이굳이 기술지식에 대해 분류한다면 저 SQL과 관련된 내용들이 내가 SQL 지식을 얼마나 아는지 묻는 것 같았다(그리고 털렸다ㅎ;)</p>
<p>프로젝트에 대한 진심 여부를 묻는구나.. 하고 느껴졌던 질문들은 아래와 같았다.</p>
<blockquote>
<ol>
<li><strong>네 프로젝트 보니까 자바 17이던데, 왜 굳이 17 썼어?</strong><ul>
<li>이건 대답 나름? 잘한 것 같다. LTS 버전별 특징을 익히고 있어서 경량 스레드가 프로젝트 내에서 필요없었고, 레코드를 DTO로 적용하려고 17을 썼었다고 설명했다. 다만 긴장해서 주절주절도 겸했다 흑<ol start="2">
<li><strong>전역 예외처리도 뒀는데 왜 이 컨트롤러에서는 try - catch 쓴 거야?</strong></li>
</ol>
<ul>
<li>이 질문이 가장 뜨끔했다. 전역 예외처리를 했었는데, 왜 특정 컨트롤러는 굳이 처리했는지 물었다. 아마 그 당시에 저 부분 예외가 유독 많이 발생했어서 별개의 예외 처리를 둔 걸로 기억하는데(사실 내가 맡은 게 아닌데...) 일단은 그렇게 주절주절거렸다...</li>
</ul>
</li>
</ul>
</li>
</ol>
</blockquote>
<p>이런 질문들이 결국 네 프로젝트에 대해 얼마나 진심이었는지 묻는 것처럼 느껴졌다. 기초에 충실하다는 말은 이런 걸 뜻하는 것 같다. 무작정 기술 스택을 늘이는 게 아닌, 코드를 작성해도 의도를 담고 작성하며 그 의도를 확인했는지를 묻는 것 같다.</p>
<h4 id="3-풀스택은-괜히-풀스택이-아니다">(3) 풀스택은 괜히 풀스택이 아니다.</h4>
<p>프론트 질문은 솔직히 기술 스택 정도만 답했고 웬만한 것들은 답하지 못했다 ㅠ 애초에 포폴에 넣지도 않았고 난 프론트까지 생각하고 이 직무에 지원한 게 아녔기 때문이다. 클라이언트의 입장에서 서버의 이상에 대해 어떻게 대처할 건지... 이런 질문들이 주였는데 나는 그런 것까지 세세하게 생각하지 않았다. 결국 내가 프론트엔드로 프로젝트를 참여만 해봤다~ 라는 경험 어필에 그쳤지, 그 경험 속에서 내가 무얼 얻고 싶었는지는 생각하지 않았던 것 같다.</p>
<p>프론트엔드 뿐만이 아니라 백엔드여도 마찬가지다. 대용량 트래픽 제어가 유행이니까 해봐야지~ 하는 안일한 생각으로 접했었다. <span style='background-color:#dcffe4'>대용량 트래픽이 어디서 필요할지? 이 상황에선 오버 엔지니어링이 아닐지? 이런 생각이 아닌, <strong>이 코드가 왜 이렇게 쓰였는지?</strong>라는 본질적인 질문을 외면하고 있었던 것 같다.</span></p>
<p>결국 단순히 다룰줄 안다, 경험했다를 넘어 이런 본질적인 질문에 답하는 과정들을 거치면서 공부해야 스택이 되는 거고, 풀스택은 그 양이 2배일 테니 괜히 풀스택이 아니구나... 하는 생각을 다시 느꼈다. </p>
<h2 id="3-면접-후">3. 면접 후</h2>
<p>깔끔하게 면접을 조지고(...) 못 잤던 잠을 보충하고 난 다음 내가 해야할 일을 생각했다.</p>
<h3 id="1-프로젝트에-대해-다시-회고해보기">1) 프로젝트에 대해 다시 회고해보기</h3>
<ul>
<li>필요하다면 코드 하나하나 뜯어보기도 해보자. 단순히 아키텍처에 그치는 건 겉핥기에 불과하다. 당시의 기억들을 되새기면서 내가 왜 이 코드를 작성했는지 생각해보기</li>
<li>포트폴리오와 이력서에 써져있는 내용들이 포폴용일지? 아니면 나의 역량일지? 이 점들을 고민해보고 다시 다듬어보기</li>
</ul>
<h3 id="2-기술지식은-매일-익히기">2) 기술지식은 매일 익히기</h3>
<ul>
<li>영단어와 같은 기술지식은 매일매일 접하자.</li>
<li>소스는 많으니 암기 → 탐구 방향으로 익히자.</li>
</ul>
<h3 id="3-스크립트에-그치지-않고-말로-내뱉는다">3) 스크립트에 그치지 않고 말로 내뱉는다</h3>
<ul>
<li>솔직히 이번 면접, 대기업이란 부담 + 3명 상대라는 점에서 자신감 없이 임했던 게 제일 아쉬웠다</li>
<li>단순히 면접 대본을 작성하는 데에, 그걸 암기하는 데에 그치지 않고 매일 말로 내뱉어야 한다</li>
<li>필요하다면 스터디를 찾아봐도 좋을듯?</li>
</ul>
<hr>
<p>제일 중요한 건, 기회가 날아갔음에 아쉬워할 순 있어도 그걸 또다른 기회로 삼으며 잠깐으로 그쳐보기
그러려고 이번 회고를 작성한 거기도 하고.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[자바 상속의 한계, 합성을 통해 극복해보기]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EC%9E%90%EB%B0%94-%EC%83%81%EC%86%8D%EC%9D%98-%ED%95%9C%EA%B3%84-%ED%95%A9%EC%84%B1%EC%9D%84-%ED%86%B5%ED%95%B4-%EA%B7%B9%EB%B3%B5%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EC%9E%90%EB%B0%94-%EC%83%81%EC%86%8D%EC%9D%98-%ED%95%9C%EA%B3%84-%ED%95%A9%EC%84%B1%EC%9D%84-%ED%86%B5%ED%95%B4-%EA%B7%B9%EB%B3%B5%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sat, 05 Apr 2025 19:18:50 GMT</pubDate>
            <description><![CDATA[<h1 id="상속과-합성">상속과 합성</h1>
<p>처음 자바를 공부하고, 객체지향 개념을 어렴풋이 익히며 SOLID가 뭐의 약자인지 끙끙거리면서 외울 때에는 <strong>상속</strong>이 신기하게만 느껴졌었으나, 몇 번의 토이 프로젝트와 사이드 프로젝트를 거치면서 생각보다 불편하다는 걸 느끼게 됐었다.
코드를 재정의하고 뭔가 다형성을 실현해야지! 하지만 뭔가 다형성에 입각한 코드 설계라기보단 그냥 외부 레퍼런스를 보고 모방하는 느낌밖에 없었다. 단순히 이론을 아는 것과 이론을 깊게 이해하고 실제의 동작을 확인하는 것은 차이가 컸다.</p>
<p>마침 이번에 스터디하려는 세션에서 상속과 합성을 비교하는 주제가 있어서 해당 교재를 바탕으로 내 나름대로의 여러 실험들도 해보면서 상속과 합성의 특성들에 대해 비교하고 뭘 고찰하며, 어떻게 실질적으로 적용할 수 있을까에 대한 정답은 아녀도 해답을 찾아보려고 한다.</p>
<blockquote>
<p><em>이번 포스팅은 왕정 저 &#39;디자인 패턴의 아름다움&#39; 교재를 공부하며 작성함</em></p>
</blockquote>
<h2 id="1-상속의-한계-그리고-대안책인-합성">1. 상속의 한계, 그리고 대안책인 합성</h2>
<h3 id="1-상속과-집합의-관계">1) 상속과 집합의 관계</h3>
<p>상속을 공부하다 보면 수학의 <strong>집합</strong>이 생각난다. 좀 생뚱맞은 얘기일 수 있는데 아래의 그림을 보자.</p>
<p><img src="https://github.com/user-attachments/assets/b12f6a2c-9fe4-44bc-8b4f-12e695cd56cc" alt="image"></p>
<p>집합 A는 집합 B의 부분집합이다. 즉, 집합 A에 속하는 모든 원소들은 곧 집합 B에 속하는 원소라고 봐도 언제나 참이다. 이를 객체의 상속 관계로 옮겨보자.</p>
<pre><code class="language-java">public class A {
    public String a;

    public void methodOnlyA() {
        System.out.println(&quot;집합 A에서만 정의&quot;);
    }

    protected void method() {
        a = &quot;집합 A&quot;;
        System.out.println(a);
    }
}</code></pre>
<pre><code class="language-java">public class B extends A {
    public String b;

    @Override
    public void method() {
        b = &quot;집합 B&quot;;
        System.out.println(b);
    }
}</code></pre>
<p>A 객체는 B 객체에 의해 상속되고 있다. 여기서 봐야할 점은 A 객체에만 정의해둔 <code>methodOnlyA()</code> 메소드인데, 이 메소드는 분명 B에는 명시되어 있지 않으나, 상속 개념에 의해 B의 인스턴스에서 해당 메소드를 호출할 수 있게 된다. 물론 접근 제한자에 따라서 호출 여부가 달라지겠지만 일단은 집합과의 관계를 위해 모든 필드와 메소드가 <code>public</code>이라고 생각해보자.</p>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) {
        B b = new B();
        b.methodOnlyA(); // 집합 A에서만 정의
        b.method(); // 집합 B
    }
}</code></pre>
<p>이를 통해서, 앞으로 A 클래스에서 (접근 제한자가 <code>public</code>이라는 가정 하에) 모든 메소드나 필드의 추가는 결국 상속 받는 자식 클래스인 B에서도 똑같이 포함된다. 결국 위의 집합처럼 A가 B의 부분집합 관계임을 알 수 있다. A 집합에 어떤 원소를 계속 추가해도 B 집합에 포함되는 관계가 유지되는 것이다.</p>
<h3 id="2-집합은-자명한-원소-기준이-있으나-상속은-다차원적이다">2) 집합은 자명한 원소 기준이 있으나, 상속은 다차원적이다.</h3>
<p>그렇지만 집합과 상속이 완벽히 매칭된다고 할 수는 없다. 집합에는 조건제시법을 통해 명확한 집합의 기준을 정의할 수 있으나 상속 관계의 클래스들은 그렇지 않다. 교재에 꽤나 재밌는 예시가 있어서 이를 확인해보자.</p>
<blockquote>
<p>우리는 &#39;새&#39;를 추상 클래스로 정의할 것이다. 그리고 클래스 내부에는 &#39;동작&#39;을 의미하는 메소드를 작성해야 될 것이다. 새의 대표적인 동작은 &#39;날기&#39;다. 그래서 <code>fly()</code>라는 메소드를 <code>Brid</code> 클래스에 넣었다. 이제 준비가 끝났으니 <code>Bird</code> 클래스를 만들어서, 세상의 모든 새들을 상속시켜서 클래스로 만들려고 한다.</p>
<p>그런데 생각해보니 타조나 닭은 날지 못한다. 하지만 얘네들 역시 새에는 포함된다. 그래서 일단은 <code>Bird</code> 클래스를 상속받긴 하는데, 날지 못하는 새들이 <code>fly()</code> 메소드를 갖고 있다. 이런 모순인 상황을 타개하기 위해 고민해봤지만, 오버라이딩해서 예외를 반환시키는 방법 말고는 적절한 게 보이지 않는다.</p>
<p>결국 <code>Bird</code> 추상 클래스를 상속하는 <code>FlyableBird</code> 추상 클래스와 <code>UnFlyableBird</code> 추상 클래스를 추가로 정의해서 이제 새들을 클래스로 정의해볼까 한다. 그런데 또 생각해보니 노래를 부를 수 있냐 없냐로도 나뉠 수 있다. 그래서 이번에는 저 날 수 있는 지를 기준으로 나눈 추상 클래스들을 또 상속하는 <code>FlyableTweetableBird</code>, <code>FlyableUnTweetableBird</code>, <code>UnFlyableTweetableBird</code>, <code>UnFlyableUnTweetableBird</code> 추상 클래스를 정의했다...</p>
</blockquote>
<p>교재에서는 새를 기준으로 클래스 정의를 했을 때, 상속과 관련해서 발생하는 문제점을 명확히 잡아내고 있다. 상속의 본질적인 핵심은 상위 클래스의 <strong>추상적 상태</strong>다. 표현이 추상적 상태라고 되어있지만, 결국 <strong>얼마나 많은 범위를 담아낼 수 있는 기준</strong>이냐를 의미한다. 결국 이 기준을 명확하게 잡지 않고 상위 클래스를 정의해서 상속을 적용하면 위와 같은 딜레마에 빠지게 된다.</p>
<p>위에서 말했던 새 클래스 예시도 결국 <strong>가장 추상적인</strong> 새를 어떻게 정의하냐에서 발생하는 문제라 볼 수 있다. 새라는 생물을 클래스로 표현하기 위해서는, 그리고 그 새들의 구체적인 종류들의 정의해나가기 위해서는 어떻게 초기 기준을 잡냐인데 그것을 <strong>날기</strong>로 잡을지 <strong>노래</strong>로 잡을지에 대한 고민이 위의 예제에서 드러나는 것이다.</p>
<p>이런 문제들에 대한 정답은 잡기가 매우 어렵다. 앞서 말했듯이 집합은 명확한 원소 기준을 제시할 수 있지만, 객체는 현실세계의 기준을 제시하기에는 그 분류 방법이 너무나도 다차원적이기 때문에 상속을 통해 기준을 실현해도 부작용이 발생할 수밖에 없다.</p>
<h3 id="3-상속-대신-합성을-써보자">3) 상속 대신 합성을 써보자</h3>
<p>위에서 말한 새 예제를 클래스로 표현한다면 이렇게 될 것이다.</p>
<pre><code class="language-java">// 새 추상 클래스
public abstract class Bird {
}

// 날 수 있냐, 없냐
public abstract class FlyableBird extends Bird {
    // 날기 메소드
}

public abstract class UnFlyableBird extends Bird {
}

// 노래할 수 있냐, 없냐
public abstract class FlyableTweetableBird extends FlyableBird {
    // 날기 메소드
    // 노래 메소드
}

public abstract class FlyableUnTweetableBird extends FlyableBird {
    // 날기 메소드
}

public abstract class UnFlyableTweetableBird extends UnFlyableBird {
    // 노래 메소드
}

public abstract class UnFlyableUnTweetableBird extends UnFlyableBird {
}</code></pre>
<p>기준이 2개로 늘었을 뿐인데, 추상 클래스가 무려 7개나 된다. 각 추상 클래스에 대응되는 메소드들을 정의하고 하위 클래스에서 맞춰 오버라이딩을 하면서 추후 추가되는 기준에 따라 또 추상 클래스가 늘어나게 되면 단순히 코드 추가에 그치지 않고 상속 계층이 깊어지면서 <strong>코드의 가독성과 유지보수성에 영향</strong>을 끼치게 된다. 또한 전통적인 소프트웨어 아키텍처의 특성인 <strong>캡슐화</strong>를 깨뜨리게 된다. 왜냐하면 부모 클래스에서 정의했던 내용을 하위 클래스에서 다시 재정의하기 위해서는 접근 제한자의 개방이 강제될 수밖에 없기 때문이다.</p>
<p>상속의 본질적인 단점의 원인은, 자식 클래스가 부모 클래스의 모든 것을 가지면서 <strong>동화</strong>되는 것이다. 즉, 부모 클래스에서 <code>public</code>하게 정의한 것들은 자식 클래스에서 동일하게 활용이 가능하기 떄문에 <strong>자식 클래스는 곧 부모 클래스와 다를 바가 없어진다</strong>라는 문장이 성립하게 된다. 이 점이 상속의 본질적인 한계이자 단점을 나타낸다. 분명 코드의 재사용성을 증가시키려고 했지만 그만큼 결합이 너무 강력해지는 것이다.</p>
<p>이제 합성을 알아보자. 위에서 언급한 상속의 본질적인 단점인 <strong>결합도가 강해지는 것</strong>을 방지하기 위해서 <strong>부모의 모든 것을 넘기는 상속</strong>에서 <strong>부모의 필요한 동작만을 넘기는 합성</strong>이 대안책으로 제시되는 것이다. 아까 확인한 새 문제는 결국 새의 모든 것을 떠넘기면서 발생하는 문제라고 볼 수 있다. 분류 기준은 <strong>날기</strong> 혹은 <strong>노래</strong> 등에 해당하는 동작에 불과할 뿐인데, 그 동작을 기반으로 분류하기 위해 <strong>새</strong>에 해당하는 모든 것을 넘겨주면서 코드의 가독성과 유지보수성이 떨어지게 되므로 <strong>동작</strong>만을 넘겨주는 방법을 채택하는 것이 합성의 핵심이다.</p>
<pre><code class="language-java">public interface Flyable {
    // 날기 추상 메소드
}

public interface Tweetable {
    // 노래 추상 메소드
}

public class Oriole implements Flyable, Tweetable {
    // 날기 메소드 구현
    // 노래 메소드 구현
}

public class Eagle implements Flyable {
    // 날기 메소드 구현
}

public class Chicken {
}</code></pre>
<p>결국 <strong>합성은 객체의 &#39;무엇이냐&#39;보다는 &#39;무엇을 할 수 있느냐&#39;에 집중</strong>한다. 즉, 객체의 행동(동작)을 조립하듯 구성함으로써 유연하고 유지보수가 쉬운 구조를 만든다. 어디서 많이 봤다 싶더만, <strong>전통적인 객체지향 프로그래밍에서 동작의 파라미터화를 통한 함수형 프로그래밍 리팩토링</strong>과 똑같은 형태다. 함수형에서 고차 함수를 활용해 행위를 분리하고 재조립하며, 객체에 포함된 해당 동작의 자세한 내용을 하드코딩하지 않고 외부의 내용에 의존하면서(의존성 주입) 다형성을 유연하게 실현하는 것과 유사하다.</p>
<h2 id="2-상속과-관련된-다양한-시나리오">2. 상속과 관련된 다양한 시나리오</h2>
<p>부모 클래스의 내용을 자식 클래스에서 넘겨받고 오버라이딩하면서 기능을 확장하는 상속의 기본 골자에서 다양한 문법들과 기능들이 결합되면서 생각할 거리들이 던져진다. 나름대로 사고실험을 진행해보면서 상속의 근본적인 단점, 그리고 그 원인들에 대해 고찰해봤다.</p>
<h3 id="1-동작이-발생했을-때-그-책임을-누구한테-넘길-것인가">1) 동작이 발생했을 때, 그 책임을 누구한테 넘길 것인가?</h3>
<p>이 질문의 핵심부터 먼저 말하자면, 상속에서는 <strong>부모가 모든 계약의 기준 시작점</strong>이라는 것이다. 자식 클래스들에서 아무리 다양하게 메소드가 구현되어도 그 모태는 결국 부모 클래스의 메소드에 의존하게 된다. 아래 예제를 보자.</p>
<pre><code class="language-java">public abstract class Parent {

    public void process() {
        stepA();
        stepB();
        stepC();
    }

    protected abstract void stepA();
    protected abstract void stepB();
    protected abstract void stepC();
}

public class Child extends Parent {
    @Override
    protected void stepA() {
        System.out.println(&quot;스텝 A&quot;);
    }

    @Override
    protected void stepB() {
        System.out.println(&quot;스텝 B&quot;);
    }

    @Override
    protected void stepC() {
        System.out.println(&quot;스텝 C&quot;);
    }
}</code></pre>
<p>다음과 같은 추상 클래스에서 로직의 스텝 단위로 추상 메소드를 정의한 다음, 해당 스텝들을 모아 하나의 프로세스 메소드를 정의하였다. 스텝들은 상속된 자식 클래스에서 다양하게 구현될 수 있을 것이다. 이 코드로만 봤을 때는 문제가 없어보이지만, 만약 <strong>스텝의 순서를 조정하거나 특정 스텝을 수정하고 추가할 경우</strong>에는 자식 클래스에서 취할 수 있는 방법이 없다. 즉, 부모 클래스를 다시 건드리게 되고 이 과정에서 또 다른 자식 클래스들에도 영향이 갈 수도 있다. 비슷한 구조를 합성의 형태로 바꿔보자.</p>
<pre><code class="language-java">public interface Step {
    void run();
}

public class Processor {
    public final List&lt;Step&gt; steps;

    public Processor(List&lt;Step&gt; steps) {
        this.steps = steps;
    }

    public void process() {
        for (Step step: steps) step.run();
    }
}

class StepA implements Step {
    @Override
    public void run() {
        System.out.println(&quot;스텝 A&quot;);
    }
}

class StepB implements Step {
    @Override
    public void run() {
        System.out.println(&quot;스텝 B&quot;);
    }
}

class StepC implements Step {
    @Override
    public void run() {
        System.out.println(&quot;스텝 C&quot;);
    }
}</code></pre>
<p>상속 형태의 로직과 유사하지만 달라진 점은 <code>Step</code> 인터페이스를 통해 각 스텝들을 구현하면서 해당 스텝 단계들을 프로세서(부모)에서 직접 조립하는 것이 아닌 외부에서 받아오고 있다. 즉, 프로세서는 순수하게 스텝들을 모아 실행하는 점에만 치중하고 그 스텝들이 어떤 것인지, 순서는 어떻게 조정되는지에 대한 내용은 외부에서 정해져서 들어오게 된다. 이 내용은 <strong>의존성 주입(Dependency Injection)</strong> 의 핵심 원리와도 일맥상통한다. 기존의 스텝들이 모인 프로세스의 구체적인 흐름이 상속 구조에서는 부모 클래스 내부에서 강하게 결합되어있던 반면, 합성 구조에서는 외부로부터 의존성 주입을 받음으로써 프로세스의 구체적인 흐름과 느슨하게 결합된다.</p>
<p>상속은 미리 조립해뒀기 때문에 수정하려면 다시 부숴야 하고, 합성은 조립을 위한 준비만 해두고 조립은 외부에서 하기 때문에 위험성이 적은 구조다. 부모의 책임 여파가 자식에게 전달되며 공동 책임이 되는 상속과 달리 책임의 상세 내용은 외부에서 이뤄지기 때문에 합성은 책임의 여파에서 상대적으로 자유롭다.</p>
<h3 id="2-상속의-구조적-제약-필드와-메소드의-취급-차이">2) 상속의 구조적 제약: 필드와 메소드의 취급 차이</h3>
<p>다음과 같은 코드가 있다. 부모, 자식, 손자로 연쇄 상속이 되며 각각 필드를 지니고 있고 메소드를 오버라이딩한다.</p>
<pre><code class="language-java">public class Parent {
    public String value = &quot;부모&quot;;

    public void method() {
        System.out.println(&quot;부모&quot;);
    }
}

public class Child extends Parent {
    public String value = &quot;자식&quot;;

    @Override
    public void method() {
        System.out.println(&quot;자식&quot;);
    }
}

public class GrandChild extends Child {
    public String value = &quot;손자&quot;;

    @Override
    public void method() {
        System.out.println(&quot;손자&quot;);
    }
}</code></pre>
<p>이제 이 클래스를 인스턴스로 호출하여서 메소드와 필드를 각각 호출해본다. <code>Parent</code> 참조 타입의 변수에 자식 인스턴스(<code>GrandChild</code> 타입)를 담아서 해당 변수로부터 메소드와 필드를 로그로 찍어본다.</p>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) {
        Parent parent = new GrandChild();

        parent.method();
        System.out.println(parent.value);
    }
}</code></pre>
<p>실행하면 다음과 같은 결과가 나온다.</p>
<img width="50%" alt="스크린샷 2025-04-06 오전 2 31 40" src="https://github.com/user-attachments/assets/1801117b-9430-488c-89b3-1fa9d142beae" />

<p>분명히 <code>GrandChild</code> 타입의 생성자를 통해 인스턴스를 생성했기 때문에 메소드 호출에서는 &quot;손자&quot; 로그가 찍히는 것이 정상인데, 필드 호출에는 생뚱맞게 &quot;부모&quot;가 찍히고 있다. 그 이유는 자바에서의 메소드 호출은 <strong>참조 타입이 아닌 인스턴스 타입</strong>이 결정하기 때문에 JVM의 가상 메소드 테이블을 통해 <strong>실제 객체 타입의 메소드를 찾아내는 동적 바인딩(다형성 실현)이 이뤄지는</strong> 반면, 필드는 <strong>컴파일 시점에 참조 타입 기준으로 정적 바인딩</strong>되기 때문이다. 그래서 메소드에는 오버라이딩이라는 개념이 있는 반면, 필드는 오버라이딩이 아닌 숨김 처리라고 표현하기도 한다.</p>
<p>이 결과가 시사하는 바는, 상속을 통해 다형성을 실현하려고 해도 메소드와 필드의 컴파일 및 런타임에서 발생하는 구조적인 차이를 극복하지 못한다. 상속 구조에서는 클래스 내부의 모든 데이터(필드, 메소드)들이 자식에게 공유되지만 그 공유 형태가 다르기 때문에 <strong>동작(메소드)은 자식 기준인데, 상태(필드)는 부모 기준</strong>인 모순적인 상황이 발생할 수 있다. 그래서 합성이 권장되는 이유기도 하는데, 그 특성상 분리와 조립이 자유롭기 때문에 유연하게 적용하면서 모순을 방지할 수 있다.</p>
<h3 id="3-다중-상속과-다중-구현의-관점-그리고-final-키워드">3) 다중 상속과 다중 구현의 관점, 그리고 final 키워드</h3>
<p>위에서 얘기했던 상속과 합성의 본질적인 차이는 결국 자바가 왜 다중 상속을 허용하지 않는 반면, 다중 구현은 허용하는지에 대한 얘기로도 이을 수 있다. 상속은 결국 부모의 책임이 자식들에게 전파되면서 공동 책임이 되는 구조이기 때문에 만약 다중 상속이 허용된다면 책임을 중복으로 지게 되면서 언어 설계 차원에서 제한을 두게 된다.</p>
<pre><code class="language-java">class A {
    void hello() {
        System.out.println(&quot;A&quot;);
    }
}

class B {
    void hello() {
        System.out.println(&quot;B&quot;);
    }
}

class C extends A, B { } // C의 메소드 호출은 그럼 누구를 기준으로 삼는가..?</code></pre>
<p>위 코드처럼 다중 상속 구조는 부모들 중 누구의 코드를 기준으로 넘겨받게 되는 지에 대해 컴파일러가 결정할 수 없게 되면서 컴파일 에러를 일으키게 된다. 컴파일러 관점에서는 그렇고 개발자 입장에서 테스트나 코드 유지보수가 매우 어려워지기 때문에 다중 상속을 막아둔 것이라고 볼 수 있겠다.</p>
<pre><code class="language-java">interface Flyable {
    void fly();
}

interface Shootable {
    void shoot();
}

class Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println(&quot;파닥파닥&quot;);
    }
}

class IronMan implements Flyable, Shootable {
    @Override
    public void fly() {
        System.out.println(&quot;비행&quot;);
    }

    @Override
    public void shoot() {
        System.out.println(&quot;발사&quot;);
    }
}</code></pre>
<p>반면 구현에서는 결국 실제 구현 책임이 공동이 아닌, 조립하는 곳에서 책임을 지면 된다. 인터페이스는 순수하게 선언에만 그치기 때문에 해당 구현체의 책임은 순전히 조립을 하는 곳에서만 질 뿐, 부모에 대응되는 인터페이스가 공동으로 책임질 이유가 없어진다. 이 부분과 관련하여 상속과 합성의 본질적인 차이가 드러나게 된다.</p>
<blockquote>
<ul>
<li>상속은 구현(메소드)와 상태(필드)를 전부 넘긴다.</li>
<li>합성은 구현(메소드)만 넘긴다.</li>
</ul>
</blockquote>
<p>위의 특징 때문에 인터페이스에서는 필드를 가지지 않는다. 더 정확히 말하자면 <strong>인스턴스 필드</strong>를 가지지 않는다. 아래처럼 필드를 인터페이스에 선언할 수 있지만, 취급은 정적 필드로 처리된다.</p>
<pre><code class="language-java">public interface Step {
    String step = &quot;스텝&quot;;

    void run();
}</code></pre>
<img width="50%" alt="스크린샷 2025-04-06 오전 3 10 13" src="https://github.com/user-attachments/assets/96a5c5ff-64e4-4423-b118-34558c068224" />

<hr>
<p>이제까지의 내용들을 정리해봤을 때, 상속이 생각보다 제약이 많은 이유는 그 구조적인 단점들이 쉽게 드러나고 코드의 전체 유지보수에 큰 영향을 끼치기 때문이다. 그래서 다중 상속 금지처럼 문법적으로 제약이 되는 부분들이 있고 아예 상속을 개발자가 직접 막는 <code>final</code> 키워드도 존재한다. 클래스에 <code>final</code> 키워드를 붙이면 상속이 금지되고 메소드에 <code>final</code> 키워드를 붙이면 오버라이딩이 금지되는 것처럼 자바에서는 상속을 최대한 신중히 허용하는 스탠스를 취해왔다. 참고로 <code>final</code> 키워드 및 그 부가 효과들은 자바 초기 버전부터 존재했다. 상속에 대해 얼마나 민감하게 대하는지를 어렴풋이 짐작할 수 있다.</p>
<h2 id="3-다른-언어에서는-어떨까">3. 다른 언어에서는 어떨까?</h2>
<p>지금까지 자바와 관련된 상속의 단점, 그리고 합성을 통한 극복 방안을 다뤄봤다. 마지막으로 다른 프로그래밍 언어들은 상속에 대해 어떤 스탠스를 취하는지 간단하게만 파악해보자.</p>
<h3 id="1-파이썬-다중-상속을-허용하지만-우선순위와-흐름은-명확히">1) 파이썬: 다중 상속을 허용하지만, 우선순위와 흐름은 명확히</h3>
<p>파이썬은 상속에 대해 상당히 관대한 스탠스를 취한다. 단일 상속뿐 아니라 다중 상속도 공식적으로 지원하며, 이로 인해 발생할 수 있는 충돌을 방지하기 위해 <strong>엄격한 탐색 규칙인 MRO(Method Resolution Order)</strong> 를 도입했다.</p>
<pre><code class="language-python">class A:
    def greet(self):
        print(&quot;A&quot;)

class B:
    def greet(self):
        print(&quot;B&quot;)

class C(A, B):
    pass

c = C()
c.greet()  # MRO에 따라 &quot;A&quot; 출력</code></pre>
<p>위 코드에서 <code>C</code>는 <code>A</code>, <code>B</code>를 동시에 상속하지만, <code>greet()</code> 호출 시 <code>A</code>의 메서드가 먼저 실행된다. 이유는 파이썬 내부에서 <strong>C3 선형화(C3 Linearization)</strong> 알고리즘을 통해 클래스의 탐색 순서를 정해두었기 때문이다. 실제로 <code>C.mro()</code>를 호출하면 다음과 같은 순서를 확인할 수 있다:</p>
<pre><code class="language-python">print(C.__mro__)
# (&lt;class &#39;__main__.C&#39;&gt;, &lt;class &#39;__main__.A&#39;&gt;, &lt;class &#39;__main__.B&#39;&gt;, &lt;class &#39;object&#39;&gt;)</code></pre>
<p>파이썬은 <strong>상속 순서를 막지 않되, 우선순위를 명확히 정하겠다</strong>는 입장이다. 이는 자바처럼 아예 다중 상속을 금지하는 방향이 아닌, 프로그래머에게 강력한 자유를 주되 <strong>그 자유로 인한 위험은 규칙(MRO)으로 제어</strong>하겠다는 철학으로도 읽힌다. 또한 파이썬의 <code>super()</code>는 단순히 바로 윗 부모 클래스만 호출하는 것이 아니라, <strong>MRO에서 다음으로 탐색할 클래스를 호출하는 역할</strong>을 한다. 이 덕분에 다중 상속 구조에서도 위임 체인을 자연스럽게 타고 올라갈 수 있다.</p>
<pre><code class="language-python">class A:
    def greet(self):
        print(&quot;A&quot;)

class B(A):
    def greet(self):
        super().greet()
        print(&quot;B&quot;)

class C(B):
    def greet(self):
        super().greet()
        print(&quot;C&quot;)

C().greet()
# A
# B
# C</code></pre>
<p>위 예제처럼 <code>super()</code> 호출이 연결되면, 마치 <strong>MRO 순서대로 메서드를 릴레이 호출</strong>하는 듯한 결과가 나온다. 특히 다중 상속에서 이 구조는 충돌을 방지하면서 명확하고 예측 가능한 흐름을 보장한다. 자바가 다중 상속을 막고 구조적 충돌을 미연에 방지하는 반면, 파이썬은 충돌이 발생할 수 있음을 전제로 규칙을 설계하고, 명확한 실행 흐름을 보장함으로써 안정성을 확보하는 방향을 택한 셈이다.</p>
<h3 id="2-코틀린-자바와-유사하면서도-더-엄격한-상속-규칙">2) 코틀린: 자바와 유사하면서도 더 엄격한 상속 규칙</h3>
<p>단순히 코틀린이 자바의 업그레이드 혹은 슈퍼셋이라고 생각했었는데, 코틀린은 코틀린만의 설계 철학을 보유하고 있었고 상속에서도 그 점이 드러난다. 원칙적으로 코틀린에서는 모든 클래스가 <code>final</code> 취급이어서 기본적으로 상속이 불가능하고 <code>open</code> 키워드를 붙여야 상속이 가능해진다.</p>
<pre><code class="language-java">open class A // open 키워드를 붙여야 상속 가능
class B : A() </code></pre>
<p>또한 인터페이스에서도 자바처럼 다중 구현을 허용하지만 <strong>명시적으로 어떤 부모를 구현할지</strong>를 나타내야 한다. </p>
<pre><code class="language-java">interface A {
    fun greet() = println(&quot;A&quot;)
}

interface B {
    fun greet() = println(&quot;B&quot;)
}

class C : A, B {
    override fun greet() {
        super&lt;A&gt;.greet()  // 명확하게 지정
    }
}</code></pre>
<p>자바에서는 분명 상속에 대해 조심스러운 스탠스를 취함에도 불구하고 명시적으로 엄금한다는 문법적 의도가 드러나는 편은 아니다. <code>final</code> 키워드나 <code>sealed</code> 키워드를 통해 상속을 금지할 수 있을 뿐, 기본적으로 모든 클래스는 상속이 가능한 구조를 띈다. 반면 코틀린은 원천적으로 상속을 금지하면서 <code>open</code> 키워드를 통해 상속을 가능하게 할 수 있고 인터페이스에서도 다중 구현은 허용하지만 그것에 대한 구체적인 명시가 요구된다. 코틀린이 객체지향에 함수형 사고를 더하며 자바를 바탕으로 재설계한 언어라는 점을 봤을 때, 유지보수의 효율성을 높이기 위한 선택 중 하나로써 상속 원천금지가 존재한다고 볼 수 있겠다.</p>
<h3 id="3-자바스크립트-프로토타입의-유연함-속-모호한-스탠스">3) 자바스크립트: 프로토타입의 유연함 속 모호한 스탠스</h3>
<p>자바스크립트는 다른 언어들과 다르게 클래스 이전에 <strong>프로토타입</strong>이란 개념을 먼저 파악해야 한다. 자바에서는 클래스를 바탕으로 인스턴스를 공장에서 찍어내는 개념이었다면 자바스크립트는 기존 객체를 바탕으로 아예 새로운 객체를 생성하는 구조를 채택하고 있다.</p>
<pre><code class="language-js">const parent = {
  greet() {
    console.log(&quot;안녕&quot;);
  }
};

const child = Object.create(parent); // child 객체를 만들되, parent를 프로토타입으로 설정
child.greet(); // child 객체에 greet() 메소드가 없으니 프로토타입 체인을 타고 거슬러 올라가 parent.greet()을 실행</code></pre>
<p>이를 프로토타입 상속이라고 하는데, 기존의 상속이 오버라이딩하면서 확장해나가는 것과 다르게 자바스크립트에서는 <strong>필요할 때 위로 거슬러 올라가 찾는다</strong>는 개념을 제시하고 있다. ES6 이후에 <code>class</code> 키워드가 자바스크립트에도 등장했지만 그냥 키워드와 그 쓰이는 모양새가 자바와 닮았을 뿐, 내부적으로는 완전 다른 동작을 취하기 때문에 자바스크립트에서는 <code>class</code>를 문법적 설탕이라는 표현으로 부른다.</p>
<pre><code class="language-js">class A {
  greet() {
    console.log(&quot;A&quot;);
  }
}

class B extends A {
  greet() {
    super.greet(); // A
    console.log(&quot;B&quot;);
  }
}

const b = new B();
b.greet(); // A \n B</code></pre>
<p>마치 자바처럼 상속되고 생성자를 호출해 메소드를 실행하는 것처럼 보이지만, 실상은 저 <code>class</code> 키워드도 자바스크립트에서는 함수로 취급되고 프로토타입 체이닝을 통해 <code>B</code>의 인스턴스가 부모 <code>A</code>의 메소드에 접근 가능하도록 연결해서 거슬러 올라간다. 그래서 <code>super</code>는 컴파일 시점에 참조 타입을 결정하는 것이 아닌, 런타임 시점에 동적으로 부모 메소드를 찾아 호출하게 된다. 이런 특성들 때문에 유연한 만큼 예측이 상당히 어려워서 타입스크립트가 등장하는 계기가 됐다.</p>
<h2 id="4-마치며">4. 마치며</h2>
<p>상속은 비단 프로그래밍 언어의 문법의 일부로 치부할 만큼 가벼운 주제가 아니다. 객체의 재사용과 효율성 향상을 위해 개발자들의 오랜 시간의 고민이 녹여져 있는 부분이며 각 프로그래밍 언어에서 이를 어떻게 활용함과 동시에 발생하는 부수적 효과들의 대처와 연관된 설계 철학이 직접적으로 드러난다고 볼 수 있다. 그만큼 생각할 거리가 많고 동시에 너무나도 어려운 내용이기도 했다. 아직도 아리송하고 더 이해할 부분들이 많긴 하지만, 나름대로 객체지향을 같이 고민하면서 상속의 본질적인 부분에 대해 건드려보고 합성이 어떻게 대안점이 되는지 둘을 비교해볼 수 있었다.</p>
<p><strong>있으니까 써야지</strong>에서 <strong>왜 있을까</strong>를 고민할 수 있어서 좋은 주제였다:)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis Stream + SSE를 통한 대기열 구현 모식]]></title>
            <link>https://velog.io/@kim00ngjun_0112/Redis-Stream-SSE%EB%A5%BC-%ED%86%B5%ED%95%9C-%EB%8C%80%EA%B8%B0%EC%97%B4-%EA%B5%AC%ED%98%84-%EB%AA%A8%EC%8B%9D</link>
            <guid>https://velog.io/@kim00ngjun_0112/Redis-Stream-SSE%EB%A5%BC-%ED%86%B5%ED%95%9C-%EB%8C%80%EA%B8%B0%EC%97%B4-%EA%B5%AC%ED%98%84-%EB%AA%A8%EC%8B%9D</guid>
            <pubDate>Sun, 30 Mar 2025 16:45:51 GMT</pubDate>
            <description><![CDATA[<h1 id="대기열queue-구현">대기열(queue) 구현</h1>
<h2 id="1-개요">1. 개요</h2>
<p>대용량 트래픽이 몰리는 경우, 가장 문제가 될 수 있는 부분은 순서 정립이다. 예를 들어 티켓팅 서비스에서 선착순에 맞춰 인원을 컷하고 티켓을 제공해야되지만 트래픽이 몰리는 상황 속에서 어떤 요청이 먼저 왔는지 우열을 파악하기가 쉽지 않다. 즉 <strong>동시성 이슈</strong>가 발생할 우려가 커진다.</p>
<p>서비스 제공 측면에서는 위와 같고 실제 서버를 운용하는 개발자 입장에서 바라보자면, 특정 요청과 관련하여 트래픽이 몰리면서 부하가 급증하는 상황이 있을 때, <strong>부하를 적절히 분배 처리</strong>함과 동시에 <strong>처리 결과의 정합성</strong>을 보장해야 하는 고민거리가 생긴다.</p>
<p>이 문제를 해결하기 위한 방법이 바로 <strong>대기열 구현</strong>인데, 사실 대기열이란 게 그리 특별한 게 아니라 우리가 흔히 마주하는 자료구조 중 하나인 <strong>큐(FIFO)</strong>다. 즉 동시다발적으로 들어오는 수많은 요청들을 큐에 집어넣어서 <strong>순서를 강제로 정립</strong>한 다음, <strong>순차적으로 요청을 처리하면서 부하를 제어하</strong>는 것이 대기열의 기본 흐름이고 여기서 <strong>대기열의 시작점과 도착점을 병렬 확장시키면 부하 분산의 효과</strong>도 기대할 수 있다.</p>
<h3 id="1-대기열-구현을-위한-요소">1) 대기열 구현을 위한 요소</h3>
<p>저 병렬 확장을 위해 대기열에서 꺼내 처리하는 과정이 병렬적으로 이뤄질 수 있는 기능이 요구되는데, 이를 위해서 대기열은 보통 <strong>메세지 큐 시스템</strong>을 활용해서 복수의 컨슈머(대기열 처리 서버)가 작업 처리를 중복 없이 수행하도록 한다. 통상 사용되는 메세지 큐에는 <strong>Kafka</strong>, <strong>RabbitMQ</strong>, <strong>Redis Stream</strong> 등이 있다.</p>
<p>또한, 요청들을 대기열에 넣은 다음에 순차적으로 병렬 처리가 이뤄지기 때문에 사용자 경험 측면에서 실시간으로 자신의 요청이 어느 정도 처리됐는지를 나타내기 위한 <strong>실시간 연결</strong>이 요구되는데 <strong>WebSocket</strong>이나 <strong>SSE</strong>를 활용할 수 있다.</p>
<p>이번 모식 구현에서는 <strong>Redis Stream</strong>과 <strong>SSE</strong>를 활용했다.</p>
<h3 id="2-redis-stream">2) Redis Stream</h3>
<p>메세지 큐의 역할을 한다는 점에서 얼핏 보면 메세지브로커인 Redis Pub/Sub과 유사해보이기도 한다. 그 차이를 설명하자면...</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/3895fd26-a502-4784-89ea-4503f975c103/image.png" alt=""></p>
<p>단순히 구독자에게 메세지를 전달하면서 순서를 보장하지 않고 실시간 전달에만 집중하며, 따로 로그가 남지 않고 휘발되는 Redis Pub/Sub과 달리, 아예 Redis에서 제공하는 자료구조 중 하나로써 <strong>Stream</strong>을 활용하면 메세지의 로그를 남기면서 각 메세지별로 고유 ID를 제공함과 동시에 <strong>읽음 처리(XGROUPREAD)</strong>에 따라 <strong>대기 상태(PENDING)</strong> 전환 및 <strong>대기열 배제(XACK)</strong> 처리 등을 활용할 수 있다.</p>
<p>또한 컨슈머 그룹에 따라서 메세지 처리 대상을 묶어 분류할 수도 있기 때문에, 메세지를 여러 컨슈머들이 중복 없이 목적에 맞춰 처리할 수 있게 된다. 좀 더 자세한 플로우는 아래에서 설명한다.</p>
<h3 id="3-sseserver-sent-event">3) SSE(Server Sent Event)</h3>
<p>본래 클라이언트와 서버는 무상태성을 전제로 요청과 응답이 오고간다. 즉, 요청이 없으면 서버와 클라이언트는 연결될 일이 없지만, 채팅 등 실시간성이 요구되는 기능에서는 무상태성을 활용할 수 없기 때문에 좀 더 업그레이드 된 연결 방법이 요구된다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/ad556ad3-8854-4427-b00b-9a9b87028f4c/image.png" alt=""></p>
<p>그 중에 대표적으로 <strong>폴링</strong>과 <strong>웹소켓</strong>이 있는데, 폴링은 지속적인 요청을 계속 보내야 하므로 리소스에 상당한 부담이 가해지게 된다. 그래서 생각할 수 있는 게 웹소켓인데, 웹소켓은 클라이언트와 서버 간 쌍방 실시간 통신이 가능하다는 점에서 대기열 구현에서는 오버 엔지니어링이 될 수도 있다. 왜냐하면 서버의 실시간 상황을 클라이언트에게 보고하는 점에서 그치면 되지 굳이 클라이언트가 실시간으로 서버에게 요청을 보낼 일은 없기 때문이다. 그래서 대기열 구현과 같이 서버가 실시간으로 클라이언트에게 정보를 전달해야 할 경우에는 <strong>SSE</strong>를 활용한다. HTML5의 표준안이면서 재접속 같은 저수준의 처리를 자동 지원해준다.</p>
<h2 id="2-대기열-구현">2. 대기열 구현</h2>
<h3 id="1-아키텍처">1) 아키텍처</h3>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/919fd8bb-1b4c-44f5-97f0-bb34a94a671a/image.png" alt=""></p>
<p>하나의 서버에서 모든 것(트래픽 수용, 대기열 처리)을 구현할 수도 있지만, 대기열의 시작점과 종단점을 구별해서 서버의 역할을 분리, 책임을 명확하게 나누기 위해 <strong>트래픽 수용 서버(8080번 포트)</strong>와 <strong>대기열 처리 서버(8081번 포트)</strong>를 간단하게 스프링부트로 구현한다.</p>
<p>트래픽 수용 서버에서 클라이언트로부터 요청을 받아들이며 대기열 처리 서버에서 SSE를 연결하여 대기열 처리 현황을 클라이언트에게 실시간 전달해준다.</p>
<h3 id="2-트래픽-수용-서버">2) 트래픽 수용 서버</h3>
<p>Redis Stream이 대기열의 역할을 맡으므로, 트래픽 수용 서버는 요청을 받아들여서 대기열에게 해당 요청을 넘기게 된다. 여기서 Redis Stream에게 <strong>XADD</strong>를 통해 메세지를 넘겨야 하는데, 서버에서는 <code>RedisTemplate</code>를 통해 이뤄진다.</p>
<pre><code class="language-java">@Slf4j
@RestController
@RequestMapping(&quot;/queue&quot;)
@RequiredArgsConstructor
public class TrafficController {

    private static final String STREAM_KEY = &quot;queue&quot;;

    private final RedisTemplate&lt;String, String&gt; redisTemplate;

    @PostMapping(&quot;/join&quot;)
    public ResponseEntity&lt;JoinDTO&gt; joinQueue(@RequestParam String userId) {
        RecordId recordId = redisTemplate.opsForStream()
                .add(STREAM_KEY, Map.of(&quot;userId&quot;, userId));

        log.info(&quot;대기열 참가: {} / {}&quot;, userId, recordId.getValue());
        return ResponseEntity.ok(new JoinDTO(userId, recordId.getValue()));
    }
}</code></pre>
<p>스트림 키를 통해 어떤 큐에게 메세지를 넘길지를 지정해주면, 해당 메세지의 고유 식별값(<code>RecordId</code>)이 반환된다. 저 메세지 식별값을 활용해서 현재 대기열 내에서 몇 번째로 처리되고 있는지를 활용할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/51421538-e3c0-4ff7-bc60-c7e044d532fa/image.png" alt=""></p>
<p>메세지 식별값은 현재 시간을 밀리세컨드로 표현하고 동일 시간대에는 인덱스를 부여하면서 고유성을 확보하게 된다. 이를 활용해서 Redis CLI에서 직접 <strong>XACK, XCLAIM, XPENDING</strong> 등을 수행할 수 있다.</p>
<p>만약 트래픽 수용과 대기열 처리가 같은 서버에서 이뤄졌으면 손쉽게 <code>recordId</code> 변수를 다른 서비스로 넘길 수 있겠지만, 나는 서버를 분리했기 때문에 다른 전달방법을 써야 하는데, 생각했던 방법은 2가지다.</p>
<blockquote>
<ol>
<li>Redis에 저장해서 대기열 처리 서버에서 조회</li>
<li>클라이언트로 전달해서 SSE 연결 과정에서 대기열 처리 서버에게 같이 요청</li>
</ol>
</blockquote>
<p>나는 2번을 선택했는데, 이유는 내 나름대로 고난의 길을 걷기 위함(...)이다. SSE의 연결과 Redis Stream 메세지 수신의 비정합성 해결을 위한 방법을 고민하려고 2번을 선택했는데(물론 1번도 똑같은 이슈가 발생하지만, 2번보다는 간극이 덜할 것으로 생각) 생각해보니 MSA 채팅앱 구현에서 저 이슈를 해결한 적이 있다. 그 얘기는 대기열 처리 서버에서 설명할 예정.</p>
<p>구현이 정상적으로 이뤄졌다면, Redis Stream에 메세지를 실을 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/b2d884a1-e7d2-424a-8f73-a2d01a71525f/image.gif" alt=""></p>
<h3 id="3-대기열-처리-서버">3) 대기열 처리 서버</h3>
<p>대기열 처리 서버는 SSE 연결을 받아들임과 동시에 Redis Stream에서 메세지를 비동기적으로 수신한다. 잠시 Redis Stream에서의 메세지 처리 과정을 설명하자면...</p>
<blockquote>
<ol>
<li>Redis Stream에 메세지가 추가된다(<strong>XADD</strong>). 각 메세지는 키와 값으로 이뤄진다.
<code>XADD &lt;stream_name&gt; &lt;id&gt; &lt;field1&gt; &lt;value1&gt; &lt;field2&gt; &lt;value2&gt; ...</code></li>
<li>메세지를 읽는다(<strong>XREADGROUP</strong>). 이때, 메세지를 <strong>컨슈머 그룹</strong>을 활용하여 읽으면 동일 컨슈머 그룹에 속한 소비자가 메세지를 분배해서 읽고 이것을 Redis가 메세지 식별값으로 추적해서 중복 처리를 방지한다.
<code>XREADGROUP GROUP mygroup myconsumer BLOCK 0 STREAMS mystream &gt;</code></li>
<li>읽은 메세지는 대기 상태로 전환된다(<strong>XPENDING</strong>)
<code>XPENDING mystream mygroup</code></li>
<li>대기 상태로 전환된 메세지를 확인 처리하면 대기열에서 빠져나온다(<strong>XACK</strong>)
<code>XACK &lt;stream_name&gt; &lt;group_name&gt; &lt;message_id&gt;</code></li>
</ol>
</blockquote>
<p>우리가 직접 관여할 스프링부트 서버에서 <code>RedisTemplate</code>을 통해 주로 다룰 부분은 4번이며, 2번은 사전 서버 세팅을 통해 부팅과 동시에 메세지를 수신하면서 읽음 처리를 수행한다. 2번이 이뤄지면 자동으로 3번이 이뤄지기 때문에 3번 이후에 대기열 처리(예약, 결제 등등)를 수행하고 4번을 코드로 호출한다. 참고로 1번은 이미 트래픽 수용 서버에서 이뤄지고 있다.</p>
<p>위의 과정은 비단 대기열 구현 외에도 Redis Stream 내에서의 메세지 생명주기를 나타내는 설명이기도 하다.</p>
<h4 id="1-사전-세팅">(1) 사전 세팅</h4>
<p>일단 Redis 설정에서 (당연히) <code>RedisTemplate</code>을 빈으로 등록해주고, <strong>스트림 메세지 리스너</strong>도 빈으로 등록해준다.</p>
<pre><code class="language-java">// 스트림 메세지 리스너 세팅
@Bean(name = &quot;listenerContainer&quot;)
public StreamMessageListenerContainer&lt;String, MapRecord&lt;String, Object, Object&gt;&gt; streamMessageListenerContainer(RedisConnectionFactory connectionFactory) {

    // ...커스텀한 메세지 리스너 컨테이너 세팅

    return StreamMessageListenerContainer.create(connectionFactory, options);
}</code></pre>
<p>스트림 메세지 리스너는 스프링부트 서버가 Redis Stream으로부터 메세지를 읽어오게 하는 역할을 담당한다. 이를 활용해서 대기열 처리 서버에서 <strong>중재자</strong>와 <strong>구독자</strong>를 세팅하여, Pub/Sub 모델을 기반으로 메세지를 병렬 처리할 수 있도록 한다.</p>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class RedisStreamConsumer
        implements StreamListener&lt;String, MapRecord&lt;String, Object, Object&gt;&gt;, InitializingBean, DisposableBean {

    // ...

    private Subscription subscription;

    private final StreamMessageListenerContainer&lt;String, MapRecord&lt;String, Object, Object&gt;&gt; listenerContainer;
    private final RedisTemplate&lt;String, String&gt; redisTemplate;

    // ...

    @Override
    public void onMessage(MapRecord&lt;String, Object, Object&gt; message) {
        // 대기열 처리(예약, 결제 등등...)

        // 수신 메세지 Ack 처리
        redisTemplate.opsForStream().acknowledge(CONSUMER_GROUP, message);
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        // ...큐 있는지 확인하고 없으면 큐 생성 + 컨슈머 그룹 세팅

        // 중재자 세팅(단일 컨슈머 그룹에서 메세지를 레디스 스트림 순서대로 수신)
        this.subscription = listenerContainer.receive(
                Consumer.from(CONSUMER_GROUP, CONSUMER_NAME),
                StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed()),
                this
        );

        // Redis Listen 시작
        this.listenerContainer.start();
    }</code></pre>
<p>코드가 상당히 복잡한데, 일단 핵심은 <strong>앱이 부팅되기 전에 메세지 리스너 컨테이너를 구축</strong>해야 되기 때문에 <code>afterPropertiesSet()</code> 메소드를 오버라이딩해야 한다. Pub/Sub 모델을 위한 중재자와 구독자 세팅을 대기열 처리 서버에서 책임지기 때문에, 중재자(<code>Subscription</code>)를 사전에 세팅하여 <strong>메세지 리스너 컨테이너에게 특정 컨슈머 그룹으로부터 메세지를 수신할 수 있는 컨슈머 네임을 지정, 부여</strong>한다. 이를 통해서 구독자(<code>Consumer</code>)가 세팅되고 메세지를 실시간으로 Redis Stream으로부터 읽어오게 된다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/11851989-1756-44f0-84d3-e1553aa99d0b/image.gif" alt=""></p>
<p>이를 통해서 아까 위에서 언급했던 2번, 읽음 처리가 메세지 수신과 동시에 수행될 수 있다. 읽어온 메세지는 앞서 언급했듯 곧바로 대기 상태로 전환된다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/3035c467-124e-4b62-a02c-9ebc8b78da18/image.png" alt=""></p>
<h4 id="2-sse-연결">(2) SSE 연결</h4>
<p>클라이언트에게 실시간으로 대기열 처리 현황을 넘겨주기 위한 SSE 구축 역시 필요하다. 꽤나 간단하게 코드가 구성되지만, 앞서 얘기했듯 <strong>SSE 연결</strong>과 <strong>Redis Stream 메세지 수신</strong>의 비정합성을 해결해야 한다. 왜냐하면 인메모리 DB 특성상, Redis는 상상 이상으로 빠르기 때문(...)에 SSE가 연결되기 전에 Redis로부터 메세지가 수신되면 연결된 SSE가 없어서 메세지가 소실될 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/ce87d286-3923-4732-9a38-d903f6ce0fc0/image.png" alt=""></p>
<p>사전 세팅은 웹소켓처럼 복잡하지 않고 컨트롤러 API 메소드에 어노테이션 값만 할당하면 끝이다.</p>
<pre><code class="language-java">@GetMapping(value = &quot;/stream&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter queue(
        @RequestParam(&quot;userId&quot;) String userId,
        @RequestParam(&quot;messageId&quot;) String messageId
) {
    log.info(&quot;sse 시작: {} / {}&quot;, userId, messageId);
    // ...</code></pre>
<p>물론 저 <code>SseEmitter</code> 객체를 반환하게 하기 위해 요청이 들어오는 클라이언트별로 해당 <code>SseEmitter</code> 객체를 생성해줘야 하는데, 그것은 일단 서비스 쪽으로 넘겼다.</p>
<pre><code class="language-java">@Slf4j
@Service
@RequiredArgsConstructor
public class SseEmitterService {

    private Map&lt;String, SseEmitter&gt; emitters = new ConcurrentHashMap&lt;&gt;();
    private Map&lt;String, QueueDTO&gt; records = new ConcurrentHashMap&lt;&gt;();
    private Map&lt;String, Long&gt; messageTimestamps = new ConcurrentHashMap&lt;&gt;();

    // ...

    public SseEmitter createEmitter(String userId, String messageId) {
        messageTimestamps.put(userId, extractTimeStamp(messageId)); // Stream 메세지 ID 저장

        SseEmitter emitter = new SseEmitter(60_000L); // 60초 타임아웃
        emitters.put(userId, emitter); // SSE Emitter 저장

        emitter.onCompletion(() -&gt; emitters.remove(userId));
        emitter.onTimeout(() -&gt; emitters.remove(userId));

        if (records.containsKey(userId)) {
            try {
                String response = objectMapper.writeValueAsString(records.get(userId));
                emitter.send(SseEmitter.event().name(&quot;queue&quot;).data(response));

                // SSE 연결 종료 책임은 클라이언트에게
            } catch (Exception e) {
                emitter.onCompletion(() -&gt; emitters.remove(userId)); // 전송 실패 시 제거
            }
        }

        return emitter;
    }</code></pre>
<p><code>ConcurrentHashMap</code> 타입들이 상당히 많은데, 클라이언트의 고유값인 <code>userId</code>를 기반으로 메세지 고유값인 <code>messageId</code>에서 타임스탬프를 추출하여 사전 저장해두고, <code>SseEmitter</code>가 생성되는 시점에서 이미 메세지가 수신되어 있으면 그것을 저장해뒀다가 확인하여 클라이언트로 송신하는 로직을 추가한다. 이를 통해 메세지 소실을 방지할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/153c5c56-53cc-4f4a-b8cc-1eea9a2730cb/image.gif" alt=""></p>
<p>8080번 포트가 점유하는 트래픽 수용 서버에 요청을 보내면, 8081번 포트에서 SSE를 통해 클라이언트로 실시간 정보가 전달되며, 구독이 이뤄진 클라이언트에서 확인이 가능해진다.</p>
<h3 id="4-클라이언트-구축--메세지-식별값-기반-처리-현황-연산">4) 클라이언트 구축 &amp; 메세지 식별값 기반 처리 현황 연산</h3>
<p>클라이언트 코드는 크게 설명은 하지 않는다. 단순 바닐라 자바스크립트를 통해 SSE 연결 수신을 구현했다. 리액트에서는 SSE 활용이 조금 다른 것으로 알고 있다.</p>
<pre><code class="language-js">function joinQueue() {
  const userId = document.getElementById(&quot;userId&quot;).value.trim();

  if (userId === &quot;&quot;) {
    alert(&quot;Please enter a user ID&quot;);
    return;
  }

  fetch(`http://localhost:8080/queue/join?userId=${userId}`, {
    method: &quot;POST&quot;,
    headers: {
      &quot;Content-Type&quot;: &quot;application/json&quot;
    }
  })
    .then(response =&gt; {
    if (!response.ok) {
      throw new Error(&quot;대기열 참가 실패&quot;);
    }
    return response.json();
  })
    .then(data =&gt; {
    const userId = data.userId;
    const messageId = data.messageId;
    startSSE(userId, messageId); // SSE 시작
  })
    .catch(err =&gt; console.error(&quot;대기열 참가 실패:&quot;, err));
}

function startSSE(userId, messageId) { 
  document.getElementById(&quot;modalUserId&quot;).textContent = userId;

  eventSource = new EventSource(`http://localhost:8081/stream?userId=${userId}&amp;messageId=${messageId}`, {
    withCredentials: true,
}</code></pre>
<p>사용자 식별값을 쿼리 파라미터로 트래픽 수용 서버에 보내줌과 동시에 받은 응답에 담긴 메세지 식별값을 담아 대기열 처리 서버에 구현된 SSE 연결 구독을 시도한다. 간단한 구현을 위해서지만 실제 서비스에서는 JWT 토큰을 헤더에 담고 메세지 식별값을 요청 바디에 담는 등의 요청이 이뤄질 것이다.</p>
<p>앞서 언급했듯, 메세지 식별값은 <strong>타임스탬프 + 인덱스</strong>를 통해 고유성을 확보한다. 이 타임스탬프를 적절히 환산해서 사용자 자신의 메세지 식별값과 현재 처리되는 메세지 식별값의 차이를 활용해서 진행률을 계산할 수 있다.</p>
<pre><code class="language-java">    emitters.forEach((clientUserId, emitter) -&gt; {
        long clientTime = messageTimestamps.getOrDefault(clientUserId, 99999L);
        double processPercent = 100 * Math.pow(Math.log10(10 * ((double) extractTimeStamp(messageId) / clientTime)), 20);
        processPercent = Math.floor(processPercent * 100) / 100.000;

        // ....

    private long extractTimeStamp(String messageId) {
        Pattern pattern = Pattern.compile(&quot;^(\\d+)-&quot;);
        Matcher matcher = pattern.matcher(messageId); // 타임스탬프 부분
        if (matcher.find()) {
            String timestamp = matcher.group(1).substring(8);  // 임의 서브스트링
            return Long.parseLong(timestamp);
        }

        return 0L;
    }</code></pre>
<p><code>SseEmitter</code> 객체들을 저장한 곳에서 일괄 조회해서 현재 메세지 식별값에 따른 현황을 지속적으로 송신한다. 나는 변화율의 음수를 방지하고 그 폭을 극단적으로 증가시키기 위해서 로그와 제곱근을 활용해서 연산했다.</p>
<h2 id="3-테스트-결과">3. 테스트 결과</h2>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/ce22fee4-18f2-4696-b0ba-f86c518714f0/image.png" alt=""></p>
<p>JMeter를 활용하여 가상 사용자 5000명을 상정하고 테스트를 수행한다.</p>
<h3 id="1-대기열-처리-서버-메세지-수신">1) 대기열 처리 서버 메세지 수신</h3>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/83a1b66a-96ac-4ba1-b156-2e965f23f90c/image.gif" alt=""></p>
<p>트래픽 수용 서버로 트래픽을 쏘면 Redis Stream으로 넘어간 메세지들이 성공적으로 수신되면서 대기열 처리 서버의 히카리풀 로그에 찍히는 것을 볼 수 있다.</p>
<h3 id="2-클라이언트-실시간-수신">2) 클라이언트 실시간 수신</h3>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/bd7351c4-a361-4cea-b20c-7049e9f23262/image.gif" alt=""></p>
<p>히카리풀 로그보다 클라이언트 데이터 수신이 더 빠르게 확인되는데, 데이터 수신은 정상적으로 이뤄지는 것이 확인되는 걸 보니 데이터 처리 속도가 매우 빨라서 히카리풀 로그가 따라가지 못하는 것으로 보인다(...)</p>
<p>만약 프론트엔드에 익숙하다면 실시간으로 변하는 데이터 처리 현황을 바탕으로 로딩바를 표현해서 좀 더 시각적으로 정보를 전달할 수도 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/f3ad2d37-906e-4def-a370-cde01f46b4a4/image.gif" alt=""></p>
<h3 id="3-후기">3) 후기</h3>
<p>생각 이상으로 어려운 토이 프로젝트였다. 처음에는 대기열 처리 서버 역시 비동기 처리에 강점을 보이는 Netty를 통해 구현하려 했으나 <code>RedisTemplate</code>에서 제공하는 Redis Stream 관련 세팅이나 기능이 제한적이었고 뭣보다 내가 리액티브 프로그래밍에 아직 익숙치 않았다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/9696c753-35b1-4588-b3df-1a0db1e6ac1f/image.png" alt=""></p>
<p>거기에 더불어 늘 생기는 CORS 이슈와 SSE 연결 소실 이슈 등등... 다만 그럼에도 최대한 아키텍처를 유지하고자 했던 것은, 추후 배포 및 실제 서버 운용에서 앞서 언급했듯 대기열의 활용 극대화를 위한 서버 오토 스케일링이나 로드밸런싱을 고려했던 부분이라 그런 부분이 준수되면서 구현된 것은 꽤 만족스럽다.</p>
<p>Redis Stream에 대한 정보들에 대해 틀린 부분이 있으면 댓글 등으로 지적 바라며, 이 포스팅이 대기열이 궁금하신 분들이나 Redis Stream을 접하고자 하는 분들께 도움이 되길 바란다.</p>
<p><em>소스 코드</em>
<em><a href="https://github.com/kimD0ngjun/queue">https://github.com/kimD0ngjun/queue</a></em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[자바의 리액티브 프로그래밍]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EC%9E%90%EB%B0%94%EC%9D%98-%EB%A6%AC%EC%95%A1%ED%8B%B0%EB%B8%8C-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EC%9E%90%EB%B0%94%EC%9D%98-%EB%A6%AC%EC%95%A1%ED%8B%B0%EB%B8%8C-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D</guid>
            <pubDate>Tue, 18 Mar 2025 07:49:17 GMT</pubDate>
            <description><![CDATA[<h1 id="리액티브-프로그래밍">리액티브 프로그래밍</h1>
<p>사실 이번 스터디를 하면서 가장 바랐던 것은 <strong>리액티브 프로그래밍</strong> 구현을 위한 배경 지식을 쌓는 것이었다. 다만 자바란 언어 자체가 함수형과 리액티브 프로그래밍 등에 어울리지는 않다. 철저하게 객체지향적으로 코드가 설계되고 그 과정에서 엄격한 타입 검증이 이뤄지기 때문에 좋게 말하자면 개발자의 의도를 듬뿍 담는 거고 나쁘게 말하면 일일이 개발자가 전부 챙겨야 하는 점에서, 선언형이 중시되는 함수형 프로그래밍과는 거리가 먼 언어라고 할 수 있다.</p>
<p>그렇기 때문에 자바를 메인으로 삼은 나로서는 단순히 자바의 함수형 패러다임 관련 API를 이해하는 것에 그치지 않고, 스프링 웹플럭스와 같은 추가적인 프레임워크 활용 공부나 코틀린, 파이썬 등까지 나아가야 리액티브 프로그래밍이 무엇인지, 실전적으로 어떻게 활용할 수 있을 지에 대해 논할 수 있을 것이다. 그런 점에서 이번 교재는 아쉬움이 많이 남는다고 할 수 있겠다. 할 게 많네...</p>
<h2 id="1-개념-정리">1. 개념 정리</h2>
<p>사실 리액티브 프로그래밍의 용어조차 혼동이 잦을 때가 많다. 비동기 프로그래밍, 함수형 프로그래밍... 비슷하면서도 뭔가 다르지만 뭐가 구체적인 것인지 확실하게 안 잡혀 있어서 일단은 개념 교통정리부터 하고 넘어갈 예정</p>
<h3 id="1-비동기-프로그래밍">1) 비동기 프로그래밍</h3>
<p><img src="https://github.com/user-attachments/assets/1cbd1a34-fdf0-4ebd-9809-060e449783b1" alt="image"></p>
<p>기존의 동기식 프로그래밍의 가장 큰 문제는 선형적인 시간 흐름 속에서 자원이 낭비되는 케이스가 잦은 것이었다. 즉, 다른 작업이 완료돼서 해당 작업의 응답이 도착할 때까지 자신의 작업은 중단된 상태로 잠드는 것이다. 간단하게 멀티 스레드를 생각했을 떄, 스레드 B가 작업 중이고 해당 작업이 완료된 응답을 대기하기 위해 스레드 A가 잠든 상태를 생각하면 된다(<strong>블로킹</strong>). 다만, 스레드는 잠들어도 여전히 자원을 점유하고 있게 된다. 이 과정에서 잠들지 않고 스레드 B의 작업과 별개로 스레드 A의 작업을 여전히 계속 이어나갈 수 있게 한다면(<strong>논 블로킹</strong>)? 이게 곧 비동기 프로그래밍 기법의 기초적인 시작점이라 볼 수 있다.</p>
<h4 id="1-간단한-예제">(1) 간단한 예제</h4>
<pre><code class="language-java">public record Shop(String name) {

    public static void delay() {
        try {
            Thread.sleep(1000L);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    public double getPrice() {
        delay();
        Random random = new Random();
        return (name.charAt(0) + name.charAt(1)) * random.nextDouble();
    }
}</code></pre>
<p>간단한 불변 객체를 만들고 해당 로직에서 일부러 딜레이를 걸어준다. 즉, 기능 수행 과정에서 1초(조금 넘는?) 시간이 소요되도록 처리해뒀다. 
그리고 아래와 같이, 데이터들의 일반적인 순차 스트림 동기 처리, 병렬 스트림 처리, 스트림 비동기 처리 로직을 짜고 벤치마킹해본다.</p>
<pre><code class="language-java">@State(Scope.Benchmark) // 같은 벤치마크끼리 객체 공유(멀티스레드 측정용)
@OutputTimeUnit(TimeUnit.MICROSECONDS) // 벤치마킹 결과 단위 설정
@BenchmarkMode(Mode.All) // JMH의 벤치마크 실행 범위 지정
public class AsyncTest {

    private static final List&lt;Shop&gt; shops = Arrays.asList(
            // 100개가 넘는 Shop 인스턴스들
    );


    @Benchmark
    public List&lt;String&gt; findPrices() {
        return shops.stream()
                .map(s -&gt; String.format(&quot;%s price is %.2f&quot;,
                        s.name(), s.getPrice()))
                .toList();
    }

    @Benchmark
    public List&lt;String&gt; findPricesByParallelStream() {
        return shops.parallelStream()
                .map(s -&gt; String.format(&quot;%s price is %.2f&quot;,
                        s.name(), s.getPrice()))
                .toList();
    }

    @Benchmark
    public List&lt;String&gt; findPricesByAsync() {
        List&lt;CompletableFuture&lt;String&gt;&gt; priceFutures =
                shops.stream()
                        .map(s -&gt; CompletableFuture.supplyAsync(
                                () -&gt; s.name() + &quot; price is &quot; + s.getPrice()
                        )).toList();

        return priceFutures.stream().map(CompletableFuture::join).toList();
    }
}</code></pre>
<img width="80%" alt="스크린샷 2025-03-18 오후 2 31 35" src="https://github.com/user-attachments/assets/e4070b8e-6e09-4495-bd91-3bfec5162595" />

<table>
<thead>
<tr>
<th>테스트 명칭</th>
<th>측정 모드</th>
<th>실행 시간 (us/op)</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td><strong>AsyncTest.findPrices</strong></td>
<td><code>thrpt</code></td>
<td>≈ 10⁻⁸ ops/us</td>
<td>처리량 매우 낮음 (순차 실행)</td>
</tr>
<tr>
<td><strong>AsyncTest.findPricesByAsync</strong></td>
<td><code>thrpt</code></td>
<td>≈ 10⁻⁷ ops/us</td>
<td>비동기 방식, 10배 향상</td>
</tr>
<tr>
<td><strong>AsyncTest.findPricesByParallelStream</strong></td>
<td><code>thrpt</code></td>
<td>≈ 10⁻⁷ ops/us</td>
<td>병렬 스트림, 10배 향상</td>
</tr>
<tr>
<td><strong>AsyncTest.findPrices</strong></td>
<td><code>avgt</code></td>
<td>104,459,682.67</td>
<td>약 104초, 가장 느림</td>
</tr>
<tr>
<td><strong>AsyncTest.findPricesByAsync</strong></td>
<td><code>avgt</code></td>
<td>15,056,668.96</td>
<td>약 15초, 7배 이상 향상</td>
</tr>
<tr>
<td><strong>AsyncTest.findPricesByParallelStream</strong></td>
<td><code>avgt</code></td>
<td>14,056,501.83</td>
<td>약 14초, 가장 빠름</td>
</tr>
</tbody></table>
<p>많은 결과들이 있지만 <strong>처리량(thrpt)</strong> 과 <strong>평균 소요 시간(avgt)</strong> 기준으로 비교하자면, 순차 스트림 방식의 처리량이 매우 낮고 소요 시간이 매우 오래 걸린 것을 파악할 수 있다. 이것을 동일 데이터 기준에서 비동기 처리(병렬 스트림 역시 비동기 처리이므로)로 개선했을 때, 처리량이 약 10배 이상 향상됐고 소요 시간은 거의 7배 가량 단축된 것을 확인할 수 있다.</p>
<h4 id="2-콜백-지옥">(2) 콜백 지옥</h4>
<p>다만 비동기 프로그래밍을 위해 코드를 작성할 때, 흔하게 마주할 수 있는 가독성 나쁜 문제가 있는데 바로 <strong>콜백 지옥</strong>이다.</p>
<pre><code class="language-java">// Java
public static void main(String[] args) {
    task1(result1 -&gt; {
        System.out.println(&quot;Task 1 완료: &quot; + result1);
        task2(result2 -&gt; {
            System.out.println(&quot;Task 2 완료: &quot; + result2);
            task3(result3 -&gt; {
                System.out.println(&quot;Task 3 완료: &quot; + result3);
                task4(result4 -&gt; {
                    System.out.println(&quot;Task 4 완료: &quot; + result4);
                });
            });
        });
    });
}</code></pre>
<pre><code class="language-js">// JS
task1(() =&gt; {
  task2(() =&gt; {
    task3(() =&gt; {
      console.log(&#39;All tasks completed&#39;);
    });
  });
});</code></pre>
<pre><code class="language-py"># Python
def start():
    task1(lambda: task2(lambda: task3(lambda: task4(lambda: print(&quot;모든 작업 완료&quot;)))))
</code></pre>
<p>이 콜백 지옥이 발생하는 이유는 콜백 함수를 인자로 전달하는 과정이 중첩되면서 발생하게 된다. 콜백 함수는 파라미터로 전달되어 특정 시점에 호출되는 함수를 뜻하는데, 보통 콜백 함수의 기능 작성을 파라미터 위치에서 수행(자바의 경우에는 익명 클래스 혹은 람다식, 자바스크립트는 화살표 익명 함수, 파이썬은 람다 익명 함수)하기 때문에, 작성 과정에서 가독성이 나빠지게 되는 구조가 된다.</p>
<p>이 콜백 지옥을 보완할 수 있는 방법 중 하나가 <strong>함수형 프로그래밍</strong>이다. </p>
<pre><code class="language-java">public static void main(String[] args) throws InterruptedException {
    CompletableFuture.supplyAsync(() -&gt; task1())
            .thenApply(result1 -&gt; {
                System.out.println(&quot;Task 1 완료: &quot; + result1);
                return task2();
            })
            .thenApply(result2 -&gt; {
                System.out.println(&quot;Task 2 완료: &quot; + result2);
                return task3();
            })
            .thenApply(result3 -&gt; {
                System.out.println(&quot;Task 3 완료: &quot; + result3);
                return task4();
            })
            .thenAccept(result4 -&gt; System.out.println(&quot;Task 4 완료: &quot; + result4));
}</code></pre>
<p>위의 예제는 아까 본 자바의 콜백 지옥 예제 코드와 동일한 기능을 수행하지만, 가독성이 어느 정도 보완된 것을 확인할 수 있다. <code>CompletableFuture</code>의 메소드 체이닝을 통해 각 작업들을 연결하듯이 작성하면서 각 작업 명시를 명확하게 표현하고 있다. 이것 외에도 여러 리액티브 라이브러리를 통해 간결하면서 가독성 좋게 표현할 수 있다.</p>
<p>후술하겠지만, 리액티브 프로그래밍의 핵심이 바로 이 비동기 프로그래밍을 기반으로 함수형 프로그래밍을 통해 보완하는 방향을 통해 데이터를 반응형으로 처리하는 기법이라고 할 수 있다.</p>
<h2 id="2-간단한-리액티브-애플리케이션-예제">2. 간단한 리액티브 애플리케이션 예제</h2>
<p>비동기 프로그래밍을 기반으로 함수형 프로그래밍을 통해 보완하는 코드 설계 방향을 파악했으니, 이제 데이터를 <strong>반응형</strong>으로 처리한다는 것에 대해 알아보자.
반응형으로 처리한다는 것은 기존의 명령형 프로그래밍의 순차(위에서부터 한 줄씩)적인 처리를 한다는 것에서 큰 차이가 있는데 아래 그림을 보자</p>
<p><img src="https://github.com/user-attachments/assets/49124801-2617-4fc7-8354-a979f0dbb0ae" alt="image"></p>
<p>기존 명령형은 위의 라인에서 코드를 그대로 읽어내려오면서 순차적으로 처리되므로 A의 값이 10으로 업데이트되는 것과 기존의 B와 C의 연산은 별개의 독립된 내용이다. 반대로 리액티브에서는 A의 값이 업데이트 되는 것이 <strong>이벤트</strong>처럼 인지되면서 기존의 B, C 연산들이 영향을 받아 반응(<strong>Reactive</strong>)하는 형태가 되기 때문에 연계된 연산들 역시 추가로 업데이트된다. 이벤트가 발생하는 것에 맞춰 추가 연산이 이뤄져야 하기 떄문에 <strong>병렬 처리, 비동기 처리</strong>가 리액티브 프로그래밍에서 핵심을 차지하게 된다. 그와 동시에, B와 C의 입장에서는 A가 1일 때와 A가 10일 때, 연산이 2번 이뤄진 셈이므로 캐싱이나 구독 취소, 조건 등을 통해 자원 관리에 주의를 기울여야 한다.</p>
<h3 id="1-자바-flow-클래스">1) 자바 Flow 클래스</h3>
<p>넷플릭스 등에서 제시한 RxJava 모듈 등도 있지만, 발행, 구독 관련 기능을 제공하는 자바 기본 <strong>Flow</strong> 클래스를 통해 간단한 리액티브 앱을 만들 수 있다.
해당 API를 구성하는 핵심 인터페이스(<code>Publisher&lt;T&gt;</code>, <code>Subscription</code>, <code>Subscriber&lt;T&gt;</code>)들을 정리하자면 아래와 같다.</p>
<p><img src="https://github.com/user-attachments/assets/66272598-533b-48df-be82-176bd55ecac6" alt="image"></p>
<ul>
<li><code>Publisher</code>는 반드시 <code>Subscription</code>의 <code>request</code> 메서드에 정의된 개수 이하의 요소만 <code>Subscriber</code>에 전달해야 한다.</li>
<li><code>Publisher</code>는 지정된 개수보다 적은 수의 요소를 <code>onNext</code>로 전달할 수 있으며 동작이 성공적으로 끝났으면 <code>onComplete</code>를 호출하고 문제가 발생하면 <code>onError</code>를 호출해 <code>Subscription</code>을 종료할 수 있다.</li>
<li><code>Subscriber</code>는 요소를 받아 처리할 수 있음을 <code>Publisher</code>에 알려야 한다. 이런 방식으로 <code>Subscriber</code>는 <code>Publisher</code>에 역압력을 행사항 수 있고 <code>Subscriber</code>가 관리할 수 없이 너무 많은 요소를 받는 일을 피할 수 있다.</li>
<li><code>onComplete</code>나 <code>onError</code> 신호를 처리하는 상황에서 <code>Subscriber</code>는 <code>Publisher</code>나 <code>Subscription</code>의 어떤 메서드도 호출할 수 없으며, <code>Subscription</code>이 최소 되었다고 가정해야 한다.</li>
<li><code>Subscriber</code>는 <code>Subscription.request()</code> 메서드 호출이 없이도 언제든 종료 시그널을 받을 준비가 되어있어야 하며 <code>Subscription.cancel()</code>이 호출된 이후에라도 한 개 이상의 <code>onNext</code>를 받을 준비가 되어있어야 한다.</li>
<li><code>Publisher</code>와 <code>Subscriber</code>는 정확하게 <code>Subscription</code>을 공유해야 하며 각각이 고유한 역할을 수행해야 한다. 그러려면 <code>onSubscribe</code>와 <code>onNext</code> 메서드에서 <code>Subscriber</code>는 <code>request</code> 메서드를 동기적으로 호출할 수 있어야 한다.</li>
<li>표준에서는<code>Subscription.cancel()</code> 메서드는 몇 번을 호출해도 한 번 호출한 것과 같은 효과를 가져야 하며, 여러 번 이 메서드를 호출해도 다른 추가 호출에 영향이 없도록 ThreadSafe 해야 한다고 명시한다. 같은<code>Subscriber</code> 객체에 다시 가입하는 것은 권장하지 않지만 이런 상황에서 예외가 발생해야 한다고 명세서가 강제하진 않는다. 이전에 취소된 가입이 영구적으로 적용되었다면 이후의 기능에 영향을 주지 않을 가능성도 있기 때문이다.</li>
</ul>
<blockquote>
<p><em>참조 : <a href="https://devbksheen.tistory.com/entry/%EB%AA%A8%EB%8D%98-%EC%9E%90%EB%B0%94-%EB%A6%AC%EC%95%A1%ED%8B%B0%EB%B8%8C-%EC%8A%A4%ED%8A%B8%EB%A6%BC%EA%B3%BC-Flow-API%EB%9E%80">https://devbksheen.tistory.com/entry/%EB%AA%A8%EB%8D%98-%EC%9E%90%EB%B0%94-%EB%A6%AC%EC%95%A1%ED%8B%B0%EB%B8%8C-%EC%8A%A4%ED%8A%B8%EB%A6%BC%EA%B3%BC-Flow-API%EB%9E%80</a></em></p>
</blockquote>
<p>참고로, 웬만한 자바 인터페이스 모듈들은 기본적인 구현체들을 제공하는데(<code>List&lt;T&gt;</code> 인터페이스의 기본 구현체인 <code>ArrayList&lt;T&gt;</code>) Flow API는 그런 게 없다. 왜냐면 API를 만들 당시에 이미 RxJava, Akka 등의 다양한 리액티브 스트림의 자바 코드 라이브러리가 존재했기 때문이다. 라이브러리들이 독립적으로 개발돼서 존재하였고, 자바 9의 표준화 과정에서 Flow API의 인터페이스를 기반으로 구현하도록 진화됐기 때문에 기본 구현 클래스가 존재하지 않는다.</p>
<h3 id="2-pubsub-모델-구현">2) Pub/Sub 모델 구현</h3>
<p>리액티브 애플리케이션을 구현할 수 있는 대표적인 패턴이자 모델로 <strong>발행/구독 모델(Publisher/Subscriber 패턴)</strong> 이 있다. 위에서 소개한 자바 Flow 클래스 역시 발행/구독 모델을 구현하기에 최적화된 인터페이스들을 제공한다. 간단하게 채팅 메세지를 발행해서 구독자들에게 전달하는 로직을 짜보자.</p>
<p>비동기 스트림의 대상 데이터인 <code>Message</code>, 데이터의 흐름을 제어하는 <code>Subscription</code> 구현체, 데이터를 수신해서 처리하는 <code>Subscriber&lt;Message&gt;</code> 구현체를 작성한다.</p>
<pre><code class="language-java">// 메세지 객체(전달자 명칭 파라미터 기반 임의의 랜덤한 메세지 내용 생성)
public record Message(String name, String message) {

    public static final Random random = new Random();

    public static Message fetch(String name) {
        if (random.nextInt(10) == 0) {
            // 10% 확률로 메세지 전송 실패
            throw new RuntimeException(&quot;메세지 전송 오류!&quot;);
        }

        char randomUpperCase = (char) (random.nextInt(26) + &#39;A&#39;);
        return new Message(name, String.valueOf(randomUpperCase));
    }

    @Override
    public String toString() {
        return name + &quot; : &quot; + message;
    }
}</code></pre>
<pre><code class="language-java">// 데이터 흐름 제어 목적의 중재자 객체
public class ChatSubscription implements Subscription {

    private final Subscriber&lt;? super Message&gt; subscriber;
    private final String name;

    public ChatSubscription(Subscriber&lt;? super Message&gt; subscriber, String name) {
        this.subscriber = subscriber;
        this.name = name;
    }

    @Override
    public void request(long n) {
        for (long i = 0L; i &lt; n; i++) {
            try {
                subscriber.onNext(Message.fetch(name)); // 현재 메세지 Subscriber 전달
            } catch (Exception e) {
                subscriber.onError(e); // 에러 발생 시, Subscriber로 에러 전달
                break;
            }
        }
    }

    @Override
    public void cancel() {
        subscriber.onComplete(); // 구독이 취소되면 완료 신호 Subscriber에게 전달
    }
}</code></pre>
<pre><code class="language-java">// 발행 메세지 수신 및 처리 구독자 객체
public class ChatSubscriber implements Subscriber&lt;Message&gt; {

    private Subscription subscription;

    @Override
    public void onSubscribe(Subscription subscription) {
        this.subscription = subscription;
        subscription.request(1); // 구독 저장 후, 첫 번째 요청 전달
    }

    @Override
    public void onNext(Message item) {
        System.out.println(item); // 메세지 출력
        this.subscription.request(1); // 다음 정보 요청
    }

    @Override
    public void onError(Throwable throwable) {
        System.out.println(throwable.getMessage()); // 에러 발생 시, 에러 메세지 출력
    }

    @Override
    public void onComplete() {
        System.out.println(&quot;종료&quot;);
    }
}</code></pre>
<p>이제 Main 클래스의 psvm에서 발행자 <code>Publisher&lt;Message&gt;</code> 구현체를 통해 메세지를 발행해본다.</p>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) {
        // 사용자 &#39;김동준&#39;을 만들고 Subscriber 구독시킴
        getChatMessage(&quot;김동준&quot;).subscribe(new ChatSubscriber());
    }

    private static Publisher&lt;Message&gt; getChatMessage(String name) {
        return subscriber -&gt; subscriber.onSubscribe(
                new ChatSubscription(subscriber, name)
        ); // 구독한 Subscriber에게 Chat 구독(ChatSubscription)을 전송하는 Publisher 반환
    }
}</code></pre>
<img width="80%" alt="스크린샷 2025-03-18 오후 4 35 11" src="https://github.com/user-attachments/assets/d97517f7-3da3-498c-84f3-ce01c0d411f9" />

<p>전체 흐름은 아래와 같다.</p>
<ol>
<li><code>getChatMessage(&quot;김동준&quot;)</code> 호출:<ul>
<li><code>Publisher&lt;Message&gt;</code> 반환. 이 <code>Publisher</code>는 <code>ChatSubscription</code>을 사용하여 구독자를 처리</li>
</ul>
</li>
<li><code>Main</code> 클래스에서 구독:<ul>
<li><code>getChatMessage(&quot;김동준&quot;).subscribe(new ChatSubscriber())</code>를 통해 <code>ChatSubscriber</code>가 구독자로 등록</li>
<li>구독자가 <code>onSubscribe()</code>를 호출하여 첫 번째 메시지를 요청</li>
</ul>
</li>
<li><code>ChatSubscription</code>에서 메시지 발행:<ul>
<li><code>ChatSubscription</code>은 메시지를 발행하고 <code>subscriber.onNext()</code>를 통해 구독자에게 메시지를 전달</li>
<li>구독자는 <code>onNext()</code>에서 메시지를 처리하고 다음 메시지를 요청</li>
</ul>
</li>
<li><code>ChatSubscriber</code>에서 메시지 처리:<ul>
<li>구독자는 <code>onNext()</code>를 통해 메시지를 처리. <code>request(1)</code>을 호출하여 다음 메시지를 요청</li>
<li>메시지가 모두 처리되면 <code>onComplete()</code>가 호출되어 종료 메시지가 출력</li>
</ul>
</li>
</ol>
<p>기존의 명령형에서는 메세지를 생산한 객체가 수신자에게 전달하는 책임까지 부담하는 구조로 코드가 짜여지는 것을, 발행자 - 중재자- 구독자의 세 단계로 나눠서 데이터의 발행과 처리, 제어에 대한 책임을 나눠서 컴포넌트의 독립성을 강화하고 시스템의 유연성을 확보하게 된다.</p>
<h2 id="3-자바에서의-리액티브-프로그래밍">3. 자바에서의 리액티브 프로그래밍</h2>
<p>위에서 언급했듯이 자바 자체가 리액티브 프로그래밍에 엄청 어울리는 언어는 아니라고 생각한다. 물론 다양한 함수형 API들이 제공되고 비동기 처리를 위한 멀티 스레드 개념을 적극적으로 활용할 수 있는 수단들이 제공되는 것은 분명 함수형 패러다임에 대한 훌륭한 대비책이겠지만, 태생 자체가 객체지향성을 바라보는 목적성은 코드 작성 과정에서도 드문드문 드러나는 것이 느껴졌다.</p>
<p>그렇기 때문에 여기서 그치지 않고 <strong>Spring WebFlux</strong> 같은 모듈이나 코틀린, 파이썬 같은 다른 언어들을 학습하여 리액티브 앱을 구현하기 위한 최적의 환경에 대한 이해도를 갖춰야 할 것이다. 어찌됐든 자바가 다른 언어 대비 가지는 최대의 장점이 엔터프라이즈 규모의 앱에 대하여 강력한 안정성 및 고성능 제공므로 이런 기존의 장점들을 바탕으로 어떻게 리액티브 앱 구현에서 자바를 활용할 수 있을 지를 고민하는 과정을 거쳐봐야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[자바의 람다식, 그리고 클로저]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EC%9E%90%EB%B0%94%EC%9D%98-%EB%9E%8C%EB%8B%A4%EC%8B%9D-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%ED%81%B4%EB%A1%9C%EC%A0%80</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EC%9E%90%EB%B0%94%EC%9D%98-%EB%9E%8C%EB%8B%A4%EC%8B%9D-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%ED%81%B4%EB%A1%9C%EC%A0%80</guid>
            <pubDate>Mon, 17 Feb 2025 11:47:34 GMT</pubDate>
            <description><![CDATA[<h1 id="람다-이모저모">람다 이모저모</h1>
<h2 id="1-함수형-인터페이스">1. 함수형 인터페이스</h2>
<p>람다식은 함수형 인터페이스(<code>@FunctionalInterface</code>)를 구현하는 것으로부터 시작됨. 이 함수형 인터페이스의 특징은 <strong>무조건 추상 메소드를 하나만 가질 것</strong>이다. 자바 API가 기본적으로 제공하는 함수형 인터페이스들(<code>Predicate</code>, <code>Comparator</code>, <code>Runnable</code>, <code>Callable</code>) 외에도 개발자가 커스텀하게 함수형 인터페이스 선언이 가능</p>
<pre><code class="language-java">@FunctionalInterface
public interface Calculator {
    int calculate(int x, int y);
}</code></pre>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) {
        Calculator plus = (x, y) -&gt; x + y;
        Calculator minus = (x, y) -&gt; x - y;
        Calculator multiple = (x, y) -&gt; x * y;
        Calculator divide = (x, y) -&gt; x / y;

        System.out.println(plus.calculate(6, 2));
        System.out.println(minus.calculate(6, 2));
        System.out.println(multiple.calculate(6, 2));
        System.out.println(divide.calculate(6, 2));
    }
}</code></pre>
<p>아래처럼 람다식의 형태가 동일해도 상위 함수형 인터페이스가 다른 경우가 있을 수도 있다.</p>
<pre><code class="language-java">import java.util.function.Function;
import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Predicate&lt;String&gt; predicate = str -&gt; str.length() &gt; 5;
        Function&lt;String, Boolean&gt; function = str -&gt; str.length() &gt; 5;

        System.out.println(predicate.test(&quot;abcdef&quot;)); // true
        System.out.println(function.apply(&quot;abcdef&quot;)); // true
    }
}</code></pre>
<p>람다식 그 자체로는 구현의 기반이 된 함수형 인터페이스가 어떤 것인지 타입을 확인하기 어렵다. 자바 컴파일러는 아래와 같은 정보들을 바탕으로 타입을 추론한다. 위처럼 람다식이 할당된 변수의 타입을 통해 컴파일러가 타입을 추론하게 된다.</p>
<blockquote>
<ol>
<li>람다식이 할당된 변수의 타입</li>
<li>람다식을 인자로 받는 메서드의 매개변수 타입</li>
<li>람다식이 사용된 컨텍스트(타입을 요구하는 상황)</li>
</ol>
</blockquote>
<p>즉 위와 같은 정보들이 없으면 컴파일러는 람다식의 타입을 추론할 수 없게 되므로 컴파일 에러를 일으키게 된다.</p>
<img width="80%" alt="스크린샷 2025-02-16 오후 7 14 15" src="https://github.com/user-attachments/assets/6a76e88d-4e09-459e-a8fe-083c7bdefea9" />

<hr>
<p>만약 메소드 오버로딩을 활용해 다른 함수형 인터페이스 타입을 인자로 받게 해서, 동일한 형식의 람다식을 메소드에게 넘겨주면 어떻게 될까?</p>
<pre><code class="language-java">import java.util.function.Function;
import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Lambda lambda = new Lambda();
        lambda.method(x -&gt; x.equals(&quot;String&quot;), &quot;String&quot;);  // ???
    }
}

class Lambda {
    public boolean method(Predicate&lt;String&gt; predicate, String data) {
        return predicate.test(data);
    }

    public boolean method(Function&lt;String, Boolean&gt; function, String data) {
        return function.apply(data);
    }
}</code></pre>
<img width="80%" alt="스크린샷 2025-02-16 오후 9 46 37" src="https://github.com/user-attachments/assets/e3cf6f81-1309-4a5f-9f0a-b925a95dbae3" />

<p>두 개의 컴파일 경고 이슈가 확인되는데, 각각 하나씩 살펴보자면</p>
<h4 id="ambiguous-method-call-issue">Ambiguous method call issue</h4>
<p>메소드 호출이 애매하다고 경고한다. psvm 내부에서 호출한 <code>method()</code>는 <code>Lambda</code> 클래스 인스턴스의 오버로딩 메소드 시그니처 2개와 동시에 일치하기 때문에 컴파일러가 어느 메소드를 호출한 것인지를 결정하지 못해서 컴파일 에러를 일으키는 것이다.</p>
<h4 id="cannot-resolve-method">Cannot resolve method</h4>
<p>이것은 람다식에 있는 <code>x</code> 변수의 타입을 추론하지 못해서 생기는 이슈다. 근데 의아한 게, 오버로딩된 메소드들을 살펴보면 어떤 것을 선택해도 결국 <code>String</code> 타입임에는 분명함에도 컴파일러가 이것조차 추론을 못 한다고 한다. 왜냐하면 앞전의 애매한 메소드 호출 이슈로 인해 애시당초 컴파일러가 어떤 함수형 인터페이스 타입인지조차 결정하지 못하기 때문에 함수형 인터페이스의 제네릭 타입이 문자열인 것도 추론하지 못하면서 컴파일 경고를 내뱉는 것이다. 실제로 실행시켜도 어차피 <strong>Ambigious method call issue</strong>를 내뱉으며 에러를 일으킬 것이므로 굳이 여기까지 가지 않아도 된다.</p>
<p>위의 이슈를 해결하려면 결국 람다식 앞에 타입을 명시적으로 추가하면서 형변환을 해줘야 해결할 수 있다.</p>
<h2 id="2-변수-캡처variable-capture와-클로저closure">2. 변수 캡처(Variable Capture)와 클로저(Closure)</h2>
<p><strong>변수 캡처</strong>는 람다식이나 익명 클래스가 외부 변수의 값을 사용할 때 발생하는 현상으로, 람다식 내부에서 외부 변수에 접근하는 방식이다.</p>
<h3 id="1-원시-타입의-변수-캡처">1) 원시 타입의 변수 캡처</h3>
<img width="80%" alt="스크린샷 2025-02-16 오후 7 53 43" src="https://github.com/user-attachments/assets/a4bdab85-2b66-4ee6-bc09-9e0e5554bb7e" />
<img width="80%" alt="스크린샷 2025-02-16 오후 8 01 06" src="https://github.com/user-attachments/assets/d83ca0fa-53f6-4707-91d1-f55bd08f98f2" />

<p>원칙적으로 전역변수가 아니면 메소드 내부에서 다루는 변수들은 전부 지역변수로써 취급되면서 그 생명주기를 공유해야 하지만, 변수 캡쳐는 그 예외로 볼 수 있다. 위의 예제에서는 <code>int</code> 타입의 <code>x</code> 변수가 람다식에 의해 캡쳐되면서 <strong>변수의 값이 복사</strong>되고 있다. 분명히 <code>x</code>라는 변수는 람다식 입장에서는 외부(렉시컬 스코프)에 위치해 있고 람다식 내부만 봤을 때는 뜬금없이 <code>x</code>라는 타입조차 추론이 안 될 변수가 등장한 거라 원칙적으로는 컴파일 에러가 나야되지만 정상적으로 동작하는 것을 볼 수 있다.</p>
<p>단, 변수 캡쳐의 전제 조건은 <strong>변수가 불변</strong>이어야 한다. 즉 변수의 상태가 <code>final</code>이거나 <code>effectively final</code>이어야 한다. 불변이 지켜지지 않으면 컴파일 에러가 발생한다.</p>
<img width="80%" alt="스크린샷 2025-02-16 오후 8 16 16" src="https://github.com/user-attachments/assets/51ac54e5-8cf1-4360-a4e3-3f31d08fe65c" />

<p>위의 경우는 변수 캡쳐된 <code>x</code> 변수가 처음에는 <code>effectively final</code> 상태(즉, 할당된 이후에 값의 변화가 발생하지 않으리라 기대하는 상태)여서 캡쳐가 허용됐으나 다음 라인에서 변수의 불변성이 깨지면서 컴파일 에러를 유발하는 사례다. <code>x</code> 변수는 원시 타입(<code>int</code>)이기 때문에 직접 참조되기 때문에 값의 변화가 곧 변수의 변화로 이어지기 때문에 변수 불변성이 지켜지지 않는 것이다.</p>
<p>이때까지는 원시 타입의 변수 캡처에 대한 설명이었고, 참조 타입의 변수 캡처는 설명이 조금 달라지게 된다.</p>
<h3 id="2-참조-타입의-변수-캡처">2) 참조 타입의 변수 캡처</h3>
<pre><code class="language-java">class Example {
    int variable;

    public Example(int variable) {
        this.variable = variable;
    }

    public int getVariable() {
        return variable;
    }

    public void setVariable(int variable) {
        this.variable = variable;
    }
}</code></pre>
<p>위와 같은 클래스가 있고, 이 클래스를 활용해서 똑같이 원시 타입의 변수 캡처 예제에 적용해본다. 결과는 아까와 똑같을 것이다.</p>
<img width="80%" alt="스크린샷 2025-02-17 오전 12 07 54" src="https://github.com/user-attachments/assets/655582c4-f04e-4700-89fe-1c9e50c3922a" />

<p>여기서 <code>Example</code> 클래스 내부의 <code>setter</code> 메소드를 호출해서 인스턴스의 필드를 변화시켜본다. 원시 타입 예제에서는 컴파일 에러가 발생했으나 여기서는 조금 다르다.</p>
<img width="80%" alt="스크린샷 2025-02-17 오전 12 12 15" src="https://github.com/user-attachments/assets/0d7f4267-94ed-414a-93c2-a04885de1227" />

<p>아까와 다르게 컴파일 에러가 바뀌지 않고 인스턴스의 필드가 정상적으로 업데이트되는 것을 확인할 수 있다. 이렇게 원시 타입 변수 캡처와 참조 타입 변수 캡처가 다르게 동작하는 이유는 <strong>캡처의 대상이 타입의 형식에 따라 다르기 때문</strong>이다.</p>
<p>모든 변수 캡처의 전제 조건은 <strong>변수의 불변성 준수</strong>다. 원시 타입의 변수 캡처는 <strong>값이 직접 캡처</strong>되기 때문에 흡사 값이 복사돼서 람다식 내부에서 사용되는 것과 같다. 그렇기 때문에 다른 값이 할당되는 시점부터 바로 불변성이 깨지게 되는 것이다. 그러나 참조 타입의 변수 캡처는 <strong>참조가 캡처</strong>되기 때문에 변수의 참조가 그대로 유지되면 내부의 필드 변화가 이뤄지든 뭘 하든 아무런 문제가 없는 것이다. 중요한 것은 참조 타입의 변수 캡처는 <strong>참조가 불변</strong>이어야 된다는 것이다. 다른 참조를 할당하게 되면 아래처럼 컴파일 에러가 발생하게 된다.</p>
<img width="80%" alt="스크린샷 2025-02-17 오전 12 18 54" src="https://github.com/user-attachments/assets/f1607fbe-b034-44a4-bd86-b4d5274ac670" />

<h3 id="3-클로저">3) 클로저</h3>
<p><strong>클로저</strong>는 람다식이나 익명 클래스가 외부 변수의 값을 캡처하고 그 변수를 계속 유지하는 성질이자, 함수와 그 함수가 참조하는 외부 변수들을 함께 묶은 객체을 뜻한다. 사실 뭔 말하는지 잘 모르겠다. 그만큼 어려운 개념이긴 하다. 하나씩 천천히 정리해보자면...</p>
<pre><code class="language-java">import java.util.function.IntSupplier;

public class Closure {
    public static void main(String[] args) {
        IntSupplier counter = closure(); // 클로저 생성

        System.out.println(&quot;변수 값: &quot; + counter.getAsInt());
        System.out.println(&quot;변수 값: &quot; + counter.getAsInt());
        System.out.println(&quot;변수 값: &quot; + counter.getAsInt());
    }

    // IntSupplier : 매개변수를 받지 않고 int 값을 반환하는 함수형 인터페이스
    static IntSupplier closure() {
        int[] count = {0};
        return () -&gt; ++count[0]; // IntSupplier 타입 람다식이 외부 변수(배열)인 count 캡처
    }
}</code></pre>
<p>다음과 같은 예제가 있다고 가정하자. 함수형 프로그래밍에서는 <strong>함수가 일급 객체로 취급</strong>된다. 그렇기 때문에 정적 메소드인 <code>closure()</code> 또한 람다식을 반환하는 함수형 객체라고도 볼 수 있다. 아까까지 봤던 변수 캡처가 <code>closure()</code> 메소드 내부에서 일어나고 있는 것을 볼 수 있다. <code>count</code>라는 배열 참조 타입의 변수가 <code>return</code>되는 람다식에 의해 캡처되고 있다.</p>
<p>이제 <code>counter</code> 변수의 추상 메소드의 구현체인 람다식을 <code>getAsInt()</code> 메소드 호출로 동작시켜보자.</p>
<img width="80%" alt="스크린샷 2025-02-17 오전 1 47 50" src="https://github.com/user-attachments/assets/88bdab74-56e5-4dec-9614-fdfd28dcf93d" />

<p><code>counter</code> 변수로부터 호출한 메소드로 인해 람다식 <code>() -&gt; ++count[0]</code>이 동작하면서 <code>count[0]</code>의 변수 값이 1씩 가산되는 것을 확인할 수 있다. 분명히 <code>closure()</code> 메소드 입장에서는 외부인 <code>main(String[] args)</code>에서 람다식이 호출됐음에도 불구하고 <code>count</code> 배열의 내부 값이 영향을 받아 변화하고 있다. 이것이 발생할 수 있는 이유가 바로 클로저 때문이다. 정리하자면,</p>
<ul>
<li><code>counter</code> 변수는 <code>closure()</code> 메서드에서 반환된 람다식, <code>() -&gt; ++count[0]</code>을 가리킴</li>
<li>람다식 내부에서 <strong>(람다식 입장에서의) 외부 변수 <code>count</code></strong>를 캡처하고, <code>count[0]</code> 값을 변경</li>
<li>람다식은 <code>closure()</code> 메서드 내에서 정의된 <code>count</code> 배열을 캡처하고 있기 때문에, <code>counter</code>가 호출될 때마다 <code>count[0]</code>의 값이 가산 갱신</li>
<li><code>closure()</code> 메서드는 람다식이 (람다식 입장에서의) 외부 상태(여기서는 <code>count[0]</code>)를 기억하고 있기 때문에, 그 상태가 갱신됐던 값으로 계속 유지</li>
</ul>
<p>참고로 <code>int</code>로 착각할 수 있는데, <code>count</code>는 배열인 참조 타입이다. 그렇기 때문에 그 내부 요소의 값이 변해도 참조는 유지되기 때문에 캡처가 유효한 것이다. 핵심은 람다식(<code>() -&gt; ++count[0]</code>) 내부에서 외부 변수(<code>count</code>)의 값이 변경(<code>++count[0]</code>)되더라도, 그 변수는 람다식 외부에서 정의된 상태(<strong>그 호출로 인한 변화</strong>)를 기억할 수 있는 것이 클로저다.</p>
<p><del>와씨 드럽게 어렵네</del></p>
<h4 id="다른-언어에서의-클로저">다른 언어에서의 클로저</h4>
<p>클로저는 자바에 한정된 개념이 아닌, 웬만한 함수형 프로그래밍을 채택하는 언어에서 등장하는 성질이다. 자바는 함수형 프로그래밍이 익명 클래스와 람다식을 통해 실현될 수 있어서 객체지향 관점에서 자주 접하기 힘든 개념이지만, 스크립트 언어인 자바스크립트와 파이썬에서는 꽤나 쉽고 빈번하게 접하는 개념이다.</p>
<pre><code class="language-js">// JavaScript

function closure() {
    let count = 0; // 외부 변수

    return function() {
        count++; // 외부 변수 값 변경
        return count;
    }
}

const counter = closure(); // 클로저 반환
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3</code></pre>
<pre><code class="language-py"># Python

def closure():
    count = 0  # 외부 변수

    def counter():
        nonlocal count  # 외부 변수에 접근
        count += 1  # 외부 변수 값 변경
        return count

    return counter

counter = closure()  # 클로저 반환
print(counter())  # 1
print(counter())  # 2
print(counter())  # 3</code></pre>
<h2 id="3-함수형-프로그래밍에서의-람다식-그리고-클로저">3. 함수형 프로그래밍에서의 람다식, 그리고 클로저</h2>
<p>사실 클로저는 함수형 프로그래밍에서 매우 중요한 개념으로 다뤄진다. 그 이유는, 클로저가 상태 캡처, 불변성, 함수 조합, 비동기 처리 등 여러 함수형 프로그래밍의 원칙들을 효과적으로 구현할 수 있게 해주는 역할로써 존재하기 때문이다. 그렇지만 자바의 태생적인 존재 이유가 <strong>객체지향 프로그래밍</strong>의 실현이라 봐도 무방하기 때문에 다른 언어들에 비해 함수형 프로그래밍의 실현도가 낮은 편이고, 더불어서 클로저의 중요도 비중도 낮은 편이다. 그럼에도 불구하고 자바 8 이후에 함수형 프로그래밍을 위한 다양한 도구들이 도입되면서 클로저 개념 역시 도입됐다는 것은 자바의 확장성을 꾀하겠다는 의도가 아닐까 싶다... <del>지만 벌써 LTS 21을 바라보고 있다.</del></p>
<p>그래도 자바에서의 클로저 존재 의의를 조금 더 살펴보자면, 위의 예제에서 봤던 것처럼 <strong>무분별한 변수의 전역화를 방지할 수 있다는 점</strong>이 가장 큰 것 같다. 그와 더불어 변수의 갱신 기억을 위해 별개의 클래스를 정의하지 않아도 기억이 가능하다는 점 정도? 클로저의 중요성과 함수형 프로그래밍에서의 정확한 동작 원리를 파악하고 활용도를 높이려면 다른 프로그래밍 언어 학습도 필요하다. 일단 자바스크립트 공부하던 시절에 너무 어려워서 무릎 꿇었던 클로저를 어느 정도 감 잡은 듯해서 나는 만족:D</p>
<p>클로저가 함수형 프로그래밍에서 중요한 이유는 다음과 같다.</p>
<h4 id="1-상태를-기억하기-때문에-함수형-프로그래밍에서의-캡슐화-실현-가능">(1) 상태를 기억하기 때문에 함수형 프로그래밍에서의 캡슐화 실현 가능</h4>
<p>아까 위에서 본 클로저 예제를 보자.</p>
<pre><code class="language-java">import java.util.function.IntSupplier;

public class Closure {
    // Controller (count 변수에 직접 접근할 수는 없지만, counter를 통해 조작할 수 있음)
    public static void main(String[] args) {
        IntSupplier counter = closure();  // count 변수를 감싸고 있는 클로저를 반환

        System.out.println(&quot;변수 값: &quot; + counter.getAsInt());
        System.out.println(&quot;변수 값: &quot; + counter.getAsInt());
        System.out.println(&quot;변수 값: &quot; + counter.getAsInt());
    }

    // Service (count 변수를 직접 노출하지 않고, 클로저를 통해서만 접근 가능)
    static IntSupplier closure() {
        int[] count = {0};  // 외부에서 직접 접근 불가능한 상태 변수
        return () -&gt; ++count[0];  // count 값을 증가시키는 클로저 반환
    }
}</code></pre>
<p><code>IntSupplier</code> 타입의 람다식을 반환하는 <code>closure()</code> 메소드를 서비스로 생각하고 <code>main(String[] args)</code> 정적 메소드를 컨트롤러로 생각해보면, 서비스의 변수라 할 수 있는 <code>count</code>는 컨트롤러에 직접 노출되지 않는다. 하지만 해당 변수를 캡처한 람다식을 통해 외부에서는 직접 접근할 수 없는 상태를 유지하면서도, 제공된 인터페이스를 통해 안전하게 값을 변경할 수 있다. 따라서 클로저를 활용하여 캡슐화를 실현하면서도 상태를 유지하는 함수를 만들 수 있다.</p>
<h4 id="2-고차함수와의-응용성">(2) 고차함수와의 응용성</h4>
<p>클로저를 활용하면 동일한 고차함수여도 다양한 기준을 제시할 수 있는 등, 범용성있게 활용할 수 있다. 아래 예제를 보자.</p>
<pre><code class="language-java">import java.util.function.IntPredicate;

public class ClosureHigherOrderFunction {

    // Service: 특정 값 이상만 허용하는 조건을 가진 클로저 반환 (고차 함수)
    static IntPredicate thresholdFilter(int threshold) {
        return value -&gt; value &gt;= threshold; // threshold 변수 캡처
    }

    // Controller
    public static void main(String[] args) {
        IntPredicate isOver10 = thresholdFilter(10);
        IntPredicate isOver20 = thresholdFilter(20);

        System.out.println(&quot;&lt;isOver10&gt; 15는 기준에 속할까? : &quot; + isOver10.test(15));
        System.out.println(&quot;&lt;isOver20&gt; 15는 기준에 속할까? : &quot; + isOver20.test(15));
    }
}</code></pre>
<img width="80%" alt="스크린샷 2025-02-17 오후 5 01 14" src="https://github.com/user-attachments/assets/d2ebb61c-d52d-4781-b11e-85747f38a029" />


<p>같은 클로저를 반환했음에도 변수를 기억하는 성질 때문에 다양한 기능을 구현하면서 코드의 재사용성이 증가하고 다형성을 실현할 수 있게 된다. 이것을 활용하여 비동기 프로그래밍에서 클로저를 강력하게 활용할 수 있을 것 같은데 아직 예제를 찾아보진 못함 ㅎ;</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[자바의 제네릭에 대한 고찰 - 타입 소거]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EC%9E%90%EB%B0%94%EC%9D%98-%EC%A0%9C%EB%84%A4%EB%A6%AD%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EC%9E%90%EB%B0%94%EC%9D%98-%EC%A0%9C%EB%84%A4%EB%A6%AD%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0</guid>
            <pubDate>Thu, 16 Jan 2025 10:48:51 GMT</pubDate>
            <description><![CDATA[<h1 id="제네릭-프로그래밍">제네릭 프로그래밍</h1>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/540044f1-fbfb-483f-bf6a-cc27c100fad9/image.png" alt=""></p>
<h2 id="1-서론">1. 서론</h2>
<p>제네릭은 자바만의 전유물은 아니고, 엄밀히 표현하자면 <strong>프로그래밍 기법</strong>이라고 할 수 있다. 구체적으로는 특정된 데이터 타입에 의존하지 않고 여러 다른 데이터 타입을 가질 수 있는 기술에 중점을 두면서 <strong>재사용도의 향상</strong>을 꾀하는 것이 제네릭 프로그래밍의 핵심이라 할 수 있다. 이 점은 객체지향 프로그래밍 패러다임에서 다형성을 실현할 수 있는 수단의 가능성을 시사하기도 한다.</p>
<p>데이터의 <strong>타입</strong>과 밀접한 관련이 있기 때문에 인터프리터를 활용하는 런타임 언어에서는 비중이 낮은 개념이긴 하다. 자바스크립트나 파이썬 같은 경우 코드를 짤 때, 타입을 명시하지 않아도 되는 이유가 런타임 시점에 타입이 결정되기 때문이다. 물론 데이터 타입의 안정성을 유지하는 것은 컴파일, 런타임을 넘어 성능과도 직결되는 중요한 부분이어서 파이썬의 <strong>타입 힌트</strong> 혹은 자바스크립트의 슈퍼셋 언어인 <strong>타입스크립트</strong> 같은 것들이 등장하게 된다.</p>
<h2 id="2-자바의-제네릭">2. 자바의 제네릭</h2>
<pre><code class="language-java">private String stringData;

private Integer integerData;

private CustomType customData;</code></pre>
<p>자바는 대표적인 정적 언어다. 즉, 컴파일 시점에 데이터의 타입이 결정되기 때문에 개발자의 구체적인 타입 명시가 중요한 언어다. 분명히 타입을 명시하는 것은 중요한 일이지만 <strong>동일한 기능을 수행함에도 데이터 타입이 다르다는 이유로 동일 기능을 각각 데이터들의 타입에 맞춰 반복 작성하는 것은 오히려 비효율적</strong>이다.</p>
<pre><code class="language-java">T TData;</code></pre>
<p>그래서 등장하는 개념이 제네릭인 것이다. 타입을 구체적으로 명시하지 않고 사용하는 케이스에 맞춰서 그때그때 타입을 <strong>유연하게 지정</strong>하는 것이 제네릭의 취지이자 핵심 동작 매커니즘이라고 할 수 있다.</p>
<p>자바 내에서 제네릭과 관련된 문법인 <strong>타입 파라미터</strong>, <strong>제네릭 타입 및 메소드</strong>, <strong>와일드카드</strong> 등에 대해서는 내가 예전에 자바 공부할 때 기록한 스터디 레코드를 참조할 것.</p>
<p><em><a href="https://velog.io/@kim00ngjun_0112/%EC%9E%90%EB%B0%94-%EA%B3%B5%EB%B6%80-%EA%B8%B0%EB%A1%9D-2%ED%9A%8C%EB%8F%859-2024.1.27-imgghcup#2-%EC%A0%9C%EB%84%A4%EB%A6%AD-%ED%83%80%EC%9E%85%EC%9D%98-%ED%83%80%EC%9E%85-%ED%8C%8C%EB%9D%BC%EB%AF%B8%ED%84%B0%EC%99%80-%ED%98%95%EB%B3%80%ED%99%98">자바 공부 기록 2회독 - 자바의 제네릭 문법</a></em></p>
<p><em>아래의 내용부터는 저우즈밍 저 &#39;JVM 밑바닥까지 파헤치기&#39;의 내용을 일부 참조하며 나의 의견을 담음</em></p>
<h2 id="3-제네릭-프로그래밍의-모순">3. 제네릭 프로그래밍의 모순</h2>
<h3 id="1-타입에-대한-제네릭의-취급">1) 타입에 대한 제네릭의 취급</h3>
<p>어느 정도 자바 문법이 익숙해지고 스프링 개발 연습을 하면서 제네릭이 점차 익숙해지고 다시 저 제네릭 프로그래밍의 개념을 유심히 읽었을 때 약간 의아한 점이 느껴졌다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/02c08b01-f722-4963-977a-7c5e2571914a/image.png" alt=""></p>
<blockquote>
<ol>
<li>제네릭은 <strong>타입 안정성</strong>을 위한 도구다.</li>
<li>제네릭을 활용하여 <strong>유연성</strong>을 발휘할 수 있다.</li>
</ol>
</blockquote>
<p>처음 접했을 때는 무심코 넘어갔지만, 다시 읽었을 때 저 두 표현이 상당히 <strong>모순적</strong>이라는 위화감을 떨칠 수가 없었다. 왜 그런가 해서 제네릭이 필요한 이유를 다시 읽어보다가 아래의 문구가 핵심이라는 느낌을 받았다.</p>
<blockquote>
<p>동일한 기능을 수행함에도 데이터 타입이 다르다는 이유로 <strong>동일 기능을 각각 데이터들의 타입에 맞춰 반복 작성하는 것</strong>은 오히려 비효율적이다...</p>
</blockquote>
<p>볼드체 처리된 부분이 핵심이라 느꼈는데, 이유는 <strong>오히려 타입 안정성을 지키는 것이 핵심이라면 중복 작업이어도 감수하고 타입에 맞춰 기능들을 작성하는, 타입을 우선적으로 생각하는 관점이 지향되어야 하지 않는가</strong>라는 점이었다.</p>
<p>애시당초 타입의 안정성을 위한다면서 타입의 유연성을 같이 챙기려는 점이 제네릭의 모순이라는 것이 포인트였다. 좀 더 구체적으로 말하자면, <strong>타입의 안정성을 지키는 것(현실)이 중요하지만, 그로 인해 생기는 반복적인 작업의 피로도와 비효율성이 높기 때문에 타입의 유연성을 발휘(이상)할 수 있는 방법</strong>으로써 제네릭이 등장한 것이다.</p>
<h3 id="2-코드로-살펴보는-모순점">2) 코드로 살펴보는 모순점</h3>
<pre><code class="language-java">int a = 100;
a = &quot;String&quot;; // Compile Error</code></pre>
<p>위의 코드는 비단 자바가 아닌 다른 정적 언어여도 컴파일 시점에서 에러를 반환하게 된다. 애시당초 정의한 타입과 다른 타입과 관련된 코드를 작성하기 때문에 문제가 발생하는 것이 자명하다. 런타임 시점에 데이터 플로우가 이뤄지는 것의 핵심은 컴파일 시점의 타입 정의이기 때문에 타입 정의가 옳지 못한 위의 코드는 런타임으로 넘어갈 필요도 없이 에러가 나게 된다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/21f26878-f97c-43c9-8f81-a51ccf6880d4/image.png" alt=""></p>
<p>위의 코드는 우리가 아는 정적 언어의 지식으로 무리 없이 받아들여진다. 근데 여기서 이제 제네릭을 도입했을 때, 과연 어떻게 될까? 아래와 같이 개략적인 상황이 있다.</p>
<pre><code class="language-java">// T : Generic Type Parameter

T data = 100;
data = &quot;String&quot;; // ???</code></pre>
<p>앞서 말했듯, <strong>제네릭은 타입 결정의 유연성을 위하여 컴파일 시점에 타입이 결정되지 않는다.</strong> 그렇기 때문에 위와 같은 코드 개략을 생각해볼 수 있다.</p>
<p>분명히 위의 코드를 컴파일한다면 <code>T</code>는 컴파일 시점에 결정되지 않은 타입이기 때문에 첫 번째 라인(<code>T data = 100;</code>)은 유연하다라는 키워드에 맞춰 이해할 수 있어도 <strong>두 번째 라인(<code>data = &quot;String&quot;;</code>)에서 괴리가 생기게 된다.</strong> 바로 이 런타임에 서로 다른 타입을 대입하는 부분이 제네릭 프로그래밍으로부터 발생하는 모순점이라고 볼 수 있다.</p>
<h3 id="3-제네릭-설계-취지의-관점">3) 제네릭 설계 취지의 관점</h3>
<p>이 모순을 해결하기 위해 정적 프로그래밍 언어의 매커니즘으로 돌아가서 살펴보면, <strong>컴파일 시점에 타입이 결정되는 절대 원칙을 고수하는 입장에서 제네릭은 어떻게 취급할 것인가</strong>가 언어별의 제네릭 설계 취지가 될 것이다.</p>
<pre><code class="language-java">// T : Generic Type Parameter

T data = 100;
data = &quot;String&quot;; // ???</code></pre>
<p>위의 모순 상황을 나타내는 코드 개략으로 돌아가보자. 분명히 이 코드는 정적 언어의 입장에서 잘못된 것이 맞다. 타입 안정성이 절대원칙인데 이것을 정면으로 위반하는 코드이기 때문에 에러나 예외를 던져서 코드 실행을 막아야 한다. 이 말은 곧 <strong>제네릭에서의 타입 결정의 태만으로 인해 에러나 예외가 발생하니까 제네릭에서 타입의 결정 시점을 정해야 한다</strong>는 뜻이 된다.</p>
<h2 id="4-모순의-해결책--타입-소거">4. 모순의 해결책(?) : 타입 소거</h2>
<p>우리는 해결책을 두 가지로 생각할 수 있다. 정적 언어는 <strong>컴파일 -&gt; 런타임</strong> 순으로 프로그램이 동작하므로 제네릭에서의 타입 결정 시점에 대해 논하자면</p>
<blockquote>
<h4 id="1-컴파일-시점에-바로-타입을-결정시킨다">1. 컴파일 시점에 바로 타입을 결정시킨다.</h4>
<h4 id="2-컴파일은-일단-넘기고-런타임-시점에-타입을-결정시킨다">2. 컴파일은 일단 넘기고 런타임 시점에 타입을 결정시킨다.</h4>
</blockquote>
<p>이렇게 정리할 수 있고, 실제 정적 언어들의 제네릭 취급에 대한 모순 해결책이 이렇게 두 개로 나뉘어지는 편이다. 이렇게 제네릭 처리 방식에 대한 두 가지 접근이 있으며, 이는 언어별 제네릭 설계 취지의 핵심을 반영한다. 각 언어는 타입 안정성과 유연성 사이에서 균형을 맞추기 위해 각자의 방식으로 제네릭을 설계하고 구현한다.</p>
<p>일단 저 두 해결책들 중 1번 해결책은 <strong>타입 소거</strong> 개념을 담고 있으며 이를 도입한 대표적인 언어가 자바이다. 그리고 2번 해결책은 <strong>타입 유지</strong> 개념을 담고 있으며 이를 도입한 대표적인 언어가 C++이다.</p>
<p>나는 1번 해결책을 중점적으로 살펴볼 예정이다. C언어 시리즈도 공부해보고 싶긴 한데 지금의 메인 언어는 자바니까 :D</p>
<h3 id="1-타입-소거의-개념">1) 타입 소거의 개념</h3>
<p>들어가기 전에 문맥이 조금 아리송할 수도 있다. <code>T</code>라는 정해지지 않은 타입을 그냥 컴파일 때에 정하면 제네릭의 유연성을 포기하겠다는 의미인가라는 뜻으로도 들릴 수 있기 때문이다. 구체적으로 <strong>런타임 때에 무리 없이 동작시키기 위한 임의의 타입 지정</strong>이라고 생각하면 된다. 제네릭의 취지는 타입의 안정성과 동시에 타입의 유연성이기 때문이다. 타입의 유연성을 조금 더 우선시한 해결책이라 생각할 수 있다.</p>
<p>동작 매커니즘은 아래와 같다.</p>
<blockquote>
<h4 id="컴파일-시점">컴파일 시점</h4>
<ol>
<li>타입 파라미터 변수가 확인된다.</li>
<li>구체화된 대입에 대한 타입 검사를 수행하여 <strong>구체적인 타입이 결정</strong>된다.</li>
<li>이를 통해 <strong>타입 안정성</strong>을 확보한다.<h4 id="런타임-시점">런타임 시점</h4>
</li>
<li>컴파일이 완료되면 <strong>타입을 소거</strong>해 버린다.</li>
<li>이로써 흡사 <strong>임의의 포괄적인 타입</strong>처럼 동작할 수 있게 된다.</li>
<li>이를 통해 <strong>타입 유연성</strong>을 확보하게 된다.</li>
</ol>
</blockquote>
<p>이런 순서를 거치기 때문에 1번 해결책을 <strong>타입 소거</strong>라고 한다. 런타임 시점에 타입 자체를 소거시켜버려서 개별적으로 구체화되는 타입이 전부 달라도 유연하게 동작할 수 있게 된다는 것이다. 이를 코드 기반으로 확인해보자.</p>
<p><code>Box</code>라는 제네릭 타입을 하나 정의한다.</p>
<pre><code class="language-java">class Box&lt;T&gt; {
    T content;

    Box(T content) {
        this.content = content;
    }
}</code></pre>
<p>그리고 두 개의 <code>Box</code> 인스턴스를<code>content</code> 필드를 채우면서 생성한다.</p>
<pre><code class="language-java">Box&lt;Integer&gt; integerBox = new Box&lt;&gt;(100);
Box&lt;String&gt; stringBox = new Box&lt;&gt;(&quot;내용물&quot;);</code></pre>
<p>이제 이 코드들을 바탕으로 타입 소거의 순서에 맞춰서 자바의 제네릭 타입 컴파일 수행 단계를 하나씩 체킹해보자.</p>
<h4 id="1-타입-파라미터-변수가-확인된다">(1) 타입 파라미터 변수가 확인된다.</h4>
<pre><code class="language-java">Box&lt;Integer&gt; integerBox = new Box&lt;&gt;(100);
Box&lt;String&gt; stringBox = new Box&lt;&gt;(&quot;내용물&quot;);</code></pre>
<p>자바 컴파일러는 컴파일 단계에서 타입 파라미터 변수들이 있나 확인을 할 것이다.</p>
<h4 id="2-구체화된-대입에-대한-타입-검사를-수행하여-구체적인-타입이-결정된다">(2) 구체화된 대입에 대한 타입 검사를 수행하여 구체적인 타입이 결정된다.</h4>
<pre><code class="language-java">integerBox.content = &quot;이상한 내용물&quot;; // Compile Error!</code></pre>
<p><code>integerBox</code>라는 변수는 <code>Box&lt;Integer&gt;</code>에서 볼 수 있듯이 <code>Integer</code> 타입의 내용물만을 받아들여야 할 것이고 <strong>컴파일러가 구체적인 타입 검사를 수행</strong>하는 것이 바로 이 부분이다. 그렇기 때문에 <code>integerBox.content</code>라는 필드 역시 <code>Integer</code> 타입이 확인되지 않으면 컴파일러가 바로 컴파일 에러를 일으킬 것이다.</p>
<h4 id="3-이를-통해-타입-안정성을-확보한다">(3) 이를 통해 타입 안정성을 확보한다.</h4>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/6f73aa0e-5154-460c-bf51-1edcfddc05da/image.png" alt=""></p>
<p>여담인 내용인데, 인텔리제이 같은 IDE에서 코드를 작성하다가 빨간 줄이 죽 그이는 것을 가끔 볼 것이다. 웬만한 빨간 줄들은 컴파일 에러를 사전에 경고하는 IDE의 기능이다. 즉, 실제로는 그러하나 우리는 마치 컴파일 결과 여기서 에러가 발생했다라고도 이해할 수 있다.</p>
<p>위의 사진처럼 컴파일러의 타입 검사 결과, <strong>컴파일 시점에 결정된 타입(<code>Integer</code>) 과 다른 타입의 데이터(<code>&quot;이상한 내용물&quot;</code>)이 할당</strong>되기 때문에 런타임까지 넘어가지 않고 컴파일 에러를 내뱉고 이를 통해 타입 안정성을 확보하는 것이다.</p>
<p>이제 런타임으로 넘어가 보자.</p>
<h4 id="4-컴파일이-완료되면-타입을-소거해-버린다">(4) 컴파일이 완료되면 타입을 소거해 버린다.</h4>
<p><strong>타입 소거가 바로 런타임 시점에서 발생</strong>하게 된다. 이게 무슨 뜻이냐면 <strong>임의의 포괄적인 타입(<code>Object</code>)으로 치환 해석</strong>한다는 의미와 유사하다.</p>
<h4 id="5-이로써-흡사-임의의-포괄적인-타입처럼-동작할-수-있게-된다">(5) 이로써 흡사 임의의 포괄적인 타입처럼 동작할 수 있게 된다.</h4>
<p>이것은 마치 코드로 나타내자면 다음과 같이 동작하게 될 것이다.</p>
<pre><code class="language-java">class Box&lt;T&gt; {
    T content;

    Box(T content) {
        this.content = content;
    }
}</code></pre>
<p>기존의 <code>Box&lt;T&gt;</code> 제네릭 타입의 컴파일을 마치고 런타임에 진입하면 마치 아래처럼 취급된다.</p>
<pre><code class="language-java">class Box {
    Object content;

    Box(Object content) {
        this.content = content;
    }
}</code></pre>
<p>이렇게 해석되기 때문에 아까 생성했던 두 개의 인스턴스도 다음처럼 취급된다.</p>
<pre><code class="language-java">// 컴파일 이전
Box&lt;Integer&gt; integerBox = new Box&lt;&gt;(100);
Box&lt;String&gt; stringBox = new Box&lt;&gt;(&quot;내용물&quot;);

// 런타임 시점
Box integerBox = new Box(100);
Box stringBox = new Box(&quot;내용물&quot;);</code></pre>
<p>즉, 분명히 다른 목적(내용물)을 갖고 생성한 별개의 제네릭 타입 인스턴스임에도 불구하고 런타임 시점에서는 동일한 타입처럼 취급되면서 <strong>타입 유연성</strong>을 확보할 수 있게 된다. </p>
<h4 id="6-이를-통해-타입-유연성을-확보하게-된다">(6) 이를 통해 타입 유연성을 확보하게 된다.</h4>
<pre><code class="language-java">integerBox.getContent();
stringBox.getContent();</code></pre>
<p>만약 <code>Box&lt;T&gt;</code> 제네릭 타입 안에 <code>T getContent()</code>라는 Getter 메소드가 존재한다면 동일한 Getter를 호출할 수 있게 되는 것 또한 타입 유연성의 실현이라 볼 수 있다.</p>
<hr>
<p>조금 근본적인 의문점이 들 수도 있는데 <strong>왜 타입을 런타임 때 소거하는지</strong>에 대해 간략히 설명하자면 다형성의 실현을 위해서다. 다형성이라는 건 단순히 코드 작성에서 중복을 줄이고 재사용성을 높이는 것만을 뜻하지 않고 JVM 내부의 컴파일링 등의 과정에서도 통용될 수 있는 개념이다. </p>
<p>만약 타입 소거가 되지 않으면 <code>Box&lt;Integer&gt;</code> 타입과 <code>Box&lt;String&gt;</code> 타입이 별개의 타입으로 취급될 수밖에 없고 이는 컴파일링 작업이 2배로 늘어나는 것을 의미한다. 그런데 애시당초 둘은 <code>Box&lt;T&gt;</code>라는 프레임을 바탕으로 생성됐기 때문에 별개의 타입으로 취급하여도 중복되는 작업이 다수 존재하므로 이런 <strong>비효율성을 해소하여 컴파일러 처리 비용과 런타임 비용을 줄이는 것이 자바의 제네릭 도입에 대한 근거이자 관점</strong>이라 할 수 있다.</p>
<h3 id="2-타입-소거의-문제점">2) 타입 소거의 문제점</h3>
<p>다만 타입 소거는 앞서 제네릭의 모순의 완벽한 해결책이 아니다. 컴파일 시점의 타입 결정으로 확실하게 검증하는 것은 분명 타입 소거의 장점이지만 결국 제네릭의 취지를 살리기 위해 검증한 타입을 소거해야 되는 점 때문에 문제가 발생할 수도 있다.</p>
<h4 id="1-issue-1-리플렉션과의-활용">(1) Issue 1: 리플렉션과의 활용</h4>
<p>자바의 <strong>리플렉션</strong>은 런타임에 클래스, 메소드, 필드 등을 동적으로 조회하고 수정할 수 있는 기능을 제공하는 기능이다. 리플렉션을 사용하면 코드가 컴파일된 후에도 객체의 구조를 분석하고 조작할 수 있기 때문에 주로 동적 프록시 패턴 등에서 활용될 수 있다.</p>
<p>자바의 제네릭 타입 컴파일링은 런타임 시점에는 타입 소거가 된다고 위에서 확인했었다. 이 과정에서 리플렉션을 제네릭 타입 인스턴스에 활용하는 코드를 작성해보자.</p>
<pre><code class="language-java">// 두 개의 박스 세팅
Box&lt;Integer&gt; integerBox = new Box&lt;&gt;(100);
Box&lt;String&gt; stringBox = new Box&lt;&gt;(&quot;내용물&quot;);

// Issue 1 : 둘 다 같은 박스인가?
System.out.println(&quot;같은 박스? : &quot; + (integerBox.getClass() == stringBox.getClass()));</code></pre>
<p>논리적으로 생각했을 때는 말이 안 된다. 분명 다른 타입의 내용물을 담는 박스니까 우리의 논리대로라면 저 둘은 다른 박스라는 결과가 나오니 <code>false</code>가 로그로 출력되어야 한다. 하지만 실행해보면</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/da7aea95-60e1-4781-b70b-3f6ae92fcd52/image.png" alt=""></p>
<p>바로 이 부분이 타입 소거의 맹점 중 하나다.</p>
<p>앞서 얘기했듯 리플렉션은 런타임 시점에서의 클래스, 메소드, 필드에 대한 작업을 처리하는데, 제네릭 타입 파라미터는 런타임 시점에서 타입이 소거되기 때문에 <code>Object</code>처럼 취급된다고 했었다. 이런 특성들이 맞물려서 리플렉션을 통해 제네릭 타입 인스턴스들의 개별적이고 구체화된 타입 조회가 불가능해진다.</p>
<h4 id="2-issue-2-원시-타입raw-type과의-호환">(2) Issue 2: 원시 타입(raw type)과의 호환</h4>
<p>여기서 말하는 원시 타입은, 자바 문법의 원시 타입(primitive type)을 말하는 것이 아닌 <strong>제네릭 프로그래밍에서 타입 파라미터 없이 사용하는 원시 타입(raw type)</strong>을 의미한다.</p>
<p>타입 파라미터 변수는 타입 소거로 <code>Object</code>처럼 취급되고, 제네릭 타입은 런타임 시점에서 원시 타입과 차이점이 없어지게 된다. 즉 <strong>런타임 시점에서 제네릭 타입 인스턴스나 원시 타입 인스턴스나 다를 바가 없다</strong>는 특징이 생기게 되는데, 이 부분 역시 문제 가능성이 다분하다.</p>
<p>앞서 봤던 <code>Box&lt;T&gt;</code> 제네릭 타입을 다시 활용하여 두 개의 제네릭 타입 인스턴스를 정의한다.</p>
<pre><code class="language-java">Box&lt;Integer&gt; integerBox = new Box&lt;&gt;(100);
Box&lt;String&gt; stringBox = new Box&lt;&gt;(&quot;내용물&quot;);</code></pre>
<p>그리고 여기서 <strong>원시 타입(<code>Box</code>) 변수에 <code>integerBox</code> 변수를 할당할 수 있다.</strong> 이것이 가능한 이유는 타입 매개변수 없는 원시 타입이 모든 타입의 객체를 받을 수 있기 때문이다. 즉, 타입 소거로 인해 발생하는 특이한 상황이기 때문에 우리는 아래와 같은 코드를 거부감 없이 받아들일 수 있다.</p>
<pre><code class="language-java">Box box = integerBox
box.content = &quot;이상한 내용물&quot;; // 내용물 바꿔치기...?

// 원시 타입의 취급
class Box {
    Object content;

    Box(Object content) {
        this.content = content;
    }
}</code></pre>
<p>여기서 이제 자바 객체의 기초를 생각하자면 <strong>변수는 객체를 참조하여 객체 주소를 담는 역할</strong>을 한다. 즉 <code>new Box&lt;&gt;(100);</code> 이라는 생성자를 통해 생성된 <code>Box&lt;Integer&gt;</code> 제네릭 타입의 객체를, 처음에는 <code>integerBox</code>라는 변수만 참조하고 있다가 이제 <code>box</code>라는 변수도 같이 참조하게 되면서 <code>box</code> 변수를 통해 객체를 건드리는 행위는 <code>integerBox</code>에도 영향을 같이 미치게 된다.</p>
<p>이제 아래의 코드를 통해 <code>integerBox</code>의 내용물을 꺼내보자. 우리는 <code>integerBox</code>라는 박스의 내부에 <code>Integer</code> 타입의 내용물이 들어있음을 알고 해당 내용물을 담는 변수의 타입을 <code>Integer</code>로 생각하는 것이 당연할 것이다.</p>
<pre><code class="language-java">Integer content = integerBox.content;</code></pre>
<p>심지어 여기까지 작성해도 IDE의 빨간줄 경고 없이 컴파일이 잘 수행된다. 코드가 잘 돌아갈까? 한번 실행해보자.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/5f16797b-7c74-4f4a-8b91-0a2211c19c29/image.png" alt=""></p>
<p>실행 결과, <code>ClassCastException</code>이 발생했다. 해당 예외는 런타임에 던져지며 <strong>잘못된 타입 변환</strong>을 수행하려고 할 때 문제가 발생한다. 아까 언급했듯 <code>Integer</code> 타입 내용물을 담은 박스 객체에 대하여 2개의 변수(<code>integerBox</code>, <code>box</code>)가 내용물에 관여했는데 마치 편법처럼 <code>box</code> 변수를 통해 <code>String</code> 타입 내용물로 바꿔치기를 한 것이 런타임에 와서야 문제로 커진 것이다.</p>
<p>아까 위에서 컴파일 에러를 유발하는 코드와 비교하면 괴리가 느껴지는 부분이다.</p>
<pre><code class="language-java">// 동일 객체 참조
Box box = integerBox;

// 비교
integerBox.content = &quot;이상한 내용물&quot;; // Compile Error!
box.content = &quot;이상한 내용물&quot;; // 컴파일은 통과, CCE 발생 원인</code></pre>
<p>분명 동일한 객체에 대한 내용물 조작임에도 하나는 컴파일에서 막히고 다른 하나는 런타임에서 막히게 된다. <strong>타입 소거로 인해 컴파일 시점에서는 <code>Box&lt;Integer&gt;</code>여도 런타임 시점에서는 원시 타입인 <code>Box</code>처럼 동작하기 때문에 동일 객체를 참조하게 하는 변수 할당 코드가 정상 동작</strong>하는 것이다. 이 부분 역시 타입 소거의 맹점 중 하나라 할 수 있다.</p>
<h3 id="3-보완책은-없을까">3) 보완책은 없을까?</h3>
<p>사실 애초에 제네릭 문법이 반쪽짜리 문법이라 느껴질 수밖에 없다. 타입 안정성과 타입 유연성을 동시에 챙긴다는 모순적인 발상에서 시작됐고, 그 간극으로 인해 발생하는 문제를 타입 소거 혹은 타입 유지로 해결하려고 하지만 정적 언어의 특성 상, 컴파일에 타입을 결정한다는 절대 원칙을 따를 것인지 아니면 인정하고 원칙의 예외로써 동작하게 할 것인지의 선택 문제이니까.</p>
<p>즉, <strong>제네릭의 취지</strong>와 <strong>정적 언어의 절대 원칙</strong>은 양립할 수 없는 태생적인 모순을 가지고 있기 때문에, <strong>절대 원칙을 준수하면서 런타임에서 늦게나마 타입의 유연성과 재사용성을 확보하는 방향(자바의 타입 소거)</strong>을 선택하거나 혹은 <strong>절대 원칙의 예외로써 런타임 시점으로 타입 결정을 미루는 방향(C#의 타입 유지)</strong> 중 하나를 울며 겨자먹기 식으로 선택할 수밖에 없다.</p>
<p>타입 유지는 크게 알아보진 않았지만, 타입 결정 작업을 제네릭 타입 인스턴스마다 따로따로 처리하게 되고 결국 런타임 비용이 늘어나는 치명적인 문제점을 갖고 있고 나아가 <strong>정적 언어의 취지인 타입 선제 해석을 통한 비용 절감이라는 취지를 위배</strong>한다. 결국 얘도 타입 소거처럼 반쪽짜리 해결책인 셈이다.</p>
<p>혹시나 자바의 <strong>와일드카드</strong>가 해결책이지 않을까... 생각할 수도 있지만 아니다. 와일드카드를 사용한 <code>&lt;?&gt;</code>은 애시당초 <strong>타입이 명확하지 않다</strong>는 것을 명시하는 것이기 때문에 <strong>타입이 명확하지 않은 상태에서 타입이 명확한 데이터를 할당하는 것이 불가능</strong>하다. 자바 컴파일러는 타입 안정성을 확보하는 것이 절대 원칙인데 이 절대 원칙을 지키지 못하는 상황에서 값의 할당을 막는 것이 당연하다.</p>
<pre><code class="language-java">Box&lt;?&gt; wildBox = new Box&lt;&gt;(&quot;와일드&quot;);
wildBox.content = &quot;바꾸기&quot;; // Compile Error!</code></pre>
<p>순수하게 읽기 전용이라면 그나마 가능하겠지만, 데이터 수정과 변경은 불가능하기 때문에 와일드카드가 제네릭에서 발생하는 모순의 해결책이 될 수 없다.</p>
<h2 id="5-마무리">5. 마무리</h2>
<p>제네릭은 처음 타입스크립트를 접했을 때에도 어려웠고, 스프링 연습을 하면서도 많이 활용해보지 못했기 때문에 이번 기회에 심도 있는 고민을 할 수 있었다. 나름대로 깊게 생각하며 제네릭 프로그래밍의 모순에 대한 해결책의 실마리라도 조금이나마 떠올려보고 싶었는데 내 역량으로는 아직 무리...</p>
<p>끝!</p>
<h3 id="1-전체-소스-코드">1) 전체 소스 코드</h3>
<pre><code class="language-java">/**
 * 컴파일 시점
 * 1. 타입 파라미터 변수가 확인된다.
 * 2. 구체화된 대입에 대한 타입 검사를 수행하여 구체적인 타입이 결정된다.
 * 3. 이를 통해 타입 안정성을 확보한다.
 *
 * 런타임 시점
 * 4. 컴파일이 완료되면 타입을 소거해 버린다.
 * 5. 이로써 흡사 임의의 포괄적인 타입처럼 동작할 수 있게 된다.
 * 6. 이를 통해 타입 유연성을 확보하게 된다.
 */
public class Generic {
    public static void main(String[] args) {
        // 두 개의 박스 세팅
        // 1. 제네릭 타입 파라미터 변수가 확인된다.
        Box&lt;Integer&gt; integerBox = new Box&lt;&gt;(100);
        Box&lt;String&gt; stringBox = new Box&lt;&gt;(&quot;내용물&quot;);

        // 2. 구체화된 대입에 대한 타입 검사를 수행하여 구체적인 타입이 결정된다.
        // 3. 이를 통해 타입 안정성을 확보한다.
//        integerBox.content = &quot;이상한 내용물&quot;; // Compile Error!

        // Issue 1 : 둘 다 같은 박스인가?
        System.out.println(&quot;같은 박스? : &quot; + (integerBox.getClass() == stringBox.getClass()));

        // Issue 2 : 원시 타입처럼 취급되면서 놓치는 맹점
        Box box = integerBox;
        box.content = &quot;이상한 내용물&quot;; // 내용물 바꿔치기...?
        Integer content = integerBox.content;

        // 와일드카드는 해결책이 될 수 없다.
//        Box&lt;?&gt; wildBox = new Box&lt;&gt;(&quot;와일드&quot;);
//        wildBox.content = &quot;바꾸기&quot;;
    }
}

class Box&lt;T&gt; {
    T content;

    Box(T content) {
        this.content = content;
    }
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[네트워크 개인 공부(2) - 패킷(1)]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B0%9C%EC%9D%B8-%EA%B3%B5%EB%B6%802-%ED%8C%A8%ED%82%B7</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B0%9C%EC%9D%B8-%EA%B3%B5%EB%B6%802-%ED%8C%A8%ED%82%B7</guid>
            <pubDate>Mon, 13 Jan 2025 16:28:06 GMT</pubDate>
            <description><![CDATA[<h1 id="네트워크-학습2">네트워크 학습(2)</h1>
<h2 id="1-네트워크-모델-종류">1. 네트워크 모델 종류</h2>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/612701c8-5156-475f-9453-ccf9559cd092/image.png" alt=""></p>
<h3 id="1-tcpip-모델">1) TCP/IP 모델</h3>
<h4 id="1계층--네트워크-인터페이스">1계층 : 네트워크 인터페이스</h4>
<ul>
<li>물리적 데이터 전송 담당</li>
<li>이더넷, 와이파이, ARP, MAC 주소</li>
<li>스위치, 허브, 랜카드<h4 id="2계층--네트워크">2계층 : 네트워크</h4>
</li>
<li>데이터 패킷화 및 경로 지정(라우팅)</li>
<li>IP, ICMP, ARP, RARP</li>
<li>라우터<h4 id="3계층--전송">3계층 : 전송</h4>
</li>
<li>신뢰성 확보한 데이터 전송 및 흐름, 오류 제어</li>
<li>TCP(연결 지향, 신뢰성 보장), UDP(비연결 지향, 빠른 전송)<h4 id="4계층--응용">4계층 : 응용</h4>
</li>
<li>사용자 상호작용 인터페이스</li>
<li>HTTP, HTTPS, FTP, SMTP, SSH, DNS</li>
</ul>
<h3 id="2-osi-7계층">2) OSI 7계층</h3>
<h4 id="1계층--물리">1계층 : 물리</h4>
<ul>
<li>전기, 빛, 무선 신호로 데이터 전송</li>
<li>허브, 리피터, 케이블, 광섬유<h4 id="2계층--데이터-링크">2계층 : 데이터 링크</h4>
</li>
<li>노드 간 신뢰성 있는 데이터 전송</li>
<li><strong>이더넷</strong>, ARP, MAC</li>
<li>스위치, 브릿지<h4 id="3계층--네트워크">3계층 : 네트워크</h4>
</li>
<li>데이터 패킷의 경로 지정(라우팅)</li>
<li><strong>IP, ICMP, ARP</strong></li>
<li>라우터<h4 id="4계층--전송">4계층 : 전송</h4>
</li>
<li>흐름 제어, 오류 복구</li>
<li><strong>TCP, UDP</strong><h4 id="5계층--세션">5계층 : 세션</h4>
</li>
<li>통신 세션 생성, 유지, 종료</li>
<li>NetBIOS, RPC, PPTP<h4 id="6계층--표현">6계층 : 표현</h4>
</li>
<li>데이터 인코딩, 암호화, 압축</li>
<li>JPEG, SSL/TLS<h4 id="7계층--응용">7계층 : 응용</h4>
</li>
<li>사용자와 직접 상호작용하는 네트워크 서비스</li>
<li><strong>HTTP</strong>, FTP, SMTP, DNS, SSH</li>
</ul>
<h3 id="3-osi와-tcpip의-차이점">3) OSI와 TCP/IP의 차이점</h3>
<ul>
<li>OSI는 역할 기반, TCP/IP는 프로토콜 기반</li>
<li>OSI는 통신 전반에 대한 표준</li>
<li>TCP/IP는 데이터 전송기술에 특화</li>
</ul>
<h3 id="4-패킷">4) 패킷</h3>
<p>앞서 언급했듯, 패킷은 <strong>네트워크 상에서 전달되는 데이터를 통칭하는 표현이자 블록 단위</strong>를 의미한다. 헤더에는 제어 정보, 주소 정보 등이 들어가고 페이로드에 사용자의 실질적인 데이터가 들어간다. 추가로 푸터가 붙을 수도 있지만 잘 붙는 편은 아니다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/511b7398-8751-4b82-8d40-60070d301416/image.png" alt=""></p>
<p>일례로, <strong>HTTP 프로토콜</strong>을 <strong>페이로드</strong>로 해서 <strong>TCP 프로토콜</strong>을 헤더에 붙인 형태(1)를 하나의 페이로드로 삼아서 <strong>IPv4 프로토콜</strong>을 헤더로 붙인 형태(2)가 존재할 수 있으며, 이 형태(2)를 페이로드라 삼아서 <strong>Ethernet 프로토콜</strong>을 헤더로 삼을 수 있다. 이렇게 패킷은 여러 프로토콜들로 캡슐화가 되는 흡사 마트료시카 인형과도 같은 양상을 보인다. 위와 같은 양상을 <strong>캡슐화(Encapsulation)</strong>라고 한다. 캡슐화의 과정은 <strong>(수치상) 상위 계층에서 하위 계층으로 향하면서 캡슐화되는 것이 원칙</strong>이다. 뜬금없이 이더넷 헤더(OSI 2계층)가 존재하는 패킷 페이로드에서 갑자기 TCP 헤더(OSI 4계층)가 붙을 수는 없는 것이다.</p>
<p>최종적으로 받아서 캡슐화의 반대 방향으로 프로토콜을 하나씨 확인하면서 패킷 페이로드를 확인하는 과정은 디캡슐화(Decapsulation)이라고 한다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/054cf9e6-e532-41b0-8ef9-c4815e30b455/image.png" alt=""></p>
<p>계층별로 프로토콜 데이터 단위 이름이 다른데, 헤더에 OSI 4계층(TCP)이 붙은 패킷은 세그먼트(Segement)라 부르고, 헤더에 OSI 3계층(IPv4)이 붙은 패킷은 패킷(이 패킷은 현재 보고 있는 패킷과 동음이의어)이라 부르고, 헤더에 OSI 2계층(Ethernet)이 붙은 패킷은 프레임이라고 부른다.</p>
<h2 id="2-wireshark로-패킷-뜯어보기">2. Wireshark로 패킷 뜯어보기</h2>
<p>터미널에 <code>ping 8.8.8.8</code>을 입력하면 구글 공용 DNS 서버로 Echo 리퀘스트를 보내고 리스폰스에 그 결과로 응답 시간, 네트워크 속도 등이 표시된다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/eba3b223-0c16-4993-8229-52a8c0a6ff95/image.png" alt=""></p>
<p>이때 보내는 리퀘스트 패킷은 <strong>ICMP</strong> 패킷이다. 이것을 Wireshark로 포착해보면 아래와 같은 패킷 캡처를 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/0ee56cb5-b752-4b32-981f-2da6d6c98d30/image.png" alt=""></p>
<p>사진을 보면 <strong>밑에서부터 ICMP(OSI 3계층), IPv4(OSI 3계층), Ethernet(OSI 2계층)</strong>으로 구성된 것을 볼 수 있다.</p>
<p>ICMP가 IPv4를 헤더로 삼은 페이로드가 되고, 이 전체를 Ethernet이 헤더로 삼은 전체 패킷이 존재하는 셈이다. 아까 위에서 말한 수치상 상위 계층에서 하위 계층으로 캡슐화(패킷으로 덮고 패킷으로 덮고 패킷으로 덮고...)가 이뤄지는 형태다. 그리고 최종 헤더가 2계층이므로 Frame이 붙은 형태다.</p>
<p><em>참고로 ICMP가 먼저 패킷 캡슐이 되고 그다음에 IPv4가 패킷 캡슐이 되는 것이 원칙이다. 또한, IPv4와 ARP가 같이 쓰이지는 않는다.</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[네트워크 개인 공부(1) - 개념, 간단한 실습]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B0%9C%EC%9D%B8-%EA%B3%B5%EB%B6%801-%EA%B0%9C%EB%85%90-%EA%B0%84%EB%8B%A8%ED%95%9C-%EC%8B%A4%EC%8A%B5</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B0%9C%EC%9D%B8-%EA%B3%B5%EB%B6%801-%EA%B0%9C%EB%85%90-%EA%B0%84%EB%8B%A8%ED%95%9C-%EC%8B%A4%EC%8A%B5</guid>
            <pubDate>Sun, 12 Jan 2025 14:23:02 GMT</pubDate>
            <description><![CDATA[<h1 id="네트워크-학습1">네트워크 학습(1)</h1>
<h2 id="1-정의">1. 정의</h2>
<p>네트워크는 <strong>노드들이 데이터를 공유할 수 있게 하는 디지털 전기통신망의 일종</strong>이다. 분산된 컴퓨터를 통신망으로 연결한 것을 말하며, 여기서 노드는 네트워크에 속한 컴퓨터 또는 통신 장비를 일컫는다.</p>
<p>인터넷은 문서, 사진, 영상과 같이 여러 형태의 데이터들을 공유하도록 구성된 세상에서 가장 큰 네트워크이며, www는 인터넷을 통해 웹과 관련된 데이터를 공유하고 분산하는 하이퍼텍스트 시스템으로 인터넷과 구별되는 개념이다.</p>
<blockquote>
<ul>
<li>네트워크 : 노드들이 데이터를 공유할 수 있는 디지털 전기통신망</li>
<li>인터넷 : 전 세계의 네트워크를 연결한 초대형 네트워크</li>
<li>www : 인터넷 기반으로 동작하는 분산형 하이퍼텍스트 정보 공유 시스템</li>
</ul>
</blockquote>
<h3 id="1-네트워크의-분류---크기">(1) 네트워크의 분류 - 크기</h3>
<blockquote>
<h4 id="lanlocal-area-network">LAN(Local Area Network)</h4>
<ul>
<li>가까운 지역을 하나로 묶은 네트워크</li>
<li>ex) 같은 PC방 내에서 스타크래프트 배틀넷 근거리 통신망 멀티 플레이</li>
</ul>
<h4 id="wanwide-area-network">WAN(Wide Area Network)</h4>
<ul>
<li>먼 지역을 한 곳으로 묶은 네트워크</li>
<li>가까운 지역끼리 묶인 LAN과 LAN을 다시 하나로 묶은 것</li>
</ul>
<h4 id="manmetropolitan-area-network">MAN(Metropolitan Area Network)</h4>
<h4 id="기타-vlan-can-pan-등등">기타 VLAN, CAN, PAN 등등...</h4>
</blockquote>
<h3 id="2-네트워크의-분류---연결-형태">(2) 네트워크의 분류 - 연결 형태</h3>
<blockquote>
<h4 id="star-형식">Star 형식</h4>
<ul>
<li>중앙 장비에 모든 노드가 연결된 형태</li>
<li>하나의 LAN 대역을 구축할 때 보통 Star 형식으로 연결</li>
<li>ex) 가정집의 공유기를 통해 핸드폰, 컴퓨터, TV 등이 연결</li>
<li>위의 예시에서 만약 공유기가 고장난다면...? 이게 Star 형식의 단점<h4 id="mesh-형식">Mesh 형식</h4>
</li>
<li>여러 노드들이 서로 그물처럼 연결된 형태</li>
<li>WAN 대역을 구축할 때 채택됨</li>
<li>ex) <a href="https://www.submarinecablemap.com/country/south-korea">https://www.submarinecablemap.com/country/south-korea</a><h4 id="tree-형식">Tree 형식</h4>
</li>
<li>나뭇가지처럼 계층 구조로 연결된 형태<h4 id="기타-링형-버스형-혼합형-등등">기타 링형, 버스형, 혼합형 등등...</h4>
</li>
<li>실제 인터넷은 Star와 Mesh가 섞인 혼합형으로 구축</li>
</ul>
</blockquote>
<h3 id="3-네트워크-통신-방식">(3) 네트워크 통신 방식</h3>
<blockquote>
<h4 id="유니-캐스트">유니 캐스트</h4>
<ul>
<li>네트워크 상의 특정 대상과 <strong>1대1</strong>로 통신하는 방식</li>
</ul>
<h4 id="멀티-캐스트">멀티 캐스트</h4>
<ul>
<li>네트워크 상의 특정 다수와 <strong>1대N</strong>으로 통신하는 방식</li>
</ul>
<h4 id="브로드-캐스트">브로드 캐스트</h4>
<ul>
<li>네트워크 상에 있는 <strong>모든 대상</strong>과 통신하는 방식</li>
</ul>
</blockquote>
<h2 id="2-네트워크-프로토콜">2. 네트워크 프로토콜</h2>
<p><strong>프로토콜</strong>은 일종의 약속이다. 택배를 보내려면 택배 양식이 존재하고, 편지도 편지지라는 양식이 존재하고, 전화 역시 전화만의 양식이 존재하듯 네트워크 역시 노드와 노드 간의 통신에서 약속이 필요하다. 구체적으로는 <strong>어떤 노드</strong>가 <strong>어떤 노드</strong>에게 <strong>어떤 데이터</strong>를 <strong>어떻게</strong> 보내는지 작성하기 위한 양식이 프로토콜의 정의라 할 수 있다.</p>
<p>이런 프로토콜이 필요한 이유는 당연한 얘기겠지만 내가 보내려는 혹은 받으려는 데이터를 정확하게 받기 위해서다. 엉뚱한 곳에 도달해있거나 아니면 전혀 다른 데이터가 전송되면 안 될 테니까.</p>
<blockquote>
<h4 id="가까운-곳과-연락할-때">가까운 곳과 연락할 때</h4>
<ul>
<li>Ethernet 프로토콜(<strong>MAC 주소</strong>)<h4 id="멀리-있는-곳과-연락할-때">멀리 있는 곳과 연락할 때</h4>
</li>
<li>ICMP, IPv4, ARP(<strong>IP 주소</strong>)<h4 id="여러-프로그램으로-연락할-때">여러 프로그램으로 연락할 때</h4>
</li>
<li>TCP, UDP(<strong>포트 번호</strong>)</li>
</ul>
</blockquote>
<h2 id="3-간단한-실습">3. 간단한 실습</h2>
<h3 id="1-traceroute-명령어">1) traceroute 명령어</h3>
<p>참고로 나는 MacOS다. 윈도우 명령어도 같이 기재할 테지만, 앞으로 진행하는 실습 내용의 대부분은 MacOS를 기준으로 연습하고 레코드를 남길 예정.</p>
<p>터미널(윈도우라면 cmd)에서 <code>traceroute 8.8.8.8</code>을 입력해보자.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/c8d8a4f4-be34-4093-809f-1e57cef2dd90/image.png" alt=""></p>
<p>해당 명령어를 입력하면 시간이 조금 소요되다가 마지막에 <strong><code>dns.google (8.8.8.8)</code></strong>에 도달하는 것을 확인할 수 있다. 이것은 경로 추적 명령어이며 <strong>패킷이 거치는 라우터(네트워크 경유지, LAN 대역)의 IP 주소 및 해당 라우터까지의 응답 시간</strong>을 순차적으로 출력한다.</p>
<p><code>8.8.8.8</code>은 구글의 DNS 서버다. DNS는 사람이 이해하는 도메인 이름(<a href="http://www.google.com)%EC%9D%84">www.google.com)을</a> 컴퓨터가 이해할 수 있는 IP 주소(8.8.8.8)로 변환하는 시스템으로 보통 인터넷 연결 상태를 테스트할 때 해당 명령어를 많이 사용한다. 윈도우의 cmd에서는 <code>tracert 8.8.8.8</code>을 활용한다.</p>
<p>나 같은 경우는 로컬 네트워크의 내부망 라우터에서 인터넷 서비스 제공 업체(ISP)의 외부 라우터와 백본에 위치한 라우터를 거쳐 다양한 중간 네트워크 라우터를 거친 끝에 구글의 공개 DNS 서버에 도달하는 것을 확인할 수 있었다.</p>
<h3 id="2-wireshark-세팅">2) Wireshark 세팅</h3>
<p>도커로도 세팅이 가능한데 대신 GUI는 활용을 못한다(...아쉽)<br>어차피 공부하고 정복하고 싶은 영역이었으니 직접 세팅을 해야겠다.</p>
<p>설치는 간단하다. 구글에 Wireshark 검색해서 나오는 공식 홈페이지에서 각자의 운영체제에 맞춰 다운받고 설치하면 끝.</p>
<h3 id="3-wireshark-간단히-들여보기">3) Wireshark 간단히 들여보기</h3>
<h4 id="1-wireshark-gui-구경">(1) Wireshark GUI 구경</h4>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/596087af-3b57-4ccc-837d-76bcd2ef78f7/image.png" alt=""></p>
<p>들어가자마자 상당히 어려워보이는 것들로 가득한데, 우선 Wireshark가 뭐하는 도구인지를 확인해야 한다.</p>
<p>Wireshark는 <strong>네트워크 패킷 분석 도구</strong>다. 즉, 네트워크를 통해 실시간으로 전달되는 데이터를 실시간 캡처 후 분석할 수 있다. 패킷은 <strong>네트워크를 통해 전송되는 데이터의 기본 단위</strong>를 의미하며 데이터가 이 패킷들로 구성된다. 보통은 주소 정보나 제어 정보를 포함한 <strong>헤더</strong>와 실제 데이터가 담긴 <strong>페이로드</strong>로 구성된다.</p>
<p>얘기를 들었을 때, 얼핏 감이 오겠지만 Wireshark는 네트워크 스니핑 툴로도 활용이 가능하다. 물론 불법적인 의도가 아닌 보안 테스트 용도이며 사실 얘로 해킹(...)을 하는 것 자체가 애초에 불가능하다. 웬만한 실시간 통신들 중 우리가 관심있는 부분들은 대부분 HTTPS 프로토콜로 이뤄졌는데 관련 SSL/TLS 키를 알고 있지 않는 한 Wireshark가 캡처할 수는 없다.</p>
<p>여튼 위에서 우리가 봐야할 것은 <strong><code>WI-FI:en0</code></strong>와 <strong><code>Loopback: lo0</code></strong>인데, 내 운영체제는 MacOS에 노트북이어서 우리집 공유기에서 나오는 와이파이의 LAN에서 전송되는 데이터 패킷을 <code>WI-FI:en0</code>에서, 내 노트북 내에서의 네트워크 통신을 위한 가상 인터페이스인 루프백(localhost)에서 전송되는 데이터 패킷을 <code>Loopback: lo0</code>에서 캡처할 수 있다.</p>
<p>그외에도 VPN 연결에 사용되는 <code>utun</code> 인터페이스 시리즈나, 유선 네트워크 연결을 나타내는 <code>Ethernet</code> 등도 있는데 어차피 지금 내 수준에서는 봐도 뭔지 모른다(...)</p>
<p>아무튼, 일단 <code>WI-FI:en0</code>에 먼저 접속해보자</p>
<h4 id="2-우리집-공유기-네트워크-패킷-들여보기">(2) 우리집 공유기 네트워크 패킷 들여보기</h4>
<p>처음 들어간 상태에서 인터넷을 만지작하면 뭐 TCP니 뭐니 하면서 마구잡이로 패킷들이 캡처되는데 어차피 난 아직 모르는 것들이라서 일단 <strong>HTTP만 포착되도록 필터 적용</strong>을 했다. 이러면 위에서 말했듯 웬만한 웹 상의 데이터 패킷들은 전부 HTTPS로 처리되기 때문에 포착되는 내용이 아무 것도 없을 것이다.</p>
<p>여기서 인텔리제이를 열어보면 꽤나 재밌는 게 포착된다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/75d5c450-f825-4aa0-9128-4e4dc9c10a38/image.png" alt=""></p>
<p>인텔리제이를 열어서 특정 프로젝트를 엶과 동시에 무슨 요청과 응답이 내 공유기 와이파이에서 포착됐다. HTTP 프로토콜로 이뤄져있는 것을 볼 수 있으며 특정 엔드포인트로 GET 요청이 이뤄졌고 301 응답이 반환된 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/c3a0eb93-736d-4faf-8248-ccda93c5f112/image.png" alt=""></p>
<p>리퀘스트 패킷을 뜯어보면 <strong>인텔리제이의 GitLab 플러그인을 통해 GitHub API에 요청</strong>을 보내는 내용을 확인할 수 있다. 아마 추측상 깃헙의 원격 레포지토리와 로컬 깃 간의 연동을 통해 push, pull 등의 작업이 가능케 하기 위한 리퀘스트인 듯하다.</p>
<p>리스폰스는 별다른 내용은 없지만 301 응답을 반환한다. 이는 깃헙 서버가 HTTP에서 HTTPS로 <strong>리디렉션</strong>을 수행해서 향후의 요청은 HTTPS 프로토콜의 엔드포인트로 리퀘스트를 보내야 함을 명시하고 있다.</p>
<h4 id="3-로컬-루프백-인터페이스-네트워크-패킷-들여다보기">(3) 로컬 루프백 인터페이스 네트워크 패킷 들여다보기</h4>
<p>이번에는 개발할 때 흔히 쓰이는 로컬호스트(127.0.0.1)를 확인해보자. 참고로 일반적인 IP 주소는 사람이 해석할 수 있는 도메인에서 DNS에 의해 매핑되는 것이 원칙이지만 localhost는 DNS를 거치지 않고 운영체제의 hosts 파일에 의해 알아서 127.0.0.1로 매핑한다.</p>
<p>한번 자바로 구축한 서버를 부팅해서, 포스트맨으로 간단한 리퀘스트를 보내 리스폰스를 받아본다. 해당 엔드포인트로 조회된 엔티티의 객체 해시코드와 필드 내용이 <code>ResponseEntity</code>에 담겨서 반환된다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/affdee4b-0f0c-46a2-b5d3-7fd2ce321aa1/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/47d602d3-9630-4b60-9eae-9516e1387dfc/image.png" alt=""></p>
<p>정상적으로 리퀘스트와 리스폰스가 주고받아진 것을 확인할 수 있다. 이제 이것을 Wireshark에서는 어떻게 찍혔는지 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/59556ee7-0455-40a8-82c3-044c789926d6/image.png" alt=""></p>
<p>localhost의 정의대로 <code>::1</code>(IPv6의 루프백 주소 축약 표현) 내에서 전송되는 것이 보이며 정상적인 리스폰스를 나타내는 상태 코드와 단순 문자열로 처리된 응답 데이터들을 확인할 수 있다.</p>
<hr>
<p>일단은 네트워크 개념들을 좀 더 공부하고 실습할 수 있는 것들을 찾아 실습해 볼 예정</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MacOS에서 Docker 멀웨어 인식 오류(?) 해결하기]]></title>
            <link>https://velog.io/@kim00ngjun_0112/MacOS%EC%97%90%EC%84%9C-Docker-%EB%A9%80%EC%9B%A8%EC%96%B4-%EC%9D%B8%EC%8B%9D-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@kim00ngjun_0112/MacOS%EC%97%90%EC%84%9C-Docker-%EB%A9%80%EC%9B%A8%EC%96%B4-%EC%9D%B8%EC%8B%9D-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 11 Jan 2025 08:18:09 GMT</pubDate>
            <description><![CDATA[<h1 id="도커-데스크톱이-되지-않아🤔">도커 데스크톱이 되지 않아...🤔</h1>
<p>RDBMS 혹은 기타 개발 도구들을 활용할 때, 나는 주로 도커를 활용하는 편이다. 간편하기도 하고 굳이 내 로컬에 세팅하지 않아도 되는 장점이 있기 때문이다. 언젠가는 정복할 대상인데 아직도 밍기적밍기적 활용의 영역에만 그치고 있다(...)</p>
<p>각설하고, 포스팅을 작성하는 오늘(2025.1.11) 오전에 QueryDSL 공부를 위해 도커를 잠시 점검하려고 맥북을 켰는데 갑자기 멀웨어(!!!) 경고 문구가 떴다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/f77c4763-6e5c-4914-ada9-080faa6c6145/image.png" alt=""></p>
<pre><code class="language-bash">&#39;com.docker.vmnetd&#39; 은 사용자의 컴퓨터를 손상시킵니다
&#39;com.docker.socket&#39; 은 사용자의 컴퓨터를 손상시킵니다</code></pre>
<p>위 이미지는 퍼온 이미지고, 나는 한국인이니까 실제로는 한글 문구로 경고문이 뜬다. 사실 저 이슈가 발생했을 때 캡처를 깜빡했다 ㅎ... 여튼 2개의 경고문이 계속 뜨면서 나를 <del>귀찮게</del> 무섭게 했다. 나도 모르는 새에 설치됐다면서 무수한 애플의 경고문이 자꾸 방해되기도 하고 어차피 도커를 써야 되므로 에러 잡기에 들어갔다.</p>
<h2 id="1-멀웨어">1. 멀웨어</h2>
<p>구글 서포트에서는 단순하게 <strong>사용자를 해하려는 악성 소프트웨어(Malicious Software의 약자)</strong>를 통틀어서 <strong>멀웨어(Malware)</strong>라고 칭하고 있다. 즉, 멀웨어는 컴퓨터, 서버, 클라이언트 또는 컴퓨터 네트워크에 장애를 일으키거나, 개인 정보를 유출하거나, 정보 또는 시스템에 무단으로 액세스하거나, 정보에 대한 액세스 권한을 박탈하거나, 사용자의 컴퓨터 보안 및 개인 정보를 무의식적으로 방해하도록 의도적으로 설계된 모든 소프트웨어를 뜻한다.</p>
<p>애플, 맥 시리즈는 세 가지 방법으로 멀웨어를 방어한다.</p>
<blockquote>
<ol>
<li>악성 코드의 설치 또는 실행 방지: App Store 또는 Notarization과 결합한 <strong>Gatekeeper</strong></li>
<li>내 시스템에서 악성 코드가 실행되지 않도록 차단: <strong>Gatekeeper</strong>, <strong>Notarization</strong> 및 <strong>XProtect</strong></li>
<li>이미 실행된 악성 코드에 대한 치료: <strong>XProtect</strong></li>
</ol>
</blockquote>
<p>보통 멀웨어 의심 앱이 감지되면 Gatekeeper가 경고창을 띄우게 되지만 이 뜻대로라면 도커가 멀웨어(?!)였다는 얘기가 되는데... 무슨 오해(?)가 있었는지 확인을 해보자.</p>
<h2 id="2-문제-확인">2. 문제 확인</h2>
<p>문제 위치는 다음과 같다. 이 두 개는 참고로 macOS 전용 도커 데스크톱 서비스다.</p>
<pre><code class="language-bash">com.docker.vmnetd
com.docker.socket</code></pre>
<p><code>com.docker.vmnetd</code>은 도커 데스크톱의 네트워크 브릿지 서비스를 맡으며 가상화된 네트워크 환경을 제공해 외부 컨테이너와 통신할 수 있게 한다. 즉, 컨테이너 및 호스트 간의 네트워크 연결을 관리한다.</p>
<p><code>com.docker.socket</code>은 도커허브에서 이미지 풀을 받는 것이 가능하게 하는 작업 등, 도커 CLI와 도커 데몬(백그라운드 서비스) 간의 통신을 맡는 Unix 소켓 파일이다. 둘 다 도커의 핵심 백그라운드 프로세스를 담당한다.</p>
<p>도커 공식 깃허브에서는 다음과 같이 문제 원인을 명시하고 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/c14efea1-9c88-44dd-903d-0f843f71315c/image.png" alt=""></p>
<p>대충 해석하자면 소프트웨어 서명 문제라고 한다. <strong>서명</strong>은 소프트웨어의 출처를 보증하기 위해 공개키 기반 암호화 시스템을 통하여 개발자가 배포했음을 인증하는 장치이며 배포 과정에서 파일이 위변조되지 않고 원본 그대로 배포됐음을 검증하는 역할을 맡는다.</p>
<p>즉, <code>com.docker.vmnetd</code> 파일과 <code>com.docker.socket</code>의 서명이 유효하지 않은 상태여서 MacOS의 Gatekeeper가 이 둘을 멀웨어로 인식하고 경고문을 계속 띄우는 것이라 볼 수 있다.</p>
<h2 id="3-문제-해결">3. 문제 해결</h2>
<p>앞서 말했듯, 도커의 깃허브에 개설된 이슈에서 이를 다루고 있고 개발자가 이미 친절하게 해결책을 제시하고 있다. 우선 해결 방법으로는 <strong>버전(4.37.2) 업그레이드</strong>(모든 프로그램이 그렇듯...)를 추천하고 있어서 나는 완전히 삭제하고 도커 데스크톱을 새롭게 설치했다. 하지만 <del>더 나빠진 인터페이스 디자인</del>과 함께 역시나 멀웨어 인식 경고문이 떴다.</p>
<p>다음 추천 방법으로는 로컬의 Sudo 권한을 통해 실행 파일 바이너리들을 전부 정지시키고 새롭게 세팅하는 것이었다. 그 방법은 아래와 같다.</p>
<p>먼저 <strong>활성 액티비티 창에서 도커 데스크톱을 완전 종료</strong> 후, 터미널 환경에서 아래 명령어를 순차적으로 입력한다.</p>
<pre><code class="language-bash">#!/bin/bash

# 도커 관련 프로세스 완전 종료, 대소문자 구별 없게 [dD]로 입력
sudo pkill &#39;[dD]ocker&#39;

# vmnetd 실행 서비스 종료
sudo launchctl bootout system /Library/LaunchDaemons/com.docker.vmnetd.plist

# socket 실행 서비스 종료
sudo launchctl bootout system /Library/LaunchDaemons/com.docker.socket.plist

# 기존 vmnetd 바이너리 실행파일 제거
sudo rm -f /Library/PrivilegedHelperTools/com.docker.vmnetd

# 기존 socket 바이너리 실행파일 제거
sudo rm -f /Library/PrivilegedHelperTools/com.docker.socket

# 새로운 실행파일들 각각 설치, 기존 실행파일들 완전 대체
sudo cp /Applications/Docker.app/Contents/Library/LaunchServices/com.docker.vmnetd /Library/PrivilegedHelperTools/
sudo cp /Applications/Docker.app/Contents/MacOS/com.docker.socket /Library/PrivilegedHelperTools/</code></pre>
<p>위 과정을 잘 마무리하고 도커 데스크톱을 실행하면 경고문들이 출력되지 않고 정상적으로 잘 작동될 것이다. 여담으로 sudo 권한이므로 당연히 비밀번호 입력이 요구되는 걸 명심하자.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/c6901625-ad3d-4fab-8107-e7e55b8decd0/image.png" alt=""></p>
<p>도커 버전과 도커 컴포즈 버전(나는 도커 버전이 20.00 이상이어서 도커 컴포즈도 같이 설치되어 있다)을 테스트 삼아 확인하고, 마지막으로 데스크톱에서 컨테이너를 실행 후 터미널에서 확인하면 정상 작동됨을 확인할 수 있다.</p>
<h2 id="4-마치며">4. 마치며</h2>
<p>간단한(?) 이슈였지만 멀웨어와 서명의 관계에 대해 확인할 수 있었다. 언젠가는 보안과 관련된 공부도 하고 싶어서 꽤 흥미롭게 이슈를 분석할 수 있었다. 언능 보안 쪽도 공부하고 싶다.<del>물론 취직부터 하고..</del></p>
<p>이제 QueryDSL 공부하러 가야징</p>
<hr>
<p><em>도커 깃허브 이슈</em>
<em><a href="https://github.com/docker/for-mac/issues/7527">https://github.com/docker/for-mac/issues/7527</a></em></p>
<p><em>도커 공식문서 이슈</em>
<em><a href="https://docs.docker.com/desktop/cert-revoke-solution/">https://docs.docker.com/desktop/cert-revoke-solution/</a></em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot + VisualVM + JMeter 기반 트래픽 시나리오 메모리 테스트]]></title>
            <link>https://velog.io/@kim00ngjun_0112/Spring-Boot-VisualVM-JMeter%EB%A5%BC-%ED%86%B5%ED%95%9C-%ED%8A%B8%EB%9E%98%ED%94%BD-%EC%8B%9C%EB%82%98%EB%A6%AC%EC%98%A4-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@kim00ngjun_0112/Spring-Boot-VisualVM-JMeter%EB%A5%BC-%ED%86%B5%ED%95%9C-%ED%8A%B8%EB%9E%98%ED%94%BD-%EC%8B%9C%EB%82%98%EB%A6%AC%EC%98%A4-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Fri, 03 Jan 2025 05:09:39 GMT</pubDate>
            <description><![CDATA[<h1 id="테스트-시나리오-정리">테스트 시나리오 정리</h1>
<p>메모리를 직접 모니터링하고 성능 향상을 할 수 있는 방법을 고민하기 위해 직접 <del>머리박치기를 통해</del> 테스트 시나리오를 작성하고 테스트를 하였다.</p>
<p>테스트 시나리오는 아래와 같으며, 총 4가지의 시나리오를 분석할 예정이다.</p>
<blockquote>
<ol>
<li>빈 초기화 지연 이슈</li>
<li>메모리 누수로 인한 객체 회수 누락</li>
<li>스레드 무분별 생성 및 대기</li>
<li>GC 옵션에 따른 동작 분석</li>
</ol>
</blockquote>
<h2 id="issue-1-빈-초기화-지연-이슈">Issue 1. 빈 초기화 지연 이슈</h2>
<p>빈은 스프링 컨테이너에 의해 관리되는 재사용 가능한 소프트웨어 구성요소(컴포넌트)를 의미한다. 이 빈들은 애플리케이션이 부팅되면서 IoC 컨테이너가 자동으로 생성하고 의존성 주입을 처리하는 초기화 작업이 수행된다.</p>
<p>이런 빈의 초기화에는 다양한 전략이 있고, 또 그것들을 반영하는 <code>@PostConstruct</code> 어노테이션, <code>InitializingBean</code> 인터페이스 구현체 등의 다양한 내용이 있으며, 그중에는 <strong>지연 초기화 전략</strong>도 포함된다.</p>
<h3 id="1-빈-초기화-지연">1. 빈 초기화 지연</h3>
<p>스프링에서는 <code>@Lazy</code> 어노테이션을 제공하는데 이를 통해 스프링 빈의 초기화를 지연시키면서 불필요한 자원 사용을 어느 정도 막고 앱의 성능을 최적화할 수 있다.</p>
<p>다만 무조건 만능은 아닌 게, 결국 빈이 초기화가 늦어지는 것은 의존성 주입을 위한 로딩이 늦춰지게 되는데 이 과정에서 성능 저하가 발생할 수 있으며 메모리에 영향을 끼칠 수 있다.</p>
<p>요약하자면, 일반 빈은 애플리케이션이 부팅할 때 초기화가 되기 때문에 사용 시점에서는 그냥 사용만 하면 되지만, <code>@Lazy</code> 어노테이션 적용 빈은 사용 시점에서 <strong>초기화 + 사용</strong>이 같이 이뤄지기 때문에 사용이 빈번해질 수록 메모리에 부담이 간다.</p>
<p>다만 이것은 이론이고, 트래픽이 몰리는 상황에서의 실제 결과는 또 다를 수 있으니...</p>
<h3 id="2-테스트-세팅">2. 테스트 세팅</h3>
<p>테스트 코드는 아래와 같다. <code>@Lazy</code> 어노테이션이 부여 여부에 따라 동일 환경에서 테스트를 2번 실행한다.</p>
<pre><code class="language-java">@Slf4j
@Component
@Lazy // 스프링은 즉시 초기화가 디폴트지만, 얘는 지연 초기화 어노테이션
public class LazyInitBean {

    public void performTask() {
        log.info(&quot;*** LazyInitBean 작업 수행 ***&quot;);
    }
}</code></pre>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/lazy&quot;)
@RequiredArgsConstructor
public class LazyInitController {

    private final LazyInitBean lazyInitBean;

    @GetMapping(&quot;/test&quot;)
    public String testLazyInit() {
        lazyInitBean.performTask();
        return &quot;느릿느릿 빈 초기화 + 작업 수행&quot;;
    }
}</code></pre>
<p>테스트 환경은 아래와 같다.</p>
<blockquote>
<ul>
<li>트래픽 발생 툴 : JMeter</li>
<li>가상 사용자 수 : 200</li>
<li>램프업 타임 : 30s</li>
<li>루프 카운트 : 30</li>
<li>계측 도구 : IntelliJ Profiler</li>
</ul>
</blockquote>
<p>사실 메모리 계측을 VisualVM으로 수행하려 했으나 왜인지 프로파일러 결과값이 나오질 않았다(...) 대략 5시간 가까이 끙끙 앓았으나 결국 포기하고 IntelliJ Ultimate Edition에서 제공하는 프로파일러로 메소드별 메모리 할당량 계측으로 선회...</p>
<h3 id="3-테스트-진행-및-결과">3. 테스트 진행 및 결과</h3>
<h4 id="1-jmeter-설정">(1) JMeter 설정</h4>
<img width="75%" alt="빈초기화테스트스레드그룹" src="https://github.com/user-attachments/assets/e641f084-8e89-432f-930d-5c531fa05c48" />

<h4 id="2-lazy-어노테이션-적용지연-초기화">(2) Lazy 어노테이션 적용(지연 초기화)</h4>
<img width="75%" alt="지연초기화메모리할당랼" src="https://github.com/user-attachments/assets/f840c41b-4c4c-412e-8ca4-05f112e5bd06" />

<h4 id="3-lazy-어노테이션-미적용즉시-초기화">(3) Lazy 어노테이션 미적용(즉시 초기화)</h4>
<img width="75%" alt="즉시초기화메모리할당량" src="https://github.com/user-attachments/assets/c9145aa8-d0ac-4c6f-9fb5-7dc1e38ff232" />

<h3 id="4-테스트-분석">4. 테스트 분석</h3>
<p>이론과 다르게 실제 메모리 할당량은 거의 차이가 없었다.</p>
<p>개인적인 고찰 결과, 트래픽 테스트로는 빈 초기화의 영향력을 확인하는 것이 어려울 것으로 생각됐다. 애시당초 트래픽 테스트는 애플리케이션의 동시 처리 능력에 더 집중하는 경향이 높은 것과 별개로 <strong>빈은 한 번 초기화가 이뤄지면 그걸로 끝</strong>이기 떄문에 총체적인 성능에 영향을 미치지는 않는 것이다.</p>
<p>그렇기 때문에 실제 프로젝트에서 <code>@Lazy</code> 어노테이션은 <strong>초기화 비용이 비싼 빈</strong>이나 <strong>활용이 매우 드문 빈</strong>에 적용하는 수준으로 고려하면 충분할 듯하다.</p>
<h2 id="issue-2-메모리-누수로-인한-객체-회수-누락">Issue 2. 메모리 누수로 인한 객체 회수 누락</h2>
<p>기본적으로 사용이 끝난(사망 판정을 받은) 객체는 해제되어야 한다. 왜냐면 사용 가치가 없는데 메모리에 해당 객체를 남겨두는 것은 곧 메모리 낭비가 되기 때문이다.</p>
<p>그렇기 때문에 사용 가치가 없는 객체를 적재적소에 정리하는 것은 매우 중요하며, 자바 진영에서는 <strong>가비지 컬렉터</strong>가 그 역할을 담당한다. 그럼에도 특정 상황에서는 GC가 객체를 회수하지 못하는 상황이 발생할 수도 있다.</p>
<h3 id="1-메모리-누수">1. 메모리 누수</h3>
<p>전술한 메모리에서 해제 예정인 객체가 제때 해제되지 않고 메모리에 남아있는 현상을 <strong>메모리 누수</strong>라고 한다. 이 메모리 누수가 지속되면 JVM의 힙 영역이 과포화되면서 성능이 저하되고, 심각할 경우 <strong>OutOfMemoryError</strong>가 발생해 앱이 종료될 수도 있다.</p>
<p>통상 사용이 끝난 객체가 컬렉션(집합, 리스트, 맵 등등...)이나 정적 필드에 저장된 상태로 남아있거나 동적 클래스의 과도한 로딩 등이 메모리 누수의 주요 원인이다. 이 메모리 누수가 발생하는 근본적인 이유는 <strong>GC가 도달 가능하되 더 이상 사용되지 않는 불필요한 객체를 판별하지 못 하기 때문</strong>이다.</p>
<h3 id="2-테스트-세팅-1">2. 테스트 세팅</h3>
<p>우선, 테스트 코드를 아래와 같이 짠다. 핵심은 불필요 객체를 담을 <strong>정적 컬렉션</strong> 변수의 도입이다.</p>
<pre><code class="language-java">@Slf4j
@Service
public class MemoryLeakService {

    private static final List&lt;byte[]&gt; memoryLeakList = new ArrayList&lt;&gt;();

    public void generateLeak() {
        // 1MB 크기의 데이터를 리스트에 추가 (의도적 누수)
        memoryLeakList.add(new byte[1024 * 1024]); // 1MB 크기
        log.info(&quot;현재 누적 객체 수: {}&quot;, memoryLeakList.size());
    }
}</code></pre>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/leak&quot;)
@RequiredArgsConstructor
public class MemoryLeakController {

    private final MemoryLeakService memoryLeakService;

    /**
     * VisualVM Heap Dump 분석 + JMeter 호출 처리
     * -&gt; 가상 사용자수 조건이 과하면 OutOfMemoryException 발생 가능성
     */
    @GetMapping(&quot;/test&quot;)
    public String testMemoryLeak() {
        memoryLeakService.generateLeak();
        return &quot;메모리 누수 발생!&quot;;
    }
}</code></pre>
<p>그런 다음, 스프링부트 앱을 실행하기 전에 인자를 제공해서 OOE가 발생할 때 힙 덤프 파일을 생성할 수 있도록 설정을 추가한다.</p>
<img width="75%" alt="스크린샷 2025-01-03 오전 1 49 34" src="https://github.com/user-attachments/assets/16267b6b-bcb1-4136-a47e-5511bcf89023" />

<p>트래픽 테스트 실행 환경은 아래와 같다.</p>
<p>테스트 환경은 아래와 같다.</p>
<blockquote>
<ul>
<li>트래픽 발생 툴 : JMeter</li>
<li>가상 사용자 수 : 200</li>
<li>램프업 타임 : 30s</li>
<li>루프 카운트 : 30</li>
</ul>
</blockquote>
<h3 id="3-테스트-진행-및-결과-1">3. 테스트 진행 및 결과</h3>
<h4 id="1-jmeter-테스트-스레드-설정">(1) JMeter 테스트 스레드 설정</h4>
<img width="75%" alt="메모리누수테스트세팅" src="https://github.com/user-attachments/assets/dde832ca-930b-4f31-b3fb-c3320d18f67f" />

<h4 id="2-실행-결과-outofmemoryerror-발생">(2) 실행 결과 OutOfMemoryError 발생</h4>
<p>정적 컬렉션에 저장되는 객체 수 카운팅 로그를 남기다가 어느 시점에서 OOE가 발생하는 것이 포착됐다. <del>그와 동시에 자바 관련 툴들 전부 먹통</del></p>
<img width="75%" alt="OOE발생" src="https://github.com/user-attachments/assets/b6885188-a9f2-4aa0-bfa4-667ade655539" />

<p>실행 인자에 <code>-XX:+HeapDumpOnOutOfMemoryError</code>를 부여해서 OOE가 발생하는 시점에 자동으로 힙 덤프 파일이 생성된다.</p>
<h4 id="3-visualvm-기반-힙-덤프-파일-체크">(3) VisualVM 기반 힙 덤프 파일 체크</h4>
<p>앱이 부팅되고 난 바로 직후의 힙 덤프 파일을 확인해보면 아래와 같다.</p>
<img width="75%" alt="초기힙덤프" src="https://github.com/user-attachments/assets/3c4770b2-4870-4361-81bf-827a91b54308" />

<p>여기서 유심히 봐야할 부분이 바로 테스트 코드의 정적 컬렉션 필드 타입인 <code>byte[]</code>인데, 현 시점에서는 메모리 차지하는 크기가 약 4MB 정도밖에 안 된다. 그리고 위에서 언급한 OOE가 발생할 때 내가 직접 캐치한 힙 덤프 파일은 아래와 같다.</p>
<img width="75%" alt="OOE발생시점힙덤프" src="https://github.com/user-attachments/assets/e93ddcc8-b516-4b94-872e-e40b372ad9f8" />

<p>아까 정적 컬렉션의 타입인 <code>byte[]</code>의 크기가 2044MB, 약 2GB인 것을 확인할 수 있다. OOE가 발생한 시점에서 대략 500배나 그 크기가 급증한 것을 확인할 수 있다. 물론 이것은 OOE 발생 시점에 정확히 찍혔다고는 보기 어려우므로 좀 더 자세하게 확인해본다.</p>
<h4 id="4-ooe-힙-덤프-파일-체크">(4) OOE 힙 덤프 파일 체크</h4>
<p>아까 앱을 실행할 때, <code>-XX:+HeapDumpOnOutOfMemoryError</code> 인자를 부여했었다. 이로 인해 자동으로 힙 덤프 파일이 생성됐다.</p>
<p>히카리풀에 명시됐던 경로인 <code>/var/folders/tz/1_xqpm3x4pd6hdvswtb_fkl40000gn/T/visualvm_kimdongjun.dat/localhost_9244/java_pid9244.hprof</code>를 탐색하면 인텔리제이로 힙 덤프 파일을 확인할 수 있다.</p>
<img width="75%" alt="Retained중심OOE힙덤프파일체킹" src="https://github.com/user-attachments/assets/1316c545-f7dc-4584-850c-d9c18a8bf1e1" />

<p>웬만한 경향은 VisualVM에서 확인한 힙 덤프와 유사하나, 직접 체크할 때 봐야 할 부분은 <strong>Retained</strong> 컬럼을 위주로 확인해야 한다. <strong>Shallow</strong> 탭은 객체 자체의 크기만을 나타내나 Retained 탭은 해당 객체가 참조하는 모든 객체가 차지하는 메모리 크기를 명시하기 때문에 Retained 컬럼을 통해 메모리 누수를 확인할 수 있다.</p>
<h3 id="4-테스트-분석-1">4. 테스트 분석</h3>
<p>앞서 이론으로 봤던 <code>static</code> 변수, 컬렉션 변수에 사용이 종료된 객체를 저장하고 따로 비우는 로직이 없으면 GC는 도달 가능한 객체로만 판별하고 사망 판정을 내리지 않기 때문에 GC가 회수할 수 없게 된다.</p>
<p>그 이유는, 정적 변수나 컬렉션 변수는 애플리케이션의 생명주기와 똑같이 가져가기 때문이다. 정적 변수는 클래스 로더가 딱 한 번 메모리에 로드하면서 참조를 유지하기 때문에 명시적인 <code>null</code> 할당이 요구된다. 컬렉션에 저장된 객체들은 컬렉션 자체가 참조를 유지하기 때문에 자연스레 컬렉션의 생명주기를 따라가면서 살아남게 되는 것이다.</p>
<p>즉, 쓸데없이 메모리 차지를 하는 객체가 정리되지 못함에 따라 메모리가 쉽게 비워지지 않으면서 결국 메모리 누수가 발생하고 OutOfMemoryError가 발생하는 것이다.</p>
<p>요약하자면 <strong>GC는 참조 여부만을 판단하지, 쓸데없는 참조인지는 판단할 수 없기 때문에 메모리 누수가 발생</strong>하고, 그 대표적인 원인은 정적 변수, 컬렉션 변수가 있다. 참고로 그냥 인스턴스 필드는 객체의 생명주기와 같이하기 때문에 객체가 참조되지 않는 시점에 바로 같이 GC에 의해 정리되므로 앞서 언급한 메모리 누수의 원인에서 자유롭다.</p>
<h2 id="issue-3-스레드-무분별-생성-및-대기">Issue 3. 스레드 무분별 생성 및 대기</h2>
<p>자바는 스레드와 별개로 볼 수 없는 관계다. 애초에 자바가 다른 프로그래밍 언어와 가지는 대표적인 특징이 멀티스레딩이기도 하니까... 이 멀티스레딩을 활용해 비동기적 작업 처리, 대기 시간 감소 등의 최적화를 꾀할 수 있지만 동시성 이슈, 병목 현상 유발 등의 문제점도 같이 갖고 있다.</p>
<p>마찬가지로 스레드가 JVM의 메모리에 미치는 영향 역시 무시할 수 없어서 테스트 시나리오로 삼게 됐다.</p>
<h3 id="1-스레드-생성과-대기">1. 스레드 생성과 대기</h3>
<p>자바의 스레드는 <code>Thread</code> 클래스 혹은 <code>Runnable</code> 인터페이스를 활용해 단위 생성된다. 이 생성된 스레드는 아래와 같은 상태를 가지게 된다.</p>
<blockquote>
<ul>
<li>New : 스레드 객체가 생성되었으나 아직 시작되지 않은 상태</li>
<li>Runnable : 실행 가능한 상태 (CPU에 의해 실행될 준비가 됨)</li>
<li>Blocked : 자원에 접근을 위해 대기 중인 상태</li>
<li>Waiting : 다른 조건을 기다리는 상태</li>
<li>Timed Waiting : 일정 시간 동안만 대기하는 상태</li>
<li>Terminated : 스레드 실행이 완료되어 종료된 상태</li>
</ul>
</blockquote>
<p>실행 가능한 상태에서 적재적소에 스레드가 실행되는 것을 관리하면 그 자체로도 최적화를 이끌어낼 수 있지만, 이유 없이 생성되거나 생성된 상태로 그저 대기만 하는 스레드는 JVM의 메모리에 악영향을 끼칠 수 있다.</p>
<h3 id="2-테스트-세팅-2">2. 테스트 세팅</h3>
<p>스레드를 생성하는 테스트 코드를 짜면서 중간에 대기 상태를 위한 동기화 로직을 작성한다. 이 로직을 의도적으로 반복시켜 메모리 소모량을 가시적으로 늘인다.</p>
<pre><code class="language-java">@Slf4j
@Service
public class ThreadWaitingService {

    public void processRequest(int threadCount) {
        for (int i = 0; i &lt; threadCount; i++) {
            new Thread(() -&gt; {
                try {
                    log.info(&quot;스레드 시작 - {}&quot;, Thread.currentThread().getName());
                    // 스레드를 대기 상태로 두기
                    synchronized (this) {
                        wait(); // 계속 대기 상태로 두기
                    }
                    log.info(&quot;스레드 종료 - {}&quot;, Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    log.error(&quot;스레드 중단됨&quot;, e);
                }
            }).start();
        }
    }
}</code></pre>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/thread&quot;)
@RequiredArgsConstructor
public class ThreadWaitingController {

    private final ThreadWaitingService threadWaitingService;

    /**
     * VisualVM Heap Dump 체킹 + 스레드풀은 RejectedExecutionException으로 효율 예외 처리?
     * -&gt; 직접 스레드를 생성하고 대기 상태로 오래 유지시키면 생기는 악영향?
     * -&gt; 스레드풀 크기 확장 + HTTP 요청 타임아웃 적용
     */
    @GetMapping(&quot;/test&quot;)
    public String testBlockingThreadPool() {
        for (int i = 0; i &lt; 100; i++) {
            threadWaitingService.processRequest(200);
        }
        return &quot;스레드풀 블로킹 처리!&quot;;
    }
}</code></pre>
<p>테스트 환경은 아래와 같다.</p>
<blockquote>
<ul>
<li>트래픽 발생 툴 : JMeter</li>
<li>가상 사용자 수 : 200</li>
<li>램프업 타임 : 30s</li>
<li>루프 카운트 : 30</li>
</ul>
</blockquote>
<h3 id="3-테스트-진행-및-결과-2">3. 테스트 진행 및 결과</h3>
<h4 id="1-jmeter-테스트-스레드-설정-1">(1) JMeter 테스트 스레드 설정</h4>
<img width="75%" alt="테스트환경설정" src="https://github.com/user-attachments/assets/22fd7fd4-6e62-4d2d-bf1f-8af74ef0b2e4" />

<h4 id="2-실행-결과-outofmemoryerror-발생-1">(2) 실행 결과 OutOfMemoryError 발생</h4>
<img width="75%" alt="OOE로그확인" src="https://github.com/user-attachments/assets/f2d7be2a-c1ec-4ec7-8ded-f80cbf425dea" />

<p>메모리 누수 이슈와 마찬가지로 OutOfMemoryError가 발생한다. 이 OOE가 발생한 이유는 시스템에서 할당할 수 있는 메모리가 부족하고 많은 스레드가 대기 상태에 진입함으로써 JVM의 힙 메모리가 부족해지면서 발생한다. 로그에 대해서는 아래에서 조금 더 상세히 분석해본다.</p>
<pre><code class="language-bash">java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached] with root cause</code></pre>
<p>네이티브 스레드, 즉 자바 스레드 모델을 동작시키기 위한 실제 커널 스레드를 생성하는 데에 실패했다는 메세지를 나타낸다.</p>
<pre><code class="language-bash">Failed to start thread &quot;Unknown thread&quot; - pthread_create failed (EAGAIN) for attributes: stacksize: 2048k, guardsize: 16k, detached.</code></pre>
<p>스택 메모리 및 보호 메모리 크기가 설정된 환경에서 스레드를 <code>pthread_create</code> 함수를 통해 새로 생성하려 했으나 실패했음을 나타낸다.</p>
<h4 id="3-테스트-전후-스레드-덤프-비교">(3) 테스트 전후 스레드 덤프 비교</h4>
<ul>
<li>테스트 전</li>
</ul>
<img width="75%" alt="테스트전스레드덤프" src="https://github.com/user-attachments/assets/f38afe06-f93a-4c14-83a9-9ff77603e1ed" />


<ul>
<li>테스트 후</li>
</ul>
<img width="75%" alt="테스트후스레드덤프1" src="https://github.com/user-attachments/assets/eabd061d-707b-488e-86d2-5b40ab1a15f4" />
<img width="75%" alt="테스트후스레드덤프2" src="https://github.com/user-attachments/assets/7ab941a6-73ed-4886-993d-bc44a24e97b1" />

<p>테스트 전후로 비교했을 때, 스레드 수가 굉장히 많이 늘어났으며(무분별한 생성) 생성된 대부분의 스레드가 <code>Object.wait()</code> 메소드로 인해 대기(<code>WAITING</code>) 상태에 상주하고 있다. 즉, 무분별한 대기 상태에 놓여져 있음을 알 수 있다.</p>
<h4 id="4-테스트-전후-힙-덤프-비교">(4) 테스트 전후 힙 덤프 비교</h4>
<ul>
<li>테스트 전</li>
</ul>
<img width="75%" alt="테스트전힙덤프" src="https://github.com/user-attachments/assets/d84abd44-cbb3-4bf0-bb50-c88ec8b228b6" />


<ul>
<li>테스트 후</li>
</ul>
<img width="75%" alt="테스트후힙덤프" src="https://github.com/user-attachments/assets/41343364-e57d-4b98-96a4-b658f00cdea7" />


<p>직접 생성된 <code>Thread</code> 관련 객체들이 얼만큼 메모리 비중을 차지하고 있는지 확인한다. 테스트 전의 개별 <code>Thread</code>의 Retained된 값은 약 14.7KB에 불과하지만, 테스트 시행 직후에 얻은 힙 덤프에서는 Retained된 값이 약 2MB로 급증했음을 알 수 있다. 생성된 스레드의 개수를 생각하면 기가바이트 단위로 확 올랐음을 짐작할 수 있다. 즉, 스레드의 자원 관리가 효율적으로 이뤄지지 않고 있다.</p>
<h3 id="4-테스트-분석-2">4. 테스트 분석</h3>
<p>사실 웬만하면 <strong>스레드 풀</strong>을 활용해서 스레드 생성과 대기를 효율적으로 관리하는 데에는, 큐 자료구조를 통해 작업의 순서와 대기에 있어 최적화를 이뤄낼 수 있기 때문이다. 단위 스레드를 생성하는 것 또한 방법 중 하나지만 경쟁 조건에 취약하다보니 동기화가 필수적이고, 이는 성능 저하로 이어질 수 있다.</p>
<p>JDK 21에서는 가상 스레드를 활용해서 조금 더 최적화된 스레드 풀을 활용할 수 있으니 이를 참고해서 스레드 생성 작업에 투입하는 것이 메모리 관리 측면에서도 옳은 방향일 것이다. 참고로 스레드 풀에서 수많은 스레드 생성으로 스레드 풀과 작업 큐의 용량을 초과하면 <code>RejectedExecutionException</code>을 발생시키며 예외로 처리한다.</p>
<h2 id="issue-4-gc-옵션에-따른-동작-분석">Issue 4. GC 옵션에 따른 동작 분석</h2>
<p>JVM의 GC는 다양한 종류가 있다. 현재 JDK 21의 디폴트 GC는 <strong>G1 GC</strong>(Garbage First GC)이며, 동일한 GC여도 다양한 실행 인자를 부여하여 애플리케이션에 최적화된 GC 옵션을 제공할 수 있다. 즉, 메모리 관리를 효율적으로 수행하려면 GC의 옵션 고려 역시 중요 사항에 속한다.</p>
<p>이번 테스트는 GC의 선택을 다르게 해서 VisualVM의 Visual GC 플러그인을 통해 객체 정리 그래프가 어떻게 출력되는지 확인해본다.</p>
<h3 id="1-g1-vs-parallel">1. G1 vs Parallel</h3>
<p>둘의 개념을 정리하는 건 생략한다. G1은 저지연 및 예측 가능한 GC 시간을 목표로 설계됐고, Parllel, 즉 병렬 GC는 높은 처리량을 목적으로 설계됐다. 시기상으로는 병렬 GC가 앞서기 때문에 조금 더 구식인 느낌이 있지만 실제로는 전술한 애플리케이션의 설계 방향에 따라 오히려 병렬 GC가 고효율의 성능을 나타낼 수 있다.</p>
<p>그렇기 때문에 사실 다양한 테스트 코드를 작성하고 비교 분석하는 것이 조금 더 정확한 테스트가 되겠으나 현재는 스터디의 목적에 충실하게 일단 그래프 분석을 우선으로 삼아 테스트를 진행해볼 예정이다.</p>
<h3 id="2-테스트-세팅-3">2. 테스트 세팅</h3>
<p>테스트 코드에는 간단히 객체를 생성하고 연산하면서 정리하는 비즈니스 로직과 컨트롤러 호출을 세팅한다.</p>
<pre><code class="language-java">@Slf4j
@Service
public class GcService {

    public void performGcIntensiveTask() {
        for (int i = 0; i &lt; 100; i++) {
            // 리스트 세팅
            List&lt;Integer&gt; numbers = new ArrayList&lt;&gt;();

            for (int j = 0; j &lt; 10_000; j++) {
                numbers.add(j);
            }

            // 간단한 연산
            int sum = numbers.stream().mapToInt(Integer::intValue).sum();
            log.info(&quot;현재 작업 연산값: {}&quot;, sum);

            // 메모리 제거
            numbers.clear();
        }
    }
}</code></pre>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/gc&quot;)
@RequiredArgsConstructor
public class GcController {

    private final GcService gcService;

    /**
     * Visual GC 플러그인 활용 -&gt; GC 옵션 바꿀 수 있으면 바꿔보기
     */
    @GetMapping(&quot;/test&quot;)
    public String testGcIssue() {
        gcService.performGcIntensiveTask();
        return &quot;GC 작동&quot;;
    }
}</code></pre>
<p>이번 테스트는 동일한 앱 내에서 GC의 동작 및 결과 차이를 확인하기 위해서므로, 실행 옵션에 GC와 관련된 파라미터를 부여한다. 인텔리제이 IDE는 VM Option을 설정에서 별도로 제공하기 때문에 쉽게 파라미터 부여가 가능하다.</p>
<img width="75%" alt="VM옵션파라미터부여" src="https://github.com/user-attachments/assets/f8de4b8d-65c3-4458-9545-66e85ff739c2" />

<p>트래픽 테스트 실행 환경은 아래와 같다.</p>
<blockquote>
<ul>
<li>트래픽 발생 툴 : JMeter</li>
<li>가상 사용자 수 : 200</li>
<li>램프업 타임 : 30s</li>
<li>루프 카운트 : 30</li>
</ul>
</blockquote>
<h3 id="3-테스트-진행-및-결과-3">3. 테스트 진행 및 결과</h3>
<h4 id="1-jmeter-테스트-스레드-설정-2">(1) JMeter 테스트 스레드 설정</h4>
<img width="75%" alt="테스트설정" src="https://github.com/user-attachments/assets/c88cac58-8904-49d2-a6d8-336ebcc672d7" />

<h4 id="2-g1-gc-테스트">(2) G1 GC 테스트</h4>
<h5 id="실행-옵션">실행 옵션</h5>
<pre><code class="language-bash">-XX:+UseG1GC -verbose:gc -Xlog:gc*:file=gc.log:time,uptime,level,tags</code></pre>
<h5 id="로그-확인">로그 확인</h5>
<img width="75%" alt="G1GC로그출력" src="https://github.com/user-attachments/assets/4055868a-8df0-48f2-810c-5a327cb06f0c" />

<h5 id="visual-gc-그래프">Visual GC 그래프</h5>
<img width="75%" alt="G1GC영역분석" src="https://github.com/user-attachments/assets/b2dbb1a9-22f7-416e-8ed5-3d5c42850717" />

<h4 id="3-parallel-gc-테스트">(3) Parallel GC 테스트</h4>
<h5 id="실행-옵션-1">실행 옵션</h5>
<pre><code class="language-bash">-XX:+UseParallelGC -XX:ParallelGCThreads=8 -XX:MaxGCPauseMillis=200 -verbose:gc -Xlog:gc*:file=gc.log:time,uptime,level,tags</code></pre>
<h5 id="로그-확인-1">로그 확인</h5>
<img width="75%" alt="병렬GC로그출력" src="https://github.com/user-attachments/assets/b9cbb31a-baad-4125-b770-11a302172124" />

<h5 id="visual-gc-그래프-1">Visual GC 그래프</h5>
<img width="75%" alt="병렬GC영역분석" src="https://github.com/user-attachments/assets/4e9b31a4-ccda-4abd-81c9-94a8e9ab0264" />


<h3 id="4-테스트-분석-3">4. 테스트 분석</h3>
<h4 id="1-gc-수행시간">(1) GC 수행시간</h4>
<blockquote>
<p><strong>G1 GC</strong><br>GC Time: 195 collections, 4.178s Last Cause: G1 Evacuation Pause</p>
<p><strong>Parallel GC</strong><br>GC Time: 162 collections, 1.964s Last Cause: Allocation Failure</p>
</blockquote>
<p>병렬 GC가 더 적은 수의 GC를 수행하고 더 짧은 시간 동안 완료됐다. 병렬 GC가 여러 스레드를 사용해 GC 작업을 병렬로 처리하여 성능 향상을 확인할 수 있다. 반면, G1 GC는 더 많은 GC를 수행했으며, GC 시간이 길어졌는데 이는 G1이 더 세밀하게 메모리 영역을 관리하려는 특성 때문일 수 있다.</p>
<p>G1 GC에서는 Evacuation Pause가 원인이 되어 GC 시간이 길어졌고, Young에서 Old 영역으로의 객체 이동 과정에서 발생한 멈춤으로 볼 수 있다. 병렬 GC에서는 Allocation Failure가 발생하여, 힙 공간 부족으로 인해 GC가 실행되었는데, Young 영역의 공간 부족으로 인해 GC가 수행된 것이며 이를 해결하기 위해 메모리 공간을 정리하는 작업이 이뤄졌다.</p>
<h4 id="2-eden-영역">(2) Eden 영역</h4>
<blockquote>
<p><strong>G1 GC</strong><br>Eden Space (4.000G, 1.576G): 948.000M, 195 collections, 4.178s</p>
<p><strong>Parallel GC</strong><br>Eden Space (1.332G, 1.274G): 332.012M, 160 collections, 1.890s</p>
</blockquote>
<p>둘 다 모두 Eden 영역에서 많은 메모리 할당을 다뤘지만, 병렬 GC는 빠르게 처리된 반면 G1 GC는 여러 차례의 세밀한 GC를 수행한 것을 확인할 수 있다. 병렬 GC는 메모리를 한 번에 많이 처리할 수 있지만, G1 GC는 조금 더 세밀한 관리를 수행하는 것이 주요 원인으로 생각된다.</p>
<h4 id="3-survivor-영역">(3) Survivor 영역</h4>
<blockquote>
<p><strong>G1 GC</strong><br>Survivor 0 (0, 0): 0<br>Survivor 1 (4.000G, 6.000M): 4.584M</p>
<p><strong>Parallel GC</strong><br>Survivor 0 (455.000M, 29.500M): 957.156K<br>Survivor 1 (455.000M, 30.000M): 0</p>
</blockquote>
<p>가장 두드러지는 특징이 Survivor 영역에서 나타났다. G1은 <strong>Survivor 영역을 세밀히 관리하여 Eden에서 Old로 직접 이동시키는 것을 최대한 지연</strong>하려고 하는 반면, 병렬 GC는 <strong>객체를 빠르게 Old 영역으로 옮겨 Survivor1을 비움으로써 빠른 GC를 유도하려 하기 때문</strong>이다.</p>
<p>Survivor 영역에서 G1 GC는 효율적인 관리를 통한 메모리 분배 경향을, 병렬 GC는 빠른 GC 성능을 우선시하면서 메모리 압박 우선 해결 경향을 보이는 것을 확인할 수 있다.</p>
<h4 id="4-old-영역">(4) Old 영역</h4>
<blockquote>
<p><strong>G1 GC</strong><br>Old Gen (4.000G, 952.000M): 35.766M, 0 collections, 0s</p>
<p><strong>Parallel GC</strong><br>Old Gen (2.667G, 171.000M): 64.503M, 2 collections, 73.634ms</p>
</blockquote>
<p>결과적으로 G1은 Old 영역의 메모리 활용을 최대한 덜하며 GC 수행 시간이 적었던 반면, 병렬 GC는 Old 영역을 빠르게 소진시키면서 GC 수행 횟수가 증가하고 해당 영역의 사용량도 증가한 것을 볼 수 있다.</p>
<h4 id="5-결론">(5) 결론</h4>
<p>테스트 코드의 트래픽 테스트에서는 병렬 GC가 더 적합할 것이다. Young 영역의 빠른 GC 회수 덕분에 성능이 개선될 수 있기 떄문이다.</p>
<p>다만 GC가 너무 자주 발생하면 G1이 더 안정적인 성능을 제공할 수 있으므로, 메모리의 크기나 사용 패턴에 따라 적합한 GC를 선택하는 것이 중요할 것이고 이 과정은 테스트를 통해 근거를 확보하는 것이 옳을 것이다.</p>
<p><em>소스 코드</em>
<em><a href="https://github.com/kimD0ngjun/JVM-memory-test">https://github.com/kimD0ngjun/JVM-memory-test</a></em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[N+1 문제 심도 있게 고민하기(JPA 기준 + 해결책 고찰)]]></title>
            <link>https://velog.io/@kim00ngjun_0112/N1-%EB%AC%B8%EC%A0%9C-%EC%8B%AC%EB%8F%84-%EC%9E%88%EA%B2%8C-%EA%B3%A0%EB%AF%BC%ED%95%98%EA%B8%B0JPA-%EA%B8%B0%EC%A4%80-%ED%95%B4%EA%B2%B0%EC%B1%85-%EA%B3%A0%EC%B0%B0</link>
            <guid>https://velog.io/@kim00ngjun_0112/N1-%EB%AC%B8%EC%A0%9C-%EC%8B%AC%EB%8F%84-%EC%9E%88%EA%B2%8C-%EA%B3%A0%EB%AF%BC%ED%95%98%EA%B8%B0JPA-%EA%B8%B0%EC%A4%80-%ED%95%B4%EA%B2%B0%EC%B1%85-%EA%B3%A0%EC%B0%B0</guid>
            <pubDate>Sun, 08 Dec 2024 09:31:09 GMT</pubDate>
            <description><![CDATA[<h1 id="n1-문제">N+1 문제</h1>
<h2 id="1-개념">1. 개념</h2>
<h3 id="1-jpa-개념-정리">(1) JPA 개념 정리</h3>
<p>JPA는 자바 애플리케이션에서 데이터베이스와 객체와의 매핑 처리를 위한 <strong>ORM 기술 표준</strong>이다. 기술 표준이라 함은 인터페이스이기 때문에 이를 구현하는 프레임워크가 존재하는데 대표적인 게 Hibernate이다.</p>
<h3 id="2-n1-문제-원인">(2) N+1 문제 원인</h3>
<p>JPA의 기능 중 데이터베이스 테이블의 외래 키 전략을 객체 단계에서 표현하기 위한 <strong>연관관계 매핑</strong>과, 이 연관된 데이터를 일괄 조회하는 것이 아닌 필요할 때에만 조회함으로써 성능 효율을 추구하는 <strong>지연 로딩</strong>이 있는데 이 둘 때문에 N+1 문제라는 이슈가 발생한다.</p>
<h2 id="2-코드-기반-탐구">2. 코드 기반 탐구</h2>
<h3 id="1-테스트">1) 테스트</h3>
<h4 id="1-테스트-환경-세팅">(1) 테스트 환경 세팅</h4>
<p>연관관계를 설정한 두 개의 엔티티를 생성한다.</p>
<pre><code class="language-java">@Entity
@Getter
@NoArgsConstructor
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;team_id&quot;)
    private Long id;

    @Column(name = &quot;team_name&quot;)
    private String name;

    @OneToMany(mappedBy = &quot;team&quot;) // ~ToMany: LAZY
    private List&lt;Member&gt; members = new ArrayList&lt;&gt;();

    public Team(String name) {
        this.name = name;
    }

    public void addMember(Member member) {
        members.add(member);
        member.setTeam(this); // 양방향 연관 관계 동기화
    }
}</code></pre>
<pre><code class="language-java">@Entity
@Getter
@Setter
@NoArgsConstructor
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;member_id&quot;)
    private Long id;

    @Column(name = &quot;member_name&quot;)
    private String name;

    @ManyToOne(cascade = CascadeType.ALL) // ~ToOne: EAGER
    @JoinColumn(name = &quot;team_id&quot;) // 외래 키(팀)를 관리하는 측(멤버)에게 부여
    private Team team;

    public Member(String name) {
        this.name = name;
    }
}</code></pre>
<p><code>Tean</code> 엔티티와 <code>Member</code> 엔티티는 1:N 단방향 관계를 맺는다.</p>
<p>이것은 데이터베이스에서도 외래키 설정을 확인할 수 있다.</p>
<img width="667" alt="외래키설정확인" src="https://github.com/user-attachments/assets/bc83a33f-e3c8-47c5-a5d7-b5cf458fa1fe">

<p>1:N에서 N에 해당하는 <code>Member</code>가, 1에 해당하는 <code>Team</code>의 ID를 외래 키로 가지고 있음을 확인할 수 있다.</p>
<h4 id="2-테스트-코드-작성">(2) 테스트 코드 작성</h4>
<p>현재 데이터베이스(MySQL)에는 다음과 같은 테이블들이 있다.</p>
<img width="671" alt="팀,멤버필드확인" src="https://github.com/user-attachments/assets/4c66645c-1035-4d5a-80c1-49c5e815c812">

<p>여기서 만약 이런 경우가 생길 수 있다.</p>
<blockquote>
<p><strong>팀의 멤버</strong>들을 조회</p>
</blockquote>
<p>현재 위의 테이블은 팀 따로, 멤버 따로 제시되어 있으니 아마 해당 경우가 원하는 테이블의 모습은 아래와 같을 것이다.</p>
<img width="502" alt="조인문" src="https://github.com/user-attachments/assets/f24840a9-6a38-4afe-ae9f-c9bce2669790">

<p>위의 테이블처럼 팀 정보와 그 팀에 해당하는 멤버들을 정보를 일괄 조회할 수 있다.</p>
<p>이것을 JPA의 데이터베이스 객체 매핑 장점을 활용해서 자바 레벨에서도 코드로 작성할 수 있다.</p>
<pre><code class="language-java">public void findAllMembersByTeamRepo() {
    List&lt;String&gt; list = teamRepository.findAll().stream()
            .flatMap(team -&gt; team.getMembers().stream()
                    .map(member -&gt; team.getName() + &quot;: &quot; + member.getName()))
            .toList();

    System.out.println(&quot;결과: &quot; + list);
}</code></pre>
<p>이것을 실행하면 다음과 같이 hibernate 쿼리 출력 로그와 결과 로그를 확인할 수 있다.</p>
<img width="935" alt="결과 출력 로그" src="https://github.com/user-attachments/assets/176515d3-0c06-4da9-bb38-40b1f8c33379">

<h3 id="2-탐구">2) 탐구</h3>
<h4 id="1-왜-문제가-되는가">(1) 왜 문제가 되는가?</h4>
<p>이게 문제가 되는 이유는, <strong>쿼리가 중복 발신</strong>되기 때문이다.</p>
<p>노란색 박스는 아래처럼 팀 테이블의 팀 ID와 이름을 조회한다.</p>
<pre><code class="language-sql">select t1_0.team_id,t1_0.team_name from team t1_0</code></pre>
<p>초록색 박스는 아래처럼 멤버 테이블에서 외래 키에 대응되는 팀의 ID 조건부로 팀 정보와 멤버 정보를 같이 조회한다.</p>
<pre><code class="language-sql">select m1_0.team_id,m1_0.member_id,m1_0.member_name from member m1_0 where m1_0.team_id=?</code></pre>
<p>사실 JPA 입장으로 봤을 때는 코드 로직을 해석해보면 충분히 납득이 간다.</p>
<p>아래처럼 순서가 이뤄지고 이것은 머릿속으로도 충분히 그림이 그려지기 때문이다.</p>
<blockquote>
<ol>
<li>일단 팀들을 전부 조회한다 - 노란색 박스</li>
<li>1번 결과로부터 팀 엔티티별로 멤버들을 조회한다 - 초록색 박스</li>
</ol>
</blockquote>
<p>이렇게 JPA 입장에서 원하는 결과를 조회하는 데에는 문제가 없지만, 문제는 데이터베이스다.</p>
<p>위의 결과를 그저 딱 한 번의 쿼리로도 충분히 조회할 수 있기 때문이다.</p>
<pre><code class="language-sql">select t.team_id, t.team_name, m.member_id, m.member_name from team t left join member m on t.team_id = m.team_id;</code></pre>
<img width="505" alt="leftjoin" src="https://github.com/user-attachments/assets/85aeab52-41b3-416b-a17c-54bf02ce759b">

<p>쿼리 문법 중 <strong><code>join</code></strong> 문법을 활용하면 두 테이블의 데이터를 동시에 가져올 수 있어서 두 번의 테이블 스캔을 한 번으로 줄임으로써 한 번의 네트워크 호출로 원하는 결과를 반환할 수 있게 된다.</p>
<p>하지만 JPA는 <strong>join</strong>을 활용하지 않고 일일이 모든 테이블을 스캔하면서 <code>where</code>로 조건부 스캔을 중복하고 있다. 지금이야 팀이 3개밖에 안되지만 팀이 많아질 수록 이 중복되는 횟수 역시 비례해서 증가하게 될 것이다.</p>
<p>즉, N+1 문제라는 것은 <strong>1번의 부모 조회</strong> + <strong>N번의 연관된 자식 조회</strong>가 발생하는 이슈라고 할 수 있다.</p>
<h4 id="2-왜-이렇게-설계됐는지---지연-로딩의-관점">(2) 왜 이렇게 설계됐는지 - 지연 로딩의 관점</h4>
<p>이런 문제가 발생하게 된 배경에는 <strong>지연 로딩</strong>이 있다.</p>
<p>지연 로딩은 엔티티를 사용하는 시점까지 데이터 로드를 미루는 기법이다. 즉, 데이터베이스에서 즉시 데이터를 가져오지 않고 필요할 때 데이터를 로드하는 방식이다. 이를 통해 현재 시점에서 불필요한 데이터 로드를 방지하면서 초기 로딩 속도를 빠르게 할 수 있다.<br /></p>
<p>아까 <code>Team</code> 엔티티를 보면</p>
<pre><code class="language-java">@Entity
@Getter
@NoArgsConstructor
public class Team {

    // ...

    @OneToMany(mappedBy = &quot;team&quot;) // ~ToMany: LAZY
    private List&lt;Member&gt; members = new ArrayList&lt;&gt;();

    // ...</code></pre>
<p>1:N 연관관계를 맺은 <code>Member</code> 엔티티 객체를 참조하고 있음을 알 수 있다.</p>
<p>즉, 만약 <code>TeamRepository</code>를 통해 <code>Team</code> 엔티티 인스턴스 객체를 조회하는 과정에서 <code>MemberRepository</code>에서 연관관계에 대응되는 해당 <code>Member</code> 엔티티 인스턴스들이 조회되면서 엔티티 내부에 있는 모든 정보들을 일괄적으로 함께 조회해야 되는 상황이 된다.<br /></p>
<p>이것을 구체화된 객체 참조가 아닌, <strong>프록시 객체</strong>로 대체해 최소한의 정보만을 제공하다가, <code>getter</code>, <code>toString()</code> 등을 통해 해당 필드의 구체적인 정보를 요구할 때 그제서야 쿼리를 발신하면서 실제 내용인 <code>List</code> 타입의 변수 내용이 반환된다.</p>
<blockquote>
<p><code>~ToMany</code> 어노테이션은 지연 로딩이 디폴트고, <code>~ToOne</code> 어노테이션은 즉시 로딩이 디폴트다.
디버깅 과정에서 찰나의 순간에 <em>Collecting data...</em> 라는 문구가 확인되는데, 프록시 객체를 실제 객체로 가져오는 과정에서 표기되는 문구로 추정</p>
<img width="956" alt="스크린샷 2024-12-07 오후 3 38 01" src="https://github.com/user-attachments/assets/8219542a-0cb3-499e-9710-7af83fcb0d79">
</blockquote>
<h4 id="3-n1-문제에-대한-고찰">(3) N+1 문제에 대한 고찰</h4>
<p><strong>지연 로딩의 관점</strong><br />
N+1 문제는 JPA, 나아가 ORM 설계상의 하자가 아니다. 지연 로딩을 통해 굳이 현시점에 호출이 불필요하나, 나중에 호출의 여지가 있는 연관관계 자식 데이터는 프록시 객체로 남겨뒀다가 호출할 때 실체화된 객체 호출 코드를 짬으로써 효율을 추구한다. 이게 곧 <strong>지연 로딩</strong>의 존재 의의라고 볼 수 있다.</p>
<p>다만, 상기의 내용을 우선하다보니 부모 데이터를 부르면서 동시에 자식 데이터의 내용들을 일괄 조회하려는 경우를 챙기지 못하는 것이다. 그렇기 때문에 fetch join 등이 N+1의 설계상 하자의 해결책이라기보다는 그저 개발 환경에 맞춰서 취사선택해야하는 전략 중 하나라는 관점이 옳을 것이다. 개발 상황에 따라 데이터 조회 전략이 다르기 때문이다.</p>
<p><strong>ORM의 관점</strong><br />
조금 더 생각해보자면, 객체지향에는 <strong>참조</strong>라는 개념이 있다. 하지만 데이터베이스에는 참조라는 개념이 없다. 그리고 ORM은 객체와 데이터베이스를 매핑하는 기술이다.</p>
<p>객체지향 주소를 바탕으로 참조값이 가리키는 객체를 알 수 있다. 즉, 해당 객체의 내부 내용(클래스의 필드나 메소드)들을 전부 끌고오지 않아도 주소만 알고 있으면 해당 객체로 넘어가서 구체화된 정보들을 확인할 수 있게 된다.</p>
<p>하지만 데이터베이스에는 참조라는 개념이 없이, 단순히 외래 키를 통해 해당 스키마의 필드가 어떤 부모 스키마의 필드에게 연관관계로써 종속됐는지 직접 확인(쿼리 호출)을 해야 되기 때문에 이것을 ORM 취지에 따라 객체와 데이터베이스 매핑을 이뤄내는 과정에서 생긴 이슈라고 볼 수 있다.</p>
<h2 id="3-해결-방안">3. 해결 방안</h2>
<h3 id="1-로딩-시점-변경-x">1) 로딩 시점 변경 (x)</h3>
<p>위의 설명에 따르면 지연 로딩이 마치 N+1 문제의 원인인 것처럼 보일 수 있다. 그렇다고 즉시 로딩으로 바꿔서 해결할 수 있을까?</p>
<pre><code class="language-java">@Entity
@Getter
@NoArgsConstructor
public class Team {

    // ...

    @OneToMany(mappedBy = &quot;team&quot;, fetch = FetchType.EAGER)
    private List&lt;Member&gt; members = new ArrayList&lt;&gt;();

    // ...</code></pre>
<p>지연 로딩을 즉시 로딩으로 설정을 바꾸고 디버깅을 해서 쿼리 출력 과정을 살펴본다.</p>
<table>
<thead>
<tr>
<th>지연 로딩</th>
<th>즉시 로딩</th>
</tr>
</thead>
<tbody><tr>
<td><img width="1440" alt="지연로딩" src="https://github.com/user-attachments/assets/216a937c-0af9-464f-abab-f128309fe219"></td>
<td><img width="1440" alt="즉시로딩은N+1해결책x" src="https://github.com/user-attachments/assets/f625a61a-db30-4a5b-917e-50a427c9dfac"></td>
</tr>
</tbody></table>
<p>보면 <code>Team</code> 엔티티 인스턴스의 내부 정보들을 조회해 올 때, 지연 로딩은 우선 <code>Team</code> 엔티티에 대응되는 team 스키마의 필드들을 호출하는 데에 그치지만, 즉시 로딩은 team 스키마 필드와 더불어 연관관계(데이터베이스 입장에서는 외래 키로써 부여된)를 맺고 있는 <code>Member</code> 엔티티 인스턴스들까지 한 번에 조회해 온다.</p>
<p>기존의 지연 로딩에서도 중복되는 쿼리 발신이 N+1 문제로 발생했었고 즉시 로딩이라고 나아지진 않는다. 결국 연관관계 필드의 로딩 시점을 조절하는 것만으로는 해결책이 될 수 없다.</p>
<h3 id="2-연관관계-방향-설정-x">2) 연관관계 방향 설정 (x)</h3>
<p>이제까지 확인했던 예제는 전부 <code>Team</code> 엔티티의 시선에서 <code>Member</code> 타입 필드 리스트를 조회하는 경우였다. 이를 반대로 <code>Member</code> 엔티티 입장에서 <code>Team</code>을 조회해보자.</p>
<pre><code class="language-java">@Entity
@Getter
@Setter
@NoArgsConstructor
public class Member {

    // ...

    @ManyToOne(cascade = CascadeType.ALL) // ~ToOne: EAGER
    @JoinColumn(name = &quot;team_id&quot;) // 외래 키(팀)를 관리하는 측(멤버)에게 부여
    private Team team;

    // ...</code></pre>
<pre><code class="language-java">// TeamMemberService

public void findAllMembersByMemberRepo() {
    List&lt;String&gt; list = memberRepository.findAll().stream()
            .map(member -&gt; member.getTeam().getName() + &quot;: &quot; + member.getName())
            .toList();

    System.out.println(&quot;결과: &quot; + list);
}</code></pre>
<img width="989" alt="방향조회변경도 해결책이아니다" src="https://github.com/user-attachments/assets/ba11f2e7-dcd7-40f9-af51-34756dbc7e81">

<p>애시당초 해결이 불가능하다. 이 방법은 오히려 쿼리의 <code>join</code> 문법 방향과 더 멀어지는 것이며, 모든 멤버를 조회하고(1) 특정 팀의 ID를 외래 키로 가진 멤버들을 조회(N)하는 방식이기 때문이다.</p>
<h3 id="3-fetch-join">3) Fetch Join</h3>
<h4 id="1-적용">(1) 적용</h4>
<p>아까 데이터베이스에 직접 SQL을 날려서 한 번의 호출로 연결된 테이블을 조회함으로써 원하는 모든 데이터를 조회할 수 있었다. 이때 쓰인 문법이 <code>join</code> 문법이다.</p>
<pre><code class="language-sql">select t.team_id, t.team_name, m.member_id, m.member_name from team t left join member m on t.team_id = m.team_id;</code></pre>
<p>위의 SQL문과 유사한 JPA의 <strong>JPQL(Java Persistence Query Language)</strong> 를 활용해서 한 번의 호출로 예상 결과를 호출할 수 있다. 이 문법을 <code>Fetch Join</code> 이라고 한다.</p>
<pre><code class="language-jpaql">SELECT t FROM Team t JOIN FETCH t.members</code></pre>
<pre><code class="language-java">@Repository
public interface TeamRepository extends JpaRepository&lt;Team, Long&gt; {
    @Query(&quot;SELECT t FROM Team t JOIN FETCH t.members&quot;)
    List&lt;Team&gt; findAllWithMembers();
}</code></pre>
<img width="989" alt="fetchjoin" src="https://github.com/user-attachments/assets/bbbe4638-3873-4862-aee7-631ac4a84f1a">

<p>위처럼 쿼리 호출이 단 한 번으로 예상했던 결과가 호출돼서 로그에 찍히는 것을 확인할 수 있다.</p>
<h4 id="2-jdbc와의-차이점">(2) JDBC와의 차이점</h4>
<p>여담이지만, JPQL을 쓰는 게 아닌 직접 SQL을 활용해서 JDBC로 해결할 수도 있다. 물론 이것은 hibernate가 작성하는 쿼리가 아니기 때문에 히카리풀 로그에 남지 않는다.</p>
<pre><code class="language-java">@Repository
@RequiredArgsConstructor
public class TeamJdbcRepository {

    private final JdbcTemplate jdbcTemplate;

    public List&lt;String&gt; findAll() {
        String sql = &quot;SELECT t.team_id, t.team_name, m.member_id, m.member_name &quot; +
                &quot;FROM team t &quot; +
                &quot;LEFT JOIN member m ON t.team_id = m.team_id&quot;;

        List&lt;String&gt; list = new ArrayList&lt;&gt;();

        jdbcTemplate.query(sql, rs -&gt; {
            String teamName = rs.getString(&quot;team_name&quot;);
            String memberName = rs.getString(&quot;member_name&quot;);

            list.add(teamName + &quot; :&quot; + memberName);
        });

        return list;
    }
}</code></pre>
<img width="995" alt="jdbc 테스트" src="https://github.com/user-attachments/assets/6af18b52-4062-471c-b1f5-00de39a67a64">

<p>다만 JPA를 활용하는 이상 JPQL을 기반으로 <code>Fetch Join</code>을 활용하는 것이 더 나아 보인다. JPA의 기능 중, 영속성 컨텍스트에 영속화하는 내용이 있는데 <code>Fetch Join</code>으로 조회된 연관관계는 영속성 컨텍스트의 1차 캐시에 저장되어 다시 엔티티 그래프를 탐색해도 조회 쿼리가 수행되지 않지만 그냥 JDBC로 <code>JOIN</code> 문법을 활용하는 것은 ORM과 무관하기 때문이다.</p>
<p>애시당초 ORM의 취지는 객체와 데이터베이스 간의 매핑을 통한 편의성 추구 및 패러다임 차이 간극을 줄이는 것이기 때문이다.</p>
<h4 id="3-페이징-관련">(3) 페이징 관련</h4>
<p><code>Fetch Join</code>이 완벽한 해결책은 아니다. 일련의 데이터들을 반환할 때 자주 사용되는 페이징을 같이 활용할 경우 문제가 발생할 수 있다.</p>
<pre><code class="language-java">@Query(&quot;SELECT t FROM Team t JOIN FETCH t.members&quot;)
Page&lt;Team&gt; findAllWithMembers(Pageable pageable);</code></pre>
<p>위와 같이 리스트가 아닌 페이징 객체를 반환하면서 JPQL을 같이 활용하는 것 자체에는 문법적으로나 실행 환경적으로나 문제가 없긴 하다. 다만 로그를 확인하면 기존의 결과에 더해 추가로 문구가 하나 더 뜬다.</p>
<img width="958" alt="fetchjoin과 페이징 병용 - 아웃 오브 메모리 위험" src="https://github.com/user-attachments/assets/13483613-95ef-4c9a-b3bc-2b3428f1a7df">

<pre><code class="language-bash">HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory</code></pre>
<p>이 문구의 의미는, <strong>페이징 처리를 메모리에서 하고 있다</strong>는 경고다. 왜 이런 문구가 발생하는지는 쿼리의 작동 의도와 실제 hibernate가 작성한 쿼리를 비교해야 한다.</p>
<p>먼저 일전의 그냥 <code>List</code> 반환과 <code>Page</code> 반환의 차이점부터 확인하자.</p>
<table>
<thead>
<tr>
<th>특징</th>
<th>List 반환</th>
<th>페이징(Pageable) 반환</th>
</tr>
</thead>
<tbody><tr>
<td><strong>데이터 반환 방식</strong></td>
<td>데이터베이스에서 반환된 데이터를 그대로 메모리에 로드.</td>
<td>데이터베이스에서 반환된 데이터를 메모리에 올린 후, 페이징 처리.</td>
</tr>
<tr>
<td><strong>페이징 처리 위치</strong></td>
<td>없음.</td>
<td>애플리케이션 레벨에서 메모리에 올린 데이터를 기준으로 페이징.</td>
</tr>
<tr>
<td><strong>메모리 사용량</strong></td>
<td>데이터베이스에서 반환된 데이터 크기와 동일.</td>
<td>데이터베이스에서 반환된 데이터 크기 + 페이징 처리로 인한 추가 메모리 사용.</td>
</tr>
<tr>
<td><strong>OOM 가능성</strong></td>
<td>데이터 크기가 너무 크면 발생 가능.</td>
<td>데이터 크기가 크지 않아도 추가 연산으로 인해 발생 가능성이 높음.</td>
</tr>
<tr>
<td><strong>중복 데이터 처리</strong></td>
<td>데이터베이스에서 반환된 중복 데이터를 그대로 유지.</td>
<td>JPA가 중복 데이터를 제거하며 메모리에 로드.</td>
</tr>
<tr>
<td><strong>데이터 크기 제한</strong></td>
<td>데이터 크기를 제한하지 않으면 전체 데이터를 가져옴.</td>
<td>Pageable을 적용했더라도 데이터베이스에서 전체 데이터를 가져옴(페이징 적용되지 않음).</td>
</tr>
</tbody></table>
<p>요약하자면, <code>List</code> 반환보다 <code>Page</code> 반환이 더 많은 메모리 사용량을 요구하는 것은 페이징 작업에 상당한 메모리 소비가 일어나기 때문이다.</p>
<p>애시당초 <code>JOIN</code> 작업은 상당한 메모리 소비를 일으키는데 그 이유는 부모 엔티티(<code>Team</code>) 기준만으로 조인 결과가 수행되지 않아서 그렇다. 자식 엔티티(<code>Member</code>)를 포함하기 때문에 부모 엔티티별 중복된 레코드가 반환된다.</p>
<p>여기서 Hibernate가 <code>Team</code>을 기준으로 중복 데이터를 제거하고 페이징을 시도하려 하는데 이때 전체 데이터를 메모리에 올려 처리하기 때문에 메모리 사용량이 급증하게 된다.</p>
<p>또한 페이징 사이즈에 따라 hibernate 쿼리가 둘 이상 출력되면서 N+1 문제 해결 목적과 어긋날 수 있다. 아래는 페이징 사이즈를 3 이하(부모 엔티티의 개수 이하)로 맞췄을 때의 로그다.</p>
<img width="964" alt="엔티티수이하로페이징사이즈를정하면카운트쿼리발생" src="https://github.com/user-attachments/assets/9dacf36c-6f13-41f3-843c-aeb899679d72">

<p>페이징 사이즈가 부모 엔티티의 개수와 같거나 작을 경우, Hibernate는 페이징 논리에 따라 <code>COUNT</code> 쿼리 + 데이터 조회 쿼리를 실행하게 된다. 그렇기에 굳이굳이 페이징을 사용한다면 부모 엔티티의 개수를 초과해서 페이징 사이즈를 정해야겠다만 원칙적으로 아웃 오브 메모리 예외가 발생할 수 있으니 권유하지 않는 것이다.</p>
<h4 id="3-연속된-1n-관계에서는-사용-불가">(3) 연속된 1:N 관계에서는 사용 불가</h4>
<img width="503" alt="연속된연관관계" src="https://github.com/user-attachments/assets/09cef5a3-e68d-42df-8462-79ff7bb366ea">

<p>엔티티 <code>One</code>이 엔티티 <code>Two</code>와 1:N 관계를 맺고, 엔티티 <code>Two</code>가 엔티티 <code>Three</code>와 1:N 관계를 맺는다. 여기서 <code>One</code> 부모 엔티티를 기반으로 그냥 반환하면 당연히 N+1 문제가 발생하게 된다.</p>
<img width="951" alt="연속N+1문제" src="https://github.com/user-attachments/assets/93a31fe9-21f9-4eea-adde-705fa24ae625">

<p>연속된 연관관계에서 각각의 부모 엔티티의 개수만큼 해당 엔티티 조회 쿼리가 생기게 되는 것을 볼 수 있는데, 이걸 JPQL의 <code>Fetch Join</code>을 여러 번 활용하면 되겠다고 생각할 수 있지만, 연속된 1:N 관계가 맺어진 최상위 부모 엔티티에 대하여 사용할 경우 예외를 반환하게 된다.</p>
<pre><code class="language-java">@Repository
public interface OneRepository extends JpaRepository&lt;One, Long&gt; {

    @Query(&quot;SELECT DISTINCT o FROM One o &quot; +
            &quot;LEFT JOIN FETCH o.twoList t &quot; +
            &quot;LEFT JOIN FETCH t.threeList th &quot; +
            &quot;ORDER BY o.id, t.id, th.id&quot;)
    List&lt;One&gt; findAllWithTwoAndThree();
}</code></pre>
<img width="979" alt="연속N+1페치조인문제발생" src="https://github.com/user-attachments/assets/862e23d9-69c8-42c6-9a37-0bcfd066c69d">

<p>위의 사진처럼 JPA가 내부적으로 <code>MultipleBagFetchException</code>을 감싸서 <code>InvalidDataAccessApiUsageException</code>으로 변환하게 된다. 즉, 래퍼 예외를 반환시킨다. Hibernate가 두 개 이상의 컬렉션(<code>twoList</code>, <code>threeList</code>)을 동시에 조인하려고 하면 SQL의 Cartesian Product(데카르트 곱) 형태로 생성된 <code>ResultSet</code>에서 어떤 데이터가 <code>twoList</code>에 속하고 어떤 데이터가 <code>threeList</code>에 속하는지 명확히 구분할 수 없다.</p>
<h4 id="4-복수의-1n-관계에서는-사용-불가">(4) 복수의 1:N 관계에서는 사용 불가</h4>
<img width="501" alt="복수N+1" src="https://github.com/user-attachments/assets/08dcec0a-6b0f-467e-8570-86915a015c8e">

<p>엔티티 <code>A</code>가 엔티티 <code>B</code>와 1:N 관계를 맺고, 엔티티 <code>A</code>가 엔티티 <code>C</code>와 1:N 관계를 맺는다. 여기서 <code>A</code> 부모 엔티티를 기반으로 그냥 반환하면 당연히 N+1 문제가 발생하게 된다.</p>
<img width="963" alt="복수일대다관계문제발생" src="https://github.com/user-attachments/assets/bc0182f9-17fb-4098-b4a9-39a7761a1e3f">

<p>이런 경우에도 JPQL의 <code>Fetch Join</code>을 여러 번 활용하면 되겠다고 생각할 수 있지만, 복수의 1:N 관계가 맺어진 부모 엔티티에 대하여 사용할 경우 예외를 반환하게 된다.</p>
<pre><code class="language-java">@Query(&quot;SELECT a FROM A a &quot; +
        &quot;LEFT JOIN FETCH a.bList b &quot; +   // A와 B를 FetchJoin
        &quot;LEFT JOIN FETCH a.cList c&quot;)     // A와 C를 FetchJoin
List&lt;A&gt; findAllWithBAndC();</code></pre>
<img width="956" alt="복수의1대다관계의페치조인은사용불가" src="https://github.com/user-attachments/assets/c4c936ac-746c-45fb-b8a7-f8368e12528a">

<p>위의 사진처럼 JPA가 내부적으로 <code>MultipleBagFetchException</code>을 감싸서 <code>InvalidDataAccessApiUsageException</code>으로 변환하게 된다. 즉, 래퍼 예외를 반환시킨다. Hibernate가 두 개 이상의 컬렉션(<code>bList</code>, <code>cList</code>)을 동시에 조인하려고 하면 중복된 결과가 생성될 수 있어서 이를 일부러 막는 것이다.</p>
<p>결국 <code>Fetch Join</code>이 N+1 문제의 완벽한 대응책은 아니다.</p>
<h3 id="4-entitygraph">4) EntityGraph</h3>
<h4 id="1-적용-1">(1) 적용</h4>
<p>N+1 문제를 해결할 수 있는 다른 방법으로 <code>@EntityGraph</code> 어노테이션이 있다. 코드와 실행 결과를 확인해보자.</p>
<pre><code class="language-java">@Repository
public interface TeamRepository extends JpaRepository&lt;Team, Long&gt; {
    @EntityGraph(attributePaths = &quot;members&quot;) // 함께 조회하려는 연관관계 필드 명시
    List&lt;Team&gt; findAll();
}</code></pre>
<img width="954" alt="entitygraph" src="https://github.com/user-attachments/assets/1fa65bf3-9180-4fb1-a6db-12696d6932d2">

<p><code>@EntityGraph</code>의 경우 페치 타입을 Eager로 변환, 즉 즉시 로딩하는 방식으로 <code>outer left join</code>을 수행하여 데이터를 가져오지만, <code>Fetch Join</code>의 경우 따로 <code>outer join</code>으로 명시하지 않는 경우 <code>inner join</code>을 수행한다는 점에서 차이가 있다.</p>
<h4 id="2-연속된-1n-관계에서의-사용-불가">(2) 연속된 1:N 관계에서의 사용 불가</h4>
<p>연속된 1:N 관계에서 사용할 수 없다.</p>
<img width="965" alt="스크린샷 2024-12-08 오전 4 15 40" src="https://github.com/user-attachments/assets/eb17f26f-e5ef-4788-8a9d-bf8c7cbea87d">

<h4 id="3-복수의-1n-관계에서는-사용-불가">(3) 복수의 1:N 관계에서는 사용 불가</h4>
<p>역시 복수의 1:N 관계에서 사용할 수는 없다.</p>
<pre><code class="language-java">@Repository
public interface ARepository extends JpaRepository&lt;A,Long&gt; {
    @EntityGraph(attributePaths = {&quot;bList&quot;, &quot;cList&quot;})
    List&lt;A&gt; findAll();
}</code></pre>
<img width="985" alt="복수의일대다는엔티티그래프도불가능" src="https://github.com/user-attachments/assets/36e1f16f-6862-4ca1-8f4d-529e95a07bdf">

<h3 id="5-batch-size-지정">5) Batch Size 지정</h3>
<p>부모 엔티티를 조회할 때 연관된 자식 데이터들까지 N번 조회하면서 추가적으로 발생하는 쿼리를 한 번에 묶어 실행하는 방식으로 동작한다. 여기서 <code>Batch Size</code>가 100으로 지정되면 최대 100개의 자식 엔티티를 한 번의 쿼리로 묶어서 조회한다.</p>
<img width="960" alt="배치사이즈테스트" src="https://github.com/user-attachments/assets/8e84c05c-5ac3-48ae-8da7-094a83cde7dc">

<p>위의 사진을 보면 쿼리가 2번 찍히는데, <code>Batch Size</code> 조정은 N에 해당하는 쿼리를 한 번으로 묶어주는 방식으로 동작하기 때문이다. 즉, 부모 엔티티를 조회하는 쿼리와 연관된 자식 엔티티들을 한 번에 불러모으는 쿼리를 실행해서 2번 찍히게 되는 것이다.</p>
<p>배치 사이즈 크기가 너무 크면 메모리 부담이, 너무 작으면 효율성이 떨어지기 때문에 적당한 값 선택이 필요하며 기본적으로 지연 로딩에 영향을 미치므로 즉시 로딩 전략에서는 효과를 기대하기 어렵다.</p>
<h3 id="6-트래픽-비교-테스트">6) 트래픽 비교 테스트</h3>
<p>아무런 조치를 취하지 않은 케이스, Fetch Join 케이스, EntityGraph 케이스, Batch Size 케이스까지 총 4가지에 대하여 일전의 <code>Team</code> 엔티티 기반으로 team 스키마와 member 스키마에서 리스트 내용을 조회하는 트래픽 테스트를 수행해본다. 테스트 조건 및 환경은 아래와 같다.</p>
<blockquote>
<ul>
<li>테스트 툴: Apache JMeter</li>
<li>가상 사용자수: 10000</li>
<li>램프 업 타임: 10</li>
<li>루프 카운트: 1</li>
</ul>
</blockquote>
<h4 id="1-기본-케이스">(1) 기본 케이스</h4>
<img width="836" alt="그냥" src="https://github.com/user-attachments/assets/e05cbdcc-76bc-4c0b-bd29-07ec5eccb9e2" />
<img width="49%" src="https://github.com/user-attachments/assets/28751609-33a1-46d6-9106-c601a39a695d" align="left" />
<img width="49%" src="https://github.com/user-attachments/assets/5257a1b4-bd04-42d4-9a22-cfd4285d72c4" align="right" />

<h4 id="2-fetch-join-케이스">(2) Fetch Join 케이스</h4>
<img width="840" alt="페치조인" src="https://github.com/user-attachments/assets/f427a62b-6b3f-4f6c-9a71-70f4d6323298" />
<img width="49%" src="https://github.com/user-attachments/assets/02f4e9a4-9914-47c4-93b4-98ff0ac8f9fd" align="left" />
<img width="49%" src="https://github.com/user-attachments/assets/e7d5c710-9d19-49d0-a2a6-244ccf71114c" align="right" />

<h4 id="3-entitygraph-케이스">(3) EntityGraph 케이스</h4>
<img width="839" alt="엔티티그래프" src="https://github.com/user-attachments/assets/b88d4d06-78af-4051-99ae-3c5b4ce50daa" />
<img width="49%" src="https://github.com/user-attachments/assets/9f4f64f6-ffbe-4a82-8d7e-e581008959e7" align="left" />
<img width="49%" src="https://github.com/user-attachments/assets/b52f73f2-2da0-472a-82da-cce378fdf709" align="right" />

<h4 id="4-batch-size-케이스">(4) Batch Size 케이스</h4>
<img width="836" alt="배치사이즈" src="https://github.com/user-attachments/assets/b4ee6f3c-e4c0-4f4b-b29d-0bd386c557b9" />
<div>
<img width="49%" src="https://github.com/user-attachments/assets/9fca8b3f-e336-4f37-b814-ba3d408a4ab1" align="left" />
<img width="49%" src="https://github.com/user-attachments/assets/ebc0f064-bf18-4fea-bdc4-6e5a41a5da28" align="right" />
  </div>


<h2 id="4-결론">4. 결론</h2>
<p>단일 부모 및 자식 엔티티 관계에서의 N+1 문제 해결책으로써는 적정하나, 복합적인 1:N 관계를 일괄 조회 처리하는 데에는 무리가 있는 해결책들로 보이므로 이 경우에는 JDBC를 활용하거나 QueryDSL 등을 활용하여 커스텀 쿼리를 작성해 활용하는 것이 올바른 해결책으로 작동할 것으로 보인다.</p>
<p>트래픽 제어의 관점에서는 N+1 문제 역시 대용량 트래픽 상황에서 성능 저하를 일으키는 요소 중 하나인 것을 확인했으며, 비즈니스 로직이 간편한 케이스에서 Fetch Join이 가장 높은 처리량을 보였다. 다만 지연시간 관점에서는 유의미한 결과가 보이지 않아서 실제 복잡한 케이스에서의 세부적인 비교가 필요한 것으로 보인다.</p>
<hr>
<p><em>스터디 레코드라 틀린 부분이 있을 수 있음</em>
<em>깃허브 링크 : <a href="https://github.com/kimD0ngjun/jpa/tree/main/src/main/java/com/example/jpa/nPlusOne">https://github.com/kimD0ngjun/jpa/tree/main/src/main/java/com/example/jpa/nPlusOne</a></em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 배치 활용 연습(3) - 성능 비교 테스트]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%ED%99%9C%EC%9A%A9-%EC%97%B0%EC%8A%B53</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%ED%99%9C%EC%9A%A9-%EC%97%B0%EC%8A%B53</guid>
            <pubDate>Fri, 22 Nov 2024 11:03:44 GMT</pubDate>
            <description><![CDATA[<h1 id="5-배치-처리-성능-비교">5. 배치 처리 성능 비교</h1>
<p>지난 포스팅에서 JPA 기반 및 JDBC 기반으로 스프링 배치 처리 코드를 구현해서 테이블 복사 작업을 수행할 수 있었다. 그리고 조금 더 상세한 성능 계측을 위해  <span style='background-color:#dcffe4'>데이터 개수를 10000개로 늘이고 JUnit 테스트 코드 작성</span>도 같이 해본다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/3e58e665-ab98-488f-a99f-900b72a93ef8/image.png" alt=""></p>
<p>데이터를 최종 11000개에 준하는 개수로 늘이고 테스트를 수행해보자.</p>
<h2 id="1-jpa-배치-처리-vs-jdbc-배치-처리">1) JPA 배치 처리 vs JDBC 배치 처리</h2>
<pre><code class="language-java">@Slf4j
@SpringBootTest
public class JdbcJpaPerformanceTest {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Autowired
    private JobLauncher jobLauncher;

    @Autowired
    private JobRegistry jobRegistry;

    private long executeBatchJob(String jobName) throws Exception {
        UUID parameter = UUID.randomUUID();

        JobParameters jobParameters = new JobParametersBuilder()
                .addString(&quot;date&quot;, parameter.toString())
                .toJobParameters();

        Job job = jobRegistry.getJob(jobName);

        long startTime = System.nanoTime();
        jobLauncher.run(job, jobParameters);
        long endTime = System.nanoTime();

        return endTime - startTime;
    }

    @BeforeEach
    public void setUp() {
        jdbcTemplate.execute(&quot;TRUNCATE TABLE AfterEntity;&quot;);
    }

    @AfterEach
    public void cleanup() {
        jdbcTemplate.execute(&quot;TRUNCATE TABLE AfterEntity;&quot;);
    }

    @DisplayName(&quot;JDBC 기반 배치 처리 실행시간 &lt; JPA 기반 배치 처리 실행시간&quot;)
    @Test
    public void test() throws Exception {
        // given &amp; when
        long jdbcBatchExecutionTime = executeBatchJob(&quot;jdbcFirstBatchJob&quot;);

        jdbcTemplate.execute(&quot;TRUNCATE TABLE AfterEntity;&quot;);

        long jpaBatchExecutionTime = executeBatchJob(&quot;firstJob&quot;);

        // then
        assertThat(jpaBatchExecutionTime)
                .describedAs(
                        String.format(
                                &quot;JDBC Batch 실행시간: %d nanoseconds, JPA Batch 실행시간: %d nanoseconds&quot;,
                                jdbcBatchExecutionTime, jpaBatchExecutionTime))
                .isGreaterThan(jdbcBatchExecutionTime);

    }
}</code></pre>
<p>동일 작업에 대하여 JDBC 배치 처리와 JPA 배치 처리를 수행한다.  <span style='background-color:#dcffe4'>불필요한 객체 생성, 캐싱, 매핑 등의 과정 없이 SQL 쿼리 실행과 결과 반환에 집중하고 직접 데이터베이스와 통신하기 때문에 JDBC 배치 처리가 훨씬 빠를 것으로 예상</span>하고 테스트 코드를 작성한 다음, 테스트를 수행했다. </p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/d80b9a76-cd0e-43cc-ae40-77d4f04b061c/image.png" alt=""></p>
<p>역시나 예상은 예상대로... 그래서 다음 시나리오를 구현하려다가 문득 든 궁금증</p>
<blockquote>
<p><strong>그래도 JPA 배치 처리가 JPA 기본제공 CRUD 메소드보단 빠르겠지..?</strong></p>
</blockquote>
<h2 id="2-jpa-배치-처리-vs-jpa-기본제공-메소드">2) JPA 배치 처리 vs JPA 기본제공 메소드</h2>
<p>JPA 기본제공 메소드를 테스트에서 활용하기 위해 <code>JpaRepository</code> 인터페이스 기반 두 엔티티의 DAO를 의존성 주입받아서 테스트 코드를 작성했다.</p>
<pre><code class="language-java">@Slf4j
@SpringBootTest
public class OrmBatchPerformanceTest {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Autowired
    private JobLauncher jobLauncher;

    @Autowired
    private JobRegistry jobRegistry;

    @Autowired
    private BeforeJpaRepository beforeJpaRepository;

    @Autowired
    private AfterJpaRepository afterJpaRepository;

    // JPA 기반 ORM 메소드 실행시간 계측 메소드
    private long executeOrmMethod() {
        long startTime = System.nanoTime();

        List&lt;BeforeEntity&gt; beforeEntities = beforeJpaRepository.findAll();
        List&lt;AfterEntity&gt; afterEntities =
                beforeEntities.stream().map(e -&gt; new AfterEntity(e.getUsername())).toList();

        // JPA 배치 처리의 메소드 호출과 조건을 동일시하기 위한 단일 저장 반복 처리
        for (AfterEntity afterEntity : afterEntities) {
            afterJpaRepository.save(afterEntity);
        }

        long endTime = System.nanoTime();

        return endTime - startTime;
    }

    // JPA 기반 배치 처리 실행시간 계측 메소드
    private long executeBatchJob() throws Exception {
        UUID parameter = UUID.randomUUID();

        JobParameters jobParameters = new JobParametersBuilder()
                .addString(&quot;date&quot;, parameter.toString())
                .toJobParameters();

        Job job = jobRegistry.getJob(&quot;firstJob&quot;);

        long startTime = System.nanoTime();
        jobLauncher.run(job, jobParameters);
        long endTime = System.nanoTime();

        return endTime - startTime;
    }

    @BeforeEach
    public void setUp() {
        jdbcTemplate.execute(&quot;TRUNCATE TABLE AfterEntity;&quot;);
    }

    @AfterEach
    public void cleanup() {
        jdbcTemplate.execute(&quot;TRUNCATE TABLE AfterEntity;&quot;);
    }

    @DisplayName(&quot;JPA 배치 처리 기반 실행시간 &lt; ORM 기반 실행시간&quot;)
    @Test
    public void test() throws Exception {
        long jpaMethodExecutionTime = executeOrmMethod();

        jdbcTemplate.execute(&quot;TRUNCATE TABLE AfterEntity;&quot;);

        long jpaBatchExecutionTime = executeBatchJob();

        // then
        assertThat(jpaMethodExecutionTime)
                .describedAs(
                        String.format(
                                &quot;ORM 메소드 실행시간: %d nanoseconds, JPA Batch 실행시간: %d nanoseconds&quot;,
                                jpaMethodExecutionTime, jpaBatchExecutionTime))
                .isGreaterThan(jpaBatchExecutionTime);
    }

}</code></pre>
<p>그래도 JPA 배치 처리가 훨씬 빠르겠지... 하면서 테스트를 돌려봤는데...</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/26a664cb-eb5f-45f5-8de3-806d3743a6b8/image.png" alt=""></p>
<p>????
JPA 기본제공 메소드로 테이블 복사한 작업이 JPA 배치 처리보다 근소하게 빠르다고 나왔다. 물론 데이터의 양이 더 많아지면 분명 차이는 생기겠지만 그래도 내 예상을 엇나간 것이 좀 놀라웠다.</p>
<pre><code class="language-java">// Batch Config

    @Bean
    public Step firstStep() {
        log.info(&quot;JPA: 첫 번쨰 스탭&quot;);

        return new StepBuilder(&quot;firstStep&quot;, jobRepository)
                .&lt;BeforeEntity, AfterEntity&gt;chunk(100, transactionManager)
                .reader(beforeReader())  // 읽기 메소드 파라미터
                // ...


        @Bean
    public RepositoryItemReader&lt;BeforeEntity&gt; beforeReader() {
        return new RepositoryItemReaderBuilder&lt;BeforeEntity&gt;()
                .name(&quot;beforeReader&quot;)
                .pageSize(100)  // findAll 메소드의 페이징 처리
                .methodName(&quot;findAll&quot;)
                .repository(beforeJpaRepository)
                .sorts(Map.of(&quot;id&quot;, Sort.Direction.ASC))  // 자원 낭비 방지용 sort
                .build();
    }</code></pre>
<p>청크 사이즈가 문제인가 해서,  <span style='background-color:#dcffe4'>배치 처리에서 <strong>페이지 조회 사이즈와 청크 사이즈를 100으로 늘이고</strong> 다시 테스트를 진행</span>해봤다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/1b3d5fca-b1dd-4a13-ace4-c4b437589b6f/image.png" alt=""></p>
<p>그 결과, 이번에는 테스트가 통과되는 것을 확인할 수 있었다. <span style='background-color:#dcffe4'>JPA 배치 처리에서는 데이터를 일정 단위(청크)로 읽고 처리해서 그 단위가 끝날 때마다 커밋</span>되는데 예를 들어, <span style='background-color:#dcffe4'>청크 사이즈를 100으로 설정하면, <strong>100개씩 처리하고 커밋</strong>함으로써 한 번에 너무 많은 데이터를 커밋하지 않고, 적당한 크기로 나누어 처리하기 때문에 <strong>데이터베이스에 부담을 줄여주는 것</strong>이기 때문에 성능이 향상된 것이 아닌가 추측</span>된다. 어찌됐든 현재 코드는 저장 단계에서 JPA 기본제공 메소드 <code>save()</code>를 사용하고 있으므로.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/256b572e-ce04-42ed-94a6-c27b81449d94/image.png" alt=""></p>
<p>그렇지만 <span style='background-color:#dcffe4'>청크 단위를 100으로 늘인 JPA 배치 처리여도 청크 단위가 10인 JDBC 배치 처리보다는 느린 성능</span>을 보이는 것은 매한가지였다.</p>
<h2 id="3-테스트-결과-정리">3) 테스트 결과 정리</h2>
<table>
<thead>
<tr>
<th>테스트 시나리오</th>
<th>성능이 더 좋은 방식</th>
</tr>
</thead>
<tbody><tr>
<td>청크 10개 JDBC <em>vs</em> 청크 10개 JPA</td>
<td>JDBC 배치처리</td>
</tr>
<tr>
<td>청크 10개 JPA <em>vs</em> JPA 기본제공 메소드</td>
<td>기본제공 메소드</td>
</tr>
<tr>
<td>청크 100개 JPA <em>vs</em> JPA 기본제공 메소드</td>
<td>JPA 배치처리</td>
</tr>
<tr>
<td>청크 10개 JDBC <em>vs</em> 청크 100개 JPA</td>
<td>JDBC 배치처리</td>
</tr>
</tbody></table>
<p>테스트를 통해 얻은 결론은 다음과 같다.</p>
<blockquote>
<p><strong>1. 청크 단위를 전략적으로 선택해야 한다.</strong></p>
</blockquote>
<p><strong>2. JPA는 배치 처리에 엄청 적합하진 않다.</strong></p>
<blockquote>
</blockquote>
<p><strong>3. 배치 처리를 사용한다면 JDBC를 활용하자.</strong></p>
<h1 id="6-배치-처리---테이블-업데이트">6. 배치 처리 - 테이블 업데이트</h1>
<p>RDBMS 영속성 내에서의 스프링 배치 처리 시나리오로 이번에는 단일 테이블 내에서 특정 필드에 따라 데이터를 업데이트하는 것을 일괄 처리해볼 예정이다.</p>
<p>시나리오 및 그에 따른 엔티티(WInEntity)는 다음과 같다.</p>
<blockquote>
<p><strong>WinEntity 테이블 내의 win 필드가 10 이상이면 reward 필드를 <code>true</code>로 업데이트한다.</strong></p>
</blockquote>
<pre><code class="language-java">@Entity
@Getter
@Setter
public class WinEntity {

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

    private String username;
    private Long win;
    private Boolean reward;
}</code></pre>
<p>사실 위의 테스트를 거친 결과 JDBC 기반 스프링 배치를 구현해야 하지만... 그래도 편의성이 좋은 JPA 기반으로 다시 스프링 배치를 구현했다.<del>(개념 학습하는 단계라고 나름 핑계를 대본다)</del></p>
<h2 id="1-jpa-기반-스프링-배치-구현">1) JPA 기반 스프링 배치 구현</h2>
<h3 id="1-코드-구현">(1) 코드 구현</h3>
<pre><code class="language-java">@Slf4j
@Configuration
@RequiredArgsConstructor
public class SecondJpaBatch {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;
    private final WinJpaRepository winJpaRepository;

    @Bean
    public Job secondJob() {
        log.info(&quot;win 엔티티 테이블 조건부 처리&quot;);

        return new JobBuilder(&quot;secondJob&quot;, jobRepository)
                .start(secondStep())
                .build();
    }

    /**
     * 단위 스뱁의 구성 : 읽기 -&gt; 처리 -&gt; 쓰기
     * 이 작업 순서 진행 단위가 chunk, 대량의 데이터를 얼만큼 끊어서 처리할 지
     * (너무 작으면 IO 처리 많아지면서 오버헤드, 너무 적으면 리소스 사용 비용 상승 및 실패 부담)
     */
    @Bean
    public Step secondStep() {
        log.info(&quot;두 번째 스탭&quot;);

        return new StepBuilder(&quot;secondStep&quot;, jobRepository)
                .&lt;WinEntity, WinEntity&gt; chunk(10, transactionManager)
                .reader(winReader())
                .processor(trueProcessor())
                .writer(winWriter())
                .build();
    }

    // WinEntity 테이블에서 읽어오는 Reader
    @Bean
    public RepositoryItemReader&lt;WinEntity&gt; winReader() {

        return new RepositoryItemReaderBuilder&lt;WinEntity&gt;()
                .name(&quot;winReader&quot;)
                .pageSize(10)
                .methodName(&quot;findByWinGreaterThanEqual&quot;)
                .arguments(Collections.singletonList(10L))  // WinEntity 의 win 필드가 10 이상인 데이터들 조회
                .repository(winJpaRepository)
                .sorts(Map.of(&quot;id&quot;, Sort.Direction.ASC))
                .build();
    }

    // 읽어온 데이터를 처리하는 Process
    @Bean
    public ItemProcessor&lt;WinEntity, WinEntity&gt; trueProcessor() {

        return item -&gt; {
            item.setReward(true);
            return item;
        };
    }

    // WinEntity 에 처리한 결과를 저장(Write)
    @Bean
    public RepositoryItemWriter&lt;WinEntity&gt; winWriter() {

        return new RepositoryItemWriterBuilder&lt;WinEntity&gt;()
                .repository(winJpaRepository)
                .methodName(&quot;save&quot;)
                .build();
    }
}</code></pre>
<p>코드 구현은 앞의 테이블 복사 시나리오 과정과 유사하기 때문에 별도의 설명은 생략한다. 다만 차이점이라면, <span style='background-color:#dcffe4'>Reader 단계에서 호출하는 JPA 메소드는 파라미터 인자가 필요하기 때문에 <code>arguments(Collections.singletonList(10L))</code> 코드가 추가</span>됐다는 것과, 단일 테이블 내에서만 이뤄지기 때문에 <code>trueProcessor()</code> 메소드의 제네릭 엔티티가 동일하다는 점이다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/c91455c2-9d7a-4593-85a1-b7c810d6c392/image.png" alt=""> win 필드가 다양하게 초기화되어있고, reward 필드는 전부 <code>false</code>로 정해지도록 데이터를 산입했다. 이제 우리가 할 배치 처리 작업은 <span style='background-color:#dcffe4'>win 필드가 10점 이상이면 <strong>reward 필드가 <code>true</code></strong>로 업데이트</span>하는 것이다. </p>
<h3 id="2-실행-결과">(2) 실행 결과</h3>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/a6b698e0-350a-4395-a5a1-98d76a8234ca/image.png" alt=""><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/198b48d9-35ca-4422-8ec7-b1242401eb76/image.png" alt=""></p>
<p>성공적으로 10점 이상인 win 필드를 가진 데이터는 reward가 <code>true</code>로 업데이트된 것을 확인할 수 있다.</p>
<hr>
<p>다음 포스팅부터는 RDBMS가 아닌 NoSQL(Redis, MongoDB) 영속성을 기반으로 스프링 배치를 구현해본다.</p>
<p><em>소스 코드</em>
<em><a href="https://github.com/kimD0ngjun/spring-batch-practice">https://github.com/kimD0ngjun/spring-batch-practice</a></em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 배치 활용 연습(2) - JPA 및 JDBC 배치 처리]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%ED%99%9C%EC%9A%A9-%EC%97%B0%EC%8A%B52</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%ED%99%9C%EC%9A%A9-%EC%97%B0%EC%8A%B52</guid>
            <pubDate>Fri, 22 Nov 2024 09:29:21 GMT</pubDate>
            <description><![CDATA[<h1 id="3-스프링-배치-구현-개요">3. 스프링 배치 구현 개요</h1>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/4474de77-0a86-4326-bbef-c9356598a566/image.png" alt=""></p>
<p>스프링 배치는 단순 스케줄러 개념이 아닌, 다양한 영속성과 통합하여 대용량 일괄 처리를 수행하는 프레임워크다. 여기서 말하는 영속성은 쉽게 말해서 저장소다.</p>
<p>대용량 일괄 처리를 실무적인 수준으로 고안하는 건 아직 내 수준에서는 어려워서... 일단은 간단하게 <span style='background-color:#dcffe4'>데이터베이스 테이블 간의 복사와 테이블 일괄 업데이트</span>를 구현해봤다. 현재는 영속성을 <strong>RDBMS</strong>로 두고 스프링 배치를 구현하지만, 차후의 포스팅에서는 NoSQL(아마 익숙한 Redis나 MongoDB가 될 것 같음)와의 통합 구현도 수행해 볼 예정이다.</p>
<h2 id="1-사전-준비">1) 사전 준비</h2>
<p>앞서 말했듯, 스프링 배치에는 <strong>메타데이터 저장소</strong> 구축도 필요하다. 만약 배치 처리 대상이 RDBMS이고, 메타데이터 저장소도 RDBMS로 선택한다면 <strong>멀티 데이터베이스 연결 설정</strong>을 해줘야 한다. 이외에도 기타 여러 설정들이 존재하기 때문에, 우선은 환경 설정부터 해본다.</p>
<p>참고로 나는, <span style='background-color:#dcffe4'>배치 처리 대상과 메타데이터 저장소를 둘 다 <strong>MySQL</strong>로 진행</span>한다.</p>
<h3 id="1-의존성-추가">(1) 의존성 추가</h3>
<pre><code class="language-java">dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-batch&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-data-jpa&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
    compileOnly &#39;org.projectlombok:lombok&#39;
    runtimeOnly &#39;com.mysql:mysql-connector-j&#39;
    annotationProcessor &#39;org.projectlombok:lombok&#39;
    testCompileOnly &#39;org.projectlombok:lombok&#39;
    testAnnotationProcessor &#39;org.projectlombok:lombok&#39;
    testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;
    testImplementation &#39;org.springframework.batch:spring-batch-test&#39;
    testRuntimeOnly &#39;org.junit.platform:junit-platform-launcher&#39;
}</code></pre>
<p>제일 중요한 <strong>스프링 배치 의존성</strong>을 추가하고, 개발에 용이한 롬복, JPA 활용을 위한 Spring Data JPA, 컨트롤러 기반 배치 처리 호출을 위한 Spring Web, MySQL 연결을 위한 의존성을 추가했다.</p>
<h3 id="2-applicationyml">(2) application.yml</h3>
<pre><code class="language-yml">spring:
  application:
    name: batch-practice

  batch:
    # prevent auto batch when app is initialized
    # to use endpoint or scheduling
    job:
      enabled: false

    # meta data table for managing batch job
    jdbc:
      initialize-schema: always
      schema: classpath:org/springframework/batch/core/schema-mysql.sql</code></pre>
<p><code>spring.batch.job.enabled</code>는 앱이 실행될 때 자동으로 배치 처리를 수행할 지를 여부를 정하기 때문에 나는 <code>false</code>로 설정했다. <code>spring.batch.jdbc</code>는 메타데이터 저장소의 테이블 관리를 맡으며 MySQL 기반 스크립트를 사용하고 스키마를 실행할 때마다 재생성하도록 설정한다.</p>
<pre><code class="language-yml">  # meta data DB info(like record)
  datasource-meta:
    driver-class-name: com.mysql.cj.jdbc.Driver
    jdbc-url: jdbc:mysql://localhost:3307/meta_db?useSSL=false&amp;useUnicode=true&amp;serverTimezone=Asia/Seoul&amp;allowPublicKeyRetrieval=true
    username: user
    password: password

  # process data DB info
  datasource-data:
    driver-class-name: com.mysql.cj.jdbc.Driver
    jdbc-url: jdbc:mysql://localhost:3308/data_db?useSSL=false&amp;useUnicode=true&amp;serverTimezone=Asia/Seoul&amp;allowPublicKeyRetrieval=true&amp;postfileSQL=true&amp;logger=Slf4JLogger&amp;maxQuerySizeToLog=999999&amp;rewriteBatchedStatements=true
    username: user
    password: password</code></pre>
<p>원래 스프링부트 앱은 디폴트가 하나의 RDBMS 연결을 상정하기 때문에 여러 개의 RDBMS 연결 설정을 하면 앱 실행이 안되기 때문에  <span style='background-color:#dcffe4'>멀티 데이터베이스 연결을 위한 설정 클래스를 별도로 작성</span>해야 한다. 일단은, 배치 처리 대상 데이터베이스 정보와 메타데이터 저장소 정보를 명시해둔다.</p>
<h2 id="2-다중-데이터베이스-연결">2) 다중 데이터베이스 연결</h2>
<h3 id="1-데이터베이스-연결-설정-클래스">(1) 데이터베이스 연결 설정 클래스</h3>
<pre><code class="language-java">/**
 * meta DB config for batch
 */
@Configuration
public class MetaDBConfig {

    @Primary  // @Primary 설정한 테이블에 테이블을 자동으로 메타데이터 테이블 생성
    @Bean
    @ConfigurationProperties(prefix = &quot;spring.datasource-meta&quot;)
    public DataSource metaSource() {
        return DataSourceBuilder.create().build();
    }

    @Primary
    @Bean
    public PlatformTransactionManager metaTransactionManager() {
        return new DataSourceTransactionManager(metaSource());
    }

}</code></pre>
<p>메타데이터 저장소 연결을 위해 yml 파일에서 설정한 설정 접두어를 properties로 빈 메소드에 부여한다.  <span style='background-color:#dcffe4'><code>@Primary</code> 어노테이션이 등록되면 해당 테이블에 자동으로 <strong>메타데이터 테이블</strong>을 생성</span>하게 된다.</p>
<pre><code class="language-java">/**
 * data DB config for batch
 */
@Configuration
@EnableJpaRepositories(
        basePackages = &quot;com.example.batchpractice.repository&quot;,
        entityManagerFactoryRef = &quot;dataEntityManager&quot;,
        transactionManagerRef = &quot;dataTransactionManager&quot;
)
public class DataDBConfig {

    @Bean
    @ConfigurationProperties(prefix = &quot;spring.datasource-data&quot;)
    public DataSource dataSource() {
        return DataSourceBuilder.create().build();
    }

    /**
     * JPA 사용한 데이터베이스 연결을 위한 EntityManagerFactory 설정하는 코드
     * JDBC 배치 처리와는 직접적인 연관 x
     */
    @Bean
    public LocalContainerEntityManagerFactoryBean dataEntityManager() {

        LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean();

        factoryBean.setDataSource(dataSource());
        factoryBean.setPackagesToScan(&quot;com.example.batchpractice.entity&quot;);
        factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());

        Map&lt;String, Object&gt; properties = new HashMap&lt;&gt;();
        properties.put(&quot;hibernate.hbm2ddl.auto&quot;, &quot;update&quot;);
        properties.put(&quot;hibernate.show_sql&quot;, true);

        factoryBean.setJpaPropertyMap(properties);
        return factoryBean;
    }

    @Bean
    public PlatformTransactionManager dataTransactionManager() {
        JpaTransactionManager transactionManager = new JpaTransactionManager();
        transactionManager.setEntityManagerFactory(dataEntityManager().getObject());

        return transactionManager;
    }

    @Bean
    public PlatformTransactionManager jdbcTransactionManager() {
        return new DataSourceTransactionManager(dataSource());  // JDBC 트랜잭션 관리
    }

    @Bean
    public JdbcTemplate jdbcTemplate() {
        return new JdbcTemplate(dataSource());
    }

}</code></pre>
<p>배치 처리 대상이 되는 데이터베이스 연결 설정에서 나는 JPA와 JDBC를 같이 구현하기 위해 둘의 연결 설정을 별도로 구현했다. 데이터 소스 생성 메소드를 빈으로 등록해서 각각의 트랜잭션 관리 메소드와 <code>JdbcTemplate</code>에 부여해줬다. 참고로 JPA는 <code>EntityManager</code>에 상호작용할 대상 데이터베이스 정보 명시가 필요하기 때문에 <code>EntityManagerFactory</code> 설정도 빈으로 추가했다.</p>
<h3 id="2-도커-컴포즈-세팅">(2) 도커 컴포즈 세팅</h3>
<p>사실 MySQL이 로컬에 세팅되어 있으면 의미없지만, 나는 도커 연습을 위해 도커 컴포즈로 배치 처리 대상 RDBMS와 메타데이터 저장소 RDBMS를 간단하게 컨테이너로 띄웠다. 배치 처리 연습이 목적이라서 볼륨 세팅은 따로 하지 않았다.</p>
<pre><code class="language-yml">services:
  meta-db:
    image: mysql:latest
    container_name: mysql-meta
    ports:
      - &quot;3307:3306&quot;
    environment:
      MYSQL_ROOT_PASSWORD: root_meta
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      MYSQL_DATABASE: meta_db

  data-db:
    image: mysql:latest
    container_name: mysql-data
    ports:
      - &quot;3308:3306&quot;
    environment:
      MYSQL_ROOT_PASSWORD: root_data
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      MYSQL_DATABASE: data_db</code></pre>
<p>여기까지 했으면 멀티 데이터베이스 소스 기반 앱 실행 준비가 끝났다. 이제, 스프링 배치 구현을 해보자.</p>
<h1 id="4-배치-처리---테이블-복사">4. 배치 처리 - 테이블 복사</h1>
<p>시나리오 및 그에 따른 임시 엔티티(BeforeEntity, AfterEntity)는 다음과 같다.</p>
<blockquote>
<p><strong>특정 테이블(BeforeEntity)의 모든 데이터를 다른 테이블(AfterEntity)에 그대로 복사한다.</strong></p>
</blockquote>
<pre><code class="language-java">@Entity(name = &quot;BeforeEntity&quot;)
@Getter
@Setter
public class BeforeEntity {

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

    private String username;
}</code></pre>
<pre><code class="language-java">@Entity(name = &quot;AfterEntity&quot;)
@Getter
@Setter
@NoArgsConstructor
public class AfterEntity {

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

    private String username;

    public AfterEntity(String username) {
        this.username = username;
    }
}</code></pre>
<p>BeforeEntity 테이블에 미리 데이터를 삽입하고, 비어 있는 AfterEntity에 복사가 이뤄질 거라서, 미리 BeforeEntity에 데이터를 산입시킨다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/5bf3a16e-0e87-4660-a9c9-7de168d778a0/image.png" alt=""></p>
<h2 id="1-jpa-기반-스프링-배치-구현">1) JPA 기반 스프링 배치 구현</h2>
<p>ORM 바탕의 인터페이스인 JPA는 스프링부트로 간단한 CRUD 구현에서 용이하게 쓰이는 편이다. JPA 구현체인 Hibernate를 통해 데이터베이스와의 객체 매핑을 실현한다. 자주 쓰이기 때문에 JPA 기반으로 스프링 배치를 먼저 구현해본다.</p>
<h3 id="1-코드-작성">(1) 코드 작성</h3>
<pre><code class="language-java">@Slf4j
@Configuration
@RequiredArgsConstructor
public class FirstJpaBatch {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;

    private final BeforeJpaRepository beforeJpaRepository;
    private final AfterJpaRepository afterJpaRepository;

    @Bean
    public Job firstJob() {
        log.info(&quot;JPA: before 엔티티 테이블 -&gt; after 엔티티 테이블 옮기기&quot;);

        return new JobBuilder(&quot;firstJob&quot;, jobRepository)  // 파라미터: 작업명 및 트래킹용 레포
                .start(firstStep())  // 스탭 파라미터
//                .next()  // 스탭 파라미터
                .build();
    }

    @Bean
    public Step firstStep() {
        log.info(&quot;JPA: 첫 번쨰 스탭&quot;);

        return new StepBuilder(&quot;firstStep&quot;, jobRepository)
                .&lt;BeforeEntity, AfterEntity&gt;chunk(10, transactionManager)
                .reader(beforeReader())  // 읽기 메소드 파라미터
                .processor(middleProcessor())  // 처리 메소드 파라미터
                .writer(afterWriter())  // 쓰기 메소드 파라미터
                .build();
    }

    @Bean
    public RepositoryItemReader&lt;BeforeEntity&gt; beforeReader() {
        return new RepositoryItemReaderBuilder&lt;BeforeEntity&gt;()
                .name(&quot;beforeReader&quot;)
                .pageSize(10)  // findAll 메소드의 페이징 처리
                .methodName(&quot;findAll&quot;)
                .repository(beforeJpaRepository)
                .sorts(Map.of(&quot;id&quot;, Sort.Direction.ASC))  // 자원 낭비 방지용 sort
                .build();
    }

    @Bean
    public ItemProcessor&lt;BeforeEntity, AfterEntity&gt; middleProcessor() {
        return item -&gt; {
            AfterEntity afterEntity = new AfterEntity();
            afterEntity.setUsername(item.getUsername());

            // 대응되는 AfterEntity 엔티티를 생성
            return afterEntity;
        };
    }

    @Bean
    public RepositoryItemWriter&lt;AfterEntity&gt; afterWriter() {
        return new RepositoryItemWriterBuilder&lt;AfterEntity&gt;()
                .repository(afterJpaRepository)
                .methodName(&quot;save&quot;)  // save 메소드
                .build();
    }
}</code></pre>
<blockquote>
<p><strong>(1) Reader</strong> 
BeforeEntity에서 모든 데이터를 읽어온다</p>
</blockquote>
<p><strong>(2) Processor</strong>
BeforeEntity에 대응되는 모든 데이터와 동일한 내용의 AfterEntity를 생성한다.</p>
<blockquote>
</blockquote>
<p><strong>(3) Writer</strong>
모든 AfterEntity를 저장한다.</p>
<p>각 메소드별 설명은 이전 포스팅에서 상세히 서술해뒀기 때문에 생략한다. </p>
<h3 id="2-배치-처리-실행-방법">(2) 배치 처리 실행 방법</h3>
<p>이제 이렇게 생성한  <span style='background-color:#dcffe4'>배치 작업(<code>&quot;firstJob&quot;</code>)을 실행하는 방법은 <strong>API 호출 실행</strong>과 <strong>스케줄러 기반 실행</strong></span>이 있다. 둘 다 실행해본 다음, 성능 테스트를 수행해보자.</p>
<h4 id="rest-api-호출-기반">REST API 호출 기반</h4>
<p>컨트롤러에 API 호출 메소드를 세팅하고, Job 파라미터를 직접 제공하면서 호출하는 방식이다.</p>
<pre><code class="language-java">@Controller
@RequestMapping(&quot;/jpa&quot;)
@RequiredArgsConstructor
public class JpaBatchController {

    // job 실행을 위한 의존성들
    private final JobLauncher jobLauncher;
    private final JobRegistry jobRegistry;

    @GetMapping(&quot;/first&quot;)
    public ResponseEntity&lt;?&gt; firstApi(@RequestParam(&quot;value&quot;) String value) throws Exception {

        JobParameters jobParameters = new JobParametersBuilder()
                .addString(&quot;date&quot;, value)
                .toJobParameters();

        jobLauncher.run(jobRegistry.getJob(&quot;firstJob&quot;), jobParameters);
        return new ResponseEntity&lt;&gt;(&quot;first batch complete for JPA&quot;, HttpStatus.OK);
    }

    // ...</code></pre>
<p>Job 실행을 위해 의존성으로 <code>JobLauncher</code>와 <code>JobRegistry</code>를 주입받아 컨트롤러 메소드에서 배치 처리 실행을 맡긴다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/6c424f26-ba37-46cf-87ee-8d135da1d4f2/image.png" alt=""><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/4274c67f-5a71-44a3-a8f2-9fc56c57a778/image.png" alt=""><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/cc45ee16-fdc4-4d9c-abed-7d5c0441eb62/image.png" alt=""></p>
<p>포스트맨으로 해당 API 엔드포인트를 호출한 결과, 배치 처리가 되면서 AfterEntity에 데이터가 복사된 것을 확인할 수 있다.</p>
<p>앞서 말했듯, 파라미터는 고유값이기 때문에 중복된 파라미터를 제공해 새로운 JobInstance를 생성하려 하면 예외를 반환시킨다. 이를 한 번 확인해보자. 아까 위에서 제공한 파라미터는 <strong>문자열 a</strong>이다. 여기서 똑같은 파라미터를 제공해서 배치 처리를 실행해본다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/0678193b-e0ef-4298-9d79-0718d2d16dc9/image.png" alt=""><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/0a3e219a-64c5-4d9c-be13-8105e32fe319/image.png" alt=""></p>
<p>보다시피 예외를 반환하는 것을 확인할 수 있다. 즉, 파라미터는 고유값을 항상 제공해야 배치 처리가 반복 실행될 수 있다. 아래처럼 <strong>문자열 b</strong>라는 고유값을 파라미터로 제공하면 실행이 된다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/13683841-5f4f-4a95-8e97-9ebc6c02586c/image.png" alt=""></p>
<h4 id="스케줄러-기반">스케줄러 기반</h4>
<p>사실 배치 처리 실행은 스케줄러가 더 큰 의미가 있는데, 보통 JobInstance의 파라미터값으로 중복되지 않는 값을 부여해야 하는데 생각할 수 있는 대표적인 것이 <strong>날짜</strong>이기 때문이다. 날짜는 계속 시간 단위로 변하기 때문에 절대로 중복될 수 없으며 반복 실행에도 부합한 파라미터다.</p>
<p>일단 <code>Truncate Table AfterEntity</code> 명령어로 테이블을 전부 비우고 ID 순서 조정을 한 다음, 스케줄러를 구현하자.</p>
<pre><code class="language-java">@Slf4j
@Configuration
@RequiredArgsConstructor
public class FirstSchedule {

    private final JobLauncher jobLauncher;
    private final JobRegistry jobRegistry;

    @Scheduled(cron = &quot;10 * * * * *&quot;, zone = &quot;Asia/Seoul&quot;)  // 매 분 10초마다 해당 배치 실행
    public void firstSchedule() throws Exception {
        log.info(&quot;first schedule start&quot;);

        SimpleDateFormat dateFormat = new SimpleDateFormat(&quot;yyyy-MM-dd-hh-mm-ss&quot;);
        String date = dateFormat.format(new Date());

        JobParameters jobParameters = new JobParametersBuilder()
                .addString(&quot;date&quot;, date)
                .toJobParameters();

        jobLauncher.run(jobRegistry.getJob(&quot;firstJob&quot;), jobParameters);
    }

}</code></pre>
<p>크론식을 써서 매 10초가 되면 해당 배치 처리가 실행되도록 스케줄러를 세팅했다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/6a2b28ca-a7d4-42aa-90a3-4630e7892d50/image.png" alt=""><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/c2074adf-1055-4516-8c22-3a79f2a7ed0b/image.png" alt=""></p>
<p>별도의 실행 명령이 없어도 자동으로 10초가 되면 배치 처리가 이뤄지는 것을 확인할 수 있다.</p>
<h3 id="3-메타데이터-저장소-확인">(3) 메타데이터 저장소 확인</h3>
<p>아까 설정에서 MySQL로 메타데이터 저장소를 구축했었다. 메타데이터 저장소에 실제로 파라미터와 작업명이 저장되는 지를 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/3914b830-3989-4fb1-a567-438ceb643016/image.png" alt=""></p>
<p>보다시피 메타데이터 테이블이 MySQL 스크립트 기반으로 생성되어 있으며,  <span style='background-color:#dcffe4'>JobInstance의 작업명 및 해시화된 파라미터 키가 저장</span>되어 있는 것을 확인할 수 있다. 이것을 통해 효율적인 배치 처리 관리가 가능해진다.</p>
<h2 id="2-jdbc-기반-스프링-배치-구현">2) JDBC 기반 스프링 배치 구현</h2>
<p>JPA와 JDBC의 가장 큰 차이점은, SQL에 얼마나 관여하는가의 차이일 것이다. 객체와 데이터베이스의 매핑을 실현하는 JPA의 가장 큰 장점이 SQL 관여도를 낮춤으로써 비즈니스 로직에 집중하는 데에 반면 JDBC는 SQL문까지 개발자가 직접 건드려야 한다.</p>
<p>근데 이것을 반대로 생각하면, <span style='background-color:#dcffe4'>SQL문의 책임이 ORM으로 인해 넘겨져서 리소스 소모가 그만큼 가중돼 성능이 저하되는 것과 같고, <strong>대용량 처리가 전제되는 배치 처리에서는 JDBC가 더 유리</strong></span>할 거라는 결론을 낼 수 있다. 그래서 이번에는 JDBC로 동일 시나리오를 구현하고 둘의 성능 비교를 간략히 해본다.</p>
<h3 id="1-레포지토리-코드-구현">(1) 레포지토리 코드 구현</h3>
<p><code>JpaRepository</code> 인터페이스를 기반으로 간단히 DAO를 구현하는 JPA와 달리, JDBC는 직접 DAO를 세팅해야 한다. BeforeEntity와 AfterEntity의 JDBC DAO는 아래처럼 작성했고, 나는 오프셋 페이징 조회와 배치 저장을 활용했다.</p>
<pre><code class="language-java">@Repository
@RequiredArgsConstructor
public class BeforeJdbcRepository {

    private static final String SQL
            = &quot;SELECT id, username FROM BeforeEntity ORDER BY id LIMIT ? OFFSET ?&quot;;

    private final JdbcTemplate jdbcTemplate;

    @Transactional
    public List&lt;BeforeEntity&gt; findAll(int pageSize, int offset) {
        return jdbcTemplate.query(SQL,
                (rs, rowNum) -&gt; {
                    BeforeEntity entity = new BeforeEntity();
                    entity.setId(rs.getLong(&quot;id&quot;));
                    entity.setUsername(rs.getString(&quot;username&quot;));
                    return entity;
                },
                pageSize, offset // 가변 인자 형태로 전달
        );
    }
}</code></pre>
<pre><code class="language-java">@Repository
@RequiredArgsConstructor
public class AfterJdbcRepository {

    private static final String SQL
            = &quot;INSERT INTO AfterEntity (id, username) VALUES (?, ?)&quot;;

    private final JdbcTemplate jdbcTemplate;

    @Transactional
    public void save(AfterEntity afterEntity) {
        jdbcTemplate.update(SQL, afterEntity.getId(), afterEntity.getUsername());
    }

    @Transactional
    public void batchSave(List&lt;? extends AfterEntity&gt; afterEntities) {
        jdbcTemplate.batchUpdate(SQL, afterEntities, afterEntities.size(),
                (ps, afterEntity) -&gt; {
                    ps.setLong(1, afterEntity.getId());
                    ps.setString(2, afterEntity.getUsername());
                });
    }

}</code></pre>
<h3 id="2-배치-코드-구현">(2) 배치 코드 구현</h3>
<pre><code class="language-java">@Slf4j
@Configuration
@RequiredArgsConstructor
public class FirstJdbcBatch {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;

    private final BeforeJdbcRepository beforeJdbcRepository;
    private final AfterJdbcRepository afterJdbcRepository;

    @Bean
    public Job jdbcBatchJob() {
        log.info(&quot;JDBC: before 엔티티 테이블 -&gt; after 엔티티 테이블 옮기기&quot;);

        return new JobBuilder(&quot;jdbcFirstBatchJob&quot;, jobRepository)
                .start(jdbcBatchStep())
                .build();
    }

    @Bean
    public Step jdbcBatchStep() {
        log.info(&quot;JDBC: 첫 번쨰 스탭&quot;);

        return new StepBuilder(&quot;jdbcFirstBatchStep&quot;, jobRepository)
                .&lt;BeforeEntity, AfterEntity&gt;chunk(10, transactionManager)
                .reader(jdbcReader())   // Reader
                .processor(jdbcProcessor()) // Processor
                .writer(jdbcWriter())   // Writer
                .build();
    }

    // Reader: 데이터를 페이지 단위로 읽어오는 로직
    @Bean
    public ItemReader&lt;BeforeEntity&gt; jdbcReader() {
        return new ItemReader&lt;&gt;() {
            private int offset = 0; // 현재 오프셋
            private List&lt;BeforeEntity&gt; entities; // 읽어온 엔티티 리스트
            private int currentIndex = 0; // 현재 인덱스 (한 페이지에서 읽은 데이터 내 인덱스)

            @Override
            public BeforeEntity read() {
                // 한 번에 데이터를 읽어오고, 다음에 계속 처리할 수 있도록
                if (entities == null || currentIndex &gt;= entities.size()) {
                    int pageSize = 10;
                    entities = beforeJdbcRepository.findAll(pageSize, offset); // 새로운 페이지 데이터 읽기
                    if (entities.isEmpty()) {
                        return null; // 더 이상 데이터가 없을 경우 null 반환 (Spring Batch 종료 조건)
                    }
                    offset += pageSize; // offset 증가
                    currentIndex = 0; // 인덱스를 처음으로 초기화
                }
                return entities.get(currentIndex++); // 하나씩 순차적으로 반환
            }
        };
    }

    // Processor: 데이터를 변환 (BeforeEntity -&gt; AfterEntity)
    @Bean
    public ItemProcessor&lt;BeforeEntity, AfterEntity&gt; jdbcProcessor() {
        return item -&gt; {
            AfterEntity afterEntity = new AfterEntity();
            afterEntity.setId(item.getId()); // ID를 그대로 유지
            afterEntity.setUsername(item.getUsername()); // Username 그대로 복사
            return afterEntity;
        };
    }

    // Writer: 배치 저장
    @Bean
    public ItemWriter&lt;AfterEntity&gt; jdbcWriter() {
        return chunk -&gt; {
            // Chunk 에서 AfterEntity 리스트 추출
            List&lt;? extends AfterEntity&gt; items = chunk.getItems();

            // 배치 저장 호출
            afterJdbcRepository.batchSave(items); // Batch Insert 호출
        };
    }
}</code></pre>
<blockquote>
<p><strong>(1) Reader</strong> 
BeforeEntity에서 오프셋 페이징 방식으로 데이터를 읽어온다</p>
</blockquote>
<p><strong>(2) Processor</strong>
BeforeEntity에 대응되는 모든 데이터와 동일한 내용의 AfterEntity를 생성한다.</p>
<blockquote>
</blockquote>
<p><strong>(3) Writer</strong>
모든 AfterEntity를 청크 단위로 저장한다.</p>
<h3 id="3-실행-결과-확인-및-jpa-배치-처리와의-비교">(3) 실행 결과 확인 및 JPA 배치 처리와의 비교</h3>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/56f7fee3-43da-4003-9f3b-85e6535ef045/image.png" alt=""><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/4dea0a00-68b1-440b-a450-58e1f1d3095e/image.png" alt=""></p>
<p>동일 조건에서 JDBC와 JPA 배치 처리를 실행하고 시간을 비교한 결과  <span style='background-color:#dcffe4'>JDBC 기반 배치 처리가 근소하게 앞섰다.</span> 아직 데이터베이스에 데이터가 100개 밖에 없어서 10000개로 늘이고 다시 확인해야겠다.</p>
<hr>
<p>포스팅이 너무 길어진 관계로 다음 포스팅에서는 JUnit 기반으로 실제 실행시간을 측정하고, 테이블 일괄 수정 시나리오를 해본다.</p>
<p><em>소스 코드</em>
<em><a href="https://github.com/kimD0ngjun/spring-batch-practice">https://github.com/kimD0ngjun/spring-batch-practice</a></em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 배치 활용 연습(1) - 개념 정리]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%ED%99%9C%EC%9A%A9-%EC%97%B0%EC%8A%B51</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%ED%99%9C%EC%9A%A9-%EC%97%B0%EC%8A%B51</guid>
            <pubDate>Fri, 22 Nov 2024 05:21:55 GMT</pubDate>
            <description><![CDATA[<h1 id="1-개요">1. 개요</h1>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/e81f2eca-e9df-41f5-8045-f7c37f92bb22/image.png" alt=""></p>
<p>챌린지 팀 프로젝트에서 대용량 데이터를 다룰 때, 해당 데이터가 저장된 데이터베이스, 정확히는 RDBMS에서 순식간에 모든 데이터의 필드 값들이 업데이트되는 작업이 빈번했다. 당시야 백엔드의 B도 몰라서 열심히 노가다를 했었는데 얼마 전에 스프링 관련 공부를 하다가 <span style='background-color:#dcffe4'><strong>스프링 배치</strong></span>에 대하여 알게 됐다.</p>
<p>키워드는 이미 들어봐서 예전에는 단순 스케줄러 느낌으로 받아들였는데, 생각 이상으로 다양한 기능 및 영속성과의 통합성을 제공하며 대용량 처리에 있어 아주 유용한 프레임워크라는 생각이 들었다. 호기심 반, 스킬 확장 반으로 무작정 뛰어들어봤다.</p>
<h2 id="1-배치-프로세싱batch-processing">1) 배치 프로세싱(Batch Processing)</h2>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/c8f7f879-0c3d-4d0d-b4ac-9dcb7e3b3cf1/image.png" alt=""></p>
<p>배치(Batch)라는 영단어는 <strong>일괄</strong>, <strong>집단</strong>이라는 의미를 가진다. 그리고 스프링 배치가 수행하는 대규모 데이터의 일괄 처리 기능을 <strong>배치 처리(배치 프로세싱)</strong>이라고 하며, 이 배치 처리를 가지고 활용하는 프레임워크를 <strong>배치 프레임워크</strong>라 한다.</p>
<p>배치 프로세싱을 통해 얻을 수 있는 이점은 다음과 같다.</p>
<blockquote>
<h4 id="1-대용량-데이터-반복-처리-효율화">1. 대용량 데이터 반복 처리 효율화</h4>
<p>설계 취지가 대용량 데이터 반복 처리의 효율성 향상이기 때문에 데이터 백업, 필터링, 정렬, 청구, 급여 처리, 월말 조정 등에서 강점을 보인다.</p>
</blockquote>
<h4 id="2-비용-및-노동력-절감">2. 비용 및 노동력 절감</h4>
<p>이른바 &#39;배치 기간&#39; 동안 데이터를 일괄 처리함으로써, 조직은 지속적인 사람의 개입 없이도 컴퓨팅 리소스의 활용 선택 범위를 넓힐 수 있게 된다.</p>
<h4 id="3-데이터-일관성-및-무결성-향상">3. 데이터 일관성 및 무결성 향상</h4>
<p>배치 처리로 여러 데이터 트랜잭션을 단일 배치로 결합하여 개별적인 처리 트랜잭션과 관련된 오버헤드를 줄여주기 때문에 처리 능력, 메모리 및 기타 컴퓨팅 리소스 사용 효율성이 증가한다.</p>
<h4 id="4-데이터-변환-및-강화">4. 데이터 변환 및 강화</h4>
<p>각각의 배치 처리에 미리 정의된 규칙, 계산 또는 변환 집합을 적용하여 데이터를 변환하고 보강할 수 있으므로, 원시 데이터에서 의미 있는 인사이트를 추출하고 데이터를 정제하거나 정규화하여 활용도를 높인다.</p>
<h4 id="5-확장성-및-에러-핸들링">5. 확장성 및 에러 핸들링</h4>
<p>데이터 일괄 처리의 크기를 쉽게 조정하고 워크로드에 따라 규모를 늘리거나 줄일 수 있어서 다양한 데이터 처리 수요에 능동적이다. 또한 오류 로깅 및 예외 처리 메커니즘을 제공한다.</p>
<h2 id="2-배치-처리-vs-스트림-처리">2) 배치 처리 vs 스트림 처리</h2>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/8b60d5fb-7545-4c44-9ad5-68ffc8952cd6/image.png" alt=""></p>
<p>사실 자바 코드를 다루면서 뭔가 비슷한 애를 본 느낌이 들었는데, 자바 8 버전에서 추가됐던 스트림이었다. 스트림의 목적인 <span style='background-color:#dcffe4'>데이터 처리 및 변환</span>이 배치 처리와 일부 겹치는 느낌이었다. 물론 파이프라인을 구축해서 즉각적인 데이터 레코드에 반응하는 확실한 차이점을 알고 있었지만 조금 더 정확한 개념 비교를 위해 내용을 비교해봤다.</p>
<h4 id="차이점-비교">차이점 비교</h4>
<table>
<thead>
<tr>
<th><strong>특징</strong></th>
<th><strong>배치 처리 (Batch Processing)</strong></th>
<th><strong>스트림 처리 (Stream Processing)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>시간 민감도</strong></td>
<td>데이터를 일정 기간 동안 모아서 처리 (예: 시간별, 일별, 주별).</td>
<td>데이터가 발생하는 즉시 처리, 실시간 분석 및 반응에 적합.</td>
</tr>
<tr>
<td><strong>지연 시간 (Latency)</strong></td>
<td>데이터를 모은 후 처리하므로 <strong>지연 시간이 길다</strong>.</td>
<td>개별 데이터나 이벤트를 즉시 처리하므로 <strong>지연 시간이 짧다</strong>.</td>
</tr>
<tr>
<td><strong>데이터량 (Volume)</strong></td>
<td>한 번에 <strong>대량의 데이터</strong>를 처리하도록 설계됨.</td>
<td>지속적으로 들어오는 <strong>고속 데이터 스트림</strong> 처리에 적합.</td>
</tr>
<tr>
<td><strong>복잡성 (Complexity)</strong></td>
<td>데이터를 한꺼번에 처리하기 때문에 복잡한 변환, 집계, 계산이 가능.</td>
<td>개별 데이터 단위나 짧은 시간 내의 데이터만 처리하므로 간단한 연산과 실시간 반응에 초점.</td>
</tr>
</tbody></table>
<h4 id="사용-사례별-비교">사용 사례별 비교</h4>
<table>
<thead>
<tr>
<th><strong>사용 사례</strong></th>
<th><strong>배치 처리 예시</strong></th>
<th><strong>스트림 처리 예시</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>로그 분석</strong></td>
<td>로그 데이터를 모아 하루나 일주일 단위로 보고서 생성.</td>
<td>로그를 실시간으로 분석하여 오류 발생 시 즉시 감지.</td>
</tr>
<tr>
<td><strong>금융 거래</strong></td>
<td>하루가 끝난 후 거래 내역을 정산하거나 보고서 생성.</td>
<td>실시간으로 이상 거래를 감지하고 경고.</td>
</tr>
<tr>
<td><strong>ETL (추출-변환-적재)</strong></td>
<td>데이터 웨어하우스로 대량 데이터 마이그레이션 및 변환.</td>
<td>스트리밍 데이터를 실시간으로 데이터베이스에 적재하거나 대시보드로 전달.</td>
</tr>
<tr>
<td><strong>추천 시스템</strong></td>
<td>구매 기록 등 과거 데이터를 기반으로 추천 모델 학습.</td>
<td>사용자가 실시간으로 활동할 때 즉각적인 상품 추천 제공.</td>
</tr>
<tr>
<td><strong>IoT 애플리케이션</strong></td>
<td>수집된 IoT 센서 데이터를 일정 기간 동안 분석하여 트렌드 도출.</td>
<td>온도 변화나 모션 데이터를 실시간으로 감지하여 즉시 알림.</td>
</tr>
</tbody></table>
<p>요약하자면, <span style='background-color:#dcffe4'><strong>배치 처리</strong>는 누적된 데이터들의 일괄적인 처리를 위해 수집을 기다리는 과정에서 <strong>지연 시간</strong>이 생기지만 <strong>복잡한 계산 및 변환</strong>이 가능</span>하며 <span style='background-color:#dcffe4'><strong>스트림 처리</strong>는 상대적으로 볼륨이 작은 고속 데이터 스트림 처리에 집중하여 이벤트성으로 동작하기 때문에 <strong>실시간성</strong>에 특화</span>됐다고 볼 수 있다.</p>
<h1 id="2-스프링-배치-개념">2. 스프링 배치 개념</h1>
<h2 id="1-스프링-배치-아키텍처">1) 스프링 배치 아키텍처</h2>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/0bd65d40-587d-48c4-8136-01393e8264b8/image.png" alt=""></p>
<p>자바 기반의 스프링 배치 이전(C, C#, 코볼 등등..)에도 웬만한 배치 프레임워크는 위의 청사진을 따랐다고 봐도 무방하다. 이후의 다양한 배치 프레임워크도 위와 같은 아키텍처를 기반으로 설계됐다. 스프링 배치와 관련돼서 우선 숙지할 키워드는 <strong>Job</strong>, <strong>Step</strong>, 그리고 <strong><code>Reader-Processor-Writer</code> 구조</strong>다. 각각의 관계는 아래의 문장으로 설명할 수 있다.</p>
<blockquote>
<p><strong>배치 처리의 단위인 Job은 여러 Step으로 구성되며, Step은 <code>Reader-Processor-Writer</code> 구조로 구성되어 있다.</strong></p>
</blockquote>
<p>이게 무슨 말인지 각 키워드들에 대해 파악해보자.</p>
<h2 id="2-job">2) Job</h2>
<p>실무적인 배치 처리는 상당히 복잡한 여러 과정들이 얽히고 섥혀있다. 그래서 더욱 중요한 것이 <span style='background-color:#dcffe4'>배치 처리 작업의 단위화를 통해 구별해야 하는데, 이 단위가 곧 <strong>Job</strong></span>이 된다. 조금 더 IT스럽게(?) 표현하자면 Job은 <strong>단위 배치 프로세스를 캡슐화한 엔티티</strong>가 된다. 후술할 Step이 조금 더 실체화된 개념에 가깝고 Job은 그런 Step들을 모아 묶어주는 인스턴스 컨테이너 개념에 가깝다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/04b5959f-a2ef-47c3-8192-ee2fafe7dfa6/image.png" alt=""></p>
<blockquote>
<ul>
<li>Job : <strong>특정 작업</strong></li>
</ul>
</blockquote>
<ul>
<li>JobInstance : <strong>며칠에 혹은 반복적으로</strong> 진행될 특정 작업</li>
<li>JobExecution : 며칠에 혹은 반복적으로 <strong>몇 번째</strong>에 진행될 특정 작업 </li>
</ul>
<p>이런 Job도 결국 인스턴스나 실행 개념을 가지고, 스프링 배치에서는 이를 JobInstance와 JobExecution으로 구현했다. 위의 그림을 보면 정리할 수 있다. 이런 식으로 계층을 세분화하는 이유는 나중에 등장할 <strong>메타데이터 저장</strong>을 위해서다.</p>
<pre><code class="language-java">@Bean
public Job footballJob(JobRepository jobRepository) {
    return new JobBuilder(&quot;footballJob&quot;, jobRepository)
                     .start(playerLoad())
                     .next(gameLoad())
                     .next(playerSummarization())
                     .build();
}</code></pre>
<p>스프링 배치는 <strong><code>JobBuilder</code></strong>를 활용해서 <code>Job</code>을 빌드하면서 해당 Job의 명칭을 정해줄 수 있다. 그리고 해당 명칭을 통해 배치 처리를 수행할 때, 내가 원하는 작업을 호출할 수 있는데, <strong><code>JobRegistry</code></strong>를 통해 가져올 수 있다. 아래 코드처럼 가지고 올 수 있다.</p>
<pre><code class="language-java">private final JobRegistry jobRegistry;

// ...

jobLauncher.run(jobRegistry.getJob(&quot;footballJob&quot;), jobParameters);</code></pre>
<p>앞서 말했듯 Job은 인스턴스화된 개념인 JobInstance가 존재한다. 이것 역시 개념에 불과하기 때문에 코드로 다루거나 직접 관여할 일은 없다. 하지만, <span style='background-color:#dcffe4'>특정 Job(시급 산정을 예로 들자)에 있어서 3월 1일에 진행한 배치 처리 작업과 3월 2일에 진행한 배치 처리 작업은 다르게 취급되어야 한다.</span> 바로 이것이 JobInstance의 존재 이유가 되고, 기록의 필요 근거가 된다.</p>
<p>이것을 그럼 스프링 배치에서는 어떻게 구별하냐면, 바로 <strong><code>JobParameter</code></strong>를 통해서다.</p>
<pre><code class="language-java">JobParameters jobParameters = new JobParametersBuilder()
        .addString(&quot;date&quot;, value)
        .toJobParameters();</code></pre>
<p>JobParameter는 외부에서 부여된 중복되지 않는 값을 해시 알고리즘으로 고유 해시값을 생성해서 각 JobInstance를 구별하는 Key 역할을 하게 된다. 이 내용들은 메타데이터 저장소에서 확인이 가능하다. 이렇게 파라미터를 부여해서 <strong><code>JobLauncher</code></strong>를 통해 스프링 배치 처리의 단위 작업을 실행할 수 있게 된다.</p>
<h2 id="3-step">3) Step</h2>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/07a92f97-5f82-46ae-a857-290b4625f8d9/image.png" alt=""></p>
<p>Step은 <span style='background-color:#dcffe4'>배치 Job의 독립적이고 순차적인 단계를 캡슐화한 도메인 객체</span>다. 정확히 말하자면, 실제 배치 처리의 모든 정보를 담고 순차화한 것이다. Step의 범위는 단순 처리에서부터 복잡한 비즈니스 로직까지 다양하게 정할 수 있기 때문에 그 개념의 범위는 모호한 편이다. 개발자에 따라 Job을 Step처럼 쓸 수도 있고 Step을 여러 개로 나눠 Job을 논리적으로 세분화할 수도 있다.</p>
<pre><code class="language-java">@Bean
public Step firstStep() {
    log.info(&quot;JPA: 첫 번쨰 스탭&quot;);

    return new StepBuilder(&quot;firstStep&quot;, jobRepository)
            .&lt;BeforeEntity, AfterEntity&gt;chunk(10, transactionManager)
            .reader(beforeReader())  // 읽기 메소드 파라미터
            .processor(middleProcessor())  // 처리 메소드 파라미터
            .writer(afterWriter())  // 쓰기 메소드 파라미터
            .build();
}</code></pre>
<p>스프링 배치에서 Step은 JobBuilder 기반의 메서드 체이닝에서 파라미터로 들어가는 빈 메소드로 생성이 되며, 그 재부 구조는 Reader-Processor-Writer 단계의 파라미터를 요구하게 된다. <strong><code>StepBuilder</code></strong>를 통해 Step을 생성할 수 있다.</p>
<h2 id="4-reader-processor-writer">4) Reader-Processor-Writer</h2>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/c3729036-c72f-4c75-8807-181b97c34a61/image.png" alt=""></p>
<p>논리적으로 대규모의 데이터를 처리하려면, <strong>처리 대상인 데이터를 확인(읽기)</strong>하고, <strong>해당 데이터를 처리</strong>한 다음, <strong>처리된 데이터를 업데이트(쓰기)</strong>하는 단계를 가지게 될 것이다. 그 구조를 아키텍처로써 명시한 것이 <strong><code>Reader-Processor-Writer</code></strong> 형태가 되는 것이며 스프링 배치에서도 이를 채택하고 있다.</p>
<p>여기서 나오는 개념이 <strong>Chunk</strong>(이하 청크)인데, 청크는 <span style='background-color:#dcffe4'>배치 처리에서 데이터를 읽고 처리하고 쓰는 기본 단위</span>가 된다. 한 번에 모든 데이터를 읽고 처리하고 쓰면 이론상 빠르겠지만 메모리 사용량과 트랜잭션 처리량이 엄청 높아지면서 성능이 저하되기 때문에 끊어서 처리하는 단위인 청크가 필요한 것이다.</p>
<p>청크 단위가 너무 작으면 I/O 처리가 많아지고 오버헤드가 발생할 수 있고, 너무 크면 적재 및 자원 사용에 대한 비용과 실패시 부담이 커지기 때문에 프로젝트 상황에 맞춰 적절한 청크 단위 지정이 요구된다.</p>
<pre><code class="language-java">/**
 * BeforeEntity 테이블에서 읽어오는 Reader
 * JPA 기반 쿼리 수행이므로 RepositoryItemReader 사용
 */
@Bean
public RepositoryItemReader&lt;BeforeEntity&gt; beforeReader() {
    return new RepositoryItemReaderBuilder&lt;BeforeEntity&gt;()
            .name(&quot;beforeReader&quot;)
            .pageSize(10)  // findAll 메소드의 페이징 처리
            .methodName(&quot;findAll&quot;)
            .repository(beforeJpaRepository)
            .sorts(Map.of(&quot;id&quot;, Sort.Direction.ASC))  // 자원 낭비 방지용 sort
            .build();
}

/**
 * 읽어온 데이터를 처리하는 Process
 * (큰 작업을 수행하지 않을 경우 생략 가능, 지금과 같이 단순 이동은 사실 필요 없음)
 */
@Bean
public ItemProcessor&lt;BeforeEntity, AfterEntity&gt; middleProcessor() {
    return item -&gt; {
        AfterEntity afterEntity = new AfterEntity();
        afterEntity.setUsername(item.getUsername());

        // 대응되는 AfterEntity 엔티티를 생성
        return afterEntity;
    };
}

/**
 * AfterEntity 테이블에 처리한 결과를 저장하는 Writer
 */
@Bean
public RepositoryItemWriter&lt;AfterEntity&gt; afterWriter() {
    return new RepositoryItemWriterBuilder&lt;AfterEntity&gt;()
            .repository(afterJpaRepository)
            .methodName(&quot;save&quot;)  // save 메소드
            .build();
}</code></pre>
<p>스프링 배치에서는 <strong><code>ItemReader</code></strong>, <strong><code>ItemProcessor</code></strong> <strong><code>ItemWriter</code></strong> 인터페이스를 제공하며, 이 인터페이스를 구현하여 커스터마이징한 청크를 구축할 수 있다. 구체적으로 우리가 흔히 생각하는 <span style='background-color:#dcffe4'>RDBMS에서의 배치 처리 뿐만 아니라, NoSQL, 메세지 큐, 엑셀 파일 등의 다양한 영속성의 청크를 구현</span>할 수 있게 된다. 이 세 개의 인터페이스 빌더 빈 메소드들은 Step의 메서드 체이닝 파라미터로 제공돼서 단일 Step을 이루게 된다.</p>
<h2 id="5-메타데이터-저장소">5) 메타데이터 저장소</h2>
<p>아까 위의 JobInstance의 구분에서 잠깐 말했는데, 스프링 배치는 모든 Job 및 그 내부의 Step의 실행을 기록한다. 그 실행을 기록하는 곳이 메타데이터 저장소가 된다. 실제로 메타데이터 저장소에 접근하면 이제까지 실행됐던 배치 작업들과 관련하여 상세히 기록되어 있다.</p>
<p>이는 <span style='background-color:#dcffe4'>작업 상태를 정확하게 추적하고, 실패한 작업에 대하여 이전 상태를 참조해서 재시작을 지원해주고 정보를 조회하게 하는 등, 메타적인 부분에 대한 지원</span> 때문이다. 모든 JobInstance를 구분하는 파라미터 역시 메타데이터 저장소에 기록되어 있기 때문에 중복된 파라미터를 부여하면 배치 처리가 실행되지 않는 것이다.</p>
<hr>
<p>지금까지 알아본 내용들은 배치 처리 및 스프링 배치와 관련된 개념적인 부분이고, 다음 포스팅에서는 JPA 및 JDBC 환경 하에서 실제로 스프링 배치를 활용해 구현해본다.</p>
<p><em>출처</em>
<em><a href="https://spring.io/projects/spring-batch">https://spring.io/projects/spring-batch</a></em>
<em><a href="https://www.digitalroute.com/resources/glossary/batch-processing/">https://www.digitalroute.com/resources/glossary/batch-processing/</a></em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GitHub Actions + AWS EC2 + Docker + Nginx 기반 Blue/Green 무중단 배포 구축(2)]]></title>
            <link>https://velog.io/@kim00ngjun_0112/GitHub-Actions-AWS-EC2-Docker-Nginx-%EA%B8%B0%EB%B0%98-BlueGreen-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-%EA%B5%AC%EC%B6%952</link>
            <guid>https://velog.io/@kim00ngjun_0112/GitHub-Actions-AWS-EC2-Docker-Nginx-%EA%B8%B0%EB%B0%98-BlueGreen-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-%EA%B5%AC%EC%B6%952</guid>
            <pubDate>Wed, 30 Oct 2024 15:26:47 GMT</pubDate>
            <description><![CDATA[<p><em>이전 글을 읽고 해당 글을 읽으셔야 합니다!</em>
<em><a href="https://velog.io/@kim00ngjun_0112/GitHub-Actions-AWS-EC2-Docker-Nginx-%EA%B8%B0%EB%B0%98-BlueGreen-%EB%AC%B4%EC%A4%91%EB%8B%A8%EB%B0%B0%ED%8F%AC-%EA%B5%AC%EC%B6%951">https://velog.io/@kim00ngjun_0112/GitHub-Actions-AWS-EC2-Docker-Nginx-%EA%B8%B0%EB%B0%98-BlueGreen-%EB%AC%B4%EC%A4%91%EB%8B%A8%EB%B0%B0%ED%8F%AC-%EA%B5%AC%EC%B6%951</a></em></p>
<h1 id="4-빌드--배포-개요">4. 빌드 &amp; 배포 개요</h1>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/6557081e-2239-4c92-aa7b-3f203368ed71/image.png" alt=""></p>
<p>간략한 모식도는 <span style='background-color:#dcffe4'>WAS 빌드 과정(도커 이미지화 및 푸시)을 거치고 EC2 인스턴스에 배포(도커 이미지 풀 및 트래픽 스위칭)</span>를 통한 <strong>CI/CD 파이프라인</strong> 구축을 통해 무중단 배포를 실현한다. 그리고 이 과정을 <strong>GitHub Actions</strong>에서 스크립트로 작성한다.</p>
<p>그렇기 위해서 일단 배포의 대상이 되는 <strong>SpringBoot WAS</strong>를 간단하게 구현하고 배포 자동화에 참여할 수 있도록 <strong>설정 파일</strong>을 세팅해야 한다. 우선 스프링부트 기반으로 앱 서버를 간단히 구축해보자.</p>
<h1 id="5-springboot-was-구현--도커-추가-세팅">5. SpringBoot WAS 구현 &amp; 도커 추가 세팅</h1>
<p>해당 포스트는 자바, 스프링 문법을 기술하는 글이 아니므로 관련 내용은 생략한다. 일단 내가 배포하려는 WAS는 나의 백엔드 기술 스택에 맞춰 <strong>스프링부트</strong>를 기반으로 간단하게 구현한다. 핵심은 <span style='background-color:#dcffe4'>WAS의 실시간 업데이트에도 배포 서버가 중단되지 않고도 버전 업데이트가 이뤄지는 것을 확인</span>하는 점이다.</p>
<h2 id="1-의존성-및-코드">1) 의존성 및 코드</h2>
<p>정말 간단한 WAS여서 의존성도 2~3개에, 코드도 컨트롤러 코드 밖에 없다. 포스트맨 등을 활용해서 확인할 수도 있지만, 배포 서비스를 가정하기 위해서 직접 웹 브라우저에서 확인하기 위한 타임리프 의존성을 추가했다.</p>
<pre><code class="language-java">// gradle dependency...

dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-thymeleaf&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
    compileOnly &#39;org.projectlombok:lombok&#39;
    annotationProcessor &#39;org.projectlombok:lombok&#39;
    testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;
    testRuntimeOnly &#39;org.junit.platform:junit-platform-launcher&#39;
}</code></pre>
<pre><code class="language-java">// main controller code

@Controller
public class WebController {

    @Value(&quot;${server.env}&quot;)
    private String env;

    @Value(&quot;${server.port}&quot;)
    private String port;

    @Value(&quot;${serverName}&quot;)
    private String serverName;

    @Value(&quot;${common.message}&quot;)
    private String commonMessage;

    @GetMapping(&quot;/&quot;)
    public String index(Model model) {
        model.addAttribute(&quot;commonMessage&quot;, commonMessage);
        model.addAttribute(&quot;port&quot;, port);
        model.addAttribute(&quot;serverName&quot;, serverName);

        return &quot;index&quot;;
    }

    @GetMapping(&quot;/env&quot;)
    public ResponseEntity&lt;?&gt; env() {
        return ResponseEntity.ok(env);
    }

    // ...

}</code></pre>
<p>WAS는 마음대로 구현하면 되지만, <span style='background-color:#dcffe4'>일단 내 방식의 블루/그린 무중단 배포 구현에 있어 <code>env()</code> 컨트롤러 메소드 및 관련 필드는 필수적으로 포함</span>시킨다. 후술하겠지만, 어느 컨테이너에 해당하는 서버 애플리케이션인지를 확인하기 위함이다. 자세한 내용은 <strong>WAS 설정 파일(yml)</strong>과 관련해서 추가 설명한다.</p>
<h2 id="2-설정-파일-작성">2) 설정 파일 작성</h2>
<p>구현 방식에 있어 중요도의 비중은 천차만별이지만, 내 방식을 기반으로 블루/그린 무중단 배포를 구현한다면 앱 기능 구현보다 더 중요한 게 WAS의 설정 관리다. 그리고 여기서 말하는 설정 파일은 <span style='background-color:#dcffe4'><code>src/main/resources</code> 경로에 위치한 <strong><code>application.yml</code></strong></span>이다. 무중단 배포가 실제로 구축됐는지 확인하기 위해 블루/그린 컨테이너에 할당된 포트별로 설정을 다르게 해서 내용을 출력하게 한다. 내용은 다르게 작성해도 되지만 <span style='background-color:#dcffe4'><strong><code>server.port</code></strong>와 <strong><code>server.address</code></strong>는 명시</span>해야 한다. <span style='background-color:#dcffe4'>WAS 역시 도커 컨테이너에 띄워서 동작</span>시키기 위해서다.</p>
<p>아래는 설정 파일이다. 나는 별도로 구별 없이 <strong>개발 환경, 블루 환경, 그린 환경, 공통 환경</strong>을 한 곳에 작성했지만, 설명을 위해 따로 분리해서 서술한다(참고로 단일 파일 내에서 설정을 분리하는 방법은 설정 단위 사이에 <code>---</code>를 입력하면 된다).</p>
<pre><code class="language-yml"># application.yml for proflie setting

spring:
  application:
    name: cicd-practice

  profiles:
    active: local  # spring:profiles:active local? blue? green?
    group:
      local: local, common
      blue: blue, common
      green: green, common

server:
  env: blue</code></pre>
<p>WAS의 개발 환경을 포괄하는 기본적인 내용을 담으며, 여기서 <span style='background-color:#dcffe4'>프로필 지정</span>이 이뤄진다. 프로필 지정이라 함은, 앱 서버가 동작하기 위한 환경을 지정하는 것을 의미한다. <span style='background-color:#dcffe4'><code>spring.profiles.group</code>을 보면 각각의 프로필이 명시됐고, 그 프로필이 그룹으로 묶여있다.</span> 즉, 저 의미는 평상시 로컬(개발 환경)로 동작하는 것은 그냥 따라가되, 모든 프로필에서 공통 환경까지 포함해서 해당하는 환경의 내용을 가져가도록 명시해둔 것이다.</p>
<p><span style='background-color:#dcffe4'><code>server.env</code>가 왜 엉뚱한 곳에 있는지, 왜 blue라는 값으로 할당됐는지 궁금할 수 있는데, 이는 조금 있다가 GitHub Actions를 작성할 때 같이 설명한다. 내 방식에서는 중요한 변수이자, 구동 환경의 이정표 역할을 하는 <strong>변수</strong>다.</span></p>
<pre><code class="language-yml"># local
spring:
  config:
    activate:
      on-profile: local

server:
  port: 8080
  address: localhost

serverName: local_server</code></pre>
<p>개발 환경에서는 포트를 8080번을 할당하고, 주소를 로컬호스트로 지정했다. 또한 임의로 구동되는 서버의 명칭을 local_server로 정했다. <span style='background-color:#dcffe4'>별도의 의존성이 없는 상태인 앱 서버를 개발 환경에서 (IDE 등을 통해) <strong>곧바로 구동(jar 파일 기반)</strong></span>시키게 된다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/267612e9-c13a-4508-bcc9-3c2a03c5ee21/image.png" alt=""></p>
<p>참고로 나는 타임리프를 써서 브라우저에서 설정 내용을 확인할 수 있도록 추출해서 컨트롤러 변수에 담았다. 개발 환경에서 구동된 화면을 보면 IP가 localhost에 포트 번호가 8080번으로 할당됐으며, 서버 네임이 local_server로 찍히는 것을 확인할 수 있다.</p>
<hr>
<pre><code class="language-yml"># blue
spring:
  config:
    activate:
      on-profile: blue

server:
  port: 8080
  address: 0.0.0.0

serverName: blue_server

---

# green
spring:
  config:
    activate:
      on-profile: green

server:
  port: 8081
  address: 0.0.0.0

serverName: green_server</code></pre>
<p>블루 환경과 그린 환경에 대해서는 포트 번호를 각각 8080번과 8081번을 할당하고 서버 네임을 각 색을 지닌 명칭으로 정했다. 이를 통해 배포 환경에서의 웹 화면에 접속하면 현재 어떤 환경이 구동되고 있는지 확인할 수 있을 것이다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/73a62ba4-ec7d-41bf-a19a-0a344423ef4a/image.png" alt=""></p>
<p><code>server.address</code>가 <code>0.0.0.0</code>, 즉 모든 네트워크로부터의 수신을 열어둔 이유는 <span style='background-color:#dcffe4'>해당 WAS가 EC2 인스턴스에서 직접 구동되는 것이 아닌 <strong>도커 컨테이너를 통해 동작</strong></span>하기 때문이다. 일반적인 컨테이너 환경에서 내부 네트워크의 모든 요청을 수신할 수 있도록 정한 것이다. </p>
<p><code>server.address</code> 지정을 잘못하면 일단 배포가 정상적으로 이뤄지지 않을 뿐더러 직접 EC2 인스턴스에서 구동해도 <code>BindException</code>이 발생하기에 중요하게 작성한다. 만약 도커를 기반으로 동작하지 않는다면 해당 EC2 인스턴스의 private IP로 지정해야 안전할 것이다.</p>
<pre><code class="language-yml"># common
spring:
  config:
    activate:
      on-profile: common

common:
  message: Same regardless of settings</code></pre>
<p>이것은 중요한 설정은 아니지만, 블루 환경이든 그린 환경이든 공통적으로 적용될 사항을 직접 명시해서 메세지로써 확인하기 위해 내용을 추가해뒀다. 혹은 스프링 시큐리티 등을 통한 중요한 키값이나 변수 등을 보관할 때 별개의 프로필을 지정해서 추가할 수도 있을 것이다.</p>
<h2 id="3-도커-추가-세팅">3) 도커 추가 세팅</h2>
<p>EC2 인스턴스와 스프링부트 WAS에 각각 도커 관련해서 추가 세팅이 필요하다. 우선 <span style='background-color:#dcffe4'>WAS에는 해당 앱이 도커 이미지화를 할 수 있도록 <strong>Dockerfile</strong>을 작성</span>한다. 해당 파일의 위치는 프로젝트 소스 패키지에 위치시키면 된다.</p>
<pre><code class="language-docker">FROM openjdk:17
ARG JAR_FILE=build/libs/*.jar

# 두 ARG는 ec2 도커 컴포즈에 세팅된 변수 값을 따라가게 됨
ARG PROFILES
ARG ENV

COPY ${JAR_FILE} app.jar
ENTRYPOINT [&quot;java&quot;, &quot;-DSpring.profiles.active=${PROFILES}&quot;, &quot;-DServer.env=${ENV}&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]</code></pre>
<p><code>ENTRYPOINT</code>에서, 도커파일에 지정된 두 개의 변수를 바탕으로 설정 프로필을 변경하고 지정값을 바꾼다. 지정값을 바꾸는 것에 대해서는 아래에서 후술한다. 이어 <span style='background-color:#dcffe4'>EC2 인스턴스에서 멀티 컨테이너 관리(즉, 신 버전 컨테이너 실행)를 위한 <strong>Docker-Compose</strong>를 작성</span>한다. 나 같은 경우는 blue의 yml과 green의 yml을 작성해서 별도로 배포 관리를 수행했다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/4043e74b-2185-4da2-8a45-9ea13f3a3110/image.png" alt=""></p>
<p><span style='background-color:#dcffe4'>WAS에 작성한 도커파일에 지정한 두 개의 변수(<code>ARG</code>)의 지정이 바로 이 도커 컴포즈</span>에서 이뤄진다. 그리고 이 도커 컴포즈의 실행은 아래에서 후술할 <strong>깃헙 액션의 워크플로우 스크립트에서 담당</strong>한다.</p>
<hr>
<p>이렇게 WAS와 관련된 내용을 마무리하고 마지막으로 GitHub Actions의 워크플로우 스크립트를 작성한다.</p>
<h1 id="6-github-actions-도입">6. GitHub Actions 도입</h1>
<p>지금까지 작성한 내용은 <strong>자동화 처리를 위한 일종의 장비</strong>를 배치한 셈이다. 장비가 배치됐기 때문에 이제 자동으로 장비들을 순차적으로 작동하게 하기 위한 <strong>대본</strong> 작성을 해야되는데, 나의 무중단 배포에서 이 대본 역할을 <strong>GitHub Actions</strong>가 맡게 된다.</p>
<h2 id="1-github-actions-개념">1) GitHub Actions 개념</h2>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/11166b54-f04c-4560-bb84-384ba77c4235/image.png" alt=""></p>
<p>GitHub Actions는 GitHub 리포지토리에서 <strong>자동화된 워크플로우</strong>를 생성하고 관리할 수 있는 기능이다. 이를 통해 <strong>CI/CD(Continuous Integration/Continuous Deployment) 파이프라인</strong>을 설정하여 <span style='background-color:#dcffe4'>코드 변경 시 자동으로 빌드, 테스트, 배포 등의 작업을 수행</span>한다. 보통 <code>yml</code> 형식의 파일을 사용하여 워크플로우 스크립트를 작성하며, 깃허브 레포와의 호환성이 매우 뛰어나다.</p>
<p><strong>Jenkins</strong>, <strong>CircleCI</strong>, <strong>Travis CI</strong> 등의 CI/CD 툴들도 존재하지만, 깃허브 레포와의 호환성 및 템플릿 기반으로 동작 명시의 간편화 등을 활용하기 위해 깃헙 액션을 활용했다.</p>
<h2 id="2-github-actions-워크플로우-스크립트">2) GitHub Actions 워크플로우 스크립트</h2>
<h3 id="1-스크립트-생성-및-경로-지정">(1) 스크립트 생성 및 경로 지정</h3>
<p><span style='background-color:#dcffe4'><code>yml</code> 스크립트의 경로는 보통 <code>.github/workflows</code>에 위치</span>한다. 별도로 디렉토리를 만들어도 되지만 버튼 클릭 한번으로도 충분하다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/c318e326-1233-4c99-82be-de4ed8a85d79/image.png" alt=""></p>
<p>배포하려는 깃헙 레포의 Actions 탭에서 저 set up 어쩌고 버튼을 클릭하면 본인만의 워크플로우를 스크립트로 작성할 수 있도록 <code>yml</code> 파일이 생성된다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/55259c91-c6c6-4bba-8bbf-a3a67a2f37fa/image.png" alt=""></p>
<hr>
<p><span style='background-color:#dcffe4'>워크플로우 스크립트는 간단한 블루/그린 무중단 배포여도 이벤트 트리깅에 따라 배포 환경을 번갈아 선택할 수 있도록 코드 로직에 대한 고려</span>가 있어야 한다. 그렇기 때문에 개인적으로 가장 어려운 부분이었으며 아직도 완벽하게 이해는 되지 않았으나 몇 번의 실패를 통해 얻은 나름의 내용으로 복습 겸 기억하고자 작성한다.</p>
<p>일단, CI/CD의 자동화는 다음 순서로 이뤄진다.</p>
<blockquote>
<ol>
<li>빌드 : 애플리케이션의 소스 코드를 컴파일하고 실행 가능하도록 패키지 처리</li>
<li>배포 : 빌드한 패키지를 프로덕션 환경에 설치해 실행</li>
</ol>
</blockquote>
<p>보통 빌드 과정에 테스트도 포함되며 워크플로우 스크립트는 순서 단위에 맞춰 각 스탭을 코드로 작성해서 CI/CD 파이프라인의 내용을 단계별로 명시한다. 즉, <span style='background-color:#dcffe4'>빌드와 관련돼서 단계별로 작성 후, 배포와 관련해서 단계별로 작성하는 것이 워크플로우 스크립트의 작성 방법</span>이다.</p>
<h3 id="2-스크립트-작성---개요">(2) 스크립트 작성 - 개요</h3>
<p>이제 워크플로우 스크립트를 보자. 해당 양이 상당히 많기 때문에 전부를 서술하지는 않고, 부분부분 중요한 내용에 비중을 맞춰 설명한다.</p>
<pre><code class="language-yml">name: CICD

# event trigger: main push or main pull request
on:
  push:
    branches: [ &quot;main&quot; ]
  pull_request:
    branches: [ &quot;main&quot; ]

# read permit
permissions:
  contents: read</code></pre>
<p>스프링부트 WAS의 설정 파일처럼 해당 작업의 명칭을 지정한다. 그리고 <code>on</code>을 작성해서 해당 자동화 과정을 동작시킬 <strong>트리거</strong>를 명시한다. 현재 나의 블루/그린 무중단 배포는 <span style='background-color:#dcffe4'>main 브랜치에 push 혹은 pull request</span>가 워크플로우의 이벤트 트리거로 동작한다.</p>
<p>엄청 중요하진 않지만, 해당 워크플로우가 내 레포지토리를 수정하거나 건드리지 않게 하려고 읽기 권한만 부여해둔 상태다.</p>
<pre><code class="language-yml"># job unit
# 1.build -&gt; 2.deploy
jobs:
  build:
    runs-on: ubuntu-latest  # 우분투 최신버전(가상 PC가 주어짐)
    steps:  # name 단위로 나눠서 스탭을 밟음

      #...

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:

      # ...</code></pre>
<p>작업들은 <code>jobs</code>의 하위에서 명시하며 보통 <span style='background-color:#dcffe4'><code>build</code>와 <code>deploy</code>로 나눠서 작업을 정의</span>하게 된다. 정의된 내용에 맞춰서, <span style='background-color:#dcffe4'><strong>깃헙 액션에서 제공하는 가상머신을 기반으로 작업 단계</strong>가 이뤄지는데, 현재 내 워크플로우 스크립트는 <strong>ubuntu</strong> 최신버전을 가상머신 운영체제로 지정</span>했다. 작업의 순서를 보장하기 위해서 <code>build</code> 작업이 완료되고 나서야 <code>deploy</code> 작업이 이뤄진다.</p>
<h3 id="3-스크립트-주요-로직---ci">(3) 스크립트 주요 로직 - CI</h3>
<p>워크플로우 스크립트를 전부 여기서 설명하기에는 양이 많기 때문에 전체 스크립트는 맨 아래에 작성된 깃헙 레포 링크를 참조하길 바란다. 여기서는 어떻게 배포 환경 프로필을 동적으로 지정하고 무중단 배포를 실현하는지에 대해서만 설명한다.</p>
<p>일단 빌드 과정의 순서는 아래와 같다.</p>
<pre><code class="language-yml"># 자바 17 세팅
- name: Install JDK 17

# 실행 권한 부여 후, gradle 기반 test 없이 빌드해서 jar 파일 생성
- name: Build with Gradle

# 도커 이미지화해서 도커허브에 푸시하고 ec2에서 풀 받기 위해 도커 로그인
- name: Login to DockerHub

# 도커파일 기반으로 빌드
- name: Build Docker

# 도커허브에 푸시
- name: Push Docker</code></pre>
<p>기본적으로 <span style='background-color:#dcffe4'>EC2 인스턴스에 전달하기 위해 도커 허브로 나의 WAS를 jar 파일 기반으로 패키지 처리해서 푸시하는 과정이 빌드 과정</span>이다. 여기서 깃헙 액션의 장점인 <strong>템플릿 제공</strong>을 활용할 수 있는데, 자바 실행 및 패키징을 위해서 가상머신에 자바를 설치해야 할 테고, 아마 apt를 통해 일일이 설치하는 명령어를 작성해야 할 것이다.</p>
<p>이 과정을 <span style='background-color:#dcffe4'>복잡한 구문 없이 간단한 템플릿으로 처리하여** 특정 작업을 수행하는 액션을 호출<strong></span>할 수 있다. 빌드 과정에서 필요한 액션언 **깃 세팅</strong>, <strong>자바 JDK 세팅</strong>, <strong>도커 로그인</strong>이며, 각각의 템플릿은 <code>uses</code> 구문을 통해 호출할 수 있다.</p>
<pre><code class="language-yml">build:
  runs-on: ubuntu-latest  # 우분투 최신버전(가상 PC가 주어짐)
  steps:  # name 단위로 나눠서 스탭을 밟음
  - uses: actions/checkout@v3  # 우분투에 깃 세팅, pull까지

  # 자바 17 세팅
  - name: Install JDK 17
    uses: actions/setup-java@v3  # JDK 17 환경 세팅

  # ...

  # 도커 이미지화해서 도커허브에 푸시하고 ec2에서 풀 받기 위해 도커 로그인
  - name: Login to DockerHub
    uses: docker/login-action@v1  # 도커 허브 레지스트리 로그인</code></pre>
<p>여기서, 도커 로그인 같은 경우는 EC2 인스턴스에서의 환경 설정 과정을 기억하면 좋다. <span style='background-color:#dcffe4'>당시 도커 세팅을 할 때, 도커 허브에 접근할 수 있도록 도커 로그인을 했었고 이는 깃헙 액션의 가상머신에서도 마찬가지</span>다. 즉, 도커 로그인 템플릿을 통한 액션 호출에서 도커 허브의 계정 정보를 <code>with</code> 구문과 더불어 넘겨줘야 한다.</p>
<p>다만 그렇다고 도커 계정 정보를 그대로 기재하면 외부에 노출돼서 보안에 심각한 하자가 생길 것이기 때문에 <strong>깃헙 시크릿 변수 관리</strong> 기능을 활용해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/0b38d6f4-3988-4806-a366-cbcf4648846c/image.png" alt=""></p>
<p>내가 저장한 시크릿 변수는 <strong>WAS 설정 yml 파일, 도커 허브 ID와 토큰, EC2 인스턴스의 pem 키파일의 암호화 데이터와 탄력적 IP</strong>다. WAS 설정 파일은 base64 인코딩 처리한 값으로, pem 키파일 암호화 데이터는 텍스트 에디터로 추출해서 전체 내용을 저장한다.</p>
<p>소기의 목적인 도커 관련 변수들을 시크릿 변수에 담은 다음, 해당 변수들을 워크플로우 스크립트에 들고 와서 <code>with</code> 구문을 활용하면 도커 계정 정보를 읽어내 깃헙 액션 VM에서 도커 허브 로그인을 할 수 있다.</p>
<pre><code class="language-yml"># 도커 이미지화해서 도커허브에 푸시하고 ec2에서 풀 받기 위해 도커 로그인
- name: Login to DockerHub
  uses: docker/login-action@v1
  with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_TOKEN }}</code></pre>
<hr>
<p>아까 언급했던 내용 중에 <span style='background-color:#dcffe4'>빌드 과정에 테스트 과정이 포함</span>된다고 했었는데, 자바 스프링 기반 WAS에서 JVM 언어(자바, 코틀린) 기반 자동 빌드를 담당하는 툴인 <strong>Gradle</strong>, <strong>Maven</strong> 등이 테스트 실행 관리를 맡는다. 이것들이 빌드되는 과정 속에서 테스트를 맡는 것이다. <span style='background-color:#dcffe4'>IDE 등으로 Gradle 빌드를 하는 과정 속에 테스트 과정도 포함됐는데, 이 전체 과정을 깃헙 액션이 자동으로, 반복적으로 처리하는 것이다. 이것이  <strong>CI(Continuous Integration : 지속적 통합)</strong>에 해당</span>한다.</p>
<p>참고로 나는 테스트 코드가 없을 뿐더러 굳이 테스트 과정을 포함시키지 않기 위해서 테스트를 배제하고 빌드하는 설정의 코드를 산입해서 처리했다.</p>
<pre><code class="language-yml"># 실행 권한 부여 후, gradle 기반 test 없이 빌드해서 jar 파일 생성
- name: Build with Gradle
  run: |
    echo ${{ secrets.APPLICATION }} | base64 --decode &gt; ./src/main/resources/application.yml
    cat ./src/main/resources/application.yml
    chmod +x ./gradlew
    ./gradlew clean build -x test</code></pre>
<h3 id="4-스크립트-주요-로직---cd">(4) 스크립트 주요 로직 - CD</h3>
<p>빌드 로직 작성이 완료되고 이어 배포 로직을 작성한다. 개요에 작성한 문법은 배포 로직에서도 동일하게 적용하고 CI 로직에서 소개된 <code>uses</code>, <code>with</code> 구문들 역시 그대로 적용된다.</p>
<p>배포 로직의 순서 및 적용 템플릿은 다음과 같다.</p>
<pre><code class="language-yml"> deploy:
   needs: build
   runs-on: ubuntu-latest
   steps:
     # 응답 코드를 기반으로 nginx 업스트림 지정
     # /env 요청을 보내 받은 응답 및 응답코드를 바탕으로 blue? green? 선택
     - name: Set target IP

     # EC2에 세팅한 도커 컴포즈 실행
     # EC2 인스턴스에 SSH로 접속하여 도커 컴포즈 명령을 실행
     - name: Docker compose
       uses: appleboy/ssh-action@master 

     # Nginx 상태 확인을 위한 Health Check
     # 지정된 URL에 대한 HTTP 응답 상태를 확인
     - name: Check deploy server URL
       uses: jtalk/url-health-check-action@v3 

     # 도커 Nginx에 저장해둔 컨테이너 이름 변경 및 Nginx 리로드
     # EC2 인스턴스에 SSH로 접속하여 Nginx 설정을 업데이트하고 리로드
     - name: Change nginx upstream
       uses: appleboy/ssh-action@master 

     # 기존 컨테이너 중지 후, 삭제
     # EC2 인스턴스에 SSH로 접속하여 현재 실행 중인 Docker 컨테이너를 중지하고 삭제
     - name: Stop current server
       uses: appleboy/ssh-action@master 
</code></pre>
<p>각 순서를 조금 더 상세히 설명하자면, 우선 <span style='background-color:#dcffe4'>현재 동작하고 있는 배포 컨테이너의 특정 엔드포인트 응답(혹은 초기화일 경우, 상태 코드)</span>을 통해 배포해야 할 환경을 지정한다. 상세 동작 로직은 아래서 설명하고, 그 다음에 EC2 인스턴스에 접근해서 해당하는 배포 환경의 도커 컴포즈를 실행하고 Nginx를 통해 기존 배포 환경의 health check를 해서 Nginx를 리로드함과 동시에 다음 배포를 대비해서 참조 변수를 변경한다. 이후, 기존 컨테이너를 중지해서 삭제한다.</p>
<p>여기서 번갈아가며 기존 배포 환경과 새로운 배포 환경 구분이 필요한데, 그것에 대한 로직이 <strong>Set target IP</strong> 단계에서 담당한다.</p>
<pre><code class="language-yml"> # 응답 코드를 기반으로 nginx 업스트림 지정
 # /env 요청을 보내 받은 응답 및 응답코드를 바탕으로 blue? green? 선택
 - name: Set target IP
   run: |
     STATUS=$(curl -o /dev/null -w &quot;%{http_code}&quot; &quot;http://${{ secrets.LIVE_SERVER_IP }}/env&quot;)
     echo $STATUS
     if [ $STATUS = 200 ]; then
       CURRENT_UPSTREAM=$(curl -s &quot;http://${{ secrets.LIVE_SERVER_IP }}/env&quot;)
     else
       CURRENT_UPSTREAM=green
     fi
     echo CURRENT_UPSTREAM=$CURRENT_UPSTREAM &gt;&gt; $GITHUB_ENV
     if [ $CURRENT_UPSTREAM = blue ]; then
       echo &quot;CURRENT_PORT=8080&quot; &gt;&gt; $GITHUB_ENV
       echo &quot;STOPPED_PORT=8081&quot; &gt;&gt; $GITHUB_ENV
       echo &quot;TARGET_UPSTREAM=green&quot; &gt;&gt; $GITHUB_ENV
     elif [ $CURRENT_UPSTREAM = green ]; then
       echo &quot;CURRENT_PORT=8081&quot; &gt;&gt; $GITHUB_ENV
       echo &quot;STOPPED_PORT=8080&quot; &gt;&gt; $GITHUB_ENV
       echo &quot;TARGET_UPSTREAM=blue&quot; &gt;&gt; $GITHUB_ENV
     else
       echo &quot;error&quot;
       exit 1
     fi</code></pre>
<p>여기서 생각할 수 있는 시나리오 및 그에 대한 코드에서의 대응책 구현은 두 가지다.</p>
<blockquote>
<h4 id="1-처음-배포를-진행했다">1. 처음 배포를 진행했다.</h4>
<p>어차피 아무런 배포가 없을 테니 해당 엔드포인트에 요청을 보내도 아무런 응답이 없다. 즉 <strong>상태 코드가 200이 아니다.</strong> <span style='background-color:#dcffe4'>상태 코드가 200이 아니므로 자연스럽게 현재 배포 환경, 즉 업스트림 서버가 green으로 지정</span>된다. 그와 동시에 다음 타겟 환경은 blue가 된다.</p>
</blockquote>
<h4 id="2-배포가-진행된-이력이-있다">2. 배포가 진행된 이력이 있다.</h4>
<p>이렇다면 해당 엔드포인트로 요청을 보냈을 때 <code>String</code> 타입의 응답이 온다. 응답값에는 blue 혹은 green이 포함되어 있다.</p>
<p>위에서 언급했던 WAS의 설정 파일 윗쪽에 작성된 <code>server.env</code> 값이 2번 시나리오에서 적용된다. <span style='background-color:#dcffe4'>처음 정해진 값이 blue였는데, 1번 시나리오에서는 green 환경에서의 배포가 이뤄지면서 다음 순서가 blue로 지정</span>된다. 이 과정에서 한 번 더 배포가 이뤄진다면 <span style='background-color:#dcffe4'>도커 컴포즈가 실행되면서 WAS에 지정한 <code>ARG</code>(프로필, env 값)를 바탕으로 배포 환경을 번갈아 지정</span>하는 것이다.</p>
<p>배포 환경이 지정되고 나면 기존 배포 환경, 즉 기존 컨테이너의 동작을 확인 후에 Nginx로 라우팅 스위치를 하고 리로드한 다음에 기존 컨테이너를 중단, 삭제한다.</p>
<p>위의 시나리오 순서대로 깃헙 액션의 VM이 EC2 인스턴스로 접근한 다음, 지속적 통합 과정에서 도커 허브에 올려진 신 버전 WAS 이미지를 pull 받아서 도커 컴포즈를 통해 실행시키는 로직이 이뤄지는 과정이 이뤄진다. 이렇게 <span style='background-color:#dcffe4'>배포 환경에 관여하면서 새로운 업데이트 내용을 배포하는 일련의 과정을 <strong>지속적 제공 / 배포(Continuous Delivery / Deployment)</strong></span>라고 한다. </p>
<hr>
<p>이전 포스팅부터 이제까지 <span style='background-color:#dcffe4'><strong>CI / CD 파이프라인</strong></span> 구축을 한 것이며 신 버전 업데이트 내용을 이제까지의 방식을 통해 무중단으로 배포하는 것을 <span style='background-color:#dcffe4'><strong>블루/그린 무중단 배포</strong></span>라고 한다.</p>
<p>여기까지 작성하면 이제 모든 준비가 완료됐다<del>(길고도 참 길었다...).</del></p>
<p>깃헙 액션의 워크플로우 스크립트에서 이벤트 트리거로써 <code>main</code> 브랜치에 대한 <code>push</code> 및 <code>pull request</code>를 지정했기 때문에, 해당 이벤트가 발생하면 무중단 배포가 일어날 것이다. 한번 테스트 해보자.</p>
<h1 id="7-무중단-배포-테스트">7. 무중단 배포 테스트</h1>
<p>처음 배포를 하면 깃헙 액션 워크플로우에서 <strong>Stop current server</strong> 부분에서 에러가 발생할 것이다. 당연한 게, 동작하는 현재 서버가 없으니... 저 과정이 에러가 난다고 배포가 멈추진 않으니 안심하고 진행하자.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/ee17be8b-1007-48e9-bea5-810df3e2d322/image.png" alt=""></p>
<p>초기화를 green 컨테이너 및 관련 환경으로 배포를 진행했기 때문에 <span style='background-color:#dcffe4'>EC2 인스턴스에서는 green 컨테이너가 확인될 것이고, 배포된 웹 서비스에서도 green 배포 환경과 관련된 내용을 확인</span>할 수 있다. 아래를 참조하자.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/9298a28f-a2d5-4887-983c-0b3af9066b5a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/67a4c85b-fa3d-4a96-bd4e-5670dc989940/image.png" alt=""></p>
<p>이제 여기서 <code>main</code> 브랜치에 push 이벤트를 발생시켜서 배포를 진행해보자. 기존 컨테이너를 삭제하거나 인스턴스를 다운할 필요는 없다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/760a01ae-1b06-4b59-b280-8585d0eb701c/image.png" alt=""></p>
<p>위처럼 깃헙 액션 워크플로우에 명시된 build와 deploy가 성공적으로 진행될 것이다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/68a070c2-1d06-4b19-98a0-f84305733e10/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/acc29eda-b6a1-4f19-8eb5-35c4735dd8e4/image.png" alt=""></p>
<p>보면 <span style='background-color:#dcffe4'>기존의 green 컨테이너가 삭제됐고 blue 컨테이너가 새롭게 떴으며, 서비스에서도 blue 환경과 관련된 포트 번호 및 서버네임이 확인</span>된다. 즉, 블루/그린 기반 무중단 배포에 성공한 것이다!</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/7ff52205-fac0-4674-bfda-a953af3463e1/image.png" alt=""></p>
<p>이 상황에서 <code>env()</code> 메소드를 호출하면 위에서 언급했던 WAS 설정 값(<code>yml</code> 파일에서 blue로 초기화했던 값)이 변경된 것을 확인할 수 있다. 즉, <span style='background-color:#dcffe4'>도커 컴포즈에 작성된 <code>ARG</code>를 기반으로 도커파일을 가변 빌드하면서 다음 배포 환경에 대한 정보 명시</span>도 잘 이뤄지고 있는 것을 확인할 수 있다.</p>
<hr>
<p>다음 과제는 MSA에서의 무중단 배포 적용이다.
물론 개별적으로 모든 인스턴스에 적용하는 것도 방법이지만... 쿠버네티스와 같이 활용하는 방법으로 고민해야겠다.</p>
<p>끝!</p>
<p><em>깃헙 레포 링크</em>
<em><a href="https://github.com/kimD0ngjun/cicd">https://github.com/kimD0ngjun/cicd</a></em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GitHub Actions + AWS EC2 + Docker + Nginx 기반 Blue/Green 무중단 배포 구축(1)]]></title>
            <link>https://velog.io/@kim00ngjun_0112/GitHub-Actions-AWS-EC2-Docker-Nginx-%EA%B8%B0%EB%B0%98-BlueGreen-%EB%AC%B4%EC%A4%91%EB%8B%A8%EB%B0%B0%ED%8F%AC-%EA%B5%AC%EC%B6%951</link>
            <guid>https://velog.io/@kim00ngjun_0112/GitHub-Actions-AWS-EC2-Docker-Nginx-%EA%B8%B0%EB%B0%98-BlueGreen-%EB%AC%B4%EC%A4%91%EB%8B%A8%EB%B0%B0%ED%8F%AC-%EA%B5%AC%EC%B6%951</guid>
            <pubDate>Tue, 29 Oct 2024 17:17:26 GMT</pubDate>
            <description><![CDATA[<h1 id="1-배경">1. 배경</h1>
<h2 id="1-개요">1) 개요</h2>
<p>한창 취준하면서 공부도 병행하던 와중, 예전 팀 프로젝트에서 아키텍처 팀이 무중단 배포를 구현했던 기억이 있다. 그 당시에는 워낙 프로젝트 데드라인에 쫓겨서 코드 리뷰가 미흡했는데 <del>이제서야</del> 해본 코드 리뷰 결과, 당시 팀원이 <span style='background-color:#dcffe4'><strong>AWS Deploy</strong>를 기반으로 무중단 배포 구현</span>에 성공했었다.</p>
<p>팀 회고에서, <strong>도커</strong>를 적극적으로 활용하지 못하고 활용한 부분에서도 이해가 전제되지 않았던 게 아쉬웠던지라 사이드 프로젝트나 스터디 프로젝트에서 도커를 애용 겸 연습했는데 일단 잘 모르겠지만 무작정 스스로 무중단 배포를 구현하고 싶었다. 겸사겸사 진행 중인 MSA 마이그레이션 및 실시간 통신 성능 계측 프로젝트에서의 무중단 배포 구현에 대해 고민도 할 겸.</p>
<p>목표는 <span style='background-color:#dcffe4'><strong>블루 그린 무중단 배포를 도커 중점으로 구현</strong></span>하는 것</p>
<h2 id="2-무중단-배포">2) 무중단 배포</h2>
<p>보통 배포는 한 번 하고 끝나지 않는다. 성능 개선이나 기능 교체가 있으면 서비스 중인 곳에 배포를 새롭게 수행해야 한다. 이 과정을 순서로 표현하면...</p>
<blockquote>
<ol>
<li>기존 WAS가 배포돼서 서비스되고 있다</li>
<li>새로운 기능이 도입돼서 해당 내용을 추가한 새 버전 WAS 배포가 필요하다</li>
<li>기존 WAS를 <strong>중단</strong>하고 새 버전 WAS를 배포한다</li>
<li>중단한 시점에 트래픽이 몰리고 있었다면...?😱</li>
</ol>
</blockquote>
<p>저 중단된 시간이 설령 찰나라고 해도, 초당 몰리는 트래픽이 존재하는 경우라면 서비스 사용자 입장에서는 불편함을 유발할 수도 있고, 기업 입장에서는 수익적인 문제로도 이어질 수 있다. 이는 서비스 운영의 측면이고...</p>
<p>개발의 관점에서 봤을 때, 무중단 배포는 <span style='background-color:#dcffe4'><strong>CI/CD(지속적 통합/지속적 배포) 파이프라인</strong>을 통해 자동화</span>된다. 보통의 배포는 수동으로 관리함으로써 개발 비용이 소모되지만 이를 절감할 수 있게 된다. 뿐만 아니라, k8s 등의 도입을 통해 스케일링 최적화까지 이끌어낼 수 있으며 장애 대응책의 밑바탕으로써 활용될 수도 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/0a409631-413b-43da-a631-006383faa53a/image.png" alt=""></p>
<p>무중단 배포에는 순차적 업데이트를 통해 부하 분산을 기대할 수 있는 <strong>롤링 업데이트</strong>, 소규모 사용자 그룹에게 우선 배포해서 문제 확인 후에 점진적으로 확장하는 <strong>카나리 배포</strong> 등이 있으나 이번에 연습할 내용은 <span style='background-color:#dcffe4'>별개의 운영 환경(보통 블루, 그린으로 나뉨)을 유지하면서 트래픽을 전환하는 <strong>블루/그린 배포</strong></span>로 구축할 생각이다.</p>
<h2 id="3-사전-배경지식">3) 사전 배경지식</h2>
<blockquote>
<ol>
<li>AWS EC2 인스턴스 생성</li>
<li>간단한 SpringBoot 기반 WAS 구현</li>
<li>Docker 활용법 &amp; Docker hub 계정 보유</li>
</ol>
</blockquote>
<h1 id="2-aws-ec2-환경-설정">2. AWS EC2 환경 설정</h1>
<h2 id="1-탄력적-ip--보안-그룹-규칙">1) 탄력적 IP &amp; 보안 그룹 규칙</h2>
<p>나는 <strong>ubuntu</strong>를 기반으로 EC2 인스턴스를 띄웠는데 그 이유는 APT 패키지를 통해 쉽게 소프트웨어를 관리할 수 있어서다. EC2를 그냥 생성하면 외부 접근용 퍼블릭 IP가 계속 변동되기 때문에 만약 인스턴스를 셧다운시켰다가 다시 올리면 WAS 설정파일 및 기타 관련 내용에서 배포 IP로 접근하기 어려워지고 이는 무중단 배포에서 이뤄질 자동화 과정에서도 똑같이 적용된다. 그렇기 때문에 무중단 배포를 구축하려면 <strong>HTTPS 도입</strong> 혹은 최소한 <strong>탄력적 IP 도입</strong>은 필수다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/931b92ea-594e-48ce-927f-2bcccb971dbf/image.png" alt=""></p>
<p>위처럼 AWS에서는 탄력적 IP 할당을 해둬서 접근 IP를 고정시킨다.</p>
<hr>
<p>EC2의 방화벽 역할을 맡는 <span style='background-color:#dcffe4'>보안 그룹의 인바운드 규칙에서 WAS가 구동되기 위한 포트를 두 개 열어줘야 한다.</span> 각 포트별로 구 버전과 신 버전이 동작하도록 번갈아 할당시키기 위함이다. 이를 통해 트래픽 스위칭을 수행하고 사용자가 느끼지 못하는 새에 배포가 이뤄진다.
<img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/729c7829-5678-4770-94eb-5951ef427902/image.png" alt=""></p>
<p>AWS에서의 EC2 인스턴스 준비는 탄력적 IP 도입과 보안 그룹 세팅까지 하면 된다.</p>
<h2 id="2-ec2-인스턴스-세팅---docker-설치--yml-작성">2) EC2 인스턴스 세팅 - Docker 설치 + yml 작성</h2>
<p>ubuntu 바탕 EC2 인스턴스에 익숙하다면 당연히 하겠지만, 나름의 학습 기록을 위해 추가한다. 일단 터미널로 EC2 인스턴스에 진입한다. 나는 mac 환경이어서 별도로 pem 파일을 저장해두고, 해당 키 파일을 통해 내가 띄운 EC2 인스턴스에 접속했다. 접속해서 항상 <strong>sudo</strong> 모드에서 모든 세팅을 진행했다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/7ac5ecf5-7e7f-4eab-a9f1-bb8d1cc9d7a2/image.png" alt=""></p>
<p>루트 권한을 부여받아야 <span style='background-color:#dcffe4'><strong>apt(Advanced Package Tool)</strong> 명령어를 통해 소프트웨어 패키지를 업데이트해서 최신화</span>를 해야 도커 설치 환경을 갖출 수 있다. <del>근데 저 명령어 볼 때마다 아파트 생각나</del> 이후, <span style='background-color:#dcffe4'>도커 설치에 필요한 보조 툴들</span>을 세팅한다.</p>
<pre><code class="language-bash"># ubuntu 시스템 패키지 업데이트
apt-get update

# https 기반 서드파티 레포 및 툴 설치 
apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common</code></pre>
<p>도커 설치를 위한 추가 작업이 필요하다. <span style='background-color:#dcffe4'><strong>도커의 공식 GPG(GNU Privacy Guard)</strong> 키를 우분투에 설치할 수 있도록 도커 패키지 서명을 검증</span>해야 한다. 또한, <span style='background-color:#dcffe4'>도커 공식 패키지를 우분투 시스템에 설치할 수 있도록 공식 레포지토리에 추가</span>한다.</p>
<pre><code class="language-bash"># 도커 공식 GPG 키 추가
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

# 도커 공식 apt 레포 추가
add-apt-repository &quot;deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable&quot;</code></pre>
<p>위의 과정까지 마무리하고 다시 한 번 더 <code>apt-get update</code> 명령을 내려주면, 시스템 패키지 업데이트를 하면서 도커 설치 준비가 완료된다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/2f8d8de4-529a-4549-a2a7-1343cda8e48d/image.png" alt=""></p>
<hr>
<p>이제 도커를 설치하자. 단순히 Docker만 설치하는 것이 아닌, <strong>Docker-Compose</strong>도 같이 설치한다. 그 이유는 번갈아 반복하는 배포 환경과 그로 인한 멀티 컨테이너의 관리를 통한 일관된 배포 처리를 위해서다.</p>
<pre><code class="language-bash"># 도커 설치
apt-get install docker-ce docker-ce-cli containerd.io

# 도커 컴포즈 설치
curl \
    -L &quot;https://github.com/docker/compose/releases/download/1.26.2/docker-compose-$(uname -s)-$(uname -m)&quot; \
    -o /usr/local/bin/docker-compose

# 도커 컴포즈 실행 권한 부여
chmod +x /usr/local/bin/docker-compose</code></pre>
<p>위 단계까지 마무리하고 아래처럼 도커 및 도커 컴포즈의 버전 확인을 통해 확인이 되면 성공적으로 도커와 도커 컴포즈가 우분투에 세팅된 것이다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/bb920051-d25e-473c-94ea-e3a17c91ac99/image.png" alt=""></p>
<p>그리고 바탕화면에서 <code>docker-compose-blue.yml</code>과 <code>docker-compose-green.yml</code> 도커 컴포즈 파일을 작성한다.</p>
<pre><code class="language-shell">version: &#39;3.8&#39;

services:
    blue:
        image: 도커 유저네임/앱 도커 저장소:latest
        container_name: blue
        ports:
            - &quot;8080: 8080&quot;
        environment:
            - PROFILES=blue
            - ENV=blue</code></pre>
<pre><code class="language-shell">version: &#39;3.8&#39;

services:
    green:
        image: 도커 유저네임/앱 도커 저장소:latest
        container_name: green
        ports:
            - &quot;8081: 8081&quot;
        environment:
            - PROFILES=green
            - ENV=green</code></pre>
<h2 id="3-docker-hub-관련-세팅">3) Docker Hub 관련 세팅</h2>
<p>도커 세팅과 더불어서 추가로 진행할 단계가 있다. 다음 포스팅에서 후술될 내용이지만, <span style='background-color:#dcffe4'>업데이트된 WAS 내용을 EC2 인스턴스로 전달하기 위한 밑작업</span>이 필요하다. 그 전달하는 방법은, <span style='background-color:#dcffe4'>업데이트된 WAS를 도커 이미지화해서 나의 도커 허브로 Push한 다음, EC2 인스턴스에서 Pull을 받아 컨테이너로 구동시키는 것</span>이다.</p>
<p>그렇기 때문에, EC2 인스턴스에서 나의 도커 허브에 접근하기 위한 세팅이 필요하다. 일단, 도커를 사용하면 당연히 도커 허브의 계정을 보유하고 있을 것이다. 해당 도커 허브의 <strong>username</strong>과 <strong>엑세스 토큰</strong>이 필요하다. 엑세스 토큰은 도커 허브의 개인 프로필에서 계정 설정으로 접근해서 Security 항목을 보면 <strong>Personnel Access Token</strong>을 생성할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/322c8e98-8a97-4abf-bfa7-fe982ceee39d/image.png" alt=""></p>
<p><span style='background-color:#dcffe4'><strong>도커 허브의 엑세스 토큰은 생성해둔 내용을 벗어나면 다시는 확인할 수 없기 때문에 별도의 메모장 등에 저장하는 것을 추천</strong></span>한다. 해당 내용은 EC2 인스턴스 외에도 깃허브의 시크릿 변수에서도 적용해야 하기 때문에 보관해둬야 한다.</p>
<p>엑세스 토큰까지 발급받았으면 다시 EC2 인스턴스로 돌아와서 터미널에 아래와 같은 명령어를 입력하고 Password에는 기억해둔 엑세스 토큰을 입력한다. 참고로 직접 입력하나 붙여넣기를 하나 아무 것도 안 뜰 텐데 원래 그렇다.</p>
<pre><code class="language-bash">docker login -u &lt;username&gt;</code></pre>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/9640e56c-432e-4b4c-ae70-52246d61e467/image.png" alt=""></p>
<p>정상적으로 입력이 완료되면 로그인이 성공했다는 메세지를 확인할 수 있다. 이제, EC2 인스턴스에서 나의 도커 허브로 접근이 가능해졌다.</p>
<h1 id="3-nginx-도입">3. Nginx 도입</h1>
<h2 id="1-nginx의-역할">1) Nginx의 역할</h2>
<p>EC2 인스턴스에 도커를 설치한 이유는 세 가지다.</p>
<blockquote>
<ol>
<li>WAS의 이미지 관리 및 그를 통한 컨테이너 구축 후 실행</li>
<li>무중단 배포 환경 구축에 필요한 툴(여기선 <strong>Nginx</strong>) 설치</li>
<li>무중단 배포 환경 관리</li>
</ol>
</blockquote>
<p>1번은 다음 포스팅에 작성할 GitHub Actions가 맡을 거고, 3번은 배포 완료 이후, 지속적인 배포에 있어서 도커가 맡을 역할이다. 2번을 설명하기에 앞서, 우리가 구현하려는 <strong>블루/그린 무중단 배포</strong>의 동작 원리를 다시 파악하자.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/d2eacf32-af91-4850-9b50-67a564dae609/image.png" alt=""></p>
<p>앞서 얘기했듯, <span style='background-color:#dcffe4'>두 개의 포트(스프링부트 WAS일 경우 보통 8080과 8081)로 WAS를 번갈아 동작시키며 구 버전에서 신 버전으로 트래픽 스위칭을 하는 것</span>이 블루/그린 무중단 배포의 핵심이다. </p>
<p>보편적인 웹 서비스의 구성은 클라이언트의 요청이 서버로 바로 넘어와서, 서버가 응답을 반환하는 방식이다. 여기서 서버에 띄운 앱 서비스의 버전 업데이트가 이뤄지면 구 버전의 앱을 중단하고 신 버전을 다시 띄워야 할 것이다. 그래서 <span style='background-color:#dcffe4'>구 버전의 WAS를 계속 동작시키면서 동시에 신 버전의 WAS를 띄우고, <strong>신 버전 포트로 스위칭</strong>을 해야 하는데, 이 역할을 <strong>Nginx</strong>가 맡는다.</span></p>
<p><strong>Nginx</strong>는 웹 서버이자, 리버스 프록시의 일종으로 덤으로 로드 밸런서의 역할도 수행할 수 있다. 다양한 기능을 제공하지만 현재 구축하려는 블루/그린 무중단 배포에서는 <strong>리버스 프록시</strong>로써 활용된다. 리버스 프록시는 <span style='background-color:#dcffe4'>클라이언트의 요청을 서버에게 보내고, 서버의 응답을 클라이언트에게 반환하는 중재자 역할</span>을 맡는다. 이를 통해 로드 밸런싱, 캐싱, SSL 종료, 보안 등의 이점을 얻을 수 있는데 블루/그린 무중단 배포에서는 <strong>트래픽 라우팅 기능</strong>을 메인으로 해서 다양한 기능을 적용할 수도 있다.</p>
<p>이제 EC2에 Nginx를 설치하고 세팅해보자.</p>
<h2 id="2-nginx-설치--세팅">2) Nginx 설치 &amp; 세팅</h2>
<p>직접 ubuntu 인스턴스에 Nginx를 설치하는 것도 있지만, 미리 설치해둔 도커를 활용해서 더 간편하게 Nginx 설치가 가능하다. Nginx 도커 이미지를 도커 허브에서 Pull 받고, 컨테이너를 생성해서 실행한다.</p>
<pre><code class="language-bash"># nginx image pull (tag: latest)
docker pull nginx

# nginx container create and run with detached mode
docker container run --name &lt;container_name&gt; -d -p 80:80 nginx

# check docker container health
docker ps</code></pre>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/ed7202d3-754d-4c6b-8ba6-c3ffc350cb72/image.png" alt=""></p>
<p>도커 기반으로 Nginx 컨테이너를 생성하고 실행하면 현재 EC2 인스턴스에 Nginx가 동작하고 있다. 이제 이 Nginx를 우리가 목표로 하는 블루/그린 무중단 배포에 맞춰 동작할 수 있도록 설정을 해줘야 한다.</p>
<p>일단 동작하고 있는 Nginx가 EC2 우분투 인스턴스에서 직접 작동시키는 게 아닌, 도커 컨테이너의 가상 환경에서 동작하고 있기 때문에 <span style='background-color:#dcffe4'>Nginx 컨테이너로 접속</span>해야 한다.</p>
<pre><code class="language-bash"># access docker nginx container bash
docker exec -it &lt;container_name&gt; bash</code></pre>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/17339fe9-181e-444b-a9e5-d628ad398dc2/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/6585a757-c02c-4791-bc6d-f8894fd2af8c/image.png" alt=""></p>
<p>보통 <span style='background-color:#dcffe4'>Nginx의 설정 파일은 <code>/etc/nginx/conf.d</code> 경로에 <strong><code>default.conf</code></strong> 명칭으로 존재</span>한다. 해당 설정 파일에 기본적인 내용들이 기재되어 있으므로 블루/그린 무중단 배포와 관련된 <strong>업스트림 세팅</strong> 및 <strong>해당 경로에 <code>inc</code> 파일 작성</strong>이 필요하다. vim 작성 방법은 별도로 서술하지 않는다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/e32724db-b07f-4517-ac56-0f99d361132c/image.png" alt=""></p>
<p>설정 파일에서는 <span style='background-color:#dcffe4'>블루 컨테이너와 그린 컨테이너에 대한 url 설정</span>을 넣어둔다. 블루 컨테이너와 그린 컨테이너에 맞춰서 <strong>EC2 인스턴스의 private IP</strong>와 <strong>포트 번호</strong>를 부여해둔다. 참고로 일단 블루/그린 무중단 배포 구현이 우선이기 때문에 로드 밸런싱 설정은 처리하지 않았다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/bd00ab4b-dc47-4ad2-b33d-2d71d423b93b/image.png" alt=""></p>
<p><span style='background-color:#dcffe4'><code>inc</code> 파일은 Nginx의 설정에서 업스트림된 <strong>컨테이너 url의 참조</strong>를 동적으로 변경</span>한다. 즉, 설정 파일의 업스트림 설정이 블루 컨테이너와 그린 컨테이너의 url 표시 역할을 맡고, <code>inc</code> 파일이 컨테이너 url에 대해 참조하기 위한 명시 역할을 맡는 셈이다.</p>
<p><strong>참고로 사진은 blue라고 뜨지만 <span style='color:red'>초기에는 green으로 작성</span>해야 한다.</strong> 사진이 blue로 되어있는 이유는 내가 이미 테스트를 하면서 변경을 확인했기 때문...ㅎ</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/d170624b-f476-4742-820d-6d8cae51263e/image.png" alt=""></p>
<p>작성한 <code>inc</code> 파일을 바탕으로 <span style='background-color:#dcffe4'><strong>include</strong> 설정과 더불어 <strong>location</strong>에서의 proxy 세팅</span>을 마무리하면 블루/그린 무중단 배포를 위한 Nginx 설정이 끝났다.</p>
<h2 id="3-나름의-트러블-슈팅">3) 나름의 트러블 슈팅</h2>
<p>개인적으로 배포가 가장 어렵게 느껴지는 건, 이런 에러가 발생했을 때 앱 레벨에서의 에러 해결법보다 난해하고 과정을 디버깅하기 까다롭다고 생각해서다. 그래서 나중에 까먹지 않으려고 나름의 트러블 슈팅을 기록하고자 한다.</p>
<p>참고로 아래의 이슈는 직전까지 작성한 것들의 설치 및 세팅 등이 마무리된 시점에서 발생했다.</p>
<h3 id="1-처음-배포-도메인으로-접근하면-에러가-발생">(1) 처음 배포 도메인으로 접근하면 에러가 발생</h3>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/5f33ca42-d1f4-484a-ade7-cfdf2d9cefef/image.png" alt=""></p>
<p>사실 기대했던 화면은 <strong>Welcome to Nginx!</strong>...라는 환영 인사였지만, 위와 같은 에러 발생 문구가 나를 반겼다. 설치와 세팅에는 나름 문제가 없었는데, Nginx 세팅이 잘못됐는지 확인하기 위해 실행 과정의 에러 로그를 분석하고자 했다.</p>
<h3 id="2-docker-기반의-동작이므로-에러-로그는-docker를-통해-확인">(2) Docker 기반의 동작이므로, 에러 로그는 Docker를 통해 확인</h3>
<p>에러 로그를 확인하려고 공식 문서를 뒤져보니, Nginx의 <code>/var/log/nginx</code> 경로에 <code>error.log</code> 파일에 에러 내용이 저장되어 있다고 한다. 접근해서 바로 확인을 하려고 했지만, 어째서인지 내용 확인이 안됐다. 기본적인 설정으로도 자동으로 에러 로그가 저장되는 것으로 알고 있었는데 혹시나 해서 설정 파일에도 <code>error.log</code> 파일 저장 및 저장 내용 레벨까지 명시했음에도 확인되지 않았다.</p>
<p>여러 소스들을 뒤져본 결과, <span style='background-color:#dcffe4'>도커 환경에서의 Nginx는 <strong>stdout(표준 출력)</strong>으로 내보내기 때문에 <code>cat</code> 명령어 등으로 내용을 확인할 수 없다</span>고 한다. 근데, 아직 <code>default.conf</code> 파일에서 해당 내용이 확인되지 않아서... 좀 더 알아봐야겠다. 결론은 <span style='background-color:#dcffe4'><strong>도커 로그</strong>로 확인</span>해야 한다.</p>
<pre><code class="language-bash">docker logs &lt;nginx_container_id&gt;</code></pre>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/d0ec3381-2ea6-4801-8774-ece5c5b35428/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/c2dc6f55-66eb-4208-af46-f4e5c4cb21ae/image.png" alt=""></p>
<p>도커 로그로 조회하니, <span style='background-color:#dcffe4'>EC2 인스턴스의 private IP에 할당된 포트 번호(8081)로의 연결이 거부된다고 한다.</span> 그래서 해당 탄력적 IP로 접근했을 때 에러 페이지를 반환한 모양이다.</p>
<h3 id="3-was-배포가-이뤄져야-해결">(3) WAS 배포가 이뤄져야 해결</h3>
<p>현재 EC2 인스턴스가 점유하고 있는 포트는 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/33cd388c-721c-4046-9c73-87bb59aa19e2/image.png" alt=""></p>
<p>도커 프록시와 관련해서 80번 포트를 점유하고 있고 <span style='background-color:#dcffe4'>연결이 거부된 8081 포트를 점유하고 있는 것은 없다.</span> 그렇기 때문에 선점 문제는 아니라는 것을 확인했다. 나는 에러 로그에서 <strong>8081 포트 번호</strong>를 가리키는 것에 집중했다. <strong>80 포트를 점유하고 있는 상황에서 왜 8081 포트로의 연결이 이뤄졌고 이것이 왜 거절당했는가?</strong></p>
<p>다시 한 번 더 Nginx 설정을 확인해봤다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/186954e1-6e71-41c9-aef7-9e9b0e11d395/image.png" alt=""></p>
<p><span style='background-color:#dcffe4'>8081 포트 번호를 담당하는 것은 <strong>그린 컨테이너와 관련된 업스트림</strong>이다. 그리고 설정 파일에서 동적으로 참조하는 변수는 <strong>green</strong>으로 초기화</span>되어 있다. Nginx의 리버스 프록시로써의 역할은 클라이언트 요청을 업스트림, 즉 백엔드 서버로 라우팅시키는 것이라는 점을 고려해서 작업 플로우를 머릿속으로 그려봤다.</p>
<blockquote>
<ol>
<li>클라이언트의 요청이 <strong>Nginx의 리버스 프록시로 인해 80번 포트</strong>로 들어온다.</li>
<li>Nginx는 이를 업스트림으로 라우팅한다.</li>
<li>현재 업스트림의 초기화는 <strong>green(포트 번호 8081)</strong>으로 되어있다.</li>
<li>하지만 현재 8081번 포트에는 띄워져 있는 내용이 아무 것도 없다.</li>
<li>그렇기 때문에 <strong>라우팅 처리가 됐으나 내용이 없어서 에러를 반환</strong>하는 것이다.</li>
</ol>
</blockquote>
<p>즉, <span style='background-color:#dcffe4'>Nginx 리버스 프록시를 통해 클라이언트에서 80번 포트로 들어오는 요청을 8081번 포트로 라우팅했으나, 현 시점에서는 8081번 포트로 띄운 WAS가 없기 때문에 에러를 반환시킨 것으로 잠정 결론</span>을 내렸다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/3ef73214-87b3-48b3-bdf4-5e93afd2bf9a/image.png" alt=""></p>
<p>GPT와의 토론(?)을 통해, GPT도 나와 같은 의견을 결론으로 내뱉긴 했지만... 사실 내 생각이 맞는지에 대한 5% 부족한 교차 검증 정도로만 활용했다.<del>(사실 GPT 잘 안 믿거든)</del></p>
<p>여튼, <span style='background-color:#dcffe4'>WAS를 배포해서 다시 접근해서 확인해야 할 문제</span>일 듯하다.</p>
<hr>
<p>지금까지 진행한 내용은, <strong>EC2 세팅 + Docker 세팅 + Nginx 세팅</strong>이다.
다음 포스팅에서는 <strong>GitHub Actions</strong>의 빌드 및 배포의 자동화 스크립트를 작성해 <strong>CI/CD 파이프라인</strong> 구축을 마무리한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[실시간 통신 트래픽 제어(6) - MSA Instance: User, Auth]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%86%B5%EC%8B%A0-%ED%8A%B8%EB%9E%98%ED%94%BD-%EC%A0%9C%EC%96%B46-MSA-Instance-User-Auth</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%86%B5%EC%8B%A0-%ED%8A%B8%EB%9E%98%ED%94%BD-%EC%A0%9C%EC%96%B46-MSA-Instance-User-Auth</guid>
            <pubDate>Sun, 06 Oct 2024 07:28:10 GMT</pubDate>
            <description><![CDATA[<h1 id="msa-인스턴스---회원-인증">MSA 인스턴스 - 회원, 인증</h1>
<p>MSA 설계를 하는 시점에서, 내가 구현하는 앱이 채팅인 만큼 <span style='background-color:#dcffe4'><strong>채팅</strong>과 <strong>채팅이 아닌 것</strong></span>을 기준으로 인스턴스를 나눠 설계해야겠다는 생각이 들었다. 채팅은 기본적으로 <strong>웹소켓 프로토콜</strong>에서 이뤄지고 그외의 것은 <strong>HTTP 프로토콜</strong>에서 이뤄지는 것이 보편적이었기 때문이었다.</p>
<p>모놀리스에서 MSA로 넘어가는 취지 중 하나였던, <span style='background-color:#dcffe4'>단일 기능이 전체 프로세스에 영향을 끼치는 것을 방지</span>하자는 의의에 맞춰서 특정 프로토콜이 다른 프로토콜에 영향을 끼치는 것을 막기 위한 것도 있었다. 사실 앱 수준이 복잡하지 않아서 채팅 기능에 포함되지 않을 법한 것이라 해봤자, 회원이랑 인증 정도였다.</p>
<p><em>참고로 <strong>해당 포스팅 시점에서 이미 기능 확장 완료 후, 1차 배포를 준비 중</strong>이다. 그래서 포스팅 성격이 회고의 느낌이 강할 예정</em></p>
<h2 id="issue-1-인증-정보의-공유">Issue 1) 인증 정보의 공유</h2>
<p>인스턴스가 전부 분리되면서 가장 먼저 든 생각은, 아래와 같았다.</p>
<blockquote>
<p><strong>Q. 해당 사용자가 인증됐음을 어떻게 다른 인스턴스에서도 인지시키는가?</strong></p>
</blockquote>
<p>이에 대하여 여러 해결책을 비교, 생각해보면서 적합한 해결책을 생각해보았다.</p>
<h3 id="a-회원-인스턴스에서의-일회성-인증-후-다른-인스턴스는-통과">A. 회원 인스턴스에서의 일회성 인증 후, 다른 인스턴스는 통과</h3>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/10fbdab9-3716-48ad-bc54-e945f225eff6/image.png" alt=""></p>
<p>가장 간단한 방법이라고 생각했다. 클라이언트의 접근 관리는 프론트엔드가 별도로 구축되어있기 때문에 로그인 화면을 넘어선 시점에서 인증이 됐다고 간주할 수 있으므로 생각할 수 있는 해결책이었다. 하지만, 이렇게 되면 <span style='background-color:#dcffe4'>다른 인스턴스에 대한 API 호출을 비인증 사용자가 수행할 수 있다는 것</span>이므로 인증의 기능을 포기하는 것과 다를 바 없으므로 이 해결책은 기각됐다.</p>
<h3 id="a-각각의-인스턴스-별로-인증-로직을-구축">A. 각각의 인스턴스 별로 인증 로직을 구축</h3>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/36a886fd-0d9d-4c77-93fc-3d61963c92f3/image.png" alt=""></p>
<p>첫 번째 해결책의 맹점을 파악하였고, 그렇다면 모든 인스턴스에 대해 인증 여부를 검증하는 로직이 갖춰져야 한다고 생각했다. 기존 모놀리식 아키텍처에서 JWT 기반 인증을 채택하였으므로, <span style='background-color:#dcffe4'>JWT 엑세스 토큰을 파싱하는 로직을 각 인스턴스의 필터 단계에서 구축</span>하면 되지 않을까라는 생각을 했다. 가장 확실한 방법이지만, API 호출에 대해 중복되는 작업을 수행하는 셈이 되기 때문에 트래픽이 몰리면 악영향을 끼칠 것으로 생각했다.</p>
<h3 id="a-인증된-정보를-캐시로써-활용해서-다른-인스턴스가-참조1차-결정">A. 인증된 정보를 캐시로써 활용해서 다른 인스턴스가 참조(1차 결정)</h3>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/33f7b3bb-0ec3-4dc6-b605-3935059b9ac5/image.png" alt=""></p>
<p>1차적으로 최종 결정된 해결책은 <span style='background-color:#dcffe4'><strong>캐시</strong>를 도입</span>함으로써 다른 인스턴스에서 이를 참조하는 것이었다. 입출력 작업이 매우 빠른 <strong>Redis</strong>를 활용해서 인증 정보(<code>username</code>, <code>authority</code>)를 조회해서 각각의 인스턴스에서 인증 객체를 생성케 하는 로직을 구상했다. 이를 조금 더 상세히 분리하는 것이 후술할 두 번째 이슈의 해결책을 마련하는 과정에 포함되어 있었다.</p>
<h2 id="issue-2-인증-책임의-관심사-분리">Issue 2) 인증 책임의 관심사 분리</h2>
<p>이 이슈를 이해하기 위해서는 인증과 관련된 의존성인 <strong>스프링 시큐리티</strong>의 역할을 이해하고 넘어가야 했다. 기본적으로 <span style='background-color:#dcffe4'>스프링 시큐리티는 <strong><code>Authentication</code> 인터페이스</strong> 기반으로 관리되는 <strong>인증 객체</strong>를 통해 디스패처 서블릿 통과 여부를 검증</span>한다. 이 말인 즉슨, 인증 객체의 생성 로직에 대해 통제하고 있으면 된다.</p>
<h3 id="disc-인증-객체의-생성-로직">disc. 인증 객체의 생성 로직</h3>
<p>스프링 시큐리티에서의 인증 객체 생성은 보통 다음과 같이 이뤄진다.</p>
<pre><code class="language-java">// ...

        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(createAuthentication(username));
        SecurityContextHolder.setContext(context);
    }

    // Authentication 객체 생성 (UPAT 생성)
    private Authentication createAuthentication(String username) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }</code></pre>
<p><code>username</code> 혹은 기타 식별 정보를 바탕으로 <code>UserDetails</code> 객체를 조회한 후, <span style='background-color:#dcffe4'>사용자의 <code>username</code>과 <code>authority</code>를 바탕으로 <code>UsernamePasswordAuthenticationToken</code> 객체를 생성</span>한다. 즉, 캐시에 존재해야 할 데이터에 <strong><code>username</code></strong>과 <strong><code>authority</code></strong>를 포함시켜야 한다.</p>
<p>이 정보를 갖고 올 수 있는 방법으로 <strong>세션 인증</strong>, <strong>JWT 인증</strong> 등 다양한 인증 수단을 강구할 수 있는 것이다. 결론은, <span style='background-color:#dcffe4'><strong>&#39;인증&#39;과 &#39;인증 수단&#39;은 분리</strong>해서 구현</span>하는 것이며, 분리의 단위를 <strong>MSA 인스턴스</strong>로 삼아 별개의 <strong>인증 수단 인스턴스</strong> 구축으로 삼았다.</p>
<h3 id="a-인증-정보-비동기적-수신-처리결정">A. 인증 정보 비동기적 수신 처리(결정)</h3>
<h4 id="nt-kafka는-비동기-스트리밍에-최적화된-도구">N.t. Kafka는 비동기 스트리밍에 최적화된 도구</h4>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/d7f1521b-f9a2-4980-b06f-28a1995e7d2b/image.png" alt=""></p>
<p><span style='background-color:#dcffe4'>API Gateway는 보통 <strong>Netty</strong> 엔진을 기반으로 구축</span>된다. 그 이유는 성능과 확장성을 극대화하고, 비동기 요청 처리에 필요한 요구 사항을 충족시키기 위해서다. 인스턴스 간의 통신 수단을 <strong>Kafka(다음 포스팅에서 후술)</strong>로 구축한 시점이기 때문에, 자연스럽게 Kafka를 통한 인증 인스턴스와 API Gateway 간의 인증 정보 송수신을 생각했다.</p>
<pre><code class="language-java">// Auth Filter in API Gateway

// 인증 요청 Kafka 전송
return kafkaProducerTemplate.send(topic, id.toString(), tokenDTO)
        .then(Mono.defer(() -&gt; {
            // 인증 응답 대기
            return kafkaReceiver
                    .receive()
                    .filter(record -&gt; record.key().equals(id.toString()))
                    .next()
                    .map(ConsumerRecord::value)
                    .flatMap(userInfoDTO -&gt; {
                        // ...</code></pre>
<p>다만 이것의 문제는 Kafka의 비동기성 및 이벤트 스트리밍 취지를 위배하는 것이었다. 실제로 요청 - 응답 모델의 형식으로 구축되는 시점에서 Locust로 테스트를 수행했을 때, <strong>인증을 전제로 하는 로그아웃 요청이 전부 실패</strong>하는 테스트 결과를 받았다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/ebf8e0f1-abf1-45f2-874a-c82ef224dfc3/image.png" alt=""></p>
<p style="text-align:center; font-size:15px; color:#808080;">Kafka 요청 - 응답 모델 테스트</p>

<p>위의 Locust 테스트 결과를 보면 로그아웃(<code>/api/users/logout</code>) 요청이 전부 실패했으며 그 원인은 서버 내부의 에러(500)임을 알 수 있는데, 애시당초 비동기에 특화된 Kafka를 요청 - 응답이라는 블로킹 방식으로 활용하려다가 문제가 발생했던 것이다.</p>
<h4 id="nt-redis의-최대-장점은-고속-성능">N.t. Redis의 최대 장점은 고속 성능</h4>
<p>해당 이슈는 Kafka 포스팅에서 자세히 후술하도록 하고, 여튼 이런 문제로 Kafka의 응답 부분을 떼어낸 후, <span style='background-color:#dcffe4'>인증 인스턴스의 결과를 <strong>Redis에 바로 저장해서 캐시로 활용</strong>함과 동시에 <strong>API Gateway에서 직접 캐시를 조회</strong>하는 방식</span>으로 수정, 구현했다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/d6e04c8d-bba5-4ec8-a2e8-a6c2a81f03f7/image.png" alt=""></p>
<pre><code class="language-java">// Auth Filter in API Gateway

// 인증 요청 Kafka 전송
return kafkaProducerTemplate.send(topic, tokenDTO)
        .flatMap(result -&gt; {
            // Kafka에 메시지 전송 후 Redis에서 결과를 비동기적으로 조회
            return checkRedisForUserInfo(id)
                    .flatMap(userInfoDTO -&gt; {
                    // ...</code></pre>
<p>여기서 고려할 점으로 <span style='background-color:#dcffe4'>API Gateway의 Redis 조회가 인증 작업보다 빠른 것에 대한 대비책</span>인데, 엑세스 토큰의 파싱 속도보다 API Gateway의 조회 속도가 더 빠를 경우 아래와 같은 로그를 출력할 수도 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/ba3d32d2-d0c9-40ec-a8fa-e1d2d1dd719a/image.png" alt=""></p>
<p style="text-align:center; font-size:15px; color:#808080;">API Gateway의 조회와 엑세스 토큰 파싱의 비정합성</p>

<p>그렇기 때문에 아래와 같은 수신 대기 메소드를 추가로 작성해서 활용하였다. 0.05초 간격으로 재시도 로직을 수행해서 인증 정보를 받아올 수 있도록 처리함과 동시에, 최대 타임아웃으로 10초를 설정하여 인증 로직 혹은 Redis에 문제가 발생해 캐시가 조회되지 않으면 예외를 내뱉도록 처리했다.</p>
<pre><code class="language-java">// redis 캐시 조회 메소드
private Mono&lt;UserInfoDTO&gt; checkRedisForUserInfo(String id) {
    return Mono.defer(() -&gt; {
            UserInfoDTO userInfo = userInfoTemplate.opsForValue().get(REDIS_ACCESS_KEY + id);

                if (userInfo != null) {
                    log.info(&quot;Redis에서 사용자 정보 조회 성공 - UserInfo: {}&quot;, userInfo);  // 성공 시 로그 추가

                    if (!id.equals(userInfo.getId())) {
                        userInfoTemplate.delete(REDIS_ACCESS_KEY + id);
                        userInfoTemplate.opsForValue().set(REDIS_ACCESS_KEY + userInfo.getId(), userInfo, 120 * 30, TimeUnit.SECONDS);
                    }

                    return Mono.just(userInfo);
                } else {
                    log.info(&quot;Redis에서 사용자 정보가 없음 - ID: {}\n 조회하는 시간: {}&quot;, id, System.nanoTime());  // 데이터가 없는 경우 로그 추가
                    return Mono.empty();
                }
            })
            .repeatWhenEmpty(flux -&gt; flux
                    .delayElements(Duration.ofMillis(50)) // 0.05초 간격으로 재시도
                    .take(200)) // 재시도
            .timeout(Duration.ofSeconds(10)) // 타임아웃 설정
            .onErrorResume(e -&gt;
                    Mono.error(new ResponseStatusException(
                            HttpStatus.INTERNAL_SERVER_ERROR, &quot;레디스 유저 임시정보 조회 에러&quot;, e)));
    }</code></pre>
<p><span style='background-color:#dcffe4'>Redis에 캐시가 있을 때는 굳이 인증 인스턴스를 거치지 않고 바로 요청을 라우팅하고, 캐시가 존재하지 않으면 그제서야 인증 인스턴스로 Kafka를 통해 송신해서 인증 정보를 캐시 조회로 받아오는 방식</span>이기 때문에 아래와 같은 시나리오를 볼 수 있다. 이를 통해 인프라적 개선을 이끌어낼 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/92749318-3c16-409b-9a0b-8a8fbe9208f5/image.png" alt=""></p>
<p style="text-align:center; font-size:15px; color:#808080;">Redis에 캐시가 존재할 경우</p>


<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/006c53f0-acb7-4bdd-9856-e2032e3f44af/image.png" alt=""></p>
<p style="text-align:center; font-size:15px; color:#808080;">Redis에 캐시가 존재하지 않을 경우</p>

<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/2fa9ded0-841a-4b5e-a420-4f760f6a83f8/image.png" alt=""></p>
<p style="text-align:center; font-size:15px; color:#808080;">Kafka 전파 - Redis 캐시 조회 모델 테스트</p>


<h2 id="issue-3-엑세스-토큰의-조회-키-활용">Issue 3) 엑세스 토큰의 조회 키 활용</h2>
<p>기본적으로 JWT 인증에서 엑세스 토큰은 클라이언트에 노출됨을 전제로 한다. 그렇기 때문에 엑세스 토큰에 들어가는 내용은 사용자의 이메일 등, 공개가 전제된 정보들을 바탕으로 생성한다. 그렇다고 한들 엑세스 토큰이 제3자에게 노출되는 것은 공격의 빌미를 제공하고 보안 취약점을 유발하는 것이기 때문에 <span style='background-color:#dcffe4'>로직 내에서의 활용에 있어 노출을 최대한 지양하는 방법</span>에 대해 고민했다.</p>
<h3 id="disc-redis-조회-키-설정">disc. Redis 조회 키 설정</h3>
<p>Redis에서 데이터를 조회하기 위한 키를 고민했다. <span style='background-color:#dcffe4'>클라이언트에 노출되면서 각 사용자별로 식별값을 가질 수 있는 엑세스 토큰(혹은 기반 키)을 Redis 조회 키</span>로 삼는다. 이를 API Gateway에서는 캐시를 조회하는 Redis 키로 활용하는 방식을 설계했다.</p>
<p>하지만 위에서 언급했듯이 엑세스 토큰이 로직 활용에 있어 대놓고 노출되는 느낌이 너무 강해서 이것을 최대한 방지하고 싶어서 Redis 조회 키 설정 기준에 대해 고민하였다.</p>
<blockquote>
<h4 id="1-각-사용자별로-고유한-값">1. 각 사용자별로 고유한 값</h4>
</blockquote>
<h4 id="2-키를-통해서는-해당-사용자가-누군지-추측할-수-없어야-함">2. 키를 통해서는 해당 사용자가 누군지 추측할 수 없어야 함</h4>
<p>상당히 모순적(...)인 기준인데, 이 기준과 관련해서 <span style='background-color:#dcffe4'><strong>디바이스 핑거프린트</strong></span>라는 개념을 접하고 키의 암호화에 적용하는 메소드를 구축했다.</p>
<h3 id="a-디바이스-핑거프린트-메소드-작성결정">A. 디바이스 핑거프린트 메소드 작성(결정)</h3>
<pre><code class="language-java">// 디바이스 핑거프린트 생성 메소드
private String createFingerPrint(String token) {
    String data = key + token;

    try {
        MessageDigest digest = MessageDigest.getInstance(&quot;SHA-256&quot;);
        byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8));

        // 바이트 배열 16진수 문자열로 변환
        StringBuilder hexString = new StringBuilder();
        for (byte b : hash) {
            String hex = Integer.toHexString(0xff &amp; b);
            if (hex.length() == 1) hexString.append(&#39;0&#39;);
            hexString.append(hex);
        }

        return hexString.toString();
    } catch (NoSuchAlgorithmException e) {
        throw new RuntimeException(&quot;해시 알고리즘 탐색 불가&quot;, e);
    }
}</code></pre>
<p><span style='background-color:#dcffe4'>엑세스 토큰과 암호화 키워드를 조합한 후, SHA-256 해시 알고리즘을 기반으로 결합된 문자열을 바이트 배열로 변환하여 해시를 생성</span>한다. 이떄, 해시 생성은 <code>MessageDigest</code> 클래스를 활용한다. 생성된 해시 바이트 배열을 16진수 문자열로 변환해서 각 바이트를 16진수로 변환하고, 만약 한 자리 수인 경우에는 앞에 &#39;0&#39;을 추가하여 두 자리로 만들어 일관된 형식을 유지한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[실시간 통신 트래픽 제어(5) - MSA Plan]]></title>
            <link>https://velog.io/@kim00ngjun_0112/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%86%B5%EC%8B%A0-%ED%8A%B8%EB%9E%98%ED%94%BD-%EC%A0%9C%EC%96%B45-MSA-Configuration</link>
            <guid>https://velog.io/@kim00ngjun_0112/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%86%B5%EC%8B%A0-%ED%8A%B8%EB%9E%98%ED%94%BD-%EC%A0%9C%EC%96%B45-MSA-Configuration</guid>
            <pubDate>Wed, 25 Sep 2024 11:21:41 GMT</pubDate>
            <description><![CDATA[<h1 id="msa-configuration">MSA Configuration</h1>
<p>모놀리식 서버 기반의 성능 테스트를 진행하고 MSA 리팩토링을 계획했지만, 사실 성능의 개선 폭은 엄청 높지는 않을 것 같았다. 왜냐하면 앱의 구조가 단순하기 때문이다. 그래도 MSA 환경에서의 테스트 및 해당 환경에서의 모니터링과 기능 확장을 통한 성능의 수직, 수평적 확장에 대한 방향을 익히는 것이 주 목적이기 때문에 천천히 나아가볼 예정.</p>
<h2 id="1-msa">1. MSA</h2>
<p><span style='background-color:#dcffe4'><strong>MSA</strong>는 <strong>Microservice Archietecture</strong>의 약자로, 애플리케이션을 느슨히 결합된 서비스의 모임으로 구조화하는 서비스 지향 아키텍처(SOA) 스타일의 일종인 소프트웨어 개발 기법</span>이다. 여기서 핵심은, 애플리케이션을 <strong>느슨히</strong> 결합한다는 것이다. 왜 느슨히 결합해야 하는지, 이것이 왜 핵심인지는 MSA와 대조되는 개념인 <strong>모놀리식(Monolithic) 아키텍처</strong>와 비교하면 좋다.</p>
<h3 id="1-msa-vs-monolithic-architecture">1) MSA vs Monolithic Architecture</h3>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/bdbff07f-0275-4b69-b17e-e818ab183dcf/image.png" alt=""></p>
<p>흔히 개발하면 생각할 수 있는 방식인 모놀리식 구조는 <span style='background-color:#dcffe4'>모듈별로 개발을 하고, 개발이 완료된 웹 어플리케이션을 하나의 결과물로 패키징 하여 배포되는 형태</span>다. 모듈 단위의 기능 개발이기 때문에 하나의 프로세스 내에서 개발, 테스트, 배포가 처리된다. 하나의 프로세스라는 관점은 심플함이라는 장점을 지니지만, <span style='background-color:#dcffe4'>강하게 묶여있기 때문에 기능 단위의 영향이 곧 전체 프로세스의 여파</span>로 미치게 된다. 예를 들면 <strong>기능 하나의 작은 부분에 에러가 생기면 전체 프로세스의 강제 종료</strong>로 이어질 가능성이 존재한다.</p>
<p>위에서 언급한 느슨한 결합이, 모놀리식의 치명적인 단점을 커버하는 MSA의 장점으로 발휘될 수 있다는 것이다. 구체적으로 들어가면 다양한 전략이 존재하겠지만 마이크로서비스 인스턴스 간의 결합도가 낮아짐으로써 <span style='background-color:#dcffe4'>기능의 독립성이 확보되고 예외 상황의 대처법이 다양해지면서 기능의 확장을 쉽게 고려</span>할 수 있다.</p>
<h3 id="2-msa-자체로-성능-개선이라-할-수-있는가">2) MSA 자체로 성능 개선이라 할 수 있는가?</h3>
<p>당연히 아니겠지만, 조금 더 정립하기 위해서 내가 알고 있는 개념과 현재 진행 중인 프로젝트의 현황을 감안해서 자체적으로 사고 논의를 해보고 결론을 정리해봤다. 덤으로 기업의 입장에서 어떤 아키텍처의 선택이 이득일 지를 같이 생각하면서 논의해봤다.</p>
<blockquote>
<h3 id="찬성-측-결론">찬성 측 결론</h3>
</blockquote>
<ul>
<li><strong>확장성 및 유연성</strong>: 개별 서비스 확장 및 성능 최적화 용이. 특정 서비스만 성능 개선 가능.</li>
<li><strong>장기적인 비용 절감 및 수익성 향상</strong>: 초기 비용 증가 가능성 있으나, 장기적으로 운영 효율성 및 유지보수 비용 절감.</li>
<li><strong>간접적 성능 개선 가능성</strong>: MSA가 TPS나 RPS 같은 성능 지표를 즉각적으로 향상시키지는 않으나, 병렬 처리 및 서비스 분리를 통해 장기적으로 성능 개선 가능.</li>
</ul>
<blockquote>
<h3 id="반대-측-결론">반대 측 결론</h3>
</blockquote>
<ul>
<li><strong>직접적인 성능 개선 한계</strong>: MSA 전환만으로는 성능 지표의 즉각적인 향상은 기대할 수 없음.</li>
<li><strong>초기 비용 및 관리 부담</strong>: 초기 인프라 투자와 관리 복잡성 증가. 성능 개선보다 관리 부담이 커질 가능성 있음.</li>
<li><strong>성능 개선과 다른 접근</strong>: MSA는 성능보다는 독립성, 관리 편리성, 유연성을 목표로 하며, 성능 개선을 위해서는 추가적인 코드 최적화 필요.</li>
</ul>
<blockquote>
<h3 id="종합-결론">종합 결론</h3>
<p>MSA는 확장성, 유연성, 관리 효율성 등에서 장점이 있으며, 성능 개선을 직접 보장하지는 않지만, 장기적으로 운영 효율성 증가로 인해 성능 향상이 가능할 수 있음.</p>
</blockquote>
<p>내가 집중한 부분은 <span style='background-color:#dcffe4'>수평적 확장의 용이함</span>이었다. 채팅 앱의 특성 상, 단순 메세징 외에도 알람, 구독 관리 등의 추가 기능을 생각할 수 있기 때문에 덧붙일 기능을 감안해서 MSA 아키텍처의 전환은 장기적으로 이득을 가지고 올 것으로 생각했다.</p>
<h3 id="3-msa-migration-plan">3) MSA Migration Plan</h3>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/6197c29f-f548-4363-b768-d2a48db72336/image.png" alt=""></p>
<p>모놀리식 서버에서 기능 단위로 인스턴스를 분리하며, 상호 간의 통신은 필요하면 카프카를 활용한다. <span style='background-color:#dcffe4'>기능이라 함은 단순 도메인 단위가 아닌, <strong>최대한의 추상화</strong>를 통한 <strong>관심사 분리</strong>를 실현</span>한다. 예를 들어, <span style='background-color:#dcffe4'>기존 모놀리스에서는 회원 엔티티에 JWT 기반 인증 서비스 로직까지 강하게 얽혀있었다. 이것을 우선, <strong>인증 방식에는 JWT 외에도 다양한 인증 방식이 있으므로, JWT 검증과 회원 로직을 분리</strong></span>한다.</p>
<h2 id="2-spring-cloud">2. Spring Cloud</h2>
<p>스프링 클라우드는<span style='background-color:#dcffe4'> <strong>마이크로서비스 아키텍처(MSA)</strong>를 쉽게 구현하고 운영할 수 있도록 도와주는 프레임워크</span>다. 주요 기능은 서비스 디스커버리, API 게이트웨이, 분산 설정 관리 등을 지원하며, 이를 통해 마이크로서비스 간의 통신, 장애 대응, 로드 밸런싱 등을 손쉽게 처리할 수 있게 한다.</p>
<h3 id="1-구성-요소-개요">1) 구성 요소 개요</h3>
<blockquote>
</blockquote>
<ol>
<li><strong>Spring Cloud Netflix Eureka</strong><ul>
<li><strong>서비스 디스커버리</strong> 기능을 제공. 마이크로서비스들이 동적으로 서로를 찾을 수 있도록 돕는 레지스트리 역할.<blockquote>
</blockquote>
</li>
</ul>
</li>
<li><strong>Spring Cloud Config</strong><ul>
<li><strong>분산 설정 관리</strong>. 중앙 저장소에서 애플리케이션 설정을 관리하고, 각 서비스에서 필요할 때 불러올 수 있게 해줌.<blockquote>
</blockquote>
</li>
</ul>
</li>
<li><strong>Spring Cloud Gateway</strong><ul>
<li><strong>API 게이트웨이</strong> 역할. 요청을 라우팅하고, 인증/인가, 로깅, 필터링 등의 기능을 제공.<blockquote>
</blockquote>
</li>
</ul>
</li>
<li><strong>Spring Cloud OpenFeign</strong><ul>
<li><strong>HTTP 클라이언트</strong>로서, 마이크로서비스 간 통신을 쉽게 처리할 수 있도록 선언적 방식으로 HTTP 호출을 지원.<blockquote>
</blockquote>
</li>
</ul>
</li>
<li><strong>Spring Cloud Sleuth</strong><ul>
<li><strong>분산 트레이싱</strong>을 제공하여, 마이크로서비스 간 호출 흐름을 추적하고 성능을 분석할 수 있음.<blockquote>
</blockquote>
</li>
</ul>
</li>
<li><strong>Spring Cloud Circuit Breaker</strong><ul>
<li><strong>서킷 브레이커</strong> 패턴을 구현하여, 장애 발생 시 시스템을 보호하고 복구 가능한 구조를 만듦.</li>
</ul>
</li>
</ol>
<p>MSA를 위해 내가 도입한 스프링 클라우드 의존성은 <strong>API Gateway, Config, Eureka</strong> 이렇게 총 3가지이며, 각각의 인스턴스 간의 통신은 다음 포스팅에서 후술할 <strong>Kafka</strong>를 통해 데이터 정합성을 맞추려고 한다.</p>
<h3 id="2-spring-cloud-config">2) Spring Cloud Config</h3>
<p><span style='background-color:#dcffe4'>Spring Cloud Config는 분산 시스템에서 외부화된 설정 정보를 서버 및 클라이언트에게 제공하는 시스템</span>이다. <strong>설정 서버</strong>는 외부에서 모든 환경에 대한 정보들을 관리해주는 중앙 서버이고, <strong>설정 클라이언트</strong>. 기본적으로 설정 정보 저장을 위해 <strong>Git</strong>을 사용하도록 되어있어서 손쉽게 외부 도구들로 접근 가능하고, 버전 관리도 가능하다.</p>
<p>물론 Git 외에도 다른 도구들도 있지만, 활용 및 관리가 편하고 즉각적인 반영이 쉽다는 부분에서 나는 Git을 활용하였다.</p>
<p>우선, <strong>설정 정보를 관리할 서버</strong>를 구성해야 한다. 해당 서버에게 필요한 의존성 및 어노테이션 할당은 다음과 같다.</p>
<pre><code class="language-java">dependencies {
    implementation &#39;org.springframework.cloud:spring-cloud-config-server&#39;

// ...</code></pre>
<pre><code class="language-java">@EnableConfigServer
@SpringBootApplication
public class ChatConfigServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(ChatConfigServerApplication.class, args);
    }

}</code></pre>
<p>사실 이것만 하면 끝(...)이긴 한데, 이러면 너무 간단해지기 떄문에, 스프링 시큐리티를 통한 인메모리 인증으로 해당 서버 기반의 UI 접근에 대한 보안을 구축할 수도 있다.</p>
<p>중요한 것은, 이제 해당 서버가 중앙 관리의 역할을 맡게 하기 위한 설정 정보를 세팅하고 각 클라이언트가 접근할 수 있도록 해야 한다. 무슨 말이냐면, <span style='background-color:#dcffe4'>설정 정보는 보통 중요하고 민감한 내용을 담고 있고, 이를 한 번에 관리하기 위해 <strong>중앙 관리를 위한 저장소</strong>를 별개로 구축</span>해야 한다. 여기서 아까 언급한 Git이 편하다고 한 이유는, Git의 레포지토리를 생성해서 해당 저장소를 private 등으로 세팅하면 아까 말한 설정 정보 저장소를 마련할 수 있게 되는 것이다.</p>
<p>정리하자면, <span style='background-color:#dcffe4'>설정 서버는 설정 정보 저장소를 접근하기 위한 일종의 관문 역할이 되며, 그 관문의 열쇠 역할을 하는 정보를 설정 정보의 <code>yml</code> 파일 등에 세팅</span>하는 것이다. 언급한 열쇠 내용을 작성하는 방법은 다양하다. 보통은 <strong>비대칭 키</strong>를 활용하는데, 나는 <strong>Git에서 제공하는 토큰</strong>을 기반으로 작성했다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/30c5d7e7-8fc4-476f-b90e-16b2b6750635/image.png" alt=""></p>
<pre><code class="language-yml"># config server

spring:
  cloud:
    config:
      server:
        git:
          username: kimD0ngjun
          password: # 깃 토큰 혹은 비대칭 공개키 암호
          uri: # 설정 정보 저장소 링크</code></pre>
<p>이렇게 작성 후, 각각의 인스턴스의 설정 파일은 설정 정보 저장소의 명명 규칙에 따라 <strong>명칭</strong>과 <strong><code>profiles active</code></strong>를 작성하고 설정 서버의 도메인을 <code>import</code>한다.</p>
<pre><code class="language-yml"># config client

spring:
  application:
    name: user

  profiles:
    active: dev

  config:
    import: # config server domain</code></pre>
<h3 id="3-spring-cloud-eureka">3) Spring Cloud Eureka</h3>
<p><span style='background-color:#dcffe4'>Spring Cloud Eureka는 마이크로서비스 환경에서 <strong>서비스 디스커버리(Service Discovery)</strong>를 담당하는 구성 요소</span>다. 넷플릭스에서 오픈 소스로 공개한 유레카 서버와 클라이언트를 기반으로 하며, 마이크로서비스들이 서로를 쉽게 찾고 통신할 수 있게 해준다.</p>
<p>주의할 점은, <strong>Prometheus</strong> 같은 모니터링 툴과는 다르다는 것이다. 각 서비스의 성능, 자원 사용량, 트래픽 같은 메트릭스 데이터를 수집하고 시각화하는 데 사용되며, 서비스의 메트릭 데이터를 수집하여 모니터링하고 알람을 설정해 시스템의 상태를 실시간으로 파악할 수 있게 한다.</p>
<p>Eureka는 마이크로서비스 인스턴스의 헬스 체크 정도에 집중하며, 모니터링의 목적보다는 인스턴스 간의 상호 조율에 더 집중하며 <span style='background-color:#dcffe4'><strong>Ribbon</strong>과 연동한 로드밸런싱을 제공</span>한다.</p>
<p>우선 Eureka 서버를 생성해야 하는데, 역시나 간단하다. 의존성 추가하고 어노테이션 할당하면 끝(...)</p>
<pre><code class="language-java">dependencies {
    implementation &#39;org.springframework.cloud:spring-cloud-starter-netflix-eureka-server&#39;

// ...</code></pre>
<pre><code class="language-java">@EnableEurekaServer
@SpringBootApplication
public class ChatServerEurekaApplication {

    public static void main(String[] args) {
        SpringApplication.run(ChatServerEurekaApplication.class, args);
    }

}</code></pre>
<p>Eureka의 서버와 클라이언트 간의 제어에 대한 설정은 Eureka 서버의 설정 파일(<code>yml</code>)에서 수행할 수 있다. </p>
<pre><code class="language-yml">eureka:
  client:
    register-with-eureka: false  # 유레카 서버 자신을 다른 유레카 서버에 등록하지 않음
    fetch-registry: false         # 유레카 서버가 다른 유레카 서버로부터 레지스트리 정보를 가져오지 않음

  server:
    enable-self-preservation: false           # Self-Preservation 모드 비활성화, 비정상 인스턴스 즉시 제거
    response-cache-update-interval-ms: 5000   # 응답 캐시를 5초마다 업데이트
    eviction-interval-timer-in-ms: 10000      # 10초마다 비정상 인스턴스 제거
</code></pre>
<p>이제 Eureka 클라이언트를 활성화해서 인스턴스를 클라이언트로 등록해야 한다. 각각의 인스턴스마다 의존성을 추가하고 어노테이션을 할당해준다.</p>
<pre><code class="language-java">dependencies {
    implementation &#39;org.springframework.cloud:spring-cloud-starter-netflix-eureka-client&#39;

// ...</code></pre>
<pre><code class="language-java">@EnableDiscoveryClient
@SpringBootApplication
public class ChatServerUserApplication {

    public static void main(String[] args) {
        SpringApplication.run(ChatServerUserApplication.class, args);
    }

}</code></pre>
<p>위의 과정까지 따라가면 정상적으로 인스턴스를 Eureka 클라이언트로써 등록할 수 있다. 나는 아직 인프라의 규모가 그리 크지 않아서 로드밸런싱 세팅까지 나아가진 않았는데, 향후 계획 중에 있다. <span style='background-color:#dcffe4'>Eureka 서버는 UI를 통해 등록한 Eureka 클라이언트의 관리</span>를 쉽게 할수 있다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/de70e686-19d0-4f34-b025-d507e593a434/image.png" alt=""></p>
<h3 id="4-spring-cloud-api-gateway">4) Spring Cloud API Gateway</h3>
<p>MSA에서 인스턴스로 분리하게 되면, 보통은 서버의 도메인이 달라지기 마련이다. 자연스럽게 클라이언트에서는 도메인이 여러 개인 것을 관리하는 것에 대한 책임이 커질 수 있기 떄문에 <span style='background-color:#dcffe4'>요청 경로에 따라 맞는 인스턴스로 경로를 재지정해 흘려보내주는 Spring API Gateway</span>가 맡는다.</p>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/b67c255e-c3e3-4279-9810-6f99f5fd913b/image.png" alt=""></p>
<p>API Gateway는 마이크로서비스 인스턴스의 전면부에 나서 클라이언트의 요청을 받아오며 필터 등을 거쳐 경로 별로 각 인스턴스에 흘려보낸다. 필터 구성을 통해 인스턴스 별로 로직을 다양화시킬 수 있는 <strong>분리</strong>의 역할과 CORS 설정을 전역화할 수 있는 <strong>통합</strong>의 역할을 동시에 맡는다. 물론 개별적으로 CORS 설정도 가능하다.</p>
<p>통상적으로 스프링 프레임워크에서의 API Gateway 세팅은 <span style='background-color:#dcffe4'>톰캣이 아닌 네티 기반으로 세팅</span>한다. 이는 API Gateway의 모듈들이 Spirng WebFlux에서 동작하도록 설계됐는데, WebFlux는 기본적으로 리액티브 프로그래밍을 전제로 한다. 왜냐하면 <span style='background-color:#dcffe4'>요청이 몰려오는 것에 대한 <strong>논블로킹 처리</strong> 및 <strong>비동기 이벤트 루프</strong>를 통한 빠른 응답과 확장성</span>을 위해서다.</p>
<pre><code class="language-java">dependencies {
    implementation &#39;org.springframework.cloud:spring-cloud-starter-gateway&#39;
    implementation &#39;org.springframework.cloud:spring-cloud-function-context&#39;

// ...</code></pre>
<p><img src="https://velog.velcdn.com/images/kim00ngjun_0112/post/466bbe4f-d91b-4c32-8773-ff0c5a730cd8/image.png" alt=""></p>
<hr>
<p>상세한 코드 및 관련한 트러블 슈팅 정리는 다음 포스팅에:)</p>
]]></description>
        </item>
    </channel>
</rss>