<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ori_gui.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Fri, 01 May 2026 13:34:02 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ori_gui.log</title>
            <url>https://velog.velcdn.com/images/ori_gui/profile/728cd695-2a9e-45b2-9ae5-34cfcba4d7b7/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ori_gui.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ori_gui" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[ STOMP 1:1 메시지 라우팅 — HTTP 인증과 분리된 WebSocket 컨텍스트에서 userId 기반 Principal 통합]]></title>
            <link>https://velog.io/@ori_gui/STOMP-11-%EB%A9%94%EC%8B%9C%EC%A7%80-%EB%9D%BC%EC%9A%B0%ED%8C%85-HTTP-%EC%9D%B8%EC%A6%9D%EA%B3%BC-%EB%B6%84%EB%A6%AC%EB%90%9C-WebSocket-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-userId-%EA%B8%B0%EB%B0%98-Principal-%ED%86%B5%ED%95%A9</link>
            <guid>https://velog.io/@ori_gui/STOMP-11-%EB%A9%94%EC%8B%9C%EC%A7%80-%EB%9D%BC%EC%9A%B0%ED%8C%85-HTTP-%EC%9D%B8%EC%A6%9D%EA%B3%BC-%EB%B6%84%EB%A6%AC%EB%90%9C-WebSocket-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-userId-%EA%B8%B0%EB%B0%98-Principal-%ED%86%B5%ED%95%A9</guid>
            <pubDate>Fri, 01 May 2026 13:34:02 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>또마니또(ttomanito)는 마니또(주는 사람) ↔ 마니띠(받는 사람)가 1:1로 매칭되어 <strong>익명 채팅으로만 서로를 도와주는</strong> 모바일 웹 게임입니다. 채팅 메시지가 두 사람 사이에서만 오가야 하는 구조이기 때문에, STOMP 라우팅이 사용자를 잘못 식별하면 게임성 자체가 무너집니다. HTTP 인증(<code>SecurityContext</code>, <code>ThreadLocal</code>)과 WebSocket 채널(비동기)의 컨텍스트 분리에서 출발해, <strong>2단계 인터셉터 + 커스텀 Principal(<code>JwtUserDetails</code>가 <code>UserDetails</code>와 <code>Principal</code>을 동시에 구현)</strong>로 <code>Principal.getName()</code>을 userId로 통일한 과정을 정리합니다.</p>
</blockquote>
<hr>
<h2 id="stomp란">STOMP란?</h2>
<p>Spring Boot에서 WebSocket을 구현할 때 자주 등장하는 <strong>STOMP(Simple Text Oriented Messaging Protocol)</strong>는 WebSocket 위에서 동작하는 서브프로토콜입니다. WebSocket 자체는 단순한 양방향 소켓 연결만 제공하지만, STOMP는 그 위에 <strong>구독(Subscribe) / 발행(Publish) 구조</strong>와 목적지(destination) 기반 라우팅을 더합니다.</p>
<table>
<thead>
<tr>
<th>프레임</th>
<th>방향</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>CONNECT</code></td>
<td>클라이언트 → 서버</td>
<td>WebSocket 연결 후 STOMP 세션을 초기화</td>
</tr>
<tr>
<td><code>SUBSCRIBE</code></td>
<td>클라이언트 → 서버</td>
<td>특정 destination 구독. 예: <code>/user/queue/chat.5</code></td>
</tr>
<tr>
<td><code>SEND</code></td>
<td>클라이언트 → 서버</td>
<td>메시지 발행. 예: <code>/app/chat.send.5</code></td>
</tr>
<tr>
<td><code>MESSAGE</code></td>
<td>서버 → 클라이언트</td>
<td>서버가 구독 채널로 메시지를 전달</td>
</tr>
</tbody></table>
<p>Spring Boot에서는 <code>@MessageMapping</code>으로 SEND를 처리하고, <code>SimpMessagingTemplate.convertAndSendToUser(userId, dest, payload)</code>로 특정 사용자에게만 메시지를 보낼 수 있습니다. 이 API가 사용자를 찾는 기준이 바로 <strong><code>Principal.getName()</code></strong>입니다. 이 글의 핵심 문제는 WebSocket 채널에서 이 Principal을 어떻게 일관되게 주입하느냐입니다.</p>
<hr>
<h2 id="1-들어가며">1. 들어가며</h2>
<p>또마니또는 게임이 시작되면 참가자 간에 1:1 익명 채팅방이 자동 생성됩니다. 메시지는 두 사람 사이에서만 오가야 하고, 다른 방으로 새면 게임성이 무너집니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/9d976d80-52fe-4439-ab75-96707eab1f94/image.png" alt="익명 채팅방 — 마니또/마니띠 양방향"></p>
<p>채팅 도메인을 구현하면서 두 가지를 동시에 풀어야 했습니다.</p>
<ol>
<li><strong>메시지 저장</strong> — 채팅 메시지를 어디에 저장할 것인가 (MongoDB vs MySQL)</li>
<li><strong>인증·라우팅</strong> — STOMP 채널이 사용자를 어떻게 식별해서 1:1 메시지를 정확히 전달할 것인가</li>
</ol>
<p>본문은 다음 흐름으로 정리합니다.</p>
<ol>
<li>채팅 도메인 구조 — STOMP + MongoDB + MySQL의 역할 분담</li>
<li>왜 메시지 저장소는 MongoDB인가</li>
<li>문제 — WebSocket 인증의 비동기 컨텍스트</li>
<li>결정적 해결 — 2단계 인터셉터 + 커스텀 Principal (userId 기반)</li>
<li>1:1 메시지 라우팅 검증</li>
</ol>
<hr>
<h2 id="2-채팅-도메인-구조--stomp--mongodb--mysql">2. 채팅 도메인 구조 — STOMP + MongoDB + MySQL</h2>
<p>채팅 도메인의 책임을 다음과 같이 세 축으로 나눴습니다.</p>
<table>
<thead>
<tr>
<th>영역</th>
<th>저장소</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>채팅방 메타</td>
<td><strong>MySQL</strong> (<code>chatting_room</code> 테이블)</td>
<td>채팅방 ID · 매칭(<code>ManitoAssignment</code>) FK · 마지막 메시지 미리보기·시각</td>
</tr>
<tr>
<td>채팅 메시지</td>
<td><strong>MongoDB</strong> (<code>chat_message</code> 컬렉션)</td>
<td>메시지 본문 · 발신자 · 시각 · 타입</td>
</tr>
<tr>
<td>실시간 전송</td>
<td><strong>STOMP / WebSocket</strong></td>
<td>CONNECT / SEND / SUBSCRIBE — 인메모리 브로커</td>
</tr>
</tbody></table>
<pre><code class="language-java">// ChatMessage.java
@Document(collection = &quot;chat_message&quot;)        // ★ MongoDB
public class ChatMessage {
    private Long senderUserId;
    private Long senderRoomUserId;
    private Long chattingRoomId;
    private MessageType type;
    private String content;
    private LocalDateTime sentAt;
}</code></pre>
<pre><code class="language-java">// ChattingRoom.java
@Entity
@Table(name = &quot;chatting_room&quot;)                // ★ MySQL
public class ChattingRoom {
    @ManyToOne private ManitoAssignment manitoAssignment;
    private String lastMessage;
    private LocalDateTime lastMessageTime;
}</code></pre>
<p>같은 &quot;채팅&quot; 단어로 묶이지만, <strong>메타데이터(ChattingRoom)</strong>와 <strong>메시지(ChatMessage)</strong>는 요구가 완전히 다릅니다. 메타는 강한 일관성과 외래 키가 필요하고, 메시지는 빠른 append와 시간 역순 페이지네이션이 필요합니다. 그래서 저장소도 분리했습니다.</p>
<p>WebSocket 측 설정의 핵심은 다음과 같습니다.</p>
<pre><code class="language-java">// WebSocketConfig.java (요약)
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker(&quot;/queue&quot;, &quot;/topic/chat&quot;, &quot;/topic/waiting&quot;);
    config.setApplicationDestinationPrefixes(&quot;/app&quot;);
    config.setUserDestinationPrefix(&quot;/user&quot;);           // ★ 1:1 메시지 라우팅 prefix
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint(&quot;/ws/chat&quot;)
            .addInterceptors(jwtHandshakeInterceptor)    // HTTP 업그레이드 단계
            .setAllowedOriginPatterns(&quot;...&quot;);
}

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
    registration.interceptors(wsAuthChannelInterceptor); // STOMP CONNECT 단계
}</code></pre>
<p>여기서 <strong><code>config.setUserDestinationPrefix(&quot;/user&quot;)</code></strong> 한 줄이 본 트러블슈팅의 출발점입니다. 이 설정이 켜져 있으면 서버에서 <code>convertAndSendToUser(userId, &quot;/queue/chat.{roomId}&quot;, payload)</code>를 호출했을 때 STOMP 프레임워크가 자동으로 <code>/user/{userId}/queue/chat.{roomId}</code> 패턴으로 변환해 <strong>그 사용자의 세션에만</strong> 전달합니다.</p>
<p>문제는 <strong>&quot;그 사용자가 누구인가&quot;</strong>를 STOMP가 어떻게 결정하느냐 였습니다.</p>
<hr>
<h2 id="3-왜-메시지-저장소는-mongodb인가">3. 왜 메시지 저장소는 MongoDB인가</h2>
<p>이 글의 메인 주제는 인증이지만, MongoDB 선택 이유를 짚고 가야 인증·라우팅 설계와의 결합이 자연스러워집니다. MySQL <code>chat_message</code> 테이블 단일 구조도 검토했지만, 다음 이유로 메시지만 MongoDB로 분리했습니다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>MySQL JPA 단일</th>
<th>MongoDB 분리</th>
</tr>
</thead>
<tbody><tr>
<td>쓰기 패턴</td>
<td>INSERT 폭주 시 인덱스 페이지 분할 비용</td>
<td>append-only 컬렉션, 자연스러운 시계열</td>
</tr>
<tr>
<td>읽기 패턴</td>
<td>시간 역순 페이지네이션 시 sort + index 재정렬</td>
<td><code>_id</code> ObjectId가 시간 정보를 포함, sort 거의 0 비용</td>
</tr>
<tr>
<td>스키마 유연성</td>
<td>메시지 타입(텍스트/이미지/시스템)마다 NULL 컬럼 누적</td>
<td>문서별 다른 필드 자연스럽게</td>
</tr>
<tr>
<td>JPA 캐스케이드 부담</td>
<td><code>ChattingRoom</code> ↔ <code>ChatMessage</code> 양방향 관리</td>
<td>완전 분리, 채팅방 삭제 시 컬렉션만 별도 삭제</td>
</tr>
<tr>
<td>인증·라우팅 결합</td>
<td>DB 트랜잭션 안에서 사용자 식별</td>
<td><strong>저장과 라우팅이 분리</strong> — 인증 책임이 STOMP 채널로 명확히 떨어짐</td>
</tr>
</tbody></table>
<p>마지막 항목이 본 트러블슈팅과 직결됩니다. 메시지를 MongoDB로 분리해두면 <strong>STOMP 채널의 책임이 &quot;메시지를 받아 저장하고, 그 결과를 1:1로 다시 라우팅하는&quot; 두 단계로 명확하게 정리</strong>됩니다. JPA 트랜잭션 경계와 WebSocket 비동기 처리가 같은 함수 안에 섞이는 위험이 사라지기 때문에, 인증·Principal 설계가 <strong>&quot;누구에게 보낼 것인가&quot;</strong> 한 가지에만 집중할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/e890603d-d46a-40f5-aaf6-a28af0cd877a/image.png" alt="Mongo Express — ttomanito_chat.chat_message 컬렉션 (senderUserId·chattingRoomId·content 확인)"></p>
<hr>
<h2 id="4-문제--websocket-인증의-비동기-컨텍스트">4. 문제 — WebSocket 인증의 비동기 컨텍스트</h2>
<p>HTTP 요청은 다음 흐름으로 사용자를 식별합니다.</p>
<pre><code>HTTP Request
   ↓ Servlet Filter (JwtAuthenticationFilter)
   ↓ JWT 파싱 → Authentication 객체
   ↓ SecurityContextHolder.getContext().setAuthentication(auth)   ★ ThreadLocal 저장
   ↓ Controller @AuthenticationPrincipal 주입</code></pre><p>핵심은 <code>SecurityContextHolder</code>가 내부적으로 <strong><code>ThreadLocal</code></strong>이라는 점입니다. 한 HTTP 요청 = 한 스레드라는 가정 위에서만 동작합니다.</p>
<p>WebSocket은 이 가정이 깨집니다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>HTTP 인증</th>
<th>WebSocket</th>
</tr>
</thead>
<tbody><tr>
<td>1. 요청 도착</td>
<td>Servlet Filter Chain</td>
<td>HTTP 핸드셰이크 (1회성)</td>
</tr>
<tr>
<td>2. 토큰 검증</td>
<td><code>JwtAuthenticationFilter</code></td>
<td><code>JwtHandshakeInterceptor</code></td>
</tr>
<tr>
<td>3. 인증 저장</td>
<td><code>SecurityContext</code> (ThreadLocal)</td>
<td>세션 attributes (Map)</td>
</tr>
<tr>
<td>4. 메시지 처리</td>
<td>동기 — 같은 스레드</td>
<td><strong>비동기 — 다른 스레드</strong></td>
</tr>
<tr>
<td>5. 사용자 식별</td>
<td><code>SecurityContextHolder.getContext().getAuthentication()</code></td>
<td><code>StompHeaderAccessor.getUser()</code></td>
</tr>
</tbody></table>
<p>핸드셰이크가 끝나고 STOMP CONNECT / SEND / SUBSCRIBE가 들어올 때마다, 처리하는 스레드는 <strong>핸드셰이크 시점과 다른 스레드</strong>입니다. ThreadLocal인 <code>SecurityContext</code>는 텅 비어 있습니다. 즉 HTTP 인증 결과를 그대로 가져다 쓸 수가 없습니다.</p>
<p>따라서 WebSocket 채널은 자체적으로 사용자를 식별하는 방법이 필요하고, 그 방법이 STOMP CONNECT 시점에 <code>acc.setUser(auth)</code>로 세션에 Principal을 부착해 두는 것입니다. 이후 같은 세션의 모든 프레임에서는 <code>StompHeaderAccessor.getUser()</code>로 해당 Principal을 꺼낼 수 있습니다. 이것이 다음 절에서 다룰 2단계 인터셉터의 역할입니다.</p>
<p>또한 STOMP 1:1 메시지 라우팅(<code>convertAndSendToUser(userId, ...)</code>)은 내부적으로 <strong><code>Principal.getName()</code>으로 사용자를 찾기</strong> 때문에, &quot;Principal의 name이 무엇이어야 하는가&quot;가 명시적으로 정의돼야 합니다. 이메일? 로그인 ID? 데이터베이스 PK?</p>
<p>만약 어떤 곳은 <code>Principal.getName() = email</code>이고 어떤 곳은 <code>Principal.getName() = userId.toString()</code>으로 섞여 있으면, <strong><code>convertAndSendToUser(&quot;42&quot;, ...)</code> 호출 시 그 세션의 Principal name이 &quot;<a href="mailto:user42@example.com">user42@example.com</a>&quot;이라 매칭이 안 되어 메시지가 사라지는</strong> 사고가 납니다. 이게 본 트러블슈팅의 본질이었습니다.</p>
<blockquote>
<p><strong>참고 — 자주 빠지는 함정</strong>
Spring Security의 기본 <code>User</code> 클래스에서 <code>getUsername()</code>이 반환하는 값은 보통 사용자가 로그인할 때 입력한 식별자(이메일·loginId)입니다. 반면 STOMP의 user-prefix 라우팅은 <code>Principal.getName()</code>을 키로 씁니다. 두 값이 같으면 문제 없지만, 어디선가 <code>userId</code>를 키로 쓰기 시작하면 즉시 어긋납니다.</p>
</blockquote>
<hr>
<h2 id="5-결정적-해결--2단계-인터셉터--커스텀-principal-userid-기반">5. 결정적 해결 — 2단계 인터셉터 + 커스텀 Principal (userId 기반)</h2>
<p>해결은 다음 네 부분을 동시에 적용하는 형태로 진행했습니다.</p>
<ol>
<li><strong>JwtHandshakeInterceptor</strong> — HTTP → WebSocket 업그레이드 시점에 JWT 추출 + 세션 attributes 저장 (검증 실패해도 핸드셰이크 자체는 통과)</li>
<li><strong>WebSocketAuthChannelInterceptor</strong> — STOMP CONNECT 시점에 JWT 재검증 + Principal 주입</li>
<li><strong><code>JwtUserDetails</code></strong> — <code>UserDetails</code>와 <code>Principal</code>을 <strong>동시에</strong> 구현 + <code>getName()</code> = <code>userId.toString()</code>으로 통일</li>
<li><strong>ChatWebSocketController</strong> — <code>Authentication</code> 파라미터로 Principal 받아 <code>convertAndSendToUser(userId.toString(), ...)</code> 호출</li>
</ol>
<h3 id="5-1-jwthandshakeinterceptor-http-업그레이드-단계">5-1. JwtHandshakeInterceptor (HTTP 업그레이드 단계)</h3>
<pre><code class="language-java">// JwtHandshakeInterceptor.java (요약)
@Component
public class JwtHandshakeInterceptor implements HandshakeInterceptor {

    public static final String ATTR_JWT = &quot;JWT_TOKEN&quot;;

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ...,
                                   Map&lt;String, Object&gt; attributes) {
        String token = resolveFromHeader(request);            // Authorization: Bearer ...
        if (token == null) token = resolveFromCookie(request, &quot;accessToken&quot;);

        if (token != null &amp;&amp; jwtProvider.isTokenValid(token)
                          &amp;&amp; !tokenRevocationService.isRevoked(token)) {
            attributes.put(ATTR_JWT, token);                  // ★ 세션 attributes에 저장
        }
        return true;                                          // ★ 항상 true — 검증은 다음 단계에서
    }
}</code></pre>
<p>핵심은 <strong><code>return true</code></strong>입니다. 핸드셰이크 자체는 항상 성공시키고, 토큰만 세션 attributes에 옮겨 둡니다. 이렇게 분리한 이유는 두 가지입니다.</p>
<ul>
<li>HTTP 핸드셰이크 단계에서 401 반환하면 클라이언트가 <strong>재시도 시 쿠키/헤더 흐름이 복잡</strong>해짐</li>
<li>채널 인터셉터 단계에서 다시 검증할 거라, 굳이 두 번 거절할 필요가 없음</li>
</ul>
<h3 id="5-2-websocketauthchannelinterceptor-stomp-connect-단계">5-2. WebSocketAuthChannelInterceptor (STOMP CONNECT 단계)</h3>
<pre><code class="language-java">// WebSocketAuthChannelInterceptor.java (요약)
@Override
public Message&lt;?&gt; preSend(Message&lt;?&gt; message, MessageChannel channel) {
    StompHeaderAccessor acc = MessageHeaderAccessor.getAccessor(
            message, StompHeaderAccessor.class);

    if (acc != null &amp;&amp; StompCommand.CONNECT.equals(acc.getCommand())) {
        // ① 핸드셰이크 단계에서 저장한 토큰
        String token = resolveFromSession(acc.getSessionAttributes());
        // ② 폴백 — STOMP native header의 Authorization
        if (token == null) token = resolveFromNativeHeader(acc);

        if (token != null &amp;&amp; jwtProvider.isTokenValid(token)) {
            UserDetails ud = jwtProvider.loadUserFromToken(token);    // ★ JwtUserDetails 로드
            var auth = new UsernamePasswordAuthenticationToken(
                    ud, null, ud.getAuthorities());
            acc.setUser(auth);                                        // ★ STOMP 사용자 등록
            log.debug(&quot;[WS] Authentication set for user={}&quot;, ud.getUsername());
        }
    }
    return message;
}</code></pre>
<p>핵심은 <strong><code>acc.setUser(auth)</code></strong> 한 줄입니다. 이 시점부터 같은 STOMP 세션에서 들어오는 모든 메시지에는 Spring이 자동으로 <code>Authentication</code>을 주입해 줍니다.</p>
<h3 id="5-3-jwtuserdetails--userdetails--principal-동시-구현">5-3. JwtUserDetails — UserDetails + Principal 동시 구현</h3>
<pre><code class="language-java">// JwtUserDetails.java (요약)
public class JwtUserDetails implements UserDetails, Principal {   // ★ 두 인터페이스 동시
    private final Long userId;
    private final String username;
    private final List&lt;? extends GrantedAuthority&gt; authorities;

    public JwtUserDetails(User user) {
        this.userId   = user.getUserId();
        this.username = String.valueOf(user.getUserId());          // ★ userId 문자열
        this.authorities = List.of(new SimpleGrantedAuthority(&quot;ROLE_USER&quot;));
    }

    @Override public String getUsername() { return username; }     // userId 문자열
    @Override public String getName()     { return userId.toString(); }  // ★ Principal.getName()
}</code></pre>
<p>이 클래스 한 곳에서 <strong>&quot;이 시스템에서 사용자를 식별하는 키는 <code>userId</code> 한 가지&quot;를 못 박습니다.</strong></p>
<ul>
<li><code>UserDetails.getUsername()</code> → <code>&quot;42&quot;</code> (userId 문자열)</li>
<li><code>Principal.getName()</code> → <code>&quot;42&quot;</code> (userId 문자열)</li>
<li>HTTP 측 <code>@AuthenticationPrincipal JwtUserDetails</code>에서도 동일</li>
<li>WebSocket 측 <code>Principal.getName()</code>에서도 동일</li>
</ul>
<p>이 일치 한 가지가 <strong><code>convertAndSendToUser(userId.toString(), ...)</code>가 항상 같은 키로 매칭</strong>됨을 보장합니다.</p>
<h3 id="5-4-chatwebsocketcontroller--principal-주입--11-라우팅">5-4. ChatWebSocketController — Principal 주입 + 1:1 라우팅</h3>
<pre><code class="language-java">// ChatWebSocketController.java (요약)
@MessageMapping(&quot;/chat.send.{chatRoomId}&quot;)
public void sendMessage(@DestinationVariable Long chatRoomId,
                        @Payload ChatMessageDto payload,
                        Authentication authentication) {           // ★ Principal 주입
    if (authentication == null
            || !(authentication.getPrincipal() instanceof JwtUserDetails jud)) {
        log.warn(&quot;[WS] 인증 없음 — 메시지 드롭&quot;);
        return;
    }
    Long senderUserId = jud.getUserId();

    // ① MongoDB에 저장
    ChatMessage saved = chatMessageRepository.save(
            ChatMessage.createText(chatRoomId, ..., senderUserId, payload.content()));

    // ② 두 사용자에게 1:1 라우팅
    List&lt;Long&gt; receivers = service.findReceivers(chatRoomId, senderRoomUserId);
    for (Long uid : receivers) {
        template.convertAndSendToUser(
                uid.toString(),                                    // ★ Principal.getName()과 매칭
                &quot;/queue/chat.&quot; + chatRoomId,
                new SocketPayload&lt;&gt;(&quot;MESSAGE&quot;, saved));
    }
}</code></pre>
<p>핵심 포인트:</p>
<ul>
<li><code>Authentication</code> 파라미터를 컨트롤러 메서드 시그니처에 명시 → Spring이 자동 주입</li>
<li><code>instanceof JwtUserDetails jud</code> 패턴 매칭으로 타입 안전 추출</li>
<li><code>convertAndSendToUser(uid.toString(), ...)</code>의 첫 인자가 <code>Principal.getName()</code>과 정확히 일치</li>
</ul>
<hr>
<h2 id="6-11-메시지-라우팅-검증">6. 1:1 메시지 라우팅 검증</h2>
<p>해결책 적용 후, 한 채팅방의 두 사용자 사이에서 STOMP 메시지가 다음과 같이 흘러갑니다.</p>
<pre><code>[Manito Browser]                  [Spring]                       [Manitti Browser]
  │ STOMP CONNECT                   │
  │   Authorization: Bearer ...     │
  │ ──────────────────────────────▶ │ JwtHandshakeInterceptor       │
  │                                 │   → attributes.JWT_TOKEN      │
  │                                 │ WebSocketAuthChannelIntercept │
  │                                 │   → acc.setUser(auth)         │
  │                                 │   Principal.getName() = &quot;42&quot;  │
  │                                 │                               │
  │ SUBSCRIBE /user/queue/chat.5    │                               │
  │ ──────────────────────────────▶ │ (Principal &quot;42&quot;의 채널)       │
  │                                 │                               │
  │ SEND /app/chat.send.5  &quot;안녕&quot;   │                               │
  │ ──────────────────────────────▶ │ ChatWebSocketController       │
  │                                 │   senderUserId = 42           │
  │                                 │   MongoDB save                │
  │                                 │   convertAndSendToUser(       │
  │                                 │     &quot;99&quot;, /queue/chat.5, ...) │
  │                                 │ ──────────────────────────▶  │
  │                                 │                               │  ◀── /user/queue/chat.5
  │                                 │                               │      &quot;안녕&quot;</code></pre><p><code>convertAndSendToUser(&quot;99&quot;, ...)</code>의 <code>&quot;99&quot;</code>가 마니띠의 <code>Principal.getName()</code>과 정확히 같은 값(<code>userId.toString()</code>)이기 때문에 STOMP가 그 사용자의 세션 큐에만 메시지를 꽂아 줍니다.</p>
<blockquote>
<p>마니또(uid=42)와 마니띠(uid=99) 두 브라우저를 동시에 열어 STOMP 연결 → 메시지 전송 → 수신까지 재현</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/5024e321-193e-4d0e-9a53-8a272be557ad/image.png" alt="capture-kit — 마니또(uid=42) · 마니띠(uid=99) STOMP 듀얼 클라이언트 테스트"></p>
<p>서버 로그에서는 JWT 핸드셰이크 인터셉터 → STOMP CONNECT 인증 설정 → MongoDB 저장 → 수신자 큐 라우팅의 전 구간이 순서대로 확인됩니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/d629cebb-a3aa-48ec-94b4-d051243f8870/image.png" alt="cap-tt-ws-server 로그 — JWT 인증 → MongoDB 저장 → /user/{id}/queue 라우팅"></p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/306c7f68-e000-493f-ad70-d168c75f3082/image.png" alt="1:1 채팅 — 마니띠에게만 도착한 메시지 + 스티커"></p>
<p><strong>찌르기·통화·알림까지 같은 채널 위에서</strong></p>
<p>같은 인증 컨텍스트 위에서 채팅뿐 아니라 <strong>찌르기(<code>/chat.poke</code>)·통화 시그널링(<code>/chat.call.start/accept/reject</code>)</strong>까지 동일하게 동작합니다. Principal이 한 가지 키(userId)로 통일되어 있으니, 어떤 매핑에서도 <strong>&quot;이 사용자에게만 알림&quot; → <code>convertAndSendToUser(userId.toString(), ...)</code> 한 줄</strong>로 끝납니다.</p>
<hr>
<h2 id="7-정리">7. 정리</h2>
<h3 id="before-vs-after">Before vs After</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>인증 컨텍스트</td>
<td>HTTP <code>SecurityContext</code>만 사용</td>
<td>HTTP + WS 인증 분리 / 일관된 Principal</td>
</tr>
<tr>
<td>Principal 키</td>
<td>이메일·loginId·userId 혼재</td>
<td><strong><code>userId.toString()</code> 단일</strong></td>
</tr>
<tr>
<td>1:1 메시지</td>
<td><code>convertAndSendToUser</code> 호출 시 매칭 실패 빈발</td>
<td><strong>Principal.getName() 정확 매칭</strong></td>
</tr>
<tr>
<td>채팅방 격리</td>
<td>다른 방으로 메시지 새는 케이스 발생 가능</td>
<td><strong>채팅방 단위 정확 라우팅</strong></td>
</tr>
<tr>
<td>알림 통합</td>
<td>푸시·찌르기·통화 각각 별도 식별자 필요</td>
<td><strong>userId 한 가지로 통합</strong></td>
</tr>
</tbody></table>
<h3 id="인증-흐름-비교">인증 흐름 비교</h3>
<pre><code>                  HTTP 요청                     WebSocket 채널
                  ─────────                     ────────────
1. 토큰 검증     JwtAuthenticationFilter       JwtHandshakeInterceptor (1회)
                                              + WebSocketAuthChannelInterceptor (CONNECT)

2. 저장소        SecurityContext (ThreadLocal) StompHeaderAccessor.user (세션 단위)

3. 사용자 키     JwtUserDetails.getUsername()  JwtUserDetails.getName()
                = userId 문자열                = userId 문자열   ← 같은 값
4. 컨트롤러      @AuthenticationPrincipal      Authentication 파라미터
                JwtUserDetails ud              → ud.getUserId()</code></pre><p>세 번째 행이 결론입니다. <strong><code>UserDetails.getUsername()</code>과 <code>Principal.getName()</code>이 같은 클래스에서 같은 값(userId)</strong>을 반환하도록 통일한 것이 곧 HTTP·WS 두 컨텍스트를 묶는 접점이었습니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>본 포스팅에서는 또마니또 프로젝트의 <strong>STOMP 1:1 채팅 라우팅</strong>이 의미를 가지기 위한 인증 인프라로서, HTTP 인증과 분리된 WebSocket 채널의 비동기 컨텍스트에서 사용자를 어떻게 식별할 것인가 문제를 풀었던 과정을 정리하였습니다.</p>
<table>
<thead>
<tr>
<th>구간</th>
<th>책임</th>
<th>핵심</th>
</tr>
</thead>
<tbody><tr>
<td>HTTP 핸드셰이크</td>
<td><code>JwtHandshakeInterceptor</code></td>
<td>토큰 추출 + 세션 attributes 저장 (검증은 다음 단계)</td>
</tr>
<tr>
<td>STOMP CONNECT</td>
<td><code>WebSocketAuthChannelInterceptor</code></td>
<td>JWT 재검증 + <code>acc.setUser(auth)</code></td>
</tr>
<tr>
<td>Principal 통일</td>
<td><code>JwtUserDetails</code></td>
<td><code>UserDetails</code> + <code>Principal</code> 동시 구현, <code>getName() = userId.toString()</code></td>
</tr>
<tr>
<td>1:1 라우팅</td>
<td><code>convertAndSendToUser(uid.toString(), ...)</code></td>
<td>Principal.getName()과 정확 매칭</td>
</tr>
</tbody></table>
<p><strong>핵심 요약</strong></p>
<ul>
<li>WebSocket은 비동기 채널이므로 HTTP의 <code>SecurityContext</code>를 그대로 못 쓴다.</li>
<li>1:1 메시지 라우팅의 키는 <strong><code>Principal.getName()</code></strong> — 이 값을 시스템 전반에서 <strong>하나</strong>로 통일해야 한다.</li>
<li><code>UserDetails</code>와 <code>Principal</code>을 같은 클래스에 두고 같은 값(<code>userId</code>)을 반환하게 하면 HTTP·WS 두 컨텍스트가 한 줄로 묶인다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[기프티콘 파싱 : GCP Vision API + 도메인 정규화 분리]]></title>
            <link>https://velog.io/@ori_gui/%EA%B8%B0%ED%94%84%ED%8B%B0%EC%BD%98-%ED%8C%8C%EC%8B%B1-GCP-Vision-API-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%A0%95%EA%B7%9C%ED%99%94-%EB%B6%84%EB%A6%AC</link>
            <guid>https://velog.io/@ori_gui/%EA%B8%B0%ED%94%84%ED%8B%B0%EC%BD%98-%ED%8C%8C%EC%8B%B1-GCP-Vision-API-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%A0%95%EA%B7%9C%ED%99%94-%EB%B6%84%EB%A6%AC</guid>
            <pubDate>Fri, 01 May 2026 09:47:08 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>&#39;AI 예산 관리·소비 코칭 모바일 서비스&#39; &#39;똑똑꺼비&#39; 프로젝트의 시그니처 기능은 사용자가 매장 앞에서 핸드폰을 한 번 흔들면, <strong>GPS 로 주변 가맹점 리스트를 띄우고, 사용자가 매장을 확인·선택하면 그 매장에서 가장 혜택 좋은 결제 카드와 사용 가능한 기프티콘을 한 화면에 보여주는 Interactive 소비 제안</strong> 입니다. 이 기능이 의미를 가지려면 <strong>사용자가 미리 등록해둔 기프티콘이 &quot;그 매장의 기프티콘&quot; 으로 정확히 분류되어 있어야</strong> 했고, 그 데이터 정형화의 출발점이 OCR 자동 등록이었습니다. 처음 검토했던 Tesseract 의 한국어 정확도 한계에서 출발해, GCP Vision API + 도메인 텍스트 파서(<code>GifticonTextParser</code>) 의 2단 구조로 전환한 과정을 정리한 내용입니다.</p>
