<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ohzzi.log</title>
        <link>https://velog.io/</link>
        <description>Backend Developeer</description>
        <lastBuildDate>Mon, 08 Jan 2024 17:46:04 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ohzzi.log</title>
            <url>https://velog.velcdn.com/images/ohzzi/profile/bad83579-102a-467f-9c6a-3c99cb602da8/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ohzzi.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ohzzi" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[이제 1년 된 유소년 개발자의 2023년 회고록]]></title>
            <link>https://velog.io/@ohzzi/%EC%9D%B4%EC%A0%9C-1%EB%85%84-%EB%90%9C-%EC%9C%A0%EC%86%8C%EB%85%84-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9D%98-2023%EB%85%84-%ED%9A%8C%EA%B3%A0%EB%A1%9D</link>
            <guid>https://velog.io/@ohzzi/%EC%9D%B4%EC%A0%9C-1%EB%85%84-%EB%90%9C-%EC%9C%A0%EC%86%8C%EB%85%84-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9D%98-2023%EB%85%84-%ED%9A%8C%EA%B3%A0%EB%A1%9D</guid>
            <pubDate>Mon, 08 Jan 2024 17:46:04 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ohzzi/post/36840702-9f99-4f25-9014-c962a9b8d291/image.png" alt=""></p>
<p><a href="https://velog.io/@ohzzi/%EB%B3%91%EC%95%84%EB%A6%AC-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-%EB%8F%8C%EC%95%84%EB%B3%B4%EB%8A%94-2022%EB%85%84">2022년 회고</a>를 쓴 것이 엊그제 같은데, 벌써(라기엔 이미 2024년이 되어버렸나요) 2023년 회고를 쓰게 되었습니다. 시간이란게 참 빠르게 지나가네요. 지난 회고에 작성한 것 처럼 저는 토스뱅크에 합격하게 되어 일하고 있습니다. 1년차 개발자의 삶을 시작했던 제가 어느덧 1년이라는 시간을 맞이했습니다. 일이 바쁘다는 핑계로 무려 9개월동안이나 블로그에 포스팅을 하지 않았던 저입니다만.. 그래도 간략하게라도 2023년을 돌아보는 시간을 가져보는게 좋을 것 같아 이렇게 키보드를 두드려봅니다.</p>
<h1 id="2023년-떼어놓을-수-없는-이야기-토스뱅크">2023년 떼어놓을 수 없는 이야기, 토스뱅크</h1>
<p>제가 첫 출근을 한 것은 2023년 1월 16일이었습니다. 아직도 기억나네요. 첫 출근에 설레서 출근해야 하는 시간보다 30분 넘게 일찍 도착해서 건물 1층에 어리둥절하고 앉아있던 제가 생각납니다. 치열한 환경 속에서 일하며 가치를 만들고 제 스스로를 성장시키고 싶었던 욕심이 컸기에, 저는 다른 어느 회사보다도 토스에 가고 싶었습니다. 그랬기 때문에 첫 출근 날이 더더욱 설레지 않았나 싶어요.</p>
<p>토스에는 메이트라는 제도가 있는데요, 메이트는 신규 입사자의 온보딩을 도와주는 일종의 짝꿍이라고 보면 될 것 같습니다. 저는 같은 스쿼드 - 일반적으로 토스는 사일로라는 조직 단위를 씁니다만, 토스뱅크에서는 스쿼드가 사일로를 대체합니다. - 의 서버 개발자분께서 메이트를 해주셨는데요. 토스뱅크 최고의 스윗가이 답게 제가 회사에 잘 적응하실 수 있도록 아낌없이 지원해주시고, 조그마한 PR이나 배포 하나하나에도 잘한다 잘한다 하고 기운을 북돋아주셨습니다. 같이 업무를 하면서 기술적으로나 커뮤니케이션적으로나 배울 점이 정말 많은 분이라는 생각이 들었습니다.</p>
<p>토스뱅크 면접을 준비하면서, 그리고 붙은 이후로도 수 없이 들려온 이야기가 토스에서는 신입에게 신입의 역량을 요구하지 않는다는 거였습니다. 곧바로 1인분을 하는 개발자가 되어야 한다는 것이죠. 대신 남들도 저를 신입이라는 시선으로 안보는 만큼 다른 회사 신입 개발자보다 훨씬 더 많은 것을 할 수 있다는 장점이 있습니다. 솔직히 그게 저를 불타오르게한 원동력 중 하나인 것 같습니다. 신입이라고 오구오구 케어받는 대신 프로덕트에 기여를 하는게 제한되어 있는 것 보다는, 제 능력을 갈아넣어 프로덕트를 성장시키는데 큰 기여를 하는 쪽을 더 원했기 때문에요. 이제 막 회사에 입사한 응애 개발자가 SLASH 영상에서 보던 능력자분들과 기술 토론을 한다? 솔직히 이거 못참죠 ㅋㅋ</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/eef1b1f1-2d61-4c58-83cb-93889798a484/image.png" alt=""></p>
<p>입사 초반의 저는 제가 우아한테크코스 1년 동안 배운 것들을 최대한 어필하고 이용하려고 노력했던 것 같습니다. 일단 우테코하면 가장 유명한 테스트. 사실 실무에서는 빠르게 개발을 하다 보면 테스트 코드를 촘촘히 작성하지 않거나 아예 작성하지 않고 넘어가는 경우가 있을 수 밖에 없는데요(대신 QA를 통해 이  부분을 많이 메우는 것 같더라고요), 심지어 저희 스쿼드는 제가 입사하기 전엔 서버 개발자가 한 분이셔서 더욱 테스트를 꼼꼼히 짜기 어려운 환경이었습니다. 저는 그래서 우테코에서 배운 습관대로 할 겸, 도메인 파악도 할 겸 입사하자마자 테스트 커버리지를 높이는데 집중했던 기억이 납니다.</p>
<p>그리고 우테코 막판에 제가 의존성과 확장 가능한 코드에 꽂혀 있었는데요. 마침 저희 스쿼드는 여러 외부 제휴사와 비슷한 프로덕트를 가지고 연동할 일이 많은 스쿼드입니다. 제가 입사했을 당시에는 제휴사가 하나밖에 없었는데, 여러 제휴사(현재는 무려 5개)와 추가로 연동할 예정이 잡혀있던 상황이었습니다. 그래서 미리 확장하기에 좋은 구조를 만들어 놓으면 좋은 상황이었죠. 덕분에 제가 당시에 꽂혀 있던 부분을 원없이 경험할 수 있었습니다. 사실, 확장 가능한 코드라는건 지금도 굉장히 머리 싸매고 고민하는 포인트긴 합니다. 머지 않은 미래에 확장할 것이 눈에 들어오는데, 그걸 다 고려하고 짜자니 당장에 데드라인은 계속해서 저를 향해 달려오고 있거든요.</p>
<h1 id="일단-만들어라-그렇다고-막-만들지는-말고">일단 만들어라 <del>그렇다고 막 만들지는 말고</del></h1>
<p>그래서 이 부분에 대해 이야기 한 번 해보려고 합니다. 제가 처음으로 실무를 경험하면서 가장 크게 느낀 부분입니다. 좋은 구조 좋은 코드 탄탄한 테스트 물론 중요한데요, 이 모든 것은 좋은 프로덕트를 만들기 위해 중요한 것입니다. 아, 여기서 제가 말하는 프로덕트란 단순히 대고객으로 나가는 상품을 말하는 것이 아니라, 각자의 팀이 만들어야 하는 유무형의 결과물, 가치를 이야기하는 것입니다. 아무리 좋은 구조로 좋은 코드를 작성하고 있다고 해도, 그것이 결과물로 이어지지 못한다면 무슨 소용일까요. 특히나, 우린 기업으로부터 보수를 받고 결과물을 내야 하는 근로자입니다. 좋은 구조, 좋은 코드, 탄탄한 테스트가 중요하지 않다는 것이 아닙니다. 우선은 정상적으로 작동하는(당연히 치명적인 버그는 없어야 합니다!) 결과물을 만들어 내는 것이 더욱 중요하다는 것이죠.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/08ba58df-06bc-4e04-b522-0526d6667806/image.png" alt=""></p>
<p>물론 우테코에서 그렇게 배우긴 했습니다. 일단 돌아가는 코드를 만들고 리팩터링을 하라고요. 하지만, 아직 실무를 경험해보지 못한 당시의 저는 어쩔 수 없이 <code>애초부터 좋은 코드</code>에 대한 미련을 버리지 못했던 것은 아닐까 싶습니다.</p>
<p>아, 물론 애초부터 좋은 코드를 짜는 것도 중요한 것 같기는 합니다. 혼자서 개발 공부를 할 때보다 훨~씬 복잡한 요구사항들이 주어지고, 심지어 예상하지 못한 요구사항이 계속 추가되기 때문에, 애초에 잘 짜놓은 코드가 있으면 그런 요구사항들을 개발하는데 훨씬 도움이 되는 것 같긴 합니다. 얼마 전에 상품 하나를 개발하면서 많이 느끼기도 했습니다.</p>
<p>결국, 데드라인을 맞추면서 그 안에서 최대한의 퀄리티를 뽑아내는게 중요한 것 같고, 이 균형은 계속 업무를 해나가면서 체득할 수 밖에 없을 것 같습니다. 다행히도 계속 여러 상품을 출시해보면서 기한과 퀄리티 사이에서 어느 쪽에 더 무게추를 두어야 할 지 경험을 쌓고 있고, 문제를 차분하게 분석하면서 더 효율적으로 업무하는 법을 배워나가고 있습니다. 그동안은 이제 막 실무에 발을 디딘 1년차의 우당탕탕이라고 할 수 있지 않을까요? 이 우당탕탕이 2년차 3년차에도 계속되지만 않으면 될 것 같습니다.</p>
<h1 id="반성문">반성문</h1>
<p>반성할 것도 있습니다. 솔직히 저는 업무 외에도 개발 공부를 엄청 많이 할 줄 알았습니다. 그런데 그게 쉽지가 않았습니다. 사실 일하는 시간 외에 그렇게 많은 시간을 확보할 수 있는 것도 아닌데, 그 시간에 하고 싶은건 많은 접니다. 이런 와중에 개발 공부가 손에 잡힐리가 있나요?</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/685cb6f5-9730-4ee6-bf73-0dc3efd3b993/image.png" alt=""></p>
<p>네. 핑계입니다. 여가 시간 일부를 미래에 투자하는 것은 제가 그동안 잘 해왔던거면서 즐거워하던거였는데, 두둑해지는 지갑과 취준으로부터의 해방감이 그 즐거움을 잊게 만든 것 같습니다. 기술 블로그도 작년에는 그렇게 열심히 썼는데 회사에 들어가고 나서는 처음에 몇 건 쓰다가 아예 손을 놔버렸습니다. <del>그렇게 블로그 작성을 통해 공부한다고 해놓고는 어휴.</del> 물론 업무를 통해서 개발 실력을 쌓아나가고 있기는 하지만요. 다만 전 분명 그 이상으로 성장하는 개발자가 되고 싶었습니다. 지난 1년처럼 한다면 그 바람을 이루기는 요원해보입니다. 다행인건 요즘 들어서는 개발 공부가 다시금 재밌어지고 있다는 점입니다. 그래서 새해에는 기술 블로그도 다시 작성해보려고 합니다. 흠... 지킬 수 있겠죠?</p>
<p>그리고 음.. 개발자로서의 반성 외적으로는, 소비가 좀 무절제했던 것 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/3808f330-2792-4149-9df5-280b53df9d26/image.png" alt=""> </p>
<p>돈을 안모은건 아닌데... 쓰지 않아도 되었을 소비가 좀 많았던 것 같습니다. 여담으로 중고로 자동차를 하나 샀는데, 요건 후회 안합니다. <del>자차는 최고입니다. 여러분도 사세요.</del> 사실 주변 동료분들은 제 나이가 아직 어리니 괜찮다면서, 처음 몇 년은 그냥 맘껏 쓰고 한 2년 늦게 취업한 셈 치라고 하기도 했습니다. 어느 정도는 맞는 말인 것 같은데, 전 부자가 되고 싶어서 안되겠습니다. 소비와 저축, 투자를 좀 더 계획적으로 할 필요가 있는 2024년인 것 같습니다.</p>
<p>그리고 몸이 좀 불었습니다. 회사가 간식을 너무 잘 줍니다... 우스갯소리로 이게 다 개발주머니라고 하고 다니기는 하는데, 음, 이대로는 안될 것 같습니다. 건강에도 영 좋을게 없고, 체력적으로도 좋지 못합니다. 인생 최대의 몸무게를 찍고 있었는데, 새해에는 반드시 다이어트를 성공할 필요가 있을 것 같네요. 연말부터 운동을 다니고 있는데, 좋은 결과로 이어지면 좋겠습니다. 제발.</p>
<h1 id="그래도-이건-좀-잘했네">그래도 이건 좀 잘했네</h1>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/d231dffc-3d06-4488-a84d-0631b5b9a629/image.png" alt=""></p>
<p>그래도 한 해를 즐겁게 보낸 것은 같습니다. 사회인 야구를 시작했습니다. 마침 회사에 사회인야구단이 있는 덕분에요. 중학교 이후로 무려 십 년 동안이나 야구경기를 고대해왔는데, 직장 생활 시작과 함께 할 수 있게 되어 정말 기뻤습니다. 덕분에 매 주 주말을 땀흘리며 <del>매 주말 땀흘렸는데 위에서 몸이 불었다고 하는거면 대체...</del> 보냈습니다. 시간이 정말 빨리 가더라고요. 뭐랄까, 주말의 야구 경기 한 경기를 위해 월화수목금을 참고 견디는 느낌으로 살았습니다. 행복하더라고요.</p>
<p>밴드도 하나 시작했습니다. 회사에서 여름에 전사 워크샵을 갔었는데, 그걸 위해 사내에서 인원이 좀 모여서 몇 번 같이 연습하고 밴드 공연을 했었는데요, 거기서 좋은 인연이 되어 직장인 밴드를 하나 하게 되었습니다. 제가 사실 대학교 때 밴드부를 하긴 했는데 코로나 때문에 공연을 못했거든요. 한을 풀었습니다. 12월에 조그만한 펍에서 공연도 했는데, 너무 재미있었습니다. 앞으로도 계속 할 예정이니 공연 놀러오세요.</p>
<p><del>요게 잘한 일인지는 모르겠으나,</del> 얼라인먼트 데이에 MC를 보기도 했습니다.</p>
<p>토스는 한 학기(6개월)마다 얼라인먼트 위크라고 전사적으로 학기의 성과를 공유하는 자리가 있는데요. 무슨 자신감이었는지 피컬팀의 꼬임에 넘어가 뱅크 얼라인먼트 데이에 MC를 봤습니다. 엄청 큰 홀에서 진짜 수많은 사람들 앞 + 줌으로 생중계 되고 있는 자리에 섰었는데, 생각보다 떨리지는 않았습니다. MC 한 덕분에 회사에 이름 하나는 확실히 알렸습니다. 이게 좋은 건지는 잘 모르겠지만요 하핳... 지금 생각해보면 더 재미있게 할 수 있었는데 하는 아쉬움이 좀 남기도 합니다. 음... 퇴사 전에 한 번 쯤은 더 해보면 재미있을지도 모르겠네요?</p>
<p>그리고 뭐 몇 개 더 좋은 일들이 있었는데 여기에 쓸만한 내용은 아닌 것 같으니 생략하겠습니다 ㅋ.</p>
<h1 id="내-지난-1년의-점수는">내 지난 1년의 점수는?</h1>
<p>저는 100점 만점에 80점 주고 싶습니다. 반성해야 할 부분도 있었지만, 어쨌든 나름 치열하게 살아온 것 같기도 하고, 그 과정에서 배운 것도 많기 때문입니다. 그리고 일단 나름 재밌는 한 해였습니다. 재밌는 일들이 많았기 때문에요. 그리고 워낙 분위기 좋은 팀에 들어가서 직장생활에서 스트레스보다는 재미와 기쁨을 - 특히나 동료들과의 관계에서도 - 느꼈다는 점에서 만족스러웠습니다.</p>
<h1 id="2024년-회고를-쓰는-나에게">2024년 회고를 쓰는 나에게</h1>
<p>솔직히 요즘 주변으로부터 자극을 많이 받고 있습니다. 원래도 주변 동료들이 정말 대단한 개발자라는 생각이 강했었는데요, 요즘 향락에서 빠져나와 다시 개발 공부를 부여잡아보니 그런 생각이 더 강해지는 것 같습니다. 정말 좋은 환경입니다. 배울 점이 많은 동료들이 있고, 그들과 연차를 가리지 않고 이야기를 나눌 수 있습니다. 주니어든 시니어든 구분없이 자기의 개발 지식과 개발 철학에 대한 이야기를 나눕니다. 그 과정에서 실제로 지난 2023년 많은 것을 얻을 수 있기도 했습니다. 이런 좋은 환경에 있는 만큼, 최대한 얻을 것을 얻어가야 하지 않겠습니까? 그래서 2024년은 반드시 개발자로서 한 단계 도약하는 시기가 되었으면 합니다. 물론 향락을 아예 안즐기겠다고 말은 못하겠습니다만... 줄일걸 좀 줄이든 시간을 더 효율적으로 쓰든지 해서 더 많은 업무, 더 많은 결과물, 더 많은 개발 지식을 산출해내는 한 해가 되면 좋겠습니다.</p>
<p>2024년 회고를 쓰는 제가 다시 이 글을 읽어볼 때, &#39;아... 작년에 이렇게 반성해놓고 올해도 또 마찬가지네...&#39; 하지는 않았으면 좋겠네요. 저는 저를 한 번 믿어보겠습니다. 2024년 파이팅!</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/166fb111-29a3-4d5a-8664-6a1e3665e2fc/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin] 함수는 뭐고 메서드는 뭐고 프로퍼티는 뭐고 필드는 무엇인가]]></title>
            <link>https://velog.io/@ohzzi/Kotlin-%ED%95%A8%EC%88%98%EB%8A%94-%EB%AD%90%EA%B3%A0-%EB%A9%94%EC%84%9C%EB%93%9C%EB%8A%94-%EB%AD%90%EA%B3%A0-%ED%94%84%EB%A1%9C%ED%8D%BC%ED%8B%B0%EB%8A%94-%EB%AD%90%EA%B3%A0-%ED%95%84%EB%93%9C%EB%8A%94-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80</link>
            <guid>https://velog.io/@ohzzi/Kotlin-%ED%95%A8%EC%88%98%EB%8A%94-%EB%AD%90%EA%B3%A0-%EB%A9%94%EC%84%9C%EB%93%9C%EB%8A%94-%EB%AD%90%EA%B3%A0-%ED%94%84%EB%A1%9C%ED%8D%BC%ED%8B%B0%EB%8A%94-%EB%AD%90%EA%B3%A0-%ED%95%84%EB%93%9C%EB%8A%94-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80</guid>
            <pubDate>Wed, 01 Mar 2023 07:14:12 GMT</pubDate>
            <description><![CDATA[<p>코틀린을 사용한지도 어느덧 3개월이 다 되어 갑니다. 자바에서 코틀린으로 처음 넘어와서 보통 궁금해 하는것이 무엇일까요? 여러가지가 있겠지만 같은 JVM 계열 언어임에도 비슷한 요소의 용어가 다른 점도 그 중 하나일 것 같습니다.</p>
<h1 id="함수-vs-메서드">함수 vs 메서드</h1>
<p>보통 언어들은 어떠한 동작을 하는 요소, 무언가를 매개변수로 받아서 무언가를 반환하는 것을 함수(function)이라고 부릅니다. 좀 더 수준있게 정의해보자면 <em>하나의 특별한 목적의 작업을 수행하기 위해 독립적으로 설계된 프로그램 코드의 집합</em> 이라고 할 수 있겠네요. 그런데 우리는 자바로 프로그래밍을 하면서는 함수라고 부르지 않습니다. 메서드라고 부르죠.</p>
<p>그런데 코틀린으로 넘어오니 다시 함수라고 부릅니다. 애초에 선언하는 키워드 자체도 fun(<del>이거 설마 재미있다는 뜻의 fun하고 노린건가요?</del>) 입니다.</p>
<pre><code class="language-java">void foo() {
    System.out.println(&quot;나는 자바&quot;);
}</code></pre>
<pre><code class="language-kotlin">fun foo() {
    println(&quot;나는 코틀린&quot;)
}</code></pre>
<p>그렇다보니 메서드라는 명칭이 입에 익어 코틀린으로 넘어왔을 때에도 메서드라고 부르는 경우가 많습니다. 실제로 저희 회사 분들도 다들 메서드라고 부르시는 경우가 많더라고요.</p>
<p>그렇다면 함수와 메서드는 무슨 차이가 있을까요? 그냥 각 언어마다 부르는 명칭의 차이만 있는 걸까요?</p>
<p>이걸 알아보기 위해서는 코틀린과 자바의 함수 구조에 대해 알아봐야 합니다.</p>
<p>다들 잘 아시겠지만, 자바에서 메서드를 정의하기 위해서는 클래스 안쪽이어야 합니다. 클래스 바깥, 즉 최상위 레벨에는 메서드를 정의할 수 없습니다. 또한 그러한 메서드들을 호출하기 위해서는 인스턴스가 필요합니다. 심지어 정적 메서드 조차도 선언할 때는 클래스 안에 선언해야 합니다. 때문에 메서드는 다음과 같이 생각할 수 있습니다.</p>
<p><em>객체와 연관된 함수
_Method is a function associated to an object.</em></p>
<blockquote>
<p>다만 정적 메서드는 객체 인스턴스를 생성하지 않고도 호출할 수 있고, 메모리에 저장되는 것도 힙이 아닌 스태틱 영역에 할당되며 단지 클래스 아래에 선언되기만 할 뿐이라 이것을 과연 메서드라 볼 수 있을지, 그저 함수일 뿐이 아닌지 다른 시선으로 볼 수도 있긴 합니다.</p>
</blockquote>
<p>결국 생각해보면 메서드도 곧 함수입니다. 단지 객체와 연관된 함수일 뿐인 것이죠. 그래서 사실 자바의 메서드를 함수라 불러도 전혀 틀린 이야기가 아닌 것 같습니다. 그러면 반대로 코틀린은 자바에서 파생된 언어이고 함수들과 객체가 연관되어 있으니 메서드라고 부르면 되지 않을까요?</p>
<p>하지만 코틀린은 꼭 클래스 안이 아니더라도 함수를 정의할 수 있다는 점이 다릅니다. 자바의 메서드에 해당하는 클래스의 멤버 함수를 제외하고도 최상위 레벨에도 함수를 정의할 수 있고, 심지어 함수 안에 지역 함수를 정의할 수 있기도 합니다. 그래서 메서드라고 통칭할 수 없으니 함수라고 부르는 것으로 보입니다. (어쨌든 메서드 포함 모두 함수니까요.)</p>
<h1 id="프로퍼티-vs-필드">프로퍼티 vs 필드</h1>
<p>함수 vs 메서드 외에도 또 하나의 용어 차이가 있습니다. 바로 프로퍼티와 필드입니다. 클래스의 멤버 변수를 자바에서는 필드(field)라고 부릅니다. 하지만, 코틀린에서는 필드 대신 프로퍼티(property)라고 합니다.</p>
<p>저는 처음에 이름만 다르게 부른다고 생각했었는데, 사실은 함수 vs 메서드 처럼 조금 차이가 있다고 합니다. 함수가 메서드를 포함하고 있듯, 프로퍼티도 필드를 포함하고 있습니다. 다만 논리적 포함관계가 아니라 실제로 포함하고 있는 것입니다.</p>
<p>프로퍼티는 다음과 같이 구성됩니다.</p>
<ul>
<li>필드</li>
<li>getter</li>
<li>setter</li>
</ul>
<p>다음 예제를 봅시다.</p>
<pre><code class="language-kotlin">class Person(
    val name: String
)

val ohzzi = Person(&quot;오찌&quot;)
println(ohzzi.name) // getter 호출</code></pre>
<p>저는 getter를 따로 정의하지 않았습니다. 그냥 프로퍼티 name만 정의했을 뿐입니다. 하지만 <code>인스턴스 변수명.프로퍼티명</code>으로 getter를 호출할 수 있습니다. 이것으로 보아, 프로퍼티는 getter를 포함하고 있습니다.</p>
<p>setter도 마찬가지입니다.</p>
<pre><code class="language-kotlin">class Person(
    val name: String
    var age: Int
)

val ohzzi = Person(&quot;오찌&quot;, 26)
ohzzi.age = 27
println(ohzzi.age) // setter 호출</code></pre>
<p><code>인스턴스 변수명.프로퍼티명 = 변경할 값</code>으로 setter를 호출할 수 있습니다.</p>
<p>저는 처음에는 그냥 필드를 public으로 연 것이 아닌가 라고 생각을 했는데, 자바로 디컴파일 해보면 다음과 같은 형태의 코드가 됩니다.</p>
<pre><code class="language-java">public class Person {
    private final String name;
    private int age;

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

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}</code></pre>
<p>필드 자체는 private으로 유지되고, val이냐 var냐에 따라 getter와 setter가 작성되는 것을 볼 수 있습니다.</p>
<p>그리고 코틀린 프로퍼티는 backing field라는 녀석도 가지고 있습니다. 아까 프로퍼티가 필드 + getter + setter라고 말씀드렸죠? backing field는 프로퍼티의 접근자, 즉 getter와 setter에서 필드 값을 참조하기 위해 field 키워드를 통해 접근하는 녀석입니다. 그냥 자바로 디컴파일 했을 때의 필드라고 생각하면 이해하기 쉽습니다.</p>
<pre><code class="language-kotlin">class Counter {
    var count = 0
        set(value) {
            if (value &gt;= 0) field = value
        }
}</code></pre>
<p>위 예제는 count 프로퍼티의 setter를 재정의 하는 로직입니다. 이 때 setter 안에서 count라는 프로퍼티의 필드를 호출하기 위한(할당을 해주려면 당연하겠죠?) 방법이 필요한데, field 키워드를 사용합니다. 이 키워드를 통해 count 프로퍼티 내부의 필드에 접근할 수 있는 것입니다. 이런 방식은 접근자, 즉 getter와 setter에서만 사용할 수 있습니다.</p>
<hr>
<p>이렇게 함수, 메서드, 프로퍼티, 필드에 대해 간단히 알아보았습니다. 제가 원래 개념 정리 글을 잘 안쓰려고 하는데, 자꾸 용어를 혼용해서 쓰던 차에 정리를 한 번 하는게 낫겠다 라는 생각에 적어보게 되었네요. 우리 올바른 용어를 사용하도록 합시다 :)</p>
<blockquote>
<p>참고 자료</p>
<p><a href="https://blog.kotlin-academy.com/kotlin-programmer-dictionary-function-vs-method-vs-procedure-c0216642ee87">Kotlin programmer dictionary: Function vs Method vs Procedure</a>
<a href="https://blog.kotlin-academy.com/kotlin-programmer-dictionary-field-vs-property-30ab7ef70531">Kotlin Programmer Dictionary: Field vs Property
</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[JpaRepository? No, Repository? Yes!]]></title>
            <link>https://velog.io/@ohzzi/JpaRepository-No-Repository-Yes</link>
            <guid>https://velog.io/@ohzzi/JpaRepository-No-Repository-Yes</guid>
            <pubDate>Sun, 12 Feb 2023 08:16:04 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ohzzi/post/95cf2c8d-965b-4452-8da5-c38e3cc147dd/image.png" alt=""></p>
<p><del>동포청년, JpaRepository를 쓰겠다고?</del></p>
<p>JPA를 사용하는 이상 Spring Data JPA를 사용하지 않는 사람은 거의 없을 것이고, Spring Data JPA를 사용하는데 JpaRepository 인터페이스를 사용하지 않는 사람도 거의 없을 것입니다. save, findById, findAll 같은 기본적인 CRUD 명세를 제공해주고, 해당 명세들에 대한 구현체를 제공해주기까지 하며(SimpleJpaRepository) 쿼리 메서드 기능이라는 강력한 기능까지 제공해주기 때문입니다.</p>
<p>하지만, 이런 의문을 가져보신 적이 없으십니까?</p>
<p><em>아, 나는 단 건 조회만 쓰고 findAll은 안쓸 것 같은데...</em>
<em>이 테이블은 soft delete를 구현할거라 삭제 기능이 필요하지 않은데...</em>
<em>아 구현체를 직접 만들려고 하니까 정의된 메서드가 왜이리 많은거야?</em></p>
<p>이 모든게 여러분이 JpaRepository를 상속하기 때문에 발생하는 일입니다.</p>
<h2 id="jparepository를-상속하지-말아야-하는-이유">JpaRepository를 상속하지 말아야 하는 이유</h2>
<h3 id="1-불필요한-기능을-제공한다">1. 불필요한 기능을 제공한다</h3>
<p>잠시 JpaRepository의 상속 구조를 살펴보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/131075a2-d53a-4772-b262-9672dd0e0a3d/image.png" alt=""></p>
<p>맨 위의 Repository 인터페이스를 시작으로 CrudRepository, PagingAndSortingRepository로 내려오며, JpaRepository는 PagingAndSortingRepository를 상속합니다. 그리고 추가로 QueryByExampleExecutor를 상속합니다.</p>
<p>JpaRepository는 Spring Data가 제공하는 인터페이스들을 하나씩 상속하며 내려오면서 편의 메서드들을 계속 상속하게 됩니다. 간단한 CRUD부터 배치 처리를 도와주는 메서드, 페이징 처리를 해주는 메서드, 영속성 컨텍스트를 초기화 시켜주는 메서드, 프록시 객체를 조회하는 메서드 등이 그것들입니다. 이런 기능들이 과연 필요할까요? 보통의 상황에서는 필요하지 않습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/22effb05-c8cb-41e2-928b-9b0fc83c33b6/image.png" alt=""></p>
<p>기본적으로 좋은 코드를 만들기 위해서는 꼭 필요한 기능만 코드로 정의하는 것이 필요합니다. 사용하기 않을 기능을 정의하고 구현한다면 이는 오용의 대상이 될 수 있기 때문입니다. 예를 들어, soft delete(실제로 데이터를 삭제하지 않고 삭제 여부를 알 수 있는 컬럼을 두어 데이터베이스 상에서는 데이터를 유지하고 삭제된 데이터라고 처리하는 방식) 방식으로 데이터를 관리하려고 한다면 데이터를 데이터베이스에서 직접 삭제하는 기능은 필요하지 않습니다. 하지만 JpaRepository를 상속한다면 사용자의 실수로 데이터를 실제로 삭제하는 코드를 작성할 수 있게 됩니다.</p>
<h3 id="2-테스트-더블을-만들기-힘들다">2. 테스트 더블을 만들기 힘들다</h3>
<p>repository 객체를 사용하는 서비스 객체들을 테스트 할 때, 데이터베이스에 직접 연결해서 테스트 하는 것은 굉장히 큰 비용입니다. 때문에 테스트 더블을 활용하여 데이터베이스와 격리된 테스트를 진행하는 경우가 많습니다. Map 등의 자료구조로 데이터베이스를 대체한 in-memory repository, 즉 fake 객체를 만들어서 테스트 하는 것입니다.</p>
<p>이 경우, Spring Data JPA를 사용하지 않은 순수한 단위 테스트를 진행해야 하기에 fake 객체를 기존 repository 인터페이스의 구현체로 만들어야 합니다. 모두 아시다시피, 인터페이스의 구현체는 인터페이스에 정의된 모든 메서드를 오버라이딩 해야 합니다.</p>
<p>때문에 앞서 말했듯 불필요한 기능이 많이 정의되어 있는 JpaRepository의 상속본을 가짜 객체로 구현하려면 굉장히 많은 메서드를 전부 오버라이딩 해야 하고, 이는 테스트 더블을 만드는데에 대한 피로로 이어집니다.</p>
<h3 id="그래서-이런-방법을-쓰기도-합니다">그래서 이런 방법을 쓰기도 합니다</h3>
<p>예를 들어 User라는 엔티티가 있다고 하겠습니다. 위 두 가지 문제를 해결하는 기막힌 방법이 있습니다.</p>
<pre><code class="language-java">public interface UserRepository {
    ...
}

public interface UserJpaRepository extends UserRepository, JpaRepository&lt;User, Long&gt; {
    ...
}</code></pre>
<p>실제로 프로덕션에서 사용하는 타입은 UserRepository로 합니다. 그리고 (infra 패키지 같은 곳에) UserJpaRepository를 만들어 UserRepository와 JpaRepository를 상속합니다. 이러면 UserJpaRepository는 JpaRepository를 상속하므로 SimpleJpaRepository 구현체도 만들어지고, UserRepository의 상속이기도 하기 때문에 UserRepository 타입의 구현체로 사용하는 것이 가능합니다.</p>
<p>그런데... 꼭 이렇게 불필요한 타입까지 만들어가면서 해야 할까요? 아니요. 다른 방법이 존재합니다.</p>
<h2 id="repository-타입으로-대체하자">Repository 타입으로 대체하자</h2>
<p>repository로서의 기능을 할 수 있는 구현체를 제공하는 역할은 어떤 인터페이스가 할까요? 많은 분들이 JpaRepository라고 생각하시겠지만, 정답은 Repository 인터페이스만 상속하고 있으면 된다 입니다.</p>
<p>Repository 인터페이스의 javadoc을 보면 다음과 같이 작성되어 있습니다.</p>
<blockquote>
<p>Central repository marker interface. Captures the domain type to manage as well as the domain type&#39;s id type. General purpose is to hold type information as well as <strong>being able to discover interfaces that extend this one during classpath scanning for easy Spring bean creation.</strong></p>
</blockquote>
<p>핵심은 마지막의 <code>클래스패스 스캐닝을 할 때 스프링 빈을 쉽게 만들어줄 수 있도록 한다</code> 부분입니다. 즉, Repository 인터페이스만 상속하고 있으면 해당 인터페이스에 대한 구현체를 Spring Data JPA가 만들어준다는 이야기입니다.</p>
<p>실제로 Spring Data JPA의 공식 문서를 보면 기본 예제로는 JpaRepository가 아닌 CrudRepository(Repository 인터페이스에 간단한 CRUD 메서드를 확장한 것)를 상속하는 예제가 나와 있고, CRUD 메서드를 선택 노출하는 방법으로 Repository 인터페이스를 상속하는 예제가 나와 있습니다.</p>
<pre><code class="language-java">@NoRepositoryBean
interface MyBaseRepository&lt;T, ID&gt; extends Repository&lt;T, ID&gt; {

    Optional&lt;T&gt; findById(ID id);

    &lt;S extends T&gt; S save(S entity);
}

