<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Make good code</title>
        <link>https://velog.io/</link>
        <description>개발자가 되는 그날까지</description>
        <lastBuildDate>Fri, 04 Sep 2026 09:49:25 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Make good code</title>
            <url>https://velog.velcdn.com/images/yoon_bly/profile/e170e8da-df45-4fde-a28a-b68fc760ca9c/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Make good code. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/yoon_bly" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[3개월 동안 제일 비쌌던 실수는 "문서를 믿은 것"이었다 — 혼정 개발기 ⑨ (完)]]></title>
            <link>https://velog.io/@yoon_bly/3%EA%B0%9C%EC%9B%94-%EB%8F%99%EC%95%88-%EC%A0%9C%EC%9D%BC-%EB%B9%84%EC%8C%8C%EB%8D%98-%EC%8B%A4%EC%88%98%EB%8A%94-%EB%AC%B8%EC%84%9C%EB%A5%BC-%EB%AF%BF%EC%9D%80-%EA%B2%83%EC%9D%B4%EC%97%88%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-%E5%AE%8C-ay2e7xup</link>
            <guid>https://velog.io/@yoon_bly/3%EA%B0%9C%EC%9B%94-%EB%8F%99%EC%95%88-%EC%A0%9C%EC%9D%BC-%EB%B9%84%EC%8C%8C%EB%8D%98-%EC%8B%A4%EC%88%98%EB%8A%94-%EB%AC%B8%EC%84%9C%EB%A5%BC-%EB%AF%BF%EC%9D%80-%EA%B2%83%EC%9D%B4%EC%97%88%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-%E5%AE%8C-ay2e7xup</guid>
            <pubDate>Fri, 04 Sep 2026 09:49:25 GMT</pubDate>
            <description><![CDATA[<!--
벨로그 제목: 3개월 동안 제일 비쌌던 실수는 "문서를 믿은 것"이었다 — 혼정 개발기 ⑨ (完)
시리즈: 혼정 개발기
태그: 회고, 코드리뷰, 백엔드, 신입개발자, 사이드프로젝트
-->

<p>마지막 편입니다.</p>
<p>3개월 동안 작업할 때마다 회고를 남겼는데 다 모으니 <strong>27개</strong>였습니다.
그중에서 <strong>여러 번 반복해서 걸린 것</strong>만 뽑았습니다.</p>
<p>반복된 것만 적는 이유는, <strong>한 번 겪은 건 기억나는데 세 번 겪은 건 아직 몸에 안 붙은 것</strong>이라서입니다.</p>
<hr>
<h1 id="1부--제일-비쌌던-실수-뭘-믿을-것인가">1부 — 제일 비쌌던 실수: 뭘 믿을 것인가</h1>
<h2 id="순위를-매기면-이렇습니다">순위를 매기면 이렇습니다</h2>
<table>
<thead>
<tr>
<th>순위</th>
<th>뭘 믿었나</th>
<th>실제로는</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><strong>정리 문서 · 회고 · 메모</strong></td>
<td>코드보다 최소 한 단계 뒤처져 있음</td>
</tr>
<tr>
<td>2</td>
<td><strong>주석</strong></td>
<td>적을 당시의 상태일 뿐. 코드가 바뀌어도 안 따라옴</td>
</tr>
<tr>
<td>3</td>
<td><strong>grep 검색 결과</strong></td>
<td>부분만 일치해서 놓침</td>
</tr>
<tr>
<td>4</td>
<td><strong>파일을 직접 열어서 확인</strong></td>
<td>이것만 믿을 수 있음</td>
</tr>
</tbody></table>
<p><strong>&quot;없다 / 안 된다 / 이미 있다&quot;를 말하기 전에 파일을 열어봐야 합니다.</strong></p>
<p>제가 실제로 이렇게 틀렸습니다.</p>
<ul>
<li><strong>&quot;식사 성향이 가입할 때 저장이 안 된다&quot;고 판단</strong> → grep이 타입 선언 줄을 놓친 거였고, 저장되고 있었습니다</li>
<li><strong>&quot;상대 닉네임 노출은 프라이버시 문제&quot;라고 단정</strong> → 근거가 주석이었는데 이미 오래된 주석이었습니다</li>
<li><strong>&quot;알림은 폴링으로 돼 있다&quot;고 단정</strong> → 근거가 주석 한 줄이었고 <strong>관련 코드는 0줄</strong>이었습니다</li>
<li><strong>&quot;로그아웃 API가 없다&quot;고 판단</strong> → 있었습니다</li>
<li><strong>&quot;함께 먹은 횟수는 서버가 채워준다&quot;고 생각</strong> → <strong><code>0L</code> 하드코딩</strong>이었습니다</li>
</ul>
<h2 id="앱에-필드가-있다와-서버가-값을-채운다는-다릅니다">&quot;앱에 필드가 있다&quot;와 &quot;서버가 값을 채운다&quot;는 다릅니다</h2>
<p>이게 특히 자주 났습니다.</p>
<p>같은 사람이 <strong>친구 목록에서는 사진이 보이는데 혼밥러 목록에서는 기본 아이콘</strong>으로 나왔습니다.
화면 문제인 줄 알았는데, <strong>응답 DTO에 사진 필드가 아예 없던 것</strong>이었습니다.</p>
<p>반대로 <strong>신고 상세 내용은 응답에 이미 들어 있는데 화면에서만 안 보여주고</strong> 있었습니다.</p>
<blockquote>
<p><strong>API는 양쪽을 다 열어봐야 합니다. 한쪽만 보면 반드시 틀립니다.</strong></p>
</blockquote>
<h2 id="진행률-문서를-믿으면-안-됐습니다">진행률 문서를 믿으면 안 됐습니다</h2>
<p>요구사항 엑셀에 완료 체크박스를 관리했는데, <strong>7월 초부터 갱신이 멈춰 있었습니다.</strong></p>
<p>7월 말에 전부 다시 확인해봤더니 — <strong>체크 안 된 38개 중 대부분이 이미 구현돼 있었습니다.</strong>
진짜 안 만든 건 <strong>딱 1개</strong>였습니다.</p>
<blockquote>
<p><strong>완료 여부의 1차 출처는 git log와 코드입니다.</strong> 정리 문서는 보조입니다.</p>
</blockquote>
<h2 id="그리고-문서가-코드와-어긋나-있었습니다">그리고 문서가 코드와 어긋나 있었습니다</h2>
<p>프로젝트 막바지에 전체 코드를 리뷰하면서 <strong>문서가 코드와 다른 곳을 전수 조사</strong>했더니 이렇게 나왔습니다.</p>
<table>
<thead>
<tr>
<th>문서</th>
<th>뭐라고 적혀 있었나</th>
<th>실제</th>
</tr>
</thead>
<tbody><tr>
<td>아키텍처 문서</td>
<td>&quot;푸시 알림은 <strong>보류</strong>&quot;</td>
<td>이미 만들어서 배포까지 함</td>
</tr>
<tr>
<td>아키텍처 문서</td>
<td>&quot;CI/CD: GitHub Actions&quot;</td>
<td><strong>그런 거 없음</strong></td>
</tr>
<tr>
<td>VS Code 실행 설정</td>
<td>&quot;빌드 도구가 <code>.env</code>를 읽는다&quot;</td>
<td>그런 코드 없음. <strong>그대로 따라하면 가짜 모드로 떠서 에러</strong></td>
</tr>
<tr>
<td>ERD 문서</td>
<td>마이그레이션 V1~V4</td>
<td>실제는 V1~V30 (<strong>21개 누락</strong>)</td>
</tr>
<tr>
<td>ERD 문서</td>
<td>채팅 테이블 항목 자체가 없음</td>
<td>실제로는 있음</td>
</tr>
<tr>
<td>개인정보 처리방침</td>
<td>탈퇴 시 &quot;지체 없이 파기&quot;</td>
<td>실제는 <strong>익명화 후 보존</strong></td>
</tr>
</tbody></table>
<p><strong>마지막 줄이 제일 아픕니다.</strong> 문서 오류가 아니라 <strong>법적 문제</strong>니까요.</p>
<h3 id="여기에-제-결정-하나가-기여했습니다">여기에 제 결정 하나가 기여했습니다</h3>
<p>6월에 <strong><code>docs/</code> 폴더를 git 추적에서 뺐습니다.</strong> 설계 문서를 편하게 쓰려는 의도였습니다.</p>
<p>대가는 나중에 나왔습니다.</p>
<blockquote>
<p><strong>문서가 코드와 어긋나도 diff에 안 잡힙니다.</strong></p>
</blockquote>
<p>실제로 같은 브랜치의 앞 커밋이 설정 파일을 바꿔서 문서 설명이 거짓이 됐는데,
<strong>그 파일을 두 번이나 편집하면서 그 줄을 못 봤습니다.</strong></p>
<blockquote>
<p><strong>문서 한 줄만 고치면 나머지가 거짓이 됩니다. 편집한 파일은 전체를 다시 봐야 합니다.</strong></p>
</blockquote>
<hr>
<h1 id="2부--다섯-번-반복된-것-같은-숫자를-여러-곳에서-세면-갈라진다">2부 — 다섯 번 반복된 것: 같은 숫자를 여러 곳에서 세면 갈라진다</h1>
<p><strong>3개월 내내 제일 자주 나온 버그 유형입니다.</strong></p>
<table>
<thead>
<tr>
<th>시점</th>
<th>증상</th>
<th>원인</th>
<th>해법</th>
</tr>
</thead>
<tbody><tr>
<td>07-04</td>
<td>혼밥 횟수가 화면마다 <strong>12 vs 15</strong></td>
<td>세는 쿼리가 <strong>3곳</strong>인데 &quot;취소 제외&quot;를 2곳만 넣음</td>
<td>기준 통일</td>
</tr>
<tr>
<td>07-19</td>
<td>&quot;N명이 혼밥했어요&quot; <strong>3</strong> vs 그래프 막대 합 <strong>7</strong></td>
<td>별도 쿼리와 그래프 쿼리가 따로</td>
<td>별도 쿼리 <strong>삭제</strong>, 막대 합에서 계산</td>
</tr>
<tr>
<td>07-20</td>
<td>저장한 친구 수가 안 맞음</td>
<td>개수 세는 쿼리가 따로 있음</td>
<td>쿼리 <strong>삭제</strong>, 목록 길이에서 계산</td>
</tr>
<tr>
<td>07-27</td>
<td>같이먹기 탭에 <strong>누를 수 없는 버튼</strong>이 뜸</td>
<td>탭 목적(같이먹기)과 데이터 기준(혼밥중)이 어긋남</td>
<td>이미 있는 함수 재사용</td>
</tr>
<tr>
<td>08-01</td>
<td>대화 목록 <strong>정렬 순서와 표시 시간이 다름</strong></td>
<td>정렬 기준과 표시 값이 다른 컬럼</td>
<td>하나로 통일</td>
</tr>
</tbody></table>
<p><strong>해법이 매번 같습니다.</strong></p>
<blockquote>
<p><strong>쿼리를 하나 더 만들지 말고, 이미 있는 값에서 계산해서 씁니다.</strong></p>
</blockquote>
<p>&quot;목록에 보이는 시간이 곧 정렬 순서&quot;가 되게 만들면 <strong>구조적으로 어긋날 수가 없습니다.</strong></p>
<p>그리고 <strong>새 상태값을 추가할 때는 그 상태를 세는 쿼리를 전부 찾아봐야</strong> 합니다.
07-04 버그가 정확히 그걸 안 해서 났습니다.</p>
<p>★하나 더. <strong>의미가 바뀐 필드에 옛날 이름을 남기면 반드시 오해합니다.</strong>
<code>hereNow</code>(여기 있음)를 <code>seekingNow</code>(모집 중)로 바꿀 때, <strong>쿼리·DTO·서비스·앱 타입·화면·테스트를 한 번에 다 바꿨습니다.</strong></p>
<hr>
<h1 id="3부--조용히-실패하는-게-제일-비싸다">3부 — 조용히 실패하는 게 제일 비싸다</h1>
<p>이 프로젝트를 관통하는 주제입니다.</p>
<table>
<thead>
<tr>
<th>어디서</th>
<th>조용히 뭐가 됐나</th>
</tr>
</thead>
<tbody><tr>
<td>카카오 로그인</td>
<td>앱 키가 없는데 부팅 실패가 아니라 <strong>무한 401</strong>로 나타남</td>
</tr>
<tr>
<td>카카오 로그인</td>
<td>앱이 실패 원인을 <code>&#39;잠시 후 다시 시도해주세요&#39;</code> 로 <strong>삼킴</strong></td>
</tr>
<tr>
<td>JWT</td>
<td>안 채워진 <code>&quot;${JWT_SECRET}&quot;</code> 이 <strong>그대로 암호 키</strong>가 됨</td>
</tr>
<tr>
<td>배포</td>
<td>서버 주소 누락이 대체값 때문에 <strong>심사 후에야</strong> 드러남</td>
</tr>
<tr>
<td>백업</td>
<td>잘린 백업 파일이 &quot;크기 0 아님&quot; 검사를 <strong>통과</strong></td>
</tr>
<tr>
<td>앱</td>
<td>결과를 안 기다리는 호출이라 <strong>사진 전송 실패가 화면에 안 뜸</strong></td>
</tr>
<tr>
<td>서버</td>
<td>응답 구조를 바꿔도 서버는 <strong>200을 주니까 아무 신호 없음</strong></td>
</tr>
</tbody></table>
<p><strong>처방이 전부 같습니다.</strong></p>
<pre><code>① 실패를 삼키지 않는다 — 원인을 로그로든 화면으로든 드러낸다
② 잘못된 상태로 뜰 바에는 아예 부팅을 실패시킨다
③ 비동기 호출이 두 개면 둘 다 감싼다 (앞만 감싸면 뒤쪽 실패는 무음)</code></pre><p>★카카오 로그인 디버깅이 제일 상징적이었습니다.</p>
<blockquote>
<p><strong>로그를 넣기 전까지 원인을 계속 잘못 짚었습니다.</strong>
OIDC 설정 문제인 줄 알았는데 실제는 <strong>번들 ID가 안 맞는 것</strong>이었습니다.
<code>console.warn</code> 한 줄을 넣은 순간 <strong>진짜 메시지가 바로 나왔습니다.</strong></p>
<p><strong>실패 원인을 감추는 코드는 디버깅 시간을 몇 배로 늘립니다.</strong></p>
</blockquote>
<hr>
<h1 id="4부--jpa와-트랜잭션에서-반복해서-걸린-것">4부 — JPA와 트랜잭션에서 반복해서 걸린 것</h1>
<h2 id="①-실패해도-남아야-하는-기록이-같이-지워집니다">① &quot;실패해도 남아야 하는 기록&quot;이 같이 지워집니다</h2>
<pre><code class="language-java">@Transactional
public void verify(...) {
    record.increaseAttempts();      // 시도 횟수 +1
    if (틀림) throw ...;             // ← 여기서 위의 +1도 같이 취소된다
}</code></pre>
<p><code>@Transactional</code>은 <strong>&quot;안에서 하나라도 실패하면 전부 없던 일로&quot;</strong> 라는 뜻입니다.
그래서 <strong>잠금 카운트 같은 건 실패해도 남아야 하는데 같이 지워집니다.</strong></p>
<p>해결은 <strong>별도 클래스로 빼고 <code>REQUIRES_NEW</code></strong> 를 붙이는 것입니다.
(= &quot;이건 별도 트랜잭션으로 처리해라. 바깥이 실패해도 이건 남겨라&quot;)</p>
<p>★<strong>같은 클래스 안에서 호출하면 안 먹힙니다.</strong> 반드시 다른 클래스여야 합니다.
스프링이 객체를 감싸서 동작하는 구조인데, 같은 클래스 안 호출은 그 감싼 걸 안 거치기 때문입니다.</p>
<p><strong>이걸 3번 겪고 4개 클래스를 분리했습니다.</strong></p>
<p>★그리고 <strong>이런 버그는 가짜 객체 테스트로 절대 못 잡습니다.</strong> 실제 DB를 써야 합니다.</p>
<h2 id="②-modifyingclearautomatically--true">② <code>@Modifying(clearAutomatically = true)</code></h2>
<p><strong>그 테이블이 아니라 JPA가 들고 있던 객체 전부를 놔버립니다.</strong> (<a href="#">5편</a>·<a href="#">8편</a>)</p>
<p>같은 옵션인데 <strong>정반대로 판단한 곳이 두 군데</strong>입니다.</p>
<pre><code>알림 전체 읽음 처리   → 씀    (뒤에서 그 객체를 안 건드리니까)
회원 탈퇴            → 금지  (뒤에서 User를 고쳐야 하니까)</code></pre><p><strong>금지 이유를 메서드 주석에 적어뒀습니다.</strong> 안 그러면 누가 &quot;관례인데요?&quot; 하면서 되돌립니다.</p>
<h2 id="③-소프트-삭제에서는-on-delete-cascade가-안-돕니다">③ 소프트 삭제에서는 <code>ON DELETE CASCADE</code>가 안 돕니다</h2>
<p>행을 안 지우니까요. 개인정보 11개 테이블을 <strong>하나씩 명시적으로 지워야</strong> 합니다.</p>
<p>그리고 함정이 둘 더 있었습니다.</p>
<ul>
<li>친구·차단 관계는 <strong>양방향으로 지워야</strong> 합니다. 내 쪽만 지우면 <strong>상대에게는 반쯤 살아 있습니다.</strong></li>
<li>휴대폰 인증 기록은 users와 연결이 없어서(번호 단위로 저장) 자동 정리에서 빠집니다.
→ 번호로 따로 지워야 하고, <strong>번호를 비우기 전에 먼저 지워야</strong> 합니다.</li>
</ul>
<h2 id="④-시간대는-세-번-틀리고-나서-근본적으로-고쳤습니다">④ 시간대는 세 번 틀리고 나서 근본적으로 고쳤습니다</h2>
<p>앱은 한국시간을 저장하는데 DB <code>now()</code>는 UTC라 9시간 차이가 났습니다.</p>
<p>세 번째에 이렇게 고쳤습니다.</p>
<pre><code class="language-sql">ALTER DATABASE honjeong SET timezone=&#39;Asia/Seoul&#39;;
-- + Docker 설정에도 같이 적어서 새 환경에서도 적용되게</code></pre>
<p>★그런데 <strong>새 서버에 DB를 만들 때 이 설정을 빠뜨리면 다시 재발합니다.</strong>
실제로 배포할 때 재발했습니다. (<a href="#">7편</a>)</p>
<h2 id="⑤-마이그레이션은-v1이-아니라-지금까지-쌓인-상태입니다">⑤ 마이그레이션은 V1이 아니라 &quot;지금까지 쌓인 상태&quot;입니다</h2>
<p>V12에서 컬럼에 주석을 달았는데, <strong>그 컬럼은 V3에서 이미 삭제돼 있었습니다.</strong>
Flyway가 실패했습니다.</p>
<p><strong>새 마이그레이션을 쓰기 전에 이전 버전의 삭제·변경을 훑어서 현재 컬럼을 확인해야</strong> 합니다.</p>
<p>그리고 <strong>Testcontainers가 이걸 잡아주는 안전망</strong>이었습니다.
커밋 전에 전체 테스트를 돌리는 이유가 여기 있습니다.</p>
<hr>
<h1 id="5부--검증에-대해-배운-것">5부 — 검증에 대해 배운 것</h1>
<h2 id="검증한-값과-실제로-쓰는-값이-다르면-검증은-장식입니다">검증한 값과 실제로 쓰는 값이 다르면, 검증은 장식입니다</h2>
<p>이미지 업로드에서 나왔습니다.</p>
<pre><code>FileService      : Content-Type이 image/ 로 시작하는지 확인   ← 클라이언트가 보낸 값
LocalFileStorage : 저장할 파일 이름의 확장자는 원본 파일명에서 그대로 가져옴</code></pre><p><strong>이 두 값은 서로 아무 관계가 없습니다.</strong></p>
<p>저장된 파일은 <code>GET /files/**</code> 로 누구나 볼 수 있고,
<strong>스프링은 확장자를 보고 파일 종류를 정합니다.</strong></p>
<p>즉 <code>Content-Type: image/png</code> 로 보내면서 파일 이름만 <code>x.html</code> 로 하면
<strong>우리 도메인에 HTML 파일이 올라가고 브라우저가 그걸 실행합니다.</strong></p>
<p>(<code>.svg</code>도 마찬가지입니다. 종류가 <code>image/svg+xml</code>이라 <strong>이미지 검사를 정정당당하게 통과</strong>하는데, SVG는 XML이라 안에 스크립트를 넣을 수 있습니다.)</p>
<blockquote>
<p><strong>뭘 근거로 뭘 결정하는지 한 줄로 이어봐야 드러납니다.</strong></p>
</blockquote>
<p>지금은 앱만 있어서 큰 문제는 아닌데, <strong>나중에 웹 화면을 붙이는 순간 진짜 문제</strong>가 됩니다.</p>
<h2 id="검증은-api가-아니라-컬럼-단위로-짝을-맞춰야-합니다">검증은 API가 아니라 컬럼 단위로 짝을 맞춰야 합니다</h2>
<p>닉네임 길이 제한이 <strong>프로필 수정 API에는 있고 가입 완료 API에는 없었습니다.</strong></p>
<p>결과가 고약합니다.</p>
<blockquote>
<p>가입할 때 긴 닉네임을 넣은 사람이 있으면,
<strong>그 사람을 신고하는 다른 사람의 요청이</strong> 신고 테이블의 길이 제한에 걸려서 터집니다.</p>
<p><strong>피해자가 제3자입니다.</strong></p>
</blockquote>
<p><strong>같은 컬럼을 쓰는 API가 여러 개면 전부 같은 제한을 걸어야 합니다.</strong></p>
<hr>
<h1 id="6부--리뷰에-대해-배운-세-가지">6부 — 리뷰에 대해 배운 세 가지</h1>
<h2 id="①-고친-뒤에도-리뷰가-필요합니다">① 고친 뒤에도 리뷰가 필요합니다</h2>
<p>푸시 알림 작업에서 <strong>재리뷰가 1차에서 못 본 3건을 잡았습니다.</strong>
그중 <strong>둘은 1차 수정이 만든 것</strong>이었습니다.</p>
<p>카카오 로그인도 같았습니다. 1차 지적을 고치다가 넣은 설정이 <strong>새로운 심각한 문제</strong>를 만들었고, 재리뷰가 잡았습니다.</p>
<blockquote>
<p><strong>&quot;서버가 안 뜨는 문제&quot;를 고치다가 &quot;인증이 뚫리는 문제&quot;를 만들 수 있습니다.</strong></p>
</blockquote>
<h2 id="②-작업-하나하나-리뷰해도-전체-리뷰를-빼면-안-됩니다">② 작업 하나하나 리뷰해도 전체 리뷰를 빼면 안 됩니다</h2>
<p>채팅 기능에서 12개 작업을 하나씩 리뷰받았는데, <strong>마지막 전체 리뷰만 심각한 걸 잡았습니다.</strong></p>
<p>대화방을 <strong>여는 곳은 1곳인데 닫아야 하는 곳은 5곳</strong>이라, <strong>작업 단위로는 각자 다 맞았거든요.</strong></p>
<blockquote>
<p><strong>여러 작업에 걸쳐 있는 문제는 쪼개서 보면 안 보입니다.</strong></p>
</blockquote>
<h2 id="③-리뷰-지적을-그대로-받아들이지-않는-것도-필요합니다">③ 리뷰 지적을 그대로 받아들이지 않는 것도 필요합니다</h2>
<p>푸시 작업에서 리뷰 지적 <strong>4건을 코드로 확인해서 반박</strong>했습니다.
(계산이 반대로 돼 있었고, 지적된 메서드는 이미 아무도 안 쓰는 코드였고, 튜닝 제안은 근거가 없었습니다.)</p>
<p>반대로 <strong>가벼운 지적으로 분류된 1건을 심각으로 올렸습니다.</strong>
배포 시나리오를 따져보니 — <strong>서버 재시작 중에 잠깐 에러를 받은 멀쩡한 사용자가 로그아웃되고 푸시 토큰까지 지워지는</strong> 문제였습니다.</p>
<blockquote>
<p><strong>리뷰 지적이 곧 정답은 아닙니다.</strong> 그대로 받아들이지도, 무시하지도 않고 <strong>코드로 확인</strong>합니다.</p>
</blockquote>
<hr>
<h1 id="7부--남은-것-정직하게">7부 — 남은 것 (정직하게)</h1>
<p>프로젝트가 끝났다고 하면 거짓말이라 남은 걸 적습니다.</p>
<h2 id="코드에-남아-있는-것">코드에 남아 있는 것</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>무엇</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><strong>유니크 제약 20개 중 409로 바꿔주는 게 6곳뿐</strong> — 나머지는 동시 요청 시 500이 나갑니다</td>
</tr>
<tr>
<td>2</td>
<td><strong>폴더끼리 참조 22쌍</strong> — 막아주는 장치가 없습니다</td>
</tr>
<tr>
<td>3</td>
<td><code>CheckIn.start()</code> 안 쓰는 코드 — <strong>정문을 우회하는 길이 열려 있습니다</strong></td>
</tr>
<tr>
<td>4</td>
<td>푸시 토큰 폐기가 실패하면 <strong>다시는 지울 수 없습니다</strong></td>
</tr>
<tr>
<td>5</td>
<td>레이트리밋 없음 (알고 미룬 것)</td>
</tr>
</tbody></table>
<h2 id="기능">기능</h2>
<ul>
<li>즐겨찾기 그룹 공개 설정 — <strong>진짜로 안 만든 유일한 기능</strong></li>
<li>레벨 시스템 · 주간 챌린지 — 설계부터 필요</li>
<li>아직 가짜인 것: <strong>S3 파일 저장</strong>, 본인인증(PASS)</li>
</ul>
<h2 id="문서">문서</h2>
<ul>
<li>ERD가 V21 이후를 못 따라감 (채팅 테이블 항목 자체가 없음)</li>
<li><strong>마이그레이션 파일이 실제 스키마의 기준</strong>입니다</li>
</ul>
<hr>
<h1 id="마치며--3개월-동안-실제로-바뀐-것">마치며 — 3개월 동안 실제로 바뀐 것</h1>
<p>기술을 배운 것보다 <strong>판단하는 방식이 바뀐 게 더 큽니다.</strong></p>
<h2 id="시작할-때">시작할 때</h2>
<pre><code>&quot;문서에 그렇게 적혀 있으니까&quot;
&quot;관례가 그러니까&quot;
&quot;테스트가 다 통과하니까&quot;
&quot;내 컴퓨터에서 잘 되니까&quot;</code></pre><h2 id="지금">지금</h2>
<pre><code>&quot;파일을 열어서 확인했나?&quot;
&quot;이 관례가 여기서도 맞는 이유가 뭔가?&quot;
&quot;일부러 망가뜨렸을 때 빨간불이 나나?&quot;
&quot;로컬에는 없고 서버에만 있는 조건이 뭔가?&quot;</code></pre><p>네 문장이 전부 <strong>&quot;확인했나&quot;</strong> 입니다.</p>
<p>비전공으로 시작해서 제일 크게 얻은 건 문법이나 프레임워크가 아니라,
<strong>&quot;내가 안다고 생각하는 것과 실제로 확인한 것을 구분하는 습관&quot;</strong> 이었습니다.</p>
<p>그리고 이게 실제로 값을 한 순간들이 있었습니다.</p>
<ul>
<li>주석에 &quot;없으면 서버가 안 뜰 것&quot;이라고 적으려다 <strong>확인하러 갔고, 확인이 버그를 잡았습니다</strong></li>
<li>백로그 기능이 어디에 쓰이는지 세다가 <strong>정답이 &#39;삭제&#39;라는 걸 알았습니다</strong></li>
<li>부하 테스트 예상 5개 중 4개가 틀렸다는 걸 <strong>재보고 나서</strong> 알았습니다</li>
<li>&quot;종료 화면 크기가 다르다&quot;는 지적을 <strong>픽셀로 재보니 678 vs 676px로 사실상 같았습니다</strong></li>
</ul>
<hr>
<h2 id="이-시리즈에서-세-문장만-남긴다면">이 시리즈에서 세 문장만 남긴다면</h2>
<blockquote>
<p><strong>① 코드를 직접 열어보는 게 유일한 기준이다.</strong> 문서·주석·회고·검색 결과는 전부 뒤처져 있습니다.</p>
<p><strong>② 조용히 실패하는 게 시끄럽게 실패하는 것보다 수백 배 비싸다.</strong></p>
<p><strong>③ &quot;전부 통과&quot;는 아무것도 증명하지 않는다. 일부러 망가뜨렸을 때 빨간불이 나는지가 증명한다.</strong></p>
</blockquote>
<hr>
<p>3개월 동안 671개 커밋을 쌓았는데, 그중 꽤 많은 게 <strong>뭔가를 지우는 커밋</strong>이었습니다.</p>
<pre><code>&quot;아무도 읽지 않는 페이징을 걷어낸다&quot;
&quot;아무도 부르지 않는 역지오코딩을 걷어낸다&quot;
&quot;FAQ에서 불필요한 4건을 뺀다&quot;</code></pre><p><strong>만드는 것보다 지울 수 있게 되는 데 더 오래 걸렸습니다.</strong></p>
<p>읽어주셔서 감사합니다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[570개가 전부 통과인데 계정이 안 지워지고 있었다 — 혼정 개발기 ⑧]]></title>
            <link>https://velog.io/@yoon_bly/570%EA%B0%9C%EA%B0%80-%EC%A0%84%EB%B6%80-%ED%86%B5%EA%B3%BC%EC%9D%B8%EB%8D%B0-%EA%B3%84%EC%A0%95%EC%9D%B4-%EC%95%88-%EC%A7%80%EC%9B%8C%EC%A7%80%EA%B3%A0-%EC%9E%88%EC%97%88%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-mk1fh6s6</link>
            <guid>https://velog.io/@yoon_bly/570%EA%B0%9C%EA%B0%80-%EC%A0%84%EB%B6%80-%ED%86%B5%EA%B3%BC%EC%9D%B8%EB%8D%B0-%EA%B3%84%EC%A0%95%EC%9D%B4-%EC%95%88-%EC%A7%80%EC%9B%8C%EC%A7%80%EA%B3%A0-%EC%9E%88%EC%97%88%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-mk1fh6s6</guid>
            <pubDate>Fri, 04 Sep 2026 09:47:51 GMT</pubDate>
            <description><![CDATA[<!--
벨로그 제목: 570개가 전부 통과인데 계정이 안 지워지고 있었다 — 혼정 개발기 ⑧
시리즈: 혼정 개발기
태그: 테스트, Testcontainers, JUnit, 백엔드, 신입개발자
-->

<p>백엔드 테스트가 <strong>827개 / 112개 파일</strong>입니다.</p>
<p>그런데 이 편에서 하고 싶은 이야기는 개수가 아닙니다.</p>
<blockquote>
<p><strong>570개가 전부 통과인 상태에서 회원 탈퇴가 계정을 안 지우고 있었습니다.</strong>
<strong>707개가 전부 통과인 상태에서, 방금 고친 걸 지켜주는 테스트가 하나도 없었습니다.</strong></p>
</blockquote>
<p><strong>&quot;전부 통과&quot;는 생각보다 아무것도 증명하지 않습니다.</strong></p>
<hr>
<h1 id="1부--어떻게-짰나">1부 — 어떻게 짰나</h1>
<h2 id="어떤-테스트가-몇-개인가">어떤 테스트가 몇 개인가</h2>
<table>
<thead>
<tr>
<th>종류</th>
<th>붙이는 애노테이션</th>
<th>파일</th>
<th>테스트</th>
<th>비율</th>
</tr>
</thead>
<tbody><tr>
<td><strong>순수 자바 테스트</strong></td>
<td>(없음)</td>
<td>45</td>
<td><strong>376</strong></td>
<td><strong>46%</strong></td>
</tr>
<tr>
<td>컨트롤러만 띄우기</td>
<td><code>@WebMvcTest</code></td>
<td>24</td>
<td>149</td>
<td>18%</td>
</tr>
<tr>
<td>DB 접근만 띄우기</td>
<td><code>@DataJpaTest</code></td>
<td>19</td>
<td>128</td>
<td>16%</td>
</tr>
<tr>
<td>가짜 객체로 서비스 테스트</td>
<td><code>@ExtendWith(MockitoExtension)</code></td>
<td>14</td>
<td>120</td>
<td>15%</td>
</tr>
<tr>
<td>서버 전체 띄우기</td>
<td><code>@SpringBootTest</code></td>
<td>10</td>
<td>52</td>
<td>6%</td>
</tr>
</tbody></table>
<p><strong>아래로 갈수록 무겁고 느립니다.</strong> 서버 전체를 띄우는 건 6%뿐입니다.</p>
<p><strong>순수 자바 테스트가 46%로 제일 많은데</strong>, 이건 로직을 스프링 밖으로 빼는 습관 때문입니다.</p>
<p>예를 들어 이런 것들을 전부 <strong>스프링 없이 도는 함수</strong>로 분리했습니다.</p>
<ul>
<li>체크인 시간을 아침/점심/저녁/밤으로 나누고 붐비는 시간대 계산하기</li>
<li>닉네임 규칙 검사</li>
<li>에러 코드를 사용자 문구로 바꾸기</li>
<li>검색 결과에서 본인 제외하고 가까운 순 정렬하기</li>
</ul>
<p><strong>스프링을 안 띄우니까 밀리초 단위로 돌고, 실패했을 때 원인이 딱 하나입니다.</strong></p>
<h2 id="h2를-안-썼습니다--이-편의-핵심">H2를 안 썼습니다 — 이 편의 핵심</h2>
<p><strong>H2</strong>는 자바로 만든 메모리 DB입니다. 테스트할 때 많이 씁니다.</p>
<table>
<thead>
<tr>
<th></th>
<th>H2</th>
<th>진짜 PostgreSQL</th>
</tr>
</thead>
<tbody><tr>
<td>속도</td>
<td><strong>아주 빠름</strong></td>
<td>느림 (컨테이너를 띄워야 함)</td>
</tr>
<tr>
<td>설정</td>
<td>라이브러리 한 줄</td>
<td>Docker 필요</td>
</tr>
<tr>
<td><strong>실제 서버와 같은가</strong></td>
<td><strong>다름</strong></td>
<td><strong>같음</strong></td>
</tr>
</tbody></table>
<p>속도 때문에 H2를 쓰고 싶어집니다. <strong>안 썼습니다.</strong></p>
<p>대신 <strong>Testcontainers</strong>로 테스트할 때마다 <strong>진짜 PostgreSQL을 컨테이너로 띄웁니다.</strong></p>
<h3 id="왜냐면-h2로는-검증이-안-되는-게-실제로-4가지-나왔습니다">왜냐면 H2로는 검증이 안 되는 게 실제로 4가지 나왔습니다</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>무엇</th>
<th>H2에서는</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><strong>조건부 유니크 인덱스</strong> (<code>WHERE</code>가 붙은 것)</td>
<td><strong>문법 자체가 없음</strong></td>
</tr>
<tr>
<td>2</td>
<td><strong>유니크 위반이 트랜잭션 전체를 망가뜨리는 동작</strong></td>
<td><strong>재현이 안 됨</strong></td>
</tr>
<tr>
<td>3</td>
<td><strong><code>ON CONFLICT</code></strong> (중복이면 넘어가기)</td>
<td>문법이 다름</td>
</tr>
<tr>
<td>4</td>
<td><strong>한글 부분 검색용 인덱스</strong>(pg_trgm)</td>
<td>없음</td>
</tr>
</tbody></table>
<p><a href="#">5편</a>에서 봤듯이 <strong>이 프로젝트의 동시성 방어가 전부 1·2·3에 걸려 있습니다.</strong>
락을 하나도 안 쓰고 DB 제약으로만 막았으니까요.</p>
<blockquote>
<p><strong>H2로 테스트했다면, 그 방어가 진짜 동작하는지 한 번도 확인 못 했을 겁니다.</strong></p>
</blockquote>
<p>실제로 뱃지 지급에서 2번 문제를 만났는데, <strong>그건 H2에서는 재현조차 안 되는 문제</strong>였습니다.</p>
<h3 id="대신-느린-걸-줄였습니다">대신 느린 걸 줄였습니다</h3>
<p>컨테이너를 테스트마다 띄우면 너무 느립니다.
그래서 <strong>한 번만 띄워서 모든 테스트가 같이 쓰게</strong> 했습니다.</p>
<pre><code class="language-java">public abstract class AbstractPostgresTest {
    static PostgreSQLContainer&lt;?&gt; POSTGRES = ...;

    static {              // ← static 블록: 프로그램당 딱 한 번만 실행된다
        POSTGRES.start();
    }
}</code></pre>
<p>DB가 필요한 테스트는 전부 이 클래스를 상속받습니다.</p>
<h2 id="테스트-이름을-한글로-썼습니다">테스트 이름을 한글로 썼습니다</h2>
<pre><code class="language-java">void 발송은_등록시각을_갱신하지_않는다() { ... }
void 다른앱_토큰은_거부한다() { ... }
void 삭제한_대화는_내_목록에서만_빠진다() { ... }</code></pre>
<p>영어로 쓰면 <code>markUsedDoesNotUpdateLastRegisteredAt()</code> 이 됩니다.</p>
<p><strong>테스트 이름은 &quot;이 코드가 지켜야 하는 규칙&quot;을 적는 자리인데, 영어로 쓰면 미묘한 뉘앙스가 날아갑니다.</strong></p>
<p>그리고 <code>@DisplayName</code>을 <strong>799개</strong> 붙였습니다(827개 중 97%).</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;발송 기록은 등록 시각을 건드리지 않는다 — 이게 깨지면 청소가 엉뚱한 걸 지운다&quot;)
void 발송은_등록시각을_갱신하지_않는다() {</code></pre>
<p><strong>&quot;이게 깨지면 무슨 일이 나는지&quot;까지</strong> 이름에 적었습니다.</p>
<p>6개월 뒤에 이 테스트가 빨간불이 됐을 때, <strong>고쳐야 할지 지워도 될지를 이름만 보고 판단</strong>할 수 있게요.</p>
<hr>
<h1 id="2부--하면서-새로-배운-세-가지">2부 — 하면서 새로 배운 세 가지</h1>
<h2 id="①-설정-파일도-테스트합니다">① 설정 파일도 테스트합니다</h2>
<p><strong>이 프로젝트에서 제일 큰 사고 두 개가 코드가 아니라 설정에서 났습니다.</strong> (<a href="#">7편</a>)</p>
<ul>
<li>배포용 설정이 없는 기능을 켜라고 해서 <strong>서버가 100% 안 뜸</strong></li>
<li>Docker 설정의 기본값이 배포용 설정을 덮어써서 <strong>인증이 꺼진 채로 뜸</strong></li>
</ul>
<p>그래서 <strong>설정 파일을 실제로 읽어보는 테스트</strong>를 만들었습니다.</p>
<pre><code class="language-java">// ApplicationContextRunner 로 배포용 설정을 실제로 올려본다
// - 필요한 객체가 제대로 만들어지는가
// - 인증 정보 없이 진짜 모드로 켜면 부팅이 실패하는가
// - 진짜 모드일 때 가짜 검증기가 안 올라오는가</code></pre>
<p><strong>먼저 빨간불이 나는 걸 확인하고</strong> 초록불로 만들었습니다.</p>
<blockquote>
<p><strong>설정 파일도 코드입니다. 그리고 이 프로젝트에서는 코드보다 더 자주 틀렸습니다.</strong></p>
</blockquote>
<h2 id="②-한-번-난-사고는-테스트로-못-박습니다">② 한 번 난 사고는 테스트로 못 박습니다</h2>
<p>인증이 꺼진 채로 떴던 사고 이후, <strong>&quot;진짜 모드일 때 가짜 검증기가 등록되면 실패&quot;</strong> 하는 테스트를 넣었습니다.</p>
<p><strong>같은 사고가 두 번 안 나게 하는 가장 확실한 방법은 그 사고를 테스트로 만드는 것</strong>이었습니다.</p>
<p>회고에 적어두는 걸로는 부족합니다. <strong>회고는 안 읽히는데 빨간불은 읽히니까요.</strong></p>
<h2 id="③-리팩터링으로-만든-성질은-따로-지켜야-합니다">③ 리팩터링으로 만든 성질은 따로 지켜야 합니다</h2>
<p>이게 제일 늦게 배운 겁니다.</p>
<p>푸시 발송 코드를 <strong>조회 / HTTP 호출 / 기록</strong> 세 구간으로 쪼개서
<strong>&quot;FCM에 요청 보낼 때는 트랜잭션이 열려 있지 않다&quot;</strong> 는 상태를 만들었습니다. (<a href="#">5편</a>)</p>
<p><strong>그런데 그걸 지켜주는 테스트가 하나도 없었습니다.</strong></p>
<pre><code>@Transactional 한 줄만 다시 붙이면 원래대로 돌아가는데
                    테스트 707개가 전부 통과</code></pre><p>왜 못 잡느냐면 —</p>
<ul>
<li><strong>가짜 객체(Mockito)로 하는 테스트에는 트랜잭션이라는 게 아예 없습니다.</strong></li>
<li><strong>실제 DB로 하는 테스트는 트랜잭션이 길어져도 어쨌든 저장은 되니까 통과합니다.</strong></li>
</ul>
<p>그래서 이렇게 했습니다.</p>
<pre><code class="language-java">// 가짜 객체가 호출될 때 &quot;지금 트랜잭션이 열려 있나?&quot;를 기록해두고,
// 나중에 그 값을 확인한다.
// (호출 자체가 없으면 값이 null로 남게 해서, 그냥 통과하는 가짜 테스트를 막았다)</code></pre>
<blockquote>
<p><strong>&quot;고쳤다&quot;와 &quot;고친 상태가 유지된다&quot;는 다른 이야기이고, 뒤엣것은 테스트를 따로 만들어야 합니다.</strong>
특히 <strong>애노테이션 한 줄</strong>처럼 되돌리기 쉬울수록 그렇습니다.</p>
</blockquote>
<hr>
<h1 id="3부--전부-통과가-증명하지-못하는-것">3부 — &quot;전부 통과&quot;가 증명하지 못하는 것</h1>
<p>여기가 이 편의 본론입니다.</p>
<h2 id="사고-①--570개-통과인데-계정이-안-지워졌다">사고 ① — 570개 통과인데 계정이 안 지워졌다</h2>
<p><a href="#">4편</a>에서 말한 그 버그입니다.</p>
<pre><code class="language-java">@Modifying(clearAutomatically = true)   // 벌크 삭제할 때 붙이는 관례라고 알고 있었다</code></pre>
<p>이 옵션은 <strong>&quot;그 테이블&quot;만 정리하는 게 아니라 JPA가 들고 있던 모든 객체를 놔버립니다.</strong></p>
<pre><code>① 개인정보 11개 테이블을 삭제   ← 여기서 JPA가 User를 놔버림
② user.withdraw() 호출          ← 이제 이건 그냥 평범한 자바 객체
③ 트랜잭션 끝                    ← DB에 아무것도 안 씀</code></pre><p><strong>결과: 개인정보만 지워지고 계정은 살아 있었습니다.</strong></p>
<p><strong>왜 570개가 전부 통과였나?</strong></p>
<blockquote>
<p>서비스 테스트가 DB 접근을 전부 가짜(Mockito)로 바꿔놨습니다.
<strong>가짜한테는 <code>clearAutomatically</code>가 애초에 실행되지 않습니다.</strong></p>
</blockquote>
<p><strong>통과한 게 오히려 문제였습니다.</strong></p>
<h2 id="사고-②--단언이-있는데-아무것도-검증하지-않았다">사고 ② — 단언이 있는데 아무것도 검증하지 않았다</h2>
<p>같은 시기에 테스트를 전수 점검하다가, <strong>실제로는 아무것도 확인하지 않던 유형 4가지</strong>를 찾았습니다.</p>
<table>
<thead>
<tr>
<th>#</th>
<th>뭐가 문제였나</th>
<th>어떻게 알았나</th>
</tr>
</thead>
<tbody><tr>
<td>①</td>
<td><strong>테스트 데이터가 애초에 비어 있음</strong></td>
<td><code>User.withdraw()</code>의 초기화 줄을 <strong>통째로 지워도 통과</strong> (익명화 확인 12개 중 8개가 무의미했음)</td>
</tr>
<tr>
<td>②</td>
<td>양방향으로 지워야 하는데 <strong>데이터를 한 방향만 넣고 OR로 확인</strong></td>
<td>쿼리를 한쪽만 지우게 고쳐도 안 걸림</td>
</tr>
<tr>
<td>③</td>
<td>두 번 호출해도 같아야 하는데 <strong>null인지만 확인</strong></td>
<td>두 번째 호출이 시각을 덮어써도 통과</td>
</tr>
<tr>
<td>④</td>
<td>정렬 테스트인데 <strong>데이터 배치가 우연히 맞아서</strong></td>
<td>정렬 기준을 뒤집어도 통과</td>
</tr>
</tbody></table>
<p><strong>전부 초록불이었습니다. 그리고 전부 아무것도 안 지키고 있었습니다.</strong></p>
<h2 id="그래서-원칙을-바꿨습니다">그래서 원칙을 바꿨습니다</h2>
<blockquote>
<p><strong>&quot;통과했다&quot;가 아니라 &quot;일부러 망가뜨렸을 때 빨간불이 나나&quot;가 테스트의 증명력입니다.</strong></p>
</blockquote>
<p>방법은 단순합니다.</p>
<pre><code>의심 가는 테스트마다
  → 실제 코드에 버그를 일부러 넣는다
  → 그 테스트가 실패하는지 본다
  → 원래대로 되돌린다</code></pre><p>푸시 알림 작업에서 새로 넣은 테스트 <strong>4개 전부에 이걸 했습니다.</strong></p>
<p>특히 위의 트랜잭션 테스트는, 일부러 망가뜨렸더니 <strong>같은 파일의 나머지 4개는 초록불 그대로</strong>였습니다.
<strong>&quot;기존 테스트로는 이걸 못 잡는다&quot;는 게 그렇게 증명됐습니다.</strong></p>
<h2 id="그-외에-자주-걸린-테스트-함정들">그 외에 자주 걸린 테스트 함정들</h2>
<table>
<thead>
<tr>
<th>함정</th>
<th>증상</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td><code>em.flush()</code>는 예외 변환을 안 함</td>
<td>DB 제약 위반을 스프링 예외로 확인하려는데 실패</td>
<td><strong><code>saveAndFlush</code></strong> 로 Repository를 거쳐서 호출</td>
</tr>
<tr>
<td>Mockito 중첩 스터빙</td>
<td><code>when().thenReturn(헬퍼())</code> 처럼 인자 안에서 가짜 객체를 만들면 에러</td>
<td><strong>변수에 먼저 담고</strong> 넘기기</td>
</tr>
<tr>
<td>primitive 매처</td>
<td><code>any(Double.class)</code>는 null을 반환해서 NPE</td>
<td><code>anyDouble()</code> 쓰기</td>
</tr>
<tr>
<td><strong>Gradle 캐시</strong></td>
<td>소스를 안 고치면 <strong>테스트가 안 돌고 통과처럼 보임</strong></td>
<td><code>cleanTest test</code> + 결과 파일에서 개수 확인</td>
</tr>
<tr>
<td>필드 추가</td>
<td>DTO에 필드를 넣으면 부르는 곳들이 조용히 안 맞음</td>
<td><strong>전부 찾아서 같은 작업에서 고치기</strong></td>
</tr>
<tr>
<td>검사 순서</td>
<td>&quot;참여자인가&quot; 검사가 &quot;상태가 맞나&quot; 검사보다 뒤에 있으면 → <strong>남의 자원인데 404 대신 409가 나가서 존재가 노출됨</strong></td>
<td>참여자 × 상태 조합을 다 테스트</td>
</tr>
</tbody></table>
<hr>
<h1 id="4부--테스트가-못-잡는-것-정직하게">4부 — 테스트가 못 잡는 것 (정직하게)</h1>
<h2 id="실제-폰에서-19건">실제 폰에서 19건</h2>
<p>배포할 때 자동 테스트 <strong>627개 + 앱 190개가 전부 통과</strong>인 상태에서
<strong>실제 폰으로 확인하니 19건이 나왔습니다.</strong></p>
<p>성격을 보면 <strong>논리 버그가 거의 없었습니다.</strong></p>
<table>
<thead>
<tr>
<th>유형</th>
<th>예</th>
</tr>
</thead>
<tbody><tr>
<td>라이브러리끼리 충돌</td>
<td>스크롤과 스와이프 제스처가 서로 안 양보함</td>
</tr>
<tr>
<td>설정값끼리 서로 무력화</td>
<td>위치 정확도와 거리 필터가 서로를 막음</td>
</tr>
<tr>
<td>실제 기기 화면</td>
<td>안전 영역·키보드</td>
</tr>
<tr>
<td><strong>조용한 실패</strong></td>
<td>사진 전송이 실패해도 화면에 아무 표시 없음</td>
</tr>
<tr>
<td>요구사항 해석 차이</td>
<td>&quot;식당 3개 보이게&quot; → 사진 있는 항목이 커서 화면 2/3를 먹음</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>이런 종류는 테스트로 잡기 어렵습니다.</strong>
<strong>빌드→설치를 짧게 반복하는 쪽이 훨씬 값쌉니다.</strong> (빌드 4분, 설치는 바로)</p>
</blockquote>
<h2 id="그런데-반대-사례도-있습니다">그런데 반대 사례도 있습니다</h2>
<p>푸시 알림 작업에서는 <strong>리뷰를 두 번 돌렸고</strong>, 지적된 것들이 대부분 <strong>&quot;조용히 잘못 도는&quot; 종류</strong>라서 자동 테스트로 잡을 수 있었습니다.</p>
<p><strong>결과는 실제 폰에서 새로 나온 결함 0건</strong>이었습니다.</p>
<pre><code>배포 때 19건  = 라이브러리·화면 같은, 실제 기기에서만 보이는 것
푸시 때 0건   = 조용히 잘못 도는 것 → 리뷰와 테스트로 잡힘</code></pre><p><strong>버그 종류에 따라 잡는 방법이 다릅니다.</strong></p>
<h2 id="시간대-문제도-못-잡았습니다">시간대 문제도 못 잡았습니다</h2>
<p>테스트 626개가 통과인데 <a href="#">7편</a>의 9시간 차이 문제는 하나도 안 걸렸습니다.
<strong>테스트는 앱과 DB가 같은 시계를 쓰는 환경에서만 도니까요.</strong></p>
<h2 id="전체-흐름-테스트e2e는-1개뿐입니다">전체 흐름 테스트(E2E)는 1개뿐입니다</h2>
<p>온보딩 전체 흐름을 훑는 것 하나입니다.</p>
<p>더 안 늘린 이유는 <strong>E2E가 비싸고 쉽게 깨지는 데 비해, 여기서 잡히는 것 대부분이 이미 다른 테스트로 잡히기 때문</strong>입니다.</p>
<p>★그런데 실제로 값을 한 사례가 있습니다.
약관 동의에 <strong>만14세 항목을 필수로 추가</strong>했더니 <strong>E2E 3개가 깨졌습니다.</strong>
온보딩 도우미 함수가 옛날 형식으로 호출하고 있었거든요.</p>
<p><strong>&quot;필드 하나 추가가 가입 전체를 막는다&quot;는 걸 E2E만 알려줬습니다.</strong></p>
<hr>
<h1 id="5부--없는-것">5부 — 없는 것</h1>
<p>정직하게 적습니다.</p>
<h2 id="①-커버리지-측정-도구가-없습니다">① 커버리지 측정 도구가 없습니다</h2>
<p>JaCoCo 같은 걸 안 붙였습니다.</p>
<p><strong>커버리지 숫자를 목표로 삼으면 3부의 &quot;아무것도 확인 안 하는 초록불&quot;이 늘어날 것 같았습니다.</strong>
위 ①~④ 유형은 <strong>전부 커버리지 100%로 잡힙니다.</strong> 코드를 실행은 하니까요.</p>
<p>다만 이건 변명이기도 합니다. <strong>커버리지가 낮은 영역을 찾는 용도로는 유용</strong>하고, 안 붙인 건 그냥 안 붙인 겁니다.</p>
<h2 id="②-레이어-규칙을-강제하는-장치가-없습니다">② 레이어 규칙을 강제하는 장치가 없습니다</h2>
<p>리뷰에서 이런 게 나왔습니다.</p>
<pre><code>폴더끼리 서로 참조하는 곳           22쌍
다른 도메인 Repository를 직접 씀    52건</code></pre><p>규칙을 <strong>문서로만 정하고 강제 장치를 안 뒀습니다.</strong>
ArchUnit 같은 걸 넣었으면 빌드가 막혔을 텐데, 지금은 알고만 있습니다.</p>
<hr>
<h2 id="정리--이-편의-요약">정리 — 이 편의 요약</h2>
<ol>
<li><strong>DB 전용 기능에 기대고 있으면 테스트도 그 DB로 해야 한다.</strong> H2를 썼으면 동시성 방어를 한 번도 검증 못 했습니다.</li>
<li><strong>테스트 이름에 &quot;깨지면 무슨 일이 나는지&quot;까지 적는다.</strong> 6개월 뒤의 나를 위한 겁니다.</li>
<li><strong>설정 파일도 테스트한다.</strong> 제일 큰 사고 두 개가 설정에서 났습니다.</li>
<li><strong>리팩터링으로 만든 성질은 따로 지켜야 한다.</strong> 기존 테스트는 그걸 볼 수 없습니다.</li>
<li><strong>&quot;통과&quot;가 아니라 &quot;일부러 망가뜨렸을 때 빨간불이 나나&quot;가 증명력이다.</strong></li>
<li><strong>테스트로 못 잡는 종류가 따로 있다.</strong> 그건 실제 기기 확인을 짧게 반복해서 막습니다.</li>
</ol>
<hr>
<p>마지막 편은 <strong>3개월 동안 반복해서 걸린 함정들</strong>입니다.
제일 비쌌던 걸 하나 꼽으면 — <strong>&quot;코드 대신 문서를 믿은 것&quot;</strong> 이었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA["로컬에서 됐다"가 "서버에서도 된다"를 뜻하지 않는다 — 혼정 개발기 ⑦]]></title>
            <link>https://velog.io/@yoon_bly/%EB%A1%9C%EC%BB%AC%EC%97%90%EC%84%9C-%EB%90%90%EB%8B%A4%EA%B0%80-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C%EB%8F%84-%EB%90%9C%EB%8B%A4%EB%A5%BC-%EB%9C%BB%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-ndr9jsf8</link>
            <guid>https://velog.io/@yoon_bly/%EB%A1%9C%EC%BB%AC%EC%97%90%EC%84%9C-%EB%90%90%EB%8B%A4%EA%B0%80-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C%EB%8F%84-%EB%90%9C%EB%8B%A4%EB%A5%BC-%EB%9C%BB%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-ndr9jsf8</guid>
            <pubDate>Fri, 04 Sep 2026 09:44:44 GMT</pubDate>
            <description><![CDATA[<!--
벨로그 제목: "로컬에서 됐다"가 "서버에서도 된다"를 뜻하지 않는다 — 혼정 개발기 ⑦
시리즈: 혼정 개발기
태그: 배포, Docker, AWS, 백엔드, 신입개발자
-->

<p>8월 3일, 서비스가 공개됐습니다.</p>
<pre><code>https://honjeong-api.com/api/health  →  {&quot;status&quot;:&quot;UP&quot;}</code></pre><p>AWS 서버 한 대, Docker Compose, HTTPS 자동 발급.</p>
<p>이 편에서 제일 크게 배운 건 한 문장입니다.</p>
<blockquote>
<p><strong>&quot;내 컴퓨터에서 됐다&quot;가 &quot;빈 서버에서도 된다&quot;를 뜻하지 않습니다.</strong>
그리고 저는 이걸 <strong>세 번 연속으로</strong> 배웠습니다.</p>
</blockquote>
<hr>
<h1 id="1부--무엇을-어떻게-골랐나">1부 — 무엇을 어떻게 골랐나</h1>
<h2 id="목표는-app-store-정식-출시가-아니라-testflight">목표는 App Store 정식 출시가 아니라 TestFlight</h2>
<p>정식 출시로 가면 <strong>위치기반서비스 신고</strong>나 약관 정식 문안 같은 게 실제 쟁점이 됩니다.
&quot;배포를 끝까지 한 번 해본다&quot;가 목적이었으니 거기까진 안 갔습니다.</p>
<h2 id="서버는-왜-aws-lightsail인가">서버는 왜 AWS Lightsail인가</h2>
<table>
<thead>
<tr>
<th>후보</th>
<th>왜 안 골랐나</th>
</tr>
</thead>
<tbody><tr>
<td>집 맥 + 터널링</td>
<td>맥을 24시간 켜둬야 함</td>
</tr>
<tr>
<td>오라클 무료 서버</td>
<td>인스턴스 확보가 불안정</td>
</tr>
<tr>
<td>Heroku 같은 PaaS</td>
<td>편한데 <strong>인프라를 안 배우게 됨</strong></td>
</tr>
<tr>
<td><strong>AWS Lightsail 2GB</strong></td>
<td>안정적 + <strong>직접 구성해보는 경험</strong></td>
</tr>
</tbody></table>
<p><strong>이력서용 프로젝트라는 목적이 선택을 갈랐습니다.</strong>
PaaS는 편하지만 &quot;Docker Compose로 스택 올리고 리버스 프록시 붙였다&quot;는 이야기가 안 남습니다.</p>
<p>나머지도 같은 기준이었습니다.</p>
<ul>
<li><strong>HTTPS는 Caddy</strong> — nginx + certbot 조합보다 부품이 적고, 인증서를 <strong>자동으로 받고 자동으로 갱신</strong>합니다.</li>
<li><strong>이미지 빌드는 서버에서 직접</strong> — 레지스트리도 CI도 없이. 부품이 최소고, 맥(ARM)과 서버(x86) 차이 문제가 아예 안 생깁니다.</li>
</ul>
<h2 id="3단계로-쪼개고-단계마다-통과-조건을-뒀다">3단계로 쪼개고 단계마다 통과 조건을 뒀다</h2>
<pre><code>1단계 — 코드·설정 준비   (돈 안 씀)
2단계 — 서버 구축
3단계 — 앱 올리기</code></pre><p>특히 <strong>2단계 통과 조건</strong>이 값을 했습니다.</p>
<blockquote>
<p><strong>&quot;기존 앱이 서버 주소만 바꿔서 공개 서버에 그대로 붙는가?&quot;</strong></p>
</blockquote>
<p>이게 통과하면 <strong>서버는 확정</strong>이고, 이후 문제는 전부 앱 쪽으로 좁혀집니다.
앱 빌드는 한 번에 20~40분씩 걸려서, 이 순서를 안 지켰으면 <strong>서버 문제를 빌드할 때마다 다시 확인</strong>해야 했을 겁니다.</p>
<h2 id="최종-구성">최종 구성</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>도메인</td>
<td>가비아에서 구매</td>
</tr>
<tr>
<td>서버</td>
<td>AWS Lightsail <strong>2GB / 서울 / Ubuntu 24.04</strong> · 고정 IP</td>
</tr>
<tr>
<td>swap</td>
<td>2GB (재부팅해도 유지되게 등록)</td>
</tr>
<tr>
<td>방화벽</td>
<td>80 · 443만 열기</td>
</tr>
<tr>
<td>컨테이너</td>
<td>caddy · app · db 세 개</td>
</tr>
<tr>
<td>데이터</td>
<td>식당 655,163행 (로컬과 같은지 확인)</td>
</tr>
</tbody></table>
<p>★<strong>AWS는 &quot;Paid 플랜&quot;으로 가입했습니다.</strong>
Free 플랜은 6개월이 지나거나 크레딧을 다 쓰면 <strong>계정이 자동으로 닫혀서 서버가 사라집니다.</strong>
둘 다 크레딧은 똑같이 받으니까 Paid가 비싼 게 아닙니다.</p>
<p>★<strong>예산 알림은 $5로 잡았습니다.</strong>
크레딧이 남아 있는 동안은 청구가 0에 가까워서, $20으로 잡으면 <strong>정작 크레딧이 떨어지는 시점을 못 잡습니다.</strong></p>
<hr>
<h1 id="2부--로컬에서-됐다가-틀린-세-번">2부 — &quot;로컬에서 됐다&quot;가 틀린 세 번</h1>
<h2 id="①-재부팅하면-서버가-안-살아납니다">① 재부팅하면 서버가 안 살아납니다</h2>
<p><code>docker compose</code>의 <code>restart</code> 기본값은 <strong><code>no</code></strong> 입니다.</p>
<p>Lightsail은 점검이나 커널 업데이트로 재부팅됩니다.
그때 <strong>컨테이너는 조용히 멈춘 채로 남습니다.</strong></p>
<p>그리고 더 나쁜 게 있었습니다.</p>
<blockquote>
<p><strong><code>depends_on</code>은 컨테이너가 &quot;켜졌는지&quot;만 기다리지 &quot;준비됐는지&quot;는 안 기다립니다.</strong></p>
</blockquote>
<p>새 서버에서 처음 켤 때 PostgreSQL이 초기 설정을 하는 동안,
앱이 DB에 붙으려다 <strong>죽고 그대로 끝납니다.</strong></p>
<p>★<strong>로컬에서는 이게 절대 안 잡힙니다.</strong> 로컬 DB에는 이미 데이터가 들어 있어서 초기 설정을 안 하거든요.</p>
<p>→ <code>restart: unless-stopped</code> 추가</p>
<p>나중에 실제로 <code>sudo reboot</code>을 해봤습니다.
<strong>49초 만에 세 컨테이너가 다 자동으로 올라왔습니다.</strong></p>
<h2 id="②-컨테이너는-호스트의-시간대를-안-물려받습니다">② 컨테이너는 호스트의 시간대를 안 물려받습니다</h2>
<p>이건 2단계 통과 조건을 확인하다가 잡혔습니다.</p>
<p>설정 파일에서 <strong>DB 컨테이너에만 시간대를 주고 앱 컨테이너에는 안 줬습니다.</strong></p>
<pre><code>내 맥 (한국시간)  →  서버 / 앱 컨테이너 (UTC)  →  DB 컨테이너 (한국시간)</code></pre><p>앱이 쓰는 <code>LocalDateTime.now()</code>와 DB의 <code>now()</code>가 <strong>9시간 차이 난 채로 같은 컬럼에 들어갑니다.</strong></p>
<p>증상이 셋이었습니다.</p>
<ol>
<li>모든 화면 시각이 <strong>9시간 과거</strong></li>
<li>&quot;오늘 혼밥 N명&quot;의 <strong>날짜 경계가 밀려서</strong>, 새벽 0~9시 체크인이 전날로 집계됨</li>
<li><strong>공지사항이 안 보임</strong> — 제가 DB에 직접 넣은 게시일(한국시간)을 앱이 UTC로 비교해서 &quot;아직 게시 전&quot;이라고 판단</li>
</ol>
<p>★<strong>로컬에서는 맥이 한국시간이라 절대 재현이 안 됩니다.</strong>
그리고 1단계의 &quot;로컬에서 배포용 설정으로 띄워보기&quot; 확인도 <strong>이걸 통과시켰습니다.</strong></p>
<blockquote>
<p><strong>정리하면: 호스트가 조용히 넣어주던 값(시간대·언어·인코딩)은 컨테이너에서 전부 기본값으로 초기화됩니다.</strong></p>
</blockquote>
<p><strong>그리고 테스트 626개가 전부 통과였습니다.</strong>
테스트는 앱과 DB가 <strong>같은 시계</strong>를 쓰는 환경에서만 도니까요.</p>
<h3 id="여기서-판단을-하나-했습니다">여기서 판단을 하나 했습니다</h3>
<p>앱이 UTC, DB가 한국시간으로 갈렸을 때 <strong>어느 쪽에 맞출 것인가.</strong></p>
<p><strong>DB를 UTC로 내리는 게 일반적인 &quot;정답&quot;에 가깝습니다.</strong> 그런데 안 그랬습니다.</p>
<p>이 프로젝트는 이미 <strong>&quot;DB는 한국시간&quot;</strong> 으로 정해뒀고 <strong>22개 마이그레이션과 모든 도메인 코드가 그 전제 위</strong>에 있었습니다.
여기서 UTC로 뒤집으면 <strong>시간 컬럼이 있는 모든 테이블의 기존 데이터를 옮겨야</strong> 합니다.</p>
<p>반대로 앱을 한국시간으로 맞추는 건 <strong>설정 파일에 한 줄</strong>이고 개발 환경과도 일치합니다.</p>
<blockquote>
<p><strong>판단 근거는 &quot;무엇이 옳은 표준인가&quot;가 아니라 &quot;이미 무엇을 기준으로 쌓아왔는가&quot;였습니다.</strong></p>
</blockquote>
<h2 id="③-그래서-얻은-결론">③ 그래서 얻은 결론</h2>
<p>세 번 다 같은 패턴이었습니다.</p>
<pre><code>로컬에서 통과 → 서버에서 실패
   원인: 로컬에는 없는 조건 (빈 DB / 다른 시간대 / 다른 호스트 환경)</code></pre><p>그래서 지금은 <strong>로컬 확인 절차의 한계를 문서에 같이 적어둡니다.</strong>
&quot;이 확인은 데이터가 이미 있는 DB라서 초기 설정 문제를 못 잡는다&quot; 같은 식으로요.</p>
<hr>
<h1 id="3부--설정이-조용히-틀리는-방식들">3부 — 설정이 조용히 틀리는 방식들</h1>
<p><strong>이 프로젝트에서 제일 비싼 사고는 전부 설정에서 났습니다.</strong></p>
<h2 id="①-compose의-env는-컨테이너-환경변수가-아닙니다">① compose의 <code>.env</code>는 컨테이너 환경변수가 아닙니다</h2>
<p>푸시 알림을 배포하기 직전에 잡혔습니다.</p>
<p>배포용 설정 파일에 이렇게 적어놓고,</p>
<pre><code class="language-yaml">push:
  mode: ${PUSH_MODE:real}</code></pre>
<p><code>docker-compose.yml</code>의 앱 서비스 <code>environment:</code> 에 <strong>그 두 줄을 안 넣었습니다.</strong></p>
<pre><code>compose의 .env  →  compose 파일 안의 ${...} 를 채우는 용도일 뿐이고,
                   컨테이너 안으로 자동으로 전달되지는 않는다</code></pre><p>결과: <strong>진짜 모드로 뜨는데 필요한 인증 정보는 못 받는 상태.</strong>
배포했으면 <code>restart: unless-stopped</code> 때문에 <strong>죽었다 켜졌다를 무한 반복</strong>했을 겁니다.</p>
<p>★그리고 이건 카카오 로그인 때 겪은 사고의 <strong>정확한 반대</strong>입니다.
그때는 값이 <strong>잘못 전달</strong>됐고, 이번엔 <strong>아예 전달이 안 됐습니다.</strong></p>
<blockquote>
<p><strong>&quot;설정 파일에 썼다&quot;가 &quot;실행 중인 프로그램까지 도착했다&quot;를 뜻하지 않습니다.</strong>
<strong>설정값 하나가 어디서 출발해서 어디까지 가는지 끝까지 따라가 봐야 하고,</strong>
<strong>컨테이너 경계가 제일 자주 끊기는 지점입니다.</strong></p>
</blockquote>
<h2 id="②-안-채워진-자리표시자가-그대로-암호-키가-됐습니다">② 안 채워진 자리표시자가 그대로 암호 키가 됐습니다</h2>
<p>설정 파일에 커밋돼 있던 <strong>개발용 JWT 비밀 키를 지우려던</strong> 작업이었습니다.</p>
<pre><code class="language-yaml">jwt:
  secret: ${JWT_SECRET}    # 기본값을 지웠다. 환경변수를 안 주면 서버가 안 뜰 줄 알았다</code></pre>
<p><strong>서버가 멀쩡히 떴습니다.</strong></p>
<p>서명 키가 문자 그대로 <strong><code>&quot;${JWT_SECRET}&quot;</code></strong> (13글자, 전 세계가 아는 값)이 된 채로요.</p>
<p>스프링은 <strong>채워지지 않은 자리표시자에 에러를 안 내고 그 글자를 그대로 넘깁니다.</strong>
그리고 <code>SecretKeySpec</code>은 키 길이를 검사하지 않아서 그냥 통과합니다.</p>
<p><strong>커밋된 비밀 키를 지우려던 작업이 그보다 훨씬 나쁜 상태를 만든 겁니다.</strong></p>
<p>→ 서버 시작할 때 <strong>빈 값 / <code>${</code>로 시작 / 32바이트 미만이면 부팅을 실패</strong>시키게 했습니다.</p>
<p>교훈이 둘이었습니다.</p>
<ol>
<li><strong>&quot;없으면 실패할 것&quot;은 확인하기 전까지 그냥 제 추측이었습니다.</strong> 주석에 그렇게 적으려다 확인하러 갔고, <strong>확인이 버그를 잡았습니다.</strong></li>
<li><strong>조용히 잘못되는 건 따로 막아줘야 합니다.</strong></li>
</ol>
<h2 id="③-envexample이-슬며시-실제-설정이-됐습니다">③ <code>.env.example</code>이 슬며시 실제 설정이 됐습니다</h2>
<p>편하라고 실제 값을 넣어뒀습니다. 그것도 <strong>설정 파일에 커밋된 것과 똑같은 JWT 비밀 키</strong>를요.</p>
<p><strong>저장소를 볼 수 있는 사람은 누구나 아무 사용자 토큰이나 만들 수 있는 상태</strong>였습니다.</p>
<p>게다가 배포용 압축 파일에 <code>.env</code>가 같이 들어가서, 서버에 <strong>이미 동작하는 <code>.env</code>가 도착</strong>했습니다.
그러면 <strong>&quot;예제를 복사해서 값을 바꾸세요&quot; 단계가 이미 끝난 것처럼 보입니다.</strong></p>
<p>→ 실제 값을 자리표시자로 바꾸고, 압축할 때 <code>.env</code>를 제외</p>
<p>★웃긴 건 <strong>같은 파일 안에서 카카오 키만 자리표시자</strong>였다는 겁니다.
<strong>파일이 스스로와 안 맞았습니다.</strong></p>
<h2 id="④-조용히-실패하는-쪽이-훨씬-비쌉니다">④ 조용히 실패하는 쪽이 훨씬 비쌉니다</h2>
<p>같은 종류의 &quot;환경변수 누락&quot;인데 <strong>발견 비용이 수백 배 차이 났습니다.</strong></p>
<table>
<thead>
<tr>
<th>누락</th>
<th>언제 알게 되나</th>
</tr>
</thead>
<tbody><tr>
<td>카카오 키 없음</td>
<td>설정 파일이 에러를 던져서 <strong>빌드 몇 초 만에</strong> 죽음</td>
</tr>
<tr>
<td>서버 주소 없음</td>
<td><code>?? &#39;localhost&#39;</code> 라는 대체값 때문에 <strong>빌드·제출·심사를 다 거친 뒤에</strong> &quot;모든 요청 실패&quot;</td>
</tr>
</tbody></table>
<p>→ 배포용 빌드에서만 에러를 던지게 바꿨습니다 (로컬에서는 대체값 유지)</p>
<h2 id="⑤-앱에서-버튼을-숨기는-건-접근-차단이-아닙니다">⑤ 앱에서 버튼을 숨기는 건 접근 차단이 아닙니다</h2>
<p>배포 전에 문자 인증(개발용이라 인증번호가 <code>000000</code> 고정)을 어떻게 할지 정해야 했습니다.
선택은 <strong>&quot;앱에서 휴대폰 로그인 버튼을 주석 처리&quot;</strong> 였습니다.</p>
<p><strong>그런데 `/api/auth/phone/</strong>` 은 로그인 없이 열려 있었습니다.**</p>
<p>공개 서버에서 <strong>API를 두 번 호출하면 계정이 만들어졌습니다.</strong>
게다가 재전송 제한이 없어서 5회 잠금도 우회됐습니다.</p>
<p>★더 위험했던 건, <strong>설계 문서에 &quot;백엔드 API는 그대로 둔다(범위 밖)&quot;라고 적어놔서 그 상태를 고정시킨 것</strong>이었습니다.
화면 주석은 위험을 정확히 적어놓고 <strong>앱만 막았습니다.</strong></p>
<p><strong>마지막 전체 리뷰가 아니었으면 그대로 배포됐습니다.</strong></p>
<p>→ <strong>문자 인증이 진짜 모드가 아니면 그 API 자체를 차단</strong>하게 했습니다.
나중에 실제 문자를 붙이면 <strong>코드를 안 고쳐도 자동으로 열립니다.</strong></p>
<hr>
<h1 id="4부--배포-실무에서-걸린-것">4부 — 배포 실무에서 걸린 것</h1>
<h2 id="잘린-백업이-검사를-통과합니다">잘린 백업이 검사를 통과합니다</h2>
<p>DB 백업을 이렇게 뜹니다.</p>
<pre><code class="language-bash">pg_dump ... | gzip &gt; backup.sql.gz</code></pre>
<p><code>set -o pipefail</code>을 붙이면 <strong>에러는 감지되지만, 이미 써진 파일은 그대로 남습니다.</strong></p>
<p><code>pg_dump</code>가 중간에 죽어도 gzip이 이미 쓴 <strong>잘린 파일이 최종 경로에 남고</strong>,
하필 검사 기준이 &quot;파일 크기가 0이 아닌지&quot;라 <strong>그 잘린 파일이 검사를 통과합니다.</strong></p>
<p>→ 임시 경로에 쓰고, 성공했을 때만 이름을 바꾸고, <strong>행 수를 대조</strong></p>
<h2 id="지운-파일이-서버에-남습니다--유령-배포">지운 파일이 서버에 남습니다 — &quot;유령 배포&quot;</h2>
<p>페이징을 걷어내는 배포에서 겪었습니다.</p>
<p><strong>압축을 푸는 건 덮어쓰기라서, 삭제는 전달이 안 됩니다.</strong>
제가 지운 <code>PageResponse.java</code>가 서버에 그대로 남아서 <strong>계속 컴파일됐습니다.</strong></p>
<p>→ 배포 전에 서버의 소스 폴더를 지우고 새로 풀기
→ 확인은 <strong>빌드된 jar 안에 그 클래스가 있는지</strong>로 (0건이면 성공)</p>
<h2 id="배포했다와-그-코드가-돌고-있다는-다른-말입니다">&quot;배포했다&quot;와 &quot;그 코드가 돌고 있다&quot;는 다른 말입니다</h2>
<p>배포한 jar에 새 코드가 진짜 들어갔는지 확인하려는데 —
<strong>서버 이미지에 <code>unzip</code>도 <code>strings</code>도 없었습니다.</strong></p>
<p>→ 파일을 호스트로 꺼내서 <code>python3 -m zipfile</code>(우분투에 기본으로 있음)로 열어봤습니다</p>
<p>그리고 재배포할 때는 <strong>로컬과 서버의 모든 파일 md5를 떠서 &quot;다른 게 그 3개뿐&quot;인지 확인</strong>한 다음 그 3개만 보냈습니다.</p>
<h2 id="로컬이-원본-서버는-복사본입니다">로컬이 원본, 서버는 복사본입니다</h2>
<p>서버에서 소스를 직접 고치면 <strong>그 순간엔 되는데, 다음 배포 때 로컬 파일이 조용히 덮어씁니다.</strong>
그러면 원인 추적이 정말 어렵습니다.</p>
<p><strong>서버에만 있어도 되는 파일은 <code>.env</code> 하나뿐</strong>입니다. 비밀값이라 일부러 git 밖에 뒀습니다.</p>
<p>시간대 수정도 <strong>로컬에서 고치고 → 서버로 보내고 → 재시작</strong> 순서로 했습니다.</p>
<h2 id="같은-이름의-설정이-두-곳에-있으면-안-쓰는-쪽이-이깁니다">같은 이름의 설정이 두 곳에 있으면 안 쓰는 쪽이 이깁니다</h2>
<p>2단계 안내에 앱 <code>.env</code>에 운영 서버 주소를 넣으라고 적어놨는데 <strong>지우라는 말이 없었습니다.</strong></p>
<p>그대로 두면 <strong>로컬 백엔드를 아무리 띄워도 시뮬레이터는 운영 서버를 때립니다.</strong>
테스트로 만든 체크인과 리뷰가 <strong>운영 DB에 쌓입니다.</strong></p>
<p>확인해보니 <strong>빌드에는 그 파일이 아예 필요 없었습니다.</strong> 빌드 서버에 등록한 값을 쓰거든요.</p>
<blockquote>
<p><strong>같은 이름의 설정이 두 곳에 있으면, 어느 쪽이 이기는지 실험으로 확인하고 안 쓰는 쪽은 지웁니다.</strong></p>
</blockquote>
<hr>
<h1 id="5부--앱-배포는-짧게">5부 — 앱 배포는 짧게</h1>
<p>앱 쪽 이야기는 이 시리즈 주제가 아니라 세 줄만 적겠습니다.</p>
<ul>
<li><strong>빌드 도구가 일부러 <code>.env</code>를 안 읽습니다.</strong> 그것 때문에 명령어가 연달아 실패했는데, <strong>에러 메시지에 이유가 안 나와서</strong> 디버그 옵션을 켜야 알 수 있었습니다.</li>
<li><strong>&quot;실패&quot;라고 나와도 업로드는 끝났을 수 있습니다.</strong> 모르고 재시도하면 빌드 번호가 겹쳐서 <strong>진짜 실패</strong>가 납니다. (한 번 헛돌았습니다)</li>
<li><strong>취소한 빌드도 번호를 씁니다.</strong> 3·4를 건너뛰고 5가 됐는데 정상입니다.</li>
</ul>
<h2 id="다만-백엔드-개발자로서-배운-규칙이-하나-있습니다">다만 백엔드 개발자로서 배운 규칙이 하나 있습니다</h2>
<pre><code>백엔드  →  재배포하면 즉시 전원 반영
앱      →  심사 1~2일 + 업데이트 안 받는 사용자는 몇 주씩 구버전</code></pre><p><strong>구버전 앱과 신버전 서버가 같이 도는 게 항상 참입니다.</strong></p>
<p>그래서 규칙 둘.</p>
<ol>
<li><strong>순서는 백엔드 먼저, 앱 나중.</strong> 반대로 하면 앱이 아직 없는 API를 부릅니다.</li>
<li><strong>응답에 필드를 추가하는 건 되고, 삭제·이름 변경·타입 변경은 안 됩니다.</strong></li>
</ol>
<p>DB 컬럼 삭제도 앱이 다 넘어간 다음입니다.
그리고 <strong>급한 버그는 앱이 아니라 백엔드에서 고칠 수 있는지 먼저 봅니다.</strong> 심사가 없으니까요.</p>
<p>(<a href="#">6편</a>에서 응답 구조를 바꿀 때 <code>content</code>라는 필드 이름을 유지한 게 정확히 이 규칙 때문이었습니다.)</p>
<hr>
<h1 id="6부--운영">6부 — 운영</h1>
<h2 id="비용">비용</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>주기</th>
<th>금액</th>
</tr>
</thead>
<tbody><tr>
<td>Apple Developer</td>
<td>1년</td>
<td>$99</td>
</tr>
<tr>
<td>도메인</td>
<td>1년</td>
<td>1.5~2만원</td>
</tr>
<tr>
<td>Lightsail 2GB</td>
<td>1달</td>
<td>$12</td>
</tr>
</tbody></table>
<p>크레딧 $100이 약 8개월치라 <strong>실제 결제는 2027년 4월쯤부터</strong>입니다.</p>
<p><strong>사람이 챙길 건 둘뿐입니다</strong> — 도메인 만료(놓치면 전면 장애)와 Apple 멤버십.
인증서는 자동으로 갱신됩니다.</p>
<h2 id="백업">백업</h2>
<p>AWS 스냅샷과 <strong>역할을 나눴습니다.</strong></p>
<pre><code>스냅샷    = 서버 통째로 복구 (디스크 이미지)
pg_dump  = 특정 테이블만 되돌리거나 다른 곳으로 옮길 때</code></pre><p>그리고 <strong>&quot;마지막 성공이 언제였는지&quot;를 파일로 따로 남깁니다.</strong>
<strong>&quot;백업 파일이 있다&quot;와 &quot;그 백업이 성공한 것이다&quot;는 다른 말</strong>이니까요.</p>
<h2 id="없는-것--cicd">없는 것 — CI/CD</h2>
<p>GitHub Actions 같은 자동 배포가 없습니다. <strong>배포는 수동입니다.</strong></p>
<p>혼자 하는 프로젝트라 <strong>배포 빈도가 낮고(며칠에 한 번) 절차가 문서에 적혀 있어서</strong> 자동화 이득이 크지 않다고 판단했습니다.</p>
<p>다만 이건 <strong>알고 안 한 것</strong>입니다.
아키텍처 문서에 &quot;CI/CD: GitHub Actions&quot;라고 적혀 있던 건 <strong>그냥 거짓말이라 리뷰에서 잡혔습니다.</strong> (<a href="#">9편</a>)</p>
<h2 id="법무--정식-출시에-필요한-것들">법무 — 정식 출시에 필요한 것들</h2>
<p>TestFlight에는 필요 없지만, 정식 출시하려면 이게 다 필요합니다.</p>
<ul>
<li>약관 3~4종</li>
<li><strong>위치기반서비스 신고</strong> (방통위, 무료)</li>
<li><strong>개인정보 처리방침을 웹에 올리기</strong> (심사에 URL이 필요합니다)</li>
<li>애플 로그인 (소셜 로그인이 있으면 필수)</li>
<li><strong>심사자용 데모 계정</strong> — 카카오 로그인만 있으면 애플 심사자가 로그인을 못 해서 거부됩니다</li>
</ul>
<p>8월에 약관 4종을 정리하고, 개인정보 처리방침을 <strong>Caddy가 직접 서빙</strong>하게 했습니다.</p>
<p>★그 과정에서 <strong>개인정보 처리방침을 두 번 고쳤습니다.</strong>
&quot;탈퇴 시 지체 없이 파기&quot;라고 적혀 있었는데 <strong>실제 동작은 익명화 후 보존</strong>이었거든요.</p>
<blockquote>
<p><strong>약관이 코드와 다르면 그건 문서 오류가 아니라 법적 문제입니다.</strong></p>
</blockquote>
<hr>
<h2 id="정리--이-편의-요약">정리 — 이 편의 요약</h2>
<ol>
<li><strong>로컬 확인에는 한계가 있고, 그 한계를 적어둬야 한다.</strong> 빈 DB, 다른 시간대는 로컬에서 절대 재현 안 됩니다.</li>
<li><strong>&quot;설정 파일에 썼다&quot;가 &quot;실행 중인 프로그램에 도착했다&quot;가 아니다.</strong> 컨테이너 경계가 제일 자주 끊깁니다.</li>
<li><strong>조용히 잘못되는 건 따로 막아줘야 한다.</strong> 안 채워진 자리표시자가 그대로 암호 키가 됩니다.</li>
<li><strong>앱에서 버튼을 숨기는 건 접근 차단이 아니다.</strong> 보안은 API에 걸어야 합니다.</li>
<li><strong>&quot;배포했다&quot;와 &quot;그 코드가 돌고 있다&quot;는 다르다.</strong> 후자는 따로 확인합니다.</li>
<li><strong>구버전 앱과 신버전 서버는 항상 같이 돈다.</strong> 응답에 필드를 빼면 안 됩니다.</li>
</ol>
<hr>
<p>다음 편은 <strong>테스트</strong>입니다.
827개가 전부 통과인데 계정이 안 지워지고 있었던 이야기를 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[예상 5개 중 1개만 맞았다 — 부하 테스트로 검색을 34배 빠르게 (혼정 개발기 ⑥)]]></title>
            <link>https://velog.io/@yoon_bly/%EC%98%88%EC%83%81-5%EA%B0%9C-%EC%A4%91-1%EA%B0%9C%EB%A7%8C-%EB%A7%9E%EC%95%98%EB%8B%A4-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A1%9C-%EA%B2%80%EC%83%89%EC%9D%84-34%EB%B0%B0-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0</link>
            <guid>https://velog.io/@yoon_bly/%EC%98%88%EC%83%81-5%EA%B0%9C-%EC%A4%91-1%EA%B0%9C%EB%A7%8C-%EB%A7%9E%EC%95%98%EB%8B%A4-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A1%9C-%EA%B2%80%EC%83%89%EC%9D%84-34%EB%B0%B0-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0</guid>
            <pubDate>Fri, 04 Sep 2026 09:35:47 GMT</pubDate>
            <description><![CDATA[<!--
벨로그 제목: 예상 5개 중 1개만 맞았다 — 부하 테스트로 검색을 34배 빠르게 (혼정 개발기 ⑥)
시리즈: 혼정 개발기
태그: 성능개선, PostgreSQL, 부하테스트, 백엔드, 신입개발자
-->

<p>서버는 AWS의 제일 작은 축인 <strong>CPU 2개 / 메모리 2GB짜리 한 대</strong>입니다.</p>
<p>사용자가 늘면 <strong>어디가 먼저 무너지는지 몰랐습니다.</strong>
&quot;느려지면 서버를 키운다&quot;가 아니라 <strong>&quot;어디가 문제인지 숫자로 짚고 고친다&quot;</strong> 를 해보고 싶었습니다.</p>
<p>결과부터 적으면 이렇습니다.</p>
<pre><code>검색 처리량      초당 4.4건  →  초당 150건에서도 안 꺾임    (최소 34배)
2글자 검색       669.8ms    →  1.2ms
실제 서버 실측   1,028ms    →  1.4ms
DB CPU (최대부하) 197%       →  20.4%</code></pre><p>그런데 이 글에서 진짜 하고 싶은 이야기는 숫자가 아닙니다.</p>
<blockquote>
<p><strong>재보기 전에 세운 예상 5개 중 맞은 건 1개였습니다.</strong></p>
</blockquote>
<hr>
<h1 id="1부--재는-환경을-만드는-것부터가-일이었다">1부 — 재는 환경을 만드는 것부터가 일이었다</h1>
<h2 id="실제-서버를-때릴-수는-없었다">실제 서버를 때릴 수는 없었다</h2>
<p>실사용자가 있고, 2GB짜리 서버는 부하를 주면 <strong>진짜로 죽습니다.</strong>
게다가 실제 서버는 카카오 로그인이 진짜로 붙어 있어서 <strong>가상 사용자 300명을 만들 방법이 없습니다.</strong></p>
<p>그래서 <strong>내 맥에서 실제 서버 환경을 흉내냈습니다.</strong>
이 흉내를 제대로 내는 게 이 작업의 절반이었습니다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>어떻게 했나</th>
<th>왜</th>
</tr>
</thead>
<tbody><tr>
<td>CPU</td>
<td>앱과 DB를 <strong>같은 CPU 2개</strong>에만 쓰게 묶음</td>
<td>실제 서버는 한 대에서 둘이 CPU를 나눠 씁니다. <strong>각각 2개씩 주면 총 4개</strong>가 돼서 경쟁이 재현이 안 됩니다</td>
</tr>
<tr>
<td>메모리</td>
<td>앱 컨테이너를 1900MB로 제한</td>
<td>자바가 메모리의 25%를 쓰도록 잡아서 실제 서버와 같아집니다</td>
</tr>
<tr>
<td>데이터</td>
<td>식당 <strong>655,163개</strong></td>
<td>실제 서버와 같은 양. 데이터가 적으면 느린 쿼리가 안 느려 보입니다</td>
</tr>
<tr>
<td>로그인</td>
<td>가짜 모드로</td>
<td>가상 사용자 300명을 만들려고</td>
</tr>
<tr>
<td>SQL 로그</td>
<td><strong>껐습니다</strong></td>
<td>켜두면 <strong>로그 찍는 비용이 측정값을 덮습니다</strong></td>
</tr>
</tbody></table>
<h2 id="그리고-한계를-먼저-적어둡니다">그리고 한계를 먼저 적어둡니다</h2>
<blockquote>
<p><strong>제 맥 CPU가 서버보다 3~4배 빠릅니다. 그래서 여기 나오는 절대값은 실제 서버 값이 아닙니다.</strong>
결론은 항상 <strong>고치기 전 / 고친 후 비율</strong>로만 말합니다.</p>
</blockquote>
<p>이걸 앞에 안 적어두면 나중에 &quot;로컬에서 2ms 나왔으니 서버도 2ms&quot;라는 잘못된 결론을 냅니다.</p>
<p>(나중에 실제 서버에서 직접 재봤더니 <strong>개선 폭이 로컬보다 더 컸습니다.</strong> 아래에서)</p>
<hr>
<h1 id="2부--어떻게-쟀나">2부 — 어떻게 쟀나</h1>
<p>k6라는 도구로 5가지를 돌렸습니다. <strong>각 테스트를 왜 그렇게 설계했는지</strong>가 중요합니다.</p>
<h3 id="테스트-1--초당-몇-건까지-버티나">테스트 1 — 초당 몇 건까지 버티나</h3>
<p>초당 0.5 → 1 → 2 → 3 → 4 → 6건으로 30초씩 올립니다.</p>
<p>★<strong>한 번에 쭉 올리지 않고 단계마다 따로 돌렸습니다.</strong>
쭉 이어서 하면 구간 경계가 섞여서 <strong>&quot;초당 4건일 때 응답이 얼마였나&quot;</strong> 를 나중에 못 가릅니다.</p>
<h3 id="테스트-2--검색어가-짧을수록-느린가">테스트 2 — 검색어가 짧을수록 느린가</h3>
<p>2·3·4글자를 <strong>동시 요청 없이 하나씩 순서대로</strong> 돌렸습니다.</p>
<p>★동시에 여러 개를 보내면 <strong>&quot;검색어 길이 차이&quot;가 아니라 &quot;줄 서서 기다린 시간&quot;</strong> 을 재게 됩니다.</p>
<p>★그리고 같은 글자 수 안에서도 <strong>검색 결과 개수를 일부러 다르게</strong> 했습니다(2글자인데 5,731건~20,964건).
<strong>길이가 원인인지 결과 개수가 원인인지, 데이터가 스스로 말하게</strong> 하려고요.</p>
<h3 id="테스트-3--앱을-켜놓은-사람이-몇-명까지-괜찮나">테스트 3 — 앱을 켜놓은 사람이 몇 명까지 괜찮나</h3>
<p>홈 화면은 15초마다 서버에 물어봅니다(폴링). 50 → 100 → 200 → 300명으로 늘려봤습니다.</p>
<p><strong>전원 강남역 좌표</strong>로 했습니다. 반경 1km에 식당이 2,914개라 <strong>제일 불리한 조건</strong>이거든요.</p>
<p>★<strong>맨 마지막에 50명을 한 번 더 돌렸습니다.</strong> 첫 번째와 값이 같아야 <strong>처음에 서버가 충분히 데워진 것</strong>입니다.</p>
<h3 id="테스트-4--부하가-빠지면-서버가-돌아오나">테스트 4 — 부하가 빠지면 서버가 돌아오나</h3>
<p>① 평상시 → ② 과부하 → ③ 다시 평상시 순으로 돌리고,
<strong>앱의 재시도 설정을 켠 경우와 끈 경우</strong>를 비교했습니다.</p>
<p>★<strong>판정은 ③의 뒤쪽 60초로만</strong> 했습니다.
③ 전체 평균에는 <strong>밀렸던 요청을 처리하는 시간</strong>이 섞여서 의미가 없습니다.</p>
<h3 id="테스트-5--채팅-목록-n1이-진짜-있나-나중에-추가">테스트 5 — 채팅 목록 N+1이 진짜 있나 (나중에 추가)</h3>
<p>처음 쟀을 때는 가상 사용자에게 <strong>대화가 0건</strong>이라 문제가 생길 수가 없었습니다.
<strong>&quot;있는지 없는지 모르는&quot; 상태</strong>였던 거죠.</p>
<p>그래서 대화방 개수별(0·5·20·50개)로 <strong>사용자 그룹을 나눠서</strong> 쟀습니다.</p>
<p>★같은 사람한테 대화를 늘려가며 재면 안 됩니다.
<strong>&quot;대화가 늘어서 느린 건지, 두 번째라 빨라진 건지&quot;</strong> 를 못 가르거든요.</p>
<p>★그리고 대화 상대는 <strong>측정하지 않는 별도 그룹</strong>에서 뽑았습니다.
대화는 양쪽 목록에 다 뜨니까, <strong>대조군(0개)을 상대로 쓰면 대조군이 0개가 아니게 됩니다.</strong></p>
<hr>
<h1 id="3부--결과-그리고-예상이-다-틀렸다">3부 — 결과, 그리고 예상이 다 틀렸다</h1>
<h2 id="무너지는-지점이-명확했다">무너지는 지점이 명확했다</h2>
<table>
<thead>
<tr>
<th>목표</th>
<th>실제</th>
<th>중앙값</th>
<th>DB CPU</th>
<th>DB 연결 수</th>
</tr>
</thead>
<tbody><tr>
<td>초당 2건</td>
<td>2.03</td>
<td>688ms</td>
<td>133%</td>
<td>3</td>
</tr>
<tr>
<td>초당 3건</td>
<td>3.03</td>
<td>740ms</td>
<td>179%</td>
<td>5</td>
</tr>
<tr>
<td><strong>초당 4건</strong></td>
<td><strong>3.90</strong></td>
<td><strong>1,984ms</strong></td>
<td><strong>197%</strong></td>
<td><strong>11</strong></td>
</tr>
<tr>
<td>초당 6건</td>
<td>4.43</td>
<td>9,524ms</td>
<td>195%</td>
<td>11</td>
</tr>
</tbody></table>
<p><strong>초당 3건까지는 정상, 4건부터 무너집니다. 한계는 초당 4.4건입니다.</strong></p>
<p>실패율은 전 구간 <strong>0%</strong> 였습니다. <strong>서버가 죽는 게 아니라 느려지기만 합니다.</strong></p>
<p><strong>병목은 DB의 CPU였습니다.</strong> 앱 쪽 CPU는 평균 <strong>1.9%</strong> 로 놀고 있었고, DB가 200%(CPU 2개)에서 천장을 쳤습니다.</p>
<p>★<strong>DB 연결 개수는 CPU가 찬 다음에 찼습니다. 원인이 아니라 결과였습니다.</strong></p>
<h2 id="예상-5개-중-1개">예상 5개 중 1개</h2>
<p>재보기 전에 이렇게 예상했었습니다.</p>
<table>
<thead>
<tr>
<th></th>
<th>예상</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>DB 연결 개수(10개)가 부족할 것이다</td>
<td><strong>틀림</strong> — CPU가 먼저 찹니다</td>
</tr>
<tr>
<td>2</td>
<td>채팅 목록에 N+1이 있을 것이다</td>
<td><strong>틀림</strong> — 있긴 한데 무해했습니다</td>
</tr>
<tr>
<td>3</td>
<td>통계 API에 캐시가 없어서 낭비일 것이다</td>
<td>확인 안 함 — 폴링이 워낙 싸서 순위가 밀렸습니다</td>
</tr>
<tr>
<td>4</td>
<td>주변 식당 조회가 메모리를 많이 쓸 것이다</td>
<td><strong>틀림</strong> — 메모리 200MB / 476MB</td>
</tr>
<tr>
<td>5</td>
<td>앱의 재시도가 회복을 막을 것이다</td>
<td><strong>맞음</strong></td>
</tr>
</tbody></table>
<p><strong>제일 먼저 의심했던 것들(연결 개수·메모리·폴링)이 전부 틀렸습니다.</strong>
실제 원인은 <strong>검색 쿼리 하나와 앱 설정 한 줄</strong>이었습니다.</p>
<h2 id="폴링은-부담이-아니었다">폴링은 부담이 아니었다</h2>
<table>
<thead>
<tr>
<th>접속자</th>
<th>초당 요청</th>
<th>응답 중앙값</th>
</tr>
</thead>
<tbody><tr>
<td>50명</td>
<td>20</td>
<td>27ms</td>
</tr>
<tr>
<td>300명</td>
<td>120</td>
<td>14ms</td>
</tr>
<tr>
<td>↺50명 (대조군)</td>
<td>20</td>
<td><strong>27ms</strong></td>
</tr>
</tbody></table>
<p><strong>검색 1건(660ms)이 폴링 47번(14ms)과 맞먹었습니다.</strong></p>
<p>&quot;15초마다 물어보는 게 서버에 부담 아닐까&quot;를 계속 걱정했는데,
<strong>재보니 검색 한 번이 훨씬 비쌌습니다.</strong></p>
<h2 id="제일-무서웠던-것--재시도가-회복을-막는다">제일 무서웠던 것 — 재시도가 회복을 막는다</h2>
<table>
<thead>
<tr>
<th></th>
<th>① 평상시</th>
<th>② 과부하</th>
<th>③ 복귀 후</th>
<th>판정</th>
</tr>
</thead>
<tbody><tr>
<td>재시도 OFF</td>
<td>677ms</td>
<td>24,965ms (실패 30.3%)</td>
<td><strong>723ms</strong></td>
<td><strong>1.07배 → 회복</strong></td>
</tr>
<tr>
<td>재시도 ON</td>
<td>683ms</td>
<td>24,623ms (실패 12.8%)</td>
<td><strong>8,985ms</strong></td>
<td><strong>13.16배 → 회복 실패</strong></td>
</tr>
</tbody></table>
<p><strong>같은 서버, 같은 부하인데 재시도 유무가 회복을 갈랐습니다.</strong></p>
<p>추가된 요청은 997건 중 149건(15%)뿐인데, 그걸로 회복이 막혔습니다.
실패한 요청을 다시 보내니까 <strong>이미 밀려 있는 줄에 계속 밀어 넣는 셈</strong>이 됩니다.</p>
<p><strong>그리고 이 줄이 제일 무섭습니다.</strong></p>
<blockquote>
<p><strong>과부하 중 실패율은 재시도 쪽이 오히려 낮습니다 (30.3% → 12.8%).</strong></p>
<p><strong>재시도가 증상은 가리고 병은 키웁니다.</strong>
<strong>에러율만 보는 모니터링으로는 이 장애를 못 잡습니다.</strong></p>
</blockquote>
<p>React Query의 기본 재시도를 그대로 쓰고 있었는데, 이렇게 바꿨습니다.</p>
<pre><code class="language-js">retry: 1   // 네트워크가 아예 안 될 때만 1번. 서버 에러(5xx)는 재시도 안 함</code></pre>
<hr>
<h1 id="4부--원인-찾기-722ms를-쪼갰다">4부 — 원인 찾기: 722ms를 쪼갰다</h1>
<p>DB 로그를 켜서 <strong>앱이 실제로 어떤 쿼리를 날리는지</strong> 봤습니다.</p>
<pre><code>목록 가져오는 쿼리   424ms
개수 세는 쿼리       298ms
──────────────────────────
                    722ms   ← 응답 730ms와 거의 일치</code></pre><h2 id="범인-①--이름순-정렬">범인 ① — 이름순 정렬</h2>
<pre><code>정렬 없음        →   0.7ms
ORDER BY name  → 215.7ms</code></pre><p><strong>왜 이렇게 차이가 나나?</strong></p>
<p><code>ORDER BY name LIMIT 20</code>은 <strong>20개만 필요해도 조건에 맞는 걸 전부 찾아서 정렬한 다음 앞 20개를 자릅니다.</strong></p>
<p>&#39;김밥&#39;은 조건에 맞는 게 <strong>10,135개</strong>입니다.
그 10,135개를 다 찾으려고 <strong>655,163행을 전부 훑습니다.</strong></p>
<h2 id="범인-②--아무도-안-쓰는-개수-세기">범인 ② — 아무도 안 쓰는 개수 세기</h2>
<p>원래는 검색 결과를 20개씩 끊어서 <strong>스크롤할 때마다 더 불러오려고</strong> 했습니다.
그래서 Spring Data의 <code>Page&lt;Place&gt;</code> 타입을 썼습니다.</p>
<p><strong><code>Page</code>를 쓰면 Spring이 &quot;총 몇 개 중 몇 번째인지&quot;를 채워주려고 <code>SELECT count(*)</code>를 자동으로 한 번 더 날립니다.</strong></p>
<p>그런데 검색이 워낙 느려서 <strong>무한 스크롤을 포기</strong>했습니다.
한 번에 20개만 보여주는 걸로 화면을 바꿨고, <strong>총 개수를 쓸 자리가 사라졌습니다.</strong></p>
<p><strong>제가 직접 쓴 코드가 아니라 자동으로 붙는 쿼리라, 화면만 바꾸고 그대로 남았습니다.</strong></p>
<blockquote>
<p><strong>앱의 어느 화면도 그 값을 안 읽는데, 서버는 가장 비싼 쿼리를 매번 돌리고 있었습니다.</strong></p>
</blockquote>
<p>이게 이 작업에서 제가 제일 좋아하는 발견입니다.</p>
<h2 id="범인-③--db가-매번-다른-방법을-고른다">범인 ③ — DB가 매번 다른 방법을 고른다</h2>
<p>여기가 조금 어렵습니다. 최대한 풀어서 적겠습니다.</p>
<p><strong>PostgreSQL은 쿼리를 받으면 &quot;어떻게 찾을지&quot;를 스스로 정합니다.</strong> 방법이 크게 둘입니다.</p>
<pre><code>(A) 인덱스를 쓴다      — 색인을 보고 해당 위치로 바로 간다 (보통 빠름)
(B) 전부 다 훑는다     — 655,163행을 처음부터 끝까지 본다 (결과가 많으면 오히려 이게 나음)</code></pre><p><strong>어느 쪽이 나을지는 &quot;결과가 몇 개나 나올까&quot;에 달렸습니다.</strong>
그래서 DB가 먼저 <strong>예상 개수</strong>를 계산하고 정합니다.</p>
<p>문제는 <code>LIKE &#39;%김밥%&#39;</code> 같은 검색은 <strong>예상이 거의 불가능하다는 것</strong>입니다.</p>
<pre><code>&#39;김밥&#39; → 예상   355개, 실제 3,378개  (10배 틀림)
&#39;커피&#39; → 예상    27개, 실제 2,963개  (110배 틀림)</code></pre><p><strong>&quot;몇 개 안 나오겠네&quot;라고 잘못 예상해놓고, 그래서 오히려 전부 훑는 쪽을 고릅니다.</strong></p>
<p>실제 측정 결과를 보면 이렇게 갈립니다.</p>
<table>
<thead>
<tr>
<th>검색어</th>
<th>결과 개수</th>
<th>DB가 고른 방법</th>
<th>걸린 시간</th>
</tr>
</thead>
<tbody><tr>
<td>김밥</td>
<td>10,135</td>
<td>전부 훑기</td>
<td>122ms</td>
</tr>
<tr>
<td>치킨</td>
<td>20,964</td>
<td>전부 훑기</td>
<td>124ms</td>
</tr>
<tr>
<td>분식</td>
<td>5,731</td>
<td>전부 훑기</td>
<td>121ms</td>
</tr>
<tr>
<td>파스타</td>
<td>1,564</td>
<td>인덱스 사용</td>
<td><strong>1.1ms</strong></td>
</tr>
<tr>
<td>떡볶이</td>
<td>4,290</td>
<td>인덱스 사용</td>
<td><strong>8.2ms</strong></td>
</tr>
</tbody></table>
<p>★<strong>2글자는 결과 개수가 3.7배 차이나도 시간이 121~124ms로 똑같습니다.</strong>
전체를 훑으니까 <strong>결과 개수와 상관이 없는</strong> 겁니다.</p>
<p>3글자부터는 인덱스를 타서 결과 개수에 비례합니다.</p>
<hr>
<h1 id="5부--고친-것">5부 — 고친 것</h1>
<h2 id="①-정렬을-name에서-id로">① 정렬을 <code>name</code>에서 <code>id</code>로</h2>
<p>여러 후보를 다 재보고 골랐습니다.</p>
<table>
<thead>
<tr>
<th>정렬</th>
<th>김밥(10,135건)</th>
<th>파스타집(17건)</th>
<th>매번 같은 결과가 나오나</th>
</tr>
</thead>
<tbody><tr>
<td><code>ORDER BY name</code> (기존)</td>
<td>182.6ms</td>
<td>0.17ms</td>
<td>O</td>
</tr>
<tr>
<td>정렬 없음</td>
<td>0.31ms</td>
<td>0.07ms</td>
<td><strong>X</strong></td>
</tr>
<tr>
<td><strong><code>ORDER BY id</code></strong></td>
<td><strong>3.8ms</strong></td>
<td><strong>0.08ms</strong></td>
<td>O</td>
</tr>
</tbody></table>
<p><code>id</code>로 정렬하면 <strong>번호 순서대로 훑다가 20개 채우는 즉시 멈출 수 있습니다.</strong></p>
<p>★<strong>제일 빠른 걸 안 골랐습니다.</strong></p>
<p>정렬을 아예 빼면 <strong>나오는 20개 자체가 매번 달라집니다.</strong>
조건에 맞는 10,135개 중에 DB가 <strong>&quot;아무 20개&quot;</strong> 를 고르거든요.</p>
<pre><code>&#39;김밥&#39;  id정렬    → 128, 149, 225, 537, 571, ...
        정렬없음  → 24174, 24244, 24293, ...    ← 완전히 다른 목록</code></pre><p>같은 검색어인데 <strong>어떨 땐 이 가게가 나오고 어떨 땐 안 나옵니다.</strong>
<strong>0.1ms 정도를 더 내고 &quot;항상 같은 결과&quot;를 산 겁니다.</strong></p>
<h2 id="②-개수-세기-없애기--page를-list로">② 개수 세기 없애기 — <code>Page</code>를 <code>List</code>로</h2>
<p><strong>반환 타입만 바꿔도 Spring이 개수 쿼리를 안 붙입니다.</strong></p>
<p>처음엔 <code>21개를 가져와서 20개만 주고, 21번째가 있으면 다음 페이지가 있다고 알려주는</code> 방식으로 절충했습니다.
나중에는 <strong>페이징 자체를 다 걷어냈습니다.</strong></p>
<p>앱이 늘 <code>page=0&amp;size=20</code>만 보내고 <strong>다음 페이지 여부도 총 개수도 안 읽는 상태</strong>로 굳었거든요.</p>
<blockquote>
<p><strong>읽는 곳이 없는 값을 만드느라 서버가 코드를 이고 있을 이유가 없습니다.</strong></p>
</blockquote>
<p>★그런데 여기 함정이 하나 있었습니다.</p>
<pre><code>전:  { content: [...], page, size, totalElements, hasNext }
후:  { content: [...] }        ← content 라는 이름은 그대로 뒀다</code></pre><p>응답 구조를 벗겨서 배열만 내리면, <strong>이미 배포된 앱이 <code>data.content</code>를 못 찾아서 목록이 통째로 빕니다.</strong>
그리고 <strong>서버는 200(정상)을 주니까 아무 신호가 없습니다.</strong></p>
<p>이건 <a href="#">7편</a>에서 다시 이야기합니다. 앱은 업데이트가 느려서 <strong>옛날 앱과 새 서버가 항상 같이 돌아갑니다.</strong></p>
<p><strong>덤으로 하나 더 고쳤습니다.</strong> 주변 식당 조회가 <strong>반경 안 전체</strong>에 대해 혼밥러 수·사진·리뷰를 다 조회한 다음 20개를 잘라내고 있었습니다. 자르는 걸 앞으로 옮겨서 <strong>최대 20개만 조회</strong>하게 했습니다.</p>
<h2 id="③-①을-고쳤더니-다른-데가-느려졌다">③ ①을 고쳤더니 다른 데가 느려졌다</h2>
<p>①을 적용하고 다시 재봤더니 <strong>결과가 적은 검색어가 느려졌습니다.</strong></p>
<table>
<thead>
<tr>
<th>결과 개수</th>
<th>20,964</th>
<th>10,135</th>
<th>1,112</th>
<th>47</th>
<th>21</th>
<th>17</th>
</tr>
</thead>
<tbody><tr>
<td>응답</td>
<td>2ms</td>
<td>2ms</td>
<td>6ms</td>
<td>70ms</td>
<td><strong>227ms</strong></td>
<td><strong>217ms</strong></td>
</tr>
</tbody></table>
<p><strong>&quot;20개 채우면 멈추기&quot;는 결과가 적을수록 불리합니다.</strong>
20개를 채우려고 번호 순서대로 한참 멀리까지 훑어야 하니까요.</p>
<p><strong>원인이 뭐였나.</strong></p>
<p>PostgreSQL은 <strong>같은 모양의 쿼리를 5번 실행하면, 그 다음부터는 검색어 값을 안 보고 계획을 미리 정해둡니다.</strong>
매번 계획을 짜는 게 낭비라고 보는 거죠.</p>
<p>그런데 우리 검색은 <strong>검색어를 봐야만 결과 개수를 짐작할 수 있습니다.</strong>
값을 안 보고 정하면 <strong>모든 검색어를 똑같이 취급</strong>해서 반드시 한쪽이 손해를 봅니다.</p>
<p><strong>해결은 설정 한 줄이었습니다.</strong></p>
<pre><code>JDBC 주소 뒤에 붙임:  ?options=-c plan_cache_mode=force_custom_plan
                      = &quot;매번 검색어를 보고 계획을 다시 세워라&quot;</code></pre><table>
<thead>
<tr>
<th>결과 개수</th>
<th>20,964</th>
<th>10,135</th>
<th>1,112</th>
<th>47</th>
<th>21</th>
<th>17</th>
</tr>
</thead>
<tbody><tr>
<td>이전</td>
<td>2ms</td>
<td>2ms</td>
<td>6ms</td>
<td>70ms</td>
<td>227ms</td>
<td>217ms</td>
</tr>
<tr>
<td><strong>이후</strong></td>
<td>6ms</td>
<td>8ms</td>
<td>12ms</td>
<td><strong>6ms</strong></td>
<td><strong>3ms</strong></td>
<td><strong>4ms</strong></td>
</tr>
</tbody></table>
<p><strong>최악이 227ms에서 3ms가 됐고 전 구간이 평평해졌습니다.</strong></p>
<h3 id="매번-계획을-다시-세우면-느리지-않나">매번 계획을 다시 세우면 느리지 않나?</h3>
<p>이게 걱정돼서 계획 시간과 실행 시간을 <strong>따로</strong> 재봤습니다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>검색어</th>
<th>계획 세우는 시간</th>
<th>실제 실행 시간</th>
</tr>
</thead>
<tbody><tr>
<td>미리 정해둠</td>
<td>김밥(10,135건)</td>
<td>0.039ms</td>
<td>0.805ms</td>
</tr>
<tr>
<td>미리 정해둠</td>
<td>스타벅스(36건)</td>
<td>0.002ms</td>
<td><strong>133.210ms</strong></td>
</tr>
<tr>
<td>매번 다시</td>
<td>김밥</td>
<td>0.081ms</td>
<td>0.433ms</td>
</tr>
<tr>
<td>매번 다시</td>
<td>스타벅스</td>
<td>0.031ms</td>
<td><strong>0.058ms</strong></td>
</tr>
</tbody></table>
<p><strong>계획 세우는 데 드는 시간은 0.03~0.08ms입니다. 계획을 잘못 고르면 133ms입니다.</strong></p>
<p><strong>1,600배 차이라 매번 다시 세우는 게 압도적으로 쌉니다.</strong></p>
<p>★그런데 여기서 제가 실수한 게 하나 있습니다.
처음엔 &quot;응답이 2ms에서 8ms로 늘었으니 이게 재계획 비용이구나&quot;라고 문서에 적어놨었습니다.
<strong>나중에 제대로 재보니 그게 아니었습니다.</strong> 그 6ms는 그냥 <strong>다른 시점에 잰 데서 온 오차</strong>였습니다.</p>
<blockquote>
<p><strong>원인을 잘못 적은 채로 문서가 굳어 있었습니다.</strong>
응답 시간 차이를 보고 원인을 단정하면 안 됩니다.</p>
</blockquote>
<h3 id="계획을-하나로-고정하면-안-되나">계획을 하나로 고정하면 안 되나?</h3>
<p>시도해봤습니다. <strong>결과가 정확히 대각선이었습니다.</strong></p>
<table>
<thead>
<tr>
<th></th>
<th>번호순으로 훑기</th>
<th>인덱스 쓰기</th>
</tr>
</thead>
<tbody><tr>
<td>흔한 검색어 (김밥)</td>
<td><strong>0.43ms</strong></td>
<td>479ms</td>
</tr>
<tr>
<td>드문 검색어 (스타벅스)</td>
<td>133ms</td>
<td><strong>0.06ms</strong></td>
</tr>
</tbody></table>
<blockquote>
<p><strong>어떤 방법으로든 계획을 하나로 고정하면 반드시 한쪽이 무너집니다.</strong></p>
<p>인덱스를 더 만들어도 안 됩니다.
<code>LIKE &#39;%..%&#39;</code> 는 <strong>검색어를 봐야만</strong> 결과 개수를 알 수 있는데,
미리 정해두는 방식은 <strong>정의상 검색어를 못 봅니다.</strong></p>
</blockquote>
<h2 id="④-위치-기반-검색--반경을-조금씩-넓히기">④ 위치 기반 검색 — 반경을 조금씩 넓히기</h2>
<p>①~③으로 일반 검색이 1.2ms가 되면서, <strong>이제 위치 기반 검색(22.6ms)이 제일 느려졌습니다.</strong></p>
<p><strong>원인은 반경 넓이였습니다.</strong></p>
<p>식당 이름 조건이 좌표 인덱스에는 없어서, <strong>일단 반경 안 식당을 전부 꺼낸 다음에</strong> 이름으로 거릅니다.
즉 <strong>넓게 잡을수록 &quot;꺼냈다가 버리는 것&quot;만 늘어납니다.</strong></p>
<table>
<thead>
<tr>
<th>반경</th>
<th>꺼내는 개수</th>
<th>남는 개수</th>
<th>시간</th>
</tr>
</thead>
<tbody><tr>
<td>10km</td>
<td>77,067</td>
<td>976</td>
<td>23.8ms</td>
</tr>
<tr>
<td>5km</td>
<td>23,159</td>
<td>281</td>
<td>6.6ms</td>
</tr>
<tr>
<td>3km</td>
<td>13,866</td>
<td>168</td>
<td>3.9ms</td>
</tr>
<tr>
<td>1km</td>
<td>2,856</td>
<td>38</td>
<td><strong>1.2ms</strong></td>
</tr>
</tbody></table>
<p><strong>해결: 1km → 3km → 요청한 반경 순서로 시도하고, 20개가 모이면 즉시 멈춥니다.</strong></p>
<p>★<strong>대충 하는 게 아니라 결과가 정확히 같습니다.</strong></p>
<p>어차피 <strong>가까운 순으로 20개만</strong> 쓰니까, 1km 안에 이미 20개가 있으면
<strong>그보다 먼 가게는 어차피 그 20개 안에 못 듭니다.</strong> 넓혀도 순위가 안 바뀝니다.</p>
<p>가까운 곳에 없으면 요청한 반경까지 넓히니까, 먼 가게를 이름으로 찾는 것도 그대로 됩니다.
(실제로 3.7km, 7.2km 가게가 정상적으로 나오는 걸 테스트로 확인했습니다.)</p>
<p><strong>결과: 22.6ms → 1.4ms</strong></p>
<p><strong>먼저 시도했다가 버린 방법도 적어둡니다.</strong></p>
<table>
<thead>
<tr>
<th>시도</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>이름 인덱스를 먼저 타게 강제</td>
<td>76.8ms — 흔한 검색어에서 오히려 느려짐</td>
</tr>
<tr>
<td>이름+좌표 합친 인덱스를 새로 만듦</td>
<td>36.8ms — 더 나쁨</td>
</tr>
</tbody></table>
<h2 id="⑤-안-한-것--최소-검색어를-2글자에서-3글자로">⑤ 안 한 것 — 최소 검색어를 2글자에서 3글자로</h2>
<p>제일 쉬운 해법은 <strong>&quot;2글자 검색을 아예 막는 것&quot;</strong> 이었습니다. <strong>안 했습니다.</strong></p>
<p>①②③으로 2글자가 이미 빨라져서 <strong>불편함을 감수할 이유가 없어졌고</strong>,
게다가 이 값이 <strong>친구 닉네임 검색과 공유</strong>돼 있어서 올리면 2글자 닉네임을 못 찾게 됩니다.</p>
<hr>
<h1 id="6부--결과">6부 — 결과</h1>
<h2 id="처리량">처리량</h2>
<table>
<thead>
<tr>
<th></th>
<th>개선 전</th>
<th>개선 후</th>
</tr>
</thead>
<tbody><tr>
<td>초당 4건</td>
<td>1,984ms (무너지기 시작)</td>
<td>—</td>
</tr>
<tr>
<td>초당 150건</td>
<td>도달 불가</td>
<td><strong>2ms, 목표치를 완전히 따라감</strong></td>
</tr>
<tr>
<td><strong>한계</strong></td>
<td><strong>초당 4.4건</strong></td>
<td><strong>초당 150건에서도 안 꺾임</strong></td>
</tr>
<tr>
<td>최대 부하 시 DB CPU</td>
<td>197% (포화)</td>
<td><strong>20.4%</strong></td>
</tr>
</tbody></table>
<p><strong>최소 34배입니다.</strong> 150건까지 밀어도 안 꺾여서 <strong>진짜 한계는 아직 모릅니다.</strong></p>
<h2 id="응답-시간">응답 시간</h2>
<table>
<thead>
<tr>
<th>경로</th>
<th>길이</th>
<th>전</th>
<th>후</th>
</tr>
</thead>
<tbody><tr>
<td>좌표 없음</td>
<td>2글자</td>
<td>669.8ms</td>
<td><strong>1.2ms</strong></td>
</tr>
<tr>
<td></td>
<td>3글자</td>
<td>29.5ms</td>
<td>1.8ms</td>
</tr>
<tr>
<td></td>
<td>4글자</td>
<td>1.1ms</td>
<td>0.4ms</td>
</tr>
<tr>
<td>좌표 있음</td>
<td>2글자</td>
<td>144.1ms</td>
<td><strong>1.4ms</strong></td>
</tr>
</tbody></table>
<p><strong>모든 검색이 2ms 아래로 들어왔고, 좌표 없는 경로에서는 글자 수 차이가 사라졌습니다.</strong></p>
<h2 id="실제-서버에서도-재봤습니다">실제 서버에서도 재봤습니다</h2>
<p>로컬은 맥이라 후하게 나옵니다. 배포한 뒤 <strong>실제 서버 DB에서 직접</strong> 같은 쿼리를 쟀습니다.</p>
<table>
<thead>
<tr>
<th>실제 서버 쿼리</th>
<th>전</th>
<th>후</th>
</tr>
</thead>
<tbody><tr>
<td>검색 (목록 + 개수 세기)</td>
<td><strong>1,028ms</strong></td>
<td><strong>1.4ms</strong></td>
</tr>
<tr>
<td>위치 기반 검색</td>
<td>71.4ms</td>
<td>4.8ms</td>
</tr>
</tbody></table>
<p>실제 서버는 CPU가 느려서 <strong>전체 훑기의 대가를 더 크게 치르고 있었습니다.</strong>
그래서 <strong>개선 폭이 로컬보다 컸습니다.</strong></p>
<p><strong>개선 전에는 실제 검색이 1초를 넘고 있었다는 뜻입니다.</strong></p>
<hr>
<h1 id="7부--있는데-안-고친-것-n1">7부 — 있는데 안 고친 것 (N+1)</h1>
<p>테스트 5에서 <strong>N+1이 진짜로 있다는 걸 확인했습니다.</strong></p>
<p><strong>N+1이란</strong>, 목록 1번을 조회한 다음 <strong>각 항목마다 쿼리를 하나씩 더 날리는</strong> 걸 말합니다.
대화방이 50개면 쿼리가 51번 나가는 식이죠.</p>
<p>DB 로그로 세어보니 정확히 그랬습니다.</p>
<table>
<thead>
<tr>
<th>대화방</th>
<th>쿼리 수</th>
<th>응답 중앙값</th>
</tr>
</thead>
<tbody><tr>
<td>0개</td>
<td>4</td>
<td>1.0ms</td>
</tr>
<tr>
<td>5개</td>
<td>10</td>
<td>1.3ms</td>
</tr>
<tr>
<td>20개</td>
<td>25</td>
<td>1.7ms</td>
</tr>
<tr>
<td>50개</td>
<td><strong>55</strong></td>
<td>2.5ms</td>
</tr>
</tbody></table>
<p>반복되는 건 <strong>안 읽은 메시지 개수 세기</strong>였습니다.
미리보기는 이미 한 번에 묶어서 가져오는데(주석에 &quot;N+1 제거&quot;라고 적혀 있습니다) <strong>안읽음만 안 묶여 있었습니다.</strong></p>
<p><strong>그런데 무해했습니다.</strong></p>
<p>대화방 하나당 <strong>0.03ms</strong>입니다. 인덱스로 딱 집어서 가져오니까 하나당 거의 공짜입니다.</p>
<p>처리량도 안 막았습니다. <strong>초당 300건</strong>(= 초당 16,500 쿼리)을 줬을 때
<strong>대화방 0개인 그룹과 50개인 그룹이 똑같이 목표치를 따라갔습니다.</strong></p>
<p>초당 300건은 <strong>동시 접속 4,500명이 15초마다 새로고침하는 양</strong>입니다.</p>
<p><strong>결론: 안 고쳤습니다.</strong> 묶으면 2.5ms → 1.1ms가 되지만 <strong>체감에도 용량에도 영향이 없습니다.</strong></p>
<blockquote>
<p><strong>&quot;N+1은 무조건 나쁘다&quot;가 아니라, 비용을 재보고 판단했습니다.</strong>
다만 이건 <strong>재봤기 때문에 할 수 있는 말</strong>입니다. 안 재고 넘어간 것과는 다릅니다.</p>
</blockquote>
<hr>
<h1 id="8부--재면서-걸린-함정들">8부 — 재면서 걸린 함정들</h1>
<p>다음에 또 잴 때 같은 데 안 빠지려고 적어둡니다.</p>
<table>
<thead>
<tr>
<th>함정</th>
<th>무슨 일이 났나</th>
<th>어떻게 했나</th>
</tr>
</thead>
<tbody><tr>
<td><strong>토큰 만료</strong></td>
<td>1시간 지나서 <strong>모든 요청이 401을 4ms에 받음</strong> → &quot;서버가 7배 빨라졌다&quot;로 보임</td>
<td>시작 전에 토큰 확인. <strong>실패율을 항상 같이 볼 것</strong></td>
</tr>
<tr>
<td><strong>예열 부족</strong></td>
<td>첫 테스트가 자바 최적화 비용을 다 씀 (650ms → 예열 후 126ms)</td>
<td>제일 무거운 것과 같은 강도로 예열 + <strong>대조군 넣기</strong></td>
</tr>
<tr>
<td><strong>앱만 봄</strong></td>
<td>앱 CPU가 7%라 &quot;서버가 논다&quot;고 오해. 실제로는 DB가 136%</td>
<td><strong>앱과 DB 둘 다</strong> 측정</td>
</tr>
<tr>
<td><strong>DB에 CPU 제한 안 검</strong></td>
<td>PostgreSQL이 맥 CPU 10개를 다 씀 → <strong>맥의 성능을 잼</strong></td>
<td>앱·DB를 같은 CPU 2개에 묶고, 안 묶였으면 실행 거부</td>
</tr>
<tr>
<td><strong>SQL 로그 켜둠</strong></td>
<td>로그 찍는 비용이 측정값을 덮음</td>
<td>부하 테스트용 설정에서 끔</td>
</tr>
<tr>
<td><strong>밀린 요청 미고려</strong></td>
<td>복귀 구간 전체를 평균 내서 &quot;회복 실패&quot;로 오판</td>
<td>판정을 <strong>복귀 뒤 60초</strong>로</td>
</tr>
<tr>
<td><strong>동시 요청 섞기</strong></td>
<td>초당 5건으로 재니 877ms (하나씩 재면 340ms)</td>
<td>응답시간 비교는 <strong>하나씩 순서대로</strong></td>
</tr>
<tr>
<td><strong>계획 시간과 실행 시간을 안 나눔</strong></td>
<td>응답 차이를 원인으로 단정 → <strong>잘못된 설명이 문서에 굳음</strong></td>
<td><code>EXPLAIN ANALYZE</code>에서 둘을 <strong>따로</strong> 읽기</td>
</tr>
</tbody></table>
<hr>
<h2 id="정리--이-편의-요약">정리 — 이 편의 요약</h2>
<ol>
<li><strong>재는 환경을 만드는 게 작업의 절반이다.</strong> CPU를 안 묶으면 서버가 아니라 내 맥을 재게 됩니다.</li>
<li><strong>예상은 대부분 틀린다.</strong> 5개 중 1개 맞았고, 제일 먼저 의심한 것들이 전부 아니었습니다.</li>
<li><strong>제일 비싼 쿼리가 아무도 안 쓰는 값을 만들고 있었다.</strong> 화면을 바꾸면서 남은 잔재였습니다.</li>
<li><strong>고치면 다른 데가 느려질 수 있다.</strong> 반드시 다시 잽니다.</li>
<li><strong>제일 빠른 걸 안 고를 때가 있다.</strong> 정렬 제거가 제일 빨랐지만 &quot;항상 같은 결과&quot;를 샀습니다.</li>
<li><strong>재시도는 증상을 가리고 병을 키운다.</strong> 에러율만 보면 이 장애를 못 잡습니다.</li>
</ol>
<hr>
<p>다음 편은 <strong>배포와 운영</strong>입니다.
<strong>&quot;로컬에서 됐다&quot;가 &quot;서버에서도 된다&quot;를 뜻하지 않는다</strong>는 걸 세 번 연속으로 배웠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[락을 한 번도 안 쓰고 동시성을 막았습니다 — 혼정 개발기 ⑤]]></title>
            <link>https://velog.io/@yoon_bly/%EB%9D%BD%EC%9D%84-%ED%95%9C-%EB%B2%88%EB%8F%84-%EC%95%88-%EC%93%B0%EA%B3%A0-%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%84-%EB%A7%89%EC%95%98%EC%8A%B5%EB%8B%88%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-ybhs9ag1</link>
            <guid>https://velog.io/@yoon_bly/%EB%9D%BD%EC%9D%84-%ED%95%9C-%EB%B2%88%EB%8F%84-%EC%95%88-%EC%93%B0%EA%B3%A0-%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%84-%EB%A7%89%EC%95%98%EC%8A%B5%EB%8B%88%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-ybhs9ag1</guid>
            <pubDate>Fri, 04 Sep 2026 09:30:16 GMT</pubDate>
            <description><![CDATA[<!--
벨로그 제목: 락을 한 번도 안 쓰고 동시성을 막았습니다 — 혼정 개발기 ⑤
시리즈: 혼정 개발기
태그: SpringBoot, JPA, PostgreSQL, 동시성, 백엔드
-->

<p>이번 편은 코드입니다. 세 가지를 봅니다.</p>
<ol>
<li><strong>체크인 상태 관리</strong> — 이 앱의 핵심</li>
<li><strong>동시성</strong> — 락을 한 번도 안 썼습니다</li>
<li><strong>채팅 서버</strong> — 소켓을 &quot;받기 전용&quot;으로 둔 이유</li>
</ol>
<p>용어는 나올 때마다 풀어서 적겠습니다.</p>
<hr>
<h1 id="1부--체크인-상태-관리">1부 — 체크인 상태 관리</h1>
<h2 id="상태가-5개입니다">상태가 5개입니다</h2>
<pre><code class="language-java">public enum CheckInStatus {
    SEEKING,     // 모집중 — 같이 갈 사람 구하는 중
    ACTIVE,      // 혼밥중 — 사람 못 구해서 혼자 먹는 중
    TOGETHER,    // 같이 먹는 중
    ENDED,       // 종료
    CANCELLED    // 취소 (통계에서 뺌)
}</code></pre>
<pre><code>                    [체크인 버튼]
                          ↓
                    ┌─────────────┐
                    │   SEEKING   │  모집중
                    └──┬───┬───┬──┘
       누가 신청 수락     │   │   │  3시간 방치되면 자동 정리
          ┌────────────┘   │   └──────────────┐
          │                │ &quot;그냥 혼자 먹을래&quot;   │
          ↓                ↓                  ↓
   ┌────────────┐   ┌────────────┐     ┌───────────┐
   │  TOGETHER  │   │   ACTIVE   │     │ CANCELLED │
   └──┬──────┬──┘   └──┬──────┬──┘     └───────────┘
      │매칭깨짐 │3시간    │취소   │종료
      ↓       ↓        ↓      ↓
  SEEKING/  ENDED  CANCELLED  ENDED
  ACTIVE</code></pre><h2 id="ended와-cancelled를-왜-나눴나"><code>ENDED</code>와 <code>CANCELLED</code>를 왜 나눴나</h2>
<pre><code>ENDED     = &quot;밥을 먹었다&quot;     → 통계에 넣음, 리뷰 쓸 수 있음
CANCELLED = &quot;밥을 안 먹었다&quot;  → 통계에서 뺌</code></pre><p>체크인만 해놓고 <strong>5분 만에 나간 걸 &quot;오늘 혼밥한 사람&quot;에 넣으면 안 되니까요.</strong></p>
<p>그래서 30분 안에 종료하면 <strong>&quot;혹시 취소하시는 건가요?&quot;</strong> 확인을 한 번 띄우고 <code>CANCELLED</code>로 보냅니다.</p>
<p><a href="#">2편</a>에서 &quot;숫자를 정직하게 유지한다&quot;고 했는데, 그게 여기서 <strong>테이블 값</strong>으로 내려온 겁니다.</p>
<h2 id="코드에-예전-방식이-남아-있었습니다">코드에 &quot;예전 방식&quot;이 남아 있었습니다</h2>
<p>리뷰하다가 발견한 건데, 적어둘 만합니다.</p>
<pre><code class="language-java">/** 새 ACTIVE 체크인을 시작한다. */
public static CheckIn start(...) { ... }

/** 새 SEEKING(모집중) 체크인을 시작한다. */
public static CheckIn startSeeking(...) { ... }</code></pre>
<p>원래 체크인은 <strong><code>ACTIVE</code>(혼밥중)으로 시작</strong>했습니다. 나중에 <code>SEEKING</code>(모집중)으로 바꿨죠.
그런데 옛날 메서드가 남아 있었습니다.</p>
<pre><code>CheckIn.start()        →  실제 코드에서 0곳 · 테스트에서 10곳
CheckIn.startSeeking() →  실제로 쓰는 곳</code></pre><p><strong>테스트가 쓰고 있어서 &quot;안 쓰는 코드&quot;로 안 보였습니다.</strong></p>
<p>남겨두면 나중에 누가 이걸 써서 <code>ACTIVE</code>로 바로 가는 코드를 짤 수 있습니다.
지금은 알고만 있고, <code>@Deprecated</code>를 붙이는 게 맞습니다.</p>
<blockquote>
<p><strong>테스트가 쓰고 있으면 죽은 코드가 안 보입니다.</strong></p>
</blockquote>
<h2 id="매칭이-깨질-때--한쪽만-정리하면-상대가-갇힙니다">매칭이 깨질 때 — 한쪽만 정리하면 상대가 갇힙니다</h2>
<p>&quot;제가 못 가게 됐어요&quot;를 누르면 어떻게 되나. 여기가 제일 조심스러운 부분이었습니다.</p>
<p><strong>잘못 만들면 이렇게 됩니다.</strong></p>
<pre><code>나:   TOGETHER → ACTIVE  (혼자 먹는다)
상대: TOGETHER 그대로  ← 갇힘
   ↓ 앱에는 &quot;○○님과 같이 먹는 중&quot;이 계속 뜸
   ↓ 새로 체크인도 못 함 (한 번에 하나만 되니까)
   ↓ 3시간 뒤 자동 정리될 때까지 아무것도 못 함</code></pre><p>그래서 실제 코드는 이렇습니다.</p>
<pre><code class="language-java">@Transactional
public CheckInResponse leaveMatch(Long userId, Long checkInId, String toStr) {
    ...
    Long mrId = mine.getMealRequestId();     // ★① 지워지기 전에 미리 꺼낸다

    // ② 상대를 SEEKING(모집중)으로 되돌리고 알림을 보낸다
    checkInRepository.findTogetherByMealRequestId(mrId).stream()
            .filter(x -&gt; !x.getUser().getId().equals(userId))   // 나 말고 상대
            .findFirst()
            .ifPresent(partner -&gt; {
                partner.leaveMatch(CheckInStatus.SEEKING, now);
                notificationService.publish(...);
            });

    mine.leaveMatch(to, now);                          // ③ 나는 선택한 상태로
    if (mrId != null) conversationService.close(mrId); // ④ 대화방 닫기
}</code></pre>
<h3 id="①번이-함정입니다">①번이 함정입니다</h3>
<pre><code class="language-java">Long mrId = mine.getMealRequestId();   // 먼저 꺼내둔다
mine.leaveMatch(to, now);              // ← 이 안에서 mealRequestId 를 null로 만든다
conversationService.close(mrId);       // 미리 꺼내놨으니 쓸 수 있다</code></pre>
<p><strong>&quot;바꾸기 전에 먼저 꺼낸다.&quot;</strong></p>
<p>이걸 안 하면 <code>mrId</code>가 이미 <code>null</code>이라 대화방을 못 닫습니다.
그리고 이런 종류의 실수가 회원 탈퇴에서도 나왔습니다 —
<strong>휴대폰 번호를 지우기 전에 그 번호로 인증 기록을 먼저 지워야</strong> 하는데, 순서를 반대로 하면 지울 대상을 잃어버립니다.</p>
<h3 id="상대는-왜-항상-seeking인가">상대는 왜 항상 <code>SEEKING</code>인가</h3>
<p><strong>상대는 아무 잘못이 없으니까</strong> 다시 모집할 기회를 줍니다.</p>
<h3 id="대화방-닫는-곳이-5곳입니다">대화방 닫는 곳이 5곳입니다</h3>
<pre><code>대화방을 여는 곳  : 1곳  (매칭 성사)
대화방을 닫는 곳  : 5곳  (직접 종료 · 매칭 깨짐 · 3시간 만료 · 차단 · 탈퇴)</code></pre><p><strong>이 비대칭이 <a href="#">4편</a>에서 말한 버그의 원인</strong>이었습니다.
&quot;매칭 깨는 기능&quot;을 만들 때 대화방을 깜빡한 거죠.</p>
<h2 id="제일-큰-트랜잭션--버튼-하나에-8가지가-바뀝니다">제일 큰 트랜잭션 — 버튼 하나에 8가지가 바뀝니다</h2>
<p>같이먹기 <strong>수락</strong> 버튼입니다.</p>
<pre><code>BEGIN (여기서부터 한 덩어리)
  ① 신청을 ACCEPTED로
  ② 받은 사람 체크인 SEEKING → TOGETHER
  ③ 신청한 사람의 기존 체크인 정리
  ④ 신청한 사람의 새 체크인 생성 (TOGETHER)
  ⑤ 나머지 신청들 EXPIRED 처리
  ⑥ 대화방 만들기
  ⑦ 뱃지 판정 (2명분)
  ⑧ 알림 보내기
COMMIT (여기까지 다 되면 저장)</code></pre><p><strong>⑥에서 실패하면 ①~⑤가 전부 되돌아갑니다.</strong></p>
<p>안 그러면 <strong>&quot;매칭은 됐는데 대화방이 없는&quot;</strong> 상태가 남는데, 그 상태는 앱에서 복구할 방법이 없습니다.
사용자는 계속 &quot;같이 먹는 중&quot;인데 대화를 못 하는 거죠.</p>
<hr>
<h1 id="2부--락을-한-번도-안-썼습니다">2부 — 락을 한 번도 안 썼습니다</h1>
<h2 id="문제-상황부터">문제 상황부터</h2>
<p>동시에 두 요청이 들어오면 이런 일이 생깁니다.</p>
<pre><code>시간 →

요청 A: [이 사람 체크인 있나?] 없음 → [체크인 만들기]
요청 B:      [이 사람 체크인 있나?] 없음 → [체크인 만들기]
                                            ↑ 두 개가 만들어진다</code></pre><p><strong>검사와 실행 사이에 틈이 있어서</strong> 그렇습니다.
버튼을 빠르게 두 번 누르거나, 네트워크가 느려서 재시도되면 실제로 생깁니다.</p>
<h2 id="흔한-해결책은-락lock입니다">흔한 해결책은 락(lock)입니다</h2>
<p>&quot;내가 처리하는 동안 다른 요청은 기다려&quot; 하고 잠그는 방식입니다.
JPA에는 <code>@Version</code>, <code>@Lock</code> 같은 게 있습니다.</p>
<p><strong>이 프로젝트는 하나도 안 썼습니다.</strong></p>
<pre><code>@Version               0건
@Lock                  0건
SELECT ... FOR UPDATE  0건</code></pre><h2 id="대신-db-제약으로-막았습니다">대신 DB 제약으로 막았습니다</h2>
<pre><code class="language-sql">-- &quot;진행 중인 체크인은 한 사람당 하나만&quot; 을 DB가 강제한다
CREATE UNIQUE INDEX uq_check_ins_current_user
    ON check_ins (user_id)
    WHERE status IN (&#39;SEEKING&#39;, &#39;ACTIVE&#39;, &#39;TOGETHER&#39;);</code></pre>
<p><code>UNIQUE INDEX</code>는 <strong>&quot;이 값이 중복되면 DB가 거부한다&quot;</strong> 는 뜻입니다.
여기에 <code>WHERE</code>를 붙여서 <strong>&quot;진행 중인 것만&quot;</strong> 중복을 막습니다.
(끝난 체크인은 몇 개가 쌓여도 되니까요.)</p>
<p>그리고 코드는 이렇게 3단으로 짰습니다.</p>
<pre><code>① 앱에서 먼저 확인한다        → 정상적인 경우 친절한 409 에러
② DB 제약이 최종 방어         → 동시 요청에서 진 쪽은 DB가 거부
③ 그 예외를 잡아서 409로 바꾼다 → 사용자한테는 ①과 똑같아 보인다</code></pre><p><strong>&quot;막는 건 DB가 하고, 앱은 사용자 말로 번역만 한다.&quot;</strong></p>
<h2 id="왜-락보다-이게-낫다고-생각했나">왜 락보다 이게 낫다고 생각했나</h2>
<table>
<thead>
<tr>
<th></th>
<th>락</th>
<th>DB 제약</th>
</tr>
</thead>
<tbody><tr>
<td>성능</td>
<td>잠긴 동안 다른 요청이 기다림</td>
<td><strong>기다림 없음</strong></td>
</tr>
<tr>
<td>데드락 (서로 기다리다 멈춤)</td>
<td><strong>생길 수 있음</strong></td>
<td>없음</td>
</tr>
<tr>
<td>코드 복잡도</td>
<td>잠그는 순서·시간 관리 필요</td>
<td>인덱스 한 줄</td>
</tr>
<tr>
<td><strong>빠뜨릴 수 있나</strong></td>
<td>락을 안 건 코드가 생기면 뚫림</td>
<td><strong>모든 경로에 적용됨</strong></td>
</tr>
</tbody></table>
<p><strong>마지막 줄이 결정적이었습니다.</strong></p>
<p>락은 <strong>&quot;내가 잠그는 코드를 짠 곳&quot;에만</strong> 걸립니다.
나중에 새 API를 만들면서 깜빡하면 조용히 뚫립니다.</p>
<p>DB 제약은 <strong>어떤 경로로 들어와도 적용되고, 제가 psql로 직접 INSERT해도 막힙니다.</strong></p>
<h2 id="다만-이건-정직하게-적어야-합니다">다만 이건 정직하게 적어야 합니다</h2>
<p>이 방법은 <strong>&quot;중복을 막는&quot; 종류에만 통합니다.</strong></p>
<p>예를 들어 포인트를 차감하는 기능이 있었다면 —</p>
<pre><code>현재 잔액을 읽고 → 계산하고 → 다시 쓴다</code></pre><p>이건 유니크 제약으로 못 막습니다. 락이나 원자적 UPDATE가 필요합니다.</p>
<p><strong>이 앱에는 그런 자리가 없었습니다.</strong> 카운터 컬럼을 하나도 안 만들었거든요.
(모든 개수는 조회할 때 그때그때 셉니다.)</p>
<p>그래서 정확한 표현은 이겁니다.</p>
<blockquote>
<p><strong>&quot;락이 필요 없는 설계를 골랐다.&quot;</strong></p>
</blockquote>
<h2 id="여기서-사고가-하나-났습니다">여기서 사고가 하나 났습니다</h2>
<p>같은 &quot;중복 방지&quot;인데 <strong>처리 방법이 두 가지</strong>입니다.</p>
<pre><code>                유니크 위반이 났다
                      │
        ┌─────────────┴─────────────┐
   사용자에게 알려야 하나?      그냥 넘어가면 되나?
   (&quot;이미 신청했어요&quot;)        (&quot;이미 있으니까 OK&quot;)
        │                           │
    catch → 409 던지기          ON CONFLICT 사용</code></pre><p><strong>&quot;그냥 넘어가기&quot;에 <code>catch</code>를 쓰면 안 됩니다.</strong> 뱃지 지급에서 직접 겪었습니다.</p>
<pre><code>BEGIN
  INSERT user_badges ...    → 중복이라 실패
  (자바가 catch로 잡음 — 그런데 PostgreSQL은 이미 포기한 상태)
  INSERT notifications ...  → ❌ 에러: 이 트랜잭션은 이미 중단됨
COMMIT                      → ❌</code></pre><p><strong>PostgreSQL은 트랜잭션 안에서 에러가 한 번 나면 그 트랜잭션 전체를 포기합니다.</strong>
자바가 예외를 잡아도 <strong>DB는 안 잡습니다.</strong> 그 뒤 명령은 전부 거부됩니다.</p>
<p>결과는 <strong>뱃지도 안 들어가고 알림도 안 가고, 조회까지 깨졌습니다.</strong></p>
<p>그래서 규칙을 정했습니다.</p>
<ul>
<li><strong><code>catch</code>는 &quot;잡고 바로 예외를 던져서 끝낼 때&quot;만 안전합니다.</strong> 뒤에 DB 명령이 없어야 합니다.</li>
<li><strong>계속 진행해야 하면 <code>ON CONFLICT</code></strong> 를 씁니다. (SQL 문법으로 &quot;중복이면 그냥 넘어가&quot;를 표현하는 것)</li>
</ul>
<pre><code class="language-sql">INSERT INTO user_badges (...) VALUES (...)
ON CONFLICT (user_id, badge_key) DO NOTHING;
-- 반환값 1 = 새로 넣음 / 0 = 이미 있었음
-- ★이 반환값으로 알림 중복 발송을 막는다</code></pre>
<blockquote>
<p><strong>이 사고는 가짜 DB(H2)로 테스트했으면 절대 못 잡습니다.</strong>
실제 PostgreSQL이 아니면 저 동작이 재현되지 않습니다. (<a href="#">8편</a>)</p>
</blockquote>
<h2 id="외부-호출은-트랜잭션-밖으로-뺐습니다">외부 호출은 트랜잭션 밖으로 뺐습니다</h2>
<p>푸시 알림을 보내는 코드입니다.</p>
<pre><code>트랜잭션 커밋 완료 → 그 다음에 실행 → ┌ 필요한 데이터 조회
                                     ├ FCM에 HTTP 요청  ← 트랜잭션 없음
                                     └ 발송 기록 저장</code></pre><p>이유가 둘입니다.</p>
<p><strong>① 취소될 일을 미리 알리면 안 됩니다.</strong>
커밋 전에 푸시를 보내면, 그 뒤에 트랜잭션이 실패했을 때 <strong>일어나지도 않은 일의 알림</strong>이 갑니다.</p>
<p><strong>② FCM 응답이 느립니다.</strong>
HTTP 왕복을 트랜잭션 안에 두면 <strong>그동안 DB 커넥션을 붙들고 있습니다.</strong>
동시 사용자가 늘면 커넥션이 금방 동납니다.</p>
<p>그리고 <strong>푸시 실패는 무시합니다.</strong> 알림이 안 갔다고 같이먹기 신청을 취소하면 안 되니까요.</p>
<blockquote>
<p>이 구조에 후일담이 있습니다.
<strong>&quot;HTTP 호출할 때 트랜잭션이 없다&quot;는 걸 확인하는 테스트가 하나도 없었습니다.</strong>
<code>@Transactional</code> 한 줄만 다시 붙이면 그대로 되돌아가는데 <strong>707개가 전부 통과</strong>였습니다. (<a href="#">8편</a>)</p>
</blockquote>
<hr>
<h1 id="3부--채팅-서버">3부 — 채팅 서버</h1>
<h2 id="소켓은-받기-전용입니다">소켓은 &quot;받기 전용&quot;입니다</h2>
<p>보통 채팅을 만들면 소켓으로 주고받기를 다 합니다. 저는 다르게 했습니다.</p>
<pre><code>메시지 보내기  →  HTTP POST /api/conversations/{id}/messages   (평범한 REST)
메시지 받기    →  WebSocket                                    (서버가 밀어줌)</code></pre><p>클라이언트가 소켓으로 보낼 수 있는 건 <strong><code>ping</code> 하나뿐</strong>입니다.</p>
<p><strong>왜 이렇게 했나 — 이유 둘.</strong></p>
<h3 id="①-권한-검사를-두-번-짜지-않으려고">① 권한 검사를 두 번 짜지 않으려고</h3>
<p>WebSocket 연결은 <strong>스프링 시큐리티 필터를 안 거칩니다.</strong></p>
<p>그래서 소켓으로 메시지를 받으면, 프레임이 들어올 때마다
<strong>&quot;이 사람이 이 대화방 참여자가 맞나&quot;를 처음부터 다시 짜야 합니다.</strong></p>
<p>REST로 보내면 기존에 만들어둔 것들이 <strong>그대로 적용됩니다.</strong>
로그인 확인, 정지/탈퇴 계정 차단 같은 게 전부요.</p>
<blockquote>
<p><strong>같은 규칙을 두 곳에 두면 반드시 갈라집니다.</strong>
한쪽만 고치고 다른 쪽을 깜빡하니까요.</p>
</blockquote>
<h3 id="②-소켓이-끊겨도-앱이-동작합니다">② 소켓이 끊겨도 앱이 동작합니다</h3>
<p>지하철에서 네트워크가 잠깐 끊기면 소켓이 죽습니다.
그런데 <strong>전송은 REST라 소켓과 상관없이 됩니다.</strong></p>
<p>안 되는 건 &quot;새 메시지가 바로 뜨는 것&quot;뿐이고, 그건 30초 폴링이 메웁니다.</p>
<blockquote>
<p><strong>장애가 &quot;기능 정지&quot;가 아니라 &quot;조금 느려짐&quot;으로 떨어집니다.</strong></p>
</blockquote>
<h2 id="stomp를-안-썼습니다">STOMP를 안 썼습니다</h2>
<p>스프링에는 STOMP라는 메시징 규약 지원이 있습니다.
<code>@MessageMapping</code>, <code>/topic/...</code> 구독 같은 걸 쓸 수 있습니다.</p>
<p><strong>안 썼습니다.</strong> 대신 이것 하나를 직접 만들었습니다.</p>
<pre><code class="language-java">// WsSessionRegistry
private final Map&lt;Long, Set&lt;WebSocketSession&gt;&gt; sessions = new ConcurrentHashMap&lt;&gt;();</code></pre>
<p><strong>&quot;사용자 id → 그 사람이 열어놓은 소켓들&quot;</strong> 하는 Map입니다. 이게 전부입니다.</p>
<pre><code class="language-java">registry.sendTo(수신자id, JSON문자열);</code></pre>
<p>STOMP는 <strong>&quot;여러 명이 한 주제를 구독하고 아무나 메시지를 뿌리는&quot;</strong> 구조를 위한 겁니다.
단톡방이나 실시간 시세 같은 것들이요.</p>
<p><strong>이 앱의 채팅은 1:1이고, 소켓으로는 아무도 메시지를 안 보냅니다.</strong>
서버가 특정 한 사람에게 밀어주는 것뿐이라 <strong>구독이라는 개념 자체가 필요 없었습니다.</strong></p>
<blockquote>
<p><strong>&quot;라이브러리가 주는 기능 중에 내가 쓰는 게 있나?&quot; — 없으면 안 씁니다.</strong></p>
</blockquote>
<h2 id="소켓-인증--30초짜리-1회용-티켓">소켓 인증 — 30초짜리 1회용 티켓</h2>
<p>여기가 제일 재밌었던 부분입니다.</p>
<p><strong>문제:</strong> WebSocket은 <strong>헤더를 붙일 수 없습니다.</strong>
JavaScript의 WebSocket API에 헤더를 넣는 방법이 아예 없습니다.</p>
<p>그래서 보통 이렇게 합니다.</p>
<pre><code>wss://서버/ws?token=eyJhbGci...</code></pre><p><strong>이게 왜 위험한가?</strong></p>
<p><strong>URL은 로그에 남습니다.</strong> 리버스 프록시 로그, 도커 로그, 접근 로그 전부요.</p>
<p>그러면 <strong>1시간짜리 로그인 토큰이 로그에 평문으로 남습니다.</strong>
로그를 볼 수 있는 사람은 <strong>1시간 동안 그 사용자인 척할 수 있습니다.</strong></p>
<p><strong>해결은 이렇게 했습니다.</strong></p>
<pre><code>① 앱:   POST /api/ws-ticket        ← 평범한 REST. 헤더에 토큰 붙임
② 서버: { ticket: &quot;aB3xK...&quot;, ttlSeconds: 30 }
③ 앱:   wss://서버/ws?ticket=aB3xK...
④ 서버: 티켓 확인하고 즉시 삭제 (1회용)</code></pre><p><strong>티켓은 로그에 남아도 쓸모가 없습니다.</strong> 30초면 만료되고, 한 번 쓰면 지워지니까요.</p>
<blockquote>
<p>놀이공원에서 신분증(로그인 토큰)을 매표소에 보여주고 <strong>입장권</strong>(티켓)을 받는 것과 같습니다.
입장권은 한 번 쓰면 찢기고, 30초 뒤엔 무효라 주워도 못 씁니다.</p>
</blockquote>
<p><strong>그리고 부수 효과가 하나 있었습니다.</strong></p>
<p>티켓 발급이 <strong>평범한 REST API</strong>라서 기존 보안 설정을 그대로 탑니다.
즉 <strong>정지·탈퇴한 사용자는 티켓 자체를 못 받습니다.</strong></p>
<p>소켓 연결에서 직접 인증했다면 그 검사를 <strong>한 벌 더 짰어야</strong> 했습니다.</p>
<h2 id="그-외에-소켓에서-겪은-것">그 외에 소켓에서 겪은 것</h2>
<ul>
<li><strong>동시에 두 스레드가 같은 소켓에 쓰면 데이터가 섞입니다.</strong> → <code>synchronized (session)</code> 로 한 번에 하나씩만 보내게 했습니다. (이 프로젝트에서 유일한 <code>synchronized</code>입니다)</li>
<li><strong>끊겼는데 끊긴 줄 모르는 소켓</strong>이 생깁니다. <code>onclose</code>가 안 불릴 수 있어서, 주기적으로 신호를 주고받아 직접 확인합니다.</li>
<li><strong>재연결 시도가 겹치면 소켓과 타이머가 계속 쌓입니다.</strong> 연결마다 번호를 매겨서 오래된 시도는 버리게 했습니다.</li>
</ul>
<hr>
<h2 id="정리--이-편의-요약">정리 — 이 편의 요약</h2>
<ol>
<li><strong>끝나는 상태를 <code>ENDED</code>/<code>CANCELLED</code>로 나눈 게 통계 신뢰도를 결정했다.</strong></li>
<li><strong>매칭을 깰 때는 항상 양쪽을 정리한다.</strong> 한쪽만 하면 상대가 갇힙니다.</li>
<li><strong>락 대신 DB 유니크 제약을 썼다.</strong> 이유는 &quot;빠뜨릴 수 없어서&quot;. 단 중복 방지형에만 통합니다.</li>
<li><strong><code>catch</code>는 잡고 바로 던질 때만 안전하다.</strong> 계속 진행할 거면 <code>ON CONFLICT</code>입니다.</li>
<li><strong>외부 HTTP 호출은 트랜잭션 밖으로 뺀다.</strong> 취소될 일을 미리 알리면 안 되고, 커넥션도 아까우니까요.</li>
<li><strong>소켓을 받기 전용으로 두면 권한 검사를 한 벌만 유지할 수 있다.</strong></li>
</ol>
<hr>
<p>다음 편은 <strong>성능</strong>입니다.
부하 테스트 전에 세운 예상 5개 중 <strong>맞은 게 1개</strong>였고,
진짜 병목은 <strong>아무도 안 쓰는 값을 만드느라 도는 쿼리 하나</strong>였습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[3개월, 671커밋 — 무슨 순서로 만들었나 (혼정 개발기 ④)]]></title>
            <link>https://velog.io/@yoon_bly/3%EA%B0%9C%EC%9B%94-671%EC%BB%A4%EB%B0%8B-%EB%AC%B4%EC%8A%A8-%EC%88%9C%EC%84%9C%EB%A1%9C-%EB%A7%8C%EB%93%A4%EC%97%88%EB%82%98-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-22k6mu67</link>
            <guid>https://velog.io/@yoon_bly/3%EA%B0%9C%EC%9B%94-671%EC%BB%A4%EB%B0%8B-%EB%AC%B4%EC%8A%A8-%EC%88%9C%EC%84%9C%EB%A1%9C-%EB%A7%8C%EB%93%A4%EC%97%88%EB%82%98-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-22k6mu67</guid>
            <pubDate>Fri, 04 Sep 2026 09:25:35 GMT</pubDate>
            <description><![CDATA[<!--
벨로그 제목: 3개월, 671커밋 — 무슨 순서로 만들었나 (혼정 개발기 ④)
시리즈: 혼정 개발기
태그: 사이드프로젝트, 개발일지, SpringBoot, 회고, 신입개발자
-->

<p>6월 8일 첫 커밋부터 9월 4일까지 <strong>671개 커밋</strong> 동안 무슨 일이 있었는지 적습니다.</p>
<p>기능 목록을 나열하기보다 <strong>&quot;어디서 막혔고 그래서 뭐가 바뀌었나&quot;</strong> 를 기준으로 나눴습니다.</p>
<hr>
<h2 id="한눈에--백엔드-테스트-개수로-본-3개월">한눈에 — 백엔드 테스트 개수로 본 3개월</h2>
<p>기능이 늘면 테스트가 늡니다. 그래서 테스트 개수가 진척도 역할을 합니다.</p>
<table>
<thead>
<tr>
<th>시점</th>
<th align="right">테스트</th>
<th>그 구간에 한 일</th>
</tr>
</thead>
<tbody><tr>
<td>06-12</td>
<td align="right">31</td>
<td>프로젝트 세팅 · 공통 코드 · 로그인</td>
</tr>
<tr>
<td>06-15</td>
<td align="right">103</td>
<td>식당 검색 · <strong>체크인</strong></td>
</tr>
<tr>
<td>06-18</td>
<td align="right">143</td>
<td>같이먹기 → <strong>1차 목표 완료</strong></td>
</tr>
<tr>
<td>06-22</td>
<td align="right">172</td>
<td>공공데이터로 식당 교체</td>
</tr>
<tr>
<td>07-05</td>
<td align="right">385</td>
<td>리뷰 · 즐겨찾기 · 친구 · 매칭 · 차단/신고</td>
</tr>
<tr>
<td>07-24</td>
<td align="right">491</td>
<td>알림함 · 공지 · 뱃지 · 생년월일</td>
</tr>
<tr>
<td>07-25</td>
<td align="right">542</td>
<td>채팅 · <strong>카카오 로그인 실연동</strong></td>
</tr>
<tr>
<td>07-30</td>
<td align="right">590</td>
<td>회원 탈퇴</td>
</tr>
<tr>
<td>08-03</td>
<td align="right">626</td>
<td><strong>배포 준비 + 보안 점검</strong></td>
</tr>
<tr>
<td>08-07</td>
<td align="right">707</td>
<td><strong>푸시 알림</strong></td>
</tr>
<tr>
<td>09-04</td>
<td align="right"><strong>827</strong></td>
<td>WebSocket · 애플 로그인 · <strong>성능 개선</strong></td>
</tr>
</tbody></table>
<hr>
<h1 id="6월-초--만들기-전에-작업-방식을-정했다">6월 초 — 만들기 전에 작업 방식을 정했다</h1>
<p>첫 나흘은 코드를 거의 안 짰습니다. 대신 <strong>어떤 순서로 일할지</strong>를 정했습니다.</p>
<pre><code>어떻게 만들지 정리 → 설계 문서 → 할 일 쪼개기 → 하나씩 TDD로 구현 → 전체 리뷰 → 병합</code></pre><p>여기서 중요한 게 마지막 두 개입니다.</p>
<p><strong>할 일 하나하나 리뷰를 받아도, 마지막에 브랜치 전체를 다시 리뷰합니다.</strong></p>
<p>왜냐면 <strong>&quot;할 일 A도 맞고 할 일 B도 맞는데, 둘을 합치면 틀린&quot;</strong> 경우가 있기 때문입니다.
쪼개서 보면 절대 안 보입니다.</p>
<p>이게 나중에 실제로 값을 했습니다. 채팅 기능에서 <strong>최종 리뷰만 잡은 심각한 버그</strong>가 나왔거든요. (아래에서 다시)</p>
<hr>
<h1 id="6월-중순--백엔드를-7조각으로-나눠서-만들었다">6월 중순 — 백엔드를 7조각으로 나눠서 만들었다</h1>
<p>기능을 세로로 쪼갰습니다. 한 조각이 <strong>DB부터 API까지 끝까지 완성</strong>되게요.</p>
<table>
<thead>
<tr>
<th>조각</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>프로젝트 세팅 (Docker · Flyway · 테스트 환경)</td>
</tr>
<tr>
<td>1</td>
<td>공통 코드 (예외 처리 · 응답 형식)</td>
</tr>
<tr>
<td>2</td>
<td>로그인 (휴대폰 인증 · JWT)</td>
</tr>
<tr>
<td>3</td>
<td>프로필</td>
</tr>
<tr>
<td>4</td>
<td>식당 검색</td>
</tr>
<tr>
<td>5</td>
<td><strong>체크인</strong> ← 이 앱의 핵심</td>
</tr>
<tr>
<td>6</td>
<td>같이먹기</td>
</tr>
<tr>
<td>7</td>
<td>전체 흐름 테스트</td>
</tr>
</tbody></table>
<p><strong>6월 18일에 여기까지 끝났습니다.</strong> 테스트 143개.</p>
<h2 id="이-구간에서-제일-크게-배운-것">이 구간에서 제일 크게 배운 것</h2>
<p>휴대폰 인증에 <strong>&quot;5번 틀리면 잠금&quot;</strong> 을 넣었는데, <strong>잠금이 절대 안 걸렸습니다.</strong></p>
<p>코드를 보면 이랬습니다.</p>
<pre><code class="language-java">@Transactional
public void verify(...) {
    record.increaseAttempts();       // 시도 횟수 +1
    if (코드가_틀림) throw ...;       // ← 예외를 던지면
}                                     //   위의 +1도 같이 취소된다</code></pre>
<p><code>@Transactional</code>은 <strong>&quot;이 안에서 하나라도 실패하면 전부 없던 일로 되돌린다&quot;</strong> 는 뜻입니다.
그래서 <strong>&quot;실패해도 기록은 남아야 하는&quot; 것까지 같이 지워졌습니다.</strong></p>
<p>해결은 카운트 기록을 <strong>별도 클래스로 빼고 <code>REQUIRES_NEW</code></strong> 를 붙이는 거였습니다.
(<code>REQUIRES_NEW</code> = &quot;이건 별도 트랜잭션으로 처리해라. 바깥이 실패해도 이건 남겨라&quot;)</p>
<p>★그런데 함정이 하나 더 있었습니다.
<strong>같은 클래스 안에서 메서드를 부르면 이게 안 먹힙니다.</strong> 반드시 다른 클래스로 빼야 합니다.
이건 스프링이 프록시라는 걸로 감싸서 동작하는데, 같은 클래스 안 호출은 그 프록시를 안 거치기 때문입니다.</p>
<p><strong>이 함정을 3번 겪고 4개 클래스를 분리했습니다.</strong></p>
<hr>
<h1 id="6월-하순--식당-데이터를-통째로-갈아엎었다">6월 하순 — 식당 데이터를 통째로 갈아엎었다</h1>
<p>식당 출처를 카카오에서 공공데이터로 바꿨습니다. (<a href="#">3편</a>)</p>
<p>DB 구조를 바꾸는 큰 작업이라 <strong>순서를 지켰습니다.</strong></p>
<pre><code>① 새 컬럼을 추가하고 옛 컬럼과 잠시 같이 둔다   → 이 시점에도 빌드 성공
② 코드를 새 컬럼 쓰도록 옮긴다                 → 이 시점에도 빌드 성공
③ 마지막에 옛 컬럼을 삭제한다</code></pre><p>한 번에 바꾸면 중간에 빌드가 깨지고, 그러면 <strong>문제가 생겼을 때 &quot;원래 있던 버그인지 지금 만든 버그인지&quot;</strong> 구분이 안 됩니다.</p>
<p>그리고 65만 행을 넣는 작업이라 <strong>연습용 테이블에 먼저 넣어서 확인하고 옮기는</strong> 방식으로 했습니다.
잘못 넣으면 되돌릴 방법이 마땅치 않아서요.</p>
<hr>
<h1 id="6월-말--7월-초--기능을-계속-붙인-2주">6월 말 ~ 7월 초 — 기능을 계속 붙인 2주</h1>
<p>가장 밀도가 높았습니다.</p>
<ul>
<li>체크인 앱 연결 · 선호 음식</li>
<li><strong>리뷰</strong> — 체크인 기록이 있으면 &quot;인증 리뷰&quot;로 자동 표시</li>
<li><strong>즐겨찾기</strong> · 프로필</li>
<li><strong>친구(메이트)</strong> — 신청/수락, 서로 신청했으면 자동 수락</li>
<li><strong>같이먹기 매칭</strong> — 상태 정리, 30분 안에 끝내면 취소 처리, 3시간 방치되면 자동 만료</li>
<li><strong>차단/신고</strong> — 애플 심사에 필요한 기능</li>
<li>지도 &#39;이 위치에서 재검색&#39; · 공지 · <strong>알림함</strong></li>
</ul>
<h2 id="여기서-가장-자주-나온-버그-유형">여기서 가장 자주 나온 버그 유형</h2>
<p>수동으로 확인하다가 <strong>화면마다 혼밥 횟수가 12와 15로 달랐습니다.</strong></p>
<p>원인을 찾아보니 <strong>같은 숫자를 세는 쿼리가 3곳</strong>에 있었고,
&quot;취소된 건 빼자&quot;는 조건을 <strong>두 곳에만 넣었습니다.</strong></p>
<p><strong>이 패턴이 3개월 동안 다섯 번 나왔습니다.</strong></p>
<table>
<thead>
<tr>
<th>시점</th>
<th>증상</th>
</tr>
</thead>
<tbody><tr>
<td>07-04</td>
<td>혼밥 횟수 12 vs 15</td>
</tr>
<tr>
<td>07-19</td>
<td>&quot;N명이 혼밥했어요&quot; 3 vs 그래프 막대 합 7</td>
</tr>
<tr>
<td>07-20</td>
<td>저장한 친구 수가 안 맞음</td>
</tr>
<tr>
<td>07-27</td>
<td>같이먹기 탭에 <strong>누를 수 없는 버튼</strong>이 뜸</td>
</tr>
<tr>
<td>08-01</td>
<td>대화 목록의 정렬 순서와 표시 시간이 다름</td>
</tr>
</tbody></table>
<p><strong>해법이 매번 같았습니다.</strong></p>
<blockquote>
<p><strong>쿼리를 하나 더 만들지 말고, 이미 있는 값에서 계산해서 쓴다.</strong></p>
</blockquote>
<p>예를 들어 &quot;N명이 혼밥했어요&quot;는 별도 쿼리로 세지 않고 <strong>그래프 막대 값을 다 더해서</strong> 씁니다.
그러면 <strong>구조적으로 어긋날 수가 없습니다.</strong></p>
<hr>
<h1 id="7월-중순--새-기능을-멈추고-가짜를-걷어냈다">7월 중순 — 새 기능을 멈추고 &quot;가짜&quot;를 걷어냈다</h1>
<p>여기서 방향이 바뀝니다.</p>
<p>디자인 목업을 그대로 옮긴 화면들이 있었는데, <strong>데이터가 없는데 있는 척하는 부분</strong>이 많았습니다.
2주 동안 그걸 다 정리했습니다.</p>
<ul>
<li>데이터가 없는 항목(영업시간·편의시설)은 <strong>지웠습니다</strong> (<a href="#">2편</a>)</li>
<li>로딩 중·에러·데이터 없음 상태를 화면마다 제대로 표시하게 했습니다</li>
<li><strong>1시간 뒤에 모든 요청이 실패하던 문제</strong>를 고쳤습니다 (access 토큰이 만료됐는데 자동으로 갱신을 안 하고 있었습니다)</li>
</ul>
<h2 id="이때-발견한-것-하나">이때 발견한 것 하나</h2>
<p>체크인 시작이 실패하면 <strong>원인과 상관없이</strong> 이렇게 떴습니다.</p>
<pre><code>&quot;이미 다른 곳에서 혼밥 중이에요&quot;</code></pre><p><strong>네트워크가 끊겨서 실패한 것도 이 메시지가 떴습니다.</strong>
사용자는 자기가 뭘 잘못한 줄 압니다.</p>
<p>에러 코드를 확인해서 <strong>진짜 중복일 때만</strong> 저 문구를 쓰고, 나머지는 &quot;연결이 불안정해요&quot;로 갈랐습니다.</p>
<blockquote>
<p><strong>에러 메시지를 하나로 퉁치면, 사용자가 원인을 오해합니다.</strong></p>
</blockquote>
<hr>
<h1 id="7월-말--출시에-꼭-필요한-것-세-개">7월 말 — 출시에 꼭 필요한 것 세 개</h1>
<h2 id="①-채팅--최종-리뷰만-잡은-버그">① 채팅 — 최종 리뷰만 잡은 버그</h2>
<p>매칭이 됐는데 <strong>만날 방법이 없다는 문제</strong>가 있어서 채팅을 넣었습니다. (<a href="#">2편</a>)</p>
<p>12개 할 일로 쪼개서 만들었고 하나하나 리뷰를 받았는데, <strong>마지막 전체 리뷰에서 심각한 게 나왔습니다.</strong></p>
<blockquote>
<p>&quot;제가 못 가게 됐어요&quot;로 매칭이 깨질 때 <strong>대화방을 안 닫고 있었습니다.</strong>
갈라선 두 사람이 계속 메시지를 주고받을 수 있었고, 그 대화방은 <strong>영원히 안 닫혔습니다.</strong></p>
</blockquote>
<p>원인은 이거였습니다.</p>
<pre><code>대화방을 여는 곳  : 1곳 (매칭 성사)
대화방을 닫는 곳  : 4곳 (직접 종료 · 3시간 만료 · 차단 · 매칭 깨짐)</code></pre><p><strong>할 일 단위로는 각자 다 맞았습니다.</strong> &quot;매칭 깨는 기능&quot;을 만들 때는 대화방 생각을 못 했고,
&quot;대화방 만드는 기능&quot;을 만들 때는 매칭이 깨지는 경우를 몰랐습니다.</p>
<p><strong>여러 조각을 걸쳐 있는 문제는 전체를 봐야만 보입니다.</strong></p>
<h2 id="②-카카오-로그인--테스트-538개가-통과인데-8개가-터졌다">② 카카오 로그인 — 테스트 538개가 통과인데 8개가 터졌다</h2>
<p><strong>백엔드는 이미 다 만들어져 있었습니다.</strong> 검증하는 클래스 하나만 추가하면 됐습니다. (<a href="#">3편</a>)</p>
<p>그런데 <strong>실제 폰에서 돌려보니 8가지가 터졌습니다.</strong> 자동 테스트는 538개 전부 통과인 상태에서요.</p>
<p>몇 개만 적으면 —</p>
<ul>
<li><strong><code>real</code> 모드로 켜면 서버가 죽음</strong> — 생성자가 2개인데 <code>@Autowired</code>를 안 붙여서 스프링이 뭘 써야 할지 몰랐습니다. <code>real</code>로 서버를 띄워보는 테스트가 없어서 못 잡았습니다.</li>
<li><strong>카카오는 <code>nonce</code>라는 값을 같이 보내야만 토큰을 줍니다.</strong> 안 보내면 그냥 안 줍니다.</li>
<li><strong><code>aud</code> 값이 문서와 달랐습니다.</strong> 공식 문서는 REST API 키라는데, 앱 SDK로 로그인하면 <strong>네이티브 앱 키</strong>가 들어옵니다. 이걸 몰라서 서명·만료 다 통과하고 마지막에 전부 거부됐습니다.</li>
</ul>
<p>마지막 건 운이 좋았습니다. <strong>확실하지 않아서 설정값으로 빼뒀던 덕에</strong> <code>.env</code> 한 줄 고쳐서 해결됐습니다.</p>
<h3 id="그런데-진짜-교훈은-따로-있었습니다">그런데 진짜 교훈은 따로 있었습니다</h3>
<p><strong>로그를 넣기 전까지 원인을 계속 잘못 짚었습니다.</strong></p>
<p>OIDC 설정이 문제인 줄 알고 며칠 헤맸는데, 실제로는 <strong>번들 ID가 안 맞는 것</strong>이었습니다.</p>
<p>앱이 실패 이유를 이렇게 삼키고 있었거든요.</p>
<pre><code class="language-js">catch (e) {
  alert(&#39;잠시 후 다시 시도해주세요&#39;);   // ← 진짜 원인이 여기서 사라진다
}</code></pre>
<p><code>console.warn(e)</code> 한 줄을 넣은 순간 <strong>진짜 메시지가 바로 나왔습니다.</strong></p>
<blockquote>
<p><strong>실패 원인을 감추는 코드는 디버깅 시간을 몇 배로 늘립니다.</strong></p>
</blockquote>
<h2 id="③-회원-탈퇴--3개월-통틀어-가장-비싼-버그">③ 회원 탈퇴 — 3개월 통틀어 가장 비싼 버그</h2>
<p>애플 심사에서 요구하는 기능이라 만들었습니다. 그리고 여기서 제일 크게 틀렸습니다.</p>
<pre><code class="language-java">@Modifying(clearAutomatically = true)   // 벌크 삭제할 때 붙이는 관례라고 알고 있었다</code></pre>
<p>이 옵션은 <strong>&quot;그 테이블&quot;만 정리하는 게 아니라 JPA가 들고 있던 모든 객체를 놔버립니다.</strong></p>
<p>그래서 이런 일이 벌어졌습니다.</p>
<pre><code>① 개인정보 11개 테이블을 삭제한다  ← 여기서 JPA가 User를 놔버림
② user.withdraw() 를 호출한다      ← 이제 이건 그냥 평범한 자바 객체
③ 트랜잭션 끝                      ← DB에 아무것도 안 씀</code></pre><p><strong>결과: 개인정보만 지워지고 계정은 살아 있었습니다.</strong></p>
<p>제일 무서운 건 이겁니다.</p>
<blockquote>
<p><strong>테스트 570개가 전부 통과였습니다.</strong></p>
</blockquote>
<p>서비스 테스트가 DB 접근을 전부 가짜(Mockito)로 바꿔놨거든요.
<strong>가짜한테는 <code>clearAutomatically</code>가 아예 실행되지 않습니다.</strong></p>
<p>실제 DB를 쓰는 테스트로 다시 짜서 <strong>먼저 빨간불을 확인하고</strong> 고쳤습니다.
그리고 <strong>왜 이걸 쓰면 안 되는지를 메서드 주석에 적어뒀습니다.</strong> 안 그러면 누가 &quot;관례인데요?&quot; 하면서 되돌릴 테니까요.</p>
<hr>
<h1 id="8월-초--배포">8월 초 — 배포</h1>
<p>3단계로 쪼갰습니다. 단계마다 <strong>&quot;여기까지 되면 통과&quot;</strong> 를 정해뒀습니다.</p>
<pre><code>1단계 — 코드·설정 준비   (돈 안 씀)
2단계 — 서버 구축        (통과 조건: 기존 앱이 주소만 바꿔서 공개 서버에 붙는가)
3단계 — TestFlight 올리기</code></pre><p>2단계 통과 조건이 특히 값을 했습니다.
<strong>이게 통과하면 서버는 확정이고, 이후 문제는 전부 앱 문제로 좁혀집니다.</strong></p>
<p><strong>8월 3일에 서비스가 공개됐습니다.</strong> AWS Lightsail 2GB 한 대, Docker Compose, HTTPS 자동 발급.</p>
<h2 id="그런데-배포가-끝이-아니었습니다">그런데 배포가 끝이 아니었습니다</h2>
<blockquote>
<p>자동 테스트 <strong>627개 + 앱 190개가 전부 통과</strong>인 상태에서
<strong>실제 폰으로 확인하니 19건이 나왔습니다.</strong> (빌드를 3번 다시 올리면서 6건 → 7건 → 6건)</p>
</blockquote>
<p>성격을 보니 <strong>논리 버그는 거의 없었습니다.</strong></p>
<ul>
<li>라이브러리끼리 충돌하는 문제</li>
<li>화면 안전 영역·키보드 같은 실제 기기에서만 보이는 것</li>
<li><strong>조용히 실패하던 것</strong> — 사진 전송이 실패해도 화면에 아무 표시가 없었습니다</li>
</ul>
<blockquote>
<p><strong>이런 종류는 테스트로 잡기 어렵습니다.</strong>
대신 <strong>빌드→설치를 짧게 반복하는 게</strong> 훨씬 값쌉니다. (빌드 4분, 설치는 바로)</p>
</blockquote>
<h2 id="그리고-이걸-확인한-방식이-하나-있습니다">그리고 이걸 확인한 방식이 하나 있습니다</h2>
<p>배포 직후 푸시 알림을 만들 때는 <strong>리뷰를 두 번 돌렸습니다.</strong>
재리뷰가 3건을 더 잡았고, <strong>그중 둘은 1차 수정이 만든 문제</strong>였습니다.</p>
<p>결과는 <strong>실제 폰에서 새로 나온 결함 0건</strong>이었습니다.</p>
<pre><code>배포 때 19건   = 라이브러리·화면 같은, 실제 기기에서만 보이는 것
푸시 때 0건    = 조용히 잘못 도는 것 → 리뷰와 테스트로 잡힘</code></pre><p><strong>어떤 종류의 버그냐에 따라 잡는 방법이 다릅니다.</strong></p>
<hr>
<h1 id="8월-중순--9월--채팅-실시간화-애플-로그인-성능">8월 중순 ~ 9월 — 채팅 실시간화, 애플 로그인, 성능</h1>
<ul>
<li><strong>8월 13일 WebSocket 채팅</strong> — 5초마다 물어보던 걸 실시간으로 바꿨습니다 (<a href="#">5편</a>)</li>
<li><strong>8월 14일 법무</strong> — 약관 4종, 개인정보 처리방침 웹 게시 (심사에 URL이 필요합니다)</li>
<li><strong>8월 17~20일 애플 로그인</strong> — 소셜 로그인이 있으면 애플 로그인도 필수입니다</li>
<li><strong>8월 20일 DB 백업 스크립트</strong></li>
<li><strong>8월 24일 ~ 9월 4일 부하 테스트와 성능 개선</strong></li>
</ul>
<p>마지막이 개인적으로 제일 만족스럽습니다.</p>
<pre><code>검색 처리량      초당 4.4건  →  초당 150건에서도 안 꺾임    (최소 34배)
2글자 검색       669.8ms    →  1.2ms
실제 서버 실측   1,028ms    →  1.4ms</code></pre><p><strong>측정하기 전에 세운 예상 5개 중 맞은 게 1개</strong>였습니다.
이 이야기는 <a href="#">6편</a>에서 통째로 다룹니다.</p>
<hr>
<h2 id="3개월을-한-줄씩-요약하면">3개월을 한 줄씩 요약하면</h2>
<pre><code>6월  │ 만든다          — 백엔드를 조각내서 하나씩
7월  │ 정직하게 만든다   — 가짜 걷어내기 · 실연동 · 출시 필수 기능
8월  │ 내보낸다         — 배포 · 실제 폰 확인 3바퀴 · 푸시
9월  │ 숫자로 만든다     — 부하 테스트 · 병목 찾아 고치기</code></pre><p>돌아보면 <strong>7월이 제일 값졌습니다.</strong></p>
<p>새 기능을 안 만들고 &quot;이게 진짜로 도는가&quot;만 2주 동안 확인했는데,
그게 없었으면 8월 배포에서 훨씬 크게 터졌을 겁니다.</p>
<hr>
<p>다음 편은 <strong>코드 이야기</strong>입니다.
체크인 상태 관리, <strong>락을 한 번도 안 쓰고 동시성을 막은 방법</strong>, 채팅 서버 설계를 봅니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot 4를 나온 지 얼마 안 됐을 때 써봤습니다 — 혼정 개발기 ③]]></title>
            <link>https://velog.io/@yoon_bly/Spring-Boot-4%EB%A5%BC-%EB%82%98%EC%98%A8-%EC%A7%80-%EC%96%BC%EB%A7%88-%EC%95%88-%EB%90%90%EC%9D%84-%EB%95%8C-%EC%8D%A8%EB%B4%A4%EC%8A%B5%EB%8B%88%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0</link>
            <guid>https://velog.io/@yoon_bly/Spring-Boot-4%EB%A5%BC-%EB%82%98%EC%98%A8-%EC%A7%80-%EC%96%BC%EB%A7%88-%EC%95%88-%EB%90%90%EC%9D%84-%EB%95%8C-%EC%8D%A8%EB%B4%A4%EC%8A%B5%EB%8B%88%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0</guid>
            <pubDate>Fri, 04 Sep 2026 09:21:28 GMT</pubDate>
            <description><![CDATA[<!--
벨로그 제목: Spring Boot 4를 나온 지 얼마 안 됐을 때 써봤습니다 — 혼정 개발기 ③
시리즈: 혼정 개발기
태그: SpringBoot, JWT, 백엔드, 신입개발자, 사이드프로젝트
-->

<p>이번 편은 <strong>백엔드를 어떻게 짰고 왜 그렇게 골랐는지</strong>입니다.</p>
<hr>
<h2 id="전체-그림">전체 그림</h2>
<pre><code>┌─────────────────┐   HTTPS / REST     ┌──────────────────────┐
│  앱             │ ─────────────────▶ │  백엔드 (Spring)       │
│  React Native   │ ◀───────────────── │  Spring Boot 4       │
└─────────────────┘   JSON + JWT       │  + WebSocket         │
                                       └──────────┬───────────┘
                                                  │
                   ┌──────────────────────────────┼──────────────┐
                   ▼                              ▼              ▼
           ┌────────────────┐        ┌──────────────┐  ┌────────────────┐
           │  PostgreSQL    │        │  FCM (푸시)   │  │ 파일 저장         │
           │  users         │        └──────────────┘  │ 로컬 디스크       │
           │  places        │                          └────────────────┘
           │  check_ins ... │
           └───────▲────────┘
                   │ 적재
           ┌───────┴────────┐
           │ 공공데이터 ETL    │  Python 스크립트 (백엔드와 분리)
           └────────────────┘</code></pre><p><img src="https://velog.velcdn.com/images/yoon_bly/post/28935d72-6f51-4703-a540-6181fdae85e5/image.png" alt=""></p>
<p><strong>모노레포</strong>입니다. 저장소 하나 안에 <code>backend/</code> · <code>app/</code> · <code>docs/</code> · <code>etl/</code> 이 다 있습니다.
혼자 하니까 앱과 서버를 같은 커밋에서 바꾸는 일이 많았고, 저장소를 나누면 그때마다 두 곳을 오가야 해서요.</p>
<hr>
<h1 id="1부--백엔드를-어떻게-나눴나">1부 — 백엔드를 어떻게 나눴나</h1>
<h2 id="요청-하나가-지나가는-길">요청 하나가 지나가는 길</h2>
<pre><code>클라이언트 → Controller → Service → Repository → DB</code></pre><table>
<thead>
<tr>
<th>층</th>
<th>하는 일</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Controller</strong></td>
<td>HTTP 요청 받기, 입력값 검사, 응답 만들기</td>
<td><code>CheckInController</code></td>
</tr>
<tr>
<td><strong>Service</strong></td>
<td>실제 로직, 트랜잭션 시작·끝</td>
<td><code>CheckInService</code></td>
</tr>
<tr>
<td><strong>Repository</strong></td>
<td>DB에 쿼리 보내기</td>
<td><code>CheckInRepository</code></td>
</tr>
<tr>
<td><strong>Entity</strong></td>
<td>DB 테이블과 짝이 되는 클래스</td>
<td><code>CheckIn</code>, <code>User</code></td>
</tr>
<tr>
<td><strong>DTO</strong></td>
<td>층 사이에 오가는 데이터 상자</td>
<td><code>CheckInRequest</code></td>
</tr>
</tbody></table>
<p>원칙은 두 개였습니다.</p>
<ol>
<li><strong>Controller는 얇게, 로직은 Service에.</strong> Controller에는 if문을 거의 안 넣었습니다.</li>
<li><strong>Entity를 그대로 응답에 내보내지 않는다.</strong> 항상 DTO로 바꿔서 내보냅니다.</li>
</ol>
<p>2번이 중요한 이유가 있습니다.
Entity를 그대로 내보내면 <strong>DB 컬럼이 늘어날 때마다 API 응답이 저절로 바뀝니다.</strong>
예를 들어 <code>User</code>에 <code>phone</code> 컬럼을 추가하면, 아무 코드도 안 건드렸는데 <strong>전화번호가 API로 새어 나갑니다.</strong></p>
<p>DTO를 따로 두면 &quot;내보낼 것&quot;을 손으로 적어야 하니까 그런 일이 안 생깁니다.</p>
<h2 id="패키지를-기능별로-나눴습니다">패키지를 기능별로 나눴습니다</h2>
<p>폴더를 나누는 방식이 두 가지가 있습니다.</p>
<pre><code>(A) 계층별로 나누기              (B) 기능별로 나누기
    controller/                      checkin/
      CheckInController                CheckInController
      ReviewController                 CheckInService
    service/                           CheckInRepository
      CheckInService                 review/
      ReviewService                    ReviewController
                                       ReviewService</code></pre><p><strong>(B)를 골랐습니다.</strong> 실제 폴더는 이렇습니다.</p>
<pre><code>com.honjeong
├── auth          로그인 · 토큰
├── user          프로필
├── place         식당
├── checkin       ★체크인 (이 앱의 핵심)
├── meal          같이먹기
├── chat          채팅
├── review        리뷰
├── mate          친구
├── favorite      즐겨찾기
├── notification  알림함
├── push          푸시 발송
├── badge         뱃지
├── block         차단
├── report        신고
├── notice        공지
├── file          이미지 업로드
└── global        공통 (예외 처리 · 설정 · 보안)</code></pre><p><strong>17개입니다.</strong></p>
<p>(B)를 고른 이유는 단순합니다. 기능 하나를 만들 때 <strong>한 폴더만 열면 되기 때문</strong>입니다.
(A)로 하면 체크인 기능 하나 고치는데 <code>controller/</code>, <code>service/</code>, <code>repository/</code> 세 폴더를 왔다 갔다 해야 합니다.</p>
<blockquote>
<p><strong>다만 정직하게 적으면</strong> — 나중에 리뷰해보니 <strong>폴더끼리 서로를 참조하는 곳이 22쌍</strong> 있었습니다.
예를 들어 <code>checkin</code>이 <code>meal</code>의 Repository를 직접 가져다 씁니다.
이걸 막아주는 장치(ArchUnit 같은 것)를 안 넣어서 그냥 쌓였습니다. 지금은 알고만 있는 상태입니다.</p>
</blockquote>
<hr>
<h1 id="2부--spring-boot-4를-써서-겪은-것">2부 — Spring Boot 4를 써서 겪은 것</h1>
<p>프로젝트를 시작한 게 2026년 6월인데, Spring Boot 4가 막 나온 시점이었습니다.
<strong>&quot;최신 버전이 이력서에 낫겠지&quot;</strong> 라는 이유로 골랐고, 딱 그만큼 고생했습니다.</p>
<h2 id="①-jwt-라이브러리가-깨졌습니다">① JWT 라이브러리가 깨졌습니다</h2>
<p>Boot 4는 JSON을 다루는 기본 라이브러리가 <strong>Jackson 3</strong>로 바뀌었습니다.
그런데 제가 쓰려던 <strong>jjwt</strong>(JWT를 만들고 검증해주는 라이브러리)는 <strong>Jackson 2</strong>에 의존하고 있었습니다.</p>
<p>그래서 서버를 띄우면 <strong>실행 중에 터졌습니다.</strong></p>
<p>선택지는 둘이었습니다.</p>
<ul>
<li>Jackson 2를 억지로 같이 넣는다 → 나중에 뭐가 터질지 모름</li>
<li><strong>JWT를 직접 만든다</strong></li>
</ul>
<p>두 번째로 갔습니다. <code>Nimbus</code>라는 라이브러리로 직접 발급하게 바꿨습니다.</p>
<p><strong>결과적으로는 이게 잘한 선택이 됐습니다.</strong> (다음 파트에서 이어집니다)</p>
<h2 id="②-테스트용-애노테이션-위치가-다-바뀌었습니다">② 테스트용 애노테이션 위치가 다 바뀌었습니다</h2>
<p>Boot 4가 테스트 관련 기능을 모듈별로 쪼개면서 클래스 위치가 이동했습니다.</p>
<table>
<thead>
<tr>
<th>애노테이션</th>
<th>Boot 4에서의 위치</th>
</tr>
</thead>
<tbody><tr>
<td><code>@WebMvcTest</code></td>
<td><code>org.springframework.boot.webmvc.test.autoconfigure.*</code></td>
</tr>
<tr>
<td><code>@DataJpaTest</code></td>
<td><code>...boot.data.jpa.test.autoconfigure</code></td>
</tr>
<tr>
<td><code>TestEntityManager</code></td>
<td><code>org.springframework.boot.jpa.test.autoconfigure</code></td>
</tr>
</tbody></table>
<p><strong>검색해도 안 나왔습니다.</strong> 나온 지 얼마 안 돼서 블로그도 스택오버플로도 없었습니다.</p>
<p>결국 이렇게 찾았습니다.</p>
<pre><code class="language-bash"># 다운로드된 라이브러리 파일(jar) 안을 직접 뒤져서 클래스가 어디 있는지 찾는다
find ~/.gradle/caches -name &quot;*.jar&quot; | xargs -I{} sh -c \
  &#39;unzip -l {} | grep -q TestEntityManager.class &amp;&amp; echo {}&#39;</code></pre>
<blockquote>
<p><strong>배운 것:</strong> 최신 버전을 쓰면 &quot;검색하면 나오는 답&quot;이 없습니다.
그럴 때는 <strong>라이브러리 파일을 직접 열어보는 게 제일 빠릅니다.</strong></p>
</blockquote>
<h2 id="③-최신-버전을-쓴-게-잘한-일이었나">③ 최신 버전을 쓴 게 잘한 일이었나</h2>
<p>솔직히 <strong>초반 2주는 손해</strong>였습니다. 그런데 지나고 보니 괜찮았습니다.</p>
<ul>
<li>고생이 <strong>초반에 몰려 있었습니다.</strong> 위치를 한 번 확정하고 나면 그 뒤로는 평범합니다.</li>
<li>jjwt가 깨진 덕에 <strong>JWT를 직접 다뤄봤고</strong>, 그게 나중에 카카오 로그인에서 그대로 쓰였습니다.</li>
</ul>
<hr>
<h1 id="3부--로그인을-어떻게-만들었나">3부 — 로그인을 어떻게 만들었나</h1>
<p>여기가 백엔드에서 제일 신경 쓴 부분입니다.</p>
<h2 id="토큰이-두-개인-이유">토큰이 두 개인 이유</h2>
<p>로그인하면 토큰(신분증 같은 문자열)을 두 개 줍니다.</p>
<table>
<thead>
<tr>
<th>토큰</th>
<th>유효기간</th>
<th>하는 일</th>
</tr>
</thead>
<tbody><tr>
<td><strong>access token</strong></td>
<td>1시간</td>
<td>API 요청할 때마다 헤더에 붙임</td>
</tr>
<tr>
<td><strong>refresh token</strong></td>
<td>14일</td>
<td>access가 만료되면 새로 받아오는 용도</td>
</tr>
</tbody></table>
<p><strong>왜 하나로 안 하나?</strong></p>
<p>access 토큰은 요청마다 왔다 갔다 하니까 <strong>노출될 기회가 많습니다.</strong>
그래서 짧게(1시간) 만들어서, 새어 나가도 1시간 뒤엔 쓸모없게 만듭니다.</p>
<p>대신 1시간마다 다시 로그인하라고 하면 사용자가 화나니까,
<strong>14일짜리 refresh 토큰으로 조용히 새 access를 받아오게</strong> 합니다.</p>
<h2 id="refresh-토큰은-원문을-저장하지-않습니다">refresh 토큰은 원문을 저장하지 않습니다</h2>
<p>DB에 이렇게 저장합니다.</p>
<pre><code>토큰 원문         : mZ8fK2p... (사용자에게만 준다)
DB에 저장하는 값   : SHA-256으로 해시한 값</code></pre><p><strong>비밀번호를 저장하는 방식과 똑같습니다.</strong></p>
<p>이렇게 하면 DB가 통째로 털려도 <strong>그 값으로는 로그인을 못 합니다.</strong>
해시는 되돌릴 수 없거든요. 서버는 사용자가 보낸 토큰을 다시 해시해서 비교만 합니다.</p>
<h2 id="한-번-쓴-refresh-토큰은-버립니다-회전">한 번 쓴 refresh 토큰은 버립니다 (회전)</h2>
<pre><code>① 앱이 refresh 토큰 A를 보낸다
② 서버가 확인하고 → 새 access + 새 refresh 토큰 B를 준다
③ ★A는 DB에서 삭제한다</code></pre><p>이걸 <strong>토큰 회전(rotation)</strong> 이라고 합니다.</p>
<p>이유는 이렇습니다.
누가 refresh 토큰을 훔쳐서 썼다고 해봅시다. 그러면 <strong>원래 주인의 토큰이 무효가 돼서</strong> 다음번에 로그인이 튕깁니다.
<strong>도난 사실이 드러납니다.</strong> 회전을 안 하면 훔친 사람과 주인이 14일 동안 사이좋게 같이 씁니다.</p>
<h2 id="온보딩-중에는-반쪽-토큰을-씁니다">온보딩 중에는 &quot;반쪽 토큰&quot;을 씁니다</h2>
<p>가입 절차가 이렇습니다.</p>
<pre><code>휴대폰 인증 → (여기서 계정이 생김) → 닉네임·프로필 입력 → 가입 완료
                    ↑ 이 중간 상태에서도 API를 불러야 한다</code></pre><p>중간 상태에서도 &quot;닉네임 중복 확인&quot;, &quot;프로필 사진 업로드&quot; 같은 API를 불러야 합니다.
그런데 아직 가입이 안 끝났으니 <strong>정식 토큰을 주면 안 됩니다.</strong></p>
<p>그래서 <strong>온보딩 전용 토큰</strong>을 따로 만들었습니다. 이 토큰으로는 정해진 몇 개 API만 부를 수 있습니다.</p>
<pre><code class="language-java">// 정식 사용자만 통과
.anyRequest().hasRole(&quot;USER&quot;)</code></pre>
<p><code>authenticated()</code>(로그인만 했으면 통과) 대신 <code>hasRole(&quot;USER&quot;)</code>(정식 회원만 통과)를 썼습니다.
이 한 글자 차이로 <strong>온보딩 토큰이 아무 API나 부르는 걸 막습니다.</strong></p>
<h2 id="계정-상태는-요청마다-확인합니다">계정 상태는 요청마다 확인합니다</h2>
<p>여기서 한 번 크게 틀렸습니다.</p>
<p>문제 상황은 이랬습니다.</p>
<blockquote>
<p>어떤 사용자를 <strong>정지(SUSPENDED)</strong> 시켰습니다.
그런데 그 사람은 <strong>이미 발급받은 access 토큰으로 계속 앱을 씁니다.</strong></p>
</blockquote>
<p><strong>JWT는 한 번 발급하면 서버가 회수할 방법이 없습니다.</strong>
토큰 안에 &quot;이 사람은 누구다&quot;가 적혀 있고 서명만 맞으면 통과하거든요. 서버는 그 토큰을 기억하지도 않습니다.</p>
<p>그래서 필터를 하나 만들었습니다.</p>
<pre><code>요청이 들어옴 → JWT 확인 → ★DB에서 users.status를 확인 → 통과 or 차단</code></pre><p>매 요청마다 DB를 한 번 더 보는 거라 비용이 들지만, 이게 없으면 <strong>정지가 아무 의미가 없습니다.</strong></p>
<p>★그리고 여기서 실수를 하나 했습니다.</p>
<pre><code class="language-java">// 처음에 짠 것
if (status == SUSPENDED || status == WITHDRAWN) 차단;

// 고친 것
if (status != PENDING &amp;&amp; status != ACTIVE) 차단;</code></pre>
<p>처음 건 <strong>나중에 상태가 하나 늘면 조용히 새어 나갑니다.</strong>
&quot;막을 것을 나열&quot;하는 대신 <strong>&quot;통과시킬 것을 나열&quot;</strong> 하면, 모르는 상태는 자동으로 막힙니다.</p>
<p>이런 걸 <strong>fail-closed</strong>(모르면 막는다)라고 부릅니다. 이 프로젝트에서 계속 쓴 원칙입니다.</p>
<hr>
<h1 id="4부--외부-연동을-전부-가짜로-먼저-만들었습니다">4부 — 외부 연동을 전부 가짜로 먼저 만들었습니다</h1>
<h2 id="mock-first가-뭔가">mock-first가 뭔가</h2>
<p>카카오 로그인, 문자 인증, 파일 저장 — 이런 건 <strong>실제로 붙이려면 준비물이 많습니다.</strong>
(앱 키 발급, 사업자 등록, 비용 결제…)</p>
<p>그래서 <strong>껍데기는 진짜로 만들고 속만 가짜로</strong> 채웠습니다.</p>
<pre><code class="language-java">public interface OAuthVerifier {
    OAuthUser verify(String idToken);   // ← 이 약속(인터페이스)은 처음부터 진짜
}</code></pre>
<pre><code class="language-java">// 개발 중: 아무 문자열이나 통과시키고 고정 사용자 반환
class MockOAuthVerifier implements OAuthVerifier { ... }

// 실제 배포: 카카오 서버에 물어보고 진짜 검증
class KakaoOAuthVerifier implements OAuthVerifier { ... }</code></pre>
<p>설정값 하나로 둘 중 뭘 쓸지 정합니다.</p>
<pre><code class="language-yaml">honjeong:
  oauth:
    mode: mock   # 또는 real</code></pre>
<p><strong>효과가 컸습니다.</strong> 나중에 카카오 로그인을 실제로 붙일 때 <strong>백엔드에서 새로 만든 게 클래스 하나</strong>였습니다.
API 주소도, DB 테이블도, 신규/재방문 분기 로직도 전부 이미 있었으니까요.</p>
<h2 id="그런데-함정이-두-개-있었습니다">그런데 함정이 두 개 있었습니다</h2>
<h3 id="함정-1--mock으로-조용히-떨어지면-그건-인증이-뚫린-겁니다">함정 1 — mock으로 조용히 떨어지면 그건 인증이 뚫린 겁니다</h3>
<p><code>docker-compose.yml</code>에 이렇게 적혀 있었습니다.</p>
<pre><code class="language-yaml">OAUTH_MODE: ${OAUTH_MODE:-mock}    # ← &quot;값이 없으면 mock을 쓴다&quot;</code></pre>
<p>배포용 설정 파일에는 <code>real</code>이라고 적어뒀는데, <strong>compose의 이 기본값이 그걸 덮어썼습니다.</strong></p>
<p>즉 <code>docker compose up -d</code> 하면 <strong>가짜 인증으로 서버가 뜹니다.</strong>
가짜 검증기는 아무 문자열이나 통과시키니까, <strong>아무 문자열로 아무 계정이나 만들 수 있었습니다.</strong></p>
<p>이건 코드 리뷰에서 잡혔습니다. 그것도 <strong>1차 지적을 고치다가 새로 만든 문제</strong>를 재리뷰가 잡은 거였습니다.</p>
<h3 id="함정-2--반대로-하면-서버가-아예-안-뜹니다">함정 2 — 반대로 하면 서버가 아예 안 뜹니다</h3>
<p>배포용 설정에 문자 인증과 주소 변환을 <code>real</code>로 적어놨는데, <strong><code>real</code> 구현 클래스가 아예 없었습니다.</strong></p>
<p>스프링이 그 클래스를 못 찾아서 <code>AuthService</code>를 못 만들고, <strong>서버 시작이 100% 실패했습니다.</strong></p>
<h3 id="그래서-원칙을-하나-정했습니다">그래서 원칙을 하나 정했습니다</h3>
<table>
<thead>
<tr>
<th>상황</th>
<th>결과</th>
<th>위험도</th>
</tr>
</thead>
<tbody><tr>
<td>진짜가 있는데 가짜로 떨어짐</td>
<td><strong>인증이 뚫림</strong></td>
<td>아주 위험</td>
</tr>
<tr>
<td>진짜가 없는데 진짜라고 적음</td>
<td><strong>서버가 안 뜸</strong></td>
<td>시끄럽지만 안전</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>방향은 반대인데 원인은 하나입니다 — 설정이 사실과 달랐습니다.</strong></p>
</blockquote>
<p>그래서 지금은 이렇게 합니다.</p>
<ol>
<li><strong>가짜 모드로 뜨면 로그를 찍습니다.</strong> 설정 파일 주석은 서버 띄우는 사람이 안 읽는다고 가정합니다.</li>
<li><strong>위험한 조합이면 아예 못 뜨게 막습니다.</strong> 예를 들어 문자 인증이 가짜 모드면 그 API를 통째로 차단합니다. 나중에 진짜 문자를 붙이면 <strong>코드를 안 고쳐도 자동으로 열립니다.</strong></li>
</ol>
<p>이 이야기는 <a href="#">7편(배포와 운영)</a>에서 더 나옵니다. 이 프로젝트에서 <strong>제일 비싼 사고는 전부 설정에서 났습니다.</strong></p>
<hr>
<h1 id="5부--식당-데이터를-어디서-가져왔나">5부 — 식당 데이터를 어디서 가져왔나</h1>
<h2 id="카카오-지도는-쓰는데-카카오-검색은-안-씁니다">카카오 지도는 쓰는데 카카오 검색은 안 씁니다</h2>
<p>지도는 카카오 지도를 씁니다. 그런데 <strong>식당 목록은 카카오에서 안 가져옵니다.</strong></p>
<p>이유가 둘입니다.</p>
<p><strong>① 약관에서 막습니다.</strong>
카카오 로컬 API로 받은 검색 결과는 <strong>오래 저장하면 안 됩니다</strong>(1~2시간 캐싱까지만 허용).
그런데 이 앱은 식당마다 체크인·리뷰·즐겨찾기가 붙습니다. <strong>식당 데이터가 영구적으로 있어야 합니다.</strong></p>
<p><strong>② 우리가 만드는 데이터가 핵심이라서요.</strong>
&quot;이 식당에서 몇 명이 혼밥했나&quot;는 <strong>어느 외부 API도 안 줍니다.</strong> 우리가 쌓는 데이터입니다.
그러려면 식당 목록을 우리가 갖고 있어야 합니다.</p>
<p>그래서 이렇게 정리했습니다.</p>
<pre><code>카카오 지도   = 배경 그림만 그려주는 역할
마커(핀)      = 우리 DB의 식당 id로 우리가 찍는다
식당 목록     = 공공데이터포털 &quot;전국일반음식점 표준데이터&quot; (655,163개)</code></pre><h2 id="적재는-백엔드-밖에서-합니다">적재는 백엔드 밖에서 합니다</h2>
<p>Python 스크립트로 따로 넣습니다(<code>etl/load_places.py</code>).</p>
<ul>
<li><strong>백엔드는 <code>places</code> 테이블을 읽기만 합니다.</strong> 외부 API를 부르는 코드가 없습니다.</li>
<li>여러 번 돌려도 결과가 같게 만들었습니다 (<code>ON CONFLICT</code> — 이미 있으면 넘어가기)</li>
<li>공공데이터 좌표가 우리가 쓰는 좌표계와 달라서(EPSG:5174 → WGS84) 변환이 필요했습니다</li>
</ul>
<p>★그리고 이 65만 행이 나중에 <strong>부하 테스트에서 결정적</strong>이었습니다.
데이터가 적으면 느린 쿼리가 안 느려 보이거든요. (<a href="#">6편</a>)</p>
<hr>
<h1 id="6부--앱-쪽은-짧게">6부 — 앱 쪽은 짧게</h1>
<p>이 시리즈는 백엔드 중심이라 앱 이야기는 줄이겠습니다. 두 가지만 적습니다.</p>
<h2 id="서버-데이터는-직접-관리하지-않았습니다">서버 데이터는 직접 관리하지 않았습니다</h2>
<p>처음엔 &quot;지금 내가 체크인 중인가&quot;를 앱에서 직접 들고 있었습니다. 그랬더니 계속 문제가 났습니다.</p>
<pre><code>① 체크인을 종료했는데 다른 화면엔 아직 진행 중으로 보임
② 버튼을 두 번 누르면 요청이 두 번 감
③ 서버가 바뀌었는데 화면이 모름</code></pre><p><strong>React Query</strong>라는 라이브러리가 이 세 가지를 표준으로 막아줍니다.
그래서 직접 만든 전역 상태를 <strong>지우고</strong> 이걸로 옮겼습니다.</p>
<h2 id="실시간은-세-가지를-섞어-썼습니다">실시간은 세 가지를 섞어 썼습니다</h2>
<table>
<thead>
<tr>
<th></th>
<th>앱이 꺼져 있으면</th>
<th>서버가 상태를 들고 있나</th>
</tr>
</thead>
<tbody><tr>
<td><strong>폴링</strong> (주기적으로 물어보기)</td>
<td>❌ 안 됨</td>
<td>안 들고 있음</td>
</tr>
<tr>
<td><strong>WebSocket</strong> (계속 연결해두기)</td>
<td>❌ 끊김</td>
<td><strong>들고 있음</strong></td>
</tr>
<tr>
<td><strong>푸시</strong> (OS가 대신 배달)</td>
<td>✅ <strong>유일한 방법</strong></td>
<td>안 들고 있음</td>
</tr>
</tbody></table>
<p>제가 처음에 오해했던 두 가지입니다.</p>
<ul>
<li><strong>&quot;푸시가 폴링보다 좋은 거 아닌가?&quot;</strong> → 아닙니다. <strong>푸시는 도착이 보장되지 않습니다.</strong> 권한 거부, 토큰 만료, 기기 꺼짐이면 조용히 안 옵니다.</li>
<li><strong>&quot;WebSocket 쓰면 푸시 필요 없지 않나?&quot;</strong> → 아닙니다. 앱이 백그라운드로 가면 연결이 끊깁니다. <strong>사용자를 불러와야 하는 알림은 푸시만 할 수 있습니다.</strong></li>
</ul>
<p>그래서 데이터마다 다르게 골랐습니다.</p>
<table>
<thead>
<tr>
<th>데이터</th>
<th>방식</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>통계 · 지도 마커 · 주변 목록</td>
<td><strong>폴링 15초</strong></td>
<td>누구에게 보낼지 특정할 수 없음 (반경 기준이라)</td>
</tr>
<tr>
<td>같이먹기 신청 · 알림</td>
<td>폴링 + <strong>푸시</strong></td>
<td>앱이 꺼져 있어도 알려야 함</td>
</tr>
<tr>
<td>채팅</td>
<td><strong>WebSocket</strong> + 폴링 + 푸시</td>
<td>대화는 바로바로 떠야 함</td>
</tr>
</tbody></table>
<p>★<strong>푸시를 붙인 뒤에도 폴링을 안 없앴습니다.</strong>
푸시가 안 올 수 있으니까 <strong>폴링을 안전망으로 남겨둔 겁니다.</strong></p>
<hr>
<h2 id="정리--이-편의-요약">정리 — 이 편의 요약</h2>
<ol>
<li><strong>Entity를 그대로 응답에 안 내보낸다.</strong> 컬럼이 늘 때 정보가 새는 걸 막습니다.</li>
<li><strong>폴더는 기능별로 나눴다.</strong> 한 폴더만 열면 되니까요. (대신 폴더끼리 얽히는 걸 막는 장치는 못 넣었습니다.)</li>
<li><strong>토큰은 짧은 것 + 긴 것 두 개, refresh는 해시로 저장하고 쓸 때마다 새로 발급.</strong></li>
<li><strong>JWT는 회수가 안 되니까, 계정 상태는 요청마다 DB에서 확인한다.</strong></li>
<li><strong>막을 것을 나열하지 말고 통과시킬 것을 나열한다.</strong> 모르는 건 자동으로 막히게요.</li>
<li><strong>설정이 사실과 다르면 인증이 뚫리거나 서버가 안 뜬다.</strong> 방향은 반대인데 원인은 같습니다.</li>
</ol>
<hr>
<p>다음 편은 <strong>3개월 동안 무슨 순서로 만들었는지</strong>입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[기능을 6일 만들다가 "삭제"를 정답으로 골랐다 — 혼정 개발기 ②]]></title>
            <link>https://velog.io/@yoon_bly/%EA%B8%B0%EB%8A%A5%EC%9D%84-6%EC%9D%BC-%EB%A7%8C%EB%93%A4%EB%8B%A4%EA%B0%80-%EC%82%AD%EC%A0%9C%EB%A5%BC-%EC%A0%95%EB%8B%B5%EC%9C%BC%EB%A1%9C-%EA%B3%A8%EB%9E%90%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0</link>
            <guid>https://velog.io/@yoon_bly/%EA%B8%B0%EB%8A%A5%EC%9D%84-6%EC%9D%BC-%EB%A7%8C%EB%93%A4%EB%8B%A4%EA%B0%80-%EC%82%AD%EC%A0%9C%EB%A5%BC-%EC%A0%95%EB%8B%B5%EC%9C%BC%EB%A1%9C-%EA%B3%A8%EB%9E%90%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0</guid>
            <pubDate>Fri, 04 Sep 2026 08:41:33 GMT</pubDate>
            <description><![CDATA[<!--
벨로그 제목: 기능을 6일 만들다가 "삭제"를 정답으로 골랐다 — 혼정 개발기 ②
시리즈: 혼정 개발기
태그: 기획, 제품, 사이드프로젝트, 회고
-->

<p><a href="#">1편</a>에서 혼정이 뭘 하는 앱인지 이야기했습니다.
이번 편은 <strong>왜 이걸 만들었고, 무엇을 만들지 않기로 했는지</strong>입니다.</p>
<p>기능 목록은 누구나 씁니다. 저는 <strong>뺀 것</strong>을 더 자세히 쓰려고 합니다.
그쪽이 3개월 동안 제가 실제로 한 판단에 훨씬 가깝기 때문입니다.</p>
<hr>
<h1 id="1부--문제-정의">1부 — 문제 정의</h1>
<h2 id="사람들은-혼자-먹는-게-싫은-게-아니다">사람들은 혼자 먹는 게 싫은 게 아니다</h2>
<p>1인 가구가 늘고 혼자 지내는 게 자연스러워졌습니다. 그런데 <strong>모든 상황에서 &#39;혼자&#39;가 편한 건 아닙니다.</strong>
특히 외식이 그렇습니다. 식당은 여전히 여러 명을 전제로 설계돼 있습니다.</p>
<p>여기서 문제를 한 번 더 좁혔습니다.</p>
<blockquote>
<p>사람들은 <strong>혼자 먹는 것</strong>이 싫은 게 아니라,
<strong>혼자인 상태가 드러나는 상황</strong>을 불편해한다.</p>
</blockquote>
<p>즉 문제의 본질은 물리적 환경이 아니라 <strong>심리적 저항</strong>입니다.
이 한 줄을 정하고 나니 기존 서비스가 왜 이 문제를 못 풀었는지가 정리됐습니다.</p>
<table>
<thead>
<tr>
<th>서비스 유형</th>
<th>장점</th>
<th>한계</th>
</tr>
</thead>
<tbody><tr>
<td>배달</td>
<td>편리함</td>
<td>외식 경험 자체를 대체하지 못함</td>
</tr>
<tr>
<td>맛집·리뷰 플랫폼</td>
<td>정보 풍부</td>
<td>혼밥 상황을 고려하지 않음</td>
</tr>
<tr>
<td>&quot;혼밥 가능 식당&quot; 목록</td>
<td>일부 적합한 환경</td>
<td>정보 부족 · <strong>심리적 부담은 그대로</strong></td>
</tr>
</tbody></table>
<p><strong>기존 서비스는 정보는 주지만 감정은 안 건드립니다.</strong></p>
<h2 id="그래서-고른-해법--매칭을-식사-전에-둔다">그래서 고른 해법 — 매칭을 &#39;식사 전&#39;에 둔다</h2>
<p>핵심 메커니즘은 <strong>같이 먹을 사람 매칭</strong>입니다. 그런데 타이밍을 하나 정해야 했습니다.</p>
<p><strong>식사 중에 합류시키면 안 됩니다.</strong>
이미 입장해서 주문까지 마친 뒤에 누가 합류하는 건, 예약 인원과 추가 주문 때문에 현실적으로 어색하거나 아예 불가능합니다.</p>
<p>그래서 매칭을 <strong>입장 전</strong>에 뒀습니다.</p>
<pre><code>갈 식당을 정한다 → &quot;모집중&quot;으로 체크인 → 신청 받음 → 수락 → 같이 입장
                                    └── 아무도 안 오면 → &quot;혼밥중&quot;으로 그냥 혼자 입장</code></pre><p><strong>이 구조의 핵심은 마지막 줄입니다.</strong> 매칭에 실패해도 사용자가 잃는 게 없습니다.
원래 혼자 갈 생각이었으니까요. 퇴로가 있으니 &quot;신청 안 오면 뻘쭘한데&quot;라는 진입 장벽이 사라집니다.</p>
<p>덕분에 이 앱의 주 무대는 <strong>&quot;혼자 가도 되지만 같이 가면 더 좋은 곳&quot;</strong> 이 됩니다.</p>
<h2 id="사회적-증거를-유령-지표로-만들지-않기">사회적 증거를 &#39;유령 지표&#39;로 만들지 않기</h2>
<p>첫 화면에 <strong>&quot;오늘 N명이 혼밥했어요 / 지금 N명이 혼밥 중&quot;</strong> 을 띄웁니다.
이게 이 서비스의 존재 이유입니다 — &quot;나만 그런 게 아니구나&quot;를 만드는 것.</p>
<p>그런데 여기서 설계 판단이 하나 있었습니다.</p>
<blockquote>
<p><strong>&quot;현재 혼밥 중&quot;으로 잡히는 사람은 누구인가?</strong></p>
</blockquote>
<p>순수하게 혼자 먹으려는 사람은 애초에 이 앱을 켜지 않습니다.
그러니 &quot;혼밥 인구 전체&quot;를 이 숫자에 끌어오려 하면 <strong>영원히 채워지지 않는 유령 지표</strong>가 됩니다.</p>
<p>그래서 정의를 뒤집었습니다.</p>
<pre><code>현재 혼밥 중 = 같이 먹고 싶어 모집했으나, 사람을 못 구해 혼자 먹게 된 실재 사용자</code></pre><p><strong>매칭 실패를 집계 원천으로 삼습니다.</strong> 그래서 이 숫자는 언제나 정직합니다.
앱 안에서 실제로 일어난 일만 세거든요.</p>
<h3 id="그리고-이-통계는-로그인-없이-열었습니다">그리고 이 통계는 로그인 없이 열었습니다</h3>
<p>원래 요구사항 명세서에는 이 엔드포인트가 🔒(인증 필요)로 적혀 있었습니다.
구현하다가 뒤집었습니다.</p>
<p><strong>사회적 증거는 아직 가입하지 않은 사람에게 보여줘야 의미가 있기 때문입니다.</strong>
로그인해야 볼 수 있는 사회적 증거는 사회적 증거가 아닙니다.</p>
<p><code>GET /api/check-ins/stats</code> 하나만 <code>permitAll</code>로 열려 있습니다.
(그 뒤 보안 전수 점검에서 공개 경로를 다시 셌을 때 health · 소셜로그인 · refresh · 통계 · <code>/files</code> 다섯뿐이었습니다.)</p>
<blockquote>
<p><strong>배운 것:</strong> 명세보다 제품 가치가 우선할 때가 있습니다. 다만 <strong>명세를 그냥 어기는 게 아니라 명세를 고칩니다.</strong> 실패 테스트 → SecurityConfig 한 줄 → 그린 → API 명세서 동기화 순서로 처리했습니다.</p>
</blockquote>
<hr>
<h1 id="2부--안-만들기로-한-것들">2부 — 안 만들기로 한 것들</h1>
<p>여기가 이 글의 본론입니다.</p>
<h2 id="①-내-동네--6일간-만들다가-정답이-삭제였던-기능">① &#39;내 동네&#39; — 6일간 만들다가 정답이 &#39;삭제&#39;였던 기능</h2>
<p>요구사항에 <strong>&#39;내 동네(지역) 선택&#39;</strong> 이 있었습니다. 지역을 고르면 그 동네 기준으로 보여주는 흔한 기능이죠.
6일 동안 구현 목록에 올라 있었고, 백엔드에는 <code>region</code> 필드와 역지오코딩 API까지 이미 있었습니다.</p>
<p>착수 직전에 &quot;<strong>이게 실제로 어디에 쓰이지?</strong>&quot; 를 확인하려고 소비처를 전부 세어봤습니다.</p>
<p><strong>핵심 루프가 전부 현재 GPS 기준이었습니다.</strong>
지도도, 주변 식당도, 혼밥러 목록도, 체크인도 전부 &quot;지금 내가 있는 곳&quot;으로 돕니다.
동네를 따로 설정할 자리가 <strong>한 군데도 없었습니다.</strong></p>
<p>그래서 기능을 만드는 대신 <strong>지웠습니다.</strong>
화면에서 &#39;내 동네&#39;가 차지하던 자리는, 이미 온보딩에서 받아놓고 아무 데도 안 쓰던 <strong>식사 성향</strong>으로 채웠습니다.</p>
<blockquote>
<p><strong>배운 것:</strong> 백로그에 있다는 건 &quot;필요하다&quot;의 증거가 아닙니다. 착수 전에 <strong>&quot;왜 필요한가&quot;를 코드로 실측</strong>합니다. 기능의 실제 소비처를 먼저 세면 됩니다.</p>
</blockquote>
<p>백엔드의 <code>region</code> 필드와 <code>/geo/reverse</code>는 하위호환용으로 남겨뒀다가, 두 달 뒤 아무도 안 부르는 걸 확인하고 <strong>그때 걷어냈습니다</strong>(<code>chore(backend): 아무도 부르지 않는 역지오코딩을 걷어낸다</code>).</p>
<h2 id="②-식당-상세의-편의시설·영업시간--데이터가-없어서-지웠다">② 식당 상세의 &quot;편의시설·영업시간&quot; — 데이터가 없어서 지웠다</h2>
<p>디자인 목업에는 주차·와이파이·영업시간·홈페이지가 다 있었습니다.
실제 DB의 <code>places</code> 테이블에 뭐가 있는지 세어봤습니다.</p>
<pre><code>name · category · 주소 · 좌표 · phone · businessStatus</code></pre><p><strong>끝입니다.</strong> 공공데이터(전국일반음식점 표준데이터)에는 영업시간도 메뉴도 편의시설도 없습니다.
카카오 로컬 API도 안 줍니다.</p>
<p>선택지는 둘이었습니다.</p>
<ul>
<li>(A) 하드코딩해서 있는 척한다</li>
<li>(B) 지운다</li>
</ul>
<p><strong>(B)를 골랐습니다.</strong> 전화만 실연결하고(<code>tel:</code> + 실패 시 번호 복사 폴백) 나머지 섹션은 제거했습니다.
그리고 그렇게 비어버린 자리는 <strong>가짜가 아닌 실데이터</strong>로 채웠습니다 — 그 식당의 시간대별 혼밥 통계 카드입니다.</p>
<p>같은 판단으로 <strong>&quot;영업중&quot; 배지도 지웠습니다.</strong> <code>businessStatus</code>는 실시간 오픈 여부가 아니라 <strong>인허가 상태</strong>입니다. 폐업이 아닌 이상 항상 초록불이라 아무 정보가 없었습니다.</p>
<blockquote>
<p><strong>배운 것:</strong> 목업의 UI 요소는 &quot;이런 게 있으면 좋겠다&quot;이지 &quot;이 데이터가 있다&quot;가 아닙니다. <strong>데이터 실측이 화면 설계를 좌우해야 합니다.</strong></p>
</blockquote>
<h2 id="③-메뉴-탭--데이터-출처가-정해지지-않아-숨김">③ 메뉴 탭 — 데이터 출처가 정해지지 않아 숨김</h2>
<p>같은 이유로 메뉴 탭은 <strong>탭 목록에서 주석 처리</strong>했습니다. 코드는 남겨뒀습니다.
&quot;데이터 출처 미정 — 점주 등록은 P2&quot;라고 적어두고 넘어갔습니다.</p>
<p>지우지 않고 숨긴 이유는, <strong>언젠가 되살릴 때 판단 근거가 같이 남아 있어야 하기 때문</strong>입니다.</p>
<h2 id="④-레벨-시스템-·-주간-챌린지--순위를-최후로-미룸">④ 레벨 시스템 · 주간 챌린지 — 순위를 최후로 미룸</h2>
<p>뱃지 화면에 &#39;이번 주 챌린지&#39; 카드가 목업 상태로 있었습니다(진행률 2/3 하드코딩).
주간 챌린지 실기능은 목표·진행률·리셋 주기 설계가 다 필요해서, <strong>카드 JSX를 주석 처리해 임시로 숨겼습니다.</strong></p>
<p>레벨 시스템도 미착수입니다. 뱃지와는 별개 도메인이고, 우선순위를 계속 최후로 뒀습니다.</p>
<h3 id="반면-뱃지는-지급형으로-제대로-만들었습니다">반면 뱃지는 &#39;지급형&#39;으로 제대로 만들었습니다</h3>
<p>여기서 갈림길이 하나 있었습니다.</p>
<table>
<thead>
<tr>
<th></th>
<th>계산형 (파생)</th>
<th>지급형 (테이블)</th>
</tr>
</thead>
<tbody><tr>
<td>구현</td>
<td>조회할 때마다 카운트로 계산</td>
<td><code>user_badges</code> 행을 실제로 INSERT</td>
</tr>
<tr>
<td>획득 시각</td>
<td><strong>모름</strong></td>
<td><code>earned_at</code>으로 남음</td>
</tr>
<tr>
<td>알림</td>
<td>못 보냄</td>
<td>획득 순간 알림 발행</td>
</tr>
</tbody></table>
<p>계산형이 훨씬 쉽습니다. 그런데 <strong>획득 시각이 없으면 &quot;최근 획득한 뱃지&quot;를 못 보여주고 알림도 못 보냅니다.</strong>
정석대로 지급형으로 갔습니다.</p>
<p>설계 중에 &quot;유저당 1행 + bool 컬럼&quot; 안도 나왔는데, <strong>뱃지를 추가할 때마다 <code>ALTER TABLE</code>이 필요하고 <code>earned_at</code>을 못 담아서</strong> 행-per-뱃지로 확정했습니다.</p>
<blockquote>
<p>안 한 것과 모르는 것은 다릅니다. <strong>적어두면 안 한 것이 되고, 안 적으면 모르는 것이 됩니다.</strong></p>
</blockquote>
<hr>
<h1 id="3부--도중에-뒤집은-결정">3부 — 도중에 뒤집은 결정</h1>
<h2 id="대화-없는-가벼운-연결에서-채팅-도입으로">&quot;대화 없는 가벼운 연결&quot;에서 채팅 도입으로</h2>
<p>초기 스펙에는 <strong>&quot;대화 기능은 범위 밖&quot;</strong> 이라고 명시돼 있었습니다.
연결의 무게를 한 끼로 묶어두려는 의도였습니다. 부담 없는 연결이 이 서비스의 컨셉이니까요.</p>
<p>그런데 실제로 매칭을 시켜놓고 보니 문제가 있었습니다.</p>
<p><strong>&quot;어디서 만나지? 뭘 입고 있지?&quot;</strong></p>
<p>매칭은 됐는데 만날 방법이 없었습니다. 인상착의와 자리 확인에는 대화가 필요합니다.
그래서 <strong>&quot;매칭된 상대와만, 매칭이 유지되는 동안만&quot;</strong> 이라는 제한을 걸고 채팅을 넣었습니다.</p>
<p>식사가 끝나면 대화방은 <strong>읽기 전용으로 잠깁니다.</strong> 지우지는 않고 보관합니다(신고 조사 근거).
연결의 무게를 한 끼로 묶는다는 원칙은 그대로 지킨 셈입니다.</p>
<h2 id="나이-표시--정확한-나이를-안-보여주기로">나이 표시 — 정확한 나이를 안 보여주기로</h2>
<p>프로필에 나이를 어떻게 표시할지 세 번 바뀌었습니다.</p>
<ol>
<li><code>age_group</code> 문자열을 사용자가 직접 고름 (온보딩 드롭다운)</li>
<li>→ &quot;OAuth로 자동 수집할 수 있나?&quot; <strong>불가 확인</strong> (애플은 아예 안 주고, 카카오는 검수 + 동의가 필요한 연령대 범위만 줍니다)</li>
<li>→ <strong>생년월일을 저장하고 서버가 연령대를 파생</strong></li>
</ol>
<p>3번으로 간 이유는, 문자열로 저장하면 <strong>매년 틀려지기 때문</strong>입니다. 생년월일을 저장하면 자동으로 갱신됩니다.</p>
<p>그런데 <strong>정확한 나이는 여전히 안 보여줍니다.</strong> 연령대만 보여줍니다.
이유는 프라이버시도 있지만 더 실용적인 게 있었습니다 — 지금 SMS 인증이 mock이라 <strong>생년월일을 아무도 검증하지 않습니다.</strong> 자기신고 나이를 정확한 값처럼 표시하면 그건 <strong>가짜 신뢰</strong>입니다.</p>
<p>본인인증(PASS)을 실연동하는 시점에 검증된 나이로 다시 검토하기로 하고 기록해뒀습니다.</p>
<p>그리고 생년월일은 <strong>프로필 수정 화면에 넣지 않았습니다.</strong> 성별과 같습니다 — 온보딩에서 한 번 정하고 자동으로 갱신되니 수정할 자리가 없습니다.</p>
<hr>
<h2 id="정리--이-편의-결론-5개">정리 — 이 편의 결론 5개</h2>
<ol>
<li><strong>문제를 한 문장으로 좁히면 해법이 좁아진다.</strong> &quot;혼자인 게 드러나는 게 불편하다&quot;가 매칭 타이밍을 결정했습니다.</li>
<li><strong>지표는 채울 수 있는 것으로 정의한다.</strong> 매칭 실패를 집계 원천으로 삼아서 유령 지표를 피했습니다.</li>
<li><strong>백로그에 있다 ≠ 필요하다.</strong> 착수 전에 소비처를 코드로 세면 &#39;삭제&#39;가 정답인 기능이 나옵니다.</li>
<li><strong>데이터가 없으면 화면에서 지운다.</strong> 있는 척하는 UI는 부채입니다.</li>
<li><strong>안 하기로 한 것은 이유와 함께 적는다.</strong> 안 적으면 다음 달에 같은 고민을 다시 합니다.</li>
</ol>
<hr>
<p>다음 편은 <strong>아키텍처와 기술 선택</strong>입니다.
출시 직후였던 Spring Boot 4를 골라서 치른 대가, 외부 연동을 전부 mock으로 시작한 이유,
그리고 카카오 지도를 쓰면서도 식당 데이터는 공공데이터에서 가져온 이유를 이야기합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[혼밥 앱을 3개월 만에 혼자 만들어 배포했습니다 — 혼정 개발기 ①]]></title>
            <link>https://velog.io/@yoon_bly/%ED%98%BC%EB%B0%A5-%EC%95%B1%EC%9D%84-3%EA%B0%9C%EC%9B%94-%EB%A7%8C%EC%97%90-%ED%98%BC%EC%9E%90-%EB%A7%8C%EB%93%A4%EC%96%B4-%EB%B0%B0%ED%8F%AC%ED%96%88%EC%8A%B5%EB%8B%88%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0</link>
            <guid>https://velog.io/@yoon_bly/%ED%98%BC%EB%B0%A5-%EC%95%B1%EC%9D%84-3%EA%B0%9C%EC%9B%94-%EB%A7%8C%EC%97%90-%ED%98%BC%EC%9E%90-%EB%A7%8C%EB%93%A4%EC%96%B4-%EB%B0%B0%ED%8F%AC%ED%96%88%EC%8A%B5%EB%8B%88%EB%8B%A4-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-%ED%98%BC%EC%A0%95-%EA%B0%9C%EB%B0%9C%EA%B8%B0</guid>
            <pubDate>Fri, 04 Sep 2026 08:34:54 GMT</pubDate>
            <description><![CDATA[<!--
벨로그 제목: 
태그: 사이드프로젝트, SpringBoot, ReactNative, 회고, 백엔드
-->


<p>이 시리즈는 그 3개월을 9편으로 나눠 정리한 기록입니다.
잘한 것보다 <strong>틀렸던 것과 그걸 어떻게 알아냈는지</strong>에 지면을 더 썼습니다.</p>
<hr>
<h2 id="혼정honjeong이-뭔가">혼정(HonJeong)이 뭔가</h2>
<p><strong>&quot;혼밥을 정상화하다&quot;</strong> 라는 뜻입니다.</p>
<p>식당에서 &quot;같이 먹을 사람 구해요&quot;로 체크인하면, 같은 식당·주변에 있는 다른 사람에게 보입니다.
누군가 같이먹기를 신청하고 수락하면 매칭되고 대화방이 열립니다.
끝내 사람을 못 구하면 <strong>혼밥중</strong>으로 넘어가 혼자 먹습니다.</p>
<p>핵심은 마지막 문장입니다. <strong>매칭에 실패해도 사용자가 잃는 게 없습니다.</strong>
원래 혼자 갈 생각이었으니까요. 이 퇴로가 있어서 &quot;신청 안 오면 어떡하지&quot;라는 부담이 없습니다.</p>
<pre><code>[체크인]
   ↓
 모집중(SEEKING) ──── 수락 ────→ 같이 먹는 중(TOGETHER) → 대화방 열림
   │
   └──── &quot;그냥 혼자 먹을래&quot; ────→ 혼밥중(ACTIVE)</code></pre><p>그리고 그 &quot;혼밥중&quot;인 사람들의 수를 지도와 첫 화면에 띄웁니다.
<strong>&quot;나만 혼자가 아니다&quot;</strong> 는 사회적 증거를 만드는 게 이 서비스의 존재 이유입니다.</p>
<hr>
<h2 id="숫자로-보는-프로젝트">숫자로 보는 프로젝트</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>개발 기간</td>
<td>2026-06-08 ~ 2026-09-04 (약 3개월)</td>
</tr>
<tr>
<td>커밋</td>
<td><strong>671개</strong></td>
</tr>
<tr>
<td>백엔드 테스트</td>
<td><strong>827개</strong> / 112개 파일</td>
</tr>
<tr>
<td>DB 마이그레이션</td>
<td><strong>30개</strong> (Flyway)</td>
</tr>
<tr>
<td>도메인 패키지</td>
<td>17개</td>
</tr>
<tr>
<td>앱 화면</td>
<td>36개</td>
</tr>
<tr>
<td>식당 데이터</td>
<td><strong>655,163행</strong> (공공데이터 ETL 적재)</td>
</tr>
<tr>
<td>배포</td>
<td>AWS Lightsail 2GB 1대 · Docker Compose · HTTPS</td>
</tr>
<tr>
<td>앱 배포</td>
<td>TestFlight <code>1.0.0</code> (빌드 30까지)</td>
</tr>
<tr>
<td>성능 개선</td>
<td>검색 처리량 <strong>최소 34배</strong> · 운영 실측 1,028ms → <strong>1.4ms</strong></td>
</tr>
<tr>
<td>참여 인원</td>
<td><strong>1명</strong></td>
</tr>
</tbody></table>
<hr>
<h2 id="기술-스택">기술 스택</h2>
<p><strong>백엔드</strong></p>
<ul>
<li>Java 21 / <strong>Spring Boot 4.0.x</strong> (출시 직후 버전을 골라서 꽤 고생했습니다 — 3편)</li>
<li>Spring Data JPA / Flyway / PostgreSQL 17</li>
<li>인증: <strong>Nimbus JOSE + JWT (HS256 자체 발급)</strong></li>
<li>실시간: <strong>WebSocket(순수, STOMP 미사용)</strong> + FCM 푸시 + 폴링 안전망</li>
<li>테스트: JUnit 5 + <strong>Testcontainers(실제 PostgreSQL)</strong> + Mockito</li>
</ul>
<p><strong>앱</strong></p>
<ul>
<li>React Native + Expo SDK 56 / TypeScript</li>
<li><strong>TanStack Query(React Query)</strong> </li>
<li>지도: 카카오 지도 JS SDK를 WebView로 로드</li>
</ul>
<p><strong>인프라</strong></p>
<ul>
<li>AWS Lightsail(Ubuntu 24.04) + Docker Compose + <strong>Caddy</strong>(Let&#39;s Encrypt 자동 인증서)</li>
<li>iOS 빌드/제출: EAS Build + Submit</li>
</ul>
<p><strong>데이터</strong></p>
<ul>
<li>식당 = 공공데이터포털 전국일반음식점 표준데이터</li>
<li>별도 Python ETL로 멱등 적재 (좌표계 EPSG:5174 → WGS84 변환)</li>
</ul>
<p><img src="blob:https://velog.io/45f0f6e8-e845-4c50-aa34-744b7de18dd6" alt="업로드중.."></p>
<hr>
<h2 id="시리즈-목차">시리즈 목차</h2>
<table>
<thead>
<tr>
<th>편</th>
<th>제목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>①</td>
<td><strong>프로젝트 소개</strong> (이 글)</td>
<td>무엇을 만들었고 어떤 규모인가</td>
</tr>
<tr>
<td>②</td>
<td>문제 정의와 제품 결정</td>
<td>왜 만들었나 · <strong>무엇을 안 만들기로 했나</strong></td>
</tr>
<tr>
<td>③</td>
<td>아키텍처와 기술 선택</td>
<td>Boot 4를 고른 대가 · mock-first · 공공데이터를 쓴 이유</td>
</tr>
<tr>
<td>④</td>
<td>3개월 개발 타임라인</td>
<td>06-08부터 09-04까지 무슨 일이 있었나</td>
</tr>
<tr>
<td>⑤</td>
<td>핵심 구현 딥다이브</td>
<td>상태 머신 · 락 없는 동시성 · WebSocket 채팅</td>
</tr>
<tr>
<td>⑥</td>
<td>성능과 부하 테스트</td>
<td>가설 5개 중 1개만 맞았다 · 34배</td>
</tr>
<tr>
<td>⑦</td>
<td>배포와 운영</td>
<td>로컬에서 됐다 ≠ 서버에서 된다 (3연속 실증)</td>
</tr>
<tr>
<td>⑧</td>
<td>테스트 전략</td>
<td>H2를 안 쓴 이유 · &quot;그린&quot;이 증명하지 못하는 것</td>
</tr>
<tr>
<td>⑨</td>
<td>회고 — 반복된 함정</td>
<td>가장 비쌌던 실수들과 원칙</td>
</tr>
</tbody></table>
<hr>
<p>다음 편에서는 <strong>왜 이걸 만들었고, 무엇을 만들지 않기로 했는지</strong>를 이야기합니다.
개인적으로는 만든 기능 목록보다 <strong>6일간 구현 목록에 있던 기능의 정답이 &#39;삭제&#39;였던 이야기</strong>가 더 재미있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Heap Stack 차이점]]></title>
            <link>https://velog.io/@yoon_bly/Heap-Stack-%EC%B0%A8%EC%9D%B4%EC%A0%90</link>
            <guid>https://velog.io/@yoon_bly/Heap-Stack-%EC%B0%A8%EC%9D%B4%EC%A0%90</guid>
            <pubDate>Thu, 04 Jan 2024 01:04:36 GMT</pubDate>
            <description><![CDATA[<h1 id="heap">Heap</h1>
<ul>
<li>자바의 힙 공간은 객체와 JRE 클래스들에게 메모리를 할당할 때 사용 된다.</li>
<li>객체가 생성되면 힙 공간에 할당되며, 이 객체에 대한 참조가 스택 메모리에 저장된다.</li>
<li>가비지컬렉션은 힙 메모리에 더이상 참조하지 않는 객체들을 정리 한다.</li>
<li>힙에 만들어진 객체는 어디서든 접근할 수 있고, 어플리케이션 어디에서나 참조될 수 있다.</li>
</ul>
<h1 id="heap-특징">Heap 특징</h1>
<ul>
<li>힙 영역이 가득차면 OutOfMemoryError가 발생</li>
<li>스택 영역에 비해 엑세스 속도가 조금 느리다.</li>
<li>스택 영역과 달리 메모리 해제가 자동으로 되지 않는다.</li>
<li>메모리 사용의 효율성을 위해 GC가 필요하다.(Garbage Collection)</li>
<li>Thread-safe하지 않기 때문에 적절히 synchronizing을 해야한다.</li>
</ul>
<h1 id="stack">Stack</h1>
<ul>
<li>정적 메모리 할당 및 쓰레드 실행을 위해 사용되는 영역</li>
<li>메소드에 고유한 기본 값과 메소드에서 참조하는 힘 개체에 대한 참조값을 포함하고 있다.</li>
<li>LIFO(Last-In-First-Out) 순서로 동작된다.</li>
<li>새 메소드가 호출될 때마다 스택 상단에 해당 메소드에 대한 새블록이 생성된다.</li>
<li>메소드 실행이 완료되면 해당 스택에서 pop되고, 호출한 메소드로 흐름이 돌아가게 된다.</li>
</ul>
<h1 id="stack-특징">Stack 특징</h1>
<ul>
<li>스택 내부의 변수는 변수를 생성한 메소드가 실행되는 동안만 존대한다.</li>
<li>메소드 실행에 따라 자동으로 할당되고 해제된다.</li>
<li>해당 메모리 영역이 가득차면 StackOverFlowError가 발생.</li>
<li>힙 메모리에 비해 엑세스 속도가 빠르다.</li>
<li>쓰레드마다 고유의 스택 영역을 가지고 있으므로, Threa-safe하다.</li>
</ul>
<h1 id="heap과-stack-차이점">Heap과 Stack 차이점</h1>
<ul>
<li>힙 메모리는 어플리케이션의 모든 부분에서 사용된다.</li>
<li>스택 메모리는 하나의 쓰레드가 실행 될 때 사용된다.</li>
<li>객체가 생성되면 항상 힙 공간에 저장된다.</li>
<li>스택 메모리는 힙 공간에 있는 객체를 참조만 한다.</li>
<li>스택 메모리는 primitive 타입의 지역변수와 힙 공간에 있는 객체 참조 변수만 갖고 있다.</li>
<li>힙 공간에 저장된 객체는 어디서든 접근이 가능하다.</li>
<li>스택 메모리는 다른 쓰레드가 접간할 수 없다. (Heap보다 어느정도 보안성이 있다.)</li>
<li>스택 메모리의 생명주기는 매우 짧다.</li>
<li>힙 메모리는 어플리케이션의 시작부터 끝까지 남아있다.</li>
<li>스택 메모리가 가득차면 java.lang.StackOverFlowError 발생</li>
<li>힙 메모리가 가득차면 java.lang.OutOfMemoryError 발생</li>
<li>스택 메모리의 사이즈는 힙 메모리와 비교시 매우 적다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[TACO 프로젝트 회고록(배포)]]></title>
            <link>https://velog.io/@yoon_bly/TACO-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9D%EB%B0%B0%ED%8F%AC</link>
            <guid>https://velog.io/@yoon_bly/TACO-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9D%EB%B0%B0%ED%8F%AC</guid>
            <pubDate>Fri, 11 Aug 2023 05:43:18 GMT</pubDate>
            <description><![CDATA[<p>js및 뷰단 타임리프작업까지 마무리가 되었다.</p>
<p>이번 회고록은 나의 배포 준비단계를 작성해보려고 한다.</p>
<p>가장 먼저 나는 docker를 이용하여 aws에 올릴 생각이었다.</p>
<p>docker에 나의 이미지를 올리고 aws인스턴스에 docker를 다운받아 해당 서버를 올릴 생각이었다.</p>
<p>그래서 3일간 열심히 찾아보고 도전해보았다.</p>
<p>docker-compose 파일을 이용하여 내 프로젝트와 db를 연결하려고 했지만, 자꾸만 오류를 나타내었다.</p>
<p>그래서 결국 aws만을 사용해서 배포하기로 결정하였다.</p>
<p>하지만, 여러 걸림돌이 걸렸었다.</p>
<h2 id="프리티어">프리티어</h2>
<p>아직 돈을 벌지 못하는 일개 대학생으로써 프리티어 버전으로 인스턴스를 만들고, 해당 인스턴스에 내 프로젝트파일을 옮기고 jar파일을 만드려하는데, 10분이 지나도 완료가 도저히 안되었다.</p>
<p>그래서 구글링 결과.</p>
<p>프리티어 버전의 인스턴스는 RAM이 1기가 밖에 안되기 때문에 파일이 크면 오래 걸리기도 안되기도 한다고 한다.</p>
<p>그래서 swap 파일을 사용하여 RAM 용량을 늘려 build하였다.</p>
<p>이전보다 금방 되는 모습을 확인할 수 있었다.</p>
<h2 id="db">DB</h2>
<p>자꾸 서버에서 패킷을 못받았다는 오류를 받았다.</p>
<p>아직 무지한 상태인 나이기에 부끄럽지만 적어본다.</p>
<p>사실 나는 원래 aws 서버에 내 프로젝트를 올리고, 내 컴퓨터에 있는 mysql db를 사용하려 했다.</p>
<p>그러나 계속 jar 파일을 실행해도, db쪽에서 에러가 났다.</p>
<p>그래서 aws의 rds를 사용하여 내 프로젝트에 해당 db를 연결한 후 서버를 실행하니 정상적으로 잘 작동 되었다.</p>
<p>내 로컬 pc에 있는 db를 사용해서 서버를 배포하는게 되는지 안되는지 사실 잘 모르겠다.</p>
<p>이것도 나중에 공부를 해볼 것이다.</p>
<hr>
<p>결국 이 프로젝트의 마무리를 지었다.</p>
<p>배포 과정과 마무리 회고록은 나중에 쓰도록 하겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TACO 프로젝트 회고록(JS, Thymeleaf 작업)4]]></title>
            <link>https://velog.io/@yoon_bly/TACO-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9DJS-Thymeleaf-%EC%9E%91%EC%97%854</link>
            <guid>https://velog.io/@yoon_bly/TACO-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9DJS-Thymeleaf-%EC%9E%91%EC%97%854</guid>
            <pubDate>Sun, 06 Aug 2023 11:12:48 GMT</pubDate>
            <description><![CDATA[<p>마지막으로 main페이지를 진행하였다.</p>
<p>나머지 자잘자잘한 것들은 간단하여 생략하였다.</p>
<h1 id="주요기능">주요기능</h1>
<ol>
<li><p>로그인시 nav바 변경</p>
</li>
<li><p>nav바 프로필 사진 변경</p>
</li>
<li><p>주소 설정 select box</p>
</li>
</ol>
<h2 id="로그인-시-nav바-변경">로그인 시 nav바 변경</h2>
<p>사실 이 프로젝트를 하기 전에 하다가 중단된 프로젝트가 있었다.</p>
<p>나를 포함한 팀원들 모두 아무것도 모르는 상태에서 하려다 중단되었었는데,</p>
<p>그 프로젝트에서도 로그인 했을 때 nav바를 변경하는 방법을 몰랐다.</p>
<p>그래서 이번 프로젝트에서도 이 부분이 가장 해결하기 어렵다 생각했던 부분 중 하나이다.</p>
<p>하지만, 지금의 나는 session공부를 함으로써 어떤 방식으로 nav바를 변경해야 하는지 알게 되었고, 정말 간단한 방법으로 nav바를 변경할 수 있었다.</p>
<p>만약  로그인을 했다면, 서버에서 해당 사용자의 session을 생성했다.</p>
<p>이 session을 사용하여  nav바를 변경하였다.</p>
<p>가장 먼저 로그인 했을 때의 nav바 파일과 로그인이 안됐을 때의 nav바 파일을 따로 생성한 후 타임리프를 이용하여 nav바를 교체하는 방식으로 진행하였다.</p>
<pre><code class="language-java">    if(userIndex != null){
            Optional&lt;ProfileEntity&gt; userProfile = profileService.findProfileEntity(userIndex);
            // session이 있을 때 true 반환해 login된 header사용
            model.addAttribute(&quot;alreadyHaveSession&quot;, &quot;true&quot;);
            if(userProfile.isPresent()){
                model.addAttribute(&quot;storeFileName&quot;, userProfile.get().getStoreFileName());
            }
        }
        else if(userIndex == null){
            // session 없을 때 false반환해 기본 header사용
            model.addAttribute(&quot;alreadyHaveSession&quot;, &quot;false&quot;);
        }
    }</code></pre>
<p>위의 코드는 메인 페이지 메서드중 하나이다.</p>
<p>위의 코드처럼 유저 index가 있을 때 alreadyHaveSession이라는 모델을 가져오도록 한다.</p>
<pre><code class="language-html">&lt;div th:if=&quot;${alreadyHaveSession == &#39;true&#39;}&quot;&gt;
        &lt;div th:replace=&quot;newHeader.html&quot;&gt;&lt;/div&gt;
      &lt;/div&gt;
      &lt;div th:if=&quot;${alreadyHaveSession == &#39;false&#39;}&quot;&gt;
        &lt;div th:replace=&quot;existingHeader.html&quot;&gt;&lt;/div&gt;
      &lt;/div&gt;</code></pre>
<p>만약 alreadyHaveSession이 true이면, 해당 사용자가 로그인을 했을 상태이기 때문에, 로그인이 된 nav바를 대체하도록 하고 false이면 로그인이 안된 상태의 nav바를 반환하도록 하였다.</p>
<h2 id="nav바-프로필-사진-변경">nav바 프로필 사진 변경</h2>
<p>이 부분은 로그인한 유저의 프로필 사진이 변경되었을 때, 프로필 사진을 가져와 nav바에 사진을 걸어 두는 것이다.</p>
<p>위의 코드에서 보듯 storeFileName이라는 모델로 해당 사용자의 프로필 사진을 가져온다.</p>
<pre><code class="language-html">        &lt;img
          class=&quot;picture&quot;
          onerror=&quot;this.src=&#39;/img/default_profile.png&#39;&quot;
          th:src=&quot;@{/upload/{uploadfile}(uploadfile=${storeFileName})}&quot;
        /&gt;</code></pre>
<p>타임리프를 사용하여 해당 사진의 주소를 받아와 보여준다.</p>
<p>가장 처음 회원가입을 했을 때에는 프로필 사진이 없기 때문에 만약 해당 프로필 사진이 없으면 기본 프로필 사진을 받아 올 수 있도록 onerror 속성을 사용하여  기본 프로필 사진을 가져오도록 하였다.</p>
<h2 id="주소-설정-select-box">주소 설정 select box</h2>
<p>이 부분이 이 프로젝트에 있어서 가장 어려웠고 시간을 가장 많이 쓴 문제였다.</p>
<p>우선 나는 프론트엔드 개발자가 아니다. js를 잘 모르는 상태로 시도해서 시간을 많이 쓴 것 같기도 하다.</p>
<p>그냥 어느정도 어떤 식으로 js가 돌아가는지만 아는 그런 수준인 상태였다.</p>
<p>나는 당근마켓 웹페이지의 주소 select와 비슷하게 만들고 싶었다.</p>
<p><img src="https://velog.velcdn.com/images/yoon_bly/post/c565d1d6-6ee5-4435-8828-e66f2fe3d38f/image.png" alt=""></p>
<p>가장 중요한 부분은 해당 구/군을 선택하면 해당 구/군에 있는 동들이 나와야 한다.</p>
<p>그리고 해당 시 / 군 / 구 를 선택할 때 해당 지역을 선택한 게시글들이 나오게 만드는 것이다.</p>
<p>예를 들면, 경기도 용인시 수지구까지만 선택했다면, 경기도 용인시 수지구를 선택했던 게시글들만 나오게 하는 것이다.</p>
<p>우선 이 select box의 option 값들이 너무 많아 프로토타입으로 서울특별시의 지역만 option값으로 넣어두었다.</p>
<p>여러가지 시도를 해보았다.</p>
<p>option값을 selected 속성을 사용하여 만들어, js로 url주소를 만들어 이동하는 방식으로도 해보았지만, 페이지가 넘어가면서 해당 주소를 selected한 option값을 못 받아 왔다.</p>
<p>이런 여러가지 방식으로 시도해왔지만, 번번히 실패했다.</p>
<p>결국 오랜 구글링 끝에 sessionStorage를 사용하여 해당 selected값을 가져오는 방식으로 도전해보았다.</p>
<p>이 방법은 이전에 같이 프로젝트를 해온 프론트엔드 팀원이 해보다 실패했다고 회의하면서 들었어서, 반신반의하며 도전하였다.</p>
<p>방식은 이렇다.</p>
<p>select box에서 해당 지역을 클릭하면 해당 option의 value 값을 받아와 sessionStorage에 저장한 후 새로운 페이지에서 해당 지역의 게시글을 받아오고, selected 한 지역은 sessionStorage에서 받아와 select box에 넣어주는 방식이다.</p>
<p>이전에 프론트를 담당한 팀원이 select box를 해놓았는데 그걸 이용하여 코드를 진행해보았다.</p>
<p>원래는 다음페이지까지 이동은 했지만, selected가 되지않아 다음 지역 selectbox가 선택되지 않았다.</p>
<p>팀원이 짠 코드를 console.log를 찍어가며 어떤 식으로 코드진행이 되는지를 알아가며 차근차근 천천히 풀어나가니 결국에 내가 원하는 기능을 제대로 실행할 수 있게 되었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TACO 프로젝트 회고록(JS, Thymeleaf 작업)3]]></title>
            <link>https://velog.io/@yoon_bly/TACO-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9DJS-Thymeleaf-%EC%9E%91%EC%97%853</link>
            <guid>https://velog.io/@yoon_bly/TACO-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9DJS-Thymeleaf-%EC%9E%91%EC%97%853</guid>
            <pubDate>Sun, 06 Aug 2023 06:05:30 GMT</pubDate>
            <description><![CDATA[<p>프로필 수정 및 user정보를 수정하는 부분을 작업하였다.</p>
<h1 id="주요-기능">주요 기능</h1>
<ol>
<li><p>프로필 수정</p>
</li>
<li><p>회원 탈퇴 및 비밀번호 변경</p>
</li>
</ol>
<h2 id="1-프로필-수정">1. 프로필 수정</h2>
<p>사실 이 부분은 js작업 보다 서버쪽 코드 수정이 주였다.</p>
<p>사실 프로필 수정 자체는 form 태그의 multipart타입으로 만들면 쉽게 만들어진다.</p>
<p>말그대로 프로필 사진과 이름, 닉네임, 전화번호도 쉽게 수정되었다.</p>
<p>한가지 문제점을 내가 놓친 것이 있었는데, 만약 사용자가 게시글을 하나 이상 작성했거나, 댓글을 쓴 경우가 있는 상태에서 프로필을 바꾸면 해당 게시글과 댓글 프로필사진 및 닉네임도 변경이되어야 하는데, 그 부분을 생각하지 못했다.</p>
<h3 id="게시글-댓글-사용자-닉네임-수정">게시글, 댓글, 사용자 닉네임 수정</h3>
<pre><code class="language-java">    public Optional&lt;UserEntity&gt; changeUserInfo(Long userIndex, UserEntity userInfo) {
        Optional&lt;UserEntity&gt; user = userRepository.findById(userIndex);

        List&lt;PostEntity&gt; userPost = postRepository.findByUserIndex(userIndex);
        List&lt;ReplyEntity&gt; userReply = replyRepository.findByReplyUserIndex(userIndex);

        for(PostEntity post : userPost){
            post.setUserNickName(userInfo.getUserNickName());

            postRepository.save(post);
        }

        for(ReplyEntity reply : userReply){
            reply.setUserNickName(userInfo.getUserNickName());

            replyRepository.save(reply);
        }

        return user.map(p -&gt; {
            user.get().setUserName(userInfo.getUserName());
            user.get().setUserNickName(userInfo.getUserNickName());
            user.get().setUserPhoneNumber(userInfo.getUserPhoneNumber());

            return p;
        })
        .map(p -&gt; userRepository.save(p));

    }</code></pre>
<p>나는 user 엔티티와 프로필 엔티티를 따로 두어 userIndex를 통해 새로운 프로필을 만들거나 수정하는 방식으로 만들었다. 그래서 위 코드에는 프로필 사진 수정에 대한 코드는 없다.(아래에서 설명할 것이다.)</p>
<blockquote>
<p>return user.map(p -&gt; {
            user.get().setUserName(userInfo.getUserName());
            user.get().setUserNickName(userInfo.getUserNickName());
            user.get().setUserPhoneNumber(userInfo.getUserPhoneNumber());
            return p;
        })
        .map(p -&gt; userRepository.save(p));</p>
</blockquote>
<p>가장먼저 바뀐 사용자 정보를 원래 가지고 있던 유저 정보에서 수정한다.</p>
<blockquote>
<p>List&lt;PostEntity&gt; userPost = postRepository.findByUserIndex(userIndex);
        List&lt;ReplyEntity&gt; userReply = replyRepository.findByReplyUserIndex(userIndex);</p>
</blockquote>
<p>해당 코드는 사용자가 쓴 댓글과 게시글 전체를 가져오는 것이다.</p>
<p>그 다음 각각의 게시글과 댓글의 닉네임을 수정하여 저장한다.</p>
<h3 id="게시글-댓글-프로필-사진-수정">게시글, 댓글 프로필 사진 수정</h3>
<pre><code class="language-java">    public Optional&lt;ProfileEntity&gt; saveProfile(MultipartFile originFileName, Long userIndex) throws IOException {
        Optional&lt;ProfileEntity&gt; storeFile = fileStore.storeFile(originFileName, userIndex);

        List&lt;PostEntity&gt; userPost = postRepository.findByUserIndex(userIndex);
        List&lt;ReplyEntity&gt; userReply = replyRepository.findByReplyUserIndex(userIndex);

        for(PostEntity post : userPost){
            post.setStoreFileName(storeFile.get().getStoreFileName());

            postRepository.save(post);
        }

        for(ReplyEntity reply : userReply){
            reply.setStoreFileName(storeFile.get().getStoreFileName());

            replyRepository.save(reply);
        }
        return storeFile;
    }</code></pre>
<p>만약 사용자가 프로필 사진을  바꾼다면 실행되는 메서드이다.</p>
<blockquote>
<p>Optional&lt;ProfileEntity&gt; storeFile = fileStore.storeFile(originFileName, userIndex);</p>
</blockquote>
<p>가장 먼저 사용자의 프로필 사진을 저장한다.</p>
<p>이후 위의  게시글, 댓글의 닉네임을 변경하듯이, 프로필 사진을 각각의 게시글과 댓글에 수정하여 저장시킨다.</p>
<h3 id="문제점">문제점</h3>
<p>사실 이 방법이 맞는건지 잘 모르겠다.</p>
<p>내가 생각한 문제점은 데이터베이스적인 문제인것 같다.</p>
<p>사실 나의 무지함에서 일어난 문제같다.</p>
<p>사용자와 댓글, 게시글은 일대다 연관관계로, jpa를 사용한다면, getUserProfile() 이런식으로 게시글에서 사용자 프로필을가져올 수 있을 것이다.</p>
<p>하지만, 나는 닉네임과 프로필 사진을 게시글과 댓글에 새로운 컬럼으로 넣어두었고, 그렇게 해서 타임리프를 통해 뷰단에 뿌릴수 있다고 생각했다.</p>
<p>만약 게시글과 댓글에 사용자의 index만 가지고 뷰단에 뿌리게 된다면, 타임리프를 통해 어떤 방식으로 사용자의  닉네임과 프로필 사진을 가져오는 방법을 모르기 때문에 위의 방식으로 프로젝트를 진행하였다.</p>
<p>이게 맞는 것인지 틀린 것인지도 잘 모르겠다.</p>
<p>우선 배포과정까지 마무리한 후 다시 한번 손봐야 할 것 같다.</p>
<h2 id="회원-탈퇴-및-비밀번호-변경">회원 탈퇴 및 비밀번호 변경</h2>
<p>해당 로직은 아주 쉽게 끝났다.</p>
<pre><code class="language-javascript">$(&quot;#withdrawBtn&quot;).click(() =&gt; {
    const confirmed = confirm(&quot;회원탈퇴를 하시겠습니까?&quot;);

    if (confirmed) {
      $.ajax({
        url: &quot;/user/delete&quot;,
        type: &quot;DELETE&quot;,
        success: function () {
          alert(&quot;회원탈퇴가 완료되었습니다.&quot;);

          window.location.href = &quot;/&quot;;
        },
      });
    }
    // alert에서 취소 누르면 원상복귀
  });</code></pre>
<p>말 그대로 회원 탈퇴 방법을 ajax를  사용하여 회원탈퇴 버튼을  누르면 탈퇴를 할것인지 alert를 띄우고, 취소를 누르면 취소가 되고, 확인 버튼을 누르면 회원탈퇴가 되도록 만들었다.</p>
<p>비밀번호 변경도 쉽게 만들었다.</p>
<pre><code class="language-javascript">$(&quot;#submitBtn&quot;).click(() =&gt; {
    const newPassword = $(&quot;#inputNewpw&quot;).val();

    // 비밀번호 변경 input 이 비어있을 경우
    if (newPassword.trim() === &quot;&quot;) {
      alert(&quot;변경사항이 없습니다.&quot;);
      return;
    }

    // 새로운 비밀번호를 입력한 경우
    const confirmed = confirm(&quot;비밀번호를 변경하시겠습니까?&quot;);

    if (confirmed) {
      $.ajax({
        url: &quot;/change/pw&quot;,
        type: &quot;POST&quot;,
        data: { newPassword: newPassword },

        success: function (response) {
          alert(&quot;비밀번호를 변경하였습니다!&quot;);
        },
        error: function (xhr, status, error) {
          alert(&quot;에러가 발생했습니다.&quot;);
        },
      });
      // alert에서 취소 누르면 원상복귀
    }
  });</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[TACO 프로젝트 회고록(JS, Thymeleaf 작업)2]]></title>
            <link>https://velog.io/@yoon_bly/TACO-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9DJS-Thymeleaf-%EC%9E%91%EC%97%852</link>
            <guid>https://velog.io/@yoon_bly/TACO-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9DJS-Thymeleaf-%EC%9E%91%EC%97%852</guid>
            <pubDate>Sun, 06 Aug 2023 04:30:03 GMT</pubDate>
            <description><![CDATA[<p>회원가입/ 로그인 기능을 끝마친 후 나는 게시글 상세화면에 대한 js작업과 thymeleaf작업을 진행하였다.</p>
<p>게시글 쓰는 페이지는 다른 팀원이 하기로 하고 나는 메인페이지에서 게시글을 클릭했을 때 나오는 게시글 상세화면을 만들었다.</p>
<h1 id="주요-기능">주요 기능</h1>
<ol>
<li><p>해당 게시글이 자신이 쓴 게시글이라면 게시글 수정 및 삭제 버튼 활성화</p>
</li>
<li><p>해당 게시글 thymeleaf 작업</p>
</li>
<li><p>해당 게시글의 댓글 thymeleaf 작업</p>
</li>
</ol>
<h2 id="1-해당-게시글이-자신이-쓴-게시글이라면-수정-및-삭제-버튼-활성화">1. 해당 게시글이 자신이 쓴 게시글이라면 수정 및 삭제 버튼 활성화</h2>
<p>이 부분이 사실 게시글 상세화면 부분에서 가장 어려운 부분이라고 생각한다.</p>
<p>우선 게시글 상세화면은 로그인세션이 있는 상태, 로그인이 되어있지 않은 상태 둘다 해당 페이지를 들어올 수 있기 때문에 게시글 수정 및 삭제 버튼은 게시글의 주인이 로그인하지 않는이상 활성화 되면 안된다.</p>
<p>이 부분을 어떻게 하면 좋을까 곰곰히 생각해보았다.</p>
<p>해당 게시글의 index와 로그인된 유저의 index가 같으면 해당 버튼들이 활성화 되어야 한다.</p>
<p>그래서 게시글을 가져올때의 메서드에 thymeleaf값 하나를 가져와서 만들기로 생각했다.(나중에 좋은 방법이 생각났다.)</p>
<pre><code class="language-java">@GetMapping(&quot;/post/{postId}&quot;)
    public String read(HttpSession session, @PathVariable(name = &quot;postId&quot;) Long postId, Model model) {
        Long userIndex = (Long) session.getAttribute(&quot;userIndex&quot;);
        model.addAttribute(&quot;userIndex&quot;, userIndex);

        // 게시글 model
        Optional&lt;PostEntity&gt; post = postService.read(postId);
        post.ifPresent(o -&gt; model.addAttribute(&quot;post&quot;, o));

        // 세션을 가져와서 만약 게시글, 댓글 userIndex와 같다면
        // 1을 출력해 수정 및 삭제 버튼 활성화 
        // 로그인 했지만, 다르면 0 
        // 로그인하지 않았다면, 2 출력
        // 수정 필요
        Integer checkedUserIndexForPost = 0;
        if(userIndex != null){
            if(userIndex == post.get().getUserIndex()){
                // 게시글, 댓글, 세션이 같으면
                checkedUserIndexForPost = 1;
                model.addAttribute(&quot;checkIndexForPost&quot;, checkedUserIndexForPost);
            } else if(userIndex != post.get().getUserIndex()){
                checkedUserIndexForPost = 2;
                model.addAttribute(&quot;checkIndexForPost&quot;, checkedUserIndexForPost);
            }
        }
        else if(userIndex == null){
            model.addAttribute(&quot;checkIndexForPost&quot;, checkedUserIndexForPost);
        }

        return &quot;post_detail&quot;;
    }</code></pre>
<p>가장 먼저 해당 게시글을 가져와 model화 해준다.</p>
<p>그 후 userIndex가 있을 때 없을 때를 비교하고 없다면 2,
로그인하여 userIndex가 있지만, 게시글 index와 다르다면 0,
해당 게시글이 로그인한 사용자의 게시글이면 1을 반환하여 model에 넣어주었다.</p>
<p>이 값을 js에서 받아서 확인하는 방식으로 진행하였다.</p>
<pre><code class="language-javascript">$(document).ready(() =&gt; {
  var index_button = $(&quot;input[name=index_button]&quot;).val();
  if (index_button == 1) {
    console.log(index_button);
    $(&quot;.deleteEdit&quot;).css(&quot;display&quot;, &quot;flex&quot;);
  }
});</code></pre>
<blockquote>
<p>$(&quot;input[name=index_button]&quot;).val();</p>
</blockquote>
<p>이 부분은 해당 타임리프 값을 input hidden 값으로 html에 집어넣어 해당 value를 가져오게 만든 것이다.</p>
<pre><code class="language-html">&lt;input type=&quot;hidden&quot; name=&quot;index_button&quot; th:value=&quot;${checkIndexForPost}&quot;/&gt;</code></pre>
<h3 id="개선사항">개선사항</h3>
<p>사실 어짜피 로그인세션의 userIndex를 model화 한다면, 위의 방법보다, userIndex의 value값을 가져오고, thymeleaf로 가져온 게시글 model에서 userIndex를 뽑아와 그 둘을 비교시켜도 되었을 것이다.</p>
<p>그것이 유지보수에 좀 더 도움이 되었을 것이다.</p>
<p>나중에 해당 프로젝트를 리펙토링하게 된다면 이 부분을 고쳐주고 싶다.</p>
<h2 id="2-해당-게시글-thymeleaf-작업">2. 해당 게시글 thymeleaf 작업</h2>
<p>해당 thymeleaf 작업은 너무 쉬워서 넘어가겠다.</p>
<h2 id="3-해당-게시글의-댓글-js-thymeleaf-작업">3. 해당 게시글의 댓글 js, thymeleaf 작업</h2>
<p>댓글 작성은 form 태그를 이용하여 구현하였다.</p>
<p>여기서 댓글을 작성한 후 해당 댓글을 보게 되면 만약 로그인한 유저가 해당 댓글을 썼다면, 수정 및 삭제 버튼이 생긴다.</p>
<p>이 부분을 위에 게시글 수정 삭제 버튼의 개선사항으로 만들어 보았다.</p>
<p>thymeleaf의 th:style을 이용하여 만약 같다면 해당 display를 활성화 시키는 방법으로 만들었다.</p>
<pre><code class="language-html">&lt;div
  class=&quot;deleteEditC&quot;
  th:style=&quot;${reply.replyUserIndex == userIndex ? &#39;display:flex&#39; : &#39;display:none&#39;}&quot;
&gt;
    &lt;button class=&quot;editC&quot;&gt;수정&lt;/button&gt;
    &lt;form
         th:action=&quot;@{/reply/delete/{replyIndex}(replyIndex=${reply.replyIndex})}&quot;
         method=&quot;post&quot;
    &gt;
    &lt;input type=&quot;hidden&quot; name=&quot;_method&quot; value=&quot;delete&quot; /&gt;
    &lt;button class=&quot;deleteC&quot;&gt;삭제&lt;/button&gt;
    &lt;/form&gt;
&lt;/div&gt;</code></pre>
<p>각각의 댓글의 userIndex를 가져와 만약 로그인세션의 userIndex와 같다면 해당 버튼들을 활성화 하고 다르면 비활성화 하게 만들었다.</p>
<p>댓글 수정은 ajax를 이용하여 비동기 방식으로 구현하였다.</p>
<pre><code class="language-javascript">document.addEventListener(&quot;DOMContentLoaded&quot;, function () {
  // Addeventlisteners &quot;수정&quot; 버튼
  function handleEditClick(event) {
    const commentBox = event.target.closest(&quot;.commentBox&quot;);
    const commentText = commentBox.querySelector(&quot;.comment&quot;);
    const commentContent = commentText.innerText;

    const textarea = document.createElement(&quot;textarea&quot;);
    textarea.classList.add(&quot;editTextarea&quot;);
    textarea.name = &quot;editedComment&quot;;
    textarea.value = commentContent;

    const saveButton = document.createElement(&quot;button&quot;);
    saveButton.classList.add(&quot;saveC&quot;);
    saveButton.innerText = &quot;저장&quot;;

    const editDeleteContainer = commentBox.querySelector(&quot;.deleteEditC&quot;);
    editDeleteContainer.innerHTML = &quot;&quot;;
    commentText.innerHTML = &quot;&quot;;
    commentText.appendChild(textarea);
    editDeleteContainer.appendChild(saveButton);

    //기능 삭제
    event.target.removeEventListener(&quot;click&quot;, handleEditClick);

    saveButton.addEventListener(&quot;click&quot;, function () {
      const editedComment = textarea.value;
      var reply_index = $(&quot;input[name=reply_index]&quot;).val();

      $.ajax({
        url: &quot;/reply/edit/&quot; + reply_index,
        type: &quot;POST&quot;,
        data: {
          replyDescription: editedComment,
        },
        success: function (response) {
          console.log(&quot;업데이트에 성공했습니다.&quot;);

          commentText.innerText = editedComment;
          editDeleteContainer.innerHTML = &quot;&quot;;

          const editButton = document.createElement(&quot;button&quot;);
          editButton.classList.add(&quot;editC&quot;);
          editButton.innerText = &quot;수정&quot;;

          const deleteButton = document.createElement(&quot;button&quot;);
          deleteButton.classList.add(&quot;deleteC&quot;);
          deleteButton.innerText = &quot;삭제&quot;;

          editDeleteContainer.appendChild(editButton);
          editDeleteContainer.appendChild(deleteButton);

          //다시 적용
          editButton.addEventListener(&quot;click&quot;, handleEditClick);
        },
      });
    });
  }

  const editButtons = document.querySelectorAll(&quot;.editC&quot;);
  editButtons.forEach((button) =&gt; {
    button.addEventListener(&quot;click&quot;, handleEditClick);
  });
});</code></pre>
<p>수정 버튼을 눌렀을 때, handleEditClick함수가 실행되어 textarea와 저장 버튼을  추가 시킨뒤 저장 버튼을 눌렀을 때 ajax로 수정이 가능하게 만들었다.</p>
<p>삭제 버튼은 form 태그를 이용하여 바로 삭제 가능하게 구현하였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TACO 프로젝트 회고록(JS, Thymeleaf 작업)1]]></title>
            <link>https://velog.io/@yoon_bly/TACO-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9DJS-Thymeleaf-%EC%9E%91%EC%97%851</link>
            <guid>https://velog.io/@yoon_bly/TACO-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9DJS-Thymeleaf-%EC%9E%91%EC%97%851</guid>
            <pubDate>Fri, 04 Aug 2023 12:47:13 GMT</pubDate>
            <description><![CDATA[<p>내 프로젝트에서는 로그인을 한 뒤에 글을 쓰거나 댓글을 쓰는등 가장 우선시 되는 것이 로그인 기능이다.</p>
<p>그래서 가장 첫번째로 건든 부분이 회원가입/로그인 부분이다.</p>
<p><strong>signup.html</strong></p>
<h1 id="회원-가입-기능">회원 가입 기능</h1>
<p>가장 회원 가입이 되기 위한 조건은</p>
<ol>
<li><p>사용 가능한 ID (사용중인 ID 사용 불가)</p>
</li>
<li><p>비밀번호 확인 (비밀번호가 같아야 함)</p>
</li>
<li><p>이름과 닉네임이 같으면 안된다.(필요할 것 같아서 넣음)</p>
</li>
<li><p>회원가입</p>
</li>
</ol>
<p>이렇게 3가지가 필요하다.</p>
<h2 id="1-사용-가능한-id">1. 사용 가능한 ID</h2>
<p>사실 기본 회원가입과 비슷하게 ID를 입력 후 중복체크하는 버튼을 만드려고 했다.</p>
<p>하지만, 우리가 만든 디자인과는 중복체크 버튼은 어울리지 않았고, 결국 keyup 기능을 이용하여 중복체크를 하기로 했다.</p>
<pre><code class="language-javascript">$(&#39;.idInput&#39;).on(&quot;propertychange change keyup paste input&quot;, function(){
  // console.log(&quot;keyup 테스트&quot;);
  var userId = $(&quot;.idInput&quot;).val();
  var data = {userId : userId}

  $.ajax({
    type: &quot;post&quot;,
    url: &quot;/check/id&quot;,
    data : data,
    success : function(result){
      //사용 가능
      if(result === 0){
        $(&quot;.condition1&quot;).css(&quot;display&quot;, &quot;flex&quot;);
        $(&quot;.id_input_re_1&quot;).css(&quot;display&quot;,&quot;inline-block&quot;);
                $(&quot;.id_input_re_2&quot;).css(&quot;display&quot;, &quot;none&quot;);
        console.log(&quot;사용 가능&quot;);
      }
      // 아이디 중복
      if(result === 1){
        $(&quot;.condition1&quot;).css(&quot;display&quot;, &quot;flex&quot;);
        $(&quot;.id_input_re_1&quot;).css(&quot;display&quot;,&quot;none&quot;);
                $(&quot;.id_input_re_2&quot;).css(&quot;display&quot;, &quot;inline-block&quot;);
        console.log(&quot;아이디 중복!&quot;);
      }
    }
  });
});</code></pre>
<p>propertychange change keyup paste input을 이용하여 input값에 입력되는 값을 실시간으로 처리할 수 있도록 만들었다.</p>
<p>이후 ajax를 이용하여 ID 중복체크 메서드를 실행시켜 사용 가능한지 중복인지 판별하는 기능을 만들었다.</p>
<p><strong>service 부분</strong></p>
<pre><code class="language-java">public int checkDuplicateNickName(String userNickName) {
        Optional&lt;UserEntity&gt; find = userRepository.findByUserNickName(userNickName);
        if(find.isEmpty()){
            System.out.println(&quot;possible NickName!!&quot;);
            return 0;
        }
        else{
            System.out.println(&quot;duplicate NickName!!&quot;);
            return 1;
        }
    }</code></pre>
<p>keyup 된 ID를 가져와서 만약 해당 ID를 갖고 있는 사용자가 있다면, 1을 반환하고, 사용가능한 아이디라면 0을 반환하도록 만들었다.</p>
<h2 id="2-비밀번호-체크">2. 비밀번호 체크</h2>
<p>대부분 회원가입 페이지를 보면 비밀번호를 누르고 한번더 비밀번호를 입력하라고 한다.</p>
<p>그 부분을 만들었다.</p>
<p>이 부분은 회원가입을 누를 때 체크하는 기능으로, 비밀번호와 한번더 입력한 비밀번호 값이 같지 않다면 회원가입이 되지 않게 만들었다.</p>
<pre><code class="language-javascript">$(document).ready(() =&gt; {
  $(&quot;button&quot;).click((event) =&gt; {
    var password = $(&quot;.passwordInput&quot;).val();
    var passwordCheck = $(&quot;.passwordCheckInput&quot;).val();

    if(password !== passwordCheck) {
      $(&quot;.condition2&quot;).css(&quot;display&quot;, &quot;&quot;);
      event.preventDefault();
    }
    if(password === passwordCheck) {
      $(&quot;.condition2&quot;).css(&quot;display&quot;, &quot;none&quot;);
    }
  })
})</code></pre>
<p>해당 input 값들을 가져와 같은지 판별하였다.</p>
<h2 id="3-이름과-닉네임이-같으면-안된다">3. 이름과 닉네임이 같으면 안된다.</h2>
<p>이 부분도 2번과 똑같이 두 input 값을 가져와 비교하여 판별하였다.</p>
<pre><code class="language-javascript">$(document).ready(() =&gt; {
  $(&quot;button&quot;).click((event) =&gt; {
    var nameVal = $(&quot;.nameInput&quot;).val();
    var nickNameVal = $(&quot;.nickNameInput&quot;).val();
    if (nameVal === nickNameVal) {
      $(&quot;.condition&quot;).css(&quot;display&quot;, &quot;&quot;);
      event.preventDefault();
    }
    if (nameVal !== nickNameVal) {
      $(&quot;.condition&quot;).css(&quot;display&quot;, &quot;none&quot;);
      event.preventDefault();
    }
  });
});</code></pre>
<h2 id="4-회원-가입">4. 회원 가입</h2>
<p>모든 유효성 검사에 가능해지면 회원가입을 할 수 있다.</p>
<p>회원 가입은 ajax를 통해 회원가입이 이루어지게 만들었다.</p>
<pre><code class="language-javascript">$(document).ready(() =&gt; {
  $(&quot;.join_button&quot;).click((event) =&gt; {
    var nameVal = $(&quot;.nameInput&quot;).val();
    var nickNameVal = $(&quot;.nickNameInput&quot;).val();
    var password = $(&quot;.passwordInput&quot;).val();
    var passwordCheck = $(&quot;.passwordCheckInput&quot;).val();

    var userId = $(&quot;.idInput&quot;).val();
    var data = {
      userId : $(&quot;.idInput&quot;).val(),
      userNickName : $(&quot;.nickNameInput&quot;).val()
    };

    $.ajax({
      type: &quot;post&quot;,
      url: &quot;/check&quot;,
      data : data,
      success : function(result){
        if(nameVal !== nickNameVal){
          if(password === passwordCheck){
            if(result === 3){
              alert(&quot;회원가입되었습니다.&quot;);
              document.getElementById(&quot;signup&quot;).submit();
            }
            else if(result === 2){
              alert(&quot;닉네임이 중복됩니다.&quot;);
            }
          }
        }
      }
    })
  })
})</code></pre>
<p>회원가입에 필요한 input값들을 data 로 만들어 ajax로 서버에 보낸다.</p>
<p>그후 js로만 만든 유효성 검사를 if문으로 판별한 후 서버에서 받아온 값을 또 확인 하여 회원가입 시켰다.</p>
<p>이때 서버에서 받아오는 값은 아이디 닉네임이 중복되는지 안되는지를 판별하는 것이다.</p>
<p>회원가입은 form태그를 이용하여 설정하였다.</p>
<pre><code class="language-java"> @PostMapping(&quot;/check&quot;)
    public ResponseEntity&lt;Integer&gt; checkIdAndNick(@RequestParam String userId, @RequestParam String userNickName){
        int checkId = userService.checkDuplicateId(userId);
        int checkNick = userService.checkDuplicateNickName(userNickName);
        //아이디 닉네임 둘다 중복될 때
        if(checkId == 1 &amp;&amp; checkNick == 1){
            return new ResponseEntity&lt;&gt;(0, HttpStatus.BAD_REQUEST);
        }
        //아이디 중복될 때
        if(checkId == 1 &amp;&amp; checkNick == 0){
            return new ResponseEntity&lt;&gt;(1, HttpStatus.BAD_REQUEST);
        }
        //닉네임 중복될 때
        if(checkId == 0 &amp;&amp; checkNick == 1){
            return new ResponseEntity&lt;&gt;(2, HttpStatus.OK);

        }
        //아이디 닉네임 둘다 사용 가능할 때
        if(checkId == 0 &amp;&amp; checkNick == 0){
            return new ResponseEntity&lt;&gt;(3, HttpStatus.OK);   
        }
        else
            return new ResponseEntity&lt;&gt;(null, HttpStatus.BAD_GATEWAY);

    }</code></pre>
<p>이렇게 회원가입 부분을 만들었다.</p>
<h1 id="로그인-기능">로그인 기능</h1>
<p>로그인 기능은 아주 쉽게 만들었다.</p>
<pre><code class="language-javascript">function login() {
  var idInput = document.querySelector(&quot;.idInput&quot;).value;
  var passwordInput = document.querySelector(&quot;.passwordInput&quot;).value;

  let data = {
    userId: $(&quot;.idInput&quot;).val(),
    userPassword: $(&quot;.passwordInput&quot;).val(),
  };
  $.ajax({
    type: &quot;POST&quot;,
    url: &quot;/login&quot;,
    data: JSON.stringify(data),
    contentType: &quot;application/json; charset=utf-8&quot;,
    dataType: &quot;json&quot;,
    success: function (response) {
      alert(&quot;로그인 되었습니다.&quot;);
      window.location.replace(&quot;/&quot;); // Redirect to the main page
    },
    error: function () {
      alert(&quot;존재하지 않는 정보입니다.&quot;);
      window.location.href = &quot;/login&quot;;
    },
  });
}</code></pre>
<p>ID와 비밀번호 input 값을 가져와 서버에 반환해 로그인 시키는 기능이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TACO 프로젝트 회고록(2023-08-04)]]></title>
            <link>https://velog.io/@yoon_bly/TACO-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9D2023-08-04</link>
            <guid>https://velog.io/@yoon_bly/TACO-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9D2023-08-04</guid>
            <pubDate>Fri, 04 Aug 2023 07:14:24 GMT</pubDate>
            <description><![CDATA[<p>마지막으로 회고록을 작성한 후 많은 시간이 지났다.</p>
<p>핑계라고 생각하면 핑계이겠지만, 마지막으로 회고록을 쓴 후, 한동안 이 프로젝트를 하지 못했다.</p>
<p>가장 큰 이유로, 이 프로젝트는 나 혼자 하는 것이 아닌, 여러명이서 함께하는 팀 프로젝트이다.</p>
<p>서버 개발과 페이지 퍼블리싱이 끝난 상태였지만, 기말고사 기간이라 바로 js 작업 및 타임리프 작업에 들어가지 못했다.</p>
<p>기말고사가 끝난 후 종강을 하며 방학 기간에 돌입하면서, 회의를 진행했었다.</p>
<p>시간이 많이 지체 되었다. 사실상 3개월이면 끝날 수 있는 프로젝트를 질질 끌고 있었으니..</p>
<p>물론 나의 잘못도 있다.</p>
<p>사실상 학교 커리큘럼에 프로젝트를 진행하는 것도 없었고, 나도 중요하지만, 후배들도 프로젝트를 진행해보면서, 자신의 실력도 늘릴 수 있는 쉽게 말해 개발 공부를 제대로 하고 싶은 사람들을 도와 함께 진행 하고 싶었다.</p>
<p>그렇다 보니, 이제 처음 프로젝트를 접한 팀원들과 할라니 시간이 지체되었던 것 같다.</p>
<p>그리고, 사실상 PM인 내가 기말고사 일정 때문에 지체될것을 몰랐고, 그로인해 일정 조율등을 잘하지 못한 나의 잘못도 있다.</p>
<p>그래서 종강 후 첫 회의에서 나는 단도직입적으로 요번 7월달까지 마무리했으면 좋겠다고 말했다.</p>
<p>대학교 4학년인 나는 사실 마음이 조급했다.</p>
<p>같이 프로젝트를 진행하는 팀원(후배)도 나의 마음을 이해했는지, 최대한 노력해서 끝내보자고 얘기를 하였고, 오늘 8월 4일 드디어 잔 수정을 남겨놓고 모두 끝마쳤다.</p>
<p>사실 그냥 서버쪽만 신경쓰려했던 나는 조급한 마음 때문에 프론트 부분도 함께 진행하였다.</p>
<p>7월 한달동안 이 프로젝트를 끝내기 위해 열심히 달려왔다.</p>
<p>회고록을 쓰지 못할 정도로 시간 투자를 많이 해서 끝내왔다.</p>
<p>하지만 또 이렇게 복습을 하며 회고록을 쓰지 않는다면, 결국 7월 한달동안 내가 했던 것이 물거품이 될 수도 있기 때문에, 회고록을 다시 작성하려고 한다.</p>
<p>이제 배포 준비를 해야하기 때문에 또 바쁠 예정이다. 하지만 이렇게 바쁘게 살다보니, 내가 살아있는 느낌이 든다.</p>
<p>역시 나는 뭔갈 해야되는 사람인가 보다.</p>
<p>아무튼 다음 회고록부터 내가 프로젝트를 진행하면서, 해왔던 작업이나, 문제, 내가 겪었던 생각들을 적어볼 생각이다.</p>
<p>얼른 배포까지 마무리하여 내가 만든 첫 프로젝트를 세상에 내보내고 싶다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ServletUriComponentsBuilder과 ResponseEntity]]></title>
            <link>https://velog.io/@yoon_bly/ServletUriComponentsBuilder%EA%B3%BC-ResponseEntity</link>
            <guid>https://velog.io/@yoon_bly/ServletUriComponentsBuilder%EA%B3%BC-ResponseEntity</guid>
            <pubDate>Thu, 06 Jul 2023 05:04:18 GMT</pubDate>
            <description><![CDATA[<p>REST API에 대하여 공부하는 중, ServletUriComponentsBuilder를 사용하여 필요한 URI를 만들어 반환하는 것에 대해 궁금증이 생겨 정리해 보았다.</p>
<hr>
<h1 id="uricomponentsbuilder">UriComponentsBuilder</h1>
<p>인터넷상에 존재하는 모든 자원(Resource)은 URI를 이용하여 그 위치를 나타내게 된다.</p>
<p>따라서, 웹 애플리케이션을 개발할 때 어떤 데이터를 리턴해주기 위해서 내부적으로 URI를 이용해 요청을 전송하게 된다.</p>
<p>UriComponentsBuilder는 이러한 URI를 작성할 때 실수를 범하거나 유지보수 측에서 유리하게 작성하기 위해 사용하는 클래스이다.</p>
<p>UriComponentsBuilder는 UriComponents들을 쉽게 작성하기위해 만들어진 builder 클래스이다.</p>
<hr>
<p>Static Factory Method 중에 하나를 이용하여 UriComponentsBuilder 객체를 생성한다.</p>
<ol>
<li><p>Static Factory Method의 종류는 아래와 같다. </p>
<ul>
<li><p>newInstance()</p>
</li>
<li><p>fromPath(String)</p>
</li>
<li><p>fromUri (URI)</p>
</li>
<li><p>fromUriString (String)</p>
</li>
<li><p>fromHttpUrl (String)</p>
</li>
<li><p>fromHttpRequest (HttpRequest)</p>
</li>
<li><p>fromOriginHeader (String)</p>
</li>
</ul>
</li>
</ol>
<hr>
<ol start="2">
<li><p>대응되는 각 메서드를 이용하여 URI 구성요소를 설정하거나 인코딩을 설정한다.</p>
<ul>
<li><p>scheme(String) </p>
</li>
<li><p>userInfo(String)</p>
</li>
<li><p>host(String) </p>
</li>
<li><p>port(String or int)</p>
</li>
<li><p>path(String)</p>
</li>
<li><p>queryParam(String, Object...)</p>
</li>
<li><p>queryParams(MultiValueMap&lt;String, String&gt;)</p>
</li>
<li><p>fragment(String) </p>
</li>
<li><p>encode(void or Charset) : void일 경우 UTF-8 로 인코딩</p>
</li>
<li><p>expand(Map&lt;String, ?&gt; or Object... or UriTemplateVariables) : URI 템플릿 변수 값을 지정</p>
</li>
</ul>
</li>
</ol>
<hr>
<ol start="3">
<li><p>build() 메서드를 이용하여 UriComponents 인스턴스를 Build 한다.</p>
<p>또는 buildAndExpand() 메서드를 이용하여 URI 템플릿 변수를 설정한 후 Build한다.</p>
<p>이후 toUri()를 통하여 URI 객체로 변환 시킨다.</p>
</li>
</ol>
<pre><code class="language-java">// 1. 간단한 링크 생성
UriComponents uriComponents1 = UriComponentsBuilder.newInstance()
    .scheme(&quot;https&quot;).host(&quot;blog.naver.com/aservmz&quot;).path(&quot;/222313864092&quot;).build(); 
    // https://blog.naver.com/aservmz/222313864092

//  2. URI 인코딩
UriComponents uriComponents2 = UriComponentsBuilder.newInstance()
    .scheme(&quot;http&quot;).host(&quot;www.example.com&quot;).path(&quot;/encodeTest test&quot;).build().encode();
    // http://www.example.com/encodeTest%20test

//  3. path 내에 템플릿 변수설정 방법 =&gt; path() 내에 {}로 변수명 지정 후 buildAndExpand()메서드에서 값 설정
URI uriComponents3 = UriComponentsBuilder.newInstance()
    .scheme(&quot;http&quot;).host(&quot;www.example.com&quot;).path(&quot;/{키워드는}/{아무렇게나 지정해도 됩니다.}&quot;)
    .buildAndExpand(&quot;UriTemplate&quot;, &quot;setting&quot;)
    .toUri();
    // http://www.example.com/UriTemplate/setting

// 4. Query Parameter가 포함된 URI 생성
URI uriComponents4 = UriComponentsBuilder.newInstance()
    .scheme(&quot;http&quot;).host(&quot;www.example.com&quot;).path(&quot;/{경로}&quot;)
    .query(&quot;q={queryValue1}&quot;).query(&quot;p={queryValue2}&quot;).buildAndExpand(&quot;testPath&quot;, &quot;value1&quot;, &quot;value2&quot;)
    .toUri();
    // http://www.example.com/testPath?q=value1&amp;p=value2

// 5. toUriString()을 이용한 방법
UriComponents uriComponents5 = UriComponentsBuilder
    .fromUriString(&quot;https://example.com/test/{testVariable}&quot;)
    .queryParam(&quot;q&quot;, &quot;{q}&quot;).encode().buildAndExpand(&quot;test&quot;, &quot;12345&quot;);
    // https://example.com/test/test?q=12345</code></pre>
<p>이후 인코딩 시에는 항상 UriComponentsBuilder안에서 인코딩을 해야한다.</p>
<hr>
<h1 id="servleturicomponentsbuilder">ServletUriComponentsBuilder</h1>
<p>ServletUriComponentsBuilder 클래스는 UriComponentsBuilder클래스를 상속하고 있다.</p>
<p>UriComponentsBuilder는 직접 URI를 생성 및 인코딩 한다면, ServletUriComponentsBuilder는이전 요청의 URI를 재사용하여 보다 편리하게 URI를 사용할 수 있도록 하는 클래스이다.</p>
<hr>
<ul>
<li><p>fromContextPath(HttpServletRequest)</p>
</li>
<li><p>fromServletMapping(HttpServletRequest)</p>
</li>
<li><p>fromRequestUri(HttpServletRequest)</p>
</li>
<li><p>fromRequest(HttpServletRequest)</p>
</li>
</ul>
<p>// 이 메서드들은 요청 객체를 RequestContextHolder로부터 얻는다는 점을 제외하고 위의 메서드들과 동일합니다.</p>
<ul>
<li><p>fromCurrentContextPath()</p>
</li>
<li><p>fromCurrentServletMapping()</p>
</li>
<li><p>fromCurrentRequestUri()</p>
</li>
<li><p>fromCurrentRequest()</p>
</li>
</ul>
<hr>
<pre><code class="language-java">@PostMapping(&quot;/users&quot;)
    public ResponseEntity&lt;User&gt; createUser(@RequestBody User user) {
        User savedUser = service.save(user);

        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path(&quot;/{id}&quot;)
                .buildAndExpand(savedUser.getId())
                .toUri();

        return ResponseEntity.created(location).build();
    }</code></pre>
<blockquote>
<p>fromCurrentRequest()</p>
</blockquote>
<ul>
<li>현재 요청된 Request 값을 가져옴</li>
<li>ex) localhost:8080/users</li>
</ul>
<blockquote>
<p>path(&quot;/{id}&quot;)</p>
</blockquote>
<ul>
<li>이후 URI 경로 설정</li>
</ul>
<blockquote>
<p>buildAndExpand(savedUser.getId())</p>
</blockquote>
<ul>
<li>위에서 결정한 파라미터 값 생성</li>
</ul>
<blockquote>
<p>toUri()</p>
</blockquote>
<ul>
<li>URI로 변경</li>
</ul>
<p>이렇게 URI를 만들 수 있다.</p>
<p>여기서 URI를 생성해서 반환하는 이유는</p>
<p>하나의 데이터를 생성한 후 데이터의 상세정보를 볼 때 id값을 다시한번 서버에 물어봐야 한다.</p>
<p>하지만 해당 URI를 반환시킨다면, 다시 물어보는 만큼의 네트워크 트래픽을 줄일 수 있기 때문에 사용하는 것이다.</p>
<hr>
<h1 id="responseentity">ResponseEntity</h1>
<p>RestAPI에서 상태코드와 함께 반환할 때 ResponseEntity를 사용한다.</p>
<p>ResponseEntity에서는 URI를 반환할 때, 필요한 상태코드들을 메서드로 제공한다.</p>
<p>created(201) 뿐만 아니라, accepted(202), noContent(204), badRequest(400), internalServerError(500), notFound(404) 등 자주 쓰이는 상태코드를 가독성 좋은 메소드로 제공한다.</p>
<p>따라서 반환 값을</p>
<pre><code class="language-java">return ResponseEntity.created(location).build();</code></pre>
<p>로 만든 것이다.</p>
<p><img src="https://velog.velcdn.com/images/yoon_bly/post/25fa3dc4-87ad-4595-b2b2-cb0c71f9f714/image.png" alt=""></p>
<p>이렇게 상태코드를 200번의 상태코드만이 아닌 다양한 상태코드를 사용하여 상태를 반환할 수 있다.</p>
<hr>
<p>참고
<a href="https://velog.io/@jakeseo_me/Spring-Boot%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-RESTful-Web-Services-%EA%B0%9C%EB%B0%9C-14-HTTP-Status-Code-%EC%A0%9C%EC%96%B4">https://velog.io/@jakeseo_me/Spring-Boot%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-RESTful-Web-Services-%EA%B0%9C%EB%B0%9C-14-HTTP-Status-Code-%EC%A0%9C%EC%96%B4</a>
<a href="https://blog.naver.com/PostView.naver?blogId=aservmz&amp;logNo=222322019981">https://blog.naver.com/PostView.naver?blogId=aservmz&amp;logNo=222322019981</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Docker]]></title>
            <link>https://velog.io/@yoon_bly/Docker</link>
            <guid>https://velog.io/@yoon_bly/Docker</guid>
            <pubDate>Tue, 20 Jun 2023 14:50:23 GMT</pubDate>
            <description><![CDATA[<p>도커란,  컨테이너 기술을 사용하여 애플리케이션에 필요한 환경을 신속하게 구축하고 테스트 및 배포를 할 수 있게 해주는 일종의 가상화 플랫폼이다.</p>
<p>가상화란 물리적 자원인 하드웨어를 효율적으로 활용하기 위해서 하드웨어 공간 위에 가상의 머신을 만드는 기술이고, 컨테이너란 컨테이너가 실행되고 있는 호스트 os의 기능을 그대로 사용하면서 프로세스를 격리해 독립된 환경을 만드는 기술을 뜻한다.</p>
<p>사실, 도커가 세상에 나오기 전 컨테이너 기술은 이미 있었다.</p>
<hr>
<p><strong>가상머신과 도커 비교</strong>
<img src="https://velog.velcdn.com/images/yoon_bly/post/423688e9-a9cb-42d6-b553-76f4a25a95fa/image.png" alt=""></p>
<hr>
<h1 id="가상머신virtual-machines">가상머신(Virtual Machines)</h1>
<p>기존의 가상화 기술인 가상머신(Virtual Machines)은 하이퍼바이저(Hypervisor)를 이용해 여러개의 운영체제를 하나의 호스트에서 생성해서 사용하는 방식이었다.</p>
<p>하이퍼바이저는 호스트 하드웨어에 설치되어 호스트와 게스트를 나누는 역할을 하고, 각각의 게스트는 하이퍼바이저에 의해 관리되며 시스템 자원을 할당받게 된다.</p>
<p>이 때 하이퍼바이저에 의해 생성된 게스트는 호스트나 다른 게스트와 상호 간섭하지 않고 완전히 분리된 환경에서 구동된다.</p>
<p>하이퍼바이저를 활용하면 마치 하드웨어가 여러 개인 것처럼 하나의 서버를 여러 명이 나눠 쓸 수도 있고, 컴퓨터 한 대에서 서로 다른 OS를 동시에 사용할 수도 있다.</p>
<p>이러한 가상화 방식을 사용하는 툴은 VirtualBox, VMware 등이 있다.</p>
<p>하지만, 이러한 가상화 방식은 기존OS 위에 OS를 실행하는 것이므로 리소스(CPU, 메모리 등)을 할당하는데 작업이 필요하고, 게스트 운영체제를 사용하기 위한 라이브러리, 커널 등을 전부 포함하기 때문에 가상 머신을 배포하기 위한 이미지로 만들었을 때 이미지의 크기 또한 커진다.</p>
<p>즉, 가상 머신은 완벽한 운영체제를 생성할 수 있다는 장점은 있지만 일반 호스트에 비해 성능 손실이 있으며, 수 기가바이트에 달하는 가상 머신 이미지를 애플리케이션으로 배포하기는 부담스럽다는 단점이 있다.</p>
<p><strong>정리</strong></p>
<ul>
<li><p>가상머신은 Hypervisor를 통해 여러개의 운영체제를 생성되고 관리됨. (Guest OS)</p>
</li>
<li><p>시스템 자원을 가상화하고 독립된 공간을 생성하는 작업은 HyperVisor를 거치므로 -&gt; 성능 손실이 큼 ↑</p>
</li>
<li><p>가상머신은 Guest OS를 사용하기 위한 라이브러리, 커널 등을 포함하므로 -&gt; 배포할 때 용량이 큼 ↑</p>
</li>
</ul>
<p>이러한 기술을 대체한 것이 바로 도커이다.</p>
<hr>
<h1 id="msamicro-seivice-architecture">MSA(Micro Seivice Architecture)</h1>
<p>사실 요즘 MSA(Micro Service Architecture)가 대세로 떠오르면서 MSA의 단점인 관리의 복잡성을 해결해주기 위해 서비스의 컨테이너화가 이루어지고, 이 과정에서 Docker와 Kubernetes가 많이 도입되었다.</p>
<p>MSA(Micro Service Architectur)란 간단하게 설명하자면, 서비스간의 의존성을 없애고 기능을 쪼개는 것을 중점적으로 설계한 아키텍처이다.</p>
<p>하나의 통합된 프로그램이 아닌 비슷한 기능을 가진 것들을 잘게 쪼개서 만든 것이다.</p>
<p>이런 방식의 아키텍처는 새로운 서비스 개발 시(기능 업데이트) 필요한 기능 부분만 수정하여 비교적 간편하게 작업을 경량화 시킬 수 있다.</p>
<p>전체적인 그림에서 서비스간 결합도(Coupling)를 줄이고 응집도(Cohesion)를 높이는 효과를 볼 수 있다.</p>
<p>MSA에 이런 장점들이 있는 반면에, 서비스들을 관리하기 복잡하다는 단점이 있다. </p>
<p>도커를 사용하면 이런 문제를 쉽게 해결할 수 있다.</p>
<hr>
<h1 id="도커docker">도커(Docker)</h1>
<p>도커 컨테이너는 가상화된 공간을 생성하기 위해 리눅스 자체 기능인 chroot, 네임스페이스(namespace), cgroup을 사용함으로써 프로세스 단위의 격리 환경을 만들기 때문에 성능 손실이 거의 없다.</p>
<p>컨테이너에 필요한 커널을 공유해서 사용하고, 컨테이너 안에는 어플리케이션을 구동하는 데 필요한 라이브러리 및 실행 파일만 존재하기 때문에 컨테이너를 이미지로 만들었을 때 이미지의 용량 또한 가상 머신에 비해 대폭 줄어든다.</p>
<p>따라서 컨테이너를 이미지로 만들어 배포하는 시간이 가상 머신에 비해 빠르며, 가상화된 공간을 사용할 때의 성능 손실도 거의 없다는 장점이 있다.</p>
<p><img src="https://velog.velcdn.com/images/yoon_bly/post/330d4375-882d-4a9f-8dc9-1a896260e17f/image.png" alt=""></p>
<ul>
<li><p>도커 컨테이너는 가상화된 공간을 생성할 때 리눅스 자체 기능을 사용하여 프로세스 단위의 격리 환경을 만드므로 -&gt; 성능 손실 없음 無</p>
</li>
<li><p>가상머신과 달리 커널을 공유해서 사용하므로, 컨테이너에는 라이브러리 및 실행파일만 있으므로 -&gt; 용량이 작음 ↓</p>
</li>
<li><p>위의 이유로 -&gt; 컨테이너를 이미지로 만들었을 때</p>
<p>배포하는 시간이 가상 머신에 비해 빠르며 ↑, 사용할 때의 성능 손실 또한 거의 없음 ↓</p>
</li>
</ul>
<hr>
<h1 id="도커를-사용하는-이유">도커를 사용하는 이유</h1>
<p>가상머신에 비해 월등히 뛰어나다, 도커는 단점이 없다. 이런 뜻이 아니다. 때에 따라 경우에 따라 사용하는 환경이 다를 뿐이다.</p>
<p>어떤 때에 도커를 사용하는 것일까?</p>
<p>가장 첫번째로 위에 말했듯이 MSA환경을 구축할 때 도커를 주로 이용한다.</p>
<p>도커를 사용하게 되면 IaC(Infrastructure as Code)가 가능 하다.</p>
<p>Docker의 진짜 큰 힘은 인프라를 코드화해서 관리할 수 있다는 점이다.</p>
<p>Dockerfile이나 docker-compose를 만져보면 이전에는 직접 시스템을 세팅해줘야 하는 부분들을 코드로 편리하게 관리할 수 있는 것을 볼 수 있다.</p>
<p>두번째로 도커는 테스트, 스테이징, 배포 등 여러 용도로도 사용 가능하다.</p>
<p>테스트 환경, 스테이징 환경, 배포 환경등이 개발환경과 다를 수 있다.
<img src="https://velog.velcdn.com/images/yoon_bly/post/30c0ead9-d0a2-4561-a7a4-fe154d2c03b0/image.png" alt=""></p>
<p>각각의 환경들을 하나의 레거시 환경에서 돌리게 된다면, 수많은 오류를 마주할 수 있다.</p>
<p>결국 도커를 사용하여 여러 환경에 맞추어 애플리케이션을 컨테이너화 시키면, 개발과 배포의 흐름이 매우 간단해져서 빠르게 진행될 수 있다. </p>
<p>서버의 운영체제나 미들웨어의 의존성에 대해 걱정할 필요없이 Docker만 설치되어 있다면 모든 것이 해결된다.</p>
<p>또한 배포 후, 애플리케이션 사용자 수가 늘어나면 해당 인프라를 확장할 시 scale-out에 매우 큰 도움이 될 수 있다.</p>
<p>결국 우리는 이러한 개발환경에 익숙해져야 하고 잘 쓸줄 알아야 한다.</p>
<p>곧 개발단계가 끝나가는 프로젝트가 있는데, 도커와 aws를 통해 배포해볼 예정이다. </p>
<p>해당 프로젝트를 배포하기전에 docker와 aws에 대해 자세히 알아볼 예정이다.</p>
<hr>
<p>References
<a href="https://myjamong.tistory.com/297">https://myjamong.tistory.com/297</a>
<a href="https://velog.io/@markany/%EB%8F%84%EC%BB%A4%EC%97%90-%EB%8C%80%ED%95%9C-%EC%96%B4%EB%96%A4-%EA%B2%83-1.-%EB%8F%84%EC%BB%A4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80">https://velog.io/@markany/%EB%8F%84%EC%BB%A4%EC%97%90-%EB%8C%80%ED%95%9C-%EC%96%B4%EB%96%A4-%EA%B2%83-1.-%EB%8F%84%EC%BB%A4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80</a>
<a href="https://seosh817.tistory.com/345#Virtual%20Machine(%EA%B0%80%EC%83%81%EB%A8%B8%EC%8B%A0)%20vs%20Docker%20Container(%EB%8F%84%EC%BB%A4%20%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88)-1">https://seosh817.tistory.com/345#Virtual%20Machine(%EA%B0%80%EC%83%81%EB%A8%B8%EC%8B%A0)%20vs%20Docker%20Container(%EB%8F%84%EC%BB%A4%20%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88)-1</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA 연관관계 매핑 2]]></title>
            <link>https://velog.io/@yoon_bly/JPA-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EB%A7%A4%ED%95%91-2</link>
            <guid>https://velog.io/@yoon_bly/JPA-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EB%A7%A4%ED%95%91-2</guid>
            <pubDate>Tue, 20 Jun 2023 03:40:01 GMT</pubDate>
            <description><![CDATA[<h1 id="일대다-단방향-연관관계">일대다 단방향 연관관계</h1>
<p>일대다 관계는 위에서 봤던 @OneToMany다. 하지만 만약 @OneToMany로 단방향 관계를 맺는다면 어떻게 될까?</p>
<p>다시 말해 @OneToMany 어노테이션이 있는 필드에 @JoinColumn을 아래 코드처럼 건다면 어떻게 될까?</p>
<pre><code class="language-java">@Entity
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String teamName;
    @OneToMany
    @JoinColumn(name = &quot;TEAM_ID&quot;)
    private List&lt;Member&gt; members = new ArrayList&lt;&gt;();
}</code></pre>
<p>해당 엔티티는 일대다 단방향 연관관계 매핑을 한 것이다.</p>
<p>하지만, 해당 외래키는 다쪽인 Member에 생긴다.</p>
<p>애당초 @OneToMany 어노테이션은 외래키 생성을 할 수 없다.</p>
<p>그래서 일대다 단방향 연관관계는 권장되지 않는다.</p>
<p>왜냐하면 연관관계 주인 엔티티에서 외래키를 관리하지않고 반대편 엔티티에서 외래키를 관리하기 때문에(데이터베이스 설계에서 1:M관계에서 외래키는 M에 존재하기 때문에) 관리가 부담스럽다.</p>
<hr>
<h1 id="일대다-양방향-연관관계">일대다 양방향 연관관계</h1>
<p>일대다 양방향 연관관계는 애초에 다대일 양방향 연관관계와 같기 때문에 존재하지 않는다.</p>
<hr>
<h1 id="일대일-단방향-연관관계">일대일 단방향 연관관계</h1>
<p>일대일 관계는 양쪽이 서로 하나의 관계만 가지는 관계이다.</p>
<p>일대일 관계에서는 외래키가 어디에 있든 상관이 없다.</p>
<p>Member 엔티티와 Locker 엔티티가 있고, 1:1 관계라 하자.</p>
<p>외래키는 Member 또는 Locker 엔티티에 존재할 수 있다.</p>
<p>연관관계 주인을 Member로 한 1:1 단방향 관계 코드를 보자.</p>
<pre><code class="language-java">@Entity
public class Locker {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
}

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String memberName;

    @OneToOne
    @JoinColumn(name = &quot;LOCKER_ID&quot;)
    private Locker locker;
}</code></pre>
<p>일대일 단방향 관계도 다대일 단방향과 비슷하다</p>
<p>Locker를 단방향 관계 주인으로 하고 싶으면 @OneToOne을 Locker에 붙여주면 된다.</p>
<p>단지 일대일 단방향 관계는 어디든 주인이 될 수 있다는 점이 특징이다.</p>
<p>반대로 Locker가 연관관계의 주인이 될 수 있다.</p>
<pre><code class="language-java">@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String memberName;
}

@Entity
public class Locker {
    @Id
    @GeneratedValue
    private Long id;
    private String lockerName;

    @OneToOne
    @JoinColumn(name = &quot;MEMBER_ID&quot;)
    private Member member;
}</code></pre>
<hr>
<h1 id="일대일-양방향-연관관계">일대일 양방향 연관관계</h1>
<p>일대일 양방향 관계도 다대일 양방향 관계랑 비슷하다. 바로 코드를 보자.</p>
<pre><code class="language-java">@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String memberName;

    @OneToOne(mappedBy = &quot;member&quot;)
    private Locker locker;
}

@Entity
public class Locker {
    @Id
    @GeneratedValue
    private Long id;
    private String lockerName;

    @OneToOne
    @JoinColumn(name = &quot;MEMBER_ID&quot;)
    private Member member;
}</code></pre>
<p>만약 Member 객체가 연관관계의 주인으로 설정하고 싶다면 mappedBy 속성을 변경해주면 된다.</p>
<hr>
<h1 id="다대일-단방향-연관관계">다대일 단방향 연관관계</h1>
<p>RDBMS에서는 다대다 관계를 2개의 테이블을 이용해 표현이 불가능하다.</p>
<p>그래서 중간에 관계 테이블을 두어 일대다, 다대일 관계로 표현한다.</p>
<p><img src="https://velog.velcdn.com/images/yoon_bly/post/2aaf1e9b-4704-45ed-ae44-0c14789cc172/image.png" alt=""></p>
<p>하지만 객체는 테이블과 다르게 객체 2개로 다대다 연관관계를 만들 수 있다.</p>
<pre><code class="language-java">@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String userName;

    @ManyToMany
    @JoinTable(name = &quot;MEMBER_PRODUCT&quot;,
    joinColumns = @JoinColumn(name = &quot;MEMBER_ID&quot;),
    inverseJoinColumns = @JoinColumn(name = &quot;PRODUCT_ID&quot;))
    private List&lt;Product&gt; product = new ArrayList&lt;&gt;();
}

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String productName;
}</code></pre>
<blockquote>
<p>@ManyToMany</p>
</blockquote>
<ul>
<li>다대다 매핑을 위한 어노테이션</li>
</ul>
<blockquote>
<p>@JoinTable</p>
</blockquote>
<ul>
<li>연결 테이블을 매핑하기 위한 어노테이션</li>
</ul>
<p>&lt;@JoinColumn 속성&gt;</p>
<ul>
<li>@JoinTable.name: 연결 테이블을 지정한다.</li>
<li>@JoinTable.joinColumns: 현재 방향인 회원과 매핑할 조인 컬럼 정보 지정</li>
<li>@JoinTable.inverseJoinColumns: 반대방향인 Product와 매핑할 조인 컬럼 정보 지정</li>
</ul>
<p>MEMBER_PRODUCT 테이블은 다대다 관계를 일대다, 다대일 관계로 풀어내기 위해 필요한 연결 테이블일 뿐이기 때문에, 다대다 관계를 사용할 때는 이 연결 테이블을 신경쓰지 않아도 된다.</p>
<hr>
<h1 id="다대다-양방향-연관관계">다대다 양방향 연관관계</h1>
<p>위의 매핑들 처럼 반대 객체에 @ManyToMany(mappedBy = &quot;&quot;)을 사용해 양방향 연관관계를 시킨다.</p>
<pre><code class="language-java">@Entity
public class Product {
    @Id
    @GeneratedValue
    private Long id;
    private String productName;
    @ManyToMany(mappedBy = &quot;product&quot;)
    private List&lt;Member&gt; members = new ArrayList&lt;&gt;();
}

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String memberName;

    @ManyToMany
    @JoinTable(name = &quot;MEMBER_PRODUCT&quot;,
    joinColumns = @JoinColumn(name = &quot;MEMBER_ID&quot;),
    inverseJoinColumns = @JoinColumn(name = &quot;PRODUCT_ID&quot;))
    private List&lt;Product&gt; product = new ArrayList&lt;&gt;();
}</code></pre>
<p>역시나 mappedBy 속성이 없는 부분이 연관관계의 주인이다.</p>
<p>양방향으로 연관관계를 매핑했으니 객체 그래프 탐색을 이용해 product.getMembers()를 사용해 역방향으로도 조회할 수 있다.(단방향 관계시 member.getProducts()만 사용해 객체 그래프 탐색 가능)</p>
<hr>
<h1 id="다대다-한계">다대다: 한계</h1>
<p>위의 예제처럼 다대다 연관관계를 사용하면 좋겠지만, 실무에서는 다대다 연관관계를 사용하기에는 한계가 있다.</p>
<p>만약 회원이 상품을 주문할 때 주문 수량이나, 날짜같은 컬럼이 추가가 된다.</p>
<p><img src="https://velog.velcdn.com/images/yoon_bly/post/55c5e95d-59f3-4de5-b76b-4c0679c554d3/image.png" alt=""></p>
<p>이렇게 컬럼이 추가 되면 더이상 @ManyToMany를 사용할 수 없다.</p>
<p>결국 연결 테이블과의 일대다, 다대일 연관관계를 이용해 매핑해야 한다.</p>
<pre><code class="language-java">@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String memberName;

    @OneToMany(mappedBy = &quot;member&quot;)
    private List&lt;MemberProduct&gt; memberProduct = new ArrayList&lt;&gt;();
}

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String productName;
}</code></pre>
<p>Member 객체와 MemberProduct 객체와는 양방향 관계지만, Product 객체와 MemberProduct는 단방향 관계이다.</p>
<p>상품 엔티티에서 회원상품 엔티티로 객체 그래프 탐색 기능이 필요하지 않다고 판단되었기 때문에 단방향으로 연관관계를 맺었다.</p>
<p>가장 중요한 회원상품 엔티티를 보자.</p>
<h2 id="복합-기본키-사용">복합 기본키 사용</h2>
<p><strong>회원상품 엔티티</strong></p>
<pre><code class="language-java">@Entity
@IdClass(MemberProductId.class)
public class MemberProduct {
    @Id
    @ManyToOne
    @JoinColumn(name = &quot;MEMBER_ID&quot;)
    private Member member;

    @Id
    @ManyToOne
    @JoinColumn(name = &quot;PRODUCT_ID&quot;)
    private Product product;

    private String orderAmount;
}</code></pre>
<p><strong>회원상품 식별자 클래스</strong></p>
<pre><code class="language-java">public class MemberProductId implements Serializable {
    private Long member;
    private Long product;

    @Override
    public int hashCode() {
        return super.hashCode();
    }

    @Override
    public boolean equals(Object obj) {
        return super.equals(obj);
    }
}</code></pre>
<p>회원상품 엔티티를 보면 기본키를 매핑하는 @Id와 외래키를 매핑하는 @JoinColumn을 동시에 사용해 기본 키 + 외래 키를 한번에 매핑했다.</p>
<p>그리고 @IdClass 어노테이션을 사용해 복합 기본 키를 매핑했다.</p>
<p>&lt;복합 기본키&gt;</p>
<ul>
<li>복합 기본키를 사용하려면 별도의 식별자 클래스가 필요하다.</li>
<li>Serializabel을 구현해야 한다.</li>
<li>equals와 hashCode 메소드를 구현해야 한다.</li>
<li>기본 생성자가 있어야 한다.</li>
<li>식별자 클래스는 public 이어야 한다.</li>
<li>@IdClass를 사용하는 방법 외에 @EmbeddedId를 사용하는 방법도 있다.</li>
</ul>
<p>이렇게 회원과 상품의 기본 키를 받아 자신의 기본키로 사용하고 외래 키로도 사용하는 것을 식별 관계라고 한다.</p>
<p>하지만 복합 기본 키를 사용하는 방법은 복잡하기 때문에 새로운 키를 사용해 복합 기본 키를 생성하지 않고 매핑하는 것이다.</p>
<p>마치 연결테이블이 새로운 테이블을 만드는 것처럼 사용하는 것이다.</p>
<h2 id="새로운-기본-키-사용">새로운 기본 키 사용</h2>
<p>DB에서 자도으로 생성해주는 대리 키를 Long 값으로 사용헤 기본키를 설정 하는 것이다.</p>
<p>해당 방법은 간편하고 거의 영구히 쓸 수 있으며 비즈니스에 의존하지 않는다.</p>
<p>또한 ORM매핑 시 복합키를 만들지 않아도 되므로 간단히 매핑을 완성 할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/yoon_bly/post/de867199-8322-4780-ac90-ffea00b9ad07/image.png" alt=""></p>
<p>새로운 ORDER_ID라는 기본키를 만들어 다시 매핑 해보자.</p>
<pre><code class="language-java">@Entity
public class Order {

    @Id
    @GeneratedValue
    @Column(name = &quot;ORDER_ID&quot;)
    private Long id;

    @ManyToOne
    @JoinColumn(name = &quot;MEMBER_ID&quot;)
    private Member member;

    @ManyToOne
    @JoinColumn(name = &quot;PRODUCT_ID&quot;)
    private Product product;

    private int orderAmount;
}</code></pre>
<hr>
<h1 id="개인적인-나의-의견">개인적인 나의 의견</h1>
<p>가장 처음 JPA 공부를 할 때, 궁금한 점이 있었다.</p>
<p>만약 회원(1)과 주문(N)간의 매핑을 할 때, 회원의 주문 목록을 들여오기 위해서는 양방향 연관관계를 써야 member.getOrders()로 객체 그래프 탐색을 이용해 주문 목록을 받아 올 수 있다고 생각을 했다.</p>
<p>하지만, 실무에서는 다대일 단방향 연관관계를 사용해도 해당 회원의 주문 목록을 가져올 수 있다는 것이다.</p>
<p>나는 회원의 주문 목록을 가져오기 위해서는 테이블에서 뿐만 아니라, 객체에서도 객체 그래프 탐색을 이용하여 받아와야지만 가능한 줄 알았다.</p>
<p>하지만, 객체 그래프 탐색을 이용하지 않고, 테이블의 양방향 연관관계로 회원Id를 이용해 가져올 수 있다고 알게 되었다.</p>
<p>처음에 나는 객체도 양방향으로 연관관계를 매핑하지 않으면 객체지향의 특징을 잃어 버리는 것인 줄 알았다.</p>
<p>구글링 해본 결과, 양방향으로 매핑하는 이유는 따로 있었다.</p>
<h2 id="양방향-매핑이-필요한-이유">양방향 매핑이 필요한 이유</h2>
<p>&lt;양방향 매핑이 필요한 이유&gt;</p>
<ul>
<li><p>양방향 매핑이 필요한 경우는 단방향 매핑된 관계를 역방향으로 조회할 때다.</p>
</li>
<li><p>데이터에 대한 일관성을 유지할 때 사용된다.</p>
</li>
<li><p>개발을 하다보면 단방향으로 매핑된 연관 관계를 역순으로 조회해야 하는 경우가 발생한다.</p>
</li>
<li><p>이처럼 필요한 경우에 한하여, 양방향 매핑을 추가적으로 구현하는 것을 권장한다.</p>
</li>
</ul>
<p>결국 객체에서의 연관관계를 역순으로 조회할 때 양방향 매핑이 필요한 것이었다.</p>
<p>그게 아니면 단방향으로도 사용 가능하다는 것을 알게 되었다.</p>
<p>그래서 처음 설계시 대부분 단방향으로 사용하고 양방향으로 구현하지 않는 것이 좋다.</p>
<p>이렇게 하나 또 알게 되면서 지식이 늘어나는 것이 행복하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA 연관관계 매핑 1]]></title>
            <link>https://velog.io/@yoon_bly/JPA-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EB%A7%A4%ED%95%91</link>
            <guid>https://velog.io/@yoon_bly/JPA-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EB%A7%A4%ED%95%91</guid>
            <pubDate>Sat, 17 Jun 2023 12:30:30 GMT</pubDate>
            <description><![CDATA[<p>가장 먼저 JPA를 쓰는 이유부터 알아야 한다.</p>
<p>현재 우리는 SQL Mapper나 ORM 기술들을 이용하여, DB와 연결하여 사용하고 있다.</p>
<p>이중 JPA는 자바 진영의 ORM 기술 표준으로, 과거 JDBC API를 사용해 매핑을 해주던 것을 보다 간결하게 만들어주는 프레임워크이다.</p>
<p>뭐 이전보다 SQL문을 덜 쓴다는 것에 대해 장점을 가져서 JPA를 사용하는 것도 있지만, 가장 큰 문제는 따로있다.</p>
<p>바로 <strong>객체와 DB와의 패러다임의 불일치 문제를 해결하기 위해</strong> 사용하는 것이다.</p>
<hr>
<h1 id="객체와-db와의-패러다임-불일치">객체와 DB와의 패러다임 불일치</h1>
<p>자바같은 객체 지향 언어는 추상화, 캡슐화, 정보은닉, 상속, 다형성 등 다양한 장치들을 제공한다.</p>
<p>특히 객체들 간에는 참조를 통해 다른 객체와 연관관계를 가지고, 참조에 접근해 연관된 객체를 조회한다.</p>
<p>반면, 테이블은 외래키(foreign key)를 이용해 다른 테이블과 연관관계를 가지고 조인을 사용해 연관된 테이블을 조회한다.</p>
<p>객체들은 참조가 있는 방향으로만 조회할 수 있다. 하지만 DB의 외래키들은 양방향으로 조회가 가능하다.</p>
<p>결국 우리는 FK, 외래키를 이용한 테이블에 맞춘 객체 모델을 사용해서, DB 테이블을 모델링했다.</p>
<pre><code class="language-java">class Member{

    String id;        // MEMBER_ID 컬럼 사용
    Long teamId;    // TEAM_ID FK 컬럼 사용
    String username;
}

class Team{

    Long id;        // TEAM_ID PK 사용
    String name;
}</code></pre>
<p>이렇게 객체를 테이블에 맞추어 모델링하면 객체를 테이블에 저장하거나 조회할 때는 편하지만, 객체에서는 연관된 객체의 참조를 보관해야 참조를 통해 연관된 객체를 찾을 수 있다.</p>
<p>이렇게 되면 좋은 객체 모델링은 기대하기 어렵고 결국 객체지향의 특징을 잃어버리게 된다.</p>
<p>결국 객체지향의 특징을 잃지 않고 테이블을 모델링 하기위해 JPA라는 것이 필요한 것이다.</p>
<p><strong>참조를 사용하는 객체 모델</strong></p>
<pre><code class="language-java">class Member{

    String id;        // MEMBER_ID 컬럼 사용
    Team team;        // 참조로 연관관계를 맺는다.
    String username;

    Team getTeam() {
        return team;
    }
}

class Team{

    Long id;        // TEAM_ID PK 사용
    String name;
}</code></pre>
<p>이처럼 참조를 사용하는 객체모델을 사용하면 객체를 테이블에 저장하거나 조회하기 쉽지 않다.</p>
<p>Member 객체는 team 필드로 연관관계를 맺고 Member 테이블은 TEAM_ID로 연관관계를 맺기 때문인데, 객체 모델은 외래키가 필요 없고 단지 참조만 있으면 된다.</p>
<p>반면에 테이블은 참조가 필요없고 외래키만 있으면 된다.</p>
<p>결국, 우리는 JPA를 이용해서 변환역활을 해야 한다.</p>
<hr>
<h1 id="연관관계-매핑">연관관계 매핑</h1>
<p>객체는 참조를 사용해서 관계를 맺고, 테이블은 외래키를 사용해 관계를 맺는다.</p>
<p>이러한 연관관계들을 매핑하는 것이 JPA의 역할이다.</p>
<hr>
<h2 id="키워드">키워드</h2>
<p>연관관계의 매핑을 이해하기 위해 세가지의 키워드가 있다.</p>
<p><strong>방향</strong></p>
<ul>
<li>[단방향, 양방향]</li>
<li>회원과 팀이 관계가 있을 때, 회원 -&gt; 팀, 팀 -&gt; 회원 둘중 한 쪽만 참조하는 것을 단방향관계, 양쪽 모두 서로 참조하는 것을 양방향 관계라 한다.</li>
<li>방향은 객체관계에만 존재, 테이블 관계는 항상 양방향이다.</li>
</ul>
<p><strong>다중성</strong></p>
<ul>
<li>[다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)]</li>
<li>회원과 팀이 관계가 있을 때, 여러 회원은 한팀에 속한다. 따라서 회원과 팀은 다대일 관계다.</li>
<li>반대로 팀과 회원은 일대다 관계이다.</li>
</ul>
<p><strong>연관관계의 주인</strong></p>
<ul>
<li>객체를 양방향 연관관계로 만들면 연관관계의 주인을 정해야 한다.</li>
</ul>
<hr>
<h2 id="단방향-양방향-연관관계">단방향, 양방향 연관관계</h2>
<h3 id="단방향">단방향</h3>
<p>이 전에 말했듯, 객체는 단방향으로 관계를 맺고, 테이블은 양방향으로 관계를 맺는다.</p>
<p>테이블은 항상 양방향으로 관계를 맺기 때문에, JPA에서 나오는 연관관계의 방향을 객체들의 방향을 의미한다.</p>
<p>예를 들어 Member와 Team 객체가 있다고 가정하자.</p>
<ul>
<li>회원은 하나의 팀에만 소속될 수 있다.</li>
<li>회원과 팀은 다대일 관계이다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/yoon_bly/post/781e4cb7-9a61-447b-8909-1cf3d45d1953/image.png" alt=""></p>
<ul>
<li>객체 연관관계<ul>
<li>회원 객체는 Member.team 필드로 팀객체와 연관관계를 맺는다.</li>
<li>따라서 현재 Member와 team 객체는 단방향 관계이다.</li>
<li>단방향 관계이므로, member.getTeam()으로 조회가 가능하지만, team.getMembers()는 불가능하다.</li>
</ul>
</li>
</ul>
<ul>
<li>테이블 연관관계<ul>
<li>회원 테이블은 TEAM_ID 외래 키로 Team 테이블과 연관관계를 맺는다.</li>
<li>테이블은 항상 양방향 관계이다. 따라서 Member Join Team과 Team Join Member 둘다 가능하다.</li>
</ul>
</li>
</ul>
<p>따라서 객체 그래프 탐색으로 Member 에서 Team을 조회할 수 있지만, Team에서는 Member를 조회할 수 없다.</p>
<h3 id="양방향">양방향</h3>
<p>그렇다면 객체 연관관계도 양방향으로 만들면 되는 것이 아닐까라는 생각을 할 것이다.</p>
<p>테이블도 외래키로의 연관관계를 이용해 양방향으로 만드니 객체에서도 양방향으로 만들면 되는거 아니야? 싶을 것이다. </p>
<p>가능하다.</p>
<p><img src="https://velog.velcdn.com/images/yoon_bly/post/170965a9-d988-4038-87c9-97fa5e4182d6/image.png" alt=""></p>
<p>팀에서 회원을 조회할 수 있게 양방향 연관관계로 만들 수 있다.</p>
<p>테이블은 양방향 연관관계이다. 하지만 객체에서의 연관관계는 양방향처럼 보일지라도, 결국 단방향인 것이 2개인 것이다.</p>
<p>엄밀히 말하면 객체에는 양방향 연관관계라는 것이 없다.</p>
<p>이렇게 단방향과 양방향의 차이를 알아보았다.</p>
<p>이제는 다중성과 방향을 동시에 알아보자.</p>
<hr>
<h1 id="다대일-단방향-연관관계">다대일 단방향 연관관계</h1>
<p>가장 먼저 코드를 통해 확인해보자</p>
<pre><code class="language-java">@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    private String MemberName;
    @ManyToOne
    @JoinColumn(name=&quot;TEAM_ID&quot;)
    private Team team;
}

@Entity
public class Team {
    @Id
    @GeneratedValue
    private Long id;
    private String teamName;
}</code></pre>
<ul>
<li>객체 연관관계: 회원 객체의 Member.Team 필드 사용</li>
<li>테이블 연관관계: 회원 테이블의 MEMBER.TEAM_ID 외래키 컬럼을 사용</li>
</ul>
<p>단방향 연관관계에서의 객체와 테이블이 사용할 수 있는 연관관계를 나타내었다.</p>
<p>객체 연관관계는 단방향으로 되어있기 때문에 Team.Member 필드를 사용할 수 없다.</p>
<p>반면에 테이블은 외래키를 이용해 양방향으로 조회를 할 수 있다.</p>
<blockquote>
<p>@ManyToOne</p>
</blockquote>
<ul>
<li>다대일 연관관계를 확인 시키는 어노테이션</li>
<li>현재 Member 객체가 다 쪽이기 때문에 다대일 연관관계 어노테이션을 사용한 것이다.</li>
</ul>
<blockquote>
<p>@JoinColumn(name=&quot;TEAM_ID&quot;)</p>
</blockquote>
<ul>
<li>해당 필드를 외래키로 설정하겠다는 어노테이션이다.</li>
</ul>
<h1 id="다대일-양방향-연관관계">다대일 양방향 연관관계</h1>
<p>양방향 연관관계 매핑 코드는 다음과 같다.</p>
<pre><code class="language-java">@Entity
public class Team {
    @Id
    @GeneratedValue
    private Long id;
    private String teamName;
    //양방향 매핑을 위해 추가
    @OneToMany(mappedBy = &quot;team&quot;)
    private List&lt;Member&gt; members = new ArrayList&lt;&gt;();
}

@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    private String MemberName;
    @ManyToOne
    @JoinColumn(name=&quot;TEAM_ID&quot;)
    private Team team;
}</code></pre>
<p>단방향 연관관계와 다르게 Team필드에 Member를 저장할 수 있는 필드가 생성된 것을 볼 수 있다.</p>
<p>해당 필드로 인해 Team에서 Member를 객체 그래프 탐색을 통해 조회할 수 있게 된다.</p>
<blockquote>
<p>@OneToMany(mappedBy = &quot;team&quot;)</p>
</blockquote>
<ul>
<li><p>객체에서의 양방향 관계는 단방향인 관계가 양쪽으로 있다고 설명했다. 따라서 다대일의 반대는 일대다 이므로 OneToMany어노테이션을 사용한다.</p>
</li>
<li><p>mappedBy 속성은 양방향 매핑일 때 사용된다.</p>
</li>
</ul>
<p>여기서 연관관계의 주인에 대해 나온다.</p>
<p>객체에는 양방향 관계는 없고 단방향인 연관관계가 두개 있다는 것인데, 그렇다면 외래키도 두개여야 할 것이라는 생각이 들 것이다.</p>
<p>하지만 테이블 특성상 외래키는 한부분에 있고, 한 곳에서 양방향 연관관계를 맺는다.</p>
<p>이 때문에 JPA는 두 객체 연관관계 중 하나를 정해서 테이블의 외래키를 관리 해야 한다.</p>
<p>이것을 연관관계의 주인이라고 한다.</p>
<h3 id="양방향-매핑의-규칙">양방향 매핑의 규칙</h3>
<p>연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래키를 관리(등록, 수정, 삭제)를 할 수 있다.반면에 주인이 아닌 쪽은 읽기만 할 수 있다.</p>
<p>그래서 연관관계의 주인은 내맘대로 설정하는 것인가? </p>
<p>그것도 아니다. 대부분 외래키가 있는 곳이 연관관계의 주인이 된다.</p>
<p>왜냐하면, 그것이  더 관리하기 편하기 때문이다. </p>
]]></description>
        </item>
    </channel>
</rss>