</blockquote>
<hr>
<h2 id="1-들어가며">1. 들어가며</h2>
<p>똑똑꺼비의 핵심 기능은 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th></th>
<th>기능</th>
<th>한 줄</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>AI 예산 관리</td>
<td>가입 시 월소득·카테고리별 예산을 AI가 자동 추천</td>
</tr>
<tr>
<td>2</td>
<td><strong>Interactive 소비 제안</strong></td>
<td>GPS 기반 주변 가맹점 인식 + 흔들기 → 최적 결제 카드 + 사용 가능 기프티콘 + QR</td>
</tr>
<tr>
<td>3</td>
<td>AI 소비 코칭</td>
<td>실시간 소비제안 알림, 월간 리포트(이상소비, 지출현황, 소비가맹점 순위, 일별 결제 현황)</td>
</tr>
<tr>
<td>4</td>
<td>게이미피케이션</td>
<td>친구·아케이드 게임 기반 N빵, 몰빵 기능</td>
</tr>
</tbody></table>
<p>② Interactive 소비 제안 기능에서, 사용자가 GS25 앞에서 핸드폰을 흔들었을 때 시스템은 다음 흐름을 풀어야 합니다.</p>
<pre><code>흔들기
  └─ GPS 좌표 + 반경 N m 이내 주변 가맹점 리스트
        └─ &quot;지금 [가맹점명] 이 맞나요?&quot; 확인 창
              └─ 사용자 확정(또는 다른 매장으로 변경)
                    └─ 그 매장 결제 카드 추천 + 사용 가능 기프티콘 목록</code></pre><p><img src="https://velog.velcdn.com/images/ori_gui/post/7e124971-c30f-4ff5-b25b-97c77fed9e4d/image.png" alt="Interactive 소비 제안 결과 — GS25 매장 선택 후 추천 결제 카드 (케이패스 3933)"></p>
<p>실제 결과 화면 — GS25 매장이 선택된 후 그 매장에서 가장 혜택이 좋은 결제 카드(케이패스 3933)가 한 화면에 노출됩니다.</p>
<p>마지막 단계 — &quot;그 매장 결제 카드 + 사용 가능 기프티콘 목록&quot; 이 의미를 가지려면, <strong>사용자가 사전에 등록해둔 기프티콘</strong> 이 정확한 가맹점(<code>franchiseId</code>) 으로 분류되어 있어야 합니다. &quot;스타벅스&quot; / &quot;Starbucks&quot; / &quot;스타벅스 코리아&quot; 가 등록 시 자유롭게 입력되어 서로 다른 가맹점으로 들어가 있다면, <strong>사용자가 흔들기 결과로 &quot;스타벅스&quot; 매장을 선택해도 본인의 &quot;Starbucks&quot; 표기 기프티콘이 매칭되지 않는</strong> 상황이 발생합니다.</p>
<p>따라서 기프티콘 <strong>등록 단계</strong> 에서 다음 두 가지를 동시에 풀어야 했습니다.</p>
<ol>
<li><strong>이미지 → 텍스트</strong> (OCR 정확도)</li>
<li><strong>텍스트 → 도메인 모델</strong> (브랜드 캐노니컬 매핑 · 만료일 LocalDate 파싱 · 상품명 추출)</li>
</ol>
<p>본문은 다음 흐름으로 정리합니다.</p>
<ol>
<li>등록 단계가 흔들기 결과 매칭에 어떻게 이어지는가</li>
<li>Tesseract(Tess4J) 한국어 OCR</li>
<li>정규식 + 캐노니컬 사전으로 후처리 보강</li>
<li>GCP Vision API + 도메인 텍스트 파서 분리</li>
<li>측정 결과</li>
</ol>
<hr>
<h2 id="2-등록-데이터-품질이-흔들기-결과-매칭을-결정한다">2. 등록 데이터 품질이 흔들기 결과 매칭을 결정한다</h2>
<p>기프티콘 등록은 흔들기와는 <strong>완전히 분리된 사전 단계</strong> 입니다. 사용자는 카카오톡 선물함 등에서 받은 기프티콘 이미지를 앱에 등록해 두고, 등록된 기프티콘은 서비스 DB 에 영속됩니다. 이후 매장 앞에서 흔들기를 했을 때, 시스템은 사용자가 선택한 가맹점에 매칭되는 기프티콘을 DB 에서 검색해 보여줍니다.</p>
<p>즉 흔들기 자체가 등록을 대체하는 게 아니라, <strong>등록 단계에서 부여된 <code>franchiseId</code> 의 정확도가 흔들기 결과 화면의 정확도를 결정</strong> 하는 구조입니다.</p>
<p>기존 등록 폼은 다음 4 개 필드로 구성되어 있었습니다.</p>
<ul>
<li><strong>브랜드(franchise)</strong> — 드롭다운 검색 (스타벅스 / 배스킨라빈스 / 배달의민족 …)</li>
<li><strong>상품명(label)</strong> — 자유 입력 (&quot;아메리카노 Tall&quot; 같은 형태)</li>
<li><strong>만료일(expiresAt)</strong> — 날짜 피커</li>
<li><strong>이미지(imageUrl)</strong> — 파일 첨부</li>
</ul>
<p>이 자유 입력 방식이 등록 단계와 이후 흔들기 매칭 단계에 누적시키는 문제는 다음과 같았습니다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>등록 단계의 문제</th>
<th>흔들기 결과 매칭 단계에서의 영향</th>
</tr>
</thead>
<tbody><tr>
<td>브랜드 표기 흔들림</td>
<td>&quot;스타벅스&quot; / &quot;Starbucks&quot; / &quot;스타벅스 코리아&quot; 가 서로 다른 <code>franchiseId</code> 로 저장</td>
<td>사용자가 흔들기로 &quot;스타벅스&quot; 매장을 선택했는데, 본인 &quot;Starbucks&quot; 표기 기프티콘이 다른 ID 로 등록되어 매칭 실패</td>
</tr>
<tr>
<td>입력 부담</td>
<td>한 장당 4 칸 직접 입력 — 12 장이면 48 칸</td>
<td>사용자가 등록 자체를 포기 → 흔들기 결과 화면에서 보여줄 기프티콘이 없음</td>
</tr>
<tr>
<td>만료일 형식 흔들림</td>
<td>&quot;2025.11.30&quot; / &quot;2025-11-30&quot; / &quot;2025년 11월 30일&quot; 자유 입력</td>
<td><code>LocalDate</code> 파싱 실패 → 만료된 기프티콘이 &quot;사용 가능&quot; 으로 노출되거나, 정상 기프티콘이 누락</td>
</tr>
</tbody></table>
<p>즉 &quot;흔들면 매장에서 쓸 수 있는 내 기프티콘이 정확히 뜬다&quot; 라는 시그니처 결과 화면의 품질이 <strong>사용자가 사전에 입력한 등록 데이터 품질에 그대로 종속</strong> 되어 있었습니다. 자동 추출과 정규화가 같이 풀려야 했습니다.</p>
<p>사용자가 등록해 둔 기프티콘이 <strong>스타벅스 / 이디야 / 탐앤탐스 등 매장 단위로 정확히 그룹화</strong> 되어 있어야, 흔들기 후 매장 선택 단계에서 곧바로 매칭되는 기프티콘을 꺼내올 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/ec51296a-3816-4910-a51a-fb4d1e18d6cc/image.png" alt="보유 기프티콘이 매장별로 정확히 분류된 결과 화면 — 스타벅스 / 이디야 / 탐앤탐스"></p>
<hr>
<h2 id="3-tesseract-tess4j">3. Tesseract (Tess4J)</h2>
<p>처음에는 외부 비용 없이 자체 OCR 을 돌리는 쪽을 우선 검토하였습니다. JVM 환경에서 가장 흔한 옵션은 <strong>Tess4J</strong> (Tesseract 의 Java 바인딩) 입니다.</p>
<pre><code class="language-gradle">implementation &#39;net.sourceforge.tess4j:tess4j:5.12.0&#39;</code></pre>
<p><strong>구현 시도</strong></p>
<pre><code class="language-java">Tesseract tess = new Tesseract();
tess.setDatapath(&quot;/usr/share/tesseract-ocr/4.00/tessdata&quot;);
tess.setLanguage(&quot;kor+eng&quot;);
String text = tess.doOCR(imageFile);</code></pre>
<p>샘플 기프티콘 이미지(스타벅스 / 배스킨라빈스 / GS25 / CU 등) 를 모아 OCR 을 돌려보았습니다. 결과 텍스트 품질에 다음과 같은 한계가 드러났습니다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>문제</th>
</tr>
</thead>
<tbody><tr>
<td>한국어 정확도</td>
<td>디자인 폰트·흘림체가 들어간 카카오톡 선물함 카드에서 ㅁ↔ㅇ, ㅈ↔ㅊ 자모 오인식 빈발</td>
</tr>
<tr>
<td>바코드 영역 노이즈</td>
<td>1D/2D 바코드가 텍스트로 잘못 인식되어 <code>ㅁ ㅁ I I I</code> 같은 쓰레기 토큰 생성</td>
</tr>
<tr>
<td>줄바꿈/레이아웃 손실</td>
<td>&quot;교환처: 스타벅스&quot; 한 줄이 <code>교환처:\n\n스 타 벅\n스</code> 로 끊어짐</td>
</tr>
<tr>
<td>의존성 무게</td>
<td>Tesseract 네이티브 바이너리 + tessdata 한국어 모델(~14MB) 을 컨테이너 이미지에 포함</td>
</tr>
<tr>
<td>운영 부담</td>
<td>Tesseract 버전 / 한국어 모델 버전 별도 관리</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>참고</strong>
Tesseract 의 한국어 정확도는 <strong>인쇄체 본문</strong> 에는 충분하지만, <strong>광고/패키징 디자인 텍스트</strong> 에서는 떨어지는 것으로 알려져 있습니다. 기프티콘 카드는 후자에 해당합니다.</p>
</blockquote>
<p>이 단계에서 OCR 결과 텍스트 자체의 품질이 너무 흔들렸기 때문에, 후처리 정규식·사전이 무엇을 해도 입력 노이즈가 그대로 따라오는 상황이었습니다.</p>
<hr>
<h2 id="4-정규식--캐노니컬-사전으로-후처리-보강">4. 정규식 + 캐노니컬 사전으로 후처리 보강</h2>
<p>OCR 결과를 그대로 쓰지 못한다면, <strong>결과 텍스트 위에 강한 정규식 + 캐노니컬 사전</strong> 을 얹어 보정해 보기로 했습니다.</p>
<pre><code class="language-java">// 흘림체로 깨진 &quot;스 타 벅 스&quot; → &quot;스타벅스&quot; 복원 시도
text = text.replaceAll(&quot;(\\S)\\s+(?=\\S)&quot;, &quot;$1&quot;);

// 만료일 패턴 — 다중 구분자 흡수
Matcher m = Pattern.compile(
    &quot;(20\\d{2})[./년 -]\\s*(\\d{1,2})[./월 -]\\s*(\\d{1,2})&quot;
).matcher(text);

// 브랜드 캐노니컬 사전 (예시)
Map&lt;String, String&gt; CANON = Map.of(
    &quot;starbucks&quot;, &quot;스타벅스&quot;,
    &quot;스타벅스코리아&quot;, &quot;스타벅스&quot;,
    &quot;STARBUCKS&quot;, &quot;스타벅스&quot;
);</code></pre>
<p><strong>확인된 한계</strong></p>
<ul>
<li>정규식이 잡는 패턴 ⊂ OCR 이 흐트러뜨린 패턴 — <strong>흘려쓰기 + 자모 오인식</strong> 이 결합되면 어떤 정규식도 회복 불가</li>
<li>캐노니컬 사전을 두껍게 만들수록 새 브랜드 추가 비용 ↑</li>
<li>사용자가 수정한 결과를 학습 데이터로 모으기엔 운영 부담 큼</li>
</ul>
<p>→ &quot;OCR 텍스트 품질을 끌어올리지 않는 한 정규식 후처리는 한계가 명확하다&quot; 가 결론이었습니다. <strong>OCR 엔진 자체를 교체하는 쪽이 본질적인 해법</strong> 이라는 판단으로 다음 단계로 넘어갔습니다.</p>
<hr>
<h2 id="5-gcp-vision-api--도메인-텍스트-파서-분리">5. GCP Vision API + 도메인 텍스트 파서 분리</h2>
<h3 id="5-1-선택-이유">5-1. 선택 이유</h3>
<p>GCP Vision API 의 <code>TEXT_DETECTION</code> 은 다음 측면에서 Tesseract 보다 유리했습니다.</p>
<ul>
<li><strong>한국어 광고/패키징 디자인 폰트</strong> 에 학습된 OCR 모델</li>
<li>줄/블록 단위 레이아웃 정보까지 함께 반환 → 정형화 단계가 훨씬 가벼움</li>
<li>인프라/모델 관리 부담 0 — credentials JSON 한 장만 컨테이너에 주입</li>
</ul>
<p><em>무료체험 계정을 사용하여 부담을 줄였습니다.</em></p>
<h3 id="5-2-인터페이스로-분리한-ocr-클라이언트">5-2. 인터페이스로 분리한 OCR 클라이언트</h3>
<p>OCR 엔진 자체는 향후 교체 여지가 있다고 판단해 <code>OcrClient</code> 인터페이스로 추상화하였습니다.</p>
<pre><code class="language-java">public interface OcrClient {
    String extractText(byte[] imageBytes) throws Exception;
}</code></pre>
<pre><code class="language-java">@Component
@ConditionalOnProperty(name = &quot;app.ocr.provider&quot;, havingValue = &quot;vision&quot;, matchIfMissing = true)
public class VisionOcrClient implements OcrClient {

    @Value(&quot;${app.ocr.credentials-path:}&quot;)
    private String credentialsPath;

    @Override
    public String extractText(byte[] imageBytes) throws Exception {
        var settings = ImageAnnotatorSettings.newBuilder();
        if (!credentialsPath.isBlank()) {
            try (var in = Files.newInputStream(Path.of(credentialsPath))) {
                var creds = GoogleCredentials.fromStream(in)
                        .createScoped(List.of(&quot;https://www.googleapis.com/auth/cloud-platform&quot;));
                settings.setCredentialsProvider(FixedCredentialsProvider.create(creds));
            }
        }
        try (var client = ImageAnnotatorClient.create(settings.build())) {
            var img  = Image.newBuilder().setContent(ByteString.copyFrom(imageBytes)).build();
            var feat = Feature.newBuilder().setType(Feature.Type.TEXT_DETECTION).build();
            var req  = AnnotateImageRequest.newBuilder().addFeatures(feat).setImage(img).build();
            var res  = client.batchAnnotateImages(List.of(req)).getResponses(0);
            if (res.hasError())
                throw new IllegalStateException(&quot;OCR error: &quot; + res.getError().getMessage());
            return res.getTextAnnotationsList().isEmpty()
                    ? &quot;&quot; : res.getTextAnnotationsList().get(0).getDescription();
        }
    }
}</code></pre>
<p>핵심 포인트</p>
<ul>
<li><code>@ConditionalOnProperty</code> — <code>app.ocr.provider=vision</code> 기본값. 향후 다른 엔진으로 교체할 때 빈 자체로 차단</li>
<li>credentials 두 가지 경로 — 명시적 파일 경로 / ADC(Application Default Credentials) 자동 fallback</li>
<li>응답에서 <code>getTextAnnotationsList().get(0)</code> 만 꺼내면 <strong>줄/블록 정보까지 합쳐진 전체 텍스트</strong> 한 덩어리</li>
</ul>
<h3 id="5-3-ocr-텍스트-→-도메인-모델-정형화-gifticontextparser">5-3. OCR 텍스트 → 도메인 모델 정형화 (<code>GifticonTextParser</code>)</h3>
<p>OCR 결과 품질이 안정되었으니, 이제 <strong>정형화 책임만 따로 떼서</strong> 도메인 텍스트 파서로 분리하였습니다. OCR 엔진과 도메인 정형화가 같은 클래스에 있으면 엔진 교체 시 변경 영역이 광범위해지기 때문입니다.</p>
<p><code>GifticonTextParser</code> 는 다음 세 함수만 노출합니다.</p>
<ul>
<li><code>findBrand(String text) → String</code></li>
<li><code>findExpiry(String text) → LocalDate</code></li>
<li><code>findLabel(String text, String brand) → String</code></li>
</ul>
<p>각각의 전략은 다음과 같습니다.</p>
<p><strong>브랜드 — 2단 fallback + 캐노니컬 매핑</strong></p>
<pre><code>1순위 : &quot;교환처:&quot; 라벨 뒤 텍스트  → 정규식 캡처
2순위 : 상위 6 줄 토큰 빈도 분석  → ≥ 2 회 등장한 토큰
캐노니컬 사전 (77 개)
        &quot;starbucks&quot; / &quot;스타벅스 코리아&quot; / &quot;STARBUCKS&quot;  →  &quot;스타벅스&quot;</code></pre><p>이 캐노니컬 매핑이 곧 <strong>흔들기 결과 매칭의 조인 키</strong> 입니다. 사용자가 흔들기 후 가맹점을 선택하면 그 가맹점의 <code>franchiseId</code> 가 결정되고, 사용자 기프티콘은 정규화된 <code>franchiseId</code> 로 즉시 검색됩니다.</p>
<p>매칭이 끝나면 사용자는 추가 입력 없이 바로 사용 가능한 기프티콘 바코드를 노출받게 됩니다 — 예: GS25 매장 선택 후 등록해둔 바나나우유 기프티콘이 즉시 노출.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/a5de3b76-d13c-468d-a6a8-673526e68cd2/image.png" alt="GS25 매장 선택 → 정규화된 franchiseId 로 매칭된 기프티콘 바코드 즉시 노출"></p>
<p><strong>만료일 — 다중 형식 정규식</strong></p>
<pre><code class="language-java">Pattern.compile(&quot;(20\\d{2})[./년- ]\\s*(\\d{1,2})[./월- ]\\s*(\\d{1,2})&quot;)
// &quot;2025/11/30&quot; / &quot;2025.11.30&quot; / &quot;2025년 11월 30일&quot; / &quot;2025-11-30&quot; 모두 흡수</code></pre>
<p><code>LocalDate</code> 로 변환되어야 흔들기 결과 화면에서 만료된 기프티콘을 자동으로 걸러낼 수 있습니다.</p>
<p><strong>상품명 — 스코어링</strong></p>
<table>
<thead>
<tr>
<th>신호</th>
<th>점수</th>
</tr>
</thead>
<tbody><tr>
<td>한글 포함</td>
<td>+2</td>
</tr>
<tr>
<td>힌트 키워드 (<code>교환권</code>, <code>이용권</code>, 용량 단위)</td>
<td>+3</td>
</tr>
<tr>
<td>용량 표기 (<code>Tall</code>, <code>300ml</code>)</td>
<td>+2</td>
</tr>
<tr>
<td>60자 이상</td>
<td>-∞ (탈락)</td>
</tr>
<tr>
<td>브랜드명 자체 / 바코드 / 로고 토큰</td>
<td>-∞ (블랙리스트)</td>
</tr>
</tbody></table>
<p>상위 점수 후보 한 개를 <code>label</code> 로 채택합니다.</p>
<h3 id="5-4-호출-흐름">5-4. 호출 흐름</h3>
<pre><code class="language-java">@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity&lt;GifticonCreateResponse&gt; upload(@RequestPart MultipartFile file) {
    Long userId = SecurityUtils.currentUserIdOrNull();
    return ResponseEntity.ok(gifticonService.createFromUpload(userId, file));
}</code></pre>
<pre><code class="language-java">@Transactional
public GifticonCreateResponse createFromUpload(Long userId, MultipartFile file) {
    String text   = ocrClient.extractText(file.getBytes());           // ① 외부 OCR
    if (!isGifticon(text)) throw new InvalidGifticonImage();           // ② 가드 (키워드 + 바코드 패턴)
    String brand  = parser.findBrand(text);                            // ③ 정형화
    LocalDate exp = parser.findExpiry(text);
    String label  = parser.findLabel(text, brand);

    Franchise fr  = franchiseService.findOrCreate(brand);              // ④ 캐노니컬 매핑
    Gifticon g    = gifticonRepository.save(Gifticon.of(userId, fr, label, exp, imageUrl));
    return GifticonCreateResponse.from(g, text);                       // ocrPreview 도 함께 반환
}</code></pre>
<p>핵심</p>
<ul>
<li><strong>OCR 호출</strong> 과 <strong>도메인 정형화</strong> 가 깨끗이 분리됨 → 엔진 교체 비용 ↓</li>
<li>응답에 <code>ocrPreview</code> 를 함께 내려, 사용자가 부정확한 필드를 즉시 손볼 수 있도록 UX 보조</li>
<li><code>franchiseService.findOrCreate(brand)</code> 가 곧 <strong>흔들기 결과 매칭이 의존하는 정규화 지점</strong></li>
</ul>
<hr>
<h2 id="6-측정-결과">6. 측정 결과</h2>
<h3 id="before-vs-after">Before vs After</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>Before (수기 입력)</th>
<th>Tesseract 시도</th>
<th>After (Vision + Parser)</th>
</tr>
</thead>
<tbody><tr>
<td>등록 입력 칸</td>
<td>4 개 (브랜드·상품명·만료일·이미지)</td>
<td>4 개 (자동 후 사용자 수정)</td>
<td><strong>1 개 (이미지만)</strong></td>
</tr>
<tr>
<td>브랜드 표기</td>
<td>&quot;스타벅스&quot; / &quot;Starbucks&quot; / &quot;스타벅스 코리아&quot; 혼재</td>
<td>흘려쓰기로 회복 불가 다수</td>
<td><strong>캐노니컬 매핑 1 개</strong></td>
</tr>
<tr>
<td>만료일 파싱 실패</td>
<td>자유 입력 → 형식 흔들리면 NULL</td>
<td>OCR 노이즈로 정규식 매치 실패 빈발</td>
<td><strong>다중 형식 정규식 — 자율 테스트셋 거의 100% 매치</strong></td>
</tr>
<tr>
<td>정형 정확도</td>
<td>—</td>
<td>약 60% (브랜드+만료일+상품명 동시 추출)</td>
<td><strong>약 95%+</strong></td>
</tr>
<tr>
<td>흔들기 결과 매칭</td>
<td>등록 표기에 따라 매칭 실패 빈발</td>
<td>OCR 노이즈로 동일</td>
<td><strong><code>franchiseId</code> 단위 정확 매칭</strong></td>
</tr>
<tr>
<td>운영 부담</td>
<td>—</td>
<td>Tesseract 버전·모델 별도 관리</td>
<td><strong>0 — 외부 SaaS</strong></td>
</tr>
</tbody></table>
<blockquote>
<p><strong>참고</strong>
위 정확도 수치는 자율 테스트셋(팀·지인 보유 기프티콘 약 50 장) 기준입니다.</p>
</blockquote>
<hr>
<h2 id="마무리">마무리</h2>
<p>본 포스팅에서는 똑똑꺼비 프로젝트의 <strong>Interactive 소비 제안(흔들기 → 가맹점 선택 → 카드/기프티콘)</strong> 이 의미를 가지기 위한 데이터 인프라로서, 기프티콘 자동 등록 OCR 을 Tesseract 후처리에서 GCP Vision API + 도메인 정규화 분리 구조로 전환한 과정을 정리하였습니다.</p>
<table>
<thead>
<tr>
<th>시도</th>
<th>결과</th>
<th>한계 / 의의</th>
</tr>
</thead>
<tbody><tr>
<td>수기 입력</td>
<td>동작</td>
<td>등록 부담 + 표기 흔들림으로 흔들기 결과 매칭 실패</td>
</tr>
<tr>
<td>Tesseract + 정규식</td>
<td>부분 동작</td>
<td>한국어 광고·패키징 폰트 정확도 한계</td>
</tr>
<tr>
<td><strong>GCP Vision API + GifticonTextParser</strong></td>
<td><strong>해결</strong></td>
<td>엔진 교체 비용 0 (인터페이스 분리), 정형화 정확도 ~95%, 흔들기 매칭 정확</td>
</tr>
</tbody></table>
<p><strong>핵심 요약</strong></p>
<ul>
<li>OCR <strong>엔진 품질 자체</strong> 가 후처리 정규식의 상한선을 결정.</li>
<li>엔진(<code>OcrClient</code>) 과 도메인 정규화(<code>GifticonTextParser</code>) 의 <strong>책임을 처음부터 분리</strong> 해야 교체 비용이 작아짐.</li>
<li>자동 추출만으로는 부족 — <strong>캐노니컬 매핑(브랜드 사전)</strong> 이 시그니처 기능(흔들기 후 가맹점 선택 시의 기프티콘 매칭) 의 조인 키 품질을 결정.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[랭킹 스케줄러 Deadlock — Blue/Green 동시 실행 문제와 ShedLock 도입]]></title>
            <link>https://velog.io/@ori_gui/%EB%9E%AD%ED%82%B9-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC-Deadlock-BlueGreen-%EB%8F%99%EC%8B%9C-%EC%8B%A4%ED%96%89-%EB%AC%B8%EC%A0%9C%EC%99%80-ShedLock-%EB%8F%84%EC%9E%85</link>
            <guid>https://velog.io/@ori_gui/%EB%9E%AD%ED%82%B9-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC-Deadlock-BlueGreen-%EB%8F%99%EC%8B%9C-%EC%8B%A4%ED%96%89-%EB%AC%B8%EC%A0%9C%EC%99%80-ShedLock-%EB%8F%84%EC%9E%85</guid>
            <pubDate>Fri, 01 May 2026 05:12:24 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>&#39;운세 강화게임과 결합된 새로운 경험의 데일리 운세 플랫폼&#39; &#39;아그작&#39; 프로젝트에서, <strong>자정에 도는 랭킹 스냅샷 스케줄러가 새벽 시간대에만 Deadlock 으로 죽는 패턴</strong>을 발견하였습니다. Grafana Alert → Mattermost 로 새벽 에러를 실시간 감지한 뒤 원인을 추적하고, ShedLock + Redis 분산 락 + 매시간 분할로 해결한 과정을 정리한 내용입니다.</p>
</blockquote>
<hr>
<h2 id="1-들어가며">1. 들어가며</h2>
<p>아그작은 사용자의 오늘 운세 점수를 기반으로 <strong>시간 단위 랭킹</strong> 을 제공하는 서비스입니다. 초기에는 <strong>매일 23:59 에 단 한 번</strong> 그날치 랭킹 스냅샷을 새로 갈아끼우는 단순 cron 으로 동작하고 있었으나, <strong>자정 직후 새벽 시간대에 간헐적으로 Deadlock 예외</strong> 가 발생하는 현상을 확인하였습니다.</p>
<pre><code>TaskUtils$LoggingErrorHandler : Unexpected error occurred in scheduled task
SqlExceptionHelper            : Deadlock found when trying to get lock; try restarting transaction</code></pre><p>낮 시간대에 동일 스케줄러를 임의로 트리거해 보면 문제 없이 돌고 있었기에, &quot;왜 새벽에만 터지는가&quot; 를 출발점으로 원인을 추적하였습니다.</p>
<p>본문은 다음 흐름으로 정리합니다.</p>
<ol>
<li>문제 상황 진단 — Grafana Alert 로 새벽 에러 감지</li>
<li>기존 스케줄러 구조와 운영 환경 분석</li>
<li>원인 — Blue/Green 두 컨테이너의 스케줄러 동시 실행</li>
<li>ShedLock + Redis 분산 락 적용 (매시간 분할 포함)</li>
<li>측정 결과 및 회고</li>
</ol>
<hr>
<h2 id="2-문제-상황-진단--grafana-alert-→-mattermost">2. 문제 상황 진단 — Grafana Alert → Mattermost</h2>
<p>운영에는 EFK + Prometheus + Grafana 기반의 모니터링 체계가 구축되어 있었고, 5xx 또는 ERROR 레벨 로그가 5분 윈도우 내에 임계치를 넘으면 Mattermost 로 알림이 전달되도록 설정되어 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/b17f0d98-0779-4067-888a-68458b9e9155/image.png" alt="Grafana 5xx FIRING 알림(Mattermost)"></p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/e245fa26-8736-43da-851d-d2ee66b95b25/image.png" alt="Mattermost — 5xx FIRING 알림 상세"></p>
<p>함께 운영 채널에는 다음과 같이 <strong>데드락 메시지를 직접 식별</strong> 한 캡처도 남아 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/2c390eb5-25a8-4f31-9c11-1c88266bdd27/image.png" alt="Mattermost — 스케줄링 로직 데드락 발견"></p>
<pre><code>[FIRING] Application 5xx ERROR detected (logs, 5m window) (critical)
Env: prod | Service: agzak-app-server | Count: 2
- 최근 5분 이내 ERROR 레벨 로그가 발생했습니다.</code></pre><p>해당 알림을 통해 <strong>새벽 시간대에 다음과 같은 패턴으로 ERROR 가 발생</strong>되고 있다는 사실을 확인하였습니다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>발생 시각</td>
<td>KST 기준 새벽 (01:00 ~ 01:05 부근)</td>
</tr>
<tr>
<td>주기</td>
<td><strong>23:59 cron (1일 1회)</strong> — 발화 후 자정대 ERROR 누적이 5분 윈도우 임계치를 넘기면서 알림 발송</td>
</tr>
<tr>
<td>메시지</td>
<td><code>Deadlock found when trying to get lock; try restarting transaction</code></td>
</tr>
<tr>
<td>영향</td>
<td>그날치 랭킹 스냅샷이 깨지거나 누락</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-기존-스케줄러-구조와-운영-환경-분석">3. 기존 스케줄러 구조와 운영 환경 분석</h2>
<p>문제가 일어난 스케줄러는 <code>RankingScheduler</code> 의 단일 메서드입니다. 당시에는 <strong>하루 한 번 23:59</strong> 에 그날치 랭킹을 통째로 갈아끼우는 단순 cron 이었습니다.</p>
<pre><code class="language-java">@Component
@EnableScheduling
@RequiredArgsConstructor
public class RankingScheduler {

    private final RankingService rankingService;