interface UserRepository extends MyBaseRepository&lt;User, Long&gt; {
    User findByEmailAddress(EmailAddress emailAddress);
}</code></pre>
<p>그런데 Repository 인터페이스에는 어떠한 메서드도 정의되어 있지 않습니다. 마커 인터페이스일 뿐입니다. 때문에 Repository를 상속하면 불필요한 메서드를 정의할 필요도, 구현할 필요도 없습니다.</p>
<p>그러면 CrudRepository에 정의된 CRUD 기능은 어케 하냐는 물음이 있을 수 있겠죠? 쿼리 메서드 규칙대로 메서드 시그니처를 정의하기만 하면 됩니다. 구현체의 구현은 Spring Data JPA가 알아서 다 해주기 때문이죠. 이렇게 Repository를 사용하면 모든 문제를 다 해결하면서도 불필요한 인터페이스를 만들지 않을 수 있습니다.</p>
<blockquote>
<p>참고 자료</p>
<p><a href="https://www.youtube.com/watch?v=MMH_ht8pf8U">[QA] JpaRepository를 상속하지 않은 이유</a>
<a href="https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.definition">Spring Data JPA docs</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin] operator fun invoke로 객체를 함수처럼 사용하기]]></title>
            <link>https://velog.io/@ohzzi/Kotlin-operator-fun-invoke</link>
            <guid>https://velog.io/@ohzzi/Kotlin-operator-fun-invoke</guid>
            <pubDate>Tue, 24 Jan 2023 08:17:51 GMT</pubDate>
            <description><![CDATA[<p>일하게 된 회사의 기술 스택이 코틀린이라 열심히 코틀린 공부를 하고 있습니다. 코틀린에는 자바만 하던 제가 정말 신기하게 느끼는 많은 기능들이 있습니다. 그 중에서도 연산자 오버로딩이라는 녀석이 있는데요, 아마 많은 분들이 아시는 기능일 겁니다. 만약 Money라는 클래스를 만들고 Money와 Money의 덧셈을 해야 한다면 자바에서는 어떻게 해야 할까요?</p>
<pre><code class="language-java">public class Money {
    private final int value;

    public Money(int value) {
        this.value = value;
    }

    public Money sum(Money money) {
        return new Money(value + money.value);
    }
}</code></pre>
<p>이렇게 클래스를 만들고,</p>
<pre><code class="language-java">Money money1 = new Money(1_000);
Money money2 = new Money(500);

Money sum = money1.sum(money2);</code></pre>
<p>이렇게 Money 클래스의 인스턴스의 메서드를 직접 호출해 사용해야 합니다. 그런데 어차피 덧셈인데 <code>money1 + money2</code>와 같이 사용할 수 있다면 좋지 않을까요?</p>
<p>코틀린에서는 가능합니다.</p>
<h2 id="코틀린의-연산자-오버로딩">코틀린의 연산자 오버로딩</h2>
<pre><code class="language-kotlin">data class Money(
    val value: Int
) {
    operator fun plus(money: Money): Money = Money(value + money.value)
}</code></pre>
<p>이렇게 plus라는 연산자를 오버로딩한 Money 클래스는 다음과 같이 쓸 수 있습니다. 함수 이름이 반드시 plus여야 합니다.</p>
<pre><code class="language-kotlin">val money1 = Money(1_000)
val money2 = Money(500)
val sum = money1 + money2</code></pre>
<p>만약 여러 값을 더해야 한다면 자바라면 계속 메서드 체이닝이 걸릴텐데, <code>+</code>만 쓰면 되는 코틀린의 방식이 훨씬 간결하고 깔끔합니다. 코틀린 연산자 오버로딩에는 plus 외에도 minus(<code>-</code>), times(<code>*</code>)... 와 같이 오버로딩 할 수 있는 많은 연산자들이 있습니다.</p>
<p>그런데 저는 대부분 산술 연산자들 쪽에만 관심을 가졌었는데요, 코드를 작성하던 중 사수분께 코드를 더 간략하고 알아보기 쉽게 할 수 있는 <code>invoke</code>에 대한 리뷰를 받게 되었습니다.</p>
<p>일반적으로 특정 함수나 기능을 실행시키는 용도로 invoke라는 이름을 많이 씁니다. 대표적인 예로 JDK Dynamic Proxy를 만들 때 오버라이딩 해야 하는 invoke 메서드 등이 있습니다. 굳이 동적 프록시 api까지 가지 않아도 스프링을 사용할 때 찍히는 많은 로그들에 invoke 메서드들에 대한 정보가 나와 있습니다.</p>
<p>코틀린에서는 아예 invoke라는 키워드 자체가 연산자이기 때문에 매우 색다르게 활용할 수 있습니다. 코틀린 공식 문서에는 invoke에 대해 다음과 같이 나와 있습니다.</p>
<blockquote>
<p>A value of a function type can be invoked by using its invoke(...) operator: f.invoke(x) or just f(x).</p>
</blockquote>
<p>예시를 하나 들어보도록 하겠습니다.</p>
<pre><code class="language-kotlin">object DoSomething {
    operator fun invoke() {
        println(&quot;do something&quot;)
    }
}</code></pre>
<p>DoSomething이라는 object를 하나 만들어주도록 하겠습니다. object로 만들었기 때문에 자바의 static 처럼 인스턴스 생성 없이 함수를 호출할 수 있습니다. 다음과 같이 말이죠.</p>
<pre><code class="language-kotlin">DoSomething.invoke() // &quot;do something&quot; 출력</code></pre>
<p>하지만 앞서 말씀드렸듯이 코틀린의 invoke는 연산자입니다. 때문에 함수 호출 없이 사용할 수 있습니다. 함수 호출 없이 어떤 방식으로 사용하는 걸까요? invoke를 오버로딩 하게 되면 객체를 함수처럼 사용할 수 있습니다. 즉, 함수를 이름없이 사용할 수 있습니다.</p>
<pre><code class="language-kotlin">DoSomething() // &quot;do something&quot; 출력</code></pre>
<p>wow, 코드가 한 층 더 간결해졌습니다.</p>
<h2 id="실전에서의-활용">실전에서의 활용</h2>
<p>실전에서 이 기능을 어떻게 활용할 수 있었을까요? 바로 함수형 인터페이스를 활용할 때 사용했습니다. 만약 연산자 오버로딩을 사용하지 않는다면 다음과 같이 사용해야 합니다.</p>
<p>(매우 비효율적이고 필요 없는 코드지만 이해를 위해 간단히 구성한 예시임을 이해 부탁드립니다.)</p>
<pre><code class="language-kotlin">fun interface StringFormatter {
    fun format(string: String): String
}

class ToUpperCaseFormatter : StringFormatter {
    override fun format(string: String): String = string.toUpperCase()
}


val toUpperCaseFormatter = ToUpperCaseFormatter()
val result = toUpperCaseFormatter.format(&quot;abc&quot;) // &quot;ABC&quot;</code></pre>
<p>변수명이나 함수명이 길어짐에 따라 위 코드는 점점 보기 어려운 코드가 되어갈 것입니다. invoke를 활용하면 훨씬 간결하면서 함수형 인터페이스의 이름을 객체처럼이 아니라 함수처럼 만들기에도 편합니다.</p>
<pre><code class="language-kotlin">fun interface FormatString {
    operator fun invoke(string: String): String
}

class toUpperCase : FormatString {
    override fun invoke(string: String): String = string.toUpperCase()
}


val toUpperCase = ToUpperCase()
val result = toUpperCase(&quot;abc&quot;) // &quot;ABC&quot;</code></pre>
<p>이런식으로 사용하면 <code>.xxx()</code>와 같이 호출할 필요가 없어 코드의 길이도 줄어들고, 함수형 인터페이스의 구현체인 toUpperCase를 객체.함수()로 호출하는 것이 아닌 toUpperCase 자체를 함수처럼 호출할 수 있어 네이밍이나 가독성 면에서도 장점을 가져갈 수 있습니다.</p>
<p>실제로 저는 실무 코드에서 변수명도 길고, 호출하는 메서드에 클래스 이름과 동일한 의미가 들어가 중복되는 의미로 네이밍이 길어지는 경우에 대해 함수형 인터페이스 + invoke 오버로딩의 조합으로 코드 길이를 줄이고 의미도 훨씬 더 잘 파악하게 할 수 있었습니다. (알려주신 메이트님 감사합니다.)</p>
<blockquote>
<p>참고 자료
<a href="https://kotlinlang.org/docs/lambdas.html#inline-functions">코틀린 공식 문서</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[YOUTHCON'22 연사 후기]]></title>
            <link>https://velog.io/@ohzzi/YOUTHCON22-%EC%97%B0%EC%82%AC-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@ohzzi/YOUTHCON22-%EC%97%B0%EC%82%AC-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Tue, 03 Jan 2023 11:25:25 GMT</pubDate>
            <description><![CDATA[<p>혹시 YOUTHCON(이하 유스콘)에 대해 알고 계신가요?</p>
<blockquote>
<p>유쾌한 스프링방에서 탄생한 유스콘은 👨‍🎓 젊은 개발자와 👨‍🏫 선배 개발자가 함께 가치 있는 기술에 관한 정보와 경험을 공유하는 콘퍼런스입니다. 여기서 발표하는 사람들을 잘 기억해 주세요. 가까운 미래에는 DEVIEW, if(kakao), SPRINGCAMP의 주인공이 될 개발자입니다.</p>
<p><a href="https://frost-witch-afb.notion.site/YOUTHCON-22-a18e4511463a416e8befd99993355215">YOUTHCON&#39;22</a></p>
</blockquote>
<p>한 해를 마무리하는 12월 31일. YOUTHCON&#39;22가 진행됐습니다.</p>
<p>유스콘에 대해 처음 알게된 것은 모 개발자 오픈 톡방에서 추석 선물로 유스콘&#39;21 다시보기 영상을 보게 되면서였습니다. <code>젊은 개발자와 선배 개발자가 함께 가치 있는 기술에 대한 정보와 경험을 공유</code>한다는 말이 굉장히 인상깊었습니다. DEVIEW, if, SPRINGCAMP, SLASH, WOOWACON 등 개발자 업계에는 다양한 컨퍼런스와 세미나, 발표가 존재합니다. 하지만 유스콘은 그들과 조금 다른데요, 보통 어느 정도 경력을 갖추신 선배 개발자분들께서 발표하시는 것과 다르게(무조건 다 그런 것은 아닙니다만) 유스콘은 선배 개발자가 멘토가 되어 주니어 개발자가 발표자가 된다는 점입니다. 실제로 유스콘&#39;21 연사 명단을 보면 우아한테크코스 3기 분들도 많이 계셨습니다.</p>
<p>저는 마침 유쾌한 스프링방에 있기도 했고, 유스콘의 개최를 담당하시는 <strong>괴물 개발자 제이슨</strong>이 우테코 코치시기 때문에 유스콘&#39;22 공고가 뜨자마자 바로 제이슨께 발표하고싶다고 말씀드렸습니다. 근처에 있던 베루스를 꼬셔서요(ㅋㅋ). 처음 베루스와 발표를 하기로 했을 때부터 <code>테스트</code>에 관한 내용을 발표하기로 마음먹었습니다. 그동안 테코톡이나 아고라같이 발표할 일이 있을 때 기술적인 부분에 대해서는 이야기를 많이 한 것 같은데 테스트에 관련해서는 이야기해보지 않은 것 같아서였습니다. 마침 같이 발표하기로 한 베루스는 우테코 4기 공인 <code>테스트에 미친 남자</code>입니다.</p>
<p>사실 준비 과정이 쉽지는 않았습니다. 바로 취업 과정과 겹쳤기 때문입니다... 원래 유스콘&#39;22는 12월 18일로 예정되어 있었는데요, 11월 말 우테코를 수료하고 12월 초에 준비하자니 취업 준비와 겹쳐서 굉장히 빡빡한 일정이었습니다. 토스뱅크 면접이 12월 1일과 14일에 있었고, 그 사이에는 모 스타트업과의 면접도 있었던데다가 모 핀테크 기업의 과제전형도 진행했기 때문이죠. 덕분에 처음 예정되었던 12월 18일 전까지 발표 준비가 많이 되어 있지 않은 상황이었습니다.</p>
<p>그나마 다행이랄까요? 오프라인 행사 준비와 전체적인 발표 퀄리티의 문제로 행사가 12월 31일로 밀리게 되었고, 저와 베루스는 좀 더 사람들의 이목을 끌 수 있을만한 발표 컨셉으로 변경해서 다시 준비하게 되었습니다. 베루스의 출근 이슈(...)가 있긴 했지만, 어찌어찌 잘 준비해서 발표 준비를 마무리 할 수가 있었습니다.</p>
<p>아, 참고로 유스콘&#39;21까지는 온라인 행사로 진행되었는데요, 유스콘&#39;22는 처음으로 오프라인 행사로 열리게 되어 한국루터회관 14층 우아한테크코스 교육장에서 진행되었습니다. 오프라인 참여자를 추첨으로 100여명 선정했고, 아쉽게도 당첨되지 못한 분들을 위해 zoom을 통해 실시간 온라인 송출도 함께 진행했습니다. 그리고 유스콘&#39;22의 축사는 무려 백기선님께서 축사 영상으로 해주셨습니다. (다음 유스콘때는 오프라인으로 오셔서 수많은 주니어 개발자들의 멘토링을 해주시는 기선님을 기대..해도 되겠죠...?) 연사들의 발표 외에도 중간 중간 멘토링도 있었습니다. 많은 분들이 멘토링을 신청해 주셨지만 여건 상 모두 다 멘토링을 받지는 못했고, 일부 인원들이 포비 박재성님과 같은 분들께 좋은 말씀을 들었습니다.</p>
<h2 id="unit-test-puzzler">Unit Test Puzzler</h2>
<p>저와 베루스가 준비한 발표는 바로 <code>Unit Test Puzzler</code>입니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/2955defe-235d-43a6-9391-7a8b3048f6cf/image.png" alt=""></p>
<p><del>뻥 뚫어주는 멘트 제가 적은거 아닙니다</del></p>
<p>굳이 퀴즈 형식을 통해 발표를 하게 된 건, 어떻게 하면 좀 더 쉽고 재밌게 단위 테스트에 대해 발표할 수 있을까 하다가 우테코 데일리 미션으로 몇 번 해봤던 <code>자바 퍼즐러</code>같은 형식이 떠올랐기 때문입니다. 퀴즈 형식으로 발표를 하게 되면 청중들이 좀 더 생각하면서 발표를 들을 수 있고 집중도가 높아질 것이라고 생각했습니다.</p>
<p>사실 퀴즈 형식으로 한다는 것은 일종의 도박이기도 했습니다. 왜냐면 청중들에게 발표자가 질문을 하고, 그에 대해 청중들이 답변하는 포맷이 들어가 있기 때문에 만약 청중의 호응을 끌어내지 못한다면 분위기가 싸해지는 역효과도 날 수 있었기 때문입니다. 그래서 유스콘 오프라인에 당첨된 동료 교육생들을 몇 명 미리 섭외하여 퀴즈의 답이 나오지 않으면 답을 말해달라고 했습니다. (하지만 입도 뻥긋하지 않은 K모 크루... 내가 기억하겠다...) 동료 교육생 외에도 다른 발표자분의 멘토로 참여해주신 갓-쿄잉님께서 퀴즈에 대한 대답을 잘 해주셔서 무사히 넘어갈 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/e0f42299-12ba-4b27-a331-5a4557cf035d/image.png" alt=""></p>
<p>유스콘은 두 개의 트랙으로 나누어져 동시에 진행되었는데요, 트랙 1은 대강의장, 트랙 2는 소강의장에서 진행하게 되었습니다. 저희 발표는 트랙 2에서 진행했습니다. 트랙 2가 트랙 1의 절반도 안되는 크기여서 오히려 발표의 부담은 덜할 거라고 생각했는데, 반은 맞고 반은 틀렸습니다. 발표를 시작할 3시쯤 되니 밖에서 쉬고 있던 많은 분들께서 우르르 들어오셨습니다. 소강의장의 몇 안되는 자리는 다 채워졌고, 강의장 뒷편과 문 바깥에까지 발표를 듣고 싶어하신 분들이 서 계셨습니다. 테스트라는 꽤나 핫한 주제, 거기에 퀴즈 형식인 것이 많은 분들에게 듣고 싶은 발표로 다가갔던 것 같습니다. 중간에 잠깐 확인해보니 온라인으로도 쉰 여 명이나 되는 많은 분들이 들어주셨습니다. 부족한 발표였음에도 불구하고 재밌게 들어주신 분들이 많았습니다. 모든 분께 너무나 감사드립니다 :)</p>
<p>행사가 다 끝난 후 행사 참여자들은 구글 폼으로 후기를 작성했는데, Unit Test Puzzler 발표가 좋았고 얻어가는 것도 많았다는 후기가 보여서 특히나 더 좋았습니다.</p>
<p>발표 내용을 모두 이 글에 담기에는 페이지가 너무 부족하여, 발표 마지막에 나왔던 각 퀴즈별 요약을 담아보도록 하겠습니다. 언젠가 시간이 난다면 발표를 줄글로 컴팩트하게 요약해 볼 수 있을지도 모르겠네요.</p>
<ol>
<li>한 테스트에서는 한 케이스만 검증해야 한다.</li>
<li>매개변수화 테스트로 중복을 제거할 수 있다.</li>
<li>테스트에서 if문을 제거한다.</li>
<li>테스트의 실행 구문은 한 줄이어야 한다.</li>
<li>private 메서드 테스트를 지양할 것.</li>
<li>테스트하기 어려운 로직은 외부로 분리하거나 추상화한다.</li>
<li>중복을 제거하기 위해 테스트 간 데이터 세팅이 결합되어서는 안된다.</li>
</ol>
<p>발표를 준비하면서 <code>좋은 단위 테스트란 무엇일까?</code>에 대해서 많은 고민을 했습니다. 저와 베루스가 1년 가까이 우아한테크코스를 진행하면서 작성했던 단위 테스트를 처음부터 돌아봤고, 문제가 있어 보이는 단위 테스트를 골라내는데 주력했습니다. 그러려면 어떤 테스트가 나쁜지, 어떤 테스트가 좋은지 확실하게 알고 있어야겠죠? 그래서 블라디미르 코리코프의 <code>단위 테스트</code> 책을 구매해서 읽어보기도 했습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/609c52d7-c565-487a-87cb-3e4bd17ee676/image.png" alt=""></p>
<p><del>뒷광고 아닙니다. 내돈내산입니다.</del></p>
<p>혹시나 단위 테스트에 대해서 공부해보고 싶으신 분이라면 이 책을 구매해서 읽어보시는 것을 정말 강추드립니다.</p>
<h2 id="다른-발표들은">다른 발표들은?</h2>
<p>트랙 1은 일반적인 발표 위주로, 트랙 2는 핸즈온 위주로 진행되었습니다. 저희 발표가 트랙 2에서 진행하게 된 것은 트랙 2에 OOP Start! 라는 핸즈온이 있었기 때문에, OOP -&gt; 단위 테스트로 진행되는 빌드업을 위해서였습니다. 우아한테크코스 크루들도 꽤 많이 발표를 했는데요, 저희 발표 뿐 아니라 로마와 페퍼의 <strong>Java 17 vs Kotlin 1.7</strong>, 애쉬와 파랑의 <strong>우아한테크코스 지원자 모두에게 프리코스 기회를 주기까지</strong>, 우디의 <strong>신입 개발자, 팀에 안정적으로 착륙하기</strong> 발표가 있었습니다. 애쉬, 파랑의 발표는 제 발표와 시간이 겹쳐서, 우디의 발표는 제 발표가 끝나고 기진맥진해 있느라 듣지 못한 것이 아쉽습니다. 로마와 페퍼의 발표는 현장에서 들었는데, 확실히 발표에 재능이 있는 친구들이라 그런지 티키타카를 해나가면서 사람들을 집중하게 만드는 재밌는 발표였습니다. 일단 주제 자체가 자바의 최신 LTS 버전과 요즘 핫한 언어 코틀린을 비교해나가는 주제여서 그런지 청중도 많고 반응도 좋았습니다. 들어간 회사가 코틀린을 사용하기 때문에 </p>
<p>우테코 크루의 발표 외에는 <a href="https://mangkyu.tistory.com/">망나니개발자</a>님의 <strong>Introduce to Clean Architecture</strong> 발표가 인상깊었습니다. 우테코 팀 프로젝트를 하면서부터 좋은 아키텍처 구조에 대해서 많은 고민을 하게 되었지만 계층형(layered) 아키텍처에서 벗어나지는 못했습니다. 계속 계층형 아키텍처를 사용하다 보며 계층형 아키텍처의 불편한 점이나 문제점들을 어느 정도는 체감하게는 되었지만 아직 다른 아키텍처에 대해 고민해보지는 못했는데, 망나니개발자님의 발표를 듣고 클린 아키텍처에 대해서 조금이나마 이해하게 된 것 같아 좋았습니다. 개인적으로 기억에 남는건 SRP가 <code>하나의 책임을 가진다</code>가 아니라 <code>변경의 이유가 하나여야 한다</code>쪽이 더 적당하다라는 내용이었습니다. 발표를 듣긴 했지만 아직은 클린 아키텍처가 어려운 것 같기는 합니다. (애초에 발표 대상이 실무 3년차 이상이었으니 당연할지도...?) 차차 공부해봐야겠습니다.</p>
<p>나머지 발표들도 현장에서 보거나 현장에서 보지 못한 발표들은 리허설로 지켜봤었는데요, 내용과 구성 모두 알찬 발표였습니다. 다들 주니어 개발자, 발표 경험이 많지 않은 개발자라고는 믿기지 않을 정도로 발표를 능숙하게 해 주셨고 내용도 꼭 한번 들어볼 만한 주제들이었습니다. 개인적으로 편집본이 유튜브로 올라오거나 하면 좋겠지만... 여건상 그것은 쉽지 않을 것 같아 아쉽긴 합니다.</p>
<h2 id="유스콘22-진짜-후기">유스콘&#39;22 진짜 후기!</h2>
<p>살면서 처음으로 개발자 컨퍼런스의 연단에 서보니 감회가 새롭습니다. 이전까지 했던 발표라고는 10분 테코톡이나 우테코 크루들을 대상으로 한 아고라 정도였는데, 처음 뵙는 수많은 개발자 분들 앞에서 발표를 하니 확연히 다른 느낌이고 떨렸습니다. 실수하거나 하는 것 없이 발표를 무사히 마친 것 같아 다행이라고 생각합니다. 그리고 이런 발표를 무사히 마칠 수 있을 정도로 한 걸음 더 성장했다는 생각이 들기도 합니다. 한 두 해 전의 저였다면 무대에 서기는 커녕 세션을 들으면서도 무슨 내용인지 이해하지도 못했을 텐데 말이죠. 덕분에 개발 관련 내용을 발표하는 것에 대해 자신감이 좀 더 붙었습니다. 다만 나중에 영상을 다시 돌려보니 말을 천천히 한다고 했는데도 여전히 말이 조금 빠른 것은 아쉬웠습니다.</p>
<p>그리고 많은 개발자분들을 실제로 만나뵐 수 있어서 좋았습니다. 오픈채팅방에서만 봤지 실제로는 뵙지 못했던 분들, 이제 들어가게 될 회사의 선배 개발자 분들, 여러 기업에서 개발자로서의 길을 걷고 계신 분들을 만나게 되어 뭔가 <code>아, 나도 개발자구나</code> 하는 실감이 다시 한번 나게 된 하루였습니다. 행사가 끝나고 회식도 하고, 2023년 카운트다운을 함께 하기도 해서 더 그랬던 것 같습니다. (한 해 마지막 순간까지 개발자들과 함께하는 사람이 있다? ㅋㅋㄹㅃㅃ) 개인적으로 너무 만족스러워서 시간이 난다면 유스콘&#39;23에는 스태프든 청중이든 꼭 참여하고 싶다는 생각도 하게 되었습니다. (아쉽게도 발표는 불가능합니다. 살면서 한 번 주어지는 기회라더군요...)</p>
<p>끝으로 유스콘의 성공적인 개최를 위해 노력해주신 스태프와 멘토분들, 컨퍼런스에 참석해 주신 많은 분들, 그리고 몇 년째 유스콘이라는 좋은 기회를 만들어주고 계신 제이슨께 감사하다는 말씀을 드리고 싶습니다.</p>
<p>유스콘&#39;23도 많이 기대해주세요!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[병아리 백엔드 개발자가 돌아보는 2022년(+ 취업 후기)]]></title>
            <link>https://velog.io/@ohzzi/%EB%B3%91%EC%95%84%EB%A6%AC-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-%EB%8F%8C%EC%95%84%EB%B3%B4%EB%8A%94-2022%EB%85%84</link>
            <guid>https://velog.io/@ohzzi/%EB%B3%91%EC%95%84%EB%A6%AC-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-%EB%8F%8C%EC%95%84%EB%B3%B4%EB%8A%94-2022%EB%85%84</guid>
            <pubDate>Fri, 30 Dec 2022 04:16:28 GMT</pubDate>
            <description><![CDATA[<p>어느 덧 한 해가 지나갑니다. 우아한테크코스 합격 메일을 보며 기쁘게 맞이하던 새해였는데, 어느덧 우테코는 수료하고 직장인이 될 준비를 하며 한 해를 마무리하고 있네요. 사실 올해 회고라고 하면 그냥 우아한테크코스 회고가 아닐까 싶을 정도로 우테코에 시간과 열정을 쏟으며 달려온 한 해가 아니었나 싶습니다. 연초에는 우테코 들어가기 전에 다 놀아둔다고 엄청 놀러다녔던 것 같고... 연말에는 취준으로 바쁘게 생활했네요. 참 밀도가 높은 한 해였던 것 같습니다.</p>
<h2 id="우아한테크코스">우아한테크코스</h2>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/63a32674-aebb-424a-a487-d555312d26a7/image.png" alt=""></p>
<p><del>우테코 10개월... 하얗게 불태웠어...</del></p>
<p>지난 11월 25일. 2월부터 장장 10개월간 이어졌던 우아한테크코스 4기가 끝났습니다. 10개월이라는 시간은 분명 짧은 시간은 아니지만, 굉장히 금방 지나간 것 같네요. 우아한테크코스 합격했다고 기뻐하던 때가 엊그제 같은데, 정신 차려보니 다 끝나고 취업하기 위해 고군분투하고 있는 저를 마주할 수 있었습니다.</p>
<p>우아한테크코스에서 보낸 10개월은 인생에서 무언가를 가장 열심히 해 본 시간이었습니다. 하다 못해 수능 공부할 때도 이렇게까지 열심히 노력하지는 않았던 것 같네요. 역시 사람은 자기 취향에 맞고 능력에 맞는 일을 해야 하는걸까요?</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/baef6001-1bb8-4a27-9ed9-7f2e8c71f907/image.png" alt=""></p>
<p><del>열심히 했다!!</del></p>
<p>아직도 많이 부족하지만, 열심히 하다 보니 제 스스로가 그만큼 늘었다고 느낍니다. 우아한테크코스에 들어올 때 까지만 해도 제는 개발자로서 큰 장점도 없는 것 같았고, 크루들 사이에서 그렇게 잘한다고 느껴지는 편도 아니었습니다. 그래서 더 열심히 했던 것 같습니다. 마침 정말 잘하고 열심히 하는 동료들이 주변에 있었고, 저는 그들을 롤모델 삼아서 흡수할 수 있는 것은 다 흡수하려고 노력했습니다. 남들과 비교하며 내가 못하는 부분을 찾아 낙담하기 보다는 &quot;오히려 좋아&quot;라는 마인드로 따라갔던 기억이 있네요.</p>
<p>그동안 학교 다니면서 틈틈이 자바 배우고 스프링 배우고 그랬었는데, 우테코 들어와서는 아예 처음부터 다시 배운 느낌이었습니다. 가장 크게 느낀건 제가 &#39;기본을 무시하고 있었다.&#39; 라는 것이었습니다. 자바도 제대로 할 줄 모르는데 스프링을 써보겠다고 낑낑거리질 않나 데이터베이스에 대한 이해도 못하는데 JPA부터 해버리질 않나... 그랬던 제가 우테코 덕분에 기초부터 다시 차근차근 쌓아나갈 수 있었습니다.</p>
<p>주도적으로 노력했던게 많은 도움이 되었던 것 같습니다. 항상 새로운걸 배우고, 직접 만들어 보는 것에 갈망이 있었습니다. 확실히 제게 맞는 학습법을 찾은 느낌이었습니다. 그동안 두꺼운 전공책만 보면서 공부하다보니 공부에 흥미가 없었던 거라는 생각도 드네요. 계속 무언가 새로운 것이 없는지 찾다 보니 우테코 정규 과정 내에 있는 팀 프로젝트 외에도 크루들이 사용할 수 있는 사이드 프로젝트를 개발하고 출시하는 경험도 했습니다. 직접 만들어보고, 피드백을 받고, 더 개선할 방법은 없는지 공부하면서 정말 많은 것을 배웠습니다.</p>
<p><a href="https://github.com/Ohzzi/ohzzi-woowacourse">우아한테크코스에서 배운 내용들을 정리한 것을 보고싶다면</a></p>
<p>팀 프로젝트도 잊을 수 없는 경험인 것 같습니다. 레벨 3부터 시작해 레벨 4 마지막을 데모데이로 장식했으니 거의 4개월을 진행한 프로젝트네요. 저희 팀이 우스갯소리로 일이 잘 풀리고 있으면 &quot;순항중&quot;, 잘 안풀리는 일이 있으면 &quot;침몰중&quot; 이렇게 표현을 했었는데요, 다 끝나고 돌아보니 전체적으로 보면 순항하는 배가 아니었나 싶습니다. 성격도 다들 잘 맞고, 개발에 대한 의지도 다들 높고, 무엇보다 프로젝트의 방향성도 함께 잘 맞춰나갈 수 있는 팀원들이 있었기 때문에 순항할 수 있었던 것 같습니다. 팀 프로젝트를 진행하기 전에 &#39;팀원들과 큰 충돌 없이 마무리하자&#39;라는 소박한 목표를 하나 세웠었는데, 그렇게 된 것 같아 기분이 좋습니다.</p>
<p>무엇보다 좋은 동료들을 많이 만난 것이 최고의 복이 아닐까 싶네요. 서로에게 자극이 되어주고, 지칠 때 손을 내밀어주고, 힘들 때 위로가 되어주는 개발자 동료들을 만날 수 있었습니다. 동료 뿐만 아니죠. 그동안 선배 개발자라고는 거의 알지 못했던 제가 많은 선배 개발자분들을 만나 도움을 받을 수 있었습니다. 일 년 동안 옆에서 지켜봐주시고 막히는 것이 있을 때마다 도와주신 우테코 코치님들, 언제든 후배들의 도움이 필요하면 도움을 주려 노력하신 선배 기수 분들까지, 많은 선배 개발자분들의 도움을 받을 수 있었습니다. 좋은 사람들을 만난 것이 2022년의 가장 큰 행복이라고 생각합니다.</p>
<h2 id="블로그">블로그</h2>
<p>개발 블로그는 아니지만 야구를 워낙 좋아해서 예전에 야구 블로그를 잠시나마 운영하던 적이 있었습니다. 어느정도 포스팅을 하다가 포기하고는 느꼈던 것이, 꾸준히 글을 쓰는게 얼마나 어려운지였습니다. 다행스럽게도 개발 블로그는 일 년 동안 꾸준히 작성할 수 있었습니다. 살면서 이렇게까지 꾸준히 뭔가를 유지한 경험이 많지가 않은데, 블로그 작성을 하나의 공부 방법으로 삼아서 중단 없이 계속할 수 있었던 것이 신기하기도 합니다. 그리고 이렇게 블로그 글을 꾸준히 작성하며 공부했던 것이 채용에도 도움이 되었던 것 같아 처음 시작했을때의 제가 기특하기도 합니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/527812ad-effa-46d1-8486-0a69897d9248/image.png" alt=""></p>
<p>그리고 블로그가 단순히 개념 정리용, 노트 필기용 블로그가 되지 않도록 했던 것이 만족스럽습니다. 초반에는 개념 정리용 게시물도 좀 있었던 것 같은데, 시간이 지나면서 이왕 시간을 쪼개어 글을 쓰는 것 제가 고민했던 내용이나 트러블 슈팅한 내용을 글로 남겨서 기억을 오래 가져가고자 했습니다. 덕분에 올해는 양질의 글을 남긴 것 같습니다.</p>
<p>티스토리를 쓸까 벨로그를 쓸까 고민했었는데, 스킨 적용하는 것 귀찮아서 옮겼던 벨로그가 이제는 마크다운 때문에라도 버릴 수 없는 몸이 되어버렸습니다. (그런데 대부분 백엔드 개발자 분들은 개인 티스토리 하나 가지고 계셔서 고민되기는 한단 말이죠...) 벨로그의 프론트엔드:백엔드 비율이 프론트엔드 쪽에 치우쳐서 백엔드 게시물의 약발이 잘 안먹는 듯 한(?) 느낌도 없지 않아 있지만, <a href="https://velog.io/@ohzzi/F12%EC%9D%98-%EB%88%88%EB%AC%BC%EB%82%98%EB%8A%94-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EC%9D%B4%EB%A1%A0%ED%8E%B8">트렌딩에</a> <a href="https://velog.io/@ohzzi/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-4%EA%B8%B0-%EC%82%AC%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-MAT.ZIP-%EA%B0%9C%EB%B0%9C%EA%B8%B0">올라간</a> <a href="https://velog.io/@ohzzi/Generic-1-Generic-who-are-you">글도</a> <a href="https://velog.io/@ohzzi/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-%ED%95%9C-%EB%8B%AC-%EC%83%9D%ED%99%9C%EA%B8%B0">몇 개</a> 있어서 뿌듯합니다 :)</p>
<h2 id="고단했던-취업-준비">고단했던 취업 준비</h2>
<p>한 해의 마무리는 역시 취업 준비였습니다. 그 전까지는 따로 특별한 취업 준비는 하지 않다가 우아한테크코스 레벨 5부터 계속 취업 준비를 하기 시작했는데요, 정말 취업 시장이 이렇게 얼어붙을지는 몰랐습니다...</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/0105703f-8e34-4391-bc05-ba8f4e87ac10/image.png" alt=""></p>
<p><del>윈터 이즈 커밍.. 아니 이미 왔구나...</del></p>
<p>우아한테크코스에 들어가면 다들 우아한형제들 전환 채용을 기대하게 됩니다. 저 역시도 마찬가지였습니다. 개인적으로 더 가고 싶은 회사가 한 군데 있긴 했지만, 어쨌든 전환 채용에 꼭 되었으면 좋겠다는 생각을 하고 있었으니까요. 자소서도 쓰고, 이력서도 쓰고, 모의 면접도 보고 준비를 열심히 했습니다. 심지어는 이력서를 컨펌받아보겠다고 <a href="https://www.youtube.com/@devbadak">개발바닥</a> 유튜브 라이브까지 나갔었네요... ㅋㅋㅋ</p>
<p>하지만 결과는...</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/e29d14d5-1e7e-4c97-8807-b69d926e33a6/image.png" alt=""></p>
<p><del>Noooooooooo</del></p>
<p>1차 탈락이었습니다. 정확히 이유야 알 수 없지만 면접이 제가 자신있는 방향으로 나오지 않았던 것이 컸던 것 같습니다. 1차 발표날에 멘탈이 완전히 박살이 났던 기억이 있네요. 그래도 딱 하루 멘탈 깨져있고 다음날부터는 멘탈을 추슬러서 다시 여러 기업 채용 전형에 지원했습니다. 마침 우아한테크코스에서 제공하는 리크루팅 데이 행사에도 여러 기업이 와서 좋은 기회가 많았거든요.</p>
<p>물론 저는 제 스스로가 한 번에 너무 많은 전형을 동시에 진행하기는 어렵겠다는 생각을 하고, 당시 생각으로는 정말 가고 싶다고 생각하는 기업들에만 지원을 하긴 했습니다. 기업 리스트를 다 밝힐 수는 없지만... 와중에도 계속 탈락 통보를 받곤 했습니다.</p>
<p>하지만 정말 다행스럽게도, 가장 가고싶었던 기업에 합격하게 되었습니다. 바로 토스뱅크입니다 :)</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/4aeb47f4-aec9-48c4-bf27-425d003b3c90/image.png" alt=""></p>
<p>원래부터 핀테크에 관심이 많고, 토스의 일하는 문화가 좋다고 생각해서(주변에서는 저보고 확신의 토스상이라고 하더군요 ㅋㅋ) 토스에서 정말 일해보고 싶었었는데, 우테코 하는 중간에는 서류부터 탈락이었다가 이력서를 이곳 저곳 손보고 나서 다시 지원하여 서류 합격을 하게 되었습니다.</p>
<p>기술 면접과 컬처핏 면접을 거치게 되었는데요, 개인적으로 봤던 어떤 면접 경험보다도 토스뱅크의 면접 경험이 좋았습니다. 1차나 2차나 면접 분위기 자체가 굉장히 편안했고, 기술 면접은 선배 개발자 분들과 깊은 기술적 대화를 나누는 좋은 자리같은 느낌을 받았습니다. 무엇보다 기술 면접을 준비하면서 팀 프로젝트를 할 때는 미처 고려하지 못했던 기술적 부분들에 대한 새로운 시각도 얻게 되었습니다. 컬처핏 면접은 정말 커피챗같은 느낌이었고, 토스 커뮤니티의 문화에 대해 다시 한번 확신을 얻을 수 있는 대화였습니다.</p>
<p>면접 분위기도 좋았고, 개인적으로 대답도 잘 했다고 생각해서 면접을 잘 본 것 같다는 생각은 들었었는데, 기술 면접과 컬처핏 면접 모두 면접을 마치고 나서 곧바로 합격 통보를 받을 수 있었습니다. 채용 시장이 얼어붙어서 꽤나 힘들뻔 했지만, 다행히 취준을 마무리하게 되어서 정말 운이 좋다고 생각합니다. 남은 우리 동기들에게도 행운이 가득하기를 바라겠습니다.</p>
<h2 id="2023-새로운-시작">2023, 새로운 시작</h2>
<p>저는 다가오는 새해 살면서 처음으로 직장인으로의 삶을 시작하게 됩니다. 처음 2022년 회고를 작성하려고 마음먹었을 때는 아직 백수 신분이었는데, 직장인이라니 신기하기 그지없습니다. 신분이 또 한번 바뀌게 되는데, 모든 환경이 새로운 만큼 걱정도 되고 기대도 됩니다. 2022년을 열심히 살았던 만큼 2023년도 열심히 살아서 2023년을 만족스럽게 돌아보며 2023 회고를 작성할 수 있으면 좋겠습니다.</p>
<p>이 글을 읽는 여러분 모두에게도 2023년 한 해는 행복한 한 해가 되길 바랍니다. 총총.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis를 활용하여 동시성 문제 해결하기]]></title>
            <link>https://velog.io/@ohzzi/Redis%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%98%EC%97%AC-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ohzzi/Redis%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%98%EC%97%AC-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 10 Dec 2022 04:46:16 GMT</pubDate>
            <description><![CDATA[<p>웹 서비스를 개발하다 보면 필연적으로 동시성 문제를 마주하게 됩니다. 기본적으로 웹 환경에서는 같은 시간에 여러 개의 요청이 들어올 수 있고, 스프링같은 멀티스레드 환경에서는 여러 스레드가 한 자원을 공유할 수 있어 데이터 정합성 문제가 발생할 수 있습니다. 때문에 백엔드 개발자라면 동시성을 문제에 대해 반드시 고려하고 넘어가야 합니다. <a href="https://velog.io/@ohzzi/%EB%8F%99%EC%8B%9C%EC%84%B1-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%A0%95%ED%95%A9%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0">예전 게시물</a>에서 비관적 락, 낙관적 락 등에 대한 고민과 결론적으로 데이터베이스의 원자적 연산 쿼리를 활용하는 방법으로 문제를 해결한 과정을 설명드렸는데요, 오늘은 조금 다른 방법으로 접근해보려고 합니다. 바로 Redis를 사용하는 방법입니다.</p>
<p>Redis는 <strong>Re</strong>mote <strong>Di</strong>ctionary <strong>S</strong>erver의 약자로서, &quot;키-값&quot; 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비관계형 데이터베이스 관리 시스템입니다. 저장소, 캐시, 메세지 브로커 등으로 사용되며, 보통은 캐시의 용도나 세션 저장소 등의 용도로 많이 쓰이는 소프트웨어입니다.</p>
<p>Redis로 어떻게 동시성을 제어한다는 것일까요? 바로 Redis를 활용한 <strong>분산 락(Distributed Lock)</strong>을 활용하면 됩니다. 분산 락은 이름 그대로 분산된 서버 또는 데이터베이스 환경에서도 동시성을 제어할 수 있는 방법인데요, 사실 반드시 Redis를 활용해서 분산 락을 구현할 필요는 없습니다. MySQL의 네임드 락 등을 활용해서도 충분히 구현할 수 있습니다. 오히려 이 쪽이 메모리 자원을 추가로 사용할 필요가 없다는 장점을 가지고 있기도 합니다. 그러나 기본적으로 디스크를 사용하는 데이터베이스보다 메모리를 사용하는 Redis가 더 빠르게 락을 획득 및 해제할 수 있기 때문에, 이번 시간에는 Redis로 동시성을 제어하는 방법을 예제를 통해 확인해보도록 하겠습니다.</p>
<h2 id="예제-코드">예제 코드</h2>
<p>서점의 재고 관리 시스템이라는 매우 간단한 예제를 통해 알아보도록 하겠습니다. 불필요한 요소들은 모두 쳐내고, <code>Book</code>과 <code>Stock</code>이라는 엔티티가 필요합니다.</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;book&quot;)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name = &quot;name&quot;, nullable = false)
    private String name;
    @Column(name = &quot;price&quot;, nullable = false)
    private int price;
    @OneToOne(optional = false, cascade = CascadeType.ALL)
    @JoinColumn(name = &quot;stock_id&quot;, nullable = false)
    private Stock stock;

    public Book(final String name, final int price, final Stock stock) {
        this.name = name;
        this.price = price;
        this.stock = stock;
    }

    public void purchase(final long quantity) {
        stock.decrease(quantity);
    }
}</code></pre>
<p>먼저 도서 도메인을 나타내는 <code>Book</code> 엔티티입니다. 재고에 대해서는 별도의 테이블로 나누어 관리하기 위해 <code>@OneToOne</code>으로 일대일 관계를 나타내주었습니다.</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;stock&quot;)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Stock {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name = &quot;quantity&quot;, nullable = false)
    private long remain;

    public Stock(final long remain) {
        this.remain = remain;
    }

    public void decrease(final long quantity) {
        if ((remain - quantity) &lt; 0) {
            throw new IllegalArgumentException();
        }
        remain -= quantity;
    }
}</code></pre>
<p>그리고 도서를 구매하는 서비스 로직도 작성해주도록 하겠습니다.</p>
<pre><code class="language-java">@Service
public class BookService {

