Jekyll2024-01-28T07:50:29+00:00https://realrain.net/feed.xmlrealrains안티 바이크쉐딩2024-01-27T00:00:00+00:002024-01-27T00:00:00+00:00https://realrain.net/posts/anti-bikeshedding<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="80%" src="/assets/image/bikeshed.jpeg" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;"></figcaption>
</figure>
<p><a href="https://en.wikipedia.org/wiki/Law_of_triviality">파킨슨의 사소함의 법칙 (Parkinson’s law of triviality)</a> 으로도 잘 알려져 있는 바이크쉐딩 (Bikeshedding)<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup> 은 조직이 중요한 일은 방치한 채 사소하고 작은 일에 불균형적으로 많은 시간을 할애하는 경향이 있음을 설명하는 단어입니다. 소프트웨어 프로젝트를 진행하면서 사소하거나 전체 프로젝트의 목표 달성에 비교적 중요하지 않은 세부 사항에 지나치게 시간을 쏟게 되었던 경험이 한 번쯤은 있을 것입니다.</p>
<p>프로젝트에 장기적으로 영향을 미치는 아키텍처나, 프레임워크 선택과 같은 중요한 부분들은 관습적인 부분이나 이전에 경험했던 프로젝트의 사례를 그대로 답습하거나 모호한 부분으로 남겨두고, 대신 변수나 클래스 네이밍, 코드 스타일, 패키지 구성, 테스트 커버리지 비율, 버저닝 컨벤션 등 비교적 덜 중요한 문제를 고민하는데 많은 시간을 보내게 되는 경우가 발생하곤 합니다. 그렇다면 우리는 어떻게 중요한 문제와 사소한 문제를 잘 구분하고, 이러한 함정에 잘 빠지지 않을 수 있을까요?</p>
<h2 id="바이크쉐딩-피하기">바이크쉐딩 피하기</h2>
<p>많은 경우 바이크쉐딩은 조직 내에서 아래 조건들이 갖추어 질 때 발생합니다.<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>
<ul>
<li><em>어떤 안건의 내용이 상대적으로 쉽고, 대다수가 이해하고 있음</em></li>
<li><em>어떤 안건의 결정 방향에 대해 의견을 제시하고자 하는 사람들이 많음</em></li>
<li><em>어떤 안건에서 내릴 수 있는 결정 옵션간의 이득 차이가 크지 않음</em></li>
<li><em>어떤 안건에 대한 최종 결정을 내릴 수 있는 권한을 가진 사람이 없거나, 결론을 제시하지 않는 경우</em></li>
</ul>
<p>바이크쉐딩은 문제가 되는 안건이 최종적으로 결론나거나 사라지기 전까지는 쉽게 해결되지 않습니다. 이러한 상황에서의 가장 쉬운 해결책은 결정을 내릴수 있는 <strong>독재자</strong>를 두는 것입니다. 그렇다면 누가 독재자가 되어야 할까요? 구성원을 리드할 수 있는 카리스마 있는 리더가 있다면 다행이지만, 매니저가 일일히 결정 사항에 개입하기 어려운 소규모 프로젝트거나 조직 구성이나 문화적인 영향 등으로 결정의 총대를 맬 사람이 나타나기 어려운 경우가 많습니다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="80%" src="/assets/image/guido-portrait.jpg" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">파이썬의 자비로운 종신 독재자(BDFL) 였던 귀도 반 로섬</figcaption>
</figure>
<p>그러한 상황에서는 아래 세 가지 스텝으로 문제를 해결해 볼 수 있을 것입니다.</p>
<ol>
<li><em>문제의 비용과 중요성 파악하기</em></li>
<li><em>중요하지 않은 문제는 중요한 문제와 같이 논의하지 않기</em></li>
<li><em>도구와 휴리스틱의 도움을 빌리기</em></li>
</ol>
<h3 id="문제의-비용과-중요도-파악하기">문제의 비용과 중요도 파악하기</h3>
<p>바이크쉐딩을 유발하는 문제들이라도 해결함으로써 프로젝트에 도움이 되지 않는 것은 아닙니다. 일관적인 코드스타일이나 규칙을 전체 프로젝트에 유지하는 것은 분명 코드의 유지보수에도 이득이 됩니다. 다만 어떤 스타일과 규칙을 적용할지에 대한 세부 사항에 대해 지나치게 많은 시간을 쏟는 것이 문제가 될 뿐이죠.</p>
<p>결국 결론을 내려야 하거나 해결해야 하는 하는 문제가 쏟는 비용대비 프로젝트의 성공에 얼마만큼 기여하는지 가늠하는 것이 필요합니다. 프로젝트의 성공이란 특정 시점에서 측정하기 어렵고 그 매트릭도 다양하므로 명확한 상관관계는 알 수 없어도 괜찮습니다. 단지 비용에 따라 기여도가 어떤 추이로 변화하는지 살펴볼 수 있으면 됩니다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="80%" src="/assets/image/cost-per-contrib.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">코드 스타일은 중요합니다. 그러나 중괄호 스타일을 K&R 로 할지, BSD 로 할지 논의하는게 큰 의미가 있을까요?</figcaption>
</figure>
<p>개별 문제에 대해 대략적인 비용-효율을 가늠해 봤다면 이를 아이젠하워 매트릭스<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup>를 벤치마킹해서 사분면에 놓아 볼 수 있을 것입니다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="90%" src="/assets/image/eisenhower-matrix.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;"></figcaption>
</figure>
<p>먼저 1사분면에 놓아지는 <strong>고비용-고기여</strong> 항목들은 논의나 결정에 필요한 비용이 크지만 그만큼 프로젝트의 성공에 중요한 것들입니다. 소프트웨어 아키텍처를 결정하거나, 핵심 기능을 구현하기 위한 프레임워크나 라이브러리를 결정하는 문제이지요. 고비용-고기여 항목들의 특징은 대부분 나중에 결정을 되바꾸기 어려운 항목이라는 것입니다. 데이터베이스와 같은 핵심 인프라스트럭처는 아무리 소프트웨어를 유연하게 설계해도 나중에 바꾸려면 많은 비용이 수반됩니다. 그래서 가장 중요한 문제들입니다.</p>
<p>다음으로 2사분면에 놓아지는 <strong>고비용-저기여</strong> 항목들은 비용이 크지만 사실 프로젝트의 성공에 크게 기여하지 않는 문제들입니다. 도메인 주도 설계에서 말하는 지원/일반 서브도메인에 해당하는 영역이라고 볼 수도 있습니다. 기여도가 작으니 아무리 획기적으로 개선해도 전체 프로젝트에 개선에 미치는 영향은 미미합니다.<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup> 일년에 한두번 호출되는 아주 느린 운영용 API 가 있는데 이를 각종 기술을 동원해 수초내로 동작하도록 개선한다고 해서 비즈니스에 큰 도움이 되진 않을 것입니다.</p>
<p>그 다음으로는 3사분면에 놓아지는 <strong>저비용-저기여</strong> 항목들입니다. 이 항목들은 해결하는데 그다지 많은 비용이 들지는 않지만 그렇다고 결정 방향에 따라서 큰 차이가 없는 경우가 많습니다. 목표 테스트 커버리지를 70% 에서 80% 로 늘린다고 해서 프로젝트가 10%p 만큼 개선될까요? 일정 수준 이상의 테스트 커버리지 목표는 존재하는 것 만으로도 프로젝트의 안정성에 많은 기여를 할 수 있지만 세부적인 수준이 어떤지 중요한 것은 아닙니다.</p>
<p>마지막으로 4사분면에 놓아지는 <strong>저비용-고기여</strong> 항목들입니다. 이곳에 존재하는 항목들은 단순히 어떤 방식이던 적용하는 것만으로도 프로젝트에 많은 잇점을 가져다줍니다. 코드 스타일 규칙 정하기, 브랜치 전략 정하기, CI 과정에 테스트 커버리지 게이트 도입하기등이 이에 해당합니다.</p>
<p>위 사분면에서 바이크쉐딩은 <strong>저비용-고기여 항목들의 세부사항인 저비용-저기여 항목에 집착</strong>하면서 리소스를 낭비하는 것들로 정의할 수 있습니다. 중요한 것은 저비용 항목들은 말 그대로 결정을 바꾸는데 드는 비용도 적다는 것입니다. 어떤 결정이 중요한지 판별하는데 비용을 들이는 것보다 일단 어떤 결정을 내리고 유연하게 결정을 변경하는 자세가 필요합니다.</p>
<h3 id="중요하지-않은-문제는-중요한-문제와-같이-논의하지-않기">중요하지 않은 문제는 중요한 문제와 같이 논의하지 않기</h3>
<p>사실 거창하게 사분면에 문제들을 분류하지 않아도 대다수의 사람들은 직관적으로 어떤 문제가 중요하고 그렇지 않은지 알고 있습니다. 다만 의식적으로 그 중요도에 따라 리소스를 분배하기가 어려운 것이 문제입니다. 조직 환경에서 우리는 참여의 표시로 자신의 의견을 표현해야 한다는 압력을 받습니다. 이때 잘 이해하고 있는 간단한 문제에 대해 이야기하는 것이 훨신 쉽기 때문에 더 중요한 문제가 있더라도 비교적 간단한 문제에 대해 이야기할 가능성이 높습니다. 이러한 상황에서 10 만큼 중요한 문제와 1 만큼 중요한 문제를 같은 자리에서 논의할 때 정확히 10:1의 시간을 분배할 수 있을까요?</p>
<p>따라서 명확하게 논의의 컨텍스트를 분리하는것도 바이크쉐딩을 피하는데 도움이 될 수 있습니다. 회의 자리라면 단일 안건 항목으로 회의를 진행하는 것이 좋습니다. 그것이 어렵다면 최소한 중요도와 비용이 비슷한 문제들끼리 비슷하게 묶는것이 좋습니다. 특히 저비용 문제들이라면 따로 회의를 할 것도 없이 간단하게 팀 메신저안에서 제안-채택하는 것 만으로도 충분할 수 있습니다. 무엇보다 중요한 것은 회의 참석자 수를 최소한의 핵심 관계자만으로 제한하는 것입니다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="90%" src="/assets/image/사공이많으면.jpeg" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">사공이 많으면 배가 진짜 산으로 간다</figcaption>
</figure>
<p>만약 풀 리퀘스트를 요청하는 상황이라면 마찬가지로 하나의 풀 리퀘스트 안에 여러 변경사항을 섞지 않는 것이 좋습니다. 특히 바이크쉐딩이 발생할만한 사소한 변경 사항이 최소한으로 포함되도록 하나의 풀 리퀘스트에서 변경하는 코드 라인의 수를 최대한 작게 유지하는것이 중요합니다.</p>
<h3 id="도구와-휴리스틱의-도움을-빌리기">도구와 휴리스틱의 도움을 빌리기</h3>
<p>바이크쉐딩을 피하기 위해서는 개개인이 바이크쉐딩의 존재를 인지하고 있는것이 가장 중요하지만 모든 상황에서 이를 의식하고 있는 것은 어렵습니다. 특히 풀 리퀘스트를 적게 유지하자, 코드 스타일은 어떻게 하자와 같은 구두로 합의한 모호한 규칙들은 언제라도 깨지기 쉽습니다.</p>
<p>따라서 도구로 해결할 수 있는 부분들은 최대한 그 힘을 빌려 인지적 비용의 낭비를 줄이는 것이 중요합니다. 코드 스타일은 언어별 공식 가이드나 구글, 에어비엔비와 같은 규모 있는 기업에서 배포한 스타일 가이드를 참고하되 통합 개발환경에서 스타일 위반 여부를 자동으로 교정해주는 플러그인을 활용하거나, 빌드 과정에서 위반 여부를 자동으로 검사하도록 강제하는것이 좋습니다. 만약 풀 리퀘스트의 규모를 제한하고 싶다면 깃허브 봇등을 활용해 풀 리퀘스트 이벤트를 받아서 총 변경 라인수가 400 라인이 넘으면 요청이 승인되지 않도록 제한해야 하고, 테스트 작성을 독려하고 싶다면 테스트 커버리지를 70% 이상 만족시키지 못하면 병합을 금지하세요.</p>
<p>여기서 중요한 것은 어떤 코드 스타일을 적용할 것인지, 라인 수 제한을 얼마로 할 것인지, 테스트 커버리지 제한이 구체적으로 얼마인지 당장은 중요하지 않다는 것입니다. 일단 적용하고, 불편하다면 그 시점에 개선하세요. 변경의 비용은 적습니다. 그러나 아마 미리 걱정한 것과 달리 불편함이 발생하는 상황은 많지 않을 것입니다.</p>
<p>도구의 힘을 빌리기 어려운 결정의 경우 일종의 휴리스틱을 적용하면 도움이 됩니다. <strong>지금 당장 결정하기 어려우면 가장 간단한 방법을 일단 채택</strong>하는 것이죠. 프로젝트의 브랜치 전략을 결정해야 한다고 생각해봅시다. git-flow, github-flow, gitlab-flow, trunk-based 등 각 상황에 따라 장단점이 있는 브랜치 전략이 있고 저울질 해볼 수 있지만 특정한 시점에서는 충분한 정보가 없을 수 있습니다. 프로젝트 시작 시점에서 규모가 커질지 작아질지는 알 수 없습니다. 반드시 두 세개의 배포 페이즈에서 다중 릴리즈를 관리하는 것이 필요할지 알 수도 없고요.</p>
<p>많은 경우 이전에 경험했던 브랜치 전략이 익숙하다는 이유로 비슷한 전략을 선택할 수 있습니다. 혹은 기존에 사용했던 브랜치 전략이 마음에 안 들어서 필요 이상으로 복잡한 전략을 채택하려 할 수 있고요. 하지만 가장 최적의 전략은 프로젝트를 어느 정도 진행해보기 전까지 알기 어려운 경우가 대부분입니다. 그렇다고 아무 규칙없이 개발을 진행할 수는 없죠. 따라서 가장 간단하게 주 브랜치와 기능 브랜치 정도만 사용하면서 시작하는 것이 좋습니다. 반드시 처음부터 TBD 의 철학을 모두 따를 필요도 없습니다. 피처 플래그 관리와 즉각적인 배포가 힘든 프로젝트라면 그 시점에 브랜치 전략을 수정하면 됩니다.</p>
<hr />
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:1" role="doc-endnote">
<p>Parkinson, C. N. 이 사소함의 법칙을 설명할 때 사람들이 원자력 발전소의 건설과 같은 큰 문제 대신, 자전거 보관소(bike shed) 색상을 무엇으로 할지 등의 사소한 문제에 집중하는 것에 은유한 것에 유래됨 <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>https://thedecisionlab.com/biases/bikeshedding <a href="#fnref:2" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:3" role="doc-endnote">
<p><a href="https://en.wikipedia.org/wiki/Time_management#The_Eisenhower_Method">The Eisenhower Method</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:4" role="doc-endnote">
<p><a href="https://ko.wikipedia.org/wiki/%EC%95%94%EB%8B%AC%EC%9D%98_%EB%B2%95%EC%B9%99">암달의 법칙 (Amdahl’s law)</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
</ol>
</div>테스트 코드에서 ktlint 규칙 비활성화 하기2024-01-13T00:00:00+00:002024-01-13T00:00:00+00:00https://realrain.net/posts/disable-ktlint-rule-in-test<p><a href="https://pinterest.github.io/ktlint/latest">ktlint</a> 는 코틀린 코드에 대한 정적 분석 도구입니다. 코드 스타일 가이드라인을 프로젝트 코드 베이스에 대해 자동으로 적용하여 일관성 있는 코드 스타일을 유지하는 데 도움을 줄 수 있습니다.</p>
<p>진행하고 있는 프로젝트에서는 gradle 플러그인 <a href="https://github.com/JLLeitschuh/ktlint-gradle">ktlint-gradle</a> 을 적용해 빌드 시 코드 스타일 가이드를 위반한 사례가 있는지 자동으로 검증하고 있는데요, 이 경우 프로젝트 루트의 <code class="language-plaintext highlighter-rouge">.editorconfig</code> 파일로 적용할 규칙을 다음과 같이 커스터마이징 할 수 있습니다.</p>
<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="py">root</span> <span class="p">=</span> <span class="kc">true</span>
<span class="nn">[*]</span>
<span class="py">end_of_line</span> <span class="p">=</span> <span class="err">lf</span>
<span class="py">insert_final_newline</span> <span class="p">=</span> <span class="kc">true</span>
<span class="py">trim_trailing_whitespace</span> <span class="p">=</span> <span class="kc">true</span>
<span class="nn">[*.{kt,kts}]</span>
<span class="py">lj_kotlin_code_style_defaults</span> <span class="p">=</span> <span class="err">KOTLIN_OFFICIAL</span>
</code></pre></div></div>
<p>다만 테스트 코드를 작성할 때에는 프로덕션 코드와 다른 규칙이 적용되어야 할 필요가 있을 수 있습니다. 대표적으로 코틀린에서 테스트 메서드를 작성할 때 메서드 이름을 백틱(<code class="language-plaintext highlighter-rouge">`</code>)으로 감싸 표현하는 사례가 이에 해당합니다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/naming-test-function.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;"></figcaption>
</figure>
<p>개인적으로는 테스트 메서드는 한글로 표현하는 것이 요구사항을 잘 검증하고 있는지 파악하는 데 용이하다고 생각하는데요, 자바의 경우 메서드 명에 한글을 사용할 수는 있지만 띄어쓰기를 쓸 수 없기 때문에 <code class="language-plaintext highlighter-rouge">1+1_의_결과는_2_이다</code> 와 같이 언더바를 포함하여 쓰거나 테스트 메서드명 자체는 영어로 짓고 <code class="language-plaintext highlighter-rouge">@DisplayName</code> 을 사용해 원하는 테스트 케이스 명을 짓곤 했습니다.</p>
<p>일반적인 메서드 명과 달리 테스트 메서드는 서술적인 면이 더 부각되기 때문에 영어로 테스트 명을 짓는 것이 쉬운 일이 아니거니와 영어로 테스트 메서드명을 짓는다고 해도 표준 스타일 가이드 대로 camel case 로 쓰게되면 긴 문장의 경우 가독성이 현저히 떨어지게 됩니다. 그렇다고 영문 메서드 명을 대강 짓고 <code class="language-plaintext highlighter-rouge">@DisplayName</code> 을 사용하는 것도 조금 불편한 면이 있는것도 사실입니다.</p>
<p>다행히 코틀린의 경우는 앞서 말한 대로 백틱(<code class="language-plaintext highlighter-rouge">`</code>)으로 감싸 일반적인 문장의 형태로 메서드 명을 짓는 것이 가능한데요 하지만 이 경우 ktlint 의 표준 스타일 규칙 중 하나인 <a href="https://pinterest.github.io/ktlint/latest/rules/standard/#function-naming">function-naming</a> 을 위반하게 됩니다. 다행히 ktlint 에서는 테스트 코드에 한해 일부 규칙을 적용하지 않고 있는데요 <code class="language-plaintext highlighter-rouge">kotest</code>, <code class="language-plaintext highlighter-rouge">junit</code> 등 테스트 관련 패키지가 임포트 된 파일의 경우 이를 무시해 줍니다.</p>
<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 허용</span>
<span class="k">fun</span> <span class="nf">foo</span><span class="p">()</span> <span class="p">{}</span>
<span class="k">fun</span> <span class="nf">fooBar</span><span class="p">()</span> <span class="p">{}</span>
<span class="k">fun</span> <span class="nf">`fun`</span> <span class="p">{}</span>
<span class="c1">// 허용되지 않음</span>
<span class="k">fun</span> <span class="nf">Foo</span><span class="p">()</span> <span class="p">{}</span>
<span class="k">fun</span> <span class="nf">Foo_Bar</span><span class="p">()</span> <span class="p">{}</span>
<span class="k">fun</span> <span class="nf">`Some</span> <span class="nf">name`</span><span class="p">()</span> <span class="p">{}</span>
<span class="k">fun</span> <span class="nf">do_something</span><span class="p">()</span> <span class="p">{}</span>
<span class="c1">// 테스트 코드에서만 허용</span>
<span class="nd">@Test</span>
<span class="k">fun</span> <span class="nf">`Some</span> <span class="nf">name`</span><span class="p">()</span> <span class="p">{}</span>
<span class="nd">@Test</span>
<span class="k">fun</span> <span class="nf">do_something</span><span class="p">()</span> <span class="p">{}</span>
</code></pre></div></div>
<p>그러나 테스트 코드를 작성하다 보면 테스트 작성에 필요한 픽스처, 재사용을 위한 테스트 스텝 등 헬퍼 코드가 필요할 때가 있는데요 이 경우 ktlint 의 테스트 코드 예외가 적용되지 않게 됩니다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/test-fixture-function.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;"></figcaption>
</figure>
<p>이 경우 다음과 같이 테스트 코드가 포함된 디렉터리에 대해 ktlint 룰을 오버라이딩 할 수 있습니다.</p>
<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[**/src/test/**.{kt,kts}]</span>
<span class="py">ktlint_standard_function-naming</span> <span class="p">=</span> <span class="err">disabled</span>
</code></pre></div></div>
<h2 id="references">References</h2>
<ul>
<li><a href="https://pinterest.github.io/ktlint/latest/rules/standard">ktlint - Sandard rules</a></li>
<li><a href="https://pinterest.github.io/ktlint/latest/rules/configuration-ktlint/#overriding-editorconfig-properties-for-specific-directories">ktlint - Overriding properties for specific directories</a></li>
</ul>ktlint 는 코틀린 코드에 대한 정적 분석 도구입니다. 코드 스타일 가이드라인을 프로젝트 코드 베이스에 대해 자동으로 적용하여 일관성 있는 코드 스타일을 유지하는 데 도움을 줄 수 있습니다.외부 API 클라이언트 테스트하기2023-12-30T00:00:00+00:002023-12-30T00:00:00+00:00https://realrain.net/posts/testing-external-api-client<p>마이크로서비스 아키텍처 (MSA) 가 일반화된 요즘의 개발 환경에서는 어플리케이션 외부의 다양한 서비스와 여러 방식으로 통신하게 됩니다. 기능 구현을 위해서 한 어플리케이션 내에서 아무리 적어도 서너개 이상의 외부 서비스와 연동하게 되곤 하는데, 내 서비스의 API 를 노출할 때와 다른 서비스의 API 를 사용할 때 모두 HTTP 프로토콜을 사용하는 것이 가장 일반적이라 사용할 API 제공자가 정의한 스펙에 맞춰 해당 HTTP API 를 호출하는 코드를 작성하는 상황이 상당히 자주 일어납니다.</p>
<p>이와 관련해 언어나 프레임워크 별로 어떤 HTTP 클라이언트를 사용할지, 어떤 방식으로 예외를 처리할지, 다수의 API 를 호출해야 하는 상황에서 성능은 어떻게 개선할지 등 여러 중요한 엔지니어링적 고민사항들이 많이 생겨나곤 합니다. 이번 포스트에서는 그 중에서도 외부 API 클라이언트의 테스트에 대해 이야기해보고, 스프링 프레임워크 기반의 개발 환경에서의 테스트 사례도 공유해드리도록 하겠습니다.</p>
<h2 id="외부-api-클라이언트-테스트가-필요한가요">외부 API 클라이언트 테스트가 필요한가요?</h2>
<p>외부 API 클라이언트에 대한 테스트를 작성한다고 했을 때, 그 필요성 자체에 의문을 품는 분들이 종종 있습니다. 외부 API 는 우리 어플리케이션에서 제어 불가능한 외부 프로세스 자원이기 때문에 테스팅을 하는 것이 의미가 없다고요. 우리 어플리케이션의 정상 동작이 결국 실 환경에서 외부 API 의 상태와 기능 변경에 영향을 받기 때문에 개발이나 스테이징 환경에서의 통합 테스트 정도만 의미가 있다고 말하기도 합니다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/diagram_exteranl_api_test.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;"></figcaption>
</figure>
<p>하지만 이 말은 절반은 맞고 절반은 틀렸습니다. 외부 API 는 분명히 우리 어플리케이션에서 제어 불가능한 외부 자원이기 때문에 우리 어플리케이션에서 아무리 잘 테스트 하더라도 항상 정상임을 보증할 수 없습니다. 그러나 <strong>테스트에서 확인하고자 하는 것은 언제까지나 우리가 작성한 코드이지 외부의 서비스가 아닙니다.</strong></p>
<p>외부 HTTP API 연동 클라이언트를 작성해 본 경험이 있는 분들이라면, 꽤나 많이 직렬화 (Serialization) 와 역직렬화 (Deserialization) 관련된 오류를 경험해 본 적이 있으실 겁니다. DTO 클래스의 필드명을 잘못 입력해서 요청/응답의 JSON 매핑에서 예외가 발생할 수도 있고, 더 심한 경우 매퍼의 설정에 따라 누락된 DTO 필드가 기본값으로 초기화 되어서 버그를 눈치채지 못하는 경우도 있습니다.</p>
<p>따라서 우리는 외부 서비스 제공자가 정의한 API 요청 및 인증 스펙에 맞춰 우리가 ‘잘’ 요청하고 있는지, 그들이 제공하기로 한 응답을 우리가 ‘잘’ 가져오고 있는지 즉, 계약 (Contract) 를 잘 준수하고 있는지 테스트 해야합니다. 이는 외부 서비스의 정상 작동여부와 완전히 별개의 문제이지요.</p>
<h2 id="테스트는-또-하나의-문서">테스트는 또 하나의 문서</h2>
<p>테스트를 작성하는 것의 또 다른 장점은 그 자체가 문서로 기능할 수 있다는 것입니다. 많은 경우 처음 API 를 연동할 때 연동 클라이언트를 개발한 개발자 이외에는 해당 외부 서비스에 대한 정보가 없는 상황이 대부분일 것입니다. 나중에 해당 클라이언트에 대한 유지 보수가 필요한 상황이 발생했을 때 클라이언트 코드가 예외 처리등 풍부한 상황에 대응하는 코드를 개발해두지 않았다면, 새로 유지보수를 맡은 개발자는 직접 따로 API 를 호출해보거나 사내 포털을 뒤적이면서 API 제공 부서의 문서를 찾아다닌 후에야 코드에서 제대로 드러나지 않는 API 스펙을 유추해 낼 수 있을 것입니다.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// API 에서 예외가 발생한다면 예외는 어떤 형식이지..?</span>
<span class="kd">public</span> <span class="nc">ProductDto</span> <span class="nf">getProduct</span><span class="o">(</span><span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">client</span><span class="o">.</span><span class="na">exchange</span><span class="o">(</span><span class="s">"/product/{id}"</span><span class="o">,</span> <span class="nc">HttpEntity</span><span class="o">.</span><span class="na">EMPTY</span><span class="o">,</span> <span class="nc">ProductDto</span><span class="o">.</span><span class="na">class</span><span class="o">,</span> <span class="n">id</span><span class="o">).</span><span class="na">getBody</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>
<p>그러나 만약 해당 클라이언트에 대한 테스트 코드가 작성되어 있다면, 해당 테스트 코드를 통해 어떤 API 를 호출하는지, 어떤 예외가 발생할 수 있는지, 어떤 응답을 받을 수 있는지 등을 쉽게 파악할 수 있을 것입니다. 최종적으로 API 제공 부서의 문서를 참고해야 할 지라도 기본적인 기능을 파악하는데 드는 시간을 훨씬 단축시킬 수 있을 것입니다.</p>
<h2 id="mockserver-를-활용한-단위-테스트">MockServer 를 활용한 단위 테스트</h2>
<p><a href="https://github.com/mock-server/mockserver">MockServer</a> 를 활용하면 외부 API 를 호출하는 클라이언트 코드를 테스트할 때, 외부 API 서버를 실제로 호출하지 않고도 테스트를 할 수 있습니다. Spring 에서 제공하는 <code class="language-plaintext highlighter-rouge">@RestClientTest</code> 가 RestTemplate 및 RestTemplateBuilder 가 사용된 코드만을 테스트 가능한 것에 비해 클라이언트 라이브러리 중립적으로 Mock 테스트가 가능하다는 장점이 있습니다.</p>
<h3 id="dependency">Dependency</h3>
<div class="language-groovy highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dependencies</span> <span class="o">{</span>
<span class="n">testImplementation</span> <span class="s1">'org.mock-server:mockserver-netty:5.15.0'</span>
<span class="o">}</span>
</code></pre></div></div>
<h3 id="client-code">Client Code</h3>
<p>HTTP Client 는 Spring Boot 3.2 부터 사용 가능한 <code class="language-plaintext highlighter-rouge">RestClient</code> 로 구현하였습니다.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="n">record</span> <span class="nf">ProductDto</span> <span class="o">(</span>
<span class="nc">Long</span> <span class="n">id</span><span class="o">,</span>
<span class="nc">String</span> <span class="n">name</span><span class="o">,</span>
<span class="nc">String</span> <span class="n">description</span><span class="o">,</span>
<span class="nc">Integer</span> <span class="n">price</span>
<span class="o">)</span> <span class="o">{}</span>
<span class="nd">@Component</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">RestProductClient</span> <span class="o">{</span>
<span class="kd">private</span> <span class="kd">final</span> <span class="nc">RestClient</span> <span class="n">client</span><span class="o">;</span>
<span class="kd">public</span> <span class="nf">RestProductClient</span><span class="o">(</span><span class="nc">String</span> <span class="n">host</span><span class="o">)</span> <span class="o">{</span>
<span class="k">this</span><span class="o">.</span><span class="na">client</span> <span class="o">=</span> <span class="nc">RestClient</span><span class="o">.</span><span class="na">create</span><span class="o">(</span><span class="n">host</span><span class="o">);</span>
<span class="o">}</span>
<span class="kd">public</span> <span class="nc">ProductDto</span> <span class="nf">getProduct</span><span class="o">(</span><span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">client</span><span class="o">.</span><span class="na">get</span><span class="o">()</span>
<span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="s">"/products/{id}"</span><span class="o">,</span> <span class="n">id</span><span class="o">)</span>
<span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
<span class="o">.</span><span class="na">body</span><span class="o">(</span><span class="nc">ProductDto</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<h3 id="test-code">Test Code</h3>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">RestProductClientTest</span> <span class="o">{</span>
<span class="kd">private</span> <span class="nc">ClientAndServer</span> <span class="n">mockServer</span><span class="o">;</span>
<span class="kd">private</span> <span class="nc">RestProductClient</span> <span class="n">client</span><span class="o">;</span>
<span class="nd">@BeforeEach</span>
<span class="kt">void</span> <span class="nf">setUp</span><span class="o">()</span> <span class="o">{</span>
<span class="n">mockServer</span> <span class="o">=</span> <span class="nc">ClientAndServer</span><span class="o">.</span><span class="na">startClientAndServer</span><span class="o">(</span><span class="mi">9876</span><span class="o">);</span>
<span class="o">}</span>
<span class="nd">@AfterEach</span>
<span class="kt">void</span> <span class="nf">tearDown</span><span class="o">()</span> <span class="o">{</span>
<span class="n">mockServer</span><span class="o">.</span><span class="na">stop</span><span class="o">();</span>
<span class="o">}</span>
<span class="nd">@Test</span>
<span class="kt">void</span> <span class="nf">getProductById</span><span class="o">()</span> <span class="o">{</span>
<span class="c1">// Given</span>
<span class="kt">var</span> <span class="n">client</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">RestProductClient</span><span class="o">(</span><span class="s">"http://localhost:9876"</span><span class="o">);</span> <span class="c1">// (1)</span>
<span class="kt">var</span> <span class="n">productId</span> <span class="o">=</span> <span class="mi">1L</span><span class="o">;</span>
<span class="kt">var</span> <span class="n">responseBody</span> <span class="o">=</span> <span class="s">"""
{
"</span><span class="n">id</span><span class="s">": 1,
"</span><span class="n">name</span><span class="s">": "</span><span class="n">iPhone</span> <span class="mi">12</span><span class="s">",
"</span><span class="n">description</span><span class="s">": "</span><span class="nc">Apple</span> <span class="n">iPhone</span> <span class="mi">12</span> <span class="mi">64</span><span class="no">GB</span><span class="s">",
"</span><span class="n">price</span><span class="s">": 1000000
}
"""</span><span class="o">;</span>
<span class="c1">// When</span>
<span class="n">mockServer</span>
<span class="o">.</span><span class="na">when</span><span class="o">(</span>
<span class="n">request</span><span class="o">()</span> <span class="c1">// (2)</span>
<span class="o">.</span><span class="na">withMethod</span><span class="o">(</span><span class="s">"GET"</span><span class="o">)</span>
<span class="o">.</span><span class="na">withPath</span><span class="o">(</span><span class="s">"/products/1"</span><span class="o">)</span>
<span class="o">).</span><span class="na">respond</span><span class="o">(</span>
<span class="n">response</span><span class="o">()</span> <span class="c1">// (3)</span>
<span class="o">.</span><span class="na">withStatusCode</span><span class="o">(</span><span class="mi">200</span><span class="o">)</span>
<span class="o">.</span><span class="na">withBody</span><span class="o">(</span><span class="n">json</span><span class="o">(</span><span class="n">responseBody</span><span class="o">))</span>
<span class="o">);</span>
<span class="kt">var</span> <span class="n">result</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="na">getProduct</span><span class="o">(</span><span class="mi">1L</span><span class="o">);</span> <span class="c1">// (4)</span>
<span class="kt">var</span> <span class="n">expected</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ProductDto</span><span class="o">(</span>
<span class="mi">1L</span><span class="o">,</span>
<span class="s">"iPhone 12"</span><span class="o">,</span>
<span class="s">"Apple iPhone 12 64GB"</span><span class="o">,</span>
<span class="mi">1000000</span>
<span class="o">);</span>
<span class="c1">// Then</span>
<span class="n">assertEquals</span><span class="o">(</span><span class="n">expected</span><span class="o">,</span> <span class="n">result</span><span class="o">);</span> <span class="c1">// (5)</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<ul>
<li>(1) : mockserver 와 동일한 포트로 접속하도록 클라이언트 인스턴스를 생성합니다.</li>
<li>(2) : 요청을 받길 기대하는 요청 메서드와 경로를 지정합니다.</li>
<li>(3) : 요청에 대한 응답을 지정합니다.</li>
<li>(4) : 클라이언트 코드를 실행하고, 응답을 바인딩합니다.</li>
<li>(5) : 응답이 기대하는 결과와 일치하는지 검증합니다.</li>
</ul>
<h2 id="api-클라이언트가-협력-객체인-경우">API 클라이언트가 협력 객체인 경우</h2>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/integration-test-diagram.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;"></figcaption>
</figure>
<p>만약 API 클라이언트 구현 자체에 대한 테스트가 아니라 위에서 구현한 <code class="language-plaintext highlighter-rouge">RestProductClient</code> 와 같은 클라이언트가 다른 비즈니스 객체의 협력 객체로 사용되는 테스트라면 어떻게 해야 할까요? 마찬가지로 MockServer 를 이용해서 HTTP 통신은 Mocking 할 수 있지만, 테스트의 핵심 관심사는 HTTP 통신이 아니라 외부 서비스로부터 받은 응답을 바탕으로 기대하는 추가적인 비즈니스 로직이 제대로 수행되는지이기 때문에 MockServer 를 사용하는건 다소 불필요하고 번거로운 일일 수 있습니다. 우리가 Repository 와 협력관계에 있는 클래스를 테스트할 때 DB 를 Mocking 하지 않는 것을 상상해보면 이해하기 쉽습니다.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Service</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ProductService</span> <span class="o">{</span>
<span class="kd">private</span> <span class="kd">final</span> <span class="nc">RestProductClient</span> <span class="n">client</span><span class="o">;</span>
<span class="kd">public</span> <span class="nf">ProductService</span><span class="o">(</span><span class="nc">RestProductClient</span> <span class="n">client</span><span class="o">)</span> <span class="o">{</span>
<span class="k">this</span><span class="o">.</span><span class="na">client</span> <span class="o">=</span> <span class="n">client</span><span class="o">;</span>
<span class="o">}</span>
<span class="kd">public</span> <span class="nc">Integer</span> <span class="nf">getProductPrice</span><span class="o">(</span><span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">client</span><span class="o">.</span><span class="na">getProduct</span><span class="o">(</span><span class="n">id</span><span class="o">).</span><span class="na">price</span><span class="o">();</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<h3 id="mock-을-활용한-단위-테스트">Mock 을 활용한 단위 테스트</h3>
<p>ProductService 에 대한 단위 테스트를 수행할 경우, 협력 객체인 RestResourceClient 를 Mocking 해서 테스트를 수행할 수 있습니다.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">ProductServiceTest</span> <span class="o">{</span>
<span class="kd">private</span> <span class="nc">ProductService</span> <span class="n">productService</span><span class="o">;</span>
<span class="kd">private</span> <span class="nc">RestProductClient</span> <span class="n">productClient</span><span class="o">;</span>
<span class="nd">@Test</span>
<span class="kt">void</span> <span class="nf">getProductPrice</span><span class="o">()</span> <span class="o">{</span>
<span class="c1">// Given</span>
<span class="n">productClient</span> <span class="o">=</span> <span class="n">mock</span><span class="o">(</span><span class="nc">RestProductClient</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
<span class="n">given</span><span class="o">(</span><span class="n">productClient</span><span class="o">.</span><span class="na">getProduct</span><span class="o">(</span><span class="mi">1L</span><span class="o">)).</span><span class="na">willReturn</span><span class="o">(</span><span class="k">new</span> <span class="nc">ProductDto</span><span class="o">(</span>
<span class="mi">1L</span><span class="o">,</span>
<span class="s">"iPhone 12"</span><span class="o">,</span>
<span class="s">"Apple iPhone 12 64GB"</span><span class="o">,</span>
<span class="mi">1000000</span>
<span class="o">));</span>
<span class="n">productService</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ProductService</span><span class="o">(</span><span class="n">productClient</span><span class="o">);</span>
<span class="c1">// When</span>
<span class="kt">var</span> <span class="n">result</span> <span class="o">=</span> <span class="n">productService</span><span class="o">.</span><span class="na">getProductPrice</span><span class="o">(</span><span class="mi">1L</span><span class="o">);</span>
<span class="c1">// Then</span>
<span class="n">assertEquals</span><span class="o">(</span><span class="mi">1000000</span><span class="o">,</span> <span class="n">result</span><span class="o">);</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<h3 id="mockbean-을-활용한-통합-테스트">MockBean 을 활용한 통합 테스트</h3>
<p>스프링 어플리케이션에 대한 통합 테스트를 수행할 경우, <code class="language-plaintext highlighter-rouge">@MockBean</code> 을 활용해서 협력 객체를 Mocking 할 수 있습니다.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@SpringBootTest</span>
<span class="kd">class</span> <span class="nc">ProductServiceIntegrationTest</span> <span class="o">{</span>
<span class="nd">@Autowired</span>
<span class="kd">private</span> <span class="nc">ProductService</span> <span class="n">service</span><span class="o">;</span>
<span class="nd">@MockBean</span>
<span class="kd">private</span> <span class="nc">RestProductClient</span> <span class="n">client</span><span class="o">;</span>
<span class="nd">@Test</span>
<span class="kt">void</span> <span class="nf">getProductPrice</span><span class="o">()</span> <span class="o">{</span>
<span class="c1">// Given</span>
<span class="n">given</span><span class="o">(</span><span class="n">client</span><span class="o">.</span><span class="na">getProduct</span><span class="o">(</span><span class="mi">1L</span><span class="o">)).</span><span class="na">willReturn</span><span class="o">(</span><span class="k">new</span> <span class="nc">ProductDto</span><span class="o">(</span>
<span class="mi">1L</span><span class="o">,</span>
<span class="s">"iPhone 12"</span><span class="o">,</span>
<span class="s">"Apple iPhone 12 64GB"</span><span class="o">,</span>
<span class="mi">1000000</span>
<span class="o">));</span>
<span class="c1">// When</span>
<span class="kt">var</span> <span class="n">result</span> <span class="o">=</span> <span class="n">service</span><span class="o">.</span><span class="na">getProductPrice</span><span class="o">(</span><span class="mi">1L</span><span class="o">);</span>
<span class="c1">// Then</span>
<span class="n">assertEquals</span><span class="o">(</span><span class="mi">1000000</span><span class="o">,</span> <span class="n">result</span><span class="o">);</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<h3 id="인터페이스를-추가하고-직접-테스트-더블-구현하기">인터페이스를 추가하고 직접 테스트 더블 구현하기</h3>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/service-stub-diagram.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;"></figcaption>
</figure>
<p>위의 두 방법은 구체 클래스인 <code class="language-plaintext highlighter-rouge">RestProductClient</code> 를 Mocking 하는 방법이었습니다. 하지만 매 테스트 코드마다 외부 의존성을 가지는 클래스를 mocking 하는 것은 쉽지 않은 일이고, <code class="language-plaintext highlighter-rouge">@MockBean</code> 을 사용할 경우 매 테스트마다 Application Context 가 재생성되기 때문에 테스트 수행 시간이 길어질 수 있습니다. 이런 경우에는 인터페이스를 추가하고 이를 구현하는 테스트 더블 구현체를 만들어서 테스트를 수행하는 것도 하나의 방법이 될 수 있습니다.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">interface</span> <span class="nc">ProductClient</span> <span class="o">{</span>
<span class="nc">ProductDto</span> <span class="nf">getProduct</span><span class="o">(</span><span class="nc">Long</span> <span class="n">id</span><span class="o">);</span>
<span class="o">}</span>
<span class="nd">@Profile</span><span class="o">({</span><span class="s">"dev"</span><span class="o">,</span> <span class="s">"production"</span><span class="o">})</span>
<span class="nd">@Component</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">RestProductClient</span> <span class="kd">implements</span> <span class="nc">ProductClient</span> <span class="o">{</span>
<span class="kd">private</span> <span class="kd">final</span> <span class="nc">RestClient</span> <span class="n">client</span><span class="o">;</span>
<span class="kd">public</span> <span class="nf">RestProductClient</span><span class="o">(</span><span class="nd">@Value</span><span class="o">(</span><span class="s">"${product.host}"</span><span class="o">)</span> <span class="nc">String</span> <span class="n">host</span><span class="o">)</span> <span class="o">{</span>
<span class="k">this</span><span class="o">.</span><span class="na">client</span> <span class="o">=</span> <span class="nc">RestClient</span><span class="o">.</span><span class="na">create</span><span class="o">(</span><span class="n">host</span><span class="o">);</span>
<span class="o">}</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="nc">ProductDto</span> <span class="nf">getProduct</span><span class="o">(</span><span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">client</span><span class="o">.</span><span class="na">get</span><span class="o">()</span>
<span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="s">"/products/{id}"</span><span class="o">,</span> <span class="n">id</span><span class="o">)</span>
<span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
<span class="o">.</span><span class="na">body</span><span class="o">(</span><span class="nc">ProductDto</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
<span class="o">}</span>
<span class="o">}</span>
<span class="c1">// 테스트 환경에서만 사용할 Stub 구현체</span>
<span class="nd">@Profile</span><span class="o">(</span><span class="s">"test"</span><span class="o">)</span>
<span class="nd">@Component</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">StubProductClient</span> <span class="kd">implements</span> <span class="nc">ProductClient</span> <span class="o">{</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="nc">ProductDto</span> <span class="nf">getProduct</span><span class="o">(</span><span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
<span class="k">return</span> <span class="k">new</span> <span class="nf">ProductDto</span><span class="o">(</span><span class="n">id</span><span class="o">,</span> <span class="s">"Product "</span> <span class="o">+</span> <span class="n">id</span><span class="o">,</span> <span class="s">"Description "</span> <span class="o">+</span> <span class="n">id</span><span class="o">,</span> <span class="mi">100</span> <span class="o">*</span> <span class="n">id</span><span class="o">.</span><span class="na">intValue</span><span class="o">());</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>마이크로서비스 아키텍처 (MSA) 가 일반화된 요즘의 개발 환경에서는 어플리케이션 외부의 다양한 서비스와 여러 방식으로 통신하게 됩니다. 기능 구현을 위해서 한 어플리케이션 내에서 아무리 적어도 서너개 이상의 외부 서비스와 연동하게 되곤 하는데, 내 서비스의 API 를 노출할 때와 다른 서비스의 API 를 사용할 때 모두 HTTP 프로토콜을 사용하는 것이 가장 일반적이라 사용할 API 제공자가 정의한 스펙에 맞춰 해당 HTTP API 를 호출하는 코드를 작성하는 상황이 상당히 자주 일어납니다.HTTP Delete 를 두 번 호출하면 응답 코드는 뭘까?2023-12-17T00:00:00+00:002023-12-17T00:00:00+00:00https://realrain.net/posts/http-idempotentency<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/http-idempotentency-title.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;"></figcaption>
</figure>
<p>같은 리소스에 대해 <code class="language-plaintext highlighter-rouge">DELETE /resource/{id}</code> 와 같이 클라이언트에서 http delete 메서드를 두 번 호출하면 서버에서는 응답 코드로 무엇을 반환해야 할까? ‘존재하지 않는 리소스’ 에 대한 삭제 요청이니 <code class="language-plaintext highlighter-rouge">404 Not Found</code> 여야 할까? 아니면 ‘이미 삭제된 것을 삭제’ 하는 멱등한 연산이니 <code class="language-plaintext highlighter-rouge">200 OK</code> 를 줄 수도 있지 않을까?</p>
<h2 id="http-delete-는-멱등하다">HTTP Delete 는 멱등하다?</h2>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="50%" src="/assets/image/dalle_404.jpeg" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">Created by DALL-E</figcaption>
</figure>
<p>수학에서 멱등(冪等, idempotent) 이란 <code class="language-plaintext highlighter-rouge">f(f(x)) = f(x)</code> 를 만족하는 함수를 말한다. 즉, 함수를 여러 번 적용해도 ‘결과’가 달라지지 않는다는 것이다. <a href="https://www.rfc-editor.org/rfc/rfc7231">RFC7231</a> 에 따르면, HTTP 메서드 중에서 멱등한 메서드로는 <code class="language-plaintext highlighter-rouge">GET</code>, <code class="language-plaintext highlighter-rouge">PUT</code>, <code class="language-plaintext highlighter-rouge">DELETE</code> 를 들 수 있다. <code class="language-plaintext highlighter-rouge">GET</code> 은 조회이므로 당연히 멱등하다. <code class="language-plaintext highlighter-rouge">PUT</code> 은 리소스를 생성하거나 갱신하는데, 같은 리소스에 대해 여러 번 호출하면 동일한 결과로 리소스를 갱신하는 것이므로 결과는 같다. <code class="language-plaintext highlighter-rouge">DELETE</code> 도 마찬가지로 여러번 삭제를 호출해도 리소스는 삭제된 상태로 동일할 것이다.</p>
<p>반면, 멱등하지 않은 메서드로는 <code class="language-plaintext highlighter-rouge">POST</code>, <code class="language-plaintext highlighter-rouge">PATCH</code> 를 들 수 있다. <code class="language-plaintext highlighter-rouge">POST</code> 는 리소스를 생성하는데 같은 요청을 여러 번 호출하면 리소스가 여러 개 생성되므로 멱등하지 않다. 한편, <code class="language-plaintext highlighter-rouge">PATCH</code> 는 리소스를 부분 갱신한다. 예를 들어 <code class="language-plaintext highlighter-rouge">PATCH</code> 메서드로 사용자의 장바구니에 상품을 추가하는 요청을 여러 번 보낸다고 하면, 같은 상품이 여러개 추가되므로 멱등하지 않다.</p>
<p><strong>HTTP 메서드의 성질</strong></p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/http_summary.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">https://ko.wikipedia.org/wiki/HTTP</figcaption>
</figure>
<p>HTTP 멱등의 의미는 <a href="https://www.rfc-editor.org/rfc/rfc7231#section-4.2.2">RFC7231 4.2.2</a> 에서 더 자세히 살펴볼 수 있는데, 결국 핵심은 멱등의 ‘결과’란 <strong>리소스(서버)의 상태</strong>를 말한다는 것이다. 따라서 <strong>응답 코드나 응답 본문에 대해서는 멱등성을 보장하지 않으며</strong> 이는 선택의 문제이다. 즉, <code class="language-plaintext highlighter-rouge">DELETE</code> 메서드는 멱등하지만 응답 코드 및 응답 바디는 멱등하지 않을 수 있다는 것이다.</p>
<h2 id="api-스펙으로서의-http-상태코드의-모호함">API 스펙으로서의 HTTP 상태코드의 모호함</h2>
<p>초기 HTTP는 서버에 있는 파일이나 문서에 대한 요청을 처리하기 위해 설계되었다. 그래서 기본적으로 응답 코드는 원격 서버에 있는 파일이나 문서의 상태를 나타내기 위해 만들어졌다. 예를 들어 <code class="language-plaintext highlighter-rouge">404 Not Found</code> 는 말 그대로 클라이언트가 요청한 URI 의 리소스가 서버에 없다는 것을 나타내고, <code class="language-plaintext highlighter-rouge">201 Created</code> 는 서버에 리소스가 생성되었다는 것을 나타낸다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="60%" src="/assets/image/ambiguous.jpeg" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">생각하기에 달렸다</figcaption>
</figure>
<p>REST 원칙을 기반으로 설계된 API 에서는 리소스가 파일이나 문서가 아니라 비즈니스 객체를 나타내는 경우가 많다. 이때 대부분 비즈니스 객체에 대한 상태를 응답 코드에 대응 시킬 수 있지만 간혹 비즈니스 이외의 논리가 개입되어 상태 코드의 의미가 모호해지는 경우가 있다. 예를 들어 <code class="language-plaintext highlighter-rouge">GET /resource/{id}</code> 를 호출할 때 <code class="language-plaintext highlighter-rouge">404 Not Found</code> 를 반환 받았다고 하자. 직관적으로 생각할 때는 id 에 대응하는 객체가 존재하지 않는다고 생각할 수 있지만, 실제로는 서버에 해당 요청을 처리하는 코드가 없어서 WAS 에서 <code class="language-plaintext highlighter-rouge">404 Not Found</code> 를 반환하는 경우일 수도 있다. 이 경우에는 클라이언트는 리소스가 존재하지 않는다고 오해할 수 있다.</p>
<p>이런 문제는 근본적으로 두 가지 계층, 즉 클라이언트와 WAS 간의 통신과 어플리케이션 내부의 비즈니스 로직의 결과가 HTTP 응답 하나만으로 처리되기 때문에 발생한 문제이다. 개발자가 코드로 리소스에 대한 생성, 삭제, 수정과 같은 기능을 구현할 때에는 각각 기능에 대응되는 메서드와 응답을 정의하고 필요에 따라 언어에서 지원하는 예외를 정의하여 구현하지만 이를 HTTP 프로토콜로 노출할 때에는 각 메서드에 대한 접근을 URI 로, 메서드의 응답을 바디와 상태 코드로 표현해야 하기 때문에 변환 과정에서 불일치가 발생하는 것이다.</p>
<h2 id="api-의-http-상태-코드-선택에-대한-여러-관점">API 의 HTTP 상태 코드 선택에 대한 여러 관점</h2>
<p>앞서 말한 ‘멱등함은 서버의 상태에만 적용된다’는 점과, ‘HTTP 상태코드는 모든 비즈니스 상태에 명확히 대응될 수 없다’는 점을 고려하면, HTTP 로 노출되는 API 를 설계할 때 상태 코드를 어떻게 정의해야 하는가에 대한 명확한 정답은 없다. 다만 API 가 사용되는 상황과 사용자의 편의성을 고려해 몇가지 관점을 고려해볼 수 있다.</p>
<h3 id="서버의-api-자체를-리소스로-취급하기">서버의 API 자체를 리소스로 취급하기</h3>
<p>첫 번째 관점은 서버가 제공하는 API 자체만을 리소스로 취급하는 것이다. 이 관점에서는 API 자체가 리소스이므로, 일단 클라이언트가 API 를 정상적으로 호출하기만 했으면 <code class="language-plaintext highlighter-rouge">200 OK</code> 를 상태 코드로 반환한다. 그밖에 비즈니스적 예외 상황이 발생했을 경우에는 <code class="language-plaintext highlighter-rouge">200 OK</code> 를 유지하며 응답 바디에 예외 상황에 대한 정보를 담아 반환한다. <code class="language-plaintext highlighter-rouge">404 Not Found</code> 는 클라이언트에서 정말 서버에 존재하지 않는 API 를 호출했을 때 발생한다.</p>
<p>이런 방식의 경우 클라이언트는 API 를 호출했을 때 항상 <code class="language-plaintext highlighter-rouge">200 OK</code> 를 반환받으므로 상태 코드에 대한 모호함은 해결할 수 있다. 그러나 클라이언트가 예외 상황에 대한 처리를 하기 위해서는 응답 바디를 파싱해야 하므로 클라이언트의 부담이 늘어난다는 단점이 있다. 한편, 대다수의 서버 모니터링 도구는 상태 코드만을 기준으로 서버의 상태를 모니터링하기 때문에, 서버의 API 호출 상태를 모니터링하기 어려운 기술적인 요소도 고려해야 한다.</p>
<h3 id="비즈니스-객체를-리소스로-취급하기">비즈니스 객체를 리소스로 취급하기</h3>
<p>두 번째 관점은 비즈니스 객체를 리소스로 취급하는 것이다. 이 관점에서는 비즈니스 객체의 상태를 HTTP 상태 코드로 반환한다. 흔히 알고 있는 REST 원칙을 따르는 설계에 해당한다. 예를 들어 <code class="language-plaintext highlighter-rouge">GET /user/{id}</code> 를 호출했을 때, id 에 해당하는 사용자가 존재한다면 <code class="language-plaintext highlighter-rouge">200 OK</code>, 존재하지 않는다면 <code class="language-plaintext highlighter-rouge">404 Not Found</code> 를 반환한다. 존재하지 않거나 삭제된 사용자를 조회하는 것이 예외 상황이 아니라고 판단한다면 상황에 따라 <code class="language-plaintext highlighter-rouge">204 No Content</code> 를 반환 할수도 있다.</p>
<p>REST 에 기반한 설계는 API 를 사용자가 이해하기 쉽게 계층화 할 수 있는 직관적인 설계 방법이지만 앞서 말한 상태 코드의 모호성은 여전히 해결할 수 없다. 따라서 그러한 모호함을 해결하기 위해 상태 코드와 별도로 예외 상황에서 응답 바디에 비즈니스 예외에 대한 상세한 정보를 제공하여 클라이언트가 명확한 정보를 얻을 수 있도록 하여야 한다.</p>
<h2 id="결론">결론</h2>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="50%" src="/assets/image/it-depends.jpeg" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">케바케</figcaption>
</figure>
<p>다소 허무하지만 결론은 <strong>‘상황에 따라 다르다’</strong> 이다. 이전 단락에서 언급한 두 가지 관점 말고도 프론트엔드에서 호출하는 API 냐, 서버에서 호출하는 API 냐에 따라 상태 코드를 다르게 정의할 수도 있다.</p>
<p>일례로, 프론트엔드 환경에서는 네트워크나 클라이언트 문제로 같은 요청이 여러 번 호출될 수 있으므로 결제 요청 등 멱등하게 처리되어야 하는 연산에 대해 상태 코드 또한 멱등하게 구현하는 것이 좋을 수 있다. 한편, 서버간 호출하는 API 라 해도 exactly-once 를 보장하지 않는 이벤트 기반의 분산 처리 시스템이라면 마찬가지로 멱등성을 상태 코드에도 확장해 <code class="language-plaintext highlighter-rouge">200 OK</code> 를 반환하는 것도 가능하다.</p>
<p>REST 도 결국 하나의 설계 원칙이지 정답은 아니다. Under-Fetching, Over-Fetching 등 REST 의 문제점을 보완할 수 있는 GraphQL, gRPC 와 같은 대체 기술도 엄연히 존재한다. 따라서 어떤 응답이 정답인지 집착하기 보다 <strong>API 가 사용되는 환경을 고려하고, 사용자 입장에서 이해하기 쉽고, 일관성을 느낄수 있는 정책과 구조로 API 를 설계하는 것이 더 중요하다고 생각한다.</strong></p>
<h2 id="참고자료">참고자료</h2>
<ul>
<li><a href="https://ko.wikipedia.org/wiki/HTTP">HTTP - 한국어 위키백과</a></li>
<li><a href="https://www.rfc-editor.org/rfc/rfc7231">RFC 7231</a></li>
<li><a href="https://blog.tossbusiness.com/articles/dev-1">멱등성이 뭔가요? - 토스페이먼츠 블로그</a></li>
<li><a href="https://developer.mozilla.org/ko/docs/Web/HTTP/Status">HTTP 상태 코드 - MDN</a></li>
</ul>자바의 원시 타입을 사용하지 말자2023-12-13T00:00:00+00:002023-12-13T00:00:00+00:00https://realrain.net/posts/do-not-use-primitive-types<p>정확히 말하면 <strong>‘비즈니스 도메인’을 나타내는 모델에서 자바 원시 타입을 사용하지 말자</strong>고 말하고 싶다.</p>
<p>구글에 자바의 원시 타입과 박싱 타입과 관련된 키워드로 검색하면, 박싱된 기본 타입 사용을 지양하자는 블로그 글이 많이 나온다. 대부분 이펙티브 자바 (Effective Java 3/E, Joshua J. Bloch) 의 Item 61 에서 언급하는 내용을 인용하며 그 차이점과 단점에 대해 서술하고 있다. 그렇다면 그러한 단점들은 어떤 것들이 있으며, 실제 어플리케이션 개발에서 얼마나 큰 영향을 미칠까?</p>
<h2 id="박싱된-기본-타입의-식별성-문제">박싱된 기본 타입의 식별성 문제</h2>
<p>박싱된 기본 타입은 식별성을 가지고 있기 때문에 두 객체가 같은지를 비교할 때 <code class="language-plaintext highlighter-rouge">==</code> 연산자를 사용할 수 없다. 동일성이 없기 때문이다. <code class="language-plaintext highlighter-rouge">==</code> 연산자는 두 객체의 주소값을 비교하기 때문에, 박싱된 기본 타입의 경우 <code class="language-plaintext highlighter-rouge">equals()</code> 메소드를 사용해야 한다.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Integer</span> <span class="n">a</span> <span class="o">=</span> <span class="mi">1</span><span class="o">;</span>
<span class="nc">Integer</span> <span class="n">b</span> <span class="o">=</span> <span class="mi">1</span><span class="o">;</span>
<span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="n">a</span> <span class="o">==</span> <span class="n">b</span><span class="o">);</span> <span class="c1">// false</span>
<span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="n">a</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">b</span><span class="o">));</span> <span class="c1">// true</span>
</code></pre></div></div>
<p>분명 <code class="language-plaintext highlighter-rouge">a</code> 와 <code class="language-plaintext highlighter-rouge">b</code> 는 같은 값을 가지고 있지만, <code class="language-plaintext highlighter-rouge">==</code> 연산자를 사용하면 <code class="language-plaintext highlighter-rouge">false</code> 가 출력된다. 이는 분명 실수를 유발할 수 있는 부분이다. 또한 <code class="language-plaintext highlighter-rouge">equals()</code> 메소드를 사용하면 <code class="language-plaintext highlighter-rouge">a</code> 의 <code class="language-plaintext highlighter-rouge">null</code> 여부를 확인해야 해서 번거롭기도 하다. 하지만 이러한 부분들이 모든 상황에서 꼭 단점이라고 할 수는 없다.</p>
<p>원시 타입은 반드시 <code class="language-plaintext highlighter-rouge">0</code> 이나 <code class="language-plaintext highlighter-rouge">false</code> 와 같은 기본 값을 가져야 한다. 하지만 현실 세계를 모델링 하는 비즈니스 어플리케이션에서는 어떠한 값이 <strong>‘존재하지 않는’</strong> 상태가 있을 수 있다. 비즈니스 모델에서 <code class="language-plaintext highlighter-rouge">int</code>, <code class="language-plaintext highlighter-rouge">long</code> 등으로 표현되는 숫자가 특정한 코드나 ID 등을 나타낸다면 기본 값 <code class="language-plaintext highlighter-rouge">0</code> 을 특수한 케이스로 취급할 수 있겠지만 수량, 금액등의 셀 수 있는 개념을 나타내는 데 사용된다면 <code class="language-plaintext highlighter-rouge">0</code> 으로는 <strong>해당 값이 존재하지 않는 상태와 실제 값이 0 인 상태를 구분할 수 없다.</strong> 결국 이러한 상태를 표현하기 위해서는 <code class="language-plaintext highlighter-rouge">null</code> 을 사용해야 할 수밖에 없다.</p>
<p>또한 외부 API 를 사용할 때도 비슷한 일이 발생할 수 있다. 외부 API 에서 JSON 형태로 자료를 제공하고 어플리케이션에서 이를 파싱하여 사용한다고 가정해보자. 이 때 특정한 JSON 필드가 <code class="language-plaintext highlighter-rouge">null</code> 또는 <code class="language-plaintext highlighter-rouge">undefined</code> 인 상황이 발생한다면, 역직렬화 할때 사용하는 라이브러리나 설정에 따라 다르겠지만 원시 타입을 사용할 경우 의도되지 않은 값으로 초기화 될 수 있다.</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w">
</span><span class="p">{</span><span class="w">
</span><span class="nl">"userId"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Jane"</span><span class="p">,</span><span class="w">
</span><span class="nl">"vip"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
</span><span class="nl">"balance"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="p">{</span><span class="w">
</span><span class="nl">"userId"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w">
</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Smith"</span><span class="p">,</span><span class="w">
</span><span class="nl">"vip"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
</span><span class="nl">"balance"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">User</span> <span class="o">{</span>
<span class="kd">private</span> <span class="kt">int</span> <span class="n">userId</span><span class="o">;</span>
<span class="kd">private</span> <span class="nc">String</span> <span class="n">name</span><span class="o">;</span>
<span class="kd">private</span> <span class="kt">boolean</span> <span class="n">vip</span><span class="o">;</span>
<span class="kd">private</span> <span class="kt">int</span> <span class="n">balance</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>
<p>위 JSON 배열을 역직렬화 하여 <code class="language-plaintext highlighter-rouge">User</code> 객체를 생성한다고 가정해보자. <code class="language-plaintext highlighter-rouge">balance</code> 필드는 <code class="language-plaintext highlighter-rouge">int</code> 타입으로 선언되어 있기 때문에 <code class="language-plaintext highlighter-rouge">null</code> 을 가질 수 없다. 따라서 <code class="language-plaintext highlighter-rouge">balance</code> 필드는 <code class="language-plaintext highlighter-rouge">0</code> 으로 초기화 될 것이다. 마찬가지로 <code class="language-plaintext highlighter-rouge">vip</code> 필드도 <code class="language-plaintext highlighter-rouge">false</code> 로 초기화 될 것이다. 이러한 상황은 의도되지 않은 값으로 사용자 Jane 과 Smith 가 어플리케이션에서 동등하게 취급되어 오류를 야기할 수 있다. 이러한 부분은 직접 외부 서비스의 응답과 역직렬화된 데이터를 비교하지 않는 이상, 상당히 발견하기 어려운 오류이다.</p>
<h2 id="박싱된-기본-타입의-성능-문제">박싱된 기본 타입의 성능 문제</h2>
<p>분명 기본타입을 박싱하고 언박싱 하는데에는 성능상의 비용이 든다. 다음은 많은 블로그에서 인용하는 예제이다.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Test</span>
<span class="kt">void</span> <span class="nf">primitiveTypeSum</span><span class="o">()</span> <span class="o">{</span>
<span class="kt">long</span> <span class="n">sum</span> <span class="o">=</span> <span class="mi">0L</span><span class="o">;</span>
<span class="k">for</span> <span class="o">(</span><span class="kt">long</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o"><=</span> <span class="nc">Integer</span><span class="o">.</span><span class="na">MAX_VALUE</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
<span class="n">sum</span> <span class="o">+=</span> <span class="n">i</span><span class="o">;</span>
<span class="o">}</span>
<span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">print</span><span class="o">(</span><span class="n">sum</span><span class="o">);</span>
<span class="o">}</span>
<span class="nd">@Test</span>
<span class="kt">void</span> <span class="nf">wrapperTypeSum</span><span class="o">()</span> <span class="o">{</span>
<span class="nc">Long</span> <span class="n">sum</span> <span class="o">=</span> <span class="mi">0L</span><span class="o">;</span>
<span class="k">for</span> <span class="o">(</span><span class="kt">long</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o"><=</span> <span class="nc">Integer</span><span class="o">.</span><span class="na">MAX_VALUE</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
<span class="n">sum</span> <span class="o">+=</span> <span class="n">i</span><span class="o">;</span><span class="n">a</span>
<span class="o">}</span>
<span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">print</span><span class="o">(</span><span class="n">sum</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/summation_test.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">fig 1. Sum 테스트</figcaption>
</figure>
<p>실제로 테스트를 해 보면 기본 타입을 사용한 경우가 박싱된 타입을 사용한 경우보다 약 4.5배 정도 빠르다. 꽤 무시하기 힘든 성능 차이인 것 같다. 하지만 이를 최적화 하기 전에 정말 성능이 중요한 부분인지를 고민해봐야 한다. 이러한 성능 차이가 실제 어플리케이션에서 큰 영향을 미칠까?</p>
<p>많은 비즈니스 어플리케이션의 핵심 로직은 데이터베이스와 연동되어 데이터를 조회하고, 이를 가공하여 사용자에게 제공하는 과정으로 이루어져 있다. 필요한 경우 다른 원격 서비스를 호출하기도 한다. 이러한 자바 프로세스 외부에서 발생하는 시간 지연은 기본 타입의 박싱, 언박싱 비용보다 어마어마하게 크다. 따라서 비즈니스 로직에서 매우 성능 집약적인 수치 계산이 일어나지 않는 한 기본 타입의 박싱, 언박싱 비용은 무시해도 좋으며 박싱, 언박싱이 일어나는 상황에서의 최적화가 어려운 일도 아니다.</p>
<h2 id="자바의-근본적인-문제">자바의 근본적인 문제</h2>
<p>또 원시 타입 사용을 지양해야 할 이유로 <code class="language-plaintext highlighter-rouge">ArrayList</code> 와 같은 컬렉션에 원시 타입을 사용할 수 없다는 점이 있다. <code class="language-plaintext highlighter-rouge"><Integer></code> 는 존재하지만 <code class="language-plaintext highlighter-rouge"><int></code> 는 불가능하다. 자바는 제네릭이 나오기 전에 설계된 언어이기 때문에 컬렉션이 원시타입을 지원하지 못한다. 템플릿 변수가 컴파일을 거치면 실제 타입별 코드로 변환되는 C++ 과 다르게 제네릭을 사용한 타입이 컴파일을 거치면 모두 <code class="language-plaintext highlighter-rouge">Object</code> 타입으로 변환된다. 이는 제네릭을 사용하는 컬렉션에 원시 타입을 사용할 수 없다는 것을 의미한다.</p>
<p>원시 타입을 사용할 수 있는 자료구조는 array 뿐이고 유연하게 사용할 수 없기 때문에 결국 <code class="language-plaintext highlighter-rouge">Map</code>, <code class="language-plaintext highlighter-rouge">Set</code> 등 다양한 자료구조를 활용하기 위해 박싱된 기본 타입을 사용해야만 한다. 선언적으로 데이터 가공을 정의할 수 있는 Java 11 의 Stream API 를 사용할 때도 마찬가지다. <code class="language-plaintext highlighter-rouge">Stream</code> 은 원시 타입을 사용할 수 없다. <code class="language-plaintext highlighter-rouge">IntStream</code>, <code class="language-plaintext highlighter-rouge">LongStream</code> 등의 원시 타입 스트림을 제공하긴 하지만 API 구성에 있어 제네릭 기반의 스트림을 사용하는 것이 훨씬 유연하다.</p>
<h2 id="박싱된-기본-타입의-미래">박싱된 기본 타입의 미래</h2>
<p>자바에서는 박싱 타입에 대한 성능 개선 작업을 지속하고 있으며 새로운 자바 버전이 나올때 마다 꾸준히 개선되고 있다. 특히 Project Valhalla 에서는 메서드, 제네릭 실행 기능과 같은 클래스의 모든 장점을 갖지만 동일성은 없는 클래스인 ‘값 클래스’, ‘원시 클래스’ 도입 등 박싱된 기본 타입의 성능을 향상시키기 위해 여러 가지 방법을 고안하고 있으므로 앞으로 자바에서 박싱된 기본 타입을 사용하는 것이 더욱 유리해질 것이다.</p>
<h2 id="reference">Reference</h2>
<ul>
<li><a href="https://openjdk.org/projects/valhalla">Project Valhalla</a></li>
<li><a href="https://www.itworld.co.kr/news/278617">프로젝트 발할라, 야심찬 자바 리팩터의 내부 들여다보기</a></li>
</ul>정확히 말하면 ‘비즈니스 도메인’을 나타내는 모델에서 자바 원시 타입을 사용하지 말자고 말하고 싶다.SQL JOIN 문을 비교해보자2023-12-11T00:00:00+00:002023-12-11T00:00:00+00:00https://realrain.net/posts/compare-sql-join<p>두 테이블 사이의 연관된 데이터를 조회할 때 SQL의 JOIN 문을 사용할 수 있습니다. JOIN 문에는 어떤 것들이 있는지 알아보고, 각각의 특징을 비교해보겠습니다.</p>
<h2 id="테이블-정의">테이블 정의</h2>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Database : MySQL</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">student</span> <span class="p">(</span>
<span class="n">id</span> <span class="nb">INT</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
<span class="n">name</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">40</span><span class="p">),</span>
<span class="n">major</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">40</span><span class="p">)</span>
<span class="p">);</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">course</span> <span class="p">(</span>
<span class="n">id</span> <span class="nb">INT</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
<span class="n">student_id</span> <span class="nb">INT</span><span class="p">,</span>
<span class="n">name</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">40</span><span class="p">),</span>
<span class="n">credit</span> <span class="nb">FLOAT</span>
<span class="p">);</span>
</code></pre></div></div>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/student_table.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">fig 1. Student Table</figcaption>
</figure>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/cource_table.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">fig 2. Course Table</figcaption>
</figure>
<h2 id="inner-join-내부-조인">INNER JOIN (내부 조인)</h2>
<p>INNER JOIN 은 조인 컬럼을 기준으로 두 테이블 사이의 공통된 데이터만을 조회합니다. INNER JOIN 은 다음과 같이 사용할 수 있습니다.</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 명시적 내부 조인 (Explicit Inner Join)</span>
<span class="k">SELECT</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">student</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">credit</span>
<span class="k">FROM</span> <span class="n">student</span> <span class="k">INNER</span> <span class="k">JOIN</span> <span class="n">course</span> <span class="k">ON</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span> <span class="o">=</span> <span class="n">course</span><span class="p">.</span><span class="n">student_id</span><span class="p">;</span>
</code></pre></div></div>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/inner_join.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">fig 3. Inner Join Result</figcaption>
</figure>
<p>묵시적 내부 조인이라 하여 다음과 같이 사용할 수도 있습니다. 결과는 위와 동일하며, 조금 더 간단하게 사용할 수 있습니다.</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 묵시적 내부 조인 (Implicit Inner Join)</span>
<span class="k">SELECT</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">student</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">credit</span>
<span class="k">FROM</span> <span class="n">student</span><span class="p">,</span> <span class="n">course</span>
<span class="k">WHERE</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span> <span class="o">=</span> <span class="n">course</span><span class="p">.</span><span class="n">student_id</span><span class="p">;</span>
</code></pre></div></div>
<h2 id="outer-join-외부-조인">OUTER JOIN (외부 조인)</h2>
<p>OUTER JOIN 은 조인 컬럼을 기준으로 두 테이블 사이의 공통된 데이터와 공통되지 않은 데이터를 모두 조회합니다. OUTER JOIN 은 다음 세 가지로 나뉩니다.</p>
<ul>
<li>LEFT OUTER JOIN</li>
<li>RIGHT OUTER JOIN</li>
<li>FULL OUTER JOIN</li>
</ul>
<h3 id="left-outer-join-왼쪽-외부-조인">LEFT OUTER JOIN (왼쪽 외부 조인)</h3>
<p>LEFT OUTER JOIN 은 조인 컬럼을 기준으로 왼쪽 테이블의 모든 데이터와 오른쪽 테이블의 공통된 데이터를 조회합니다. 오른쪽 테이블의 공통된 데이터가 없다면 <code class="language-plaintext highlighter-rouge">NULL</code> 로 표시됩니다.</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">student</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">credit</span>
<span class="k">FROM</span> <span class="n">student</span> <span class="k">LEFT</span> <span class="k">OUTER</span> <span class="k">JOIN</span> <span class="n">course</span> <span class="k">ON</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span> <span class="o">=</span> <span class="n">course</span><span class="p">.</span><span class="n">student_id</span><span class="p">;</span>
</code></pre></div></div>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/left_outer_join.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">fig 4. Left Outer Join Result</figcaption>
</figure>
<p>위 결과에서 <code class="language-plaintext highlighter-rouge">student_id = 4</code> 인 컬럼은 course 테이블에 없기 때문에 <code class="language-plaintext highlighter-rouge">NULL</code> 로 표시됩니다.</p>
<h3 id="right-outer-join-오른쪽-외부-조인">RIGHT OUTER JOIN (오른쪽 외부 조인)</h3>
<p>RIGHT OUTER JOIN 은 조인 컬럼을 기준으로 오른쪽 테이블의 모든 데이터와 왼쪽 테이블의 공통된 데이터를 조회합니다. 왼쪽 테이블의 공통된 데이터가 없다면 <code class="language-plaintext highlighter-rouge">NULL</code> 로 표시됩니다.</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">student</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">credit</span>
<span class="k">FROM</span> <span class="n">student</span> <span class="k">RIGHT</span> <span class="k">OUTER</span> <span class="k">JOIN</span> <span class="n">course</span> <span class="k">ON</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span> <span class="o">=</span> <span class="n">course</span><span class="p">.</span><span class="n">student_id</span><span class="p">;</span>
</code></pre></div></div>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/right_outer_join.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">fig 5. Right Outer Join Result</figcaption>
</figure>
<p>위 결과에서 <code class="language-plaintext highlighter-rouge">student_id = 5</code> 인 컬럼은 student 테이블에 없기 때문에 <code class="language-plaintext highlighter-rouge">NULL</code> 로 표시됩니다. 결국 RIGHT OUTER JOIN 과 LEFT OUTER JOIN 은 쿼리에 사용된 테이블의 순서만 바뀐다면 동일한 결과를 얻을 수 있으므로, 둘 중 하나만 사용하면 됩니다.</p>
<h3 id="full-outer-join-전체-외부-조인">FULL OUTER JOIN (전체 외부 조인)</h3>
<p>FULL OUTER JOIN 은 두 테이블의 모든 데이터를 조회합니다. LEFT OUTER JOIN 과 RIGHT OUTER JOIN 의 결과를 합친 것과 동일합니다.</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">student</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">credit</span>
<span class="k">FROM</span> <span class="n">student</span> <span class="k">FULL</span> <span class="k">OUTER</span> <span class="k">JOIN</span> <span class="n">course</span> <span class="k">ON</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span> <span class="o">=</span> <span class="n">course</span><span class="p">.</span><span class="n">student_id</span><span class="p">;</span>
</code></pre></div></div>
<p>PostgresQL, Oracle 에서는 FULL OUTER JOIN 을 지원하지만, MySQL 에서는 FULL OUTER JOIN 을 지원하지 않습니다. 따라서 FULL OUTER JOIN 과 동일한 결과를 얻으려면 다음과 같이 LEFT OUTER JOIN 과 RIGHT OUTER JOIN 을 합쳐서 사용해야 합니다.</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- MySQL 에서 FULL OUTER JOIN 을 사용하는 방법</span>
<span class="k">SELECT</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">student</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">credit</span>
<span class="k">FROM</span> <span class="n">student</span> <span class="k">LEFT</span> <span class="k">OUTER</span> <span class="k">JOIN</span> <span class="n">course</span> <span class="k">ON</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span> <span class="o">=</span> <span class="n">course</span><span class="p">.</span><span class="n">student_id</span>
<span class="k">UNION</span>
<span class="k">SELECT</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">student</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">credit</span>
<span class="k">FROM</span> <span class="n">student</span> <span class="k">RIGHT</span> <span class="k">OUTER</span> <span class="k">JOIN</span> <span class="n">course</span> <span class="k">ON</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span> <span class="o">=</span> <span class="n">course</span><span class="p">.</span><span class="n">student_id</span><span class="p">;</span>
</code></pre></div></div>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/full_outer_join.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">fig 6. Full Outer Join Result</figcaption>
</figure>
<h2 id="cross-join-교차-조인">CROSS JOIN (교차 조인)</h2>
<p>CROSS JOIN 은 두 테이블의 모든 행의 가능한 조합<a href="https://ko.wikipedia.org/wiki/%EA%B3%B1%EC%A7%91%ED%95%A9">(곱집합)</a>을 모두 조회합니다. CROSS JOIN 은 다음과 같이 사용할 수 있습니다.</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">student</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">credit</span>
<span class="k">FROM</span> <span class="n">student</span> <span class="k">CROSS</span> <span class="k">JOIN</span> <span class="n">course</span><span class="p">;</span>
</code></pre></div></div>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/cross_join.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">fig 7. Cross Join Result</figcaption>
</figure>
<p>CROSS JOIN 이 INNER, OUTER 조인과 다른 점은 조인 컬럼을 반드시 사용하지 않아도 된다는 것입니다. 하지만 조인 컬럼이나 별도의 조건을 사용하지 않는다면 두 테이블의 모든 행의 가능한 조합을 조회하므로 테이블의 행의 수가 많다면 매우 많은 데이터를 조회하게 됩니다.</p>
<p>다음과 같이 CROSS JOIN 에 조인 컬럼을 지정한다면 INNER JOIN 과 동일한 결과를 얻을 수 있습니다.</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">student</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">course</span><span class="p">.</span><span class="n">credit</span>
<span class="k">FROM</span> <span class="n">student</span> <span class="k">CROSS</span> <span class="k">JOIN</span> <span class="n">course</span> <span class="k">ON</span> <span class="n">student</span><span class="p">.</span><span class="n">id</span> <span class="o">=</span> <span class="n">course</span><span class="p">.</span><span class="n">student_id</span><span class="p">;</span>
</code></pre></div></div>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/inner_join.png" alt="" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">fig 8. Corss Join with Join Column Result</figcaption>
</figure>두 테이블 사이의 연관된 데이터를 조회할 때 SQL의 JOIN 문을 사용할 수 있습니다. JOIN 문에는 어떤 것들이 있는지 알아보고, 각각의 특징을 비교해보겠습니다.사이드 프로젝트에서 데이터베이스 선택하기2023-10-04T00:00:00+00:002023-10-04T00:00:00+00:00https://realrain.net/posts/choose-database<p>조금씩 끄적이고 있는 사이드 프로젝트가 있다. 정부에서 제공하는 Open API 를 통해 데이터를 모은 다음, 조금 더 편리하게 보여줄 수 있는 웹 사이트나 어플리케이션을 만들 생각이다. API 를 연동할 수 있는 클라이언트와 매일 새로 생겨나는 데이터를 저장할 배치 잡을 포함한 프로토타입이 완성되어 AWS 환경에 배포를 해보려고 조금 공부를 해 보았는데 몇 가지 문제가 생겼다.</p>
<p>이 프로젝트가 돈을 벌 수 있을지 아닐지 모르지만 수익이 없더라도 개인적인 목적으로도 활용할 여지가 있기에 오랫동안 유지할 수 있는 서비스이길 원하므로 최대한 비용 효율적으로 어플리케이션을 구성해야 했다. 직장에서야 매니지드 K8S 환경에 데이터베이스도 성능이나 비즈니스 적합도만 고민하면서 고르면 되었지만 이건 내 지갑에서 달러가 나가는 문제이다보니 기술 선택에 있어 비용이라는 새로운 변수가 하나 생긴 셈이다.</p>
<p>프로토타입은 가장 익숙한 Spring 기반으로 MySQL이나 PostgresQL 과 같은 RDB 를 쓰지 않을까 했는데 요금을 보고 다른 데이터베이스를 사용해야겠다는 생각이 들었다. 2023년 10월 기준으로 AWS에서는 RDS라는 이름으로 관계형 데이터베이스 서비스를 제공하고 있다.</p>
<p>구성에 따라 조금씩 다르지만 가장 저렴하게 구성했을 때 예시로 MySQL 호환 Aurora 를 t2.small 인스턴스를 사용해 한달동안 사용하면 대략 30달러의 비용이 소요된다. 요즘 환율 기준으로 대략 4만원 정도가 드는 셈인데 트래픽도 거의 없을 프로젝트에 서버 인스턴스를 제외하고 순수 DB 비용으로만 월 4만원을 지출하는 건 낭비라고 생각했다.</p>
<p>인스턴스가 항상 떠 있어야 하는 다른 RDB 서비스나 Mongodb 호환 DocumentDB 도 비용은 비슷한 것 같아서 DynamoDB를 사용해보기로 했다. DynamoDB 의 장점은 서버리스 서비스이기 때문에 인스턴스가 필요가 없어 요금이 저렴하다는 점이다. 지금 만들고 있는 것 같은 소규모 프로젝트는 초기에는 프로덕션 환경에서도 트래픽이 거의 없을 것이기 때문에 제로 스케일링시 요금을 거의 내지 않는것도 가능하다.</p>
<p>다만, 단점은 AWS 종속적인 서비스이기 떄문에 AWS에서 제공하는 SDK 외에는 Spring 을 비롯한 프레임워크와 통합하려면 다소 품이 든다는 점이다. 공식적으로 Spring Data 에서 DynamoDB 바인딩을 지원하고 있지는 않기 때문에 서드파티 패키지를 사용하거나 직접 구현해야 하는 부분들이 존재한다. 하지만 NoSQL 데이터베이스를 사용할 때 Spring Data 에서 제공하는 기능의 장점이 얼마나 있을까에 대해서는 조금 의문이 있기도 해서 일단은 도전해 볼 생각이다.</p>조금씩 끄적이고 있는 사이드 프로젝트가 있다. 정부에서 제공하는 Open API 를 통해 데이터를 모은 다음, 조금 더 편리하게 보여줄 수 있는 웹 사이트나 어플리케이션을 만들 생각이다. API 를 연동할 수 있는 클라이언트와 매일 새로 생겨나는 데이터를 저장할 배치 잡을 포함한 프로토타입이 완성되어 AWS 환경에 배포를 해보려고 조금 공부를 해 보았는데 몇 가지 문제가 생겼다.CQRS에 대한 오해와 읽기모델 분리하기2023-06-03T00:00:00+00:002023-06-03T00:00:00+00:00https://realrain.net/posts/misconceptions-about-cqrs<h2 id="cqrs에-대한-오해">CQRS에 대한 오해</h2>
<p>CQRS(Command and Query Responsibility Seperation) 패턴은 읽기(Query) 요구사항과 생성, 삭제, 변경을 포함하는 명령(Command) 요구사항을 처리하는데에 대한 책임을 분리하는 것을 의미합니다. CQRS 패턴은 클린 아키텍처, 이벤트 소싱등의 아키텍처와 주로 자주 같이 언급되기 때문에 일종의 아키텍처로 여겨지거나 복잡한 응용기술중 하나로 오해받기도 합니다.</p>
<p>그러나 CQRS는 매우 <strong>간단한 패턴</strong>이며 기존 어플리케이션 구성에서 <strong>단계적 리팩터링을 통해 적용할 수 있는 패턴</strong>입니다. 본 글에서는 도메인 주도 설계 (DDD, Domain Driven Design)로 만들어진 어플리케이션에서 발생할 수 있는 문제점을 바탕으로 CQRS 패턴을 설명하지만 CQRS 패턴은 우리가 흔히 아는 다른 디자인 패턴들과 마찬가지로 아키텍처 독립적인 기술입니다.</p>
<h2 id="도메인-모델을-읽기-요구사항에서-사용할-때의-문제점">도메인 모델을 읽기 요구사항에서 사용할 때의 문제점</h2>
<p>계층형 구조를 가지고 있는 많은 어플리케이션에서는 관계형 데이터베이스와 같은 데이터 소스로부터 가져온 데이터를 어플리케이션의 데이터 접근 계층에서 추상화된 도메인 모델로 변환하고, 해당 클래스를 통해 여러 비즈니스 로직과 관련된 동작들을 수행하게 됩니다. 이러한 동작들을 구현하기 위해서는 필연적으로 데이터 일관성을 보장하기 위해 여러가지 검증 로직이 추가되고, 여러 프로세스가 같은 데이터에 접근할 때 일어나는 경합을 막기 위한 동시성 제어 로직들이 수반되게 됩니다.</p>
<p>어플리케이션의 읽기 요구사항에서 명령 요구사항에서와 동일한 도메인 모델을 사용하게되면 데이터 일관성을 보장하기 위해 구현된 여러가지 로직들이 읽기 요구사항에도 많은부분 동일하게 적용되어 불필요한 오버헤드를 발생시킬 수 있습니다. 예를 들어, Spring Data JPA 와 같은 ORM 프레임워크에서 영속성 컨텍스트가 제공하는 캐싱 및 변경감지 로직들은 읽기 요구사항에서는 불필요합니다. 또한 읽기 요구사항에서 필요한 데이터 필드만을 선별적으로 가져오기가 어려워지고, 복잡한 도메인 클래스 - DTO 간 변환 로직을 구현해야 합니다. 또 엔티티 관계 구성에 따라 불필요한 테이블 조인이 발생할 수도 있습니다.</p>
<p>무엇보다 가장 큰 문제는 읽기 요구사항이 도메인 클래스와 연관 클래스들의 설계에 영향을 줄 수 있다는 것입니다. 읽기 요구사항이 추가될 때마다 도메인의 핵심 기능과는 무관한 속성이 추가될 가능성이 있고 도메인 모델의 복잡도는 증가하게 됩니다. 특히 애그리거트(Aggregate) 하위에 존재하는 루트가 아닌 엔티티를 조회해야 하는 요구사항이 발생할 때 도메인 주도 설계 원칙에 위배되는 하위 엔티티에 대한 레포지터리들이 무분별하게 생겨날 위험도 있습니다.</p>
<h2 id="cqrs-패턴으로-리팩터링하기-in-spring">CQRS 패턴으로 리팩터링하기 (in Spring)</h2>
<p>위와같은 문제가 발생하는 근본적인 문제는 도메인 주도 설계가 도메인 상태의 일관성을 유지하는 것을 주된 목표로 애그리거트 단위의 업데이트를 강제하기 때문에 읽기 요구사항이 필요한 기능과 코드레벨에서 불일치가 발생하기 때문입니다. 따라서 읽기 요구사항에 의해 도메인 모델이 복잡해지는 경우 도메인 모델에서 읽기 모델을 분리할 수 있습니다. 다음은 스프링 프레임워크가 적용된 자바 어플리케이션을 CQRS 패턴을 통해 리팩터링 하는 예제입니다.</p>
<p>입학원서를 나타내는 Application 엔티티와 1:N 관계를 가지는 증빙파일 Attachment 엔티티가 있습니다. Attachment 는 Application 의 하위 도메인이므로 Application 도메인을 통해서만 추가, 제거되어야 하며 repository 도 root 엔티티인 Application 만 가져야 합니다. 마찬가지로 입학 원서를 심사하는 심사자 Reviewer 엔티티도 1:N 으로 존재합니다.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Entity</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">Application</span> <span class="o">{</span>
<span class="nd">@Id</span>
<span class="kd">private</span> <span class="nc">Long</span> <span class="n">id</span><span class="o">;</span>
<span class="kd">private</span> <span class="nc">Long</span> <span class="n">applicantId</span><span class="o">;</span>
<span class="nd">@OneToMany</span>
<span class="kd">private</span> <span class="nc">List</span><span class="o"><</span><span class="nc">Attachment</span><span class="o">></span> <span class="n">attachments</span><span class="o">;</span>
<span class="nd">@OneToMany</span>
<span class="kd">private</span> <span class="nc">List</span><span class="o"><</span><span class="nc">Reviewer</span><span class="o">></span> <span class="n">reviewers</span><span class="o">;</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">addAttachment</span><span class="o">(</span><span class="nc">String</span> <span class="n">filePath</span><span class="o">)</span> <span class="o">{</span> <span class="cm">/* */</span> <span class="o">}</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">deleteAttachment</span><span class="o">(</span><span class="nc">Long</span> <span class="n">attachmentId</span><span class="o">)</span> <span class="o">{</span> <span class="cm">/* */</span> <span class="o">}</span>
<span class="o">}</span>
<span class="nd">@Entity</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">Attachment</span> <span class="o">{</span>
<span class="nd">@Id</span>
<span class="kd">private</span> <span class="nc">Long</span> <span class="n">id</span><span class="o">;</span>
<span class="kd">private</span> <span class="nc">Long</span> <span class="n">applicationId</span><span class="o">;</span>
<span class="kd">private</span> <span class="nc">String</span> <span class="n">filePath</span><span class="o">;</span>
<span class="kd">private</span> <span class="nc">LocalDateTime</span> <span class="n">createdAt</span><span class="o">;</span>
<span class="o">}</span>
<span class="nd">@Entity</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">Reviewer</span> <span class="o">{</span>
<span class="nd">@Id</span>
<span class="kd">private</span> <span class="nc">Long</span> <span class="n">id</span><span class="o">;</span>
<span class="kd">private</span> <span class="nc">Long</span> <span class="n">professorId</span><span class="o">;</span>
<span class="o">}</span>
<span class="kd">public</span> <span class="kd">interface</span> <span class="nc">ApplicationRepository</span> <span class="kd">extends</span> <span class="nc">CrudRepository</span><span class="o"><</span><span class="nc">Application</span><span class="o">,</span> <span class="nc">Long</span><span class="o">></span> <span class="o">{</span>
<span class="nc">Optional</span><span class="o"><</span><span class="nc">Application</span><span class="o">></span> <span class="nf">findById</span><span class="o">(</span><span class="nc">Long</span> <span class="n">id</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>
<p>이 구조에서 Application 에 대한 Attachment 추가나 삭제는 꽤 잘 동작할 것입니다. 모든 추가/삭제 요청은 Application ID 를 통해 Application 를 찾은뒤에 이루어질 테니까요. 만약 지원자 (applicantId) 가 올린 모든 Attachment 를 조회해야 할 경우는 어떨까요?</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">interface</span> <span class="nc">ApplicationRepository</span> <span class="kd">extends</span> <span class="nc">CrudRepository</span><span class="o"><</span><span class="nc">Application</span><span class="o">,</span> <span class="nc">Long</span><span class="o">></span> <span class="o">{</span>
<span class="nc">Optional</span><span class="o"><</span><span class="nc">Application</span><span class="o">></span> <span class="nf">findById</span><span class="o">(</span><span class="nc">Long</span> <span class="n">id</span><span class="o">);</span>
<span class="nc">List</span><span class="o"><</span><span class="nc">Application</span><span class="o">></span> <span class="nf">findByApplicantId</span><span class="o">(</span><span class="nc">Long</span> <span class="n">applicantId</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>
<p>위와 같이 새로운 조회 메서드 <code class="language-plaintext highlighter-rouge">findByApplicantId</code> 를 ApplicationRepository 에 추가해 지원자 기준으로 모든 Application 을 가져온 뒤 stream 을 활용해 모든 Attachment 리스트를 만들어 낼 수 있겠지요. Attachment 만을 가져오기 위해 Reviewer 와도 불필요한 조인이 발생할 것입니다. fetch type 을 LAZY 로 설정해 해결할 수도 있겠지만 이는 Application 을 가져오는 다른 모든 기능들에 영향을 주기 때문에 다른 곳에서 성능 저하가 발생할 수 있습니다. 읽기 요구사항을 만족시키기 위한 변경사항이 다른곳에 영향을 주는 대표적인 예입니다.</p>
<p>그렇다면 Attachemnt 만을 조회할 수 있는 JPA Repository 를 추가하면 되지 않을까요? 조금은 맞았지만 이 역시 좋은 방법은 아닙니다. Attachement 가 root 엔티티가 아님에도 추가/삭제가 가능한 인터페이스가 열려 있으면 조심한다 하더라도 데이터 일관성이 깨질 수 있는 잠재적인 위험이 있기 때문입니다. 읽기 요구사항에 불필요한 영속성 컨텍스트가 여전히 동작하기도 하고요.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Transactional</span><span class="o">(</span><span class="n">readOnly</span> <span class="o">=</span> <span class="kc">true</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">interface</span> <span class="nc">AttachmentRepository</span> <span class="kd">extends</span> <span class="nc">CrudRepository</span><span class="o"><</span><span class="nc">Attachment</span><span class="o">,</span> <span class="nc">Long</span><span class="o">></span> <span class="o">{</span>
<span class="nd">@Query</span><span class="o">(</span><span class="cm">/* */</span><span class="o">)</span>
<span class="nc">List</span><span class="o"><</span><span class="nc">Attachment</span><span class="o">></span> <span class="nf">findByApplicantId</span><span class="o">(</span><span class="nc">Long</span> <span class="n">applicantId</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>
<p>위와 같이 읽기 전용 트랜잭션을 가지도록 제한하거나 JPA Projection 을 활용할수도 있겠습니다. 업데이트 동작을 제한할수도 있고 영속성 컨텍스트를 거치지 않게 할수도 있죠. 그러나 @Query 어노테이션에 JPQL 을 작성해야 하는 번거로움과 JPA 구현체 하나당 엔티티 하나만을 조회할 수 있다는 제한 때문에 읽기 요구사항이 생길때마다 하위 도메인에 대한 읽기 전용 Repository 가 추가되어야 합니다.</p>
<p>가장 좋은 방법은 읽기 요구사항만을 만족시키기 위한 별도의 쿼리 서비스를 구현하는 것입니다. 대표적으로 QueryDSL 이 있습니다. 엔티티 하나당 Repository 를 하나하나 만들 필요도 없고 번잡한 JPQL 이나 네이티브 쿼리를 작성할 필요도 없습니다.</p>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://learn.microsoft.com/ko-kr/azure/architecture/patterns/cqrs">CQRS 패턴 - Microsoft Learn</a></li>
</ul>CQRS에 대한 오해스프링 시큐리티 - Filter2022-09-27T00:00:00+00:002022-09-27T00:00:00+00:00https://realrain.net/posts/spring-security-filter<p><a href="https://docs.spring.io/spring-security/reference/servlet/architecture.html">참고 문서 : Spring Security / Servlet Applications / Architecture</a></p>
<p>스프링 시큐리티의 기능은 서블릿 필터 (Servlet Filter) 를 기반으로 구현되어 있다. 그렇다면 이 <code class="language-plaintext highlighter-rouge">Filter</code> 란 뭘까? 스프링이 아닌 기본적인 서블릿 어플리케이션으로 돌아가 보자.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// https://github.com/realrains/my-servlet-sample</span>
<span class="nd">@WebServlet</span><span class="o">(</span><span class="s">"/hello"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">HelloServlet</span> <span class="kd">extends</span> <span class="nc">HttpServlet</span> <span class="o">{</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">doGet</span><span class="o">(</span><span class="nc">HttpServletRequest</span> <span class="n">request</span><span class="o">,</span> <span class="nc">HttpServletResponse</span> <span class="n">response</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">IOException</span> <span class="o">{</span>
<span class="n">response</span><span class="o">.</span><span class="na">getWriter</span><span class="o">().</span><span class="na">print</span><span class="o">(</span><span class="s">"Hello Servlet!"</span><span class="o">);</span>
<span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"HelloServlet.doGet"</span><span class="o">);</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<p>서블릿 어플리케이션에서 위와 같은 서블릿을 선언하면 톰캣과 같은 서블릿 컨테이너가 request path 를 확인한후 적절한 서블릿을 찾아 서블릿에서 반환하는 값을 클라이언트에 되돌려 주게 된다.</p>
<p>그러나 특정한 서블릿으로 요청이 전달되기 전에 일괄적으로 수행해야할 작업이 필요할 수 있다. 클라이언트의 요청 로그를 남긴다거나, 요청에 담긴 데이터의 인코딩을 변경하거나, 요청 헤더에 담긴 토큰과 같은 인증정보를 확인하고 요청을 승인할 것인지 말것인지 결정하는 작업이 있을 수 있다.</p>
<p>이러한 작업들을 각 서블릿에서 직접 구현하게 되면 번거롭기도 하고 단일 책임 원칙을 위반하기 때문에 복잡도가 증가하고 테스트가 어려워질 수밖에 없다. 따라서 서블릿 외부에서 공통기능을 핸들링 한후 요청을 전달해주는것이 좋은데 그러한 상황에서 사용할 수 있는것이 바로 <code class="language-plaintext highlighter-rouge">Filter</code> 이다.</p>
<h3 id="filter">Filter</h3>
<p><a href="https://jakarta.ee/specifications/servlet/5.0/apidocs/jakarta/servlet/filter">JavaDoc (Jakarta EE) - Filter</a></p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">interface</span> <span class="nc">Filter</span> <span class="o">{</span>
<span class="k">default</span> <span class="kd">public</span> <span class="kt">void</span> <span class="nf">init</span><span class="o">(</span><span class="nc">FilterConfig</span> <span class="n">filterConfig</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">ServletException</span> <span class="o">{}</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">doFilter</span><span class="o">(</span><span class="nc">ServletRequest</span> <span class="n">request</span><span class="o">,</span> <span class="nc">ServletResponse</span> <span class="n">response</span><span class="o">,</span> <span class="nc">FilterChain</span> <span class="n">chain</span><span class="o">)</span>
<span class="kd">throws</span> <span class="nc">IOException</span><span class="o">,</span> <span class="nc">ServletException</span><span class="o">;</span>
<span class="k">default</span> <span class="kd">public</span> <span class="kt">void</span> <span class="nf">destroy</span><span class="o">()</span> <span class="o">{}</span>
<span class="o">}</span>
</code></pre></div></div>
<p><code class="language-plaintext highlighter-rouge">Filter</code> 는 리소스에 대한 접근을 필터링하는 작업을 수행하는 객체를 위한 인터페이스이다. 필터가 인스턴스화 될때 서블릿 컨테이너가 호출하는 <code class="language-plaintext highlighter-rouge">init</code> 메서드, <strong>요청/응답 체인</strong>에서 해당 필터를 거쳐갈 때 호출되는 <code class="language-plaintext highlighter-rouge">doFilter</code>, 필터의 작업이 종료되거나 중단될 때 clean-up 작업을 수행하는 <code class="language-plaintext highlighter-rouge">destroy</code> 메서드로 구성되어 있다.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@WebFilter</span><span class="o">(</span><span class="n">filterName</span> <span class="o">=</span> <span class="s">"LoggingFilter"</span><span class="o">,</span> <span class="n">urlPatterns</span> <span class="o">=</span> <span class="s">"/*"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">LoggingFilter</span> <span class="kd">implements</span> <span class="nc">Filter</span> <span class="o">{</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">doFilter</span><span class="o">(</span><span class="nc">ServletRequest</span> <span class="n">request</span><span class="o">,</span> <span class="nc">ServletResponse</span> <span class="n">response</span><span class="o">,</span> <span class="nc">FilterChain</span> <span class="n">chain</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">IOException</span><span class="o">,</span> <span class="nc">ServletException</span> <span class="o">{</span>
<span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"LoggingFilter.doFilter Starts"</span><span class="o">);</span>
<span class="n">chain</span><span class="o">.</span><span class="na">doFilter</span><span class="o">(</span><span class="n">request</span><span class="o">,</span> <span class="n">response</span><span class="o">);</span>
<span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"LoggingFilter.doFilter Ends"</span><span class="o">);</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<p>간단히 요청 전후로 콘솔로그를 프린트하도록 하는 <code class="language-plaintext highlighter-rouge">LoggingFilter</code> 를 구현해보았다. <code class="language-plaintext highlighter-rouge">GET /hello</code> 요청을 서버로 전송하면 서버측 콘솔에서는 아래와 같은 결과를 확인할 수 있다.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>LoggingFilter.doFilter Starts
HelloServlet.doGet
LoggingFilter.doFilter Ends
</code></pre></div></div>
<p>이때 등장하는 체인이란 무엇일까? 단어의 의미에서 유추할 수 있듯 연속된 필터로 이루어진 체인을 말한다. <code class="language-plaintext highlighter-rouge">doFilter</code> 메서드에 파라미터로 주어지는 <code class="language-plaintext highlighter-rouge">FilterChain</code> 이 바로 그것이다. 필터는 1개 이상이 존재할 수 있으며 각각의 필터는 맡은 작업을 수행하고 작업이 다음 필터에서 수행될 수 있도록 제어권을 넘겨주면서 최종적으로 서블릿까지 도달한다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/filterchain.png" alt="filter chain" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">https://docs.spring.io/spring-security/reference/_images/servlet/architecture/filterchain.png</figcaption>
</figure>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@WebFilter</span><span class="o">(</span><span class="n">filterName</span> <span class="o">=</span> <span class="s">"ResponseAppendFilter"</span><span class="o">,</span> <span class="n">urlPatterns</span> <span class="o">=</span> <span class="s">"/*"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ResponseAppendFilter</span> <span class="kd">implements</span> <span class="nc">Filter</span> <span class="o">{</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">doFilter</span><span class="o">(</span><span class="nc">ServletRequest</span> <span class="n">request</span><span class="o">,</span> <span class="nc">ServletResponse</span> <span class="n">response</span><span class="o">,</span> <span class="nc">FilterChain</span> <span class="n">chain</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">IOException</span><span class="o">,</span> <span class="nc">ServletException</span> <span class="o">{</span>
<span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"ResponseAppendFilter.doFilter Starts"</span><span class="o">);</span>
<span class="n">chain</span><span class="o">.</span><span class="na">doFilter</span><span class="o">(</span><span class="n">request</span><span class="o">,</span> <span class="n">response</span><span class="o">);</span>
<span class="n">response</span><span class="o">.</span><span class="na">getWriter</span><span class="o">().</span><span class="na">append</span><span class="o">(</span><span class="s">" ResponseAppendFilter"</span><span class="o">);</span>
<span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"ResponseAppendFilter.doFilter Ends"</span><span class="o">);</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<p>response 에 특정 문자열을 추가하는 <code class="language-plaintext highlighter-rouge">ResponseAppendFilter</code> 를 추가하고 이전과 동일하게 <code class="language-plaintext highlighter-rouge">GET /hello</code> 요청을 날려보면 서버 측에서 아래와 같은 로그를 확인할 수 있다.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ResponseAppendFilter.doFilter Starts
LoggingFilter.doFilter Starts
HelloServlet.doGet
LoggingFilter.doFilter Ends
ResponseAppendFilter.doFilter Ends
</code></pre></div></div>
<p><code class="language-plaintext highlighter-rouge">ResponseAppendFilter</code>, <code class="language-plaintext highlighter-rouge">LoggingFilter</code> 순으로 로그가 찍히는 것을 확인할 수 있고 이때 <code class="language-plaintext highlighter-rouge">Filter</code> 내부에서 디버그를 통해 파라미터로 전달된 <code class="language-plaintext highlighter-rouge">FilterChain</code> 를 확인해보면</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/filterchain-debug.png" alt="FilterChain Instance" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">Filter 로 전달된 FilterChain, ApplicationFilterChain 은 Tomcat의 구현체이다</figcaption>
</figure>
<p>로그에서 확인한 순서대로 필터 설정을 보관하고 있음을 확인할 수 있다. <code class="language-plaintext highlighter-rouge">Filter</code> 가 서블릿 request 와 response 를 수정할 수 있다는 것에서 알 수 있듯이 순서가 앞선 필터는 그 뒤의 필터에 영향을 끼칠수 있게 되므로 <code class="language-plaintext highlighter-rouge">FilterChain</code> 에 정의된 <strong>필터의 순서는 웹 어플리케이션의 동작에 매우 큰 영향을 미칠 수 있음에 유의하여야 한다.</strong></p>
<h3 id="delegatingfilterproxy">DelegatingFilterProxy</h3>
<p><code class="language-plaintext highlighter-rouge">Filter</code> 는 Java EE 스펙이고 이는 Tomcat 같은 서블릿 컨테이너 영역에서 사용하는 객체이다. 위에서 살펴봤던 방식처럼 표준적인 방식으로 구현, 등록되었을 경우 해당 필터는 당연히 스프링 어플리케이션 컨텍스트 외부의 객체이므로 스프링 빈을 사용할 수 없다.</p>
<p>이를 해결하기 위해 spring-web 모듈에서는 <code class="language-plaintext highlighter-rouge">DelegatingFilterProxy</code> 라는 <code class="language-plaintext highlighter-rouge">Filter</code> 구현체를 제공한다. 해당 객체가 Tomcat 과 같은 서블릿 컨테이너와 스프링 어플리케이션 컨텍스트 사이의 일종의 브릿지 역할을 함으로서 스프링 빈을 사용하는 필터를 구현할 수 있게 해준다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/delegatingfilterproxy.png" alt="DelegatingFilterProxy" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">https://docs.spring.io/spring-security/reference/_images/servlet/architecture/delegatingfilterproxy.png</figcaption>
</figure>
<p><code class="language-plaintext highlighter-rouge">DelegatingFilterProxy</code> 는 생성시에 빈 필터 이름을 파라미터로 받는다. 내부 코드를 보면 <code class="language-plaintext highlighter-rouge">initDelegate</code> 에서 해당 빈 이름으로 어플리케이션 컨텍스트로부터 빈을 가져오고 동작을 위임할 <code class="language-plaintext highlighter-rouge">Filter</code> 로 세팅한다. 이후 <code class="language-plaintext highlighter-rouge">doFilter</code> 에서 빈으로 선언된 필터 객체에게 동작을 위임하는 것을 확인할 수 있다.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// DelegatingFilterProxy.class 구현 일부</span>
<span class="kd">protected</span> <span class="nc">Filter</span> <span class="nf">initDelegate</span><span class="o">(</span><span class="nc">WebApplicationContext</span> <span class="n">wac</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">ServletException</span> <span class="o">{</span>
<span class="nc">String</span> <span class="n">targetBeanName</span> <span class="o">=</span> <span class="n">getTargetBeanName</span><span class="o">();</span>
<span class="nc">Assert</span><span class="o">.</span><span class="na">state</span><span class="o">(</span><span class="n">targetBeanName</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">,</span> <span class="s">"No target bean name set"</span><span class="o">);</span>
<span class="c1">// 어플리케이션 컨텍스트에서 필터 빈을 가져온다.</span>
<span class="nc">Filter</span> <span class="n">delegate</span> <span class="o">=</span> <span class="n">wac</span><span class="o">.</span><span class="na">getBean</span><span class="o">(</span><span class="n">targetBeanName</span><span class="o">,</span> <span class="nc">Filter</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
<span class="k">if</span> <span class="o">(</span><span class="n">isTargetFilterLifecycle</span><span class="o">())</span> <span class="o">{</span>
<span class="n">delegate</span><span class="o">.</span><span class="na">init</span><span class="o">(</span><span class="n">getFilterConfig</span><span class="o">());</span>
<span class="o">}</span>
<span class="k">return</span> <span class="n">delegate</span><span class="o">;</span>
<span class="o">}</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">doFilter</span><span class="o">(</span><span class="nc">ServletRequest</span> <span class="n">request</span><span class="o">,</span> <span class="nc">ServletResponse</span> <span class="n">response</span><span class="o">,</span> <span class="nc">FilterChain</span> <span class="n">filterChain</span><span class="o">)</span>
<span class="kd">throws</span> <span class="nc">ServletException</span><span class="o">,</span> <span class="nc">IOException</span> <span class="o">{</span>
<span class="c1">// Lazily initialize the delegate if necessary.</span>
<span class="nc">Filter</span> <span class="n">delegateToUse</span> <span class="o">=</span> <span class="k">this</span><span class="o">.</span><span class="na">delegate</span><span class="o">;</span>
<span class="k">if</span> <span class="o">(</span><span class="n">delegateToUse</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
<span class="kd">synchronized</span> <span class="o">(</span><span class="k">this</span><span class="o">.</span><span class="na">delegateMonitor</span><span class="o">)</span> <span class="o">{</span>
<span class="n">delegateToUse</span> <span class="o">=</span> <span class="k">this</span><span class="o">.</span><span class="na">delegate</span><span class="o">;</span>
<span class="k">if</span> <span class="o">(</span><span class="n">delegateToUse</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
<span class="nc">WebApplicationContext</span> <span class="n">wac</span> <span class="o">=</span> <span class="n">findWebApplicationContext</span><span class="o">();</span>
<span class="k">if</span> <span class="o">(</span><span class="n">wac</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
<span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalStateException</span><span class="o">(</span><span class="s">"No WebApplicationContext found: "</span> <span class="o">+</span>
<span class="s">"no ContextLoaderListener or DispatcherServlet registered?"</span><span class="o">);</span>
<span class="o">}</span>
<span class="n">delegateToUse</span> <span class="o">=</span> <span class="n">initDelegate</span><span class="o">(</span><span class="n">wac</span><span class="o">);</span>
<span class="o">}</span>
<span class="k">this</span><span class="o">.</span><span class="na">delegate</span> <span class="o">=</span> <span class="n">delegateToUse</span><span class="o">;</span>
<span class="o">}</span>
<span class="o">}</span>
<span class="c1">// 필터 빈에 doFilter 동작을 위임한다.</span>
<span class="n">invokeDelegate</span><span class="o">(</span><span class="n">delegateToUse</span><span class="o">,</span> <span class="n">request</span><span class="o">,</span> <span class="n">response</span><span class="o">,</span> <span class="n">filterChain</span><span class="o">);</span>
<span class="o">}</span>
<span class="kd">protected</span> <span class="kt">void</span> <span class="nf">invokeDelegate</span><span class="o">(</span>
<span class="nc">Filter</span> <span class="n">delegate</span><span class="o">,</span> <span class="nc">ServletRequest</span> <span class="n">request</span><span class="o">,</span> <span class="nc">ServletResponse</span> <span class="n">response</span><span class="o">,</span> <span class="nc">FilterChain</span> <span class="n">filterChain</span><span class="o">)</span>
<span class="kd">throws</span> <span class="nc">ServletException</span><span class="o">,</span> <span class="nc">IOException</span> <span class="o">{</span>
<span class="n">delegate</span><span class="o">.</span><span class="na">doFilter</span><span class="o">(</span><span class="n">request</span><span class="o">,</span> <span class="n">response</span><span class="o">,</span> <span class="n">filterChain</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>
<h3 id="filterchainproxy">FilterChainProxy</h3>
<p>스프링 시큐리티에서는 위에서 설명한 <code class="language-plaintext highlighter-rouge">DelegatingFilterProxy</code> 에서 필터 동작을 위임할 빈 필터를 <code class="language-plaintext highlighter-rouge">FilterChainProxy</code> 라는 클래스로 제공한다. <code class="language-plaintext highlighter-rouge">FilterChainProxy</code> 는 내부에 <code class="language-plaintext highlighter-rouge">SecurityFilterChain</code> 을 구현한 필터들을 리스트로 가지고 있는데, <code class="language-plaintext highlighter-rouge">FilterChainProxy</code> 가 <code class="language-plaintext highlighter-rouge">doFilter</code> 동작을 위임받으면 다시 내부의 <code class="language-plaintext highlighter-rouge">SecurityFilterChain</code> 리스트들에게 필터링 동작을 위임하게 된다. 이러한 구조를 그림으로 도식화하면 다음과 같다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/securityfilterchain.png" alt="SecurityFilterChain" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">https://docs.spring.io/spring-security/reference/_images/servlet/architecture/securityfilterchain.png</figcaption>
</figure>
<p>스프링 시큐리티 의존성을 추가한 뒤, 실제로 <code class="language-plaintext highlighter-rouge">DelegatingFilterProxy</code> 클래스에서 delegate filter 로 어떤 빈이 사용되는지 브레이크 포인트를 걸고 확인해보면 아래와 같이 <code class="language-plaintext highlighter-rouge">FilterChainProxy</code> 가 할당된 것을 볼 수 있고, <code class="language-plaintext highlighter-rouge">FilterChainProxy.filterChains</code> 에는 스프링 시큐리티에서 기본적으로 제공하는 <code class="language-plaintext highlighter-rouge">SecurityFilterChain</code> 한개가 들어 있는것을 볼수 있고, 해당 필터 체인에서 제공하는 필터 기능들을 <code class="language-plaintext highlighter-rouge">SecurityFilterChain.filters</code> 에서 확인할 수 있다. <code class="language-plaintext highlighter-rouge">FilterChainProxy</code> 가 스프링 시큐리티에서 제공하는 서블릿 필터링 기능의 시작점인 셈이다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/filterchainproxy-debug-point.png" alt="DelegateingFilterProxy Debug Point" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;"></figcaption>
</figure>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/securityfilterchain-list.png" alt="SecurityFilterChainList" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">filterChains 에 SecurityFilterChain 이 등록되어 있다.</figcaption>
</figure>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">WebSecurityConfig</span> <span class="o">{</span>
<span class="nd">@Bean</span>
<span class="nd">@Order</span><span class="o">(</span><span class="mi">1</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">SecurityFilterChain</span> <span class="nf">helloSecurityFilterChain</span><span class="o">(</span><span class="nc">HttpSecurity</span> <span class="n">httpSecurity</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">Exception</span> <span class="o">{</span>
<span class="n">httpSecurity</span><span class="o">.</span><span class="na">antMatcher</span><span class="o">(</span><span class="s">"/hello/**"</span><span class="o">);</span>
<span class="k">return</span> <span class="n">httpSecurity</span><span class="o">.</span><span class="na">build</span><span class="o">();</span>
<span class="o">}</span>
<span class="nd">@Bean</span>
<span class="nd">@Order</span><span class="o">(</span><span class="mi">2</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">SecurityFilterChain</span> <span class="nf">globalSecurityFilterChain</span><span class="o">(</span><span class="nc">HttpSecurity</span> <span class="n">httpSecurity</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">Exception</span> <span class="o">{</span>
<span class="n">httpSecurity</span><span class="o">.</span><span class="na">antMatcher</span><span class="o">(</span><span class="s">"/**"</span><span class="o">);</span>
<span class="k">return</span> <span class="n">httpSecurity</span><span class="o">.</span><span class="na">build</span><span class="o">();</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<p>새로운 <code class="language-plaintext highlighter-rouge">SecurityFilterChain</code> 을 정의하려면 위와 같이 <code class="language-plaintext highlighter-rouge">HttpSecurity</code> 를 주입받아 빈으로 생성하면 된다. (<code class="language-plaintext highlighter-rouge">WebSecurityConfigurerAdapter</code> 의 확장 클래스를 통해 설정하는 방법은 스프링 시큐리티 5.7.3 기준으로 deprecated 된 상태이다.)</p>
<p>예시에서는 <code class="language-plaintext highlighter-rouge">/hello/**</code> 경로의 서블릿 요청을 핸들링하는 <code class="language-plaintext highlighter-rouge">helloSecurityFilterChain</code> 과 모든 경로의 요청을 핸들링하는 <code class="language-plaintext highlighter-rouge">globalSecurityFilterChain</code> 을 선언했다.</p>
<p><code class="language-plaintext highlighter-rouge">FilterChainProxy</code> 에 여러개의 <code class="language-plaintext highlighter-rouge">SecurityFilterChain</code> 이 존재할 경우 <code class="language-plaintext highlighter-rouge">FilterChainProxy</code>는 조건에 맞는 필터 체인 중 가장 먼저 매칭되는 <code class="language-plaintext highlighter-rouge">SecurityFilterChain</code>을 사용하게 된다. 따라서 각 빈의 순서를 <code class="language-plaintext highlighter-rouge">@Order</code>를 통해 알맞게 정의하는게 중요하다.</p>
<figure style="margin-top: 30px; margin-bottom: 30px; margin-left: 5%; margin-right: 5%;">
<img style="display: block; margin: 0 auto;" data-action="zoom" width="" src="/assets/image/multiple-securityfilterchain.png" alt="Multiple SecurityFilterChain" />
<figcaption style="margin-top: 15px; text-align: center; color: gray; font-style: italic;">/hello 요청에 대해 0번째 SecurityFilterChain이 매칭된다</figcaption>
</figure>참고 문서 : Spring Security / Servlet Applications / Architecture