    /** 매일 23:59: 그날치 ranking 스냅샷을 통째로 재생성 */
    @Scheduled(cron = &quot;0 59 23 * * *&quot;, zone = &quot;Asia/Seoul&quot;)
    public void todayRebuild() {
        rankingService.rebuildTodaySnapshot();
    }
}</code></pre>
<p><code>rebuildTodaySnapshot()</code> 의 핵심은 <strong>해당 날짜 스냅샷을 통째로 교체</strong> 하는 부분입니다.</p>
<pre><code class="language-java">@Transactional
public void replaceTodaySnapshot(LocalDate date, List&lt;Ranking&gt; rows) {
    rankingRepository.deleteByDate(date);   // ① 그날치 ranking 전부 삭제
    if (rows != null &amp;&amp; !rows.isEmpty()) {
        rankingRepository.saveAll(rows);    // ② 새 랭킹 일괄 INSERT
    }
}</code></pre>
<p>운영 환경은 다음과 같이 구성되어 있습니다.</p>
<ul>
<li><strong>AWS EC2</strong> 단일 인스턴스 위에 <strong>Docker compose</strong> 로 서비스 기동</li>
<li>백엔드는 <strong>Blue/Green 무중단 배포</strong> 구조 — 두 컨테이너(<code>agzak-app-blue</code>, <code>agzak-app-green</code>) 가 nginx 뒤에서 트래픽을 나눠 받음</li>
<li>두 컨테이너 모두 <strong>동일한 MySQL/Redis 를 바라봄</strong></li>
</ul>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/71c69dfe-0827-488e-9678-80e792f36f3f/image.png" alt="인프라 아키텍처(EC2 단일 호스트 위 Docker compose, Blue/Green)"></p>
<p>CI/CD 부분만 따로 보면 다음과 같이 <strong>Jenkins → Spring BE(Blue/Green) → Nginx</strong> 흐름이 잡혀 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/40af133e-9b40-43e2-9a05-e5b7097e79a5/image.png" alt="CI/CD 토폴로지 — Jenkins → Spring BE Blue/Green → Nginx"></p>
<p>이 환경에서 &quot;왜 동시에 두 인스턴스가 같은 스케줄러를 돌고 있는가?&quot; 가 사건의 진짜 원인이 됩니다.</p>
<hr>
<h2 id="4-원인--bluegreen-두-컨테이너의-스케줄러-동시-실행">4. 원인 — Blue/Green 두 컨테이너의 스케줄러 동시 실행</h2>
<p><code>@Scheduled</code> 어노테이션은 <strong>각 JVM 마다 독립적으로 동작</strong> 합니다. 즉 Blue 와 Green 두 컨테이너가 동시에 떠 있는 상태에서는, <strong>양쪽이 같은 cron 시각에 동일한 스케줄러를 동시에 실행</strong> 합니다.</p>
<p>이 상태에서 두 컨테이너가 23:59 정각에 동시에 다음을 수행하게 됩니다.</p>
<pre><code>[Blue]  TX-A: DELETE FROM ranking WHERE date = &#39;2025-11-18&#39;
[Green] TX-B: DELETE FROM ranking WHERE date = &#39;2025-11-18&#39;
[Blue]  TX-A: INSERT INTO ranking (...) VALUES (...) x N
[Green] TX-B: INSERT INTO ranking (...) VALUES (...) x N</code></pre><p><code>ranking</code> 테이블에는 <code>(member_id, date)</code> 복합 유니크 제약과 <code>(date, rank_no)</code> 인덱스가 잡혀 있습니다.</p>
<pre><code class="language-java">@Table(
    name = &quot;ranking&quot;,
    uniqueConstraints = {
        @UniqueConstraint(name = &quot;UQ_ranking_member_date&quot;, columnNames = {&quot;member_id&quot;, &quot;date&quot;})
    },
    indexes = { @Index(name = &quot;IDX_ranking_date_rank&quot;, columnList = &quot;date, rank_no&quot;) }
)</code></pre>
<p>이 구조에서는 다음 시나리오로 Deadlock 이 발생합니다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>Blue (TX-A)</th>
<th>Green (TX-B)</th>
</tr>
</thead>
<tbody><tr>
<td>①</td>
<td>DELETE row 1~50 (gap lock + row lock)</td>
<td>(대기)</td>
</tr>
<tr>
<td>②</td>
<td>(대기)</td>
<td>DELETE row 51~100</td>
</tr>
<tr>
<td>③</td>
<td>INSERT (member=10, date=오늘) — TX-B 가 잡은 락 대기</td>
<td>INSERT (member=20, date=오늘) — TX-A 가 잡은 락 대기</td>
</tr>
<tr>
<td>④</td>
<td><strong>Deadlock victim 선정</strong> → 한쪽 ROLLBACK</td>
<td></td>
</tr>
</tbody></table>
<blockquote>
<p><strong>참고</strong>
InnoDB 는 인덱스 단위로 락을 잡고, <code>DELETE ... WHERE date = ?</code> 는 인덱스 범위 락(gap + record lock) 을 광범위하게 점유합니다. 두 트랜잭션이 같은 범위에 동시에 들어오면 <strong>락 획득 순서가 어긋나는 순간 Deadlock</strong> 이 발생할 수 있습니다.</p>
</blockquote>
<p>실제로 동일 시나리오를 로컬에서 재현하여 <code>SHOW ENGINE INNODB STATUS</code> 의 <code>LATEST DETECTED DEADLOCK</code> 섹션을 확인하면, 두 트랜잭션이 서로의 gap lock 을 기다리다 한쪽이 victim 으로 롤백되는 구조가 그대로 드러납니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/5e9d4957-927f-4114-8cb0-c1fc9b3dd27b/image.png" alt="InnoDB STATUS — TRANSACTION (1) HOLDS / WAITING (Blue/Green 동시 INSERT)"></p>
<p>위 캡처는 Blue 컨테이너의 트랜잭션이 <code>ranking</code> 테이블 PRIMARY 인덱스 위에서 <code>lock_mode X locks gap before rec</code> 를 보유한 채로 자신의 <code>insert intention</code> 락을 또다시 기다리고 있는 상태를 보여줍니다. Green 쪽도 같은 상태에 들어가면 InnoDB 가 한쪽을 victim 으로 골라 ROLLBACK 합니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/813714a8-94e7-44c6-8550-903093d2c9a6/image.png" alt="InnoDB STATUS — TRANSACTION (2) + WE ROLL BACK TRANSACTION (2)"></p>
<p>InnoDB 가 <code>*** WE ROLL BACK TRANSACTION (2)</code> 로 victim 을 명시하는 부분이 운영 환경에서 보이던 <code>Deadlock found when trying to get lock; SQLState 40001</code> 의 직접적인 원인이었습니다.</p>
<h3 id="그렇다면-왜-새벽자정-직후에만-터지는가">그렇다면 왜 새벽(자정 직후)에만 터지는가?</h3>
<p>당시 스케줄러는 <strong>매일 23:59 단 한 번</strong> 만 도는 cron 이었기 때문에, 매일 같은 시각에 두 컨테이너가 거의 밀리초 단위로 동시에 트리거됩니다. 더불어 자정 직후에는 외부 트래픽이 거의 없어 다른 SQL 의 간섭이 들어오지 않으니, <strong>두 트랜잭션의 락 획득 시점이 정확히 같은 윈도우</strong> 에 진입합니다.</p>
<p>즉, 자정 Deadlock 은 다음 두 조건이 동시에 만족된 결과였습니다.</p>
<ul>
<li><strong>1일 1회 정시 cron</strong> — 두 컨테이너가 동일 시각에 ms 단위로 동기화되어 출발</li>
<li><strong>외부 트래픽 0</strong> — 다른 트랜잭션이 락 윈도우를 흩뜨릴 여지가 없음</li>
</ul>
<hr>
<h2 id="5-shedlock--redis-분산-락-적용-매시간-분할-포함">5. ShedLock + Redis 분산 락 적용 (매시간 분할 포함)</h2>
<p>해결은 두 가지를 함께 적용하는 형태로 진행하였습니다.</p>
<ol>
<li><strong>ShedLock + Redis 분산 락</strong> — Blue/Green 두 컨테이너가 떠 있어도 한쪽만 실제 작업 수행</li>
<li><strong>자정 1회 → 매시간 분할</strong> — 시간 단위 랭킹 요구사항에도 맞춰, <code>@Scheduled</code> 를 매시간 cron 으로 잘게 쪼갬</li>
</ol>
<p>ShedLock(<code>net.javacrumbs.shedlock</code>) 은 <strong>다중 인스턴스 환경에서 <code>@Scheduled</code> 가 한 번만 실행되도록 보장</strong> 해주는 라이브러리입니다. 이미 운영에 사용 중이던 Redis 를 락 저장소로 그대로 활용할 수 있어 추가 인프라 부담이 없었습니다.</p>
<h3 id="의존성">의존성</h3>
<pre><code class="language-groovy">implementation &#39;net.javacrumbs.shedlock:shedlock-spring:5.15.1&#39;
implementation &#39;net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.15.1&#39;</code></pre>
<h3 id="lockprovider-설정">LockProvider 설정</h3>
<pre><code class="language-java">@Configuration
public class SchedulerLockConfig {

    @Bean
    public LockProvider lockProvider(StringRedisTemplate stringRedisTemplate) {
        return new RedisLockProvider(
                Objects.requireNonNull(stringRedisTemplate.getConnectionFactory()),
                &quot;shedlock:agzak&quot;          // Redis key prefix
        );
    }
}</code></pre>
<h3 id="매시간-분할--schedulerlock-적용">매시간 분할 + <code>@SchedulerLock</code> 적용</h3>
<pre><code class="language-java">@Component
@EnableScheduling
@RequiredArgsConstructor
public class RankingScheduler {

    private final RankingService rankingService;

    /** 매시간 59분 55초: Redis → MySQL 스냅샷 재생성 */
    @Scheduled(cron = &quot;55 59 * * * *&quot;, zone = &quot;Asia/Seoul&quot;)
    @SchedulerLock(name = &quot;rankingSnapshot&quot;, lockAtMostFor = &quot;PT4M&quot;, lockAtLeastFor = &quot;PT20S&quot;)
    public void todayRebuild() {
        rankingService.rebuildTodaySnapshot();
    }

    /** 매시간 55분: MySQL → Redis 리씨드 (정합성 보정) */
    @Scheduled(cron = &quot;0 55 * * * *&quot;, zone = &quot;Asia/Seoul&quot;)
    @SchedulerLock(name = &quot;rankingReseed&quot;, lockAtMostFor = &quot;PT4M&quot;, lockAtLeastFor = &quot;PT10S&quot;)
    public void hourlyReseedRedis() {
        rankingService.reseedRedisFromMysqlToday();
        int pruned = rankingService.pruneTodayOrphans();
        if (pruned &gt; 0) log.info(&quot;Pruned {} orphan(s) after reseed&quot;, pruned);
    }
}</code></pre>
<p>각 옵션의 의미는 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th>옵션</th>
<th>값</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>name</code></td>
<td><code>rankingSnapshot</code> / <code>rankingReseed</code></td>
<td>Redis 키. 같은 이름이면 글로벌 단일 실행 보장</td>
</tr>
<tr>
<td><code>lockAtMostFor</code></td>
<td><code>PT4M</code> (4분)</td>
<td>락을 최대로 보유할 시간. JVM 이 죽어도 이 시간 후엔 자동 해제</td>
</tr>
<tr>
<td><code>lockAtLeastFor</code></td>
<td><code>PT20S</code> / <code>PT10S</code></td>
<td>너무 빠른 재실행을 막기 위한 최소 보유 시간</td>
</tr>
</tbody></table>
<h3 id="동작-흐름">동작 흐름</h3>
<pre><code>55분 정각
 ├─ Blue:  RedisLockProvider.lock(&quot;rankingReseed&quot;) → SET NX 성공 → 실행
 └─ Green: RedisLockProvider.lock(&quot;rankingReseed&quot;) → SET NX 실패 → 스킵</code></pre><p>내부적으로는 Redis <code>SET key value NX PX &lt;ms&gt;</code> 명령을 사용하기 때문에, <strong>두 컨테이너가 정확히 동시에 시도해도 한쪽만 락을 획득</strong> 합니다. 락을 못 받은 쪽은 조용히 메서드를 빠져나갑니다.</p>
<p>이 구성으로 &quot;Blue/Green 동시 실행&quot; 이라는 근본 원인이 사라지고, 자정 1회 → 매시간 분할로 바뀌면서 <strong>만에 하나 한 사이클이 깨져도 다음 시간 cron 에서 자동 회복</strong> 되는 안전망이 생깁니다.</p>
<p>Grafana 의 Loki 로그 패널에서 두 컨테이너의 동작을 함께 보면, <strong>한쪽은 <code>ACQUIRED — snapshot ok</code>, 다른 쪽은 <code>skipped — locked by ...</code></strong> 가 매 사이클마다 교차로 찍히는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/881548e7-8085-4d2a-aa83-1090b95956b4/image.png" alt="Grafana — ShedLock ACQUIRED / skipped 교차 로그(blue/green)"></p>
<p>같은 시각에 양쪽이 동시에 트리거되어도 실제 DB 작업은 단 한 번만 수행되며, 락 토큰(<code>d450e30d9dde-...</code>, <code>2483813bd96c-...</code>) 으로 어느 인스턴스가 락을 잡았는지 추적이 가능합니다.</p>
<blockquote>
<p><strong>참고 — 위 Grafana 캡처는 로컬 재현 환경에서 캡처한 자료입니다.</strong></p>
</blockquote>
<hr>
<h2 id="6-측정-결과">6. 측정 결과</h2>
<p>ShedLock + 매시간 분할 적용 전후를 운영 로그 기준으로 비교하였습니다.</p>
<h3 id="before-vs-after">Before vs After</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>Before</th>
<th>After (ShedLock + 매시간 분할)</th>
</tr>
</thead>
<tbody><tr>
<td>자정 시간대 Deadlock 알림</td>
<td>1일 1회</td>
<td><strong>0건</strong></td>
</tr>
<tr>
<td>랭킹 스냅샷 누락</td>
<td>발견됨</td>
<td><strong>0건</strong></td>
</tr>
<tr>
<td>스케줄러 중복 실행 흔적 (로그)</td>
<td>두 컨테이너 모두 시작 로그</td>
<td>한쪽만 시작 + 다른 쪽 즉시 스킵</td>
</tr>
<tr>
<td>운영 부담</td>
<td>인스턴스별 수동 관리 필요</td>
<td>무관, 자동</td>
</tr>
</tbody></table>
<hr>
<h2 id="7-회고-및-정리">7. 회고 및 정리</h2>
<p><strong><code>@Scheduled</code> 는 인스턴스마다 독립 실행이 기본값입니다.</strong>
Blue/Green 구조에서 이것이 데드락으로 이어진다는 사실은 직접 겪기 전까지는 실감하기 어려웠습니다.</p>
<p><strong>&quot;트래픽 0 + 정시 cron&quot; 조합은 생각보다 위험합니다.</strong>
자정처럼 조용한 시간대는 오히려 두 인스턴스의 락 획득 윈도우가 완벽히 겹쳐 충돌 확률이 높아집니다. 시간 단위 분할이 이 윈도우를 흩뜨리는 데 유효했습니다.</p>
<p><strong>알람 없이는 새벽 장애를 모르고 지나쳤을 것입니다.</strong>
모든 추적의 출발점은 Mattermost 로 들어온 FIRING 한 줄이었습니다. 모니터링·알람 체계 자체가 인프라의 일부라는 점을 다시 확인했습니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>본 포스팅에서는 자정에만 발생하던 랭킹 스케줄러 Deadlock 의 원인을 추적하고, ShedLock + Redis 분산 락 + 매시간 분할로 해결한 과정을 정리하였습니다.</p>
<p><strong>핵심 요약</strong></p>
<ul>
<li><code>@Scheduled</code> 는 인스턴스마다 독립 실행이라는 기본 동작을 인지할 것.</li>
<li>무중단 배포 환경에서는 <strong>분산 락으로 단일 실행을 명시적으로 보장</strong> 하는 것이 표준 해법.</li>
<li>&quot;정시 cron + 트래픽 0 시간대&quot; 라는 조합은 동시 출발 확률이 높아 데드락에 취약 — 시간 분할이 함께 가야 안전.</li>
<li>알람·리포트 체계가 갖춰져 있을 때, 자정 같은 비활성 시간대 장애도 표면 위로 끌어올릴 수 있음.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis ZSET + MySQL 스냅샷 이중 저장 구조 도입]]></title>
            <link>https://velog.io/@ori_gui/Redis-ZSET-MySQL-%EC%8A%A4%EB%83%85%EC%83%B7-%EC%9D%B4%EC%A4%91-%EC%A0%80%EC%9E%A5-%EA%B5%AC%EC%A1%B0-%EB%8F%84%EC%9E%85</link>
            <guid>https://velog.io/@ori_gui/Redis-ZSET-MySQL-%EC%8A%A4%EB%83%85%EC%83%B7-%EC%9D%B4%EC%A4%91-%EC%A0%80%EC%9E%A5-%EA%B5%AC%EC%A1%B0-%EB%8F%84%EC%9E%85</guid>
            <pubDate>Fri, 01 May 2026 02:17:43 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>&#39;운세 강화게임과 결합된 새로운 경험의 데일리 운세 플랫폼&#39; &#39;아그작&#39; 프로젝트의 시간 단위 랭킹 기능에서, 단일 MySQL 테이블 + <code>ORDER BY</code> 정렬만으로는 <strong>실시간성·운영 안정성·과거 조회</strong> 세 가지 요구를 동시에 만족시키지 못한다는 한계를 확인하였습니다. 이를 <strong>Redis ZSET 기반 실시간 랭킹 + MySQL 스냅샷 보관소</strong> 라는 이중 저장 구조로 분리한 의사결정 과정을 정리한 내용입니다.</p>
</blockquote>
<hr>
<h2 id="1-들어가며">1. 들어가며</h2>
<p>아그작은 사용자가 받은 <strong>오늘의 운세 점수</strong> 를 기반으로 다음 세 가지 랭킹을 제공합니다.</p>
<table>
<thead>
<tr>
<th>랭킹 종류</th>
<th>정렬 기준</th>
</tr>
</thead>
<tbody><tr>
<td>Total</td>
<td>강화 후 최종 총점</td>
</tr>
<tr>
<td>Original</td>
<td>강화 전 원래 총점</td>
</tr>
<tr>
<td>Gain (증가율)</td>
<td>(final − original) / original</td>
</tr>
</tbody></table>
<p>처음에는 단순하게 <code>Fortune</code> 테이블을 그날 날짜로 조회한 뒤 메모리 정렬로 응답하였으나, 실제 운영 환경에 올린 후 다음과 같은 문제를 마주하였습니다.</p>
<ol>
<li>매 요청마다 풀스캔 + ORDER BY 발생</li>
<li>&quot;내 등수 가져오기&quot; 가 별도 카운트 쿼리로 분기되어 비용이 또 발생</li>
<li>어제·과거 일자 랭킹을 보고 싶은 요구가 생김 — 이때는 운세 데이터가 변경되어도 <strong>그 시점의 순위가 보존</strong> 되어야 함</li>
</ol>
<p>본문은 다음 흐름으로 정리합니다.</p>
<ol>
<li>문제 상황 — 단일 MySQL 정렬 기반 구현의 한계</li>
<li>본질 재정의 — &quot;실시간 랭킹&quot; 과 &quot;스냅샷&quot; 은 다른 자료구조가 필요하다</li>
<li>Redis ZSET(실시간) + MySQL Ranking(스냅샷) 이중 저장</li>
<li>동기화 전략 — 시간당 reseed + 매시간 스냅샷</li>
<li>측정 결과</li>
<li>회고 및 정리</li>
</ol>
<hr>
<h2 id="2-문제-상황--단일-mysql-정렬-기반-구현의-한계">2. 문제 상황 — 단일 MySQL 정렬 기반 구현의 한계</h2>
<p>초기 구현은 <code>Fortune</code> 테이블을 날짜 기준으로 조회한 뒤, 애플리케이션에서 정렬 + 페이징으로 잘라 응답하는 단순 구조였습니다.</p>
<pre><code class="language-java">// 의사 코드 — 초기 구현
List&lt;Fortune&gt; fortunes = fortuneRepository.findAllByDateWithMember(today);
return fortunes.stream()
    .sorted(Comparator.comparingInt(Fortune::getTotalScore).reversed())
    .limit(limit)
    .map(this::toRankingItem)
    .toList();</code></pre>
<p>확인된 문제점은 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>문제</th>
</tr>
</thead>
<tbody><tr>
<td>매 요청 풀스캔</td>
<td>같은 날 데이터가 변경되지 않아도 매번 전체 행을 읽음</td>
</tr>
<tr>
<td>세 가지 정렬 모두 메모리 처리</td>
<td>Total / Original / Gain 각 기준으로 매번 정렬</td>
</tr>
<tr>
<td>내 등수 조회 추가 비용</td>
<td>사용자별 등수는 결국 같은 결과를 한 번 더 정렬·인덱싱</td>
</tr>
<tr>
<td>과거 일자 보존 X</td>
<td>Fortune 데이터가 갱신되면, 어제 랭킹의 &quot;그 시점&quot; 결과가 사라짐</td>
</tr>
<tr>
<td>초마다 갱신되는 점수 반영 어려움</td>
<td>게임으로 점수가 자주 바뀌는데 매번 DB 정렬을 다시 도는 구조</td>
</tr>
</tbody></table>
<p>특히 마지막 항목 — <strong>&quot;어제 랭킹은 어제 그대로 보존&quot; + &quot;오늘 랭킹은 점수가 바뀔 때마다 즉시 반영&quot;</strong> — 두 요구를 단일 테이블 + 정렬만으로는 깨끗하게 표현하기 어려웠습니다.</p>
<hr>
<h2 id="3-본질-재정의--실시간-랭킹과-스냅샷은-다른-자료구조가-필요하다">3. 본질 재정의 — 실시간 랭킹과 스냅샷은 다른 자료구조가 필요하다</h2>
<p>요구사항을 다시 정리하면 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th>요구</th>
<th>특성</th>
<th>적합한 자료구조</th>
</tr>
</thead>
<tbody><tr>
<td>오늘 랭킹</td>
<td>점수 변동이 잦고, 즉시 정렬된 상태가 필요</td>
<td><strong>정렬 자료구조 in-memory</strong> (Redis ZSET)</td>
</tr>
<tr>
<td>과거 일자 랭킹</td>
<td>이미 확정된 결과, 영속 보관 + 가끔 조회</td>
<td><strong>RDB 스냅샷 테이블</strong> (MySQL)</td>
</tr>
<tr>
<td>내 등수</td>
<td>&quot;이 사용자의 현재 등수&quot; 단건 조회</td>
<td>ZSET <code>ZREVRANK</code> 또는 스냅샷 <code>rank_no</code> 컬럼</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>MySQL 은 정형 + 영속 보관에 강하고, ZSET 은 정렬 상태 자체를 자료구조로 들고 있는 구조입니다.</strong>
<strong>두 영역은 요구되는 갱신 빈도와 정렬 비용이 근본적으로 다릅니다.</strong></p>
</blockquote>
<p>→ &quot;오늘 랭킹은 Redis ZSET 으로, 과거 일자는 MySQL 스냅샷 테이블로&quot; <strong>저장소 자체를 분리</strong> 하기로 의사결정하였습니다.</p>
<hr>
<h2 id="4-redis-zset-실시간--mysql-ranking-스냅샷">4. Redis ZSET (실시간) + MySQL Ranking (스냅샷)</h2>
<h3 id="시스템-구성">시스템 구성</h3>
<pre><code>                점수 변경 이벤트
사용자 → Fortune 갱신 → refreshTodayForMember(memberId)
                            │
                            ▼
                    ┌────────────────┐
                    │  Redis ZSET    │  ← 오늘 랭킹 실시간
                    │  HASH (user)   │
                    └───────┬────────┘
                            │  매시간 59분 55초
                            ▼  rebuildTodaySnapshot()
                    ┌────────────────┐
                    │ MySQL ranking  │  ← 과거 일자 보관
                    └────────────────┘
                            ▲
                            │  매시간 55분 reseed (정합성 보정)
                    ┌───────┴────────┐
                    │ MySQL fortune  │
                    └────────────────┘</code></pre><h3 id="redis-키-설계">Redis 키 설계</h3>
<pre><code class="language-java">// rank:20251119:total      ZSET (memberId → totalScore)
// rank:20251119:original   ZSET (memberId → originalScore)
// rank:20251119:gain       ZSET (memberId → 증가율 %)
// rank:20251119:user:42    HASH (nickname/total/original)

