<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Merge Simpson의 매너 있고 다정한 한국어 개발자 블로그]]></title><description><![CDATA[Spring Boot]]></description><link>https://blog.letsdev.me</link><generator>RSS for Node</generator><lastBuildDate>Mon, 07 Sep 2026 18:40:00 GMT</lastBuildDate><atom:link href="https://blog.letsdev.me/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[CORS 설정: PNA(Private Network Access) 정책 제어 <시나리오>]]></title><description><![CDATA[PNA의 역할 및 의의
거의 불필요하다는 설명으로 시작할까요?
💼 역할
PNA(Private Network Access) 정책은 브라우저가 ‘비인가된 공개(public) 웹 사이트에서 사용자의 비공개 네트워크(사설망) 주소로 접근할 수 없도록’ 제한하는, CORS 정책의 새로운 부가 옵션입니다.(사전 지식: CORS 설정 및 Preflight 요청)
예를 들어, 외부 사이트 https://blog.example.com에 접속해 사설망 http...]]></description><link>https://blog.letsdev.me/pna</link><guid isPermaLink="true">https://blog.letsdev.me/pna</guid><category><![CDATA[PNA]]></category><category><![CDATA[private network]]></category><category><![CDATA[private network access]]></category><category><![CDATA[CORS]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Sat, 13 Sep 2025 07:41:42 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1757029841244/95e3012c-4583-4c7e-a55c-402dfbd1621e.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-pna">PNA의 역할 및 의의</h1>
<h2 id="heading-6rgw7j2yiou2io2vhoyalo2vmoulpouklcdshktrqoxsnlzrozwg7iuc7j6r7zwg6rmm7jqupw">거의 불필요하다는 설명으로 시작할까요?</h2>
<h3 id="heading-kirwn5k8ioyxre2vocoq"><strong>💼 역할</strong></h3>
<p>PNA(Private Network Access) 정책은 브라우저가 ‘<strong>비인가된 공개(public) 웹 사이트에서 사용자의 비공개 네트워크(사설망) 주소로 접근할 수 없도록</strong>’ 제한하는, CORS 정책의 새로운 부가 옵션입니다.<br />(<sup>사전 지식:</sup> CORS 설정 및 Preflight 요청)</p>
<p>예를 들어, 외부 사이트 <code>https://blog.example.com</code>에 접속해 사설망 <code>http://192.168.0.10/api</code>에 요청을 보내려 하면, 브라우저가 서버와 협력해 차단합니다. CORS 설정일 뿐이므로 전통적인 네트워크 접근 제어·보안 규칙을 대신하지는 않죠.</p>
<p>비공개 네트워크임을 알 수 있는 아이피의 패턴이 몇 가지 있는데, 접속한 사이트가 외부망 서비스이면서, 요청 대상 서비스의 IP가 비공개 네트워크의 패턴에 해당하면 브라우저가 서버에 사전 요청으로 CORS 정책을 확인할 때 PNA 조건을 포함하죠.<sup>[1]</sup></p>
<p><strong>❓ 그렇다면 왜 이런 PNA 설정을 ‘거의 불필요했다’고 설명할까요? 살펴 봅시다!</strong></p>
<h3 id="heading-kirwn5ciioukpuydgcdrj4tsnouqkg"><strong>🐢 늦은 도입</strong></h3>
<p>구글은 2022년 PNA(Private Network Access) 사양을 발표했죠. 당시 크롬에 이미 PNA 사양 중 일부를 포함하긴 했지만(2021 말), 웹 표준에 도입한 시기가 굉장히 늦다고 볼 수 있습니다.</p>
<blockquote>
<p>Chrome은 비공개 네트워크 접근(PNA) 사양의 일환으로 ‘퍼블릭한 웹사이트에서 비공개 네트워크 엔드포인트에 대한 직접 액세스’를 지원 중단합니다. (2022)</p>
</blockquote>
<p>그런데 PNA를 늦게 도입한 배경은, 이미 현대 사회의 일반 환경에서 PNA 유무가 생각보다 치명적이지 않았기 때문이기도 합니다. ⬇️</p>
<hr />
<h3 id="heading-pna-1">🎯 누가 PNA 제어가 필요한 대상일까요? (1: 악성 사이트 이용자 입장)</h3>
<blockquote>
<p>“CSRF 방어가 약하던 시절, 여러 사람의 공유기가 악성 사이트에 의해 오염되곤 했습니다. 그때라면 PNA 정책이 아주 큰 효과를 거두었을 겁니다.”</p>
</blockquote>
<p>옛날 일부 공유기는 CSRF 방어가 약하거나 없었기 때문에, 악성 사이트에서 사용자의 브라우저를 통해 공유기에 제어 요청을 보낼 수 있었는데요. 이를 통해 방화벽을 무력화하거나 관리자 권한을 획득하는 등 일부 장비에 강력한 공격이 가능했습니다.</p>
<p>이런 이유로 PNA는 인터넷 보급 초창기 또는 IT 산업이 발전하던 시기에 도입하였다면 좋았을 정책이죠. 그 무렵 많은 사용자가 여전히 인터넷 익스플로러 애호가였다는 사실을 빼고 본다면요.</p>
<p><strong>현대적인 장비는 이러한 방어 체계를 이미 갖추고 있기 때문에, 작금에 이르러 거의 필요하지 않은 정책이 되었습니다.</strong> 물론 레거시 장비나 값싸고 인증받지 않은 라우터를 사용 중이라면 여전히 위험할 수 있었고, 이때는PNA 정책이 보안 강화 요인으로 유효할 수 있습니다.</p>
<h3 id="heading-pna-2">🎯 누가 PNA 제어가 필요한 대상일까요? (2: 서비스 제공자 입장)</h3>
<p>우선 ‘일반적인 환경’을 갖춘 조건에서는 PNA 제어가 그렇게 중요하지 않습니다.</p>
<ul>
<li><p><strong>💪 CORS 설정이 충분히 잘되어 있다면?</strong> → ✅ PNA 제어의 중요성이 낮습니다.</p>
</li>
<li><p><strong>🤝 서로 믿을 수 있는 사이트만 교류한다면?</strong> → ✅ PNA 제어의 중요성이 낮습니다.</p>
</li>
<li><p><strong>🌐 외부망/내부망을 혼용하지 않는다면?</strong> (정상적인 망 분리) → ✅ PNA 제어의 중요성이 낮습니다.</p>
</li>
</ul>
<p>앞서, PNA는 CORS 설정일 뿐이므로 전통적인 네트워크 접근 제어나 보안 규칙을 대신하지는 않는다고 설명했는데요. 오히려 사이트 이용자는 우리 사무실 등 이미 사설망 이용 가능 환경에 있을 확률이 높죠. 아니 정확히는 사설망에 이미 접속해 있어야 ‘사설망’에 요청하는 게 우리 서버에 닿을 수 있으니, 사설망 이용 환경임은 분명합니다.</p>
<p><strong>🔍 이 점에서 PNA 정책이 ‘일반 사용자’보다 ‘내부 직원’의 사용에 관련이 있다고 유추할 수 있는 겁니다.</strong></p>
<p>그래서 우리가 신뢰할 수 있는 모든 조건을 갖추었을 때는 추가로 브라우저와 PNA 관련 도움을 주고받을 필요가 없었던 거죠. 그만큼 표준 도입이 늦어도 논란이 적었습니다.</p>
<hr />
<h1 id="heading-7iuc64ky66as7jik">시나리오</h1>
<p>그렇다면 어떨 때 PNA 제어가 의미가 있고, 어떤 설명들이 단지 오해에서 비롯되었는지 뜯어 봅시다!</p>
<hr />
<h2 id="heading-pna-pna-dependent-scenarios">PNA 시나리오(PNA-Dependent Scenarios)</h2>
<p>PNA 정책을 도입함으로써 보안을 보조적으로 강화할 수 있는 보안 케이스입니다.</p>
<h3 id="heading-8jyicdslyxshleg7iks7j207yq4ioydtoyaqeyekcdsnoxsnqu">😈 악성 사이트 이용자 입장</h3>
<p>나중에 쓸래요.</p>
<h3 id="heading-8jupydshjzruytsiqqg7kcc6ro17j6qioyeheyepq">🔧 서비스 제공자 입장</h3>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text"><strong>내부 직원이 외부 서비스에 접속한 상태로 내부망에 접근? (매우 관련 있음)</strong></div>
</div>

<blockquote>
<p>한 대의 컴퓨터가 내·외부망을 혼용한다는 전제여야 합니다. 하지만 이는 일반적이지 않습니다.<br />일반적으로 내부망과 외부망의 혼용은 권장하지 않으며, 오히려 뚜렷한 구분을 중요시합니다.<br />(물리적 망 분리, 논리적 망 분리)</p>
</blockquote>
<p>이 항목은 PNA 설정과 관련이 매우 깊지만 이는 일반적인 설정이 아닙니다.</p>
<ol>
<li><p>직원이 외부망 서비스 <code>https://example.com</code>에 접속합니다.</p>
</li>
<li><p>해당 사이트의 스크립트가 (악의 또는 실수로) 내부망 <code>http://192.168.0.10/api</code>에 요청합니다.</p>
</li>
<li><p>신기하게도 그게 하필 또 우리 서버의 유효한 API 엔드포인트라고 전제합니다.</p>
</li>
<li><p>우리 서버가 평소 <code>https://example.com</code> 사용자를 신뢰하여 CORS를 허용했다고 전제합니다.</p>
</li>
<li><p><strong>Data 전달</strong>: 우리 서버 → 직원 브라우저</p>
</li>
<li><p>신기하게도 그 사이트는 응답을 받아서 본인들의 서버로 전송합니다.</p>
</li>
<li><p><strong>Data 전달:</strong> 직원 브라우저 → <code>example.com</code> 소유자와 관련되거나 무관한 외부 서버</p>
</li>
</ol>
<p>원래라면 내부망으로 격리되어 있었을 우리 서버의 데이터가, ‘신뢰한 사이트’에 접속한 내부 직원을 통해 외부 서버로 유출되는 시나리오입니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1757748007836/05b0c869-5da6-4134-8500-c11e9fc61cb1.webp" alt class="image--center mx-auto" /></p>
<p>이때 ‘내부 직원’이 악의적인 프록시 서버 등이 아닌 정상적인 브라우저를 사용했지만, 데이터가 유출되는 경로가 되었습니다. CORS 정책의 대상이 보안에서 직접 유효한 케이스는 이처럼 ‘정상적인 브라우저를 통해 발생하는 공격 유형’일 때입니다.</p>
<p><strong>이 시나리오에서는 ‘외부망 서비스에서 요청한 대상 서버가 내부망일 때 거부’하는 정책을 적용하면 매우 효과적이겠죠. 그것이 바로 ‘PNA’입니다.</strong></p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text"><strong>CORS 허용 대상을 동적으로 등록하는 등, 반드시 신뢰하는 대상만 등록하지는 않는다면?</strong></div>
</div>

<p>이때도 위 시나리오와 동일한 상황이 발생할 수 있습니다.</p>
<p>신뢰할 수 없는 서비스도 존재하기 때문에 이때 위 시나리오와 같은 실수 또는 악의적 행동이 증가할 수도 있습니다.</p>
<hr />
<h2 id="heading-pna-pna-independent-scenarios">PNA와 무관한 시나리오(PNA-Independent Scenarios)</h2>
<p>PNA 정책 도입 전부터 원래 안전했거나 무관한 시나리오입니다.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text"><strong>Private → Public 요청? (무관)</strong></div>
</div>

<p>PNA 정책에서 다루는 요청과 정반대인 요청입니다. Private → public 요청은 원래 CORS 정책에서 origin 허용 정책에 더욱 밀접합니다.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text"><strong>신뢰할 수 없는 사이트에서 우리 내부망에 요청? (일부 연관)</strong></div>
</div>

<p>허용 출처(origin) 목록에 신뢰할 수 있는 주소 목록만 작성했다면, 신뢰할 수 없는 사이트가 내부 직원을 통해 우리 내부망에 직접 액세스할 수 없습니다.<sup>[2]</sup></p>
<p>일반적인 서비스는 신뢰하는 주소 목록만 등록하므로 대체로 PNA와 무관합니다.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text"><strong>외부망의 프론트엔드 개발자가 로컬호스트로 접속하여 개발 서버에 요청? (무관)</strong></div>
</div>

<p>잘 보면, 앞서 작성한 ‘private to public’ 요청 사례 중 하나입니다. 접속망은 외부망이지만 주소창에는 로컬호스트를 입력했습니다. 또한 외부망에서 외부망으로 접근하므로 ‘to public’ 요청입니다.<br />(사설망, 가상사설망 미이용 시)</p>
<hr />
<h1 id="heading-4os577ipiou2goqwgcdsojxrs7qg67cpioywuoyhsa">ℹ️ 부가 정보 및 참조</h1>
<h2 id="heading-1-pna">[1] PNA 정책에서 내부 네트워크로 인식하는 주소</h2>
<p>크롬 등 대표적인 브라우저는 이하 목록을 내부 네트워크로 인식합니다. DNS 조회로 얻은 결과 아이피를 바탕으로 식별하므로, 도메인 네임을 입력했다고 해서 외부 서비스로 인식하지는 않습니다.</p>
<blockquote>
<p>이하 목록에서 * 기호는 임의의 값이 올 수 있음을 전달합니다.<br />실제로는 0을 입력하여 마스크를 표현합니다.</p>
</blockquote>
<ul>
<li><p><strong>사설 IPv4</strong> (RFC1918)</p>
<p>  10.*.*.*/8 → A 클래스</p>
<p>  172.16.*.*/12 → B 클래스 (172.16.*.* .. 172.31.*.*)<strong><sup>[3]</sup></strong></p>
<p>  192.168.*.*/16 → C 클래스</p>
</li>
<li><p>루프백 IPv4 (Loopback)</p>
<p>  127.*.*.*/8</p>
</li>
<li><p>링크 로컬 IPv4 (Link-Local)</p>
<p>  169.254.*.*/16</p>
</li>
<li><p>IPv6 내부 주소</p>
<ul>
<li><p>::1/128 (루프백)</p>
</li>
<li><p>fe80::/10 (링크 로컬: fe80:: .. febf:*:*:*:*:*:*:*)</p>
</li>
<li><p>fc00::/7 (사설)</p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-2">[2] 외부 서비스의 내부망 접근 가능성</h2>
<p>이 참조의 역참조에 있는 설명은 PNA 정책의 영향에 한정한 설명입니다.</p>
<p>이미 내·외부망을 혼용하는 상황을 전제로 하는 설명이므로, 망 분리가 완전하지 않아 내부망 침투 경로가 존재합니다.</p>
<h2 id="heading-3-cidr-172160012">[3] 아이피의 CIDR 표기: 172.16.0.0/12 등</h2>
<p><em>\</em>역참조: 부가 정보 및 참조 [1]*</p>
<p>서브넷 마스크 표기에 자주 등장하는 CIDR 표기 방식은 <code>/12</code> 등 ‘같은 네트워크(수퍼넷)’<br />(CIDR: Classless Inter-Domain Routing)</p>
<hr />
<div data-node-type="callout">
<div data-node-type="callout-emoji">🤔</div>
<div data-node-type="callout-text"><strong>읽기 편한 글을 위해 노력하고 있습니다!</strong> 자유로운 피드백과 반응 부탁드립니다.</div>
</div>]]></content:encoded></item><item><title><![CDATA[클래스에 Serializable 인터페이스를 구현 받는 이유가 무엇인가요? #42]]></title><description><![CDATA[이 아티클은 깃허브 nettee-space 조직의 디스커션 #42 항목을 옮겨 온 것입니다.관련 논의: nettee-space/backend-sample-hexagonal-simple-crud/discussions/42


Question: 클래스에 Serializable 인터페이스를 구현 받는 이유가 무엇인가요?
여러 소스들을 접하면서 VO 객체 등에 Serializable를 구현받는 것을 많이 접했습니다.
저희 헥사고날(스터디 팀내 2단계 ...]]></description><link>https://blog.letsdev.me/serializable</link><guid isPermaLink="true">https://blog.letsdev.me/serializable</guid><category><![CDATA[Java]]></category><category><![CDATA[serializable]]></category><category><![CDATA[java api]]></category><category><![CDATA[library]]></category><category><![CDATA[ Flexible design]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Fri, 07 Feb 2025 01:20:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1738873040933/035e53da-636c-4898-a66e-3e3f678b523e.avif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>이 아티클은 깃허브 nettee-space 조직의 디스커션 #42 항목을 옮겨 온 것입니다.</strong><br /><strong>관련 논의</strong>: <a target="_blank" href="https://github.com/nettee-space/backend-sample-hexagonal-simple-crud/discussions/42">nettee-space/backend-sample-hexagonal-simple-crud/discussions/42</a></p>
</blockquote>
<hr />
<h1 id="heading-question-serializable">Question: 클래스에 Serializable 인터페이스를 구현 받는 이유가 무엇인가요?</h1>
<p>여러 소스들을 접하면서 VO 객체 등에 Serializable를 구현받는 것을 많이 접했습니다.</p>
<p>저희 헥사고날(스터디 팀내 2단계 프로젝트)에서도 하기와 같이 적용을 하였는데, 어떤 의도로 사용하게 되는 걸까요?</p>
<pre><code class="lang-java"><span class="hljs-meta">@Getter</span>
<span class="hljs-meta">@MappedSuperclass</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">LongBaseEntity</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">Serializable</span> </span>{
    <span class="hljs-meta">@Id</span>
    <span class="hljs-meta">@GeneratedValue(strategy = GenerationType.IDENTITY)</span>
    <span class="hljs-keyword">private</span> Long id;
}
</code></pre>
<p>리서치를 했을 때는 객체를 자바 환경에서 직렬화, 역직렬화하여 활용한다는데 와닿지가 않네요.</p>
<hr />
<h1 id="heading-the-answer-starts-here">The Answer Starts Here.</h1>
<blockquote>
<p>안녕하세요, <a class="user-mention" href="https://hashnode.com/@silberbullet">silberbullet</a> 님! 언제나 좋은 질문 감사드립니다! 👍<br />많은 분들이 의례적인 작업으로 생각하시고 넘어가는 파트인 것 같습니다.<br />말씀해 주신 내용과 관련해서 <strong><em>다형성</em></strong>과 <strong><em>마커 인터페이스</em></strong>를 짧게(?) 다루어 보겠습니다.<br />각 섹션은 짧습니다.</p>
</blockquote>
<hr />
<blockquote>
<h3 id="heading-4pqh77ipiouwloybncdsp4hsnqxsnbjsnyqg7jye7zwcioyaloyvvq">⚡️ 바쁜 직장인을 위한 요약</h3>
<ul>
<li><p><code>Serializable</code>은 그 자체로 특별한 기능을 하지 않습니다. 마치 애노테이션처럼 <mark>"이 클래스는 직렬화해도 됨."</mark>을 표시하는 역할입니다. (마커 인터페이스)</p>
</li>
<li><p>현대적으로는 다형성의 특징을 잘 살릴 수 있습니다.</p>
</li>
<li><p>고전적으로는 다형성의 특징을 파라미터 수준에서 완성하지 않았습니다.</p>
<ul>
<li><p>일부러 <strong>느슨한 제약을 가진 공통 상위타입을 설계하여, 설계의 원형을 유지합니다.</strong><br />  (공통 상위 타입으로 사용되는 <code>ObjectOutput</code> 인터페이스의 <code>writeObject(Object object)</code> 메서드는, 파라미터로 <code>Serializable</code>을 받지 않고 <code>Object</code>를 받는 느슨한 제약을 가진 추상메서드를 제공하죠.)</p>
</li>
<li><p>대신 런타임에 <code>Serializable</code> 인스턴스인지 체크합니다. (<code>instanceof</code>)</p>
</li>
</ul>
</li>
</ul>
<p><strong>❓ 그렇다면 Serializable이 왜 필요할까요?</strong></p>
<ul>
<li><p>작업자의 의도에 맞는 객체만 취사선택하여 직렬화 대상으로 체크할 수 있습니다. 중요한 정보가 무방비하게 직렬화되지 않도록 보호됩니다.</p>
</li>
<li><p>내부적으로 직렬화가 불가능하거나 부적절한 케이스를 미리 체크해 둘 수 있습니다. (빠른 예외 반환)</p>
</li>
</ul>
</blockquote>
<hr />
<h1 id="heading-4pyu77ipiounioy7pcdsnbjthldtjpjsnbtsiqq6io2vqoyimoulvcdsojzqs7xtlzjsp4ag7jwk7j2m">✔️ 마커 인터페이스: 함수를 제공하지 않음</h1>
<ul>
<li><p><code>Serializable</code> 인터페이스는 아무런 함수도 제공하지 않습니다.</p>
</li>
<li><p>따라서 현대적인 개발 문화로는 <strong><em>다형성을 제공하는</em></strong> 정도 역할로 생각하고 있습니다.</p>
</li>
<li><p>또한 일종의 ***'마커 인터페이스'***이므로, <code>instanceof</code> 연산자로 체크하여 <code>Serializable</code> 인스턴스인지 확인할 수 있습니다. (애노테이션과 유사한 역할)</p>
</li>
</ul>
<h2 id="heading-8jfovcfllrwn5qioulpo2yleyeseycvouhnoyena">🟢🔺🟪 다형성으로서</h2>
<p>자주 이야기되는 다형성은 런타임 다형성으로, 상위 타입 객체가 하위 타입 인스턴스를 포함할 수 있는 개념입니다. 흔히 '부모는 자식을 품을 수 있다' 등으로 연상시켜 기억을 돕습니다.</p>
<h3 id="heading-64uk7ziv7isxioyyioylna">다형성 예시</h3>
<p>만약 직렬화 함수들이 파라미터로 <code>Serializable</code>을 받기로 한다면, 이곳에 전달하려는 객체는 <code>Serializable</code>을 구현해야 합니다.</p>
<p>다음과 같은 함수가 있다고 하겠습니다.</p>
<pre><code class="lang-java"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">serialize</span><span class="hljs-params">(Serializable source)</span> </span>{<span class="hljs-comment">/* ... */</span>}
</code></pre>
<p>위 함수를 사용하는 조건은, <code>source</code>로 제공된 파라미터가 <code>Serializable</code>의 자손(다소 틀린 표현이지만 편의상)이어야 한다는 것입니다.</p>
<pre><code class="lang-java"><span class="hljs-comment">// JDK 16+ (14 Preview)</span>
<span class="hljs-keyword">if</span> (item <span class="hljs-keyword">instanceof</span> Serializable s) {
    serialize(s); <span class="hljs-comment">// ✅ Ensurance</span>
} <span class="hljs-keyword">else</span> {
    serialize(item); <span class="hljs-comment">// ❌ Error !!</span>
}
</code></pre>
<p>이곳에 넣을 객체들은 <code>Serializable</code> 인터페이스를 구현한 클래스의 인스턴스여야 합니다.</p>
<p>또한 이는 컴파일타임에 체크할 수 있는 예시입니다.</p>
<h2 id="heading-instanceof-instanceof">✅ instanceof 🟩: <code>instanceof</code> 연산자</h2>
<h3 id="heading-instanceof"><code>instanceof</code> 연산자로 런타임에 체크하는 예시</h3>
<p>파라미터 타입에서 <code>Serializable</code>로 제한하지 않고 설계하는 예시로, 이번 예시에서는 <code>Object</code>로 모든 객체를 받는다고 하겠습니다.</p>
<p>이 함수는 구현하는 사람의 의도에 따라, 런타임에 <code>instanceof</code>로 <code>Serializable</code>로 마크된 객체인지 확인할 수 있습니다.</p>
<pre><code class="lang-java"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">serialize</span><span class="hljs-params">(Object source)</span> </span>{
    <span class="hljs-keyword">if</span> (!(source <span class="hljs-keyword">instanceof</span> Serializable)) {
        <span class="hljs-keyword">throw</span> ...;
    }

    <span class="hljs-comment">// do serialization here</span>
    <span class="hljs-comment">//  ...</span>
}
</code></pre>
<p>이 함수를 호출할 때는 반드시 <code>Serializable</code> 인스턴스가 아니어도 호출 자체는 됩니다. 하지만 <code>Serializable</code> 인스턴스가 아니라면 런타임에 오류를 띄웁니다.</p>
<pre><code class="lang-java"><span class="hljs-comment">// JDK 16+ (14 Preview)</span>
<span class="hljs-keyword">if</span> (item <span class="hljs-keyword">instanceof</span> Serializable s) {
    serialize(s); <span class="hljs-comment">// ✅ Ensurance</span>
} <span class="hljs-keyword">else</span> {
    serialize(item); <span class="hljs-comment">// ❎ 호출 가능 -&gt; 실행 시 Runtime Error !!</span>
}
</code></pre>
<p><strong>⚠️ 미리 진단되지 않은 코드</strong></p>
<blockquote>
<p><strong>이 방식은 컴파일타임에 체크할 수 있었던 문제를 런타임으로 유보시켜</strong><br /><strong><mark>프로세스 운영에 잠재적 문제</mark>를 야기하는 코드로서 현대적으로 선호되는 방식은 아닙니다.</strong></p>
<p>❓ 그럼에도 자바를 설계한 사람들이 이러한 방식을 초기에 선택했던 이유가 무엇일까요?</p>
</blockquote>
<h1 id="heading-8jsosdripdsiqjtlzwg7kcc7jw97j2yioqzte2gtsdshktqs4trtoa">💡 느슨한 제약의 공통 설계부</h1>
<pre><code class="lang-mermaid">---
config:
  look: handDrawn
  theme: forest
---
classDiagram
%% 마커 인터페이스
class Serializable {
  &lt;&lt;marker&gt;&gt;
}

%% 직렬화를 위한 인터페이스
class ObjectOutput {
  &lt;&lt;interface&gt;&gt;
  +writeObject(obj: Object)
}

%% 역직렬화를 위한 인터페이스
class ObjectInput {
  &lt;&lt;interface&gt;&gt;
  +readObject() Object
}

%% 직렬화 기능 구현 클래스
class ObjectOutputStream {
  +writeObject(obj: Object)
  +[기타 메서드...]
}

%% 역직렬화 기능 구현 클래스
class ObjectInputStream {
  +readObject() Object
  +[기타 메서드...]
}

%% 관계 표현
ObjectOutputStream ..|&gt; ObjectOutput
ObjectInputStream ..|&gt; ObjectInput
%% ObjectOutputStream --&gt; Serializable : &lt;&lt;&lt;span&gt;uses&lt;/span&gt;&gt;&gt;
%% ObjectInputStream --&gt; Serializable : &lt;&lt;&lt;span&gt;uses&lt;/span&gt;&gt;&gt;
</code></pre>
<p>'추상적인' 설계 파트에서는 느슨한 제약을 선호할 수 있습니다. 위 방식은 다음과 같은 항목을 포함합니다.</p>
<ul>
<li><p>메서드는 <code>Object</code> 타입 파라미터를 받습니다.</p>
</li>
<li><p>함수 내부에서 런타임에 <code>instanceof</code>로 <code>Serializable</code> 인스턴스인지 확인합니다.</p>
</li>
<li><p>(아마도) 오버라이딩이 가능한 상태거나, 수퍼 메서드가 있을 것입니다. 즉, 가상메서드(≠ 추상메서드)가 존재할 것입니다.</p>
<ul>
<li>위 다이어그램에서 <code>ObjectOutput</code> 인터페이스의 <code>writeObject(Object object)</code> 메서드가 해당합니다.</li>
</ul>
</li>
</ul>
<p>이 방식은 직렬화 함수나 그 원형이 오버라이딩이 가능한 함수라는 전제에서 다음과 같은 특징을 갖습니다.</p>
<ul>
<li><p>오버라이딩한 함수에서는 <code>Serializable</code>이 아닌 <code>Object</code> 인스턴스까지 실제 직렬화 대상으로 통과시키는 의도를 추가할 수 있습니다. (<code>instanceof</code> 제거)</p>
</li>
<li><p>그 선택을 공통 상위타입을 설계하는 사람이 <code>Serializable</code>로 제한하지 않고, 그 설계를 이어 받은(오버라이딩하는) 사람이 선택할 수 있도록 합니다.</p>
</li>
<li><p>공통 상위타입은 느슨한 제약으로 설계의 원형을 오랫동안 유지할 수 있게 됩니다. (문제가 발생할 때 상위 타입 대신 구현부를 수정할 수 있습니다.)</p>
</li>
</ul>
<p>자바를 만든 사람들은 본인들이 자바에서 처음 설계한 것들이 나중에 과도하게 바뀌는 것을 최대한 피하려고 한 것으로 유명합니다. (1.8까지 하위호환 대체로 보장)</p>
<p><strong>따라서, 처음부터 '느슨한 제약'으로 설계하여 설계의 원형을 오랫동안 유지하려고 한 것 같습니다.</strong></p>
<p>만약 처음부터 <code>Serializable</code> 객체만 입력받도록 만들었다면, 추후 <code>Serializable</code>이 아닌 객체를 직렬화하는 직렬화 함수로 확장할 수 없습니다. 또한 <code>Serializable</code>로 마크되지 않은 객체는 직렬화할 수 없으므로, 다른 라이브러리에서 가져 온 어느 클래스의 객체를 직접 직렬화할 수 없을 수 있습니다. (그 클래스가 <code>Serializable</code>을 누락했다면.) 이러한 이유로 상위에서 느슨한 제약을 선택한 것으로 추측합니다.</p>
<h2 id="heading-serializable">❓ 그렇다면 <code>Serializable</code>이 왜 필요할까요?</h2>
<p>앞서 설명한 대로라면, <code>Object</code> 타입을 받아서 모두 직렬화에 통과시키면 되는 문제로 생각이 듭니다. 그런데 왜 굳이 <code>Serializable</code>을 추가하고, 이 타입을 체크해 두지 않으면 필터하는 걸까요?</p>
<p>이는 일종의 '안전 장치'로 마크하는 것과 같은 의도입니다.<br /><code>Serializable</code>을 붙이지 않은 클래스의 객체는 직렬화 대상에서 제외하는 거죠.<br />직렬화할 객체의 클래스에는 <code>Serializable</code>을 붙여서 직렬화 대상이 된다는 걸 표시하는 거구요.</p>
<p>이로써 다음과 같은 효과를 얻을 수 있습니다.</p>
<p><strong>🧑🏻‍💻 작업자의 의도를 담을 수 있습니다.</strong> 작업자는 이 클래스의 인스턴스가 직렬화 대상인지 아닌지 명시할 수 있습니다. 이로써 보안상 함부로 직렬화해선 안 되는 정보를 무방비한 직렬화로부터 보호할 수 있고, 내부적으로 직렬화가 부적절한 유동 환경 정보 등을 실수로 직렬화하는 상황을 피할 수 있습니다.</p>
<ul>
<li><p><strong>🎁 민감한 정보 보호</strong>: 의도하지 않은 데이터가 무방비하게 직렬화되는 것을 방지할 수 있습니다.</p>
</li>
<li><p><strong>👷 안전한 직렬화 명시</strong>: 내부적으로 직렬화가 불가능하거나 부적절한 유동적 환경 정보가 포함되는 객체가 있을 수 있습니다. 이를 무작정 직렬화하면, 추후 역직렬화 시 적절한 재구조화가 안 될 수 있습니다. 예를 들어 운영체제에 종속되는 정보를 직렬화 대상으로 포함하면 안 되겠죠!</p>
</li>
</ul>
<hr />
<p><a class="user-mention" href="https://hashnode.com/@silberbullet">silberbullet</a> 님께서 모두에게,<br />세세한 부분에서 좋은 호기심으로 접근할 수 있도록<br />유익한 질문을 제시해 주셨습니다! 🚀</p>
<p>항상 좋은 대화의 장을 만들어 주셔서 감사합니다! 👍</p>
<hr />
<p>의견 보충 및 추가적인 질문은 언제든 멘션(<a class="user-mention" href="https://hashnode.com/@letsdev">Merge Simpson</a>) 부탁드리곘습니다!</p>
]]></content:encoded></item><item><title><![CDATA[이메일 OTP 인증에서 오해하는 것들 (One-Time Password)]]></title><description><![CDATA[✍️ 스터디원의 고민
공부를 쉬지 않는 스터디원들 중 한 분이, 대학에서 다른 사이드 프로젝트를 병행하시면서 꾸준히 공부 현황을 공유해 오셨습니다. 그러다가 이번에 문득 이메일 인증을 언급하셨습니다.

💬 “백엔드 쪽 이메일 인증을 만들어 놔야겠네요. 리서치가 필요할 듯합니다.”
- 텐둥이, the 오랜 friend of Merge Simpson

아, 리서치!
💭 좋은 자료가 농도 높게 존재하는 주제라면 리서치에서 충분히 좋은 정보를 얻을...]]></description><link>https://blog.letsdev.me/email-otp</link><guid isPermaLink="true">https://blog.letsdev.me/email-otp</guid><category><![CDATA[이메일 인증]]></category><category><![CDATA[OTP]]></category><category><![CDATA[email verification API]]></category><category><![CDATA[email verification]]></category><category><![CDATA[SMS OTP API]]></category><category><![CDATA[Email OTP]]></category><category><![CDATA[SMS OTP]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Fri, 31 Jan 2025 14:59:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1738101994076/48abb6e2-db57-40f9-b0f4-581792effec9.avif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-4pyn77ipioykpo2esouuloybkoydmcdqs6drr7w">✍️ 스터디원의 고민</h1>
<p>공부를 쉬지 않는 스터디원들 중 한 분이, 대학에서 다른 사이드 프로젝트를 병행하시면서 꾸준히 공부 현황을 공유해 오셨습니다. 그러다가 이번에 문득 이메일 인증을 언급하셨습니다.</p>
<blockquote>
<p>💬 “백엔드 쪽 이메일 인증을 만들어 놔야겠네요. 리서치가 필요할 듯합니다.”</p>
<p>- 텐둥이, the 오랜 friend of Merge Simpson</p>
</blockquote>
<p>아, 리서치!</p>
<p>💭 좋은 자료가 농도 높게 존재하는 주제라면 리서치에서 충분히 좋은 정보를 얻을 수 있겠지만, 이메일 인증에 대한 자료는 다소 미비한 게 많다고 느꼈습니다. 많은 글에서 간단하게 OTP를 생성해서 이메일로 전송하는 자체에만 집중한다고 느꼈거든요.</p>
<p><strong>자그마한 인사이트 불어넣기 👀</strong></p>
<p>어쩌면 낮은 농도의 글들 사이에서도 충분히 유익한 아이디어를 도출하실지도 모릅니다만, 적어도 탐구할 가치가 있는 인사이트를 미리 보충하시면 모호한 수준의 리서치에서 그치지 않으실 거라고 생각했습니다. 계기도 생겼겠다, 바쁜 시기지만 블로그에도 한번 새 글을 게시해 보겠습니다!</p>
<hr />
<h1 id="heading-otp">🤔 OTP, 만들어서 보내기</h1>
<p>가장 기본적인 것은, 당연하게도 다른 글들에서 다루는 것처럼, <strong>OTP(One-Time Password)를 생성해서 사용자에게 전달하는</strong> 행위 그 자체입니다! 이번에는 이메일에 대한 인증이기 때문에, OTP를 이메일로 전달할 겁니다. 이메일 인증을 위한 OTP는 주로 서버에서 생성합니다.</p>
<p><strong>생성</strong></p>
<ul>
<li><p>OTP의 길이는 <strong>최소 6글자로</strong> 합니다. NIST(미국 국립표준기술연구소)에서도 이를 권합니다.</p>
</li>
<li><p>이메일 OTP 방식에서는 OTP를 <strong>암호학적 난수 생성 함수로</strong> 생성합니다. (참고: 반드시 소프트웨어 기반이 아니어도 됩니다.)</p>
</li>
</ul>
<p><strong>전달</strong></p>
<ul>
<li>메일 서비스를 사용해서 전달합니다. (예를 들어 발신 서버를 구글로 한다면, 우리 서버 → 구글 메일 서버 → 수신 메일 서버 → 사용자 컴퓨터 순으로 이메일이 전달됩니다.)</li>
</ul>
<p><strong>보존</strong> (대조군)</p>
<ul>
<li><p>서버에도 OTP를 보존합니다. (랜덤 생성 시)</p>
</li>
<li><p>OTP의 수명을 짧게 하는 것이 일반적입니다.</p>
</li>
</ul>
<h2 id="heading-otp-vs">🎁 OTP 보존 방식 비교 (평문 vs 단방향 암호화)</h2>
<ul>
<li><p><strong>평문 저장을 한다면 그 이유</strong></p>
<ul>
<li><p>OTP의 수명이 매우 짧습니다.</p>
</li>
<li><p>데이터베이스 접근 제어가 충분히 잘되어 있다면 유출 가능성이 낮습니다.</p>
</li>
<li><p><strong>6자리 숫자 OTP</strong>: 만약 실시간으로 유출되면 단방향 암호화가 충분히 보호해 줄 수 없는 경우의 수입니다.</p>
<p>  솔트와 함께 보존된 단방향 암호화라면 경우의 수가 적어서 임의 대입으로부터 충분한 시간 동안 보호해 줄 수 없습니다. (100만 가지 경우는 암호화 방식과 공격자 컴퓨팅 성능에 따라 수 초 이내에 돌파될 수 있습니다.)</p>
</li>
</ul>
</li>
</ul>
<p>    <strong>비교: 일반적인 단방향 암호화 시</strong></p>
<ul>
<li><p>6자리 정도 짧은 OTP일 때, 데이터베이스 유출로부터 충분히 보호해 주지 않습니다.</p>
</li>
<li><p>서버에서 추가적인 처리로 자원을 낭비합니다.</p>
</li>
</ul>
<ul>
<li><p><strong>단방향 암호화를 원한다면 그 이유와 방식</strong></p>
<ul>
<li><p>데이터베이스의 접근 제어는 많은 유명 기업으로부터 빈번하게 실수가 발생하는 작업입니다.</p>
</li>
<li><p>따라서 ‘오프라인에서’ 임의로 해석하거나 대입해 볼 수 없는 구조를 지향하는 것이 좋습니다.</p>
</li>
</ul>
</li>
</ul>
<p>    <strong>방식</strong></p>
<ul>
<li><p><strong><mark>시크릿키</mark> 또는 페퍼링으로</strong> 데이터베이스 유출 시에도 오프라인에서 임의로 해싱을 시도할 수 없도록 합니다.</p>
</li>
<li><p>이렇게 하면 공격자는 해시 값 대조에 앞서 시크릿키 또는 페퍼 값을 먼저 알아내야 합니다.</p>
</li>
<li><p>데이터베이스의 실시간 유출 시에도 시크릿키 또는 페퍼 값이 노출되지 않도록 데이터베이스와 격리하여 관리합니다.</p>
</li>
</ul>
<p>이렇게 OTP를 생성해서 전달하는 과정은 간단한데요! 서버에도 평문 저장이 일반적이어서 대체로 구현도 간단합니다.</p>
<p>문제는 OTP가 사용자의 이메일로 안전하게 전달될 것이라는 가정을 쉽게 한다는 거죠. 하지만 이 과정에서 놓치지 말아야 할 것은 OTP가 생성된 후 <strong><mark>여러 메일 서비스를 거쳐 수신자에게 도달할 때까지</mark></strong>, 우리가 <strong><mark>OTP를 완전히 은닉할 수 없는</mark></strong> 점입니다.</p>
<p>즉, <strong><em>“OTP 발급 시 경유하는 메일 서비스들을 신뢰할 수 있어야 하지만, 남의 서비스라서 보장되는 일은 아닙니다.”</em></strong></p>
<hr />
<h1 id="heading-8jviidwn5gaio2gteylocdqtazqsiqg7iug66kw64e">🕊 👀 통신 구간 신뢰도</h1>
<p><strong>서버에서 생성한 OTP를 사용자에게 전달할 때는, 반드시 통신 구간을 거칩니다.</strong> 특히 이메일 인증을 위한 OTP 전달은, 여러 메일 서비스를 거치게 되는데요. 이때 경유하는 메일 서비스들을 신뢰할 수 있어야 하지만, 그 서비스의 의도를 신뢰하는 것도, 그 메일 서비스의 통신 구간 암호화 수준을 신뢰하는 것도 참 낙천적인 일입니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1738117105016/687f3fdf-7ed1-488a-b477-6d2bc86162b1.avif" alt="여러분의 서버 → 통신구간 A → 구글 → 통신구간 B → 수신자 메일 서버 → 통신구간 C → 수신자 클라이언트" class="image--center mx-auto" /></p>
<p>우리가 서버에서 은닉할 값을 생성해도, 메일을 통한 <strong><mark>OTP 전달 프로세스</mark>에서</strong> 결국 다음과 같은 <mark>유출 위험</mark>을 맞이하게 됩니다.</p>
<ul>
<li><p><strong>은닉할 값이 결국 경유하는 메일 서비스들에 공개됩니다.</strong> 이 서비스들이 악의가 없다고 신뢰해야 합니다(?).</p>
</li>
<li><p>메일 서비스와 메일 서비스 사이의 통신 구간 보안도 그들의 몫입니다. 즉, 우리가 통제할 수 없습니다.</p>
</li>
<li><p>수신 메일 서비스로부터 사용자가 메일을 받을 때의 <strong>통신 구간 보안도 우리가 통제할 수 없습니다</strong>.</p>
</li>
</ul>
<p>어디든 네트워크상에서 ‘중간자(MITM: Man in The Middle)’라는 위협이 도사리고 있죠. 이래서 중요한 값을 은닉하는 통신은 모두 암호화가 필수인데 말입니다.</p>
<p>예를 들어, 위 그림에서 통신구간 B와 C는 남들의 몫입니다. 어떻게 노출이 될지 모르기 때문에 <strong>중간자 공격으로부터</strong> 안전하다 할 순 없을 거예요. 게다가 우리가 아무리 발신 메일 서버를 신뢰해도, 수신 메일 서버는 신뢰하기 어려운 서비스일 수도 있을 겁니다. 아, 사용자의 메일 우회 설정이나, 수신 메일 서버의 알 수 없는 여러 중간 전달 과정으로 더 많은 메일 서버를 거칠 수도 있으니까, 더 많은 B와 C가 있을 수 있습니다. ✌️</p>
<p>위 그림에 대해서 스크린리더가 읽어 줄 텍스트 대신, 아래에 구간별로 읽어 주는 목록으로 대신합니다.</p>
<ul>
<li><p>여러분의 서버 → (통신구간 A) → 구글</p>
</li>
<li><p>구글 → (통신구간 B) → 수신자 메일 서버</p>
</li>
<li><p>수신자 메일 서버 → (통신구간 C) → 수신자 클라이언트</p>
</li>
</ul>
<h2 id="heading-8jzjydsmrdrpqzqsiag7iug66kw7zwy64quio2gteylocdqtazqsitsl5dqsowg4occ67aa7yobio2vmoucmcdtlzjquldigj0">🙏 우리가 신뢰하는 통신 구간에게 “부탁 하나 하기”</h2>
<p><em>그래서 어떻게든 우리가 신뢰할 수 있는 수단을 활용해서 보조하면 더 좋을 것 같습니다!</em></p>
<p>예를 들어 이때는, <strong><mark>‘사용자가 우리 사이트에 접속해 있는 시점’을 활용</mark>하는</strong> 거죠.</p>
<p>사용자가 우리 사이트에 접속해서 요청을 주고 받는 건, 분명 우리가 신뢰할 수 있는 통신 구간이잖아요? 이 통신 구간을 활용해서 보조해 보려고 합니다.</p>
<blockquote>
<p><strong>우리 서버 → (신뢰할 수 있는 통신 구간) → 수신자 클라이언트</strong></p>
</blockquote>
<h3 id="heading-otp-1">사용자가 보낸 요청: “OTP를 메일로 보내 주세요.”에 응답할 때 토큰 심기</h3>
<p>우리가 이메일 인증용 OTP를 발송하는 것은 잘 생각해 보면 ‘사용자 상호작용’이 있을 때입니다. 그냥 보내면 그건 스팸이죠. 명절 선물로 스팸을 주고 받는 것도 한국식 문화라고 합니다. 😅</p>
<p>사용자의 요청은 다음과 같습니다.</p>
<blockquote>
<p>“저 회원가입 할 건데, 이게 제 메일이거든요. 이 메일이 <strong><mark>제 소유라는 걸 확인시키기 위한 OTP 값을 제 메일로 보내 주세요</mark>.</strong>“</p>
<p>(예시) Request Body or Parameter:</p>
<p>{“email”: “their.email@example.com“}</p>
</blockquote>
<p>여기서 사용자에게 ‘수명’이 있는 특정 토큰을 발급해 줍니다. 전달 방식은 다양하지만, 예시니까 바디로 응답할까요?</p>
<blockquote>
<p>서버 “네, 메일로 OTP 보내는 중인데 이거 좀 갖고 계시다가, <mark>OTP 확인할 때 같이 제시</mark>해 주실래요? <mark>임시 신분증</mark>이에요.“</p>
<p>(예시) Response Body:</p>
<p>{“email_otp_verification_token“: “사용자(사람)가 직접 외우는 게 아니기 때문에 충분히 긴 토큰이 좋습니다.“}</p>
</blockquote>
<p>이후 단계는 다음과 같습니다.</p>
<ul>
<li><p>이처럼 사용자에게 토큰을 발급해 줄 때, 서버에서도 이 충분히 긴 길이의 토큰을 안전하게 보관합니다. (stateful 관리 시)</p>
</li>
<li><p>클라이언트에서도(≈ 프론트엔드에서도) 토큰을 잘 저장해 두었다가 나중에 OTP와 토큰을 함께 서버로 보냅니다.</p>
</li>
</ul>
<p><strong>생성과 보존</strong></p>
<ul>
<li><p>이 토큰은 프론트엔드 쪽 스크립트로 관리되는 토큰이기 때문에 충분히 긴 토큰으로 생성합니다.<br />  (여섯 자리 OTP는 사용자가 직접 다루어야 하기 때문에 짧은 길이를 선호하는 것과 대조적입니다.)</p>
</li>
<li><p>서버 측에 저장할 때, 단방향 암호화를 하는 의미가 전혀 없지 않기 때문에(충분히 많은 경우의 수) 기본적인 습관을 해싱으로 하는 것이 좋습니다. (stateful 관리 시)</p>
</li>
</ul>
<p>수명은 OTP보다 짧지 않아야 하고, 마찬가지로 수명이 짧아서 단방향 암호화 여부가 필수적인 것은 아닙니다.</p>
<p><strong>주의사항</strong></p>
<ul>
<li><p><strong>토큰이라고 해서 반드시 JWT로 생성하지 않아도 됩니다</strong>. 👀 특히 <strong>JWT의 페이로드(본문)에 OTP를 심으면, 이메일 소유를 확인하는 의미가 전혀 없겠죠</strong>. ⚠️</p>
</li>
<li><p>예시는 간단하고 안전하게 stateful한 관리를 하는 겁니다. 이 방식을 권하는 이유는 OTP 재발급, 과도한 시도 횟수 등 여러 이유로 상태를 서버에서 제어할 때가 있을 수 있기 때문입니다.</p>
</li>
</ul>
<hr />
<p>이렇게 생성한 토큰은, HTTPS 등 우리가 신뢰해도 되는 안전한 통신 환경에서 전달하고, 이후 이메일 OTP 인증 시 토큰을 함께 보내도록 요구할 수 있습니다.</p>
<p>이로써 한층 안전한 이메일 인증 환경을 구축할 수 있습니다.</p>
<p><strong>참고: 이메일 인증 시퀀스 다이어그램</strong></p>
<pre><code class="lang-mermaid">---
config:
  theme: forest
  look: handDrawn
---
sequenceDiagram
  participant Client Email Server
  box rgba(150, 250, 100, 0.5) 통제 가능한 통신 구간
    participant Client
    participant  Server
  end
  activate Client
  Client -&gt;&gt; Server: 인증 메일 요청
  activate Server
  Server -&gt;&gt; Sender Email Server : OTP 발행
  Server -&gt;&gt; Client: Token 발행
  deactivate Server
  Sender Email Server --&gt;&gt; Client Email Server: OTP가 담긴 메일 전달
  Client Email Server -&gt;&gt; Client: OTP 확인
  Client -&gt;&gt; Server: 인증 (OTP, Token)
  deactivate Client
</code></pre>
<p>그럼 오늘도 다들 모쪼록 착한 개발 하세요. 🐳</p>
]]></content:encoded></item><item><title><![CDATA[개념 2. JWT 액세스 토큰의 생성과 전달, Stateful한 리프레시 토큰의 생성, 전달, 보존]]></title><description><![CDATA[이전 글에서 정리에 꽤 힘을 뺐기 때문에, 이번 글에서는 서두와 부연설명을 줄이고 필요한 정보를 담아 전달해 보겠습니다.

JWT 액세스 토큰
JWT 액세스 토큰은 인가에 직접 사용되는 토큰이고, stateless 하다는 장점이 있었습니다.
액세스 토큰의 생성
JWT(JSON Web Token)로 생성합니다. 비밀번호 인증 등 자격 검토 후 JWT를 발급합니다.
JWT는 헤더, 페이로드, 시그니처 세 영역을 점(.) 기호로 구분한다고 했습니다....]]></description><link>https://blog.letsdev.me/jwt-authentication-issuing-concept</link><guid isPermaLink="true">https://blog.letsdev.me/jwt-authentication-issuing-concept</guid><category><![CDATA[JWT]]></category><category><![CDATA[JSON Web Tokens (JWT)]]></category><category><![CDATA[refresh-token]]></category><category><![CDATA[Redis]]></category><category><![CDATA[valkey]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Wed, 23 Oct 2024 12:20:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1729618958819/c4600545-0ed3-40cf-aeea-25a7085ae29f.avif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a target="_blank" href="https://blog.letsdev.me/jwt-authentication">이전 글</a>에서 정리에 꽤 힘을 뺐기 때문에, 이번 글에서는 서두와 부연설명을 줄이고 필요한 정보를 담아 전달해 보겠습니다.</p>
<hr />
<h1 id="heading-jwt">JWT 액세스 토큰</h1>
<p>JWT 액세스 토큰은 인가에 직접 사용되는 토큰이고, stateless 하다는 장점이 있었습니다.</p>
<h2 id="heading-7jwh7is47iqkio2goo2bsoydmcdsg53shle">액세스 토큰의 생성</h2>
<p>JWT(JSON Web Token)로 생성합니다. 비밀번호 인증 등 자격 검토 후 JWT를 발급합니다.</p>
<p>JWT는 헤더, 페이로드, 시그니처 세 영역을 점(.) 기호로 구분한다고 했습니다.</p>
<ul>
<li><p><strong>헤더</strong>: 이 토큰에 대한 메타 데이터입니다. 즉, 이 토큰이 무엇인지 설명하는 부연 정보입니다.</p>
</li>
<li><p><strong>페이로드</strong>: 전달하는 본문 메시지입니다. 페이로드 객체의 각 항목을 claim(클레임)이라고 합니다.</p>
<p>  우리가 JWT 인증·인가에서 주로 관심을 갖는 주요 클레임(claim)은 다음과 같습니다.</p>
<ul>
<li><p><code>sub</code> (subject): 전달하는 주제(주요 대상)입니다. 주로 사용자 계정 이름(username)이나 이메일 또는 매핑된 식별 값을 담습니다.</p>
</li>
<li><p>역할(<code>role</code>) 또는 권한(<code>authority</code>): 커스텀 클레임(claim)입니다.</p>
</li>
<li><p><code>exp</code> (expiration time): JWT를 만료시키는 시각입니다.</p>
</li>
</ul>
</li>
</ul>
<p>    표준 스펙에 등록된 클레임 목록(Registered Claims)은 다음과 같습니다. 이것을 모두 포함해야 하는 것은 아니며, 원하는 새 항목을 추가해도 됩니다.</p>
<ul>
<li><p><code>sub</code> (subject): 전달하는 주제입니다.</p>
</li>
<li><p><code>iat</code> (issued at time): JWT를 발급한 시각입니다.</p>
</li>
<li><p><code>exp</code> (expiration time): JWT를 만료시키는 시각입니다.</p>
</li>
<li><p><code>iss</code> (issuer): JWT 발급자 정보입니다.</p>
</li>
<li><p><code>aud</code> (audience): JWT를 수신할 대상입니다.</p>
</li>
<li><p><code>nbf</code> (not before time): JWT가 아직 효력을 갖지 않는 시각을 <code>nbf</code>에 설정할 수 있습니다.</p>
</li>
<li><p><code>jti</code> (JWT ID): JWT에 고유 ID를 할당하면, 이 JWT를 일회용으로 사용하는 시스템 등에 재사용 탐지와 방지에 활용하도록 제공할 수 있습니다. (JWT 전체 대신 <code>jti</code>를 보존)</p>
</li>
</ul>
<ul>
<li><strong>시그니처</strong>: 헤더와 페이로드가 위·변조된 것이 아니라는 것을 확인시켜 주는 값입니다. 이는 HMAC 알고리즘으로 헤더와 페이로드를 해싱해서 생성합니다.</li>
</ul>
<p>헤더와 페이로드에 민감한 정보를 담으면 안 됩니다. Base64 인코딩은 암호화가 아니므로 누구나 제약 없이 디코딩을 할 수 있습니다.</p>
<h2 id="heading-7jwh7is47iqkio2goo2bsoydmcdsoitri6zqs7wg67o07kg0">액세스 토큰의 전달과 보존</h2>
<p>일반적으로 액세스 토큰 보존 방식은 클라이언트에 결정을 맡길 수 있습니다. 즉, 백엔드 개발자가 직접 신경쓸 부분은 전달까지고, 이를 보존하는 것은 프론트엔드 개발자가 신경써야 합니다.</p>
<p><strong>액세스 토큰의 전달</strong></p>
<ul>
<li><p>HTTP 응답 바디의 <code>access_token</code> 필드에 담아서 반환합니다.</p>
</li>
<li><p>HTTPS 환경에서 응답하여야 합니다.</p>
</li>
<li><p>서버에는 보존하지 않습니다. (서버에 보존하지 않기 위해 JWT로 생성했습니다.)</p>
<p>  특수한 목적이 있을 때는 보존할 수 있지만, 유출에 대비한 형태로 보존합니다.</p>
</li>
</ul>
<p><strong>액세스 토큰의 보존</strong> (주로 프론트엔드 작업)</p>
<ul>
<li><p>클라이언트가 보존합니다.</p>
</li>
<li><p><strong>많은 회사</strong>: 체감상 많은 회사가 로컬 스토리지(local storage)에 JWT 액세스 토큰을 보존합니다.</p>
</li>
<li><p><strong>많은 권고</strong>: 상태 관리 라이브러리 등을 사용할 수 있습니다. 새로고침 시 값이 손실되므로, 리프레시 요청을 바로 보내어 약간의 지연 이후부터 API를 이용할 수 있게 됩니다.</p>
</li>
<li><p>해석</p>
<ul>
<li><p>보안을 더 중요시하려면, 누군가의 실수로 취약점이 발생하더라도 액세스 토큰 탈취 가능성이 줄어들도록 권고대로 private 변수에 보존하는 방식을 택할 수 있습니다.</p>
</li>
<li><p>따로 취약점이 발생하지 않는다면, 로컬스토리지에 저장하는 방식이 개발 편의성도 높으면서, 페이지 진입 초기에 사용자에게 빠른 API를 제공합니다. (취약점은 언제든 생길 수 있기 때문에 완전히 안전한 조치는 아닙니다.)</p>
</li>
</ul>
</li>
</ul>
<hr />
<h1 id="heading-66as7zse66ci7iucio2goo2bsa">리프레시 토큰</h1>
<p>JWT 액세스 토큰의 stateless 인가는 ‘즉시 만료’가 안 되어 악의적 사용자를 즉시 걸러낼 수 없으며, 액세스 토큰의 수명 동안 계속 통과되기 때문에 이로 인한 잠재적인 위협을 내포할 수 있다고 했습니다. 따라서 우리는 액세스 토큰의 수명을 짧게 하고, 이를 리프레시 하는 과정에서 stateful 인가로 사용자의 자격 검토에 관여할 기회를 빈번하게 갖습니다. 리프레시 자격 증명 수단이 리프레시 토큰입니다.</p>
<h2 id="heading-66as7zse66ci7iucio2goo2bsoydmcdsg53shle">리프레시 토큰의 생성</h2>
<p>Stateful 인가를 위해 생성하는 토큰이기 때문에, JWT로 생성하지 않습니다.</p>
<ul>
<li><p>암호학적으로 안전한 랜덤 함수로 생성합니다.<br />  (자바는 <code>SecureRandom</code> 등이 안전하다고 평가받습니다.)</p>
</li>
<li><p>길이는 보안 강도에 따라 선택합니다.<br />  잘 모르겠다면 128 bits(16 Bytes)나 256 bits(32 bytes) 중에서 선택할 수 있습니다.</p>
</li>
</ul>
<h2 id="heading-66as7zse66ci7iucio2goo2bsoydmcdsoitri6w">리프레시 토큰의 전달</h2>
<p>리프레시 토큰은 쿠키에 저장하는 것을 권합니다. 이 쿠키는 다음 옵션을 포함해야 합니다.</p>
<p><strong>HTTP Only 옵션</strong></p>
<p>리프레시 토큰은 클라이언트 스크립트에 영향을 받지 않게 하여 XSS(Cross Site Scripting) 공격에 면역을 갖게 합니다. <code>HttpOnly</code> 옵션을 추가한 쿠키에 담으면 이 쿠키를 브라우저만 받고, 프론트엔드의 소스 코드가 동작하는 환경에는 제공해 주지 않겠다는 약속을 브라우저에 보낼 수 있습니다. 브라우저는 프론트엔드 스크립트에서는 <code>HttpOnly</code> 옵션이 있는 쿠키에 접근할 수 없게 하고, 서버에 요청을 보낼 때 자동으로 이 쿠키를 붙여서 보냅니다.</p>
<p><strong>Secure 옵션</strong></p>
<p>리프레시 토큰은 통신 구간에서 은닉되어야 합니다. 통신 구간 암호화는 HTTPS 통신을 통해 수행하며, <code>Secure</code> 옵션을 추가한 쿠키에 담으면 정상적인 브라우저는 HTTPS 요청에서만 쿠키를 전달합니다.</p>
<p>만약 서버가 <code>Secure</code> 옵션을 붙인 쿠키를 HTTPS가 아닌 HTTP 환경에서 전달해도, 정상 브라우저는 이 쿠키를 저장하지 않고 무시합니다. (이미 암호화되지 않은 통신 구간에 노출된 리프레시 토큰입니다. 재요청 시에도 재사용이 발생하지 않도록 새로 생성하는 로직을 유지하세요.)</p>
<p><strong>SameSite 옵션</strong></p>
<p>브라우저는 <code>SameSite=Lax</code> 또는 <code>SameSite=Strict</code> 옵션이 있는 쿠키를 외부 도메인에 보내는 요청에 담지 않을 것을 약속합니다.</p>
<p>위 쿠키 옵션은 정상적인 네트워크와 브라우저에서 수행되는 약속입니다. 취약한 환경을 이용하는 것은 여전히 사용자에 의해 발생할 수 있는 취약점입니다.</p>
<h2 id="heading-66as7zse66ci7iucio2goo2bsoydmcdrs7tsobqgkoyenouyhck">리프레시 토큰의 보존 (서버)</h2>
<ul>
<li><p>데이터베이스에 보존합니다. (Redis 등 휘발성 데이터 관리에 편한 DB)</p>
</li>
<li><p>리프레시 토큰을 해싱하여, 원문 유출이 없도록 저장합니다.</p>
</li>
<li><p>해싱 시 암호화 강도는 비밀번호 암호화에 비해 낮추고 사용자 경험과 균형을 맞출 수 있습니다.</p>
</li>
</ul>
<p><strong>데이터베이스 (Redis Recommended)</strong></p>
<p><strong>휘발성 데이터를 관리하기 좋은 Redis에 리프레시 토큰을 담을 수 있습니다.</strong> DB 종류에는 영향을 받지 않지만 사용자 경험을 위해 조회 성능을 고려하는 것이 좋습니다.</p>
<p><strong>Redis 대체재</strong></p>
<p>Valkey 데이터베이스는 Redis 데이터베이스와 동일한 프로토콜에서, 동일한 기본 CRUD API 스펙을 제공합니다. Redis 라이선스 이슈에 관심이 있다면 Valkey 같은 대체재를 사용해 보는 것도 좋습니다. Valkey를 사용하더라도 Redis 관련 라이브러리를 사용할 수 있으며, 기본 CRUD 작업을 위해 새로운 라이브러리를 추가하지 않아도 됩니다. (스탠드얼론 이미지와 클러스터 이미지가 따로 존재합니다.)</p>
<p><strong>해싱</strong></p>
<p>리프레시 토큰은 서버에서 유출되더라도 원문을 알 수 없도록 해싱을 하여 저장합니다.</p>
<p>해싱 강도는 비밀번호에 비해 낮추고 사용자 경험과 균형을 맞출 수 있습니다. 리프레시 토큰이 완전히 랜덤으로 생성되며, 그 평균 수명이 대체로 짧고, 최대 수명까지 사용되는 리프레시 토큰도 적으며, 공격자의 오프라인 어택으로 1초에 수억 개씩 해싱을 시도해도 리프레시 토큰의 최대 수명까지 우연히 해독될 가능성이 매우 낮기 때문입니다.</p>
<hr />
<h1 id="heading-7jqu7jw9ioygleumra">요약 정리</h1>
<h2 id="heading-7jwh7is47iqkio2goo2bscdsojxrpqw">액세스 토큰 정리</h2>
<ul>
<li><p><strong>생성</strong></p>
<ul>
<li><p>비밀번호 등 인증이 완료되면 JWT를 발행합니다.</p>
</li>
<li><p>페이로드에는 최소 정보만 담아야 하며, 암호화되지 않습니다.<br />  (Base64 인코딩은 누구나 디코딩 할 수 있으며, 암호화가 아닙니다.)</p>
</li>
</ul>
</li>
<li><p><strong>전달</strong>: HTTPS 통신 환경에서 response body의 <code>access_token</code> 필드에 담아 응답합니다.</p>
<ul>
<li>리프레시 토큰처럼 Secure 옵션 등으로 보조할 수 없기 때문에, 응답이 HTTPS 환경에서만 수행되는지 따로 확인합니다.</li>
</ul>
</li>
<li><p><strong>보존</strong>: 클라이언트에 결정을 맡깁니다.</p>
<ul>
<li><p>상태 관리 라이브러리 등 private 변수에서 관리합니다.</p>
</li>
<li><p>새로고침 시 액세스 토큰이 사라지기 때문에 리프레시 요청을 보냅니다.</p>
</li>
<li><p>많은 회사들은 이 방식 대신 local storage에 담기도 합니다.</p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-66as7zse66ci7iucio2goo2bscdsojxrpqw">리프레시 토큰 정리</h2>
<ul>
<li><p><strong>생성</strong>: 암호학적으로 안전한 랜덤 함수를 사용해, 충분한 길이로 생성합니다.</p>
<ul>
<li>예를 들어 16바이트(128비트) 또는 32바이트(256비트)를 선택할 수 있습니다.</li>
</ul>
</li>
<li><p><strong>전달</strong>: 쿠키에 담아서 클라이언트에 전송합니다.</p>
<ul>
<li><code>HttpOnly; Secure; SameSite=Lax</code> 또는 <code>HttpOnly; Secure; SameSite=Strict</code> 옵션을 갖고 있는 쿠키에 담습니다.</li>
</ul>
</li>
<li><p><strong>보존</strong>: 해싱을 하여 데이터베이스에 저장합니다.</p>
<ul>
<li><p>해싱의 강도는 비밀번호 암호화보다 낮춰 사용자 경험과 조절할 수 있습니다.</p>
<ul>
<li>리프레시 토큰이 완전히 랜덤으로 생성되며, 그 평균 수명이 대체로 짧고, 최대 수명까지 사용되는 리프레시 토큰도 적으며, 공격자의 오프라인 어택으로 1초에 수억 개씩 해싱을 시도해도 리프레시 토큰의 최대 수명까지 우연히 해독될 가능성이 매우 낮기 때문입니다.</li>
</ul>
</li>
<li><p>데이터베이스는 Redis 등 휘발성 데이터 보존에 편리한 것을 사용할 수 있습니다.</p>
</li>
<li><p>데이터베이스의 종류는 상관이 없지만, 조회 성능이 좋은 것을 사용하는 것이 좋습니다.</p>
</li>
</ul>
</li>
</ul>
<hr />
<p>이번에는 JWT 인증·인가 중 ‘인증(authorization)’의 일부인 토큰 발행을 집중적으로 정리했습니다. JWT 액세스 토큰을 발행하고, 리프레시 토큰을 생성해 우리 서버만 사용할 수 있는 쿠키에 담았는데요.</p>
<p>이 파트만 해도 낯선 분들께는 정리할 내용이 많은 만큼, 다음 글에서는 새로운 정보를 전달하기보다, 이 스펙에 맞춰 스프링 시큐리티 없이 구현하는 과정을 로직 중심으로 작성해 보겠습니다.</p>
]]></content:encoded></item><item><title><![CDATA[당신이 JWT 인증에서 오해하는 것들 (기초와 진단) - Concept. 01]]></title><description><![CDATA[Intro.
JWT 인증 방식은 이제 많은 서비스가 메인으로 채택하는 대중적인 인증 방식 중 하나인데요. 사용자의 요청을 가볍고 빠르게 인가할 수 있는 장점이 있어, 분산 환경에서 가장 인기 있는 인증·인가 방식이죠.
하지만 JWT를 지금만큼 대중적으로 채택한 것이 긴 역사는 아닌 만큼, 개발자분들과 소통하거나, 여러 블로그를 탐색하다 보면 JWT에 대한 오개념이 곳곳에 산재해 있었습니다.
이렇게 많은 오해가 산재하는 환경이지만, 많은 사람들이...]]></description><link>https://blog.letsdev.me/jwt-authentication</link><guid isPermaLink="true">https://blog.letsdev.me/jwt-authentication</guid><category><![CDATA[로그인]]></category><category><![CDATA[JWT]]></category><category><![CDATA[authentication]]></category><category><![CDATA[Signin]]></category><category><![CDATA[access-token]]></category><category><![CDATA[refresh-token]]></category><category><![CDATA[JSON Web Tokens (JWT)]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Mon, 21 Oct 2024 00:00:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1729342954670/15ccafe6-308c-49ca-be84-5e2210797542.avif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-intro">Intro.</h1>
<p>JWT 인증 방식은 이제 많은 서비스가 메인으로 채택하는 대중적인 인증 방식 중 하나인데요. 사용자의 요청을 가볍고 빠르게 인가할 수 있는 장점이 있어, 분산 환경에서 가장 인기 있는 인증·인가 방식이죠.</p>
<p>하지만 JWT를 지금만큼 대중적으로 채택한 것이 긴 역사는 아닌 만큼, 개발자분들과 소통하거나, 여러 블로그를 탐색하다 보면 JWT에 대한 오개념이 곳곳에 산재해 있었습니다.</p>
<p>이렇게 많은 오해가 산재하는 환경이지만, 많은 사람들이 여러 블로그에서 그것에 대해 정확하게 다루고 있다고 기대하는 것 같았습니다. JWT가 대중적인 개념으로 보이는 만큼, 신뢰도 높은 정보로 정제되어 있다는 생각이 들었을 테니까요.</p>
<p>이 글에서는 JWT 액세스 토큰과 그것을 보조하는 리프레시 토큰에 대해 흔한 오해를 풀며, JWT 인증 방식에 대해 설명해 보겠습니다.</p>
<hr />
<h1 id="heading-67me6rwq7zwy6riwoidqs6dsoitsoihsnbgg7is47iwyioq4souwmcdsgqzsmqnsnpag7j247kad">비교하기: 고전적인 세션 기반 사용자 인증</h1>
<blockquote>
<p>세션에 로그인 사용자 정보를 담으면 로그인이 완료되어 간단하고도 안전하게 구현할 수 있습니다.</p>
</blockquote>
<p>세션과 쿠키는 전통적으로 웹 환경에서 사용되고 있는 대표적인 임시 저장 공간입니다.</p>
<ul>
<li><p><strong>세션</strong>: 서버마다 갖는 저장 공간 (<strong>S</strong>ession - <strong>S</strong>erver)</p>
</li>
<li><p><strong>쿠키</strong>: 클라이언트가 갖는 저장 공간과 데이터 (<strong>C</strong>ookie - <strong>C</strong>lient)</p>
</li>
</ul>
<p>세션은 서버에 존재하기 때문에 외부에서 임의로 접근하기 어려워 보안이 좋습니다. 그래서 민감한 임시 데이터는 세션에 담는 관례 같은 것이 있었고, 로그인에도 다들 사용해 온 겁니다.</p>
<pre><code class="lang-java"><span class="hljs-comment">// 비밀번호 인증</span>
<span class="hljs-keyword">if</span> (!encoder.matches(rawPassword, encodedPassword)) {
    <span class="hljs-keyword">throw</span> ...
}

<span class="hljs-comment">// 비밀번호 인증을 통과하면 세션에 사용자 정보를 담습니다.</span>
session.setAttribute(<span class="hljs-string">"SIGN_USER"</span>, signedUser);
</code></pre>
<h2 id="heading-67ae7ikwioylncdqs7xsnkdrkjjsp4ag7jwk7j2aioyeuoyfma">분산 시 공유되지 않은 세션</h2>
<p>과거에는 서버의 규모를 키우는 스케일업으로 버티는 곳도 많았지만, 현대에는 여러 가지 효율을 이유로 서버를 여러 개로 쪼개서 늘리거나 줄이는 ‘스케일아웃’을 선호하는 분위기입니다.</p>
<p>여기서 세션의 단점이 나타났는데, 세션은 서버에 속한 정보라서 여러 서버가 세션을 공유하지 않았기도 하고, 스케일 아웃의 장점을 살리려면 무리한 세션 공유도 줄여야 했으니.</p>
<p>당연히 로그인 정보도 서버 간에 공유되지 않아, 다음 그림으로 보면 사용자가 서버 A에 로그인을 했을 때, 서버 B는 로그인 사용자 정보를 갖지 않고, 결국 비회원으로 취급합니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729389597811/27a318e4-2f74-47ab-9592-9e212835c220.avif" alt class="image--center mx-auto" /></p>
<blockquote>
<p><strong>우리말 번역</strong></p>
<p><strong>클라이언트</strong>: 안녕, 나는 1이라는 세션 아이디를 갖고 있어. 다들 나 알지?<br /><strong>서버 A</strong>: 안녕. 난 네가 나에게 담은 것들을 알지.<br /><strong>서버 B</strong>: 안녕! 나는 네가 서버 A에 뭘 담았는지 몰라.</p>
</blockquote>
<p>이러한 문제로 개발자들은 분산 환경에서 유지할 수 있는 로그인 방식을 고민해야 했습니다.</p>
<h1 id="heading-67ae7ikwio2zmoqyveyxkoyencdsi5zrj4ttlaag7iiyioyeiouklcdsnbjspp0g67cp7iud65ok">분산 환경에서 시도할 수 있는 인증 방식들</h1>
<h2 id="heading-sticky-session"><strong>스티키 세션 방식 Sticky Session</strong></h2>
<p>우선 사전 지식으로 <strong>사용자 요청의 전달 과정</strong>을 많이 축약하면 다음과 같습니다.</p>
<ul>
<li>사용자 요청 → (인터넷) → <strong>각종 네트워크를 거쳐서</strong> → 서버에 도착</li>
</ul>
<p>여기서 ‘각종 네트워크를 거치는’ 과정에서 로드밸런싱(부하 분산)이라는 것을 하는데, 이때 요청을 어느 서버로 보낼지도 정합니다. 스티키 세션 방식은 이 로드 밸런싱을 할 때, 클라이언트를 항상 같은 서버로 보내는 방식입니다.</p>
<p>어쨌든 동종서버들 내에서는 이 사용자를 담당하는 전용 서버가 할당된다는 개념이고, 다른 서버 그룹도 사용자를 담당하는 서버를 하나씩 할당할 테니, 담당 서버끼리만 세션을 공유해 두어도 되는 개념입니다.</p>
<p>✅ 분산 환경에서 사용자의 인증·인가를 할 수 있습니다.</p>
<p>✅ (공유 방식에 따라 다르지만) DB를 거치지 않기 때문에 빠릅니다.</p>
<p>🟥 부하 분산이 고루게 되는 것은 보장되지 않습니다.</p>
<p>🟥 각 서버가 저렴한 구성인 것이 스케일아웃의 장점인데, 메모리·디스크 사용량이 늘어날 수 있습니다.</p>
<h2 id="heading-kirshljshzjsnyqg6ro17jyg7zwy64quiouwqeylntog6ria66gc67kmioyeuoyfmcoq"><strong>세션을 공유하는 방식: 글로벌 세션</strong></h2>
<p>DB 서버에 세션을 공유합니다. 사용자 로그인 정보가 DB에 저장되기 때문에 로그인 여부를 공유할 수 있습니다.</p>
<p>✅ 분산 환경에서 사용자의 인증·인가를 할 수 있습니다.</p>
<p>✅ 부하 분산을 고루게 해도 됩니다.</p>
<p>✅ 각 서버의 메모리·디스크를 별로 소비하지 않습니다.</p>
<p>🟥 모든 인가에서 매번 DB를 거치기 때문에 느립니다. (치명적 단점)</p>
<h2 id="heading-kirshljshzjsnyqg6ro17jyg7zwy64quiouwqeylntog6ria66gc67kmic0g66gc7lusioupmeq4so2zlcoq"><strong>세션을 공유하는 방식: 글로벌 - 로컬 동기화</strong></h2>
<p>DB 서버에 세션을 공유하고, 각 애플리케이션 서버가 이를 공유받아 자신의 세션에 동기화합니다.</p>
<p>✅ 분산 환경에서 사용자의 인증·인가를 할 수 있습니다.</p>
<p>✅ 부하 분산을 고루게 해도 됩니다.</p>
<p>✅ (동기화하고 나면) DB를 거치지 않기 때문에 빠릅니다.</p>
<p>🟥 실시간 동기화도 공짜가 아닙니다.</p>
<p>🟥 메모리·디스크 사용량이 늘어날 수 있습니다.</p>
<h2 id="heading-7kcv66asoidrtotsgrag7zmy6rk97j2yioyduoymnsdrsknsi53rk6q">정리: 분산 환경의 인증 방식들</h2>
<p>분산 환경에서 인증·인가 방식의 초점은 다음과 같습니다.</p>
<ul>
<li><p>충분한 보안성을 보장해야 합니다.</p>
</li>
<li><p>(인증은 느려도 되지만) 인가는 거의 모든 요청에 포함되기 때문에 빨라야 합니다.</p>
</li>
<li><p>스케일아웃의 장점을 살리기 위해 과도한 자원에 의존하는 인가 방식을 삼갑니다.</p>
</li>
</ul>
<h1 id="heading-hmac">사전 지식: HMAC 알고리즘 요약</h1>
<blockquote>
<p>전달 메시지 = 원문 메시지 + 해시 값</p>
</blockquote>
<p>HMAC 알고리즘은, 송신자와 수신자가 서로 약속한 방식으로 메시지를 해싱하면서, 메시지가 변조되지 않았다는 것을 확인하는 방식입니다. 이를 메시지의 무결성을 보장한다고 표현합니다.</p>
<blockquote>
<p><strong>참고: HMAC의 대략적인 흐름</strong></p>
<p><strong>준비</strong>: 송신자와 수신자는 동일한 비밀 키를 미리 공유합니다.</p>
<p><strong>발행: <em>송신자(생성, 전송) → 수신자</em></strong></p>
<ol>
<li><p>해시: <code>f(비밀 키, 원문 메시지) → 1차 해시 획득</code></p>
</li>
<li><p>해시: <code>f(비밀 키, 1차 해시) → 2차 해시 획득 (최종 HMAC 값)</code></p>
<p> 두 번의 해싱은 키 은닉에 도움을 줍니다.</p>
</li>
<li><p>전달: <code>전달 메시지 = { 원문 메시지, 2차 해시 값 }</code></p>
</li>
</ol>
<p><strong>사용(무결성 확인): <em>송신자 → 수신자(무결성 확인)</em></strong></p>
<ol start="4">
<li><p>수신자는 수신한 메시지 중 원문을 동일한 알고리즘으로 새로 해싱합니다. (동일한 비밀 키 사용)</p>
</li>
<li><p>수신자는 자신이 계산한 HMAC 값과 송신자가 보낸 HMAC 값이 일치하는지 확인합니다.</p>
</li>
<li><p>(일치하면) 위변조 된 메시지가 아니라고 확인하여, 발신자와 메시지를 신뢰합니다.</p>
</li>
</ol>
</blockquote>
<h1 id="heading-jwt-hmac">JWT 인증 방식에서 HMAC의 활용</h1>
<p>HMAC 알고리즘으로 무결성을 확인하면 다음을 알 수 있다고 정리할 수 있습니다.</p>
<ul>
<li><p>인증되지 않은 발신자에 의해 임의로 위조된 가짜 정보가 아님을 알 수 있습니다.</p>
</li>
<li><p>메시지가 전달 도중에 중간자에 의해 변조되지 않았음을 알 수 있습니다.</p>
</li>
</ul>
<p>JWT 인증 방식을 HMAC의 전송·인가 구조로 보면 다음과 같습니다.</p>
<blockquote>
<p><strong>‘송신자 → (전송: 클라이언트가 전달) → 수신자’</strong> 구조에서</p>
<ul>
<li><p><strong>송신자 = 인증서버</strong></p>
</li>
<li><p><strong>전송 구간 =</strong> (다른 네트워크 구간도 있지만) <strong>클라이언트</strong></p>
</li>
<li><p><strong>수신자 = 다른 서버들(각종 API 서버 등)</strong></p>
</li>
</ul>
</blockquote>
<p>이때 인증 서버는 JWT라는 토큰을 생성하고, 그것을 전달받아 확인하는 것이 다른 서버들입니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729409594399/677d0ef7-91fb-490a-99bb-94f2100fa3e3.avif" alt class="image--center mx-auto" /></p>
<p>좀 더 JWT 인증스럽게 풀어서 보는 흐름은 다음 그림과 같습니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729446363595/b6a6ef7c-a9e7-405d-91fb-433cbf01099a.png" alt class="image--center mx-auto" /></p>
<blockquote>
<p><strong>우리말 번역</strong></p>
<p><strong>인증 서버:</strong> “로그인되었습니다! 이 토큰을 받아서 당신의 요청에 첨부하세요.“<br /><strong>클라이언트:</strong> “그 토큰을 저장해 두고, 요청을 보낼 때 전달할게요.”<br /><strong>다른 서버들:</strong> “아, 저는 이 토큰을 만든 게 우리 쪽 서버들 중 하나라는 걸 알 수 있어요.“</p>
</blockquote>
<h2 id="heading-jwt-access-token">JWT Access Token의 구조</h2>
<blockquote>
<p>JWT는 점(.)을 구분자로 <code>HEADER.PAYLOAD.SIGNATURE</code> 세 구간을 연결한 하나의 문자열입니다.</p>
</blockquote>
<p>표준을 따르는 JWT 구조는 대략 다음과 같습니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729436964922/503d536a-a5b8-4ad7-8e4c-c6ad71d2efe9.avif" alt class="image--center mx-auto" /></p>
<p><strong>원문 메시지</strong><br />(전송을 위해 Base64로 인코딩한 비암호화 데이터. Base64는 암호화가 아니라 전송을 위한 인코딩.)</p>
<ul>
<li><p><strong>헤더(Header):</strong> 이 JWT에 대한 일종의 메타데이터입니다. (데이터에 대한 부가 정보)</p>
</li>
<li><p><strong>페이로드(Payload):</strong> 탑재물을 말하는 페이로드는 우리가 옮기려는 데이터입니다. (혼모노)</p>
</li>
</ul>
<p><strong>HMAC 값</strong></p>
<ul>
<li><strong>시그니처(Signature):</strong> 헤더와 페이로드를 해싱 한 HMAC 값입니다. (혼모노가 혼모노인지 확인)</li>
</ul>
<blockquote>
<p>JWT 인증 방식에서 인증을 할 때는 토큰 발행을, 인가를 할 때는 토큰 해석을 합니다.</p>
<ul>
<li><p>각 API 서버들은 페이로드에 있는 데이터를 신뢰하고 사용할 수 있습니다.</p>
</li>
<li><p>페이로드에는 어느 사용자가 인가되어 있는지와 최소한의 사용자 정보를 담습니다.</p>
<ul>
<li>절대 민감한 데이터를 담아선 안 됩니다. (페이로드는 비암호화 구간)</li>
</ul>
</li>
<li><p>액세스 토큰의 수명 등을 페이로드에 담습니다.</p>
</li>
<li><p>‘권한(authority)’ 또는 ‘역할(role)’을 페이로드에 담습니다.</p>
<ul>
<li><p>일반적인 구현에서는 하나의 역할이면 충분합니다.</p>
</li>
<li><p>여러 권한이나 역할을 담을 때는 인가에서 성능 지연이 생기지 않도록 합니다.</p>
</li>
<li><p>여러 권한이나 역할을 담을 때도 stateless한 방식을 유지하는 것이 좋습니다.</p>
</li>
</ul>
</li>
</ul>
</blockquote>
<h2 id="heading-jwt">JWT 인증을 채택하는 이유</h2>
<p>이러한 장점 때문에 JWT 인증 방식을 채택합니다.</p>
<ol>
<li><p>분산 환경에서 인증·인가를 구현하기 위해서입니다.</p>
</li>
<li><p>DB를 거치지 않아 빠르기 때문입니다.</p>
</li>
<li><p>DB를 거치지 않기 위해 서버의 자원을 무리하게 낭비하지 않기 때문입니다. (stateless)</p>
</li>
</ol>
<h2 id="heading-jwt-1">JWT 액세스 토큰의 문제점</h2>
<p><strong>통신 용량 증가는 적당한 비용입니다.</strong></p>
<p>JWT 인증 방식을 사용하면 통신 용량이 살짝 증가하는, 무시해도 될 만큼 가벼운 이슈가 있지만 이것은 우리가 stateless 인증 방식의 장점을 위해 지불하는 비용이라 큰 문제가 되지 않습니다.</p>
<p><strong>헤더와 Payload 오용을 조심하세요.</strong></p>
<p>드물게 헤더와 페이로드가 암호화되어 있다고 오해하는 분들이 있습니다. 일부 사례는 여기에 비밀번호 해시 값을 담기도 하였다고 듣곤 합니다. 헤더와 페이로드에는 절대로 민감한 데이터를 담지 않고, 최소 정보만 담는 노력을 해야 합니다.</p>
<h3 id="heading-jwt-2">JWT 액세스 토큰은 ‘로그아웃한 척’을 할 수 있습니다.</h3>
<p><strong>JWT 액세스 토큰은 ‘즉시 만료’를 보장하는 기능이 없습니다. 반드시 수명이 다해야 만료됩니다.</strong></p>
<p>‘프론트엔드’에서 토큰을 삭제할 수는 있지만 그것도 사용자가 그 프론트엔드 클라이언트만 사용할 때고, 토큰을 놓고 보면 굳이 그 프론트엔드를 사용하지 않아도 토큰을 담아 API 요청을 보낼 방법은 많습니다.</p>
<p>그래서 로그아웃도 ‘사용자에게 달린 기능’이 되기 때문에, 로그아웃을 시늉만 하고 실제로는 하지 않을 수 있는 거죠. 큰 문제는 다음으로 이어집니다.</p>
<h3 id="heading-jwt-3">JWT 액세스 토큰이 살아 있는 동안 이 사용자는 차단되지 않습니다.</h3>
<p><strong>이것은 오직 JWT 인증·인가에 모든 것을 맡길 때 이야기입니다.</strong></p>
<p>JWT 액세스 토큰은 사용자가 삭제하지 않는 이상 메모장에만 옮겨 놔도 수명이 다할 때까지 사용할 수 있습니다. 이것이 stateless 인증·인가에 모든 것을 맡기는 사람들이 마주하는 문제죠.</p>
<p>심플하게도 이 문제는 JWT로 만든 모든 토큰의 수명을 짧게 관리하는 방식으로 일차적으로 해결하고, 이 토큰의 수명이 다하면 재발급을 받을 때 걸러내는 것을 기본 방식으로 합니다. 여기서 스포를 하자면, 이것이 리프레시 토큰을 JWT로 생성하지 않는 이유이기도 합니다.</p>
<p><strong>추가: 블랙리스트 관리는 JWT의 장점을 떨어뜨립니다.</strong></p>
<p>그 외에도 인가에서 블랙리스트를 조회하기도 하는데, 민감한 기능의 인가에 유용한 방식입니다. 하지만 모든 JWT 인가에 블랙리스트를 사용하면, DB를 거치지 않아서 빠르다는 JWT의 장점을 거의 버리는 방식이 됩니다. 이러면 위에 잠깐 언급한 글로벌 캐시를 통한 세션 공유 방식보다 특별히 메리트가 없고, JWT 때문에 통신 용량까지 증가시켜 단점을 증폭시키는 방식이 될 겁니다. 대신 로그인된 사용자 목록 조회보다 크기가 작은 블랙리스트에서 조회가 더 빠르겠지만, ‘불행 중 다행식’ 장점을 찾을 게 아니라, 이 방식이 JWT의 장점을 거의 완전히 버리는 방식이라는 것이 중요합니다.</p>
<p>조금 더 보완하는 방식은 JWT 인가 개념 포스팅에서 블랙리스트 관리를 가볍게 다루겠습니다.</p>
<h1 id="heading-stateful">Stateful 리프레시 토큰</h1>
<blockquote>
<p><strong>리프레시 토큰은 액세스 토큰의 ‘수명이 짧아서’ 사용하는 게 아닙니다.</strong><br />액세스 토큰의 ‘<strong>수명을 짧게 하기 위해서’ 사용하는 게 진짜 목적입니다.</strong></p>
</blockquote>
<p><strong>JWT 인증·인가 방식은 stateless의 장점을 가져다 줌과 동시에, 만료시킬 수 없다는 치명적인 단점을 가져다 주었습니다. 그렇다면, ‘중간 중간 stateful하게 인가를 해서 확인하는’ 방식이 가능하다면, 그런 방식을 사용해서 보완할 수 있을 겁니다.</strong></p>
<p><strong>그것이 바로 ‘리프레시 토큰’입니다.</strong></p>
<p>조금 더 구체적으로는 다음 두 가지를 충족하면 됩니다.</p>
<ol>
<li><p>액세스 토큰의 수명은 짧게 둡니다.</p>
</li>
<li><p>Stateful한 리프레시 토큰을 사용합니다.</p>
</li>
</ol>
<p>이로써 사용자는 “중간중간 refresh token을 사용해서 인가하기”를 하게 됩니다.</p>
<ol>
<li><p>수명이 짧은 액세스 토큰이 자주 만료됩니다.</p>
</li>
<li><p>사용자는 리프레시 토큰을 사용해 서버에 리프레시를 요청합니다.</p>
</li>
<li><p>이때 서버는 사용자가 아직 자격을 갖고 있는지 확인합니다.</p>
</li>
<li><p>사용자가 리프레시를 받아도 된다면 새 액세스 토큰을 발급해 줍니다.</p>
<p> 일반적으로 이때 리프레시 토큰도 갱신합니다.</p>
<ul>
<li><p>새 리프레시 토큰 발급으로 기존 리프레시 토큰이 만료됩니다.</p>
</li>
<li><p>새 리프레시 토큰 발급으로 로그인 유지 수명이 연장됩니다.</p>
</li>
</ul>
</li>
</ol>
<p>사용자는 액세스 토큰이 빈번하게 만료되어 중간중간 리프레시 토큰을 사용해야 하기 때문에 stateful 인가 방식이 주기적으로 간섭할 수 있는 구조가 완성됩니다.</p>
<h2 id="heading-jwt-4">그래서 리프레시 토큰은 JWT로 생성하지 않습니다.</h2>
<p>리프레시 토큰은 stateful한 관리가 중요하기 때문에 JWT로 생성하지 않고 암호학적으로 안전한 랜덤 함수를 사용해 생성하는 것을 권합니다. 자세한 것은 다음 글에서 다룹니다.</p>
<h2 id="heading-66as7zse66ci7iucio2goo2bsoydgcdtg4jst6jrkjjrqbqg6riw67o47kcb7jy866gcioulteydtcdsl4bsirxri4jri6qu">리프레시 토큰은 탈취되면 기본적으로 답이 없습니다.</h2>
<p>리프레시 토큰은 한번 발급되고 나면, 서버에서 삭제하기 전까지 사용자가 액세스 토큰 발급에 사용할 수 있습니다. 마치 로그인을 대신하는 비밀번호 대체 값과 같은 역할입니다.</p>
<p>로그인 유지에 사용하는 만큼, 한번 탈취된 토큰은 (별도 탐지와 조치를 하지 않으면) 새 토큰의 발급에 반복적으로 사용되면서 지속적으로 인가를 유지할 수 있습니다. 만료되기 전에 재발급을 받는다면요.</p>
<h3 id="heading-7kcb7ja064eioyenouyhoyxkoyencdsnkdstpzrkjjripqg7j287j2aioyxhuywtoyvvcdtlanri4jri6qu">적어도 서버에서 유출되는 일은 없어야 합니다.</h3>
<p><strong>비슷한 사례로 비밀번호 해시 값 유출은 생각보다 빈번한 일입니다.</strong></p>
<p>비밀번호 해시 값이 수십만에서 수천만 개까지 유출이 되어도 비교적 안전했던 이유는 단방향 암호화를 한 상태로 저장하고, 경우의 수가 많은 만큼, 유추하기 어려운 비밀번호는 초당 수억 개씩 찍어 보더라도 인간의 수명보다 더 긴 시간이 필요하며, 현대적인 주요 단방향 암호화 함수들이 하드웨어 저항성을 갖고 있어서 초당 수억 개씩 찍어볼 수도 없기 때문입니다. 물론 유추하기 쉬운 비밀번호는 금방 시도되기 때문에 해시 유출이 되지 않도록 방지하는 게 우선이지만요.</p>
<p><strong>리프레시 토큰도 비밀번호를 대신할 수 있다면 유출에 대비해야 합니다.</strong></p>
<p><strong>그래서 리프레시 토큰도 서버에 저장할 때 해싱이라도 하는 게 좋습니다.</strong> 물론 비밀번호에 비해 랜덤하게 생성하여 쉽게 유추할 수 없는 리프레시 토큰은, 비밀번호만큼 보안을 위해 사용자 경험을 많이 양보하지 않아도 적절한 균형일 수 있습니다. 예를 들어 비밀번호 암호화에 1초를 사용한다고 해도, 리프레시 토큰 암호화는 그만큼 긴 시간을 사용하지 않아도 되죠.</p>
<p>초점은, 리프레시 토큰이 사용자의 요청 도중 ‘인가’ 과정에 가끔 간섭하기 위해 사용되고, 이때 사용자는 로그인을 유지하는 작업(리프레시)이 수행되고 있다는 인지 없이 API 응답이 잠시 느려지는 듯한 경험을 한다는 것입니다. 리프레시가 액세스 토큰의 수명이 만료될 때만 수행되는 만큼, 사용자 경험을 과도하게 해치지는 않지만, 우리가 로그인에 30초를 사용하지 않는 것처럼, 리프레시에도 1~2초를 사용할 필요가 없다면 조율이 가능하다는 것입니다. 이것은 팀과 회사의 결정에 따를 수 있습니다.</p>
<p><strong>양방향 암호화를 할 필요가 없기 때문에, 보안성이 더 강한 단방향 암호화를 합니다.</strong></p>
<p>리프레시 토큰은 ‘맞는지 확인'만 하는 용도입니다. 우리는 이런 것에 대해 원문으로 복호화를 하지 않고, 해시 값 상태로 비교하면 된다는 것을 비밀번호 비교에서 자주 관찰할 수 있습니다.</p>
<p>리프레시 토큰도 값이 일치하는지 비교하는 것이 목적이고, 원문의 내용을 복원해서 별도 작업을 할 일이 없는 데이터입니다. 그래서 보안성이 강한 단방향 암호화를 택하는 것이 좋습니다. 성능은 원하는 범위로 조절하면 되지만, 단방향 암호화는 복호화가 없기 때문에 보안 및 성능 요구사항을 도중에 바꾸려면 기존 암호화 방식과 병행하는 구조를 설계해 운영해야 합니다.</p>
<h3 id="heading-7kce7iahioq1roqwhoyxkoyencdtg4jst6jrkjjsp4ag7jwk7jwe7jw8io2vqeulioulpc4">전송 구간에서 탈취되지 않아야 합니다.</h3>
<p>그래서 리프레시 토큰은 Secure 옵션이 있는 쿠키에 담아서 HTTPS 환경에서만 함께 전송되도록 해야 합니다. 비밀번호를 다루는 모든 요청이 HTTPS를 사용하는 것처럼 리프레시 토큰도 HTTPS 환경에서 전달되어야 합니다.</p>
<h3 id="heading-7iks7jqp7j6qioy4osdqs7xqsqnsl5ag6riw67o47kcb7j24iouptoyxreydtcdsnojslrtslbwg7zwp64ui64uklg">사용자 측 공격에 기본적인 면역이 있어야 합니다.</h3>
<p>클라이언트 측으로 다양한 공격 시도가 있을 수 있습니다. 흔하게 언급되는 XSS<sup>Cross Site Scripting</sup> 공격, CSRF<sup>Cross Site Request Forgery</sup> 공격에 면역을 갖게 설계해야 합니다.</p>
<h4 id="heading-xsscross-site-scripting">XSS(Cross Site Scripting)</h4>
<p>XSS 공격은 유형이 나뉘지만, 모두 악의적인 소스 코드가 사용자의 환경에 전달되어 실행되게 합니다. 현대적인 프론트엔드 기술은 이 공격에 많은 면역을 갖는 환경을 제공하지만, 일부 동적인 기능은 여전히 관련된 위협을 내포할 수 있습니다. 따라서 XSS 발생 루트를 프론트엔드와 백엔드 모두에서 차단합니다.</p>
<p>전체 소스코드에 기본적인 XSS 방어 조치가 완료되어 있는지를 떠나서, 리프레시 토큰의 탈취 가능성을 차단해야 합니다. 어느 작업자의 실수로 XSS 공격을 받을 수 있는 취약점이 발생해도 리프레시 토큰에 접근할 수 없도록 <code>HttpOnly</code> 옵션을 사용할 수 있습니다.</p>
<p><code>HttpOnly</code> 옵션은, 이 쿠키를 브라우저만 받고, 프론트엔드 소스 코드가 동작하는 환경에는 제공해 주지 않겠다는 약속입니다. 정상적인 브라우저는 프론트엔드 스크립트에서는 <code>HttpOnly</code> 옵션이 있는 쿠키에 접근할 수 없게 하고, 서버에 요청을 보낼 때 자동으로 이 쿠키를 붙여서 보냅니다.</p>
<p>사용자가 정상적인 브라우저, 정상적인 네트워크를 이용해야 하며, 사용자가 이상한 브라우저나 취약한 네트워크를 이용하는 것은 여전히 사용자에 의해 발생할 수 있는 취약점입니다.</p>
<h4 id="heading-csrfcross-site-request-forgery">CSRF(Cross Site Request Forgery)</h4>
<p>사용자의 자격이 탈취되는 일을 방지해야 합니다.</p>
<p>CSRF 공격은 주로 쿠키에 대해 시도될 수 있는데요. 브라우저가 요청에 쿠키를 자동으로 담아서 보내기 때문에, 공격자가 이를 자신에게 보내게 하여 탈취하려는 시도가 있을 수 있습니다. 이 또한 웹 표준이 제공하는 쿠키 옵션으로 해결되며, <code>SameSite=Lax</code> 또는 <code>SameSite=Strict</code> 옵션을 추가하면 브라우저는 외부 도메인에 보내는 요청에는 이 쿠키를 담지 않을 것을 약속합니다.</p>
<p>사용자가 정상적인 브라우저, 정상적인 네트워크를 이용해야 하며, 사용자가 이상한 브라우저나 취약한 네트워크를 이용하는 것은 여전히 사용자에 의해 발생할 수 있는 취약점입니다.</p>
<h4 id="heading-66as7zse66ci7iucio2goo2bsoydhcdri7tsnyag7lg7ykk7jeqioycroyaqe2vmouklcdsmlxshzgg7kcv66as">리프레시 토큰을 담은 쿠키에 사용하는 옵션 정리</h4>
<p>쿠키에 필요한 옵션을 정리하면 다음과 같습니다. 각각 XSS 방어, 전송구간 암호화 요구, CSRF 방어를 위한 옵션이며, 이것이 누락된 것만으로 곧바로 유출되는 것은 아니지만, 모든 보안이 완벽하지 않을 때 이 옵션으로 서버와 정상적인 브라우저가 협업하여 리프레시 토큰 쿠키를 보호해 줄 것입니다.</p>
<ul>
<li><p>HttpOnly</p>
</li>
<li><p>Secure</p>
</li>
<li><p>SameSite=Lax 또는 SameSite=Strict</p>
</li>
</ul>
<h4 id="heading-corscross-origin-resource-sharing">CORS(Cross Origin Resource Sharing)는 보조적입니다.</h4>
<p>간혹 CORS 설정을 통해 방어할 수 있다고 오해하는 분들도 있지만, 우회 수단을 통한 공격에는 면역이 전혀 없기 때문에 직접적인 방어 수단이 아닙니다. 극단적으로 말하면, 이 보호에 관련해서는 CORS가 거의 도움이 되지 않을 수도 있습니다. 이미 탈취한 토큰이 있다면 자신을 위장하여 공격하는 것이 매우 쉽기 때문입니다. (CORS에 대한 자세한 설명은 본문과 무관하여 생략합니다.)</p>
<h3 id="heading-7iks7jqp7j6q7jeqioydmo2vncdsnkdstpzsnyag7keb7kcriounieydhcdsijgg7jeg7iq164ui64uklg">사용자에 의한 유출은 직접 막을 수 없습니다.</h3>
<p>인증과 인가는 사용자와 서버 간 신뢰성에 대한 협약입니다. 그 기밀성을 사용자 쪽에서 깬다면, 서버는 그것을 직접 방지할 수단이 없습니다. 그래서 마치 비밀번호처럼, 사용자의 부주의로 인한 유출은 서버가 직접 막지는 못합니다. 일부 공격 기법에 면역을 갖는 구조로 운영할 수는 있습니다만, 기본적인 조치일 뿐, 사용자 측의 모든 유출 수단을 없앨 수 없습니다.</p>
<p><strong>하지만 이상한 활동을 탐지할 수는 있습니다.</strong> 우리는 사용자의 활동 이력을 수집해 사이트의 보안 강화에 사용할 수 있습니다. 예를 들어 UA(User Agent)를 수집해서 사용자가 접속한 환경을 확인할 수 있고, 우회하여 은닉된 IP가 아니라면 IP 주소로 사용자의 대략적인 마지막 접속 위치를 알 수 있습니다. 이때 일부 활동의 수집은 사용자의 동의가 필요할 수 있습니다.</p>
<p>리프레시 토큰은 접속 환경당 하나씩이기 때문에, 여러 접속 환경을 사용하는 사용자는 리프레시 토큰을 복수로 사용할 수 있습니다. 하지만 적어도 각 토큰의 접속 환경은 불변이어야겠죠. 만약 다른 환경에서 토큰을 사용해 리프레시 요청을 한다면, DB에 잘 저장된 토큰이더라도 환경 불일치를 탐지합니다.</p>
<p>일부 서비스는 이 토큰이 각각 어느 환경에서 사용되고 있는지 사용자에게 공개하는 방식으로(로그인 된 기기 목록 등) 사용자 스스로 보안 모니터링에 동참하도록 유도합니다. 그리고 사용자 IP로 접속 국가를 알 수 있기 때문에, 사용자에게 기기별 마지막 접속 국가를 안내하거나, 평소 사용하지 않던 국가에서의 요청 발생 시 등록된 연락수단으로 알림을 보내거나, 사용자가 허용하지 않는 국가 IP로 리프레시 요청을 하면 무시하도록 사용자가 설정하는 등의 기능을 제공할 수 있습니다.</p>
<p>사용자 과실로 인한 유출은 시나리오가 많은 만큼 모두 막을 수 없고, 정상적인 패턴과 대부분 일치하는 탈취 패턴은 탐지부터 까다롭습니다. 단지 우리는 보안을 강화하기 위해 다양한 시나리오를 탐색하면서 조치를 추가하는 거죠.</p>
<h3 id="heading-7j207kceioumro2uhougioylncdthqdtgbag7j2066cl7j2eiouztoyhto2vmouptcdsoovsirxri4jri6qu">이전 리프레시 토큰 이력을 보존하면 좋습니다.</h3>
<p>사용되어 만료된 리프레시 토큰을 적어도 기존 수명까지는 보존할 수도 있습니다. 이렇게 하면 리프레시 토큰의 재사용 시도를 탐지할 수 있습니다. 액세스 토큰을 리프레시 할 때, 보통은 리프레시 토큰도 함께 재발급을 하기 때문에 리프레시 토큰을 안전한 일회성으로 사용합니다.</p>
<h3 id="heading-7iks7jqp7j6qioqzvoylpoyxkcdsnzjtlzwg7jyg7lac7j2yio2dkoyngoucmcdrsknsp4dripqg7isg7yod7kcb7j28ioyimcdsnojsirxri4jri6qu">사용자 과실에 의한 유출의 탐지나 방지는 선택적일 수 있습니다.</h3>
<p>좋은 이야기만 하고 싶지만, 수많은 시나리오를 커버할 만큼 활동을 탐지하며 보안을 강화하는 것이 일반 사용자 계정에 필요한지는 많은 기업들에게 고민일 수 있습니다. 관련된 보안 조치는 대부분 선택적이며, 사용자 과실로 인한 유출 탐지 및 방지에 리소스 과투자를 막는 것을 합리적으로 생각하는 곳이 많습니다.</p>
<hr />
<p>각 구간의 내용을 최대한 축약했습니다만, 항목이 많아서 전체 분량이 늘어났네요.</p>
<p>이 글에서 다룬 내용을 정리하자면 다음 내용들이 주류였던 것 같습니다.</p>
<ul>
<li><p>JWT 인증 방식의 장점은 분산 환경에서도 지연이 거의 없이 빠르게 인가할 수 있다는 것</p>
</li>
<li><p>stateless한 특징이 장점이지만 수명 내에는 만료가 없는 무적이기 때문에 수명을 짧게 둔다는 것</p>
</li>
<li><p>리프레시 토큰으로 stateful한 인가 과정을 중간중간 넣을 수 있어서 안전하다는 것</p>
</li>
<li><p>블랙리스트 관리는 인가를 지연시키지 않는 방식을 고민해야 한다는 것</p>
</li>
</ul>
<p>다음 글은 JWT 액세스 토큰의 생성과 전달, 리프레시 토큰의 생성, 전달, 보존 등을 다룹니다.</p>
]]></content:encoded></item><item><title><![CDATA[비밀번호 생성 규칙: 서비스의 정책 결정 (NIST 비밀번호 가이드라인 2024) NIST Password Guidelines]]></title><description><![CDATA[NIST의 비밀번호 권고 변경
요즘 암호학적 안전은 계산의 복잡성을 전제로 하면서 점점 '사회공학적(social engineering)' 측면을 더욱 세심하게 고려하고 있습니다. 그리고 암호 규칙은 사람들의 관습에서 관찰되는 패턴에 따라 규칙이 역전되거나 더욱 정교한 방향을 찾고 있죠.
1. 비밀번호 길이가 거의 모든 이슈의 중심입니다.
비밀번호는 규칙의 복잡성(ex: 특수문자를 반드시 포함, 대문자를 반드시 포함 등)보다 비밀번호 길이가 중요...]]></description><link>https://blog.letsdev.me/nist-password-guidelines-2024-kor</link><guid isPermaLink="true">https://blog.letsdev.me/nist-password-guidelines-2024-kor</guid><category><![CDATA[2024 password guidelines]]></category><category><![CDATA[비밀번호 규칙]]></category><category><![CDATA[password guidelines]]></category><category><![CDATA[passwords]]></category><category><![CDATA[password manager]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Mon, 09 Sep 2024 02:17:42 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1725858480704/088caaf6-3a9d-45ff-9af8-ff7f8fd00184.avif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-nist">NIST의 비밀번호 권고 변경</h1>
<p>요즘 암호학적 안전은 계산의 복잡성을 전제로 하면서 점점 '사회공학적(social engineering)' 측면을 더욱 세심하게 고려하고 있습니다. 그리고 암호 규칙은 사람들의 관습에서 관찰되는 패턴에 따라 규칙이 역전되거나 더욱 정교한 방향을 찾고 있죠.</p>
<h1 id="heading-1">1. 비밀번호 길이가 거의 모든 이슈의 중심입니다.</h1>
<p>비밀번호는 규칙의 복잡성(ex: 특수문자를 반드시 포함, 대문자를 반드시 포함 등)보다 비밀번호 길이가 중요합니다. 특수문자가 포함될 수 있다는 것이 암호학적 계산의 복잡성은 높여 주지만, 어차피 사용자가 특수문자를 사용하는 위치와 개수 등이 대체로 비슷하기 때문에 효과적이지 않았습니다. 이는 오랫동안 꾸준히 언급되고 있습니다.</p>
<p>비밀번호의 최소 길이에서 권장 길이는 다음과 같습니다.</p>
<ul>
<li><p>사용자가 생성하는 비밀번호: 최소 8글자 이상</p>
</li>
<li><p>기계 생성 비밀번호: 최소 6글자 이상 (ex: OTP 등)</p>
</li>
</ul>
<h1 id="heading-2-64">2. 64글자 비밀번호를 사용할 수 있도록 허용합니다.</h1>
<p>즉, 비밀번호에서 최대 길이에서 권장 길이는 64 또는 그 이상으로 설정하는 것이 좋습니다. 예를 들어, 구글은 100글자까지 입력할 수 있도록 허용합니다.</p>
<p>64글자나 그 이상의 긴 비밀번호는 쉽게 유출되지 않고 고유한 문구를 사용할 수 있기 때문에 기억하기 쉽습니다.</p>
<p><strong>64글자 고유한 문구 예시</strong> (영문으로 작성하는 64글자 예시)</p>
<ul>
<li><p>"불닭볶음면잘됐으면좋겠다맛있는데군대에서도먹었"</p>
</li>
<li><p>"더맥락없는문장으로쓰면안전한비밀번호가된다고하던"</p>
</li>
</ul>
<p>사용자는 보안성보다 사용성에 초점을 두는 비밀번호 습관을 보여 주는데, 길고 고유한 문구를 사용하는 비밀번호는 보안성과 사용성을 함께 갖출 수 있어 사용자에게 안전한 비밀번호를 유도합니다.</p>
<h1 id="heading-3">3. 비밀번호 블랙리스트를 확인하세요.</h1>
<p>비밀번호를 만들 때는 다음 목록에 해당하지 않아야겠죠.<br />(위험도가 높으면 차단, 아니면 경고)</p>
<ul>
<li><p>유출이 확인된 적이 있는 비밀번호</p>
</li>
<li><p>사전 공격에 등록된 단어<br />  (사람들이 자주 사용하는 비밀번호나 그와 닮은 문자 조합, 사용자의 개인 정보 등이 주요 대상)</p>
</li>
<li><p>반복적이거나 순차적인 문자(ex: "aaaaa", "1234abcd")</p>
</li>
<li><p>유추되는 문맥(ex: 서비스 이름, 사용자 계정명, 많이 노출된 개인정보 등)</p>
</li>
</ul>
<p><a target="_blank" href="https://github.com/danielmiessler/SecLists/tree/master/Passwords/Leaked-Databases">여기</a> 사용자의 비밀번호를 유추하기 좋은 '<a target="_blank" href="https://github.com/danielmiessler/SecLists/tree/master/Passwords/Leaked-Databases">유출 비밀번호 목록</a>'이 있습니다.</p>
<blockquote>
<p>Leaked Password Databases:<br /><a target="_blank" href="https://github.com/danielmiessler/SecLists/tree/master/Passwords/Leaked-Databases">https://github.com/danielmiessler/SecLists/tree/master/Passwords/Leaked-Databases</a></p>
</blockquote>
<p>비밀번호가 길더라도 반복되고 강조되는 문자로만 되어 있다면 쉽게 유추될 수도 있으니, 비밀번호 길이 안전성을 계산할 때는 반복되는 토큰을 추려낸 다음 고려하는 게 좋아 보입니다.</p>
<h1 id="heading-4">4. 특수문자를 강요하지 마세요.</h1>
<p>(단, 입력은 할 수 있게 합니다.)</p>
<blockquote>
<p>사용자에게 특수문자를 포함하도록 강요하는 것은, 오히려 사용자에게 쉬운 암호 패턴을 유도한다고 보입니다. 사용자는 어차피 비밀번호에 한두 글자의 특수문자만 포함하고 그 위치도 유추하기 쉬우며, 기억하는 부담을 줄이기 위해 오히려 취약한 비밀번호를 생성할 가능성을 높일 수 있습니다.</p>
</blockquote>
<p>NIST는 비밀번호에 대문자나 특수문자를 포함하도록 하면, 사용자가 오히려 취약한 비밀번호를 생성할 가능성을 높인다고 주장합니다. 특수문자를 포함하는 규칙은 사용자의 비밀번호 생성 패턴에서 생각보다 강력하지 않았다고 분석되었거든요. 어쩌면 여러분도 한두 글자만 포함하고 계실지도 모릅니다.</p>
<p>따라서 특수문자, 대문자 등의 규칙을 필수 정책으로 하기보다, 선택사항으로 하자는 겁니다.</p>
<p>또 한 가지 중요한 것은, 이것이 사용자가 특수문자를 사용할 수 없도록 하자는 취지는 아니라는 겁니다. 사용자가 애착 문자<sup>❤️</sup>를 하나 정해서 사이트마다 요구사항에 따라서만 조금씩 바꾸고 돌려 쓰는 방식은 별로지만, 특수문자를 다양하게 포함해서 경우의 수를 늘리는 방식은 여전히 좋은 방식입니다. 비밀번호 관리 도구를 사용할 때도 유용할 수 있죠.</p>
<h1 id="heading-5">5. 비밀번호에 대한 피드백을 제공합시다.</h1>
<p>사용자 비밀번호를 강도 높은 수준으로 다룰 수 있도록 유도할 수 있습니다. 일반적으로는 이런 방법들이 도움이 될 거예요.</p>
<ul>
<li><p>비밀번호 세기 측정기를 구현합니다.<br />  (password-strength meters)</p>
</li>
<li><p>비밀번호 시도 횟수를 제한합니다.</p>
</li>
<li><p>작성 중인 비밀번호를 볼 수 있는 UI를 제공합니다.<br />  (예를 들어 눈 모양 아이콘을 눌러 마스킹되지 않은 비밀번호를 확인할 수 있도록 합니다.)</p>
</li>
</ul>
<p>사용자가 기준에 맞지 않는 비밀번호를 만들려고 하면, 어떤 규칙을 위반하는지 설명해 주어야 합니다.</p>
<h1 id="heading-6">6. 비밀번호 찾기 힌트를 제공하지 않습니다.</h1>
<p>"어릴 적 추억의 장소는?", "나의 초등학교 이름은?"</p>
<p>저처럼 젊은 세대에게는 낯선 표현이지만, 어른들 세대에는 이것이 충분히 안전한 방식으로 좋은 사용자 경험을 제공한다고 생각한 것 같습니다.</p>
<p>하지만 이 힌트가 사용자 본인에게만 힌트인 것은 아닙니다. 사용자 계정에 무단으로 로그인을 시도하는 사람에게도 힌트를 주겠죠.</p>
<p>따라서 비밀번호 분실 시 힌트를 제공하는 것보다, 이메일, 전화번호, 또는 본인확인서비스 등 대체 인증 수단으로 신원을 확인해서 비밀번호 재설정을 하는 방식이 보안에 좋습니다.</p>
<p>(이런 비밀번호 규칙은 주로 온라인 서비스를 대상으로 합니다. 오프라인 단말기 암호 등은 다른 이유로 힌트가 필요할 수 있습니다.)</p>
<h1 id="heading-7">7. 패스워드 매니저를 안전하게 사용하세요.</h1>
<p>많은 사람들이 비밀번호 관리 도구(패스워드 매니저)를 사용하고 있습니다. NIST에서 패스워드 매니저 사용을 명시적으로 권장하는 것은 아니지만, 계정 관리자가 비밀번호 관리 프로그램을 사용할 수 있도록 복사하여 붙여넣기 기능을 허용하자고 안내하고 있습니다. (직원들에게 허용하도록)</p>
<p>NIST는 비밀번호 관리자 사용에 대해 다음과 같은 권장 사항을 제시했습니다.</p>
<ul>
<li><p>외울 수 있는 긴 비밀번호 문구를 선택하세요.</p>
</li>
<li><p>패스워드 매니저에서 모든 계정에 대해 고유한 비밀번호를 만드세요.</p>
</li>
<li><p>마스터 비밀번호의 복구를 허용하는 패스워드 매니저는 피하세요.</p>
</li>
<li><p>패스워드 매니저에 MFA(다중 인증. 주로 OTP 등)를 사용하세요.</p>
</li>
<li><p>온라인 보안 질문에 대해 무작위로 복잡한 답변을 생성하세요.</p>
</li>
</ul>
<h1 id="heading-8">8. 비밀번호는 필요할 때만 바꾸도록 합니다.</h1>
<p>우리나라는 아직 개인정보 취급자 계정(직원용 계정)의 비밀번호를 6개월마다 반드시 바꾸도록 하지만, 이제 그것이 보안을 강화하기보다 오히려 동일하거나 비슷한 비밀번호를 다시 사용하는 습관을 부추기는 것을 알게 되었습니다.</p>
<p>이제 NIST는 비밀번호를 변경해야 하는 경우를 다음 두 가지로 이야기합니다.</p>
<ul>
<li><p>인증 기관에 대한 해킹이 발견되었을 때</p>
</li>
<li><p>사용자가 그냥 원할 때</p>
</li>
</ul>
<h1 id="heading-9">9. 오프라인 공격 저항성을 보장하면서 비밀번호를 저장합니다.</h1>
<p>데이터베이스 등에 있는 비밀번호 해시 값은 생각보다 자주 유출되어 왔습니다. 해시 값이 유출되고 나면 더 이상 운영 중인 서버에 브루트포스 등을 시도할 필요가 없습니다. 컴퓨터에 환경을 구축해서 오프라인 상태로 비밀번호를 찍어 볼 수 있거든요.</p>
<p>이것을 방지하기 위해서, 동시에 여러 비밀번호를 암호화하기 버겁도록 적절한 KDF(키 유도 함수. Key Derivation Function)를 사용하여 비밀번호를 솔팅 및 해싱 하도록 권장합니다.</p>
<p>'단방향 암호화'는 원래 필수이고, 이때 알맞은 암호화 함수를 사용하는 것을 권장하는 것입니다.</p>
<p><strong>비밀번호 해시 값 유출 사례</strong></p>
<p>개인정보 유출 사례 중 단방향 암호화된 비밀번호를 포함하는 사례는 많이 있습니다. 대부분 기본적으로 단방향 암호화를 해 둔 상태였으며, 이때 유출되는 정보로는 비밀번호를 도출하거나, 그 사이트에서 사용 가능한 대체 비밀번호를 만드는 데에 상당한 시간이 필요합니다.</p>
<ul>
<li><p>2008 | 옥션 | 1081만 명 → 1863만 명 | 비밀번호 포함</p>
</li>
<li><p>2010 | 25개 업체(신세계 등) | 2000만 건 | 일부는 비밀번호 포함</p>
</li>
<li><p>2011 | 네이트-싸이월드 | 3500만 명 | 비밀번호 포함</p>
</li>
<li><p>2011 | 넥슨 | 1320만 명 | 비밀번호 포함</p>
</li>
<li><p>2013 | 어도비 | 290만 명 → 3800만 명 | 비밀번호 포함</p>
</li>
<li><p>2014 | 네이버 | 20만 명 | 비밀번호 포함</p>
</li>
<li><p>2014 | 티몬 | 113만 명 | 비밀번호 포함</p>
</li>
<li><p>2014(2011. 06 발생 짐작) | 천재교육 | 규모 모름(350만 명 이하) | 비밀번호 포함</p>
</li>
<li><p>2014 | 스킨푸드 | 55만 명 | 비밀번호 포함</p>
</li>
<li><p>2014 | 토니모리 | 50만 명 | 비밀번호 포함</p>
</li>
<li><p>2014 | 이베이 | 최대 1억 4500만 명 | 비밀번호 포함</p>
</li>
<li><p>2014 | 아프리카TV | 규모 안 밝힘 | 비밀번호 포함</p>
</li>
<li><p>2014 | 능률교육 | 1,048,576 건 | 비밀번호 포함</p>
</li>
<li><p>2015 | 뽐뿌 | 190만 명 | 비밀번호 포함<br />  | 심각(MD5)</p>
</li>
<li><p>2016(2012 발생) | 링크드인 | 1억 6700만 건 | 비밀번호 포함</p>
</li>
<li><p>2016 | 링크드인 | 1억 1700만 명 | 비밀번호 포함 (위와 별건)</p>
</li>
<li><p>2016 | 인터파크 | 1030만 명(2540만 건) | DB 유출</p>
</li>
<li><p>2017 | 이스트소프트 | 13만 3800여 건 | 비밀번호 포함</p>
</li>
<li><p>2017(2013 발생) | 야후 | 10억 명 → 30억 명 | 비밀번호 포함<br />  | 심각(MD5)</p>
</li>
<li><p>2018 | 윈윈소프트 | 규모 안 밝힘 | DB 해킹, 비밀번호 포함</p>
</li>
<li><p>2019 | 스카이에듀 | 규모 안 밝힘 | 비밀번호 여부는 모호하게 표현</p>
</li>
<li><p>2019 | 메가스터디 | 570만 명 | 비밀번호 포함</p>
</li>
<li><p>2019 | 슈프리마<br />  | 2780만 건 | 지문, 안면데이터 등이 암호화되지 않은 채 노출됨 (유출 여부 모름)<br />  | 심각(생체정보 평문)</p>
</li>
<li><p>2020 | 국내 여러 카드 및 멤버십 가맹점, 은행 등<br />  | 단순계산 시 누적 412억 건 정도(1.5테라바이트) | 카드 번호, 카드 유효기간, 카드 비밀번호 포함</p>
</li>
<li><p>2020 | 위더스교육 | 비밀번호 포함</p>
</li>
<li><p>2021 | 밀크T(천재교육) | 비밀번호 여부는 모호하게 표현</p>
</li>
<li><p>2022 | 발란 | 비밀번호 여부는 모호하게 표현</p>
</li>
<li><p>2023 | LG U+ | 29만 명 → 59만 명 | 비밀번호 포함</p>
</li>
<li><p>2023 | 더쿠 | 규모 안 밝힘 | 비밀번호 포함</p>
</li>
</ul>
<p><strong>외부에서 암호가 유출되었을 가능성이 있는 사례</strong></p>
<ul>
<li><p>2023 | 한국장학재단 | 규모 안 밝힘 | 해외 IP의 대규모 로그인 시도</p>
</li>
<li><p>2023 | 워크넷(한국고용정보원)<br />  | 로그인 시도 500만여 건 중 38만여 건이 성공(23만 명 유출) | 해외 IP의 대규모 로그인 시도</p>
</li>
</ul>
<hr />
<h1 id="heading-6riw7yoa">기타</h1>
<h2 id="heading-q-microsoft"><strong>Q. 비밀번호 힌트를 제공하는 Microsoft?</strong></h2>
<blockquote>
<p>"아직도 비밀번호 힌트를 사용하세요? 그런 건 마이크로소프트나 하는 건데요."</p>
<p>비밀번호 힌트가 더 이상 추천되지 않는데도 불구하고, 많은 단말기의 운영체제가 사용자 계정에 대해 '비밀번호 힌트'를 제공합니다. 사실 이는 보안보다는 사용자 경험에 균형을 맞추기 위한 것이고, 다음 이유로 비교적 합리적인 결정일 수 있죠.</p>
<ul>
<li><p><strong>오프라인 환경</strong></p>
<p>  개인용 기기에서 사용되는 경우가 많아 온라인 공격에 노출될 가능성이 상대적으로 낮습니다.</p>
</li>
<li><p><strong>능숙하지 않은 사용자</strong></p>
<p>  특히 고령자나 기술적으로 덜 숙련된 사용자에게는 비밀번호 힌트가 필요한 수단일 수 있습니다.</p>
</li>
<li><p><strong>접근·사용 빈도가 낮은 기기나 계정</strong></p>
<p>  사용 빈도가 낮고 중요도가 낮은 계정이라면, 복잡한 분실 복구보다 비밀번호 힌트가 합리적으로 보일 수 있습니다.</p>
</li>
<li><p><strong>비밀번호 분실 시 초기화할 수 없는 사용자도 존재</strong></p>
<p>  어떤 사용자는 기술적으로 사소한 일을 하는 데에도 어려움을 겪을 수 있습니다. 또는 대체 인증 수단이 없어서 이메일이나 전화번호 인증을 통한 복구도 안 될 수도 있습니다. 예를 들어 계정을 만들 때 등록한 이메일, 전화 번호를 더 이상 사용하지 않을 수도 있죠.</p>
<p>  그럼에도 급한 용무로 기기를 사용해야 할 수도 있는 사용자를 위해서 비밀번호를 떠올릴 수단을 제공할 수 있습니다.</p>
</li>
</ul>
</blockquote>
<hr />
<h1 id="heading-references"><strong>References</strong></h1>
<ol>
<li><p>NIST의 패스워드 가이드라인을 해석하여 정리</p>
<p> <a target="_blank" href="https://www.itsasap.com/blog/nist-password-guidelines">https://www.itsasap.com/blog/nist-password-guidelines</a></p>
</li>
<li><p>유출된 비밀번호들<br /> <a target="_blank" href="https://github.com/danielmiessler/SecLists/tree/master/Passwords/Leaked-Databases">https://github.com/danielmiessler/SecLists/tree/master/Passwords/Leaked-Databases</a></p>
</li>
<li><p>비밀번호 해시 값 유출 사례</p>
<ol>
<li><p>목록 확인 (나무위키 "개인정보 유출 사태")</p>
<p> <a target="_blank" href="https://namu.wiki/w/%EA%B0%9C%EC%9D%B8%20%EC%A0%95%EB%B3%B4%20%EC%9C%A0%EC%B6%9C%20%EC%82%AC%ED%83%9C">https://namu.wiki/w/%EA%B0%9C%EC%9D%B8%20%EC%A0%95%EB%B3%B4%20%EC%9C%A0%EC%B6%9C%20%EC%82%AC%ED%83%9C</a></p>
</li>
<li><p>규모와 암호화된 비밀번호 유출 여부 등은 각각 기사를 찾아 보며 기록했습니다.</p>
</li>
</ol>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[비밀번호 재사용 방지, 현대적인 단방향 암호화의 성능 이슈 (Restricted Password Reuse; Password Reuse Prevention)]]></title><description><![CDATA[글의 요약 (주요 글감)

최근 사용한 비밀번호 100개를 재사용 할 수 없도록 막는 데에 기존 현대적인 단방향 암호화로는 큰 시간을 소요함. (해커가 동시에 여러 비밀번호를 시도하기 더 번거롭도록 설계됨.)

인증용 암호는 BCrypt, SCrypt, Argon2 등 충분히 안전한 암호화 함수와 가변 솔트 사용

비밀번호 재사용 확인용 해시는 암호학적 해시 함수, 사용자 고정 솔트, 페퍼또는 시크릿 키를 사용


이 방식의 취약점 대처 1 요...]]></description><link>https://blog.letsdev.me/password-history-kor</link><guid isPermaLink="true">https://blog.letsdev.me/password-history-kor</guid><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Fri, 06 Sep 2024 12:03:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1725491978191/1009e913-8f82-47c4-9210-2ff1af1bf259.avif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725490104998/2679c0fa-5626-4d3d-a624-db87555b78fd.avif" alt class="image--center mx-auto" /></p>
<p><strong>글의 요약 (주요 글감)</strong></p>
<ul>
<li><p>최근 사용한 비밀번호 100개를 재사용 할 수 없도록 막는 데에 기존 현대적인 단방향 암호화로는 큰 시간을 소요함. (해커가 동시에 여러 비밀번호를 시도하기 더 번거롭도록 설계됨.)</p>
</li>
<li><p>인증용 암호는 BCrypt, SCrypt, Argon2 등 충분히 안전한 암호화 함수와 가변 솔트 사용</p>
</li>
<li><p>비밀번호 재사용 확인용 해시는 암호학적 해시 함수, 사용자 고정 솔트, 페퍼<sup>또는 시크릿 키</sup>를 사용</p>
</li>
</ul>
<p><strong>이 방식의 취약점 대처 1 요약: DB의 해시 값 유출 시</strong></p>
<ul>
<li><p>DB의 해시 값 유출 시, 비밀번호 재사용 확인용 해시는 인증용 암호에 비해 쉽게 해독될 수 있음.</p>
<ul>
<li>이는 사용자의 인증용 암호를 유추하는 새로운 수단이 됨.<br />  (인증 테이블 대신 히스토리 테이블을 공략할 수 있음.)</li>
</ul>
</li>
<li><p>사용자별 고정 솔트를 인증 DB와 격리된 공간에 저장하면 DB 유출 시 해독에 대한 면역성 증가</p>
</li>
<li><p>격리된 저장소를 운영하기 어렵다면, 복잡하게 연산하여 솔트를 구하도록 해도 어느 정도 보호됨.</p>
</li>
</ul>
<p><strong>이 방식의 취약점 대처 2 요약: 현재 사용 중인 비밀번호의 쉬운 해시가 생성되지 않게 하기</strong></p>
<ul>
<li><p>새 비밀번호(인증에 사용하는 비밀번호)는 히스토리를 저장하지 않음.</p>
</li>
<li><p>그 대신 비밀번호 변경 시 기존 비밀번호를 함께 받고, 기존 비밀번호로 인증에 성공하면, 만료되는 기존 비밀번호를 해싱하여 히스토리에 저장하는 방식을 택할 수 있음.</p>
</li>
<li><p>비밀번호 히스토리의 모든 해시 값이 노출되어도 '지금 사용 중인 비밀번호'의 해시 값은 없음.</p>
</li>
<li><p>인증용 테이블에 있는 비밀번호 해시 값은 가변 솔트를 사용하여 저장되는 방식을 유지함.<br />  (현재 사용 중인 비밀번호는 이 테이블에만 존재)</p>
</li>
</ul>
<p><strong>대략적인 권장의 예시 1</strong></p>
<ul>
<li><p>비밀번호 변경 요청에서 기존 비밀번호와 새 비밀번호를 모두 받음.</p>
<ul>
<li><p>인증용 암호화에는 Argon2id 사용을 권장할 수 있음.</p>
</li>
<li><p>히스토리용 암호화는 Argon2d 사용을 권장할 수 있으며, 이는 인증에 사용되어선 안 됨.</p>
</li>
</ul>
</li>
<li><p><strong>1단계</strong>: 기존 비밀번호 인증(Argon2id)</p>
</li>
<li><p><strong>2단계</strong>: 히스토리 검토(Argon2d 및 고정 솔트)<br />  * <em>Argon2 함수는 가변솔트가 표준이므로, 비표준적인 사용이며 일부분 직접 구현해야 할 수 있음.</em></p>
<ul>
<li><p>인증에 성공해야 이 단계를 수행하므로 사이드 채널 어택 가능성이 거의 없음.<br />  (반드시 Argond2i나 Argon2id를 택하지 않아도 됨.)</p>
</li>
<li><p>해시 값 유출에 대한 대비를 더 강화하는 것이 좋음.<br />  (해시 값 유출에 한하여 Argon2d가 Argon2id보다 저항성이 있음.)</p>
</li>
</ul>
</li>
<li><p><strong>3단계</strong>: 암호화한 해시 저장</p>
<ul>
<li><p>새 비밀번호는 암호화하여 인증 테이블에 저장(Argon2id) - 이 단계에 사용자에게 응답</p>
</li>
<li><p>기존 비밀번호는 비밀번호 히스토리에 저장(Argon2d) - 이 작업은 비동기로 넘겨 두어도 됨.</p>
</li>
</ul>
</li>
</ul>
<p><strong>대략적인 권장의 예시 2: 재인증과 토큰을 통한 인가 (사용자 경험 개선)</strong></p>
<ul>
<li><p>요청 1: 비밀번호 변경 전 재인증 수행</p>
<ul>
<li><p>기존 비밀번호 입력으로 인증하고 토큰 발행 (이때 응답)</p>
</li>
<li><p>이때 토큰은 JWT처럼 원문과 함께 보내는 무결성 체크가 아님.</p>
</li>
<li><p>기존 비밀번호는 히스토리용으로 암호화하여 미리 임시 보관을 할 수 있음.<br />  (비동기로 처리할 수 있으므로 인증 시간을 불필요하게 늘리지 않을 수 있음.)</p>
</li>
</ul>
</li>
<li><p>요청 2: 토큰으로 인가하여 비밀번호 변경</p>
<ul>
<li><p>인가 과정이 토큰으로 변경되었기 때문에 충분히 빠름.</p>
</li>
<li><p>새 비밀번호를 히스토리용으로 암호화하여 히스토리 테이블과 비교</p>
</li>
<li><p>히스토리에 중복이 없다면 새 비밀번호를 인증용으로 암호화하여 인증 테이블에 저장 및 응답</p>
</li>
<li><p>임시 보관되어 있던 기존 비밀번호의 히스토리용 암호화 해시 값을 히스토리 테이블에 저장</p>
</li>
</ul>
</li>
</ul>
<hr />
<h1 id="heading-67cw6rk9">배경</h1>
<h2 id="heading-64si66y0ioyepuydgcdruytrsidrsojtmlgg67oa6rk97j2aiou5houwgouyio2yucdsnqzsgqzsmqnsnyqg7jyg67cc">너무 잦은 비밀번호 변경은 비밀번호 재사용을 유발</h2>
<h3 id="heading-nist">암호학과 사회공학의 결합, NIST의 가이드라인 변화</h3>
<p><strong><em>"비밀번호를 자주 바꾸게 하면, 오히려 쉬운 암호를 찾거나 과거에 사용했던 암호를 (비슷하게나 그대로) 다시 사용합니다."</em></strong></p>
<p>과거부터 암호학은 연산중심적으로 비밀번호의 안전성을 평가하면서, 동시에 사용자들의 비밀번호 사용 습관이나 생활 습관을 반영하면서 발전해 온 것 같습니다. 그러다가 사람들의 습관에 따라 가이드라인을 완전히 바꾸어 놓기도 하고, 일부 규칙은 사용자가 생각보다 더 '<em>스스로를 보호하는 습관을 만들지 않을 것이기 때문</em>'에 바뀌었죠. 그중 하나가 비밀번호 업데이트 주기입니다.</p>
<h3 id="heading-67me67ca67ki7zi4ioyxheunsoydto2kucdsi5zsoja">비밀번호 업데이트 시점</h3>
<p>요즘 NIST 비밀번호 가이드라인은 비밀번호의 잦은 업데이트를 추천하지 않습니다. NIST가 권장하는 비밀번호 업데이트 시점은 다음과 같죠.</p>
<ul>
<li><p>❌ 90일에 한 번씩 변경하도록 권장합니다. (옛날)</p>
</li>
<li><p>❌ 1년에 한 번 정도는 바꾸어야 좋습니다. (루머)</p>
</li>
<li><p>✅ 비밀번호가 유출되었다는 것이 발견될 때는 바꾸어야 합니다.</p>
</li>
<li><p>✅ 사용자가 원하면 바꿀 수 있습니다.</p>
</li>
</ul>
<p>비밀번호를 자주 바꾸는 것은 그다지 도움이 되는 보안이 아니었으며, 오히려 쉬운 암호 사용이나 과거에 사용했던 암호를 다시 사용하는 습관을 유발했죠.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725610810650/5359e80e-680b-4d1d-9a98-2848ac285eef.avif" alt class="image--center mx-auto" /></p>
<p><a target="_blank" href="https://www.auditboard.com/blog/nist-password-guidelines/">https://www.auditboard.com/blog/nist-password-guidelines/</a></p>
<p>&lt;디지털 신원 가이드라인&gt;, NIST SP-800-63, 2020</p>
<ul>
<li><p>비밀번호 길이(복잡성 요구사항보다), 저장된 비밀번호의 솔팅 및 해싱, MFA(다중 인증: 2FA 등), 사용자가 비밀번호 보안 정책을 더 쉽게 준수할 수 있도록 안내</p>
</li>
<li><p>조직은 직원들에게 1년에 한 번 이상 비밀번호를 재설정 하도록 요구해서는 안 되며, 새 비밀번호를 모니터링 하여 일반적인 비밀번호 및 유출된 비밀번호 목록과 비교하여 테스트할 것.</p>
</li>
</ul>
<p>과거에는 NIST가 오히려 비밀번호를 자주 바꾸기를 권했고(90일), 그것이 전세계적으로 '상식'이 되어, 우리나라에서는 개인정보를 다루는 직원들의 계정은 6개월에 한 번씩 반드시 비밀번호를 바꾸어야 하죠. 그리고 아직 많은 사이트에서 일반 사용자 계정에도 비밀번호를 바꿀 건지 3개월마다 묻습니다. 그것이 세계적인 상식에서 원래 보안에 좋은 거였으니까요. 이런 습관은 시나브로 바뀌어 갈 것 같습니다.</p>
<h2 id="heading-6ro86rgwiou5houwgouyio2yuoydmcdsnqzsgqzsmqkg67cp7kea7zwy6riw">과거 비밀번호의 재사용 방지하기</h2>
<p><strong><em>"비밀번호를 자주 바꾸지 않게 하는 것도, 쉬운 비밀번호 사용과 비밀번호 재사용을 막기 위해서입니다."</em></strong><br /><strong><em>과거에 사용한 비밀번호는 보안에 비교적 취약하기 때문입니다.</em></strong></p>
<p>비밀번호에 관련한 공격은 주로 사람들의 생활 습관을 반영합니다. 많은 사람들은 과거에 사용한 적 있는 비밀번호를 다시 사용하거나, 여러 사이트에서 동일한 비밀번호를 사용하는 습관을 갖고 있죠. 공격자는 사용자가 비슷하거나 동일한 비밀번호를 반복적으로 사용할 가능성을 고려할 수 있고, 아마 공격 전략에 사용자의 유출된 적 있는(다른 사이트를 통해서라도) 비밀번호나 그와 비슷한 비밀번호를 포함할 겁니다.</p>
<p>사용자는 비밀번호의 재사용과 관련해서 다음 세 가지를 조심해야 합니다.</p>
<ol>
<li><p>여러 서비스에 동일한 비밀번호를 사용하지 않습니다.</p>
</li>
<li><p>이미 유출된 것으로 잘 알려진 비밀번호를 다시 사용하지 않습니다.</p>
</li>
<li><p>사용한 적이 있는 비밀번호와 동일하거나 비슷한 비밀번호를 사용하지 않습니다.</p>
</li>
</ol>
<p>1, 2, 3 중에서 1번은 사용자 스스로 조심하는 수밖에 없지만, 2번과 3번은 우리가 서비스를 구축하면서 제공하는 보안 요구사항이 될 수 있죠. 이번 글은 3번 항목에 대해서 다룹니다.</p>
<hr />
<h1 id="heading-67me67ca67ki7zi4io2eioykpo2gooumrcdqtidrpqzsnzgg7jqu6rws7iks7zwt">비밀번호 히스토리 관리의 요구사항</h1>
<h2 id="heading-7jqu6rws7iks7zwtioqygo2goo2vmoq4sa">요구사항 검토하기</h2>
<p>보안과 사용자 경험을 적절히 만족시키는 것이 좋습니다. 우선순위는 보안이어야 합니다. 사용자 경험은 대체로 사용감과 편의성을 위한 것으로 이해할 수 있고, 매출과 경쟁력을 높이는 요소입니다.</p>
<h3 id="heading-n">✅ 과거에 사용한 것과 동일한 비밀번호 사용을 <strong><em>N 개까지</em></strong> 막기</h3>
<p>특히 비밀번호가 유출을 의심할 만할 때 변경되는 것을 대전제로 하면, 비밀번호를 다시 사용하는 것은 '유출되었을 가능성이 다른 것에 비해 높은' 비밀번호를 사용하는 것입니다.</p>
<p>예를 들어 구글은 사용자가 최근에 사용한 적이 있는 비밀번호 100가지를 다시 사용할 수 없도록 합니다.</p>
<h3 id="heading-n-1">❌ 과거에 사용한 것과 동일한 비밀번호 사용을 <strong><em>N 년 동안</em></strong> 막기</h3>
<p>동일한 비밀번호의 재사용을 개수로 제한하는 의견처럼, 기간으로도 제한할지 검토할 수 있습니다. 예를 들어 10년 이내에 사용한 비밀번호만 제한하고, 그보다 오래된 비밀번호는 '충분히 오래 전에 사용했기 때문에' 보존할 필요가 없다고 생각하여 삭제하거나, 기존 히스토리가 새로운 히스토리 정책 운영 방식에 너무 맞지 않아 어차피 배제해야 하는 상황이 있을 수 있습니다.</p>
<p>이처럼 각자의 이유로, 오래된 비밀번호 이력을 삭제하거나 운영 방식에서 제외할 수 있습니다. 대부분은 운영상의 이유일 것 같습니다. 또는 개수 제한을 없애고 기간에 따라서만 제한할 수도 있지만, 과도하게 사용자 경험을 해치며, 사용자가 원하는 비밀번호를 택할 수 없게 만들 수도 있고, 비밀번호 변경 이력을 너무 자주 남겨 불필요한 데이터량을 오랫동안 유지해야 할 수도 있습니다.</p>
<p>우리는 이것이 보안 강화를 위한 선택은 아니라는 것을 알아야 합니다.</p>
<h3 id="heading-4p2mioqzvoqxsoyxkcdsgqzsmqntlzwg6rkd6ro8icoqkifruytsirftlzwnkioqiou5houwgouyio2yucdsgqzsmqnsnyqg66ej6riw">❌ 과거에 사용한 것과 <strong><em>'비슷한'</em></strong> 비밀번호 사용을 막기</h3>
<p>동일한 것이 아니라 '유사한' 비밀번호의 재사용을 제한하는 요구사항은 개발자가 거부해야 합니다.</p>
<p>개발자가 이러한 요구사항을 거부해야 하는 이유는 다음과 같습니다.</p>
<ul>
<li><p>비슷한 패턴을 찾기 위해 원문을 비교할 수 있으면 안 됩니다. 비밀번호는 반드시 단방향 암호화를 해서 저장하도록 되어 있습니다.</p>
</li>
<li><p>암호학적으로 저항성이 없는 해시를 사용하는 것도 안 됩니다. 암호학적 해시 함수는 눈사태 효과에 따라 해시 값으로 입력값을 유추할 수 없으며(역상 저항성), 해시 값의 유사도로 입력값의 유사도를 알 수 없어야 합니다. 만약 암호학적이지 않은 해시를 사용하면 유출 사고의 피해가 크게 증폭되어 서비스 보안에 치명적입니다. 정보 보호에 최선을 다하지 않은 회사에 큰 책임이 생깁니다.</p>
</li>
</ul>
<p>따라서 기술적으로 '과거에 사용한 비밀번호와 <strong>비슷한</strong> 비밀번호의 재사용 막기'는 주요 보안 요구사항을 우회하는 형태가 되기 때문에, 그 구현 가능성과 보안성 등에 꼼꼼한 검토가 필요하죠.</p>
<h3 id="heading-24">🔍 처음 사용된 것이 최근 24시간 이내인 비밀번호는 허용하기 (논의)</h3>
<p><strong><em>처음 사용된 것이 최근 24시간 이내인 비밀번호를 다시 선택할 수 있도록 허용하는 것은 사용자 경험 및 보안에서 모두 긍정적인 도움이 될 수 있습니다.</em></strong></p>
<p>이 내용은 저의 개인적인 견해이며, 전문가들이나 전문 기관에 의해 충분히 논의되지 않았습니다. 따라서 참고 정도로 봐 주시고, 필요에 따라 신뢰할 수 있는 자료로 삼아 주시기 바랍니다.</p>
<p><strong><em>기억하기 쉬우면서도 안전한 비밀번호를 되찾기</em></strong></p>
<p>'사용자에게 기억하기 어려운 새로운 비밀번호를 자주 요구하는 것'이 보안에 크게 도움이 되지 않는다는 것을 이야기했습니다. 우리는 사용자가 오늘 사용을 시도한 비밀번호 중 충분히 길고 안전하여 유추하기 어려우면서도 기억은 잘할 수 있는 비밀번호를 되찾게 함으로써, 사용성과 안전을 모두 갖춘 비밀번호를 선택하도록 할 수 있습니다.</p>
<ul>
<li><p>오늘 시도한 비밀번호 중 하나가 충분히 안전하면서도 기억에 잘 남는다면, 사용자는 그 비밀번호를 다시 사용하고 싶을 수 있습니다.</p>
<blockquote>
<p>특히 사람의 기억 방식 중에는 '초두효과(primacy effect)'라는 개념이 널리 사용되고 있으며, 먼저 선택한 비밀번호가 기억에 오래 남을 수 있습니다. 사용자는 잘 기억할 수 있는 비밀번호 중 가장 안전하다고 생각하는 암호를 신뢰하겠죠. 이 비밀번호를 허용함으로써 우리는 사용자가 더 안전한 비밀번호를 유지하도록 도울 수 있습니다.</p>
</blockquote>
</li>
<li><p>사용자는 기억하기 어려운 마지막 비밀번호를 강요받을 때, 더 짧고 예측 가능한 비밀번호로 전환할 가능성이 있습니다.</p>
<blockquote>
<p>따라서 사용자가 기억하기 어려운 최근 비밀번호를 대체하기 위한 선택지로, 짧고 유추하기 쉬운 비밀번호를 선택하게 하는 것보다 '안전하고 기억할 수 있다고 생각한 비밀번호'를 다시 선택할 수 있도록 하는 것이 더 안전하고 합리적인 정책으로 보이는 것입니다.</p>
</blockquote>
</li>
</ul>
<p>처음으로 사용하여 유출되지 않은 비밀번호를 다시 선택하는 것은 보안 리스크를 증가시키지 않습니다. 이때 안전한 비밀번호를 되찾을 권리를 사용자에게 주는 것은 합리적이며, 그런 선택의 실행을 지나치게 어렵게 만들 필요가 없습니다.</p>
<p><strong><em>사용자 본인에 의한 비밀번호 히스토리 무력화 줄이기</em></strong></p>
<ul>
<li><p>사용자가 최근 비밀번호를 사용하기 위해, 비밀번호를 N+1 번 변경하도록 유도하지 않습니다. 즉, 비밀번호 히스토리를 사용자가 자발적으로 무력화시키는 상황을 줄일 수 있습니다.</p>
<blockquote>
<p>예를 들어 일부 구글 사용자는 방금 처음으로 사용한 비밀번호를 되찾기 위해 비밀번호를 추가로 100번 더 바꾸곤 합니다. 이는 비밀번호 히스토리를 완전히 초기화하는 것과 거의 같으며, 이후 사용자의 동일 비밀번호 재사용을 제한하는 효과를 한 차례 거의 잃습니다.</p>
</blockquote>
</li>
</ul>
<p>사용자에 의한 비밀번호 히스토리 무력화는 비밀번호 히스토리 정책의 취지를 완전히 빗겨 가는 것이며, 이 정책으로 보안을 강화하기 위해서는 사용자의 무력화 시도를 유도하지 않도록 잘 검토해야 합니다.</p>
<h3 id="heading-4pyfioycroyaqeyekoyxkoqyjcdtllzrk5zrsleg7kcc6ro1">✅ 사용자에게 피드백 제공</h3>
<p>사용자에게 이 비밀번호를 사용할 수 없는 이유가 무엇인지, 또는 이 비밀번호가 위험한 이유가 무엇인지 안내하는 것이 좋습니다.</p>
<p>이 피드백에서 과거와 동일한 비밀번호를 다시 사용하면 안 된다는 것을 알려 줄 수 있고, 어떠한 대안을 사용하는 것이 좋은지 등 사용자의 가이드가 되어 줄 수도 있습니다.</p>
<h3 id="heading-n-2">✅ 비밀번호 이력을 비교하는 데에 필요한 시간을 N초 이하로 줄이기</h3>
<blockquote>
<p><strong><em>Performance ⚡ vs Security 🔒</em></strong><br /><strong><em>UX(사용자 경험)보다 먼, 보안보다는 가까운.</em></strong></p>
</blockquote>
<p>보안을 해치지 않는 한 사용자 경험을 보장할 수 있습니다.</p>
<p>보안이 중요한 작업은 사용자들이 어느 정도 지연을 납득할 수 있습니다. 특히 요청을 안전하게 처리하고 있다고 피드백을 제공하여 신뢰할 수 있는 보안이 적용되고 있음을 알림으로써 사용자 경험을 개선할 수 있습니다.</p>
<p>사용자 경험과 지연 시간에 뚜렷한 기준이 통일되어 있지는 않지만 대략적인 안내는 다음과 같습니다.<br />(페이지 로딩은 1~3초 이내. 3초를 넘으면 사용자가 사이트를 이탈하는 비율 급증.)</p>
<ul>
<li><p>60~100 밀리초 이내: 채팅이나 게임 등 실시간성이 필요한 기능은 이런 지연시간이나 그 이하를 목표로 합니다.</p>
</li>
<li><p>100~500 밀리초 이내: 일반적인 기능들은 작업량에 따라 이런 목표 시간을 설정합니다. 처리량이 많은 작업은 초 단위 목표 시간이 되기도 하며, 다른 방식으로 사용자 경험을 개선할 수 있습니다.</p>
</li>
<li><p>1~5초 이내: 보안 때문에 성능을 양보해야 한다면, 보통 3초나 5초까지 기다리게 할 수 있습니다.<br />  (대형 서비스는 5초까지 소요해도 신뢰를 유지하지만, 오히려 작은 서비스는 조금 더 신속한 것을 목표로 할 수 있습니다. 이는 기능에 따라서도 차이가 생깁니다.)</p>
</li>
<li><p>5초 초과: 서비스 품질에 대한 의심이 보안에 대한 인식으로 연결되지 않도록 하는 것이 중요합니다.</p>
</li>
</ul>
<p>따라서 비밀번호 이력을 비교하고 비밀번호 암호화와 등록까지 모두 완료하는 과정이 3초나 5초 이내가 되도록 합니다.</p>
<h3 id="heading-4pyfiou5houwgouyio2yucdsnbtrokxsnyqg67me6rwq7zwy64quioupmeyvicdrsjzsg53tlzjripqg7isc67ke7j2yiou2go2vmoulvcdspitsnbtqula">✅ 비밀번호 이력을 비교하는 동안 발생하는 서버의 부하를 줄이기</h3>
<p>특히 하드웨어 저항성이 있는 단방향 암호화 함수를 사용하고 있다면, 비밀번호 이력을 하나씩 비교하는 것은 서버의 부하를 늘립니다. 만약 빠른 응답을 위해 많은 솔트로 동시에 여러 암호화를 한다면, 스스로 DoS 공격을 받는 서비스를 만드는 것일 수 있고, 정상적인 사용자 요청도 부하가 될 것입니다.</p>
<h1 id="heading-67me67ca67ki7zi4io2eioykpo2gooumrcdshktqs4ttlzjqula">비밀번호 히스토리 설계하기</h1>
<h2 id="heading-7zie64ya7kcb7j24iou5houwgouyio2yucdslzttmljtmzqg7zwo7iiy7j2yioyeseukpsdsnbtsiogg7j207zw07zwy6riw">현대적인 비밀번호 암호화 함수의 성능 이슈 이해하기</h2>
<p><strong>요약</strong></p>
<ul>
<li><p>반복 해싱을 적용하며, 이 과정에서 일부러 해시 함수가 느리게 동작하도록 유도합니다.</p>
</li>
<li><p>가변 솔트를 적용하며, 모든 비밀번호의 솔트가 다르기 때문에 암호화를 각각 실행해야 합니다.</p>
</li>
</ul>
<blockquote>
<p><a target="_blank" href="https://blog.letsdev.me/password-encryption-concept-kor"><strong>자세히 보기:</strong> 현대적인 비밀번호 암호화와 가변솔트, 반복 해싱</a><br /><a target="_blank" href="https://blog.letsdev.me/password-encryption-concept-kor">https://blog.letsdev.me/password-encryption-concept-kor</a></p>
</blockquote>
<h3 id="heading-7ykkioykpo2kuougioy5reqzvcdrsjjrs7ug7zw07iux">키 스트레칭과 반복 해싱</h3>
<p>최근 비밀번호의 암호화에 많이 사용하는 것은 BCrypt, PBKDF2, SCrypt, Argon2 등입니다. 이중 PBKDF2를 제외한 BCrypt, SCrypt, Argon2 등은 모두 공격자가 동시에 여러 비밀번호를 시도할 때 성능에 부하가 많이 생기도록 설계되어 매우 안전하다고 평가하죠.</p>
<p><strong><em>오프라인 어택, 브루트포스 어택, 사전 공격(딕셔너리 어택)</em></strong></p>
<p><strong>비밀번호 해시 값이 유출된 사례는 생각보다 적지 않습니다.</strong> 레인보우 테이블이든, 브루트포스 공격이든, 브루트포스에서 '비밀번호일 가능성이 높은 문자 조합'부터 대입해 보는 딕셔너리 공격이든, 운영 서버에 바로 시도하는 것은 효과가 적거나 실행할 수 없기 때문에 해시 탈취 후 오프라인에서 시도합니다.</p>
<p>오프라인 어택은 해시를 탈취한 다음 본인의 환경에 암호화 프로그램을 만들어서, 동일한 해시가 있는지 비교하며 딕셔너리 어택을 하는 식으로 시도될 수 있습니다. 이렇게 하면 로그인 시도 횟수에 제한 없이 빠르게 많은 대입을 시도할 수 있기 때문입니다.</p>
<p><strong><em>하드웨어 저항성</em></strong></p>
<p>우리는 해시 값이 유출되더라도 비밀번호 노출의 리스크를 거의 없애기 위하여 하드웨어 저항성이 높은 함수를 선호합니다. 여기서 말하는 하드웨어 저항성은 고성능 장치(CPU, GPU, ASIC 등)를 사용하여 비밀번호를 빠르게 알아내려는 시도에 견디는 능력을 말합니다. 하드웨어 저항성이 높다면 동시에 여러 비밀번호를 암호화해 시도하는 것이 많은 리소스를 소모하기 때문에, 동시에 여러 비밀번호 대입 시도를 확연하게 낮출 수 있습니다.</p>
<h3 id="heading-dynamic-salting">가변 솔트(Dynamic Salting)</h3>
<p>해시 함수는 같은 입력을 넣으면 같은 결과를 생성합니다. 이 특징은 정답표(레인보우테이블)를 만들 수 있게 하기 때문에, 보통은 솔트라고 하는 것을 입력 값에 첨가하여 '같은 입력'이 아니게 만들죠.</p>
<p>솔트를 서비스 전체에서 동일하게 사용하는 것은 이제 솔트라고 부르지 않고, '페퍼'라고 부릅니다. 요즘 솔트는 암호화할 때마다 다른 값을 사용할 수 있고, 이것을 영어로는 동적인 솔팅(Dynamic salting), 한국어로는 '가변 솔트'라고 번역합니다.</p>
<p>비밀번호 비교는 양쪽 값이 모두 단방향 암호화된 상태에서 하는데, 만약 비밀번호 100개에 가변 솔트를 적용하면, 그 비밀번호 100개와 비교하기 위해서는 암호화를 100번 수행해야 합니다.</p>
<p>암호화 한 번에 1초 가량을 소요하도록 목표를 정했다면 총 100초 정도가 필요하겠죠.</p>
<h2 id="heading-67o07jwiioyaloq1roycro2vrtog7zwe7jqu7zwciouztoyvicdqsjxrj4qg7yym7jwf7zwy6riw">보안 요구사항: 필요한 보안 강도 파악하기</h2>
<h3 id="heading-7j247kad7jqpiou5houwgouyio2yuoyzgcdtnojsiqtthqdrpqzsmqkg7zw07iucioqwkuydtcdrj5nsnbztlbtslbwg7zwgio2vhoyalouklcdsl4bsnyw">인증용 비밀번호와 히스토리용 해시 값이 동일해야 할 필요는 없음</h3>
<p>저는 예전에 이 솔루션을 생각해 냈을 때, 이것이 현대에 매우 일반적이거나, 또는 이것보다 효율적이고 유명한 방식이 보편화되어 있을 것이라고 생각했습니다. 그래서 제가 생각한 것이 얼마나 맞을지 곳곳에 물어보고 다니기 바빴죠.</p>
<p>그렇게 최근에도 위 딜레마의 솔루션을 떠올리는 분들이 계신지 종종 찾아 보았습니다. 사람들은 대부분 저 문제를 한 번에 해결할 방법을 찾지 못했습니다만, 몇 번의 오답 피드백 이후 결국 답에 근접하는 분도 있었습니다. 사실 편견 하나만 깨면 생각보다 간단하게 해결할 수 있는 문제였죠.</p>
<p>그리고 동일한 비밀번호의 재사용을 최근 100개까지 방지하는 기업이면, 이미 좋은 솔루션을 적용 중일 것 같습니다. 어쩌면 그곳은 이미, 제가 떠올린 방식의 실사 환경일지도 모르죠.</p>
<p><strong><em>솔루션 찾기</em></strong></p>
<p>솔루션의 키는 아주 작은 실마리에서 시작합니다. "가변 솔트를 적용하면 하나하나 새롭게 암호화한 후 비교하는 수밖에 없다."라는 전제죠. 그러면 논리적으로 어떻게 살펴 봐도 가변 솔트 환경에서는 절대로 비밀번호 히스토리를 한 번에 비교하는 것은 큰 욕심일 수 있었습니다. 사용자마다 고정 솔트를 사용하는 환경에서는 아주 쉬운 문제였을 텐데 말이죠. 그리고 떠올랐죠.</p>
<p><strong><em>'잘 생각해 보면 비밀번호 히스토리는 로그인에 사용하는 게 아니잖아? 히스토리지.'</em></strong></p>
<p>생각이 거기에 미치자 이후 솔루션 개척은 척척 이루어졌습니다. 인증용 암호는 가변 솔트를 적용하지만, 히스토리용 해시 값은 고정 솔트를 사용하는 대신 인증에 사용하지 않는 것으로 엄격하게 정하는 것이죠.</p>
<p>비밀번호 히스토리라고 해서 반드시 인증에 사용했던 해시 값을 그대로 기록해야 할 이유가 없었거든요.</p>
<p><strong><em>암호화 방식을 구분하기</em></strong></p>
<p>그래서 히스토리용 해싱을 할 때는 비밀번호 100개와 비교할 때 이슈가 되는 가변 솔트를 제거했습니다.</p>
<ul>
<li><p><strong>인증용 비밀번호 해시 값</strong>: BCrypt, SCrypt, Argon2 등과 가변 솔트를 적용하여 암호화합니다.</p>
</li>
<li><p><strong>히스토리용 해시 값</strong>: <strong><em>고정 솔트를 사용합니다.</em></strong> 히스토리에만 사용하고, 로그인에 사용하지 않습니다.</p>
</li>
</ul>
<p>아래 자료처럼 가변 솔트는 인증용 암호에, 고정 솔트는 히스토리용에 사용하는 것이 첫 번째 키였죠.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725484385456/2b13511b-48f7-46d3-b8fc-e7c91742233b.avif" alt class="image--center mx-auto" /></p>
<h3 id="heading-7iks7jqp7j6q67oeioqzooyglsdshpttirgg7jq07jib">사용자별 고정 솔트 운영</h3>
<p>고정된 솔트라고 해서 서비스 전체에 같은 값을 사용할 필요는 없습니다. 어차피 사용자 단위로 비밀번호 히스토리가 구분되어 취급되기 때문에, 사용자 단위로 고정된 솔트를 운영하는 것이 안전하죠.</p>
<p>사용자의 고정 솔트를 필요할 때 유연하게 업데이트할 수 있도록 하면, 향후 추가적인 보안 조치를 열어 둘 수 있습니다.</p>
<p>사용자의 고정 솔트에 대한 추가적인 요구사항은 '해시 값 유출에 대비하기 1: 고정 솔트를 인증 DB에서 격리'에서 후술하겠습니다.</p>
<h3 id="heading-7y6y7y2866eb">페퍼링</h3>
<p><strong><em>공격 준비 시간을 부여하는 게 되는 고정 솔트</em></strong></p>
<p>고정 솔트의 위험성은 해시 값 유출을 늦게 알아차렸을 때 나타납니다. 만약 고정 솔트가 함께 유출되어 있다면, 공격자는 이 사용자의 비밀번호 히스토리에 대한 레인보우 테이블을 준비할 수 있습니다. 완전히 채운 레인보우 테이블은 아니더라도, 자주 사용할 만한 비밀번호 사전에 대해서는 준비할 수 있겠죠.</p>
<p>그렇게 주요 사용자 계정에 대해 공격을 준비할 시간이 생깁니다. 비밀번호 히스토리의 최신 값이 인증용 비밀번호와 같은 원문에서 암호화됐다면, 가변 솔트를 사용한 인증용 테이블의 비밀번호는 해시 값 유출 이후 공격을 시작하여야 하는 데에 비해, 고정 솔트를 사용한 히스토리 테이블은 미리 만들어 둔 값들과 비교해 빠르게 공략할 수 있게 됩니다.</p>
<p><strong><em>데이터베이스 유출로는 노출되지 않는 페퍼</em></strong></p>
<p>우리는 사용자의 솔트와 해시 값이 유출되어도 함께 유출되지 않는 '페퍼'를 추가할 수 있습니다. 페퍼는 솔트와 거의 비슷하지만, 서비스 내에서 유일한 값으로 볼 수 있습니다. 그리고 데이터베이스에 저장하지 않죠. 데이터베이스에서 인증용 비밀번호의 해시 값과 가변 솔트 등이 함께 유출되어도, 추가된 페퍼를 알 수 없다면 솔트로 비밀번호를 알아맞혀도 페퍼 때문에 전혀 다른 결과가 되죠.</p>
<p>페퍼는 공격자가 알 수 없는 값이라는 것이 핵심이며, 시크릿키 등으로 대체될 수 있습니다.</p>
<hr />
<blockquote>
<p>이하 작성하고 있습니다.</p>
</blockquote>
<h3 id="heading-67cy65oc7iucioylnoq4soulvcdquldroz0">반드시 시기를 기록</h3>
<p>다른 이유가 없더라도 히스토리니까 시각을 기록하는 것은 당연한 이야기입니다. 이 히스토리를 나중에 어떻게 참고해서 사용할지 모르니까요.</p>
<p>여기서는 보안상으로도 시기가 기록되어야 하는 이유가 있는데, 데이터베이스와 격리된 정보와 상호작용 때문입니다. 히스토리 테이블 관리에 참여하는 페퍼처럼 일부 정보는 데이터베이스에 종속되는 데이터가 아니죠. 오히려 데이터베이스와 격리되어 보안을 강화합니다.</p>
<p>이처럼 데이터베이스에 종속되지 않은 데이터는 적용 시기를 알 수 있어야 함.</p>
<p>암호화 방식이나 솔트를 히스토리 테이블 등 인증 DB에 저장하지 않고, 페퍼도 업데이트 하기 편하도록.</p>
<h2 id="heading-1-db">해시 값 유출에 대비하기 1: 고정 솔트를 인증 DB에서 격리</h2>
<p>해시 값 유출 시 비밀번호 히스토리 테이블은 인증용 테이블에 비해서 공략이 쉬울 수 있음. 특히 인증용 해시 값이 아니기 때문에 보안 수준을 낮추고 서버의 성능을 보장하려는 곳도 있을 수 있음. (이에 대비해 충분한 암호화를 적용하고 있지 않을 수 있음.)</p>
<p>이때는 적어도 솔트라도 알 수 없게 하면 좋음.</p>
<h3 id="heading-67oe64eioyggoyepsdqs7xqsiq">별도 저장 공간</h3>
<p>인증 DB의 유출로는 사용자 고정 솔트가 유출되지 않음. 유연하게 업데이트 가능.</p>
<p>비밀번호 변경은 자주 사용하는 기능이 아니기 때문에 꼭 비용이 비싼 저장소를 쓸 필요도 없음.</p>
<h3 id="heading-67o17j6h7zwcioyxsoycsoycvouhncdqs6dsojug7iau7yq4ioydneyesq">복잡한 연산으로 고정 솔트 생성</h3>
<p>서버만 아는 값을 시드로 사용. 이 값을 사용자의 식별 가능한 정보와 결합해서 복잡한 연산을 통해 솔트 생성.</p>
<p><strong><em>여전히 업데이트 가능한 솔트</em></strong></p>
<p>보안 이슈가 있을 때나 주기적인 갱신이 필요할 때 사용자의 고정 솔트를 하나씩 업데이트할 필요 없이, 이 '서버만 아는 값'을 업데이트할 수 있음. 별도 저장 공간을 사용할 때에 비해서 개별 업데이트는 X</p>
<p>데이터베이스에 보존되지 않아서 보통 환경변수 등으로 쓰니까. 솔트만 보면 스테이트리스한 관리.</p>
<h2 id="heading-2">해시 값 유출에 대비하기 2: 히스토리에 인증용 암호는 없게</h2>
<h3 id="heading-7z6i7iqk7yag66asio2fjoydtou4loyxkcan7zie7j6sioyvlo2yucfripqg67o07kg07zwy7keaioyviuuklcdsoitrnru">히스토리 테이블에 '현재 암호'는 보존하지 않는 전략</h3>
<p>새 비밀번호로 변경할 때, 기존 비밀번호를 히스토리에 넣고, 새 비밀번호는 히스토리에 넣지 않음.</p>
<p>히스토리 테이블 해시 값 털어도 어차피 로그인에 쓰이는 비밀번호를 알 수 있는 해시 값은 전혀 없음.</p>
<h3 id="heading-67me67ca67ki7zi4iouzgoqyvsdsmptssq3sl5dripqgjq4soyhtcdruytrsidrsojtmlgn66w8iouwmyvhoyencdsnbjspp3tlzjqula">비밀번호 변경 요청에는 '기존 비밀번호'를 받아서 인증하기</h3>
<p>보안상으로도 이게 좋음. 로그인이 되어 있다고 이런 민감한 기능도 바로 수행하기보다, 비밀번호 인증과 함께 수행하거나, 비밀번호 인증 후 임시 토큰을 제공.</p>
<p>임시 토큰을 제공하는 경우 아래 방식 중 '기존 비밀번호를 히스토리에 넣기'를 위해 별도로 임시 저장이 되어야 함. (ex: 사용자에게 임시 토큰을 주고, 기존 비밀번호는 단방향 암호화하여 redis 등에 저장)</p>
<p>이 토큰은 JWT가 아니며(원문과 함께 전달하지 않음), 만료 시간이 짧아야 함. 당연히 리프레시 없음.</p>
<h3 id="heading-7j247kadioyzhoujjouqncdslzttmljrpbwg6re465wmio2eioykpo2gooumroyxkcdtlbtsi7htlbtshjwg64sj6riw">인증 완료된 암호를 그때 히스토리에 해싱해서 넣기</h3>
<p><strong>비밀번호를 함께 받아서 처리할 때</strong></p>
<p>기존 암호로 인증도 됐고 새 암호가 중복도 없으면, 기존 암호는 해싱해서 히스토리 테이블에 넣기.</p>
<p><strong>인증을 완료한 후 임시 토큰으로 인가하여 비밀번호를 변경할 때</strong></p>
<p>인가됐으니 새 비밀번호가 중복이 있는지 보고, 중복이 없으면 임시로 저장된 기존 비밀번호의 해시 값을 히스토리 테이블에 저장.</p>
<h2 id="heading-3">해시 값 유출에 대비하기 3: 하드웨어 저항성에 특화</h2>
<p>암호화 강도가 인증용에 비해 낮아도 되는 것은 당사 사이트 기준(그 사이트의 로그인에는 안 쓰니까.)</p>
<p>사용자의 비밀번호 히스토리는 다른 사이트에서 사용하는 비밀번호일 수도 있음.</p>
<p>따라서 과도하게 낮은 보안 강도는 안 됨.</p>
<h3 id="heading-argon2">Argon2의 세 가지 옵션의 방어 특화</h3>
<ul>
<li><p>Argon2d: GPU 공격에 특화</p>
</li>
<li><p>Argon2i: 사이드 채널 어택에 특화</p>
</li>
<li><p>Argon2id: GPU 공격과 사이드 채널 어택에 골고루 특화</p>
</li>
</ul>
<h3 id="heading-67me67ca67ki7zi4io2eioykpo2gooumroyxkcdshysg7ksrio2vmoucmoulvcdsk7tri6trqbq">비밀번호 히스토리에 셋 중 하나를 쓴다면</h3>
<p>'비밀번호 바꾸기' 요청에서 일단 한 번은 암호화 하니까. 이때 연산 평균시간 등 사이드채널 공격이 생길 수도 있지 않을까 -&gt; Argon2id가 나아 보일 수 있음. -&gt; 근데 잘 생각해 보면 -&gt; 어차피 인증용 암호 받기로 해서 -&gt; 인증에 성공해야 비밀번호 히스토리랑 비교하는 거라서 -&gt; 사이드채널 어택보다는 그냥 해시 값 유출에 대비 -&gt; Argon2d 선택</p>
<p>Argon2d + 고정 솔트 (비표준)</p>
<p>완성된 라이브러리에서는 자동으로 솔트 생성하고 있을 수도 있음.</p>
<h1 id="heading-1"><strong>대략적인 권장의 예시 1: 인증과 함께 수행</strong></h1>
<h2 id="heading-7jqu7lktiouwjydslzttmljtmzq">요청 및 암호화</h2>
<p>비밀번호 변경 요청에서 기존 비밀번호와 새 비밀번호를 모두 받음.</p>
<ul>
<li><p>인증용 암호화에는 Argon2id 사용을 권장할 수 있음.</p>
</li>
<li><p>히스토리용 암호화는 Argon2d 사용을 권장할 수 있으며, 이는 인증에 사용되어선 안 됨.</p>
</li>
</ul>
<h2 id="heading-steps">Steps</h2>
<h3 id="heading-1-argon2id"><strong>1단계</strong>: 기존 비밀번호 인증(Argon2id)</h3>
<p>기존 비밀번호와 새 비밀번호를 모두 받아 둠.</p>
<p>기존 비밀번호로 인증.</p>
<p>여기도 인증을 포함하기 때문에 인증 비밀번호 시도 루트가 됨. 따라서 시도 횟수 제한 필요.</p>
<h3 id="heading-2-argon2d"><strong>2단계</strong>: 히스토리 검토(Argon2d 및 고정 솔트)</h3>
<p><em>Argon2 함수는 가변솔트가 표준이므로, 비표준적인 사용이며 일부분 직접 구현해야 할 수 있음.</em></p>
<ul>
<li><p>인증에 성공해야 이 단계를 수행하므로 사이드 채널 어택 가능성이 거의 없음.<br />  (반드시 Argond2i나 Argon2id를 택하지 않아도 됨.)</p>
</li>
<li><p>해시 값 유출에 대한 대비를 더 강화하는 것이 좋음.<br />  (해시 값 유출에 한하여 Argon2d가 Argon2id보다 저항성이 있음.)</p>
</li>
</ul>
<h3 id="heading-3-1"><strong>3단계</strong>: 암호화한 해시 저장</h3>
<ul>
<li><p>새 인증용 비밀번호는 암호화하여 인증 테이블에 저장(Argon2id)</p>
</li>
<li><p>기존 비밀번호는 비밀번호 히스토리에 저장(Argon2d 및 사용자 고정 솔트)</p>
</li>
</ul>
<p>응답까지 최소 세 번의 암호화를 포함하기 때문에, 이를 고려한 성능 요구사항 체크 필요함.</p>
<ul>
<li><p>암호화 1: 기존 비밀번호 인증(Argon2id)</p>
</li>
<li><p>암호화 2: 새 비밀번호 히스토리 비교(Argon2d)</p>
</li>
<li><p>암호화 3: 새 비밀번호 인증용 암호화(Argon2id) - 완료 후 사용자에게 응답 가능</p>
</li>
<li><p>암호화 4: 기존 비밀번호 히스토리용 암호화(Argon2d)</p>
</li>
</ul>
<h1 id="heading-2-1">대략적인 권장의 예시 2: 인증 후 토큰으로 인가 수행</h1>
<p>이 토큰은 JWT가 아님.</p>
<p>원문과 함께 전달하면 안 되기 때문(이거 JWT로 하라고 하면 분명 누군가는 비밀번호 담으려고 함).</p>
<p>stateless일 필요 없으니까.</p>
<h2 id="heading-6rcbioyvlo2yuo2zlcdsmijsi5w">각 암호화 예시</h2>
<ul>
<li><p>인증용 비밀번호: Argon2id</p>
</li>
<li><p>히스토리용 해시: Argon2d</p>
</li>
<li><p>토큰: Secure Random (암호학적으로 안전한 랜덤 함수)</p>
</li>
</ul>
<h2 id="heading-1-1">1단계: 재인증 및 임시 토큰 발행</h2>
<h3 id="heading-1-1-1">1-1: 재인증 요청 처리</h3>
<h3 id="heading-1-2">1-2: 토큰 발행 (응답)</h3>
<h3 id="heading-1-3">1-3: 기존 비밀번호의 히스토리용 해시 임시 저장</h3>
<h2 id="heading-2-2">2단계: 토큰 인가 및 비밀번호 변경</h2>
<h3 id="heading-2-1-1">2-1: 토큰과 새 비밀번호를 담아서 비밀번호 변경 요청</h3>
<h3 id="heading-2-2-1">2-2: 토큰 인가</h3>
<p>기존 비밀번호를 암호화해서 인증하는 것보다 빠름.</p>
<h3 id="heading-2-3">2-3: 새 비밀번호가 히스토리와 겹치는지 비교</h3>
<h3 id="heading-2-4">2-4: 새 비밀번호를 인증 테이블에 저장</h3>
<p>이때 응답.</p>
<h3 id="heading-2-4-1">2-4: 기존 비밀번호를 히스토리 테이블에 저장</h3>
<hr />
]]></content:encoded></item><item><title><![CDATA[비밀번호 단방향 암호화: 가변솔트와 반복해싱(Dynamic Salting and Iterative Hashing)]]></title><description><![CDATA[비밀번호를 단방향 암호화하는 이유
사이트를 만드는 누구나 당신의 비밀번호를 볼 수 있다면


<회원가입의 역습: 당신이 직접 준 비밀번호>, 2024

생각해 볼게요. 제가 만든 사이트에 여러분이 접속해서 회원가입을 합니다.
여러분은 아마 평소에 자주 사용하는 계정과 비밀번호가 있을 거예요.
"그리고 그걸 저에게 주겠죠."
제가 만든 유익하거나 댕쩌는 재미를 주는 사이트를 이용하려고 회원가입을 할 테니까요.


당신의 메일을 도용하기

저는 이...]]></description><link>https://blog.letsdev.me/password-encryption-concept-kor</link><guid isPermaLink="true">https://blog.letsdev.me/password-encryption-concept-kor</guid><category><![CDATA[password encoder]]></category><category><![CDATA[key stretching]]></category><category><![CDATA[dynamic salting]]></category><category><![CDATA[iterative hashing]]></category><category><![CDATA[variable salting]]></category><category><![CDATA[dynamic salt]]></category><category><![CDATA[password encryption]]></category><category><![CDATA[passwords]]></category><category><![CDATA[password]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Mon, 02 Sep 2024 10:44:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1725586392652/d1084897-fe7a-4dfb-a064-2a88367be3fc.avif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-67me67ca67ki7zi466w8ioulqouwqe2wpsdslzttmljtmzttlzjripqg7j207jyg">비밀번호를 단방향 암호화하는 이유</h1>
<h2 id="heading-7iks7j207yq466w8iounjoutnouklcdriitqtazrgpgg64u57iug7j2yiou5houwgouyio2yuoulvcdrs7wg7iiyioyeioulpoupta">사이트를 만드는 누구나 당신의 비밀번호를 볼 수 있다면</h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1724966942692/b3dc3673-b9dc-4051-9243-75880a62f821.avif" alt class="image--center mx-auto" /></p>
<ol>
<li><p><strong>&lt;회원가입의 역습: 당신이 직접 준 비밀번호&gt;, 2024</strong></p>
<blockquote>
<p>생각해 볼게요. 제가 만든 사이트에 여러분이 접속해서 회원가입을 합니다.</p>
<p>여러분은 아마 평소에 자주 사용하는 계정과 비밀번호가 있을 거예요.</p>
<p><strong><em>"그리고 그걸 저에게 주겠죠."</em></strong></p>
<p>제가 만든 유익하거나 댕쩌는 재미를 주는 사이트를 이용하려고 회원가입을 할 테니까요.</p>
</blockquote>
</li>
<li><p><strong>당신의 메일을 도용하기</strong></p>
<blockquote>
<p>저는 이제 당신의 메일 중 하나를 찾아 봅니다. 당신은 한국인이기 때문에 어쩌면 네이버 메일을 주로 사용하고 있을 수 있고, 그 메일로 가입한 사이트가 200곳이나 될지도 모릅니다.</p>
</blockquote>
</li>
<li><p><strong>당신의 메일을 통한 인증</strong></p>
<blockquote>
<p>그러면 이제 그 200개 사이트에서 '비밀번호 변경'을 눌러서 '메일로 인증하기'를 선택한다면, 당신 행세를 하며 비밀번호를 바꾸고, 당신의 자격으로 그 사이트를 이용할 수 있겠네요. 그리고 SNS에 접속해서 당신인 척을 하며 당신의 주변 사람들을 속일 수도 있습니다.</p>
</blockquote>
</li>
</ol>
<p>이처럼 아무 사이트에 회원가입만 해도 비밀번호를 그대로 노출시킬 수 있고, 직원 중 누군가가 악의적인 생각을 품으면 사용자들의 비밀번호를 알 수 있을지도 모릅니다.</p>
<h2 id="heading-7iks7jqp7j6qiou5houwgouyio2yucdrj4tsmqnsnyqg66ej6riwioycho2vncdsirxqtidrj4qg7j6i7iq164ui64uklg">사용자 비밀번호 도용을 막기 위한 습관도 있습니다.</h2>
<p>위와 같은 시도를 100% 막을 수는 없습니다. 따라서 우리는 사이트의 운영자가 내 비밀번호를 알아내도 다른 사이트에서 사용할 수 없도록 사이트마다 다른 비밀번호를 사용할 수 있습니다.</p>
<p>비밀번호 관리자를 사용해서 사이트마다 다른 암호를 쉽게 관리하거나, 사이트 도메인으로부터 추가적인 암호문을 만들어 비밀번호의 일부에 덧붙이는 나만의 패턴을 추가하는 것 등이 비밀번호 관리에 도움을 줍니다. 이러한 방안 중 비밀번호 관리자는 매우 편리하고 강력한 수단이지만, 신뢰할 수 있는 서비스를 사용해야 합니다.</p>
<p>여기까지는 일반 사용자의 노력입니다. 더 중요한 것은 시스템적으로 많은 사용자가 안심하고 사용할 수 있어야 한다는 거겠죠.</p>
<h2 id="heading-7jes65sioucmoudvoqwgcdslyjsoittlzwg7isc67me7iqk66w8iouztoyepe2vmoq4scdsnittlbqg64w466cl7zwp64ui64uklg">여러 나라가 안전한 서비스를 보장하기 위해 노력합니다.</h2>
<p>우리나라를 포함해서 많은 국가들에서 온라인 서비스가 안전하게 운영되도록 노력하고 있습니다. 보안은 많은 나라가 보장하려고 하는 중요한 요소겠죠. 서비스가 클수록 보안 심사를 통해 사용자들이 안심하고 사용할 수 있는 서비스인지 확인도 하고, 필요하다면 법률을 만들거나 노동자들의 직업 윤리와 더불어서 회사의 서비스가 고의나 과실에 의한 보안 위배를 하지 않도록 이끌고 있습니다.</p>
<h3 id="heading-7jet7iuciouztoyvioydgcdrrlzrpqzsoihctq4soyiooyggsdsobdsuzjrs7tri6qgjq0goumroyggscg7kgw7lmy">역시 보안은 물리적·기술적 조치보다 '관리적' 조치</h3>
<p>개인정보의 유출은 70% 이상이 내부자에 의해 발생한다는 설명을 본 적이 있습니다. 바로 납득했었죠. 대부분의 보안은 물리적·기술적인 것을 마련한 상태로 유지할 수 있지만, 정책은 미리 마련해도 운영하는 과정에서 '인재(人災)'가 생길 수 있고, 관리적으로 미비하면 물리적 기술적 조치도 많은 부분 생략될 수 있거든요.</p>
<p>우선 각 기업의 보안 조치는 크게 세 가지로 나누어서 검토합니다.</p>
<ul>
<li><p><strong>물리적 조치</strong> (물리적 자산에 대한 접근 제어 및 유지)</p>
<blockquote>
<p>보안 시스템이 안정적으로 작동하며, 외부인의 출입 등으로부터 안전하게 보호되어야 합니다.<br />(출입 통제, CCTV, 비상전력, 방화방재 시스템 등)</p>
</blockquote>
</li>
<li><p><strong>기술적 조치</strong> (직접적인 정보 보호)</p>
<blockquote>
<p>하드웨어와 소프트웨어를 모두 포함하는 개념입니다. 방화벽 설정, 네트워크 모니터링과 적절한 대응, 암호화와 인증, 안티바이러스, 망 분리 등이 기술적 조치에 해당합니다.</p>
</blockquote>
</li>
<li><p><strong>관리적 조치</strong> (인력과 정책)</p>
<blockquote>
<p>인력에 대한 역할 및 책임 분배, 인력과 작업에 대한 감사 및 모니터링, 정책 수립과 문서화, 보안 교육, 접근 권한 관리, 재해 복구 계획 중 보안사고 관련 시나리오 등을 포함합니다.</p>
</blockquote>
</li>
</ul>
<p>비밀번호의 암호화는 이중에 기술적 조치에 해당하지만, 그것을 시행할 것인지 여부는 관리적 조치에서 잘 뒷받침되어야 합니다.</p>
<h3 id="heading-20-30-40">그래서 20대 30대 40대 개발자가 무조건 해야 하는 '이것'</h3>
<blockquote>
<p><strong>"비밀번호는 반드시 단방향 암호화하여 저장하여야 합니다."</strong></p>
</blockquote>
<p>비밀번호 암호화는 '기술적 조치'에 해당하지만, 만약 의무가 아니었다면 어쩌면 생략하는 기업이 많았을 수도 있습니다. 그래서 우리나라를 포함한 많은 나라에서 '비밀번호의 암호화'는 필수로 하고 있죠.</p>
<p><strong><em>"그리고 비밀번호 암호화는 원본 평문을 알 수 없도록 해야 합니다."</em></strong></p>
<p>이것을 위해 우리는 '단방향 암호화'라는 것을 하고, 단방향 암호화는 '암호화(평문→암호문)'는 있지만, '복호화(암호문→평문)'는 없다는 것이 특징입니다. 따라서 사용자가 비밀번호를 입력하였을 때, 곧바로 단방향 암호화하여 저장하면 사용자의 비밀번호는 원본을 알 수 없는 상태로 저장되기 때문에 악용되지 않죠. 아, 이것을 의무화하였다니, 이래서 지금까지 우리가 안전한 거였습니다.</p>
<p>물론 개인이 운영하는 사이트나 이상한 사이트는 함부로 가입하면 안 되겠죠. 회사 단위로 운영되고 있는 서비스는 보통 직원들의 보는 눈이 있기 때문에, 중요한 조치는 포함되어 있을 것입니다. (실제 존재하는 회사여야 하고, 최소한의 규모나 사업 자격 심사 등을 통과한 상태여야 충분히 믿을 수 있습니다.)</p>
<h1 id="heading-hashing">해싱(Hashing)과 단방향 암호화</h1>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725037483968/2922646f-53f0-404a-8310-e551666000de.avif" alt class="image--center mx-auto" /></p>
<p>일반적인 시스템에서 사용하는 단방향 암호화는 '해싱(hashing)'이라는 기술을 핵심 동작으로 합니다.</p>
<p>해싱은 값을 가는 행위라고 생각할 수 있습니다. 해시라는 건 무언가를 갈아서 뭉치는 것을 말하거든요. 해시 함수는 입력받은 값을 갈아서 고정된 길이로 결과를 반환합니다. 그리고 '갈아 낸다'는 표현 방식을 택한 것처럼 해시 함수를 통해 얻어낸 결괏값인 다이제스트(digest)는 다시 원본으로 역변환할 수 없는 산물입니다. 이것을 비밀번호 암호화에 사용하는 거죠.</p>
<ul>
<li><p>해싱을 하면 원래 문자를 알 수 없습니다.</p>
</li>
<li><p>대신 같은 내용을 (동일한 조건으로) 해싱하면 항상 같은 결과를 준다는 특징이 있습니다.</p>
</li>
</ul>
<blockquote>
<p><strong><em>Q. 해싱을 하면 원본을 알 수 없다는 건데, 비밀번호를 나중에 어떻게 대조할까요?</em></strong></p>
<p>복호화를 할 수 없다는 말은 결국 사용자가 로그인을 할 때 비밀번호를 입력해도, 마치 그것이 맞는지 비교할 수 없다고 들릴 수도 있습니다. 몇몇 분들은 예리하게 그 포인트를 찾아내 물어 보곤 했습니다.</p>
<p>그래도 조금 더 생각해 보면, 우리는 사용자 비밀번호를 비교하기 위해서 원문을 알 필요가 없습니다. 왜냐하면 굳이 원문이 아니어도 해싱이 된 결과끼리 비교할 수 있거든요. 해시 함수는 같은 입력값에 대해 항상 같은 결과가 나온다는 것을 보장해 주기 때문에, 양쪽 데이터를 모두 해싱한 상태에서 서로 동일한지 비교하면 됩니다.</p>
</blockquote>
<p>단방향 암호화는 보안의 3 요소 중 기밀성, 무결성을 직접적으로 제공하고, 가용성은 비교적 간접적으로 제공하는 것으로 평가합니다. 전체 보안 시스템에서 해싱과 단방향 암호화는 주로 기밀성을 제공하는 데 사용하고, 필요하다면 무결성을 위해서도 활용합니다(전송·보존된 중요 데이터의 위·변조 방지). 시스템 가용성(주로 시스템의 안정적 이용과 관련)을 위해 도입하는 직접적인 기술은 아닙니다.</p>
<h2 id="heading-3">현대적인 단방향 암호화 구현의 핵심 기법 3 요소</h2>
<p>현대적인 비밀번호 단방향 암호화는 다음 요소들을 포함합니다.</p>
<ol>
<li><p><strong>암호학적 해시 함수</strong>(cryptographic hash function)</p>
</li>
<li><p><strong>솔트</strong>(salt): 특히 가변 솔트(dynamic salting).</p>
</li>
<li><p><strong>키 스트레칭</strong>(key stretching)</p>
</li>
</ol>
<h2 id="heading-cryptographic-hash-function">암호학적 해시 함수(Cryptographic Hash Function)</h2>
<p>암호학적으로 안전한 해시 함수는 다음과 같은 특징을 충족합니다. 단어만 어렵고, 내용은 쉽습니다.</p>
<ul>
<li><p><strong>제1 역상 저항성</strong>: 해싱된 결과만 보고 입력값을 유추하는 것이 난해해야 합니다.</p>
</li>
<li><p><strong>제2 역상 저항성</strong>: 입력값 1과 그 해싱된 결과만 보고, 똑같은 해싱 결과를 만들 수 있는 입력값 2를 알아내는 것이 난해해야 합니다.</p>
</li>
<li><p><strong>충돌 저항성</strong>: 동일한 해싱 결과를 만들 수 있는 다른 입력값이 거의 없어야 합니다. 즉, 해시 충돌이 거의 없어야 합니다.</p>
</li>
</ul>
<h3 id="heading-pre-image-resistance"><strong>역상 저항성</strong>(Pre-Image Resistance)</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725297861010/4f7c2148-69eb-4c72-ac19-8d557992c5f3.avif" alt class="image--center mx-auto" /></p>
<p>해싱 결과만 보고 입력값을 유추하는 것을 어렵게 합니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725050138712/20d189fa-caf9-4c33-8aa8-c5015f018a61.avif" alt class="image--center mx-auto" /></p>
<h3 id="heading-2-second-pre-image-resistance"><strong>제2 역상 저항성</strong>(Second Pre-Image Resistance)</h3>
<blockquote>
<p>"갑각류 없이 새우와 똑같은 맛과 식감뿐 아니라, 동일한 영양 성분까지 만들어 봐."<br />"좋은 아이디어네요, 노벨상 받을 지식만 있다면요."</p>
</blockquote>
<p><strong><em>입력값 1과 그 해싱된 결과만 보고, 똑같은 해싱 결과를 만들 수 있는 입력값 2를 알아내는 것이 난해해야 합니다.</em></strong></p>
<p>역상 저항성(제1 역상 저항성)이 '해싱 결과만 보고' 입력값을 유추하기 어렵다는 것이었다면, 제2 역상 저항성은 '입력값과 해싱 결과'를 아는 상태에서, 같은 해싱 결과가 나올 수 있는 다른 입력값을 유추하기 어렵다는 것입니다. 해시 충돌이 생기는 조합을 일부러 찾는 것은 어렵다는 거예요. 이로써 특정 결과를 만드는 데 필요한 패턴을 유추하기 어렵기 때문에 악용을 방지할 수 있거든요.</p>
<p>인풋을 조금만 바꾸어도 해싱 결과가 크게 바뀌는 '눈사태 효과'라는 것이 있습니다. 눈사태 효과 덕분에 인풋-아웃풋의 규칙성을 유추하기 더 어렵기 때문에, 제2 역상 저항성은 눈사태 효과의 도움을 받습니다.</p>
<h3 id="heading-collision-resistance"><strong>충돌 저항성</strong>(Collision Resistance)</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725044525062/0084eca9-fb14-4a83-9609-f9f146b86d99.avif" alt class="image--center mx-auto" /></p>
<blockquote>
<p><strong>해시 충돌</strong>: 서로 다른 입력값이 동일한 해시 값으로 해싱이 되면 해시 충돌이라고 합니다. 해시 함수는 일대일 매핑 함수가 아니기 때문에 해시 충돌이 나타날 수 있습니다.</p>
</blockquote>
<p>암호학적인 해시 함수는 서로 다른 입력값이 같은 해시 값으로 해싱되는 일이 드물어야 합니다. 즉, 해시 충돌이 적어야 합니다.</p>
<h2 id="heading-66ci7j2467o07jqwio2fjoydtou4la">레인보우 테이블</h2>
<p><strong><em>"해시 함수는 역변환은 안 되지만, 같은 입력에 대해 항상 같은 결과를 준다는 특징이 있었죠."</em></strong></p>
<p>해시 함수는 이런 특징을 이용해서 정답표를 만들 수 있습니다. 우리는 그것을 '레인보우 테이블'이라고 부르죠.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725134491682/a74a0d78-ec58-4384-b13f-f9797fb1e016.avif" alt class="image--center mx-auto" /></p>
<p>즉, 레인보우 테이블은 해시 함수에서 나올 수 있는 값들을 미리 계산해 표로 만든 것이고, 원하는 결과를 얻을 수 있는 입력값을 찾는 데에 사용합니다. 레인보우 테이블을 방지하기 위해서 암호학적 해시 함수의 주요 특성인 역상저항성 등이 왜 필요한지 이해할 수 있죠.</p>
<h2 id="heading-7iau7yq4ioyyqoqwga">솔트 첨가</h2>
<p><strong><em>"레인보우 테이블은 같은 입력값이 같은 결과를 보장하기 때문에 생긴다고 했습니다."</em></strong></p>
<p>그래서 회사들은 '사용자 입력에만 의존하지 않는' 입력값을 만들면 된다는 것을 잘 활용하고 있죠. 우선 사용자가 넣은 입력값에 '솔팅(salting)'이라는 처리를 합니다. 영어로 써서 대단해 보이지만, 해석하면 '소금을 친다'라는 뜻으로 기본적으로 간단한 작업입니다. 사용자 입력값을 바꿔서 쓰는 거죠.</p>
<blockquote>
<p>변환함수(사용자 입력값, 솔트) = 해커가 모르는 입력값<br />→ 해시 함수에 사용</p>
</blockquote>
<p><strong><em>간단한 솔팅</em></strong></p>
<p>제1, 제2 역상 저항성을 만드는 '눈사태 효과' 덕분에, 변환 함수는 아주 간단하게도 가능합니다. 어차피 내용이 조금만 바뀌어도 결과가 완전히 바뀌기 때문에 복잡하게 변환할 필요가 없겠죠. 단순히 문자열을 이어 붙이는 등의 간단한 전처리로도 충분합니다.</p>
<blockquote>
<p>사용자 입력값 + 솔트 = 해커가 모르는 입력값<br />→ 해시 함수에 사용</p>
</blockquote>
<p><strong><em>고유한 솔트</em></strong></p>
<p>솔팅은 단순한 문자열 결합일 수 있지만, 중요한 것은 이 변환이 예측 불가능하고 고유한 결과를 만들어 내는 것입니다. 이렇게 함으로써, '사용자 입력값'이 같아도 '다른 솔트'를 합쳐서 해시 함수에 사용하기 때문에, 해시 값으로는 사용자 입력값을 유추할 수 없게 만들어 레인보우 테이블의 생성을 막죠.</p>
<p><strong>고유한 솔트의 대표적인 예로는 '가변 솔트'가 있습니다.</strong></p>
<h1 id="heading-dynamic-salting">가변 솔트(Dynamic Salting)</h1>
<p><strong><em>가변 솔트는 새로운 비밀번호를 암호화할 때마다 새롭게 생성되는 솔트입니다. 주로 랜덤하게 생성하죠.</em></strong></p>
<p>가변 솔트를 적용하면 같은 내용을 암호화하여도 서로 다른 암호문을 얻을 수 있기 때문에 더욱 예측하기 어려운 암호화를 할 수 있습니다. 회사의 내부자가 DB에 접근해도 원래 내용을 유추할 수 없죠. (로그인 시에는 암호가 서로 일치하는지 비교하기 위해 암호화에 사용한 솔트를 그대로 다시 사용합니다.)</p>
<p>잘 생성된 가변 솔트를 단방향 암호화에 다룰 때, 작업에서 특징적인 부분은 다음과 같습니다.</p>
<ul>
<li><p><strong>데이터베이스에는 암호화된 비밀번호와 가변 솔트를 함께 저장합니다.</strong></p>
<p>  이때는 보통 하나의 문자열로 저장합니다.</p>
<p>  저장 양식의 예시는 다음과 같고, 양식은 함수와 요구사항마다 다릅니다.</p>
<pre><code class="lang-plaintext">  {암호화함수이름}$버전_등_함수별_필요정보들$초기_솔트와_해싱된_비밀번호
</code></pre>
</li>
<li><p><strong>로그인 등에서 비밀번호를 확인할 때는 데이터베이스에서 솔트를 읽어 와서, 데이터베이스에 저장된 비밀번호와 동일한 함수와 솔트를 사용해 암호화하고, 둘 다 암호화된 상태에서 비교합니다.</strong></p>
<p>  <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1724091907610/43fbeb41-9f3b-47e9-a49a-c4d28c03f263.avif" alt class="image--center mx-auto" /></p>
</li>
</ul>
<p><strong><em>암호학적으로 안전한 랜덤 함수를 사용해야 합니다.</em></strong></p>
<p>고유한 솔트는 '예측이 불가능해야' 한다고 했고, 가변 솔트는 랜덤하게 만드는 것이 일반적이라고 했죠. 하지만 컴퓨터는 100% 랜덤은 만들 수 없다 보니까, 모든 건 산출돼서 나오는 연산 결과입니다. 시드와 함수에 따라 특정 값 패턴으로 나와요. 우린 그걸 순서대로 사용하는 거죠.</p>
<p>하지만 그러면 다른 사람이 유추할 수 있는 정보가 될 수도 있거든요. 그래서 이렇게 유출되어선 안 되는 랜덤은 유추하기 어려운, 즉 암호학적으로 안전한 랜덤 함수를 사용해야 합니다.</p>
<p>현대적인 비밀번호 암호화 함수들은, 우리가 따로 솔트를 생성하지 않아도 암호학적으로 안전한 솔트를 내부적으로 생성하는 것이 보편적입니다.</p>
<p><strong><em>안전하고 랜덤한 자동 솔트 생성</em></strong> <em>(가변 솔트의 자동 적용)</em></p>
<ul>
<li><p>그 함수의 표준 스펙에 명시되어 있을 수 있습니다.</p>
<p>  <strong>예: BCrypt, SCrypt, ARGON2</strong></p>
</li>
<li><p>그 함수의 표준 스펙에 없지만, 주요 라이브러리 구현에서 솔트의 자동 생성을 보조할 수 있습니다.</p>
<p>  <strong>예: 스프링 시큐리티 PBKDF2 인코더</strong></p>
</li>
</ul>
<h1 id="heading-iterative-hashing">반복 해싱(Iterative Hashing)</h1>
<h2 id="heading-fast-hashing-slow-password-encryption">Fast Hashing, Slow Password Encryption</h2>
<blockquote>
<p><strong>"해싱은 빨라, 빠르면 기차.<br />기차는 길어, 길면 비밀번호 암호화."</strong></p>
<p>— Code Monkey's Heap is Red</p>
</blockquote>
<p><strong><em>해싱은 원래 빠른 동작을 제공해야 한다는 원칙이 있습니다.</em></strong></p>
<p>해싱은 단방향 암호화를 제공하기 위해서만 사용하는 것이 아니고, 동시에 많은 처리나 빠른 처리를 하는 시스템에서도 해싱을 중요하게 사용할 때가 있기 때문입니다.</p>
<p><strong><em>단방향 암호화에서는 굉장히 많은 해싱을 반복하여 일부러 느린 동작을 유도합니다.</em></strong></p>
<p>빠른 해싱을 베이스로 하면서도, 적어도 수백 번, 통상 수천 번 이상 반복적인 해싱을 하는 것이 비밀번호 암호화 함수들의 핵심 기술 중 하나입니다. 이때는 매 회차에 영향을 주는 각종 기술이 첨가되기도 하죠.</p>
<p>다음 그림은 그 예시로, 각 해싱 회차에서 생성되는 정보로 내부 상태를 업데이트하고 다음 회차 해싱에 내부 상태가 영향을 주는 과정을 간략히 표현합니다. 이 내부 상태는 보통 암호화 함수 종료 시 소멸하고, 필요한 정보만 반환하여 보존합니다. 처음부터 똑같이 암호화하면 같은 내부 상태를 재현하며 동일하게 암호화할 수 있고, 도중에 한 번이라도 암호화함수를 종료했다가 다시 그 결과를 재사용하여 암호화하면 새로운 내부 상태를 사용하여 다른 결과가 나옵니다. 이는 반복 해싱 횟수의 합산이 같더라도, 각 암호화 함수의 시행이 독립적이어서, 만약 반복 해싱을 1024번씩 적용하여 두 차례 암호화한 것과 2048번의 반복 해싱으로 한 차례 암호화한 것을 비교하면 서로 다른 결과를 확인할 수 있다는 것을 의미하죠. (이런 정보는 추후, 운영 중인 시스템의 암호화 함수를 개선해야 할 때 중요한 고려사항이 됩니다.)</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725018586110/02ee6925-8881-4977-bbd7-e9d25b5119f7.avif" alt class="image--center mx-auto" /></p>
<p>반복 해싱을 적용함으로써 레인보우 테이블, 브루트포스 등의 공격을 막을 수 있습니다.</p>
<ul>
<li><p>레인보우 테이블 생성은 사실상 시도하는 사람이 없게 됩니다.</p>
</li>
<li><p>성능을 요구하는 작업이 되기 때문에, 랜덤 대입 공격의 시도 횟수를 감소시켜 사용자를 보호합니다.</p>
</li>
</ul>
<p><strong><em>레인보우 테이블을 시도하지 않는 이유</em></strong></p>
<ul>
<li><p>사용자 입력에 대한 해시 값을 알 수 있어야 레인보우 테이블을 사용할 수 있기 때문에 요즘은 레인보우 테이블을 거의 사용할 수 없는 시대입니다.</p>
</li>
<li><p>사용자의 해시 값을 알게 되더라도 사용자마다 솔트가 다르고, 반복 해싱으로 레인보우 테이블 생성 비용이 상당히 높습니다. 사용자 한 명을 대상으로는 동일한 해시 값을 얻을 수 있는지만 확인하면 되기 때문에 레인보우 테이블을 만들 필요 없이 랜덤으로 대입해 보는 공격이 효율적입니다.</p>
<blockquote>
<p>특히 암호화 함수의 종류와 인자 값(예: BCrypt의 버전과 cost factor) 등을 시스템만 인식할 수 있거나 관리자가 특정 상황에만 열어 볼 수 있게 하고, 인증 DB에 대체된 정보로 저장한다면, 반복 인자 등을 알 수 없기 때문에 레인보우 테이블 생성 시 보존해야 할 정보가 기하급수적으로 증가합니다.</p>
</blockquote>
</li>
</ul>
<p>반복 해싱이 실질적으로 더 유효한 도움을 준다고 평가하는 것은 랜덤 대입 공격(브루트 포스 공격류)을 훨씬 줄일 수 있다는 것입니다.</p>
<blockquote>
<p><strong>🔍 브루트포스 공격(무차별 대입 공격)</strong></p>
<p>레인보우 테이블은 일찌감치 옛날 이야기가 되었습니다. 이제 남은 것은 암호를 찍어 보는 겁니다.</p>
<p>브루트포스는 공격 환경에 따라 두 가지 방식으로 나눌 수 있습니다.</p>
<ul>
<li><p><strong>서비스 플랫폼에 직접 대입</strong></p>
<ul>
<li><p>사용자의 비밀번호 해시 값을 모를 때 사용합니다.</p>
</li>
<li><p>플랫폼에 무작위로 비밀번호를 대입할 수 있습니다.</p>
</li>
</ul>
</li>
<li><p><strong>본인의 컴퓨터에 환경을 구축하여 제한 없이 대입</strong></p>
<ul>
<li><p>사용자의 비밀번호 해시 값이 노출된 경우 사용합니다.</p>
</li>
<li><p>동일한 해시 함수, 인자, 솔트를 사용하여 무작위로 사용자 비밀번호의 해시 값을 생성하는 방식입니다.</p>
</li>
</ul>
</li>
</ul>
<p>요즘 많은 사이트가 비밀번호를 '연속으로' 틀리면 계정을 보호 상태로 전환하는 조치를 하기 때문에, 계정당 연속 시도 횟수를 줄이고 장기적으로 재시도를 하는 식으로 공격할 수도 있습니다. 예를 들어, 사이트 정책상 연속 시도 횟수를 24시간마다 초기화한다면 공격자의 연속 시도 횟수를 초기화받으며 매일 재시도할 수 있고, 연속 시도 횟수를 초기화하지 않는다면 비활성 계정들에 대한 공격을 줄일 수 있습니다.</p>
<p>주기적으로 초기화하지 않고 로그인이 되는 시점에만 초기화하는 방식이면, 로그인 연속 시도 횟수를 초기화받는 계정은 주로 활성 계정일 것이며, 계정이 탈취되더라도 비교적 빠른 시일에 확인이 가능할 것입니다.</p>
<p>단, 단기간에 큰 피해가 생길 수 있는 금융, 메일 등 중요한 서비스는 추가적인 탐지 및 방지로 각별히 주의합니다.</p>
<p><strong>🔍 딕셔너리 공격 (사전 공격)</strong></p>
<p>찍어도 남들이 많이 쓰는 암호로 찍어 보겠다는 공격입니다. 사람들이 자주 사용하는 비밀번호 목록을 참고하여 다른 사용자의 계정으로 로그인을 시도합니다. 이 공격은 가끔 소셜 엔지니어링과 결합하여 사용자 개인정보를 바탕으로 시도될 수도 있습니다. (생일, 전화번호, 가족 생일, 가족 전화번호, 실명, 기념일, 별명, 출신지 등 주로 SNS에 노출되어 있는 정보)</p>
</blockquote>
<p><strong><em>느린 로그인을 지향하기</em></strong></p>
<p>로그인은 의도적인 지연을 허용합니다. 표준화된 기준이 없지만, 서버에서만 1초 정도를 소요하는 것을 목표로 할 수 있습니다.</p>
<ul>
<li><p><strong>안정성 측면</strong></p>
<ul>
<li><p>암호화 연산에 상당한 비효율을 발생시켜 입력 값을 알아내기 어렵게 만듭니다.</p>
<blockquote>
<p>사용자 비밀번호의 해시 값 노출은 있어서는 안 될 일이지만, 사고로 발생하거나 내부자에 의해 접근될 수 있습니다.</p>
<p>이 해시 값을 만들기 위한 입력값을 찾는 것은 실제 서비스 대신 공격자 본인의 컴퓨터에서 암호화 함수를 실행하여 수행될 수 있습니다. 위에서 언급한 딕셔너리 공격이나 브루트포스 등이 공격자 본인 컴퓨터에서 제한 없이 수행되는 상황을 말합니다.</p>
</blockquote>
</li>
<li><p>일부 암호화 함수의 '하드웨어 저항성'이라는 특징이 특정 소요 시간 이상에서 효과적일 수도 있으며, 특히 500밀리초나 1초 이상에서 효과적일 수 있다는 실측 보고들이 있습니다.<br />  <em>(</em>* <em>관련 실험의 공식적인 리포트나 논문이 아니라, 사람들의 실측 보고를 바탕으로 합니다.)</em></p>
<p>  <a target="_blank" href="https://x.com/Sc00bzT/status/1084839341762011137"><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725244162526/5be40e4d-b052-4c43-a641-d4515c180dea.avif" alt class="image--center mx-auto" /></a></p>
</li>
</ul>
</li>
<li><p><strong>사용자 경험 측면</strong></p>
<ul>
<li><p>1초를 조금 넘는 정도의 로그인 시간은 사용자가 불편함을 느끼지 않는다고 가정하고 보안을 우선시하는 것입니다.</p>
<ul>
<li><p>많은 사용자가 로그인을 기다리는 것에 적응했으며, 1초를 조금 넘는 로그인 시간은 크게 불편하지 않은 것으로 가정합니다.<br />  <em>(</em>* <em>관련해서 객관적인 통계나 사용자 경험에 따른 피드백 리서치가 발견되지 않았습니다.)</em></p>
</li>
<li><p>예를 들어 구글은 로그인 응답에 3초 이상을 소요하기도 하는데, 사용자가 이를 체감하지 못할 때도 있습니다.</p>
</li>
</ul>
</li>
<li><p>로그인은 서비스 이용 내내 빈번하게 발생하는 작업이 아닙니다. 낮은 빈도로 발생하고, 어떤 작업의 선행 작업 정도로 생각되기 때문에, 사용자가 조금은 기다릴 수 있다고 가정합니다.</p>
</li>
</ul>
</li>
</ul>
<h1 id="heading-6riw7yoa">기타</h1>
<h2 id="heading-67me67ca67ki7zi4ioy1noumgcdquljsnbqg7kcc7zwc">비밀번호 최대 길이 제한</h2>
<h3 id="heading-6ri47iiy66gdioyiiydgcdruytrsidrsojtmlg">길수록 좋은 비밀번호</h3>
<blockquote>
<p>비밀번호는 길수록 좋습니다.</p>
<p>"나는다람쥐와도토리묵을묵찌빠스타"처럼 특별히 맥락은 없는데 암기할 만한 문장이나 단어 조합을 사용하면 다른 사람이 쉽게 유추할 수 없고, 자릿수가 노출되어도 매우 길기 때문에 경우의 수가 많아 계정 보호에 도움이 됩니다.</p>
</blockquote>
<p>초보자분들의 프로젝트에서 데이터베이스 설계를 들여다 보면, 가끔 비밀번호 길이 제한을 두려고 하고, 그걸 또 데이터베이스에 반영하는 것을 발견합니다. 또 일부 공기업과 공공기관 사이트가 사용자 계정의 비밀번호를 최대 10~12글자로 제한하지만, 이는 보안상 좋지 않습니다. (경우의 수는 많지만, 흔히 쓰는 패턴을 반영하면 경우의 수가 엄청나게 줄어듭니다.)</p>
<p>그리고 저장 효율은 비밀번호 길이와 무관합니다. 비밀번호 암호화는 해시 함수 특성상 고정 길이 결과를 반환하여, 사용자 비밀번호의 원문과 무관하게 같은 길이로 저장됩니다. 예를 들어 BCrypt는 60글자, SCrypt, ARGON2, PBKDF2 등은 설정에 따라 선택됩니다.</p>
<h3 id="heading-7kce7iahio2aqoycqoqzvcdshjzrsoqg67aa64u0">전송 효율과 서버 부담</h3>
<p>우선 비밀번호 길이는 길게 쓰는 것을 최대한 권장해야 합니다. 마음만 같아서는 1000글자도 허용하고 싶지만 효율을 위해 길이 제한을 설정할 수 있고, 또 수만 글자를 허용하는 등의 스펙은 과도한 트래픽과 서버 자원 낭비를 유발하죠.</p>
<p>만약 20만 글자나 되는 비밀번호가 스프링 API 서버에 도착하면 어떨까요? 일단 메모리상에 매우 크고 임의적이고 불필요한 문자열이 상수 풀에 생성되면서, 요청을 받는 것만으로 서버 부담이 증가할 겁니다. 만약 글자 제한이 훨씬 짧았다면, 스프링 서버에 도착하기 전에 프록시 서버 등에서 로그인 요청의 바디 길이를 제한할 수 있었을 겁니다. 그러면 스프링 서버에 오기도 전에 이상한 요청을 막을 수 있었겠죠.</p>
<p>그래서 불필요할 정도로 너무 긴 비밀번호 길이보다는, 적당히 긴 길이까지만 입력을 받게 정책을 정하면 서버 운영의 부담을 덜 수 있습니다. 최대 길이는 수백 글자 범위를 추천하고, NIST는 사용자가 64글자 암호를 사용할 수 있다면 안전하다고 설명합니다.</p>
<p>예를 들어 대형 서비스인 구글은 100글자로 타이트하게 제한합니다. 트래픽과 부하가 있는 서비스이기 때문에 비교적 적은 글자를 허용하는 것으로 보이며, 이 정도로도 충분히 긴 암호를 사용할 수 있습니다.</p>
<h3 id="heading-7lap64mioyggo2vreyeseydmcdsnkdsp4a">충돌 저항성의 유지</h3>
<p>길이 제한을 둔다고 해서 해시 충돌 확률이 줄어드는 것은 아닙니다. 잘 설계된 해시 함수는 길이 제한이 없더라도 해시 충돌의 확률이 충분히 비슷합니다. 대신, 굉장히 긴 비밀번호를 사용하는 사람이 우연히도 아주 짧고 쉬운 비밀번호와 해시 충돌이 생겨 계정이 탈취될 가능성이 아주 조금은 생기는데, 이는 길이 제한이 있는 시스템에서도 생길 수 있는 현상이며 그 확률이 거의 없다고 볼 수 있습니다.</p>
<h3 id="heading-10-12"><strong>비밀번호 최대 길이 10글자, 12글자가 절대 충분하지 않은 이유</strong></h3>
<p><strong><em>10글자 암호의 경우의 수, 계산상 많지만...</em></strong></p>
<p>영문 대소문자와 숫자로 된 10글자 암호는 62의 10제곱의 경우의 수가 존재합니다.</p>
<blockquote>
<p>10글자 Alphanumeric Password의 경우의 수(대소문자 52, 숫자 10가지):<br />62의 10제곱 = 839,299,365,868,340,224가지</p>
</blockquote>
<p>1초에 1억 개씩 연산할 수 있다고 할 때 모든 경우의 수를 계산하는 데에는 97,141일이 조금 넘게 걸리고, 이는 인간의 자연 수명을 아득하게 뛰어넘습니다. 266년이 넘는 것으로 계산되거든요. 하지만 사람들의 암호 작성 패턴을 반영하면 경우의 수가 극적으로 급감합니다.</p>
<ul>
<li><p>사람들은 영문 소문자와 숫자로만 조합하기를 선호합니다.</p>
</li>
<li><p>사람들은 영문 소문자와 숫자를 번갈아 쓰는 경우가 거의 없습니다.</p>
<ul>
<li><p>대부분 영문 소문자 조합 뒤에 숫자 조합을 붙입니다.</p>
</li>
<li><p>영문이나 숫자 중 하나로만 조합할 수도 있죠.</p>
</li>
<li><p>영문 소문자와 숫자를 번갈아 쓴다면 q1w2e3나 1q2w3e처럼 오히려 쉬운 패턴을 씁니다.</p>
</li>
</ul>
</li>
</ul>
<p>만약 소문자와 숫자로만 조합되는 비밀번호를 찍어 본다면 1초에 1억 개씩 계산할 때 약 14개월 정도로 기존 266년에 비하면 말도 안 되게 줄어듭니다. 여기서 영문자를 먼저 쓰고 숫자를 작성하는 것으로만 반영해도 훨씬 적은 경우의 수가 되겠죠. (10글자일 때 229,390,280,436,736가지 경우의 수가 있고 1초에 1억 개씩 총 26일 정도면 모든 경우의 수를 검토할 수 있습니다.)</p>
<p>평소 12글자보다 긴 암호를 사용해 온 사람들은 평소에 쓰던 안전하고 긴 비밀번호를 사용할 수 없으며, 기억하기 쉬운 새로운 비밀번호를 만들어서 사용하려고 할 것이고, 유추하기 쉬운 암호가 유도됩니다. 즉, 최대 10~12글자 제한은 사전공격을 준비할 때 힌트가 되겠죠.</p>
<p>흔히 "Tr0ub4dor&amp;3" 같은 암호는 3일 만에 해독될 수 있다고 소개합니다. (troubadour: 음유시인)<br />(대치된 규칙: o → 0, A → 4)</p>
<p><a target="_blank" href="https://www.wsj.com/articles/the-man-who-wrote-those-password-rules-has-a-new-tip-n3v-r-m1-d-1502124118"><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725304316579/5a217377-322d-42d6-b054-785f77ec0d77.avif" alt class="image--center mx-auto" /></a></p>
<p><a target="_blank" href="https://security.stackexchange.com/questions/167235/how-does-the-password-tr0ub4dor3-have-28-bits-of-entropy"><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725304375665/da211c84-d52b-405d-ba8c-de73ee555d9d.avif" alt class="image--center mx-auto" /></a></p>
<p>다른 해석들은 비밀번호가 길수록 안전하다는 것을 계산하여 설명합니다.</p>
<p><a target="_blank" href="https://www.quora.com/Do-four-random-common-words-make-a-stronger-password-than-passwords-like-Tr0ub4dor-3"><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725304788726/f1be1698-c101-40c7-b235-ab676f8bb899.avif" alt class="image--center mx-auto" /></a></p>
<h2 id="heading-7y6y7y2866eb">페퍼링</h2>
<p>페퍼링은 가변 솔트보다 늦게 붙은 용어지만, 그 개념의 고안은 가변 솔트보다 한참 앞설 수도 있습니다. 대부분 서비스는 페퍼를 사용하지 않고 운영되므로, 메이저한 개념은 아닙니다.</p>
<p>페퍼는 '해시 값 노출'에서 생기는 위협으로부터 사용자를 한 층 더 보호하는 역할을 합니다.</p>
<h3 id="heading-amp-db-db">솔트&amp;페퍼: DB가 아는 가변 솔트와 DB는 모르는 고정 페퍼</h3>
<p>가변 솔트는 사용자의 비밀번호와 함께 저장된다고 했습니다. 그렇다면 해시 값이 노출되었을 때 솔트도 함께 노출되겠죠. 그러면 공격자는 이 솔트를 바탕으로 사용자 비밀번호를 유추하기 위한 브루트포스를 준비할 수 있을 테구요. 그러다 우연히 맞는 게 있다면 드디어 정답을 하나 찾았다고 생각할 겁니다.</p>
<p>하지만 항상 그럴까요?</p>
<blockquote>
<p>"그건 내 솔트의 일부일 뿐이다."</p>
</blockquote>
<p>일부 민감한 서비스는 비밀번호와 함께 저장하는 솔트만 두지 않습니다. 아무리 DB를 털어 봐도 나오지 않는, '숨은 솔트'를 둘 수 있죠. 대신 DB에 저장하지 않는 만큼 '임의성'이 너무 다양하게 나타나지 않게 하는데, 기본 개념으로는 '서비스를 통틀어 하나의 값'이라고 이해할 수 있고, 이를 '페퍼'라고 부릅니다.</p>
<ul>
<li><p>서비스 단일 솔트: 페퍼 (고정된 단일값)</p>
</li>
<li><p>사용자 암호별 솔트: 가변 솔트</p>
</li>
</ul>
<p>그래서 공격자가 찾아 낸 암호는 사실 솔트의 '일부분'만 사용한 것이기 때문에, 공격자는 본인이 찾아 낸 암호를 사용해도 원래의 서비스에는 접속할 수 없습니다. 페퍼를 모르니 본인 컴퓨터에서 브루트포스를 해 봤자 사용자 암호를 알 수 없고, 결국 탈취한 해시 값은 무용지물이 되어 다시 서버에 브루트포스 공격 준비를 해야 합니다. 그건 굉장히 오랜 시간, 굉장히 느리게 수행하겠죠.</p>
<p><strong><em>페퍼는 이렇게 해시 값 노출 시 위협으로부터 사용자를 한 층 더 보호하는 역할을 할 수 있습니다.</em></strong></p>
<p>하지만 일반적으로 해시 값이 노출되는 일은 드물다고 보고, 대부분 페퍼는 사용하지 않습니다. 갱신 등 관리가 복잡하고, 해시 값 노출이 없는 한 페퍼의 효과가 크지는 않다고 보기 떄문이죠.</p>
<h3 id="heading-7y6y7y287j2yio2eioykpo2gooumrcdqtidrpqw">페퍼의 히스토리 관리</h3>
<p>페퍼의 갱신은 번잡한 문제입니다. 페퍼가 적용되어 있는 비밀번호들은 모두 이 페퍼가 없으면 비밀번호 암호화를 할 수 없기 때문에 기존 페퍼도 보존되어야 하죠.</p>
<p><strong><em>페퍼의 보존 위치</em></strong></p>
<p>페퍼의 히스토리 관리는 이런 식으로 한다면 편할 것 같습니다.</p>
<ul>
<li><p>최신본: 인증 서버 측에 바로 저장해 두고 사용. (인증 DB에 담지 않기)</p>
</li>
<li><p>Old (예전 버전들): 시간 등 버전을 정해서 인증 DB와 격리된 스토리지에 보존.</p>
</li>
</ul>
<p>최신본은 앞으로 자주 조회될 거고, 예전 버전들은 조회를 적게 할 겁니다. 둘 다 최대한 인증 DB로부터 격리된 저장 공간을 사용해야 의미가 있습니다.</p>
<p>페퍼는 환경변수나 기타 수단으로 보존·전달하여 서버 프로그램이 바로 접근할 수 있게 합니다. 구버전은 기본적으로는 별개 저장소에 옮겨 담아도 무방하지만, 최근까지 쓰인 구버전은 호출 빈도가 높기 때문에 가까운 자원에 두어도 됩니다.</p>
<p><strong><em>주기적으로 갱신되는 페퍼를 적용한 환경에서 비밀번호 비교</em></strong></p>
<p>오래된 페퍼를 점점 호출하지 않는 이유는, 활성 사용자들이 오래된 페퍼로 로그인을 해도 갱신된 페퍼로 비밀번호를 다시 암호화하기 때문입니다.</p>
<ol>
<li><p>사용자 입력</p>
<ul>
<li><p>계정 (username)</p>
</li>
<li><p>비밀번호 (raw password)</p>
</li>
</ul>
</li>
<li><p>DB에서 암호화된 비밀번호와 비밀번호 생성 시각 조회</p>
<ul>
<li><p>암호화된 비밀번호: 비교용</p>
</li>
<li><p>비밀번호 생성 시각: 페퍼 조회용</p>
</li>
</ul>
</li>
<li><p>비밀번호 생성 시각에 기반하여 어느 페퍼를 사용하는지 찾아서 페퍼 적용</p>
</li>
<li><p>비밀번호 비교(솔팅 및 반복 해싱)</p>
<ul>
<li>통과 시 예전 페퍼를 사용 중이라면, 이번 로그인에 입력된 비밀번호를 사용해서 최신 페퍼를 적용해 DB에 담을 비밀번호를 갱신합니다.</li>
</ul>
</li>
<li><p>이렇게 하여 기존 페퍼의 영향을 받는 비밀번호가 없다면, 그 페퍼는 삭제해도 되는 값이 됩니다.</p>
</li>
</ol>
<p>주기적인 갱신을 하지 않는다면 페퍼의 의의가 다소 약할 것이며, 페퍼 값의 주기적인 갱신 시 페퍼 기반 로그인을 위한 프로세스는 대략 위와 같은 느낌일 겁니다.</p>
<hr />
]]></content:encoded></item><item><title><![CDATA[컴파일러 최적화 (1) 상수: 상수 폴딩과 함수 인라이닝]]></title><description><![CDATA[고수준과 저수준

고수준(High level): 컴퓨터 구조에서 고수준이란, 사람에게 가까운 영역을 뜻합니다.

저수준(Low level): 컴퓨터 구조에서 저수준이란, 기계에게 가까운 영역을 뜻합니다. 아래 그림의 계층적인 그림에서 기반이 되는 영역, 즉 기저에 있는 영역들이 저수준이에요.


컴퓨터 구조론에서는 아래 그림처럼 기반 시스템에 가까울수록 저수준, 그 위에 돌아가는 프로그램들은 고수준으로 표현합니다.

하이레벨과 로우레벨로 표현...]]></description><link>https://blog.letsdev.me/compiler-optimization-1-kor</link><guid isPermaLink="true">https://blog.letsdev.me/compiler-optimization-1-kor</guid><category><![CDATA[compiler optimization]]></category><category><![CDATA[constant folding]]></category><category><![CDATA[constant propagation]]></category><category><![CDATA[function inlining]]></category><category><![CDATA[compiler]]></category><category><![CDATA[optimization]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Tue, 27 Aug 2024 14:54:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1724688603786/2ecffebe-7487-4d95-9d6b-5c51de01ecde.avif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-6rog7iiy7ksa6ro8ioyggoyimoykga">고수준과 저수준</h1>
<ul>
<li><p><strong>고수준</strong>(High level): 컴퓨터 구조에서 고수준이란, 사람에게 가까운 영역을 뜻합니다.</p>
</li>
<li><p><strong>저수준</strong>(Low level): 컴퓨터 구조에서 저수준이란, 기계에게 가까운 영역을 뜻합니다. 아래 그림의 계층적인 그림에서 기반이 되는 영역, 즉 기저에 있는 영역들이 저수준이에요.</p>
</li>
</ul>
<p>컴퓨터 구조론에서는 아래 그림처럼 기반 시스템에 가까울수록 저수준, 그 위에 돌아가는 프로그램들은 고수준으로 표현합니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1724704679736/6eef5b78-1813-4ee7-a908-93a88356ef18.avif" alt class="image--center mx-auto" /></p>
<p>하이레벨과 로우레벨로 표현하지만 이는 학습 난이도를 표현하는 개념이 아니라, 기반 시스템과 그 위에 쌓이는 것들의 각 계층을 구분하는 표현입니다.</p>
<p><strong>저수준</strong></p>
<ul>
<li><p><strong>하드웨어</strong>: 이때 하드웨어는 반도체로 가득한 기계 덩어리를 말한다고 볼 수 있습니다.</p>
</li>
<li><p><strong>펌웨어</strong>: 그 기계에 있는 각종 소자 등의 제어를 할 수 있는 가장 기본 동작을 담은 프로그램입니다.</p>
</li>
<li><p><strong>운영체제</strong>: 하드웨어와 펌웨어 위에 올라가는 시스템 소프트웨어로, 우리가 흔히 알고 있는 윈도우, 맥, 리눅스 등이 운영체제에 해당합니다. 우리가 컴퓨터를 생각할 때 여기까지를 한 덩어리로 보는 것이고, 여기까지가 기반 시스템, 즉 플랫폼이라고 부를 수 있는 계층입니다.</p>
</li>
</ul>
<p><strong>고수준</strong></p>
<ul>
<li><strong>응용 프로그램</strong>: 메모장, 터미널, 탐색기, 크롬, 인텔리제이, VS Code, 이클립스, 카카오톡, 그림판 등 우리가 흔히 사용하는 프로그램은 대부분 응용 프로그램입니다. 시스템 소프트웨어와 구분해서 부르는 표현입니다. (소프트웨어는 시스템 소프트웨어와 응용 소프트웨어로 분류합니다.)</li>
</ul>
<h1 id="heading-7lu07yym7j28">컴파일</h1>
<p>컴파일(compile)은 사람이 작성한 소스 코드를 기계어처럼 컴퓨터가 바로 알 수 있는 명령으로 바꾸는 동작을 뜻하는 개념이었습니다. 이렇게 컴파일을 수행해 주는 도구가 '컴파일러(compiler)'입니다.</p>
<ul>
<li><p><strong>넓은 의미</strong>: 어떤 언어를 다른 언어로 변환하는 것입니다. 요즘은 이렇게 넓은 의미로 사용합니다.</p>
<ul>
<li><p>예를 들어, 타입스크립트를 자바스크립트로 컴파일합니다.</p>
</li>
<li><p>예를 들어, SCSS를 CSS로 컴파일합니다.</p>
</li>
<li><p>보통 동수준 또는 저수준으로 변환하는 방향일 때 컴파일, 저수준에서 고수준으로 변환할 때는 디컴파일이라고 부릅니다.</p>
</li>
</ul>
</li>
<li><p><strong>좁은 의미</strong>: 고급 언어를 저급 언어(또는 저수준에 가까운 언어)로 변환하는 것입니다.</p>
<ul>
<li><p><strong>고급 언어</strong>(고수준 언어)는 사람이 이해하기 쉬운 언어입니다.</p>
</li>
<li><p><strong>저급 언어</strong>(저수준 언어)는 기계가 이해하기 쉬운 언어입니다. 좁은 의미로는 프로세서가 바로 이해할 수 있는 언어여야 하기 때문에, 기계어와 어셈블리어가 저급언어에 해당합니다.</p>
</li>
</ul>
</li>
</ul>
<p>따라서 우리가 프로그램을 만들고 실행하는 순서를 요약하면 다음과 같아요. (전통적인 프로세스)</p>
<ol>
<li><p>사람이 프로그램 소스 코드를 작성합니다.</p>
</li>
<li><p>컴파일러가 이 소스 코드를 해석해서 기계가 알아먹을 수 있는 표현으로 바꿉니다.</p>
</li>
<li><p>운영체제가 프로그램을 작동합니다.</p>
</li>
</ol>
<h1 id="heading-7lu07yym7j2865s7j2yioy1noygge2zla">컴파일러의 최적화</h1>
<p><strong>컴파일러의 주요 기능</strong></p>
<ul>
<li><p>역할 1: 컴파일러는 입력받은 프로그램(= 명령어들)의 의미를 보존하여 번역해야 합니다.</p>
</li>
<li><p>역할 2: 컴파일러는 입력받은 프로그램을 실용적으로 개선합니다.</p>
</li>
</ul>
<p>현대적인 컴파일러는 프로그래밍 언어의 번역 외에도 '최적화(optimization)' 기능을 탑재합니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1724769193090/c3127a2d-7dd6-4c30-b53a-ff8d779fac99.avif" alt class="image--center mx-auto" /></p>
<blockquote>
<p><strong>우리가 작성한 코드</strong>(가독성 위주) <strong>→ 최적화 → 최적화된 코드</strong>(성능 위주)</p>
</blockquote>
<hr />
<blockquote>
<p>💡 <strong>최초의 컴파일러와 최초의 '완전한' 컴파일러</strong></p>
<p>최초의 컴파일러는 1952년 그레이스 호퍼가 만든 A-0 시스템입니다. A-0 시스템의 역할은 기계어 작성을 단순화하기 위한 자동화 도구에 가까웠습니다. 사용자는 A-0 시스템에 특정 키워드로 작성된 명령어와 내장 라이브러리의 서브루틴(= 함수)을 사용하여 사람들에게 친화적인 고수준 수학 공식을 입력합니다. A-0 시스템은 입력받은 고수준 수학 공식을 머신 코드로 변환하는 자동화 도구였습니다. 단순화한 특정 명령어를 사용하고 라이브러리의 서브루틴을 호출하는 점에서 오늘날 고급 프로그래밍 언어에 영감을 준 시조격입니다.</p>
<p>최초의 완전한 컴파일러는 1957년 IBM의 존 베커스가 만든 포트란 컴파일러입니다. 이 컴파일러는 최적화 기능을 탑재한 완전한 컴파일러였습니다. 고급 프로그래밍 언어 중 최초로 대인기를 끌었다고 인정받는 포트란은, 그 컴파일러가 언어를 번역하는 기능뿐 아니라 최적화하는 기능을 수행함으로써 현대적인 컴파일러의 주요 역할을 충족하여 최초의 완전한 컴파일러로 봅니다.</p>
</blockquote>
<hr />
<h2 id="heading-7lwc7kcb7zmu66w8ioycho2vncdsg4hsijgg7iks7jqp">최적화를 위한 상수 사용</h2>
<p><strong>💡 상수를 사용하면 일부가 컴파일타임에 해석되어 최적화됩니다.</strong></p>
<p>컴파일러는 자신이 예측하기 쉬운 것들은 쉽게 최적화할 수 있습니다. 프로그래밍에서 상수 사용은 값의 불변성을 보장하기 때문에, 예측하기 쉬운 상태가 됩니다.</p>
<h1 id="heading-kirsg4hsijgg7y065spkio"><strong>상수 폴딩</strong></h1>
<p>상수 폴딩은 값을 미리 계산할 수 있는 것들은 컴파일타임에 미리 계산해 준다는 최적화 개념입니다.</p>
<p>기호 상수를 사용하면 상수 전파도 적용될 수 있습니다. 기호 상수의 값을 컴파일타임에도 알 수 있을 때, 기호 상수를 리터럴 상수로 대체합니다. 이것이 상수 전파(constant propagation)입니다.</p>
<p>상수 전파 후 상수 폴딩으로 간단한 수식 정도는 컴파일타임에 계산해서 결과를 넣습니다.</p>
<p><strong>상수 폴딩 전</strong></p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Example</span> </span>{

    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> GAIN = <span class="hljs-number">1024</span>;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> <span class="hljs-title">getCalculatedValue</span><span class="hljs-params">()</span> </span>{
        <span class="hljs-keyword">int</span> a = <span class="hljs-number">10</span>;
        <span class="hljs-keyword">int</span> b = <span class="hljs-number">2</span> * a + <span class="hljs-number">1</span>;
        <span class="hljs-keyword">return</span> (a * b) + GAIN;
    }
}
</code></pre>
<p><strong>상수 폴딩 후</strong></p>
<p>컴파일타임에 <code>GAIN</code>을 알기 때문에 상수 전파가 되고, 식이 모두 계산되어 결과만 남깁니다. 다른 값들은 리터럴 상수를 넣은 지역변수이기 때문에 컴파일러가 미리 계산할 수 있습니다.</p>
<pre><code class="lang-java"><span class="hljs-comment">// 컴파일 타임 이후 원래 바이트코드 등으로 표현되지만 자바 코드로 봅시다.</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Example</span> </span>{

    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> GAIN = <span class="hljs-number">1024</span>;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> <span class="hljs-title">getCalculatedValue</span><span class="hljs-params">()</span> </span>{
        <span class="hljs-keyword">return</span> <span class="hljs-number">1234</span>;
    }
}
</code></pre>
<h1 id="heading-kirtlajsijgg7j2465287j2064udkio"><strong>함수 인라이닝</strong></h1>
<p>함수를 호출하는 비용도 아낄 수 있도록 함수의 동작을 그대로 함수 호출부로 넣는 작업입니다.</p>
<p>예를 들어 메서드가 상수를 반환할 때는 메서드 호출 대신 그 상수를 넣을 수 있습니다.</p>
<p><strong>인라이닝 전</strong> (위 예시의 함수를 사용함)</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Main</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">void</span> <span class="hljs-title">main</span><span class="hljs-params">()</span> </span>{
        System.out.println(Example.getCalculatedValue());
    }
}
</code></pre>
<p><strong>인라이닝 후</strong></p>
<p>쉬운 이해를 위해 인라이닝 후 상수 폴딩을 적용했습니다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Main</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">void</span> <span class="hljs-title">main</span><span class="hljs-params">()</span> </span>{
        System.out.println(<span class="hljs-number">1234</span>);
    }
}
</code></pre>
<h1 id="heading-7iob7iiyioygho2mjcwg7iob7iiyio2ptouuqswg7zwo7iiyioyduoudvoydtoulneydmcdsijzshjw">상수 전파, 상수 폴딩, 함수 인라이닝의 순서</h1>
<ul>
<li><p>일부 상수는 함수를 통해 제공되고 있을 수 있습니다. (함수 인라이닝 필요)</p>
</li>
<li><p>일부 함수는 상수 폴딩을 해야 최종 결과로 상수를 반환하는 것을 확인할 수 있습니다.</p>
</li>
</ul>
<p>이처럼 최적화 기법이 서로 종속될 수 있기 때문에, 적용 순서에 따라 최적화 스텝이 달라질 수 있습니다.</p>
<p>(중략 ^^)</p>
<p>일반적으로 함수 인라이닝을 상수 폴딩보다 먼저 수행하는 것이 좋습니다. 함수 인라이닝을 통해 앞으로 필요한 최적화 요소를 같이 발견할 수 있기 때문입니다.</p>
<p>따라서 오늘 나온 개념을 컴파일타임에 적용할 때, 권장하는 순서는 다음과 같습니다.</p>
<ol>
<li><p><strong>함수 인라이닝</strong></p>
</li>
<li><p><strong>상수 전파</strong></p>
</li>
<li><p><strong>상수 폴딩</strong></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Java의 상수 필드는 꼭 static final로 선언할까?]]></title><description><![CDATA[서론
✅ 자바에서 상수 필드를 선언할 때 static final을 세트처럼 자주 사용합니다.
이 조합은 워낙 빈번하게 등장해서 마치 관례로 보일 정도입니다.
// 어느 클래스의 static 멤버
public static final String EXAMPLE =
        SomeClass.getPropertyValue("example");

✅ 자바 인류는 어쩌다 이 표기를 습관적으로 쓰게 되었을까요?
자바 인류가 어쩌다 이 표기를 습관적으...]]></description><link>https://blog.letsdev.me/static-final-kor</link><guid isPermaLink="true">https://blog.letsdev.me/static-final-kor</guid><category><![CDATA[Java]]></category><category><![CDATA[static]]></category><category><![CDATA[constant]]></category><category><![CDATA[immutable]]></category><category><![CDATA[immutability]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Mon, 26 Aug 2024 03:52:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1724620814174/edbb11cf-b695-49a2-ac65-20e010d89171.avif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-7isc66gg">서론</h1>
<p><strong>✅ 자바에서 상수 필드를 선언할 때</strong> <code>static final</code><strong>을 세트처럼 자주 사용합니다.</strong></p>
<p>이 조합은 워낙 빈번하게 등장해서 마치 관례로 보일 정도입니다.</p>
<pre><code class="lang-java"><span class="hljs-comment">// 어느 클래스의 static 멤버</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> String EXAMPLE =
        SomeClass.getPropertyValue(<span class="hljs-string">"example"</span>);
</code></pre>
<p><strong>✅ 자바 인류는 어쩌다 이 표기를 습관적으로 쓰게 되었을까요?</strong></p>
<p>자바 인류가 어쩌다 이 표기를 습관적으로 쓰게 되었는지, 이 선언 방식이 가져다 주는 이점을 알아 보고, 다른 상수 관리 방식도 가볍게 살펴 보겠습니다.</p>
<hr />
<h1 id="heading-7ykk7jum65oc7j2yioyxre2voa">키워드의 역할</h1>
<h2 id="heading-static">Static</h2>
<p><code>static</code>으로 선언하면, 인스턴스(≈ 객체)를 만들지 않아도 클래스를 통해 바로 접근할 수 있죠.</p>
<pre><code class="lang-java"><span class="hljs-comment">// 인스턴스를 만들지 않고 클래스를 통해 접근</span>
Example.MAX_VALUE

<span class="hljs-comment">// 인스턴스를 만들지 않고 클래스를 통해 접근</span>
Example.staticMethod();
</code></pre>
<ul>
<li><p>자바에서 <code>static</code>을 사용하면 필드의 소유권을 인스턴스 대신 클래스로 옮길 수 있습니다.</p>
</li>
<li><p>그래서 <code>static</code> 변수는 새 객체를 만들 때마다 새로 생기지 않고, 프로그램 생명주기 동안 하나의 정체성으로 존재하게 됩니다.</p>
</li>
<li><p><code>static</code>은 (처음 생성된 이후) 프로그램의 생명 주기 동안 살아 있습니다.</p>
</li>
<li><p><code>static</code>은 클래스에 종속되어 있기는 하지만, 어느 정도 독립적인 핸들링이 가능합니다.</p>
</li>
</ul>
<h2 id="heading-final">Final</h2>
<p>자바에서 <code>final</code>은 해당 요소가 한 번만 할당될 수 있도록 합니다.</p>
<pre><code class="lang-java"><span class="hljs-comment">// 선언 (주소 할당)</span>
<span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> num;

<span class="hljs-comment">// 대입 (값 할당)</span>
num = <span class="hljs-number">10</span>;
</code></pre>
<ul>
<li><p><code>final</code> <strong>+ 변수</strong>인 기호 상수는 값을 한 번만 대입할 수 있습니다. 이 변수는 자신이 갖고 있는 값이 최종 값임을 보장해요.</p>
</li>
<li><p><code>final</code> <strong>+ 메서드</strong>는 가상 함수가 아니게 되어서 오버라이딩을 예약할 수 없게 됩니다. 오버라이딩을 할 수 없게 된 이 메서드는 자신이 담고 있는 내용이 최종 동작임을 보장해요.</p>
</li>
<li><p><code>final</code> <strong>+ 클래스</strong>는 상속을 할 수 없습니다. 이 클래스는 상속되기 위한 목적이 아니라고 선언하여 자신을 구성하는 설계가 더 이상 하위로 상속되어 변경되지 않고 최종 설계임을 보장해요. 메서드도 모두 final 메서드와 같은 효과를 얻겠죠.</p>
</li>
</ul>
<p>지역변수가 아닌 것들에 <code>final</code>을 붙이면 컴파일러는 안정성도 보장하지만, 종종 최적화에서도 도움이 될 때가 있습니다. (컴파일러는 예측하기 쉬운 것일수록 잘 최적화하는 편이거든요.)</p>
<hr />
<p><strong>덧붙이는 정보</strong></p>
<p><strong>💡 자바는 기본적으로</strong> <code>final</code><strong>이 아닌 메서드는 모두 가상 메서드입니다.</strong></p>
<blockquote>
<p>가상 메서드는 추상 메서드와 다릅니다.<br />추상 메서드(abstract method)는 선언만 있고 내용이 아직 없는 메서드를 말합니다.<br />가상 메서드(virtual method)는 오버라이딩이 가능한 메서드를 말합니다.</p>
<p>C++에서는 <code>virtual</code> 키워드를 붙여야 가상 함수가 되지만, 자바에서는 따로 키워드가 없다면 모두 가상 함수입니다.</p>
</blockquote>
<p><strong>💡 자바에서는 상속에 열린 클래스를 기본으로 합니다.</strong></p>
<blockquote>
<p>따로 키워드를 붙이지 않은 자바 클래스는 언제든 상속이 가능한 상태에 있습니다.</p>
<p>자바에서는 <code>final</code>, <code>sealed</code> 등 키워드를 붙여야 상속에 (조금 또는 완전히) 닫힌 클래스가 되지만, 코틀린에서는 따로 <code>open</code> 키워드를 붙이지 않으면 상속에 완전히 닫힌 클래스가 됩니다.</p>
</blockquote>
<p><strong>💡 상수를 사용하면 일부가 컴파일타임에 해석되어 최적화됩니다.</strong></p>
<blockquote>
<p>컴파일러는 자신이 예측하기 쉬운 것들은 쉽게 최적화할 수 있습니다. 즉, 우리 코드를 컴파일러가 더 빠른 코드로 살려 줍니다. 이런 최적화 덕분에 우리가 코드 수준에서는 가독성을 우선시할 수 있죠.</p>
<p><strong>상수 전파, 상수 폴딩</strong></p>
<p>상수 폴딩은 값을 미리 계산할 수 있는 것들은 컴파일타임에 미리 계산해 준다는 최적화 개념입니다.</p>
<p>기호 상수를 사용할 때는 상수 전파도 적용될 수 있습니다. 그 기호 상수가 컴파일타임에도 값을 알 수 있는 것일 때 기호 상수를 리터럴 상수로 대치하기도 합니다. 상수 전파를 한 후 상수 폴딩으로 간단한 수식 정도는 컴파일타임에 계산해서 결과를 넣습니다.</p>
<p><strong>함수 인라이닝</strong></p>
<p>함수를 호출하는 비용도 아낄 수 있도록 함수의 동작을 그대로 함수 호출부로 넣는 작업입니다.</p>
<p>메서드가 결국 상수를 반환할 때는 메서드 호출 대신 그 상수를 넣을 수 있습니다.</p>
</blockquote>
<hr />
<h1 id="heading-static-final">Static과 Final을 함께 쓰는 이유</h1>
<p><code>static</code>으로 선언하지 않은 상수는 그 클래스의 모든 인스턴스에 해당 값에 대한 메모리를 새로 할당해 메모리 낭비가 발생합니다. 또 인스턴스 생성·정리 비용도 조금은 증가하겠죠.</p>
<ul>
<li><p>여러 이유로 상수 사용은 권장되는 방식입니다. (안정성, 최적화, 일부 로직에서 쉬운 핸들링 등)</p>
</li>
<li><p><code>static</code> 사용으로 메모리를 아낄 수 있습니다. (인스턴스마다 중복 생성을 하지 않으니까요.)</p>
</li>
<li><p><code>static</code> 사용으로 인스턴스에 종속되지 않은 상수를 사용할 수 있습니다.</p>
</li>
<li><p><code>static</code>이지만 <code>final</code>이기 때문에 최적화에 용이합니다.</p>
</li>
<li><p>이렇게 작성한 일부 상수는 컴파일타임에 값을 보장하기도 합니다.</p>
</li>
</ul>
<hr />
<h1 id="heading-8jsuydsnpdquldslbwsioyypo2vtoyvvc4">💻 자기야, 오해야.</h1>
<h2 id="heading-static-final-1">"상수 필드는 꼭 <code>static final</code>로 선언해야 할까요?"라는 오해 풀기</h2>
<p><strong>✅ 자바에서 상수 필드를 선언할 때</strong> <code>static final</code><strong>을 세트처럼 자주 사용합니다.</strong></p>
<p>그러다 보니 몇몇 분들이 오해하기를, 자바에서 상수 필드를 선언할 때는 반드시 <code>static final</code>로 해야 바람직하다고 생각하는 것 같습니다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Example</span> </span>{
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> MAX_VALUE = <span class="hljs-number">100</span>;
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> MIN_VALUE = <span class="hljs-number">0</span>;
}
</code></pre>
<p><strong>✅ 사실 처음 가르칠 때는 그렇게 설명하는 게 편하고 좋습니다.</strong></p>
<p>값을 바로 기호 상수에 담는 거라면 그게 좋거든요. (위 예시처럼요.)</p>
<p><strong>✅ 객체마다 다른 값을 갖는 상수는</strong> <code>static</code><strong>이 아닙니다.</strong></p>
<p>이러면 보통 생성자에서 초기화합니다. (공통되는 작업은 인스턴스 초기화 블록을 쓸 수도 있습니다만, 유연한 관리를 위해 상수는 반드시 생성자에서 초기화하는 것을 권합니다.)</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ExampleClass</span> </span>{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> String message;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">ExampleClass</span><span class="hljs-params">(String message)</span> </span>{
        <span class="hljs-keyword">this</span>.message = message;
    }
}
</code></pre>
<p><strong>✅ 객체에 종속되지 않고 항상 일정한 값을</strong> <code>static final</code><strong>로 선언하는 겁니다.</strong></p>
<p>어차피 객체마다 만들어도 같은 값을 사용할 거라면, 재생성을 할 필요 없이 <code>static</code>으로 취급합니다.</p>
<hr />
<h2 id="heading-static-1"><strong>💡 "static을 붙이면 컴파일타임에 값을 할당한다."라는 오해 풀기</strong></h2>
<p>static은 정적으로 관리되는 것은 맞지만, 컴파일타임에 값을 항상 보장하는 것은 아니에요.</p>
<p>컴파일러가 똑똑해서 일부는 컴파일타임에 해석해서 보장해 주는 거예요.</p>
<p><strong>✅ 리터럴 상수를 바로 대입하고 있을 때</strong> → 컴파일타임에 해석이 가능합니다.</p>
<p><strong>✅ static이더라도 값의 결정이 런타임에 될 때</strong> → 컴파일타임에는 값을 정할 수 없습니다.</p>
<p>예를 들어, 런타임에 결과가 정해지는 함수를 사용할 때 그렇습니다.</p>
<p><strong>✅ 이건 static final이더라도 마찬가지예요.</strong></p>
<p>자바의 <code>final</code>은 어떤 식으로든 한 번만 대입이 된다는 게 보장되면 되고, 이건 다음 예시처럼 런타임에 값을 결정해서 대입하는 것을 방지하진 않거든요.</p>
<pre><code class="lang-java"><span class="hljs-comment">// 어느 클래스의 static 멤버</span>
<span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> String EXAMPLE =
        SomeClass.getPropertyValue(<span class="hljs-string">"example"</span>);
</code></pre>
<pre><code class="lang-java"><span class="hljs-comment">// 사용된 클래스와 함수</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">SomeClass</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> String <span class="hljs-title">getPropertyValue</span><span class="hljs-params">(String propertyName)</span> </span>{
        <span class="hljs-comment">// 이곳에서 어느 파일의 값을 읽어 반환하고 있다고 가정합니다.</span>
        <span class="hljs-comment">// 파일은 런타임에 읽습니다.</span>
        <span class="hljs-comment">// (컴파일타임에는 외부 파일의 내용을 알 수 없고, 파일의 내용이 불변임을 보장할 수 없습니다.)</span>
    }
}
</code></pre>
]]></content:encoded></item><item><title><![CDATA[차근차근 Modern Spring Boot 3 기초 (8) DTO와 Entity의 Mapping]]></title><description><![CDATA[DTO와 Entity는 비슷해 보여도 역할이 다릅니다.
DTO와 Entity의 쓰임 구분
우리는 앞서 용어를 불필요하게 넓은 의미로 사용하기보다는, 자주 사용되는 의미면서 권장하는 의미로 설명하려고 했습니다. 그때 DTO와 entity는 다음처럼 구분했습니다.

DTO: 사용자(클라이언트)와 주고 받는 데이터입니다.

Entity: 데이터베이스와 주고 받는 데이터 양식의 기준이 되는 형태고, DB 테이블에 매핑되는 필드 구조를 띱니다. (즉, ...]]></description><link>https://blog.letsdev.me/step-by-step-springboot3-8-kor</link><guid isPermaLink="true">https://blog.letsdev.me/step-by-step-springboot3-8-kor</guid><category><![CDATA[Modern Java]]></category><category><![CDATA[Springboot]]></category><category><![CDATA[SpringBoot3]]></category><category><![CDATA[mapstruct]]></category><category><![CDATA[mapper]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Thu, 15 Aug 2024 14:38:01 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1723266546842/ba11837a-a51d-4d8b-9c32-0d9865cbc088.avif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-dto-entity">DTO와 Entity는 비슷해 보여도 역할이 다릅니다.</h1>
<h2 id="heading-dto-entity-1">DTO와 Entity의 쓰임 구분</h2>
<p>우리는 앞서 용어를 불필요하게 넓은 의미로 사용하기보다는, 자주 사용되는 의미면서 권장하는 의미로 설명하려고 했습니다. 그때 DTO와 entity는 다음처럼 구분했습니다.</p>
<ul>
<li><p>DTO: 사용자(클라이언트)와 주고 받는 데이터입니다.</p>
</li>
<li><p>Entity: 데이터베이스와 주고 받는 데이터 양식의 기준이 되는 형태고, DB 테이블에 매핑되는 필드 구조를 띱니다. (즉, 데이터베이스 테이블(또는 행)의 '스프링에서의 모습'처럼 생각할 수 있습니다.)</p>
</li>
</ul>
<p>그림을 간소화하면 다음과 같습니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723269346466/fc4fc1ce-1cc9-4544-912f-c1e277ed5c39.avif" alt class="image--center mx-auto" /></p>
<p>그림을 조금 더 자세히 보면 이렇습니다. (Entity는 서비스와 persistence 레이어에서 사용)</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723268451517/5510c2cc-65b1-4a96-916c-be7a7e295bb5.avif" alt class="image--center mx-auto" /></p>
<p>DTO와 entity는 아무리 데이터 구조가 비슷해 보이더라도 위처럼 주로 사용되는 구간이나 역할이 서로 다르기 때문에 우리는 그것들을 잘 구분해 사용하는 것이 편리합니다.</p>
<p>(변환이 필요하다는 내용)</p>
<p>(여전히 entity를 관리하지 않는 회사들 중, DTO와 entity의 역할이 섞인 VO라는 것을 만들어서 사용하는 곳을 자주 볼 수 있습니다.)</p>
<h1 id="heading-dto-entity-2">DTO를 Entity로 변환하기</h1>
<p>DTO를 entity로 변환하는 데에는 여러 방식이 있습니다.</p>
<ul>
<li><p>DTO는 순수하게 데이터를 전달하는 역할을 해야 합니다.</p>
</li>
<li><p>사용하는 데에 있어 충분히 편리해야 합니다.</p>
</li>
</ul>
<h2 id="heading-1-dto-toentity">후보 1: DTO에 toEntity() 메서드</h2>
<p>DTO 클래스에 인스턴스 메서드로 <code>toEntity()</code>를 만드는 것은 매우 흔한 선택 중 하나입니다. 필요한 역할을 충분히 수행하면서도, 설계에 큰 영향을 주지 않는 편으로 볼 수 있습니다.</p>
<p><strong>toEntity() 메서드</strong></p>
<pre><code class="lang-java"><span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SampleDto</span><span class="hljs-params">(
        ... <span class="hljs-comment">/* 필드 목록 */</span>
)</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">public</span> SampleEntity <span class="hljs-title">toEntity</span><span class="hljs-params">()</span> </span>{
        <span class="hljs-keyword">return</span> <span class="hljs-comment">/* 이곳에서 SampleEntity의 생성자 또는 빌더 등을 사용하여 인스턴스 생성 */</span>;
    }
}
</code></pre>
<p><strong>사용</strong></p>
<pre><code class="lang-java"><span class="hljs-comment">// DTO -&gt; entity</span>
SampleEntity entity = dto.toEntity();

<span class="hljs-comment">// use entity</span>
sampleRepository.save(entity);
</code></pre>
<ul>
<li><p>DTO는 순수하게 데이터를 전달하는 역할을 해야 합니다. (O)</p>
<ul>
<li><p><strong>비판적 의견</strong><br />  일부 사람들은 DTO가 toEntity() 등 변환 메서드를 포함하는 것을 원하지 않습니다. DTO는 데이터를 넣고 빼는 일 외에 다른 기능을 포함하지 않는 것이 좋기 때문입니다.</p>
</li>
<li><p><strong>긍정적 의견</strong><br />  변환 메서드까지는 DTO가 데이터를 전달하는 수단의 하나로 볼 수도 있습니다.</p>
</li>
</ul>
</li>
</ul>
<p>단점이라고 할 정도는 아니지만, 작은 사이드 이펙트가 있습니다.</p>
<ul>
<li>DTO가 일부 entity에 종속되는 개념이 됩니다.</li>
</ul>
<h2 id="heading-2-mapstruct">후보 2: Mapstruct (채택)</h2>
<p>우리는 toEntity() 메서드 대신 매핑 라이브러리를 사용할 수 있습니다.</p>
<p>그중 MapStruct는 매핑 라이브러리 중 비교적 젊은 축에 속하는 기술입니다. 오랫동안 사용되던 매핑 라이브러리인 ModelMapper 대신 주목받고 채택되고 있는 기술입니다.</p>
<h3 id="heading-modelmapper">비교군 ModelMapper (런타임에 리플렉션)</h3>
<p>오래 전부터 사용되던 ModelMapper는 리플렉션(reflection)이라는 기술을 사용합니다. 리플렉션은 클래스나 객체를 뜯어 자유롭게 관리할 수 있도록 돕는 기술이고, 라이브러리 제작에서 자주 활용합니다. 보통 사내 코드에서는 지양하고, 타인의 코드를 건들지 않고 다루고 싶을 때 하는 선택에 가깝습니다.</p>
<p>리플렉션은 클래스와 인스턴스를 자유롭게 조작할 수 있는 대신 다음과 같은 단점을 포함합니다. 대부분 런타임에 동적으로 동작하기 때문에 최적화와 진단 등과 관련해 발생하는 단점입니다. (컴파일 타임이나 에디터 작성 중 확인되지 않는 동작들을 런타임에 포함하기 때문입니다.)</p>
<ul>
<li><p><strong>성능 저하</strong></p>
<ul>
<li>런타임에 동적으로 동작하기 때문에, 일반적인 메서드 호출이나 필드 접근보다 느립니다.</li>
</ul>
</li>
<li><p><strong>디버깅이 어려움</strong></p>
<ul>
<li>런타임에 동적으로 동작하기 때문에 미리 체크되지 않은 오류가 발생할 수 있고, 가독성을 낮춰 디버깅하기 어렵습니다. (런타임에 발견되는 오류)</li>
</ul>
</li>
<li><p><strong>안정성 및 보안</strong></p>
<ul>
<li><p>런타임에 동적으로 동작하기 때문에 미리 체크되지 않은 동작이 추가되어, 코드의 보안 수준을 낮출 수 있습니다.</p>
</li>
<li><p>리플렉션은 설계적인 안정성을 깨뜨리는 기술이기 때문에, 클래스의 설계자가 의도하지 않은 동작이 다른 작업자에 의해 추가될 수 있습니다. 이는 예기치 않은 취약점이 될 수 있습니다.</p>
</li>
</ul>
</li>
</ul>
<p>이중 라이브러리로 제공받는 경우, 리플렉션은 내부적으로만 사용되는 경우가 많기 때문에 성능 저하가 주된 고려 대상이 될 수 있습니다. (라이브러리 취약점은 리플렉션을 떠나서 주의할 항목)</p>
<h3 id="heading-mapstruct">MapStruct의 장단점</h3>
<p>MapStruct는 다음과 같은 장점이 있습니다.</p>
<ul>
<li><p>충분한 자유도를 보장합니다.</p>
</li>
<li><p>사용이 쉽습니다.</p>
</li>
<li><p>컴파일타임에 미리 구현됩니다. (런타임의 안정성과 성능에 도움이 됩니다.)</p>
</li>
<li><p>복잡한 객체의 매핑도 우리가 원하는 대로 지시하기 쉽습니다.</p>
</li>
<li><p>여러 개발 문화권을 고려한 완성도로 굉장히 스마트한 코드 호환성을 갖고 있습니다.</p>
</li>
<li><p>다양한 환경과의 호환을 위해 다양한 매핑 전략을 고를 수 있습니다.</p>
</li>
</ul>
<p>MapStruct는 ModelMapper에 비해 성능, 사용성, 자유도 등에서 만족도가 높습니다.</p>
<p>일반적으로 단점으로 언급될 만한 것을 추려 보면 다음과 같습니다.</p>
<ul>
<li><p>APT(Annotation Processor Tool) 기반입니다. (애노테이션을 처리하여 프리컴파일)</p>
<ul>
<li>일부 조직에서는 각자의 이유로 자바 스프링 프로젝트에서 APT에 의존하는 상황을 지양하며, 스프링의 철학에 일부 벗어난다고 볼 수도 있으며, 일부 마이그레이션에서 고려 사항이 됩니다.</li>
</ul>
</li>
<li><p>여러 환경에 대한 호환성 처리 등 다양한 매핑 전략을 택해야 할 때 아직 러닝커브가 존재합니다.</p>
</li>
</ul>
<h3 id="heading-mapstruct-1">MapStruct 의존성 라이브러리 추가</h3>
<p><code>dependencies</code>에 다음 세 항목을 추가합니다.</p>
<pre><code class="lang-kotlin">dependencies {
    <span class="hljs-comment">// MapStruct</span>
    implementation(<span class="hljs-string">"org.mapstruct:mapstruct:1.5.5.Final"</span>)
    annotationProcessor(<span class="hljs-string">"org.mapstruct:mapstruct-processor:1.5.5.Final"</span>)
    annotationProcessor(<span class="hljs-string">"org.projectlombok:lombok-mapstruct-binding:0.2.0"</span>)
}
</code></pre>
<p>마지막 항목은 Lombok에서 MapStruct와 프리컴파일의 동작 순서가 꼬이지 않도록 만들어 게시하여 둔 추가적인 애노테이션 프로세서입니다.</p>
<blockquote>
<p><strong>💡 org.mapstruct:mapstruct:1.5.5.Final</strong></p>
<p>MapStruct 사용을 위한 의존성 라이브러리입니다.</p>
<p><strong>💡 org.mapstruct:mapstruct-processor:1.5.5.Final</strong> (Annotation Processor)</p>
<p>의존성 목록에 반드시 Annotation Processor를 추가하여야 합니다.<br />MapStruct는 우리가 작성한 매퍼 인터페이스 등을 클래스로 구현하는 프리컴파일을 합니다.</p>
<p><strong>💡 org.projectlombok:lombok-mapstruct-binding:0.2.0</strong> (Annotation Processor)</p>
<p>롬복은 MapStruct와 충돌을 없애기 위한 org.projectlombok:lombok-mapstruct-binding 애노테이션 프로세서를 제공합니다. 이것을 사용하지 않으면 롬복 Annotation Processor와 동작 순서 등에서 충돌이 있습니다.</p>
</blockquote>
<h3 id="heading-mapper">Mapper 인터페이스 선언</h3>
<p>매핑을 위해 더 이상 복잡한 작업은 필요하지 않습니다. 메서드를 선언할 수 있는 인터페이스만 있으면, 편하게 매핑용 객체를 얻을 수 있습니다.</p>
<ul>
<li>Package: <code>com.example.demo.auth.mapper</code></li>
</ul>
<pre><code class="lang-java"><span class="hljs-keyword">import</span> org.mapstruct.Mapper;

<span class="hljs-meta">@Mapper(componentModel = "spring")</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">AccountDtoMapper</span> </span>{
    <span class="hljs-function">Account <span class="hljs-title">toEntity</span><span class="hljs-params">(SignUpRequest dto, AccountStatus status, Instant createdAt)</span></span>;
    <span class="hljs-function">SignUpResponse <span class="hljs-title">toSignUpResponse</span><span class="hljs-params">(Account entity)</span></span>;
}
</code></pre>
<p>수행한 작업은 다음과 같습니다.</p>
<ul>
<li><p><code>@Mapper</code> 애노테이션을 추가합니다.</p>
<ul>
<li><p>컴포넌트 모델은 <code>"spring"</code>입니다. (이로써 자동으로 스프링의 빈으로 등록됩니다.)</p>
</li>
<li><p>유효한 컴포넌트 모델은 다음과 같습니다. 이중 우리는 스프링으로 합니다.</p>
<ul>
<li><p>default</p>
</li>
<li><p>cdi</p>
</li>
<li><p>spring</p>
</li>
<li><p>jsr330</p>
</li>
<li><p>jakarta</p>
</li>
<li><p>jakarta-cdi</p>
</li>
</ul>
</li>
</ul>
</li>
<li><p>인풋은 메서드 매개변수, 아웃풋은 반환 타입으로 선언하기만 하면 준비 완료입니다.</p>
<ul>
<li><p>예를 들어 <code>SignUpRequest</code> 객체를 <code>Account</code> 객체로 변환하는 메서드는 다음과 같습니다.</p>
<pre><code class="lang-java">  <span class="hljs-function">Account <span class="hljs-title">toEntity</span><span class="hljs-params">(SignUpRequest dto)</span></span>;
</code></pre>
</li>
</ul>
</li>
</ul>
<h3 id="heading-mapper-1">Mapper를 주입받아서 사용하기</h3>
<p>이제 API에서 번거롭게 빌더로 변환하고 있던 코드를 간결하게 바꾸어 보겠습니다. 우선 생성자 주입을 통해 빈을 받아 옵니다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@RestController</span>
<span class="hljs-meta">@RequiredArgsConstructor</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AuthenticationApi</span> </span>{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> SignUpUseCase signUpUseCase;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> AccountDtoMapper mapper; <span class="hljs-comment">// 추가된 필드</span>

    <span class="hljs-comment">// ...</span>
}
</code></pre>
<p>기존 메서드는 다음처럼 데이터 변환을 직접 하고 있었습니다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@PostMapping("/sign-up")</span>
<span class="hljs-meta">@ResponseStatus(HttpStatus.CREATED)</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> SignUpResponse <span class="hljs-title">signUp</span><span class="hljs-params">(<span class="hljs-meta">@RequestBody</span> <span class="hljs-meta">@Valid</span> SignUpRequest body)</span> </span>{
    Account account = Account.builder()
            .username(body.username())
            .nickname(body.nickname())
            .password(body.password())
            .status(AccountStatus.ACTIVE)
            .createdAt(Instant.now())
            .build();

    Account savedAccount = signUpUseCase.signUp(account);

    <span class="hljs-keyword">return</span> SignUpResponse.builder()
            .username(savedAccount.getUsername())
            .nickname(savedAccount.getNickname())
            .createdAt(savedAccount.getCreatedAt())
            .status(savedAccount.getStatus())
            .build();
}
</code></pre>
<p>매퍼를 사용하면 다음처럼 간결하게 변경됩니다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@PostMapping("/sign-up")</span>
<span class="hljs-meta">@ResponseStatus(HttpStatus.CREATED)</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> SignUpResponse <span class="hljs-title">signUp</span><span class="hljs-params">(<span class="hljs-meta">@RequestBody</span> <span class="hljs-meta">@Valid</span> SignUpRequest body)</span> </span>{
    <span class="hljs-comment">// 매퍼: DTO -&gt; Entity</span>
    Account account = mapper.toEntity(body, AccountStatus.ACTIVE, Instant.now());

    Account savedAccount = signUpUseCase.signUp(account);

    <span class="hljs-comment">// 매퍼: Entity -&gt; DTO</span>
    <span class="hljs-keyword">return</span> mapper.toSignUpResponse(savedAccount);
}
</code></pre>
<h3 id="heading-mapstruct-2">선택 사항: MapStruct의 기본 컴포넌트 모델을 스프링으로 설정</h3>
<p><code>@Mapper</code> 애노테이션을 사용할 때 컴포넌트 모델(<code>componentModel = ...</code>)을 작성하는 것은 빠뜨리기 쉽고 귀찮은 작업입니다. 매번 명시하는 것도 괜찮지만, 명시하지 않아도 스프링의 빈으로 등록이되도록 설정할 수 있습니다.</p>
<p><code>build.gradle.kts</code> 파일에서 다음 내용을 추가합니다. 다음 코드는 컴파일타임에 JVM에 매개변수로 MapStruct의 기본 컴포넌트 모델(<code>defaultComponentModel</code>)을 설정하는 옵션을 전달한 것입니다.</p>
<pre><code class="lang-kotlin">tasks.withType&lt;JavaCompile&gt; {
    options.forkOptions.jvmArgs = listOf(
            <span class="hljs-string">"-Amapstruct.defaultComponentModel=spring"</span>,
    )
}
</code></pre>
<p>이제 MapStruct 매퍼 인터페이스 작성이 다음처럼 더 간편하게 완료됩니다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@Mapper</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">AccountDtoMapper</span> </span>{
    <span class="hljs-function">Account <span class="hljs-title">toEntity</span><span class="hljs-params">(SignUpRequest dto, AccountStatus status, Instant createdAt)</span></span>;
    <span class="hljs-function">SignUpResponse <span class="hljs-title">toSignUpResponse</span><span class="hljs-params">(Account entity)</span></span>;
}
</code></pre>
<p>MapStruct 매퍼 인터페이스를 작성할 때마다 모든 팀원이 <code>componentModel = spring</code>처럼 기억하기 번거로운 코드를 외우거나 찾지 않아도 사용할 수 있습니다.</p>
<hr />
<p><strong>&lt; Prev</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-7-kor">DTO와 API</a></p>
<p><strong>Next &gt;</strong></p>
<p>작성 예정</p>
]]></content:encoded></item><item><title><![CDATA[(취약점 제거됨) Spring Boot: 2024 JJWT 취약점(v0.12.5 이하) 및 'signWith(java.security.Key, io.jsonwebtoken.SignatureAlgorithm)' is deprecated 해결 (v0.12.0 이상)]]></title><description><![CDATA[JJWT Impl의 취약점 발견
취약점 보고(CVE)

참고: signWith() 함수의 deprecated는 0.12.0 버전에 되어 CVE 보고와 무관합니다. 취약점은 사소한 것이며, 0.11 버전 이하를 사용하더라도 반드시 올려야 하는 것은 아닙니다.벤더 측에서 릴리스한 버전을 권장하기 위하여 정보를 공유합니다.

해당 취약점은 CVE에 CVE-2024-31033로 보고되었습니다. (2024-03-27, 논쟁 있음)
이 보고의 내용은 이렇...]]></description><link>https://blog.letsdev.me/jjwt-signwith-2024</link><guid isPermaLink="true">https://blog.letsdev.me/jjwt-signwith-2024</guid><category><![CDATA[JJWT]]></category><category><![CDATA[Java JWT]]></category><category><![CDATA[Springboot]]></category><category><![CDATA[SpringBoot3]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Tue, 16 Jul 2024 16:00:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1721145383124/781568d4-665d-4e76-9de8-edce148912c9.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-jjwt-impl">JJWT Impl의 취약점 발견</h1>
<h2 id="heading-cve">취약점 보고(CVE)</h2>
<blockquote>
<p>참고: <code>signWith()</code> 함수의 deprecated는 0.12.0 버전에 되어 CVE 보고와 무관합니다. 취약점은 사소한 것이며, 0.11 버전 이하를 사용하더라도 반드시 올려야 하는 것은 아닙니다.<br />벤더 측에서 릴리스한 버전을 권장하기 위하여 정보를 공유합니다.</p>
</blockquote>
<p>해당 취약점은 CVE에 <a target="_blank" href="https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-31033"><strong>CVE-2024-31033</strong></a>로 보고되었습니다. (2024-03-27, 논쟁 있음)</p>
<p>이 보고의 내용은 이렇습니다. (논쟁)</p>
<blockquote>
<p>0.12.5 이하 JJWT(Java JWT)는 특정 문자를 무시하므로 사용자에게 강력한 키(key)가 있다고 잘못 판단했을 수 있습니다.</p>
</blockquote>
<p>JJWT 공급자는 다음처럼 이의를 제기합니다.</p>
<blockquote>
<p>JJWT 사용 방식에서 사용자의 오류가 없는 한 '무시(ignores)' 동작이 어떤 버전에서도 발생할 수 없으며, 실제 테스트된 버전은 6년 이상 지나야 하기 때문에 이의를 제기하고 있습니다.</p>
</blockquote>
<p>이 취약점의 영향을 받는 코드는 다음과 같습니다.</p>
<ul>
<li><p><code>DefaultJwtParser</code> 클래스 내의 <code>setSigningKey()</code> 메서드</p>
</li>
<li><p><code>DefaultJwtBuilder</code> 클래스 내의 <code>signWith()</code> 메서드</p>
</li>
</ul>
<h2 id="heading-7ioiiouyhoyghcdrprtrpqzsiqq">새 버전 릴리스</h2>
<p>MVN Repository에서 다음처럼 0.12.5 이하 버전은 모두 취약점 하나가 체크되어 있으며, 올해 6월 21일에 그 다음 버전인 0.12.6 버전이 릴리스되었습니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1721147376330/96d8023b-1bb6-4211-8552-b6a3cfade0f1.png" alt class="image--center mx-auto" /></p>
<hr />
<h1 id="heading-v0120">새 버전에서 유효한 방식 (v0.12.0 이상)</h1>
<h2 id="heading-7j2y7kg07isxioy2loqwga">의존성 추가</h2>
<p>다음처럼 v0.12.6으로 의존성을 추가합니다.</p>
<pre><code class="lang-kotlin"><span class="hljs-comment">// build.gradle에서 관련 모듈의 dependencies</span>
dependencies {
    <span class="hljs-comment">// jjwt</span>
    implementation <span class="hljs-string">'io.jsonwebtoken:jjwt-api:0.12.6'</span>
    runtimeOnly <span class="hljs-string">'io.jsonwebtoken:jjwt-impl:0.12.6'</span>
    runtimeOnly <span class="hljs-string">'io.jsonwebtoken:jjwt-jackson:0.12.6'</span>
}
</code></pre>
<h2 id="heading-jwt-provider">JWT Provider 작성</h2>
<p>deprecated 예시</p>
<pre><code class="lang-java"><span class="hljs-comment">// 0.11 버전에서 deprecated 되었습니다.</span>
<span class="hljs-comment">// (io.jsonwebtoken.SignatureAlgorithm, String)</span>
.signWith(SignatureAlgorithm.HS256, secret)

<span class="hljs-comment">// 0.12.0 버전에서 deprecated 되었습니다.</span>
<span class="hljs-comment">// (java.security.Key, io.jsonwebtoken.SignatureAlgorithm)</span>
.signWith(secretKey, SignatureAlgorithm.HS256)
</code></pre>
<p>유효한 함수 (기본 알고리즘: HS256)</p>
<pre><code class="lang-java"><span class="hljs-comment">// signWith(java.security.Key)</span>
.signWith(secretKey)
</code></pre>
<p>전체 코드 예시 (generateToken() 함수 위주로 보세요.)</p>
<pre><code class="lang-java"><span class="hljs-keyword">import</span> example.demo.common.jwt.properties.DemoJwtProperties;
<span class="hljs-keyword">import</span> io.jsonwebtoken.Claims;
<span class="hljs-keyword">import</span> io.jsonwebtoken.Jwts;
<span class="hljs-keyword">import</span> io.jsonwebtoken.io.Decoders;
<span class="hljs-keyword">import</span> io.jsonwebtoken.security.Keys;
<span class="hljs-keyword">import</span> org.springframework.stereotype.Component;

<span class="hljs-keyword">import</span> java.security.Key;
<span class="hljs-keyword">import</span> java.util.Date;
<span class="hljs-keyword">import</span> java.util.HashMap;
<span class="hljs-keyword">import</span> java.util.Map;

<span class="hljs-meta">@Component</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">JwtProvider</span> </span>{

    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> Key secretKey;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">long</span> maxAge;

    <span class="hljs-comment">// 구성 속성을 받아 올 때는 편한 방식(@Value 등)을 사용하면 됩니다.</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">JwtProvider</span><span class="hljs-params">(DemoJwtProperties demoJwtProperties)</span> </span>{
        secretKey = generateSecretKey(demoJwtProperties.secret());
        maxAge = demoJwtProperties.maxAge();
    }

    <span class="hljs-meta">@Override</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> String <span class="hljs-title">generateToken</span><span class="hljs-params">(String subject, Map&lt;String, ?&gt; payload)</span> </span>{
        <span class="hljs-comment">// header, payload, signature -&gt; base64</span>
        Claims claims = generateClaims(subject, payload);

        <span class="hljs-keyword">return</span> Jwts.builder()
                .signWith(secretKey)
                .claims(claims)
                .compact();
    }

    <span class="hljs-comment">// DemoJwtProperties 파일에 작성할 수도 있지만, 속성 파일에 외부 기술에 대한 종속성을 만들지 않기 위해 여기서 작성.</span>
    <span class="hljs-function"><span class="hljs-keyword">private</span> Key <span class="hljs-title">generateSecretKey</span><span class="hljs-params">(String secret)</span> </span>{
        <span class="hljs-keyword">byte</span>[] keyBytes = Decoders.BASE64.decode(secret);
        <span class="hljs-keyword">return</span> Keys.hmacShaKeyFor(keyBytes);
    }

    <span class="hljs-function"><span class="hljs-keyword">private</span> Claims <span class="hljs-title">generateClaims</span><span class="hljs-params">(String subject, Map&lt;String, ?&gt; payload)</span> </span>{
        Date now = <span class="hljs-keyword">new</span> Date();
        Date expirationAt = <span class="hljs-keyword">new</span> Date(now.getTime() + maxAge);

        <span class="hljs-keyword">return</span> Jwts.claims()
                .subject(subject)
                .issuedAt(now)
                .expiration(expirationAt)
                .add(payload)
                .build();
    }
}
</code></pre>
]]></content:encoded></item><item><title><![CDATA[차근차근 Modern Spring Boot 3 기초 (7) DTO와 API]]></title><description><![CDATA[페이지 요청과 AJAX 요청
우리는 두 요청 중 페이지 요청은 다루지 않습니다. 페이지 요청은 말 그대로 웹 페이지에 대한 요청이고, AJAX 요청은 쉽게 말하자면 이미 페이지를 받아서 띄운 상태에서, 추가적으로 데이터나 명령에 대하여 서버에 요청하는 것입니다.
페이지 요청을 다루지 않는 이유는, 최근 프론트엔드와 백엔드의 작업 영역, 배포하는 서버 등이 예전에 비해 뚜렷하게 구분되고 있기 때문입니다. 페이지 요청을 프론트엔드 쪽으로 하고, 데...]]></description><link>https://blog.letsdev.me/step-by-step-springboot3-7-kor</link><guid isPermaLink="true">https://blog.letsdev.me/step-by-step-springboot3-7-kor</guid><category><![CDATA[RestController]]></category><category><![CDATA[Modern Java]]></category><category><![CDATA[Java]]></category><category><![CDATA[Springboot]]></category><category><![CDATA[SpringBoot3]]></category><category><![CDATA[Ajax]]></category><category><![CDATA[dto]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Fri, 12 Jul 2024 12:47:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1723807005373/e0cdbc93-b82b-48f0-8e55-6e05ef75a993.avif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-ajax">페이지 요청과 AJAX 요청</h1>
<p>우리는 두 요청 중 페이지 요청은 다루지 않습니다. 페이지 요청은 말 그대로 웹 페이지에 대한 요청이고, AJAX 요청은 쉽게 말하자면 이미 페이지를 받아서 띄운 상태에서, 추가적으로 데이터나 명령에 대하여 서버에 요청하는 것입니다.</p>
<p>페이지 요청을 다루지 않는 이유는, 최근 프론트엔드와 백엔드의 작업 영역, 배포하는 서버 등이 예전에 비해 뚜렷하게 구분되고 있기 때문입니다. 페이지 요청을 프론트엔드 쪽으로 하고, 데이터와 각종 명령을 백엔드 서버에 요청한다고 이해할 수 있습니다. 반드시 이렇게 나뉘는 것은 아닙니다.</p>
<h2 id="heading-7y6y7j207keaioyaloyyrq">페이지 요청</h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720682605957/82ec8aa6-5765-4c49-955f-8cdba66d16f9.png" alt class="image--center mx-auto" /></p>
<p>클라이언트에서 서버로 페이지를 요청합니다. 서버는 HTML 등 페이지의 정적 파일을 응답합니다.</p>
<p>이 역할은 예전에는 통합되어 있던 서버에서 많이 수행했지만, SPA(리액트 등) 진영이 성장하게 되면서 프론트엔드 서버에서 담당하는 편입니다. 서버라고 불렀지만, 정적 파일을 배포하는 스토리지에서 바로 응답하기도 합니다. 그 형태는 이번 공부에서 중요한 것은 아니고, 이번 스프링 부트 프로젝트는 페이지 요청을 다루지 않고 API 서버로 사용한다는 것이 중점입니다.</p>
<h2 id="heading-ajax-1">AJAX 요청</h2>
<p>AJAX(Asynchronous JavaScript and XML)라는 개념을 쉽게 생각하면, 그냥 페이지를 띄워 놓은 상태에서 서버와 통신하는 것입니다. 페이지의 새로고침 없이 서버와 데이터를 주고 받거나, 명령을 주고 받는 기술입니다. 대표적으로 fetch API, XHR, axios 등 다양한 기술을 통해 구현되고 있습니다.</p>
<h3 id="heading-ajax-jquery">AJAX는 jQuery의 함수 이름이 아닙니다.</h3>
<p>교육이나 실무에서 ajax라는 함수를 접한 개발자 중 일부는 ajax가 jQuery 함수 이름이라고 오해하곤 합니다. jQuery 라이브러리에 포함된 ajax 함수 때문인데, 사실 특정 라이브러리의 함수 이름이 아니라 자바스크립트에서 비동기 통신을 하기 위한 기술을 칭하는 표현입니다. "AJAX 대신 AXIOS나 fetch를 써라." 하는 식의 설명은 그러한 오해에서 비롯되었겠죠.</p>
<h1 id="heading-restcontroller">RestController</h1>
<p>일반 컨트롤러 애노테이션(<code>@Controller</code>)을 사용하여 만든 클래스는, 페이지 요청과 Ajax 요청을 모두 처리할 수 있는 컨트롤러가 됩니다. 이러면 ajax 요청을 처리하기 위해서 몇 가지 작업이 추가되죠. 그런 작업을 매번 반복하지 않기 위해서, ajax 요청만 처리하는 컨트롤러용 애노테이션이 준비되었습니다.</p>
<p><code>@RestController</code> 애노테이션을 클래스 위에 선언하면, 이 클래스는 이제 ajax 요청 처리용 컨트롤러 클래스가 됩니다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@RestController</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AuthApi</span> </span>{

}
</code></pre>
<p>이곳에서 use case(서비스)를 바로 사용할 수 있습니다. 빈으로 주입받도록 생성자를 만들어 줍니다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@RestController</span>
<span class="hljs-meta">@RequiredArgsConstructor</span> <span class="hljs-comment">// final과 non-null 필드에 대한 생성자를 만듭니다.</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AuthApi</span> </span>{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> SignUpUseCase signUpUseCase;

    <span class="hljs-comment">// 곧 이곳에 메서드를 추가하여 API를 만들 것입니다.</span>
}
</code></pre>
<h1 id="heading-dto">요청과 응답용 DTO</h1>
<p>DTO는 전달할 데이터 객체를 말합니다. 그 객체를 클래스로 표현할 수도 있으니, 작업하는 관점에서는 주고 받을 데이터의 양식(클래스)이라고 생각할 수도 있습니다. "우리가 만드는 API를 이용하려면, 이런 데이터를 주세요, 그러면 우리가 이런 데이터를 드리겠습니다."라고 할 때의 '데이터'들을 DTO라고 부를 수 있습니다.</p>
<h2 id="heading-dto-1">DTO 모아 두기</h2>
<p>패키지 내에 각각의 DTO용 클래스 파일을 여러 개 만들어도 사용하는 데에는 문제가 없습니다. 하지만 우리는 모던한 방향을 탐구하고 있고, 패키지 안에 너무 많은 파일이 있다면 이름순으로 정렬해도 원하는 클래스를 찾는 데에 눈과 손이 고생한다는 것을 예상할 수 있습니다. 그렇다면 조금 더 개선된 해결책을 찾는 것이 우리 탐구 방향에 맞을 것입니다.</p>
<p>그래서 하나의 클래스 파일을 만들고, 그 안에 내부 클래스로 필요한 DTO들을 분류하는 방안을 택하고 있습니다. 이때는 여러 request용 DTO와 response용 DTO를 어떤 식으로 정렬해 두어야 찾기 편한 배치인지 생각해 볼 수 있죠. 기능 단위로 1차 분류를 할 것인지 request/response로 묶어 1차 분류를 할 것인지 정할 수 있을 것입니다. 이때 제 본능과 경험상의 결론은 이렇습니다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-keyword">final</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AuthDto</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-title">AuthDto</span><span class="hljs-params">()</span> </span>{}

    <span class="hljs-comment">// [1] request DTO를 나열</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignUpRequest</span><span class="hljs-params">(<span class="hljs-comment">/* data */</span>)</span> </span>{}

    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignInRequest</span><span class="hljs-params">(<span class="hljs-comment">/* data */</span>)</span> </span>{}

    <span class="hljs-comment">// ...</span>

    <span class="hljs-comment">// [2] response DTO를 나열</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignUpResponse</span><span class="hljs-params">(<span class="hljs-comment">/* data */</span>)</span> </span>{}

    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignInResponse</span><span class="hljs-params">(<span class="hljs-comment">/* data */</span>)</span> </span>{}
}
</code></pre>
<p>이와 같은 배치의 이유는 다음과 같습니다.</p>
<p><strong>우리 뇌는 작업을 하다 보면 집중력이 감소합니다.</strong></p>
<ul>
<li><p>막상 DTO를 찾으려고 할 때, 기능의 이름은 바로바로 떠오르지 않을 수도 있습니다.</p>
</li>
<li><p>반면 request용 DTO를 찾으려고 한 것인지 response용 DTO를 찾으려고 한 것인지는 압니다.</p>
</li>
<li><p>따라서 1차 분류가 request DTO와 response DTO로 되어 있는 것이 눈으로 찾기 더 편합니다.</p>
</li>
</ul>
<p><strong>기능 단위로 먼저 묶어서 배치하면 원하는 클래스를 찾기까지 생각보다 많은 스크롤을 요구할 겁니다.</strong></p>
<ul>
<li><p>탐색 영역이 늘어난 만큼 사람의 눈으로 찾기는 더 불편할 겁니다.</p>
</li>
<li><p>물론 대략적인 위치도 기억할 수 있고, 단어를 기억하면 금방 검색할 수 있습니다. 하지만 앞서 말한 대로 지속적인 작업에서 에너지를 절약할 수 있도록 정리해 두는 것이 낫습니다.</p>
</li>
</ul>
<p><strong>탐색 영역을 반으로 줄이려면, 상위에 request들을, 하위에 response들을 모아 놓으면 됩니다.</strong></p>
<ul>
<li>네, 그뿐입니다. 이로써 우리 뇌가 주요 작업도 아닌 'DTO를 어디에 써 두었는지 찾기'를 위해 너무 많은 에너지를 쓰지 않아도 되죠.</li>
</ul>
<p>물론 우리 체력이 남아 있을 때는 통상, 원하는 DTO를 찾기 위해 IDE에서 클래스 이름을 검색하는 것이 빠르고 편합니다. 그럼에도 어느 정도 기준으로 정리되어 있는 파일을 추구하겠다는 것이죠. 코드 스타일 관례와 마찬가지로, DTO 배치를 정리하는 데에 있어서 저와 비슷한 기준을 택하는 빅테크들이 있습니다.</p>
<h2 id="heading-signup-dto-record">SignUp DTO들 작성 (Record)</h2>
<p>기본적으로 record는 클래스의 한 유형입니다. record는 불변 객체를 만들기 위한 클래스임과 동시에, 불필요한 코드 작성을 확연하게 줄여 주는 자바의 신기능입니다. Java 16 이상에서 사용할 수 있으며, 스프링부트 3 이상에서는 Java 호환성 버전이 17 이상이기 때문에 앞으로 신규 프로젝트에서는 대부분 사용이 가능할 것입니다.</p>
<p>불변객체란, 모든 필드가 <code>final</code> 필드인 객체라고 생각하면 됩니다. 당연한 선언이기 때문에, 생략하고 필드의 타입과 변수명만 작성하면 됩니다. 작성 시 알아야 할 내용은 다음과 같습니다.</p>
<ul>
<li><p>모든 필드를 채울 수 있는 All arguments constructor를 자동으로 생성합니다.</p>
</li>
<li><p>클래스 선언과 동시에 마치 생성자를 작성하듯, 소괄호를 열고 그 안에 생성자의 파라미터겸 필드를 작성하면 됩니다(아래 SignUprequest). 아래 예시에서는 가독성을 위해 줄바꿈을 추가하였지만, 중괄호가 아닌 소괄호 안에 작성하였다는 것을 눈여겨 봐야 합니다.</p>
</li>
</ul>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-keyword">final</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AuthDto</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-title">AuthDto</span><span class="hljs-params">()</span> </span>{}

    <span class="hljs-meta">@Builder</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignUpRequest</span><span class="hljs-params">(
            String username,
            String password,
            String nickname
    )</span> </span>{}

    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignInRequest</span><span class="hljs-params">(<span class="hljs-comment">/* data */</span>)</span> </span>{}

    <span class="hljs-comment">// responses</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignUpResponse</span><span class="hljs-params">(<span class="hljs-comment">/* data */</span>)</span> </span>{}

    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignInResponse</span><span class="hljs-params">(<span class="hljs-comment">/* data */</span>)</span> </span>{}
}
</code></pre>
<p><strong>빌더 패턴 Builder Pattern</strong></p>
<p>우리는 record를 사용할 때, 편의를 위해 롬복(lombok)의 <code>@Builder</code>라는 애노테이션을 붙여 줍니다. 여러 대안 중에서도 편의성에 중점을 둔 선택입니다. 이에 대해 일부 개발자가 반감을 보일 수도 있다는 예상을 하지만, 아직까지는 반감의 반응을 보지 않았습니다. (예상하는 반감의 반응은, 설계의 안정성을 보다 강력하게 추구하는 사람들로부터 나올 수 있다고 생각합니다. 아직 이 글의 독자님들이 빌더 사용의 장단점을 다루는 진도가 아니기 때문에, 지금은 편의를 위해 빌더를 사용한다고만 알아 두세요.)</p>
<p>빌더(builder) 패턴은 주로 생성자를 위하여 적용하는데, 생성자 파라미터 조합이 복잡하거나, 파라미터 타입이 많이 겹쳐서 원하는 파라미터 조합으로 생성자 오버로딩이 잘 안 되는 상황 등, 여러 필드에 대해 생성자를 관리하는 것이 어려울 때 매우 유용한 패턴입니다.</p>
<p>특히 위 예시처럼 생성자의 세 파라미터가 모두 String 타입이라면, 첫 번째 파라미터로 넣은 문자열이 username인지 password인지 nickname인지 타입으로는 헷갈릴 수 있습니다. 젊은 스타일을 띠는 언어들은 파라미터의 이름을 지정해서 대입하는 기능을 제공하지만, 자바는 언어 수준에서는 아직 그런 기능이 없습니다. 대신 롬복의 빌더를 통해 파라미터의 이름을 통한 대입을 지원함으로써, 문제를 해결할 수 있죠. 빌더의 사용은 다음과 같습니다.</p>
<ul>
<li><p>우선 클래스의 이름 뒤에 static 메서드인 <code>.builder()</code>를 통해 빌더를 호출할 수 있습니다.<br />  (<code>@Builder</code> 애노테이션이 자동으로 생성하는 함수 이름의 기본값입니다.)</p>
</li>
<li><p>빌더가 호출되면, 그 뒤에 메서드 체이닝(메서드 뒤에 점을 찍어서 연결)을 통해 파라미터 이름으로 대입용 함수를 나열할 수 있습니다. (아래 사용례 확인)</p>
</li>
<li><p>마지막에 빌더로부터 최종적으로 객체를 생성할 때는 <code>.build()</code> 함수를 사용합니다.</p>
</li>
</ul>
<p>이처럼 빌더의 사용은 <code>.builder()</code>로 시작해서 <code>.build()</code>로 끝난다고 기억하면 쉽습니다.</p>
<pre><code class="lang-java">SignUpRequest dto = SignUpRequest.builder()
        <span class="hljs-comment">// 세 파라미터를 채우는 메서드는 사용 순서가 바뀌어도 됩니다.</span>
        .username(<span class="hljs-string">"abc123"</span>)
        .password(<span class="hljs-string">"abc123!@"</span>)
        .nickname(<span class="hljs-string">"고길동"</span>)
        .build();
</code></pre>
<h3 id="heading-validation">유효성 관리(Validation)</h3>
<blockquote>
<p>프론트엔드에서의 유효성 체크는 보안보다 사용자의 편의성과 관련이 있습니다.<br />유효성 체크는 백엔드에서도 반드시 수행해야 합니다.</p>
</blockquote>
<p>예를 들어 비밀번호는 영문, 숫자, 특수문자를 모두 포함하여 8자리 이상이어야 한다는 문구를 보신 적이 있을 겁니다. 이처럼 데이터의 입력 양식에 대해서 체크하는 것을 유효성 체크라고 합니다. 그렇다면 올바른 데이터 입력 양식을 위한 유효성 체크는 프론트엔드와 백엔드 중 어느 곳에서 수행해야 할까요?</p>
<p>유효성과 관련한 안내를 주로 사용자 화면에서 접했습니다. 그러다 보니 유효성 체크를 프론트엔드에서 관리한다고 생각하는 분들이 종종 계십니다. 물론 화면에서 안내되지 않는다면 사용자는 올바른 데이터 입력을 할 수 없기 때문에, 사용자의 편의성을 위해서 반드시 필요한 기능이기는 합니다. 하지만 보안을 위해서 작성한다는 취지는 논쟁의 쟁점이 될 수 있습니다. 클라이언트에서 보내는 요청 데이터는, 유효성 체크를 하는 프론트엔드의 로직을 모두 건너뛰고 개발자도구만 켜도 쉽게 변경해서 서버로 보낼 수 있기 때문입니다.</p>
<p>프론트엔드와 백엔드에서 유효성 체크의 실질적인 역할은 다음과 같습니다.</p>
<ul>
<li><p><strong>프론트엔드의 유효성 체크:</strong> 사용자 <mark>편의성</mark></p>
</li>
<li><p><strong>백엔드의 유효성 체크:</strong> 사용자 입력에 대한 보안 (각 데이터에 대한 정책 관리)</p>
</li>
</ul>
<p><strong>Java Bean Validation API와 Hibernate Validator</strong></p>
<p>우리는 유효성(validation) 체크를 위해 다음 의존성 라이브러리를 추가했습니다. (build.gradle.kts)</p>
<pre><code class="lang-kotlin">dependencies {
    implementation(<span class="hljs-string">"org.springframework.boot:spring-boot-starter-validation"</span>)
}
</code></pre>
<p>이 라이브러리가 추가되어 있다면 다음처럼 쉽게 기본적인 유효성 체크를 수행할 수 있습니다.</p>
<pre><code class="lang-java">
<span class="hljs-keyword">import</span> jakarta.validation.constraints.NotBlank;
<span class="hljs-keyword">import</span> jakarta.validation.constraints.Pattern;
<span class="hljs-keyword">import</span> lombok.Builder;

<span class="hljs-keyword">public</span> <span class="hljs-keyword">final</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AuthDto</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-title">AuthDto</span><span class="hljs-params">()</span> </span>{}

    <span class="hljs-meta">@Builder</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignUpRequest</span><span class="hljs-params">(
            <span class="hljs-meta">@NotBlank</span>
            <span class="hljs-meta">@Pattern(regexp = "^[a-z]+[a-z0-9]{3,30}$")</span>
            String username,

            <span class="hljs-meta">@NotBlank</span>
            <span class="hljs-meta">@Pattern(regexp = "(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&amp;])[A-Za-z\d@$!%*#?&amp;]{8,100}$")</span>
            String password,

            <span class="hljs-meta">@NotBlank</span>
            <span class="hljs-meta">@Pattern(regexp = "^[A-Za-z0-9ㄱ-ㅣ가-힣]{3,30}$")</span>
            String nickname
    )</span> </span>{}

    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignInRequest</span><span class="hljs-params">(<span class="hljs-comment">/* data */</span>)</span> </span>{}

    <span class="hljs-comment">// responses</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignUpResponse</span><span class="hljs-params">(<span class="hljs-comment">/* data */</span>)</span> </span>{}

    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignInResponse</span><span class="hljs-params">(<span class="hljs-comment">/* data */</span>)</span> </span>{}
}
</code></pre>
<p>주요 애노테이션에 대한 설명입니다. 필수 입력 항목(required item)을 체크하는 애노테이션이 없다면 다른 애노테이션은 null 등에 대해서 체크를 건너뛰기 때문에, 필수 입력 항목인 경우 관련 애노테이션을 함께 써야 합니다.</p>
<pre><code class="lang-java"><span class="hljs-comment">// 필수 입력 항목(required item)임을 표현하는 애노테이션</span>
<span class="hljs-meta">@NotNull</span> <span class="hljs-comment">// null이어선 안 됨. (required item)</span>
<span class="hljs-meta">@NotEmpty</span> <span class="hljs-comment">// null, 빈 배열 또는 빈 문자열("")이어선 안 됨.</span>
<span class="hljs-meta">@NotBlank</span> <span class="hljs-comment">// null, 빈 문자열, 공백 문자열(" ")이어선 안 됨.</span>

<span class="hljs-comment">// 정규 표현식 (필수 입력 항목인 경우 위 애노테이션들과 함께 사용)</span>
<span class="hljs-meta">@Pattern(regexp = "^[A-Za-z0-9ㄱ-ㅣ가-힣]{3,30}$")</span>

<span class="hljs-comment">// 정수 또는 big decimal의 boundary (inclusive)</span>
<span class="hljs-meta">@Min(0)</span>
<span class="hljs-meta">@Max(100)</span>
<span class="hljs-meta">@DecimalMin("0")</span> <span class="hljs-comment">// Long.MIN_VALUE보다 작은 범위도 지원</span>
<span class="hljs-meta">@DecimalMax(" ... ")</span> <span class="hljs-comment">// Long.MAX_VALUE보다 작은 범위도 지원</span>
<span class="hljs-meta">@Digits(integer = 10, fraction = 2)</span> <span class="hljs-comment">// 십진수에서 자릿수</span>
<span class="hljs-comment">// Digits의 주요 속성: 정수부 자릿수(integer), 소숫점 이하 자릿수(fraction)</span>

<span class="hljs-comment">// 길이</span>
<span class="hljs-meta">@Size(min = 0, max = 150)</span> <span class="hljs-comment">// 컬렉션, 맵, 배열, 문자열</span>
<span class="hljs-meta">@Length(min = 2, max = 30)</span> <span class="hljs-comment">// 문자열. (@Pattern에 통합 가능, @Size로 대체 가능)</span>

<span class="hljs-comment">// 날짜 (현재와 비교)</span>
<span class="hljs-meta">@Future</span>
<span class="hljs-meta">@FutureOrPresent</span> <span class="hljs-comment">// inclusive</span>
<span class="hljs-meta">@Past</span>
<span class="hljs-meta">@PastOrPresent</span> <span class="hljs-comment">// inclusive</span>
<span class="hljs-comment">// ex:</span>
<span class="hljs-comment">// @Future LocalDate date; // 내일부터</span>
<span class="hljs-comment">// @FutureOrPresent LocalDate date; // 오늘부터</span>

<span class="hljs-comment">// * 다른 조건에서 일시를 비교하기 위해서는 record의 compact 생성자 활용</span>

<span class="hljs-comment">// 이메일 (로컬@도메인 각 파트에서 로컬 최대 64글자, @ 1글자, 도메인 파트 최대 255글자)</span>
<span class="hljs-meta">@Email</span>
<span class="hljs-comment">// ex:</span>
<span class="hljs-comment">//  @Email @NotBlank @Size(max = 255) String email;</span>

<span class="hljs-comment">/* 표준에서는 이메일에: 로컬 + @ + 도메인을 합쳐 최대 320글자(RFC)
 *  서비스 운영상으로는 최대 255자 권장 (255를 넘으면 다른 이메일 사용 권장)
 *  점은 연속 두 개 이상 놓일 수 없음(.. 등)
 *  그 외 허용 특수 문자: ! # $ % &amp; ' * + - / = ? ^ _ ` { | } ~
 *  도메인 파트도 각 label은 1~63글자 (점으로 구분된 파트)
 *  이 애노테이션 처리에서는 이메일 주소에 주석(소괄호)을 사용할 수 없음.
 *  퓨니코드(한글 등의 이메일 계정, 도메인) 허용됨.
 */</span>
</code></pre>
<p>이 외에도 다양한 애노테이션이 있지만, 활용도가 높은 애노테이션은 위와 같습니다.</p>
<p>참고로 첫 설명이기 때문에 가독성을 위해 생략했지만, 위 애노테이션들은 message 속성과 함께 쓸 수 있습니다.</p>
<pre><code class="lang-json">@NotBlank(message = <span class="hljs-string">"사용자 이름을 입력하세요."</span>)
@Pattern(
        regexp = <span class="hljs-string">"^[a-z]+[a-z0-9]{3,30}$"</span>,
        message = <span class="hljs-string">"아이디는 알파벳 소문자와 숫자만 허용하며, 소문자로 시작해야 합니다. (3~30 글자)"</span>
)
</code></pre>
<h2 id="heading-dto-2">응답 DTO</h2>
<p>응답 DTO에서는 각 데이터의 유효성을 다시 체크하지 않아도 됩니다. 정상적인 시스템을 구성하기 위해 서비스 수준에서 이미 자신이 담당하는 서버 측 자원을 올바르게 관리하도록 구성하고, DTO는 서버에서 제공하는 것을 신뢰하는 것이, 자원 관리의 일원화에 도움이 되는 구조입니다.</p>
<p>또는 서비스나 그 인근의 레이어에서 정책에 관한 기능을 제공해 주고, 이를 응답 DTO에서 확인하거나, 데이터 누락을 방지하기 위해서 롬복의 <code>@NonNull</code> 또는 <code>Objects.requireNonNull()</code> 등을 사용할 수 있습니다. 중요한 것은 유효성의 '결정 권한'을 응답 DTO에서 생성하지 않는 것입니다. 필수 입력 정도 체크는 할 수 있습니다.</p>
<p>다음은 유효성 체크를 하지 않는 응답 DTO를 작성한 코드입니다.</p>
<pre><code class="lang-java">
<span class="hljs-keyword">import</span> com.fasterxml.jackson.annotation.JsonInclude;
<span class="hljs-keyword">import</span> jakarta.validation.constraints.NotBlank;
<span class="hljs-keyword">import</span> jakarta.validation.constraints.Pattern;
<span class="hljs-keyword">import</span> lombok.Builder;

<span class="hljs-keyword">import</span> java.time.Instant;

<span class="hljs-keyword">public</span> <span class="hljs-keyword">final</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AuthDto</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-title">AuthDto</span><span class="hljs-params">()</span> </span>{}

    <span class="hljs-meta">@Builder</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignUpRequest</span><span class="hljs-params">(
            <span class="hljs-meta">@NotBlank</span>
            <span class="hljs-meta">@Pattern(regexp = "^[a-z]+[a-z0-9]{3,30}$")</span>
            String username,

            <span class="hljs-meta">@NotBlank</span>
            <span class="hljs-meta">@Pattern(regexp = "(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&amp;])[A-Za-z\d@$!%*#?&amp;]{8,100}$")</span>
            String password,

            <span class="hljs-meta">@NotBlank</span>
            <span class="hljs-meta">@Pattern(regexp = "^[A-Za-z0-9ㄱ-ㅣ가-힣]{3,30}$")</span>
            String nickname
    )</span> </span>{}

    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignInRequest</span><span class="hljs-params">(<span class="hljs-comment">/* data */</span>)</span> </span>{}

    <span class="hljs-comment">// responses</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignUpResponse</span><span class="hljs-params">(
            <span class="hljs-meta">@JsonInclude(Include.NON_NULL)</span>
            String username,
            <span class="hljs-meta">@JsonInclude(Include.NON_NULL)</span>
            String nickname,
            <span class="hljs-meta">@JsonInclude(Include.NON_NULL)</span>
            Instant createdAt,
            <span class="hljs-meta">@JsonInclude(Include.NON_NULL)</span>
            AccountStatus status
    )</span> </span>{}

    <span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">SignInResponse</span><span class="hljs-params">(<span class="hljs-comment">/* data */</span>)</span> </span>{}
}
</code></pre>
<p>스프링에서 JSON 객체를 만들거나 해석할 때 기본적으로 Jackson이라는 라이브러리를 사용합니다. 이런 기능을 가진 것들을 메시지 컨버터라고도 부릅니다. 설정을 바꾸지 않는 한 기본 메시지 컨버터는 Jackson입니다. 메시지 컨버터는 자바 객체를 JSON 문자열로, JSON 문자열을 자바 객체로 바꾸어 클라이언트와 주고 받습니다. 이 과정은 자동으로 수행됩니다.</p>
<p>JSON이란, 문자열로 데이터를 표현하는 방식 중 하나로 자바스크립트 객체의 작성 요령 중 한 가지를 정해서 양식화한 것입니다. 다음은 JSON 문자열의 예시입니다. 양쪽 끝 중괄호를 포함합니다.</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"username"</span>: <span class="hljs-string">"abc123"</span>,
  <span class="hljs-attr">"nickname"</span>: <span class="hljs-string">"고길동"</span>,
  <span class="hljs-attr">"status"</span>: <span class="hljs-string">"active"</span>
}
</code></pre>
<p><strong>자바스크립트의 null과 undefined</strong></p>
<p>최근 프론트엔드 쪽에서는 타입스크립트를 통한 타입 제약 등 여러 이유로 JSON 반환에서 null 데이터 대신 그 필드를 완전히 JSON 문자열에 포함하지 않는 것을 선호하고 있습니다. Jackson에서는 이를 위한 기능으로 <code>@JsonInclude(Include.NON_NULL)</code> 애노테이션을 제공하고 있습니다. 프론트엔드에서 이 필드에 접근하면 null 대신 undefined를 반환받음으로써, 관리의 복잡성을 줄일 수 있습니다.</p>
<h1 id="heading-dto-3">DTO를 사용한 컨트롤러 메서드</h1>
<p>이제 사용자와 주고 받을 DTO와, 작업을 수행할 서비스를 모두 준비했으니, 컨트롤러에 API 메서드를 만들 수 있습니다.</p>
<p>다음 예시에서는 서비스 빈 사용에 필요한 데이터 변환을 위해 빌더를 사용한 부분이 조금 길어 보일 뿐, 아주 간단한 3 스텝으로 구성된 메서드입니다. 리팩토링을 하기 전까지는 이 코드를 먼저 이해해 봅시다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">import</span> com.example.demo.auth.controller.dto.AuthDto.SignUpRequest;
<span class="hljs-keyword">import</span> com.example.demo.auth.controller.dto.AuthDto.SignUpResponse;
<span class="hljs-keyword">import</span> com.example.demo.auth.domain.Account;
<span class="hljs-keyword">import</span> com.example.demo.auth.domain.AccountStatus;
<span class="hljs-keyword">import</span> com.example.demo.auth.usecase.SignUpUseCase;
<span class="hljs-keyword">import</span> jakarta.validation.Valid;
<span class="hljs-keyword">import</span> lombok.RequiredArgsConstructor;
<span class="hljs-keyword">import</span> org.springframework.http.HttpStatus;
<span class="hljs-keyword">import</span> org.springframework.web.bind.annotation.PostMapping;
<span class="hljs-keyword">import</span> org.springframework.web.bind.annotation.RequestBody;
<span class="hljs-keyword">import</span> org.springframework.web.bind.annotation.ResponseStatus;
<span class="hljs-keyword">import</span> org.springframework.web.bind.annotation.RestController;

<span class="hljs-keyword">import</span> java.time.Instant;

<span class="hljs-meta">@RestController</span>
<span class="hljs-meta">@RequiredArgsConstructor</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AuthApi</span> </span>{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> SignUpUseCase signUpUseCase;

    <span class="hljs-meta">@PostMapping("/sign-up")</span>
    <span class="hljs-meta">@ResponseStatus(HttpStatus.CREATED)</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> SignUpResponse <span class="hljs-title">signUp</span><span class="hljs-params">(<span class="hljs-meta">@RequestBody</span> <span class="hljs-meta">@Valid</span> SignUpRequest body)</span> </span>{
        <span class="hljs-comment">// [1] DTO를 Entity로 변환</span>
        Account account = Account.builder()
                .username(body.username())
                .nickname(body.nickname())
                .password(body.password())
                .status(AccountStatus.ACTIVE)
                .createdAt(Instant.now())
                .build();

        <span class="hljs-comment">// [2] 서비스에게 회원가입의 구체적인 동작을 위임.</span>
        Account savedAccount = signUpUseCase.signUp(account);

        <span class="hljs-comment">// [3] 결과 반환</span>
        <span class="hljs-keyword">return</span> SignUpResponse.builder()
                .username(savedAccount.getUsername())
                .nickname(savedAccount.getNickname())
                .createdAt(savedAccount.getCreatedAt())
                .status(savedAccount.getStatus())
                .build();
    }
}
</code></pre>
<p><strong>PostMapping</strong></p>
<p>우리 서버에 있는 이 함수로 요청을 보내려면 HTTP method와 요청할 경로가 필요합니다. 저 함수로 도착하기 위해서는 이런 정보가 필요합니다.</p>
<ul>
<li><p><strong>host</strong>: localhost (우리 단말기를 뜻하는 표현)</p>
</li>
<li><p><strong>port</strong>: 8080 (스프링부트 애플리케이션을 실행할 때 기본값. 즉 내장 서버의 기본값)</p>
</li>
<li><p><strong>path</strong>: /sign-up</p>
</li>
<li><p><strong>HTTP 메서드</strong>: POST</p>
</li>
</ul>
<p>스프링에서는 이것을 표현하기 위해서 RequestMapping이라는 것을 사용할 수도 있지만, 최근에는 좀 더 편하게 <code>@GetMapping</code>, <code>@PostMapping</code> 등을 사용할 수 있습니다. HTTP 메서드에 따라서, GET, POST, PUT, PATCH, DELETE 등을 위한 애노테이션이 있습니다.</p>
<p>호스트와 포트를 포함하면, 위 함수에 요청을 보내기 위해서는 다음 경로와 메서드로 보낼 수 있습니다.</p>
<ul>
<li><p>URL: <code>http://localhost:8080/sign-up</code></p>
</li>
<li><p>HTTP 메서드: <code>POST</code></p>
</li>
</ul>
<p><strong>응답 상태코드(HTTP 상태 코드 중 응답에 쓰이는 것들)</strong></p>
<ul>
<li><p>200번대: 정상 응답 (200 OK, 201 Created, 204 No Content 등)</p>
</li>
<li><p>400번대: 클라이언트 측에서 핸들링할 수 있는 오류 응답</p>
</li>
<li><p>500번대: 서버 사이드의 오류에 대한 응답</p>
</li>
</ul>
<p>HTTP 요청에 대한 응답은 크게 위와 같이 분류합니다. 스프링에서 응답 상태코드를 작성할 때, 다음과 같은 수단을 사용하는 편입니다.</p>
<ul>
<li><p>ResponseEntity 객체를 통해 상태 코드를 직접 결정합니다. (아직 다루지 않습니다.)</p>
</li>
<li><p><code>@ResponseStatus(HttpStatus.상태코드_선택)</code> 애노테이션을 통해 미리 지정합니다.</p>
</li>
</ul>
<p>저는 이중에서 정상 응답 코드는 <code>@ResponseStatus</code> 애노테이션을 통해 지정하는 것을 선호합니다.</p>
<h2 id="heading-api">API 쏴 보기</h2>
<p>Postman을 열고 Collection 생성(마음대로 정렬 안 됨) &gt; Folder 생성(정렬 가능) &gt; Request 생성 후, 다음 내용대로 작성합니다. (컬렉션, 폴더, 요청 이름은 마음대로 작성해도 됩니다.)</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720945076072/6c59ea34-cf05-4ecd-9272-76b7a9108e66.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720945176180/83f3f503-2f82-4886-922b-94ada8784135.png" alt class="image--center mx-auto" /></p>
<ul>
<li><p>HTTP Method는 POST입니다.</p>
</li>
<li><p>URL은 <code>http://localhost:8080/sign-up</code>으로 합니다.</p>
</li>
<li><p>요청 body는 body &gt; raw &gt; json을 선택하여 JSON 문자열로 된 바디를 작성합니다.</p>
<pre><code class="lang-json">  {
      <span class="hljs-attr">"username"</span>: <span class="hljs-string">"abc123"</span>,
      <span class="hljs-attr">"password"</span>: <span class="hljs-string">"abc123!@"</span>,
      <span class="hljs-attr">"nickname"</span>: <span class="hljs-string">"고길동"</span>
  }
</code></pre>
<ul>
<li><p>사용자 이름(아이디)은 알파벳 소문자와 숫자를 조합하여 3~30글자이며, 소문자로 시작해야 합니다.</p>
</li>
<li><p>비밀번호는 영문, 숫자, 특수문자를 모두 사용하여 8~100글자로 입력하세요.</p>
</li>
<li><p>닉네임은 영문, 숫자, 한글로 3~30글자입니다.</p>
</li>
</ul>
</li>
</ul>
<p><strong>응답 확인</strong></p>
<p>Send 버튼을 누르고 하단에 응답을 확인할 수 있습니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720945509431/3c3ccab4-6126-4e30-bf53-f21fadb9f3a9.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-enum">Enum 열거 상수를 소문자로 응답하기</h2>
<p>이 부분은 의무적인 것은 아닙니다. 프론트엔드와 논의해서 대문자로 된 열거 상수를 사용하자고 하여도 되고, 소문자로 통일해도 됩니다. 하지만 자바에서는 열거 상수 네이밍에 대문자를 그대로 사용하겠죠.</p>
<pre><code class="lang-java"><span class="hljs-keyword">import</span> com.fasterxml.jackson.annotation.JsonProperty;

<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">enum</span> <span class="hljs-title">AccountStatus</span> </span>{
    <span class="hljs-meta">@JsonProperty("pending")</span>
    PENDING,
    <span class="hljs-meta">@JsonProperty("active")</span>
    ACTIVE,
    <span class="hljs-meta">@JsonProperty("protected")</span>
    PROTECTED,
    <span class="hljs-meta">@JsonProperty("suspended")</span>
    SUSPENDED,
    <span class="hljs-meta">@JsonProperty("slept")</span>
    SLEPT,
    <span class="hljs-meta">@JsonProperty("removed")</span>
    REMOVED
}
</code></pre>
<p>이제 API를 다시 이용하면 소문자로 된 status 필드 값을 확인할 수 있습니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720956940682/5b73da7e-904e-4696-84e1-405650edf43a.png" alt class="image--center mx-auto" /></p>
<p>enum이 아니더라도 다음 예시처럼 활용할 수 있습니다. 예를 들어 외부와 데이터를 주고 받는 기술에서 특정 필드의 표준 네이밍이 snake_case로 되어 있다면, 자바에서 camelCase 등 관례를 유지하면서 JSON 문자열에서만 필드 이름을 변경할 수 있습니다. (앞서 enum은 value에서 네이밍 변경, 이번에 사용하는 예시는 key에서 네이밍 변경으로 볼 수 있습니다.)</p>
<pre><code class="lang-java"><span class="hljs-comment">// 예시</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> record <span class="hljs-title">ExampleResponse</span><span class="hljs-params">(
        <span class="hljs-meta">@JsonProperty("access_token")</span>
        String accessToken
)</span> </span>{}
</code></pre>
<p>이러면 JSON 문자열은 다음과 같습니다. (Jackson 사용 시)</p>
<pre><code class="lang-json">{<span class="hljs-attr">"access_token"</span>: <span class="hljs-string">"..."</span>}
</code></pre>
<hr />
<p><strong>&lt; Prev</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-6-kor">서비스 인터페이스의 세분화와 빈 불러오기</a></p>
<p><strong>Next &gt;</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-8-kor">DTO와 Entity의 Mapping</a></p>
]]></content:encoded></item><item><title><![CDATA[차근차근 Modern Spring Boot 3 기초 (6) 서비스 인터페이스의 세분화와 빈 불러오기]]></title><description><![CDATA[참고: 따라치기 위한 코드는 '인증 서비스 코드 작성' 챕터에 있습니다.

서비스 레이어의 DIP(의존성 역전 원칙)

한 레이어에서 다른 레이어를 이용할 때 자주 적용하는 규칙이 있습니다. 우리 서버 애플리케이션에서는 주로 서비스 레이어를 이용할 때 기본적으로 적용하는 설계 원칙인 '의존성 역전 원칙(DIP)'입니다. 사실 다른 레이어(persistence 등)에도 적용하지만, 설명하고 이해하는 데에는 서비스 레이어만 한 게 없죠.
의존성 역...]]></description><link>https://blog.letsdev.me/step-by-step-springboot3-6-kor</link><guid isPermaLink="true">https://blog.letsdev.me/step-by-step-springboot3-6-kor</guid><category><![CDATA[Java]]></category><category><![CDATA[Springboot]]></category><category><![CDATA[Modern Java]]></category><category><![CDATA[JDK21]]></category><category><![CDATA[Usecase]]></category><category><![CDATA[SpringBoot3]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Wed, 10 Jul 2024 14:59:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1720592453323/c1466ab3-af66-4939-a360-25d22943f19b.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>참고: 따라치기 위한 코드는 '인증 서비스 코드 작성' 챕터에 있습니다.</p>
</blockquote>
<h1 id="heading-dip">서비스 레이어의 DIP(의존성 역전 원칙)</h1>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720623373879/36d1fd46-8441-471c-a024-49cb38f075f7.png" alt class="image--center mx-auto" /></p>
<p>한 레이어에서 다른 레이어를 이용할 때 자주 적용하는 규칙이 있습니다. 우리 서버 애플리케이션에서는 주로 서비스 레이어를 이용할 때 기본적으로 적용하는 설계 원칙인 '의존성 역전 원칙(DIP)'입니다. 사실 다른 레이어(persistence 등)에도 적용하지만, 설명하고 이해하는 데에는 서비스 레이어만 한 게 없죠.</p>
<p>의존성 역전 원칙을 쉽게 말하면, 구현되어 있는 클래스보다는 추상적인 인터페이스에 의존하는 것이 더 바람직하다고 보는 원칙입니다. 이때 의존이라는 표현을 일상적인 용어로 바꾸면, 사용한다는 표현과 그 의미가 거의 같습니다. 마치 광부가 곡괭이에 의존한다는 말이, 광부가 곡괭이를 사용하고 있다는 의미도 담듯이 말이죠. 또 곡괭이가 전혀 다르게 바뀐다면 광부의 작업 방식이나 성과에도 영향을 줄 수 있기에, 의존한다는 표현이 어떤 문맥에서 성립하는 것인지 알 수 있죠.</p>
<p><strong>그림: 설계 원칙이 없을 때 생길 수 있는 각기 다른 구현 이슈</strong></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720625225329/44e9c2af-f878-4246-9e81-6d9b646f533b.png" alt class="image--center mx-auto" /></p>
<p>설계 원칙의 발상은, 이 의존과도 밀접할 때가 많습니다. 광부가 곡괭이에 대해서 너무 과도한 의존성을 갖는다면 어떨까요? 만약 곡괭이를 바꾸어야 할 때 광부가 일을 전혀 못하게 된다면 회사는 전보다 나은 방향을 생각하게 될 것입니다.</p>
<p>예를 들어, 곡괭이에 대해서 '스탠다드'를 정해 주는 것이죠. 앞으로 광부가 받는 모든 곡괭이는 규격화된 제품으로, 광부들은 여러 곡괭이에 적응할 필요 없이 규격화된 곡괭이를 계속 사용할 수 있을 것입니다.</p>
<p>이러한 규칙을 일반화한 것이 의존성 역전 원칙입니다. 각 레이어는 다른 레이어를 사용할 때 인터페이스 타입을 사용하도록 하고, 인터페이스는 규격화된 인풋, 아웃풋을 갖는 메서드를 보장합니다. 그리고 실제 메서드의 동작은, 그 인터페이스를 구현한 클래스에서 제공하는 것이죠. 구현 클래스 내부에서 일어나는 일은 외부에서는 하나도 궁금하지 않습니다! 그저 이 메서드를 사용할 때, 약속한 인풋을 넣으면, 약속한 아웃풋을 준다는 것이 중요하죠. 이것이 우리가 자바를 다루면서 인터페이스를 중요시하는 이유입니다!</p>
<p><strong>의존성 역전 원칙을 사용하는 예 (1) 서비스 인터페이스를 타입으로 사용하기</strong></p>
<p>예를 들어 다음처럼 서비스 인터페이스와 그것을 구현한 클래스가 있다고 합시다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">ExampleService</span> </span>{
    <span class="hljs-comment">// 인터페이스의 메서드는 인풋과 아웃풋을 보장합니다.</span>
    <span class="hljs-function">SomeData <span class="hljs-title">findById</span><span class="hljs-params">(String id)</span></span>;
}

<span class="hljs-meta">@Service</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">DefaultExampleService</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">ExampleService</span> </span>{
    <span class="hljs-meta">@Override</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> SomeData <span class="hljs-title">findById</span><span class="hljs-params">(String id)</span> </span>{
        <span class="hljs-comment">// ... (이번 설명에서 구현된 내용은 중요하지 않습니다.)</span>
        <span class="hljs-keyword">return</span> result;
    }
}
</code></pre>
<p>하나의 인터페이스와 그것을 구현한 하나의 클래스죠. 이제 앞서 말한 의존성 역전 원칙대로라면, 여기서 인터페이스와 클래스 중 어떤 것을 다른 레이어에서 사용해야 할까요?</p>
<pre><code class="lang-java"><span class="hljs-comment">// 1번: 필드의 타입으로 인터페이스를 사용한다.</span>
<span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> ExampleService exampleService;
<span class="hljs-comment">// 2번: 필드의 타입으로 클래스를 사용한다.</span>
<span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> DefaultExampleService exampleService;
</code></pre>
<p>추상적인 타입인 1번 인터페이스를 사용하는 것이 맞습니다! 만약 구현 클래스가 다른 클래스로 바뀌면, 2번 방식을 사용하고 있을 때는 위 코드에 수정이 필요하지만, 1번 방식을 사용하고 있었다면 주입하는 객체(빈) 외에는 수정하지 않아도 되기 때문입니다.</p>
<p>게다가 빈을 주입하는 부분도 우리가 아니라 스프링이 알아서 관리해 준다는 것을 앞서 설명했죠. 코드로 간단히 살펴 봐야겠습니다.</p>
<h2 id="heading-7iqk7zse66eb7jeq7iscioydmoyhtoyessdso7zsnoxsnzgg7jes65siouwqeylnq">스프링에서 의존성 주입의 여러 방식</h2>
<p>스프링에는 의존성을 주입하는 세 방식이 있습니다. 그중 레거시 프로젝트에서 많이 쓰이는 방식이 있고, 요즘 더 권장하는 방식이 있습니다. 우리는 그중 요즘 더 권장되는 방식을 사용하려고 합니다. 주입하는 객체는 방금 만든 서비스를 예시로 하겠습니다.</p>
<p>주입의 세 방식은 다음과 같습니다.</p>
<ul>
<li><p>필드를 통한 주입</p>
</li>
<li><p>Setter를 통한 주입 (또는 기타 메서드를 통한 주입)</p>
</li>
<li><p>생성자를 통한 주입</p>
</li>
</ul>
<p>이걸 모두 살펴보면 헷갈릴 수 있으니, 레거시 진영에서 많이 사용하는 '필드를 통한 주입'과, 최근 가장 권장되고 있는 '생성자를 통한 주입'만 살펴 보겠습니다.</p>
<h3 id="heading-7zwe65ocioyjvoyehq">필드 주입</h3>
<p>필드를 선언하고 이 위에 <code>@Autowired</code>라는 애노테이션을 붙이는 방식입니다. 간단한 방식이고 이해하기 쉽기 때문에 레거시 진영에서 많이 사용하는 방식입니다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@RestController</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">final</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ExampleApi</span> </span>{
    <span class="hljs-meta">@Autowired</span>
    <span class="hljs-keyword">private</span> ExampleService exampleService;
}
</code></pre>
<p>이제 exampleService에 무언가를 대입하는 코드 없이 exampleService 객체를 사용할 수 있습니다.</p>
<p>하지만 이 방식은 다음과 같은 단점이 존재합니다. 다음 단점을 지금 모두 이해할 필요는 없습니다.</p>
<ul>
<li><p>필드를 final로 선언하면 안 됩니다.</p>
</li>
<li><p>애노테이션을 처리하고 객체를 채우는 동작을 DI(의존성 주입) 프레임워크에 완전히 의존합니다.</p>
<ul>
<li>따라서 프레임워크에 완전히 종속되는 방식입니다.</li>
</ul>
</li>
<li><p>테스트 코드를 작성할 때 필드에 목업(mockup) 객체를 넣는 과정이 더 복잡합니다.</p>
</li>
</ul>
<h3 id="heading-7iod7isx7j6qioyjvoyehq">생성자 주입</h3>
<p>생성자를 통한 주입입니다. 스프링에서는 빈을 생성할 때, 다른 빈이 필요하다면 생성자의 파라미터에서 그 빈을 사용할 수 있도록 자동으로 주입해 줍니다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@RestController</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">final</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ExampleApi</span> </span>{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> ExampleService exampleService;

    <span class="hljs-comment">// (1) 생성자의 파라미터에 필요한 빈을 선언합니다. 그러면 그 자리에 빈이 옵니다.</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">ExampleApi</span><span class="hljs-params">(ExampleService exampleService)</span> </span>{
        <span class="hljs-comment">// (2) 받은 빈을 우리 필드에 옮겨 줍니다. (this.a = a 구조)</span>
        <span class="hljs-keyword">this</span>.exampleService = exampleService;
    }
}
</code></pre>
<ul>
<li><p>필드를 final로 사용할 수 있습니다.</p>
</li>
<li><p>애플리케이션을 구동하는 초기에 생성자 주입을 통해 빈 이용에 문제가 없는지 미리 체크됩니다.</p>
</li>
<li><p>생성자를 사용할 수 있으면 되기 때문에, DI(의존성 주입) 프레임워크에 완전히 종속되지 않습니다.</p>
</li>
<li><p>생성자를 사용할 수 있으면 되기 때문에, 테스트 코드 작성 시 목업 객체를 주입하기 쉽습니다.</p>
</li>
</ul>
<h1 id="heading-7isc67me7iqkio2btouemoykpoyxkoyencdroijtj6zsp4dthldrpqwg7iks7jqp7zwy6riw">서비스 클래스에서 레포지터리 사용하기</h1>
<h2 id="heading-dip-1">서비스 클래스의 DIP 네이밍</h2>
<p>서비스 레이어는 인터페이스와 그것을 구현한 클래스로 구성된다고 설명했습니다. 그렇다면, 그 역할이 겹치는 인터페이스와 클래스는 각각 어떻게 명명할 수 있을까요?</p>
<h3 id="heading-impl">가장 보편적인 레거시 네이밍(-Impl 접미사)</h3>
<p>기존에 가장 많이 사용되어 온 방식은, 기능상에서의 역할뿐 아니라 구조상에서의 위치까지 네이밍으로 표현하는 방식이었습니다.</p>
<p>인터페이스는 역할만 표현하는 반면, 그것을 구현한 클래스는 '구현했다'라는 의미까지 담았죠.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">ExampleSerivce</span> </span>{ }

<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ExampleServiceImpl</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">ExampleService</span> </span>{}
</code></pre>
<p>하지만 엄밀히 <code>ExampleService</code> 객체라는 것은 말이 되어도, <code>ExampleServiceImpl</code> 객체라는 분류는 말이 안 되는 표현이기 때문에, '사용자'가 아닌 '개발자'의 관점에만 맞는 네이밍이라고 볼 수 있습니다.</p>
<p>역할에 맞는 이름이 아니라, 작업용 이름인 것이죠. 그래서 네이밍에 변화를 주는 사람들은 다음과 같이 이름을 바꾸어 사용했습니다.</p>
<h3 id="heading-default">의미가 어색하지 않도록 개선된 네이밍(Default- 접두사)</h3>
<p>특별히 의미를 담는 단어를 붙일 수 있다면 앞에 그 단어를 붙일 수 있습니다. 하지만 특별히 붙일 단어가 없다면, <code>Default-</code> 접두사를 붙여서 명명할 수 있죠.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">ExampleSerivce</span> </span>{ }

<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">DefaultExampleService</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">ExampleService</span> </span>{}
</code></pre>
<p>구조에는 변화가 없지만, 의미가 더 잘 맞습니다. 임시 방편이어야 했던 <code>-Impl</code> 접미사가 어느 순간부터 보편적인 네이밍으로 자리를 잡았습니다. 그것에 어색함을 느끼는 사람들이 대안으로 제시하는 것입니다.</p>
<h3 id="heading-usecase">서비스 인터페이스의 피처 단위 세분화(usecase)</h3>
<p>위 방식들은 네이밍은 다르지만, 결국 동일한 구조에서 작성되었습니다.</p>
<p>하지만 구조 또한 미래지향적인 아키텍처를 택할 수 있습니다. 우리가 이번에 사용할 방식입니다. 서비스 인터페이스를 기능에 따라 세분화하여 <code>-UseCase</code>라고 명명합니다. 그리고 각 컨트롤러는 usecase에 의존하도록 합니다.</p>
<p>이렇게 구현한다면, 그 기능을 담당하는 서비스 클래스가 바뀌어도 기존 코드를 수정할 필요가 없습니다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">SignUpUseCase</span> </span>{<span class="hljs-comment">/* 이곳에 회원가입 함수 선언 */</span>}
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">SignInUseCase</span> </span>{<span class="hljs-comment">/* 이곳에 로그인 함수 선언 */</span>}
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">PasswordResetUseCase</span> </span>{<span class="hljs-comment">/* 이곳에 비밀번호 변경 함수 선언 */</span>}

<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AuthService</span>
        <span class="hljs-keyword">implements</span> <span class="hljs-title">SignUpUseCase</span>,
        <span class="hljs-title">SignInUseCase</span>,
        <span class="hljs-title">PasswordResetUseCase</span> </span>{

    <span class="hljs-comment">// Ctrl + I를 눌러 메서드 구현 시작</span>

}
</code></pre>
<p>현대적인 관점에서는, 언제든 쉽게 떼어내고 쉽게 결합할 수 있는 아키텍처를 더욱 바람직한 아키텍처로 평가합니다. use case로 기능 단위 인터페이스를 만들어 두면, 언제든 쉽게 서비스나 모듈을 교체할 수 있기 때문에, (비록 초기 공수가 더 필요하더라도) 보다 바람직한 아키텍처라고 평가할 수 있습니다.</p>
<p>게다가 작업의 영역이 뚜렷하게 구분되기 때문에 사람의 눈으로 작업하는 데에 훨씬 편한 점도 있습니다.</p>
<h2 id="heading-7j247kadioyenou5hoykpcdsvztrk5wg7j6r7isx">인증 서비스 코드 작성</h2>
<blockquote>
<p>이제 우리가 사용할 코드를 작성할 테니, 잘 이해해 보며 따라 치시면 됩니다.</p>
</blockquote>
<h3 id="heading-signupusecase">SignUpUseCase</h3>
<p>auth 아래에 usecase라는 패키지를 만들어 사용하겠습니다.</p>
<ul>
<li>Package: <code>com.example.demo.auth.usecase</code></li>
</ul>
<pre><code class="lang-java"><span class="hljs-keyword">import</span> com.example.demo.auth.domain.Account;

<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">SignUpUseCase</span> </span>{
    <span class="hljs-function">Account <span class="hljs-title">signUp</span><span class="hljs-params">(Account account)</span></span>;
}
</code></pre>
<h3 id="heading-authservice">AuthService 작성</h3>
<p>생성자를 통해 빈을 주입받는 코드를 포함합니다.</p>
<ul>
<li>Package: <code>com.example.demo.auth.service</code></li>
</ul>
<pre><code class="lang-java"><span class="hljs-keyword">import</span> com.example.demo.auth.domain.Account;
<span class="hljs-keyword">import</span> com.example.demo.auth.repository.AccountRepository;
<span class="hljs-keyword">import</span> com.example.demo.auth.usecase.SignUpUseCase;
<span class="hljs-keyword">import</span> org.springframework.stereotype.Service;

<span class="hljs-meta">@Service</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AuthenticationService</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">SignUpUseCase</span> </span>{

    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> AccountRepository accountRepository;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">AuthenticationService</span><span class="hljs-params">(AccountRepository accountRepository)</span> </span>{
        <span class="hljs-keyword">this</span>.accountRepository = accountRepository;
    }

    <span class="hljs-comment">// Ctrl + i를 눌러 아래 메서드의 틀을 받을 수 있습니다.</span>

    <span class="hljs-meta">@Override</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> Account <span class="hljs-title">signUp</span><span class="hljs-params">(Account account)</span> </span>{
        <span class="hljs-comment">// 레포지터리에 데이터를 전달하고, 결과를 반환해 주면 됩니다.</span>
        <span class="hljs-keyword">return</span> accountRepository.save(account);
    }
}
</code></pre>
<ul>
<li><p>레포지터리는 생성자를 통해 주입받아서 사용했습니다.</p>
</li>
<li><p>레포지터리에서 <code>.save(entity)</code> 함수는 데이터를 삽입하는 역할을 기본으로 합니다.</p>
<ul>
<li>PK인 id가 겹친다면 update문으로 동작하는 특징이 있습니다. (SQL에도 있는 기능입니다.)</li>
</ul>
</li>
</ul>
<p><strong>Lombok 애노테이션 사용 시</strong></p>
<p>생성자를 우리가 직접 타이핑하지 않아도 됩니다. <code>this.a = a</code> 구조의 생성자는 롬복의 애노테이션으로 생성할 수 있습니다. <code>@RequiredArgsConstructor</code> 부분에 집중하시면 됩니다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">import</span> com.example.demo.auth.domain.Account;
<span class="hljs-keyword">import</span> com.example.demo.auth.repository.AccountRepository;
<span class="hljs-keyword">import</span> com.example.demo.auth.usecase.SignUpUseCase;
<span class="hljs-keyword">import</span> lombok.RequiredArgsConstructor;
<span class="hljs-keyword">import</span> org.springframework.stereotype.Service;

<span class="hljs-meta">@Service</span>
<span class="hljs-meta">@RequiredArgsConstructor</span> <span class="hljs-comment">// final, non-null 필드에 대한 생성자</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AuthenticationService</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">SignUpUseCase</span> </span>{

    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> AccountRepository accountRepository;

    <span class="hljs-meta">@Override</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> Account <span class="hljs-title">signUp</span><span class="hljs-params">(Account account)</span> </span>{
        <span class="hljs-comment">// (나중에 다른 작업을 추가합니다.)</span>
        <span class="hljs-keyword">return</span> accountRepository.save(account);
    }
}
</code></pre>
<p>이제 생성자를 직접 작성하지 않아도 되어서 코드가 매우 간결합니다.</p>
<ul>
<li><p>롬복에 의존하는 것을 지양하는 관점도 있습니다.</p>
<ul>
<li>하지만 현대적인 아이디어와 스타일을 갖고 있는 언어들에 비해, 아직 자바는 롬복을 사용해야 편하게 작성 가능한 코드가 많은 편입니다.</li>
</ul>
</li>
<li><p><code>final</code> 선언을 빠뜨리는 실수를 하지 마세요.</p>
</li>
</ul>
<hr />
<p><strong>&lt; Prev</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-5-kor">JPA Entity를 사용하는 JPA Repository</a></p>
<p><strong>Next &gt;</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-7-kor">DTO와 API</a></p>
]]></content:encoded></item><item><title><![CDATA[차근차근 Modern Spring Boot 3 기초 (5) JPA Entity를 사용하는 JPA Repository]]></title><description><![CDATA[JPA Repository 객체를 다루기 위해 알아야 할 것

JPA Repository로 만든 repository 객체들은 스프링에서 bean(빈)이라는 것으로 관리됩니다.

이 개념을 이해하고 설명하기 위해 다음 개념들을 학습해야 합니다.
레이어드 아키텍처(Layered Architecture)

수직적으로 계층을 나누는 프로젝트 아키텍처(프로젝트 구조)입니다. 그중 대표적으로 세 계층에 대하여 알아야 합니다. 우리가 기억해야 할 것은 코드...]]></description><link>https://blog.letsdev.me/step-by-step-springboot3-5-kor</link><guid isPermaLink="true">https://blog.letsdev.me/step-by-step-springboot3-5-kor</guid><category><![CDATA[JPA Repository]]></category><category><![CDATA[JPA Entity]]></category><category><![CDATA[Java]]></category><category><![CDATA[Modern Java]]></category><category><![CDATA[jpa]]></category><category><![CDATA[Spring Data Jpa]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Tue, 09 Jul 2024 15:30:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1720520487919/f0f968c8-2b0b-466b-80fd-6a042911b48b.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-jpa-repository">JPA Repository 객체를 다루기 위해 알아야 할 것</h1>
<ul>
<li>JPA Repository로 만든 repository 객체들은 스프링에서 bean(빈)이라는 것으로 관리됩니다.</li>
</ul>
<p>이 개념을 이해하고 설명하기 위해 다음 개념들을 학습해야 합니다.</p>
<h1 id="heading-layered-architecture">레이어드 아키텍처(Layered Architecture)</h1>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720535191933/78e3f3db-805b-4efe-a7ad-952ff8e60f77.png" alt class="image--center mx-auto" /></p>
<p>수직적으로 계층을 나누는 프로젝트 아키텍처(프로젝트 구조)입니다. 그중 대표적으로 세 계층에 대하여 알아야 합니다. 우리가 기억해야 할 것은 코드에서 직접 명명하여 사용하는 다음 세 명칭입니다.</p>
<ul>
<li><p><strong>⭐️ 컨트롤러</strong>(Controller)</p>
<p>  사용자의 요청을 받아 사용자에게 응답합니다. 마치 은행 창구처럼 사용자와 직접 소통을 주고 받는 계층입니다. 따라서 필요한 처리에 대하여, 다른 계층에 명령을 전달하는 역할을 하며, 계층에서는 지휘관 역할로 이해할 수 있습니다.</p>
<p>  따라서 컨트롤러의 소스 코드는, 마치 영어 문장을 읽듯이 편하게 읽히는 수준에 가깝게 작성하여, 로직 흐름을 쉽게 파악할 수 있도록 하는 것이 좋습니다. 복잡한 처리는 실무자인 서비스 계층으로 넘겨 주는 것이 좋습니다.</p>
</li>
<li><p><strong>⭐️ 서비스</strong>(Service)</p>
<p>  복잡한 처리를 역할별로 담당하는 계층입니다. 역할에 따라 실무자가 따로 존재하는 것이 좋습니다. 프로그래밍에서 고전적으로 함수를 나누어 작성함으로써 코드의 가독성을 높여 왔는데, 그런 함수 묶음을 관리하는 것은 주로 서비스 계층의 역할입니다. (일부 동작은 유틸리티 클래스로 대체 가능)</p>
</li>
<li><p><strong>⭐️ 레포지터리</strong>(Repository)</p>
<p>  서비스 계층에서 관리하는 여러 처리는 주로 프로그램 내부 로직입니다. 프로그램 외부와 소통하기 위한 영역은 따로 구분해 두는 것이 좋습니다.</p>
<p>  외부와 소통하는 로직은, 프로그램 내부 로직의 흐름 중 일부분으로 포함되면서도, 프로그램 외부와 소통하기 위하여 외부에도 종속되기 때문에, 프로그램 내부에서 다룰 땐 독립적 관리가 중요합니다.</p>
<p>  따라서 여러 처리 중에서도 데이터베이스와 연결되는 영역은 레포지터리(repository)라는 빈으로 관리하게 됩니다. (Persistence layer)</p>
</li>
</ul>
<h1 id="heading-ioc-bean">IoC 컨테이너와 Bean</h1>
<p>순수하게 자바를 배우는 학습자는 인스턴스를 생성하기 위해서 <code>new 생성자()</code> 등의 코드로 직접 객체를 생성해야 하며, 이것을 변수 등에 직접 대입하여 사용해야 한다고 알고 있을 것입니다.</p>
<p>하지만 프레임워크에서는 이러한 작업을 개발자가 직접 수행할 필요가 없는 경우가 많습니다. 개발자는 프레임워크가 요구하는 것들을 미리 준비해서 프레임워크에 제공하며, 전체적인 제어는 개발자가 아니라 프레임워크가 수행하는 '제어의 역전(IoC)'이라는 개념 덕분입니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720535111113/32e57842-7a33-430e-a903-6e45d36dc3b4.png" alt class="image--center mx-auto" /></p>
<p>스프링에서도 이 제어의 역전을 담당하는 'IoC 컨테이너'라는 것이 있는데, 이 컨테이너는 제어의 역전과 동시에 몇몇 주요한 개념에 관련하여 핵심 동작을 관리합니다. IoC 컨테이너를 통한 빈(bean)의 관리와 의존성 주입(DI; Dependency Injection) 개념을 짧게 다룹니다.</p>
<p>스프링에서 빈을 쉽게 이해하기 위해서는, 이것이 어떤 의미로는 단순히 객체를 뜻한다는 것을 이해해야 합니다. 빈은 재사용 가능한 컴포넌트 객체인데, 위에서 설명한 컨트롤러, 서비스, 레포지터리 등이 모두 빈으로 등록되는 것들입니다.</p>
<p>특징적인 것은 우리가 new 키워드 등을 통해 직접 객체를 생성하지 않아도, 몇몇 애노테이션이나 기타 스프링에서 사용 가능한 방식으로 표시해 두면 자동으로 객체가 생성되어 빈으로 등록된다는 것입니다. 참고로 빈 관리에서 보통 선택하는 전략은 싱글톤 전략이고, 하나의 객체만 만들어 두고 재사용합니다. (단, 싱글톤 패턴을 완전히 충족하지는 않습니다.)</p>
<h1 id="heading-jpa-repository-1">JPA Repository</h1>
<p><code>JpaRepository&lt;T, ID&gt;</code> 인터페이스를 계승하는 인터페이스를 작성해 두면 자동으로 구현되어 빈으로 등록도 됩니다. 다음이 예시입니다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">AccountRepository</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">JpaRepository</span>&lt;<span class="hljs-title">Account</span>, <span class="hljs-title">UUID</span>&gt; </span>{
    <span class="hljs-comment">// Account save(Account entity);</span>
    <span class="hljs-comment">// Optional&lt;Account&gt; findById(UUID id);</span>
    <span class="hljs-comment">// boolean existsById(UUID id);</span>
    <span class="hljs-comment">// void deleteById(UUID id);</span>
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<ul>
<li><p><code>JpaRepository&lt;다룰_엔티티, 아이디_타입&gt;</code> 인터페이스를 통해 <code>save</code>, <code>findById</code> 등 이미 생성되어 있는 메서드를 사용할 수 있습니다. 또한 메서드 네이밍의 규칙을 잘 따른다면 메서드를 커스텀하여 사용할 수 있습니다.</p>
</li>
<li><p>상단에 <code>@Repository</code> 애노테이션을 사용하지 않아도 빈으로 등록됩니다.</p>
</li>
</ul>
<p>이제 서비스 클래스 등 context로 관리되는 스코프에서 이 JPA Repository의 객체인 빈을 주입받아 사용할 수 있습니다. 빈 등록은 이번 글에서 다루었고, 다음 글에서는 등록된 빈을 사용해 보겠습니다.</p>
<hr />
<p><strong>&lt; Prev</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-4-kor">Persistent Entity 만들기(JPA Entity)</a></p>
<p><strong>Next &gt;</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-6-kor">서비스 인터페이스의 세분화와 빈 불러오기</a></p>
]]></content:encoded></item><item><title><![CDATA[차근차근 Modern Spring Boot 3 기초 (4) Persistent Entity 만들기(JPA Entity)]]></title><description><![CDATA[Entity, DTO 용어의 제한
Entity는 범용적인 용어입니다. DTO 또한 데이터 전달에 사용되면 모두 DTO라고 할 수 있죠. 하지만 이렇게 넓은 의미로 사용되면, 작업 스타일을 정할 때 방해가 될 수 있습니다.
우리는 다음처럼 entity와 DTO의 의미를 제한해 보겠습니다.

Entity: 구체적으로 JPA Entity를 뜻하는 것으로 하겠습니다. 이렇게 하면 결국 테이블에 그대로 대응하는 데이터가 됩니다.

DTO: 오직 사용자(...]]></description><link>https://blog.letsdev.me/step-by-step-springboot3-4-kor</link><guid isPermaLink="true">https://blog.letsdev.me/step-by-step-springboot3-4-kor</guid><category><![CDATA[Java]]></category><category><![CDATA[Modern Java]]></category><category><![CDATA[Springboot]]></category><category><![CDATA[jpa]]></category><category><![CDATA[Persistent Entity]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Mon, 08 Jul 2024 14:58:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1720451185185/229643b1-f43c-4b6b-a439-9f3925815033.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-entity-dto">Entity, DTO 용어의 제한</h1>
<p>Entity는 범용적인 용어입니다. DTO 또한 데이터 전달에 사용되면 모두 DTO라고 할 수 있죠. 하지만 이렇게 넓은 의미로 사용되면, 작업 스타일을 정할 때 방해가 될 수 있습니다.</p>
<p>우리는 다음처럼 entity와 DTO의 의미를 제한해 보겠습니다.</p>
<ul>
<li><p><strong>Entity</strong>: 구체적으로 JPA Entity를 뜻하는 것으로 하겠습니다. 이렇게 하면 결국 테이블에 그대로 대응하는 데이터가 됩니다.</p>
</li>
<li><p><strong>DTO</strong>: 오직 사용자(또는 다른 서버 등)와 주고 받는, 즉 외부와 교류하는 데이터 양식을 DTO라고 부르겠습니다.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720505729473/25f06fd2-2191-4c23-9a64-0a2b59846032.png" alt class="image--center mx-auto" /></p>
<h1 id="heading-jpa-entity">JPA Entity 만들기</h1>
<p>우리는 Flyway를 통해 DDL을 실행합니다. 테이블은 그렇게 만들죠.</p>
<p>바꿔서 말하자면, 테이블에 대응하는 JPA Entity도 DDL에 대응하게 만들면 됩니다.</p>
<h2 id="heading-enum">Enum을 통한 상태 목록 관리</h2>
<p>Enum은 C언어 시절에도 선택지 목록을 관리할 때 유용한 구조였습니다. 자바에서도 enum 클래스가 선택지 구조에서 유용하게 활용되며, 예를 들어 무언가의 상태(status)를 몇 가지 중 한 가지로 표현할 때 사용할 수 있습니다.</p>
<p>단, enum으로 관리하는 경우, 나중에 목록을 수정할 때마다 프로그램을 다시 배포해야 합니다. 간단히 구분해 보자면 다음과 같습니다. (절대적인 것은 아니며, 항상 상황에 따라 다를 수 있습니다.)</p>
<ul>
<li><p>수정할 필요가 (거의) 없는 선택지 목록 관리: <code>enum</code>으로 관리 (재배포에 의존)</p>
</li>
<li><p>수정할 경우가 때때로 생기는 선택지 목록 관리: 데이터베이스를 통해 동적으로 관리.</p>
</li>
</ul>
<p>개발자가 설계할 적에, 예상되는 수정의 빈도만으로 결정했을 때에는 운영 과정에서 생각보다 동적으로 관리해야 했던 것들을 enum으로 작성하였던 경우가 생길 수 있습니다. 따라서 단지 주관적으로 생각한 빈도로 선택하는 것은 아닙니다.</p>
<h3 id="heading-enum-account-status">Enum Account Status</h3>
<p>우리는 앞서 DDL 작성 시 <code>status</code>라고 하는 컬럼을 작성하였습니다. 타입은 문자열이었습니다. 이것을 enum으로 관리해 보겠습니다. 다음처럼 작성하면 이제 자바 타입으로 사용할 수 있습니다.</p>
<ul>
<li>Package: <code>com.example.demo.auth.domain</code></li>
</ul>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">enum</span> <span class="hljs-title">AccountStatus</span> </span>{
    <span class="hljs-comment">/** 가입 대기 */</span>
    PENDING,
    <span class="hljs-comment">/** 활성화 */</span>
    ACTIVE,
    <span class="hljs-comment">/** 보호됨(비밀번호를 연속으로 틀리는 등) */</span>
    PROTECTED,
    <span class="hljs-comment">/** 블락 처리 */</span>
    SUSPENDED,
    <span class="hljs-comment">/** 휴면 계정 */</span>
    SLEPT,
    <span class="hljs-comment">/** 삭제된 계정 */</span>
    REMOVED
}
</code></pre>
<h2 id="heading-account-entity">Account Entity 클래스 작성</h2>
<p>엔티티 클래스는 우리가 데이터베이스의 테이블을 다룰 때 편리하게 다룰 수 있도록, 테이블에 매핑되는 클래스를 선언한 것입니다. 엔티티는 범용적인 표현이기 때문에, 데이터베이스와 무관한 엔티티 개념도 사용되지만, 우리는 분명히 데이터베이스 테이블을 다루기 위한 JPA 엔티티를 언급하고 있습니다.</p>
<p>엔티티 클래스를 작성하기 위해서는, 앞서 만든 DDL 파일을 보면서 테이블의 컬럼을 알맞게 옮겨 주면 됩니다. 우리는 Flyway를 통해 DDL 관리를 하고 있기 때문에 <code>@Column</code> 같은 애노테이션을 세세하게 작성하지 않아도 됩니다.</p>
<p>DDL Auto의 속성 값을 <code>none</code>으로 했거나 따로 설정하지 않았다면 아무 문제가 없으며, <code>validate</code> 등 세세한 체크를 켜 두었다면 Flyway에 적용된 제약조건을 엔티티에도 반영해야 합니다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">import</span> jakarta.persistence.Entity;
<span class="hljs-keyword">import</span> jakarta.persistence.EnumType;
<span class="hljs-keyword">import</span> jakarta.persistence.Enumerated;
<span class="hljs-keyword">import</span> jakarta.persistence.GeneratedValue;
<span class="hljs-keyword">import</span> jakarta.persistence.Id;
<span class="hljs-keyword">import</span> jakarta.persistence.Table;
<span class="hljs-keyword">import</span> lombok.AllArgsConstructor;
<span class="hljs-keyword">import</span> lombok.Builder;
<span class="hljs-keyword">import</span> lombok.Getter;
<span class="hljs-keyword">import</span> lombok.NoArgsConstructor;

<span class="hljs-keyword">import</span> java.time.Instant;
<span class="hljs-keyword">import</span> java.util.UUID;
<span class="hljs-keyword">import</span> java.util.function.Supplier;

<span class="hljs-meta">@Entity</span>
<span class="hljs-meta">@Table(
        name = "account"
)</span>
<span class="hljs-meta">@Getter</span>
<span class="hljs-meta">@Builder</span>
<span class="hljs-meta">@NoArgsConstructor</span>
<span class="hljs-meta">@AllArgsConstructor</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Account</span> </span>{
    <span class="hljs-meta">@Id</span>
    <span class="hljs-meta">@GeneratedValue(generator = "uuid2")</span>
    <span class="hljs-keyword">private</span> UUID id;

    <span class="hljs-keyword">private</span> String username;
    <span class="hljs-keyword">private</span> String password;
    <span class="hljs-keyword">private</span> String nickname;
    <span class="hljs-meta">@Enumerated(EnumType.STRING)</span>
    <span class="hljs-keyword">private</span> AccountStatus status;
    <span class="hljs-keyword">private</span> Instant createdAt;
    <span class="hljs-keyword">private</span> Instant updatedAt;

    <span class="hljs-comment">// setter 삼가기.</span>
    <span class="hljs-comment">// 수정할 부분만 모아서 여러 update 메서드를 따로 만드는 것.</span>
}
</code></pre>
<ul>
<li><p>id는 자동으로 생성됩니다.</p>
</li>
<li><p>중요한 것은 우리는 enum 타입을 사용할 때, 가급적 <code>EnumType.String</code>으로 사용합니다. 작성 시 많이 누락하는 부분이니, 잘 체크해 두세요.</p>
</li>
<li><p>또 타임존, 타임 오프셋 등에 구애받지 않는 시스템을 구성하는 것이 좋습니다.</p>
<ul>
<li><p>GMT, UTC 기준으로 +00:00에 맞추어 타임스탬프를 사용합니다. (<a target="_blank" href="https://namu.wiki/w/%EC%9C%A0%EB%8B%89%EC%8A%A4%20%EC%8B%9C%EA%B0%84">유닉스 타임스탬프</a>)</p>
</li>
<li><p>타임존과 그 존의 오프셋은 '클라이언트'가 결정할 영역입니다.</p>
</li>
</ul>
</li>
</ul>
<p>이러한 이유로 시간에는 <code>Instant</code> 타입(Java 8 이상)을 사용하고, DB에서는 <code>timestamp</code>를 사용하는 것이 일반적인 선택이 되고 있습니다. 여러분이 아는 글로벌 테크기업들 중에도 그런 경우가 많죠!</p>
<p>그 외에도 getter는 허용하면서 setter는 잘 허용하지 않는 것이, 널리 쓰이고 있는 기초 아키텍처에서 함께 선택되는 전략입니다. 이는 엔티티 객체의 변화를 방지하기 위한 조치입니다. (일부 아키텍처에서는 변화를 public으로 허용할 수도 있습니다. 몇몇 리스크를 제거한 상태에서 택할 수 있는 전략입니다.)</p>
<p>그리고 위 엔티티 클래스에는 편의상 <code>@Builder</code>를 추가해 두었습니다. 자바는 모던한 언어들에 비해서 생성자나 메서드의 파라미터를 다루는 것이 조금 불편한데, 롬복(lombok)의 빌더는 빌더 패턴을 통해 이를 보완하고 있습니다.</p>
<hr />
<p><strong>&lt; Prev</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-3-kor">Flyway를 통한 DDL 관리</a></p>
<p><strong>Next &gt;</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-5-kor">JPA Entity를 사용하는 JPA Repository</a></p>
]]></content:encoded></item><item><title><![CDATA[차근차근 Modern Spring Boot 3 기초 (3) Flyway를 통한 DDL 관리]]></title><description><![CDATA[Gradle 프로젝트
우선 flyway를 적용하기 위하여 우리 프로젝트의 의존성 라이브러리 관리 방식을 이해해야 합니다.
우리는 빌드 도구로 gradle을 선택했습니다. 우리가 이번 프로젝트에서 gradle을 다루기 위해 눈여겨 봐야 할 파일들은 다음과 같습니다.

build.gradle.kts: 가장 많은 작업을 하게 되는 파일입니다. 우리는 이곳에 의존성 라이브러리를 나열하고 관리할 수 있으며, 각종 스크립트를 작성해 둘 수 있습니다.

s...]]></description><link>https://blog.letsdev.me/step-by-step-springboot3-3-kor</link><guid isPermaLink="true">https://blog.letsdev.me/step-by-step-springboot3-3-kor</guid><category><![CDATA[Java]]></category><category><![CDATA[Springboot]]></category><category><![CDATA[JDK21]]></category><category><![CDATA[Docker compose]]></category><category><![CDATA[flyway]]></category><category><![CDATA[Modern Java]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Mon, 08 Jul 2024 14:55:50 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1720444102169/c590c38a-b786-49fd-b196-f91695726a73.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-gradle">Gradle 프로젝트</h1>
<p>우선 flyway를 적용하기 위하여 우리 프로젝트의 의존성 라이브러리 관리 방식을 이해해야 합니다.</p>
<p>우리는 빌드 도구로 <code>gradle</code>을 선택했습니다. 우리가 이번 프로젝트에서 gradle을 다루기 위해 눈여겨 봐야 할 파일들은 다음과 같습니다.</p>
<ul>
<li><p><code>build.gradle.kts</code>: 가장 많은 작업을 하게 되는 파일입니다. 우리는 이곳에 의존성 라이브러리를 나열하고 관리할 수 있으며, 각종 스크립트를 작성해 둘 수 있습니다.</p>
</li>
<li><p><code>settings.gradle.kts</code>: 이번 실습에서는 거의 다루지 않아도 됩니다. 모듈을 관리하거나, 기초가 되는 일부 명령을 작성해 두는 데에 사용합니다.</p>
</li>
<li><p><code>gradle.properties</code>(기본 생성이 아님): 이 파일은 지금 프로젝트에 포함되어 있지 않습니다. 이 파일에는 우리가 <code>build.gradle</code>(<code>build.gradle.kts</code>)이나 <code>settings.gradle</code> 등에서 사용하는 변수 목록을 작성해 둘 수 있습니다.</p>
</li>
</ul>
<h2 id="heading-7j2y7kg07isxioudvoydtou4joufroumrcdsnphshle">의존성 라이브러리 작성</h2>
<p><code>build.gradle.kts</code> 파일에 기존 내용 대신 다음 내용을 입력합니다.</p>
<pre><code class="lang-kotlin">plugins {
    java
    id(<span class="hljs-string">"org.springframework.boot"</span>) version <span class="hljs-string">"3.3.1"</span>
    id(<span class="hljs-string">"io.spring.dependency-management"</span>) version <span class="hljs-string">"1.1.5"</span>
}

group = <span class="hljs-string">"com.example"</span>
version = <span class="hljs-string">"0.0.1-SNAPSHOT"</span>

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(<span class="hljs-number">21</span>)
        sourceCompatibility = JavaVersion.VERSION_21
        targetCompatibility = JavaVersion.VERSION_21
    }
}

configurations {
    compileOnly {
        extendsFrom(configurations.annotationProcessor.<span class="hljs-keyword">get</span>())
    }
    configureEach {
        <span class="hljs-comment">// exclude LOGBACK</span>
        exclude(group = <span class="hljs-string">"org.springframework.boot"</span>, module = <span class="hljs-string">"spring-boot-starter-logging"</span>)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(<span class="hljs-string">"org.springframework.boot:spring-boot-starter-web"</span>)
    implementation(<span class="hljs-string">"org.springframework.boot:spring-boot-starter-data-jpa"</span>)
    implementation(<span class="hljs-string">"org.springframework.boot:spring-boot-starter-validation"</span>)
    implementation(<span class="hljs-string">"org.springframework.boot:spring-boot-starter-log4j2"</span>)
    testImplementation(<span class="hljs-string">"org.springframework.boot:spring-boot-starter-test"</span>)
    developmentOnly(<span class="hljs-string">"org.springframework.boot:spring-boot-devtools"</span>)
    annotationProcessor(<span class="hljs-string">"org.springframework.boot:spring-boot-configuration-processor"</span>)

    <span class="hljs-comment">// DB</span>
    runtimeOnly(<span class="hljs-string">"org.postgresql:postgresql"</span>)

    <span class="hljs-comment">// flyway</span>
    implementation(<span class="hljs-string">"org.flywaydb:flyway-core:9.22.3"</span>)

    <span class="hljs-comment">// lombok</span>
    compileOnly(<span class="hljs-string">"org.projectlombok:lombok"</span>)
    annotationProcessor(<span class="hljs-string">"org.projectlombok:lombok"</span>)
}

tasks.withType&lt;Test&gt; {
    useJUnitPlatform()
}
</code></pre>
<p>위 내용 중 <code>dependencies</code>에 나열한 항목이 이 프로젝트에 적용되는 의존성 라이브러리 목록입니다.</p>
<p>위 내용 중 flyway의 의존성 라이브러리를 추가한 부분은 다음과 같습니다.</p>
<pre><code class="lang-kotlin">dependencies {
    <span class="hljs-comment">// ...</span>

    <span class="hljs-comment">// flyway</span>
    implementation(<span class="hljs-string">"org.flywaydb:flyway-core:9.22.3"</span>)
}
</code></pre>
<ul>
<li><p><a target="_blank" href="https://mvnrepository.com/">MVN Repository</a>(<a target="_blank" href="https://mvnrepository.com/">https://mvnrepository.com/</a>)에서 검색하여 의존성 라이브러리를 추가할 수 있습니다. (이번에는 제가 검색해서 갖고 왔습니다.)</p>
</li>
<li><p>현 시점 기준으로 Flyway는 10 버전까지 있습니다. (위에 우리가 적용 중인 것은 9 버전)</p>
<ul>
<li><p>PostgreSQL 14 버전에 적용 가능한 Flyway 버전은 최대 9 버전까지입니다.</p>
</li>
<li><p>만약 DB 접속 정보가 정확함에도 Flyway 연결 오류가 발생한다면, 실수로 10 버전 이상으로 작성하였거나 버전 입력을 생략하여 10 버전 이상이 적용된 것일 수 있습니다.</p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-sync-gradle-refresh-gradle">Sync Gradle (Refresh Gradle)</h2>
<p>만약 <code>settings.gradle</code> 또는 <code>build.gradle</code> 등을 수정했다면, 인텔리제이는 화면의 우상단에 코끼리 버튼(Load Gradle Changes 버튼)을 보여 줄 것입니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720446507101/ba312c6b-1af5-45c3-9cbb-408995b7a21d.png" alt class="image--center mx-auto" /></p>
<p>이 버튼을 누르면 우리가 추가한 내용이 반영됩니다. 반드시 이 버튼을 누르는 것을 기억하십시오. 만약 이 버튼을 누르지 않고 오른쪽 닫기 버튼을 눌러 닫았다면, 화면 어딘가에서 gradle 탭을 찾아야 합니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720446612813/01494ce1-4097-421a-b7a2-fc03471d0da3.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720446644831/38c839b0-edd4-4a2f-8893-397b0ebb1eba.png" alt class="image--center mx-auto" /></p>
<p>이곳에서 새로고침(🔄) 버튼을 누릅니다. 또는 다음처럼 프로젝트 단위로 <code>Reload gradle project</code>나 <code>Refresh Gradle Dependencies</code>를 선택할 수 있습니다. (project 탭이 아니라 gradle 탭임.)</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720446920133/63c436c2-f5eb-4068-9bae-bc848461be2f.png" alt class="image--center mx-auto" /></p>
<p>확인을 위해 빌드 탭을 찾아서 열어 보세요. 화면의 UI는 저와 다를 수 있습니다. 저는 자리에 위 의존성 라이브러리들이 설치되어 있기 때문에 메시지가 짧습니다. 확인해야 하는 <code>BUILD SUCCESSFUL</code> 메시지는 동일하게 작성되어 있을 것입니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720447177714/eb561871-4fd3-45b3-8756-3d77a265a2b6.png" alt class="image--center mx-auto" /></p>
<p>오타가 없고 인터넷에 잘 연결되어 있다면 <code>BUILD SUCCESSFUL</code> 메시지를 확인할 수 있을 것입니다.</p>
<h1 id="heading-7iqk7zse66ebiou2go2kucdtlitrozzsoj3tirg">스프링 부트 프로젝트</h1>
<h2 id="heading-application">Application 구성 속성</h2>
<p>우선 <code>src/main/resources</code>에 있는 <code>application.properties</code> 파일을 <code>application.yml</code>로 이름을 변경합니다. 그렇게 함으로써 우리가 훨씬 다루기 편합니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1724590793486/dbca5610-9ad9-44e2-98c6-be8fdedd2a34.avif" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720447694609/5b2c7adb-15da-45f7-b195-62f17e977468.png" alt class="image--center mx-auto" /></p>
<p>리팩터 버튼(Refactor)을 눌러 변경을 완료합니다.</p>
<h3 id="heading-db-flyway">DB 접속 정보, 커넥션 풀, Flyway 설정 입력</h3>
<pre><code class="lang-yaml"><span class="hljs-attr">spring:</span>
  <span class="hljs-attr">application.name:</span> <span class="hljs-string">example-springboot3-webmvc</span>

  <span class="hljs-attr">datasource:</span>
    <span class="hljs-attr">driver-class-name:</span> <span class="hljs-string">org.postgresql.Driver</span>
    <span class="hljs-attr">url:</span> <span class="hljs-string">${POSTGRES_URL:jdbc:postgresql://localhost:5442/demo}</span>
    <span class="hljs-attr">username:</span> <span class="hljs-string">${POSTGRES_USERNAME:root}</span>
    <span class="hljs-attr">password:</span> <span class="hljs-string">${POSTGRES_PASSWORD:root}</span>

    <span class="hljs-comment"># connection pool</span>
    <span class="hljs-attr">hikari:</span>
      <span class="hljs-attr">connection-timeout:</span> <span class="hljs-string">30_000</span>
      <span class="hljs-attr">idle-timeout:</span> <span class="hljs-string">60_000</span>
      <span class="hljs-attr">max-lifetime:</span> <span class="hljs-string">1_800_000</span>
      <span class="hljs-attr">maximum-pool-size:</span> <span class="hljs-number">300</span>
      <span class="hljs-attr">minimum-idle:</span> <span class="hljs-number">5</span>
      <span class="hljs-attr">leak-detection-threshold:</span> <span class="hljs-number">2000</span>

  <span class="hljs-attr">flyway:</span>
    <span class="hljs-attr">baseline-on-migrate:</span> <span class="hljs-literal">true</span> <span class="hljs-comment"># flyway schema history 테이블이 없다면 생성한다.</span>
</code></pre>
<p>DB 접속 정보(데이터 소스)</p>
<ul>
<li><p><code>${환경변수}</code>는 환경 변수를 읽어서 사용합니다.</p>
</li>
<li><p><code>${환경변수:기본값}</code>은 해당 환경 변수가 없을 때 기본값을 사용합니다. 환경 변수가 있다면 기본값은 무시됩니다.</p>
</li>
</ul>
<p>커넥션 풀(데이터소스의 하위 속성)</p>
<ul>
<li><p>다음 설명이 아직 어렵다면 지금 바로 이해할 필요는 없습니다. 나중에 따로 설명할 일이 거의 없을 거라 생각해서 미리 설명해 두었습니다.</p>
</li>
<li><p>DB에 접속할 때마다 커넥션을 생성하고 작업을 마칠 때마다 커넥션을 종료하는 동작이 주요 로직 흐름에서 비용(cost)이 되지 않도록, 미리 커넥션 풀(connection pool)이라는 곳에 커넥션들을 생성해 두어 관리하고, 로직에 커넥션이 필요하면 그때그때 임대해 쓰는 방식입니다.</p>
</li>
</ul>
<p>Flyway 설정</p>
<ul>
<li><p>Flyway는 처음 사용할 때 <code>flyway schema history</code>라는 테이블을 생성해야 합니다.</p>
</li>
<li><p><code>baseline-on-migrate</code> 옵션을 <code>true</code>로 변경하면, <code>flyway schema history</code> 테이블이 없을 때 그 테이블을 생성합니다.</p>
</li>
</ul>
<p>이제 Flyway를 사용할 준비가 되었습니다.</p>
<p><strong>(참고) 파일 인코딩 변경</strong></p>
<p>application.yml 파일의 기본 인코딩이 UTF-8로 되어 있지 않다면, 다음처럼 마우스를 올려 변경할 수 있습니다. <code>Change file encoding</code>을 클릭하고, 목록에서 <code>UTF-8</code>을 선택해 줍니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720448673573/b955a551-2405-43de-971b-d7ed6b103a61.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720448811002/3f769ff3-0b6c-4da6-9ce5-774b48061517.png" alt class="image--center mx-auto" /></p>
<p>Convert를 누르는 것이 가장 좋습니다. (보이는 문자를 유지한 상태로 인코딩을 자동으로 맞춰 줍니다.)</p>
<h2 id="heading-flyway-ddl">Flyway DDL 버전 파일 작성</h2>
<p>우리는 앞서 Flyway 의존성 추가(<code>dependencies</code>)와 Flyway 구성 속성 작성(<code>application.yml</code>)을 완료했습니다.</p>
<p>이제 Flyway의 다른 구성 속성을 변경하지 않는다면, 다음 경로에 DDL 파일을 작성하면 됩니다.</p>
<ul>
<li><p>경로: <code>src/main/resources/db/migration</code></p>
</li>
<li><p>위 경로 중 <code>db/migration</code> 폴더는 우리가 만들어야 합니다.<br />  (인텔리제이에서는 <code>db.migration</code>처럼 보일 수 있습니다. 실제 폴더 이름을 <code>db.migration</code>으로 만들지 마세요. 육안으로 보이는 것과 다르고, 이름 바꾸기 등을 통해 확인할 수 있습니다.)</p>
</li>
</ul>
<p><strong>(참고) DDL, DML이란?</strong></p>
<ul>
<li><p>DDL: 스키마를 구축할 때 사용하는 SQL입니다. CREATE, ALTER, DROP 등입니다. 테이블을 만들 때 사용한다고 이해하면 됩니다.</p>
</li>
<li><p>DML: 테이블에 데이터를 넣고, 조회하고, 수정하고, 삭제하는 동작을 위한 SQL입니다. 테이블을 이용할 때 사용한다고 이해하면 됩니다.</p>
</li>
</ul>
<p>서비스를 만들고 운영하는 관점에서 보면, DDL은 서비스를 만드는 동안에 사용되지만, DML은 서비스 배포 후에도 사용자의 요청을 처리하는 데에 활발하게 사용됩니다. 즉, 운영 내내 사용됩니다.</p>
<p><strong>테이블을 만들고(DDL) → 사용하는(DML) 것입니다.</strong></p>
<h3 id="heading-flyway-ddl-1">Flyway DDL 파일 이름 규칙</h3>
<ul>
<li><p><code>V버전__설명.sql</code>: 버전 파일입니다. 순서대로 잘 실행됩니다. 스키마 변경의 히스토리 역할을 하기 때문에 이미 운영 환경에 적용된 <code>V</code> 파일은 그대로 두고, 새로운 <code>V</code> 파일을 추가하여 사용합니다.</p>
</li>
<li><p><code>R__설명.sql</code>: 반복 파일입니다. 수정될 때마다 실행됩니다. 더미 데이터나 시드 데이터를 삽입해 둘 때 사용할 수 있습니다.</p>
</li>
</ul>
<h3 id="heading-ddl">DDL 작성</h3>
<p>경로: <code>src/main/resources/db/migration</code></p>
<p>파일 이름: <code>V1_0_0__init_schema.sql</code></p>
<pre><code class="lang-sql"><span class="hljs-comment">-- Enable UUID</span>
<span class="hljs-keyword">CREATE</span> EXTENSION <span class="hljs-keyword">IF</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span> <span class="hljs-string">"uuid-ossp"</span>;
</code></pre>
<p>경로: 위와 같음.</p>
<p>파일 이름: <code>V1_0_1__add_tb_account.sql</code></p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> <span class="hljs-keyword">IF</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span> <span class="hljs-keyword">account</span> (
    <span class="hljs-keyword">id</span>              <span class="hljs-keyword">UUID</span>                <span class="hljs-keyword">DEFAULT</span> uuid_generate_v4(),
    username        <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">255</span>),
    <span class="hljs-keyword">password</span>        <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">255</span>),
    nickname        <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">255</span>),

    <span class="hljs-keyword">status</span>          <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">255</span>),
    created_at      <span class="hljs-built_in">TIMESTAMP</span>           <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">now</span>(),
    updated_at      <span class="hljs-built_in">TIMESTAMP</span>,

    <span class="hljs-keyword">CONSTRAINT</span> pk_account PRIMARY <span class="hljs-keyword">KEY</span> (<span class="hljs-keyword">id</span>),
    <span class="hljs-keyword">CONSTRAINT</span> uq_account_username <span class="hljs-keyword">UNIQUE</span> (username),
    <span class="hljs-keyword">CONSTRAINT</span> uq_account_nickname <span class="hljs-keyword">UNIQUE</span> (nickname)
);
</code></pre>
<h2 id="heading-ddl-1">애플리케이션을 실행하여 DDL이 작동하는지 확인</h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1724589146508/39dce56a-866f-4c76-9b8e-bef5bdc9e7e7.avif" alt class="image--center mx-auto" /></p>
<p>우리가 실행할 애플리케이션 파일은 베이스 패키지(<code>com.example.demo</code>)에 있습니다. 프로젝트의 초기 생성 이름에 따라 자동으로 붙은 이름이 모두 다를 수 있습니다. 저 파일을 열고 인텔리제이의 실행 버튼 또는 디버그 버튼을 눌러 실행하면 됩니다. (<em>재생 버튼 ▶️</em>)</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1724590527515/eaea2df2-6dbc-4e8d-8186-b0ef1d2a3bdf.avif" alt class="image--center mx-auto" /></p>
<p>이제 애플리케이션을 실행하면 위 DDL을 순서대로 실행하는 것을 로그로 확인할 수 있습니다. DB는 켜 둔 상태여야 합니다. (도커 데스크톱에서 컨테이너 단위로 끄고 켤 수 있습니다.)</p>
<p>또한 DBeaver 등 DB 접속 도구에서 테이블이 존재하는지 확인할 수 있습니다. DB에 접속하는 방식은 이전 글을 참고하세요. (원래 켜 둔 상태였다면 항목을 잘 선택해서 F5를 눌러 새로고침을 해 주세요.)</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720450384744/ca64c3d8-1955-4e75-9cc2-e8c896ff5152.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-flyway-v">(참고) Flyway의 V 파일을 수정하고 싶어요.</h2>
<p>운영 환경에 이미 적용한 파일은 수정하면 안 됩니다(평시에는.).</p>
<p>우리는 지금 로컬에서만 사용하고 있기 때문에, 언제든 수정할 수 있습니다. 대신 V 파일을 수정한다면, 가급적 모든 테이블을 날려 버린 후 다시 구축하는 것이 좋습니다. 애플리케이션을 실행하기만 해도 쉽게 구축할 수 있다는 것을 명심하세요.</p>
<hr />
<p><strong>&lt; Prev</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-2-kor"><strong>Docker Compose로 프로젝트별 DB 설치</strong></a></p>
<p><strong>Next &gt;</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-4-kor">Persistent Entity(JPA Entity) 만들기</a></p>
]]></content:encoded></item><item><title><![CDATA[차근차근 Modern Spring Boot 3 기초 (2) 로컬에 쓴 Docker Compose]]></title><description><![CDATA[도커 컨테이너의 활용
도커와 컨테이너를 낯설게 느끼는 초심자분들 중 일부는, 도커가 오직 배포를 위해서 사용하는 것이라고 이해하고 계셨습니다. 물론 도커와 컨테이너 개념은 배포 환경에서 아주 유용하게 활용되는 개념입니다. 하지만 로컬(개발자 컴퓨터)에서 개발 환경을 세팅하기 위해서 활용하기에도 손색이 없습니다.

배포를 위해서

로컬 작업을 편하게 하기 위해서


모두 활용할 수 있습니다.
프로젝트마다 독립된 DB 사용
🛢+🛢 Q. 여러 프...]]></description><link>https://blog.letsdev.me/step-by-step-springboot3-2-kor</link><guid isPermaLink="true">https://blog.letsdev.me/step-by-step-springboot3-2-kor</guid><category><![CDATA[Java]]></category><category><![CDATA[JDK21]]></category><category><![CDATA[Springboot]]></category><category><![CDATA[Docker compose]]></category><category><![CDATA[PostgreSQL]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Mon, 08 Jul 2024 13:02:42 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1720449404584/f0c94150-4ef3-4d0b-b115-b97f7e67804c.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-64e7lukioy7qo2fjoydtoueioydmcdtmzzsmqk">도커 컨테이너의 활용</h1>
<p>도커와 컨테이너를 낯설게 느끼는 초심자분들 중 일부는, 도커가 오직 배포를 위해서 사용하는 것이라고 이해하고 계셨습니다. 물론 도커와 컨테이너 개념은 배포 환경에서 아주 유용하게 활용되는 개념입니다. 하지만 로컬(개발자 컴퓨터)에서 개발 환경을 세팅하기 위해서 활용하기에도 손색이 없습니다.</p>
<ul>
<li><p>배포를 위해서</p>
</li>
<li><p>로컬 작업을 편하게 하기 위해서</p>
</li>
</ul>
<p>모두 활용할 수 있습니다.</p>
<h2 id="heading-db">프로젝트마다 독립된 DB 사용</h2>
<p><strong>🛢+🛢 Q. 여러 프로젝트에서 DB의 스키마와 테이블이 겹칩니다.</strong></p>
<p>기본적으로 백엔드 시스템에서 DB와 상호작용하는 것은 매우 중요한 역할 중 하나입니다. 하지만 여러 프로젝트를 하기 위해, 매번 내 자리에 데이터베이스를 설치하고 서로 다른 프로젝트끼리 DB 스키마와 테이블 이름이 겹치지 않도록 번거롭게 관리해야 할까요?</p>
<p>로컬에서의 작업 편의를 위해 운영 환경에 영향을 줄지도 모르는 스키마, 테이블 관리 전략을 택하거나, 운영 환경과 너무 상이한 로컬 환경을 구성하는 것은 아무래도 현대적인 방식이 아닌 것 같습니다.</p>
<p><strong>🎁 A. 우리는 도커 컨테이너의 독립성을 로컬에서 유용하게 활용할 수 있습니다.</strong></p>
<p>도커 컨테이너는 독립된 환경을 갖습니다. 그리고 우리가 원할 때 언제든 쉽게 끄고 켜거나, 삭제 후 다시 설치할 수도 있죠. 이렇게 편리한 특징을 이해한다면, 프로젝트마다 아주 손쉽게 데이터베이스 등 도구를 독립적으로 관리할 수 있습니다.</p>
<p>프로젝트마다 완전히 독립된 데이터베이스를 사용할 수 있기 때문에, 데이터가 겹칠 일도 없으며 스키마, 테이블 이름을 관리하는 전략은 서로 다른 프로젝트에 전혀 영향을 주지 않습니다. 또 사용하지 않을 땐 쉽게 삭제하고, 나중에 다시 필요할 때 쉽게 설치할 수 있습니다. 이 과정을 위해 마치 번거로운 작업들이 필요할 것 같겠지만, 아주 간단하게 모든 작업이 완료됩니다.</p>
<h1 id="heading-docker-compose">Docker Compose를 통한 데이터베이스 설치</h1>
<p>우리는 도커 컴포즈를 사용하기 위해 docker desktop을 설치했습니다. 도커 컴포즈는 여러 컨테이너 관리를 한 번에 쉽게 할 수 있게 도와주는 도구입니다.</p>
<p>도커 컴포즈는 도커 데스크톱과 독립적으로도 설치할 수 있는 도구지만, 가장 편리하게 설치하는 방법은 도커 데스크톱을 설치하는 것입니다. 또한 이후 도커 데스크톱을 통해 편하게 관리할 수 있습니다.<br />(단, 도커 데스크톱을 활용한다면 도커 데스크톱과 관련한 취약점 보고가 있는지 눈여겨 봐야 합니다.)</p>
<h2 id="heading-docker-desktop">Docker Desktop 실행</h2>
<p>도커 컴포즈 명령어를 수행할 수 있도록 우선 도커 데스크톱을 실행합니다.</p>
<blockquote>
<p>만약 도커 컴포즈 명령어가 실행 가능한 명령 또는 프로그램이 아니라고 나온다면, 내 컴퓨터에 도커 데스크톱이 실행되고 있는지 확인하여야 합니다. 적어도 백그라운드에서는 돌고 있어야 합니다.</p>
</blockquote>
<h2 id="heading-docker-compose-1">프로젝트에 Docker Compose 파일 작성</h2>
<p>도커 컴포즈는 프로젝트와 독립적으로도 관리할 수 있습니다. 따라서 반드시 프로젝트 안에 도커 컴포즈 파일을 작성해야 하는 것은 아닙니다. 하지만, 우리는 프로젝트 단위로 도커 컴포즈를 관리하는 예정이기 때문에 프로젝트 안에 도커 컴포즈 파일을 작성하는 것이 관리하기 편리합니다.</p>
<p>도커 컴포즈 파일은 복잡한 파일 조합을 갖지 않고, 단일 파일로 모두 작성할 수 있기 때문에, 프로젝트의 루트 경로에 바로 작성하는 것이 이번 기초 프로젝트에 맞습니다.</p>
<p><strong>다음 파일을 생성하고, 다음 내용을 붙여 넣어 작성합니다.</strong></p>
<ul>
<li><p><strong>파일 경로</strong>: 프로젝트의 루트 (이전 글에서 <code>example-springboot3-webmvc</code> 폴더)</p>
</li>
<li><p><strong>파일 이름</strong>: <code>docker-compose.yml</code></p>
</li>
<li><p><strong>파일 내용</strong></p>
<pre><code class="lang-yaml">  <span class="hljs-attr">version:</span> <span class="hljs-string">"3"</span>

  <span class="hljs-attr">services:</span>
    <span class="hljs-attr">sample_postgres14:</span>
      <span class="hljs-comment"># postgresql 공식 이미지</span>
      <span class="hljs-attr">image:</span> <span class="hljs-string">postgres:14</span>
      <span class="hljs-comment"># 환경 변수 (규약된 것들)</span>
      <span class="hljs-attr">environment:</span>
        <span class="hljs-attr">TZ:</span> <span class="hljs-string">Asia/Seoul</span>
        <span class="hljs-attr">POSTGRES_DB:</span> <span class="hljs-string">demo</span>
        <span class="hljs-attr">POSTGRES_USER:</span> <span class="hljs-string">root</span>
        <span class="hljs-attr">POSTGRES_PASSWORD:</span> <span class="hljs-string">root</span>
        <span class="hljs-attr">POSTGRES_INITDB_ARGS:</span> <span class="hljs-string">'--encoding=UTF-8 --lc-collate=C --lc-ctype=C'</span>
      <span class="hljs-comment"># 포트포워딩 (외부:내부)</span>
      <span class="hljs-attr">ports:</span>
        <span class="hljs-comment"># 외부: 변경해도 됨. 우리가 접속할 포트.</span>
        <span class="hljs-comment"># 내부: 변경하면 안 되는 경우가 많음. 컨테이너 내에서 취급되는 포트.</span>
        <span class="hljs-bullet">-</span> <span class="hljs-number">5442</span><span class="hljs-string">:5432</span>
      <span class="hljs-comment"># 연결할 폴더들 (외부:내부)</span>
      <span class="hljs-attr">volumes:</span>
        <span class="hljs-comment"># 콜론의 왼쪽에 내 PC의 로컬 경로를 써도 됨.</span>
        <span class="hljs-comment"># 콜론의 왼쪽에 볼륨 컨테이너를 써도 됨.</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">sticky_volume_sample_postgres14:/var/lib/postgresql/data</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">./db/initdb.d:/docker-entrypoint-initdb.d:ro</span>

  <span class="hljs-attr">volumes:</span>
    <span class="hljs-attr">sticky_volume_sample_postgres14:</span>
</code></pre>
</li>
<li><p><code>#</code>으로 시작하는 문장은 주석입니다.</p>
</li>
<li><p>가장 아래 volumes에 작성한 것은 볼륨 컨테이너들입니다. (이 파일에선 하나만 작성하였음.)</p>
</li>
<li><p>환경변수(<code>environment</code>)는 공식 이미지가 제공하는 목록을 참고하여 작성합니다.</p>
</li>
<li><p>컨테이너 외부 포트(<code>5442</code>)가 우리가 접근 시 사용할 포트입니다.</p>
</li>
<li><p>컨테이너 내부 포트(<code>5432</code>)는 각 이미지가 점유하는 경우가 많으며, 우리가 수정하지 않습니다.</p>
</li>
</ul>
<p>위와 같이 만들었을 때, 데이터베이스 접속 정보입니다.</p>
<ul>
<li><p>URL: <code>jdbc:postgresql://localhost:5442/demo</code></p>
<ul>
<li>host: <code>localhost</code>, port: <code>5442</code>, DB name: <code>demo</code></li>
</ul>
</li>
<li><p>username: <code>root</code></p>
</li>
<li><p>password: <code>root</code></p>
</li>
</ul>
<h2 id="heading-docker-compose-2">Docker Compose 실행</h2>
<p><strong>터미널 열기</strong></p>
<p>명령어는 IDE에서 터미널 아이콘을 찾아 선택한 후, 터미널의 현재 경로(<code>pwd</code>)가 프로젝트 루트로 되어 있고, 프로젝트 루트에 정확하게 <code>docker-compose.yml</code> 파일이 존재해야 합니다.<br />(현재 경로에 있는 파일 목록 확인 명령어는 windows: <code>dir</code>, 그 외: <code>ls</code>입니다. 이 명령어를 입력하면 <code>docker-compose.yml</code> 파일이 보여야 합니다. 만약 올바른 경로가 아니라면, <code>cd</code> 명령어를 통해 올바른 경로로 이동하세요. <code>cd ..</code>은 상위 폴더로 이동, <code>cd 폴더이름</code> 또는 <code>cd 폴더이름/폴더이름/폴더이름</code> 형태로 다른 경로로 이동할 수 있습니다.)</p>
<p><strong>도커 컨테이너 게시 및 실행</strong></p>
<p>실행 명령어입니다. 실행할 수 없는 명령이라고 오류가 발생하면, 도커 데스크톱을 실행한 상태에서 다시 명령어를 입력하세요.</p>
<pre><code class="lang-bash">docker-compose up -d
</code></pre>
<p>실습에서는 위 명령어까지만 하면 됩니다. 아래 명령어들은 참고로 알아 둡니다.</p>
<p><strong>(참고) 도커 컨테이너 내리기</strong></p>
<p>기본적으로 볼륨 컨테이너를 보존합니다. 이 경우, 우리 실습 기준으로는 데이터가 보존됩니다.</p>
<pre><code class="lang-bash">docker-compose down
</code></pre>
<p><strong>(참고) 볼륨 컨테이너까지 내리기</strong></p>
<p><code>-v</code> 옵션까지 붙여서 실행하면, 이 도커 컴포즈 파일로 관리하는 모든 컨테이너와 볼륨 컨테이너를 모두 내립니다.</p>
<pre><code class="lang-bash">docker-compose down -v
</code></pre>
<h2 id="heading-642w7j207ysw67kg7j207iqkioygkeygjsdtmzxsnbg">데이터베이스 접속 확인</h2>
<p>우리 컴퓨터의 네트워크 설정에 복잡하고 이상한 것들이 없다면, DB 접속 도구를 통해 이 데이터베이스에 로컬호스트로 접근할 수 있을 것입니다.</p>
<p>만약 네트워크 설정 등 이유로 <code>localhost</code> 접근이 안 된다면, 네트워크 설정을 확인하여 내 PC의 IP를 통해 접근할 수 있습니다. 예를 들어, 내 PC에 할당된 내부 IP가 <code>192.168.10.100</code>이라면, DB URL을 <code>jdbc:postgresql://192.168.10.100:5442/demo</code>로 하여 접근해 보십시오.</p>
<p><strong>접속 예(localhost)</strong></p>
<ol>
<li><p>DBeaver 실행</p>
</li>
<li><p>새 접속(새 연결) 생성</p>
<p> <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720440159819/a4507fa7-b6ec-494b-8c1a-d9c6136d8f71.png" alt class="image--center mx-auto" /></p>
</li>
<li><p>PostgerSQL 선택, 다음 버튼 클릭</p>
<p> <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720440230760/4e5e0dcc-af22-4dcc-8def-69227aa5359a.png" alt class="image--center mx-auto" /></p>
</li>
<li><p>접속 정보 입력</p>
<p> <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720440474386/c41a78b6-0a96-42ae-9976-a59f13216c14.png" alt class="image--center mx-auto" /></p>
</li>
<li><p>Test Connection 클릭 (필요하다면 드라이버를 다운로드 받아야 합니다.)</p>
</li>
<li><p>성공하면 확인 누르고, 완료 선택</p>
<p> <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720440554325/31d3b1a0-fab7-46b0-8fce-c82745660057.png" alt class="image--center mx-auto" /></p>
</li>
</ol>
<h2 id="heading-spring-boot-docker-compose-support">(참고) Spring Boot Docker Compose Support</h2>
<p>스프링부트와 도커 컴포즈를 함께 사용하면, 이를 보조할 수 있는 도커 컴포즈 support가 있습니다.</p>
<p><strong>⛔️ 우리는 안 씁니다.</strong></p>
<p>이유는 다음과 같습니다.</p>
<ul>
<li><p>Spring Boot Docker Compose Support를 통하여, 스프링 부트 앱을 실행할 때 도커 컴포즈 명령을 실행하여 컨테이너를 게시하고, 스프링 부트 앱을 종료할 때 컨테이너를 끄거나 종료하도록 설정할 수 있습니다.</p>
<ul>
<li>수동으로 할 때에 비해 메리트가 그다지 크지 않습니다.</li>
</ul>
</li>
<li><p>나온 지 얼마 되지 않았습니다. 그리고 우리 시스템에 명령어를 전달하는 것으로 보입니다.</p>
<ul>
<li><p>만약 취약점이 발견된다면, 이따금 치명적인 취약점이 될 수도 있습니다.</p>
</li>
<li><p>작은 메리트(우리 손으로 명령어를 치지 않고 애플리케이션 생명 주기와 컨테이너 생명 주기를 연결해 관리하는 기능)를 위해 그런 취약점 가능성을 감수하고 싶지 않습니다.</p>
</li>
</ul>
</li>
</ul>
<p>참고로 우리가 사용하고 있는 도커 데스크톱 또한 취약점이 생길 수 있는 도구입니다. 그럼에도 사용하고 있기 때문에, spring boot docker compose support 또한 사용하고 싶다면 사용해 보셔도 됩니다.</p>
<hr />
<p><strong>&lt; Prev</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-1-kor">설치 및 IDE 세팅</a></p>
<p><strong>Next &gt;</strong></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-3-kor">Flyway를 통한 DDL 관리</a></p>
]]></content:encoded></item><item><title><![CDATA[차근차근 Modern Spring Boot 3 기초 (1)
설치 및 IDE 세팅]]></title><description><![CDATA[설치
대체재를 택해도 괜찮습니다만, 그대로 따라하실 분들은 가급적 같은 프로그램을 설치해 주세요.

Amazon Corretto 21 (Open JDK)

설치 파일로 설치하면 환경변수 등록도 완료됩니다.

환경 변수 등록을 완료하지 않았더라도 IDE(인텔리제이)에서 인식할 수 있으면 됩니다.



Docker Desktop (일부 운영체제의 오래된 버전에서 도커가 지원되지 않습니다.)

⭐️ 설치 후 첫 실행까지 완료해 주십시오.


Inte...]]></description><link>https://blog.letsdev.me/step-by-step-springboot3-1-kor</link><guid isPermaLink="true">https://blog.letsdev.me/step-by-step-springboot3-1-kor</guid><category><![CDATA[Modern Java]]></category><category><![CDATA[Java]]></category><category><![CDATA[Springboot]]></category><category><![CDATA[Docker compose]]></category><category><![CDATA[JDK21]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Mon, 08 Jul 2024 08:51:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1720449305226/d7645a86-9369-4128-9608-c63560cb6a28.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-7isk7lmy">설치</h1>
<p>대체재를 택해도 괜찮습니다만, 그대로 따라하실 분들은 가급적 같은 프로그램을 설치해 주세요.</p>
<ul>
<li><p><strong>Amazon Corretto 21</strong> (Open JDK)</p>
<ul>
<li><p>설치 파일로 설치하면 환경변수 등록도 완료됩니다.</p>
</li>
<li><p>환경 변수 등록을 완료하지 않았더라도 IDE(인텔리제이)에서 인식할 수 있으면 됩니다.</p>
</li>
</ul>
</li>
<li><p><strong>Docker Desktop</strong> (일부 운영체제의 오래된 버전에서 도커가 지원되지 않습니다.)</p>
<ul>
<li>⭐️ 설치 후 첫 실행까지 완료해 주십시오.</li>
</ul>
</li>
<li><p><strong>Intellij IDEA Community</strong></p>
</li>
<li><p><strong>DBeaver</strong> 또는 기타 범용 DB 접속 툴</p>
</li>
<li><p><strong>Postman</strong></p>
</li>
</ul>
<hr />
<h1 id="heading-spring-boot">Spring Boot 프로젝트 생성</h1>
<h2 id="heading-7iod7isx">생성</h2>
<ul>
<li>Spring Initializr: <a target="_blank" href="https://start.spring.io/">https://start.spring.io/</a></li>
</ul>
<p>위 사이트에서 다음 내용으로 입력합니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720413431222/f3fabd2c-c0f6-4d5f-a251-072cffc8eebb.png" alt="스프링 이니셜라이저 사이트에서 세팅한 내용의 캡처본 사진. 빌드 도구는 그레이들이고, 그레이들의 언어는 코틀린으로, 프로젝트 언어는 자바로, 버전은 스냅샷이 아닌 것 중 가장 높은 버전으로 선택하였다. 이 시점에서는 삼쩜삼쩜일 버전이 가장 높다. 그룹명은 com.example이고, 아티팩트 이름과 네임은 example-springboot3-webmvc이다. 디스크립션은 수정할 필요가 없고, 패키징은 jar로 한다. 자바 버전은 21로 선택했다. 오른쪽 디펜던시스는 아무것도 추가하지 않았다." class="image--center mx-auto" /></p>
<ul>
<li><p>(그림의 왼쪽) 버전은 메이저 버전이 3인 것 중에 선택합니다. (SNAPSHOT 제외)</p>
</li>
<li><p>(그림의 오른쪽) 의존성 라이브러리를 미리 추가할 수 있지만, 우리는 곧 직접 작성할 예정입니다.</p>
</li>
</ul>
<p>이제 페이지에서 'GENERATE' 버튼을 찾아서 누릅니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720413330856/a5b34977-5ad8-479f-8d36-14d2d39db231.png" alt="생성 버튼 모양을 캡처한 사진." class="image--center mx-auto" /></p>
<h2 id="heading-7jum7ygs7iqk7y6y7j207iqkio2ptounlcdsg53shle">워크스페이스 폴더 생성</h2>
<p>가급적 단순한 경로에 워크스페이스용 폴더를 생성합니다. 경로 예시는 다음과 같습니다.</p>
<ul>
<li><p>(윈도우) <code>D:\workspace-group\intellij-personal-workspace</code></p>
</li>
<li><p>(Mac) <code>/Users/username/Desktop/workspace-group/intellij-personal-workspace</code></p>
</li>
</ul>
<blockquote>
<p><strong>💡 참고사항</strong></p>
<ul>
<li><p>PC 사용자 이름을 포함하여 전체 경로 중 <strong>한글이 없어야</strong> 안정적입니다(영문, 숫자).</p>
</li>
<li><p>띄어쓰기도 경로에 포함하지 않는 것이 낫습니다. (특히 STS나 이클립스를 병용하는 분들)</p>
</li>
<li><p>띄어쓰기 대신 하이픈(-)을 사용해도 좋습니다.</p>
</li>
</ul>
</blockquote>
<h2 id="heading-7jum7ygs7iqk7y6y7j207iqkio2ptounloyxkcdsnbtrj5k">워크스페이스 폴더에 이동</h2>
<p>Spring initializr(<a target="_blank" href="https://start.spring.io/">https://start.spring.io/</a>)에서 생성한 프로젝트를 워크스페이스로 옮깁니다.</p>
<p><strong>압축을 풀어야 합니다.</strong></p>
<hr />
<h1 id="heading-7j247ywu66as7kcc7j20ioylpo2wisdrsi8g7isk7kcv">인텔리제이 실행 및 설정</h1>
<p>인텔리제이를 실행하고, 아까 생성한 프로젝트 폴더를 엽니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720418498459/fd826116-9b58-4baf-8cc7-a1cf8b31ba0f.png" alt class="image--center mx-auto" /></p>
<p>프로젝트를 열었을 때 최상위 폴더(루트)가 프로젝트 폴더의 이름이어야 합니다. 아까 작성한 내용대로 했다면 <code>example-springboot3-webmvc</code> 폴더입니다.</p>
<p>인텔리제이에서 세세한 UI는 저와 다를 수 있습니다(각 메뉴의 아이콘 모양 등).</p>
<h2 id="heading-7j247ywu66as7kcc7j20ioyepoyglq">인텔리제이 설정</h2>
<p>설정(Mac: preferences, Windows: settings)을 열어 줍니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720419017947/a97fbd91-1294-45e4-9aa3-f23e786d6e1e.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-gradle">인텔리제이 Gradle 설정</h3>
<p>Gradle 속성을 작성하는 언어는 <code>groovy</code> 또는 <code>kotlin</code>입니다. 두 언어는 모두 JVM 언어에 해당하며, 사용할 JVM을 우리가 설정해 줄 수 있습니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720419332784/b3a4604b-80c2-49ed-b0af-6178f14c4832.png" alt class="image--center mx-auto" /></p>
<p>빌드 도구 설정을 찾아 Gradle 설정에서 Gradle JVM을 Project SDK로 선택합니다. 옆에 뜨는 작은 글씨는 아직 다른 내용일 수 있습니다. 확인(OK)을 눌러 줍니다.</p>
<h3 id="heading-sdk">프로젝트 구조에서 프로젝트 SDK 설정</h3>
<p>파일 &gt; 프로젝트 구조(Project Structure)를 선택합니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720419861916/c4bfb553-e158-4cd9-84ae-cf0f37a7af84.png" alt class="image--center mx-auto" /></p>
<p><strong>Project 탭에서 project SDK를 JDK 21로 합니다.</strong></p>
<p>창이 뜨면 왼쪽 탭에서 project를 선택 후 SDK를 선택합니다. 이것이 다른 설정에서 Project SDK로 뜨는 항목입니다.</p>
<p>우리가 설치한 JDK 21(Amazon Corretto 21)을 선택합니다. 이미 되어 있을 수도 있습니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720419925532/7acbcd17-17dd-45b6-bd60-dc90de329278.png" alt class="image--center mx-auto" /></p>
<p><strong>Modules 탭에서 언어 수준을 project default로 합니다.</strong></p>
<p>이미 되어 있을 수도 있습니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720420283363/7ea5ce82-203c-4b9c-a3f3-0c189d692bcd.png" alt class="image--center mx-auto" /></p>
<p>완료(OK)를 선택합니다.</p>
<h3 id="heading-7j6e7ys7yq4ioyyteyfmcdshktsoju">임포트 옵션 설정</h3>
<p>구글의 자바 코드 컨벤션에서는 임포트 시 <code>*</code>(asterisk) 임포트 대신 개별 임포트를 추천하고 있습니다.</p>
<p>다시 설정 창(preferences 또는 settings)을 열고, import를 검색합니다.</p>
<p>에디터 &gt; 코드 스타일 중 자바를 선택하면, 인텔리제이는 다음처럼 관련 메뉴에 하이라이트를 켜 줍니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720420724590/39b763fa-d302-4978-bcaa-8a2c5abe763d.png" alt class="image--center mx-auto" /></p>
<p>imports 탭을 선택하여, 다음처럼 수정해 줍니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1720420998203/018416c6-b386-4d8e-86b2-cc95c0bcf9dd.png" alt class="image--center mx-auto" /></p>
<ul>
<li><p>내부 클래스(inner class)를 임포트하도록 설정했습니다. 일부 코드 습관에 도움이 됩니다.</p>
</li>
<li><p>Asterisk(<code>*</code>)를 통한 와일드카드식 임포트를 막기 위해, 동일 출처에서 개별 임포트를 999개까지 할 수 있도록 하였습니다.</p>
</li>
</ul>
<p>이제 인텔리제이 작업 환경의 준비가 완료되었습니다.</p>
<hr />
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-2-kor"><strong>Next &gt;</strong></a></p>
<p><a target="_blank" href="https://letsdev.hashnode.dev/step-by-step-springboot3-2-kor">Docker Compose로 프로젝트별 DB 설치</a></p>
]]></content:encoded></item><item><title><![CDATA[Spring: interface ErrorCode 요약 정리]]></title><description><![CDATA[글의 발단
저는 확장성과 편의성, 더 나은 설계 방향(특히 설계상 의존성의 방향을 바로잡는 것)을 위해 인터페이스 에러 코드를 사용해 왔습니다. 그리고 관련해서 포스팅도 한두 차례 해 본 적이 있죠. 그런데 글의 이해를 위해 필요한 부연 설명, 서두의 배경 설명이 모두 길다 보니 글을 읽는 것이 부담이 되어 보였습니다.
그래서 이를 축약하여 정리해 봅니다.
ErrorCode와 Custom Exception
기존 enum Error Code 방식...]]></description><link>https://blog.letsdev.me/synopsys-interface-error-code-kor</link><guid isPermaLink="true">https://blog.letsdev.me/synopsys-interface-error-code-kor</guid><category><![CDATA[Java]]></category><category><![CDATA[custom exception]]></category><category><![CDATA[exception handler]]></category><category><![CDATA[exceptionhandling]]></category><category><![CDATA[error code]]></category><dc:creator><![CDATA[Merge Simpson]]></dc:creator><pubDate>Sat, 25 May 2024 06:43:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1716637511696/17e5619b-f234-4e46-ac72-dc17b227ef70.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-6ria7j2yiouwnoulqa">글의 발단</h1>
<p>저는 확장성과 편의성, 더 나은 설계 방향(특히 설계상 의존성의 방향을 바로잡는 것)을 위해 인터페이스 에러 코드를 사용해 왔습니다. 그리고 관련해서 포스팅도 한두 차례 해 본 적이 있죠. 그런데 글의 이해를 위해 필요한 부연 설명, 서두의 배경 설명이 모두 길다 보니 글을 읽는 것이 부담이 되어 보였습니다.</p>
<p>그래서 이를 축약하여 정리해 봅니다.</p>
<h1 id="heading-errorcode-custom-exception">ErrorCode와 Custom Exception</h1>
<h2 id="heading-enum-error-code">기존 enum Error Code 방식</h2>
<p>기존에는 사람들이 <code>enum</code>으로 된 <code>ErrorCode</code>에 프로젝트 전체의 예외 종류를 관리했습니다.</p>
<p>그러나 이 방식은 다음과 같은 이슈를 포함합니다.</p>
<ul>
<li><p><code>enum</code>은 상속되지 않습니다. 따라서 모든 에러 코드를 하나의 파일에 모아 두게 됩니다.</p>
</li>
<li><p>과도한 계획성을 요구합니다. 이 <code>ErrorCode</code> 열거 타입에 의존성을 갖고 있는 각 서비스들이 어떤 에러를 계획하고 있는지 이곳에 명시되어야 합니다.</p>
</li>
<li><p>결과적으로 의존성의 방향이 '구현'에서와 '계획'에서 상이합니다.</p>
<ul>
<li><p>의도는 각 세부 서비스가 <code>ErrorCode</code>에 의존하는 것입니다.</p>
<p>  세부 서비스 → <code>ErrorCode</code> (구현 시)</p>
</li>
<li><p>계획된 것은 <code>ErrorCode</code>에 각 세부 서비스에 대한 계획상 의존이 발생합니다.</p>
<p>  <code>ErrorCode</code> → 세부 서비스 (계획 시)</p>
</li>
</ul>
</li>
<li><p>공통 부분이기 때문에 여러 작업자의 커밋이 이 파일에 몰릴 수 있습니다.</p>
<p>  이는 빈번한 충돌로 연결될 수 있으며, 필요하다면 별도 작업 방식을 정해야 합니다.</p>
</li>
<li><p>한 파일로 커버하는 영역이 너무 넓기 때문에, 너무 비대한 파일이 되거나 유지보수 편의상 추상적 에러 코드 목록만 관리하게 됩니다.</p>
</li>
</ul>
<p>분류되지 않은 에러 코드는 이처럼 설계 관점에 어긋나 있고, 작업의 불편을 초래합니다.</p>
<h2 id="heading-error-code">인터페이스를 통해 확장된 Error Code</h2>
<p>그래서 저는 최상위에 인터페이스로 된 에러 코드를 두었습니다. 그리고 각 리소스나 피처 단위에서 에러 코드를 관리하고, 그 단계에서 <code>enum</code>을 사용하여 이 인터페이스 <code>ErrorCode</code>를 구현하도록 정했습니다.</p>
<h3 id="heading-interface-errorcode">Interface ErrorCode 예시</h3>
<p>저는 우선 다음 메서드들이 공통적으로 필요하다고 보았습니다.</p>
<p>그 외 메서드는 필요에 따라 추가하실 수 있다고 생각합니다.</p>
<ul>
<li><p><code>String name()</code>: 각 열거 상수의 이름을 문자열로 반환해 주는 메서드로, 오버라이딩 하지 않아도 하위 <code>enum</code>에서 자동으로 구현됩니다.</p>
<blockquote>
<p>💡 (참고) <code>enum</code>에는 <code>name()</code> 메서드와 <code>ordinal()</code> 메서드 등이 자동으로 오버라이딩 됩니다. 에러 코드는 나열 순서와 무관하기 때문에 <code>ordinal()</code>은 애초에 배제했습니다.</p>
</blockquote>
</li>
<li><p><code>String defaultMessage()</code>: 각 에러 코드가 제공할 예외 메시지입니다.</p>
</li>
<li><p><code>HttpStatus defaultHttpStatus()</code>: 각 에러 코드가 제공할 HTTP 상태 코드입니다.</p>
</li>
<li><p><code>RuntimeException defaultException()</code>: 각 에러 코드가 기본적으로 제공할 예외입니다.</p>
</li>
<li><p><code>RuntimeException defaultException(Throwable cause)</code>: 에러 콜스택을 추가할 수 있습니다.</p>
</li>
</ul>
<pre><code class="lang-java"><span class="hljs-keyword">import</span> org.springframework.http.HttpStatus;

<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">ErrorCode</span> </span>{
    <span class="hljs-function">String <span class="hljs-title">name</span><span class="hljs-params">()</span></span>; <span class="hljs-comment">// automatically overridden at enum</span>
    <span class="hljs-function">String <span class="hljs-title">defaultMessage</span><span class="hljs-params">()</span></span>;
    <span class="hljs-function">HttpStatus <span class="hljs-title">defaultHttpStatus</span><span class="hljs-params">()</span></span>;
    <span class="hljs-function">RuntimeException <span class="hljs-title">defaultException</span><span class="hljs-params">()</span></span>;
    <span class="hljs-function">RuntimeException <span class="hljs-title">defaultException</span><span class="hljs-params">(Throwable cause)</span></span>;
}
</code></pre>
<h3 id="heading-error-code-1">리소스 및 피처 단위로 분류한 Error Code 구현 예시</h3>
<ul>
<li><p><code>implements ErrorCode</code>: 에러 코드 인터페이스를 구현합니다.</p>
</li>
<li><p><code>CustomException</code>: 우리가 에러 코드와 긴밀하게 사용할 커스텀 예외입니다.</p>
</li>
</ul>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">enum</span> <span class="hljs-title">SignUpErrorCode</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">ErrorCode</span> </span>{
    <span class="hljs-comment">// 열거_상수("message", status)</span>
    CONFLICTED_USERNAME(<span class="hljs-string">"이미 존재하는 아이디입니다."</span>, HttpStatus.CONFLICT),
    CONFLICTED_NICKNAME(<span class="hljs-string">"이미 사용 중인 닉네임입니다."</span>, HttpStatus.CONFLICT),
    <span class="hljs-comment">// ...</span>
    DEFAULT(
            <span class="hljs-string">"회원 가입 오류입니다. 오류가 지속되면 문의하시기 바랍니다."</span>,
            HttpStatus.INTERNAL_SERVER_ERROR
    );

    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> String message; <span class="hljs-comment">// final이지만 static이 아니기 때문에 camel case</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> HttpStatus status;

    <span class="hljs-comment">// 생성자</span>
    SignUpErrorCode() {
        <span class="hljs-keyword">this</span>.message = message;
        <span class="hljs-keyword">this</span>.status = status;
    }

    <span class="hljs-meta">@Override</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> String <span class="hljs-title">defaultMessage</span><span class="hljs-params">()</span> </span>{
        <span class="hljs-keyword">return</span> message;
    }

    <span class="hljs-meta">@Override</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> HttpStatus <span class="hljs-title">defaultHttpStatus</span><span class="hljs-params">()</span> </span>{
        <span class="hljs-keyword">return</span> status;
    }

    <span class="hljs-meta">@Override</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> CustomException <span class="hljs-title">defaultException</span><span class="hljs-params">()</span> </span>{
        <span class="hljs-comment">// 에러 코드를 받는 생성자를 사용합니다. (this가 곧 에러 코드 본인이니까)</span>
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> CustomException(<span class="hljs-keyword">this</span>);
    }

    <span class="hljs-meta">@Override</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> CustomException <span class="hljs-title">defaultException</span><span class="hljs-params">(Throwable cause)</span> </span>{
        <span class="hljs-comment">// 에러 코드를 받는 생성자를 사용합니다. (this가 곧 에러 코드 본인이니까)</span>
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> CustomException(<span class="hljs-keyword">this</span>, cause);
    }
}
</code></pre>
<h3 id="heading-customexception-type-a">CustomException Type A</h3>
<p>간단한 커스텀 예외 예시입니다. 이 예시에서는 모든 생성자가 에러 코드를 반드시 받도록 되어 있습니다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CustomException</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">RuntimeException</span> </span>{
    <span class="hljs-keyword">protected</span> <span class="hljs-keyword">final</span> ErrorCode errorCode;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">CustomException</span><span class="hljs-params">(ErrorCode errorCode)</span> </span>{
        <span class="hljs-keyword">super</span>(errorCode.defaultMessage());
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">CustomException</span><span class="hljs-params">(ErrorCode errorCode, Throwable cause)</span> </span>{
        <span class="hljs-keyword">super</span>(errorCode.defaultMessage(), cause);
    }
}
</code></pre>
<h3 id="heading-customexception-type-b">CustomException Type B</h3>
<p>기본 에러 코드를 추가한 겁니다. 사실 생각보다 쓸 일은 없지만, 설계상 미리 추가해 두는 개념입니다.</p>
<p>이 경우 일부 생성자는 외부에서 ErrorCode를 받지 않는다면 기본 에러 코드를 사용하게 됩니다.</p>
<ul>
<li><code>DEFAULT_ERROR_CODE</code>: Lazy load와 Thread-safe를 보장하기 위해 내부 클래스를 통해 유일 객체를 생성했습니다. 자바는 클래스 로드 타임에 동시성을 보장해 주고, 클래스는 실제로 사용될 때 로드되기 때문에 thread-safe와 lazy load를 모두 보장할 수 있습니다.</li>
</ul>
<pre><code class="lang-java"><span class="hljs-keyword">import</span> org.springframework.http.HttpStatus;

<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CustomException</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">RuntimeException</span> </span>{

    <span class="hljs-comment">// ===== statics =====</span>
    <span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> ErrorCode <span class="hljs-title">getDefaultErrorCode</span><span class="hljs-params">()</span> </span>{
        <span class="hljs-keyword">return</span> DefaultErrorCodeHolder.DEFAULT_ERROR_CODE;
    }

    <span class="hljs-comment">// ===== non-static fields =====</span>
    <span class="hljs-keyword">protected</span> <span class="hljs-keyword">final</span> ErrorCode errorCode;

    <span class="hljs-comment">// ===== Constructors =====</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">CustomException</span><span class="hljs-params">()</span> </span>{
        <span class="hljs-keyword">super</span>(getDefaultErrorCode().defaultMessage());
        <span class="hljs-keyword">this</span>.errorCode = getDefaultErrorCode();
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">CustomException</span><span class="hljs-params">(String message)</span> </span>{
        <span class="hljs-keyword">super</span>(message);
        <span class="hljs-keyword">this</span>.errorCode = getDefaultErrorCode();
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">CustomException</span><span class="hljs-params">(String message, Throwable cause)</span> </span>{
        <span class="hljs-keyword">super</span>(message, cause);
        <span class="hljs-keyword">this</span>.errorCode = getDefaultErrorCode();
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">CustomException</span><span class="hljs-params">(ErrorCode errorCode)</span> </span>{
        <span class="hljs-keyword">super</span>(errorCode.defaultMessage());
        <span class="hljs-keyword">this</span>.errorCode = errorCode;
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">CustomException</span><span class="hljs-params">(ErrorCode errorCode, Throwable cause)</span> </span>{
        <span class="hljs-keyword">super</span>(errorCode.defaultMessage(), cause);
        <span class="hljs-keyword">this</span>.errorCode = errorCode;
    }

    <span class="hljs-comment">// ===== Non-static Methods =====</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> ErrorCode <span class="hljs-title">getErrorCode</span><span class="hljs-params">()</span> </span>{
        <span class="hljs-keyword">return</span> errorCode;
    }

    <span class="hljs-comment">// ===== Inner Classes =====</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">DefaultErrorCodeHolder</span> </span>{ <span class="hljs-comment">// 사용할 때 로드 + 스레드 세이프(클래스 로드 타임은 동시성 보장됨.)</span>
        <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> ErrorCode DEFAULT_ERROR_CODE = <span class="hljs-keyword">new</span> ErrorCode() {
            <span class="hljs-meta">@Override</span>
            <span class="hljs-function"><span class="hljs-keyword">public</span> String <span class="hljs-title">name</span><span class="hljs-params">()</span> </span>{
                <span class="hljs-keyword">return</span> <span class="hljs-string">"SERVER_ERROR"</span>;
            }

            <span class="hljs-meta">@Override</span>
            <span class="hljs-function"><span class="hljs-keyword">public</span> HttpStatus <span class="hljs-title">defaultHttpStatus</span><span class="hljs-params">()</span> </span>{
                <span class="hljs-keyword">return</span> HttpStatus.INTERNAL_SERVER_ERROR;
            }

            <span class="hljs-meta">@Override</span>
            <span class="hljs-function"><span class="hljs-keyword">public</span> String <span class="hljs-title">defaultMessage</span><span class="hljs-params">()</span> </span>{
                <span class="hljs-keyword">return</span> <span class="hljs-string">"서버 오류"</span>;
            }

            <span class="hljs-meta">@Override</span>
            <span class="hljs-function"><span class="hljs-keyword">public</span> CustomException <span class="hljs-title">defaultException</span><span class="hljs-params">()</span> </span>{
                <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> CustomException(<span class="hljs-keyword">this</span>);
            }

            <span class="hljs-meta">@Override</span>
            <span class="hljs-function"><span class="hljs-keyword">public</span> CustomException <span class="hljs-title">defaultException</span><span class="hljs-params">(Throwable cause)</span> </span>{
                <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> CustomException(<span class="hljs-keyword">this</span>, cause);
            }
        };
    }
}
</code></pre>
<h3 id="heading-642uioyeuou2goyggeyducdsu6tsiqtthyag7jii7jm4">더 세부적인 커스텀 예외</h3>
<p>물론 더 세부적인 커스텀 예외를 만들 수 있습니다. 이때는 생성자 일부 또는 전체를 상속받습니다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">SignUpException</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">CustomException</span> </span>{
    <span class="hljs-comment">// intellij: Ctrl + O를 눌러 필요한 생성자를 상속받습니다.</span>
    <span class="hljs-comment">// eclipse: 알트 쉬프트 S였나, 하여간 자동생성을 통해 생성자를 만듭니다.</span>
}
</code></pre>
<h1 id="heading-global-exception-handler">Global Exception Handler</h1>
<p>앞서 설명한 것은 비교적 독창적이어서 다른 회사들에 자주 보이지 않는 방식이었습니다. 이번에 작성할 controller advice는 일반적인 회사들이 많이 채택하고 있는 방식입니다.</p>
<p>컨트롤러 메서드에서까지 catch 하지 않고 throw 된 예외는 이곳에 옵니다. (지정한 예외만)</p>
<p>이곳에서 예외 응답을 결정할 수 있습니다.</p>
<ul>
<li><code>ApiResponseError</code>: 예외 응답을 할 때 사용할 객체로 제가 따로 만든 클래스입니다. 예외 응답에 사용할 클래스는 각자 만들어서 사용하시거나, 비교적 한정적인 사용을 예상한다면 <code>Map</code> 타입으로 사용할 수 있습니다. (<code>Map</code>은 기피하는 작업자들이 많이 있으니 유념하세요.)</li>
</ul>
<pre><code class="lang-java"><span class="hljs-meta">@RestControllerAdvice</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">final</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">GlobalExceptionHandler</span> </span>{
    <span class="hljs-meta">@ExceptionHandler(CustomException.class)</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> ResponseEntity&lt;ApiResponseError&gt; <span class="hljs-title">handleMemberException</span><span class="hljs-params">(CustomException exception)</span> </span>{
        HttpStatus httpStatus = exception
                .getErrorCode()
                .defaultHttpStatus();
        ApiResponseError response = ApiResponseError.of(exception);

        <span class="hljs-keyword">return</span> ResponseEntity
                .status(httpStatus)
                .body(response);
    }
}
</code></pre>
<hr />
<h1 id="heading-7jqp66ga">용례</h1>
<p>단건 조회에 많이 사용하는 <code>Optional&lt;T&gt;</code> 클래스를 취급할 때, <code>orElseThrow(...)</code>와 상성이 좋습니다.</p>
<pre><code class="lang-java">Item item = itemQueryRepository
        .findById(id)
        .orElseThrow(ExampleErrorCode.EXAMPLE::defaultException);
</code></pre>
]]></content:encoded></item></channel></rss>