    private final BookRepository bookRepository;

    public BookService(final BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    @Transactional
    public void purchase(final Long bookId, final long quantity) {
        Book book = bookRepository.findById(bookId)
                .orElseThrow(IllegalArgumentException::new);
        book.purchase(quantity);
    }
}</code></pre>
<p>위와 같은 예제 코드에서 중요한 비즈니스 로직은 무엇일까요?</p>
<ul>
<li>도서 구매에 성공하면 재고가 1 감소해야 한다.</li>
<li>도서 구매 시 재고가 부족하면 예외를 반환한다.</li>
</ul>
<p>간단하게 이 두 가지 요구사항을 확인할 수 있습니다. 그리고 이 요구사항에 동시성과 관련된 요구사항을 하나 추가한다면,</p>
<ul>
<li>동시에 여러 명이 도서 구매 요청을 하더라도 재고의 정합성은 유지되어야 한다.</li>
</ul>
<p>를 추가할 수 있습니다. 이는 N명이 동시에 어떤 도서를 한 권씩 구매한다고 했을 때, 해당 도서의 재고가 N개 줄어들어야 한다는 의미와 일맥상통합니다. 해당 요구사항을 만족시킬 수 있는지 테스트 코드를 통해 알아보도록 하겠습니다.</p>
<pre><code class="language-java">@Test
void 동시에_100명이_책을_구매한다() throws InterruptedException {
    Long bookId = bookRepository.save(new Book(&quot;이펙티브 자바&quot;, 36_000, new Stock(100)))
            .getId();
    ExecutorService executorService = Executors.newFixedThreadPool(100);
    CountDownLatch countDownLatch = new CountDownLatch(100);

    for (int i = 0; i &lt; 100; i++) {
        executorService.submit(() -&gt; {
            try {
                bookService.purchase(bookId, 1);
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();
    Book actual = bookRepository.findById(bookId)
            .orElseThrow();

    assertThat(actual.getStock().getRemain()).isZero();
}</code></pre>
<p>만약 동시성 문제가 잘 해결된다면, 100개의 도서에 대한 100명의 구매 요청이므로 재고는 0이 되어야 할 것입니다. 하지만 테스트 코드를 실행해보면</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/c035e692-0e96-4a65-b007-2df28f458524/image.png" alt=""></p>
<p>재고는 14개밖에 줄어들지 않았습니다. 즉, 총 86개에 대한 갱신 유실이 발생한 것입니다. 이는 한 트랜잭션이 커밋되기 전에 다른 트랜잭션이 변경하려는 값을 읽어버려서 생기는 문제입니다. 이제 Redis를 사용해 해당 문제를 해결해보도록 하겠습니다.</p>
<h2 id="lettuce-redisson">Lettuce? Redisson?</h2>
<p>그 전에 한 가지 고려해야 할 것이 있습니다. 자바에서 Redis를 쓸 수 있게 해주는 클라이언트에 대한 선택입니다. Redis 클라이언트로는 Lettuce와 Redisson이 있는데요, Spring Data Redis를 사용하면 기본적으로 지원하는 클라이언트는 Lettuce입니다. 때문에 좀 더 사용하기 편하다는 장점이 있습니다.</p>
<p>하지만 Lettuce로 분산 락을 구현하려면 반드시 스핀 락의 형태로 구현해야 한다는 단점이 있습니다. 스핀 락은 락을 획득하기 위해 <code>SETNX</code>라는 명령어로 계속해서 Redis에 락 획득 요청을 보내야 하는 구조입니다. 때문에 필연적으로 Redis에 많은 부하를 가하게 됩니다. 이를 방지하기 위해 락 획득 요청 사이 사이마다 <code>Thread.sleep</code>을 통해 부하를 줄여줘야 하고, 설령 sleep을 통해 줄여준다 하더라도 많은 부하가 가는 문제가 있습니다.</p>
<blockquote>
<p>여기서 잠깐, SETNX란?</p>
<p><strong>SET</strong> if <strong>N</strong>ot e<strong>X</strong>ist의 줄임말로, 특정 key 값이 존재하지 않을 경우에 set 하라는 명령어 입니다. 특정 키에 대해 SETNX 명령어를 사용하여 value가 없을 때만 값을 세팅하는, 즉 락을 획득하는 효과를 낼 수 있습니다.</p>
</blockquote>
<p>또한 스핀 락으로 구현하게 될 경우 락의 타임아웃을 처리하기 힘들어집니다. 스핀 락을 사용하는 Lettuce 코드의 경우, 자체적인 타임아웃 구현이 존재하지 않습니다. 때문에 락을 영원히 반환하지 않는다든가, 락을 획득하지 못해 무한 루프를 돈다든가 하는 문제를 해소하려면 애플리케이션 코드 상에서 타임아웃을 직접 구현해야 합니다.</p>
<p>반면 Redisson을 이용하면 부하와 타임아웃에 대한 문제를 해결할 수 있습니다. 먼저 Redis에 가해지는 부하의 측면에서 살펴보면, Redisson은 Lettuce처럼 주기적으로 락 획득 요청을 보낼 필요가 없습니다. Redis는 메시지 브로커의 역할을 할 수 있다고 말씀드렸는데요, 메시지에 대한 publish와 subscribe 기능을 지원합니다. Redisson은 이 기능을 통해 락을 획득 및 해제 하는 로직을 구현하고 있습니다.</p>
<p>조금 더 쉽게 설명하기 위해, 동시에 5개의 스레드가 락 획득을 위해 경합한다고 하겠습니다. 스레드 1번이 락을 획득하고 로직을 처리합니다. 그리고 대기하고 있는 스레드 2~5는 락 획득을 위해 특정 채널을 subscribe하고 있습니다. 로직의 처리가 완료되면 락을 해제합니다. 락이 해제되면 락이 해제되었다는 메시지를 대기 스레드들이 subscribe하고 있는 채널에 publish합니다. 이어서 대기 스레드 중 하나가 다시 락을 획득하고, 이 과정을 반복합니다.</p>
<p>또한 Redisson에서 제공하는 락 관련 기능은 락의 타임아웃도 구현해놨다는 장점이 있습니다. 무려 락을 획득했을 때의 타임아웃과, 락 대기 타임아웃 모두를요. 때문에 타임아웃 기능을 간편하게 사용할 수 있다는 장점도 있습니다.</p>
<p>다만 <a href="http://redisgate.kr/redis/clients/redisson_intro.php">Redisson은 Lettuce에 비해 사용이 어렵다는 후기</a>도 존재합니다. 하지만 분산 락을 구현하기에 Redisson이 최적의 라이브러리라고 생각하여 Redisson을 사용하도록 하겠습니다.</p>
<h2 id="redisson을-사용하여-분산-락-구현">Redisson을 사용하여 분산 락 구현</h2>
<p>우선 Redisson에 대한 의존성을 설정해주어야 합니다. 일반적으로 Redis를 사용할 때 많이 사용하는 Spring Data Redis는 기본 클라이언트로 Lettuce를 사용하기 때문에, Redisson은 추가적인 의존성을 추가해주어야 합니다.</p>
<pre><code class="language-groovy">implementation &#39;org.redisson:redisson-spring-boot-starter:3.18.0&#39;</code></pre>
<p>redisson-spring-boot-starter는 Spring Data Redis의 기능들을 포함하고 있기 때문에, 굳이 spring-boot-starter-data-redis를 implementation 할 필요가 없습니다.</p>
<p>Redisson에는 RLock이라는 객체가 존재합니다. 이 객체를 통해 락을 컨트롤할 수 있습니다.</p>
<pre><code class="language-java">public interface RLock extends Lock, RLockAsync {

    String getName();

    void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException;

    boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;

    void lock(long leaseTime, TimeUnit unit);

    boolean forceUnlock();

    boolean isLocked();

    boolean isHeldByThread(long threadId);

    boolean isHeldByCurrentThread();

    int getHoldCount();

    long remainTimeToLive();
}</code></pre>
<p>RLock을 얻기 위해서는 RedissonClient.getLock 메서드를 호출해주어야 합니다. (참고로, RedissonClient는 getSpinLock을 통해 앞서 Lettuce에서 언급했던 스핀 락을 얻을 수도 있습니다.)</p>
<pre><code class="language-java">public interface RedissonClient {
    ...
    RLock getLock(String name);
}</code></pre>
<p>그러면 Redisson을 통해 분산 락을 획득해보도록 하겠습니다.</p>
<pre><code class="language-java">@Transactional
public void purchase(final Long bookId, final long quantity) {
    RLock lock = redissonClient.getLock(String.format(&quot;purchase:book:%d&quot;, bookId));
    try {
        boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
        if (!available) {
            System.out.println(&quot;redisson getLock timeout&quot;);
            return;
        }
        Book book = bookRepository.findById(bookId)
                .orElseThrow(IllegalArgumentException::new);
        book.purchase(quantity);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        lock.unlock();
    }
}</code></pre>
<p>RedissonClient로부터 락 객체를 얻어온 뒤, try ~ catch 안에서 tryLock을 호출해주도록 하겠습니다. 락을 무사히 획득했다면, 기존에 작성되어있던 서비스 로직 코드가 호출됩니다. 그리고 finally 구문을 통해 락을 해제합니다. 락을 구현했으니 이제 테스트 코드도 잘 돌아가겠죠?</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/9758b39a-2f86-4fbe-8678-97f33202670a/image.png" alt=""></p>
<p>이전 코드에 비해 재고 감소량이 늘어났다는 점은 있지만, 이번에도 테스트 통과에 실패합니다. 왜 실패할까요? 답은 분산락 해제 시점과 트랜잭션 커밋 시점의 불일치 때문입니다. 코드를 보면 purchase 메서드에 <code>@Transactional</code> 어노테이션이 붙어 있습니다. 때문에 스프링 AOP를 통해 purchase 메서드 바깥으로 트랜잭션을 처리하는 프록시가 동작하게 됩니다. 반면 락 획득과 해제는 purchase 메서드 내부에서 일어납니다. 때문에 스레드 1과 스레드 2가 경합한다면 스레드 1의 락이 해제되고 트랜잭션 커밋이 되는 사이에 스레드 2가 락을 획득하게 되는 상황이 발생합니다. 데이터베이스 상으로 락이 존재하지 않기 때문에 스레드 2는 데이터를 읽어오게 되고, 스레드 1의 변경 내용은 유실됩니다. 때문에 락 범위가 트랜잭션 범위보다 크도록 Facade를 만들어주도록 하겠습니다.</p>
<pre><code class="language-java">@Service
public class BookLockFacade {

    private final BookService bookService;
    private final RedissonClient redissonClient;

    public BookLockFacade(final BookService bookService, final RedissonClient redissonClient) {
        this.bookService = bookService;
        this.redissonClient = redissonClient;
    }

    public void purchase(final Long bookId, final int quantity) {
        RLock lock = redissonClient.getLock(String.format(&quot;purchase:book:%d&quot;, bookId));
        try {
            boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
            if (!available) {
                System.out.println(&quot;redisson getLock timeout&quot;);
                throw new IllegalArgumentException();
            }
            bookService.purchase(bookId, quantity);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}</code></pre>
<p>서비스 코드에서는 Redisson을 사용한 코드를 제거해주도록 하겠습니다.</p>
<pre><code class="language-java">@Service
public class BookService {

    private final BookRepository bookRepository;

    public BookService(final BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    @Transactional
    public void purchase(final Long bookId, final long quantity) {
        Book book = bookRepository.findById(bookId)
                .orElseThrow(IllegalArgumentException::new);
        book.purchase(quantity);
    }
}</code></pre>
<p>테스트 코드는 BookService를 테스트하는 코드에서 BookLockFacade를 테스트하는 코드로 변경해주면 됩니다.</p>
<pre><code class="language-java">@Test
void 동시에_100명이_책을_구매한다() throws InterruptedException {
    Long bookId = bookRepository.save(new Book(&quot;이펙티브 자바&quot;, 36_000, new Stock(100)))
            .getId();
    ExecutorService executorService = Executors.newFixedThreadPool(100);
    CountDownLatch countDownLatch = new CountDownLatch(100);

    for (int i = 0; i &lt; 100; i++) {
        executorService.submit(() -&gt; {
            try {
                bookLockFacade.purchase(bookId, 1);
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();
    Book actual = bookRepository.findById(bookId)
            .orElseThrow();

    assertThat(actual.getStock().getRemain()).isZero();
}</code></pre>
<p>이제 테스트를 다시 실행해보도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/542e2cae-b438-4a75-80e3-1331f04c3d62/image.png" alt=""></p>
<p>테스트가 정상적으로 통과하는 것을 볼 수 있습니다.</p>
<h2 id="개선-방향을-생각해보기">개선 방향을 생각해보기</h2>
<p>정상적으로 동작하는 코드를 만들었지만, 좀 더 개선할 부분을 생각해볼 수 있습니다. 우선 분산 락을 적용할 곳이 많을 경우입니다. 이런 경우, 위에 만들었던 퍼사드 코드가 계속해서 생길 수 밖에 없습니다. 중복 로직을 개선하기 위해서 AOP의 적용을 생각해볼 수 있습니다.</p>
<p>사실 중복 코드보다 더 문제가 될만한 부분도 존재합니다. 만약 해당 도서가 엄청 인기 있는 한정판 도서여서 오픈런이 열린다면 어떻게 될까요? 정말 엄청나게 많은 사람들이 동시에 요청을 한다고 가정해보도록 하겠습니다. 백만 명이 요청을 한다면, 백만개의 트랜잭션이 락 획득을 위한 대기를 해야 합니다. 또한 재고가 충분하고, 모든 트랜잭션이 락 획득에 성공하여 로직 수행에 성공한다면 데이터베이스에 단 건 업데이트 쿼리가 백만개가 들어가야 합니다. 이는 데이터베이스에 큰 부하입니다. 락 타임아웃을 적용한다고 하면 요청 다수가 실패하는 문제도 발생합니다.</p>
<p>이런 부분을 해결하기 위해 애초에 재고와 같은 수치를 Redis로 관리하는 방법을 생각할 수 있습니다. 다음 시간에는 재고를 Redis로 관리하는 방법에 대해서 알아보도록 하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[동기-비동기, 블로킹-논블로킹, 대체 차이가 뭐에요?]]></title>
            <link>https://velog.io/@ohzzi/%EB%8F%99%EA%B8%B0-%EB%B9%84%EB%8F%99%EA%B8%B0-%EB%B8%94%EB%A1%9C%ED%82%B9-%EB%85%BC%EB%B8%94%EB%A1%9C%ED%82%B9-%EB%8C%80%EC%B2%B4-%EC%B0%A8%EC%9D%B4%EA%B0%80-%EB%AD%90%EC%97%90%EC%9A%94</link>
            <guid>https://velog.io/@ohzzi/%EB%8F%99%EA%B8%B0-%EB%B9%84%EB%8F%99%EA%B8%B0-%EB%B8%94%EB%A1%9C%ED%82%B9-%EB%85%BC%EB%B8%94%EB%A1%9C%ED%82%B9-%EB%8C%80%EC%B2%B4-%EC%B0%A8%EC%9D%B4%EA%B0%80-%EB%AD%90%EC%97%90%EC%9A%94</guid>
            <pubDate>Tue, 22 Nov 2022 15:09:16 GMT</pubDate>
            <description><![CDATA[<p>우아한테크코스 4기 크루들과 면접 준비를 할 때 종종 나오는 질문이 있었습니다. <code>동기-비동기의 차이는 무엇인가요? 블로킹-논블로킹과는 어떤 차이가 있나요?</code> 특히 <code>@Async</code> 어노테이션을 활용해 비동기 처리를 한 코드가 있었을 경우에는 거의 무조건 질문이 나왔던 것 같습니다. 사실 저도 동기-비동기와 블로킹-논블로킹을 제대로 알지 못하고 있었습니다. 막연하게 <code>동기면 다음 작업 처리 못하고 비동기면 계속 작업하는거 아니야?</code>라고 생각했었습니다. (이러면 동기와 블로킹의 차이가 없어져버리죠)</p>
<p>공부해보니 동기-비동기와 블로킹-논블로킹은 좀 다른 개념이었습니다. 그리고 이해하기 쉽지 않은 헷갈리는 개념이기도 했습니다. 앞으로 비동기 처리나 논블로킹 IO를 활용할 일이 많을 것으로 예상하기 때문에 미리 정리를 하는 시간은 가져보려 합니다. 설명의 편의를 위해 함수 또는 작업이라는 용어를 사용하게 될텐데요, 단순히 코드에 작성한 함수 뿐 아니라 IO를 진행하는 프로세스, 네트워크 모델에서의 요청 및 응답 등에도 적용되는 개념이라고 생각해주시면 편할 것 같습니다. 저는 설명의 편의를 위해 함수 기준으로 설명하도록 하겠습니다.</p>
<h2 id="동기---비동기는-작업의-진행-여부와-상관이-없다">동기 - 비동기는 작업의 진행 여부와 상관이 없다!</h2>
<p>저를 포함 많은 분들이 <code>동기는 함수를 호출한 후 return될 때까지 자기 작업을 진행하지 못한다</code>, <code>비동기는 다른 함수를 호출한 후에도 자기 작업을 진행한다</code>라고 알고 있는 경우가 있는 것 같습니다. 저희가 사용하는 케이스가 거의 동기+블로킹, 비동기+논블로킹 케이스여서 개념이 섞여있는 것 같습니다.</p>
<p>비동기 논블로킹 처리에 대해서 공부하며 알게 된 사실로는, 동기 - 비동기는 함수의 진행 여부와는 상관이 없습니다. 이는 함수의 <code>제어권</code>에 대한 문제이기 때문입니다. 반면 동기 - 비동기는 프로세스의 수행 순서 보장에 대한 메커니즘입니다.</p>
<p>동기 - 비동기에 대해 좀 더 쉽게 이해하기 위해, 동기의 뜻에 대해서 알아보도록 하겠습니다.</p>
<blockquote>
<p>동기(同期)
같은 시기. 또는 같은 기간.</p>
</blockquote>
<p>동기라 함은 같은 시기, 또는 같은 기간이라는 뜻을 가지고 있습니다. 여기까지만 봐서는 잘 이해가 되지 않는데요, <code>현재 작업의 응답과 다음 작업의 요청이 동시에 일어난다</code> 정도의 해석이 가장 적절하지 않을까 싶습니다. 결론적으로 말하면 <code>작업이 어떠한 순서를 보장한다.</code> 정도가 될 수 있겠네요. 동기 - 비동기의 뜻에 대한 좀 더 자세한 탐구는 <a href="https://evan-moon.github.io/2019/09/19/sync-async-blocking-non-blocking/">여기</a>를 참고해주시면 좋을 것 같습니다. 응답과 요청의 순서를 보장하려면 어떻게 해야 할까요? 호출한 함수(작업)의 응답이 왔는지, 응답 값이 무엇인지에 대해 알고 있어야 합니다. 때문에 동기 - 비동기는 다음과 같이 생각할 수 있습니다.</p>
<ul>
<li>동기(Synchronous): 호출한 함수의 반환값을 계속해서 신경 씀</li>
<li>비동기(Asynchronous): 호출한 함수의 반환값을 신경쓰지 않음</li>
</ul>
<p>상황으로 알아보면 이런 상황을 생각할 수 있습니다.</p>
<blockquote>
<p>나: 블로그에 글 하나만 써줘
상대: OK
...
나: 글 다 썼음?
상대: NO
나: 글 다 썼음?
상대: NO
나: 글 다 썼음?
상대: YES. 주소 여기있음
나: (주소를 받아서 추가적인 작업을 처리한다)</p>
</blockquote>
<p>이렇게 호출 대상의 반환값을 계속해서 체크한다면 동기입니다. 반면 비동기는 다음과 같은 상황이 됩니다.</p>
<blockquote>
<p>나: 블로그에 글 하나만 써줘
상대: OK
...
나: 내 일 다 했으니까 가야겠다(퇴장)
상대: (글을 계속 쓴다)
...
(상대 작업 완료. 하지만 글 주소를 받을 &#39;나&#39;는 이미 없음)</p>
</blockquote>
<p>여기서 아주 중요한 문제가 있는데요, 비동기는 호출한 함수의 반환값을 신경쓰지 않는다는 것입니다. 즉, 비동기 함수를 호출했을 때 그 반환값을 호출한 쪽에서 다시 이용하는 것은 불가능하다는 이야기입니다. 때문에 비동기 방식을 사용하는 경우 호출한 함수의 완료 이후 해당 반환값을 가지고 추가적인 처리를 해주기 위해 콜백(callback) 함수를 이용하게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/9d7c5bdd-6cbf-43db-a0c5-94afba06f072/image.png" alt="">
<del>콜백 지옥이 바로 비동기 처리 때문에 생기는 것입니다!!</del>
<del><a href="https://hanamon.kr/javascript-%EC%BD%9C%EB%B0%B1-%EC%A7%80%EC%98%A5-%ED%83%88%EC%B6%9C%ED%95%98%EA%B8%B0-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EB%B2%95/">사진 출처</a></del></p>
<p>동기 처리라고 해도 호출한 함수의 작업을 진행하면서 동시의 자기 작업을 진행하는 상황이 가능하기도 합니다. 한 작업의 응답과 다음 작업의 요청에 대한 순서만 보장해주면 되지 그 사이에 자기 작업의 남은 일을 하지 말아야 한다는 법은 없으니까요. 이에 대해서는 블로킹 - 논블로킹에 대해서도 체크한 뒤 좀 더 자세히 알아보도록 하겠습니다.</p>
<h2 id="블로킹---논블로킹은-제어권을-생각해야-한다">블로킹 - 논블로킹은 제어권을 생각해야 한다!</h2>
<p>제어권이란 무엇일까요? 제어권은 자기 작업을 실행할 수 있는 권한이라고 생각하면 될 것 같습니다. 자바 코드로 생각해보도록 하겠습니다. A 메서드의 첫 줄에서 B 메서드를 호출한다고 하면, 두 번째 줄부터의 작업은 B 메서드의 작업이 모두 끝나기 전까지는 실행되지 않습니다. 이는 자바의 메서드 호출이 일반적으로는 블로킹 방식이기 때문입니다.</p>
<p>그림으로 나타내면 다음과 같은 진행 플로우를 가지게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/f1df4a75-df9a-4099-b232-713883aa34ac/image.png" alt=""></p>
<p>함수 A가 함수 B를 호출하면서 제어권도 함께 뺏기게 됩니다. 때문에 함수 B의 작업이 모두 끝날 때 까지 함수 A는 어떤 작업도 할 수 없는 상태가 됩니다. 작업 진행이 Block되어서 블로킹이라고 생각하시면 될 것 같습니다. (함수 뿐 아니라 쓰레드 등 다른 개념을 넣어도 같은 의미로 동작합니다.)</p>
<p>그렇다면 논블로킹은 어떨까요? 블로킹과는 반대의 의미일테니, 제어권을 넘겨주지 않는 것으로 생각하면 이해하기 쉬울 것 같습니다. 즉, 다른 함수를 호출하더라도 호출한 쪽 작업을 계속해서 진행할 수 있는 방식입니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/fb83b4e4-cb67-45b4-abb9-29f9e95aec9c/image.png" alt=""></p>
<h2 id="동기-비동기-블로킹-논블로킹을-조합해보자">동기, 비동기, 블로킹, 논블로킹을 조합해보자</h2>
<p>자 이렇게 동기 - 비동기와 블로킹 - 논블로킹이 다른 개념이라면, 두 개념의 조합이 가능하겠죠? 총 네 가지 경우의 수가 나오게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/8d124cc6-e86e-400f-8b33-36ce49ec6532/image.png" alt=""></p>
<p>가장 먼저, 우리에게 친숙한 동기 - 블로킹 조합을 알아보도록 하겠습니다. 그림에서 볼 수 있듯이, 일반적인 읽기/쓰기가 동기 - 블로킹 조합으로 이루어집니다. 특히 제가 사용한느 언어인 자바의 경우 대부분의 코드가 동기 - 블로킹 조합으로 작동하게 됩니다.</p>
<p>마찬가지로 비동기 - 논블로킹 조합도 쉽게 찾아볼 수 있습니다. 자바스크립트를 생각하면 될 것 같은데요, 자바스크립트의 모든 함수가 비동기인 것은 아니지만, fetch, setTimeOut과 같은 비동기 함수를 쉽게 찾아볼 수 있습니다. </p>
<p>그렇다면 동기 - 논블로킹 조합은 어떨까요? 위의 그림에서는 잘 이해가 가지 않을 수 있으니 간단한 예시를 들어보도록 하겠습니다. 어떤 게임들은 실행하면 로딩 창이 뜨고 시간이 지남에 따라 로딩 바가 점점 차오르게 됩니다. 그런데 화면에 로딩 바가 차오르는 것을 그리는 작업과, 실제로 맵이나 유닛 등을 로딩하는 작업은 서로 다른 작업입니다. 이런 경우는 동기 - 논블로킹이라고 할 수 있습니다.</p>
<p>게임을 진행하는 함수는 맵을 로딩하는 함수를 호출합니다. 맵을 로딩하는 동안, 화면이 멈춰 있어서는 안되겠죠? 따라서 얼만큼 로딩되었는지 로딩 바를 그려서 보여줍니다. 위의 함수 A, 함수 B 그림에서 보자면 함수 A는 화면에 로딩 바를 보여주고, 함수 B는 맵 데이터를 가져오는 역할이 됩니다. 그런데 생각해 봅시다. 맵을 불러오는 동안 로딩 바가 변하지 않는다면 굉장히 이상할 것입니다. 때문에 로딩 바를 그리는 함수 A는 계속해서 함수 B에게 로딩이 얼만큼 되었는지 확인합니다. 그리고 로딩 된 만큼 로딩 바를 렌더링 하겠죠. 이 상황이 바로 동기 - 논블로킹 상황이 되는 것입니다. (화면을 구성하는 함수는 로딩 함수와 상관없이 계속해서 본인의 작업을 진행. 하지만 로딩 함수가 맵의 얼만큼 로딩했는지 지속적으로 확인하기 때문.)</p>
<p>그렇다면 비동기 - 블로킹 조합은 어떨까요? 사실 단순히 생각해보면 굉장히 비효율적일 수밖에 없습니다. 호출한 함수의 결과는 관심도 없는데, 제어권은 넘겨줘서 아무 동작도 못하고 있게 되기 때문입니다. 실제 상황에서 발생할 수 있는 경우로는 비동기 처리를 하는 도중에 블로킹 형태로 작동하는 작업을 하는 경우가(예를 들어 데이터베이스에 접근한다든가) 있을 수 있다고 하네요. 그림에는 I/O 멀티플렉싱도 비동기 - 블로킹 조합이라고 하는데, 이 부분은 블로킹인지 논블로킹인지에 대해 논란이 있다고 합니다. </p>
<h2 id="조금-더-자세히-알고-싶다면">조금 더 자세히 알고 싶다면</h2>
<p>사실 굉장히 어려운 개념이고, 머릿속에 들어있는 내용을 글로 표현하기가 너무 어려운 것 같습니다. 이 글을 통해서는 간단히 동기 - 비동기와 블로킹 - 논블로킹이 다른 개념이라는 점만 인지하고, 좀 더 자세한 설명이 필요하다면 우아한테크코스 테코톡 중 좋은 자료가 있어 해당 영상을 참고하시면 좋을 것 같습니다.</p>
<p><a href="https://www.youtube.com/watch?v=IdpkfygWIMk">[10분 테코톡] 🎧 우의 Block vs Non-Block &amp; Sync vs Async</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[@OneToMany 단방향 매핑의 update 쿼리를 없애려면 어떻게 해야 할까?]]></title>
            <link>https://velog.io/@ohzzi/OneToMany-%EB%8B%A8%EB%B0%A9%ED%96%A5-%EB%A7%A4%ED%95%91%EC%9D%98-update-%EC%BF%BC%EB%A6%AC%EB%A5%BC-%EC%97%86%EC%95%A0%EB%A0%A4%EB%A9%B4-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@ohzzi/OneToMany-%EB%8B%A8%EB%B0%A9%ED%96%A5-%EB%A7%A4%ED%95%91%EC%9D%98-update-%EC%BF%BC%EB%A6%AC%EB%A5%BC-%EC%97%86%EC%95%A0%EB%A0%A4%EB%A9%B4-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Sat, 12 Nov 2022 14:38:17 GMT</pubDate>
            <description><![CDATA[<h2 id="전-분명-insert만-했는데요">전 분명 INSERT만 했는데요?</h2>
<p>JPA의 다양한 연관관계 매핑 중 <code>@OneToMany</code>를 통한 일대다 단방향 매핑이 있습니다. 아마 JPA에 대해 조금이라도 공부하신 분들은 일대다 단방향 매핑에 대해 다음과 같은 단점을 들어보셨을테죠.</p>
<blockquote>
<p>일대다 단방향 매핑의 단점은 매핑한 객체가 관리하는 외래 키가 다른 테이블에 있다는 점이다. 본인 테이블에 왜래 키가 있으면 엔티티의 저장과 연관관계 처리를 INSERT SQL 한 번으로 끝낼 수 있지만, 다른 테이블에 외래 키가 있으면 연관관계 처리를 위한 UPDATE SQL을 추가로 실행해야 한다.</p>
<p>김영한 저, 자바 ORM 표준 JPA 프로그래밍</p>
</blockquote>
<p>즉, INSERT 쿼리만을 의도했지만 UPDATE 쿼리가 추가적으로 발생하는 단점이 있다는 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/e68c6eb0-a2e9-4b9e-a393-89834c71eff8/image.png" alt=""></p>
<p><del>심심하면 의도하지 않은 쿼리가 발생하는 JPA. 이때는 대략 정신이 멍해진다.</del></p>
<p>다음과 같은 엔티티를 통해 직접 확인해보도록 하겠습니다. 편의를 위해 롬복을 사용했습니다.</p>
<pre><code class="language-java">@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name = &quot;team_id&quot;)
    private List&lt;Player&gt; players = new ArrayList&lt;&gt;();

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

    public void add(final Player player) {
        players.add(player);
    }

    public void remove(final Player player) {
        players.remove(player);
    }

    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Team)) {
            return false;
        }
        Team team = (Team) o;
        return Objects.equals(id, team.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Player {

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

    public Player(final String name) {
        this.name = name;
    }

    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Player)) {
            return false;
        }
        Player player = (Player) o;
        return Objects.equals(id, player.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}</code></pre>
<p>이 엔티티들을 테스트 코드를 통해 몇 개의 쿼리가 나가는지 체크해보도록 하겠습니다. 간단하게 쿼리 카운터를 구현할 수도 있긴 하지만, 어떤 쿼리가 나가는지 목적이므로 로그를 통해 확인하겠습니다.</p>
<pre><code class="language-java">@DataJpaTest
class TeamRepositoryTest {

    @Autowired
    private TeamRepository teamRepository;

    @Test
    void oneToManyTest() {
        Team drx = new Team(&quot;DRX&quot;);
        Player deft = new Player(&quot;데프트&quot;);
        Player zeka = new Player(&quot;제카&quot;);
        drx.add(deft);
        drx.add(zeka);

        teamRepository.save(drx);
        // @DataJpaTest의 트랜잭션으로 인해 update 쿼리가 쓰기 지연 저장소에 있다 롤백됨
        // 이를 로그로 보여주기 위해 강제 flush
        teamRepository.flush();
    }
}</code></pre>
<p>로그를 확인해볼까요?</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/47558c6c-0329-4489-9773-40a395efeb08/image.png" alt=""></p>
<p>drx, deft, zeka 각각에 대한 INSERT 쿼리만 실행되면 되는데 deft, zeka에 대한 UPDATE 쿼리가 추가로 발생했습니다. 이는 연관관계의 주인은 Team이지만 실제 테이블 매핑에서는 외래키를 Player쪽이 가지고 있기 때문에 발생하는 문제입니다. 실제로 쿼리를 보시면 알겠지만 Player를 INSERT할 때 id, name만 매핑하고 team_id값은 매핑하지 않는 것을 볼 수 있습니다. 즉, 처음 INSERT 시점에는 외래키 값을 모르므로 일단 NULL을 매핑하고 Team의 값으로 외래키를 UPDATE를 해주는 것을 볼 수 있습니다.</p>
<p>이 때문에 일대다 단방향 대신 양방향을 사용할 것이 권장되기도 합니다. 하지만 객체지향적인 관점에서 의존성의 순환을 끊어주기 위해 일대다 단방향 매핑을 선호하는 경우도 분명 있습니다. 실제로 우아한테크코스 프로젝트와 미션을 진행하면서, 쿼리 때문에 불필요한 객체 참조를 달아주는 것에 거부감을 느껴 단방향 매핑을 해주는 동료들이 꽤 있었습니다.</p>
<p>이들을 위한, UPDATE 쿼리를 없앨 방법은 없을까요?</p>
<p>앞서 쿼리를 보면 INSERT 쿼리로는 외래키에 NULL이 들어가게 됩니다. 여기서 NULL이 허용되지 않도록 NOT NULL 제약조건을 걸어주면 되지 않을까라는 생각을 해볼 수 있습니다. <code>@JoinColum</code>에 <code>nullable=false</code> 조건을 넣고 다시 해보겠습니다. 실제 값이 어떻게 매핑되는지도 확인하기 위해 쿼리 파라미터에 어떤 값이 들어가는지도 로그로 남도록 설정도 바꿔주겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/153e4c3e-b300-4b10-8a15-e6838128a95b/image.png" alt=""></p>
<p>기대한대로 INSERT 시점에 외래키 값이 들어가는 것을 확인할 수 있습니다. 하지만 그럼에도 불구하고 이미 매핑된 외래키 값을 다시 UPDATE하는 쿼리가 추가로 발생합니다. 이미 매핑이 된 컬럼인데 무의미한 UPDATE쿼리가 나가고 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/93d14f13-57ae-4ffe-a41e-826529b97c36/image.png" alt=""></p>
<p><del>JPA 너란 녀석...</del></p>
<p>그렇다면 정말 UPDATE 쿼리를 없앨 방법이 없는 것일까요?</p>
<h2 id="선생님은-이제부터-update를-할-수-없습니다">선생님은 이제부터 UPDATE를 할 수 없습니다</h2>
<p>&quot;UPDATE&quot;가 문제라면 UPDATE를 할 수 없게 강제해버리면 되지 않을까요? JPA에는 마침 updatable이라는 옵션이 존재합니다. 뭔가 이 옵션을 false로 만들면 UPDATE 쿼리를 날리지 않을 것 같습니다.</p>
<pre><code class="language-java">    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name = &quot;team_id&quot;, nullable = false, updatable = false)
    private List&lt;Player&gt; players = new ArrayList&lt;&gt;();</code></pre>
<p>updatable=false 옵션을 주고 다시 테스트코드를 실행해보도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/f09e8bea-102b-411b-bbb2-e3d72cfc754f/image.png" alt=""></p>
<p>드디어 저희가 원하는대로 INSERT 쿼리만 세 개 나가는 것을 볼 수 있습니다! 아예 UPDATE를 하지 못하도록 막아버리니 INSERT 한 번에 레코드가 완성될 수 있도록 쿼리를 구성하는 것을 볼 수 있습니다. (애초에 updatable 옵션을 true로 설정하지 않아도 의도대로 작동하면 얼마나 좋을까요...)</p>
<h2 id="그런데-이러면-아예-수정을-못하잖아요">그런데 이러면 아예 수정을 못하잖아요</h2>
<p>사실 이렇게 하면 문제가 하나 있습니다. <code>@JoinColumn</code>에 updatable=false를 걸어뒀기 때문에, team_id 컬럼은 삽입 및 조회만 가능하고 수정이 불가능한 컬럼이 됩니다. 코드를 통해 확인해보도록 하겠습니다.</p>
<pre><code class="language-java">@Test
void oneToManyTest() {
    Team drx = new Team(&quot;DRX&quot;);
    Player deft = new Player(&quot;데프트&quot;);
    Player zeka = new Player(&quot;제카&quot;);
    drx.add(deft);
    drx.add(zeka);

    teamRepository.save(drx);

    Team t1 = new Team(&quot;T1&quot;);
    drx.remove(deft);
    t1.add(deft);

    // @DataJpaTest의 트랜잭션으로 인해 update 쿼리가 쓰기 지연 저장소에 있다 롤백됨
    // 이를 로그로 보여주기 위해 강제 flush
    teamRepository.flush();
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/a7a436bf-d48b-4c3f-a531-86a5b4f5683e/image.png" alt=""></p>
<p>deft를 drx에서 remove를 하고 t1에 add 했습니다. 연관관계의 주인이 Team이기 때문에 deft의 외래키가 drx의 id에서 t1의 id로 바뀌어야 하지만, <code>@JoinColumn</code>의 updatable=false 속성으로 인해 UPDATE 쿼리가 실행되지 않습니다. 즉, updatable=false로 지정하게 되면 한 번 정한 연관관계를 바꾸지 못하게 되는 것입니다. 이렇게 될 경우 수정 대신 기존의 엔티티를 삭제하고 연관관계를 제외한 나머지 속성들이 같은 새 엔티티를 만들어서 저장해주는 것으로 연관관계 수정과 비슷한 효과를 낼 수는 있습니다. 하지만 연관관계의 수정 자체는 불가능하게 됩니다.</p>
<p>또한 UPDATE 쿼리의 발생은 방지했지만 여전히 <code>연관관계의 주인과 외래키 관리의 책임 주체가 다르다</code>는 문제는 그대로입니다. 논리적으로 연관관계의 주인과 외래키가 속한 테이블을 일치시켜주고 싶다면 일대다 단방향은 사용할 수 없습니다.</p>
<h2 id="어떨-때-써야-할까">어떨 때 써야 할까?</h2>
<p>사실 정답은 없는 것 같습니다. 객체지향에 좀 더 많은 비중을 둔다면 일대다 단방향 매핑과 nullable=false, updatable=false를 활용할 수 있을 것이고, 객체지향을 좀더 포기하더라도 외래키의 관리를 용이하게 하고 UPDATE 쿼리를 발생시키지 않으면서 연관관계의 제약 조건도 없애고 싶다면 양방향 매핑을 사용할 수 있을 것입니다.</p>
<p>개인적으로 저라면 두 가지 상황에 따라 다르게 적용할 것 같습니다.</p>
<ol>
<li>Many쪽 엔티티가 그 자체만으로 유의미한 엔티티일 경우</li>
</ol>
<p>위에서 예시로 살펴본 Team - Player의 관계를 보겠습니다. Player는 Team의 하위 엔티티가 아닙니다. 언제든 Team을 바꿀 수 있고, Player 자체만으로 독립적인 의미를 갖습니다. 이런 경우라면 양방향 연관관계를 맺어주는 것이 더 편하고 개념적으로도 맞다고 생각합니다.</p>
<ol start="2">
<li>Many쪽 엔티티가 One쪽 엔티티의 하위 엔티티임이 명확한 경우</li>
</ol>
<p>주문 도메인의 주문이라는 개념 자체와 주문 항목이라는 세부 사항이 있다고 하면, 보통 주문은 여러개의 주문 항목을 가지게 됩니다. 그리고 주문 항목은 단독으로 의미가 있기 보다는 주문과 생명주기가 일치하는 주문의 하위 엔티티라고 볼 수 있습니다. 이렇게 엔티티가 특정 엔티티의 생명주기의 엔티티에 의존하고, 개념적으로 특정 엔티티의 하위 항목을 나타낼 경우, 상위 엔티티에 의해 관리받는 일대다 단방향 매핑이 적당하다고 생각합니다. 어차피 특정 엔티티의 생명주기에 종속되므로 연관관계를 바꿔줄 필요가 존재하지 않아 nullable=false, updatable=false를 사용해도 문제가 없기 때문입니다. 게다가 하위 엔티티 -&gt; 상위 엔티티로의 객체 그래프 탐색도 필요하지 않기 때문에 굳이 양방향 매핑을 해줄 필요가 없다는 점도 고려할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링은 어떻게 @Bean의 싱글턴을 보장할까?]]></title>
            <link>https://velog.io/@ohzzi/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-Bean%EC%9D%98-%EC%8B%B1%EA%B8%80%ED%84%B4%EC%9D%84-%EB%B3%B4%EC%9E%A5%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@ohzzi/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-Bean%EC%9D%98-%EC%8B%B1%EA%B8%80%ED%84%B4%EC%9D%84-%EB%B3%B4%EC%9E%A5%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Sat, 12 Nov 2022 08:33:39 GMT</pubDate>
            <description><![CDATA[<p>스프링에는 <code>싱글턴 레지스트리를 사용해서 실제로는 싱글턴 패턴이 아닌 빈의 싱글턴을 보장한다.</code>라는 특징이 있습니다. (물론 프로토타입 빈의 경우 당연하게도 싱글턴이 아닙니다!) 싱글턴 패턴을 사용하게 되면 상속 불가, 테스트 어려움, 전역으로 상태 관리 등 다양한 단점이 있기 때문에 싱글턴 패턴의 장점을 유지하면서도 단점을 해소하기 위한 방법입니다.</p>
<p>다들 아시다시피 스프링 빈을 등록하는 방법으로는 클래스에 <code>@Component</code> 어노테이션을 붙이는 방법과 <code>@Bean</code> 어노테이션이 붙은 메서드를 작성하여 등록하는 두 가지 방법이 있습니다. 그런데 <code>@Bean</code> 어노테이션을 사용할 때, 이런 케이스가 있을 수 있습니다.</p>
<pre><code class="language-java">@Configuration
public class AppConfig {

    @Bean
    public MyRepository myRepository() {
        return new MyRepository();
    }

    @Bean
    public ServiceA serviceA() {
        return new ServiceA(myRepository());
    }

    @Bean
    public ServiceB serviceB() {
        return new ServiceB(myRepository());
    }
}</code></pre>
<p>SerivceA와 ServiceB 모두 MyRepository를 필요로 합니다. 그래서 생성자에 MyRepository를 주입할 수 있도록 MyRepository를 반환하는 myRepository() 메서드를 호출해서 넣어주도록 하겠습니다. 이런 상황에서 얼핏 보면 serviceA 메서드와 serviceB 메서드가 각각 따로 myRepository 메서드를 호출하므로 매 번 새로운 MyRepository가 생성되어 주입될 수 있다고 생각할 수 있습니다. 그렇다면 싱글턴 보장이 깨지게 됩니다. 과연 그럴까요?</p>
<pre><code class="language-java">@SpringBootTest
class AppConfigTest {

    @Autowired
    private ServiceA serviceA;
    @Autowired
    private ServiceB serviceB;

    @Test
    void 싱글턴_테스트() {
        MyRepository myRepositoryA = serviceA.getMyRepository();
        MyRepository myRepositoryB = serviceB.getMyRepository();

        assertThat(myRepositoryA).isSameAs(myRepositoryB);
    }
}</code></pre>
<p>만약 매 번 새로 myRepository 메서드를 호출해서 싱글턴 보장이 되지 않는다면 ServiceA와 ServiceB의 MyRepository의 주소값을 비교하는 위 테스트는 실패해야 합니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/37b19546-21d1-4710-8dae-ecfbe9dddf26/image.png" alt=""></p>
<p>그러나 테스트를 성공합니다! 즉, myRepository 메서드를 여러번 호출하더라도 매 번 같은 MyRepository 인스턴스를 반환한다는 것을 알 수 있습니다.</p>
<p>어떻게 싱글턴을 보장하는지 확인해보기에 앞서, 조건을 달리해서 한 번 더 테스트를 진행해보도록 하겠습니다. 만약 <code>@Bean</code> 어노테이션을 사용하는 곳이 <code>@Configuration</code>이 아니라 <code>@Component</code>라면 어떻게 될까요?</p>
<pre><code class="language-java">@Component
public class AppConfig {

    @Bean
    public MyRepository myRepository() {
        return new MyRepository();
    }

    @Bean
    public ServiceA serviceA() {
        return new ServiceA(myRepository());
    }

    @Bean
    public ServiceB serviceB() {
        return new ServiceB(myRepository());
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/f95d1969-04a3-49e1-ace8-c92c1cf11a3a/image.png" alt=""></p>
<p>친절하게도 인텔리제이가 경고를 띄워줍니다. 뭔가 싱글턴 보장을 못할 것 같다는 느낌이 오죠? 물론 컴파일은 되기 때문에 테스트를 진행해보도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/cab75c6a-6c4d-4d9c-9669-e128d953f61e/image.png" alt=""></p>
<p>두 MyRepository가 서로 다른 객체여서 테스트에 실패합니다. 즉, <code>@Component</code> 안에서는 싱글턴 보장을 하지 않는다는 것을 알 수 있습니다. 그렇다면 <code>@Configuration</code>에 뭔가 특별한 처리를 해주기 때문에 싱글턴 보장이 된다는 것을 유추할 수 있습니다. 이를 알아보기 위해 <code>@Configuration</code>에 작성되어 있는 javadoc을 시작으로 타고 타고 들어가서 확인해보았습니다. 뭔가 빈 후처리를 통해 처리해줄 것 같다는 느낌이 드네요. javadoc에 적혀 있는 <code>ConfigurationClassPostProcessor</code>로 타고 들어가보겠습니다.</p>
<p>ConfigurationClassPostProcessor를 살펴보다보니 250번 라인에 다음과 같은 내용을 발견할 수 있었습니다.</p>
<pre><code class="language-java">
    /**
     * Prepare the Configuration classes for servicing bean requests at runtime
     * by replacing them with CGLIB-enhanced subclasses.
     */
    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
        int factoryId = System.identityHashCode(beanFactory);
        if (this.factoriesPostProcessed.contains(factoryId)) {
            throw new IllegalStateException(
                    &quot;postProcessBeanFactory already called on this post-processor against &quot; + beanFactory);
        }
        this.factoriesPostProcessed.add(factoryId);
        if (!this.registriesPostProcessed.contains(factoryId)) {
            // BeanDefinitionRegistryPostProcessor hook apparently not supported...
            // Simply call processConfigurationClasses lazily at this point then.
            processConfigBeanDefinitions((BeanDefinitionRegistry) beanFactory);
        }

        enhanceConfigurationClasses(beanFactory);
        beanFactory.addBeanPostProcessor(new ImportAwareBeanPostProcessor(beanFactory));
    }</code></pre>
<p>주석을 확인해보겠습니다. 빈 요청에 대해 런타임에 CGLIB으로 처리된 서브클래스(즉, 프록시를 의미하겠죠?)를 제공하기 위한 준비를 하는 메서드라고 합니다. 여기서 우리는 <code>@Bean</code>에 대한 싱글턴을 보장하기 위해 CGLIB을 사용한다는 것을 알 수 있습니다. 이번에는 마지막 부분에 호출하는 <code>enhanceConfigurationClasses</code> 메서드를 확인해보도록 하겠습니다. 해당 메서드에는 다음과 같은 javadoc 주석이 달려 있습니다.</p>
<blockquote>
<p>Post-processes a BeanFactory in search of Configuration class BeanDefinitions; any candidates are then enhanced by a ConfigurationClassEnhancer. Candidate status is determined by BeanDefinition attribute metadata.</p>
</blockquote>
<p><code>ConfigurationClassEnhancer</code>를 사용한다고 합니다. 실제로 enhanceConfigurationClasses 메서드 내부에 ConfigurationClassEnhancer를 생성해서 호출하는 코드가 들어있습니다.</p>
<pre><code class="language-java">        ConfigurationClassEnhancer enhancer = new ConfigurationClassEnhancer();
        for (Map.Entry&lt;String, AbstractBeanDefinition&gt; entry : configBeanDefs.entrySet()) {
            AbstractBeanDefinition beanDef = entry.getValue();
            // If a @Configuration class gets proxied, always proxy the target class
            beanDef.setAttribute(AutoProxyUtils.PRESERVE_TARGET_CLASS_ATTRIBUTE, Boolean.TRUE);
            // Set enhanced subclass of the user-specified bean class
            Class&lt;?&gt; configClass = beanDef.getBeanClass();
            Class&lt;?&gt; enhancedClass = enhancer.enhance(configClass, this.beanClassLoader);
            if (configClass != enhancedClass) {
                if (logger.isTraceEnabled()) {
                    logger.trace(String.format(&quot;Replacing bean definition &#39;%s&#39; existing class &#39;%s&#39; with &quot; +
                            &quot;enhanced class &#39;%s&#39;&quot;, entry.getKey(), configClass.getName(), enhancedClass.getName()));
                }
                beanDef.setBeanClass(enhancedClass);
            }
        }</code></pre>
<p>마지막으로 enhancer로 타고 들어가서 확인해 보겠습니다. 먼저 주석입니다.</p>
<blockquote>
<p>Enhances Configuration classes by generating a CGLIB subclass which interacts with the Spring container to respect bean scoping semantics for @Bean methods. Each such @Bean method will be overridden in the generated subclass, only delegating to the actual @Bean method implementation if the container actually requests the construction of a new instance. Otherwise, a call to such an @Bean method serves as a reference back to the container, obtaining the corresponding bean by name</p>
<p>@Bean 메서드에 대한 빈 범위 지정 의미를 존중하기 위해 Spring 컨테이너와 상호 작용하는 CGLIB 서브클래스를 생성하여 Configuration 클래스를 향상시킵니다. 이러한 각 @Bean 메소드는 생성된 서브클래스에서 재정의되며 컨테이너가 실제로 새 인스턴스의 구성을 요청하는 경우에만 실제 @Bean 메소드 구현으로 위임합니다. 그렇지 않으면 그러한 @Bean 메소드에 대한 호출은 컨테이너에 대한 참조 역할을 하여 이름으로 해당 Bean을 얻습니다.</p>
</blockquote>
<p>이 주석만으로도 모든 의문이 해결되었습니다! ConfigurationClassEnhancer가 <code>@Configuration</code>이 붙은 빈을 CGLIB을 통해 내부 <code>@Bean</code> 메서드에 대한 특별한 후처리가 된 빈으로 만드는 것이었습니다! 그리고 이 특별한 후처리가 바로 <code>@Bean</code>메서드가 새 인스턴스를 요구하는 요청일 때만 실제 <code>@Bean</code> 메서드를 호출하고, 이후 호출 시에는 이미 만들어서 컨테이너에 등록한 빈을 반환해주는 역할을 하는 것이었습니다.</p>
<pre><code class="language-java">
    /**
     * Loads the specified class and generates a CGLIB subclass of it equipped with
     * container-aware callbacks capable of respecting scoping and other bean semantics.
     * @return the enhanced subclass
     */
    public Class&lt;?&gt; enhance(Class&lt;?&gt; configClass, @Nullable ClassLoader classLoader) {
        if (EnhancedConfiguration.class.isAssignableFrom(configClass)) {
            if (logger.isDebugEnabled()) {
                logger.debug(String.format(&quot;Ignoring request to enhance %s as it has &quot; +
                        &quot;already been enhanced. This usually indicates that more than one &quot; +
                        &quot;ConfigurationClassPostProcessor has been registered (e.g. via &quot; +
                        &quot;&lt;context:annotation-config&gt;). This is harmless, but you may &quot; +
                        &quot;want check your configuration and remove one CCPP if possible&quot;,
                        configClass.getName()));
            }
            return configClass;
        }
        Class&lt;?&gt; enhancedClass = createClass(newEnhancer(configClass, classLoader));
        if (logger.isTraceEnabled()) {
            logger.trace(String.format(&quot;Successfully enhanced %s; enhanced class name is: %s&quot;,
                    configClass.getName(), enhancedClass.getName()));
        }
        return enhancedClass;
    }</code></pre>
<p>이 역할은 위에 보이는 <code>enhance</code> 메서드에서 진행하는 것을 볼 수 있습니다.</p>
<p>이렇게 Configuration 클래스 내부에서 생성하는 빈들에 대해서 스프링이 어떻게 싱글턴을 보장하는지에 대해 알아보았습니다. 내부적으로 더 많은 자세한 코드들이 있어서 모든 부분을 이해하지는 못했지만, 최소한 공식적으로 <code>CGLIB을 통해 후처리하여 최초 빈 생성 요청시에만 빈 생성 메서드를 호출한다</code>라는 것을 알게 되었습니다.</p>
<p>그런데 <code>@Configuration</code>을 사용하면서 이 후처리를 진행하지 않을 수는 없을까요? 스프링 5.2부터는 <code>@Configuration</code> 어노테이션 안의 속성값인 <code>proxyBeanMethods</code>를 false로 지정하면 된다고 합니다. 실제로 false로 지정하고 테스트 해보면 테스트가 실패하는 것을 볼 수 있습니다.</p>
<blockquote>
<p>Specify whether @Bean methods should get proxied in order to enforce bean lifecycle behavior, e.g. to return shared singleton bean instances even in case of direct @Bean method calls in user code. This feature requires method interception, implemented through a runtime-generated CGLIB subclass which comes with limitations such as the configuration class and its methods not being allowed to declare final.
The default is true, allowing for &#39;inter-bean references&#39; via direct method calls within the configuration class as well as for external calls to this configuration&#39;s @Bean methods, e.g. from another configuration class. If this is not needed since each of this particular configuration&#39;s @Bean methods is self-contained and designed as a plain factory method for container use, switch this flag to false in order to avoid CGLIB subclass processing.
Turning off bean method interception effectively processes @Bean methods individually like when declared on non-@Configuration classes, a.k.a. &quot;@Bean Lite Mode&quot; (see @Bean&#39;s javadoc). It is therefore behaviorally equivalent to removing the @Configuration stereotype.
Since:
5.2</p>
</blockquote>
<p>이 주석에서 알 수 있는 또 하나의 사실은, Configuration 클래스와 빈 생성 메서드들이 final이면 안된다는 것입니다. final이게 될 경우 서브클래스를 만들 수 없기 때문에 당연한 일이라고 생각합니다.</p>
<blockquote>
<p>참고 자료</p>
<p><a href="http://www.javabyexamples.com/cglib-proxying-in-spring-configuration">http://www.javabyexamples.com/cglib-proxying-in-spring-configuration</a></p>
</blockquote>
<blockquote>
<p>함께 고민해준 수달, 루키 감사합니다 :)</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[도메인 이벤트 기반 MAT.ZIP 프로젝트 개선기]]></title>
            <link>https://velog.io/@ohzzi/%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B8%B0%EB%B0%98-MAT.ZIP-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B0%9C%EC%84%A0%EA%B8%B0</link>
            <guid>https://velog.io/@ohzzi/%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B8%B0%EB%B0%98-MAT.ZIP-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B0%9C%EC%84%A0%EA%B8%B0</guid>
            <pubDate>Tue, 08 Nov 2022 12:57:13 GMT</pubDate>
            <description><![CDATA[<p><a href="https://matzip.today">MAT.ZIP</a> 프로젝트는 음식점 조회 시 별점을 기준으로 정렬하여 보여주는 기능이 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/1366808d-ada7-44e7-9964-0a3894d537ea/image.png" alt=""></p>
<p>그런데 별점의 정보는 음식점 테이블이 아닌 리뷰 테이블에 담겨 있습니다. 따라서 음식점 조회 시 리뷰 테이블을 조인하거나 서브쿼리를 사용해서 평균 별점을 계산해야 하는 문제가 있습니다. 지금은 음식점 개수가 많지 않고 리뷰 개수도 많지 않아 큰 문제가 없지만, 만약 서비스가 확장되어 음식점 개수가 많아지거나, 사용자가 많아져 리뷰 개수가 많아지게 될 경우 조회 성능이 저하되는 문제가 생기게 됩니다. 어떻게 하면 개선할 수 있을까요?</p>
<p>사실 답은 이미 나와 있습니다. <a href="https://velog.io/@ohzzi/F12%EC%9D%98-%EB%88%88%EB%AC%BC%EB%82%98%EB%8A%94-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EC%9D%B4%EB%A1%A0%ED%8E%B8">F12의 눈물나는 쿼리 개선기 - 이론편</a>에서 설명한 것처럼 반정규화와 커버링 인덱스를 사용한 페이징을 적용하면 됩니다. <a href="https://f12.app">F12</a> 프로젝트와 MAT.ZIP의 도메인 구조가 굉장히 유사하기 때문에, F12의 쿼리를 개선한 방법을 MAT.ZIP에도 비슷하게 적용할 수 있습니다.</p>
<p>이렇게 쿼리 성능 문제를 해결했으니, 다음은 그로 인한 동시성 문제에 초점을 맞춰 보겠습니다. 이 역시 F12에서 이미 겪은 문제였는데요, <a href="https://velog.io/@ohzzi/%EB%8F%99%EC%8B%9C%EC%84%B1-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%A0%95%ED%95%A9%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0">동시성 그리고 정합성, 문제 해결기</a> 포스팅에 그 내용이 자세히 나와 있습니다.</p>
<p>오늘은 단순히 성능 및 동시성 개선에서 더 나아가 관심사를 분리하여 조금 더 깔끔한 코드와 구조를 만드는 부분에 대해서 알아보겠습니다.</p>
<p>동시성 문제 포스팅에서 알 수 있듯이, 반정규화로 생긴 집계 컬럼 정보를 업데이트 할 때 동시성 문제가 생깁니다. 이 문제를 일단은 직접 업데이트 쿼리를 실행하는 방법으로 해결했습니다.</p>
<pre><code class="language-java">@Service
public class ReviewService {
    ...
    @Transactional
    public void createReview(final String githubId, final Long restaurantId,
                             final ReviewCreateRequest reviewCreateRequest) {
        Member member = memberRepository.findMemberByGithubId(githubId)
                .orElseThrow(MemberNotFoundException::new);
        Review review = reviewCreateRequest.toReviewWithMemberAndRestaurantId(member, restaurantId);
        reviewRepository.save(review);
        restaurantRepository.updateRestaurantByReviewInsert(restaurantId, review.getRating());
    }
    ...
}</code></pre>
<h2 id="이벤트-사용하기">이벤트 사용하기</h2>
<p>하지만, 다음과 같은 문제가 있습니다.</p>
<ul>
<li>ReviewService는 리뷰에 대한 비즈니스 로직을 다루는 서비스입니다. 그런데 주 관심사가 아닌 Restaurant에 대한 비즈니스 로직도 포함하고 있습니다.</li>
<li>Review에 대한 로직과 Restaurant에 대한 로직이 같은 트랜잭션으로 묶여 있습니다. 리뷰를 작성하는 로직은 성공하고, 그 뒤 음식점의 리뷰 개수를 증가시키는 로직에서 예외가 발생했다고 가정하겠습니다. 이 경우, 서비스의 주 관심사인 <code>리뷰를 작성한다</code>는 문제없이 성공했음에도 불구하고 주 관심사가 아닌 <code>음식점의 리뷰 개수를 증가시킨다</code>의 실패로 인해 리뷰 작성마저 롤백되게 됩니다. 핵심 로직의 순수성을 유지하기 위해서 트랜잭션의 분리가 필요합니다.</li>
<li>트랜잭션을 불필요하게 길게 잡고 있습니다. 리뷰 작성, 수정, 삭제 쿼리가 완료되면 트랜잭션이 커밋되어도 무방한데, 음식점에 대한 쿼리를 추가로 날리기 위해 트랜잭션을 더 길게 유지합니다. 결국 사용자에게 가는 응답이 불필요하게 늦어지게 됩니다.</li>
</ul>
<p>음식점에 대한 로직을 어떻게 하면 리뷰 로직에서 분리시킬 수 있을까요?</p>
<p>저희 MAT.ZIP 팀은 이 문제에 대한 해답으로 <code>이벤트</code>를 사용하기로 결정했습니다. 이벤트를 사용한 로직은 크게 두 부분, 발행과 구독으로 나누어집니다. 이벤트를 발행하는 쪽에서 특정 이벤트를 발행하면, 해당 이벤트에 대해 구독하고 있던 리스너가 이벤트를 받아서 그에 맞는 처리를 해주는 방식입니다.</p>
<p>스프링이 이벤트를 지원하기 때문에 이벤트 기능을 구현하는 것 자체는 어렵지 않습니다. 여기서 잠깐 스프링의 이벤트에 대해서 알아보고 넘어가도록 하겠습니다. 스프링에는 ApplicationEventPublisher라는 이벤트 발행 빈이 존재합니다. ApplicationEventPublisher의 publishEvent 메서드를 사용하면 이벤트를 발행할 수 있습니다.</p>
<p>이벤트를 발행하는 쪽이 있으면 구독하는 쪽도 있어야겠죠? 스프링에서 이벤트 리스너를 구현하는 방법은 몇 가지가 있지만, 가장 간단한 방법은 어노테이션을 기반으로 한 방법입니다. <code>@EventListener</code>, <code>@TransactionalEventListener</code> 어노테이션을 사용한 메서드를 통해 이벤트 구독을 할 수 있습니다.</p>
<pre><code class="language-java">@Component
@Async
public class RestaurantEventListener {

    private final RestaurantService restaurantService;

    public RestaurantEventListener(final RestaurantService restaurantService) {
        this.restaurantService = restaurantService;
    }

    @TransactionalEventListener
    public void handleReviewCreateEvent(final ReviewCreatedEvent event) {
        Long restaurantId = event.getRestaurantId();
        int rating = event.getRating();
        restaurantService.updateWhenReviewCreate(restaurantId, rating);
    }

    @TransactionalEventListener
    public void handleReviewDeleteEvent(final ReviewDeletedEvent event) {
        Long restaurantId = event.getRestaurantId();
        int rating = event.getRating();
        restaurantService.updateWhenReviewDelete(restaurantId, rating);
    }

    @TransactionalEventListener
    public void handleReviewUpdateEvent(final ReviewUpdatedEvent event) {
        Long restaurantId = event.getRestaurantId();
        int ratingGap = event.getRatingGap();
        restaurantService.updateWhenReviewUpdate(restaurantId, ratingGap);
    }
}</code></pre>
<p>이벤트 리스너를 만들어주었기 때문에, 기존에 직접 RestaurantRepository의 업데이트 메서드를 호출하던 부분을 이벤트 발행 부분으로 바꿔주면 됩니다.</p>
<pre><code class="language-java">@Service
public class ReviewService {
    ...
    @Transactional
    public void createReview(final String githubId, final Long restaurantId,
                             final ReviewCreateRequest reviewCreateRequest) {
        Member member = memberRepository.findMemberByGithubId(githubId)
                .orElseThrow(MemberNotFoundException::new);
        Review review = reviewCreateRequest.toReviewWithMemberAndRestaurantId(member, restaurantId);
        reviewRepository.save(review);
        applicationEventPublisher.publishEvent(new ReviewCreatedEvent(restaurantId, review.getRating());
    }
    ...
}</code></pre>
<p>자, 그런데 여기서 주목할 부분이 두 가지가 있습니다. 왜 이벤트 리스너는 <code>@TransactionalEventListener</code>일까요? 왜 <code>@Async</code>가 선언이 된 것일까요?</p>
<h3 id="왜-비동기인가">왜 비동기인가?</h3>
<p>우선 <code>@Async</code>에 주목해보겠습니다. 이벤트 리스너에 <code>@Async</code>를 붙이고 <code>@EnableAsync</code>가 선언된 <code>@Configuration</code>이 존재할 경우, 이벤트 리스너가 비동기로 작동하게 됩니다.</p>
<p>비동기 처리를 한 이유는 크게 두 가지 입니다. 1. 리뷰 작성, 수정, 삭제에 대한 응답 latency를 줄인다., 2. 독립된 트랜잭션을 만든다.
만약 동기 처리를 하게 될 경우 발행한 이벤트 처리가 완료될 때 까지 메인 트랜잭션이 기다리게 될 것입니다. 하지만 주 관심사도 아닌 로직을 굳이 기다리지 않고 사용자에게 리뷰 작성, 수정, 삭제 요청에 대한 응답을 돌려주는 것이 더 자연스럽고 응답 시간도 더 빨라지게 됩니다. 때문에 비동기가 필요한 상황이라고 판단하여 적용했습니다.</p>
<p>완전히 독립된 트랜잭션을 만드는 것도 또 하나의 목적입니다. 트랜잭션 전파 레벨 중 REQUIRES_NEW를 사용하면 독립된 트랜잭션을 만드는 것 처럼 보이나 사실 그렇지 않습니다. 새 트랜잭션을 만드나 부모 트랜잭션과 독립된 트랜잭션은 아닙니다. REQUIRES_NEW로 새로 만든 트랜잭션의 예외가 부모 트랜잭션으로 전파될 수 있고, UncheckedException이 전파되면 부모 트랜잭션이 롤백됩니다. (때문에 동기 + REQUIRES_NEW를 사용하려면 자식 트랜잭션 쪽에서 예외 처리를 해서 전파가 안되게 해야 합니다.) 이는 트랜잭션의 롤백 및 예외 정보가 ThreadLocal로 관리되기 때문입니다. (관련 문서) 하지만 비동기 작업으로 진행하면 아예 다른 쓰레드에서 작업이 실행되기 때문에 독립된 트랜잭션에서 로직을 진행할 수 있습니다. (이는 스프링이 멀티쓰레드 - 단일 트랜잭션을 지원하지 않기 때문입니다.) 때문에 예외 전파를 걱정할 필요가 없습니다.</p>
<h3 id="왜-transactionaleventlistener인가">왜 @TransactionalEventListener인가?</h3>
<p>비동기이기 때문에 굳이 TransactionalEventListener가 아닌 EventListener를 사용해도 되지 않을까라는 생각을 할 수도 있습니다. 하지만 다음과 같은 상황이 발생할 수 있습니다.</p>
<p>리뷰 작성 트랜잭션의 모든 작업 완료 -&gt; 이벤트 발행 -&gt; (비동기로 이벤트 처리 중) -&gt; 트랜잭션 커밋 -&gt; 커밋 중 모종의 이유로 커밋 실패 -&gt; 리뷰 작성 트랜잭션 롤백 -&gt; 이벤트를 처리하는 트랜잭션은 비동기이기 때문에 롤백하지 않음 -&gt; 리뷰 작성이 실패했는데 리뷰 개수가 올라감</p>
<p>이런 예외가 자주 발생하지는 않겠지만, 가능성을 차단하기 위해서 반드시 리뷰 쪽 트랜잭션이 커밋되어 EventListener가 실행되어야 하는 상황임을 보장한 후 실행되도록 했습니다. (Default 옵션인 AFTER_COMMIT 옵션 적용)</p>
<h3 id="async-쓰레드-풀-적용하기">Async 쓰레드 풀 적용하기</h3>
<p>비동기 이벤트 처리는 별도의 쓰레드에서 동작합니다. 이 때, 이벤트 처리마다 무한히 쓰레드를 생성하기 보다는 쓰레드 풀을 사용하여 관리하는 방법을 선택할 수 있습니다.</p>
<pre><code class="language-java">@Configuration
@EnableAsync
public class AsyncEventConfig {

    @Bean(name = &quot;asyncTaskExecutor&quot;)
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
        threadPoolTaskExecutor.setCorePoolSize(10);
        threadPoolTaskExecutor.setMaxPoolSize(20);
        threadPoolTaskExecutor.setQueueCapacity(25);
        threadPoolTaskExecutor.initialize();
        return threadPoolTaskExecutor;
    }
}</code></pre>
<p><code>@Configuration</code>을 통해 쓰레드 풀을 빈으로 생성해줍니다. <code>@Async</code> 어노테이션에는 value를 넣는 부분이 있습니다. 이 부분에 저희가 생성한 쓰레드 풀 빈의 이름을 넣어주면, 비동기 동작이 해당 쓰레드 풀로부터 쓰레드를 얻어 진행하게 됩니다.</p>
<pre><code class="language-java">@Component
@Async(value = &quot;asyncTaskExecutor&quot;)
public class RestaurantEventListener {
    ...
}</code></pre>
<p>(참고로, <code>@Async</code> 어노테이션은 클래스 레벨에 선언하면 내부의 모든 메서드가 비동기 처리가 되도록 동작합니다.)</p>
<h2 id="이벤트-발행을-도메인에서-할-수는-없을까">이벤트 발행을 도메인에서 할 수는 없을까?</h2>
<p>아직 아쉽습니다. Pull Request에 대해 다음과 같은 리뷰가 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/e4334554-6bfb-4d08-b6c4-90cb743714bb/image.png" alt=""></p>
<p>곰곰히 생각해보면 맞는 이야기입니다. <code>리뷰가 작성되었습니다</code>라는 이벤트는 누가 이벤트를 구독하고 있든 구독하고 있지 않든 상관없이 항상 리뷰가 작성될 때마다 발행되어야 합니다. 하지만 만약 실수로 서비스에서 리뷰 생성 및 저장만 하고 이벤트를 발행하지 않는다면 어떻게 될까요? 아마 리뷰 작성 이벤트를 필요로 하는 로직들이 실행되지 않을 것입니다. 즉 리뷰를 작성한다 -&gt; 리뷰 작성에 대한 이벤트를 발행한다 라는 하나의 작업의 원자성이 보장되지 않는 것입니다.</p>
<p>이 부분은 어떻게 개선할 수 있을까요? 이벤트 발행 로직을 도메인으로 이동시켜서 도메인 생성과 생성 이벤트 발행을 하나로 묶을 수는 없을까요? ApplicationEventPublisher를 사용할 수는 없습니다. Review가 ApplicationEventPublisher를 의존하는 순간 POJO가 아닌 스프링에 의존하는 객체가 되어버리고, 의존성의 방향도 뒤틀리게 됩니다.</p>
<p>다행히도 저희는 Spring Data JPA를 사용하고 있고, Spring Data에서는 AbstractAggregateRoot라는 도메인 이벤트 편하게 사용할 수 있는 클래스를 지원합니다. (AbstractAggregateRoot를 사용하지 않더라도, @DomainEvents와 @AfterDomainEventPublication 어노테이션을 활용하여 구현할 수 있습니다.) AbstractAggregateRoot에는 이벤트를 등록할 수 있는 protected registerEvent 메서드가 존재합니다. 도메인이 AbstractAggregateRoot를 상속받도록 하고, 내부에서 registerEvent 메서드를 호출해주면 원하는 이벤트를 등록할 수 있습니다. 이렇게 등록된 이벤트는 내부에 <code>@Transient</code>로 선언된 이벤트 리스트로 관리됩니다.</p>
<p>그런데 registerEvent는 이벤트를 등록만 할 뿐, 발행하지는 않습니다. 때문에 발행하는 작업이 필요한데요, Spring Data JPA에서는 repository의 save, saveAll, delete, deleteAll을 호출할 때 엔티티에 쌓여 있는 이벤트를 모두 발행합니다.</p>
<p>AbstractAggregateRoot 사용을 통해 코드를 다음과 같이 개선할 수 있습니다.</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;review&quot;)
@EntityListeners(AuditingEntityListener.class)
@Getter
public class Review extends AbstractAggregateRoot&lt;Review&gt; {
    ...
    @Builder
    public Review(final Long id, final Member member, final Long restaurantId, final String content, final int rating,
                  final String menu, final LocalDateTime createdAt) {
        validateRating(rating);
        LengthValidator.checkStringLength(menu, MAX_MENU_LENGTH, &quot;메뉴의 이름&quot;);
        LengthValidator.checkStringLength(content, MAX_CONTENT_LENGTH, &quot;리뷰 내용&quot;);
        this.id = id;
        this.member = member;
        this.restaurantId = restaurantId;
        this.content = content;
        this.rating = rating;
        this.menu = menu;
        this.createdAt = createdAt;
        registerEvent(new ReviewCreatedEvent(restaurantId, rating));
    }
    ...
}</code></pre>
<p>이벤트 등록 로직이 도메인으로 들어갑니다. 이벤트 발행은 reviewRepository.save 호출 시 이루어지는데, 어차피 영속화를 위해 서비스에서 호출하고 있으므로, 기존에 이벤트를 발행하던 ApplicationEventPublisher 로직만 지워주면 됩니다.</p>
<pre><code class="language-java">@Service
public class ReviewService {
    ...
    @Transactional
    public void createReview(final String githubId, final Long restaurantId,
                             final ReviewCreateRequest reviewCreateRequest) {
        Member member = memberRepository.findMemberByGithubId(githubId)
                .orElseThrow(MemberNotFoundException::new);
        Review review = reviewCreateRequest.toReviewWithMemberAndRestaurantId(member, restaurantId);
        reviewRepository.save(review);
    }
    ...
}</code></pre>
<p>이벤트 생성의 주체가 도메인으로 바뀌면서 서비스는 save 메서드를 호출하기만 할 뿐 이벤트 생성 및 발행의 책임은 가져가지 않게 되었습니다. 훨씬 더 깔끔한 코드가 되었네요.</p>
<h3 id="update의-경우에는-어떡하지">update의 경우에는 어떡하지?</h3>
<p>리뷰 수정 이벤트의 경우에 약간의 문제가 있습니다. 기존에는 JPA의 변경 감지 기능을 사용해서 리뷰 정보를 수정했습니다. 하지만 앞서 말했듯이 AbstractAggregateRoot는 save 또는 delete 메서드를 호출할 때 도메인이 가지고 있는 이벤트들을 전부 발행합니다. 때문에 JPA의 변경 감지 기능은 사용할 수 없습니다. 대신 이 경우에도 save 메서드를 호출하는 방법을 사용해야 합니다. JpaRepository의 save 메서드는 비영속 상태의 엔티티가 아닌 경우 em.merge를 호출하여 수정 또는 삽입을 진행합니다. 때문에 변경 감지 기능을 포기하고 리뷰 수정 시에도 save 메서드를 호출하도록 하여 이벤트 발행을 보장하도록 하겠습니다.</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;review&quot;)
@EntityListeners(AuditingEntityListener.class)
@Getter
public class Review extends AbstractAggregateRoot&lt;Review&gt; {
    ...
    public void update(final String githubId,
                       final String content,
                       final int rating,
                       final String menu) {
        validateOwner(githubId);
        validateRating(rating);
        LengthValidator.checkStringLength(menu, MAX_MENU_LENGTH, &quot;메뉴의 이름&quot;);
        LengthValidator.checkStringLength(content, MAX_CONTENT_LENGTH, &quot;리뷰 내용&quot;);
        registerEvent(new ReviewUpdatedEvent(restaurantId, calculateGap(rating)));
        this.content = content;
        this.rating = rating;
        this.menu = menu;
    }
    ...
}</code></pre>
<pre><code class="language-java">@Service
public class ReviewService {
    ...
    @Transactional
    public void updateReview(final String githubId,
                             final Long reviewId,
                             final ReviewUpdateRequest reviewUpdateRequest) {
        Member member = memberRepository.findMemberByGithubId(githubId)
                .orElseThrow(MemberNotFoundException::new);
        Review review = reviewRepository.findById(reviewId)
                .orElseThrow(ReviewNotFoundException::new);
        review.update(member.getGithubId(),
                reviewUpdateRequest.getContent(),
                reviewUpdateRequest.getRating(),
                reviewUpdateRequest.getMenu());
        reviewRepository.save(review);
    }
    ...
}</code></pre>
<p>이렇게 해서 리뷰 수정 시에도 수정 이벤트를 발행할 수 있게 되었습니다.</p>
<h3 id="삭제-시에는-어떻게">삭제 시에는 어떻게...?</h3>
<p>리뷰 작성이나 수정의 경우에는 쉽습니다. 왜냐면 생성자든, update 메서드든, 작성 및 수정이라는 로직을 담당하는 도메인 메서드가 존재하기 때문입니다. 하지만 삭제의 경우는 어떨까요? 만약 soft delete 방식을 채택하고 있었다면, 도메인 내에 deleted = true를 만드는 delete 메서드를 만들고 reviewRepository.save를 호출하면 될 문제였습니다. (삭제에 대해 save를 호출한다는 것이 조금 이상하지만요)</p>
<p>하지만 hard delete 방식을 사용하고 있기 때문에 삭제를 위해 도메인에서 호출할 메서드가 존재하지 않습니다. 그렇다고 이벤트를 등록하는 로직만 존재하는 메서드를 만들고, 이를 서비스에서 호출하는 것은 도메인 로직이 또다시 서비스 레이어로 분산된다는 점에서 고려하지 않았습니다. 이 부분을 어떻게 해결할 것인지 다양한 의견을 구해보았습니다. 처음 제가 생각한 방법은 <a href="https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.custom-implementations">Spring Data JPA의 Repository 커스텀 기능</a>이었습니다.</p>
<pre><code class="language-java">public interface ReviewDeleteRepository {

    void delete(Review review);
}

public class ReviewDeleteRepositoryImpl implements ReviewDeleteRepository {

    private final EntityManager em;

    public ReviewDeleteRepositoryImpl(final EntityManager em) {
        this.em = em;
    }

    @Override
    public void delete(final Review review) {
        검증 로직...
        review.이벤트 발행
        em.remove(em.contains(review) ? review : em.merge(review));
    }
}

public interface ReviewRepository extends Repository&lt;Review, Long&gt;, ReviewDeleteRepository {

    Review save(Review review);

    Optional&lt;Review&gt; findById(Long reviewId);

    List&lt;Review&gt; findAll();
    ...
}</code></pre>
<p>이렇게 하면 delete 메서드를 호출하는 것 만으로 이벤트 등록, 발행, 엔티티 삭제를 모두 처리할 수 있습니다. 하지만 이런 의견도 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/23dc4564-412d-4df1-bad0-c9685b2f1e4c/image.png" alt=""></p>
<p>여기서 두 의견이 충돌했습니다. 저는 <code>도메인은 순수해야 한다. 도메인이 리포지토리를 아는 것 보다 리포지토리가 도메인 로직을 사용하는 쪽이 더 맞는 것 같다.</code>의 의견을, 같은 팀 팀원 후니는 <code>레이어드 아키텍처에서 도메인 계층 아래에 인프라 계층(영속성 계층)이 존재하고 리포지토리는 엄밀히 따지면 해당 계층에 존재한다. 따라서 차라리 리포지토리에서 도메인의 로직을 사용하는 것은 맞지 않을 것 같다.</code> 라는 의견을 제시했습니다. 우아한테크코스 내 다른 크루(교육생)들의 의견을 구해보기도 했는데요, 의견이 분분해서 하나로 결론이 나지 않았습니다. 그러던 중, 제이슨 코치님이 해결책을 제시해주셨습니다.</p>
<blockquote>
<p><code>@PreRemove</code>를 사용하는 방법도 있습니다</p>
</blockquote>
<p><code>@PreRemove</code>는 JPA 엔티티 생명 주기 이벤트 중의 하나입니다. <a href="https://www.baeldung.com/jpa-entity-lifecycle-events">Baeldung</a>을 참고하면, 다음과 같은 어노테이션들이 존재합니다.</p>
<ul>
<li>before persist is called for a new entity – @PrePersist</li>
<li>after persist is called for a new entity – @PostPersist</li>
<li>before an entity is removed – @PreRemove</li>
<li>after an entity has been deleted – @PostRemove</li>
<li>before the update operation – @PreUpdate</li>
<li>after an entity is updated – @PostUpdate</li>
<li>after an entity has been loaded – @PostLoad</li>
</ul>
<p>저희는 엔티티가 삭제 되기 전에 이벤트를 등록하고, 등록한 이벤트가 엔티티 삭제 시점에 발행되도록 해야 합니다. 때문에 이 어노테이션들 중 <code>@PreRemove</code>를 사용할 수 있습니다.</p>
<blockquote>
<p><strong>여기서 잠깐</strong></p>
<p>만약 <code>@PostRemove</code>를 사용하면, 이벤트의 발행 로직이 호출되는 시점보다 이벤트의 등록 시점이 늦어지므로(<code>@PostRemove</code>의 호출 시점은 데이터베이스에서 실제로 데이터가 삭제되는 시점입니다. JPA 쓰기 지연으로 인해 delete 메서드가 종료된 후 트랜잭션이 커밋될 때 delete 쿼리가 나가므로 이벤트의 등록 시점보다 이벤트 발행 시점이 앞입니다.) 이벤트가 정상적으로 발행되지 않습니다.</p>
</blockquote>
<p>Review 도메인에 이벤트를 등록하는 메서드를 만들고, <code>@PreRemove</code>를 붙여주도록 하겠습니다.</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;review&quot;)
@EntityListeners(AuditingEntityListener.class)
@Getter
public class Review extends AbstractAggregateRoot&lt;Review&gt; {
    ...
    @PreRemove
    private void registerDeletedEvent() {
        registerEvent(new ReviewDeletedEvent(restaurantId, rating));
    }
    ...
}</code></pre>
<p>이렇게 해서 리뷰가 영속성 컨텍스트에서 remove 처리되기 전에 이벤트가 등록되면서도, 이벤트 등록 메서드를 바깥에서 호출할 필요가 없도록 만들어줄 수 없습니다. 서비스에서는 전처럼 reviewRepository.delete만 호출해주면 됩니다.</p>
<pre><code class="language-java">@Service
public class ReviewService {
    ...
    @Transactional
    public void deleteReview(final String githubId, final Long reviewId) {
        Member member = memberRepository.findMemberByGithubId(githubId)
                .orElseThrow(MemberNotFoundException::new);
        Review review = reviewRepository.findById(reviewId)
                .orElseThrow(ReviewNotFoundException::new);
        if (!review.isWriter(member.getGithubId())) {
            throw new ForbiddenException(&quot;리뷰를 삭제 할 권한이 없습니다.&quot;);
        }
        reviewRepository.delete(review);
    }
    ...
}</code></pre>
<h2 id="개선할-점">개선할 점</h2>
<p>도메인 이벤트 발행과 비동기 이벤트 처리를 통해 리뷰를 작성, 수정, 삭제 하는 트랜잭션과 음식점의 집계 컬럼을 수정하는 트랜잭션을 물리적으로 분리하고 의존성도 끊었습니다. 이로써 구조상으로도 기존보다 한결 더 나은 코드가 되었고, 음식점 테이블에 걸리는 불필요한 락도 제거했으며, 트랜잭션을 유지하는 시간도 줄일 수 있었습니다. 하지만 아직 개선해야할 문제가 남아 있습니다.</p>
<p><code>만약 비동기 이벤트 처리가 실패한다면 어떻게 할 것인가?</code></p>
<p>지금은 별다른 처리를 하지 않았기 때문에 롤백을 하게 됩니다. 그렇게 되면, 실제 리뷰의 정보와 음식점이 가지고 있는 리뷰 정보의 정합성이 맞지 않는 상황이 발생하게 됩니다. 이를 방지하기 위해서 여러 방법이 있겠습니다만, 지금 고려할 수 있는 방법은 실패한 이벤트 정보를 저장한 뒤 나중에 스케줄러를 활용해 배치 처리를 하는 방법일 것 같습니다. 이 부분에 대해서는 어떤 식으로 구현할 것인지 아직은 감이 잡히지 않기 때문에 좀 더 학습하고, 고민할 필요가 있을 것 같습니다.</p>
<blockquote>
<p>참고 자료</p>
<p><a href="https://www.baeldung.com/spring-data-ddd">Baeldung</a>
<a href="https://docs.spring.io/spring-data/jpa/docs/current/reference/html/">Spring Data JPA Reference Docs</a></p>
<p>Pull Request가 궁금하다면
<a href="https://github.com/The-Fellowship-of-the-matzip/mat.zip-back/pull/116">GitHub</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[우테코 마지막 미션 후기 - 객체 지향과 의존성]]></title>
            <link>https://velog.io/@ohzzi/%EC%9A%B0%ED%85%8C%EC%BD%94-%EB%A7%88%EC%A7%80%EB%A7%89-%EB%AF%B8%EC%85%98-%ED%9B%84%EA%B8%B0-%EA%B0%9D%EC%B2%B4-%EC%A7%80%ED%96%A5%EA%B3%BC-%EC%9D%98%EC%A1%B4%EC%84%B1</link>
            <guid>https://velog.io/@ohzzi/%EC%9A%B0%ED%85%8C%EC%BD%94-%EB%A7%88%EC%A7%80%EB%A7%89-%EB%AF%B8%EC%85%98-%ED%9B%84%EA%B8%B0-%EA%B0%9D%EC%B2%B4-%EC%A7%80%ED%96%A5%EA%B3%BC-%EC%9D%98%EC%A1%B4%EC%84%B1</guid>
            <pubDate>Fri, 04 Nov 2022 08:21:21 GMT</pubDate>
            <description><![CDATA[<p>우아한테크코스 마지막 미션인 레거시 코드 리팩토링하기 미션의 마지막 4단계 PR을 제출했습니다. 이번 미션은 우테코를 하면서 가장 많이 고민한 미션이 아니었나 싶은데요, 그동안 우테코에서 <code>객체 지향 프로그래밍</code>에 대해 많이 배웠다고 생각했는데, 이번 미션을 하면서 사실 진짜 객체 지향에 대한 깊은 고민은 아직 해보지 않았다는 것을 느꼈습니다. 고민한 것도 많고, 얻은 것도 많기 때문에 미션을 진행하면서 새로 얻은 지식이나 고민한 결과를 기록으로 남기기로 결정했습니다.</p>
<p>이번 미션은 <a href="https://www.youtube.com/watch?v=dJ5C4qRqAgA">우아한객체지향</a>이라는 우아한테크세미나 내용을 바탕으로 공부한 내용이 많습니다. 함께 참고하시면 좋을 것 같아요 :)</p>
<h1 id="도메인은-벌크업-서비스는-다이어트">도메인은 벌크업, 서비스는 다이어트</h1>
<p>아무 생각 없이 코드를 짜다 보면, 온갖 비즈니스 로직이 다 서비스에 들어가 있고 정작 도메인은 데이터를 담는 VO의 역할밖에 하지 않는 경우가 있습니다. 검증 로직도, 도메인 변경 로직도 다 서비스에 있고 도메인은 그냥 DB에 저장만 하게 되는 것이죠. 이것은 좋은 설계는 아니라고 생각합니다.</p>
<p>애플리케이션 로직이 간단할 때는 크게 문제가 없을 거라고 생각합니다. 하지만 비즈니스 로직이 많아지게 되면, 서비스가 굉장히 비대해지고 난잡한 코드가 되게 될 가능성이 높습니다. 이는 가독성의 저해와 유지보수의 어려움을 가져오게 됩니다. 이 로직들을 최대한 도메인으로 넣을 수 있다면 어떨까요? 각각의 도메인 객체에 관련된 로직이 한 곳으로 모이게 되어 응집성이 증가하는 것은 물론이고, 서비스는 도메인의 로직을 호출만 하면 되기 때문에 서비스를 얇게 만들 수 있습니다. 또한 도메인에 로직이 다 들어가 있기 때문에 여러 서비스에서 도메인을 가져다 쓰면 되어 재사용성도 좋아집니다. 그리고 테스트 하기 좋은 코드가 됩니다. 도메인 객체를 테스트하는데는 스프링 프레임워크도, mockito도 필요하지 않습니다. 그저 연관된 객체들을 생성해서 테스트 하기만 하면 됩니다. 반면 로직이 서비스에 있을 경우 모킹을 활용하든 통합 테스트를 하든 무언가 다른 프레임워크의 도움을 받아 테스트를 하게되어 테스트하기 더 불편해집니다.</p>
<p>이번 미션의 2단계가 마침 서비스 리팩토링이라는 주제로 서비스 로직을 최대한 도메인 로직으로 밀어넣는 과제였고, 반드시 repository를 호출해서 검증해야 할 필요가 있는 로직같이 도메인으로 들어가기 힘든 로직들을 제외하고 최대한 로직을 도메인으로 밀어넣는 코드를 작성하려고 노력했습니다.</p>
<p>이게 보통 힘든 일이 아니더라고요. 우선 설계를 고민하는데 정말 많은 시간과 노력이 필요합니다. 각각의 도메인 객체에 대한 깊은 이해가 선행되지 않으면 로직이 있어야 할 곳에 있지 못하고 흩어지게 되고, 의존성도 꼬여서 하나를 수정하면 다른 객체들도 연쇄적으로 수정되는 등 오히려 변경 및 확장에 더 유연하지 못한 코드가 되어 버리게 됩니다.</p>
<h1 id="의존성">의존성</h1>
<p>의존성이 꼬이게 된다고 했는데, 의존성이란 무엇일까요? 의존성이란 변경이 전파된다는 의미입니다. 즉, <code>A가 B에 의존한다.</code>는 <code>B가 변하면 A도 변한다.</code>라는 의미가 됩니다. 이는 변경에 유연한 설계를 하기 위해서는 의존성을 최대한 약하게 유지해야 한다는 의미가 됩니다.</p>
<p>사실 위에서 말한 서비스 로직을 도메인 로직으로 옮기는 것을 아주 간단하게 해결하는 방법이 있습니다. 필요한 객체를 전부 의존하면 됩니다. 이번 미션을 예로 들어보겠습니다. 이번 미션에는 다음과 같은 도메인들이 있었습니다.</p>
<ul>
<li>MenuGroup</li>
<li>Menu</li>
<li>MenuProduct</li>
<li>Product</li>
<li>Order</li>
<li>OrderLineItem</li>
<li>OrderTable</li>
<li>TableGroup</li>
</ul>
<p>그리고 레거시 상태로 받아봤을 때 이 객체들은 다음과 같은 관계를 맺고 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/2d3f2a75-6801-43bf-b9ee-f908e7ea73e7/image.png" alt=""></p>
<p>실선은 객체 참조, 점선은 id를 통한 간접 참조입니다.</p>
<p>말씀드린대로 서비스 로직을 도메인 로직으로 옮기는 것은 필요한 객체를 전부 의존하도록 바꾸면 해결됩니다. 즉, 보이는 다이어그램에서 점선을 전부 실선으로 바꾸면 해결됩니다. 그렇게 되면 객체 그래프 탐색을 통해 다른 도메인의 데이터를 가져올 수 있고, 그 값을 통해 검증이 가능합니다. 예를 들어보겠습니다. Menu를 만들 때, <code>MenuProduct의 가격 총 합보다 Menu의 가격이 작아야 한다.</code> 라는 비즈니스 로직이 있습니다.</p>
<pre><code class="language-java">final List&lt;MenuProduct&gt; menuProducts = menu.getMenuProducts();

BigDecimal sum = BigDecimal.ZERO;
for (final MenuProduct menuProduct : menuProducts) {
    final Product product = productDao.findById(menuProduct.getProductId())
            .orElseThrow(IllegalArgumentException::new);
    sum = sum.add(product.getPrice().multiply(BigDecimal.valueOf(menuProduct.getQuantity())));
}

if (price.compareTo(sum) &gt; 0) {
    throw new IllegalArgumentException();
}</code></pre>
<p>이 로직을 위해서는 반드시 각각의 MenuProduct마다 가진 productId를 바탕으로 ProductDao(ProductRepository)에서 Product를 조회 한 뒤, 가격 총합을 해서 Menu의 가격과 비교하는 로직을 <code>서비스</code>에서 실행해줘야 합니다. 이 로직을 도메인으로 넣으려면 어떻게 해야 할까요? 도메인 안으로 들어가려면 Menu 안에서 MenuProduct 가격 총합을 계산해서 스스로의 Price와 비교할 수 있어야 합니다. 그런데 각각의 MenuProduct는 가격을 직접 알지 못하고, 단지 연관된 productId를 가지고 있을 뿐입니다. 그래서 MenuProduct가 직접 가격을 계산하려면 MenuProduct가 Product를 연관관계로 가지고 있어야 한다는 결과가 나옵니다.</p>
<p>이렇게 되면 강한 의존성을 가지게 되어 변경에 유연하지 못한 코드가 됩니다. 게다가 MenuProduct - Product 뿐 아니라 다른 도메인들도 검증 로직에 필요한 도메인을 서로 다 직접 참조 해버리면 JPA 마이그레이션 시 조회 범위를 어디까지 할 것인가에 대한 성능 문제가 발생하기도 합니다. 그렇다고 서비스 로직으로 validation을 내보내기는 싫습니다. 어떻게 해결할 수 있을까요? </p>
<p>미션을 어려워하는 크루들을 위해 <strong>제이슨</strong> 코치님이 직접 라이브 코딩으로 보여주셨습니다. 이미 정해져있는 객체 구조와 데이터베이스 구조를 유지하면서 하려던 제게 이 라이브 코딩 과정이 큰 깨달음을 주었는데요, MenuProduct가 Product로부터 필요한 값은 Price입니다. 라이브 코딩 과정에서 제이슨은 Product의 Price를 복사해서 MenuProduct가 가지도록 했습니다. (DB 스키마의 수정도 필요합니다. 일종의 반정규화(비정규화)입니다.) 이렇게 하니 MenuProduct - Product의 의존 문제가 간단히 풀리게 되었습니다.</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;menu_product&quot;)
public class MenuProduct {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long seq;
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = &quot;menu_id&quot;, nullable = false)
    private Menu menu;
    @Column(name = &quot;product_id&quot;, nullable = false)
    private Long productId;
    @Embedded
    private Quantity quantity;
    @Embedded
    private Price price;

    ...
}</code></pre>
<p>MenuProduct는 생성 시점에 Price까지 받아와서 직접 저장합니다. 이렇게 되면 MenuProduct는 Product를 직접 몰라도 되게 되고, Price의 경우 서비스에서 Product를 조회해서 가져오든 임의의 값을 넣든 MenuProduct가 모르는 상태가 됩니다. 의존성을 약하게 만들 수 있습니다.</p>
<p>Order 쪽은 조금 다른 고민이 있었는데요, 3단계 요구사항 중 <code>메뉴의 이름과 가격이 변경되면 주문 항목도 함께 변경된다. 메뉴 정보가 변경되더라도 주문 항목이 변경되지 않게 구현한다.</code>라는 부분이 있었습니다. 지금은 Order가 menuId로 Menu를 간접참조 하고 있었는데요, 아이디만 가지고 있고 정보는 전혀 가지고 있지 않기 때문에 Menu 정보를 불러오려면 반드시 Menu 테이블을 조회해와야 하고, 그로 인해 DB의 Menu 정보가 바뀌면 Order도 변하게 되는 것이죠. 객체적으로는 간접 참조지만 실제로는 어쨌든 의존성이 있는 상황이었습니다. 결국 이 문제도 반정규화를 통해 풀어낼 수 있었습니다. 메뉴의 이름과 가격을 OrderLineItem이 가지도록 해서 말이죠.</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;order_line_item&quot;)
public class OrderLineItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long seq;
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = &quot;order_id&quot;, nullable = false)
    private Order order;
    @Embedded
    private Quantity quantity;
    @Embedded
    private OrderedMenu orderedMenu;

    ...
}

@Embeddable
public class OrderedMenu {

    @Column(name = &quot;menu_name&quot;, nullable = false)
    private String name;
    @Embedded
    @AttributeOverride(name = &quot;value&quot;, column = @Column(name = &quot;menu_price&quot;))
    private Price price;

    ...
}</code></pre>
<p>Order를 생성하는 시점에 Menu를 조회해와서 그 값을 가지고 OrderedMenu를 만들어주고, 이를 바탕으로 Order를 생성하면 Order는 메뉴를 직접 알지 않으면서도 Menu의 내용을 저장하며, Menu가 변하더라도 Order는 변하지 않게 됩니다.</p>
<p>자, 이제 의존성이 잘 짜여진 코드처럼 보일 수 있습니다만, 서비스까지 포함시켜보면 다릅니다. 저는 비슷한 도메인들끼리의 집합, 바운디드 컨텍스트를 정의했습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/8bcf9325-26cc-4914-8ca8-16d1c9ee0aae/image.png" alt=""></p>
<p>빨간색 점선 화살표가 컨텍스트 간 의존성 화살표입니다. 저는 이번 3단계의 목표를 두 가지로 잡았습니다.</p>
<ul>
<li>서로 다른 컨텍스트의 객체 끼리는 강한 결합(필드 매핑을 통한 연관관계)을 가지지 않는다.</li>
<li>컨텍스트 간 의존성은 단방향으로 흘러야 한다. 사이클이 생겨서는 안된다.</li>
</ul>
<p>그런데 위 다이어그램을 보시면 아시겠지만 Order 컨텍스트와 Table 컨텍스트가 서로 양방향 의존을 하고 있다는 문제가 있었습니다.</p>
<blockquote>
<p>여기서 잠깐
같은 컨텍스트 내에서 객체끼리 양방향 의존하는 것은 해결해야 할 문제로 치지 않았습니다. 이는 JPA를 사용할 때 일대다 단방향 매핑 시 불필요한 쿼리가 추가적으로 나가는 문제를 해결하기 위해 생기는 양방향이기 때문입니다. 또한 같은 컨텍스트 내에서는 서로가 서로를 알더라도 크게 문제가 되지 않을 것이라고 판단했습니다.</p>
</blockquote>
<p>Order와 Table에 왜 양방향 의존이 생겼을까요? 이는 다음과 같은 검증 로직 때문입니다.</p>
<ul>
<li>Order를 생성할 때, Order가 속한 OrderTable의 empty 값이 true면 안된다.</li>
<li>OrderTable의 empty값을 변경할 때, OrderTable에 속한 Order 중 status가 COOKING이나 MEAL인 Order가 있으면 안된다.</li>
<li>TableGroup을 해제할 때, TableGroup으로 묶인 OrderTable에 속한 Order 중 status가 COOKING이나 MEAL인 Order가 있으면 안된다.</li>
</ul>
<p>컨텍스트간 객체 직접 참조는 끊어놨지만, 서로 다른 컨텍스트의 값이 필요하기 때문에 서비스 -&gt; 레포지토리 의존을 통해 의존하고 있습니다. 문제는 이 방향이 양방향이라는 것이죠. 이 문제를 해결하기 위해 여러 가지 방법을 생각해보았습니다.</p>
<ul>
<li>Validator를 사용</li>
<li>이벤트 방식 사용</li>
</ul>
<p>여기서 첫 번째 Validator 방식은, 저는 개인적으로 도메인의 순수성을 해치는 것 같아 손이 가지 않았습니다. 그래서 처음에는 이벤트 발행 방식을 고려했습니다. 그러나, 이 경우 다음과 같은 문제가 있었습니다.</p>
<ul>
<li>이벤트는 상태 전파에 사용하는 것인데 이벤트를 validation에 사용. 사용은 가능하지만 본래 의도에 맞지 않음.</li>
<li>ApplicationEventPublisher를 사용할 경우, 이벤트 발행 로직을 서비스로 보내주어야 함. 도메인 자체에서 로직을 처리하지 못함.</li>
<li>도메인에서 이벤트를 생성하는 AbstractAggregateRoot가 있지만, 이 경우 반드시 JpaRepository의 save 또는 delete를 호출해줘야 하므로 역시 서비스에 의존적이게 됨. 또한 JPA의 더티 체킹을 사용할 수 없음.</li>
</ul>
<p>앞서 말했듯 <code>도메인은 벌크업, 서비스는 다이어트</code> 원칙까지 지키면서 구현해야 했고, 이벤트의 본래 의도와도 맞지 않았기 때문에 3단계 PR을 보낸 후 이벤트 방식을 폐기하기로 결정했습니다. 그렇다면 과연 어떤 식으로 해결할 수 있을지, 같은 백엔드 크루 차리와 거의 1~2시간은 토의하면서 고민한 것 같습니다. 제가 내린 결론은 다음과 같습니다.</p>
<p><code>또 한번 데이터베이스 반정규화하고, 중간 객체를 활용하자.</code></p>
<p>앞서 MenuProduct - Product 관계를 끊는 부분을 제이슨이 라이브 코딩으로 보여줬다는 부분을 기억하시나요? 그 때 나왔던 기법에 우아한객체지향에서 본 중간 객체라는 개념을 하나 얹었습니다. 우선 Order는 생성 시점에 OrderTable이 반드시 필요하기 때문에 Order -&gt; Table 컨텍스트 의존성은 그대로 유지하기로 하고, Table -&gt; Order 의존성을 끊기로 결정했습니다. 이 때 Table의 상태를 바꾸거나 단체 지정을 해제할 때, 포함된 Order의 OrderStatus가 문제가 되는 것인데요. 저는 Order 생성 시점에 OrderStatus를 복사해서 중간 객체로 만들어 DB에 저장하는 것으로 해결했습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/ddda69c4-4a42-47f6-ab3d-f77ca872c525/image.png" alt=""></p>
<p>우아한객체지향에 나온 위 이미지 같은 느낌이 됩니다. 구현 코드는 다음과 같습니다.</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;order_status_record&quot;)
public class OrderStatusRecord implements Persistable&lt;Long&gt; {

    @Id
    @Column(name = &quot;order_id&quot;)
    private Long orderId;
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = &quot;order_table_id&quot;, nullable = false)
    private OrderTable orderTable;
    @Enumerated(value = EnumType.STRING)
    @Column(name = &quot;order_status&quot;)
    private OrderStatus orderStatus;

    ...
}</code></pre>
<p>중간 객체 OrderStatusRecord입니다. 그리고 이 중간 객체는,</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;order_table&quot;)
public class OrderTable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;table_group_id&quot;)
    private TableGroup tableGroup;
    @Embedded
    private NumberOfGuests numberOfGuests;
    @Column(name = &quot;empty&quot;, nullable = false)
    private boolean empty;
    @BatchSize(size = 100)
    @OneToMany(mappedBy = &quot;orderTable&quot;, cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true)
    private List&lt;OrderStatusRecord&gt; orderStatusRecords = new ArrayList&lt;&gt;();
    ...
}</code></pre>
<p>OrderTable에서 가지도록 했습니다. 이렇게 되면,</p>
<pre><code class="language-java">    ...
    private void validateCookingOrMealOrderNotExistsWhenChangeEmpty() {
        if (orderStatusRecords.stream().anyMatch(OrderStatusRecord::isNotCompleted)) {
            throw new CookingOrMealOrderTableCannotChangeEmptyException();
        }
    }
    ...</code></pre>
<p>이렇게 검증 로직을 OrderTable안에 작성할 수 있고, TableGroup 역시 객체 그래프 탐색을 통해 들어가서 검증 로직을 도메인 안으로 넣을 수 있습니다.</p>
<p>최종적으로는 다음과 같은 의존성 구조를 가지게 되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/fc581448-e15e-418f-8fb4-f090f3443cad/image.png" alt=""></p>
<h1 id="느낀-점">느낀 점</h1>
<p><code>의존성을 고려하면서 설계해야지</code>라는 다짐을 했던 적은 많았는데, 정작 정말 의존성을 고려하면서 설계하려고 하니 여간 쉬운 일이 아니라는 것을 느꼈습니다. 그리고 미션을 하고 나니까 레벨 3, 레벨 4에서 진행한 프로젝트 코드가 의존성을 많이 고려하지 않고 있다는 느낌도 받았습니다. 미션에는 적용했다가 다시 롤백했지만, 이벤트 방식에 대해서 관심을 가질 수도 있어서 좋은 기회였습니다. 또한 DDD의 기초 개념 정도는 살짝 찍먹해본 것 같아 그 부분에서도 재밌는 미션이었다고 생각합니다.</p>
<blockquote>
<p>코드가 궁금하시다면?
<a href="https://github.com/Ohzzi/jwp-refactoring/tree/ohzzi">GitHub</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[CORS 허용 좀 해주세요...☆]]></title>
            <link>https://velog.io/@ohzzi/CORS-%ED%97%88%EC%9A%A9-%EC%A2%80-%ED%95%B4%EC%A3%BC%EC%84%B8%EC%9A%94</link>
            <guid>https://velog.io/@ohzzi/CORS-%ED%97%88%EC%9A%A9-%EC%A2%80-%ED%95%B4%EC%A3%BC%EC%84%B8%EC%9A%94</guid>
            <pubDate>Tue, 25 Oct 2022 01:33:59 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ohzzi/post/3aac308d-677c-44fd-a24d-594a3958c0f1/image.png" alt=""></p>
<blockquote>
<p>이 글은 우아한테크코스 학습로그 공유 사이트 <a href="https://prolog.techcourse.co.kr/">Prolog</a>에 업로드한 글을 재구성한 글입니다.</p>
</blockquote>
<p>프론트엔드와 협업하게 되면서 생기는 가장 큰 차이점은 바로 <code>프론트엔드와 백엔드가 각각 따로 서버를 띄운다</code> 라는 것입니다. 이렇게 서버를 각각 띄우게 되면서 가장 간과하고 넘어갈 수 있는 문제가 바로 <code>CORS 문제</code>입니다. 아마 별다른 설정을 하고 백엔드 서버를 배포한다면, 프론트엔드 팀원으로부터 이런 연락을 받게 될 지도 모릅니다.</p>
<blockquote>
<p>&quot;오찌... CORS 허용 좀 해주세요...☆&quot;</p>
</blockquote>
<h2 id="cors란-무엇인가">CORS란 무엇인가?</h2>
<p>CORS는 <code>Cross-Origin Resource Sharing</code>의 약자로, <code>교차 출처 자원 공유</code> 라고 번역할 수 있습니다. MDN에서는 CORS에 대해 이렇게 설명하고 있다.</p>
<blockquote>
<p>교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)는 추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제입니다. 웹 애플리케이션은 리소스가 자신의 출처(도메인, 프로토콜, 포트)와 다를 때 교차 출처 HTTP 요청을 실행합니다.</p>
</blockquote>
<p>쉽게 설명하자면 CORS란 도메인이 다른 서버끼리 리소스를 주고 받는 정책이라고 생각하면 됩니다. 이게 왜 문제가 되냐면, 기본적으로 웹 브라우저의 기본 정책은 <code>Same-Origin</code>으로, <code>origin</code>이 다른 서버와의 리소스 공유를 허용하고 있지 않기 때문입니다.</p>
<p>여기서 <code>origin</code>이란 <code>scheme(일반적으로 프로토콜)</code>, <code>host(도메인)</code>, <code>port</code>를 모두 포함하는 것으로, 셋 중 하나라도 일치하지 않는다면 다른 <code>origin</code>으로 판단합니다. 예를 들어, 한 컴퓨터에서 React 서버(3000 포트)와 Springboot(8080 포트) 서버를 모두 띄워서 서로 리소스를 주고 받으려 한다면 포트가 다르기 때문에 <code>origin</code>이 달라 CORS 위반 문제가 발생하고, 개발자 도구 창에서는 시뻘건 CORS 위반 에러 메시지를 볼 수 있게 됩니다.</p>
<p>그래서 서버에서는 &quot;어? 요청이 왔네?&quot; 하고 200번대 OK 상태 코드를 응답하고 리소스를 정상적으로 보내더라도, 응답을 받은 뒤 브라우저가 판단하기에 &quot;아 이거 같은 <code>origin</code>이 아니네.&quot;하고 판단해서 에러를 띄워버립니다. 결론적으로, 서버에서는 CORS 위반을 확인할 수 없습니다.</p>
<h2 id="cors는-어떻게-작동하는가">CORS는 어떻게 작동하는가?</h2>
<h3 id="preflight-request">Preflight Request</h3>
<p>기본적으로 브라우저는 HTTP 요청을 보낼 때, 사전에 <code>OPTIONS</code> 메서드를 통한 HTTP 요청을 보내서 요청을 보내기 안전한지 확인해야 합니다. 미리 전송(preflight) 한다고 해서 이를 <code>프리플라이트 요청(preflight request)</code>이라고 하는데요, 이 때 요청하려는 메서드 정보를 <code>Access-Control-Request-Method</code> 헤더에, 요청에 담길 헤더 정보를 <code>Access-Control-Request-Headers</code> 헤더에 담습니다.</p>
<pre><code>OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type</code></pre><p>이런 식으로. 그러면 서버는 응답으로 어떤 것을 허용하는지에 대한 정보를 담아서 돌려줍니다.</p>
<pre><code>HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive</code></pre><p>이런 식으로 <code>Access-Control-Allow-Origin</code>에는 허용된 <code>origin</code> 정보를, <code>Access-Control-Allow-Methods</code>에는 허용하는 메서드를, <code>Access-Control-Allow-Header</code>에는 사용 가능한 헤더 목록을, <code>Access-Control-Max-Age</code>에는 현재의 preflight request를 브라우저가 캐싱 가능한 최대 시간을 담아서 제공합니다.</p>
<h3 id="simple-request">Simple Request</h3>
<p>하지만 이런 preflight request를 보내지 않는 경우도 있습니다. 우선 브라우저를 쓰지 않으면 보내지 않는데요, 앞서 말했듯이 origin이 다른지 판단하는 것은 브라우저 스펙이기 때문입니다. 그래서 Postman과 같은 기능을 사용하면 CORS 문제가 발생하지 않습는다. 하지만 브라우저에서도 preflight request를 생략하는 경우가 있는데, 이를 <code>simple request</code>라고 합니다. simple request는 아무 때나 보낼 수 있는 것은 아니고 다음의 세 가지 조건을 만족해야 합니다.</p>
<ul>
<li>본 요청 메서드가 <code>GET</code>, <code>HEAD</code>, <code>POST</code> 중 하나일 것</li>
<li>클라이언트에서 자동으로 넣어주는 헤더와 Fetch 표준 정책에서 정의한 <code>CORS-safelisted request header</code>라는 헤더 목록에 들어 있는 헤더 외에 다른 헤더를 수동으로 넣어주지 않았을 것<ul>
<li>CORS-safelist request Header<ul>
<li>Accept</li>
<li>Accept-Language</li>
<li>Content-Language</li>
<li>Content-Type</li>
</ul>
</li>
</ul>
</li>
<li>Content-Type의 경우 다음의 값들만 있을 것<ul>
<li>application/x-www-form-urlencoded</li>
<li>multipart/form-data</li>
<li>text/plain</li>
</ul>
</li>
</ul>
<p>이 경우에는 preflight 요청을 보내지 않고 서버가 본 요청에 대한 응답 헤더에 CORS 관련된 헤더를 보내서 브라우저가 이를 검사하는 형태로 CORS 정책 위반 여부를 검사합니다.</p>
<h3 id="credentialed-request">Credentialed Request</h3>
<p>이외에 헤더에 인증과 관련된 정보를 담아서 보내는 <code>credentialed request</code>라는 경우도 있는데, 이 경우에는 <code>credentials</code> 옵션을 사용하며 CORS 정책 위반 여부를 검사하는 규칙에 몇 가지 규칙이 더 들어가게 됩니다.</p>
<p>자바스크립트의 fetch API를 사용하거나 Axios, Ajax 등을 사용할 때 서버로 쿠키를 함께 전송해야 하는 경우가 있는데요, 요청에 쿠키가 담기게 되면 Credentialed Request 허용이 되어 있어야 합니다. 이 때는 서버 쪽에서 응답 헤더에 <code>Access-Control-Allow-Credentials: true</code>를 보내주지 않는다면 브라우저에서 응답을 받는 것을 거부하게 됩니다.</p>
<p>여기서 주의할 점이 있습니다. Credentialed Request의 경우, <code>Access-Control-Allow-Origin</code> 헤더 값이 와일드카드여서는 안됩니다. 대신 <code>https://foo.com</code>과 같이 구체적인 origin을 지정해주어야 합니다.</p>
<h2 id="서버에서-cors-허용해주기">서버에서 CORS 허용해주기</h2>
<p>CORS에 대해서 알아봤으니 이제 서버에서 CORS 문제를 해결할 수 있도록 설정해봅시다. 사실 간단한데요, <code>WebMvcConfigurer</code>를 상속받은 <code>@Configuration</code> 빈을 만들어 주면 됩니다.</p>
<pre><code class="language-java">@Configuration
public class WebConfig implements WebMvcConfigurer {
    public static final String ALLOWED_METHOD_NAMES = &quot;GET,HEAD,POST,PUT,DELETE,TRACE,OPTIONS,PATCH&quot;;