private String key(LocalDate d, String suffix) {
    return &quot;rank:&quot; + d.format(DAY) + &quot;:&quot; + suffix;
}
public String zTotal(LocalDate d)    { return key(d, &quot;total&quot;); }
public String zOriginal(LocalDate d) { return key(d, &quot;original&quot;); }
public String zGain(LocalDate d)     { return key(d, &quot;gain&quot;); }
public String hUser(LocalDate d, Long memberId){ return key(d, &quot;user:&quot;+memberId); }</code></pre>
<p>ZSET 에는 <strong>점수만</strong>, HASH 에는 <strong>닉네임/원본 점수 등 부가 정보</strong> 를 분리 저장하여 ZSET 조회 시 메모리·네트워크 비용을 줄였습니다.</p>
<p>RedisInsight 로 실제 키 구조를 보면, <code>rank:20251125</code> 네임스페이스 아래에 <code>total / original / gain</code> 세 ZSET 과 사용자별 HASH 가 함께 적재된 것이 보입니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/2f3ba21f-fd11-40e4-8db3-7c1c5cae5ba0/image.png" alt="RedisInsight — rank:20251125:total ZSET TOP 10 (점수 내림차순)"></p>
<p><code>total</code> ZSET 옆에는 동일 날짜의 <code>original</code> (강화 전 점수 기준) ZSET 도 함께 보관되어, 같은 사용자에 대해 정렬 기준만 다른 두 결과를 곧바로 꺼낼 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/8a7f2ded-b920-4006-af19-c78b245d207e/image.png" alt="RedisInsight — rank:20251125:original ZSET (강화 전 원점수 기준)"></p>
<h3 id="점수-변경-→-즉시-반영">점수 변경 → 즉시 반영</h3>
<p>운세 점수가 바뀌는 모든 도메인 이벤트(쿠키 굽기·먹기 등) 직후 다음 메서드가 호출됩니다.</p>
<pre><code class="language-java">public void upsertTodayUser(Long memberId, String nickname,
                            Integer total, Integer original) {
    LocalDate d = today();
    double gain = RankCalculator.increasePct(original, total);

    redis.executePipelined((RedisCallback&lt;Object&gt;) connection -&gt; {
        HashOperations&lt;String, String, String&gt; h = redis.opsForHash();
        ZSetOperations&lt;String, String&gt; z = redis.opsForZSet();

        if (nickname != null) h.put(hUser(d, memberId), &quot;nickname&quot;, nickname);
        if (total != null)    h.put(hUser(d, memberId), &quot;total&quot;,    String.valueOf(total));
        if (original != null) h.put(hUser(d, memberId), &quot;original&quot;, String.valueOf(original));

        if (total != null)    z.add(zTotal(d),    String.valueOf(memberId), total.doubleValue());
        if (original != null) z.add(zOriginal(d), String.valueOf(memberId), original.doubleValue());
        z.add(zGain(d), String.valueOf(memberId), gain);
        return null;
    });
}</code></pre>
<p>ZSET <code>ZADD</code> 는 O(log N) 으로 끝나기 때문에, 점수 갱신 자체가 <strong>DB 정렬 한 번보다 압도적으로 가볍습니다.</strong></p>
<h3 id="top-n-읽기">TOP N 읽기</h3>
<pre><code class="language-java">// 오늘 TOP N — Redis 만으로 끝남
public List&lt;UserRow&gt; readTop(LocalDate d, String zset, int limit) {
    Set&lt;TypedTuple&lt;String&gt;&gt; rows =
        redis.opsForZSet().reverseRangeWithScores(zset, 0, Math.max(0, limit-1));
    // ZSET 결과 + HASH 보강 후 반환
    // ...
}</code></pre>
<p><code>ZREVRANGE 0 N-1 WITHSCORES</code> 한 번이면 정렬된 상태가 그대로 나옵니다.</p>
<h3 id="내-등수-단건-조회">&quot;내 등수&quot; 단건 조회</h3>
<pre><code class="language-java">public Integer zrevRank1Base(String zset, long memberId) {
    if (!redis.hasKey(zset)) return null;
    Long zero = redis.opsForZSet().reverseRank(zset, String.valueOf(memberId));
    return zero == null ? null : (int)(zero + 1);
}</code></pre>
<p><code>ZREVRANK</code> 는 <strong>O(log N)</strong> 으로 등수를 바로 반환합니다. 별도 카운트 쿼리·메모리 정렬 없이 끝납니다.</p>
<hr>
<h2 id="5-동기화-전략--시간당-reseed--매시간-스냅샷">5. 동기화 전략 — 시간당 reseed + 매시간 스냅샷</h2>
<p>Redis 가 빠르고 가볍지만, <strong>단일 실패점이 되면 곤란</strong> 합니다. 따라서 두 가지 동기화 잡을 두어 정합성·내구성을 함께 챙겼습니다.</p>
<h3 id="시간당-reseed-mysql-→-redis">시간당 Reseed (MySQL → Redis)</h3>
<pre><code class="language-java">@Scheduled(cron = &quot;0 55 * * * *&quot;, zone = &quot;Asia/Seoul&quot;)
@SchedulerLock(name = &quot;rankingReseed&quot;, lockAtMostFor = &quot;PT4M&quot;, lockAtLeastFor = &quot;PT10S&quot;)
public void hourlyReseedRedis() {
    rankingService.reseedRedisFromMysqlToday();
    rankingService.pruneTodayOrphans();
}</code></pre>
<pre><code class="language-java">@Transactional
public void reseedRedisFromMysqlToday() {
    LocalDate today = redisStore.today();
    var fortunes = mysqlStore.fortunes(today);
    if (fortunes.isEmpty()) return;

    // ZSET 사이즈와 MySQL 행 수가 다르면 리씨드
    Long zcard = redisStore.zcard(redisStore.zTotal(today));
    boolean needsReseed = (zcard == null) || (zcard.intValue() != fortunes.size());
    if (!needsReseed) return;

    var rows = fortunes.stream().map(...).toList();
    try {
        redisStore.bulkUpsertTodayUsers(rows);
    } catch (Exception e) {
        log.warn(&quot;Redis reseed failed: {}&quot;, e.getMessage());
    }
}</code></pre>
<p>핵심 포인트:</p>
<ul>
<li><code>ZCARD</code> 와 MySQL 행 수가 같으면 <strong>noop</strong> — 매시간 강제로 갈아엎지 않음</li>
<li>다르면 <strong>bulk upsert</strong> 로 ZSET·HASH 한 번에 채움 (Redis 파이프라이닝)</li>
<li>실패해도 예외를 그대로 던지지 않고 로그만 — Redis 가 잠깐 문제여도 다음 요청에서 회복</li>
</ul>
<h3 id="매시간-스냅샷-redis-→-mysql-ranking">매시간 스냅샷 (Redis → MySQL <code>ranking</code>)</h3>
<pre><code class="language-java">@Scheduled(cron = &quot;55 59 * * * *&quot;, zone = &quot;Asia/Seoul&quot;)
@SchedulerLock(name = &quot;rankingSnapshot&quot;, lockAtMostFor = &quot;PT4M&quot;, lockAtLeastFor = &quot;PT20S&quot;)
public void todayRebuild() {
    rankingService.rebuildTodaySnapshot();
}</code></pre>
<pre><code class="language-java">@Transactional
public void rebuildTodaySnapshot() {
    LocalDate today = redisStore.today();
    var all = redisStore.readTop(today, redisStore.zTotal(today), Integer.MAX_VALUE);
    if (all == null || all.isEmpty()) {
        mysqlStore.replaceTodaySnapshot(today, List.of());
        return;
    }

    // 탈퇴 회원 등 고아 데이터 제거
    Set&lt;Long&gt; ids = all.stream().map(UserRow::memberId).collect(Collectors.toSet());
    Set&lt;Long&gt; alive = mysqlStore.existingMemberIds(ids);

    int rank = 1;
    List&lt;Ranking&gt; rows = new ArrayList&lt;&gt;(alive.size());
    for (var r : all) {
        if (!alive.contains(r.memberId())) continue;
        rows.add(Ranking.builder()
            .member(mysqlStore.refMember(r.memberId()))
            .date(today).rankNo(rank++)
            .totalScore(r.total()).originalTotalScore(r.original())
            .build());
    }
    mysqlStore.replaceTodaySnapshot(today, rows);
}</code></pre>
<p>스냅샷 단계에서 회원 탈퇴 등으로 발생한 <strong>Redis 고아 데이터</strong> 를 정리하는 로직을 함께 둠으로써, ZSET 이 시간이 지날수록 부풀어 오르는 문제를 막았습니다.</p>
<p>스냅샷이 끝난 직후 MySQL <code>ranking</code> 테이블을 보면, 해당 일자의 ZSET 결과가 <code>(member_id, date, rank_no, total_score, original_total_score)</code> 컬럼에 <code>rank_no</code> 1번부터 차례대로 영속 저장되어 있는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/10219a4a-d7a3-41a8-ab9d-307edc689346/image.png" alt="Adminer — `ranking` 테이블 스냅샷 (date=2025-11-25, rank_no 1~22)"></p>
<p>이 시점 이후로는 <code>Fortune</code> 의 점수가 다시 갱신되더라도 <strong>이미 영속 저장된 과거 일자 랭킹은 더 이상 흔들리지 않습니다.</strong> 어제 1등이었던 사용자는 오늘 점수가 어떻게 변하든 어제 시점에서는 그대로 1등입니다.</p>
<blockquote>
<p><strong>참고 — 두 스케줄러는 모두 <code>@SchedulerLock</code> 으로 보호됩니다.</strong>
Blue/Green 두 컨테이너가 떠 있어도 한쪽만 실제 실행됩니다.
자세한 배경은 별도 포스팅 <a href="agzak_scheduler_deadlock.md">새벽 랭킹 스케줄러 Deadlock 트러블슈팅</a> 에 정리하였습니다.</p>
</blockquote>
<h3 id="읽기-fallback">읽기 fallback</h3>
<p>Redis 가 아예 죽어 있는 상황을 대비하여 <strong>읽기 경로에는 fallback</strong> 을 두었습니다.</p>
<pre><code class="language-java">public RankingListDto getTodayTopN(int limit) {
    LocalDate d = redisStore.today();
    try {
        var total    = redisStore.readTop(d, redisStore.zTotal(d),    limit);
        var original = redisStore.readTop(d, redisStore.zOriginal(d), limit);
        var gain     = redisStore.readTop(d, redisStore.zGain(d),     limit);

        if (모두 비어있으면) return getSnapshotTopN(d, limit);   // MySQL 스냅샷
        return /* Redis 결과 가공 */;
    } catch (Exception e) {
        return getSnapshotTopN(d, limit);                       // MySQL 스냅샷
    }
}</code></pre>
<p>오늘 랭킹이라도 Redis 에 데이터가 없거나 예외가 나면, <strong>마지막 정시 스냅샷</strong> 으로 자동 대체됩니다. 사용자 입장에서는 잠시 갱신이 멈춘 것처럼 보이지만, 5xx 에러는 발생하지 않습니다.</p>
<hr>
<h2 id="6-측정-결과">6. 측정 결과</h2>
<blockquote>
<p><strong>참고</strong>
본 프로젝트는 실 사용자 트래픽이 큰 상용 서비스가 아닌 개발·배포 단계의 자율 프로젝트이므로, 아래 수치는 <strong>EFK 수집 로그와 MySQL Slow Query 카운트, 자체 부하 테스트 기반</strong> 입니다.</p>
</blockquote>
<h3 id="before-vs-after-랭킹-응답-경로">Before vs After (랭킹 응답 경로)</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>Before (MySQL ORDER BY)</th>
<th>After (Redis ZSET)</th>
</tr>
</thead>
<tbody><tr>
<td>오늘 TOP 100 응답</td>
<td><code>Fortune</code> 풀스캔 + 메모리 정렬</td>
<td><code>ZREVRANGE 0 99</code> 단건</td>
</tr>
<tr>
<td>세 가지 정렬 (total/orig/gain)</td>
<td>정렬 3회 / 메모리</td>
<td>ZSET 3개 — 각 O(log N)</td>
</tr>
<tr>
<td>내 등수 조회</td>
<td>별도 SELECT + 정렬</td>
<td><code>ZREVRANK</code> O(log N)</td>
</tr>
<tr>
<td>과거 일자 조회</td>
<td>Fortune 변경 시 결과 변동</td>
<td><code>ranking</code> 스냅샷에서 그대로 보존</td>
</tr>
<tr>
<td>Slow Query 발생 빈도</td>
<td>측정됨</td>
<td><strong>0건</strong> 유지 (운영 기간 동안)</td>
</tr>
</tbody></table>
<h3 id="운영-모니터링--slow-query-0건-유지">운영 모니터링 — Slow Query 0건 유지</h3>
<p>런칭 직후에는 MySQL QPS 가 일시적으로 300~500 까지 튀는 구간이 있었으나, <strong>이번 저장소 분리 이후 일상 QPS 가 한 자리 수까지 내려갔고</strong>, EFK 기반 일별 리포트에서 추적한 Slow Query 카운트는 <strong>0건</strong> 으로 유지되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/4dce87f0-09a3-4eff-a8b7-e20bd119b990/image.png" alt="EFK + Notion 자동 리포트 (Daily Error / Log Count)"></p>
<p>또한 EFK 파이프라인을 통해 일별·주별 로그 분포를 Kibana 대시보드에서도 확인하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/d013f185-1874-4390-81d2-2198cb1b5ea3/image.png" alt="Kibana 대시보드 4분할"></p>
<h3 id="사용자-체감">사용자 체감</h3>
<ul>
<li>게임을 통해 점수를 강화한 직후 <strong>랭킹 페이지에서 즉시 반영</strong> 되는 모습 확인 (캐시 TTL 대기 X)</li>
<li>과거 일자(전날·전주) 랭킹은 <strong>그 시점 그대로</strong> 보존 — Fortune 데이터가 갱신되어도 흔들리지 않음</li>
</ul>
<hr>
<h2 id="7-회고-및-정리">7. 회고 및 정리</h2>
<p><strong>같은 &quot;랭킹&quot; 이라도 요구가 다르면 저장소를 분리해야 합니다.</strong>
갱신 빈도·보존 요구·정렬 비용이 다른 두 도메인을 단일 테이블에 끼워 맞추려 했던 것이 비용을 키운 원인이었습니다.</p>
<p><strong>ZSET 은 &quot;정렬 결과를 자료 구조로 갖는 것&quot; 입니다.</strong>
ORDER BY 가 매번 정렬 비용을 지불하는 반면, ZSET 은 ZADD 시점에 O(log N) 으로 정렬 상태를 유지합니다. <code>ZREVRANGE</code> / <code>ZREVRANK</code> 는 그 상태를 그대로 꺼내기만 합니다.</p>
<p><strong>fallback + reseed 없는 Redis 1차 저장소는 단일 장애점입니다.</strong>
읽기 fallback(MySQL 스냅샷 대체), 시간당 reseed(정합성 자동 회복), 고아 데이터 prune — 세 가지를 함께 뒀기 때문에 운영 부담 없이 유지할 수 있었습니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>본 포스팅에서는 랭킹 데이터를 <strong>Redis ZSET 실시간 + MySQL 스냅샷 보관</strong> 두 저장소로 분리한 의사결정 과정을 정리하였습니다.</p>
<p><strong>핵심 요약</strong></p>
<ul>
<li>점수 갱신 즉시 반영은 <strong>ZSET <code>ZADD</code> (O(log N))</strong>.</li>
<li>TOP N 응답은 <strong><code>ZREVRANGE</code> 단건 호출</strong> 로 정렬 비용 0.</li>
<li>&quot;내 등수&quot; 는 <strong><code>ZREVRANK</code></strong> 로 별도 쿼리 없이.</li>
<li>과거 일자는 <strong>MySQL <code>ranking</code> 스냅샷</strong> 으로 영속 보존.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[도서 검색 응답 시간 개선 (MySQL LIKE → Elasticsearch, 1,200ms → 275ms)]]></title>
            <link>https://velog.io/@ori_gui/%EB%8F%84%EC%84%9C-%EA%B2%80%EC%83%89-%EC%9D%91%EB%8B%B5-%EC%8B%9C%EA%B0%84-%EA%B0%9C%EC%84%A0-MySQL-LIKE-Elasticsearch-1200ms-275ms</link>
            <guid>https://velog.io/@ori_gui/%EB%8F%84%EC%84%9C-%EA%B2%80%EC%83%89-%EC%9D%91%EB%8B%B5-%EC%8B%9C%EA%B0%84-%EA%B0%9C%EC%84%A0-MySQL-LIKE-Elasticsearch-1200ms-275ms</guid>
            <pubDate>Thu, 30 Apr 2026 12:54:00 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>도서 쇼핑몰 프로젝트의 개발 단계에서 검색 응답 시간이 평균 1,200ms 수준으로 측정되어, MySQL LIKE 기반 구현을 Elasticsearch로 전환한 과정을 정리한 내용입니다.</p>
</blockquote>
<hr>
<h2 id="1-들어가며">1. 들어가며</h2>
<p>도서 쇼핑몰 프로젝트를 개발하면서 마주한 <strong>검색 성능 문제</strong>와 그 해결 과정을 정리한 내용입니다.</p>
<p>해당 프로젝트는 실제 사용자에게 운영되는 서비스가 아니라 개발 및 배포 단계까지만 진행한 프로젝트였습니다. 그러나 개발 단계에서 검색 기능을 테스트하던 중 응답 시간이 평균 <strong>1,200ms</strong>에 달하는 것을 확인하였고, 이는 정상적인 사용이 어려운 수준이라 판단되어 개선 작업을 진행하게 되었습니다.</p>
<p>본문에서는 다음과 같은 흐름으로 정리하였습니다.</p>
<ol>
<li>문제 상황 진단 및 기존 구현 분석</li>
<li>B-Tree 인덱스 추가 시도와 실패</li>
<li>MySQL Full-Text Search 검토와 한계</li>
<li>Elasticsearch 도입 및 설계 의사결정</li>
<li>측정 결과 및 회고</li>
</ol>
<hr>
<h2 id="2-문제-상황-진단-및-기존-구현-분석">2. 문제 상황 진단 및 기존 구현 분석</h2>
<p>개발 환경에서 키워드 검색 부하 테스트를 진행한 결과, 다수의 SLOW QUERY 로그가 출력되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/fe0cde61-2e88-4972-9ff0-3cafc9cd6fae/image.png" alt="개선 전 운영 로그"></p>
<pre><code>WARN  [nio-8080-exec-4]
[SLOW QUERY] SELECT DISTINCT b FROM Book b LEFT JOIN ...
WHERE b.title LIKE &#39;%데이터베이스%&#39; duration=1525ms type=FULL_TABLE_SCAN</code></pre><p>30분간 60건 요청 기준으로 다음과 같은 수치가 측정되었습니다.</p>
<ul>
<li>평균 응답 시간 : <strong>1,187ms</strong></li>
<li>Slow Query (≥1,500ms) : <strong>18건 / 60건 (30%)</strong></li>
</ul>
<p>검색 한 번에 1.5초가 소요되는 빈도가 30%에 달하였고, 이는 사용자 입장에서 정상적인 사용이 불가능한 수준입니다.</p>
<p><strong>기존 구현 코드</strong></p>
<p>기존 검색 기능은 QueryDSL 의 <code>.contains()</code> 메서드를 사용하여 구현되어 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/1c76863a-4a60-4fde-ac6c-84334e9fd4ef/image.png" alt="Repository Before — BooleanBuilder + LIKE"></p>
<pre><code class="language-java">BooleanBuilder builder = new BooleanBuilder();
builder.and(
    qBook.title.contains(keyword)            // → LIKE %keyword%
    .or(qBook.description.contains(keyword))
    .or(qContributor.name.contains(keyword))
    .or(qTag.name.contains(keyword))
);</code></pre>
<p>QueryDSL 의 <code>.contains()</code> 는 내부적으로 <code>LIKE %keyword%</code> 로 변환됩니다. 또한 검색 시 4개 테이블에 LEFT JOIN 이 걸리는 구조였습니다.</p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/62147d8a-fa4d-4cb0-96b1-093d50837145/image.png" alt="Repository Before — LEFT JOIN 4개 및 N+1"></p>
<p>확인된 문제점을 정리하면 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>문제</th>
</tr>
</thead>
<tbody><tr>
<td>인덱스 사용 불가</td>
<td><code>LIKE %keyword%</code> 앞 와일드카드로 인해 Full Table Scan</td>
</tr>
<tr>
<td>4개 JOIN</td>
<td>contributor, tag, category 조인 시 결과 행 폭발</td>
</tr>
<tr>
<td>N+1</td>
<td>각 도서별 기여자·카테고리 조회 추가 쿼리 발생</td>
</tr>
<tr>
<td>별도 count 쿼리</td>
<td>페이징 전체 건수 집계용 쿼리 별도 실행</td>
</tr>
<tr>
<td>한국어 형태소 분석 미지원</td>
<td>&#39;자바&#39; 검색 시 &#39;자바스크립트&#39; 누락</td>
</tr>
<tr>
<td>관련성 정렬 미지원</td>
<td>단순 views 컬럼 기준 정렬만 가능</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-첫-번째-시도--b-tree-인덱스-추가">3. 첫 번째 시도 — B-Tree 인덱스 추가</h2>
<p>가장 단순한 접근 방법인 인덱스 추가부터 시도하였습니다.</p>
<pre><code class="language-sql">CREATE INDEX idx_book_title       ON book (title);
CREATE INDEX idx_book_description ON book (description(100));</code></pre>
<p>인덱스 추가 후 EXPLAIN을 통해 실행 계획을 확인하였습니다.</p>
<pre><code>+----+------+------+------+----------+-------------+
| id | type | key  | rows | filtered | Extra       |
+----+------+------+------+----------+-------------+
|  1 | ALL  | NULL | 9821 |    11.11 | Using where |  ← Full Table Scan
+----+------+------+------+----------+-------------+</code></pre><p><code>type</code> 컬럼이 여전히 <code>ALL</code> 로 출력되는 것을 확인할 수 있었습니다. 즉, 인덱스를 추가하였음에도 Full Table Scan 이 발생하고 있는 상황입니다.</p>
<blockquote>
<p><strong>원인 분석</strong>
<code>LIKE &#39;%keyword%&#39;</code> 와 같이 검색어 앞에 와일드카드가 위치할 경우, B-Tree 인덱스를 사용할 수 없습니다.
B-Tree 는 데이터를 정렬된 상태로 저장하고 앞 글자부터 순차 탐색하는 구조이기 때문에, 시작점이 정해지지 않으면 전체 스캔으로 처리됩니다.</p>
</blockquote>
<p>따라서 인덱스 추가만으로는 본 문제를 해결할 수 없다고 판단하였습니다.</p>
<hr>
<h2 id="4-두-번째-시도--mysql-full-text-search">4. 두 번째 시도 — MySQL Full-Text Search</h2>
<p>B-Tree 인덱스로 해결이 어렵다는 점을 확인한 후, MySQL 의 Full-Text Search 기능을 검토하였습니다.</p>
<pre><code class="language-sql">ALTER TABLE book ADD FULLTEXT INDEX ft_title_desc (title, description);

