<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>rxg_1211.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Wed, 20 May 2026 12:26:16 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. rxg_1211.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/rxg_1211" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Day97]]></title>
            <link>https://velog.io/@rxg_1211/Day97</link>
            <guid>https://velog.io/@rxg_1211/Day97</guid>
            <pubDate>Wed, 20 May 2026 12:26:16 GMT</pubDate>
            <description><![CDATA[<h1 id="character--share-backend-todo">Character / Share Backend TODO</h1>
<blockquote>
<p>기준일: 2026-05-20<br>범위: 캐릭터 도메인, 공유 도메인, 관련 user/item 도메인 연동 지점</p>
</blockquote>
<hr>
<h2 id="1-현재-정리된-방향">1. 현재 정리된 방향</h2>
<h3 id="11-공유-카드-생성">1.1 공유 카드 생성</h3>
<p>현재 공유 카드 생성은 프론트엔드가 이미지를 먼저 생성하고 업로드한 뒤, 공개 이미지 URL을 백엔드에 전달하는 방식이다.</p>
<pre><code class="language-text">컴포넌트 렌더링
-&gt; 이미지 캡처
-&gt; GET /api/share/v1/presigned-url
-&gt; presignedUrl로 R2/S3 직접 PUT 업로드
-&gt; POST /api/share/v1/share-cards { characterId, imageUrl }
-&gt; POST /api/share/v1/share-events</code></pre>
<p><code>POST /api/share/v1/share-cards</code> 요청에는 현재 <code>headline</code>을 포함하지 않는다.</p>
<pre><code class="language-json">{
  &quot;characterId&quot;: 10,
  &quot;imageUrl&quot;: &quot;https://cdn.polaris.app/share-cards/UUID.png&quot;
}</code></pre>
<h3 id="12-공유-보상">1.2 공유 보상</h3>
<p>공유 보상은 API 명세 기준 하루 1회, <code>10</code> 별조각이다.</p>
<pre><code class="language-json">{
  &quot;rewardPaid&quot;: true,
  &quot;rewardStarPiece&quot;: 10
}</code></pre>
<h3 id="13-공유-headline">1.3 공유 headline</h3>
<p>현재 백엔드 구현은 사용자 입력 headline을 받지 않고, 서버 고정 문구를 내려준다.</p>
<pre><code class="language-java">private static final String PLACEHOLDER_HEADLINE = &quot;Today, I shone a little.&quot;;</code></pre>
<p>단기적으로는 서버 기본 문구를 유지할 수 있다. 다만 공유 카드가 반복 노출되는 기능이므로, 추후에는 headline 후보 문구를 테이블로 관리하고 공유 카드 생성 시 하나를 선택해 저장하는 구조가 적합하다.</p>
<hr>
<h2 id="2-p0-바로-해결해야-할-문제">2. P0: 바로 해결해야 할 문제</h2>
<h3 id="21-공유-보상-wallet-적립-연동">2.1 공유 보상 wallet 적립 연동</h3>
<p><strong>상태: 일부 수정완료</strong></p>
<p>현재 <code>ShareService#createShareEvent</code>는 <code>rewardPaid</code>와 <code>rewardStarPiece</code>를 계산하고 <code>share_logs</code>에 저장하지만, 실제 wallet 적립을 호출하지 않는다.</p>
<p>완료된 부분:</p>
<ul>
<li><code>user</code> 도메인 <code>WalletService#earnStarPiece</code> 메서드는 존재한다.</li>
<li><code>proto/src/main/proto/user/v1/wallet_service.proto</code>에 <code>EarnStarPiece</code> RPC가 추가되어 있다.</li>
<li><code>WalletGrpcController#earnStarPiece</code>가 구현되어 있다.</li>
</ul>
<p>남은 문제:</p>
<ul>
<li><code>character</code> 도메인 <code>ShareService</code>에 wallet 연동 TODO가 남아 있다.</li>
<li><code>ShareService#createShareEvent</code>에서 아직 user wallet gRPC를 호출하지 않는다.</li>
<li>공유 보상이 실제 wallet에 적립되지 않는다.</li>
<li><code>ShareEventResult.walletStarPiece</code>는 여전히 <code>0</code>을 반환한다.</li>
</ul>
<p>남은 작업:</p>
<ol>
<li>character/share 도메인에서 user wallet gRPC client 연결</li>
<li>공유 보상 지급 시 <code>EarnStarPiece</code> 호출</li>
<li><code>reason=SHARE_REWARD</code>, <code>refType=SHARE</code>, <code>refId=shareLogId 또는 shareCardId</code>, <code>idempotencyKey</code> 전달</li>
<li>응답의 <code>wallet.starPiece</code>에 최신 잔액 반영</li>
<li>wallet 적립 실패 시 <code>share_logs</code> 저장과 보상 지급 상태를 어떻게 맞출지 정책 결정</li>
</ol>
<h3 id="22-공유-보상-일일-1회-멱등성-강화">2.2 공유 보상 일일 1회 멱등성 강화</h3>
<p><strong>상태: 개선 필요</strong></p>
<p>현재 공유 이벤트는 클라이언트가 body로 전달한 <code>idempotencyKey</code>를 그대로 사용한다.</p>
<p>문제:</p>
<ul>
<li>클라이언트가 매번 다른 <code>idempotencyKey</code>를 보내면 같은 날 중복 요청을 완전히 막기 어렵다.</li>
<li><code>existsByUserIdAndShareDateAndRewardPaidTrue</code> 확인 후 저장하는 방식만으로는 race condition 방어가 부족하다.</li>
</ul>
<p>권장 방향:</p>
<ul>
<li>보상 지급용 멱등키는 서버가 생성한다.</li>
</ul>
<pre><code class="language-text">SHARE_REWARD:{userId}:{yyyy-MM-dd}</code></pre>
<ul>
<li>클라이언트 body의 <code>idempotencyKey</code>는 공유 이벤트 요청 재시도 식별용으로만 쓸지, 아예 서버 생성 키로 통일할지 정책을 정한다.</li>
<li>DB 레벨에서 일일 보상 중복 지급을 막는다.</li>
</ul>
<p>가능한 DB 방어:</p>
<pre><code class="language-sql">-- reward_paid=true인 공유 보상은 유저/날짜당 1개만 허용
CREATE UNIQUE INDEX uq_share_logs_daily_reward
ON share_logs (user_id, share_date)
WHERE reward_paid = TRUE;</code></pre>
<p>완료된 부분:</p>
<ul>
<li>보상 지급용 멱등키를 서버에서 생성하는 로직이 추가되었다.</li>
</ul>
<pre><code class="language-text">SHARE_REWARD:{userId}:{yyyy-MM-dd}</code></pre>
<ul>
<li><code>share_logs</code>에 일일 보상 중복 방지용 partial unique index가 추가되었다.</li>
</ul>
<pre><code class="language-sql">CREATE UNIQUE INDEX IF NOT EXISTS uq_share_logs_daily_reward_paid
ON share_logs (user_id, share_date)
WHERE reward_paid = TRUE;</code></pre>
<p>남은 개선점:</p>
<ul>
<li>동시 요청에서 unique index 충돌이 발생했을 때 예외를 잡아 기존 보상 로그를 조회하고 동일 응답으로 replay하는 처리가 필요하다.</li>
<li>클라이언트가 보낸 <code>idempotencyKey</code>를 보상용 키와 이벤트 기록용 키 중 어떤 의미로 유지할지 정책을 더 명확히 해야 한다.</li>
<li>wallet 적립 연동 후에는 wallet transaction의 <code>idempotencyKey</code>도 <code>SHARE_REWARD:{userId}:{yyyy-MM-dd}</code>와 일관되게 맞춰야 한다.</li>
</ul>
<h3 id="23-공유-이벤트-소유자-검증">2.3 공유 이벤트 소유자 검증</h3>
<p><strong>상태: 수정됨</strong></p>
<p>현재 <code>createShareEvent(userId, shareCardId, ...)</code>는 <code>shareCardId</code>로 카드를 조회하지만, 해당 카드가 요청 사용자 소유인지 확인하지 않는다.</p>
<p>필요 작업:</p>
<ul>
<li><code>ShareCard card</code> 조회 후 <code>card.getUserId().equals(userId)</code> 검증</li>
<li>불일치 시 <code>NOT_SHARE_CARD_OWNER</code> 또는 적절한 권한 에러 반환</li>
</ul>
<p>이 검증이 없으면 다른 사용자의 <code>shareCardId</code>로 내 공유 보상을 받을 수 있다.</p>
<h3 id="24-오늘-공유-보상-상태-조회-api-구현">2.4 오늘 공유 보상 상태 조회 API 구현</h3>
<p><strong>상태: 수정됨</strong></p>
<p>API 명세에는 아래 endpoint가 있다.</p>
<pre><code class="language-http">GET /api/share/v1/share-events/today</code></pre>
<p>하지만 현재 gateway <code>ShareController</code>에는 해당 endpoint가 없다.</p>
<p>필요 작업:</p>
<ul>
<li>character proto에 today status RPC 추가</li>
<li>character <code>ShareService</code>에 오늘 보상 수령 여부 조회 구현</li>
<li>gateway <code>ShareController</code>에 endpoint 추가</li>
</ul>
<p>응답 예시:</p>
<pre><code class="language-json">{
  &quot;rewardClaimed&quot;: true,
  &quot;lastSharedAt&quot;: &quot;2026-05-19T09:22:24.400Z&quot;
}</code></pre>
<h3 id="25-공유-링크-og-html-라우트-구현">2.5 공유 링크 OG HTML 라우트 구현</h3>
<p><strong>상태: 개선 필요</strong></p>
<p>현재 구현된 공개 공유 링크 API는 JSON이다.</p>
<pre><code class="language-http">GET /api/share/v1/share-links/{shareId}</code></pre>
<p>하지만 카카오톡, 디스코드, X 등 외부 서비스의 미리보기는 JS를 실행하지 않고 HTML <code>&lt;head&gt;</code>의 OG 태그를 읽는다.</p>
<p>필요 작업:</p>
<ul>
<li><code>/share/{shareId}</code> HTML 라우트 제공</li>
<li>서버에서 동적으로 OG 태그 렌더링</li>
<li>gateway 모듈에 공유 링크 전용 HTML 컨트롤러를 추가하는 방식을 우선 검토한다.</li>
</ul>
<p>현재 gateway는 <code>spring-boot-starter-web</code>만 사용하고 Thymeleaf 의존성이 없다. 따라서 MVP에서는 별도 템플릿 엔진을 추가하지 않고, <code>@Controller</code> 또는 <code>@RestController</code>에서 <code>text/html</code> 응답을 직접 반환하는 경량 구현이 적합하다.</p>
<p>예상 흐름:</p>
<pre><code class="language-text">GET /share/{shareId}
-&gt; gateway ShareHtmlController
-&gt; ShareGatewayService#getShareLink(shareId)
-&gt; character/share 도메인에서 imageUrl, headline, characterName, signupUrl 조회
-&gt; gateway가 OG meta tag 포함 HTML 반환</code></pre>
<p>필수 OG 태그:</p>
<pre><code class="language-html">&lt;meta property=&quot;og:title&quot; content=&quot;...&quot; /&gt;
&lt;meta property=&quot;og:description&quot; content=&quot;...&quot; /&gt;
&lt;meta property=&quot;og:image&quot; content=&quot;https://...&quot; /&gt;
&lt;meta property=&quot;og:url&quot; content=&quot;https://polaris.app/share/{shareId}&quot; /&gt;</code></pre>
<p>주의:</p>
<ul>
<li><code>og:image</code>는 반드시 <code>https://...</code> 절대 URL이어야 한다.</li>
<li>권장 이미지 크기는 <code>1200 x 630px</code>이다.</li>
<li>프론트 SPA에서 동적으로 meta tag를 바꾸는 방식은 메신저 미리보기를 보장하지 못한다.</li>
</ul>
<p>권장 구현 형태:</p>
<pre><code class="language-java">@Controller
@RequiredArgsConstructor
public class ShareHtmlController {

    private final ShareGatewayService shareGatewayService;

    @GetMapping(value = &quot;/share/{shareId}&quot;, produces = MediaType.TEXT_HTML_VALUE)
    @ResponseBody
    public String getShareHtmlPage(@PathVariable String shareId) {
        var shareLink = shareGatewayService.getShareLink(shareId);
        // HTML escape, URL validation 후 HTML 문자열 반환
    }
}</code></pre>
<p>구현 시 필수 보완:</p>
<ul>
<li><code>characterName</code>, <code>headline</code>, <code>imageUrl</code>, <code>signupUrl</code> 등 HTML에 삽입되는 값은 반드시 escape한다.</li>
<li><code>og:image</code>와 redirect URL은 <code>https://...</code> 절대 URL인지 검증한다.</li>
<li><code>imageUrl</code>이 비어 있거나 올바르지 않으면 기본 공유 이미지로 fallback한다.</li>
<li><code>og:url</code>의 base URL은 코드에 고정하지 말고 설정값으로 분리한다. 예: <code>app.public-base-url=https://polaris.app</code></li>
<li>없는 <code>shareId</code>는 JSON 에러가 아니라 404 HTML 또는 기본 OG HTML로 응답할지 정책을 정한다.</li>
<li>실제 사용자를 위해 JavaScript redirect만 두지 말고 body에 기본 링크와 <code>&lt;noscript&gt;</code> fallback을 함께 둔다.</li>
<li>X/Twitter 대응을 위해 <code>twitter:card</code>, <code>twitter:title</code>, <code>twitter:description</code>, <code>twitter:image</code>도 함께 추가한다.</li>
<li>공유 카드 이미지가 갱신될 수 있으므로 초기 cache 정책은 짧게 둔다. 예: <code>Cache-Control: public, max-age=300</code></li>
</ul>
<p>주의할 점:</p>
<ul>
<li><code>ShareController</code>는 <code>@RestController</code>와 <code>/api/share</code> 전용으로 유지한다.</li>
<li><code>/share/{shareId}</code>는 공개 endpoint이므로 인증 인터셉터 대상에 포함하지 않는다. 현재 gateway <code>AuthInterceptor</code>는 <code>/api/**</code>에만 적용되므로 구조상 공개 접근이 가능하다.</li>
<li>문자열 HTML을 직접 조립하더라도 escape와 URL 검증을 생략하면 안 된다.</li>
</ul>
<p>완료된 부분:</p>
<ul>
<li>gateway 모듈에 <code>/share/{shareId}</code> HTML 응답 컨트롤러가 추가되었다.</li>
<li><code>ShareGatewayService#getShareLink(shareId)</code>를 사용해 공유 링크 정보를 조회한다.</li>
<li>OG 태그와 X/Twitter 카드 태그를 반환한다.</li>
<li>HTML escape, <code>https://...</code> URL 검증, 기본 이미지 fallback, cache header가 포함되어 있다.</li>
<li>JavaScript redirect와 body fallback link가 포함되어 있다.</li>
</ul>
<p>남은 개선점:</p>
<ul>
<li>없는 <code>shareId</code> 또는 character/share 서비스 오류 발생 시 JSON 에러가 아닌 HTML 404/fallback 페이지로 응답하도록 예외 처리가 필요하다.</li>
<li><code>og:title</code>이 현재 고정 <code>Polaris</code>라면, 캐릭터 이름이나 headline template 정책에 맞춰 더 풍부하게 구성할지 결정해야 한다.</li>
<li><code>app.public-base-url</code>, <code>app.default-share-image-url</code> 설정값이 운영/스테이징 환경별로 제대로 주입되는지 확인해야 한다.</li>
<li>공유 카드 imageUrl 갱신 정책과 cache TTL이 충돌하지 않는지 실제 공유 플랫폼 캐시 특성을 기준으로 검증해야 한다.</li>
</ul>
<hr>
<h2 id="3-p1-도메인-연동-정합성">3. P1: 도메인 연동 정합성</h2>
<h3 id="31-돌봄-액션-소모품-차감">3.1 돌봄 액션 소모품 차감</h3>
<p><strong>상태: 수정 필요</strong></p>
<p>현재 <code>POST /api/character/v1/characters/{characterId}/care-logs</code>에서 <code>itemId</code>를 보내도 item 도메인 수량 차감이 일어나지 않는다.</p>
<p>필요 작업:</p>
<ul>
<li>item 도메인에 소모품 사용/차감 RPC 추가</li>
<li>userId, itemId, quantity, actionType, idempotencyKey 전달</li>
<li>보유 수량 부족 시 <code>ITEM_QUANTITY_NOT_ENOUGH</code> 반환</li>
<li>character 상태 회복과 item 수량 차감의 실패 처리 정책 결정</li>
</ul>
<p>주의:</p>
<ul>
<li>분산 트랜잭션이 아니므로, item 차감 성공 후 character 저장 실패 같은 보상/재시도 정책을 정해야 한다.</li>
<li>MVP에서는 character 도메인 처리 전에 item 차감을 먼저 시도하고, 실패하면 돌봄 액션을 진행하지 않는 방향이 단순하다.</li>
</ul>
<h3 id="32-스킨-장착-소유-검증">3.2 스킨 장착 소유 검증</h3>
<p><strong>상태: 수정 필요</strong></p>
<p>현재 <code>PUT /api/character/v1/characters/{characterId}/equipped-skin</code>은 사용자가 해당 스킨을 보유했는지 검증하지 않는다.</p>
<p>필요 작업:</p>
<ul>
<li>item 도메인에 user item 보유 검증 RPC 추가</li>
<li><code>itemType=SKIN</code> 확인</li>
<li>보유하지 않은 경우 <code>ITEM_NOT_OWNED</code> 반환</li>
<li>장착 성공 시 item 도메인의 <code>equipped</code> 상태와 character 도메인의 <code>equippedSkinId</code> 동기화 정책 결정</li>
</ul>
<h3 id="33-스킨-이름-mock-제거">3.3 스킨 이름 mock 제거</h3>
<p><strong>상태: 수정 필요</strong></p>
<p>현재 gateway character 응답에서 스킨 이름을 item 도메인에서 조회하지 않고 placeholder로 만든다.</p>
<p>예시:</p>
<pre><code class="language-text">스킨 {id}
Skin {id}</code></pre>
<p>필요 작업:</p>
<ul>
<li>item 도메인에서 item 상세 조회 또는 user item 상세 조회 RPC 제공</li>
<li><code>characters/me</code>, <code>equipped-skin</code> 응답에서 실제 스킨 이름 반환</li>
</ul>
<h3 id="34-공유-카드-imageurl-갱신-처리">3.4 공유 카드 imageUrl 갱신 처리</h3>
<p><strong>상태: 수정됨</strong></p>
<p>현재 <code>ShareService#createShareCard</code>는 같은 <code>(userId, characterId)</code> 카드가 있으면 기존 카드를 재사용한다. 코드상으로는 새 <code>uploadedImageUrl</code>을 반환값에 반영하지만, DB의 <code>share_cards.image_url</code>은 갱신하지 않는다.</p>
<p>필요 작업:</p>
<ul>
<li><code>ShareCard#updateImageUrl(String imageUrl)</code> 메서드 추가</li>
<li>기존 공유 카드를 재생성할 때 DB imageUrl 갱신</li>
<li>갱신 시점에 기존 공유 URL을 유지할지 새 shareId를 발급할지 정책 결정</li>
</ul>
<p>권장:</p>
<ul>
<li>같은 캐릭터의 공유 카드는 shareId를 유지하되, 새 이미지 업로드 시 imageUrl만 갱신한다.</li>
</ul>
<hr>
<h2 id="4-p2-품질-및-안정성-개선">4. P2: 품질 및 안정성 개선</h2>
<h3 id="41-캐릭터-생성-이름-validation">4.1 캐릭터 생성 이름 validation</h3>
<p><strong>상태: 수정됨</strong></p>
<p>캐릭터 이름 수정은 <code>1~10자</code> 검증이 있지만, 생성 시에는 명시적 도메인 검증이 약하다.</p>
<p>필요 작업:</p>
<ul>
<li>생성 시에도 <code>null</code>, blank, 10자 초과를 <code>CHARACTER_NAME_INVALID</code>로 반환</li>
<li>DB 길이 오류가 API 밖으로 새지 않도록 한다.</li>
</ul>
<h3 id="42-돌봄-액션-메시지-한국어polaris-톤-정리">4.2 돌봄 액션 메시지 한국어/Polaris 톤 정리</h3>
<p><strong>상태: 수정됨</strong></p>
<p>현재 돌봄 액션 메시지는 영어 고정 문구다.</p>
<p>예시:</p>
<pre><code class="language-text">Mmm... light has a taste too.
...zz. Thanks.
That was fun. Let&#39;s do it again sometime.</code></pre>
<p>필요 작업:</p>
<ul>
<li>Polaris 톤에 맞는 한국어 문구로 변경</li>
<li>캐릭터 타입별 말투 분기는 추후 확장으로 둔다.</li>
</ul>
<h3 id="43-care-log-idempotency-migration-안전화">4.3 care log idempotency migration 안전화</h3>
<p><strong>상태: 수정됨</strong></p>
<p>현재 migration은 기존 <code>character_care_logs</code>에 바로 <code>NOT NULL UNIQUE</code> 컬럼을 추가한다.</p>
<pre><code class="language-sql">ALTER TABLE character_care_logs
ADD COLUMN idempotency_key VARCHAR(100) NOT NULL UNIQUE;</code></pre>
<p>기존 데이터가 있는 DB에서는 실패할 수 있다.</p>
<p>권장 순서:</p>
<ol>
<li>nullable 컬럼 추가</li>
<li>기존 row backfill</li>
<li>unique index 추가</li>
<li>not null 제약 추가</li>
</ol>
<h3 id="44-r2-presigned-url-extension-검증">4.4 R2 presigned URL extension 검증</h3>
<p><strong>상태: 수정됨</strong></p>
<p>현재 extension 값을 파일명과 content type에 그대로 사용한다.</p>
<p>필요 작업:</p>
<ul>
<li>allowlist 적용</li>
</ul>
<pre><code class="language-text">png, jpg, jpeg, webp</code></pre>
<ul>
<li>허용되지 않은 값은 기본 <code>png</code>로 대체하거나 validation error 반환</li>
</ul>
<hr>
<h2 id="5-headline-template-확장안">5. Headline Template 확장안</h2>
<p><strong>상태: 수정 필요</strong></p>
<h3 id="51-배경">5.1 배경</h3>
<p>현재 공유 링크 응답의 <code>headline</code>은 서버 고정 문구다. 사용자 입력 headline을 받지 않기 때문에 프론트 구현은 단순하지만, 모든 공유 카드가 같은 문구를 쓰면 반복감이 생긴다.</p>
<p>Polaris 톤을 일관되게 유지하려면 프론트에서 임의 문구를 만들기보다 서버가 headline 후보 풀을 관리하는 편이 좋다.</p>
<h3 id="52-권장-모델">5.2 권장 모델</h3>
<p>새 테이블을 추가한다.</p>
<pre><code class="language-text">share_headline_templates
- id
- text
- character_type_code nullable
- tag nullable
- weight int
- active boolean
- created_at
- updated_at</code></pre>
<p>예시 문구:</p>
<pre><code class="language-text">오늘도 조금 반짝였어요.
작은 일을 하나 해냈어요.
내 별친구가 오늘을 기록했어요.
느리지만 분명히 움직였어요.</code></pre>
<h3 id="53-선택-시점">5.3 선택 시점</h3>
<p>권장 방식은 공유 카드 생성 시 headline을 하나 선택하고 <code>share_cards.headline</code>에 저장하는 것이다.</p>
<pre><code class="language-text">POST /api/share/v1/share-cards
-&gt; imageUrl 저장
-&gt; active headline template 중 하나 선택
-&gt; share_cards.headline 저장
-&gt; share link 조회/OG HTML에서 동일 headline 사용</code></pre>
<p>이 방식의 장점:</p>
<ul>
<li>같은 <code>shareId</code>를 열 때마다 문구가 바뀌지 않는다.</li>
<li>OG 미리보기와 앱 내부 공유 링크 화면이 같은 문구를 보여준다.</li>
<li>추후 캐릭터 타입별 말투 분기가 쉽다.</li>
</ul>
<h3 id="54-선택-정책">5.4 선택 정책</h3>
<p>우선순위:</p>
<ol>
<li><code>active=true</code></li>
<li>요청 캐릭터의 <code>character_type_code</code>와 일치하는 후보</li>
<li>후보가 없으면 공통 후보(<code>character_type_code is null</code>) fallback</li>
<li><code>weight</code> 기반 랜덤 선택 또는 단순 랜덤 선택</li>
</ol>
<h3 id="55-api-영향">5.5 API 영향</h3>
<p>단기:</p>
<ul>
<li><code>POST /api/share/v1/share-cards</code> request는 계속 <code>{ characterId, imageUrl }</code> 유지</li>
<li>서버가 headline을 선택해 저장</li>
<li>response에 <code>headline</code>을 추가할지 여부는 선택</li>
</ul>
<p>권장 response:</p>
<pre><code class="language-json">{
  &quot;shareCardId&quot;: 800,
  &quot;shareId&quot;: &quot;sh_abc123&quot;,
  &quot;imageUrl&quot;: &quot;https://cdn.polaris.app/share-cards/800.png&quot;,
  &quot;headline&quot;: &quot;오늘도 조금 반짝였어요.&quot;,
  &quot;shareUrl&quot;: &quot;https://polaris.app/share/sh_abc123&quot;
}</code></pre>
<p>공유 링크 조회:</p>
<pre><code class="language-json">{
  &quot;shareId&quot;: &quot;sh_abc123&quot;,
  &quot;characterName&quot;: &quot;노바별&quot;,
  &quot;imageUrl&quot;: &quot;https://cdn.polaris.app/share-cards/800.png&quot;,
  &quot;headline&quot;: &quot;오늘도 조금 반짝였어요.&quot;,
  &quot;signupUrl&quot;: &quot;https://polaris.app/signup?shareId=sh_abc123&quot;
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day96]]></title>
            <link>https://velog.io/@rxg_1211/Day96</link>
            <guid>https://velog.io/@rxg_1211/Day96</guid>
            <pubDate>Tue, 19 May 2026 12:10:30 GMT</pubDate>
            <description><![CDATA[<h1 id="공유-관련-정책-정리">공유 관련 정책 정리</h1>
<hr>
<h2 id="1-공유-기능-전체-개요">1. 공유 기능 전체 개요</h2>
<p>사용자가 캐릭터 카드를 공유할 때 두 가지 방식을 제공한다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>링크 공유</td>
<td>캐릭터 카드 URL을 공유. SNS/메신저에서 OG 태그 기반 미리보기 카드 표시</td>
</tr>
<tr>
<td>이미지 다운로드</td>
<td>캐릭터 카드를 이미지 파일로 변환하여 로컬 저장</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-링크-공유---open-graph-미리보기">2. 링크 공유 - Open Graph 미리보기</h2>
<p>링크 공유 시 카카오톡, 디스코드 등 메신저에서 미리보기 카드가 표시되도록 OG 태그를 설정한다. SNS/메신저가 URL을 감지하면 해당 서버가 직접 URL에 접근하여 HTML <code>&lt;head&gt;</code>의 OG 태그를 읽어 카드를 생성한다.</p>
<h3 id="필수-og-태그">필수 OG 태그</h3>
<table>
<thead>
<tr>
<th>태그</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>og:title</code></td>
<td>카드에 표시되는 제목</td>
</tr>
<tr>
<td><code>og:description</code></td>
<td>카드에 표시되는 설명</td>
</tr>
<tr>
<td><code>og:image</code></td>
<td>썸네일 이미지 (R2에 저장된 캐릭터 이미지 URL)</td>
</tr>
<tr>
<td><code>og:url</code></td>
<td>실제 페이지 URL</td>
</tr>
</tbody></table>
<h3 id="주의사항">주의사항</h3>
<ul>
<li><code>og:image</code>는 반드시 절대 경로(<code>https://...</code>)로 지정해야 한다. 상대 경로로 지정 시 외부 서버에서 이미지를 찾지 못한다.</li>
<li>권장 이미지 크기는 1200 x 630px이다.</li>
<li>OG 태그는 서버(Thymeleaf)에서 동적으로 생성한다. 프론트엔드 JS로는 처리할 수 없다. (메신저 서버는 JS를 실행하지 않고 HTML만 읽기 때문)</li>
</ul>
<hr>
<h2 id="3-이미지-다운로드---html2canvas">3. 이미지 다운로드 - html2canvas</h2>
<h3 id="방식-선택-근거">방식 선택 근거</h3>
<p>이미지 생성 방식은 <strong>서버 생성</strong>과 <strong>프론트엔드 생성(html2canvas)</strong> 두 가지가 있다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>서버 생성</th>
<th>html2canvas (채택)</th>
</tr>
</thead>
<tbody><tr>
<td>구현 위치</td>
<td>Java (서버)</td>
<td>브라우저 (JS)</td>
</tr>
<tr>
<td>디자인 반영</td>
<td>코드로 직접 조립</td>
<td>화면에 보이는 그대로 캡처</td>
</tr>
<tr>
<td>서버 부하</td>
<td>있음</td>
<td>없음</td>
</tr>
<tr>
<td>캐릭터+아이템 합성</td>
<td>별도 구현 필요</td>
<td>CSS 레이아웃 그대로 캡처</td>
</tr>
</tbody></table>
<p>캐릭터 위에 아이템을 겹쳐 합성하는 구조이므로, 화면에 렌더링된 결과를 그대로 이미지로 변환하는 html2canvas 방식이 적합하다. 서버 부하도 없고 디자인 변경 시 별도 수정이 필요 없다.</p>
<h3 id="사용-라이브러리">사용 라이브러리</h3>
<p><code>html2canvas</code> 또는 <code>dom-to-image</code> (프론트엔드 JS 라이브러리)</p>
<hr>
<h2 id="4-하루-1회-공유-보상-정책">4. 하루 1회 공유 보상 정책</h2>
<p>공유 보상(별조각)은 하루 최대 1회로 제한하며, 중복 지급을 막기 위해 <strong>비즈니스 로직</strong>과 <strong>DB 제약조건</strong> 두 단계로 검증한다.</p>
<h3 id="4-1-비즈니스-로직-날짜-기준-조회">4-1. 비즈니스 로직 (날짜 기준 조회)</h3>
<p>사용자가 공유 이벤트를 발생시키면 <code>ShareService</code>에서 오늘 날짜(<code>LocalDate today</code>)를 기준으로 DB를 먼저 조회한다.</p>
<pre><code class="language-java">// 오늘 이미 보상을 받은 기록이 있는지 확인
boolean alreadyRewarded = shareLogRepository
    .existsByUserIdAndShareDateAndRewardPaidTrue(userId, today);

// 이미 받았으면 0, 처음이면 50 지급
boolean rewardPaid = !alreadyRewarded;
int rewardAmount = rewardPaid ? 50 : 0;</code></pre>
<p>보상 없이 기록만 남기는 경우 <code>rewardPaid = false</code>로 저장한다.</p>
<h3 id="4-2-db-유니크-제약조건-멱등성-키-방어">4-2. DB 유니크 제약조건 (멱등성 키 방어)</h3>
<p>비즈니스 로직만으로는 공유 버튼을 짧은 순간에 두 번 클릭하는 경우(Race Condition) 두 요청이 동시에 통과할 수 있다. 이를 방지하기 위해 프론트엔드가 요청 시 고유한 <strong>멱등성 키</strong>를 생성하여 함께 전송하도록 설계한다.</p>
<ul>
<li>키 형식: <code>SHARE_REWARD:{userId}:{yyyy-MM-dd}</code></li>
</ul>
<p><code>share_logs</code> 테이블에 해당 키에 UNIQUE 제약조건을 걸어두어, 동시에 두 요청이 로직을 통과하더라도 DB 저장 시점에 하나는 중복 키 에러로 차단된다.</p>
<pre><code class="language-sql">CONSTRAINT uq_share_logs_idempotency_key UNIQUE (idempotency_key)</code></pre>
<hr>
<h2 id="5-이미지-스토리지-선택-cloudflare-r2">5. 이미지 스토리지 선택: Cloudflare R2</h2>
<p>캐릭터 카드 공유에 사용할 이미지 스토리지로 <strong>Cloudflare R2</strong>를 채택한다.</p>
<h3 id="결정-배경">결정 배경</h3>
<p>운영자가 캐릭터 이미지를 미리 업로드해두고, 사용자가 카드를 공유할 때 해당 이미지 URL을 내려주는 구조다. 이 구조에서 핵심 비용은 <strong>이미지를 사용자에게 전달할 때 발생하는 egress(데이터 전송) 비용</strong>이다.</p>
<h3 id="aws-s3-대비-r2를-선택한-이유">AWS S3 대비 R2를 선택한 이유</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>Cloudflare R2</th>
<th>AWS S3</th>
</tr>
</thead>
<tbody><tr>
<td>Egress 비용</td>
<td><strong>무료</strong></td>
<td>GB당 $0.09</td>
</tr>
<tr>
<td>스토리지 비용</td>
<td>GB당 $0.015</td>
<td>GB당 $0.023</td>
</tr>
<tr>
<td>무료 티어</td>
<td>스토리지 10GB, 읽기 1000만 건/월</td>
<td>12개월 한정</td>
</tr>
<tr>
<td>S3 호환 API</td>
<td>지원</td>
<td>-</td>
</tr>
<tr>
<td>이미지 변환</td>
<td>미지원</td>
<td>미지원</td>
</tr>
</tbody></table>
<ul>
<li>이미지 변환 기능이 필요 없는 구조이므로 Cloudinary의 핵심 장점을 활용할 수 없어 제외했다.</li>
<li>R2는 egress가 무료이므로 트래픽이 늘어도 이미지 전송 비용이 발생하지 않는다.</li>
<li>S3 호환 API를 지원하므로 추후 S3로 마이그레이션이 필요할 경우 코드 변경이 거의 없다.</li>
<li>현재 규모에서는 무료 티어 안에서 운영 가능하다.</li>
</ul>
<h3 id="향후-전환-기준">향후 전환 기준</h3>
<p>트래픽이 크게 증가하거나 AWS 생태계와의 깊은 통합이 필요해지는 시점에 S3로 전환을 검토한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day95]]></title>
            <link>https://velog.io/@rxg_1211/Day95</link>
            <guid>https://velog.io/@rxg_1211/Day95</guid>
            <pubDate>Mon, 18 May 2026 11:32:49 GMT</pubDate>
            <description><![CDATA[<p>서버 간의 gRPC 통신이 처음이라면 이 흐름이 복잡하고 어색하게 느껴지는 것이 아주 당연합니다</p>
<p>가장 최근에 만든 <strong>캐릭터 이름 수정 (4.5 API)</strong>을 예시로, 사용자의 요청이 데이터베이스에 반영될 때까지의 여정을 <strong>5개의 단계</strong>로 아주 쉽게 나누어서 설명해 드릴게요.</p>
<p>전체 구조는 다음과 같습니다:
<code>[클라이언트] -(HTTP)-&gt; [Gateway] -(gRPC)-&gt; [Character 모듈] -&gt; [데이터베이스(JPA)]</code></p>
<hr>
<h3 id="1단계-gateway---charactercontroller-외부-rest-api-입구">1단계: Gateway - <a href="file:///c:/Users/1/Desktop/sparta%5Cpolaris%5Cgateway%5Csrc%5Cmain%5Cjava%5Cp5laris%5Cgateway%5Cdomain%5Ccharacter%5Capi%5CCharacterController.java">CharacterController</a> (외부 REST API 입구)</h3>
<p>외부 인터넷(웹 브라우저나 모바일 앱)에서 <code>PATCH /api/character/v1/characters/10</code> 같은 HTTP 요청이 들어오는 곳입니다.</p>
<pre><code class="language-java">@org.springframework.web.bind.annotation.PatchMapping(&quot;/v1/characters/{characterId}&quot;)
public ApiResponse&lt;UpdateCharacterNameResponse&gt; updateCharacterName(
        @PathVariable Long characterId,
        @LoginUserId Long userId,  // 🔐 로그인한 사용자 ID (인증 토큰에서 추출)
        @RequestBody UpdateCharacterNameRequest request) { // 수정할 이름 {&quot;name&quot;: &quot;노바별&quot;}

    // GatewayService로 데이터를 토스합니다.
    return ApiResponse.success(characterGatewayService.updateCharacterName(characterId, userId, request));
}</code></pre>
<ul>
<li><strong>역할</strong>: 사용자 인증(로그인 여부)을 확인하고, HTTP 요청 정보를 자바 객체로 변환하여 내부 서비스에 토스합니다. <strong>Gateway는 데이터베이스(JPA)에 직접 접근하지 않습니다.</strong></li>
</ul>
<hr>
<h3 id="2단계-gateway---charactergatewayservice-grpc-클라이언트">2단계: Gateway - <a href="file:///c:/Users/1/Desktop/sparta%5Cpolaris%5Cgateway%5Csrc%5Cmain%5Cjava%5Cp5laris%5Cgateway%5Cdomain%5Ccharacter%5Cinfrastructure%5Cgrpc%5CCharacterGatewayService.java">CharacterGatewayService</a> (gRPC 클라이언트)</h3>
<p>여기서 <strong>서버 간의 gRPC 통신</strong>이 시작됩니다. Gateway가 Character 서버에게 전화를 거는 과정이라고 생각하시면 됩니다.</p>
<pre><code class="language-java">public UpdateCharacterNameResponse updateCharacterName(Long characterId, Long userId, UpdateCharacterNameRequest request) {

    // 1. protobuf 규격에 맞춰 전송용 &quot;택배 박스(Request)&quot;를 조립합니다.
    var grpcRequest = com.p5laris.proto.character.v1.UpdateCharacterNameRequest.newBuilder()
            .setCharacterId(characterId)
            .setUserId(userId)
            .setName(request.name())
            .build();

    // 2. characterStub(전화기)를 사용해 Character 서버의 updateCharacterName 함수를 호출합니다. (네트워크 통신)
    var grpcResponse = characterStub.updateCharacterName(grpcRequest);

    // 3. Character 서버에서 돌려받은 결과(택배 박스)를 다시 웹 응답용 DTO로 포장합니다.
    return UpdateCharacterNameResponse.builder()
            .id(grpcResponse.getId())
            .name(grpcResponse.getName())
            .updatedAt(java.time.Instant.parse(grpcResponse.getUpdatedAt()))
            .build();
}</code></pre>
<ul>
<li><strong>역할</strong>: 일반 Java 객체 데이터를 네트워크를 통해 전송하기 편리한 <strong>gRPC/Protobuf 메시지 포맷</strong>으로 직렬화(Serialization)하여 실제 Character 서버로 발송합니다.</li>
</ul>
<hr>
<h3 id="3단계-규격-정의서---character_serviceproto-interface-contract">3단계: 규격 정의서 - <a href="file:///c:/Users/1/Desktop/sparta%5Cpolaris%5Cproto%5Csrc%5Cmain%5Cproto%5Ccharacter%5Cv1%5Ccharacter_service.proto">character_service.proto</a> (Interface Contract)</h3>
<p>통신할 때 &quot;우리는 이 구조의 데이터를 주고받을 것이다&quot;라고 정의해 둔 규격서(약속)입니다. 이 파일을 기반으로 Java 통신 코드가 자동으로 컴파일 및 생성됩니다.</p>
<pre><code class="language-protobuf">// 약속 1: 이런 함수(RPC)를 주고받을 거야.
rpc UpdateCharacterName(UpdateCharacterNameRequest) returns (UpdateCharacterNameResponse);

// 약속 2: 보낼 때는 이 데이터를 꼭 담아줘.
message UpdateCharacterNameRequest {
  int64 character_id = 1;
  int64 user_id = 2;
  string name = 3;
}

// 약속 3: 응답할 때는 이 데이터를 돌려줄게.
message UpdateCharacterNameResponse {
  int64 id = 1;
  string name = 2;
  string updated_at = 3;
}</code></pre>
<hr>
<h3 id="4단계-character-모듈---charactergrpccontroller-grpc-서버-입구">4단계: Character 모듈 - <a href="file:///c:/Users/1/Desktop/sparta%5Cpolaris%5Ccharacter%5Csrc%5Cmain%5Cjava%5Cp5laris%5Ccharacter%5Cdomain%5Capi%5CCharacterGrpcController.java">CharacterGrpcController</a> (gRPC 서버 입구)</h3>
<p>Character 서버가 Gateway에서 보낸 gRPC 전화를 받는 곳입니다. </p>
<pre><code class="language-java">@Override
public void updateCharacterName(UpdateCharacterNameRequest request,
                                StreamObserver&lt;UpdateCharacterNameResponse&gt; responseObserver) {
    // 1. gRPC 요청 박스를 열어 데이터를 꺼내고, 실제 비즈니스 로직(Service)을 실행합니다.
    var result = characterService.updateCharacterName(
            request.getCharacterId(),
            request.getUserId(),
            request.getName()
    );

    // 2. 돌려줄 gRPC 응답 박스를 조립합니다.
    UpdateCharacterNameResponse response = UpdateCharacterNameResponse.newBuilder()
            .setId(result.id())
            .setName(result.name())
            .setUpdatedAt(result.updatedAt().toString())
            .build();

    // 3. 통신망을 통해 Gateway 서버로 전송합니다.
    responseObserver.onNext(response);
    responseObserver.onCompleted();
}</code></pre>
<ul>
<li><strong>역할</strong>: 외부 모듈(Gateway)로부터 전달받은 gRPC 네트워크 호출을 받아 해석한 뒤, 로컬 Spring 컨테이너의 서비스 레이어로 비즈니스 처리를 위임하고 결과를 돌려줍니다.</li>
</ul>
<hr>
<h3 id="5단계-character-모듈---characterservice-순수-비즈니스-로직--jpa">5단계: Character 모듈 - <a href="file:///c:/Users/1/Desktop/sparta%5Cpolaris%5Ccharacter%5Csrc%5Cmain%5Cjava%5Cp5laris%5Ccharacter%5Cdomain%5Capplication%5CCharacterService.java">CharacterService</a> (순수 비즈니스 로직 / JPA)</h3>
<p>여기서부터는 질문자님이 이미 아주 잘 알고 계시는 <strong>순수 CRUD/JPA 영역</strong>입니다!</p>
<pre><code class="language-java">@Transactional
public UpdateCharacterNameResponse updateCharacterName(Long characterId, Long userId, String newName) {
    // 1. DB에서 캐릭터 엔티티를 꺼내옵니다.
    UserCharacter userCharacter = userCharacterRepository.findById(characterId)
            .orElseThrow(() -&gt; new IllegalArgumentException(&quot;Character not found: &quot; + characterId));

    // 2. 권한 검증: 이 캐릭터가 해당 유저의 것이 맞는지 검사합니다.
    if (!userCharacter.getUserId().equals(userId)) {
        throw new IllegalArgumentException(&quot;User does not own this character&quot;);
    }

    // 3. 엔티티 내부에서 비즈니스 검증(글자 수 등)을 하며 이름을 변경합니다. (JPA Dirty Checking으로 자동 변경 감지 후 DB 반영)
    userCharacter.updateName(newName);

    // 4. 결과를 반환합니다.
    return UpdateCharacterNameResponse.builder()
            .id(userCharacter.getId())
            .name(userCharacter.getName())
            .updatedAt(userCharacter.getUpdatedAt())
            .build();
}</code></pre>
<hr>
<h3 id="💡-요약하자면">💡 요약하자면!</h3>
<ul>
<li><strong>Controller / Service / Entity / DB (5단계, 4단계 일부)</strong>: 기존 싱글 서버 환경에서 작성하던 데이터 조작 logic과 100% 동일합니다.</li>
<li><strong>Gateway (1단계, 2단계)</strong>: 외부 사용자로부터 HTTP 요청을 받아, 어느 마이크로서비스로 전화를 걸어야 할지 찾아서 조율(Orchestration)해 주는 얇은 껍데기입니다.</li>
<li><strong>Proto &amp; Stub (3단계)</strong>: 마이크로서비스 간에 데이터를 빠르고 안전하게 전송하기 위한 통로이자 규격 약속서입니다.</li>
</ul>
<p>이렇게 중간에 통신 규격(gRPC)을 거침으로써, <code>Gateway</code>와 <code>Character</code> 서버가 독립적인 서버로 실행되면서도 서로 함수 호출하듯이 통신할 수 있게 됩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day94]]></title>
            <link>https://velog.io/@rxg_1211/Day94</link>
            <guid>https://velog.io/@rxg_1211/Day94</guid>
            <pubDate>Fri, 15 May 2026 01:43:43 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="product-price-at-a-given-date"><a href="https://leetcode.com/problems/product-price-at-a-given-date/description/">Product Price at a Given Date</a></h3>
<pre><code class="language-sql">with rn_product as (
    select product_id, new_price, ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY change_date desc ) AS rn
    from Products
    where change_date &lt;= &#39;2019-08-16&#39;
), group_product as (
    select product_id
    from Products
    group by product_id
)

select a.product_id, ifnull(b.new_price, 10) as price
from group_product a
left join rn_product b
on a.product_id = b.product_id
where b.rn = 1 or b.rn is null;</code></pre>
<h3 id="n개의-최소공배수"><a href="https://school.programmers.co.kr/learn/courses/30/lessons/12953">N개의 최소공배수</a></h3>
<pre><code class="language-java">import java.util.Arrays;

class Solution {
    public int solution(int[] arr) {
        int answer = 0;

        int[] array = arr;

        for (int i = 0; i &lt; halflength(arr.length); i++) {
            if (array.length &lt;= 1){
                break;
            }
            array = arrays(array);
        }
        System.out.println(Arrays.toString(array));
        answer = array[0];
        for (int j : arr) {
            answer *= (j / array[0]);
        }

        return answer;
    }
    public int halflength(int l){
        if (l%2 != 0){
            return l/2 + 1;
        }
        return l/2;
    }

    public int[] arrays(int[] list){
        int[] array = new int[halflength(list.length)];

        boolean odd = false;
        if (list.length % 2 != 0){
            odd = true;
        }

        for (int i = 0; i &lt; halflength(list.length); i++) {
            if(odd &amp;&amp; i == halflength(list.length)-1){
                array[i] = list[list.length-1];
            }else {
                array[i] = gcd(list[2*i], list[2*i+1]);
            }
        }

        return array;
    }

    // 최대공약수 찾기
    public int gcd(int a, int b) {
        while (b != 0) {
            int r = a % b;
            a = b;
            b = r;
        }
        return a;
    }
}</code></pre>
<ul>
<li><p>길어졌으나 실패..너무 복잡하게 생각했음을 깨달았다</p>
<pre><code class="language-java">class Solution {
  public int solution(int[] arr) {
      int answer = arr[0];

      for (int i = 1; i &lt; arr.length; i++) {
          answer = lcm(answer, arr[i]);
      }

      return answer;
  }

  //최소공배수 찾기
  public int lcm(int a, int b) {
      return (a / gcd(a, b)) * b;
  }

  // 최대공약수 찾기
  public int gcd(int a, int b) {
      while (b != 0) {
          int r = a % b;
          a = b;
          b = r;
      }
      return a;
  }
}</code></pre>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day93]]></title>
            <link>https://velog.io/@rxg_1211/Day93</link>
            <guid>https://velog.io/@rxg_1211/Day93</guid>
            <pubDate>Thu, 14 May 2026 00:31:05 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="consecutive-numbers">Consecutive Numbers</h3>
<pre><code class="language-sql">SELECT DISTINCT num as ConsecutiveNums 
FROM (
    SELECT num,
           LEAD(num, 1) OVER (ORDER BY id) AS next1,
           LEAD(num, 2) OVER (ORDER BY id) AS next2
    FROM Logs
) AS ConsecutiveLogs
WHERE num = next1 AND num = next2;</code></pre>
<h3 id="예상-대진표"><a href="https://school.programmers.co.kr/learn/courses/30/lessons/12985">예상 대진표</a></h3>
<pre><code class="language-java">class Solution
{
    public int solution(int n, int a, int b)
    {
        int answer = 0;

        int[] loglist = {};
        if(a&lt;b){
            loglist = new int[]{n, a, b};
        }else{
            loglist = new int[]{n, b, a};
        }

        for(int i = 0; i &lt;= n/2; i++){
            if(loglist[1]%2 == 1 &amp;&amp; loglist[1] == loglist[2] - 1){
                answer = i + 1;
                break;
            }
            loglist[0] = loglist[0]/2 + loglist[0]%2;
            loglist[1] = loglist[1]/2 + loglist[1]%2;
            loglist[2] = loglist[2]/2 + loglist[2]%2;
        }

        return answer;
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day92]]></title>
            <link>https://velog.io/@rxg_1211/Day92</link>
            <guid>https://velog.io/@rxg_1211/Day92</guid>
            <pubDate>Wed, 13 May 2026 08:01:34 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="triangle-judgement"><a href="https://leetcode.com/problems/triangle-judgement/description/">Triangle Judgement</a></h3>
<pre><code class="language-sql">select x, y, z, (
    case when x + y &lt;= z then &#39;No&#39;
    when z + x &lt;= y then &#39;No&#39;
    when y + z &lt;= x then &#39;No&#39;
    else &#39;Yes&#39;
    end
) as triangle
from Triangle;</code></pre>
<h3 id="카펫"><a href="https://school.programmers.co.kr/learn/courses/30/lessons/42842">카펫</a></h3>
<pre><code class="language-java">class Solution {
    public int[] solution(int brown, int yellow) {
        int[] answer = {3,3};

        for (int i = 3; i &lt; brown/2; i++) {
            int width = brown / 2 - i + 2;

            if (carpet(brown, yellow, width, i)) {
                answer[0] = width;
                answer[1] = i;
                break;
            }
        }

        return answer;
    }

    public boolean carpet(int brown, int yellow, int width, int i){
        int total = brown + yellow;

        if (width + i != brown / 2 + 2) {
            return false;
        }

        if (width*i != total){
            return false;
        }

        return true;
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day91]]></title>
            <link>https://velog.io/@rxg_1211/Day91</link>
            <guid>https://velog.io/@rxg_1211/Day91</guid>
            <pubDate>Mon, 11 May 2026 23:51:42 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="the-number-of-employees-which-report-to-each-employee"><a href="https://leetcode.com/problems/the-number-of-employees-which-report-to-each-employee/description/">The Number of Employees Which Report to Each Employee</a></h3>
<pre><code class="language-sql">with reporter as (
    select reports_to, count(employee_id) as reports_count, avg(age) as average_age
    from Employees
    group by reports_to
)

select a.employee_id, a.name, b.reports_count, round(b.average_age, 0) as average_age
from Employees a
left join reporter b
on a.employee_id = b.reports_to
where b.reports_count &gt;= 1
order by a.employee_id asc
;</code></pre>
<h3 id="primary-department-for-each-employee"><a href="https://leetcode.com/problems/primary-department-for-each-employee/description/">Primary Department for Each Employee</a></h3>
<pre><code class="language-sql">WITH Employees AS (
    SELECT *,
           ROW_NUMBER() OVER (PARTITION BY employee_id ORDER BY primary_flag) AS rn
    FROM Employee
)

SELECT employee_id, department_id 
FROM Employees
WHERE rn = 1;</code></pre>
<h3 id="이진-변환-반복하기"><a href="https://school.programmers.co.kr/learn/courses/30/lessons/70129">이진 변환 반복하기</a></h3>
<pre><code class="language-java">class Solution {
    public int[] solution(String s) {
        int[] answer = {0,0};
        String sb = s;

        while (sb.length() &gt; 1) {
            answer[1] += counter(sb);
            String binaryString = Integer.toString(nonzero(sb), 2);

            answer[0]++;
            sb = binaryString;
        }

        return answer;
    }


    public int nonzero(String sen){
        return sen.replace(&quot;0&quot;,&quot;&quot;).length();
    }

    public int counter(String sen){
        return sen.replace(&quot;1&quot;,&quot;&quot;).length();
    }
}</code></pre>
<h3 id="피보나치-수"><a href="https://school.programmers.co.kr/learn/courses/30/lessons/12945">피보나치 수</a></h3>
<pre><code class="language-java">import java.util.ArrayList;
import java.util.List;

class Solution {
    public int solution(int n) {
        int fibonacci = fibonacci(n);

        return fibonacci;
    }
    public int fibonacci(int n){
        List&lt;Integer&gt; fibonaccies = new ArrayList&lt;&gt;();
        fibonaccies.add(0);
        fibonaccies.add(1);

        for(int i = 2; i &lt;= n; i++){
            fibonaccies.add((fibonaccies.get(i-1)+ fibonaccies.get(i-2))%1234567);
        }

        return fibonaccies.get(n);
    }
}</code></pre>
<ul>
<li><code>모듈러 산술(나머지 연산)의 분배법칙 성질</code> 활용하기!!
덧셈: (A + B) % M = ((A % M) + (B % M)) % M
곱셈: (A * B) % M = ((A % M) * (B % M)) % M
뺄셈: (A - B) % M = ((A % M) - (B % M) + M) % M (음수가 나오는 것을 방지하기 위해 M을 더해줌)</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day90]]></title>
            <link>https://velog.io/@rxg_1211/Day90</link>
            <guid>https://velog.io/@rxg_1211/Day90</guid>
            <pubDate>Mon, 11 May 2026 00:27:51 GMT</pubDate>
            <description><![CDATA[<h1 id="트러블슈팅-가이드">트러블슈팅 가이드</h1>
<p>커피숍 주문 시스템 개발 중 발생한 문제와 해결 방법을 정리한 문서입니다.</p>
<hr>
<h2 id="📋-목차">📋 목차</h2>
<ol>
<li><a href="#1-gradle-%EB%B9%8C%EB%93%9C-%EC%98%A4%EB%A5%98">Gradle 빌드 오류</a></li>
<li><a href="#2-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EC%97%B0%EA%B2%B0-%EC%98%A4%EB%A5%98">데이터베이스 연결 오류</a></li>
<li><a href="#3-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%EC%9D%B8%EC%A6%9D-%EC%8B%A4%ED%8C%A8">비밀번호 인증 실패</a></li>
<li><a href="#4-%ED%95%9C%EA%B8%80-%EC%9D%B8%EC%BD%94%EB%94%A9-%EA%B9%A8%EC%A7%90">한글 인코딩 깨짐</a></li>
<li><a href="#5-querydsl-q%ED%81%B4%EB%9E%98%EC%8A%A4-%EC%83%9D%EC%84%B1-%EC%98%A4%EB%A5%98">QueryDSL Q클래스 생성 오류</a></li>
<li><a href="#6-jwt-%ED%86%A0%ED%81%B0-%EC%83%9D%EC%84%B1-%EC%98%A4%EB%A5%98">JWT 토큰 생성 오류</a></li>
<li><a href="#7-currentuser-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98-%EC%98%A4%EB%A5%98">@CurrentUser 어노테이션 오류</a></li>
<li><a href="#8-%EC%A3%BC%EB%AC%B8-%EC%83%9D%EC%84%B1-%EC%8B%9C-repository-%EC%98%A4%EB%A5%98">주문 생성 시 Repository 오류</a></li>
<li><a href="#9-docker-mysql-%EC%9E%AC%EC%8B%9C%EC%9E%91-%ED%9B%84-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%86%90%EC%8B%A4">Docker MySQL 재시작 후 데이터 손실</a></li>
<li><a href="#10-%EB%8B%A4%EC%A4%91-%ED%95%AD%EB%AA%A9-%EC%A3%BC%EB%AC%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%A0%84%ED%99%98">다중 항목 주문 시스템 전환</a></li>
</ol>
<hr>
<h2 id="1-gradle-빌드-오류">1. Gradle 빌드 오류</h2>
<h3 id="문제-상황">문제 상황</h3>
<pre><code>Execution failed for task &#39;:compileJava&#39;.
&gt; Could not resolve all files for configuration &#39;:annotationProcessor&#39;.</code></pre><p>IntelliJ에서 Gradle 빌드 시 QueryDSL 관련 오류 발생.</p>
<h3 id="원인">원인</h3>
<ul>
<li><code>com.ewerk.gradle.plugins.querydsl</code> 플러그인이 Gradle 8.x와 호환되지 않음</li>
<li>플러그인 방식의 QueryDSL 설정이 최신 Gradle 버전과 충돌</li>
</ul>
<h3 id="해결-방법">해결 방법</h3>
<p><strong>build.gradle 수정</strong>:</p>
<pre><code class="language-gradle">// ❌ 제거: 호환되지 않는 플러그인
// id &#39;com.ewerk.gradle.plugins.querydsl&#39; version &#39;1.0.10&#39;

dependencies {
    // ✅ 추가: Annotation Processor 방식
    implementation &#39;com.querydsl:querydsl-jpa:5.0.0:jakarta&#39;
    annotationProcessor &#39;com.querydsl:querydsl-apt:5.0.0:jakarta&#39;
    annotationProcessor &#39;jakarta.annotation:jakarta.annotation-api&#39;
    annotationProcessor &#39;jakarta.persistence:jakarta.persistence-api&#39;
}

// ✅ 추가: Q클래스 생성 경로 설정
def querydslDir = &quot;$buildDir/generated/querydsl&quot;

sourceSets {
    main.java.srcDirs += [querydslDir]
}

tasks.withType(JavaCompile) {
    options.generatedSourceOutputDirectory = file(querydslDir)
}

clean {
    delete file(querydslDir)
}</code></pre>
<p><strong>Q클래스 생성</strong>:</p>
<pre><code class="language-bash">./gradlew clean compileJava</code></pre>
<p><strong>생성 위치</strong>: <code>build/generated/querydsl/</code></p>
<hr>
<h2 id="2-데이터베이스-연결-오류">2. 데이터베이스 연결 오류</h2>
<h3 id="문제-상황-1">문제 상황</h3>
<pre><code>Communications link failure
The last packet sent successfully to the server was 0 milliseconds ago.</code></pre><p>애플리케이션 실행 시 MySQL 연결 실패.</p>
<h3 id="원인-1">원인</h3>
<ol>
<li>Docker 컨테이너가 실행되지 않음</li>
<li><code>application.yaml</code>의 DB 설정이 <code>.env</code> 파일과 불일치</li>
</ol>
<h3 id="해결-방법-1">해결 방법</h3>
<p><strong>1단계: Docker 컨테이너 확인</strong></p>
<pre><code class="language-bash"># 컨테이너 상태 확인
docker-compose ps

# 컨테이너가 없으면 실행
docker-compose up -d

# 로그 확인
docker-compose logs mysql</code></pre>
<p><strong>2단계: application.yaml 수정</strong></p>
<pre><code class="language-yaml">spring:
  datasource:
    url: jdbc:mysql://localhost:3307/coffee_order?useSSL=false&amp;serverTimezone=Asia/Seoul&amp;characterEncoding=UTF-8
    username: root
    password: 1234
    driver-class-name: com.mysql.cj.jdbc.Driver</code></pre>
<p><strong>3단계: .env 파일 확인</strong></p>
<pre><code class="language-env">DB_PORT=3307
DB_USERNAME=root
DB_PASSWORD=1234
DB_NAME=coffee_order</code></pre>
<p><strong>참고</strong>: </p>
<ul>
<li>로컬 MySQL이 3306 포트를 사용 중이므로 Docker는 3307 포트 사용</li>
<li>username은 <code>coffee_user</code>가 아닌 <code>root</code> 사용 (간단한 로컬 개발 환경)</li>
</ul>
<hr>
<h2 id="3-비밀번호-인증-실패">3. 비밀번호 인증 실패</h2>
<h3 id="문제-상황-2">문제 상황</h3>
<pre><code>이메일 또는 비밀번호가 일치하지 않습니다</code></pre><p>Postman에서 로그인 시도 시 계속 실패. DB에 저장된 비밀번호와 입력한 비밀번호가 일치하는데도 실패.</p>
<h3 id="원인-2">원인</h3>
<ul>
<li>DB에 저장된 BCrypt 해시값이 잘못됨</li>
<li>SQL 초기화 스크립트에서 사용한 해시값이 실제 비밀번호와 매칭되지 않음</li>
</ul>
<h3 id="해결-방법-2">해결 방법</h3>
<p><strong>1단계: 올바른 BCrypt 해시 생성</strong></p>
<p>온라인 BCrypt 생성기 또는 Java 코드로 생성:</p>
<pre><code class="language-java">BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String hash = encoder.encode(&quot;admin123&quot;);
System.out.println(hash);</code></pre>
<p><strong>2단계: SQL 스크립트 수정</strong></p>
<pre><code class="language-sql">-- ❌ 잘못된 해시
INSERT INTO users (email, password, name, role) VALUES
(&#39;admin@coffee.com&#39;, &#39;$2a$10$wronghash...&#39;, &#39;관리자&#39;, &#39;ADMIN&#39;);

-- ✅ 올바른 해시
INSERT INTO users (email, password, name, role) VALUES
(&#39;admin@coffee.com&#39;, &#39;$2a$10$YourCorrectHashHere...&#39;, &#39;관리자&#39;, &#39;ADMIN&#39;);</code></pre>
<p><strong>3단계: DB 재생성</strong></p>
<pre><code class="language-bash"># Docker 컨테이너 중지 및 볼륨 삭제
docker-compose down -v

# 다시 시작 (초기화 스크립트 재실행)
docker-compose up -d</code></pre>
<p><strong>검증된 비밀번호</strong>:</p>
<ul>
<li><code>admin123</code> → <code>$2a$10$...</code> (관리자)</li>
<li><code>user123</code> → <code>$2a$10$...</code> (일반 사용자)</li>
</ul>
<hr>
<h2 id="4-한글-인코딩-깨짐">4. 한글 인코딩 깨짐</h2>
<h3 id="문제-상황-3">문제 상황</h3>
<pre><code class="language-json">{
  &quot;menuId&quot;: 8,
  &quot;name&quot;: &quot;ì•„ì´ìŠ¤í‹°&quot;,
  &quot;description&quot;: &quot;ìƒí¼í•œ ì•„ì´ìŠ¤í‹°&quot;
}</code></pre>
<p>Postman에서 메뉴 조회 시 한글이 깨져서 표시됨. DB에는 정상적으로 저장되어 있음.</p>
<h3 id="원인-3">원인</h3>
<ol>
<li>SQL 초기화 스크립트 파일이 UTF-8로 저장되지 않음</li>
<li>MySQL 연결 시 characterEncoding 설정 누락</li>
<li>Spring Boot 응답 인코딩 설정 누락</li>
</ol>
<h3 id="해결-방법-3">해결 방법</h3>
<p><strong>1단계: SQL 파일 인코딩 확인</strong></p>
<pre><code class="language-bash"># IntelliJ에서 파일 인코딩 확인
# 우측 하단 인코딩 표시 → UTF-8로 변경
# &quot;Reload&quot; 선택 시 기존 내용 손상 가능
# &quot;Convert&quot; 선택 권장</code></pre>
<p><strong>2단계: SQL 스크립트에 인코딩 설정 추가</strong></p>
<pre><code class="language-sql">-- 파일 최상단에 추가
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;

CREATE DATABASE IF NOT EXISTS coffee_order
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;</code></pre>
<p><strong>3단계: application.yaml 수정</strong></p>
<pre><code class="language-yaml">spring:
  datasource:
    url: jdbc:mysql://localhost:3307/coffee_order?useSSL=false&amp;serverTimezone=Asia/Seoul&amp;characterEncoding=UTF-8</code></pre>
<p><strong>4단계: WebConfig 추가</strong> (선택사항)</p>
<pre><code class="language-java">@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void configureMessageConverters(List&lt;HttpMessageConverter&lt;?&gt;&gt; converters) {
        converters.stream()
            .filter(converter -&gt; converter instanceof MappingJackson2HttpMessageConverter)
            .forEach(converter -&gt; {
                MappingJackson2HttpMessageConverter jsonConverter = 
                    (MappingJackson2HttpMessageConverter) converter;
                jsonConverter.setDefaultCharset(StandardCharsets.UTF_8);
            });
    }
}</code></pre>
<p><strong>5단계: DB 재생성</strong></p>
<pre><code class="language-bash">docker-compose down -v
docker-compose up -d</code></pre>
<p><strong>참고</strong>: </p>
<ul>
<li>SQL 파일을 UTF-8로 저장하는 것이 가장 중요</li>
<li>기존 데이터가 깨진 경우 반드시 DB 재생성 필요</li>
<li>새로 생성한 메뉴는 정상 표시되지만 초기 데이터는 깨질 수 있음</li>
</ul>
<hr>
<h2 id="5-querydsl-q클래스-생성-오류">5. QueryDSL Q클래스 생성 오류</h2>
<h3 id="문제-상황-4">문제 상황</h3>
<pre><code class="language-java">import static kr.spartaclub.coffeeorder.domain.user.entity.QUser.user;
// Cannot resolve symbol &#39;QUser&#39;</code></pre>
<p>QueryDSL 사용 시 Q클래스를 찾을 수 없음.</p>
<h3 id="원인-4">원인</h3>
<ul>
<li>Q클래스가 생성되지 않음</li>
<li>IDE가 생성된 Q클래스를 인식하지 못함</li>
</ul>
<h3 id="해결-방법-4">해결 방법</h3>
<p><strong>1단계: Q클래스 생성</strong></p>
<pre><code class="language-bash">./gradlew clean compileJava</code></pre>
<p><strong>2단계: IDE 새로고침</strong></p>
<pre><code>IntelliJ IDEA:
1. File → Invalidate Caches / Restart
2. 또는 Gradle 탭 → Reload All Gradle Projects</code></pre><p><strong>3단계: 생성 확인</strong></p>
<pre><code>build/generated/querydsl/
└── kr/spartaclub/coffeeorder/domain/
    ├── user/entity/QUser.java
    ├── menu/entity/QMenu.java
    └── ...</code></pre><p><strong>4단계: import 수정</strong></p>
<pre><code class="language-java">// ✅ 올바른 import
import static kr.spartaclub.coffeeorder.domain.user.entity.QUser.user;

// QueryDSL 사용
JPAQueryFactory queryFactory;
List&lt;User&gt; users = queryFactory
    .selectFrom(user)
    .where(user.email.eq(&quot;test@example.com&quot;))
    .fetch();</code></pre>
<hr>
<h2 id="6-jwt-토큰-생성-오류">6. JWT 토큰 생성 오류</h2>
<h3 id="문제-상황-5">문제 상황</h3>
<pre><code>java.lang.UnsupportedOperationException
    at java.base/java.util.Collections$UnmodifiableMap.put</code></pre><p>JWT 토큰 생성 시 Claims 객체에 값을 추가할 수 없음.</p>
<h3 id="원인-5">원인</h3>
<ul>
<li><code>Jwts.claims()</code> 메서드가 불변(Immutable) Map을 반환</li>
<li>생성 후 값을 추가하려고 시도하여 오류 발생</li>
</ul>
<h3 id="해결-방법-5">해결 방법</h3>
<p><strong>❌ 잘못된 코드</strong>:</p>
<pre><code class="language-java">Claims claims = Jwts.claims();
claims.put(&quot;userId&quot;, userId);  // UnsupportedOperationException
claims.put(&quot;email&quot;, email);
claims.put(&quot;role&quot;, role);</code></pre>
<p><strong>✅ 올바른 코드</strong>:</p>
<pre><code class="language-java">// Builder 패턴 사용
String token = Jwts.builder()
    .claim(&quot;userId&quot;, userId)
    .claim(&quot;email&quot;, email)
    .claim(&quot;role&quot;, role)
    .setIssuedAt(new Date())
    .setExpiration(new Date(System.currentTimeMillis() + expiration))
    .signWith(key, SignatureAlgorithm.HS256)
    .compact();</code></pre>
<hr>
<h2 id="7-currentuser-어노테이션-오류">7. @CurrentUser 어노테이션 오류</h2>
<h3 id="문제-상황-6">문제 상황</h3>
<pre><code>java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Long</code></pre><p><code>@CurrentUser Long userId</code> 사용 시 String을 Long으로 캐스팅할 수 없다는 오류.</p>
<h3 id="원인-6">원인</h3>
<ul>
<li><code>@CurrentUser</code> 어노테이션이 email(String)을 반환하도록 구현됨</li>
<li>컨트롤러에서는 userId(Long)를 기대함</li>
</ul>
<h3 id="해결-방법-6">해결 방법</h3>
<p><strong>1단계: UserPrincipal 클래스 생성</strong></p>
<pre><code class="language-java">@Getter
@AllArgsConstructor
public class UserPrincipal {
    private Long userId;
    private String email;
    private String role;
}</code></pre>
<p><strong>2단계: JwtTokenProvider 수정</strong></p>
<pre><code class="language-java">public Authentication getAuthentication(String token) {
    Claims claims = parseClaims(token);

    UserPrincipal principal = new UserPrincipal(
        claims.get(&quot;userId&quot;, Long.class),
        claims.get(&quot;email&quot;, String.class),
        claims.get(&quot;role&quot;, String.class)
    );

    return new UsernamePasswordAuthenticationToken(
        principal, 
        &quot;&quot;, 
        getAuthorities(claims)
    );
}</code></pre>
<p><strong>3단계: CurrentUserArgumentResolver 수정</strong></p>
<pre><code class="language-java">@Override
public Object resolveArgument(
    MethodParameter parameter,
    ModelAndViewContainer mavContainer,
    NativeWebRequest webRequest,
    WebDataBinderFactory binderFactory
) {
    Authentication authentication = SecurityContextHolder
        .getContext()
        .getAuthentication();

    if (authentication == null || 
        !(authentication.getPrincipal() instanceof UserPrincipal)) {
        return null;
    }

    return authentication.getPrincipal();  // UserPrincipal 반환
}</code></pre>
<p><strong>4단계: 컨트롤러 수정</strong></p>
<pre><code class="language-java">// ❌ 이전
@GetMapping
public ResponseEntity&lt;?&gt; getOrders(@CurrentUser Long userId) {
    // ...
}

// ✅ 현재
@GetMapping
public ResponseEntity&lt;?&gt; getOrders(@CurrentUser UserPrincipal principal) {
    Long userId = principal.getUserId();
    // ...
}</code></pre>
<hr>
<h2 id="8-주문-생성-시-repository-오류">8. 주문 생성 시 Repository 오류</h2>
<h3 id="문제-상황-7">문제 상황</h3>
<pre><code>org.springframework.data.repository.query.QueryCreationException: 
Could not create query for public abstract long 
kr.spartaclub.coffeeorder.domain.order.repository.OrderRepository.countByMenuId(java.lang.Long); 
No property &#39;menuId&#39; found for type &#39;Order&#39;</code></pre><p>애플리케이션 실행 시 OrderRepository의 메서드를 찾을 수 없음.</p>
<h3 id="원인-7">원인</h3>
<ul>
<li>Order 엔티티에서 <code>menuId</code> 필드를 제거했지만</li>
<li>OrderRepository에 <code>countByMenuId()</code> 메서드가 남아있음</li>
<li>다중 항목 주문 시스템으로 전환하면서 발생한 불일치</li>
</ul>
<h3 id="해결-방법-7">해결 방법</h3>
<p><strong>1단계: 불필요한 메서드 제거</strong></p>
<pre><code class="language-java">// OrderRepository.java

// ❌ 제거: Order 엔티티에 menuId가 없음
// long countByMenuId(Long menuId);
// List&lt;Order&gt; findByMenuIdOrderByOrderTimeDesc(Long menuId, Pageable pageable);

// ✅ 유지: 필요한 메서드만
List&lt;Order&gt; findByUserIdOrderByOrderTimeDesc(Long userId, Pageable pageable);
Page&lt;Order&gt; findByUserIdAndOrderTimeBetween(
    Long userId, 
    LocalDateTime startDate, 
    LocalDateTime endDate, 
    Pageable pageable
);</code></pre>
<p><strong>2단계: 메뉴별 통계는 OrderItem 사용</strong></p>
<pre><code class="language-java">// OrderItemRepository.java
List&lt;OrderItem&gt; findByMenuId(Long menuId);
long countByMenuId(Long menuId);</code></pre>
<p><strong>3단계: 애플리케이션 재시작</strong></p>
<pre><code class="language-bash">./gradlew clean build
./gradlew bootRun</code></pre>
<hr>
<h2 id="9-docker-mysql-재시작-후-데이터-손실">9. Docker MySQL 재시작 후 데이터 손실</h2>
<h3 id="문제-상황-8">문제 상황</h3>
<pre><code class="language-bash">docker-compose restart
# 재시작 후 테이블은 있지만 데이터가 없음</code></pre>
<p>Docker 컨테이너 재시작 후 초기 데이터가 사라짐.</p>
<h3 id="원인-8">원인</h3>
<ul>
<li><code>docker-compose restart</code>는 초기화 스크립트를 재실행하지 않음</li>
<li>초기화 스크립트는 컨테이너 최초 생성 시에만 실행됨</li>
</ul>
<h3 id="해결-방법-8">해결 방법</h3>
<p><strong>데이터 완전 초기화가 필요한 경우</strong>:</p>
<pre><code class="language-bash"># 1. 컨테이너와 볼륨 삭제
docker-compose down -v

# 2. 다시 시작 (초기화 스크립트 실행됨)
docker-compose up -d

# 3. 로그 확인
docker-compose logs mysql</code></pre>
<p><strong>데이터 유지하면서 재시작</strong>:</p>
<pre><code class="language-bash"># 볼륨 유지하고 재시작
docker-compose restart mysql</code></pre>
<p><strong>수동으로 데이터 재삽입</strong>:</p>
<pre><code class="language-bash"># MySQL 컨테이너 접속
docker exec -it coffee-order-mysql mysql -uroot -p1234 coffee_order

# SQL 파일 실행
source /docker-entrypoint-initdb.d/01-init.sql;</code></pre>
<p><strong>참고</strong>:</p>
<ul>
<li><code>-v</code> 옵션은 볼륨(데이터)을 삭제함</li>
<li>개발 중에는 <code>down -v</code> 후 <code>up -d</code>로 깨끗하게 초기화하는 것을 권장</li>
<li>프로덕션에서는 절대 <code>-v</code> 옵션 사용 금지</li>
</ul>
<hr>
<h2 id="10-다중-항목-주문-시스템-전환">10. 다중 항목 주문 시스템 전환</h2>
<h3 id="문제-상황-9">문제 상황</h3>
<p>단일 메뉴만 주문 가능한 시스템을 여러 메뉴를 한 번에 주문할 수 있도록 변경 필요.</p>
<h3 id="변경-사항">변경 사항</h3>
<p><strong>1단계: 데이터베이스 스키마 변경</strong></p>
<p><strong>orders 테이블</strong>:</p>
<pre><code class="language-sql">-- ❌ 이전
CREATE TABLE orders (
    order_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    menu_id BIGINT NOT NULL,  -- 제거
    price INT NOT NULL,        -- 제거
    order_time DATETIME NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- ✅ 현재
CREATE TABLE orders (
    order_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    total_price INT NOT NULL,  -- 추가
    order_time DATETIME NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);</code></pre>
<p><strong>order_items 테이블 추가</strong>:</p>
<pre><code class="language-sql">CREATE TABLE order_items (
    order_item_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_id BIGINT NOT NULL,
    menu_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    price INT NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE CASCADE,
    FOREIGN KEY (menu_id) REFERENCES menus(menu_id) ON DELETE RESTRICT
);</code></pre>
<p><strong>2단계: 엔티티 수정</strong></p>
<p><strong>Order 엔티티</strong>:</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;orders&quot;)
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;order_id&quot;)
    private Long id;

    @Column(name = &quot;user_id&quot;, nullable = false)
    private Long userId;

    // ❌ 제거
    // private Long menuId;
    // private Integer price;

    // ✅ 추가
    @Column(name = &quot;total_price&quot;, nullable = false)
    private Integer totalPrice;

    @Column(name = &quot;order_time&quot;, nullable = false)
    private LocalDateTime orderTime;
}</code></pre>
<p><strong>OrderItem 엔티티 추가</strong>:</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;order_items&quot;)
public class OrderItem extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;order_item_id&quot;)
    private Long id;

    @Column(name = &quot;order_id&quot;, nullable = false)
    private Long orderId;

    @Column(name = &quot;menu_id&quot;, nullable = false)
    private Long menuId;

    @Column(nullable = false)
    private Integer quantity;

    @Column(nullable = false)
    private Integer price;

    public int getSubtotal() {
        return price * quantity;
    }
}</code></pre>
<p><strong>3단계: DTO 수정</strong></p>
<p><strong>CreateOrderRequest</strong>:</p>
<pre><code class="language-java">// ❌ 이전
public class CreateOrderRequest {
    private Long menuId;
}

// ✅ 현재
public class CreateOrderRequest {
    @NotEmpty(message = &quot;주문 항목은 최소 1개 이상이어야 합니다&quot;)
    private List&lt;OrderItemRequest&gt; items;

    @Getter
    @AllArgsConstructor
    public static class OrderItemRequest {
        @NotNull(message = &quot;메뉴 ID는 필수입니다&quot;)
        private Long menuId;

        @NotNull(message = &quot;수량은 필수입니다&quot;)
        @Positive(message = &quot;수량은 1개 이상이어야 합니다&quot;)
        private Integer quantity;
    }
}</code></pre>
<p><strong>OrderResponse</strong>:</p>
<pre><code class="language-java">// ✅ 현재
@Getter
@Builder
public class OrderResponse {
    private Long orderId;
    private Integer totalPrice;
    private Integer remainingBalance;
    private LocalDateTime orderTime;
    private List&lt;OrderItemResponse&gt; items;

    @Getter
    @Builder
    public static class OrderItemResponse {
        private Long menuId;
        private String menuName;
        private Integer quantity;
        private Integer price;
        private Integer subtotal;  // price * quantity
    }
}</code></pre>
<p><strong>4단계: 서비스 로직 수정</strong></p>
<pre><code class="language-java">@Transactional
public OrderResponse createOrder(Long userId, CreateOrderRequest request) {
    // 1. 모든 메뉴 조회 및 검증
    List&lt;Long&gt; menuIds = request.getItems().stream()
        .map(item -&gt; item.getMenuId())
        .collect(Collectors.toList());

    Map&lt;Long, Menu&gt; menuMap = menuRepository.findAllById(menuIds)
        .stream()
        .collect(Collectors.toMap(Menu::getId, menu -&gt; menu));

    // 2. 총 금액 계산
    int totalPrice = 0;
    List&lt;OrderItemResponse&gt; itemResponses = new ArrayList&lt;&gt;();

    for (OrderItemRequest itemRequest : request.getItems()) {
        Menu menu = menuMap.get(itemRequest.getMenuId());
        if (menu == null) {
            throw new MenuNotFoundException(itemRequest.getMenuId());
        }
        if (!menu.isAvailable()) {
            throw new OrderException(ErrorCode.ORDER_002);
        }

        int subtotal = menu.getPrice() * itemRequest.getQuantity();
        totalPrice += subtotal;

        itemResponses.add(OrderItemResponse.of(
            menu.getId(),
            menu.getName(),
            itemRequest.getQuantity(),
            menu.getPrice()
        ));
    }

    // 3. 포인트 차감
    int remainingBalance = userPointService.usePoint(userId, totalPrice, &quot;주문 결제&quot;);

    // 4. 주문 생성
    Order order = Order.create(userId, totalPrice);
    Order savedOrder = orderRepository.save(order);

    // 5. 주문 상세 생성
    for (OrderItemRequest itemRequest : request.getItems()) {
        Menu menu = menuMap.get(itemRequest.getMenuId());
        OrderItem orderItem = OrderItem.create(
            savedOrder.getId(),
            menu.getId(),
            itemRequest.getQuantity(),
            menu.getPrice()
        );
        orderItemRepository.save(orderItem);

        // 6. 메뉴 통계 업데이트 (수량만큼)
        updateMenuStatistics(menu.getId(), itemRequest.getQuantity());
    }

    return OrderResponse.of(savedOrder, itemResponses, remainingBalance);
}</code></pre>
<p><strong>5단계: Repository 정리</strong></p>
<pre><code class="language-java">// OrderRepository.java
// ❌ 제거: Order에 menuId가 없음
// long countByMenuId(Long menuId);
// List&lt;Order&gt; findByMenuIdOrderByOrderTimeDesc(Long menuId, Pageable pageable);

// OrderItemRepository.java 추가
public interface OrderItemRepository extends JpaRepository&lt;OrderItem, Long&gt; {
    List&lt;OrderItem&gt; findByOrderId(Long orderId);
    List&lt;OrderItem&gt; findByOrderIdIn(List&lt;Long&gt; orderIds);
    List&lt;OrderItem&gt; findByMenuId(Long menuId);
}</code></pre>
<p><strong>6단계: 데이터베이스 마이그레이션</strong></p>
<pre><code class="language-bash"># 1. 기존 데이터 백업 (필요시)
docker exec coffee-order-mysql mysqldump -uroot -p1234 coffee_order &gt; backup.sql

# 2. 컨테이너와 볼륨 삭제
docker-compose down -v

# 3. 새 스키마로 재시작
docker-compose up -d

# 4. 확인
docker exec -it coffee-order-mysql mysql -uroot -p1234 coffee_order
SHOW TABLES;
DESC orders;
DESC order_items;</code></pre>
<p><strong>7단계: 문서 업데이트</strong></p>
<ul>
<li>ERD 설계서: orders, order_items 테이블 구조 수정</li>
<li>API 명세서: 주문 생성 요청/응답 형식 수정</li>
<li>기능 명세서: F-008 주문 생성 로직 수정</li>
<li>README: 주요 특징에 &quot;다중 항목 주문&quot; 추가</li>
</ul>
<hr>
<h2 id="🔍-일반적인-디버깅-팁">🔍 일반적인 디버깅 팁</h2>
<h3 id="1-로그-확인">1. 로그 확인</h3>
<pre><code class="language-bash"># 애플리케이션 로그
./gradlew bootRun

# Docker 로그
docker-compose logs -f mysql
docker-compose logs -f redis
docker-compose logs -f kafka</code></pre>
<h3 id="2-데이터베이스-직접-확인">2. 데이터베이스 직접 확인</h3>
<pre><code class="language-bash"># MySQL 접속
docker exec -it coffee-order-mysql mysql -uroot -p1234 coffee_order

# 테이블 확인
SHOW TABLES;
DESC users;
SELECT * FROM users;</code></pre>
<h3 id="3-redis-확인">3. Redis 확인</h3>
<pre><code class="language-bash"># Redis CLI 접속
docker exec -it coffee-order-redis redis-cli -a redis_password

# 키 확인
KEYS *
GET lock:user:1:point</code></pre>
<h3 id="4-kafka-확인">4. Kafka 확인</h3>
<pre><code class="language-bash"># Kafka UI 접속
http://localhost:8989

# 또는 CLI
docker exec -it coffee-order-kafka bash
kafka-topics --bootstrap-server localhost:9092 --list</code></pre>
<h3 id="5-포트-충돌-확인">5. 포트 충돌 확인</h3>
<pre><code class="language-bash"># Windows
netstat -ano | findstr :8080
netstat -ano | findstr :3307

# 프로세스 종료
taskkill /PID &lt;PID&gt; /F</code></pre>
<hr>
<h2 id="📚-참고-자료">📚 참고 자료</h2>
<h3 id="공식-문서">공식 문서</h3>
<ul>
<li><a href="https://docs.spring.io/spring-boot/docs/current/reference/html/">Spring Boot Documentation</a></li>
<li><a href="http://querydsl.com/static/querydsl/latest/reference/html/">QueryDSL Reference</a></li>
<li><a href="https://docs.docker.com/compose/">Docker Compose Documentation</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/">MySQL 8.0 Reference</a></li>
</ul>
<h3 id="프로젝트-문서">프로젝트 문서</h3>
<ul>
<li><a href="https://github.com/hoon3948/coffee-order/blob/main/docs/SA/01-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B0%9C%EC%9A%94%EC%84%9C.md">프로젝트 개요서</a></li>
<li><a href="https://github.com/hoon3948/coffee-order/blob/main/docs/SA/05-ERD-%EC%84%A4%EA%B3%84%EC%84%9C.md">ERD 설계서</a></li>
<li><a href="https://github.com/hoon3948/coffee-order/blob/main/docs/SA/06-API-%EB%AA%85%EC%84%B8%EC%84%9C.md">API 명세서</a></li>
<li><a href="https://github.com/hoon3948/coffee-order/blob/main/docs/SA/09-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%EC%84%A4%EA%B3%84%EC%84%9C.md">동시성 제어 설계서</a></li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day89]]></title>
            <link>https://velog.io/@rxg_1211/Day89</link>
            <guid>https://velog.io/@rxg_1211/Day89</guid>
            <pubDate>Fri, 08 May 2026 00:35:11 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="customers-who-bought-all-products"><a href="https://leetcode.com/problems/customers-who-bought-all-products/description/">Customers Who Bought All Products</a></h3>
<pre><code class="language-sql">select customer_id
from Customer
group by customer_id
having count(distinct product_key) &gt;= (
    select count(distinct product_key) as count
    from Product
);</code></pre>
<h3 id="jadencase-문자열-만들기">J<a href="https://school.programmers.co.kr/learn/courses/30/lessons/12951">adenCase 문자열 만들기</a></h3>
<pre><code class="language-java">class Solution {
    public String solution(String s) {
        StringBuilder answer = new StringBuilder();

        String[] words = s.split(&quot; &quot;, -1);

        for (String word : words) {
            if(word.isEmpty()){
                answer.append(&quot; &quot;);
                continue;
            }

            if(word.charAt(0) == &#39; &#39; &amp;&amp; word.length() &gt; 1){
                int count = counting(word);

                String newW = Character.toUpperCase(word.charAt(count)) + word.substring(count + 1);

                for (int i = 0; i &lt; count; i++) {
                    answer.append(&quot; &quot;);
                }

                answer.append(newW).append(&quot; &quot;);
            }else {
                String newWord = Character.toUpperCase(word.charAt(0)) + word.substring(1).toLowerCase();
                answer.append(newWord).append(&quot; &quot;);
            }
        }

        return answer.deleteCharAt(answer.length() - 1).toString();
    }

    public int counting(String word){
        int count = 0;

        char[] w = word.toCharArray();

        for (char c : w) {
            if(c == &#39; &#39;){
                count++;
            }
        }

        return count;
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day88]]></title>
            <link>https://velog.io/@rxg_1211/Day88</link>
            <guid>https://velog.io/@rxg_1211/Day88</guid>
            <pubDate>Thu, 07 May 2026 00:40:03 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="biggest-single-number"><a href="https://leetcode.com/problems/biggest-single-number/description/">Biggest Single Number</a></h3>
<pre><code class="language-sql">SELECT (
    SELECT num
    FROM MyNumbers
    GROUP BY num
    HAVING COUNT(num) = 1
    ORDER BY num DESC
    LIMIT 1
) AS num;</code></pre>
<ul>
<li><code>ifnull</code>로 해결하려했지만 <code>having</code>과 <code>limit</code>로 행자체가 없어져서 <code>null</code>이 출력되지않음</li>
<li><blockquote>
<p>전체를 <code>select</code>로 감싸서 해결</p>
</blockquote>
</li>
</ul>
<h3 id="최댓값과-최솟값"><a href="https://school.programmers.co.kr/learn/courses/30/lessons/12939">최댓값과 최솟값</a></h3>
<pre><code class="language-java">import java.util.Arrays;

class Solution {
    public String solution(String s) {
        String answer = &quot;&quot;;

        int[] num = Arrays.stream(s.split(&quot; &quot;))
                .mapToInt(Integer::parseInt)
                .sorted()
                .toArray();
        answer = num[0] + &quot; &quot; + num[num.length -1];
        return answer;
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day87]]></title>
            <link>https://velog.io/@rxg_1211/Day87</link>
            <guid>https://velog.io/@rxg_1211/Day87</guid>
            <pubDate>Wed, 06 May 2026 00:12:51 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="find-followers-count"><a href="https://leetcode.com/problems/find-followers-count/description/">Find Followers Count</a></h3>
<pre><code class="language-sql">select user_id, count(distinct follower_id) as followers_count
from Followers
group by user_id;</code></pre>
<h3 id="신고-결과-받기"><a href="https://school.programmers.co.kr/learn/courses/30/lessons/92334">신고 결과 받기</a></h3>
<pre><code class="language-java">import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

class Solution {
    public int[] solution(String[] id_list, String[] report, int k) {
        int[] answer = new int[id_list.length];

        Arrays.fill(answer, 0);

        for(String id: id_list){
            List&lt;Integer&gt; reportedId = new ArrayList&lt;&gt;();

            for (String r : report){
                String[] arr = r.split(&quot; &quot;);

                if (!arr[1].equals(id)){
                    continue;
                }

                int index = finding(id_list, arr[0]);
//                System.out.println(&quot;index : &quot; + index);

                if (index &lt; 0){
                    continue;
                }
                if (reportedId.isEmpty()){
                    reportedId.add(index);
                    continue;
                }
                if (!reportedId.contains(index)){
                    reportedId.add(index);
                }
            }

//            System.out.println(&quot;reportedId : &quot; + reportedId);
            if (reportedId.size() &lt; k){
                continue;
            }
            for (int i: reportedId){
                answer[i]++;
            }
        }

        return answer;
    }

    public int finding(String[] id_list, String nickname){
        int index = -1;

        for(int i = 0; i &lt; id_list.length; i++){
            if(id_list[i].equals(nickname)){
                index = i;
                break;
            }
        }

        return index;
    }
}</code></pre>
<ul>
<li>런타임 에러가 발생해서 아래로 수정</li>
</ul>
<pre><code class="language-java">import java.util.*;

class Solution {
    public int[] solution(String[] id_list, String[] report, int k) {
        int[] answer = new int[id_list.length];

        // 중복 신고 처리를 쉽게 하기 위해 List 대신 Set(HashSet)을 사용합니다.
        Map&lt;String, Set&lt;String&gt;&gt; reportList = new HashMap&lt;&gt;();
        Map&lt;String, Integer&gt; reportCount = new HashMap&lt;&gt;();

        for (String r : report) {
            String[] parts = r.split(&quot; &quot;);

            // 컴파일 에러 수정 및 초기화 로직 분리
            reportList.putIfAbsent(parts[0], new HashSet&lt;&gt;());
            // Set.add()는 최초 추가 시에만 true를 반환하므로 중복 카운팅을 방지할 수 있습니다.
            if (reportList.get(parts[0]).add(parts[1])) {
                // 신고자가 아닌 &#39;신고 당한 사람&#39;의 카운트를 증가시킵니다.
                reportCount.put(parts[1], reportCount.getOrDefault(parts[1], 0) + 1);
            }
        }

        for (int i = 0; i &lt; id_list.length; i++) {
            // 신고 기록이 없는 유저에 대한 NullPointerException을 방지합니다.
            Set&lt;String&gt; part = reportList.getOrDefault(id_list[i], new HashSet&lt;&gt;());
            for (String p : part) {
                if (reportCount.getOrDefault(p, 0) &gt;= k) {
                    // indexOf를 매번 호출하지 않고 i 인덱스를 직접 사용하여 성능을 향상시킵니다.
                    answer[i]++;
                }
            }
        }

        return answer;
    }
}</code></pre>
<ul>
<li><code>map</code> 너무 어렵다...</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day86]]></title>
            <link>https://velog.io/@rxg_1211/Day86</link>
            <guid>https://velog.io/@rxg_1211/Day86</guid>
            <pubDate>Mon, 04 May 2026 00:08:51 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="classes-with-at-least-5-students"><a href="https://leetcode.com/problems/classes-with-at-least-5-students/description/">Classes With at Least 5 Students</a></h3>
<pre><code class="language-sql">select class
from Courses
group by class
having count(student) &gt;= 5
;</code></pre>
<h3 id="공원-산책"><a href="https://school.programmers.co.kr/learn/courses/30/lessons/172928">공원 산책</a></h3>
<pre><code class="language-java">import java.util.ArrayList;
import java.util.List;

class Solution {
    public int[] solution(String[] park, String[] routes) {
        int[] answer = findstart(park);

        for (String route : routes) {
            char op = route.charAt(0);
            int index = Integer.parseInt(route.substring(2));

            boolean bool = false;

            switch (op) {
                case &#39;N&#39;:
                    bool = movingy(answer[1], answer[0], park, -index);
                    if (bool) {
                        answer[0] -= index;
                    }
                    break;
                case &#39;S&#39;:
                    bool = movingy(answer[1], answer[0], park, index);
                    if (bool) {
                        answer[0] += index;
                    }
                    break;
                case &#39;W&#39;:
                    bool = movingx(answer[1], answer[0], park, -index);
                    if (bool) {
                        answer[1] -= index;
                    }
                    break;
                case &#39;E&#39;:
                    bool = movingx(answer[1], answer[0], park, index);
                    if (bool) {
                        answer[1] += index;
                    }
                    break;
                default:
                    break;
            }
        }

        return answer;
    }

    public int[] findstart(String[] park){
        int[] answer = {0,0};

        for (int i = 0; i &lt; park.length; i++) {
            int index = park[i].indexOf(&quot;S&quot;);
            if (index != -1) {
                answer[0] = i;
                answer[1] = index;
            }
        }

        return answer;
    }

    public boolean movingx(int x, int y, String[] park, int d) {
        if (x + d &lt; 0 || x + d &gt;= park[0].length()) {
            return false;
        }

        List&lt;Integer&gt; indexl = new ArrayList&lt;&gt;();

        char[] chars = park[y].toCharArray();
        for (int i = 0; i&lt;chars.length; i++){
            if (chars[i] == &#39;X&#39;){
                indexl.add(i);
            }
        }

        if (indexl.size() == 0) {
            return true;
        }else {
            int sum = 0;

            for (Integer index : indexl) {
                if (!check(x, d, index)) {
                    sum++;
                }
            }

            if (sum == 0) {
                return true;
            }
        }

        return false;
    }

    public boolean movingy(int x, int y, String[] park, int d) {
        if (y + d &lt; 0 || y + d &gt;= park.length) {
            return false;
        }

        StringBuilder yindex = new StringBuilder();
        List&lt;Integer&gt; indexl = new ArrayList&lt;&gt;();

        for (int i = 0; i &lt; park.length; i++) {
            yindex.append(park[i].charAt(x));
            if (park[i].charAt(x) == &#39;X&#39;) {
                indexl.add(i);
            }
        }

        if (indexl.size() == 0) {
            return true;
        }else {
            int sum = 0;

            for (Integer index : indexl) {
                if (!check(y, d, index)) {
                    sum++;
                }
            }

            if (sum == 0) {
                return true;
            }
        }

        return false;
    }

    public boolean check(int target, int d, int index){
        if (d &gt;= 0) {
            if (index &gt; target + d || index &lt; target) {
                return true;
            }
        } else {
            if (index &gt; target || index &lt; target + d) {
                return true;
            }
        }

        return false;
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day85]]></title>
            <link>https://velog.io/@rxg_1211/Day85</link>
            <guid>https://velog.io/@rxg_1211/Day85</guid>
            <pubDate>Fri, 01 May 2026 01:28:56 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="product-sales-analysis-iii">Product Sales Analysis III</h3>
<pre><code class="language-sql">select a.product_id, a.year as first_year, a.quantity, a.price
from Sales a
left join (
    select *, ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY year) AS rn
    from Sales
) as b
on a.product_id = b.product_id
and a.year = b.year
where b.rn = 1
order by a.sale_id asc
;</code></pre>
<h3 id="달리기-경주">달리기 경주</h3>
<pre><code class="language-java">import java.util.Arrays;

class Solution {
    public String[] solution(String[] players, String[] callings) {
        String[] answer = players;

        for (String calling : callings){
            int index = Arrays.asList(answer).indexOf(calling);

            if(index == -1 || index == 0){
                continue;
            }

            String temp = answer[index-1];

            answer[index] = temp;
            answer[index-1] = calling;
        }

        return answer;
    }
}</code></pre>
<ul>
<li>런타임 에러가 발생해서 아래로 수정</li>
</ul>
<pre><code class="language-java">import java.util.HashMap;
import java.util.Map;

class Solution {
    public String[] solution(String[] players, String[] callings) {
        String[] answer = players;

        Map&lt;String, Integer&gt; ranking = new HashMap&lt;&gt;();

        for (int i = 0; i &lt; players.length; i++) {
            ranking.put(players[i], i);
        }

        for (String calling : callings) {
            int index = ranking.get(calling);
            String player = answer[index-1];

            answer[index-1] = calling;
            answer[index] = player;

            ranking.put(calling, index-1);
            ranking.put(player, index);
        }

        return answer;
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day84]]></title>
            <link>https://velog.io/@rxg_1211/Day84</link>
            <guid>https://velog.io/@rxg_1211/Day84</guid>
            <pubDate>Thu, 30 Apr 2026 02:18:24 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="user-activity-for-the-past-30-days-i">User Activity for the Past 30 Days I</h3>
<pre><code class="language-sql">select date_format(activity_date, &#39;%Y-%m-%d&#39;) as day, count(distinct user_id) as active_users
from Activity
where activity_date between &#39;2019-06-28&#39; and &#39;2019-07-27&#39;
and activity_type is not null
group by year(activity_date), month(activity_date), day(activity_date)
;</code></pre>
<h3 id="개인정보-수집-유효기간">개인정보 수집 유효기간</h3>
<pre><code class="language-java">import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

class Solution {
    public int[] solution(String today, String[] terms, String[] privacies) {
        List&lt;Integer&gt; answers = new ArrayList&lt;&gt;();
        LocalDate todaydate = LocalDate.parse(today.replace(&quot;.&quot;, &quot;-&quot;));

        for(int i = 0; i &lt; privacies.length; i++){
            LocalDate dateTime = date(terms, privacies[i]);
            System.out.println(i + &quot;번: &quot; + dateTime);
            if (dateTime.isEqual(LocalDate.parse(&quot;1900-01-01&quot;))){
                continue;
            }
            if(dateTime.isBefore(todaydate)){
                answers.add(i+1);
            }
        }

        return answers.stream().mapToInt(Integer::intValue).toArray();
    }
    public static LocalDate date(String[] term, String privacy){
        char a = privacy.charAt(privacy.length()-1);
        String date = privacy.split(&quot; &quot;)[0].replace(&quot;.&quot;, &quot;-&quot;);

        LocalDate dateTime = LocalDate.parse(date);

        for(String t:term){
            if(t.charAt(0) == a){
                int plusMonths = Integer.parseInt(t.split(&quot; &quot;)[1]);
                dateTime = dateTime.plusMonths(plusMonths);
                return dateTime;
            }
        }

        return LocalDate.parse(&quot;1900-01-01&quot;);
    }
}</code></pre>
<ul>
<li>이렇게 하였는데 모든 달은 28일까지로 되어있어서 아래로 수정<pre><code class="language-java">import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
</code></pre>
</li>
</ul>
<p>class Solution {
    public int[] solution(String today, String[] terms, String[] privacies) {
        List<Integer> answers = new ArrayList&lt;&gt;();
        LocalDate todaydate = LocalDate.parse(today.replace(&quot;.&quot;, &quot;-&quot;));</p>
<pre><code>    for(int i = 0; i &lt; privacies.length; i++){
        LocalDate dateTime = date(terms, privacies[i]);
        if (dateTime.isEqual(LocalDate.parse(&quot;1900-01-01&quot;))){
            continue;
        }
        if(dateTime.isBefore(todaydate)){
            answers.add(i+1);
        }
    }

    return answers.stream().mapToInt(Integer::intValue).toArray();
}
public static LocalDate date(String[] term, String privacy){
    char a = privacy.charAt(privacy.length()-1);
    String date = privacy.split(&quot; &quot;)[0];

    for(String t:term){
        if(t.charAt(0) == a){
            int plusMonths = Integer.parseInt(t.split(&quot; &quot;)[1]);
            return dateadded(date, plusMonths);
        }
    }

    return LocalDate.parse(&quot;1900-01-01&quot;);
}

// 한달은 28일로 가정
public static LocalDate dateadded(String date, int month){
    int[] index = Arrays.stream(date.split(&quot;\\.&quot;)).mapToInt(Integer::parseInt).toArray();

    index[2] += month*28 - 1;
    if (index[2] &gt; 28){
        index[1] += index[2] / 28;
        index[2] %= 28;
        if (index[2] == 0){
            index[2] = 28;
            index[1] -= 1;
        }
    }
    if (index[1] &gt; 12){
        index[0] += index[1] / 12;
        index[1] %= 12;
        if (index[1] == 0){
            index[1] = 12;
            index[0] -= 1;
        }
    }

    return LocalDate.parse(String.format(&quot;%d-%02d-%02d&quot;, index[0], index[1], index[2]));
}</code></pre><p>}
```</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day83]]></title>
            <link>https://velog.io/@rxg_1211/Day83</link>
            <guid>https://velog.io/@rxg_1211/Day83</guid>
            <pubDate>Wed, 29 Apr 2026 00:34:41 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="number-of-unique-subjects-taught-by-each-teacher">Number of Unique Subjects Taught by Each Teacher</h3>
<pre><code class="language-sql">select teacher_id, count(distinct subject_id) as cnt
from Teacher
group by teacher_id
;</code></pre>
<h3 id="바탕화면-정리">바탕화면 정리</h3>
<pre><code class="language-java">import java.util.ArrayList;
import java.util.List;

class Solution {
    public int[] solution(String[] wallpaper) {
        int[] answer = {0,0,0,0};
        List&lt;Integer&gt; minmax = new ArrayList&lt;&gt;();
        List&lt;Integer&gt; index = new ArrayList&lt;&gt;();

        for (int w = 0; w &lt; wallpaper.length; w++) {
            boolean filled = false;
            char[] chars = wallpaper[w].toCharArray();

            for (int i = 0; i &lt; chars.length; i++) {
                if (chars[i] == &#39;#&#39;){
                    minmax.add(i);
                    filled = true;
                }
            }

            if (filled){
                index.add(w);
            }
        }

        answer[0] = index.stream().min(Integer::compareTo).get();
        answer[1] = minmax.stream().min(Integer::compareTo).get();
        answer[2] = index.stream().max(Integer::compareTo).get() +1;
        answer[3] = minmax.stream().max(Integer::compareTo).get() +1;

        return answer;
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day82]]></title>
            <link>https://velog.io/@rxg_1211/Day82</link>
            <guid>https://velog.io/@rxg_1211/Day82</guid>
            <pubDate>Tue, 28 Apr 2026 01:39:49 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="game-play-analysis-iv">Game Play Analysis IV</h3>
<pre><code class="language-sql"># Write your MySQL query statement below
WITH ranked AS (
    SELECT a.*, b.event_date as after_event_date, if(datediff(b.event_date, a.event_date) = 1, 1, 3) as date_gap,
            ROW_NUMBER() OVER (PARTITION BY a.player_id 
                                ORDER BY a.event_date asc , 
                                    if(datediff(b.event_date, a.event_date) = 1, 1, 3) asc
            ) AS rn
    FROM Activity a
    left join Activity b
    on a.player_id = b.player_id
)

SELECT ROUND(
    AVG(CASE 
        WHEN date_gap = 1 THEN 1 
        ELSE 0 
    END), 2
) AS fraction
FROM ranked
WHERE rn = 1;</code></pre>
<h3 id="성격-유형-검사하기">성격 유형 검사하기</h3>
<pre><code class="language-java">import java.util.Arrays;

class Solution {
    public String solution(String[] survey, int[] choices) {
        String answer = &quot;&quot;;
        int[] RCJA = {0,0,0,0};
        int[] TFMN = {0,0,0,0};
        String[] surveylist = {&quot;RT&quot;, &quot;TR&quot;, &quot;CF&quot;, &quot;FC&quot;, &quot;JM&quot;, &quot;MJ&quot;, &quot;AN&quot;, &quot;NA&quot;};
        for (int i = 0; i &lt; survey.length; i++) {
            int[] points = {};

            for (int j = 0; j &lt; surveylist.length; j++) {
                if (survey[i].equals(surveylist[j])) {
                    points = point(j, choices[i]);
                }
            }

            if(points[0] == 0){
                RCJA[points[1]-1] += points[2];
            }else{
                TFMN[points[1]-1] += points[2];
            }
        }

        char[][] word = {{&#39;R&#39;, &#39;T&#39;}, {&#39;C&#39;, &#39;F&#39;}, {&#39;J&#39;, &#39;M&#39;}, {&#39;A&#39;, &#39;N&#39;}};
        for (int i = 0; i &lt; RCJA.length; i++) {
            if (RCJA[i] &gt;= TFMN[i]) {
                answer += word[i][0];
            }else {
                answer += word[i][1];
            }
        }
        return answer;
    }

    public int[] point(int i, int choice){
        int[] points = {0,0,0}; // 어느배열인지, 인덱스 위치, 점수

        points[1] = (i+2)/2;

        if(choice == 4){
            points[2] = 0;
        }else if (choice &lt; 4){
            points[2] = 4 - choice;
            points[0] = (i+2)%2;
        }else{
            points[2] = choice - 4;
            points[0] = (i+3)%2;
        }

        return points;
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day81]]></title>
            <link>https://velog.io/@rxg_1211/Day81</link>
            <guid>https://velog.io/@rxg_1211/Day81</guid>
            <pubDate>Mon, 27 Apr 2026 00:33:25 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="immediate-food-delivery-ii">Immediate Food Delivery II</h3>
<pre><code class="language-sql">with deliveries as (
    select *
    FROM Delivery d1
    WHERE order_date = (
        SELECT MIN(order_date)
        FROM Delivery d2
        WHERE d2.customer_id = d1.customer_id
    )
)

select round(avg(case when order_date = customer_pref_delivery_date then 1 else 0 end) * 100, 2) as immediate_percentage
from deliveries
;</code></pre>
<pre><code class="language-sql">WITH ranked AS (
    SELECT *,
           ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) AS rn
    FROM Delivery
)

SELECT ROUND(
    AVG(CASE 
        WHEN order_date = customer_pref_delivery_date THEN 1 
        ELSE 0 
    END), 2
) AS immediate_percentage
FROM ranked
WHERE rn = 1;</code></pre>
<p>윈도우 함수 사용한 해답</p>
<ul>
<li><code>ROW_NUMBER()</code>: 행 번호 매기기 (여기선 <code>rn</code>이 행 번호)</li>
<li><code>PARTITION BY</code>: 그룹 나누기 (<code>GROUP BY</code>랑 비슷하지만 다름)<ul>
<li>파티션 그룹 내에서 정렬이 가능</li>
</ul>
</li>
</ul>
<h3 id="햄버거-만들기">햄버거 만들기</h3>
<pre><code class="language-java">import java.util.ArrayList;
import java.util.List;

class Solution {
    public int solution(int[] ingredient) {
        int answer = 0;

        List&lt;Integer&gt; workbench = new ArrayList&lt;&gt;();

        for (int ing : ingredient) {
            workbench.add(ing);

            if (workbench.size() &gt;= 4) {
                int size = workbench.size();
                if (workbench.get(size - 4) == 1
                        &amp;&amp; workbench.get(size - 3) == 2
                        &amp;&amp; workbench.get(size - 2) == 3
                        &amp;&amp; workbench.get(size - 1) == 1) {

                    answer++;

                    workbench.remove(size - 1);
                    workbench.remove(size - 2);
                    workbench.remove(size - 3);
                    workbench.remove(size - 4);
                }
            }
        }

        return answer;
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day80]]></title>
            <link>https://velog.io/@rxg_1211/Day80</link>
            <guid>https://velog.io/@rxg_1211/Day80</guid>
            <pubDate>Fri, 24 Apr 2026 00:46:21 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="monthly-transactions-i"><a href="https://leetcode.com/problems/monthly-transactions-i/description/">Monthly Transactions I</a></h3>
<pre><code class="language-sql">select date_format(trans_date, &quot;%Y-%m&quot;) as month, country, count(id) as trans_count, 
    sum(case when state = &quot;approved&quot; then 1 else 0 end) as approved_count, 
    sum(amount) as trans_total_amount, 
    sum(case when state = &quot;declined&quot; then 0 else amount end) as approved_total_amount
from Transactions
group by year(trans_date), month(trans_date), country;</code></pre>
<h3 id="둘만의-암호">둘만의 암호</h3>
<pre><code class="language-java">import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

class Solution {
    public String solution(String s, String skip, int index) {
        String answer = &quot;&quot;;

        char[] chars = s.toCharArray();
        List&lt;Character&gt; list = skiplist(skip);

        int index2 = index;
        if(index2 &gt;= list.size()) {
             index2 = index - list.size();
        }

        for(char c:chars){
            int i = Arrays.binarySearch(list.toArray(), c);
            if(i + index2 &gt;= list.size()){

                answer += list.get(i + index2 - list.size());
            }else{
                answer += list.get(i + index2);
            }
        }

        return answer;
    }
    private List&lt;Character&gt; skiplist(String skips){
        char[] alpha = {&#39;a&#39;,&#39;b&#39;,&#39;c&#39;,&#39;d&#39;,&#39;e&#39;,&#39;f&#39;,&#39;g&#39;,&#39;h&#39;,&#39;i&#39;,&#39;j&#39;,&#39;k&#39;,&#39;l&#39;,&#39;m&#39;,&#39;n&#39;,&#39;o&#39;,&#39;p&#39;,&#39;q&#39;,&#39;r&#39;,&#39;s&#39;,&#39;t&#39;,&#39;u&#39;,&#39;v&#39;,&#39;w&#39;,&#39;x&#39;,&#39;y&#39;,&#39;z&#39;};
        List&lt;Character&gt; alphaskip = new ArrayList&lt;&gt;();
        for(char a:alpha){
            alphaskip.add(a);
        }

        for(char skip: skips.toCharArray()){
            int i = Arrays.binarySearch(alphaskip.toArray(), skip);
            alphaskip.remove(i);
        }

        return alphaskip;
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day79]]></title>
            <link>https://velog.io/@rxg_1211/Day79</link>
            <guid>https://velog.io/@rxg_1211/Day79</guid>
            <pubDate>Thu, 23 Apr 2026 01:17:38 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="queries-quality-and-percentage">Queries Quality and Percentage</h3>
<pre><code class="language-sql">SELECT 
    query_name,
    ROUND(AVG(rating / position), 2) AS quality,
    ROUND(SUM(CASE WHEN rating &lt; 3 THEN 1 ELSE 0 END) * 100 / COUNT(*), 2) AS poor_query_percentage
FROM Queries
WHERE query_name IS NOT NULL
GROUP BY query_name;</code></pre>
<h3 id="대충-만든-자판">대충 만든 자판</h3>
<pre><code class="language-java">import java.util.Arrays;

class Solution {
    public int[] solution(String[] keymap, String[] targets) {
        int[] answer = new int[targets.length];
        char[][] keys = new char[keymap.length][];
        for (int i = 0; i &lt; keymap.length; i++) {
            keys[i] = new char[keymap[i].length()];
            keys[i] = keymap[i].toCharArray();
        }
        for(int i = 0; i &lt; targets.length; i++) {
            answer[i] = 0;
            for(char t: targets[i].toCharArray()){
                if (findTarget(keys,t) == -1){
                    answer[i] = -1;
                    break;
                }else {
                    answer[i] += findTarget(keys,t);
                }
            }
        }

        return answer;
    }

    public int findTarget(char[][] keymap, char target) {
        int[] indexes = new int[keymap.length];

        for (int i = 0; i &lt; keymap.length; i++) {
            indexes[i] = 200; // keymap의 최대 원소길이가 100

            for (int j = 0; j &lt; keymap[i].length; j++) {
                if (keymap[i][j] == target) {
                    indexes[i] = j + 1;
                    break;
                }
            }
        }
        int index = Arrays.stream(indexes).min().getAsInt();
        if (index == 200){
            return -1;
        } else {
            return index;
        }
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Day78]]></title>
            <link>https://velog.io/@rxg_1211/Day78</link>
            <guid>https://velog.io/@rxg_1211/Day78</guid>
            <pubDate>Wed, 22 Apr 2026 00:55:05 GMT</pubDate>
            <description><![CDATA[<h2 id="알고리즘-코드카타">알고리즘 코드카타</h2>
<h3 id="percentage-of-users-attended-a-contest">Percentage of Users Attended a Contest</h3>
<pre><code class="language-sql">-- SET @usercount = (SELECT COUNT(user_id) FROM Users);

SELECT contest_id, ROUND(COUNT(user_id) * 100 / (SELECT COUNT(user_id) FROM Users), 2) AS percentage
FROM Register
GROUP BY contest_id
ORDER BY percentage DESC, contest_id ASC;</code></pre>
<p><code>set</code>문 사용하려하였는데 <code>leetcode</code>에서는 지원하지않는듯함...</p>
<h3 id="문자열-나누기">문자열 나누기</h3>
<pre><code class="language-java">class Solution {
    public int solution(String s) {
        int answer = 0;
        char[] sen = s.toCharArray();

        char c = &#39; &#39;;
        int same = 0;
        int nsame = 0;
        int index = -1;

        for (int i = 0; i &lt; sen.length; i++){
            if(c == &#39; &#39;){
                c = sen[i];
            }

            if (sen[i] == c){
                same++;
            } else {
                nsame++;
            }

            if(same == nsame){
                answer++;
                c = &#39; &#39;;
                same = 0;
                nsame = 0;
                index = i;
            }
        }

        if (index != sen.length-1){
            answer++;
        }
        return answer;
    }
}</code></pre>
<p>아래는 배열을 추가해서 계산!</p>
<pre><code class="language-java">import java.util.ArrayList;
import java.util.List;

class Solution {
    public int solution(String s) {
        char[] sen = s.toCharArray();
        List&lt;String&gt; strings = new ArrayList&lt;&gt;();

        char c = &#39; &#39;;
        int same = 0;
        int nsame = 0;
        String string = &quot;&quot;;

        for (int i = 0; i &lt; sen.length; i++) {
            string += sen[i];

            if(c == &#39; &#39;){
                c = sen[i];
            }

            if (sen[i] == c){
                same++;
            } else {
                nsame++;
            }

            if(same == nsame){
                c = &#39; &#39;;
                same = 0;
                nsame = 0;
                strings.add(string);
                string = &quot;&quot;;
            }

        }
        if (!string.equals(&quot;&quot;)){
            strings.add(string);
        }

        return strings.size();
    }
}</code></pre>
]]></description>
        </item>
    </channel>
</rss>