    @Override
    public void addCorsMappings(final CorsRegistry registry) {
        registry.addMapping(&quot;/api/**&quot;)
                .allowedMethods(ALLOWED_METHOD_NAMES.split(&quot;,&quot;))
                .exposedHeaders(HttpHeaders.LOCATION);
    }
}</code></pre>
<p><code>WebMvcConfigurer</code>에는 <code>addCorsMappings</code>라는 메서드가 존재하는데, 이 메서드로 CORS 정책을 허용해 줄 url을 지정해줄 수 있습니다. 위 코드는 우아한테크코스 레벨 2 장바구니 미션의 레거시 코드에 포함되어 있는 코드인데요, 하나씩 뜯어보면 <code>addCorsMappings</code>메서드를 오버라이딩 한 뒤, <code>registry</code>에 <code>addMapping</code>으로 CORS를 허용할 메서드를 지정해 줍니다. 이후 <code>allowedMethods</code>로 허용하는 메서드를, <code>exposedHeader</code>로 서버에서 반환해 줄 헤더를 지정합니다.</p>
<p>여기서 &quot;어? 허용하는 <code>origin</code> 값은 왜 안 주지?&quot; 라는 생각이 들어야 합니다. 허용하는 <code>origin</code>을 지정하는 <code>allowedOrigins</code> 메서드와 <code>allowedOriginPatterns</code>도 존재하지만, 이 메서드들을 호출하지 않는다면 기본 값으로 <code>&quot;*&quot;</code>를 지정해서 모든 <code>origin</code>에 대해 CORS를 허용합니다.</p>
<pre><code class="language-java">public class CorsRegistry {

    private final List&lt;CorsRegistration&gt; registrations = new ArrayList&lt;&gt;();

    public CorsRegistration addMapping(String pathPattern) {
        CorsRegistration registration = new CorsRegistration(pathPattern);
        this.registrations.add(registration);
        return registration;
    }
    ...
}

public class CorsRegistration {

    public CorsRegistration(String pathPattern) {
        this.pathPattern = pathPattern;
        this.config = new CorsConfiguration().applyPermitDefaultValues();
    }
    ...
}

public class CorsConfiguration {
    ...
    public CorsConfiguration applyPermitDefaultValues() {
        if (this.allowedOrigins == null &amp;&amp; this.allowedOriginPatterns == null) {
            this.allowedOrigins = DEFAULT_PERMIT_ALL;
        }
        ...
        return this;
    }
    ...
}</code></pre>
<p><code>CorsRegistry</code> -&gt; <code>CorsRegistration</code> -&gt; <code>CorsConfiguration</code> 순으로 들어가 보면, <code>allowedOrigins</code>가 지정되지 않으면 <code>DEFAULT_PERMIT_ALL</code>로 지정하기 때문에 모든 <code>origin</code>에 대해서 허용하도록 설정하는 것을 볼 수 있습니다.</p>
<p>그런데 Credentialed-Request를 허용해 주어야 한다면 특정한 origin들을 지정해주어야겠죠? 추가적으로 <code>.allowCredentials(true)</code> 메서드를 호출하여 Credential 옵션을 허용해주어야 합니다.</p>
<h2 id="주의-인증이-필요한-url에-preflight-요청">주의: 인증이 필요한 URL에 preflight 요청</h2>
<p>이 경우에 주의해야 하는 부분이 있습니다. 예를 들어 <code>api/products</code> URL에 interceptor를 통해 <code>Authorization</code> 헤더의 Jwt 토큰 값을 확인하는 인가 로직이 들어있다고 하겠습니다. 만약 클라이언트에서 보내는 본 요청의 <code>Content-Type</code>이 <code>application/json</code> 이라면 simple request의 조건을 만족하지 못하기 때문에 preflight request를 보낼 것입니다. 그리고 서버는 preflight request에 대해서도 <code>Authorization</code> 헤더를 검사할 것입니다.</p>
<p>하지만 preflight request는 본 요청과는 다르게 <code>Authorization</code> 헤더에 토큰 정보를 담지 못합니다. 본 요청에 어떤 헤더가 들어갈 지를 <code>Access-Control-Request-Headers</code>헤더에 담을 뿐입니다. 따라서 서버는 인증이 필요한 URL에 인증되지 않은 클라이언트가 요청을 보낸다고 판단하게 되어 정상적으로 요청을 처리할 수 없게 되고, 클라이언트에서는 preflight request가 실패했으니 본 요청을 보내지 않게 됩니다. 따라서 preflight request의 HTTP 메서드인 <code>OPTIONS</code>에 대한 추가적인 처리가 필요합니다.</p>
<p>라이브러리를 사용하는 등 다양한 방법이 있지만, 가장 간단하게는 만들어준 interceptor로 가서 <code>preHandle</code> 메서드에 <code>OPTIONS</code>의 경우 바로 <code>true</code>를 반환하도록 하는 로직을 추가하면 됩니다.</p>
<pre><code class="language-java">@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    if (HttpMethod.OPTIONS.matches(request.getMethod()) {
        return true;
    }
    ...
}</code></pre>
<h2 id="참고-자료">참고 자료</h2>
<p><a href="https://developer.mozilla.org/ko/docs/Web/HTTP/CORS">교차 출처 리소스 공유 (CORS)</a>
<a href="https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-CORS-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95-%F0%9F%91%8F">[WEB] 📚 CORS 개념 💯 완벽 정리 &amp; 해결 방법 👏</a>
<a href="https://prolog.techcourse.co.kr/studylogs/2207">CORS, CORS Error 간단히 알아보기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[동시성 그리고 정합성, 문제 해결기]]></title>
            <link>https://velog.io/@ohzzi/%EB%8F%99%EC%8B%9C%EC%84%B1-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%A0%95%ED%95%A9%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@ohzzi/%EB%8F%99%EC%8B%9C%EC%84%B1-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%A0%95%ED%95%A9%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Wed, 19 Oct 2022 01:22:27 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@ohzzi/F12%EC%9D%98-%EB%88%88%EB%AC%BC%EB%82%98%EB%8A%94-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EC%9D%B4%EB%A1%A0%ED%8E%B8">쿼리 개선기</a> 에서 알 수 있듯이, F12는 데이터베이스 조회 성능을 개선하고자 product 테이블과 member 테이블에 집계 컬럼을 추가하게 되었습니다. 따로 캐시 계층이나 조회용 NoSQL을 두지 않았기 때문에, 통계 정보를 지속적으로 업데이트 하기 위해서 리뷰를 작성하거나 다른 회원을 새로 팔로우 할 때마다 집계 컬럼을 업데이트를 해 주어야 합니다. 전체적으로는 다음과 같은 프로세스로 진행이 되겠네요.</p>
<ul>
<li>리뷰 작성<ul>
<li>제품 조회 -&gt; 이미 등록한 리뷰가 있는지 조회 -&gt; 리뷰 작성 -&gt; 제품의 통계 정보 업데이트</li>
</ul>
</li>
<li>팔로우<ul>
<li>팔로우 대상 회원 조회 -&gt; 이미 팔로우했는지 조회 -&gt; 팔로잉 정보 추가 -&gt; 회원의 팔로워 수 업데이트</li>
</ul>
</li>
</ul>
<p>처음 이런 프로세스를 구성할 당시에는 이 과정에서 동시성과 관련된 문제가 발생할 수 있다는 사실을 캐치하지 못하고 넘어갔습니다. 어떤 동시성 문제가 발생할까요?</p>
<p>반정규화로 만들어낸 제품 통계인 <code>리뷰 개수</code>, <code>리뷰 총점</code>, <code>평균 리뷰 점수</code>와 회원 통계인 <code>팔로워 수</code>를 업데이트하는 로직은 JPA의 더티 체킹 기능을 통해 이루어집니다. 더티 체킹, 또는 변경 감지란 트랜잭션 커밋 종료 시점 또는 영속성 컨텍스트의 flush가 일어나는 시점에 엔티티의 스냅샷을 비교하여 변경된 컬럼이 있는지 확인하여 변경된 엔티티에 대해 update 쿼리를 실행하는 방식입니다. 직접 쿼리를 작성하지 않고 JPA 기능을 활용하여 최대한 도메인으로 비즈니스 로직을 응집시킬 수 있다는 장점이 있었습니다. 하지만 이 부분 때문에 문제가 발생했습니다.</p>
<p>더티 체킹을 활용하기 위해서는 당연히 기존 엔티티를 데이터베이스로부터 조회해 온 뒤 해당 엔티티의 필드를 수정해주어야 합니다. 그런데 엔티티를 조회해 온 뒤 트랜잭션이 커밋되어 업데이트 쿼리를 실행하는 사이에 다른 트랜잭션이 레코드를 변경한다면 어떻게 될까요?</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/da8591cd-5526-4d4a-acbb-c4f2ffa4aef8/image.png" alt=""></p>
<p>그림과 같은 상황이 발생하게 됩니다. a와 b가 각각 로그인하여 팔로우 메서드를 실행합니다. 트랜잭션에 진입한 이후 c를 데이터베이스에서 조회해오고, 팔로워 수는 0입니다. 그리고 A 트랜잭션이든 B 트랜잭션이든 한 트랜잭션이 로직을 마치고 커밋을 하게 됩니다. c의 팔로워 수는 1로 업데이트 됩니다. 이어서 다른 트랜잭션도 커밋합니다. 처음 조회해 온 엔티티의 팔로워 수를 1 증가시키는 로직이 있으므로, 엔티티의 팔로워 수는 1. 변경 감지로 인해 팔로워 수를 1로 업데이트하는 쿼리가 실행됩니다. 트랜잭션들이 모두 커밋된 후, 실제로 생성된 팔로우 개수는 2개지만 c 회원의 팔로워 수는 1개입니다. 데이터 정합성이 맞지 않게 되는 것이죠.</p>
<h1 id="문제를-해결하기-위한-여러-고민">문제를 해결하기 위한 여러 고민</h1>
<p>정합성 문제를 어떻게 해결할 수 있을까요? 여러가지 방법을 생각해보았습니다.</p>
<h2 id="1-트랜잭션-격리-레벨-조정">1. 트랜잭션 격리 레벨 조정</h2>
<p>가장 높은 트랜잭션 격리 레벨인 <code>Serializable</code>을 사용하면 문제를 해결할 수 있지 않을까요? 하지만 Serializable을 적용할 수는 없었습니다.</p>
<p>Serializable을 사용하면 select 쿼리에 공유락(Shared Lock, S-Lock)이 걸리게 됩니다. 그리고 업데이트 쿼리가 실행될 때 MySQL이 배타락(Exclusive Lock, X-Lock)이 걸리게 됩니다. 공유 락이 걸리니까 다른 트랜잭션이 데이터를 쓰지 못해서 문제를 해결할 수 있다고 생각할 수 있습니다. 하지만 이 경우 데드락 문제가 발생합니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/7e0ad4ca-e9d0-4fe3-a196-86eae16b92ea/image.png" alt=""></p>
<p>그림에서 보이는 것처럼 Serializable로 인해 A, B 트랜잭션 각각 c 회원에 대한 공유락을 획득합니다. 공유락끼리는 호환이 되기 때문에, A 트랜잭션이 공유락을 얻었더라도 B 트랜잭션이 공유락을 얻을 수 있습니다. 때문에 이후 로직이 진행됩니다. 문제는 A 트랜잭션을 커밋하기 위해 더티 체킹을 통해 update 쿼리가 실행될 때 발생합니다. c에 대한 업데이트 쿼리를 실행하기 위해서는 A 트랜잭션이 배타락을 얻어야 합니다. 그런데 B 트랜잭션이 공유락을 가지고 있습니다. 때문에 A 트랜잭션은 대기 상태로 들어가게 됩니다.</p>
<p>이제 B 트랜잭션 쪽도 커밋을 하기 위해 업데이트 쿼리를 실행합니다. 그런데 아까 A 트랜잭션이 배타락을 얻기 위해 커밋하지 않고 대기하고 있기 때문에, 공유락을 아직 가지고 있습니다. 그래서 B 트랜잭션도 배타락을 얻기 위해 대기합니다. A, B 트랜잭션 모두 배타락을 얻기 위해 서로 다른 트랜잭션이 커밋되기만을 기다리는 상황입니다. 즉, 데드락에 빠집니다. 데드락에 대한 예외 처리 등을 통해 어찌 저찌 해결할 수는 있겠지만, 사실상 격리 레벨 조정으로는 문제를 해결할 수 없습니다.</p>
<h2 id="2-비관적-락">2. 비관적 락</h2>
<p>비관적 락을 사용하는 방법도 있습니다. 회원 조회 메서드에 비관적 락을 걸어주게 되면 회원 조회 시 배타락을 얻게 됩니다. 때문에 A 트랜잭션에서 비관적 락으로 c 회원을 조회해오게 되면, A 트랜잭션이 끝날 때 까지 다른 트랜잭션들은 배타락도, 공유락도 얻어올 수 없습니다. 한번에 한 트랜잭션만 락을 얻을 수 있으므로 정합성 문제도, 데드락 문제도 해결됩니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/7f80067b-cbc7-4b30-9431-5515b8895097/image.png" alt=""></p>
<p>단, 이렇게 할 경우 대기 시간이 발생합니다. 그림에서 보시는 것처럼 트랜잭션의 시작이 배타락을 얻는 조회이기 때문에 각각의 트랜잭션들은 먼저 시작된 트랜잭션이 커밋 또는 롤백될 때까지 로직을 실행하지 못하고 대기 상태에 빠지게 됩니다. 게다가 비즈니스 로직 상 트랜잭션 시작 시점부터 배타락을 걸어버리기 때문에, 사실상 트랜잭션 하나가 통으로 락을 잡아먹는 것과 다름없는 상황이 됩니다. 정합성을 확실하게 맞출 수는 있지만, 동시에 접근하는 트랜잭션이 많아지면 많아질수록 API 콜의 대기 시간은 늘어나게 됩니다.</p>
<p>또한 데이터베이스 자체적으로 애플리케이션은 모르는 이런 저런 락을 걸기 때문에, 애플리케이션 레벨에서 명시적으로 락을 거는 것은 어떤 부작용을 가지고 올 지 모릅니다. 특히나 저희처럼 이제서야 막 트랜잭션과 락에 대해 공부한다면 더더욱 그렇습니다. 이런 상황에 대해 질문드렸던 우아한테크코스 코치님들이나 현업에 계신 선배 크루들께서도 비관적 락은 최대한 기피한다는 말씀을 주셨습니다.</p>
<p>이 두가지 이유로 비관적 락은 기각되었습니다.</p>
<h2 id="3-낙관적-락">3. 낙관적 락</h2>
<p>비관적 락 대신 낙관적 락을 사용하면 어떨까요? 데드락 문제도 발생하지 않고, 비관적 락 방식처럼 레코드에 락을 걸어버리지도 않으니 괜찮지 않을까요? 게다가 JPA를 사용하기 때문에 낙관적 락을 편하게 구현할 수도 있습니다. 하지만 낙관적 락은 정합성을 지키기 위해 비즈니스 로직을 희생해야 하는 문제가 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/4badf6ec-47b6-4514-9621-59f7f474efbe/image.png" alt=""></p>
<p>낙관적 락을 활용하게 되면 버전 정보를 활용해서 버전이 일치하는 경우에만 커밋을 하고, 일치하지 않는 경우에는 롤백 처리를 하게 됩니다. 이 경우 위 그림처럼 실제로 생성되는 팔로잉은 a -&gt; c 하나기 때문에 팔로워 카운트가 1만 증가되어도 정합성에 문제는 생기지 않습니다. 하지만 b -&gt; c 팔로잉 로직이 <code>팔로워 카운트 정합성을 맞추는 로직의 실패 때문에 실패</code>하는 상황이 생기게 됩니다.</p>
<p>팔로잉 생성과 팔로워 카운트 정합성을 맞추는 로직의 트랜잭션을 분리하면 되지 않냐고요? 그러면 다시 팔로워 카운트 정합성이 맞지 않는 상황이 발생하게 됩니다.</p>
<h2 id="4-더티-체킹을-대신-쿼리-직접-실행">4. 더티 체킹을 대신 쿼리 직접 실행</h2>
<p>마지막 방법은 더티 체킹을 포기하고 레코드 자체의 값을 통해 통계를 계산해 주는 방법입니다. 도메인 값을 변경하지 않고 서비스 레이어에서 직접 레포지토리의 메서드를 호출해줘야 하기 때문에 도메인에서 최대한 모든 로직을 처리하는 것은 불가능해지지만, 정합성을 맞출 수는 있습니다. 이는 앞서 설명했던 대로 update 쿼리를 실행할 때 데이터베이스 자체적으로 배타락을 걸어주는 덕분입니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/3cd1fc6e-5d8c-4219-b95a-ef7889ab2d69/image.png" alt=""></p>
<pre><code class="language-java">@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = &quot;update Member m set m.followerCount = m.followerCount + 1 where m.id = :followingMemberId&quot;)
void increaseFollowerCount(Long followingMemberId);</code></pre>
<p>현재 저장된 회원의 팔로워 카운트 값에 +1을 해주는 JPQL입니다. 이렇게 자기 자신의 값을 이용하여 계산하게 해줄 경우, 팔로우 개수를 count 쿼리를 사용해 계산하는 것보다 훨씬 빠르며 배타락 덕분에 데이터 정합성도 보장할 수 있습니다. 그림에서 보시는 것처럼 먼저 실행된 트랜잭션이 update 쿼리를 마치고 커밋 또는 롤백 할 때까지 락 획득을 위해 대기하고 있기 때문입니다. 이 경우 회원 c의 레코드에 공유락이나 배타락을 걸어주는 다른 서비스 로직이 있지 않는 한 데드락 문제도 피할 수 있습니다.</p>
<p>위의 세 가지 방법들과 비교해보았을 때, JPA의 더티 체킹 기능을 포기하여 덜 객체 지향적인 코드가 되고, 도메인의 로직이 바깥으로 이동하기 때문에(서비스와 레포지토리로 분산됨) 비대한 서비스 코드를 만들 수 있다는 점. 그리고 서비스 레이어를 모킹하고 있는 F12의 테스트 코드 특성 상 테스트로 서비스 로직을 완벽하게 테스트하기 어렵다는 점을 단점으로 꼽을 수 있습니다. 하지만 락을 최소화하면서 정합성을 가장 확실하게 보장할 수 있는 방법이기 때문에 저희 팀은 이 방법을 통해 동시성으로 인한 정합성 문제를 해결하기로 결정했습니다.</p>
<p>여기서 잠깐 코드 레벨로 들어가보면, 특정 데이터베이스에 종속되는 것을 막기 위해 최대한 JPQL 문법을 사용하도록 고민을 많이 했는데요, 다행히도 JPQL이 지원하는 문법만으로 문제를 해결할 수 있어서 네이티브 쿼리를 사용하지 않을 수 있었습니다.</p>
<p>이 때 주의할 점이 있었는데요, 많은 분들이 아시다시피 JPQL은 실행 전 영속성 컨텍스트를 flush 합니다. 즉, 쓰기 지연 저장소에 저장되어 있던 쿼리들이 실행된다는 의미인데요, 쓰기 지연 저장소에 저장된 모든 쿼리를 실행시키는 것이 아니라는 점에 주의해야 합니다. 위의 코드로 예를 들어보겠습니다. JPQL은 <code>update Member m set m.followerCount = m.followerCount + 1 where m.id = :followingMemberId&quot;</code> 입니다. 이 쿼리는 Member에 대해서만 관련이 있는 쿼리입니다. 때문에 쓰기 지연 저장소에서 Member에 대한 쿼리만 실행되게 됩니다.</p>
<p>보통 <code>@Modifying</code>이 들어가는 메서드, 즉 JPQL을 직접 작성하고 실행하는 메서드를 사용할 경우, 영속성 컨텍스트와 데이터베이스의 정합성이 맞지 않는 문제를 해결하기 위해 <code>clearAutomatically = true</code> 옵션을 걸어 영속성 컨텍스트를 아예 초기화 해버립니다. 그런데 지금 상황에서 이렇게 할 경우, 쓰기 지연 저장소에 저장된 쿼리 중 Member에 대한 쿼리를 제외한 다른 쿼리들이 실행되지 못하고 유실되게 됩니다.</p>
<p>예를 들어 팔로잉 객체의 식별자 생성 전략이 <code>IDENTITY</code>가 아니어서 insert 쿼리가 쓰기 지연 된다든가, 언팔로우 하는 상황이서어 팔로잉에 대한 delete 쿼리가 쓰기 지연 저장소에 저장되는 경우 insert / delete 쿼리는 영속성 컨텍스트 초기화에 의해 유실되고 팔로워 카운트만 변화하는 상황이 발생할 수 있습니다. 때문에 어떤 엔티티에 관련된 쿼리인지 상관 없이 쓰기 지연 저장소의 모든 쿼리를 실행시켜줘야 하고, <code>flushAutomatically = true</code> 옵션을 넣어주어 이 문제를 해결했습니다.</p>
<h2 id="번외-배치-스케줄을-통해-나중에-정합성-맞추기">번외. 배치 스케줄을 통해 나중에 정합성 맞추기</h2>
<p>아예 실시간 정합성은 신경쓰지 않고 나중에 스케줄러와 배치 업데이트 쿼리를 활용하여 정합성을 맞추는 방법도 있습니다. 저희 팀도 처음에는 이 방법을 생각했습니다. 동시성 문제가 아주 빈번하게 발생하지는 않을 것이라는 생각을 하기도 했고(실제로 서비스 환경에서 직접 문제를 터뜨려보기가 여간 힘든 일이 아니었습니다.), 당시에는 락 vs 스케줄러로 고민을 하고 있었을 때였는데 락으로 인한 문제를 겪는 것 보다는 정합성이 안맞는 시간이 잠깐 존재하는 것이 낫다는 판단을 했었습니다.</p>
<p>특히 리뷰에 대한 제품 통계가 아닌, 회원의 팔로워 수 관련 로직에서는 더 그렇게 생각했습니다. 저희 팀은 비즈니스 규칙으로 팔로워 수는 보여주지만 누가 팔로우하고 있는지는 보여주지 않기 때문에, 팔로워 수가 정확히 몇 개가 변했는지 실시간으로는 크게 중요하지 않고 나중에 정합성을 맞춰주어도 되는 상황이라고 생각했습니다. 그래서 기존 방식대로 JPA 더티 체킹 사용 + 일정 시간마다 스케줄러로 정합성 맞추는 쿼리 실행 조합의 코드를 실제로 작성까지 했는데요, 락에 대해서 좀 더 공부하던 도중 이 방식이 굉장히 비효율적인 방식이라는 것을 깨닫고 지금의 방법으로 수정했습니다.</p>
<p>왜 비효율적인 방식이라 생각했냐면, 도메인 로직을 사용하여 카운트를 증가시키고 더티 체킹으로 업데이트 하는 과정에서 update 쿼리로 인한 배타락이 걸리기 때문에 계산 쿼리를 직접 실행해주는 것과 배타락이 걸리는 시간에서는 큰 차이가 없는 반면 데이터의 실시간 정합성은 맞지 않기 때문입니다. 이 방식이 효율적이려면 애초에 팔로워 카운트에 대한 update 쿼리가 실행되지 않아야 합니다. 그래야 락을 최소화할 수 있습니다. 문제는 이럴 경우 모든 리뷰 작성 / 수정 / 삭제, 팔로우 / 언팔로우 로직마다 정합성이 안맞는 시간이 존재한다는 것입니다. 인스타그램처럼 팔로워가 엄청 많아 K단위로 보여줘 +1이 큰 의미가 없는 경우라면 모를까, 저희의 서비스에는 맞지 않는 상황이라고 느꼈습니다.</p>
<p>물론 update 쿼리를 실행하지 않고도 사용자에게 보여주는 카운트는 올릴 수도 있습니다. 적절한 캐시 레이어를 사용하면 됩니다. 캐시에 팔로워 카운트를 실시간으로 동기화시켜놓고, 주기적으로 데이터베이스에 반영하는 방식으로 사용하면 되겠다는 생각을 했습니다. 하지만 현재 저희 팀의 상황 상 캐시를 구축하기 위한 시간적 비용을 감당할 상황이 아니기 때문에 캐시를 적용하여 데이터 정합성을 맞추는 방식은 기각되었습니다.</p>
<h1 id="추후-개선하고-싶은-점">추후 개선하고 싶은 점</h1>
<p>어느 정도 해결되기는 했지만 아직 개선하고 싶은 문제점이 남아있습니다. 정합성을 맞추기 위해 서비스 로직이 비대해졌다는 것입니다. 당장에 구현할 능력은 부족하여 추후 개선점으로 남기지만, 구상해 본 바는 다음과 같습니다.</p>
<ul>
<li>이벤트 발행을 통해 서비스에서 팔로워 카운트 업데이트 로직 분리<ul>
<li>이 경우 트랜잭션이 분리되므로 정합성이 안맞는 경우가 발생할 수 있음</li>
<li>이런 경우는 매우 드물기 때문에 이 부분만 배치 스케줄러를 사용해도 좋을 듯</li>
</ul>
</li>
<li>팔로워 카운트를 저장하는 캐시 레이어 도입<ul>
<li>매 팔로우 / 언팔로우마다 카운트 업데이트를 위해 데이터베이스에 한 번 더 접근해야 하는데, 인 메모리 I/O는 디스크 I/O보다 빠르므로 I/O 시간을 감소시킬 수 있음</li>
</ul>
</li>
</ul>
<p>이런 부분들에 대해서 어떻게 하면 더 효율적인 방식을 도입할 수 있을지 좀 더 고민해보면서 점차 나은 성능과 구조를 보여주는 프로젝트를 만들 수 있도록 개선해나가는 것도 재밌을 것 같습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[쿠키를 사용한 API 통신을 할 때 주의할 점]]></title>
            <link>https://velog.io/@ohzzi/%EC%BF%A0%ED%82%A4%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-API-%ED%86%B5%EC%8B%A0%EC%9D%84-%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90</link>
            <guid>https://velog.io/@ohzzi/%EC%BF%A0%ED%82%A4%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-API-%ED%86%B5%EC%8B%A0%EC%9D%84-%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90</guid>
            <pubDate>Sat, 08 Oct 2022 02:27:17 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ohzzi/post/0980fac3-7791-4fc0-bd89-007b1dc1472b/image.png" alt=""></p>
<p>팀 프로젝트를 진행하면서 Refresh token을 HTTP only 쿠키에 담기로 결정하여, 인증 인가 서비스에 쿠키를 사용하는 로직이 추가로 들어가게 되었습니다. 저희 팀은 단순히 서버에서 토큰을 응답으로 내려줄 때 <code>Set-Cookie</code> 헤더로 쿠키 값을 설정해주도록 내려주기만 하면 이후의 API 통신은 자동으로 쿠키를 보내게 될거라고 생각을 했는데요, 브라우저에 쿠키가 잘 세팅되기는 했지만 저희가 생각한대로 작동하지 않았습니다. 이번 포스팅에서는 어째서 쿠키가 보내지지 않게 되었는지, 어떻게 하면 쿠키를 서버로 잘 보낼 수 있는지 해결 과정을 공유하는 시간을 가져보도록 하겠습니다.</p>
<h1 id="cross-site-요청-시-axios에-쿠키를-담는-법">Cross-Site 요청 시 Axios에 쿠키를 담는 법</h1>
<p>우선은 당연하게도 클라이언트가 요청을 보낼 때 쿠키를 요청에 담아서 보내줘야 합니다. 이 부분은 백엔드의 영역은 아니지만, 백엔드 개발자도 알면 좋을 것 같습니다. 일반적으로 백엔드 단에서 간단하게 클라이언트 페이지와 서버 설정을 하고 테스트해보게 되면, 서버에서 응답에 Set-Cookie 헤더를 내려주는 것 만으로도 이후 요청에 쿠키값이 담아지는 것을 확인할 수 있었습니다. 하지만 실제 운영 환경으로 가서 테스트해보면 서버로 쿠키 값을 보내지 않았습니다.</p>
<p>이는 <a href="https://prolog.techcourse.co.kr/studylogs/2414">CORS로 인한 문제</a>였습니다. CORS를 허용하는 옵션 중에 기존에 사용하지 않아서 대수롭지 않게 넘어갔던 옵션이 있었는데요, 바로 <code>withCredentials</code> 옵션입니다. 기본적으로 쿠키는 Same-Origin에만 담도록 설정이 되어 있고, Cross-Origin에 대해서는 CORS 허용을 해주는 추가적인 옵션이 필요합니다.</p>
<p>스프링에서는 <code>WebMvcConfigurer</code>의 <code>addCorsMappings</code> 메서드를 오버라이딩 할 때 CorsRegistry의 <code>allowCredentials</code> 메서드에 true 값을 넣어주는 것으로 허용 처리를 해줄 수 있습니다. 그리고 Set-Cookie 헤더가 Cross-Site 요청에서 기본적으로 사용할 수 있는 헤더가 아니므로, <code>exposedHeaders</code> 메서드 안에 Set-Cookie도 넣어주어야 합니다.</p>
<p>또한 주의할 점으로, <code>allowCredentials=true</code> 옵션을 주게 되면 요청을 허용하는 Origin 값에 와일드카드(*)를 사용할 수 없습니다. 보안에 대해 생각해 봤을 때, 쿠키를 사용하여 인증 옵션을 켰는데 아무 origin에나 허용해주면 안되겠죠?</p>
<pre><code class="language-java">@Override
public void addCorsMappings(final CorsRegistry registry) {
    registry.addMapping(&quot;/api/**&quot;)
            .allowedMethods(CORS_ALLOWED_METHODS.split(&quot;,&quot;))
            .allowedOrigins(MAIN_SERVER_DOMAIN, MAIN_SERVER_WWW_DOMAIN, TEST_SERVER_DOMAIN, FRONTEND_LOCALHOST)
            .allowCredentials(true)
            .exposedHeaders(HttpHeaders.LOCATION, HttpHeaders.SET_COOKIE);
}</code></pre>
<p>하지만 이것만으로는 withCredentials 옵션이 켜지지 않고, 클라이언트에서도 처리를 해주어야 합니다. 저희 F12팀은 클라이언트 단에서 API 통신에 Axios라는 라이브러리를 사용하는데요, Axios 에서도 옵션을 추가해야 합니다.</p>
<pre><code class="language-javascript">const data = await axios.get(url, {
  withCredentials: true
});</code></pre>
<p>이렇게 서버와 클라이언트 양쪽 설정을 해주면 withCredentials 옵션을 활성화하고 쿠키를 담아 보낼 수 있습니다.</p>
<h1 id="samesite-주의">SameSite 주의</h1>
<p>하지만 여기서 끝나지 않습니다. 위 설정을 하더라도 어떤 요청에서는 쿠키가 정상적으로 담기지 않는 상황이 발생했습니다. 이는 SameSite 옵션 때문인데요, 쿠키는 도메인을 기준으로 퍼스트 파티 쿠키와 서드 파티 쿠키로 나뉩니다. 기본적으로 쿠키는 도메인 별로 관리되고 보내지게 됩니다. 이 때 요청을 보내는 곳, 즉 Referrer와 같은 도메인의 쿠키는 퍼스트 파티, 다른 도메인의 쿠키는 서드 파티라고 하는데요, 서드 파티 쿠키의 전송을 막는 SameSite 정책 때문에 쿠키가 제대로 날아가지 않는 것이었습니다.</p>
<p>Cross-Site 간 쿠키 전송은 CSRF 공격에 취약하다는 문제점이 있는데요, 이 문제를 해결하기 위해 나온 정책이 SameSite입니다. 과거에는 기본적으로 SameSite 정책이 <code>none</code>이었습니다. <code>none</code>으로 설정된 경우 Cross-Site로 쿠키를 보내더라도, 즉 서드 파티 쿠키를 보내더라도 전혀 제한이 없었습니다. 그러나 이제 정책이 바뀌고 있는데요, 크롬이 2020년 2월 4일 80버전부터 포문을 열었습니다. 브라우저의 SameSite 정책을 none에서 Lax로 변경한 것이죠. SameSite 정책이 Lax일 경우, <code>&lt;a href=...&gt;</code>과 같은 페이지 이동 링크나 GET, HEAD 요청을 제외하고는 서드 파티 쿠키 사용이 불가능합니다. 그리고 크롬을 시작으로 파이어폭스 등 다른 브라우저들도 SameSite 정책을 변경하고 있습니다.</p>
<p>결국 브라우저가 기본적으로 서드 파티 쿠키를 전송하지 않도록 설정하고 있기 때문에 쿠키가 정상적으로 요청에 담기지 않는 문제가 발생하는 것이었습니다. 보통 클라이언트가 접속하는 사이트와 API 통신을 받는 백엔드 서버의 도메인은 다르기 때문이죠.</p>
<p>때문에 쿠키를 만들어줄 때 SameSite=Lax 정책이 적용되지 않도록 다음과 같은 설정을 해줄 필요가 있습니다.</p>
<pre><code class="language-java">private ResponseCookieBuilder createTokenCookieBuilder(final String value) {
    return ResponseCookie.from(REFRESH_TOKEN, value)
            .httpOnly(true)
            .secure(true)
            .path(&quot;/&quot;)
            .sameSite(SameSite.NONE.attributeValue());
}</code></pre>
<p>ResponseCookieBuilder를 만들 때 sameSite(SameSite.NONE.attributeValue())를 호출해 주면 됩니다.</p>
<p>이렇게 withCredentails 옵션을 켜주고, SameSite 설정까지 끝나면 API 통신에서 쿠키를 통해 값을 주고받을 수 있게 됩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[F12의 눈물나는 쿼리 개선기 - 실전편]]></title>
            <link>https://velog.io/@ohzzi/F12%EC%9D%98-%EB%88%88%EB%AC%BC%EB%82%98%EB%8A%94-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EC%8B%A4%EC%A0%84%ED%8E%B8</link>
            <guid>https://velog.io/@ohzzi/F12%EC%9D%98-%EB%88%88%EB%AC%BC%EB%82%98%EB%8A%94-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EC%8B%A4%EC%A0%84%ED%8E%B8</guid>
            <pubDate>Wed, 28 Sep 2022 02:27:31 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@ohzzi/F12%EC%9D%98-%EB%88%88%EB%AC%BC%EB%82%98%EB%8A%94-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EC%9D%B4%EB%A1%A0%ED%8E%B8">지난 시간</a>에 인덱스를 활용하여 어떻게 하면 쿼리 성능을 개선할 수 있을지 MySQL 콘솔을 통해 실험해가며 알아보았습니다. 그를 통해 개선된 쿼리를 만들어 낼 수 있었죠. 하지만 저희 팀 서비스는 결국 스프링과 JPA를 사용한 웹 애플리케이션이고, 개선된 쿼리를 자바 코드로 풀어내는 것 또한 중요합니다. 그래서 이번 시간에는 개선된 쿼리를 어떻게 웹 애플리케이션 코드에 적용했는지에 대해 알아보도록 하겠습니다.</p>
<h2 id="복잡한-연관관계로-인한-n1-문제-해결">복잡한 연관관계로 인한 N+1 문제 해결</h2>
<p>JPA의 지연 로딩 기능을 사용하거나, 즉시 로딩을 사용하더라도 JPQL을 통한 조회를 할 경우 N+1 문제가 발생합니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/7edbc603-a330-48bc-8ab3-30219509a8a4/image.png" alt=""></p>
<p>저희 팀의 연관관계 상황입니다. Member 엔티티와 InventoryProduct 엔티티는 Member 쪽이 일, InventoryProduct쪽이 다인 다대일 양방향 매핑이 되어 있습니다. 그리고 InventoryProduct는 Product와는 다대일 단방향 매핑을 하고 있습니다. (Member - Product 다대다 매핑을 일대다, 다대일 관계로 풀어낸 것입니다.)</p>
<p>이 상황에서 Member를 조회한다면 어떻게 될까요? 지연 로딩이 걸려있기 때문에 InventoryProduct를 조회하기 위해 N+1 쿼리가 발생할 것입니다. 그런데 InventoryProduct를 뷰로 보내주기 위해서는 마찬가지로 Product의 정보를 필요로 합니다. 때문에 Member를 조회하는 하나의 쿼리에 2N+1개의 쿼리가 발생하게 됩니다.</p>
<p>N+1 문제를 해결하는 가장 대표적인 방법으로는 <code>fetch join</code>이 있습니다. 하지만 저희 팀은 페이징을 사용하고 있기 때문에 일대다 상황에서 fetch join을 사용할 수 없는 문제를 안고 있습니다.</p>
<blockquote>
<p>참고</p>
<p>DB에서 1과 N을 조인하면 그 결과가 N이 되기 때문에 row 수가 뒤틀려서 페이징이 불가능해집니다. 때문에 <code>@OneToMany</code>에서는 fetch join을 사용할 경우 애플리케이션으로 데이터를 전부 읽어온 뒤에 애플리케이션 단에서 페이징을 하게 되는 이슈가 있습니다.</p>
</blockquote>
<p>때문에 일종의 임시방편으로 Member - InventoryProduct는 <code>@BatchSize</code>를 이용하여 in 쿼리를 이용해 조회하고, InventoryProduct - Product는 둘 사이의 연관관계를 즉시 로딩으로 설정하여 해결했습니다.</p>
<p>하지만 기본적으로 특별한 이유가 있지 않은 이상 엔티티 간의 연관관계는 지연 로딩으로 이루어져 있는 것이 좋습니다. 그런데 지연 로딩으로 바꿀 경우 InventoryProduct - Product에 fetch join을 적용해야 했는데, Member의 BatchSize와 함께 사용하기가 곤란합니다. 때문에 InventoryProduct - Product를 지연 로딩으로 바꾸기 위해 코드 개선이 필요했습니다.</p>
<h3 id="해결-아이디어-batchsize를-직접-구현하자">해결 아이디어: @BatchSize를 직접 구현하자</h3>
<p><code>@BatchSize</code>를 사용하게 되면 여러 개의 프록시 객체를 조회하기 위해 in 쿼리를 사용해서 묶습니다. 따라서 BatchSize의 구현을 애플리케이션 코드로 대체하기만 하면 됐는데요, InventoryProduct - Product 관계에서 fetch join을 사용하기 위해 BatchSize 대신 in 쿼리를 이용한 조립을 애플리케이션에서 직접 해주기로 결정했습니다.</p>
<pre><code class="language-java">public MemberPageResponse findByContains(@Nullable final Long loggedInId,
                                         final MemberSearchRequest memberSearchRequest, final Pageable pageable) {
    final Slice&lt;Member&gt; slice = findBySearchConditions(memberSearchRequest, pageable);
    setInventoryProductsToMembers(slice);
    if (isNotLoggedIn(loggedInId)) {
        return MemberPageResponse.ofByFollowingCondition(slice, false);
    }
    final List&lt;Following&gt; followings = followingRepository.findByFollowerIdAndFollowingIdIn(loggedInId,
            extractMemberIds(slice.getContent()));
    return MemberPageResponse.of(slice, followings);
}

// findBySearchConditions는 여기에 중요한 로직이 아니라 생략했습니다.

private void setInventoryProductsToMembers(final Slice&lt;Member&gt; slice) {
    final List&lt;InventoryProduct&gt; mixedInventoryProducts = inventoryProductRepository.findWithProductByMembers(
            slice.getContent());
    for (Member member : slice.getContent()) {
        final List&lt;InventoryProduct&gt; memberInventoryProducts = mixedInventoryProducts.stream()
                .filter(it -&gt; it.getMember().isSameId(member.getId()))
                .collect(Collectors.toList());
        member.updateInventoryProducts(memberInventoryProducts);
    }
}</code></pre>
<p>우선 조회 조건에 따라 Member의 리스트를 가져옵니다. 그 뒤 Member 안에 있는 InventoryProduct들의 프록시, 즉 PersistenceBag을 초기화해줘야 하는데요, 이 작업을 Member가 가진 InventoryProduct들을 fetch join을 사용하여 전부 가져온 뒤, 각각의 Member에 맞게 조립해주는 과정을 거쳐줍니다.</p>
<p>이렇게 하면 Member 조회 쿼리 하나, InventoryProduct - Product 조회 쿼리 하나, 이렇게 두 개의 쿼리만 가지고 세 테이블간의 관계를 풀어낼 수 있습니다.</p>
<p>참고로 fetch join 메서드는 다음과 같습니다.</p>
<pre><code class="language-java">@Query(&quot;select i from InventoryProduct i join fetch i.product where i.member IN :members&quot;)
List&lt;InventoryProduct&gt; findWithProductByMembers(List&lt;Member&gt; members);</code></pre>
<h2 id="querydsl로-커버링-인덱스를-통한-페이징-구현하기">QueryDSL로 커버링 인덱스를 통한 페이징 구현하기</h2>
<p>다음은 커버링 인덱스입니다. 전 편에서 많은 데이터의 정렬 및 페이징을 커버링 인덱스를 통해 개선하는 쿼리를 살펴보았습니다. 이 쿼리를 JdbcTemplate를 활용해 네이티브 쿼리로 사용하는 것도 좋겠지만, 저희 팀은 이미 QueryDSL을 사용하고 있었고, QueryDSL이 주는 장점인 컴파일 시점의 문법 오류 체크, 자동 완성, 코드 재사용 등을 포기하고 싶지 않아 QueryDSL을 이용해 커버링 인덱스 페이징을 구현하기로 결정했습니다. 성능은 네이티브 쿼리를 사용하는 쪽이 조금 더 빠릅니다.</p>
<p>QueryDSL을 사용해 페이징 쿼리를 작성하려고 하니 문제가 있습니다. 작성해야 하는 MySQL 쿼리를 다시 한 번 확인해보겠습니다.</p>
<pre><code class="language-sql">select * from member m
join (select id from member order by
      follower_count desc, 
      id desc limit 191527, 10)
temp on temp.id = m.id;</code></pre>
<p>의도하는 쿼리를 작성하려면 from 절에 서브쿼리를 사용해야 합니다. 하지만 <strong>JPQL은 from 절에 서브쿼리를 지원하지 않습니다.</strong> 따라서 서브쿼리 / 메인쿼리로 나눠서 두 번의 쿼리를 실행시킨 뒤 결과를 조합해야 합니다.</p>
<p>먼저 서브쿼리를 QueryDSL로 만들어보도록 하겠습니다.</p>
<pre><code class="language-java">final List&lt;Long&gt; subQueryResult = jpaQueryFactory.select(member.id)
        .from(member)
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize() + 1)
        .orderBy(makeOrderSpecifiers(member, pageable))
        .fetch();
        final Slice&lt;Long&gt; memberIds = toSlice(pageable, subQueryResult);</code></pre>
<p><code>makeOrderSpecifiers</code>, <code>toSlice</code>는 저희 팀에서 QueryDSL 조회 조건 및 결과를 원하는 형식으로 만들기 위해 제네릭을 사용해 일반화한 유틸 메서드입니다. <code>toSlice</code>는 Spring Data JPA의 Slice 타입을 쓰기 위해 다음 페이지가 있는지 hasNext 값을 구하는 로직이 들어 있고, 여기서 신경써야 할 makeOrderSpecifiers 메서드에는 Pageable로 들어온 Sort 객체를 풀어내는 로직이 들어 있는데요, 위 코드에서는 최종적으로 <code>member.followerCount.desc(), member.id.desc()</code>가 나온다고 보시면 되겠습니다.</p>
<p>조회 결과로 조회 대상의 id가 리스트로 나오게 됩니다. 그 뒤 해당 id들을 사용해서 실제 select 쿼리를 실행하면 되는데요, 여기서 주의할 점이 하나 있습니다. 만약 서브쿼리의 결과가 빈 값이라면, 즉 조건을 만족하는 id가 아무것도 없다면, 굳이 다시 한 번 select 쿼리를 실행해 줄 필요가 없습니다. 만약 id들이 비어있다면 빈 결과를 바로 return 해주도록 하겠습니다.</p>
<pre><code class="language-java">if (memberIds.isEmpty()) {
    return new SliceImpl&lt;&gt;(Collections.emptyList(), pageable, false);
}</code></pre>
<p>이어서 id 리스트가 비어있지 않다면 최종적으로 조회 쿼리를 실행하도록 하겠습니다.</p>
<pre><code class="language-java">final List&lt;Member&gt; mainQueryResult = jpaQueryFactory.selectFrom(member)
        .where(member.id.in(memberIds.getContent()))
        .orderBy(makeOrderSpecifiers(member, pageable))
        .fetch();

return new SliceImpl&lt;&gt;(mainQueryResult, pageable, memberIds.hasNext());</code></pre>
<p>이 때 메인쿼리에도 orderBy가 들어가는 것에 의문을 품으실 수 있는데요, 만약 MySQL 상에서 실행시켰던 한 줄 짜리 쿼리를 실행했다면 서브쿼리에서 정렬한 결과대로 조회가 되어 메인쿼리에서는 따로 정렬할 필요가 없었을텐데요, 하지만 위 코드처럼 쿼리를 두 개로 나눠서 사용하게 될 경우 두 번째 쿼리에는 where ~ in ~ 절만 있기 때문에 조회 결과가 의도한 대로 정렬되지 않게 됩니다. 때문에 orderBy를 한 번 더 걸어서 순서를 맞춰주도록 하는 코드입니다.</p>
<p>테스트 코드를 통해 쿼리가 의도한대로 잘 나가는지 확인해보도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/ec35d70c-b42c-414c-853a-fa4022d8b6e3/image.png" alt="">
<img src="https://velog.velcdn.com/images/ohzzi/post/7fd4edfa-f485-4170-9ba7-22e7a09455a7/image.png" alt=""></p>
<p>의도한 대로 id만 가져오는 쿼리가 하나 나간 뒤, 해당 id들을 in 절에 넣어 엔티티를 조회하는 쿼리가 실행되는 것을 확인할 수 있습니다.</p>
<h2 id="쿼리-개선-과정의-한계">쿼리 개선 과정의 한계</h2>
<p>지금까지 이전 시간에 알아봤던 쿼리 개선 과정을 JPA와 QueryDSL을 활용한 애플리케이션 코드로 옮기는 과정을 살펴보았습니다.완벽하게 쿼리를 개선하지는 못했지만, 성능 상으로도 유의미한 결과를 얻을 수 있었고, 무엇보다 인덱스에 대해 잘 이해하지 못하던 제가 인덱스를 어느 정도 이해할 수 있었던 좋은 경험이었다고 생각합니다.</p>
<p>지난 시간과 이번 시간을 통해 쿼리 성능을 대단히 개선할 수 있었지만, 개선하지 못한 쿼리들도 많이 남아 있습니다. 가장 대표적으로 검색어가 들어간 조회 쿼리입니다.</p>
<p>현재 검색 방식을 <code>like %XXX%</code> 형태로 하고 있는데요, 때문에 인덱스를 타지 못하는 상황입니다. 실제 성능 테스트 과정에서도 검색어가 들어간 요청만 많은 시간이 소요되는 것을 확인할 수 있었습니다. FULL TEXT INDEX를 고려해보기도 했는데, product 테이블에 대한 검색 같은 경우, 워낙 검색 대상의 이름이 길기도 하고, 검색어를 길게 할 때 오히려 검색 속도가 더 나오지 않는 문제도 있었습니다. 때문에 다시 원래대로 like 검색을 사용했는데요, 검색에 대해서는 조금 더 알아보고 어떻게 하면 효율적인 검색이 이루어질 수 있을지 고민해 볼 필요가 있을 것 같습니다.</p>
<p>또한 where이 걸리는 쿼리의 경우 그냥 where에만 인덱스를 걸고 사용했는데요, 때문에 오히려 필터가 걸리는 쿼리가 필터 없는 페이징보다 더 느린 이상한 결과가 나오기도 했습니다. 여기도 커버링 인덱스를 조회하고 싶었지만, where에 걸리는 부분까지 함께 새로운 복합 인덱스로 만들어야 해서 인덱스가 너무 많아지는 것 같아 포기했습니다.</p>
<p>그래서 인덱스 외에도 조회 성능을 좀 더 개선할 방법을 찾아보려고 합니다. 지금 생각하기로는 DB Replication, 캐시 서버 등의 키워드들이 떠오르고 있습니다. 학습이 부족한 만큼, 다양한 주제로 좀 더 학습해봐야겠다는 생각이 듭니다.</p>
<h2 id="참고자료">참고자료</h2>
<ul>
<li><a href="https://jojoldu.tistory.com/529?category=637935">2. 페이징 성능 개선하기 - 커버링 인덱스 사용하기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[우아한테크코스 4기] 스프린트 5 F12 개발일지]]></title>
            <link>https://velog.io/@ohzzi/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-4%EA%B8%B0-%EC%8A%A4%ED%94%84%EB%A6%B0%ED%8A%B8-5-F12-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80</link>
            <guid>https://velog.io/@ohzzi/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4-4%EA%B8%B0-%EC%8A%A4%ED%94%84%EB%A6%B0%ED%8A%B8-5-F12-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80</guid>
            <pubDate>Mon, 26 Sep 2022 08:34:27 GMT</pubDate>
            <description><![CDATA[<p>레벨 4는 프로젝트가 주가 아니라는 생각에 레벨 4 초반에는 프로젝트보다는 개인 공부에 집중했었다. 백엔드 쪽 요구사항으로 인덱스와 스레드 설정이 주어졌었는데, 오히려 잘 모르다 보니 오래 고민하고 오래 테스트 해봐야 한다는 것을 몰라서 쉽게 끝낼 수 있을 거라고 착각했다. (결과적으로 마지막 주에 피봤다.)</p>
<h2 id="인증-인가-방식-변경">인증 인가 방식 변경</h2>
<p>백엔드 요구사항이 따로 나오기는 했지만, 프론트엔드와 백엔드가 합쳐서 부족한 기능을 채워넣는 것도 소홀히 해서는 안되는 부분이었다. 이번 스프린트에 이야기가 나온 것은 인증, 인가에 관련된 내용이었다. 우리 팀은 인증 인가에 JWT를 활용한 방식을 사용하고 있었는데, 보통 토큰 방식의 단점을 보완하기 위해 Refresh Token도 함께 사용하는 편이지만 레벨 3 때는 그닥 필요성을 못느껴서 Access Token만 사용했었다. 그러다가 이번 스프린트 들어</p>
<ol>
<li>Access Token만 사용하면 로그인이 너무 자주 풀린다.</li>
<li>현재 우리가 Access Token을 사용하고 있는 방식은 공격에 취약할 수 있다.</li>
</ol>
<p>의 이유로 Refresh Token을 사용하자는 의견이 나왔다. 하지만 Access Token과 Refresh Token을 어디에 저장해서 사용할지를 정하는 과정이 쉽지 않았다. 특히나 사용성 뿐 아니라 보안 측면에서도 함께 접근했기 때문에 더더욱 그랬다.</p>
<p>Access Token과 Refresh Token에 대한 논의는 <a href="https://velog.io/@ohzzi/Access-Token%EA%B3%BC-Refresh-Token%EC%9D%84-%EC%96%B4%EB%94%94%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C">Access Token과 Refresh Token을 어디에 저장해야 할까?</a>를 참고하자.</p>
<h2 id="인덱스-설정-및-스레드-설정">인덱스 설정 및 스레드 설정</h2>
<p>원래 나는 이번 스프린트에 어드민 페이지를 만들기로 했었다. <del>이유는 3레벨 방학식 때 DB를 날려먹은 죄로 다시는 DB를 날려먹지 않기 위해</del> 그래서 열심히 어드민 페이지를 만들고 있었는데, 데모 데이를 일주일 남기고 사건이 터졌다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/a906f16c-bcb9-44ec-a1b2-0dfaa931f268/image.png" alt=""></p>
<p>이번 스프린트에 위와 같은 요구사항이 주어져서, 적절한 설정 값을 정하기 위해 성능 테스트를 진행했는데, 더미 데이터를 넣어주고 vUSER 값이 늘어나니 수십 초가 걸리는 요청도 있었고 아예 응답이 돌아오지 않는 요청도 있었다. 우리 도메인 자체가 조회 및 정렬 측면에서 복잡한 도메인이다보니 조회 성능을 챙기는 것이 무엇보다 중요했는데, 그런 부분을 전혀 생각하지 않은 설계가 이루어졌던 탓이다. (애플리케이션 레벨의 설계 보다는 데이터베이스 테이블 설계나 인덱스를 설정하는 부분이 잘못된 탓이 컸다.) 때문에 장장 일주일을 MySQL 콘솔과 책만 보면서 쿼리를 튜닝하고 인덱스를 설정하는 시간을 가졌다.</p>
<p><a href="https://velog.io/@ohzzi/F12%EC%9D%98-%EB%88%88%EB%AC%BC%EB%82%98%EB%8A%94-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EC%9D%B4%EB%A1%A0%ED%8E%B8">눈물나는 쿼리 튜닝기는 여기로</a></p>
<p>참고로 결국 인덱스 설정하느라 어드민 페이지는 못 만들었다. 스프린트 6에 들어가서 만들 듯 싶다.</p>
<h2 id="스프린트-5가-준-교훈">스프린트 5가 준 교훈</h2>
<p>레벨 3 스프린트 4개를 하면서 단 한번도 시간이 엄청 촉박했다거나, 발표 당일까지 발표 자료도 완성되지 않은 경우는 없었다. 하지만 이번에 처음으로 그런 상황을 맞이했다. 레벨 4의 스프린트는 레벨 3의 스프린트와 다르게 한 달짜리였는데, 요구 사항 볼륨 자체는 비슷하다 보니 이번에도 2주면 되겠지 하고 앞의 2주를 프로젝트에 전혀 투자하지 않은 탓이 크다. 확실히 미리미리 해두는 것의 중요성을 느낀 스프린트인 것 같다. 정말 마지막 한 주는 새벽에도 잠을 포기해가며 데이터베이스 자료를 조사하고 코드를 짰던 것 같다. <del>당연히 건강에도 치명타</del></p>
<p>그래서 우리 팀원들 모두 스프린트 5 회고 시간에 스프린트 6에서는 미리미리 요구 사항 및 우리가 구현하고 싶은 부분들을 계획을 세워서 진행하자고 합의했다. 개인적으로는 미션도 중요하지만 프로젝트에도 적용해보고 싶은 부분들이 많아서 미리미리 프로젝트에 시간을 좀 투자할 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[F12의 눈물나는 쿼리 개선기 - 이론편]]></title>
            <link>https://velog.io/@ohzzi/F12%EC%9D%98-%EB%88%88%EB%AC%BC%EB%82%98%EB%8A%94-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EC%9D%B4%EB%A1%A0%ED%8E%B8</link>
            <guid>https://velog.io/@ohzzi/F12%EC%9D%98-%EB%88%88%EB%AC%BC%EB%82%98%EB%8A%94-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EC%9D%B4%EB%A1%A0%ED%8E%B8</guid>
            <pubDate>Tue, 20 Sep 2022 07:55:27 GMT</pubDate>
            <description><![CDATA[<p>우아한테크코스에서는 팀 프로젝트를 진행중입니다. 그 중 이번 5차 데모 데이의 백엔드 요구 사항으로 다음과 같은 부분이 있었습니다.</p>
<ul>
<li>서비스에서 사용하는 쿼리를 정리하고, 각 쿼리에서 사용하는 인덱스 설정<ul>
<li>서비스에서 사용하는 모든 조회 쿼리와 테이블에 설정한 인덱스 공유</li>
<li>인덱스를 설정할 수 없는 쿼리가 있는 경우, 인덱스를 설정할 수 없는 이유 공유</li>
</ul>
</li>
</ul>
<p>레벨 3 8주동안 열심히 테이블을 설계하고 코드를 작성했지만, 쿼리의 성능과 인덱스에 대한 정리는 하나도 되어 있지 않은 상태였습니다. 무엇보다 어떤 쿼리가 성능이 잘 나오고, 어떤 쿼리가 성능이 잘 나오지 않는지 데이터베이스에 대한 지식이 약하다보니 데이터베이스를 어떻게 튜닝해야 할지도 감이 오지 않았습니다. 인덱스를 설정하라고 하는데, 어떤 컬럼에 인덱스를 적용해야 쿼리가 개선되는지도 판단할 수 없었습니다. 그래서 유의미한 성능 개선을 하기 위해 <a href="https://naver.github.io/ngrinder/">nGrinder</a>로 성능 테스트를 진행해보기로 했습니다.</p>
<p>쿼리 개선 과정에 대한 이해를 돕기 위해 간단한 ERD를 그려보았습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/858d3adf-7dfb-4139-8063-4aec67f63790/image.png" alt=""></p>
<p>실제 저희 팀의 데이터베이스의 스키마를 쿼리 조회에 필요한 컬럼들만 넣어서 그린 ERD입니다. (실제로는 더 많은 컬럼들이 존재합니다.)</p>
<p>실제 애플리케이션에서 위의 모든 테이블에 대해 삽입 및 조회 쿼리를 날리는 부분이 있으며, 일부 테이블들은 수정 및 삭제 쿼리를 실행하는 부분도 있습니다.</p>
<p>또한 성능에 영향을 끼칠만한 부분으로, 저희 팀의 애플리케이션은 무한 스크롤 방식의 페이지네이션(Pagination)을 사용하고 있습니다.</p>
<h2 id="성능-테스트-결과-돌아오지-않는-쿼리">성능 테스트 결과: 돌아오지 않는 쿼리</h2>
<p>우선 쿼리의 비효율성을 체크하기 위해 성능 테스트를 돌려보았습니다. 유의미한 성능 테스트를 위해서는 적당한 크기의 데이터셋이 있어야 했는데요, 저희 팀은 각 테이블마다 약 20만개씩의 더미 데이터를 삽입하고 테스트를 진행했습니다.</p>
<p>테스트 결과는 처참했습니다.</p>
<p>기본적으로 저희 팀의 페이지네이션 방식의 맹점(offset 방식을 사용) 때문에 뒤쪽 페이지를 조회할수록 조회 성능이 떨어질 것이라는 예상은 하고 있었습니다. 하지만 product 테이블에 대한 조회를 기준으로 동시 접속자 수가 조금만 많게 설정해도 앞 쪽 페이지를 조회할 때도 레이턴시가 발생하고 있었으며, 뒤쪽 페이지를 조회할 때는 10초 가량이 걸리는 경우도 있었습니다.</p>
<p>화룡점정은 회원에 대한 조회였습니다. 테이블의 크기인 20만에 가까운 페이지를 조회할 경우, 회원 테이블에 대한 조회는 요청만 가고 아예 응답이 돌아오지를 않았습니다. 커넥션을 뱉어내지를 않아서 테스트를 돌리고 있던 스프링 서버가 뻗어버리고, 데이터베이스도 뻗어버렸습니다. <del>난리도 아니었습니다.</del></p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/c2f65bdd-4100-484b-aeb8-282c304cbcbf/image.png" alt=""></p>
<p><del>제가 보낸 쿼리... 잘 지내고 계신가요...?</del></p>
<h2 id="풀-스캔-또-풀-스캔">풀 스캔, 또 풀 스캔</h2>
<p>문제는 테이블 풀 스캔이 너무 빈번하게 이루어진다는 것이었습니다. 이는 저희 팀의 테이블 구조로부터 기인한 것입니다. F12는 목록을 조회할 때 정렬 조건으로 외부 테이블의 조건을 사용해야 합니다. 그런데 그 외부 테이블의 조건이 심지어 <code>집계 함수</code>를 이용한 조건입니다. 예를 들어볼까요?</p>
<p>문제가 되었던 회원 테이블의 경우, 정렬 조건이 <code>팔로워 수</code>입니다. 하지만 회원 테이블의 각각의 row는 본인을 팔로우 하는 회원이 몇 명인지, 즉 본인의 id를 FK following_id로 하는 following 테이블의 row 수가 몇 개인지 알 방법이 없습니다. 때문에 저희는 이 문제를 서브쿼리를 통해서 해결했습니다. JPA에서는 <code>@Formula</code> 어노테이션이 바로 그것이죠.</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;member&quot;)
@EntityListeners(AuditingEntityListener.class)
@Builder
@Getter
public class Member {

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

    @Column(name = &quot;github_id&quot;, nullable = false)
    private String gitHubId;

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

    @Column(name = &quot;image_url&quot;, length = 65535, nullable = false)
    private String imageUrl;

    @Column(name = &quot;career_level&quot;)
    @Enumerated(EnumType.STRING)
    private CareerLevel careerLevel;

    @Column(name = &quot;job_type&quot;)
    @Enumerated(EnumType.STRING)
    private JobType jobType;

    @Builder.Default
    @Embedded
    private InventoryProducts inventoryProducts = new InventoryProducts();

    @Formula(&quot;(SELECT COUNT(1) FROM following f WHERE f.following_id = id)&quot;)
    private int followerCount;

    ...
}</code></pre>
<p>SQL로 표현하면 다음과 같습니다.</p>
<pre><code class="language-sql">select *, (select count(1) from following f
where f.following_id = m.id) f_count
from member m order by f_count desc, id desc;</code></pre>
<blockquote>
<p><strong>id desc는 왜 걸어주나요?</strong></p>
<p>서브쿼리 결과 f_count는 중복도가 높습니다. 때문에 중복이 되지 않는 확실한 정렬 조건을 달아주기 위해 두 번째 정렬 조건으로 id 역순(최신순)을 명시해주었습니다.</p>
</blockquote>
<p>그리고 이 쿼리는 mysql 콘솔 상으로도 쿼리 실행이 제대로 되지 않았습니다. 왜냐하면 member 테이블의 하나의 row 당 following 테이블에 대한 count를 가져와야 하기 때문입니다.</p>
<p>그런데 의문점이 있었습니다. product 테이블 역시 똑같은 방법으로 정렬을 하고 있었는데, product에 대한 조회 요청은 오래 걸리긴 해도 응답이 오기는 왔다는 것이죠. 그래서 실행 계획을 찾아봤습니다. product에 대한 조회 쿼리는 다음과 같습니다.</p>
<pre><code class="language-sql">select *, (select count(1) from review r where r.product_id = p.id) r_count
from product p order by r_count desc, id desc;</code></pre>
<p>이제 두 쿼리의 실행 계획을 비교해보도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/84abf5a2-4fd7-4d0d-9778-0f987fc0ff74/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/34df4fb9-3f3f-45dd-a110-3bd5172a169c/image.png" alt=""></p>
<p>key, rows, Extra 부분을 유심히 봐주세요. product 조회 쿼리는 메인쿼리에서는 인덱스를 타지 못하지만(order by 조건이 서브쿼리이기 때문에 인덱스 적용이 불가) 서브쿼리에서는 FK를 통해 인덱스를 타게 됩니다. 때문에 기껏해야(?) 20만여 row만 조회하면 되는 것이죠. 하지만 member 조회 쿼리는 그렇지 않습니다. 저희 팀은 following 테이블과 member 테이블의 의존성을 끊어 주는(간접 참조) 형태로 설계했었는데요, 때문에 FK가 걸려 있지 않았습니다. 그래서 인덱스가 자동으로 설정되지 않았고, 서브쿼리조차 인덱스를 타지 못하게 되는 것입니다.</p>
<p>때문에 member 조회 쿼리는 최대 20만 x 20만 = 400억 row를 조회해야 하는 쿼리가 나오는 것입니다. (지금은 20만짜리 테이블이지만 만약 100만 x 100만 테이블이면 1조를 조회해야 합니다!)</p>
<p>그래서 follwing 테이블의 following_id에 인덱스를 걸고 다시 조회해봤습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/1bb3ac37-7346-4e27-a1e1-6ea6bab04037/image.png" alt=""></p>
<p>서브쿼리에서 인덱스를 타게 됩니다. 한결 편안해집니다. 현재 row 수에서 최악의 페이징 효율을 가정하고, <code>limit 191527, 10</code>을 걸고 쿼리를 실행시켜 보겠습니다. </p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/704f93c7-c81e-4d43-bf17-411730487978/image.png" alt=""></p>
<p>기존에 응답도 돌아오지 않던 쿼리가 0.99초 만에 완료되었습니다.</p>
<p>이제 1차적으로 <code>하염없이 응답을 기다리는 상황</code>은 해결하게 되었습니다.</p>
<h2 id="정규화에-집착하지-않기">정규화에 집착하지 않기</h2>
<p>하지만 아직 만족스럽지 않습니다. 20만정도 테이블 크기에도 <code>쿼리 실행만</code> 1초씩 걸리는데, HTTP 요청 왔다갔다하고 애플리케이션 내부 로직 처리하고 하면  여전히 메인쿼리는 테이블 풀 스캔을 해야 합니다. 메인쿼리에서 인덱스를 걸 수는 없을까 하고 생각해봤지만, 결국 페이지네이션에 쓰이는 정렬 조건이 외부 테이블을 집계 함수로 연산해 온 서브쿼리이기 때문에 적절한 인덱스를 태울 수 없겠다는 결론이 나왔습니다.</p>
<p>결국 최대한 데이터베이스 정규화를 한 것이 조회 성능의 부담으로 다가오는 것인데요, 이 상황에서 인덱스를 활용하고 서브쿼리를 제거하여 조회 성능을 개선하려면 어쩔 수 없이 정렬 조건으로 쓰이는 <code>product의 리뷰 개수</code>, <code>product의 평균 평점</code>, <code>member의 follower 수</code>를 각각의 테이블에 추가해줘야 하는 상황입니다. 그리고 이 컬럼들은 삽입, 수정, 삭제 과정에서 다시 계산하여 수정이 들어가야 하죠. 조회 성능은 올라가지만 그 외의 삽입, 수정, 삭제 성능은 필연적으로 저하됩니다.</p>
<p>하지만 저희 서비스가 어디에 더 중점을 두고 있느냐를 생각해봤을 때, 삽입, 수정, 삭제 보다는 조회 쪽이 더 빈번하게 일어나고 있는 서비스라는 생각이 들어 데이터베이스 정규화를 어느 정도 포기하기로 결정했습니다.</p>
<p>정규화를 포기하고 member 테이블에 follower_count가 들어가면 follower_count를 기준으로 정렬을 할 수 있으므로 인덱스를 걸어주도록 하겠습니다. </p>
<blockquote>
<p><strong>여기서 잠깐</strong></p>
<p>MySQL 8.0 버전부터는 역방향 인덱스를 생성할 수 있습니다. 조회 시 항상 follower_count가 많은 순으로 조회 할 예정이기 때문에 (follower_count desc, id desc)로 인덱스를 생성해주도록 하겠습니다. </p>
</blockquote>
<p>그리고 조회를 member 테이블만 가지고 해보도록 하겠습니다. 먼저 실행 계획을 확인합니다.</p>
<pre><code class="language-sql">select * from member order by follower_count desc limit 191527, 100);</code></pre>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/11c2adfe-a954-4a07-9b65-90c1f4d970de/image.png" alt=""></p>
<p>index를 사용할 것이라는 예상과는 다르게 filesort를 사용합니다. 하지만 이는 자연스러운 것으로, 어차피 테이블 풀 스캔에 가깝게 조회해야 하기 때문에 인덱스를 사용하지 않는 것입니다. (인덱스를 사용하면 성능이 더 떨어집니다.) offset 방식의 페이지네이션의 허점이죠. 하지만 실제 성능은 개선됩니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/1ff9b28a-f946-4e6f-a48c-175b5757f76f/image.png" alt=""></p>
<p>서브쿼리를 사용하지 않기 때문에 풀 스캔을 하더라도 성능이 더 개선됩니다. 1초 가량이 걸리던 쿼리가 0.26초까지 개선되는 것을 볼 수 있습니다.</p>
<h2 id="커버링-인덱스를-통해-페이징-개선하기">커버링 인덱스를 통해 페이징 개선하기</h2>
<p>하지만 데이터 수가 더 많아지면 점점 더 성능이 안좋아질 것입니다. offset 페이징의 문제로 현재의 조회 쿼리는 인덱스를 제대로 사용하지 못하고 있습니다. (실행 계획에서 Extra에 Using fileSort가 나오는 것을 볼 수 있습니다.)</p>
<p>그렇다면 no offset 방식을 사용하면 안될까요? 안타깝게도 저희 서비스는 그럴 수 없습니다. 저희 서비스의 정렬 조건은 다음과 같습니다.</p>
<ul>
<li>member: 팔로워 수</li>
<li>product: 리뷰 개수</li>
<li>product: 평점 평균</li>
<li>review: 최신순</li>
</ul>
<p>여기서 review의 경우 id를 활용하면 no offset 페이징이 가능합니다. 하지만 다른 조건들은 조건의 중복으로 인해 불가능합니다. 100번째 row까지 읽었다고 하면, 101번째 row가 어떤 row인지 알 수 있을까요?</p>
<p>예를 들어, following_count 기준으로 정렬을 해서 100번째 row까지 읽었다고 합시다. 그리고 100번째 row의 following_count 값은 10입니다. 하지만 101번째 row의 값이 11이라는 보장이 없습니다. 때문에 where following_count &gt; 11과 같은 방식의 페이징은 사용할 수 없게 됩니다.</p>
<p>하지만 페이징 성능을 좀 더 개선해보고 싶어 이것 저것 찾아보던 도중, <a href="https://jojoldu.tistory.com/">이동욱 님의 블로그</a>를 통해 <strong>커버링 인덱스</strong>의 존재를 알게 되었습니다.</p>
<p>기본적으로 MySQL은 id를 클러스터 인덱스로 가지고 있습니다. 이는 id 값으로 데이터의 실제 위치에 접근할 수 있음을 의미합니다. (클러스터 인덱스가 곧 물리적 정렬을 의미하기 때문입니다.) 반면 넌클러스터 인덱스의 경우 데이터 블록의 위치를 모르고 대신 클러스터 인덱스를 알고 있습니다.</p>
<p>이로 인해 조회 시 두 가지 경우의 수가 발생합니다.</p>
<ol>
<li>select 쿼리에 포함된 컬럼(where, order by, group by 등에 들어가는 모든 컬럼을 포함) 중 인덱스에 포함되지 않은 컬럼이 있는 경우</li>
</ol>
<p>-&gt; 넌클러스터 인덱스에 있는 클러스터 인덱스 값을 찾아 해당 값으로 데이터 row에 직접 접근
2. select 쿼리에 포함된 컬럼이 모두 인덱스에 포함된 경우
-&gt; 넌클러스터 인덱스로 모든 컬럼을 찾을 수 있으므로 데이터 row에 직접 접근하지 않음</p>
<p>커버링 인덱스는 바로 2번의 경우, 즉 쿼리의 모든 컬럼을 인덱스로 조회할 수 있는 경우를 의미합니다. 다시 말하자면 <strong>쿼리를 충족하는데 필요한 모든 컬럼을 가지는 인덱스</strong>를 커버링 인덱스라고 합니다. 기본적으로 인덱스 탐색이 데이터 테이블 접근보다 빠릅니다. 커버링 인덱스를 이용한 페이징은 이를 십분 활용하는 방식이라고 보시면 되겠습니다.</p>
<p>쿼리는 다음과 같습니다.</p>
<pre><code class="language-sql">select * from member m
join (select id from member order by
      follower_count desc, 
      id desc limit 191527, 10)
temp on temp.id = m.id;</code></pre>
<p>join 절 안쪽을 주목해주세요. <code>select id from member order by follower_count desc, id desc limit 191527, 10</code>에서 필요한 컬럼은 id, follower_count 입니다. 그리고 이 두 컬럼은 복합 인덱스(follower_count desc, id_desc)로 묶여있습니다. 이 때 순서와 desc도 중요한데요, order by 에 인덱스를 사용하기 위해서는 순서와 desc가 맞아야 합니다. 그래서 정렬 조건에 사용하기 위해 follower_count desc, id_desc로 인덱스를 묶었습니다.</p>
<p>다시 본론으로 돌아와서, order by에 인덱스를 인덱스 풀 스캔 방식으로 사용할 수 있습니다. 인덱스 풀 스캔으로 정렬을 한 뒤, id 값만 가져오기 때문에 실제 데이터에 접근하지도 않고 id 10개만 가져오게 됩니다. 반면 메인쿼리인 <code>select * from member m</code>은 인덱스 외의 컬럼이 필요하므로 데이터 접근이 필요한데요, join 쪽 서브쿼리의 결과로 id 10개만 가져와 조인하기 때문에 딱 10번의 데이터만 접근해도 됩니다. 또한 가져오는 값이 id, 즉 클러스터 인덱스기 때문에 주소를 검색하는 추가 작업 없이 데이터 테이블에 바로 접근할 수 있습니다.</p>
<p>실제 쿼리를 실행시켜 보겠습니다. 먼저 실행 계획입니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/73261bd2-2e83-49c5-ad82-08d90bc42325/image.png" alt=""></p>
<p>첫 번째 row는 메인쿼리, 두 번째 row는 조인, 세 번째 row는 서브쿼리입니다. 3번째 row를 보시면 <code>idx_follower_count</code>라는 인덱스 키를 사용하여 <code>type = index</code>, <code>Extra = Using index</code> 결과가 나왔습니다. 즉, 서브쿼리에서는 인덱스 풀 스캔을 사용합니다. 조인 + 서브쿼리의 결과로 id 10개가 나올테니 서브쿼리에서 인덱스 풀 스캔을 사용하면 제대로 계획이 되었다고 볼 수 있습니다. 이제 실행 계획이 아닌 실제로 실행을 시켜보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/5c133d66-4145-4df8-8563-a20b10e77ad9/image.png" alt=""></p>
<p>쿼리를 실행하는데 0.06초가 걸렸습니다. 반정규화를 하기 전 0.99초, 커버링 인덱스를 적용하기 전인 0.25초에 비해 압도적으로 쿼리 실행 시간이 빨라진 것을 볼 수 있습니다.</p>
<p>정리하자면 커버링 인덱스를 이용한 페이징 방식은 <code>인덱스만 읽는 것은 빠르다. 테이블을 읽는 것은 느리다. 그러니까 페이징은 인덱스만 읽어서 처리하고 테이블을 최대한 적게 읽자.</code>라는 아이디어입니다. 당연히 no offset 방식을 사용하는 것보다는 느릴 수 밖에 없지만, 저희 팀 같이 no offset 방식을 사용할 수 없는 경우에 충분히 효율적으로 쿼리를 튜닝할 수 있는 방법인 것 같습니다.</p>
<p>애플리케이션 코드는 하나도 건드리지 않고, 데이터베이스 이론을 공부하고 실행 계획, 그리고 실제 실행 시간 측정만 하면서 온갖 경우의 수를 다 체크하고 인덱스를 구상하는 것 만으로도 여간 힘든 것이 아니었습니다. <del>학교 다닐때 데이터베이스 수업 좀 듣지 그랬냐</del> </p>
<p>다음 시간에는 이런 쿼리 개선을 실제 JPA 코드로 구현하여 애플리케이션을 완성하는 과정에 대해서 알아보도록 하겠습니다.</p>
<p><a href="https://velog.io/@ohzzi/F12%EC%9D%98-%EB%88%88%EB%AC%BC%EB%82%98%EB%8A%94-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EC%8B%A4%EC%A0%84%ED%8E%B8">다음 편 보기</a></p>
<h2 id="참고자료">참고자료</h2>
<ul>
<li><a href="https://jojoldu.tistory.com/476">1. 커버링 인덱스 (기본 지식 / WHERE / GROUP BY)</a></li>
<li><a href="https://jojoldu.tistory.com/481?category=761883">2. 커버링 인덱스 (WHERE + ORDER BY / GROUP BY + ORDER BY )</a></li>
<li><a href="https://jojoldu.tistory.com/243">[mysql] 인덱스 정리 및 팁</a></li>
<li><a href="https://tecoble.techcourse.co.kr/post/2021-10-12-covering-index/">커버링 인덱스</a></li>
<li><a href="http://www.kyobobook.co.kr/product/detailViewKor.laf?mallGb=KOR&amp;ejkGb=KOR&amp;barcode=2909101309302">Real MySQL 8.0</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Data JPA] findByXXXId 는 불필요한 join을 유발한다]]></title>
            <link>https://velog.io/@ohzzi/Data-Jpa-findByXXXId-%EB%8A%94-%EB%B6%88%ED%95%84%EC%9A%94%ED%95%9C-join%EC%9D%84-%EC%9C%A0%EB%B0%9C%ED%95%9C%EB%8B%A4</link>
            <guid>https://velog.io/@ohzzi/Data-Jpa-findByXXXId-%EB%8A%94-%EB%B6%88%ED%95%84%EC%9A%94%ED%95%9C-join%EC%9D%84-%EC%9C%A0%EB%B0%9C%ED%95%9C%EB%8B%A4</guid>
            <pubDate>Fri, 16 Sep 2022 00:53:37 GMT</pubDate>
            <description><![CDATA[<p>프로젝트에서 JPA를 사용하던 도중 이상한 부분을 발견했습니다. 엔티티 끼리 연관관계가 있을 때 어떤 곳에서는 <code>findByXXX</code> 형태의 쿼리 메서드를, 어떤 곳에서는 <code>findByXXXId</code> 형태의 쿼리 메서드를 사용하고 있는데요, <code>findByXXX</code>를 사용했을 때는 생각한 대로 쿼리가 나가지만, <code>findByXXXId</code>를 사용했을 때는 join이 걸려서 나가는 것을 확인할 수 있었습니다.</p>
<p>테스트를 통해 확인해보도록 하겠습니다. 실험 대상인 엔티티는 다음과 같습니다.</p>
<pre><code class="language-java">@Entity
@Getter
public class Team {

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

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

    protected Team() {
    }

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

    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        final Team team = (Team) o;
        return Objects.equals(id, team.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}</code></pre>
<pre><code class="language-java">@Entity
@Getter
public class Member {

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

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;team_id&quot;)
    private Team team;

    protected Member() {
    }

    public Member(final String name, final Team team) {
        this.name = name;
        this.team = team;
    }

    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        final Member member = (Member) o;
        return Objects.equals(id, member.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}</code></pre>
<p>Member와 Team은 N:1 연관관계를 맺고 있습니다. JPQL은 엔티티를 직접 사용해도 외래키로 조회하는 것과 같기 때문에 이 상태에서 Member에 대해 <code>findByMember</code>로 조회하든 <code>findByMemberId</code>로 조회하든 똑같이 다음 쿼리가 실행 될 것이라고 생각했습니다.</p>
<p><code>select * from member where member.team_id = ?</code></p>
<p>하지만 결과는 그렇지 않았습니다. 테스트 코드를 통해 <code>findByMember</code>와 <code>findByMemberId</code>를 모두 호출해 보겠습니다.</p>
<pre><code class="language-java">Team team = teamRepository.save(new Team(&quot;팀&quot;));
memberRepository.save(new Member(&quot;회원&quot;, team));

memberRepository.findByTeam(team);
System.out.println(&quot;======================&quot;);
memberRepository.findByTeamId(team.getId());</code></pre>
<p><code>&quot;======================&quot;</code>를 기준으로 쿼리가 어떻게 달라지는지 확인해보도록 하겠습니다.</p>
<pre><code>Hibernate: 
    select
        member0_.id as id1_1_,
        member0_.name as name2_1_,
        member0_.team_id as team_id3_1_ 
    from
        member member0_ 
    where
        member0_.team_id=?
======================
Hibernate: 
    select
        member0_.id as id1_1_,
        member0_.name as name2_1_,
        member0_.team_id as team_id3_1_ 
    from
        member member0_ 
    left outer join
        team team1_ 
            on member0_.team_id=team1_.id 
    where
        team1_.id=?</code></pre><p>위가 <code>findByMember</code>, 아래가 <code>findByMemberId</code> 인데요, <code>findByMember</code>는 예상한 대로 쿼리가 나왔지만 <code>findByMemberId</code>는 <code>Member</code> 테이블의 컬럼들만 조회하는데도 불구하고 left outer join을 걸어서 조회를 해 오는 것을 확인할 수 있었습니다.</p>
<p>혹시 <code>optional = true</code>이기 때문에, null의 가능성 때문일까요? 실제로 <a href="https://stackoverflow.com/questions/43949099/spring-data-jpa-unnecessary-left-join">Stack Overflow</a> 글을 찾아보았는데, 그런 의견을 제시한 답변이 있었습니다. 그래서 이번엔 <code>@ManyToOne(optional = false)</code>와 <code>@Column(nullable = false)</code>를 걸고 테스트해보도록 하겠습니다.</p>
<pre><code>Hibernate: 
    select
        member0_.id as id1_1_,
        member0_.name as name2_1_,
        member0_.team_id as team_id3_1_ 
    from
        member member0_ 
    inner join
        team team1_ 
            on member0_.team_id=team1_.id 
    where
        team1_.id=?</code></pre><p>left outer join이 inner join으로 바뀌었을 뿐(연관관계의 optional이 불가능하므로 null 값이 들어올 상황이 없기 때문에 굳이 outer를 걸 필요가 없기 때문에 inner로 바뀌는 것이죠), 조인은 그대로 걸리는 것을 확인할 수 있었습니다.</p>
<p>대체 왜 조인이 걸리는 것일까요?</p>
<p>조인이 걸린다는 것은 결국 <code>연관된 엔티티의 값을 조회</code>할 필요가 있다는 의미입니다. 그렇다면 조회 조건이 <code>team_id</code>라는 컬럼이 아니라 <code>team.id</code>인 것이라는 의미가 되는 것이지 않을까요?</p>
<p>생각해보면, Data Jpa가 제공하는 쿼리 메서드 기능은 엔티티의 필드를 조건으로 사용합니다. 그런데 저희가 만든 <code>Member</code> 엔티티에는 <code>teamId</code>라는 필드가 존재하지 않습니다. <code>team_id</code>라는 컬럼과 매칭된 <code>team</code> 필드가 있을 뿐이죠.</p>
<p>이렇게 생각하니 <code>findByTeamId</code>가 <code>team_id</code> 컬럼을 조회 조건으로 사용한다는 것 자체가 이상하게 느껴졌습니다. 그래서 필드를 조금 바꿔서 테스트 해보았습니다.</p>
<p><code>Team</code>의 식별자인 <code>id</code> 필드를 <code>teamId</code>로 이름을 바꿔서 다시 테스트 해보겠습니다. <code>teamId</code>로 바꾸니 <code>findByTeamId</code>라는 쿼리 메서드 기능 자체가 동작하지 않았습니다. <code>findByTeamTeamId</code> 라는 이름으로 작성해야 기능이 동작합니다. 이를 통해 우리는 이러한 사실을 알 수 있습니다.</p>
<blockquote>
<p>findByXXXId는 XXX_id라는 외래 키를 가지고 조회하는 것이 아닌, XXX의 식별자를 가지고 조회하는 것</p>
</blockquote>
<p>조회에 조건으로 사용하는 것이 조회 대상인 <code>Member</code>의 필드가 아니라 연관된 엔티티인 <code>Team</code>의 필드이므로, 당연히 <code>team</code> 테이블을 조인으로 가져와서 조건을 걸어줄 수 밖에 없는 것입니다.</p>
<p>왜 이렇게 될까요? 공식 문서를 통해 이유를 찾아볼 수 있었습니다.</p>
<blockquote>
<p>Property expressions can refer only to a direct property of the managed entity, as shown in the preceding example. At query creation time, you already make sure that the parsed property is a property of the managed domain class. However, you can also define constraints by traversing nested properties. Consider the following method signature:</p>
<p><code>List&lt;Person&gt; findByAddressZipCode(ZipCode zipCode);</code></p>
<p>Assume a Person has an Address with a ZipCode. In that case, the method creates the x.address.zipCode property traversal. The resolution algorithm starts by interpreting the entire part (AddressZipCode) as the property and checks the domain class for a property with that name (uncapitalized). If the algorithm succeeds, it uses that property. If not, the algorithm splits up the source at the camel-case parts from the right side into a head and a tail and tries to find the corresponding property — in our example, AddressZip and Code. If the algorithm finds a property with that head, it takes the tail and continues building the tree down from there, splitting the tail up in the way just described. If the first split does not match, the algorithm moves the split point to the left (Address, ZipCode) and continues.</p>
</blockquote>
<p>쿼리 메서드 기능은 메서드 네이밍을 분석할 때 엔티티의 <code>프로퍼티</code>, 즉 <code>필드</code>만 참조할 수 있다고 되어 있습니다. 또한, <code>nested properties</code>, 즉 필드의 필드도 조회에 사용할 수 있다고 되어 있습니다. 공식 문서에서 예시로 들어주는 <code>List&lt;Person&gt; findByAddressZipCode(ZipCode zipCode);</code> 같은 경우, 실제 <code>Person</code> 엔티티에 있는 값은 <code>Address</code>이고, <code>ZipCode</code>는 <code>Address</code> 내부의 필드이기 때문에 <code>AddressZipCode</code>와 같은 조건으로 조회가 가능한 것이죠. 앞서 제가 예로 들었던 <code>findByTeamId</code>의 경우와 비슷하다고 볼 수 있습니다.</p>
<p>그렇다면 id만 사용하면서 원래 의도한 쿼리를 만드는 방법은 없을까요? Spring Data Jpa의 쿼리 메서드 기능을 쓰면서 의도하지 않은 JPQL과 쿼리가 만들어 진 것이지, JPQL 자체는 조회 조건에 연관된 엔티티를 넣는 것과 외래 키를 넣는 것이 똑같이 동작합니다. 즉,</p>
<p><code>select m from Member m where m.team = :team</code>
<code>select m from Member m where m.team.id = :teamId</code></p>
<p>두 JPQL은 같은 JPQL이라는 뜻입니다. 따라서 <code>@Query</code> 메서드를 통해 JPQL을 직접 작성해주면 문제를 해결할 수 있습니다.</p>
<pre><code class="language-java">@Query(&quot;select m from Member m where m.team.id = :teamId&quot;)
List&lt;Member&gt; findByTeamId(final Long teamId);</code></pre>
<pre><code>Hibernate: 
    select
        member0_.id as id1_1_,
        member0_.name as name2_1_,
        member0_.team_id as team_id3_1_ 
    from
        member member0_ 
    where
        member0_.team_id=?</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[Access Token과 Refresh Token을 어디에 저장해야 할까?]]></title>
            <link>https://velog.io/@ohzzi/Access-Token%EA%B3%BC-Refresh-Token%EC%9D%84-%EC%96%B4%EB%94%94%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@ohzzi/Access-Token%EA%B3%BC-Refresh-Token%EC%9D%84-%EC%96%B4%EB%94%94%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Wed, 14 Sep 2022 04:16:20 GMT</pubDate>
            <description><![CDATA[<p>F12 팀 프로젝트는 JWT 토큰을 Access Token으로 하는 인증 인가 서비스를 구현하고 있습니다. 로그인을 하면 백엔드 서버에서 토큰을 만들어 보내주고, 이후 클라이언트에서 보내는 요청의 <code>Authorization</code> 헤더에 토큰을 담아서 보내면 서버에서 토큰을 디코딩하여 로그인한 회원을 확인하는 방식입니다.</p>
<p>보통 Access Token은 서버 쪽에서 따로 로그아웃을 시켜줄 수 없다 보니(토큰 블랙리스트를 만드는 방식으로 가능하긴 합니다.) 보안 상의 이유로 만료 기간을 짧게 가져가는 편인데요, 이로 인해 시간이 조금만 지나면 로그인을 새로 해야 하는 불편함이 있습니다. 이를 보완하기 위해 Refresh Token을 함께 사용하지만, 레벨 3 기간동안 F12 프로젝트는 Refresh Token을 도입하지 않았습니다.</p>
<p>그러나 실제 사용을 해보니 로그인을 자주 해야 하는 불편함은 상당히 컸습니다. 로그인이 유지되고 있는 줄 알고 리뷰를 작성하거나 내 페이지를 들어가려고 하는데 <code>로그인이 필요합니다.</code> 메시지가 뜨면서 거부당하고, 결국 로그인 버튼을 눌러서 로그인 한 뒤 다시 가려던 페이지를 찾아 들어가는 것은 여간 귀찮은 일이 아니죠. 그래서 레벨 4에서는 Refresh Token을 도입하기로 결정했습니다.</p>
<p>Access Token과 Refresh Token을 함께 사용하는 이유에 대해서는 <a href="https://stackoverflow.com/questions/3487991/why-does-oauth-v2-have-both-access-and-refresh-tokens/12885823">잘 정리된 Stack Overflow 문서</a>나 <a href="https://hudi.blog/refresh-token/">우아한테크코스 크루의 블로그 글</a>이 있으니 참고해 보셔도 좋을 것 같습니다.</p>
<h2 id="본론-access-token과-refresh-token을-어디에-저장해야-할까">본론. Access Token과 Refresh Token을 어디에 저장해야 할까?</h2>
<p>이제 또다른 문제가 있습니다. Access Token과 Refresh Token을 어디에 저장해야 할까요?</p>
<h3 id="기호-1번-로컬-스토리지-or-세션-스토리지">기호 1번. 로컬 스토리지 or 세션 스토리지</h3>
<p>로컬 스토리지와 세션 스토리지의 차이는 데이터의 영구성 정도이기 때문에 함께 알아보도록 하겠습니다. 기존에 Access Token만 사용할 때는 브라우저의 세션 스토리지에 담고 있었습니다. 로그인 성공 시 받아온 토큰을 세션 스토리지에 저장한 뒤, 요청을 보낼 때 자바스크립트로 꺼내서 보내는 방식입니다.</p>
<p>로컬 스토리지나 세션 스토리지에 저장하는 방식은 어떤 문제가 있을까요? <code>자바스크립트로 토큰 값을 꺼내서 보내는 방식</code>이라는 점에서 그 답을 알 수 있습니다. 로컬 스토리지나 세션 스토리지에 저장하는 방식은 <strong>XSS(Cross Site Scripting)</strong> 공격에 취약합니다. React를 사용하기 때문에 XSS 공격을 막아준다고는 하지만, 이것이 항상 통하는 것은 아니기 때문에 스토리지에 저장하는 것은 안전하지 않다고 생각했습니다.</p>
<h3 id="기호-2번-쿠키http-only">기호 2번. 쿠키(HTTP Only)</h3>
<p><img src="https://velog.velcdn.com/images/ohzzi/post/507e40d0-8349-43ac-b715-2cf5c6ab85de/image.png" alt=""></p>
<p><del>배고파도 식사가 없고 목말라도 음료가 없는 이 쿠키는 아닙니다.</del></p>
<p>쿠키에 담을 수도 있습니다. 단, 쿠키 역시 자바스크립트로 접근이 가능하므로 <code>HTTP Only</code> 옵션을 걸어주어야 합니다. 또한 HTTPS가 적용되지 않은 이미지 등으로 인해 쿠키를 탈취당할 수 있으므로 <code>secure</code> 옵션도 걸어주어야 합니다. 이렇게 해주면 쿠키를 탈취당할 위험도 방지할 수 있고, 자바스크립트로 쿠키 값을 취득하는 것도 막을 수 있습니다. HTTP 통신 자체를 하이재킹 당하더라도, HTTPS로 암호화가 되어 있기 때문에 쿠키 값을 알아낼 수는 없습니다.</p>
<p>그렇다면 쿠키는 무한정 안전할까요? 그렇지 않습니다. 쿠키에 토큰을 담으면 <strong>CSRF(Cross-Site Request Forgery)</strong> 공격에 취약합니다. XSS 공격을 당하는 것과는 상황이 조금 다른데요, XSS 공격을 통해서는 <code>토큰 값</code> 자체를 가져올 수 있지만 CSRF 공격을 통해서는 <code>로그인 된 상태로 특정 위험한 동작을 하게 만든다</code>고 생각하시면 될 것 같습니다. 즉, CSRF 공격으로 쿠키에 저장되어 있는 토큰 값 자체를 가져오는 것은 아닙니다. (물론 애플리케이션 설계와 공격 형태에 따라 토큰 값도 가져올 수는 있겠죠.)</p>
<p>그래서 Refresh Token은 쿠키에 저장해도 된다는 판단을 내렸습니다. 왜냐면 Refresh Token으로 Access Token을 재발급 받는 요청 외에 인증 인가가 필요한 작업들에 Refresh Token으로는 접근할 수 없기 때문입니다. 물론 Access Token 재발급 요청은 할 수 있기 때문에 이에 대한 적절한 방어는 필수입니다.</p>
<p>쿠키에 저장하면 값 자체의 탈취를 할 수 없다고 했지만, 만에 하나 어떠한 방법으로든 Refresh Token 탈취가 된다는 가정 하에, 위험성을 최대한 줄이기 위해 RTR(Refresh Token Rotation)이라는 방식을 도입하기도 합니다. 이 방법은 Refresh Token을 통해 Access Token을 재발급 할 때 Refresh Token을 새 것으로 교체해서 단 한번만 사용할 수 있도록 하는 방식입니다. (여기에 이미 사용된 Refresh Token으로 요청이 들어오면 모든 Refresh Token을 폐기하는 보안 조치도 추가로 넣어준다고 하네요.) 이렇게 하더라도 사용하지 않은 Refresh Token을 탈취하면 한 번은 Access Token을 발급받을 수 있지만, 탈취된 Refresh Token이 무한정 사용되는 것은 막을 수 있습니다.</p>
<p>하지만 Access Token마저 쿠키에 담아버리면 CSRF 공격을 통해 인증 인가 과정으로 보호된 동작을 실행해버릴 수 있으니 Refresh Token과 Access Token을 모두 한 번에 쿠키에 담으면 안되겠죠?</p>
<h3 id="그럼-access-token은-어디에-저장해야-하나요">그럼 Access Token은 어디에 저장해야 하나요?</h3>
<p>F12는 이 문제를 해결하기 위해 Access Token을 <code>자바스크립트 private 변수로 저장</code>하는 방법을 선택했습니다. private 변수로 저장된 Access Token은 XSS 공격으로 탈취할 수 없고, 당연히 CSRF 공격을 당할 가능성도 없습니다.</p>
<p>그런데 페이지를 이동할 때 마다 토큰이 날아가지 않냐고요? 하지만 우리는 <code>React</code>를 사용하고 있고, 리액트는 SPA(Single Page Application)이므로, 페이지를 이동하는 것처럼 보여도 페이지가 실제로 이동하는 것이 아니기 때문에 private 변수가 그대로 유지됩니다. 단, 새로고침을 하면 변수가 날아갑니다. 때문에 이 경우에 추가로 Refresh Token만 가지고 Access Token을 발급받는 API를 만들어주어야 합니다.</p>
<h2 id="정리">정리</h2>
<p>이러한 내용들을 바탕으로 F12 팀의 인증 인가 정책을 고민해보았고, 결론적으로는 다음과 같은 방식을 사용하기로 결정했습니다.</p>
<ul>
<li>로그인 시 유효 기간이 매우 짧은 Access Token과 유효 기간이 긴 Refresh Token을 함께 발급.</li>
<li>Refresh Token은 서버에 저장하여 관리</li>
<li>클라이언트는 Access Token을 private 변수로 저장</li>
<li>Refresh Token은 HTTP Only 쿠키에 저장</li>
<li>Access Token이 만료되면 Refresh Token을 통해 새 Access Token과 새 Refresh Token을 재발급</li>
<li>새로고침으로 Access Token 값이 없어지면 Refresh Token을 통해 새 Access Token과 새 Refresh Token을 발급</li>
</ul>
<p>완벽한 보안은 아니겠지만, 이 과정을 통해 가능한 공격의 수를 최대한 줄여서 좀 더 안전한 애플리케이션을 만들 수 있을 것 같습니다.</p>
<blockquote>
<p><strong>참고 자료</strong></p>
<p><a href="https://pomo0703.tistory.com/208#recentComments">[프로젝트] Refresh Token 적용하기</a>
<a href="https://tecoble.techcourse.co.kr/post/2021-10-20-refresh-token/">refresh token 도입기</a>
<a href="https://hudi.blog/refresh-token/">Access Token의 문제점과 Refresh Token</a></p>
</blockquote>
]]></description>
        </item>
    </channel>
</rss>