SELECT * FROM book
WHERE MATCH(title, description) AGAINST(&#39;자바&#39; IN BOOLEAN MODE);</code></pre>
<p>쿼리 자체의 응답 시간은 LIKE 방식보다 빨라졌으나, 두 가지 본질적인 한계가 있었습니다.</p>
<table>
<thead>
<tr>
<th>한계</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>한국어 형태소 분석 미지원</td>
<td>&#39;스프링부트&#39; 를 &#39;스프링&#39; + &#39;부트&#39; 로 분리하지 못함</td>
</tr>
<tr>
<td>복합 필드 검색 한계</td>
<td>기여자·태그 별도 테이블이라 JOIN + FTS 조합이 어려움</td>
</tr>
</tbody></table>
<p>특히 &#39;자바&#39; 키워드로 검색하였을 때 &#39;자바스크립트&#39; 도서는 매칭되지만, &#39;김영한&#39; 저자의 도서는 매칭되지 않는 현상이 발생하였습니다. 저자명은 별도 테이블에 정규화되어 있어 FTS 만으로는 처리할 수 없는 구조였기 때문입니다.</p>
<p>이 시점에서 다음과 같은 의문이 들었습니다.</p>
<blockquote>
<p>이 문제가 정말 MySQL 의 영역에서 해결할 수 있는 문제인가?</p>
</blockquote>
<p>두 차례의 시도가 모두 만족스러운 결과를 내지 못하였기에, 문제의 본질을 다시 분석하였습니다.</p>
<blockquote>
<p><strong>MySQL 은 정형 데이터를 관계형 모델로 저장하는 데 최적화된 데이터베이스입니다.</strong>
<strong>반면, 키워드 기반 전문 검색은 본질적으로 비정형이며 관련도 기반 정렬이 필요합니다.</strong>
<strong>두 영역은 요구하는 저장 구조와 처리 방식이 근본적으로 다릅니다.</strong></p>
</blockquote>
<p>검색은 비정형(여러 필드 동시 매칭)이고 관련도 순 정렬이 필요하지만, RDB 는 정형(컬럼 단위 조건)에 최적화되어 있고 관련도를 계산할 구조가 없습니다. 또한 한국어 형태소 분석 역시 MySQL 은 플러그인 없이 지원하지 않습니다.</p>
<p>쿼리 튜닝만으로는 위 구조적 불일치가 해소되지 않는다고 판단하였고, <strong>검색 전용 저장소를 분리하는 방향</strong>으로 의사결정을 진행하였습니다.</p>
<hr>
<h2 id="5-elasticsearch-도입-및-설계">5. Elasticsearch 도입 및 설계</h2>
<p><strong>시스템 구성</strong></p>
<pre><code>┌─────────────┐   Logstash   ┌──────────────────┐
│   MySQL DB  │ ───────────▶ │  Elasticsearch    │
│ (원본 데이터)│  10초 주기   │   books index     │
└─────────────┘  JDBC sync   └──────┬───────────┘
                                    │
              ┌────────────┐        │  검색 쿼리
              │   Spring   │◀───────┘
              │    Boot    │
              │            │──▶ Kibana 모니터링
              └────────────┘      :5601</code></pre><p>원본 데이터는 그대로 MySQL 에 저장하되, 검색 전용 데이터를 Elasticsearch 에 별도로 색인하는 구조입니다. 두 저장소는 Logstash JDBC 를 통해 주기적으로 동기화됩니다.</p>
<p><strong>동기화 전략 — Logstash JDBC</strong></p>
<p>데이터 동기화 방식을 두 가지 후보 중에서 검토하였습니다.</p>
<table>
<thead>
<tr>
<th>방법</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>Logstash JDBC</td>
<td>MySQL → ES 자동 주기 동기화, 기존 코드 변경 불필요</td>
<td>별도 인프라 필요</td>
</tr>
<tr>
<td>Spring Data ES</td>
<td>도메인 이벤트 기반 실시간 동기화</td>
<td>모든 도서 수정 로직에 ES 업데이트 코드 추가 필요</td>
</tr>
</tbody></table>
<p>도서 등록·수정은 관리자만 수행하는 도메인 특성상 실시간성보다는 안정성과 코드 응집도가 중요하다고 판단하여 <strong>Logstash 10초 주기 동기화</strong>를 선택하였습니다.</p>
<pre><code class="language-ruby">input {
  jdbc {
    schedule  =&gt; &quot;*/10 * * * * *&quot;
    statement =&gt; &quot;&quot;&quot;
      WITH RECURSIVE category_hierarchy AS ( ... )
      SELECT
        b.book_id, b.title AS book_title,
        (SELECT JSON_ARRAYAGG(JSON_OBJECT(&#39;id&#39;, c.id, &#39;name&#39;, c.name, &#39;role&#39;, cr.name))
         FROM book_contributor bc JOIN contributor c ...
         WHERE bc.book_id = b.book_id) AS book_contributor,
        ...
      FROM book b LEFT JOIN publisher p ON b.publisher_id = p.publisher_id
      WHERE b.is_active = true
    &quot;&quot;&quot;
  }
}
output {
  elasticsearch {
    index         =&gt; &quot;books&quot;
    document_id   =&gt; &quot;%{[@metadata][_id]}&quot;
    doc_as_upsert =&gt; true
    action        =&gt; &quot;update&quot;
  }
}</code></pre>
<p><code>JSON_ARRAYAGG</code> 함수를 사용하여 nested 필드(기여자, 태그, 카테고리)를 ES 문서 형식으로 변환한 뒤 색인하는 방식입니다.</p>
<p><strong>매핑 설계 — nested vs object</strong></p>
<p>해당 부분은 개발 과정에서 시행착오를 겪은 부분입니다. 처음에는 <code>object</code> 타입으로 매핑을 설계하였습니다.</p>
<pre><code class="language-json">&quot;book_contributor&quot;: [
  { &quot;name&quot;: &quot;김영한&quot;, &quot;role&quot;: &quot;저자&quot; },
  { &quot;name&quot;: &quot;박성철&quot;, &quot;role&quot;: &quot;역자&quot; }
]</code></pre>
<p>그러나 ES 의 <code>object</code> 타입은 내부적으로 평탄화(flatten)됩니다.</p>
<pre><code>&quot;name&quot;: [&quot;김영한&quot;, &quot;박성철&quot;]
&quot;role&quot;: [&quot;저자&quot;, &quot;역자&quot;]</code></pre><p>이로 인해 객체 간 쌍(pair) 관계가 보존되지 않아, <strong>&quot;김영한 역자&quot;</strong> 와 같은 잘못된 매칭이 발생하였습니다. 이를 해결하기 위해 <code>nested</code> 타입으로 변경하였으나, <strong>인덱스 매핑 변경은 전체 재색인을 수반</strong>하므로 작지 않은 비용이 발생하였습니다.</p>
<blockquote>
<p><strong>참고</strong>
Elasticsearch 에서 매핑은 한번 설정되면 일부 속성을 제외하고는 변경이 불가능합니다.
타입 변경은 사실상 인덱스를 새로 만들고 데이터를 다시 색인해야 하므로,
초기 설계 단계에서 검색 케이스를 충분히 정리한 뒤 매핑을 결정하는 것이 좋습니다.</p>
</blockquote>
<p><strong>Nori 분석기 <code>decompound_mode</code> 튜닝</strong></p>
<p>기본 Nori 분석기를 그대로 사용할 경우 복합어 처리에 문제가 발생합니다.</p>
<pre><code class="language-json">&quot;tokenizer&quot;: &quot;nori_tokenizer&quot;
// &quot;스프링부트&quot; → [&quot;스프링부트&quot;]   (단일 토큰)</code></pre>
<p>위 설정에서는 사용자가 &#39;스프링&#39; 으로 검색하였을 때 &#39;스프링부트&#39; 도서가 매칭되지 않습니다. <code>decompound_mode</code> 옵션을 <code>mixed</code> 로 설정하면 원형과 분해된 형태소를 모두 보존합니다.</p>
<pre><code class="language-json">&quot;tokenizer&quot;: {
  &quot;type&quot;: &quot;nori_tokenizer&quot;,
  &quot;decompound_mode&quot;: &quot;mixed&quot;
}
// &quot;스프링부트&quot; → [&quot;스프링부트&quot;, &quot;스프링&quot;, &quot;부트&quot;]</code></pre>
<p>해당 설정 변경 이후 복합어에 대한 검색 정확도가 크게 향상되었습니다.</p>
<p><strong>검색 구현 — 정렬 분기</strong></p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/fa2e517a-ed03-41ec-ae2d-43708563ad5a/image.png" alt="Repository After 상단 — switch expression"></p>
<pre><code class="language-java">List&lt;Map.Entry&lt;String, String&gt;&gt; sortCriteria = switch (sort) {
    case &quot;popularity&quot;    -&gt; List.of(Map.entry(&quot;book_likes&quot;, &quot;desc&quot;), Map.entry(&quot;book_views&quot;, &quot;desc&quot;));
    case &quot;newest&quot;        -&gt; List.of(Map.entry(&quot;book_published_at&quot;, &quot;desc&quot;));
    case &quot;lowest_price&quot;  -&gt; List.of(Map.entry(&quot;book_selling_price&quot;, &quot;asc&quot;));
    case &quot;rating&quot;        -&gt; List.of(Map.entry(&quot;book_rating_avg&quot;, &quot;desc&quot;));
    case &quot;reviews&quot;       -&gt; List.of(Map.entry(&quot;book_review_count&quot;, &quot;desc&quot;));
    default              -&gt; List.of();  // relevance: ES score 기본 정렬
};</code></pre>
<p><strong>검색 구현 — multi_match 및 nested 쿼리</strong></p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/bebd5993-3432-44ec-8f77-37cccb10a5e6/image.png" alt="Repository After 중반 — multi_match + nested"></p>
<pre><code class="language-java">// 제목^10 · 제목.ngram^3 · 설명^3 · 출판사^2 가중치 적용
bq.should(sq -&gt; sq.multiMatch(mm -&gt; mm
    .fields(&quot;book_title^10&quot;, &quot;book_title.ngram^3&quot;,
            &quot;book_title.jaso&quot;, &quot;book_title.synonym&quot;,
            &quot;book_desc^3&quot;, &quot;book_desc.ngram&quot;,
            &quot;book_publisher^2&quot;, &quot;book_publisher.ngram&quot;)
    .query(keyword)));

// nested: 카테고리 검색
bq.should(sq -&gt; sq.nested(n -&gt; n.path(&quot;book_category&quot;)
    .query(cq -&gt; cq.multiMatch(mm -&gt; mm
        .fields(&quot;book_category.name^3&quot;, &quot;book_category.name.ngram&quot;)
        .query(keyword)))));</code></pre>
<p><code>^10</code>, <code>^3</code> 와 같은 표기는 가중치(boost)를 의미하며, 제목에서 매칭되었을 때 설명에서 매칭되었을 때보다 점수가 높게 산정됩니다.</p>
<p><strong>검색 구현 — ES 호출 및 결과 매핑</strong></p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/fff41105-2630-4440-9407-330e8eef941c/image.png" alt="Repository After 하단 — search 호출 및 결과 매핑"></p>
<p>ES 응답에 전체 건수(total)가 포함되어 있으므로, MySQL 구현 시 필요했던 별도 count 쿼리가 불필요합니다.</p>
<hr>
<h2 id="6-개선-결과">6. 개선 결과</h2>
<blockquote>
<p><strong>참고</strong>
본 프로젝트는 실제 사용자 트래픽을 받지 않은 개발·배포 단계의 프로젝트였기에, 개발 진행 당시 측정값을 Kibana 대시보드로 그대로 재현하는 것은 어려웠습니다.
따라서 아래 Kibana 대시보드 이미지는 당시 로그 기반 측정값을 시각화 형태로 재구성한 자료이며, <strong>실제 수치는 응용 서버 로그(<code>duration</code>, <code>took</code>) 기반으로 검증한 값</strong>입니다.</p>
</blockquote>
<p><strong>Kibana 대시보드 (재구성)</strong></p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/ee343c79-a6f8-44e9-a7e5-14a120dbd8c5/image.png" alt="Kibana 성능 대시보드"></p>
<ul>
<li>평균 took : <strong>275ms</strong> (개선 전 ~1,200ms)</li>
<li>P90 : <strong>371ms</strong> / P99 : 453ms</li>
<li><strong>약 4.4배 개선</strong></li>
<li>일별 요청 수가 증가하여도 응답 시간이 안정적으로 유지됨</li>
</ul>
<p><strong>Dev Tools 응답 확인</strong></p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/0e4ce416-30a4-4aca-ad48-a091d10943c0/image.png" alt="Dev Tools 응답"></p>
<p><code>&quot;took&quot;: 243</code> 은 ES 내부 처리 시간을 의미하며, Spring Boot 레이어를 포함한 전체 응답 시간은 약 275ms 수준입니다.</p>
<p><strong>Before vs After 비교</strong></p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/8bcbe3a0-9fc7-417c-bb9d-77bc964189e1/image.png" alt="키워드별 응답 시간 및 비교표"></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>Before (MySQL LIKE)</th>
<th>After (Elasticsearch)</th>
<th>개선</th>
</tr>
</thead>
<tbody><tr>
<td>평균 응답 시간</td>
<td>~1,200 ms</td>
<td>~275 ms</td>
<td><strong>약 4.4배</strong></td>
</tr>
<tr>
<td>P90</td>
<td>~1,580 ms</td>
<td>~371 ms</td>
<td>4.3배</td>
</tr>
<tr>
<td>P99</td>
<td>~2,230 ms</td>
<td>~453 ms</td>
<td>4.9배</td>
</tr>
<tr>
<td>Slow Query (≥1,500ms)</td>
<td>30% (18/60건)</td>
<td><strong>0건</strong></td>
<td>완전 해소</td>
</tr>
<tr>
<td>인덱스 사용</td>
<td>Full Table Scan</td>
<td>역색인 (Inverted Index)</td>
<td>--</td>
</tr>
<tr>
<td>한국어 분석</td>
<td>미지원</td>
<td>Nori + ngram + jaso</td>
<td>검색 품질 향상</td>
</tr>
</tbody></table>
<p><strong>개선 후 응용 서버 로그</strong></p>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/00ad707e-9e46-41e2-98f0-b3954611b272/image.png" alt="개선 후 운영 로그"></p>
<pre><code>INFO  [nio-8080-exec-1]
[ES] searchByKeyword keyword=&#39;디자인패턴&#39; duration=397ms took=367ms

INFO  [nio-8080-exec-2]
[ES] searchByKeyword keyword=&#39;디자인패턴&#39; sort=lowest_price duration=262ms took=223ms</code></pre><p>WARN 레벨의 SLOW QUERY 로그가 출력되지 않으며, slow_queries 는 <strong>0건</strong>입니다.</p>
<hr>
<h2 id="7-회고-및-정리">7. 회고 및 정리</h2>
<p><strong><code>LIKE &#39;%keyword%&#39;</code> 는 인덱스로 풀 수 없습니다.</strong>
B-Tree 인덱스 전략과 무관하게 Full Table Scan 이 강제됩니다. <code>EXPLAIN type: ALL</code> 한 줄이 그 사실을 확인해 줬고, 이후 모든 의사결정의 출발점이 되었습니다.</p>
<p><strong>ES 도입 비용은 띄우는 것이 아니라 매핑·튜닝에 있습니다.</strong>
nested vs object 오결정 시 전체 재색인, Nori <code>decompound_mode</code> 파라미터 튜닝, 동기화 전략 선택 — 시간이 걸리는 부분은 모두 여기에 집중됐습니다.</p>
<p><strong>본질은 성능 개선이 아니라 저장 구조 분리였습니다.</strong>
같은 쿼리를 빠르게 만든 것이 아니라, 비정형 검색·관련도·형태소 분석이라는 요구사항에 맞는 저장소로 책임을 옮긴 결과입니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 포스팅에서는 MySQL LIKE 기반 검색의 한계를 분석하고, Elasticsearch 도입을 통해 평균 응답 시간을 약 4.4배 개선한 과정을 정리하였습니다.</p>
<table>
<thead>
<tr>
<th>시도</th>
<th>결과</th>
<th>원인</th>
</tr>
</thead>
<tbody><tr>
<td>B-Tree 인덱스 추가</td>
<td>실패</td>
<td><code>LIKE %keyword%</code> 앞 와일드카드로 인한 인덱스 미사용</td>
</tr>
<tr>
<td>MySQL Full-Text Search</td>
<td>부분 해결</td>
<td>한국어 형태소 및 복합 필드 처리 한계</td>
</tr>
<tr>
<td><strong>Elasticsearch 도입</strong></td>
<td><strong>해결</strong></td>
<td>역색인 + Nori + nested 구조가 요구사항과 부합</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[22954. 그래프 트리 분할]]></title>
            <link>https://velog.io/@ori_gui/22954.-%EA%B7%B8%EB%9E%98%ED%94%84-%ED%8A%B8%EB%A6%AC-%EB%B6%84%ED%95%A0</link>
            <guid>https://velog.io/@ori_gui/22954.-%EA%B7%B8%EB%9E%98%ED%94%84-%ED%8A%B8%EB%A6%AC-%EB%B6%84%ED%95%A0</guid>
            <pubDate>Sun, 26 Apr 2026 05:43:09 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p><a href="https://www.acmicpc.net/problem/22954">[BOJ-22954] 그래프 트리 분할</a></p>
<hr>
<h3 id="문제-해결-아이디어">문제 해결 아이디어</h3>
<blockquote>
<p>BFS로 연결 컴포넌트와 스패닝 트리를 동시에 구성하고, 불가능 조건을 걸러낸 뒤 분할!</p>
</blockquote>
<h4 id="조건">조건</h4>
<ul>
<li>그래프에서 간선 삭제만으로 <strong>서로 다른 크기</strong>의 트리 2개로 분할</li>
<li>각 트리는 연결 그래프여야 하고, 정점 및 간선 공유 불가</li>
<li>N ≤ 100,000, M ≤ 200,000</li>
</ul>
<h4 id="불가능-조건">불가능 조건</h4>
<table>
<thead>
<tr>
<th>조건</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>N ≤ 2</td>
<td>크기가 다른 두 트리로 나눌 수 없음 (1+1만 가능)</td>
</tr>
<tr>
<td>컴포넌트 수 ≥ 3</td>
<td>간선 삭제만으론 3개 이상의 조각을 2개로 합칠 수 없음</td>
</tr>
<tr>
<td>컴포넌트 수 = 2이고 크기가 같음</td>
<td>두 트리의 크기가 동일</td>
</tr>
</tbody></table>
<h4 id="bfs-스패닝-트리-접근">BFS 스패닝 트리 접근</h4>
<ul>
<li>BFS 탐색으로 각 연결 컴포넌트의 <strong>스패닝 트리</strong> 자동 구성</li>
<li><code>parent[v]</code>, <code>parentEdge[v]</code> : BFS 트리에서 v의 부모 노드와 연결 간선 번호</li>
<li>루트 노드는 <code>parent[root] = -1</code></li>
</ul>
<h4 id="분할-전략">분할 전략</h4>
<p><strong>케이스 1. 컴포넌트 1개 (연결 그래프)</strong></p>
<ul>
<li>BFS 스패닝 트리에서 <strong>리프 노드</strong> 하나를 분리 → 크기 <strong>1 + (N-1)</strong></li>
<li>리프 조건: <code>parent[v] != -1</code> (루트 제외) <code>&amp;&amp;</code> <code>childCount[v] == 0</code></li>
</ul>
<p><strong>케이스 2. 컴포넌트 2개 (크기가 다름)</strong></p>
<ul>
<li>각 컴포넌트의 BFS 스패닝 트리를 그대로 출력</li>
</ul>
<hr>
<h3 id="코드">코드</h3>
<pre><code class="language-java">import java.util.*;
import java.io.*;

public class BOJ_22954 {
    static int N, M;
    static List&lt;List&lt;int[]&gt;&gt; graph;
    static int[] parent;
    static int[] parentEdge;
    static boolean[] visited;

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());

        N = Integer.parseInt(st.nextToken());
        M = Integer.parseInt(st.nextToken());

        graph = new ArrayList&lt;&gt;();
        for (int i = 0; i &lt;= N; i++) {
            graph.add(new ArrayList&lt;&gt;());
        }

        for (int i = 1; i &lt;= M; i++) {
            st = new StringTokenizer(br.readLine());
            int u = Integer.parseInt(st.nextToken());
            int w = Integer.parseInt(st.nextToken());
            graph.get(u).add(new int[]{w, i});
            graph.get(w).add(new int[]{u, i});
        }

        parent = new int[N + 1];
        parentEdge = new int[N + 1];
        visited = new boolean[N + 1];
        Arrays.fill(parent, -1);
        Arrays.fill(parentEdge, -1);

        List&lt;List&lt;Integer&gt;&gt; components = new ArrayList&lt;&gt;();
        for (int i = 1; i &lt;= N; i++) {
            if (!visited[i]) {
                List&lt;Integer&gt; comp = new ArrayList&lt;&gt;();
                bfs(i, comp);
                components.add(comp);
            }
        }

        int numComp = components.size();
        boolean impossible = N &lt;= 2
                || numComp &gt;= 3
                || (numComp == 2 &amp;&amp; components.get(0).size() == components.get(1).size());

        if (impossible) {
            System.out.println(-1);
            return;
        }

        if (numComp == 1) {
            List&lt;Integer&gt; all = components.get(0);
            int[] childCount = new int[N + 1];
            for (int node : all) {
                if (parent[node] != -1) childCount[parent[node]]++;
            }

            int leaf = -1;
            for (int node : all) {
                if (parent[node] != -1 &amp;&amp; childCount[node] == 0) {
                    leaf = node;
                    break;
                }
            }

            System.out.println(&quot;1 &quot; + (N - 1));
            System.out.println(leaf);

            StringBuilder sbV = new StringBuilder();
            StringBuilder sbE = new StringBuilder();
            for (int node : all) {
                if (node == leaf) continue;
                if (sbV.length() &gt; 0) sbV.append(&#39; &#39;);
                sbV.append(node);
                if (parent[node] != -1) {
                    if (sbE.length() &gt; 0) sbE.append(&#39; &#39;);
                    sbE.append(parentEdge[node]);
                }
            }
            System.out.println(sbV);
            System.out.println(sbE);
        } else {
            List&lt;Integer&gt; c1 = components.get(0);
            List&lt;Integer&gt; c2 = components.get(1);

            System.out.println(c1.size() + &quot; &quot; + c2.size());
            printTree(c1);
            printTree(c2);
        }
    }

    static void printTree(List&lt;Integer&gt; comp) {
        StringBuilder sbV = new StringBuilder();
        StringBuilder sbE = new StringBuilder();
        for (int node : comp) {
            if (sbV.length() &gt; 0) sbV.append(&#39; &#39;);
            sbV.append(node);
            if (parent[node] != -1) {
                if (sbE.length() &gt; 0) sbE.append(&#39; &#39;);
                sbE.append(parentEdge[node]);
            }
        }
        System.out.println(sbV);
        System.out.println(sbE);
    }

    static void bfs(int start, List&lt;Integer&gt; comp) {
        Queue&lt;Integer&gt; q = new LinkedList&lt;&gt;();
        q.offer(start);
        visited[start] = true;
        comp.add(start);

        while (!q.isEmpty()) {
            int u = q.poll();
            for (int[] e : graph.get(u)) {
                int next = e[0];
                int eid = e[1];
                if (!visited[next]) {
                    visited[next] = true;
                    parent[next] = u;
                    parentEdge[next] = eid;
                    q.offer(next);
                    comp.add(next);
                }
            }
        }
    }
}</code></pre>
<hr>
<h3 id="예제-풀이">예제 풀이</h3>
<ul>
<li><strong>N=5, M=5</strong></li>
</ul>
<p><strong>그래프 구성:</strong></p>
<table>
<thead>
<tr>
<th>간선 번호</th>
<th>연결</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>1 ↔ 2</td>
</tr>
<tr>
<td>2</td>
<td>1 ↔ 3</td>
</tr>
<tr>
<td>3</td>
<td>2 ↔ 3</td>
</tr>
<tr>
<td>4</td>
<td>3 ↔ 4</td>
</tr>
<tr>
<td>5</td>
<td>4 ↔ 5</td>
</tr>
</tbody></table>
<hr>
<p><strong>BFS 스패닝 트리 (시작: 1):</strong></p>
<table>
<thead>
<tr>
<th>노드</th>
<th>parent</th>
<th>parentEdge</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>2</td>
<td>1</td>
<td>1</td>
</tr>
<tr>
<td>3</td>
<td>1</td>
<td>2</td>
</tr>
<tr>
<td>4</td>
<td>3</td>
<td>4</td>
</tr>
<tr>
<td>5</td>
<td>4</td>
<td>5</td>
</tr>
</tbody></table>
<hr>
<p><strong>리프 탐색:</strong></p>
<table>
<thead>
<tr>
<th>노드</th>
<th>childCount</th>
<th>리프 여부</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>2</td>
<td>✗ (루트)</td>
</tr>
<tr>
<td>2</td>
<td>0</td>
<td>✓ <strong>← leaf</strong></td>
</tr>
<tr>
<td>3</td>
<td>1</td>
<td>✗</td>
</tr>
<tr>
<td>4</td>
<td>1</td>
<td>✗</td>
</tr>
<tr>
<td>5</td>
<td>0</td>
<td>✗ (2보다 늦게 발견)</td>
</tr>
</tbody></table>
<hr>
<p><strong>출력 결과:</strong></p>
<table>
<thead>
<tr>
<th>구분</th>
<th>출력</th>
</tr>
</thead>
<tbody><tr>
<td>크기</td>
<td><code>1 4</code></td>
</tr>
<tr>
<td>트리1 정점</td>
<td><code>2</code></td>
</tr>
<tr>
<td>트리1 간선</td>
<td>(0개)</td>
</tr>
<tr>
<td>트리2 정점</td>
<td><code>1 3 4 5</code></td>
</tr>
<tr>
<td>트리2 간선</td>
<td><code>2 4 5</code></td>
</tr>
</tbody></table>
<p>트리2 간선: <code>parentEdge[3]=2 (1↔3), parentEdge[4]=4 (3↔4), parentEdge[5]=5 (4↔5)</code></p>
<p><strong>sizes: 1 ≠ 4 → 정답</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[24042. 횡단보도]]></title>
            <link>https://velog.io/@ori_gui/24042.-%ED%9A%A1%EB%8B%A8%EB%B3%B4%EB%8F%84</link>
            <guid>https://velog.io/@ori_gui/24042.-%ED%9A%A1%EB%8B%A8%EB%B3%B4%EB%8F%84</guid>
            <pubDate>Fri, 24 Apr 2026 09:22:17 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p><a href="https://www.acmicpc.net/problem/24042">[BOJ-24042] 횡단보도</a></p>
<hr>
<h3 id="문제-해결-아이디어">문제 해결 아이디어</h3>
<blockquote>
<p>시간 그래프에서 다음 파란불까지 대기 시간을 간선 가중치로 바꾼 다익스트라</p>
</blockquote>
<h4 id="조건">조건</h4>
<ul>
<li>주기 M분, 매 분마다 딱 하나의 횡단보도만 파란불</li>
<li>신호 i (0-indexed): t ≡ i (mod M) 일 때 파란불 → 그 순간에 건너기 시작해야 함</li>
<li>현재 시각 t에서 period p 횡단보도를 이용하려면 t&#39; ≡ p (mod M)인 가장 가까운 미래 t&#39;까지 대기</li>
<li>N, M이 크므로 최대 시간 ≈ N × M → <strong>long 필수</strong></li>
</ul>
<h4 id="다익스트라-접근">다익스트라 접근</h4>
<ul>
<li><code>times[v]</code> : 1번 지역에서 v까지 도달하는 최소 시간 (<code>long</code> 배열)</li>
<li>간선 가중치 = 다음 파란불까지 대기 시간 + 1 (건너는 시간)</li>
<li>현재 시각 <code>t</code>에서 period <code>p</code> 횡단보도를 이용할 때 도착 시각:</li>
</ul>
<h4 id="대기-시간-계산">대기 시간 계산</h4>
<p><code>curP = t % M</code> (현재 주기 내 위치)</p>
<table>
<thead>
<tr>
<th>조건</th>
<th>대기</th>
<th>도착 시각</th>
</tr>
</thead>
<tbody><tr>
<td>curP == p</td>
<td>0</td>
<td>t + 1</td>
</tr>
<tr>
<td>curP &lt; p</td>
<td>p - curP</td>
<td>t + (p - curP) + 1</td>
</tr>
<tr>
<td>curP &gt; p</td>
<td>M - curP + p</td>
<td>t + (M - curP + p) + 1</td>
</tr>
</tbody></table>
<hr>
<h3 id="코드">코드</h3>
<pre><code class="language-java">import java.util.*;
import java.io.*;

public class BOJ_24042 {
    static int N;
    static int M;
    static List&lt;List&lt;Crosswalk&gt;&gt; graph = new ArrayList&lt;&gt;();
    static long[] times;

    static class Crosswalk {
        int nx;
        int p;

        Crosswalk (int nx, int p) {
            this.nx = nx;
            this.p = p;
        }
    }

    static class Node {
        int x;
        long t;

        Node (int x, long t) {
            this.x = x;
            this.t = t;
        }
    }

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());

        N = Integer.parseInt(st.nextToken());
        M = Integer.parseInt(st.nextToken());

        for (int i = 0; i &lt;= N; i++) {
            graph.add(new ArrayList&lt;&gt;());
        }

        for (int i = 0; i &lt; M; i++) {
            st = new StringTokenizer(br.readLine());

            int x = Integer.parseInt(st.nextToken());
            int nx = Integer.parseInt(st.nextToken());

            graph.get(x).add(new Crosswalk(nx, i));
            graph.get(nx).add(new Crosswalk(x, i));
        }

        times = new long[N+1];
        Arrays.fill(times, Long.MAX_VALUE);
        times[1] = 0;

        PriorityQueue&lt;Node&gt; pq = new PriorityQueue&lt;&gt;(Comparator.comparingLong((Node n) -&gt; n.t)
                                                         .thenComparingInt((Node n) -&gt; n.x));
        pq.add(new Node(1, 0));

        while (!pq.isEmpty()) {
            Node cur = pq.poll();

            if (cur.t &gt; times[cur.x]) continue;

            for (Crosswalk nc : graph.get(cur.x)) {
                int curP = (int)(cur.t % M);
                long nt = cur.t + 1;

                if (curP &lt; nc.p) {
                    nt += nc.p - curP;
                } else if (curP &gt; nc.p) {
                    nt += (M - curP) + nc.p;
                }

                if (nt &lt; times[nc.nx]) {
                    times[nc.nx] = nt;
                    pq.add(new Node(nc.nx, nt));
                }
            }
        }

        System.out.println(times[N]);
    }
}</code></pre>
<hr>
<h3 id="예제-풀이">예제 풀이</h3>
<ul>
<li><strong>N=4, M=5</strong></li>
<li>신호 스케줄 (0-indexed period):</li>
</ul>
<table>
<thead>
<tr>
<th>period</th>
<th>횡단보도</th>
<th>파란불 시각</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>1 ↔ 2</td>
<td>0, 5, 10, ...</td>
</tr>
<tr>
<td>1</td>
<td>3 ↔ 4</td>
<td>1, 6, 11, ...</td>
</tr>
<tr>
<td>2</td>
<td>1 ↔ 3</td>
<td>2, 7, 12, ...</td>
</tr>
<tr>
<td>3</td>
<td>4 ↔ 1</td>
<td>3, 8, 13, ...</td>
</tr>
<tr>
<td>4</td>
<td>2 ↔ 3</td>
<td>4, 9, 14, ...</td>
</tr>
</tbody></table>
<hr>
<p><strong>다익스트라 시뮬레이션</strong></p>
<table>
<thead>
<tr>
<th>현재 노드</th>
<th>현재 시각 t</th>
<th>curP</th>
<th>이동 횡단보도</th>
<th>대기</th>
<th>도착 노드</th>
<th>도착 시각</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>0</td>
<td>0</td>
<td>1↔2 (p=0)</td>
<td>0</td>
<td>2</td>
<td>1</td>
</tr>
<tr>
<td>1</td>
<td>0</td>
<td>0</td>
<td>1↔3 (p=2)</td>
<td>2</td>
<td>3</td>
<td>3</td>
</tr>
<tr>
<td>1</td>
<td>0</td>
<td>0</td>
<td>4↔1 (p=3)</td>
<td>3</td>
<td><strong>4</strong></td>
<td><strong>4</strong> ✓</td>
</tr>
</tbody></table>
<p><strong>times[4] = 4 → 정답: 4</strong></p>
<blockquote>
<p>1번에서 4번으로 직접 연결된 횡단보도(4↔1, period=3)를 t=3에 이용하는 것이 최적</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[6236. 용돈 관리]]></title>
            <link>https://velog.io/@ori_gui/6236.-%EC%9A%A9%EB%8F%88-%EA%B4%80%EB%A6%AC</link>
            <guid>https://velog.io/@ori_gui/6236.-%EC%9A%A9%EB%8F%88-%EA%B4%80%EB%A6%AC</guid>
            <pubDate>Sun, 05 Apr 2026 10:19:30 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p><a href="https://www.acmicpc.net/problem/6236">[BOJ-6236] 용돈 관리</a></p>
<hr>
<h3 id="문제-해결-아이디어">문제 해결 아이디어</h3>
<blockquote>
<p>인출 금액 K에 대해 이진 탐색, <code>check</code> 함수로 M번 이내 인출 가능 여부 판단!</p>
</blockquote>
<h4 id="조건">조건</h4>
<ul>
<li>정확히 M번 인출 (부족하면 강제, 남아도 선택적으로 재인출 가능)</li>
<li>하루치 금액보다 K가 작으면 불가능</li>
<li>최소 인출 횟수 ≤ M 이면, 나머지는 임의로 재인출하여 M번 맞출 수 있음</li>
</ul>
<h4 id="이진-탐색-접근">이진 탐색 접근</h4>
<ul>
<li>탐색 범위: <code>l = max(pay[i])</code> ~ <code>r = sum(pay[i])</code><ul>
<li>최소: 하루치는 감당해야 하므로 최댓값</li>
<li>최대: 한 번에 전부 뽑는 경우</li>
</ul>
</li>
<li><strong>최소 K</strong> 탐색 → <code>check(mid)</code> 참이면 <code>ans = mid</code>, 더 작은 값 탐색 (<code>r = mid - 1</code>)</li>
</ul>
<h4 id="check-함수">check 함수</h4>
<ul>
<li>K원으로 시작, 잔액이 당일 금액보다 부족하면 재인출 (cnt++)</li>
<li>최종 인출 횟수 <code>cnt ≤ M</code> 이면 가능</li>
</ul>
<hr>
<h3 id="코드">코드</h3>
<pre><code class="language-java">import java.util.*;
import java.io.*;

public class BOJ_6236 {
    static int N;
    static int M;
    static int[] pay;
    static int sum = 0;
    static int max = 0;

    private static boolean check(int k) {
        int cnt = 1;
        int remain = k;

        for (int i = 0; i &lt; N; i++) {
            if (remain &lt; pay[i]) {
                cnt += 1;
                remain = k;
            }
            remain -= pay[i];
        }
        return cnt &lt;= M;
    }

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());

        N = Integer.parseInt(st.nextToken());
        M = Integer.parseInt(st.nextToken());
        pay = new int[N];

        for (int i = 0; i &lt; N; i++) {
            pay[i] = Integer.parseInt(br.readLine().strip());
            sum += pay[i];
            max = Math.max(max, pay[i]);
        }

        int l = max;
        int r = sum;
        int mid = (l + r) / 2;
        int ans = 0;

        while (l &lt;= r) {
            if (check(mid)) {
                ans = mid;
                r = mid - 1;
            } else {
                l = mid + 1;
            }
            mid = (l + r) / 2;
        }

        System.out.println(ans);
    }
}</code></pre>
<hr>
<h3 id="예제-풀이">예제 풀이</h3>
<ul>
<li><strong>N=7, M=5</strong></li>
<li><strong>pay</strong> = [100, 400, 300, 100, 500, 101, 400]</li>
<li><strong>max</strong> = 500, <strong>sum</strong> = 1901</li>
<li><strong>이진 탐색 범위</strong>: l=500, r=1901</li>
</ul>
<hr>
<p><strong>K=500 으로 check 시뮬레이션</strong></p>
<table>
<thead>
<tr>
<th>날</th>
<th>지출</th>
<th>잔액</th>
<th>인출 횟수</th>
</tr>
</thead>
<tbody><tr>
<td>1일</td>
<td>100</td>
<td>500 → 400</td>
<td>1</td>
</tr>
<tr>
<td>2일</td>
<td>400</td>
<td>400 → 0</td>
<td>1</td>
</tr>
<tr>
<td>3일</td>
<td>300</td>
<td>부족 → 재인출, 500 → 200</td>
<td>2</td>
</tr>
<tr>
<td>4일</td>
<td>100</td>
<td>200 → 100</td>
<td>2</td>
</tr>
<tr>
<td>5일</td>
<td>500</td>
<td>부족 → 재인출, 500 → 0</td>
<td>3</td>
</tr>
<tr>
<td>6일</td>
<td>101</td>
<td>부족 → 재인출, 500 → 399</td>
<td>4</td>
</tr>
<tr>
<td>7일</td>
<td>400</td>
<td>부족 → 재인출, 500 → 100</td>
<td>5</td>
</tr>
</tbody></table>
<p><strong>cnt = 5 ≤ M(5) → 가능</strong></p>
<p>이진 탐색으로 K=500보다 작은 값도 탐색하지만 모두 불가능 → <strong>ans = 500</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[16235. 나무 재테크]]></title>
            <link>https://velog.io/@ori_gui/16235.-%EB%82%98%EB%AC%B4-%EC%9E%AC%ED%85%8C%ED%81%AC</link>
            <guid>https://velog.io/@ori_gui/16235.-%EB%82%98%EB%AC%B4-%EC%9E%AC%ED%85%8C%ED%81%AC</guid>
            <pubDate>Thu, 03 Apr 2025 03:09:35 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p><a href="https://www.acmicpc.net/problem/16235">[BOJ-16235] 나무 재테크</a></p>
<hr>
<h3 id="문제-해결-아이디어">문제 해결 아이디어</h3>
<blockquote>
<p>시뮬레이션인데 이제 자료구조를 곁들인..🥲</p>
</blockquote>
<h4 id="조건">조건</h4>
<blockquote>
<p>1 ≤ $N$ ≤ 10
1 ≤ $M$ ≤ $N^2$
1 ≤ $K$ ≤ 1,000
1 ≤ $A[r][c]$ ≤ 100
1 ≤ 입력으로 주어지는 나무의 나이 ≤ 10
입력으로 주어지는 나무의 위치는 모두 서로 다름</p>
</blockquote>
<p>주어진 조건의 크기가 별로 크지 않아서 단순 시뮬레이션이라고 생각했었다.. <del><em>(시간초과 어마무시..)</em></del></p>
<h4 id="linkedlist-접근">LinkedList 접근</h4>
<ul>
<li>Tree 리스트를 매년 정렬 하는데 O(n log n) 걸린다.. 그래서 처음 입력받은 나무들 한 번만 정렬</li>
<li>가을에 맨 앞에 나이 1인 친구들을 삽입하면 정렬할 필요가 없기 때문에, 맨 앞 삽입이 O(1)인 LinkedList 사용 (ArrayList는 O(n))</li>
</ul>
<h4 id="봄">봄</h4>
<ul>
<li>Tree 리스트 순회하면서 해당 땅에 양분이 나무 나이보다 크거나 같으면 한 살 더 먹고, 아니면 죽는다..!</li>
</ul>
<h4 id="여름">여름</h4>
<ul>
<li>Tree 리스트 순회시 <code>Iterator</code>를 사용하면 한 번 순회 만으로 바로 Tree리스트에 죽은 나무들을 제거해준다!! (반복 횟수 줄이는데 용이)</li>
</ul>
<h4 id="가을">가을</h4>
<ul>
<li>Tree 리스트 순회 하면서, 번식하여 생긴 한 살인 애기 나무들을 바로 Tree 리스트에 삽입하면 forEach로 순회해서 문제가 있고, 또 정렬해줘야한다..!</li>
<li>그래서 따로 애기들용 Tree 리스트에다가 삽입 후, <code>addAll</code> 메소드로 기존 Tree 리스트에 맨 앞에 삽입 (정렬 필요x)</li>
</ul>
<h4 id="겨울">겨울</h4>
<ul>
<li>그저 양분 공급</li>
</ul>
<hr>
<h3 id="코드">코드</h3>
<pre><code class="language-java">import java.io.*;
import java.util.*;

public class Solution16235 {
    static int N, M, K;
    static int[][] A, B;
    static int x, y, z;
    static List&lt;Tree&gt; trees;
    static int[] dx = {0, 0, 1, -1, 1, 1, -1, -1};
    static int[] dy = {1, -1, 0, 0, 1, -1, 1, -1};
    static int treeCnt;

    static class Tree implements Comparable&lt;Tree&gt; {
        int x;
        int y;
        int age;
        boolean isAlive;

        Tree(int x, int y, int age, boolean isAlive) {
            this.x = x;
            this.y = y;
            this.age = age;
            this.isAlive = isAlive;
        }

        @Override
        public int compareTo(Tree o) {
            return Integer.compare(this.age, o.age);
        }
    }

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());

        N = Integer.parseInt(st.nextToken()); // N*N 땅
        M = Integer.parseInt(st.nextToken()); // M 그루
        K = Integer.parseInt(st.nextToken()); // K 년 후
        A = new int[N][N]; // 겨울에 추가될 양분
        B = new int[N][N]; // 내 땅 양분


        trees = new LinkedList&lt;&gt;();
        treeCnt = 0;

        for (int i = 0; i &lt; N; i++) {
            st = new StringTokenizer(br.readLine());
            for (int j = 0; j &lt; N; j++) {
                A[i][j] = Integer.parseInt(st.nextToken());
                B[i][j] = 5;
            }
        }

        for (int i = 0; i &lt; M; i++) {
            st = new StringTokenizer(br.readLine());
            x = Integer.parseInt(st.nextToken()); // 나무 x좌표
            y = Integer.parseInt(st.nextToken()); // 나무 y좌표
            z = Integer.parseInt(st.nextToken()); // 나무 나이 z
            trees.add(new Tree(x-1, y-1, z, true));
        }
        Collections.sort(trees);

        for (int k = 1; k &lt;= K; k++) { // 나이별 나무 (오름차순)

            // 봄
            for (Tree tree : trees) {
                if (B[tree.x][tree.y] &gt;= tree.age &amp;&amp; tree.isAlive) {
                    B[tree.x][tree.y] -= tree.age;
                    tree.age += 1;
                } else {
                    tree.isAlive = false;
                }
            }

            // 여름
            Iterator&lt;Tree&gt; iter = trees.iterator();
            while (iter.hasNext()) {
                Tree tree = iter.next();
                if (!tree.isAlive) {
                    B[tree.x][tree.y] += tree.age / 2; // 죽은나무 양분
                    iter.remove();
                }
            }

            // 가을
            List&lt;Tree&gt; newTrees = new LinkedList&lt;&gt;(); 
            for (Tree tree : trees) {
                if (tree.age % 5 == 0 &amp;&amp; tree.isAlive) {
                    for (int d = 0; d &lt; 8; d++) {
                        int nx = tree.x + dx[d];
                        int ny = tree.y + dy[d];
                        if (nx &lt; 0 || ny &lt; 0 || nx &gt;= N || ny &gt;= N) continue;
                        newTrees.add(0, new Tree(nx, ny, 1, true));
                    }
                }
            }
            trees.addAll(0, newTrees);

            // 겨울
            for (int i = 0; i &lt; N; i++) {
                for (int j = 0; j &lt; N; j++) {
                    B[i][j] += A[i][j];
                }
            }
        }

        for (Tree tree : trees) {
            if (tree.isAlive) {
                treeCnt += 1;
            }
        }
        System.out.println(treeCnt);
    }
}

</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[1249. 보급로]]></title>
            <link>https://velog.io/@ori_gui/1249.-%EB%B3%B4%EA%B8%89%EB%A1%9C</link>
            <guid>https://velog.io/@ori_gui/1249.-%EB%B3%B4%EA%B8%89%EB%A1%9C</guid>
            <pubDate>Wed, 02 Apr 2025 06:22:18 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p><a href="https://swexpertacademy.com/main/code/problem/problemDetail.do?contestProbId=AV15QRX6APsCFAYD">[SWEA-1249] 보급로</a></p>
<hr>
<h3 id="문제-해결-아이디어">문제 해결 아이디어</h3>
<blockquote>
<p>2차원 배열 지도의 거리를 Node 객체 좌표 별로 갱신하는게 🦵킥! 전형적인 다익스트라</p>
</blockquote>
<h4 id="조건">조건</h4>
<ul>
<li>방문 거리 고려하지 않고, 오직 복구 비용 (가중치)민 고려 </li>
<li>시작점과 도착점이 정해져있음</li>
</ul>
<h4 id="dijkstra-접근">Dijkstra 접근</h4>
<ul>
<li>Node 객체를 생성하여 <code>x</code>,<code>y</code> 좌표 와 <code>dist</code> 시작점 부터 누적 거리를 저장</li>
<li><code>dist[][]</code> 2차원 배열 <code>Integer.MAX_VALUE</code>로 초기화</li>
</ul>
<h4 id="priorityqueue-활용">PriorityQueue 활용</h4>
<ul>
<li><p>PriorityQueue에 Node의 <code>dist</code> 오름차순으로 정렬 (짧은 거리)</p>
</li>
<li><p>방문 배열  없이, 이미 해당 좌표까지 더 짧은 거리가 저장되어 있으면 <code>contiunue</code> </p>
<pre><code class="language-java">if (dist[cur.x][cur.y] &lt; cur.dist) continue;</code></pre>
</li>
<li><p>2차원 배열 지도에 4방향으로 탐색 (범위 벗어 나면 <code>continue</code>)</p>
</li>
<li><p>현재 좌표까지의 거리(복구비용) + 다음 방향 까지 거리(복구비용)이 이미 존재하는 다음 좌표에 대한 거리(복구비용) 보다 작으면 갱신</p>
</li>
</ul>
<hr>
<h3 id="코드">코드</h3>
<pre><code class="language-java">import java.io.*;
import java.util.*;

public class Solution1249 {
    static int T;
    static int N;
    static int[][] map;
    static int[] dx = {1, 0, -1, 0};
    static int[] dy = {0, 1, 0, -1};
    static int minCost;

    static class Node {
        int x;
        int y;
        int dist;

        Node(int x, int y, int dist) {
            this.x = x;
            this.y = y;
            this.dist = dist;
        }
    }

    static int dijkstra(Node s) {
        // 시작점 부터 거리 배열 초기화
        int dist[][] = new int[N][N];
        for (int i = 0; i &lt; N; i++) {
            Arrays.fill(dist[i], Integer.MAX_VALUE);
        }
        dist[s.x][s.y] = s.dist;

        PriorityQueue&lt;Node&gt; pq = new PriorityQueue&lt;&gt;(
            Comparator.comparingInt((Node n) -&gt; n.dist));

        pq.offer(s);

        while(!pq.isEmpty()) {
            Node cur = pq.poll();

            if (dist[cur.x][cur.y] &lt; cur.dist) continue; // 이미 더 짧은 거리 찾음

            for (int i = 0; i &lt; 4; i++) {
                int nx = cur.x + dx[i];
                int ny = cur.y + dy[i];

                if(nx &lt; 0 || ny &lt; 0 || nx &gt;= N || ny &gt;= N) continue;

                int cost = cur.dist + map[nx][ny];
                if (dist[nx][ny] &gt; cost) {
                    dist[nx][ny] = cost;
                    pq.offer(new Node(nx, ny, cost));
                }
            }
        }
        return dist[N-1][N-1];
    }


    public static void main(String[] args) throws NumberFormatException, IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        T = Integer.parseInt(br.readLine());

        for (int tc = 1; tc &lt;= T; tc++) {
            N = Integer.parseInt(br.readLine());
            map = new int[N][N];

            for (int i = 0; i &lt; N; i++) {
                String str = br.readLine();
                for (int j = 0; j &lt; N; j++) {
                    map[i][j] = str.charAt(j) - &#39;0&#39;;
                }
            }

            minCost = dijkstra(new Node(0, 0, 0));
            System.out.printf(&quot;#%d %d\n&quot;, tc, minCost);
        }
    }
}

</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[14719. 빗물]]></title>
            <link>https://velog.io/@ori_gui/14719.-%EB%B9%97%EB%AC%BC</link>
            <guid>https://velog.io/@ori_gui/14719.-%EB%B9%97%EB%AC%BC</guid>
            <pubDate>Wed, 02 Apr 2025 06:08:48 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p><a href="https://www.acmicpc.net/problem/14719">[BOJ-14719] 빗물</a></p>
<hr>
<h3 id="문제-해결-아이디어">문제 해결 아이디어</h3>
<blockquote>
<p>우선순위 큐를 활용해서 2차원 세계의 벽 높이를 오름차순으로 정렬 후 순서대로 물 채우기🪣</p>
</blockquote>
<h4 id="조건">조건</h4>
<ul>
<li>직관적으로 벽 사이에 물이 고임 </li>
<li>2차원 세계의 바닥은 막혀있음 (0이 마지막)</li>
</ul>
<h4 id="priorityqueue-접근">PriorityQueue 접근</h4>
<ul>
<li>Block 객체를 생성하여 <code>i</code> index, <code>h</code> height 저장 </li>
<li>PriorityQueue에 <code>h</code> 내림차순으로 정렬 (높은 벽 먼저)</li>
<li><code>b1</code>, <code>b2</code> 높은 벽과 그 다음 높은 벽 꺼내서 반복</li>
</ul>
<h4 id="빗물-채우기">빗물 채우기</h4>
<p><code>b1</code>부터 <code>b2</code>사이에 index에 위치한 세계에 <code>b2</code> 높이 만큼 빗물 채우기</p>
<pre><code class="language-java">for (int i = l + 1; i &lt; r; i++) {
    int water = b2.h - world[i];
    if (water &gt; 0) {
        ans += water;
        world[i] = b2.h;
    }
}</code></pre>
<hr>
<h3 id="코드">코드</h3>
<pre><code class="language-java">import java.io.*;
import java.util.*;

public class Solution14719 {
    static int H, W;
    static int[] world;
    static int ans;
    static PriorityQueue&lt;Block&gt; pq;

    static class Block {
        int i;
        int h;

        Block(int i, int h) {
            this.i = i;
            this.h = h;
        }
    }

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());

        H = Integer.parseInt(st.nextToken());
        W = Integer.parseInt(st.nextToken());
        world = new int[W];
        ans = 0;
        pq = new PriorityQueue&lt;&gt;(Comparator.comparingInt((Block b) -&gt; b.h).reversed());

        st = new StringTokenizer(br.readLine());
        for (int i = 0; i &lt; W; i++) {
            world[i] = Integer.parseInt(st.nextToken());
            pq.offer(new Block(i, world[i]));
        }

        while (pq.size() &gt; 1) {
            Block b1 = pq.poll();
            Block b2 = pq.peek();

            int l = Math.min(b1.i, b2.i);
            int r = Math.max(b1.i, b2.i);

            for (int i = l + 1; i &lt; r; i++) {
                int water = b2.h - world[i];
                if (water &gt; 0) {
                    ans += water;
                    world[i] = b2.h;
                }
            }
        }
        System.out.println(ans);
    }
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[GPS]]></title>
            <link>https://velog.io/@ori_gui/GPS</link>
            <guid>https://velog.io/@ori_gui/GPS</guid>
            <pubDate>Sun, 30 Mar 2025 14:43:45 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p><a href="https://school.programmers.co.kr/learn/courses/30/lessons/1837">프로그래머스 - GPS</a></p>
<hr>
<h3 id="문제-해결-아이디어">문제 해결 아이디어</h3>
<blockquote>
<p>목표는 수정 횟수(오차로 기록된 위치를 바꾼 횟수)를 최소화하여 실제 이동 가능한 경로로 만들기, 시간 t에 v 정점에 있을 때 수정 횟수를 DP로..!</p>
</blockquote>
<h4 id="조건">조건</h4>
<ul>
<li>택시는 인접한 두 거점 사이를 이동</li>
<li>이동 하지 않고 머무르기 가능</li>
<li>주어진 GPS 로그의 시작(첫번째)와 도착(마지막) 거점은 바꿀 수 없고, 나머지 위치는 원하는 거점으로 수정가능</li>
</ul>
<h4 id="dp-접근">DP 접근</h4>
<p>각 시간 t (0 ≤ t &lt; k)에서, 택시가 거점 v에 있을 때 지금까지 수정한 최소 횟수를 dp[t][v]라고 한다.</p>
<p>초기 상태:
<code>t=0</code>에서는 GPS 로그의 첫 번째 위치 s가 고정되므로 dp[0][s] = 0이고, 다른 거점은 <code>Integer.MAX_VALUE</code>로 둔다.</p>
<p>전이:
<code>t</code>에서 <code>v</code>에 있을 때, <code>t+1</code>에는 <code>v</code>에서 이동 가능한 모든 거점 <code>w</code> (자신 포함)로 이동할 수 있다. 이때, GPS 로그에 기록된 <code>t+1</code>번째 값과 <code>w</code>가 같으면 추가 오류 수정 없이, 다르면 1회의 오류 수정 카운트</p>
<pre><code class="language-java">dp[t+1][w] = min(dp[t+1][w], dp[t][v] + (w == gps_log[t+1] ? 0 : 1))</code></pre>
<p><code>k-1</code> 시점에 GPS 로그의 도착 위치에 도달하는 최소 수정 횟수가 정답!
도달 불가능하면 -1을 반환</p>
<hr>
<h3 id="코드">코드</h3>
<pre><code class="language-java">import java.util.*;

class Solution {
    static List&lt;Integer&gt;[] graph;
    public int solution(int n, int m, int[][] edge_list, int k, int[] gps_log) {
        // 1부터 n까지 정점, 양방향 간선으로 인접 리스트 생성
        graph = new ArrayList[n + 1];
        for (int i = 1; i &lt;= n; i++) {
            graph[i] = new ArrayList&lt;&gt;();
            // &quot;머무르기&quot; 가능
            graph[i].add(i);
        }
        for (int[] edge : edge_list) {
            int a = edge[0];
            int b = edge[1];
            graph[a].add(b);
            graph[b].add(a);
        }

        // dp[t][v] : 시간 t에 v에 있을 때까지 수정한 최소 횟수
        int[][] dp = new int[k][n + 1];
        for (int i = 0; i &lt; k; i++) {
            Arrays.fill(dp[i], Integer.MAX_VALUE);
        }
        // 시작 위치는 gps_log[0]
        dp[0][gps_log[0]] = 0;

        // 마지막 위치 gps_log[k-1]는 수정 불가
        for (int t = 0; t &lt; k - 1; t++) {
            for (int v = 1; v &lt;= n; v++) {
                if (dp[t][v] == Integer.MAX_VALUE) continue;
                // v에서 이동 가능한 모든 거점 w (v 자신 포함)
                for (int w : graph[v]) {
                    int cost = (w == gps_log[t + 1] ? 0 : 1);
                    dp[t + 1][w] = Math.min(dp[t + 1][w], dp[t][v] + cost);
                }
            }
        }

        int answer = dp[k - 1][gps_log[k - 1]];
        return (answer == Integer.MAX_VALUE ? -1 : answer);
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[동굴 탐험]]></title>
            <link>https://velog.io/@ori_gui/%EB%8F%99%EA%B5%B4-%ED%83%90%ED%97%98</link>
            <guid>https://velog.io/@ori_gui/%EB%8F%99%EA%B5%B4-%ED%83%90%ED%97%98</guid>
            <pubDate>Sat, 29 Mar 2025 18:08:45 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p><a href="https://school.programmers.co.kr/learn/courses/30/lessons/67260">프로그래머스 - 동굴 탐험</a></p>
<hr>
<h3 id="문제-해결-아이디어">문제 해결 아이디어</h3>
<blockquote>
<p>위상정렬을 잘 몰랐는데 BFS로 구현 한게 위상정렬이었던 것,
제약조건이 있어서, 특정 방🔒에 들어가기전 방문해야하는 방이 있음 -&gt; pre[] 배열, wait[] 배열을 사용하여 i번 방을 방문하기 전에 방문해야하는 방, i번 방을 방문 한 후에 풀리는 방을 저장</p>
</blockquote>
<ol>
<li><p><strong>그래프 구성</strong>  </p>
<ul>
<li><code>graph</code> 배열에 각 방(0번~n-1번)의 인접한 방 정보를 저장 </li>
<li>입력으로 주어진 <code>path</code>를 양방향 간선으로 추가</li>
</ul>
</li>
<li><p><strong>제약 정보 설정</strong>  </p>
<ul>
<li><code>pre</code> 배열은 각 방이 방문되기 전에 반드시 방문해야 하는 선행 방의 번호를 저장하며, 기본값은 -1(제약 없음)</li>
<li><code>order</code> 배열을 순회하며, <code>[A, B]</code> 형태의 제약을 <code>pre[B] = A</code>로 저장</li>
<li><code>wait</code> 배열은 아직 방문하지 못한, 방문해야 할 방을 보류하는 용도로 사용</li>
</ul>
</li>
<li><p><strong>BFS 탐색</strong>  </p>
<ul>
<li>0번 방(입구)에서 시작</li>
<li>탐색 중, 인접한 방을 방문할 때 해당 방이 선행 조건을 만족하지 않으면(즉, <code>pre[next]</code>가 방문되지 않았으면) <code>wait</code>에 저장 </li>
<li>만약 현재 방문한 방 <code>cur</code>가 선행 조건을 제공하는 방이라면, <code>wait[cur]</code>에 저장된 방을 큐에 추가하여 잠금 해제</li>
</ul>
</li>
<li><p><strong>최종 확인</strong>  </p>
<ul>
<li>모든 방이 방문되었는지 확인하여, 하나라도 방문하지 못했다면 false를 반환하고, 모두 방문했다면 true를 반환</li>
</ul>
</li>
</ol>
<hr>
<h3 id="코드">코드</h3>
<pre><code class="language-java">import java.util.*;

class Solution {
    public boolean solution(int n, int[][] path, int[][] order) {
        // 1. 그래프 구성
        List&lt;Integer&gt;[] graph = new ArrayList[n];
        for (int i = 0; i &lt; n; i++) {
            graph[i] = new ArrayList&lt;&gt;();
        }
        for (int[] p : path) {
            graph[p[0]].add(p[1]);
            graph[p[1]].add(p[0]);
        }

        // 2. 제약 정보 설정
        // pre[i] : i번 방을 방문하기 전에 반드시 방문해야 하는 방 (없으면 -1)
        int[] pre = new int[n];
        Arrays.fill(pre, -1);
        // wait[x] : x번 방을 방문한 후에 unlock될, 아직 방문 못한 방 (없으면 -1)
        int[] wait = new int[n];
        Arrays.fill(wait, -1);

        for (int[] o : order) {
            pre[o[1]] = o[0];
        }

        // 0번 방(입구)가 잠겨있으면 탐험 불가능
        if (pre[0] != -1) {
            return false;
        }

        // 3. BFS 탐색 0번 방부터 시작
        boolean[] v = new boolean[n];
        Deque&lt;Integer&gt; q = new ArrayDeque&lt;&gt;();
        v[0] = true;
        q.offer(0);

        while (!q.isEmpty()) {
            int cur = q.poll();

            // cur 방문으로 인해 unlock되어야 하는 방이 있다면 큐에 추가
            if (wait[cur] != -1) {
                int next = wait[cur];
                q.offer(next);
                v[next] = true;
                wait[cur] = -1;
            }

            for (int next : graph[cur]) {
                if (v[next]) {
                    continue;
                }

                // next가 방문 조건(선행 방문)이 있고, 아직 방문 전이라면 next 대기
                if (pre[next] != -1 &amp;&amp; !v[pre[next]]) {
                    wait[pre[next]] = next;
                } else {
                    v[next] = true;
                    q.offer(next);
                }
            }
        }

        // 4. 모든 방을 방문했는지 확인
        for (boolean flag : v) {
            if (!flag) {
                return false;
            } 
        }
        return true;
    }
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[합승 택시 요금]]></title>
            <link>https://velog.io/@ori_gui/%ED%95%A9%EC%8A%B9-%ED%83%9D%EC%8B%9C-%EC%9A%94%EA%B8%88-v24a4hkf</link>
            <guid>https://velog.io/@ori_gui/%ED%95%A9%EC%8A%B9-%ED%83%9D%EC%8B%9C-%EC%9A%94%EA%B8%88-v24a4hkf</guid>
            <pubDate>Sat, 29 Mar 2025 17:35:24 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p><a href="https://school.programmers.co.kr/learn/courses/30/lessons/72413">프로그래머스 - 합승 택시 요금</a></p>
<hr>
<h3 id="문제-해결-아이디어">문제 해결 아이디어</h3>
<blockquote>
<p>DP인데 &#39;플로이드 워셜&#39;이었던 것 점화식 구하는게 중요!</p>
</blockquote>
<ol>
<li><p><strong>그래프 구성</strong>  </p>
<ul>
<li>주어진 <code>fares</code> 정보를 바탕으로 <strong>인접 행렬</strong>(dist 배열)을 생성</li>
<li><code>dist[i][j]</code>는 i번 지점에서 j번 지점으로 이동하는 최소 요금을 저장</li>
<li>직통 경로가 없으면 큰 값(주어진 조건을 만족하는 최대 거리)으로 초기화</li>
<li>자기 자신으로의 이동 비용은 0으로 설정</li>
</ul>
</li>
<li><p><strong>Floyd-Warshall 알고리즘</strong>  </p>
<ul>
<li>점화식:  <code>dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]</code></li>
<li>이 과정을 k=1..n, i=1..n, j=1..n에 대해 반복</li>
</ul>
</li>
<li><p><strong>합승 지점 x 선택</strong>  </p>
<ul>
<li>최종적으로 s(출발점)에서 x까지 함께 택시를 탄 뒤, x에서 A, B 각각의 집으로 따로 이동하는 경우를 고려  </li>
<li>즉, <strong>모든 지점 x</strong>(1부터 n까지)를 순회하며, 비용 <code>dist[s][x] + dist[x][a] + dist[x][b]</code>의 최솟값</li>
</ul>
</li>
</ol>
<hr>
<h3 id="코드">코드</h3>
<pre><code class="language-java">class Solution {
    public int solution(int n, int s, int a, int b, int[][] fares) {
        // 1. dist 배열 초기화
        int[][] dist = new int[n + 1][n + 1];
        for (int i = 1; i &lt;= n; i++) {
            for (int j = 1; j &lt;= n; j++) {
                if (i != j) {
                    dist[i][j] = 20000000; // 최대 비용 (최대 요금 100,000 * 지점갯수 200 = 20,000,000)
                }
            }
        }

        // 2. 요금 정보 입력
        for (int[] fare : fares) {
            int c = fare[0];
            int d = fare[1];
            int f = fare[2];
            dist[c][d] = f;
            dist[d][c] = f;
        }

        // 3. Floyd-Warshall 알고리즘
        for (int k = 1; k &lt;= n; k++) {
            for (int i = 1; i &lt;= n; i++) {
                for (int j = 1; j &lt;= n; j++) {
                    if (dist[i][j] &gt; dist[i][k] + dist[k][j]) {
                        dist[i][j] = dist[i][k] + dist[k][j];
                    }
                }
            }
        }

        // 4. s→x 합승 후 x→a, x→b 각각 이동 비용의 최솟값 탐색
        int answer = dist[s][a] + dist[s][b]; // s→a + s→b 비용
        for (int x = 1; x &lt;= n; x++) {
            int cost = dist[s][x] + dist[x][a] + dist[x][b];
            if (cost &lt; answer) {
                answer = cost;
            }
        }

        return answer;
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[코딩테스트 공부]]></title>
            <link>https://velog.io/@ori_gui/%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B3%B5%EB%B6%80</link>
            <guid>https://velog.io/@ori_gui/%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B3%B5%EB%B6%80</guid>
            <pubDate>Sat, 29 Mar 2025 17:01:28 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p><a href="https://school.programmers.co.kr/learn/courses/30/lessons/118668">프로그래머스 - 코딩테스트 공부</a></p>
<hr>
<h3 id="문제-해결-아이디어">문제 해결 아이디어</h3>
<blockquote>
<p>알고력과 코딩력을 달성하기 위한 최소 시간을 dp[i][j]로 구성하는게 포인트🎯 </p>
</blockquote>
<ol>
<li><p><strong>목표 알고력, 코딩력 설정</strong>  </p>
<ul>
<li>problems에서 모든 <code>alp_req</code> 중 최댓값과 <code>cop_req</code> 중 최댓값</li>
</ul>
</li>
<li><p><strong>DP 배열 구성</strong>  </p>
<ul>
<li><code>dp[i][j]</code>를 알고력 <code>i</code>와 코딩력 <code>j</code>를 달성하기 위한 최소 시간을 저장하는 2차원 배열로 정의</li>
<li>초기 상태 <code>dp[alp][cop] = 0</code>으로 시작하며, 나머지 값은 큰 값(<code>Integer.MAX_VLAUE</code>)으로 초기화</li>
<li>단, 초기 alp 또는 cop가 목표치를 넘는다면 목표치로 맞춤 (배열 길이 고려)</li>
</ul>
</li>
<li><p><strong>상태 전이</strong>  </p>
<ul>
<li><strong>공부(알고리즘/코딩)</strong>  <ul>
<li>알고리즘 공부: <code>dp[i+1][j] = min(dp[i+1][j], dp[i][j] + 1)</code>  </li>
<li>코딩 공부: <code>dp[i][j+1] = min(dp[i][j+1], dp[i][j] + 1)</code>  </li>
</ul>
</li>
<li><strong>문제 풀이</strong>  <ul>
<li>만약 현재 상태 <code>(i, j)</code>가 문제 p의 요구치(<code>alp_req</code>, <code>cop_req</code>)를 만족한다면, 문제 p를 풀어 새 상태 <code>(min(target_alp, i + alp_rwd), min(target_cop, j + cop_rwd))</code>로 전이되고, 소요 시간 <code>cost</code>가 추가</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>최종 답안</strong>  </p>
<ul>
<li>목표 알고력과 코딩력을 달성한 상태 <code>dp[target_alp][target_cop]</code>의 값이 최소 총 공부 시간이 됩니다.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="코드">코드</h3>
<pre><code class="language-java">import java.util.*;

class Solution {
    public int solution(int alp, int cop, int[][] problems) {
        // 1. 목표 알고력, 코딩력 설정 (각 문제의 최대 요구치)
        int maxAlp = 0, maxCop = 0;
        for (int[] prob : problems) {
            maxAlp = Math.max(maxAlp, prob[0]);
            maxCop = Math.max(maxCop, prob[1]);
        }
        // 초기치가 목표치를 초과하면 목표치로 맞춤
        alp = Math.min(alp, maxAlp);
        cop = Math.min(cop, maxCop);

        // 2. dp 배열 초기화
        int[][] dp = new int[maxAlp + 1][maxCop + 1];
        for (int i = 0; i &lt;= maxAlp; i++) {
            Arrays.fill(dp[i], Integer.MAX_VALUE);
        }
        dp[alp][cop] = 0;

        // 3. DP 전이 (알고리즘 공부, 코딩 공부, 문제 풀기)
        for (int i = alp; i &lt;= maxAlp; i++) {
            for (int j = cop; j &lt;= maxCop; j++) {
                if (dp[i][j] == Integer.MAX_VALUE) continue;

                // 3-1. 알고리즘 공부: 알고력 1 증가
                if (i &lt; maxAlp) {
                    dp[i+1][j] = Math.min(dp[i+1][j], dp[i][j] + 1);
                }
                // 3-2. 코딩 공부: 코딩력 1 증가
                if (j &lt; maxCop) {
                    dp[i][j+1] = Math.min(dp[i][j+1], dp[i][j] + 1);
                }
                // 3-3. 각 문제 풀이
                for (int[] prob : problems) {
                    if (i &gt;= prob[0] &amp;&amp; j &gt;= prob[1]) {
                        int newAlp = Math.min(maxAlp, i + prob[2]);
                        int newCop = Math.min(maxCop, j + prob[3]);
                        dp[newAlp][newCop] = Math.min(dp[newAlp][newCop], dp[i][j] + prob[4]);
                    }
                }
            }
        }
        return dp[maxAlp][maxCop];
    }
}
</code></pre>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[택배 배달과 수거하기]]></title>
            <link>https://velog.io/@ori_gui/%ED%83%9D%EB%B0%B0-%EB%B0%B0%EB%8B%AC%EA%B3%BC-%EC%88%98%EA%B1%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ori_gui/%ED%83%9D%EB%B0%B0-%EB%B0%B0%EB%8B%AC%EA%B3%BC-%EC%88%98%EA%B1%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 29 Mar 2025 16:39:30 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p><a href="https://school.programmers.co.kr/learn/courses/30/lessons/150369">프로그래머스 - 택배 배달과 수거하기</a></p>
<hr>
<h3 id="문제-해결-아이디어">문제 해결 아이디어</h3>
<blockquote>
<p>이 문제는 <strong>뒤쪽(가장 먼 집)부터 처리하는 그리디 알고리즘</strong>을 활용해야겠다고 생각했다!</p>
</blockquote>
<ol>
<li><p><strong>뒤쪽부터 처리하기</strong>  </p>
<ul>
<li>집들은 물류창고에서 가까운 순서대로 1번부터 n번까지 배치되어 있으므로, 가장 먼 집부터 남은 배달 또는 수거가 있는 집까지의 거리를 기준으로 처리</li>
</ul>
</li>
<li><p><strong>최대 거리 결정</strong>  </p>
<ul>
<li>아직 처리할 배달이나 수거가 남은 집들 중 가장 먼 집의 번호를 찾기  </li>
<li>해당 집까지의 왕복 거리는 (집 번호 = <code>index</code> + 1) * 2</li>
</ul>
</li>
<li><p><strong>한 번 이동에서 cap 만큼 처리</strong>  </p>
<ul>
<li>트럭의 용량 cap만큼 배달과 수거 작업을 진행 </li>
<li>각각의 배열에서 가장 먼 집부터 cap 만큼의 택배 상자를 처리하고, 남은 건수 갱신</li>
</ul>
</li>
<li><p><strong>반복 처리</strong>  </p>
<ul>
<li>모든 집에 대한 배달 및 수거가 완료될 때까지 위 과정을 반복하며, 이동 거리 누적</li>
</ul>
</li>
</ol>
<hr>
<h3 id="코드">코드</h3>
<pre><code class="language-java">import java.util.*;

class Solution {
    public long solution(int cap, int n, int[] deliveries, int[] pickups) {
        long answer = 0;
        int dIdx = n - 1; // deliveries의 마지막 인덱스 (가장 먼 집)
        int pIdx = n - 1; // pickups의 마지막 인덱스 (가장 먼 집)

        // 아직 배달 또는 수거할 집이 남아있는 동안 반복
        while (dIdx &gt;= 0 || pIdx &gt;= 0) {
            // 배달할 집 중 남은 건수가 0인 집은 건너뛰기
            while (dIdx &gt;= 0 &amp;&amp; deliveries[dIdx] == 0) {
                dIdx--;
            }
            // 수거할 집 중 남은 건수가 0인 집은 건너뛰기
            while (pIdx &gt;= 0 &amp;&amp; pickups[pIdx] == 0) {
                pIdx--;
            }

            // 배달과 수거가 모두 완료되었다면 종료
            if (dIdx &lt; 0 &amp;&amp; pIdx &lt; 0) {
                break;
            }

            // 이번 회 이동할 최대 거리는 두 포인터 중 더 큰 값(인덱스)에 1을 더한 거리
            int dist = Math.max(dIdx, pIdx) + 1;
            answer += (long) dist * 2;  // 왕복 이동 거리 누적

            int deliverCap = cap; // 이번 회 배달 처리에 사용할 남은 용량
            int pickupCap = cap;  // 이번 회 수거 처리에 사용할 남은 용량

            // 배달 처리: 가장 먼 집부터 cap 만큼 배달을 처리
            while (dIdx &gt;= 0 &amp;&amp; deliverCap &gt; 0) {
                if (deliveries[dIdx] &lt;= deliverCap) {
                    deliverCap -= deliveries[dIdx];
                    deliveries[dIdx] = 0;
                    dIdx--;
                } else {
                    deliveries[dIdx] -= deliverCap;
                    deliverCap = 0;
                }
            }

            // 수거 처리: 가장 먼 집부터 cap 만큼 수거를 처리
            while (pIdx &gt;= 0 &amp;&amp; pickupCap &gt; 0) {
                if (pickups[pIdx] &lt;= pickupCap) {
                    pickupCap -= pickups[pIdx];
                    pickups[pIdx] = 0;
                    pIdx--;
                } else {
                    pickups[pIdx] -= pickupCap;
                    pickupCap = 0;
                }
            }
        }

        return answer;
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[도넛과 막대 그래프]]></title>
            <link>https://velog.io/@ori_gui/%EB%8F%84%EB%84%9B%EA%B3%BC-%EB%A7%89%EB%8C%80-%EA%B7%B8%EB%9E%98%ED%94%84</link>
            <guid>https://velog.io/@ori_gui/%EB%8F%84%EB%84%9B%EA%B3%BC-%EB%A7%89%EB%8C%80-%EA%B7%B8%EB%9E%98%ED%94%84</guid>
            <pubDate>Sat, 29 Mar 2025 16:16:11 GMT</pubDate>
            <description><![CDATA[<h3 id="문제">문제</h3>
<p><a href="https://school.programmers.co.kr/learn/courses/30/lessons/258711">프로그래머스 - 도넛과 막대 그래프</a></p>
<hr>
<h3 id="문제-해결-아이디어">문제 해결 아이디어</h3>
<blockquote>
<p>도넛, 막대, 팔자 그래프 유형을 판별하는 방식을 빨리 찾아내는게 중요한데..😂</p>
</blockquote>
<ol>
<li><p><strong>전체 정점 범위 파악 및 그래프 구성</strong>  </p>
<ul>
<li>주어진 간선 정보를 순회하며 정점 번호 수(<code>nCount</code>)를 파악  </li>
<li>정점 번호를 인덱스로 하는 인접 리스트와 각 정점의 진입차수(<code>inDegree</code>) 배열을 생성</li>
</ul>
</li>
<li><p><strong>생성 정점 찾기</strong>  </p>
<ul>
<li>inDegree가 0이면서, out-degree가 2 이상인 정점을 생성 정점으로 결정</li>
</ul>
</li>
<li><p><strong>BFS를 통한 서브그래프(연결 요소) 분리</strong>  </p>
<ul>
<li>생성 정점의 자식 정점들을 시작점으로 하여 BFS를 수행 </li>
<li>BFS 과정에서 해당 서브그래프에 포함된 정점 수(<code>compV</code>)와 간선 수(<code>compE</code>)를 집계 </li>
</ul>
</li>
<li><p><strong>서브그래프 유형 판별</strong>  </p>
<ul>
<li>각 서브그래프에 대해 집계한 <code>compV</code>와 <code>compE</code>의 값으로 유형을 구분<ul>
<li>도넛 그래프: <code>compE == compV</code></li>
<li>막대 그래프: <code>compE == compV - 1</code></li>
<li>팔자(8자) 그래프: <code>compE == compV + 1</code></li>
</ul>
</li>
</ul>
</li>
</ol>
<hr>
<h3 id="코드">코드</h3>
<pre><code class="language-java">import java.util.*;

class Solution {
    public int[] solution(int[][] edges) {
        // 1. 전체 정점 번호 범위 파악
        int nCount = 0;
        for (int[] e : edges) {
            nCount = Math.max(nCount, Math.max(e[0], e[1]));
        }

        // 2. 그래프와 inDegree 배열 초기화
        List&lt;List&lt;Integer&gt;&gt; graph = new ArrayList&lt;&gt;(nCount + 1);
        int[] inDegree = new int[nCount + 1];
        for (int i = 0; i &lt;= nCount; i++) {
            graph.add(new ArrayList&lt;&gt;());
        }
        for (int[] e : edges) {
            int u = e[0], v = e[1];
            graph.get(u).add(v);
            inDegree[v]++;
        }

        // 3. &quot;생성 정점&quot; 찾기: inDegree == 0 이면서 outDegree (graph.get(i).size())가 2 이상인 정점
        int start = -1;
        for (int i = 1; i &lt;= nCount; i++) {
            if (inDegree[i] == 0 &amp;&amp; graph.get(i).size() &gt;= 2) {
                start = i;
                break;
            }
        }

        int donut = 0, stick = 0, eight = 0;
        boolean[] v = new boolean[nCount + 1];

        // 4. BFS로 서브그래프 탐색
        for (int child : graph.get(start)) {
            if (v[child]) continue;

            int compV = 0, compE = 0;
            Queue&lt;Integer&gt; queue = new LinkedList&lt;&gt;();
            queue.offer(child);
            v[child] = true;

            while (!queue.isEmpty()) {
                int cur = queue.poll();
                compV++;

                for (int next : graph.get(cur)) {
                    compE++; // cur -&gt; next 간선 카운트
                    if (!v[next]) {
                        v[next] = true;
                        queue.offer(next);
                    }
                }
            }

            // 5. 서브그래프 분류
            if (compE == compV) {         // 도넛 그래프: 간선 수 == 정점 수
                donut++;
            } else if (compE == compV - 1) { // 막대 그래프: 간선 수 == 정점 수 - 1
                stick++;
            } else if (compE == compV + 1) { // 팔자(8자) 그래프: 간선 수 == 정점 수 + 1
                eight++;
            }
        }

        return new int[] { start, donut, stick, eight };
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CS 스터디] 네트워크 정리1]]></title>
            <link>https://velog.io/@ori_gui/CS-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%A0%95%EB%A6%AC1</link>
            <guid>https://velog.io/@ori_gui/CS-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%A0%95%EB%A6%AC1</guid>
            <pubDate>Fri, 28 Mar 2025 07:46:56 GMT</pubDate>
            <description><![CDATA[<h3 id="🌐-네트워크의-큰-그림">🌐 네트워크의 큰 그림</h3>
<blockquote>
<p>현대 사회에서 인터넷은 없어서는 안 될 인프라이다. 이 인터넷을 포함한 컴퓨터 간의 통신은 <strong>네트워크(Network)</strong> 라는 구조를 통해 이루어진다. 이 글에서는 네트워크의 기본 구조부터 IP 주소, MAC 주소까지의 개념을 하나씩 정리해본다.</p>
</blockquote>
<hr>
<h3 id="🕸️-네트워크의-구조-이해하기">🕸️ 네트워크의 구조 이해하기</h3>
<h3 id="📌-네트워크-토폴로지-network-topology">📌 네트워크 토폴로지 (Network Topology)</h3>
<p>네트워크에서 <strong>노드(컴퓨터, 서버 등)</strong> 간의 연결 형태를 <strong>토폴로지</strong>라고 한다.</p>
<ul>
<li><strong>버스형 (Bus)</strong> : 하나의 주 라인에 여러 장치가 연결된 형태</li>
<li><strong>성형 (Star)</strong> : 중앙 장치에 모든 장치가 연결된 형태</li>
<li><strong>링형 (Ring)</strong> : 원형으로 각 장치가 연결된 형태</li>
<li><strong>트리형 (Tree)</strong> : 계층적 구조</li>
<li><strong>망형 (Mesh)</strong> : 모든 장치가 서로 직접 연결</li>
</ul>
<blockquote>
<p>구조에 따라 통신 속도, 장애 발생 시의 영향, 비용 등에 차이가 발생한다.</p>
</blockquote>
<h3 id="👥-호스트-host">👥 호스트 (Host)</h3>
<ul>
<li><strong>최초 송신지와 최종 수신지를 의미하는 장치</strong></li>
<li>예: 노트북에서 구글 서버에 웹페이지 요청 → 구글 서버가 응답</li>
</ul>
<hr>
<h3 id="🖧-lan과-wan">🖧 LAN과 WAN</h3>
<ul>
<li><strong>LAN (Local Area Network)</strong> : 한정된 공간(사무실, 집 등) 내에서 가까운 장치들을 연결하는 네트워크</li>
<li><strong>WAN (Wide Area Network)</strong> : 장거리, 광범위한 지역을 연결하는 네트워크<ul>
<li><strong>ISP (Internet Service Provider)</strong> 업체가 관리</li>
</ul>
</li>
</ul>
<hr>
<h3 id="📦-패킷과-주소">📦 패킷과 주소</h3>
<blockquote>
<p>네트워크 통신은 <strong>패킷(Packet)</strong> 단위로 데이터를 주고받는다. 패킷은 다음과 같은 구조를 가진다:</p>
</blockquote>
<ul>
<li><strong>헤더 (Header)</strong> : 송수신지 정보, 전송 제어 정보 등</li>
<li><strong>페이로드 (Payload)</strong> : 실제 데이터</li>
<li><strong>트레일러 (Trailer)</strong> : 에러 검사 정보</li>
</ul>
<h3 id="📮-주소의-종류">📮 주소의 종류</h3>
<ul>
<li><strong>IP 주소</strong> : 네트워크 내 위치 식별</li>
<li><strong>MAC 주소</strong> : 물리적 장치 식별</li>
</ul>
<h3 id="🧭-전송-방식">🧭 전송 방식</h3>
<ul>
<li><strong>유니캐스트</strong> : 1:1 통신</li>
<li><strong>브로드캐스트</strong> : 1:모두 (네트워크 상의 모든 장치로 전송)<ul>
<li><strong>브로드캐스트 도메인</strong> : 브로드캐스트가 전달되는 범위</li>
</ul>
</li>
<li><strong>멀티캐스트</strong> : 1:선택된 그룹</li>
<li><strong>애니캐스트</strong> : 1:가장 가까운 노드로</li>
</ul>
<hr>
<h3 id="🔁-두-호스트-간-통신-절차">🔁 두 호스트 간 통신 절차</h3>
<blockquote>
<p>네트워크 상에서 장치들이 서로 통신하기 위해서는 <strong>규약(프로토콜)</strong> 과 <strong>계층 구조</strong>가 필요하다.</p>
</blockquote>
<hr>
<h3 id="🧾-프로토콜이란">🧾 프로토콜이란?</h3>
<p><strong>프로토콜</strong>은 네트워크 통신에서 지켜야 할 <strong>규칙 또는 약속</strong>이다.</p>
<ul>
<li><strong>IP (Internet Protocol)</strong> : 주소 지정, 라우팅</li>
<li><strong>ARP (Address Resolution Protocol)</strong> : IP주소 ↔ MAC주소 매핑</li>
<li><strong>TCP (Transmission Control Protocol)</strong> : 신뢰성 있는 통신</li>
<li><strong>UDP (User Datagram Protocol)</strong> : 빠른 통신, 비신뢰성</li>
<li><strong>HTTPS</strong> : 암호화된 HTTP 통신</li>
</ul>
<hr>
<h3 id="🧱-네트워크-계층-모델">🧱 네트워크 계층 모델</h3>
<h3 id="📚-osi-7계층">📚 OSI 7계층</h3>
<ol>
<li><strong>물리 계층</strong> - 전기적, 기계적 신호 전달</li>
<li><strong>데이터 링크 계층</strong> - 물리적 주소(MAC) 기반의 데이터 전송</li>
<li><strong>네트워크 계층</strong> - 논리적 주소(IP) 기반의 라우팅</li>
<li><strong>전송 계층</strong> - 데이터 흐름 제어, 오류 검출 (TCP/UDP)</li>
<li><strong>세션 계층</strong> - 통신 세션 제어</li>
<li><strong>표현 계층</strong> - 데이터 표현 방식 (암호화, 압축 등)</li>
<li><strong>응용 계층</strong> - 사용자 응용 프로그램 (웹, 이메일 등)</li>
</ol>
<h3 id="🧰-tcpip-4계층">🧰 TCP/IP 4계층</h3>
<ol>
<li><strong>네트워크 인터페이스 계층</strong> - OSI 1~2계층</li>
<li><strong>인터넷 계층</strong> - OSI 3계층</li>
<li><strong>전송 계층</strong> - OSI 4계층</li>
<li><strong>응용 계층</strong> - OSI 5~7계층</li>
</ol>
<blockquote>
<p>실무에서는 TCP/IP 모델을 더 많이 사용한다.</p>
</blockquote>
<hr>
<h3 id="📦-캡슐화와-역캡슐화">📦 캡슐화와 역캡슐화</h3>
<p><strong>캡슐화(Encapsulation)</strong></p>
<p>→ 데이터를 하위 계층으로 보낼 때, 각 계층의 헤더를 추가하며 이동</p>
<p><strong>역캡슐화(Decapsulation)</strong></p>
<p>→ 데이터를 수신할 때, 계층별로 헤더를 제거하며 상위 계층으로 전달</p>
<hr>
<h3 id="🧭-ip-주소의-구조와-전달">🧭 IP 주소의 구조와 전달</h3>
<h3 id="📌-ip-주소의-목적">📌 IP 주소의 목적</h3>
<ul>
<li><strong>호스트 식별</strong> 및 <strong>데이터 라우팅</strong></li>
<li><strong>단편화(Fragmentation)</strong> 를 통해 큰 데이터를 나눠 전송</li>
</ul>
<h3 id="📬-신뢰성-없는-통신">📬 신뢰성 없는 통신</h3>
<ul>
<li>IP는 <strong>신뢰성 없는</strong>, <strong>비연결형 프로토콜</strong></li>
<li><strong>최선형 전송(Best-Effort Delivery)</strong> 이며, 보장하지는 않음</li>
</ul>
<hr>
<h3 id="🧩-ip-주소의-체계">🧩 IP 주소의 체계</h3>
<h3 id="📍-클래스풀-주소-체계">📍 클래스풀 주소 체계</h3>
<ul>
<li>A클래스, B클래스, C클래스로 구분</li>
<li>각 클래스마다 네트워크와 호스트의 비트 수가 다름</li>
</ul>
<h3 id="🎭-클래스리스-주소와-서브넷-마스크">🎭 클래스리스 주소와 서브넷 마스크</h3>
<ul>
<li><strong>CIDR (Classless Inter-Domain Routing)</strong> 표기 사용</li>
<li><strong>서브넷 마스크</strong>를 통해 네트워크 영역과 호스트 영역 구분</li>
<li><strong>서브네팅</strong> : 큰 네트워크를 나누어 효율적으로 관리</li>
</ul>
<hr>
<h3 id="🌎-공인-ip-vs-사설-ip">🌎 공인 IP vs 사설 IP</h3>
<ul>
<li><strong>공인 IP</strong> : 인터넷에서 고유하게 식별 가능한 주소</li>
<li><strong>사설 IP</strong> : 내부 네트워크에서만 사용</li>
</ul>
<h3 id="📥-ip-주소-확인-방법">📥 IP 주소 확인 방법</h3>
<ul>
<li>Windows : <code>ipconfig /all</code></li>
<li>macOS/Linux : <code>ifconfig</code></li>
</ul>
<hr>
<h3 id="🔄-ip-주소의-할당">🔄 IP 주소의 할당</h3>
<ul>
<li><strong>DHCP (Dynamic Host Configuration Protocol)</strong> : 자동 할당</li>
<li><strong>정적 할당</strong> : 수동으로 지정</li>
</ul>
<h3 id="📡-기타-설정">📡 기타 설정</h3>
<ul>
<li><strong>게이트웨이</strong> : 외부 네트워크로 나가는 출입구</li>
<li><strong>DNS</strong> : 도메인을 IP로 변환</li>
</ul>
<hr>
<h3 id="🛠️-전송-보완--icmp">🛠️ 전송 보완 : ICMP</h3>
<ul>
<li><strong>ICMP (Internet Control Message Protocol)</strong><ol>
<li>오류 메시지 보고</li>
<li>네트워크 상태 정보 제공</li>
</ol>
</li>
</ul>
<hr>
<h3 id="🔗-mac-주소와의-대응--arp">🔗 MAC 주소와의 대응 : ARP</h3>
<ul>
<li><strong>ARP (Address Resolution Protocol)</strong> 은 IP 주소에 대응되는 <strong>MAC 주소를 조회</strong></li>
<li>네트워크 통신에서 두 호스트가 직접 통신하기 위해 반드시 필요한 과정</li>
</ul>
<hr>
<p>Ref. 📗《이것이 취업을 위한 컴퓨터 과학이다 with CS 기술 면접》, 강민철</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CS 스터디] 네트워크 정리3]]></title>
            <link>https://velog.io/@ori_gui/CS-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%A0%95%EB%A6%AC3</link>
            <guid>https://velog.io/@ori_gui/CS-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%A0%95%EB%A6%AC3</guid>
            <pubDate>Wed, 26 Mar 2025 05:59:47 GMT</pubDate>
            <description><![CDATA[<h1 id="📡응용계층---http의-응용">📡응용계층 - HTTP의 응용</h1>
<hr>
<h2 id="🍪-쿠키-stateless-http">🍪 쿠키: Stateless HTTP</h2>
<blockquote>
<p>HTTP는 기본적으로 <strong>Stateless</strong>하다. 즉, 이전 요청과 다음 요청 사이에 연결 정보나 상태를 유지하지 않는다. 이를 보완하기 위해 사용하는 것이 바로 <strong>쿠키(cookie)</strong>이다.</p>
</blockquote>
<ul>
<li>쿠키는 <strong>&lt;이름, 값&gt;</strong> 쌍으로 이루어진 데이터이며, 서버가 클라이언트에 전송하여 <strong>브라우저에 저장</strong>한다.</li>
<li>이후 같은 서버에 요청할 때 브라우저는 해당 쿠키를 <strong>요청 헤더에 포함</strong>시켜 전송한다.</li>
</ul>
<h3 id="쿠키-관련-주요-헤더">쿠키 관련 주요 헤더</h3>
<table>
<thead>
<tr>
<th>헤더</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>Set-Cookie</code></td>
<td>서버가 쿠키를 클라이언트에 설정할 때 사용</td>
</tr>
<tr>
<td><code>Expires</code></td>
<td>쿠키의 유효 만료 시간 지정 (절대 시간)</td>
</tr>
<tr>
<td><code>Max-Age</code></td>
<td>쿠키의 유효 기간 (초 단위)</td>
</tr>
<tr>
<td><code>Secure</code></td>
<td>HTTPS 요청에만 쿠키 전송</td>
</tr>
<tr>
<td><code>HttpOnly</code></td>
<td>JavaScript에서 접근 불가 (XSS 방지 목적)</td>
</tr>
</tbody></table>
<h3 id="웹-스토리지-web-storage">웹 스토리지 (Web Storage)</h3>
<ul>
<li><strong>로컬 스토리지 (Local Storage)</strong>: 브라우저를 꺼도 유지되는 <strong>영구 저장소</strong></li>
<li><strong>세션 스토리지 (Session Storage)</strong>: 브라우저가 열려 있는 동안만 유지되는 임시 저장소</li>
</ul>
<hr>
<h2 id="📦-http-캐시">📦 HTTP 캐시</h2>
<blockquote>
<p>웹 페이지에 포함된 이미지, CSS, JS 파일은 <strong>자주 변하지 않으며 용량도 크다</strong>. 매 요청마다 다운로드하면 속도와 리소스 낭비가 크다. 이를 해결하는 기술이 바로 <strong>HTTP 캐시</strong>이다.</p>
</blockquote>
<h3 id="캐시의-핵심-개념">캐시의 핵심 개념</h3>
<ul>
<li><strong>Expires</strong>: 이 날짜 이전까지는 <strong>캐시된 자원을 그대로 사용</strong>해도 됨</li>
<li><strong>신선도(freshness)</strong>: 캐시된 데이터가 <strong>서버의 최신 데이터와 유사한 정도</strong></li>
<li><strong>유효 기간이 만료된 경우</strong>, 서버에 원본 자원이 변경되었는지 질의 후 판단</li>
</ul>
<h3 id="조건부-요청">조건부 요청</h3>
<h4 id="🔖-if-modified-since-헤더">🔖 <code>If-Modified-Since</code> 헤더</h4>
<ul>
<li>클라이언트가 가진 데이터의 <strong>최종 수정 시간</strong>을 서버에 전송</li>
<li>서버는 이 시간 이후로 자원이 <strong>수정되었는지 확인</strong></li>
</ul>
<table>
<thead>
<tr>
<th>서버 상황</th>
<th>응답 코드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>변경됨</td>
<td>200 OK</td>
<td>최신 자원 반환</td>
</tr>
<tr>
<td>변경 안 됨</td>
<td>304 Not Modified</td>
<td>캐시된 자원 사용 가능</td>
</tr>
<tr>
<td>삭제됨</td>
<td>404 Not Found</td>
<td>자원이 존재하지 않음</td>
</tr>
</tbody></table>
<h4 id="🔖-etag과-if-none-match-헤더">🔖 <code>Etag</code>과 <code>If-None-Match</code> 헤더</h4>
<ul>
<li><code>Etag</code>: 자원의 버전을 식별하는 <strong>엔티티 태그</strong></li>
<li>클라이언트가 <code>If-None-Match</code>로 현재 <code>Etag</code>를 전송하여, 서버에 <strong>변경 여부를 확인</strong></li>
</ul>
<blockquote>
<p>결과는 위와 동일하게 200 / 304 / 404 코드로 판단된다.</p>
</blockquote>
<p>📌 <strong>결론</strong>: 캐시는 단순 저장이 아닌, <strong>서버의 상태를 고려하여 효율적으로 자원을 관리하는 시스템</strong>이다.</p>
<hr>
<h2 id="🌍-콘텐츠-협상content-negotiation">🌍 콘텐츠 협상(Content Negotiation)</h2>
<blockquote>
<p>같은 자원을 <strong>여러 표현 방식으로 제공</strong>할 수 있다. 예를 들어, 텍스트 파일은 XML, JSON, HTML 등 다양한 포맷으로 표현할 수 있다. 이 중 <strong>클라이언트에게 적합한 형식</strong>을 제공하는 것이 콘텐츠 협상이다.</p>
</blockquote>
<h3 id="📑-주요-헤더">📑 주요 헤더</h3>
<table>
<thead>
<tr>
<th>헤더</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>Accept</code></td>
<td>선호하는 미디어 타입 (ex. <code>application/json</code>)</td>
</tr>
<tr>
<td><code>Accept-Language</code></td>
<td>선호하는 언어 (ex. <code>ko-KR</code>)</td>
</tr>
<tr>
<td><code>Accept-Encoding</code></td>
<td>선호하는 인코딩</td>
</tr>
<tr>
<td><code>q</code> 값</td>
<td>품질 인자. 0~1 사이의 값으로 <strong>우선순위 표시</strong>. 높을수록 선호함</td>
</tr>
</tbody></table>
<p>예시:</p>
<pre><code>GET /api/data HTTP/1.1
Host: example.com
Accept: application/json;q=1.0, text/html;q=0.8, application/xml;q=0.6
Accept-Language: ko-KR;q=1.0, en-US;q=0.8, ja-JP;q=0.5
Accept-Encoding: gzip;q=1.0, deflate;q=0.8, br;q=0.5
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
</code></pre><p>👉 클라이언트는 JSON, 한국어, gzip을 더 선호한다는 의미</p>
<hr>
<h2 id="🔒-https와-tlsssl">🔒 HTTPS와 TLS/SSL</h2>
<blockquote>
<p>HTTP는 내용을 암호화하지 않는다. 민감한 정보가 <strong>그대로 노출될 수 있다.</strong> 이를 막기 위해 사용하는 것이 <strong>HTTPS</strong>이며, <strong>SSL/TLS</strong> 프로토콜을 기반으로 한다.</p>
</blockquote>
<h3 id="https-구성-요소">HTTPS 구성 요소</h3>
<ul>
<li>HTTPS = HTTP + TLS</li>
<li>통신 과정 요약:<ol>
<li>TCP 3-way 핸드셰이크</li>
<li>TLS 핸드셰이크 (암호화 통신 준비)</li>
<li>실제 HTTP 메시지 송수신</li>
</ol>
</li>
</ul>
<hr>
<h3 id="🔐-암호화-알고리즘의-동작-방식">🔐 암호화 알고리즘의 동작 방식</h3>
<blockquote>
<p>TLS 통신에서 사용되는 <strong>대칭키 또는 비대칭키 암호화 방식</strong>은 아래와 같은 흐름으로 동작한다.</p>
</blockquote>
<h4 id="🔐-암호화">🔐 암호화</h4>
<ul>
<li>입력: 평문 + 키</li>
<li>출력: 암호문</li>
</ul>
<h4 id="🔓-복호화">🔓 복호화</h4>
<ul>
<li>입력: 암호문 + 키</li>
<li>출력: 평문</li>
</ul>
<p>👉 키가 같으면 대칭키 방식, 다르면 비대칭키 방식이다.</p>
<hr>
<h3 id="🤝-tls-13-핸드셰이크-흐름">🤝 TLS 1.3 핸드셰이크 흐름</h3>
<blockquote>
<p>TLS는 클라이언트와 서버 간의 <strong>비밀키 공유, 인증서 검증, 암호화 방식 협상</strong>을 포함한다.</p>
</blockquote>
<h3 id="📶-핸드셰이크-순서">📶 핸드셰이크 순서</h3>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/c1212102-9cd4-4a3e-a7f3-0df33d90e2a8/image.png" alt=""></p>
<ol>
<li>클라이언트 → 서버: <code>ClientHello</code> (key_share 포함)</li>
<li>서버 → 클라이언트: <code>ServerHello</code> (key_share 포함), <code>EncryptedExtensions</code>, <code>Certificate</code>, <code>CertificateVerify</code>, <code>Finished</code></li>
<li>클라이언트 → 서버: <code>Finished</code></li>
</ol>
<p>✔ 이 후부터는 <strong>서로 동일한 키로 암호화된 데이터 송수신</strong> 가능</p>
<hr>
<h3 id="🛡-인증서와-보안">🛡 인증서와 보안</h3>
<ul>
<li>서버는 <strong>공인된 인증기관(CA)</strong>에서 발급받은 인증서를 전송한다</li>
<li>클라이언트는 이를 통해 <strong>서버의 신뢰성 확인</strong></li>
</ul>
<p>크롬에서 확인:</p>
<blockquote>
<p>🔒 사이트 정보보기 -&gt; 이 연결은 안전합니다 → 인증서 유효함 → 인증서 뷰어</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/2a4435a4-9a18-43fd-a8c8-10d831856ef7/image.png" alt=""></p>
<hr>
<h1 id="🏗-프록시와-안정적인-트래픽">🏗 프록시와 안정적인 트래픽</h1>
<blockquote>
<p>웹 시스템은 단순히 서버 하나로 구성되지 않는다. 수많은 사용자의 요청을 안정적으로 처리하고, 갑작스러운 부하에도 견딜 수 있어야 한다. 이를 위해 사용하는 대표적인 구조가 <strong>프록시, 로드 밸런싱, 스케일링, 고가용성 설계</strong>이다.</p>
</blockquote>
<hr>
<h2 id="🧭-오리진-서버와-프록시-서버">🧭 오리진 서버와 프록시 서버</h2>
<h3 id="🖥-오리진-서버origin-server">🖥 오리진 서버(Origin Server)</h3>
<blockquote>
<p>오리진 서버는 자원의 <strong>최초 생성지</strong>이며, 클라이언트 요청에 대한 <strong>최종 응답을 보장할 수 있는 서버</strong></p>
</blockquote>
<h3 id="🔁-포워드-프록시-forward-proxy">🔁 포워드 프록시 (Forward Proxy)</h3>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/d40bbd91-0f97-4daa-abdf-4a0857917c22/image.png" alt=""></p>
<blockquote>
<p> 클라이언트와 서버 사이에 위치한 중간 대리 서버로, 클라이언트 요청을 받아 대신 서버에 요청을 전달하고 응답을 받아 다시 클라이언트에 전달</p>
</blockquote>
<h4 id="✅-주요-기능">✅ 주요 기능</h4>
<ul>
<li><strong>캐시 저장</strong>으로 응답 속도 향상</li>
<li><strong>클라이언트의 IP 차단 우회</strong></li>
<li><strong>접근 제어</strong>, <strong>익명성 보장</strong></li>
</ul>
<p><em>📌 클라이언트 입장에서 직접 오리진 서버에 접근하지 않고, 대신 요청을 수행하는 <strong>심부름꾼</strong> 같은 존재이다.</em></p>
<h3 id="🛡-리버스-프록시-reverse-proxy-게이트웨이">🛡 리버스 프록시 (Reverse Proxy, 게이트웨이)</h3>
<blockquote>
<ul>
<li>클라이언트가 오리진 서버에 직접 요청하는 대신, <strong>리버스 프록시 서버가 먼저 요청을 받아 오리진 서버로 전달</strong></li>
</ul>
</blockquote>
<h4 id="✅-주요-기능-1">✅ 주요 기능</h4>
<ul>
<li>클라이언트는 오리진 서버를 <strong>직접 알 필요 없음</strong></li>
<li>오리진 서버를 <strong>숨길 수 있어 보안상 유리</strong></li>
<li>부하 분산, SSL 종료, 캐시 제공 등에 활용</li>
</ul>
<p><em>📌 리버스 프록시는 서버 진영의 <strong>문지기 또는 경비</strong> 역할을 한다.</em></p>
<hr>
<h2 id="🔄-고가용성high-availability">🔄 고가용성(High Availability)</h2>
<blockquote>
<p>고가용성은 시스템이 <strong>장시간 중단되지 않고 동작할 수 있는 능력</strong>이다.</p>
</blockquote>
<h3 id="📊-가용성">📊 가용성</h3>
<blockquote>
<p>가용성 = 업타임 / (업타임 + 다운타임)</p>
</blockquote>
<ul>
<li><strong>업타임(uptime)</strong>: 정상 작동 시간</li>
<li><strong>다운타임(downtime)</strong>: 장애로 인해 시스템이 멈춘 시간</li>
</ul>
<h3 id="고가용성-구현-요소">고가용성 구현 요소</h3>
<ul>
<li><strong>결함 감내(Fault Tolerance)</strong>: 일부 컴포넌트가 장애를 일으켜도 전체 시스템은 작동 가능해야 한다.</li>
<li><strong>다중화(Redundancy)</strong>: 핵심 구성 요소를 복수로 구성</li>
</ul>
<h3 id="대표적인-기술">대표적인 기술</h3>
<ul>
<li><strong>하트비트(Heartbeat)</strong>: 서버 간 생존 여부를 신호로 주고받는 시스템</li>
<li><strong>헬스 체크(Health Check)</strong>: 서버 상태를 주기적으로 검사
<img src="https://velog.velcdn.com/images/ori_gui/post/41da8e07-bcc6-44cf-9492-1d7e52e358bd/image.png" alt=""></li>
</ul>
<hr>
<h3 id="⚖️-로드-밸런싱-load-balancing">⚖️ 로드 밸런싱 (Load Balancing)</h3>
<blockquote>
<p>다수의 서버가 동일한 서비스를 제공할 때, <strong>클라이언트 요청을 고르게 분산</strong>시켜주는 장치가 <strong>로드 밸런서(load balancer)</strong>이다.</p>
</blockquote>
<h3 id="주요-알고리즘">주요 알고리즘</h3>
<table>
<thead>
<tr>
<th>알고리즘</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>라운드 로빈</td>
<td>순서대로 하나씩 서버에 분배</td>
</tr>
<tr>
<td>최소 연결</td>
<td>현재 연결 수가 가장 적은 서버에 전달</td>
</tr>
<tr>
<td>가중치 기반</td>
<td>성능 좋은 서버에 더 많은 요청을 분산</td>
</tr>
</tbody></table>
<p>📌 로드 밸런싱은 <strong>서버 과부하 방지, 서비스 안정성 향상</strong>을 목적으로 한다.</p>
<hr>
<h3 id="📈-스케일링-scaling">📈 스케일링 (Scaling)</h3>
<blockquote>
<p>트래픽이 급증할 경우, 서버 용량을 확장하여 시스템을 유지해야 한다. 이를 <strong>스케일링</strong>이라 한다.</p>
</blockquote>
<h3 id="스케일-업scale-up">스케일 업(Scale Up)</h3>
<ul>
<li>하나의 서버를 <strong>더 좋은 사양으로 교체</strong></li>
<li>수직 확장(vertical scaling)</li>
</ul>
<p>✅ 장점: 구성 간단</p>
<p>❌ 단점: 비용 증가, 한계 존재</p>
<h3 id="스케일-아웃scale-out">스케일 아웃(Scale Out)</h3>
<ul>
<li><strong>서버 수를 늘려서</strong> 처리 능력 향상</li>
<li>수평 확장(horizontal scaling)</li>
</ul>
<p>✅ 장점: 유연한 확장</p>
<p>❌ 단점: 시스템 설계 복잡</p>
<h3 id="🔁-오토-스케일링-auto-scaling">🔁 오토 스케일링 (Auto Scaling)</h3>
<ul>
<li>실시간 트래픽 변화에 따라 <strong>자동으로 서버 수를 늘리거나 줄이는 기능</strong></li>
<li>예: 티켓팅 시스템, 수강 신청처럼 <strong>순간적으로 트래픽이 몰리는 상황</strong></li>
</ul>
<hr>
<h2 id="🧰-nginx로-알아보는-로드-밸런싱-실전">🧰 Nginx로 알아보는 로드 밸런싱 실전</h2>
<h3 id="📂-nginx-설정-파일-구조">📂 Nginx 설정 파일 구조</h3>
<pre><code class="language-bash">${nginx}/nginx.conf          # 메인 설정 파일
${nginx}/conf.d/*.conf       # 웹 서버 관련 하위 설정
${nginx}/log/nginx/          # 접근/오류 로그 저장 경로
</code></pre>
<h3 id="🔧-주요-설정-예시">🔧 주요 설정 예시</h3>
<pre><code>http {
    upstream backend {
        server backend1.example.com;
        server backend2.example.com;
    }

    server {
        listen 80;
        server_name localhost;

        location / {
            proxy_pass http://backend;
        }
    }
}
</code></pre><ul>
<li><code>listen 80</code>: 80 포트에서 요청 대기</li>
<li><code>location /</code>: 루트 경로 요청 시 처리</li>
<li><code>proxy_pass http://backend;</code>: 요청을 backend 서버 그룹으로 전달</li>
</ul>
<p>📌 Nginx는 단순 웹 서버를 넘어 <strong>리버스 프록시, 로드 밸런서 역할까지 수행할 수 있는 범용 HTTP 서버</strong>이다.</p>
<hr>
<h3 id="🔁-업스트림upstream--다운스트림downstream">🔁 업스트림(Upstream) / 다운스트림(Downstream)</h3>
<p>이 두 개는 <strong>데이터 흐름의 방향</strong>을 기준으로 한다.</p>
<h3 id="📤-업스트림-upstream">📤 업스트림 (Upstream)</h3>
<ul>
<li><strong>요청을 보내는 쪽</strong>, 또는 <strong>상위 시스템</strong></li>
<li>예: 클라이언트 → 서버일 때, 클라이언트가 업스트림</li>
<li>예: 서비스 A → 서비스 B → DB 구조에서 A는 B의 업스트림</li>
<li>🔄 위로 흐른다 생각하면 이해 쉬움 (하류 → 상류)</li>
</ul>
<h3 id="📥-다운스트림-downstream">📥 다운스트림 (Downstream)</h3>
<ul>
<li><strong>요청을 받는 쪽</strong>, 또는 <strong>하위 시스템</strong></li>
<li>예: 클라이언트 → 서버일 때, 서버가 다운스트림</li>
<li>예: A → B → DB 구조에서 DB는 A의 다운스트림</li>
</ul>
<blockquote>
<p>✅ 정리: &quot;업스트림은 데이터를 보내는 방향&quot;, 다운스트림은 그걸 받는 쪽</p>
</blockquote>
<hr>
<h3 id="🔃-인바운드inbound--아웃바운드outbound">🔃 인바운드(Inbound) / 아웃바운드(Outbound)</h3>
<p>이 둘은 <strong>요청/응답이 어느 시스템 안으로 들어오느냐, 나가느냐</strong>를 기준으로 한다.</p>
<h3 id="🔽-인바운드-inbound">🔽 인바운드 (Inbound)</h3>
<ul>
<li><strong>외부 → 내부로 들어오는 트래픽</strong></li>
<li>예: 외부 클라이언트가 내 API 서버로 요청 보냄 → 인바운드 요청</li>
<li>예: 방화벽 입장에서 외부에서 들어오는 요청</li>
</ul>
<h3 id="🔼-아웃바운드-outbound">🔼 아웃바운드 (Outbound)</h3>
<ul>
<li><strong>내부 → 외부로 나가는 트래픽</strong></li>
<li>예: 내 서버가 외부 API 호출 → 아웃바운드 요청</li>
<li>예: 방화벽 입장에서 내부에서 외부로 나가는 요청</li>
</ul>
<blockquote>
<p>✅ 정리: &quot;인바운드는 들어오는 것&quot;, 아웃바운드는 나가는 것&quot;</p>
</blockquote>
<hr>
<p>Ref. 📗《이것이 취업을 위한 컴퓨터 과학이다 with CS 기술 면접》, 강민철</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CS 스터디] 네트워크 정리2]]></title>
            <link>https://velog.io/@ori_gui/CS-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%A0%95%EB%A6%AC2</link>
            <guid>https://velog.io/@ori_gui/CS-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%A0%95%EB%A6%AC2</guid>
            <pubDate>Thu, 20 Mar 2025 07:56:41 GMT</pubDate>
            <description><![CDATA[<h1 id="전송-계층---tcp와-udp">전송 계층 - TCP와 UDP</h1>
<blockquote>
<p>컴퓨터 네트워크에서 <strong>전송 계층(Transport Layer)</strong> 은 데이터가 송수신될 때 <strong>최종 목적지인 프로세스(프로그램)</strong> 에 정확히 전달되도록 관리하는 계층이다.</p>
</blockquote>
<h2 id="🔹-tcp와-udp의-목적과-특징">🔹 TCP와 UDP의 목적과 특징</h2>
<p>패킷이 네트워크를 통해 전송될 때, <strong>IP 주소와 MAC 주소</strong> 를 이용하여 <strong>호스트(컴퓨터 또는 네트워크 장치)</strong> 를 식별할 수 있다. 하지만 <strong>호스트 내에서 실행 중인 특정 프로세스(응용 프로그램)</strong> 에 도달하려면 <strong>포트 번호(Port Number)</strong> 가 필요하다.</p>
<table>
<thead>
<tr>
<th>프로토콜</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td><strong>TCP</strong></td>
<td>신뢰성 높은 데이터 전송</td>
</tr>
<tr>
<td><strong>UDP</strong></td>
<td>빠른 데이터 전송 (비신뢰성)</td>
</tr>
</tbody></table>
<hr>
<h2 id="🏛-tcp-transmission-control-protocol">🏛 TCP (Transmission Control Protocol)</h2>
<blockquote>
<p>TCP는 <strong>신뢰할 수 있는 데이터 전송</strong> 을 보장하는 <strong>연결형 프로토콜</strong> 이다. 즉, 데이터가 손실되지 않고, 순서대로 도착하도록 하는 것이 특징이다.</p>
</blockquote>
<h3 id="✅-tcp의-주요-특징">✅ TCP의 주요 특징</h3>
<ol>
<li><strong>신뢰성 있는 데이터 전송</strong><ul>
<li>패킷 손실이 발생하면 <strong>재전송</strong> 을 수행하며, 데이터가 정확히 전달되도록 보장한다.</li>
<li>흐름 제어, 오류 제어, 혼잡 제어 기능을 포함한다.</li>
</ul>
</li>
<li><strong>연결형 통신(Connection-Oriented Communication)</strong><ul>
<li>데이터 전송 전에 반드시 <strong>3-way Handshake</strong> 를 통해 연결을 설정하고, 데이터 전송이 끝나면 <strong>4-way Handshake</strong> 로 연결을 종료한다.</li>
</ul>
</li>
<li><strong>순서 보장</strong><ul>
<li>각 패킷에는 <strong>순서 번호(Sequence Number)</strong> 가 포함되어 있어, 받은 패킷을 순서대로 정렬하여 처리할 수 있다.</li>
</ul>
</li>
<li><strong>흐름 제어(Flow Control)</strong><ul>
<li>송신자가 수신자의 처리 속도를 고려하여 데이터를 전송하는 방식이다.</li>
</ul>
</li>
<li><strong>혼잡 제어(Congestion Control)</strong><ul>
<li>네트워크 트래픽이 과부하 상태가 되는 것을 방지하기 위해 패킷 전송량을 조절한다.</li>
</ul>
</li>
</ol>
<h3 id="📜-tcp-헤더-구조">📜 TCP 헤더 구조</h3>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/3f898773-1848-4f23-8679-efac9e6a7571/image.png" alt=""></p>
<h3 id="🔹tcp의-제어-비트flags">🔹TCP의 제어 비트(Flags)</h3>
<ul>
<li><strong>ACK</strong>: 응답 확인(Acknowledgment)</li>
<li><strong>SYN</strong>: 연결 요청(Synchronize)</li>
<li><strong>FIN</strong>: 연결 종료(Finish)</li>
<li><strong>RST</strong>: 연결 초기화(Reset)</li>
<li><strong>PSH</strong>: 수신 측에서 즉시 전달(Push)</li>
<li><strong>URG</strong>: 긴급 데이터(Urgent)</li>
</ul>
<hr>
<h2 id="🚀-udp-user-datagram-protocol">🚀 UDP (User Datagram Protocol)</h2>
<blockquote>
<p>UDP는 <strong>빠른 데이터 전송을 위해 신뢰성을 보장하지 않는 비연결형 프로토콜</strong> 이다. TCP와 달리 <strong>데이터 손실, 순서 보장, 흐름 제어가 없다</strong>.</p>
</blockquote>
<h3 id="✅-udp의-주요-특징">✅ UDP의 주요 특징</h3>
<ol>
<li><strong>비연결형 통신(Connectionless Communication)</strong><ul>
<li>데이터를 보내기 전에 연결을 설정하지 않으며, 수신 확인도 하지 않는다.</li>
</ul>
</li>
<li><strong>빠른 데이터 전송</strong><ul>
<li>별도의 오류 제어나 흐름 제어 없이 단순히 데이터를 보내므로 속도가 빠르다.</li>
</ul>
</li>
<li><strong>데이터 손실 가능</strong><ul>
<li>데이터가 손실될 가능성이 있으며, 손실된 데이터의 재전송을 보장하지 않는다.</li>
</ul>
</li>
<li><strong>헤더가 단순함</strong><ul>
<li>TCP보다 간단한 헤더 구조를 가지고 있어 오버헤드가 적다.</li>
</ul>
</li>
</ol>
<h3 id="📜-udp-헤더-구조">📜 UDP 헤더 구조</h3>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/e4d4a6fc-d97e-4345-bc48-62ac8773ba13/image.png" alt=""></p>
<h2 id="🔥-tcp와-udp-비교">🔥 TCP와 UDP 비교</h2>
<table>
<thead>
<tr>
<th>특성</th>
<th>TCP</th>
<th>UDP</th>
</tr>
</thead>
<tbody><tr>
<td><strong>연결 방식</strong></td>
<td>연결형(Connection-Oriented)</td>
<td>비연결형(Connectionless)</td>
</tr>
<tr>
<td><strong>신뢰성</strong></td>
<td>신뢰성 보장 (데이터 손실 없음)</td>
<td>신뢰성 없음 (데이터 손실 가능)</td>
</tr>
<tr>
<td><strong>데이터 전송 속도</strong></td>
<td>느림 (오버헤드 존재)</td>
<td>빠름 (오버헤드 없음)</td>
</tr>
<tr>
<td><strong>데이터 순서 보장</strong></td>
<td>O</td>
<td>X</td>
</tr>
<tr>
<td><strong>흐름 제어, 혼잡 제어</strong></td>
<td>O</td>
<td>X</td>
</tr>
<tr>
<td><strong>사용 예시</strong></td>
<td>HTTP, HTTPS, FTP, SMTP</td>
<td>DNS, VoIP, 온라인 게임, 스트리밍</td>
</tr>
</tbody></table>
<hr>
<h2 id="🎯-포트-번호-정리">🎯 포트 번호 정리</h2>
<h3 id="🔹-포트-종류">🔹 <strong>포트 종류</strong></h3>
<table>
<thead>
<tr>
<th>포트 종류</th>
<th>범위</th>
</tr>
</thead>
<tbody><tr>
<td><strong>잘 알려진 포트 (Well-Known Port)</strong></td>
<td>0 ~ 1023</td>
</tr>
<tr>
<td><strong>등록된 포트 (Registered Port)</strong></td>
<td>1024 ~ 49151</td>
</tr>
<tr>
<td><strong>동적 포트 (Dynamic Port)</strong></td>
<td>49152 ~ 65535</td>
</tr>
</tbody></table>
<h3 id="🔹-자주-사용하는-포트-번호">🔹 <strong>자주 사용하는 포트 번호</strong></h3>
<table>
<thead>
<tr>
<th>포트 번호</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>20, 21</strong></td>
<td>FTP</td>
</tr>
<tr>
<td><strong>22</strong></td>
<td>SSH</td>
</tr>
<tr>
<td><strong>23</strong></td>
<td>TELNET</td>
</tr>
<tr>
<td><strong>53</strong></td>
<td>DNS</td>
</tr>
<tr>
<td><strong>67, 68</strong></td>
<td>DHCP</td>
</tr>
<tr>
<td><strong>80</strong></td>
<td>HTTP</td>
</tr>
<tr>
<td><strong>443</strong></td>
<td>HTTPS</td>
</tr>
<tr>
<td><strong>1194</strong></td>
<td>OpenVPN</td>
</tr>
<tr>
<td><strong>1433</strong></td>
<td>MS SQL Server</td>
</tr>
<tr>
<td><strong>3306</strong></td>
<td>MySQL</td>
</tr>
<tr>
<td><strong>6379</strong></td>
<td>Redis</td>
</tr>
<tr>
<td><strong>8080</strong></td>
<td>HTTP 대체 포트</td>
</tr>
</tbody></table>
<hr>
<h2 id="🔗-tcp의-연결-수립-과정-3-way-handshake">🔗 <strong>TCP의 연결 수립 과정 (3-Way Handshake)</strong></h2>
<blockquote>
<p>TCP 통신을 시작하기 위해 <strong>3-Way Handshake</strong> 방식이 사용된다. 클라이언트와 서버가 세그먼트를 주고받으며 연결을 설정한다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/0f2f5416-b13b-4050-870d-b52d1cfb3a2a/image.png" alt=""></p>
<p>1️⃣ <strong>클라이언트 → 서버 (SYN 전송)</strong></p>
<p>클라이언트는 연결을 요청하는 <strong>SYN</strong> 세그먼트를 서버에게 보낸다.</p>
<p>2️⃣ <strong>서버 → 클라이언트 (SYN + ACK 전송)</strong></p>
<p>서버는 연결 요청을 수락하며 <strong>SYN + ACK</strong> 세그먼트를 클라이언트에게 보낸다.</p>
<p>3️⃣ <strong>클라이언트 → 서버 (ACK 전송)</strong></p>
<p>클라이언트는 서버에게 연결을 확인하는 <strong>ACK</strong> 세그먼트를 보내면서 연결이 확립된다.</p>
<p>📌 <strong>이 과정이 완료되면 클라이언트와 서버 간에 데이터 전송이 가능해진다.</strong></p>
<hr>
<h2 id="⚙️-tcp의-오류·흐름·혼잡-제어">⚙️ <strong>TCP의 오류·흐름·혼잡 제어</strong></h2>
<h3 id="🛠️-tcp-오류-제어-error-control">🛠️ <strong>TCP 오류 제어 (Error Control)</strong></h3>
<blockquote>
<p>TCP는 신뢰성 있는 데이터 전송을 보장하기 위해 <strong>오류 제어</strong> 기능을 수행한다. 오류 제어 방식에는 <strong>재전송</strong>과 <strong>중복 확인 응답(ACK)</strong>이 있다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/42892dea-0b1f-4171-878b-bef197f4664c/image.png" alt=""></p>
<h3 id="☑️-1-재전송을-통한-오류-제어">☑️ <strong>1. 재전송을 통한 오류 제어</strong></h3>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/815f35f7-2b62-4bc6-b9f9-19181540865e/image.png" alt=""></p>
<ul>
<li>송신 측에서 데이터를 보낸 후 일정 시간 내에 <strong>ACK(확인 응답)</strong>을 받지 못하면 해당 데이터를 <strong>재전송</strong>한다.</li>
<li>데이터 손실이 발생하면 송신 측은 <strong>타임아웃이 발생할 때까지 ACK을 기다린 후 다시 데이터를 전송</strong>한다.</li>
</ul>
<p>📌 <strong>타임아웃</strong>이란? 송신 측이 일정 시간 내에 ACK을 받지 못하면 데이터가 손실된 것으로 판단하고 재전송하는 메커니즘이다.</p>
<hr>
<blockquote>
<h3 id="🚀-파이프라이닝-pipelining-기법">🚀 <strong>파이프라이닝 (Pipelining) 기법</strong></h3>
<p> TCP는 데이터 전송 속도를 높이기 위해 <strong>파이프라이닝(Pipelining)</strong> 방식을 사용한다.
<img src="https://velog.velcdn.com/images/ori_gui/post/98a25975-58e9-4732-ae9b-e911972df82a/image.png" alt=""></p>
</blockquote>
<h4 id="🔄-파이프라이닝이란">🔄 <strong>파이프라이닝이란?</strong></h4>
<ul>
<li>송신 측이 <strong>ACK을 기다리지 않고 여러 개의 세그먼트를 연속으로 전송</strong>하는 방식이다.</li>
<li>확인 응답(ACK)은 <strong>누적적으로 응답</strong>하여 여러 개의 패킷을 한 번에 확인할 수 있다.
💡 <strong>장점:</strong> 네트워크의 활용도를 높이고 전송 속도를 향상시킨다.
⚠️ <strong>단점:</strong> 손실이 발생하면 해당 구간의 데이터 전체를 다시 전송해야 한다.</li>
</ul>
<hr>
<h3 id="☑️-2-흐름-제어-flow-control">☑️ <strong>2. 흐름 제어 (Flow Control)</strong></h3>
<ul>
<li>수신 호스트가 한 번에 처리할 수 있는 데이터의 양을 초과하지 않도록 조절하는 메커니즘이다.</li>
<li><strong>수신 윈도우(Receive Window)</strong> 크기를 설정하여 조절한다.</li>
</ul>
<p>💡 <strong>예제:</strong>
→ 수신 호스트가 <strong>3개의 패킷만 수용 가능</strong>한 경우, 송신 호스트는 이를 초과하지 않도록 <strong>송신 속도를 조절</strong>한다.</p>
<hr>
<h3 id="☑️-3-혼잡-제어-congestion-control">☑️ <strong>3. 혼잡 제어 (Congestion Control)</strong></h3>
<blockquote>
<p>네트워크에 과부하가 발생하지 않도록 데이터 전송 속도를 조절하는 기법이다.</p>
</blockquote>
<h3 id="🏗️-혼잡-제어-방식">🏗️ <strong>혼잡 제어 방식</strong></h3>
<p>✅ <strong>AIMD(Additive Increase Multiplicative Decrease)</strong></p>
<ul>
<li>패킷이 정상적으로 도착하면 <strong>혼잡 윈도우를 증가</strong></li>
<li>패킷 손실이 감지되면 <strong>혼잡 윈도우 크기를 줄이는 방식</strong></li>
</ul>
<p>📌 <strong>혼잡 윈도우 (Congestion Window)란? **
송신 호스트가 네트워크의 상태를 고려하여 스스로 전송 가능한 데이터 크기를 결정한다. 즉, **&#39;혼잡 없이 전송할 수 있을 정도의 양&#39;</strong></p>
<p>📌 <strong>혼잡 상태를 판단하는 기준</strong></p>
<ul>
<li><strong>중복된 ACK이 여러 번 수신될 경우</strong></li>
<li><strong>타임아웃 발생 시</strong></li>
</ul>
<hr>
<h2 id="🔚-tcp-연결-종료-과정-4-way-handshake">🔚 <strong>TCP 연결 종료 과정 (4-Way Handshake)</strong></h2>
<blockquote>
<p>TCP 연결을 종료하는 과정은 <strong>4-Way Handshake</strong>를 통해 이루어진다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/f6090a8a-61f2-43a2-a6c7-ce9a9021e173/image.png" alt=""></p>
<p>1️⃣ <strong>클라이언트 → 서버 (FIN 전송)</strong></p>
<ul>
<li>클라이언트가 서버에게 <strong>FIN 세그먼트</strong>를 보내 연결 종료를 요청한다.</li>
</ul>
<p>2️⃣ <strong>서버 → 클라이언트 (ACK 전송)</strong></p>
<ul>
<li>서버는 이를 확인하고 <strong>ACK 세그먼트</strong>를 클라이언트에게 보낸다.</li>
</ul>
<p>3️⃣ <strong>서버 → 클라이언트 (FIN 전송)</strong></p>
<ul>
<li>서버는 모든 처리가 끝난 후 클라이언트에게 <strong>FIN 세그먼트</strong>를 보내 연결 종료를 요청한다.</li>
</ul>
<p>4️⃣ <strong>클라이언트 → 서버 (ACK 전송)</strong></p>
<ul>
<li>클라이언트가 이를 확인하고 <strong>ACK 세그먼트</strong>를 서버에게 보내며 연결이 완전히 종료된다.</li>
</ul>
<p>📌 <strong>연결 종료 시 송신 측은 ‘액티브 클로즈’, 수신 측은 ‘패시브 클로즈’ 상태가 된다.</strong></p>
<hr>
<h2 id="🖥️-tcp의-상태-관리-tcp-state-management">🖥️ TCP의 상태 관리 (TCP State Management)</h2>
<blockquote>
<ul>
<li>TCP(Transmission Control Protocol)는 신뢰성 있는 데이터 전송을 제공하는 <strong>스테이트풀(상태 기반) 프로토콜</strong>이다.</li>
</ul>
</blockquote>
<ul>
<li>즉, TCP는 통신 과정에서 특정한 <strong>연결 상태</strong>를 유지하며, 각 상태에 따라 적절한 처리를 수행한다.</li>
<li>TCP 상태 관리<ul>
<li><strong>연결 수립(Connection Establishment)</strong>,</li>
<li><strong>데이터 전송(Data Transmission)</strong>, </li>
<li><strong>연결 종료(Connection Termination)</strong> </li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ori_gui/post/3ebacb49-c19c-46a9-ab3c-059199ce3940/image.png" alt=""></p>
<hr>
<h3 id="🔹-tcp-연결이-수립되지-않은-상태">🔹 TCP 연결이 수립되지 않은 상태</h3>
<blockquote>
<p>TCP 연결이 아직 생성되지 않았거나, 종료된 상태이다.</p>
</blockquote>
<h4 id="1️⃣-closed-닫힘-상태">1️⃣ <strong>CLOSED (닫힘 상태)</strong></h4>
<ul>
<li><strong>설명:</strong> TCP 연결이 존재하지 않는 상태.</li>
<li><strong>특징:</strong> 모든 통신이 종료된 상태로, 새로운 연결 요청이 없으면 그대로 유지됨.</li>
</ul>
<h4 id="2️⃣-listen-대기-상태">2️⃣ <strong>LISTEN (대기 상태)</strong></h4>
<ul>
<li><strong>설명:</strong> 서버(수신 측)가 연결 요청(SYN 세그먼트)을 기다리는 상태.</li>
<li><strong>특징:</strong> <code>passive open</code> 상태에서만 발생하며, 클라이언트로부터 SYN 패킷을 수신하면 <strong>SYN-RECEIVED</strong> 상태로 이동.</li>
</ul>
<hr>
<h3 id="🔹-tcp-연결-수립-과정-three-way-handshake">🔹 TCP 연결 수립 과정 (Three-Way Handshake)</h3>
<blockquote>
<p>TCP는 <strong>3단계 핸드셰이크(Three-Way Handshake)</strong>를 통해 신뢰성 있는 연결을 수립한다.</p>
</blockquote>
<h4 id="3️⃣-syn-sent-연결-요청-송신">3️⃣ <strong>SYN-SENT (연결 요청 송신)</strong></h4>
<ul>
<li><strong>설명:</strong> 클라이언트(액티브 오픈)가 서버에게 SYN 세그먼트를 전송한 상태.</li>
<li><strong>특징:</strong> 서버의 응답(SYN + ACK)을 기다리며, 응답을 받으면 <strong>ESTABLISHED</strong> 상태로 이동.</li>
</ul>
<h4 id="4️⃣-syn-received-연결-요청-수신">4️⃣ <strong>SYN-RECEIVED (연결 요청 수신)</strong></h4>
<ul>
<li><strong>설명:</strong> 서버가 클라이언트의 SYN 요청을 받고, 이에 대한 응답으로 SYN + ACK를 보낸 상태.</li>
<li><strong>특징:</strong> 클라이언트가 ACK를 다시 보내면 <strong>ESTABLISHED</strong> 상태로 이동.</li>
</ul>
<h4 id="5️⃣-established-연결-수립">5️⃣ <strong>ESTABLISHED (연결 수립)</strong></h4>
<ul>
<li><strong>설명:</strong> 3단계 핸드셰이크가 완료된 후, 양측이 데이터를 자유롭게 송수신할 수 있는 상태.</li>
<li><strong>특징:</strong> 정상적인 통신이 이루어지는 상태이며, 이 단계에서 데이터 전송이 가능함.</li>
</ul>
<hr>
<h3 id="🔹-tcp-연결-종료-과정-four-way-handshake">🔹 TCP 연결 종료 과정 (Four-Way Handshake)</h3>
<blockquote>
<p>TCP 연결을 종료하기 위해서는 <strong>4단계 핸드셰이크(Four-Way Handshake)</strong>를 수행한다.</p>
</blockquote>
<h4 id="6️⃣-fin-wait-1-연결-종료-요청-전송">6️⃣ <strong>FIN-WAIT-1 (연결 종료 요청 전송)</strong></h4>
<ul>
<li><strong>설명:</strong> 액티브 클로즈 측(연결을 종료하려는 측)이 FIN 세그먼트를 전송한 상태.</li>
<li><strong>특징:</strong> 상대방의 ACK를 기다리며, ACK를 수신하면 <strong>FIN-WAIT-2</strong> 상태로 이동.</li>
</ul>
<h4 id="7️⃣-close-wait-연결-종료-요청-승인">7️⃣ <strong>CLOSE-WAIT (연결 종료 요청 승인)</strong></h4>
<ul>
<li><strong>설명:</strong> 패시브 클로즈 측(연결 종료 요청을 받은 측)이 FIN을 받고, ACK를 보낸 상태.</li>
<li><strong>특징:</strong> 애플리케이션에서 연결 종료 준비를 마친 후 FIN을 보내면 <strong>LAST-ACK</strong> 상태로 이동.</li>
</ul>
<h4 id="8️⃣-fin-wait-2-ack-수신-후-대기">8️⃣ <strong>FIN-WAIT-2 (ACK 수신 후 대기)</strong></h4>
<ul>
<li><strong>설명:</strong> FIN-WAIT-1 상태에서 상대방의 ACK를 받은 상태.</li>
<li><strong>특징:</strong> 상대방이 FIN을 보낼 때까지 기다리며, FIN을 받으면 <strong>TIME-WAIT</strong> 상태로 이동.</li>
</ul>
<h4 id="9️⃣-last-ack-마지막-fin-전송">9️⃣ <strong>LAST-ACK (마지막 FIN 전송)</strong></h4>
<ul>
<li><strong>설명:</strong> 패시브 클로즈 측이 FIN을 전송한 후, 마지막 ACK를 기다리는 상태.</li>
<li><strong>특징:</strong> 마지막 ACK를 받으면 <strong>CLOSED</strong> 상태로 이동하여 연결 종료.</li>
</ul>
<h4 id="🔟-time-wait-일정-시간-대기">🔟 <strong>TIME-WAIT (일정 시간 대기)</strong></h4>
<ul>
<li><strong>설명:</strong> 액티브 클로즈 측이 FIN에 대한 마지막 ACK를 전송한 후, 일정 시간 동안 대기하는 상태.</li>
<li><strong>특징:</strong> 중복된 FIN 패킷이 도착할 경우를 대비해 일정 시간 동안 유지되며, 이후 <strong>CLOSED</strong> 상태로 이동.</li>
</ul>
<hr>
<h1 id="🌐-응용-계층---http의-기초">🌐 응용 계층 - HTTP의 기초</h1>
<h2 id="🔹-dns와-uriurl">🔹 DNS와 URI/URL</h2>
<h3 id="📌-dnsdomain-name-system란">📌 DNS(Domain Name System)란?</h3>
<blockquote>
<p>인터넷에서 <strong>도메인 네임</strong>을 이용해 웹사이트에 접속할 수 있도록 돕는 시스템이다.</p>
</blockquote>
<ul>
<li><strong>도메인 네임(Domain Name)</strong>: 사람이 읽기 쉬운 주소 (예: <code>google.com</code>)</li>
<li><strong>IP 주소(IP Address)</strong>: 컴퓨터가 이해할 수 있는 숫자로 된 주소 (예: <code>142.250.190.78</code>)</li>
<li><strong>네임 서버(Name Server)</strong>: 도메인 네임을 IP 주소로 변환하는 서버</li>
</ul>
<p>📌 <strong>DNS 동작 과정</strong></p>
<p>1️⃣ 사용자가 <code>www.example.com</code> 입력
2️⃣ 브라우저가 <strong>로컬 DNS 캐시</strong> 확인
3️⃣ 캐시에 없으면 <strong>ISP(인터넷 서비스 제공자)의 DNS 서버</strong> 조회
4️⃣ ISP의 DNS가 루트 네임 서버를 통해 <strong>IP 주소 반환</strong>
5️⃣ 최종적으로 <strong>해당 IP 주소로 접속</strong></p>
<h3 id="📌-urluniform-resource-locator과-uriuniform-resource-identifier">📌 URL(Uniform Resource Locator)과 URI(Uniform Resource Identifier)</h3>
<blockquote>
<p>URI와 URL은 웹에서 자원을 식별하는 방식이다.</p>
</blockquote>
<p>✅ <strong>URI</strong>: 웹 상의 자원을 식별하기 위한 주소 전체를 의미</p>
<p>✅ <strong>URL</strong>: URI 중에서도 <strong>자원의 위치(Location)를 포함한 것</strong></p>
<p>📌 <strong>URL의 구성 요소</strong></p>
<pre><code>https://www.example.com:8080/path/to/resource?query=value#fragment
</code></pre><ul>
<li><strong>Scheme (프로토콜):</strong> <code>https://</code> (HTTP, HTTPS 등)</li>
<li><strong>Authority (호스트 정보):</strong> <code>www.example.com:8080</code> (도메인 + 포트 번호)</li>
<li><strong>Path (경로):</strong> <code>/path/to/resource</code> (서버 내 자원의 위치)</li>
<li><strong>Query (쿼리 문자열):</strong> <code>?query=value</code> (추가 정보 전달)</li>
<li><strong>Fragment (조각):</strong> <code>#fragment</code> (페이지 내 특정 위치 지정)</li>
</ul>
<hr>
<h2 id="🔹-http의-특징">🔹 HTTP의 특징</h2>
<p>✅ <strong>1. 요청-응답 기반 프로토콜</strong>
클라이언트(브라우저)와 서버가 요청(Request)과 응답(Response)으로 데이터를 주고받는다.</p>
<p>✅ <strong>2. 미디어 독립적</strong>
어떤 데이터 유형(HTML, JSON, XML, 이미지 등)도 전송 가능하다.</p>
<p>✅ <strong>3. 스테이트리스(Stateless) 프로토콜</strong>
서버는 이전 요청의 상태를 기억하지 않는다.</p>
<p>→ 클라이언트가 매 요청마다 필요한 정보를 전달해야 한다.</p>
<p>✅ <strong>4. 지속 연결(Persistent Connection)</strong>
<img src="https://velog.velcdn.com/images/ori_gui/post/4b9e4e45-7638-4fad-ae7c-cebf7828723e/image.png" alt=""></p>
<ul>
<li>HTTP/1.0 → 기본적으로 요청-응답 후 연결 종료 (비지속 연결)</li>
<li>HTTP/1.1 → 기본적으로 연결 유지 (지속 연결, <code>Connection: keep-alive</code>)</li>
<li>HTTP/2.0 → 다중 요청 처리 가능 (병렬 스트림)</li>
</ul>
<p>📌 <strong>비지속 연결 vs 지속 연결</strong></p>
<ul>
<li>비지속 연결: 요청마다 새 연결 생성, 성능 저하</li>
<li>지속 연결: 여러 요청을 하나의 연결에서 처리, 성능 향상</li>
</ul>
<hr>
<h2 id="🔹-http-메시지-구조">🔹 HTTP 메시지 구조</h2>
<blockquote>
<p>HTTP 메시지는 크게 <strong>요청(Request) 메시지</strong>와 <strong>응답(Response) 메시지</strong>로 나뉜다.</p>
</blockquote>
<h3 id="📌-요청-메시지-구조">📌 요청 메시지 구조</h3>
<pre><code>GET /hello HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0
Accept: text/html
</code></pre><p>1️⃣ *<em>요청라인(Request Line) *</em> → 시작 라인</p>
<ul>
<li><strong>메서드(Method):</strong> <code>GET</code> (요청 방식)</li>
<li><strong>요청 대상(Request-Target):</strong> <code>/hello</code> (요청 경로)</li>
<li><strong>HTTP 버전:</strong> <code>HTTP/1.1</code></li>
</ul>
<p>2️⃣ <strong>헤더(Header)</strong> → 필드 라인</p>
<ul>
<li><code>Host:</code> 요청을 보낼 서버 주소</li>
<li><code>User-Agent:</code> 클라이언트 정보</li>
<li><code>Accept:</code> 받을 수 있는 데이터 타입</li>
</ul>
<p>3️⃣ <strong>본문(Body, 선택적)</strong> → POST 요청에서 데이터 포함</p>
<h3 id="📌-응답-메시지-구조">📌 응답 메시지 구조</h3>
<pre><code>HTTP/1.1 200 OK
Date: Tue, 19 Mar 2025 12:00:00 GMT
Server: Apache/2.4.41
Content-Type: text/html
Content-Length: 1234
</code></pre><p>1️⃣ <strong>상태라인(Status Line)</strong> → 시작 라인</p>
<ul>
<li><strong>HTTP 버전:</strong> <code>HTTP/1.1</code></li>
<li><strong>상태 코드:</strong> <code>200</code></li>
<li><strong>이유 구문:</strong> <code>OK</code> (성공)</li>
</ul>
<p>2️⃣ <strong>헤더(Header)</strong> → 필드 라인</p>
<ul>
<li><code>Server:</code> 서버 정보</li>
<li><code>Content-Type:</code> 응답 데이터 유형</li>
<li><code>Content-Length:</code> 데이터 크기</li>
</ul>
<p>3️⃣ <strong>본문(Body, 선택적)</strong> → HTML, JSON 등 데이터 포함</p>
<hr>
<h3 id="🔹-http-메서드">🔹 HTTP 메서드</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>GET</strong></td>
<td>리소스 요청 (읽기)</td>
</tr>
<tr>
<td><strong>HEAD</strong></td>
<td><code>GET</code>과 동일하지만 본문 제외</td>
</tr>
<tr>
<td><strong>POST</strong></td>
<td>새로운 데이터 전송 (생성)</td>
</tr>
<tr>
<td><strong>PUT</strong></td>
<td>기존 데이터 대체</td>
</tr>
<tr>
<td><strong>PATCH</strong></td>
<td>기존 데이터 일부 수정</td>
</tr>
<tr>
<td><strong>DELETE</strong></td>
<td>리소스 삭제</td>
</tr>
<tr>
<td><strong>CONNECT</strong></td>
<td>터널링 요청 (프록시)</td>
</tr>
<tr>
<td><strong>OPTIONS</strong></td>
<td>지원하는 메서드 확인</td>
</tr>
<tr>
<td><strong>TRACE</strong></td>
<td>루프백 테스트</td>
</tr>
</tbody></table>
<hr>
<h3 id="🔹-http-상태-코드">🔹 HTTP 상태 코드</h3>
<p><strong>✅ 1xx (정보성 상태 코드)</strong></p>
<table>
<thead>
<tr>
<th>코드</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><strong>100</strong></td>
<td>Continue (계속)</td>
</tr>
<tr>
<td><strong>101</strong></td>
<td>Switching Protocols (프로토콜 변경)</td>
</tr>
</tbody></table>
<p><strong>✅ 2xx (성공 상태 코드)</strong></p>
<table>
<thead>
<tr>
<th>코드</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><strong>200</strong></td>
<td>OK (성공)</td>
</tr>
<tr>
<td><strong>201</strong></td>
<td>Created (새 리소스 생성)</td>
</tr>
<tr>
<td><strong>204</strong></td>
<td>No Content (본문 없음)</td>
</tr>
</tbody></table>
<p><strong>✅ 3xx (리디렉션 상태 코드)</strong></p>
<table>
<thead>
<tr>
<th>코드</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><strong>301</strong></td>
<td>Moved Permanently (영구 이동)</td>
</tr>
<tr>
<td><strong>302</strong></td>
<td>Found (일시적 이동)</td>
</tr>
<tr>
<td><strong>304</strong></td>
<td>Not Modified (캐시된 리소스 사용)</td>
</tr>
</tbody></table>
<p><strong>✅ 4xx (클라이언트 오류 코드)</strong></p>
<table>
<thead>
<tr>
<th>코드</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><strong>400</strong></td>
<td>Bad Request (잘못된 요청)</td>
</tr>
<tr>
<td><strong>401</strong></td>
<td>Unauthorized (인증 필요)</td>
</tr>
<tr>
<td><strong>403</strong></td>
<td>Forbidden (접근 금지)</td>
</tr>
<tr>
<td><strong>404</strong></td>
<td>Not Found (리소스 없음)</td>
</tr>
</tbody></table>
<p><strong>✅ 5xx (서버 오류 코드)</strong></p>
<table>
<thead>
<tr>
<th>코드</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><strong>500</strong></td>
<td>Internal Server Error (서버 오류)</td>
</tr>
<tr>
<td><strong>502</strong></td>
<td>Bad Gateway (잘못된 게이트웨이)</td>
</tr>
</tbody></table>
<hr>
<p>Ref. 📗《이것이 취업을 위한 컴퓨터 과학이다 with CS 기술 면접》, 강민철</p>
]]></description>
        </item>
    </channel>
</rss>