<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>HI-JIN2</title>
        <link>https://velog.io/</link>
        <description>안드로이드... 좋아하세요?</description>
        <lastBuildDate>Wed, 18 Mar 2026 08:45:36 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>HI-JIN2</title>
            <url>https://velog.velcdn.com/images/jini_1514/profile/5819a350-349e-44cf-93f5-436691d232eb/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. HI-JIN2. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jini_1514" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[2025 카카오 하반기 2차] 선인장 숨기기 풀이 (Sliding Window + Deque, Python)]]></title>
            <link>https://velog.io/@jini_1514/2025-%EC%B9%B4%EC%B9%B4%EC%98%A4-%ED%95%98%EB%B0%98%EA%B8%B0-2%EC%B0%A8-%EC%84%A0%EC%9D%B8%EC%9E%A5-%EC%88%A8%EA%B8%B0%EA%B8%B0-%ED%92%80%EC%9D%B4-Sliding-Window-Deque-Python</link>
            <guid>https://velog.io/@jini_1514/2025-%EC%B9%B4%EC%B9%B4%EC%98%A4-%ED%95%98%EB%B0%98%EA%B8%B0-2%EC%B0%A8-%EC%84%A0%EC%9D%B8%EC%9E%A5-%EC%88%A8%EA%B8%B0%EA%B8%B0-%ED%92%80%EC%9D%B4-Sliding-Window-Deque-Python</guid>
            <pubDate>Wed, 18 Mar 2026 08:45:36 GMT</pubDate>
            <description><![CDATA[<p><a href="https://school.programmers.co.kr/learn/courses/30/lessons/468379">프로그래머스 LV2 - 선인장 숨기기</a></p>
<p><a href="https://school.programmers.co.kr/learn/challenges?order=recent&amp;page=1&amp;partIds=94316">작년 하반기 카카오 2차 코테 문제</a>인데, 그때는 못풀었던 것 같다.
해설이 궁금해서 찾아보니, 슬라이딩 윈도우를 가로세로로 각각 적용해서 푸는 문제라고 한다. </p>
<p>슬라이딩 윈도우의 개념만 알고 있었지, 이를 코드로 구현하는데는 자신이 없었던 지라 기본 개념을 코드로 옮기는 방법부터 문제 풀이까지 포스팅 해보려고 한다.
<img src="https://velog.velcdn.com/images/jini_1514/post/5a362c34-210e-48d8-969b-cc15850abb38/image.png" alt=""></p>
<hr>
<h1 id="1-슬라이딩-윈도우란">1. 슬라이딩 윈도우란?</h1>
<p>슬라이딩 윈도우(Sliding Window)는
고정된 크기의 구간(window)을 한 칸씩 이동시키며 값을 계산하는 기법이다.</p>
<p>예를 들어 배열이 다음과 같다고 하자:</p>
<pre><code class="language-text">[4, 2, 7, 1, 5]</code></pre>
<p>길이 <code>k = 3</code>인 구간을 본다면:</p>
<pre><code class="language-text">[4, 2, 7]
   [2, 7, 1]
      [7, 1, 5]</code></pre>
<p>이처럼 창(window)을 오른쪽으로 이동시키며 계산한다.</p>
<hr>
<h2 id="왜-필요한가">왜 필요한가?</h2>
<p>각 구간마다 <code>min()</code> 또는 <code>max()</code>를 구하면:</p>
<pre><code class="language-text">시간복잡도: O(n * k)</code></pre>
<p><code>n</code>과 <code>k</code>가 클 경우 비효율적이다.</p>
<hr>
<h2 id="deque-기반-슬라이딩-윈도우">deque 기반 슬라이딩 윈도우</h2>
<p>슬라이딩 윈도우를 O(n)으로 줄이는 핵심 아이디어는 다음과 같다.</p>
<p>&quot;쓸모 없는 값은 미리 제거한다&quot;</p>
<hr>
<h3 id="슬라이딩-윈도우-핵심-코드-패턴">슬라이딩 윈도우 핵심 코드 패턴</h3>
<pre><code class="language-python"># 1. 큰 값 제거
while dq and arr[dq[-1]] &gt;= arr[i]:
    dq.pop()

# 2. 현재 값 추가
dq.append(i)

# 3. 범위 밖 제거
if dq[0] &lt;= i - k:
    dq.popleft()
# 4. 앞
if i &gt;= k - 1:
    result.append(arr[dq[0]])</code></pre>
<hr>
<h3 id="직관">직관</h3>
<p>deque는 항상 다음과 같은 상태를 유지한다:</p>
<pre><code class="language-text">앞 → 최소값
뒤 → 큰 값들</code></pre>
<p>작은 값은 오래 살아남고, 큰 값은 제거된다.</p>
<hr>
<h2 id="예시">예시</h2>
<pre><code class="language-text">arr = [4, 2, 7, 1, 5], k=3
결과: [2, 1, 1]</code></pre>
<p>과정:</p>
<table>
<thead>
<tr>
<th>i</th>
<th>값</th>
<th>deque 상태 (값 기준)</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>4</td>
<td>[4]</td>
<td>-</td>
</tr>
<tr>
<td>1</td>
<td>2</td>
<td>[2]</td>
<td>-</td>
</tr>
<tr>
<td>2</td>
<td>7</td>
<td>[2, 7]</td>
<td>2</td>
</tr>
<tr>
<td>3</td>
<td>1</td>
<td>[1]</td>
<td>1</td>
</tr>
<tr>
<td>4</td>
<td>5</td>
<td>[1, 5]</td>
<td>1</td>
</tr>
</tbody></table>
<hr>
<h1 id="2-선인장-숨기기-문제">2. <a href="https://school.programmers.co.kr/learn/courses/30/lessons/468379">선인장 숨기기 문제</a></h1>
<h2 id="문제-요약">문제 요약</h2>
<ul>
<li>m x n 격자</li>
<li>비가 떨어지는 순서가 주어짐</li>
<li>h x w 크기의 직사각형을 놓는다</li>
<li>해당 영역에서 가장 먼저 비 맞는 시간 (최솟값)을 구한다</li>
<li>그 값이 가장 큰 위치를 찾는다
<img src="https://velog.velcdn.com/images/jini_1514/post/0e27d00b-eb4e-4124-b879-818188f04711/image.png" alt=""></li>
</ul>
<hr>
<h2 id="핵심-아이디어">핵심 아이디어</h2>
<p>직사각형 최소값을 빠르게 구하는 것이 핵심이다.</p>
<h3 id="naive-접근">naive 접근</h3>
<pre><code class="language-text">직사각형마다 전체 탐색 → O(n^3)</code></pre>
<h3 id="최적화-전략">최적화 전략</h3>
<pre><code class="language-text">1. 가로 슬라이딩 (w)
2. 세로 슬라이딩 (h)</code></pre>
<p>2차원 문제를 1차원 두 번으로 나눈다.</p>
<hr>
<h3 id="step-1-rain-배열-생성">Step 1: rain 배열 생성</h3>
<pre><code class="language-python">rain[r][c] = 해당 칸에 비가 오는 시간</code></pre>
<p>비가 오지 않는 칸은 <code>INF</code></p>
<hr>
<h3 id="step-2-가로-슬라이딩">Step 2: 가로 슬라이딩</h3>
<p>각 행에서 길이 w 구간의 최소값을 구한다.</p>
<p>예:</p>
<pre><code class="language-text">[1, INF, INF, INF, 8]
→ [1, INF, INF, 8]</code></pre>
<p>결과를 <code>row_min</code>에 저장한다.</p>
<hr>
<h3 id="step-3-세로-슬라이딩">Step 3: 세로 슬라이딩</h3>
<p><code>row_min</code>을 기준으로 세로 h 구간의 최소값을 구한다.</p>
<hr>
<h2 id="핵심-직관">핵심 직관</h2>
<blockquote>
<p>직사각형 최소값 = 각 행의 가로 최소값들 중 최소</p>
</blockquote>
<p>따라서 가로 → 세로 순서로 계산하면 된다.</p>
<hr>
<h2 id="예제1">예제1</h2>
<pre><code class="language-text">m=4, n=5, h=2, w=2</code></pre>
<h3 id="rain-배열">rain 배열</h3>
<pre><code class="language-text">[1, INF, INF, INF, 8]
[INF, 5, INF, 3, INF]
[INF, INF, 6, 7, 4]
[INF, 2, INF, INF, INF]</code></pre>
<hr>
<h3 id="가로-슬라이딩-결과">가로 슬라이딩 결과</h3>
<pre><code class="language-text">[1, INF, INF, 8]
[5, 5, 3, 3]
[INF, 6, 6, 4]
[2, 2, INF, INF]</code></pre>
<hr>
<h3 id="세로-슬라이딩-결과">세로 슬라이딩 결과</h3>
<pre><code class="language-text">[1, 5, 3, 3]
[5, 5, 3, 3]
[2, 2, 6, 4]</code></pre>
<hr>
<h3 id="최종-결과">최종 결과</h3>
<p>가장 큰 값은 6</p>
<pre><code class="language-text">정답: [2, 2]</code></pre>
<hr>
<h2 id="시간복잡도">시간복잡도</h2>
<pre><code class="language-text">가로: O(m * n)
세로: O(m * n)
총: O(m * n)</code></pre>
<hr>
<h2 id="정답-코드">정답 코드</h2>
<pre><code class="language-python">from collections import deque

def solution(m, n, h, w, drops):
    total = m * n
    INF = 999

    rain = [INF] * total

    # 2차원 배열을 1차원 배열로 변환
    for i in range(len(drops)):
        r, c = drops[i]
        rain[r * n + c] = i + 1
    print(rain)

    new_n = n - w + 1
    row_min = [0] * (m * new_n)

    # 가로 슬라이딩 윈도우 (row 기준)
    for r in range(m):
        dq = deque()

        for c in range(n):
            while dq and rain[r*n + dq[-1]] &gt;= rain[r*n + c]:
                dq.pop()

            dq.append(c)

            if dq[0] &lt;= c - w:
                dq.popleft()

            if c &gt;= w - 1:
                row_min[r * new_n + (c - w + 1)] = rain[r*n + dq[0]]

    new_m = m - h + 1

    best_time = -1
    best_r = 0
    best_c = 0

    # 세로 슬라이딩 윈도우 (column 기준)
    for c in range(new_n):
        dq = deque()

        for r in range(m):
            val = row_min[r * new_n + c]

            while dq and row_min[dq[-1] * new_n + c] &gt;= val:
                dq.pop()

            dq.append(r)

            if dq[0] &lt;= r - h:
                dq.popleft()

            if r &gt;= h - 1:
                cur = row_min[dq[0] * new_n + c]
                sr = r - h + 1

                if (cur &gt; best_time or
                   (cur == best_time and (sr &lt; best_r or (sr == best_r and c &lt; best_c)))):

                    best_time = cur
                    best_r = sr
                    best_c = c

    return [best_r, best_c]</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Race Condition 해결기]]></title>
            <link>https://velog.io/@jini_1514/Race-Condition-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@jini_1514/Race-Condition-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Wed, 28 Jan 2026 07:42:45 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p><a href="https://play.google.com/store/apps/details?id=com.eatssu.android">EAT-SSU</a> 앱을 운영하면서 이상한 버그를 자주 만났다. 로컬에서는 아무 문제 없이 잘 돌아가는데, 배포하고 나면 간헐적으로 이상한 현상이 발생했다.</p>
<p>더 이상한 건, 에뮬레이터에서 기능 자체는 정상적으로 작동한다는 점이었다. 서버에는 데이터가 잘 저장되는데 사용자 화면에는 에러가 뜨거나, 갑자기 로그아웃되는 식이었다.</p>
<p>원인을 찾아보니 전부 <strong>Race Condition</strong> 때문이었다.</p>
<p>이 글에서는 실제로 겪었던 두 가지 사례와 해결 과정을 공유한다.</p>
<hr>
<h2 id="문제를-찾기-어려웠던-이유">문제를 찾기 어려웠던 이유</h2>
<p>두 문제 모두 비슷한 특징이 있었다.</p>
<ul>
<li>✅ 기능 자체는 성공함</li>
<li>✅ 서버 에러도 아님</li>
<li>❌ 로컬 환경에서는 거의 재현 안 됨</li>
<li>❌ 실제 서비스 환경에서만 가끔 발생</li>
</ul>
<p>결국 <strong>타이밍</strong>이 문제였고, 이건 동시성 문제라는 신호였다.</p>
<hr>
<h2 id="race-condition이-뭔가">Race Condition이 뭔가?</h2>
<p><strong>여러 작업이 동시에 실행될 때, 실행 순서에 따라 결과가 달라지는 현상</strong>을 말한다.</p>
<p>쉽게 비유하자면:</p>
<blockquote>
<p>두 명이 동시에 같은 은행 계좌에서 돈을 인출하려고 할 때,<br>누가 먼저 처리되느냐에 따라 잔액이 달라지는 상황</p>
</blockquote>
<p>각 코드는 문제없는데, 동시에 실행되면 사고가 나는 것이다.</p>
<h3 id="모바일-앱에서-자주-발생하는-이유">모바일 앱에서 자주 발생하는 이유</h3>
<p>모바일 앱은 기본적으로 <strong>비동기 환경</strong>이다.</p>
<ul>
<li>네트워크 요청은 여러 개가 동시에 날아가고</li>
<li>Coroutine/Thread가 병렬로 실행되고</li>
<li>UI 상태는 비동기 결과로 업데이트된다</li>
</ul>
<p>이 과정에서 <strong>공유 자원</strong>이 생긴다:</p>
<ul>
<li>Access Token / Refresh Token</li>
<li>UI 상태 (<code>UiState</code>)</li>
<li>로컬 저장소 (DataStore 등)</li>
</ul>
<p>이 자원들에 <strong>동시 접근 + 순서 보장 없음</strong>이 겹치면 Race Condition이 발생한다.</p>
<h3 id="race-condition의-무서운-점">Race Condition의 무서운 점</h3>
<ul>
<li>항상 발생하지 않는다 (간헐적)</li>
<li>로컬에서는 잘 안 보인다</li>
<li>QA나 실제 사용자 환경에서만 가끔 나타난다</li>
<li>로그 없이는 원인 파악이 정말 어렵다</li>
</ul>
<p>내가 겪은 두 문제도 정확히 이랬다.</p>
<hr>
<h2 id="사례-1-토큰-재발급이-안되어서-하루마다-로그아웃되는-현상">사례 1: 토큰 재발급이 안되어서 하루마다 로그아웃되는 현상</h2>
<h3 id="문제-상황">문제 상황</h3>
<p>EAT-SSU의 토큰 정책은 이렇다:</p>
<ul>
<li>Access Token: 1일</li>
<li>Refresh Token: 7일</li>
</ul>
<p>배포 후, <strong>하루에 한 번씩 갑자기 로그아웃되는</strong> 현상이 간헐적으로 발생했다.</p>
<p>서버 문제도 아니고, 토큰 정책 오류도 아니었다. 추적해보니 <strong>토큰 재발급 로직의 동시성 문제</strong>였다.</p>
<h3 id="어떻게-발생했나">어떻게 발생했나?</h3>
<p>Access Token이 만료된 시점에 여러 API 요청이 동시에 날아가면:</p>
<p>1️⃣ 여러 요청이 동시에 401 응답을 받음<br>2️⃣ 각 요청이 <strong>같은 Refresh Token으로 재발급 요청</strong><br>3️⃣ 첫 번째 요청이 Refresh Token을 사용해서 성공<br>4️⃣ 두 번째 요청은 이미 사용된 Refresh Token으로 시도<br>5️⃣ 서버에서 401 반환 → 강제 로그아웃</p>
<h3 id="해결-방법">해결 방법</h3>
<p>먼저 DEV 모드 환경의 토큰 만료 기간을 짧게 변경해달라고 요청한다. </p>
<p><img src="https://velog.velcdn.com/images/jini_1514/post/3e964837-58b0-4195-a4ed-1d8f9195f1e3/image.png" alt=""></p>
<h4 id="mutex로-동시성-제어">Mutex로 동시성 제어</h4>
<blockquote>
<p><strong>Refresh Token 재발급은 딱 한 번만 일어나야 한다</strong></p>
</blockquote>
<p><code>Mutex</code>를 사용해서 해결했다:</p>
<ul>
<li>첫 번째 401 요청만 재발급 수행</li>
<li>나머지 요청들은 Lock 대기</li>
<li>재발급 완료 후, 갱신된 토큰을 사용</li>
</ul>
<h3 id="구조-개선">구조 개선</h3>
<p>동시성 해결과 함께 전체 인증 구조도 정리했다:</p>
<ul>
<li><strong>TokenAuthenticator</strong>: 401 처리 전담</li>
<li><strong>TokenInterceptor</strong>: Authorization 헤더 주입만</li>
<li><strong>ReissueAndStoreTokenUseCase</strong>: 재발급 + 저장 로직 분리</li>
<li><strong>TokenEventBus</strong>: 로그아웃 이벤트 관리</li>
</ul>
<p>로그아웃 시 원인도 명확히 알 수 있게 <code>MISSING_REFRESH_TOKEN</code>, <code>REFRESH_TOKEN_EXPIRED</code> 등을 로깅하도록 개선했다.</p>
<h4 id="코드">코드</h4>
<pre><code class="language-kotlin">     return runBlocking {
            mutex.withLock {
                val currentAccessToken = getAccessTokenUseCase()
                val requestAuthHeader = response.request.header(&quot;Authorization&quot;)

                // 이미 다른 요청이 토큰을 재발급/저장한 경우, 저장된 토큰으로만 재시도
                if (!requestAuthHeader.isNullOrBlank() &amp;&amp; requestAuthHeader != &quot;Bearer $currentAccessToken&quot;) {
                    Timber.d(&quot;TokenAuthenticator → token already refreshed by another call; retrying with stored token&quot;)
                    return@withLock response.request.newBuilder()
                        .header(&quot;Authorization&quot;, &quot;Bearer $currentAccessToken&quot;)
                        .build()
                }

                Timber.d(&quot;TokenAuthenticator → attempting token reissue&quot;)
                when (val result = reissueAndStoreTokenUseCase()) {
                    is ReissueAndStoreResult.Success -&gt; response.request.newBuilder()
                        .header(&quot;Authorization&quot;, &quot;Bearer ${result.accessToken}&quot;)
                        .build()

                    is ReissueAndStoreResult.MissingRefreshToken -&gt; {
                        Timber.e(&quot;TokenAuthenticator → refreshToken is blank; forcing logout&quot;)
                        logoutUseCase()
                        TokenEventBus.notifyTokenExpired(LogoutReason.MISSING_REFRESH_TOKEN)
                        null
                    }

                    is ReissueAndStoreResult.RefreshInvalid -&gt; {
                        Timber.e(
                            &quot;TokenAuthenticator → refresh invalid: code=${result.responseCode}, message=${result.message}&quot;
                        )
                        logoutUseCase()
                        TokenEventBus.notifyTokenExpired(LogoutReason.REFRESH_TOKEN_EXPIRED)
                        null
                    }

                    is ReissueAndStoreResult.TransientFailure -&gt; {
                        Timber.w(
                            result.throwable,
                            &quot;TokenAuthenticator → transient reissue failure: code=${result.responseCode}, message=${result.message}&quot;
                        )
                        null
                    }
                }
            }
</code></pre>
<h3 id="결과">결과</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>As-Is</th>
<th>To-Be</th>
</tr>
</thead>
<tbody><tr>
<td>동시성 제어</td>
<td>❌ 없음</td>
<td>✅ Mutex 적용</td>
</tr>
<tr>
<td>재발급 요청</td>
<td>여러 요청이 동시에 시도</td>
<td>1회만</td>
</tr>
<tr>
<td>결과</td>
<td>일부 실패 → 로그아웃</td>
<td>모든 요청 성공</td>
</tr>
</tbody></table>
<ul>
<li>✅ 간헐적 로그아웃 완전 제거</li>
<li>✅ 재발급 네트워크 요청 수 <strong>N → 1</strong></li>
<li>✅ 코드 가독성과 유지보수성 향상</li>
</ul>
<blockquote>
<p>해당 PR: <a href="https://github.com/EAT-SSU/Android/pull/453">https://github.com/EAT-SSU/Android/pull/453</a></p>
</blockquote>
<hr>
<h2 id="사례-2-사진을-포함한-리뷰-작성-뒤-api-통신이-실패했다는-다이알로그-그러나-실제-요청은-성공함-0_0">사례 2: 사진을 포함한 리뷰 작성 뒤 API 통신이 실패했다는 다이알로그. 그러나 실제 요청은 성공함 0_0</h2>
<h3 id="문제-상황-1">문제 상황</h3>
<p>QA에서 이런 제보가 들어왔다:
<img src="https://velog.velcdn.com/images/jini_1514/post/f78ad854-4987-4bb4-823f-48305af790cd/image.png" alt="">
<img src="https://velog.velcdn.com/images/jini_1514/post/e3eb7f5c-eeac-4037-ac46-624fa5bc5fab/image.png" alt=""></p>
<blockquote>
<p>&quot;리뷰 작성이 성공했는데 네트워크 오류 다이얼로그가 떠요&quot;</p>
</blockquote>
<p>실제로 서버에는 리뷰가 잘 저장되어 있었다. 하지만 사용자는 에러를 보게 되니 서비스 신뢰도가 떨어지는 상황이었다.</p>
<h3 id="원인-분석">원인 분석</h3>
<p>리뷰 작성 플로우는 이렇게 작동했다:</p>
<ol>
<li><code>postReview</code> API 호출</li>
<li><code>saveS3</code> 이미지 업로드</li>
<li>두 작업 모두 비동기로 처리</li>
</ol>
<p>문제는 <strong>두 작업 모두에서 <code>UiState.Success</code>를 설정</strong>하고 있었다는 점이었다.</p>
<h4 id="코드-1">코드</h4>
<pre><code class="language-kotlin">@HiltViewModel
class UploadReviewViewModel @Inject constructor(
    private val writeReviewUseCase: WriteReviewUseCase,
    private val getImageUrlUseCase: GetImageUrlUseCase,
) : ViewModel() {

    private val _uiState = MutableStateFlow&lt;UiState&lt;Unit&gt;&gt;(UiState.Init)
    val uiState = _uiState.asStateFlow()

    private val _uiEvent: MutableSharedFlow&lt;UiEvent&gt; = MutableSharedFlow()
    val uiEvent = _uiEvent.asSharedFlow()

    fun postReview(menuId: Long, reviewData: WriteReviewRequest) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            val success = writeReviewUseCase(menuId, reviewData)

            if (!success) {
                _uiState.value = UiState.Error
                _uiEvent.emit(UiEvent.ShowToast(&quot;리뷰 작성에 실패하였습니다.&quot;))
                return@launch
            }

            _uiState.value = UiState.Success(Unit)
            _uiEvent.emit(UiEvent.ShowToast(&quot;리뷰가 작성되었습니다.&quot;))
        }
    }

    suspend fun saveS3(file: File): String? {
        _uiState.value = UiState.Loading
        val url = getImageUrlUseCase(file)

        if (url == null) {
            _uiState.value = UiState.Error
            _uiEvent.emit(UiEvent.ShowToast(&quot;이미지 업로드에 실패하였습니다.&quot;))
            return null
        }

      //  _uiState.value = UiState.Success(Unit)  여기를 삭제했더니 race condition을 막을 수 있었다 
        return url
    }
}</code></pre>
<p><strong>문제 발생 시나리오:</strong></p>
<p>1️⃣ <code>postReview</code> 성공 → <code>UiState.Success</code> 설정<br>2️⃣ Activity가 성공으로 인식 → <code>finish()</code> 호출<br>3️⃣ ViewModelScope 종료<br>4️⃣ 아직 끝나지 않은 <code>saveS3</code>가 <code>IOException</code> 발생<br>5️⃣ 네트워크 오류로 오인 → 에러 다이얼로그 표시</p>
<p>기능은 성공했지만, <strong>상태 관리 경쟁</strong> 때문에 사용자 경험이 망가진 것이다.</p>
<h3 id="해결-방법-ui-성공-상태-단일화">해결 방법: UI 성공 상태 단일화</h3>
<p>해결 전략은 명확했다:</p>
<ul>
<li><code>UiState.Success</code>를 <strong>단 한 곳에서만 관리</strong></li>
<li>모든 비동기 작업이 완료된 후에만 성공 처리</li>
<li><code>finish()</code> 호출 시점 보장</li>
</ul>
<p>즉, <strong>UI 상태는 전체 작업 흐름을 대표해야 한다</strong>는 원칙을 적용했다.</p>
<h3 id="결과-1">결과</h3>
<ul>
<li>✅ 잘못된 네트워크 오류 다이얼로그 제거</li>
<li>✅ 리뷰 작성 UX 정상화</li>
<li>✅ ViewModel 상태 흐름 단순화</li>
</ul>
<blockquote>
<p>해당 PR: <a href="https://github.com/EAT-SSU/Android/pull/414">https://github.com/EAT-SSU/Android/pull/414</a></p>
</blockquote>
<hr>
<h2 id="두-사례의-공통점">두 사례의 공통점</h2>
<p>두 문제 모두 이런 공통점이 있었다:</p>
<ul>
<li>기능 로직 자체는 정상</li>
<li>&quot;동시에 실행될 수 있다&quot;는 가정을 못함</li>
<li>네트워크, 인증, UI 상태 어디서든 발생 가능</li>
</ul>
<p>Race Condition은 특정 기술의 문제가 아니라 <strong>설계의 문제</strong>였다.</p>
<hr>
<h2 id="배운-점">배운 점</h2>
<p>이번 경험을 통해 확실히 배운 게 있다:</p>
<p><strong>1. 실제 서비스에서는 동시성 문제가 반드시 발생한다</strong></p>
<ul>
<li>로컬에서 괜찮다고 안심하면 안 됨</li>
<li>특히 인증, 상태 관리, 네트워크 경계에서 자주 발생</li>
</ul>
<p><strong>2. 해결의 핵심은 구조</strong></p>
<ul>
<li>단일 책임: 하나의 일만 하도록</li>
<li>단일 진입점: 한 곳에서만 관리</li>
<li>명시적인 동기화: Mutex 같은 도구 활용</li>
</ul>
<p><strong>3. &quot;운 좋게 잘 되는 코드&quot;는 언젠가 문제를 만든다</strong></p>
<p>이번 리팩토링은 단순한 버그 수정이 아니라, EAT-SSU 전체를 더 견고하게 만드는 계기가 되었다.</p>
<p>배포 전 QA가 꼭 필요하다는 사실을 다시 한번 상기하며 마무리한다
윤소양 고마워요<del>~ QA해주는 최고의 PM</del>
<img src="https://velog.velcdn.com/images/jini_1514/post/1a43a222-fd7c-4f08-9518-bfb1320a9e99/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DI는 왜 유지보수성을 높일까]]></title>
            <link>https://velog.io/@jini_1514/DI%EB%8A%94-%EC%99%9C-%EC%9C%A0%EC%A7%80%EB%B3%B4%EC%88%98%EC%84%B1%EC%9D%84-%EB%86%92%EC%9D%BC%EA%B9%8C</link>
            <guid>https://velog.io/@jini_1514/DI%EB%8A%94-%EC%99%9C-%EC%9C%A0%EC%A7%80%EB%B3%B4%EC%88%98%EC%84%B1%EC%9D%84-%EB%86%92%EC%9D%BC%EA%B9%8C</guid>
            <pubDate>Sun, 21 Dec 2025 08:56:42 GMT</pubDate>
            <description><![CDATA[<p>안드로이드 개발자 JD를 보면 Hilt를 쉽게 볼 수 있다.
안드로이드 개발을 접한지 얼마 안되었을 시기에는 Hilt가 너무너무 어려웠다.
하지만 이제는 Hilt 없는 안드로이드 프로젝트를 찾아보기 어려울 정도로 안드로이드 계의 기본 소양이 되었다.</p>
<p>오늘은 Hilt, 그리고 DI에 대해 탐구해보려 한다!</p>
<h2 id="의존성-생성-책임-분리와-개방폐쇄-원칙ocp의-연결">의존성 생성 책임 분리와 개방–폐쇄 원칙(OCP)의 연결</h2>
<p>안드로이드 개발을 하다 보면 DI(Dependency Injection)를 “편해서 쓰는 도구” 정도로 받아들이기 쉽다.
하지만 실제로 DI는 단순한 편의 기능이 아니라, <strong>변경에 강한 구조를 만들기 위한 설계 기법</strong>이다.</p>
<p>이 글에서는</p>
<ul>
<li>DI가 말하는 “의존성 생성 책임 분리”가 무엇인지</li>
<li>이것이 어떻게 <strong>개방–폐쇄 원칙(OCP)</strong> 과 연결되는지
안드로이드 ViewModel–Repository 구조를 예로 들어 정리해본다.</li>
</ul>
<hr>
<h2 id="di-없이-생기는-문제">DI 없이 생기는 문제</h2>
<p>DI를 사용하지 않고 ViewModel에서 Repository를 직접 생성한다고 가정해보자.</p>
<pre><code class="language-kotlin">class ReviewViewModel : ViewModel() {

    private val repository = ReviewRepository(
        apiService,
        localDataSource
    )
}</code></pre>
<p>이 구조에는 몇 가지 문제가 있다.</p>
<h3 id="1-생성-책임이-viewmodel에-있다">1. 생성 책임이 ViewModel에 있다</h3>
<p>ViewModel은 본래 상태 관리와 UI 로직에 집중해야 하지만, 
Repository를 <strong>어떻게 생성하는지</strong>까지 알고 있다.</p>
<h3 id="2-생성-로직이-중복된다">2. 생성 로직이 중복된다</h3>
<p>여러 ViewModel에서 같은 Repository를 사용한다면,
각 ViewModel마다 동일한 생성 코드가 반복된다.</p>
<h3 id="3-변경에-매우-취약하다">3. 변경에 매우 취약하다</h3>
<p>만약 <code>ReviewRepository</code> 생성자에 파라미터가 하나 추가된다면 어떻게 될까?</p>
<pre><code class="language-kotlin">class ReviewRepository(
    apiService: ApiService,
    localDataSource: LocalDataSource,
    logger: Logger
)</code></pre>
<p>이 경우,</p>
<ul>
<li><code>ReviewRepository</code>를 생성하는 모든 ViewModel을 수정해야 한다</li>
<li>변경의 영향 범위가 ViewModel 전체로 퍼진다</li>
</ul>
<p>이 구조는 <strong>변경에 닫혀 있지 않다</strong>.</p>
<hr>
<h2 id="di의-핵심-의존성-생성-책임-분리">DI의 핵심: 의존성 생성 책임 분리</h2>
<p>DI를 적용하면 ViewModel은 더 이상 Repository를 생성하지 않는다.</p>
<pre><code class="language-kotlin">@HiltViewModel
class ReviewViewModel @Inject constructor(
    private val repository: ReviewRepository
) : ViewModel()</code></pre>
<p>Repository는 별도의 Module에서 생성된다.</p>
<pre><code class="language-kotlin">@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {

    @Provides
    fun provideReviewRepository(
        apiService: ApiService,
        localDataSource: LocalDataSource
    ): ReviewRepository {
        return ReviewRepository(apiService, localDataSource)
    }
}</code></pre>
<p>여기서 중요한 변화는 단 하나다.</p>
<blockquote>
<p><strong>ViewModel은 Repository를 “어떻게 만드는지”를 더 이상 모른다</strong></p>
</blockquote>
<hr>
<h2 id="변경의-영향을-한-곳으로-모은다">변경의 영향을 한 곳으로 모은다</h2>
<p>이제 <code>ReviewRepository</code> 생성자가 변경되더라도,</p>
<pre><code class="language-kotlin">fun provideReviewRepository(
    apiService: ApiService,
    localDataSource: LocalDataSource,
    logger: Logger
): ReviewRepository</code></pre>
<ul>
<li>수정해야 할 곳은 Module 하나뿐이다</li>
<li>ViewModel 코드는 전혀 수정하지 않는다</li>
</ul>
<p>즉,</p>
<ul>
<li>변경은 발생하지만</li>
<li>변경의 영향은 <strong>한 곳에만 집중된다</strong></li>
</ul>
<p>이 지점에서 <strong>개방–폐쇄 원칙(OCP)</strong> 과 연결된다.</p>
<hr>
<h2 id="개방폐쇄-원칙ocp과의-연결">개방–폐쇄 원칙(OCP)과의 연결</h2>
<p>개방–폐쇄 원칙은 다음과 같이 정의된다.</p>
<blockquote>
<p>확장에는 열려 있고, 변경에는 닫혀 있어야 한다</p>
</blockquote>
<p>DI 구조에서 이를 다시 해석하면 다음과 같다.</p>
<ul>
<li>Repository의 생성 방식이나 구현은 <strong>확장 가능</strong></li>
<li>Repository를 사용하는 ViewModel은 <strong>변경되지 않음</strong></li>
</ul>
<p>구성(configuration)은 바뀔 수 있지만, 사용 코드는 닫혀 있다</p>
<p>DI는 의존성 생성 책임을 분리함으로써
<strong>변경이 필요한 지점과 변경되지 않아야 할 지점을 명확히 나눈다</strong>.</p>
<p>이것이 DI가 OCP를 자연스럽게 만족시키는 이유다.</p>
<hr>
<h2 id="싱글톤은-핵심이-아니다">싱글톤은 핵심이 아니다</h2>
<p>DI의 장점을 설명할 때 흔히 “객체를 하나만 만들어서 재사용한다”는 이야기가 나온다.
하지만 이는 <strong>부가적인 효과</strong>일 뿐, 핵심은 아니다.</p>
<ul>
<li>싱글톤은 객체의 생명주기 관리 전략이다</li>
<li>DI는 의존성을 외부에서 주입하는 설계 방식이다</li>
</ul>
<p>DI를 사용하면서 싱글톤을 선택할 수는 있지만,
DI의 본질은 <strong>재사용</strong>이 아니라 <strong>변경 관리</strong>에 있다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>DI가 유지보수성을 높이는 이유는 단순하다.</p>
<ul>
<li>의존성 생성 책임을 분리하고</li>
<li>변경의 영향을 한 곳으로 모으며</li>
<li>사용하는 코드를 변경 없이 확장할 수 있게 만들기 때문이다</li>
</ul>
<p>이 구조는 자연스럽게 개방–폐쇄 원칙을 만족시킨다.</p>
<blockquote>
<p><strong>DI는 객체를 주입하는 기술이 아니라,
변경에 강한 구조를 만드는 설계 도구다.</strong></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[HandlerThread와 AsyncTask, 그리고 왜 우리는 Coroutine으로 왔을까]]></title>
            <link>https://velog.io/@jini_1514/HandlerThread%EC%99%80-AsyncTask-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%99%9C-%EC%9A%B0%EB%A6%AC%EB%8A%94-Coroutine%EC%9C%BC%EB%A1%9C-%EC%99%94%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@jini_1514/HandlerThread%EC%99%80-AsyncTask-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%99%9C-%EC%9A%B0%EB%A6%AC%EB%8A%94-Coroutine%EC%9C%BC%EB%A1%9C-%EC%99%94%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Sat, 13 Dec 2025 16:11:38 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@jini_1514/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C%EC%97%90%EC%84%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EC%99%80-%EC%8A%A4%EB%A0%88%EB%93%9C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-LooperHandlerCoroutine%EA%B9%8C%EC%A7%80">앞선 글</a>에서 프로세스, 스레드, UI 스레드, 그리고 Handler와 Looper까지 살펴봤다.</p>
<p>사실 앞선 글과 이번 글은 안드로이드 프로그래밍 Next Step 3장을 읽다가 정리한 내용을 바탕으로 작성하였다.
앞선 글이 추가적으로 조사한 내용을 바탕으로 작성한 글이고
이 글이 3장의 내용을 정리한 것이다. </p>
<p>3장에서는 백그라운드 스레드를 주제로 다음 두 방식을 소개한다.</p>
<ul>
<li>HandlerThread</li>
<li>AsyncTask</li>
</ul>
<p>둘 다 “백그라운드 작업을 안전하게 처리하기 위한 시도”라는 공통점을 가진다.</p>
<hr>
<h2 id="handlerthread-looper가-붙은-백그라운드-스레드">HandlerThread: Looper가 붙은 백그라운드 스레드</h2>
<p><code>HandlerThread</code>는 이름 그대로 <code>Thread</code>를 상속받은 클래스다.
차이점은 단 하나, <strong>Looper가 자동으로 붙어 있다는 것</strong>이다.</p>
<p>앞에서 봤듯이,</p>
<ul>
<li>UI 스레드는 기본적으로 Looper가 있다</li>
<li>일반 Thread는 Looper가 없다</li>
</ul>
<p>그래서 일반 Thread 안에서 <code>Handler()</code> 기본 생성자를 쓰면 문제가 생긴다.
Handler는 반드시 <strong>Looper가 있는 스레드</strong>에 붙어야 하기 때문이다.</p>
<p>이 문제를 해결하기 위해 등장한 것이 <code>HandlerThread</code>다.</p>
<hr>
<h3 id="왜-handlerthread가-필요했을까">왜 HandlerThread가 필요했을까</h3>
<p>일반 Thread의 특징은 단순하다.</p>
<ul>
<li>시작하면 실행</li>
<li>작업이 끝나면 종료</li>
</ul>
<p>하지만 이런 요구가 생긴다.</p>
<ul>
<li>백그라운드에서</li>
<li>계속 살아 있으면서</li>
<li>요청이 들어올 때마다</li>
<li><strong>순차적으로</strong> 처리하고 싶다</li>
</ul>
<p>이건 사실상 UI 스레드와 동일한 구조다.
단지 UI가 없을 뿐이다.</p>
<p>그래서 안드로이드는</p>
<ul>
<li>Looper</li>
<li>MessageQueue</li>
</ul>
<p>를 가진 <strong>백그라운드 전용 스레드</strong>를 제공했고,
그게 <code>HandlerThread</code>다.</p>
<hr>
<h3 id="handlerthread의-핵심-포인트">HandlerThread의 핵심 포인트</h3>
<ul>
<li>내부에 Looper가 있음</li>
<li>MessageQueue 기반</li>
<li>작업을 <strong>순서대로 처리</strong></li>
<li>직접 스레드 관리 필요</li>
</ul>
<p>여기서 중요한 문장이 하나 있다.</p>
<blockquote>
<p>스레드는 기본적으로 실행 순서를 보장하지 않는다.</p>
</blockquote>
<hr>
<h3 id="즐겨찾기-버튼을-마구-클릭하면-생기는-문제">즐겨찾기 버튼을 마구 클릭하면 생기는 문제</h3>
<p>예를 들어 즐겨찾기 버튼을 빠르게 클릭한다고 해보자.</p>
<ul>
<li>클릭할 때마다 Thread 생성</li>
<li>네트워크 요청 시작</li>
<li>완료 시점은 제각각</li>
</ul>
<p>이 상태에서 스레드들이 서로 순서를 보장하지 않으면,</p>
<ul>
<li>마지막 클릭이 먼저 끝날 수도 있고</li>
<li>이전 클릭이 나중에 반영될 수도 있다</li>
</ul>
<p>이게 바로 <strong>레이스 컨디션</strong>이다.</p>
<p>그래서 필요한 게 <strong>큐(queue)</strong>다.</p>
<ul>
<li>요청은 순서대로 쌓고</li>
<li>하나씩 처리</li>
</ul>
<p>HandlerThread는 이 구조를 자연스럽게 제공한다.</p>
<hr>
<h3 id="handlerthread의-한계">HandlerThread의 한계</h3>
<p>하지만 HandlerThread는 여전히 단점이 많다.</p>
<ul>
<li><code>Looper.loop()</code>는 무한 루프라 직접 종료해야 함</li>
<li><code>Looper.quit()</code>는 다른 스레드에서 호출해야 함</li>
<li>생명주기 관리가 번거로움</li>
<li>실수하면 메모리 릭으로 직행</li>
</ul>
<p>그래서 다음 단계로 등장한 것이 AsyncTask다.</p>
<hr>
<h2 id="asynctask-안드로이드가-제공한-간편한-비동기-도구">AsyncTask: 안드로이드가 제공한 간편한 비동기 도구</h2>
<p><code>AsyncTask</code>는 Thread보다 안드로이드 개발자에게 친숙했다.</p>
<p>이유는 간단하다.</p>
<ul>
<li>안드로이드 API</li>
<li>사용법이 쉬움</li>
<li>UI 스레드와의 연결이 자동</li>
</ul>
<p>즉, “스레드 + Handler + UI 전환”을 한 번에 감싸준 도구였다.</p>
<hr>
<h3 id="하지만-asynctask의-치명적인-문제">하지만 AsyncTask의 치명적인 문제</h3>
<p>가장 큰 문제는 <strong>액티비티 생명주기를 따라가지 않는다는 점</strong>이다.</p>
<ul>
<li>Activity는 종료됨</li>
<li>AsyncTask는 계속 실행 중</li>
<li>Activity를 참조하고 있다면?</li>
<li>→ 메모리 누수</li>
</ul>
<p>이 문제를 막기 위해 <code>isCancelled()</code>를 체크하고
<code>onDestroy()</code>에서 cancel을 호출하는 방식이 등장했지만,</p>
<p>이건 <strong>개발자에게 책임을 떠넘긴 설계</strong>에 가깝다.</p>
<hr>
<h3 id="asynctask의-또-다른-문제-예외-처리">AsyncTask의 또 다른 문제: 예외 처리</h3>
<p>AsyncTask에서 예외 처리는 까다롭다.</p>
<ul>
<li>try-catch 범위가 불분명</li>
<li>에러 전달 구조가 명확하지 않음</li>
</ul>
<p>이 문제를 해결하기 위해 RxJava가 주목받기 시작했다.</p>
<p>RxJava는</p>
<ul>
<li><code>onNext</code></li>
<li><code>onError</code></li>
<li><code>onComplete</code></li>
</ul>
<p>처럼 <strong>에러를 구조적으로 다룰 수 있었기 때문</strong>이다.</p>
<hr>
<h3 id="asynctask의-문제-2-실행-순서-보장-불가">AsyncTask의 문제 2: 실행 순서 보장 불가</h3>
<p>AsyncTask는 병렬 실행이 가능하다.
하지만 병렬 실행은 항상 위험을 동반한다.</p>
<p>예를 들어,</p>
<ul>
<li>개요 API</li>
<li>상세 API</li>
</ul>
<p>이 두 개가 순서대로 실행되어야 한다면?</p>
<p>병렬 실행에서는</p>
<ul>
<li>어떤 요청이 먼저 끝날지 보장할 수 없다</li>
<li>순서를 가정하는 순간 버그가 된다</li>
</ul>
<p>이건 운의 영역이다.</p>
<p>그래서 이런 경우에는 차라리</p>
<ul>
<li>병렬이 아니라</li>
<li><strong>순차 실행</strong>이 더 안전하다</li>
</ul>
<p>실행 순서를 조정하기 위해
<code>CountDownLatch</code> 같은 동기화 도구가 등장했지만,
코드는 점점 복잡해졌다.</p>
<hr>
<h2 id="그래서-결론은-coroutine이다">그래서 결론은 Coroutine이다</h2>
<p>여기까지의 흐름을 정리하면 명확하다.</p>
<ul>
<li>Thread: 너무 저수준</li>
<li>HandlerThread: 관리가 어려움</li>
<li>AsyncTask: 생명주기와 어긋남</li>
<li>RxJava: 강력하지만 학습 비용 큼</li>
</ul>
<p>그래서 안드로이드는 결국 <strong>Coroutine을 표준으로 선택했다</strong>.</p>
<p>Coroutine은</p>
<ul>
<li>Handler + Looper 위에서 동작하지만</li>
<li>생명주기와 자연스럽게 결합되고</li>
<li>순차 코드처럼 읽히며</li>
<li>취소와 예외 처리가 명확하다</li>
</ul>
<pre><code class="language-kotlin">viewModelScope.launch {
    val overview = loadOverview()
    val detail = loadDetail(overview.id)
    updateUi(detail)
}</code></pre>
<p>이 코드는</p>
<ul>
<li>순서를 보장하고</li>
<li>UI 스레드를 안전하게 사용하며</li>
<li>Activity 종료 시 자동으로 취소된다</li>
</ul>
<hr>
<h2 id="마무리하며">마무리하며</h2>
<p>HandlerThread와 AsyncTask는
안드로이드가 <strong>비동기 문제를 해결하기 위해 지나온 과정</strong>이다.</p>
<p>지금 우리가 Coroutine을 쓰는 이유는 단순히 “새로워서”가 아니다.</p>
<ul>
<li>스레드 관리</li>
<li>생명주기</li>
<li>순서 보장</li>
<li>예외 처리</li>
</ul>
<p>이 모든 문제를 <strong>현실적으로 가장 잘 해결한 도구</strong>이기 때문이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[안드로이드에서 프로세스와 스레드, 그리고 Looper·Handler·Coroutine까지]]></title>
            <link>https://velog.io/@jini_1514/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C%EC%97%90%EC%84%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EC%99%80-%EC%8A%A4%EB%A0%88%EB%93%9C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-LooperHandlerCoroutine%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@jini_1514/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C%EC%97%90%EC%84%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EC%99%80-%EC%8A%A4%EB%A0%88%EB%93%9C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-LooperHandlerCoroutine%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Sat, 13 Dec 2025 16:01:07 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://developer.android.com/guide/components/processes-and-threads">https://developer.android.com/guide/components/processes-and-threads</a></p>
</blockquote>
<p>안드로이드 개발을 하다 보면 자연스럽게 이런 질문을 만나게 된다.</p>
<ul>
<li>왜 UI 스레드를 막으면 안 될까?</li>
<li>Handler와 Looper는 정확히 뭘 하는 걸까?</li>
<li>Coroutine은 왜 더 안전하고 편한 방식일까?</li>
</ul>
<p>이 글에서는 <strong>운영체제 관점</strong>과 <strong>안드로이드 내부 구조</strong>를 함께 엮어, 안드로이드의 실행 모델을 처음부터 끝까지 정리해본다.</p>
<hr>
<h2 id="1-프로세스process-앱의-실행-단위">1. 프로세스(Process): 앱의 실행 단위</h2>
<p>운영체제에서 <strong>프로세스</strong>는 실행 중인 프로그램의 인스턴스다.
각 프로세스는 독립된 메모리 공간을 가지며, 다른 프로세스와 메모리를 직접 공유하지 않는다.</p>
<p>안드로이드에서도 동일하다.</p>
<ul>
<li>앱이 실행되면 <strong>리눅스 프로세스 1개</strong>가 생성된다.</li>
<li>기본적으로 Activity, Service, BroadcastReceiver, ContentProvider는
<strong>모두 같은 프로세스</strong>에서 실행된다.</li>
</ul>
<p>즉, 일반적인 안드로이드 앱은 “앱 하나 = 프로세스 하나” 구조를 가진다.</p>
<p>필요하다면 <code>android:process</code> 속성을 통해 컴포넌트를 분리할 수 있지만, 대부분의 앱에서는 사용하지 않는다.</p>
<hr>
<h2 id="2-안드로이드-프로세스는-언제-종료될까">2. 안드로이드 프로세스는 언제 종료될까</h2>
<p>안드로이드는 메모리가 부족해지면 프로세스를 종료한다.
이때 기준은 “사용자에게 얼마나 중요한가”다.</p>
<ul>
<li>화면에 보이는 Activity를 가진 프로세스는 우선순위가 높다.</li>
<li>백그라운드에만 있는 프로세스는 언제든 종료될 수 있다.</li>
</ul>
<p>그래서 안드로이드 앱은 항상 <strong>프로세스가 죽고 다시 살아나는 상황</strong>을 전제로 설계해야 한다.</p>
<hr>
<h2 id="3-스레드thread-실행-흐름의-단위">3. 스레드(Thread): 실행 흐름의 단위</h2>
<p>프로세스 안에는 하나 이상의 <strong>스레드</strong>가 존재한다.</p>
<ul>
<li>스레드는 프로세스의 메모리를 공유한다.</li>
<li>대신 동시성 문제(레이스 컨디션)가 발생할 수 있다.</li>
</ul>
<p>안드로이드 앱이 시작되면 가장 먼저 생성되는 스레드가 있다.</p>
<hr>
<h2 id="4-메인-스레드ui-스레드의-역할">4. 메인 스레드(UI 스레드)의 역할</h2>
<p>안드로이드는 앱 시작 시 <strong>메인 스레드(Main Thread)</strong>를 하나 생성한다.
이 스레드는 흔히 <strong>UI 스레드</strong>라고도 불린다.</p>
<p>UI 스레드의 역할은 다음과 같다.</p>
<ul>
<li>터치, 키 입력 같은 사용자 이벤트 처리</li>
<li>화면 그리기</li>
<li>Activity / Fragment 생명주기 콜백 실행</li>
</ul>
<p>즉, UI 스레드는 <strong>앱의 화면과 상호작용을 전담하는 핵심 스레드</strong>다.</p>
<hr>
<h2 id="5-왜-ui-스레드를-막으면-안-되는가">5. 왜 UI 스레드를 막으면 안 되는가</h2>
<p>UI 스레드는 이벤트를 하나씩 처리한다.</p>
<ol>
<li>버튼 클릭 이벤트 처리</li>
<li>화면 상태 변경</li>
<li>다시 그리기 요청 처리</li>
</ol>
<p>이 과정 중 UI 스레드에서 오래 걸리는 작업을 수행하면 어떻게 될까?</p>
<ul>
<li>이벤트가 처리되지 않는다.</li>
<li>화면이 갱신되지 않는다.</li>
<li>사용자는 앱이 멈췄다고 느낀다.</li>
</ul>
<p>5초 이상 UI 스레드가 응답하지 않으면
안드로이드는 <strong>ANR(Application Not Responding)</strong>을 발생시킨다.</p>
<p>그래서 안드로이드에는 명확한 규칙이 있다.</p>
<ol>
<li>UI 스레드를 블로킹하지 말 것</li>
<li>UI 변경은 반드시 UI 스레드에서만 할 것</li>
</ol>
<hr>
<h2 id="6-이벤트-루프와-looper">6. 이벤트 루프와 Looper</h2>
<p>여기서 자연스럽게 질문이 생긴다.</p>
<blockquote>
<p>UI 스레드는 어떻게 앱이 종료될 때까지 계속 살아 있을까?</p>
</blockquote>
<p>그 답이 <strong>이벤트 루프(Event Loop)</strong>다.</p>
<p>UI 스레드는 내부적으로 다음과 같은 구조를 가진다.</p>
<pre><code class="language-text">메시지가 올 때까지 대기
→ 메시지 하나 꺼내서 실행
→ 다시 대기</code></pre>
<p>이 무한 루프를 담당하는 객체가 <strong>Looper</strong>다.</p>
<ul>
<li>Looper는 스레드에 하나만 존재한다.</li>
<li>UI 스레드는 기본적으로 Looper를 가지고 있다.</li>
<li>일반 스레드는 Looper가 없다.</li>
</ul>
<hr>
<h2 id="7-messagequeue와-handler">7. MessageQueue와 Handler</h2>
<p>Looper 옆에는 항상 <strong>MessageQueue</strong>가 있다.</p>
<ul>
<li>MessageQueue에는 실행할 작업(Runnable, Message)이 쌓인다.</li>
<li>Looper는 이 큐를 계속 감시하며 하나씩 실행한다.</li>
</ul>
<p>그렇다면 누가 큐에 작업을 넣을까?</p>
<p>그 역할을 하는 것이 <strong>Handler</strong>다.</p>
<p>Handler는 특정 Looper(즉, 특정 스레드)에 연결되어
그 스레드의 MessageQueue에 작업을 전달한다.</p>
<pre><code class="language-kotlin">Handler(Looper.getMainLooper()).post {
    textView.text = &quot;hello&quot;
}</code></pre>
<p>이 코드는 “UI 스레드에서 이 코드를 실행해달라”는 요청이다.</p>
<hr>
<h2 id="8-백그라운드-스레드와-ui-스레드-통신">8. 백그라운드 스레드와 UI 스레드 통신</h2>
<p>네트워크, DB, 파일 IO 같은 작업은
반드시 UI 스레드가 아닌 <strong>백그라운드 스레드</strong>에서 수행해야 한다.</p>
<p>하지만 UI 업데이트는 UI 스레드에서만 가능하다.</p>
<p>그래서 일반적인 흐름은 다음과 같다.</p>
<ol>
<li>백그라운드 스레드에서 작업 수행</li>
<li>Handler를 통해 UI 스레드에 결과 전달</li>
<li>UI 스레드 Looper가 이를 실행</li>
</ol>
<p>이 구조 덕분에 UI 스레드는 안전하게 유지된다.</p>
<hr>
<h2 id="9-handlerthread-looper를-가진-백그라운드-스레드">9. HandlerThread: Looper를 가진 백그라운드 스레드</h2>
<p>일반 Thread는 작업이 끝나면 종료된다.
하지만 계속 살아 있으면서 요청을 처리해야 하는 스레드도 필요하다.</p>
<p>이를 위해 제공되는 것이 <strong>HandlerThread</strong>다.</p>
<ul>
<li>Thread + Looper를 합친 구조</li>
<li>순차 처리 보장</li>
<li>시스템 컴포넌트나 레거시 코드에서 자주 사용</li>
</ul>
<p>다만 코드 복잡도가 높고 생명주기 관리가 어렵다.</p>
<hr>
<h2 id="10-coroutine-더-높은-수준의-추상화">10. Coroutine: 더 높은 수준의 추상화</h2>
<p>Coroutine은 Thread를 직접 다루지 않는다.
이미 존재하는 스레드 위에서 <strong>실행 흐름만 관리</strong>한다.</p>
<p>핵심은 Dispatcher다.</p>
<ul>
<li>Dispatchers.Main → UI 스레드</li>
<li>Dispatchers.IO → IO 작업용 스레드 풀</li>
</ul>
<pre><code class="language-kotlin">withContext(Dispatchers.Main) {
    textView.text = &quot;hello&quot;
}</code></pre>
<p>은 내부적으로 다음과 같다. </p>
<pre><code class="language-kotlin">Handler(Looper.getMainLooper()).post {
    textView.text = &quot;hello&quot;
}</code></pre>
<p>이처럼, Coroutine은 Handler + Looper 구조 위에서 동작하지만, 개발자는 이를 직접 신경 쓰지 않아도 된다.</p>
<hr>
<h2 id="11-왜-dispatchersmain은-안전한가">11. 왜 Dispatchers.Main은 안전한가</h2>
<p><code>Dispatchers.Main</code>은 내부적으로</p>
<ul>
<li>UI 스레드의 Looper</li>
<li>UI 스레드의 MessageQueue</li>
</ul>
<p>에 작업을 등록한다.</p>
<p>즉, Handler를 직접 쓰는 것과 본질적으로 동일하지만
더 안전하고 가독성이 좋으며 취소와 생명주기 관리가 가능하다.</p>
<hr>
<h2 id="12-정리">12. 정리</h2>
<p>안드로이드의 실행 구조를 한 줄로 요약하면 다음과 같다.</p>
<ul>
<li>프로세스는 앱의 실행 단위다.</li>
<li>스레드는 실행 흐름의 단위다.</li>
<li>UI 스레드는 이벤트와 화면을 담당한다.</li>
<li>Looper는 스레드의 이벤트 루프다.</li>
<li>Handler는 특정 스레드에 작업을 전달하는 도구다.</li>
<li>Coroutine은 이 모든 구조 위에 얹힌 고수준 추상화다.</li>
</ul>
<p>이 흐름을 이해하면
왜 안드로이드가 이런 구조를 가지는지,
왜 Coroutine이 표준이 되었는지도 자연스럽게 이해할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS EC2 + RDS 위에 Redash 직접 구축하기]]></title>
            <link>https://velog.io/@jini_1514/AWS-EC2-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4%EC%97%90-Redash-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jini_1514/AWS-EC2-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4%EC%97%90-Redash-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 05 Dec 2025 05:08:07 GMT</pubDate>
            <description><![CDATA[<p>EAT-SSU 팀에서 서비스 운영 데이터를 시각화하기 위해 <strong>Redash</strong>를 도입했다.
Managed SaaS 대신 <strong>AWS EC2 위에 직접 설치</strong>하는 방식을 선택했고, 그 과정에서 인프라 전반을 직접 만지게 되었다.</p>
<p>공식 문서를 그대로 따라가면 금방 끝날 것 같았지만,
실제로는 <strong>디스크 용량, 메모리, 보안그룹, VPC 라우팅</strong>까지 연쇄적으로 문제가 발생했다.</p>
<p>이 글에서는</p>
<ol>
<li>EC2 + RDS 환경에서 Redash를 처음부터 끝까지 구축하는 과정</li>
<li>실제로 겪었던 트러블슈팅과 그로부터 얻은 교훈</li>
</ol>
<p>을 정리한다.</p>
<p><img src="https://velog.velcdn.com/images/jini_1514/post/fbe86c1b-aa52-4db5-b513-05c140ac36be/image.png" alt=""></p>
<hr>
<h2 id="1-redash를-ec2에-직접-올린-이유">1. Redash를 EC2에 직접 올린 이유</h2>
<p>Redash는 데이터 분석과 대시보드를 위한 오픈소스 도구다.
팀에서는 다음 이유로 EC2 직접 설치 방식을 선택했다.</p>
<ul>
<li>비용 통제 (사용량이 크지 않음)</li>
<li>Docker 기반으로 비교적 단순한 설치</li>
<li>인프라 구조를 직접 이해하고 운영해보기 위함</li>
</ul>
<p>다만, “생각보다 쉽다”는 기대와 달리 실제 구축 과정에서는 꽤 많은 인프라 요소를 고려해야 했다.</p>
<hr>
<h2 id="2-아키텍처-개요">2. 아키텍처 개요</h2>
<p>구성은 단순하다.</p>
<ul>
<li><strong>EC2</strong>: Redash 서버 (Docker 컨테이너)</li>
<li><strong>RDS</strong>: 서비스 운영 DB (MySQL 또는 PostgreSQL)</li>
<li>EC2와 RDS는 <strong>같은 VPC</strong>에 배치</li>
<li>보안그룹은 <strong>SG → SG</strong> 방식으로 연결</li>
</ul>
<h3 id="트래픽-흐름">트래픽 흐름</h3>
<ul>
<li>브라우저 → EC2 퍼블릭 IP:80 → Nginx → Redash Server(5000)</li>
<li>Redash Server → RDS:3306 (또는 5432)</li>
</ul>
<hr>
<h2 id="3-ec2-인스턴스-준비">3. EC2 인스턴스 준비</h2>
<h3 id="3-1-인스턴스-타입">3-1. 인스턴스 타입</h3>
<p>Redash는 생각보다 리소스를 많이 사용한다.</p>
<ul>
<li>최소 권장: <code>t3.small</code> (2vCPU, 2GB RAM)</li>
<li>안정적: <code>t3.medium</code> (2vCPU, 4GB RAM)</li>
</ul>
<p><code>t2.micro(1GB)</code>로도 시도해봤지만,
메모리·디스크 모두 부족해 설치 단계에서 실패했다.</p>
<h3 id="3-2-디스크-용량">3-2. 디스크 용량</h3>
<p>Root EBS 볼륨은 <strong>최소 20GB</strong>, 가능하면 <strong>30GB 이상</strong>을 권장한다.</p>
<ul>
<li>Docker 이미지 레이어</li>
<li>Postgres, Redis 데이터</li>
<li>Python 패키지 설치 과정에서의 임시 공간</li>
</ul>
<p>8GB 환경에서는 설치 중 <code>no space left on device</code> 오류가 거의 확정적으로 발생한다.</p>
<h3 id="3-3-네트워크">3-3. 네트워크</h3>
<ul>
<li>EC2는 <strong>퍼블릭 서브넷</strong>에 생성</li>
<li>Route Table에
<code>0.0.0.0/0 → Internet Gateway</code>
라우트 존재 확인</li>
<li>퍼블릭 IPv4 또는 Elastic IP 할당 필수</li>
</ul>
<hr>
<h2 id="4-보안그룹-설정">4. 보안그룹 설정</h2>
<p>Redash용 EC2 보안그룹 예시 (<code>redash-group</code>)</p>
<h3 id="inbound">Inbound</h3>
<ul>
<li>22/TCP → 내 IP (SSH)</li>
<li>80/TCP → 내 IP 또는 0.0.0.0/0 (웹 UI)</li>
<li>5000/TCP → 내 IP (디버깅용, 선택)</li>
</ul>
<h3 id="outbound">Outbound</h3>
<ul>
<li>All traffic → 0.0.0.0/0</li>
</ul>
<p>중요한 점은 <strong>DB 포트(3306/5432)는 EC2 인바운드에 열 필요가 없다는 것</strong>이다.
DB 포트는 RDS 보안그룹에서 제어해야 한다.</p>
<hr>
<h2 id="5-스왑-메모리-설정">5. 스왑 메모리 설정</h2>
<p>메모리가 2GB 이하라면 스왑을 설정해 두는 것이 안전하다.</p>
<pre><code class="language-bash">sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo &#39;/swapfile none swap sw 0 0&#39; | sudo tee -a /etc/fstab
free -h</code></pre>
<hr>
<h2 id="6-docker-설치">6. Docker 설치</h2>
<pre><code class="language-bash">sudo apt update
sudo apt install -y docker.io docker-compose-plugin
sudo systemctl enable docker
sudo systemctl start docker</code></pre>
<hr>
<h2 id="7-redash-설치">7. Redash 설치</h2>
<p>공식 setup 스크립트를 그대로 사용했다.</p>
<pre><code class="language-bash">git clone https://github.com/getredash/setup.git
cd setup
sudo ./setup.sh</code></pre>
<p>설치 후 다음 컨테이너들이 떠 있어야 한다.</p>
<ul>
<li>redash-server</li>
<li>redash-worker</li>
<li>redash-scheduler</li>
<li>redash-nginx</li>
<li>redash-postgres</li>
<li>redash-redis</li>
</ul>
<p>EC2 내부 테스트:</p>
<pre><code class="language-bash">curl http://localhost
curl http://localhost:5000</code></pre>
<hr>
<h2 id="8-브라우저-접속">8. 브라우저 접속</h2>
<ul>
<li><code>http://&lt;EC2 퍼블릭 IP&gt;</code> (Nginx 경유)</li>
<li>또는 <code>http://&lt;EC2 퍼블릭 IP&gt;:5000</code></li>
</ul>
<p>관리자 계정 생성 화면이 나오면 정상이다.</p>
<hr>
<h2 id="9-rds-보안그룹-설정">9. RDS 보안그룹 설정</h2>
<p>핵심은 <strong>IP가 아니라 보안그룹을 Source로 지정하는 것</strong>이다.</p>
<p>RDS 보안그룹 Inbound:</p>
<ul>
<li>3306 (또는 5432)</li>
<li>Source: <code>redash-group</code></li>
</ul>
<p>이렇게 하면 Redash EC2만 DB에 접근할 수 있다.</p>
<hr>
<h2 id="10-트러블슈팅-회고">10. 트러블슈팅 회고</h2>
<h3 id="1-디스크-부족-no-space-left-on-device">1) 디스크 부족 (<code>no space left on device</code>)</h3>
<p><code>t2.micro + 8GB</code> 환경에서 Docker 이미지 레이어 추출 중 실패.</p>
<p><strong>원인</strong></p>
<ul>
<li>Docker + DB + 라이브러리 합쳐 8GB 초과</li>
</ul>
<p><strong>해결</strong></p>
<ul>
<li>EBS 30GB 확장</li>
<li>파일시스템 확장 후 재설치</li>
</ul>
<p><strong>교훈</strong></p>
<ul>
<li>디스크는 넉넉하게 잡는 게 결국 더 싸다</li>
</ul>
<hr>
<h3 id="2-컨테이너는-떠-있는데-접속이-안-됨">2) 컨테이너는 떠 있는데 접속이 안 됨</h3>
<ul>
<li><code>docker ps</code> 정상</li>
<li>브라우저 접속 불가</li>
</ul>
<p><strong>점검 순서</strong></p>
<ol>
<li>EC2 내부 <code>curl localhost</code></li>
<li>보안그룹 포트</li>
<li>서브넷 Route Table</li>
<li>퍼블릭 IP 여부</li>
</ol>
<p><strong>교훈</strong></p>
<ul>
<li>애플리케이션 문제인지, 네트워크 문제인지 계층별로 분리해서 봐야 한다</li>
</ul>
<hr>
<h3 id="3-db-포트를-ec2-sg에-열어둔-실수">3) DB 포트를 EC2 SG에 열어둔 실수</h3>
<ul>
<li>DB 연결 안 된다고 EC2 SG에 3306 개방</li>
</ul>
<p><strong>문제</strong></p>
<ul>
<li>DB는 EC2가 아니라 RDS가 “받는 쪽”</li>
</ul>
<p><strong>정답</strong></p>
<ul>
<li>RDS SG Inbound에
<code>3306 → Source: redash-group</code></li>
</ul>
<p><strong>교훈</strong></p>
<ul>
<li>보안그룹은 “누가 누구에게 접근하는가”를 기준으로 봐야 한다</li>
</ul>
<hr>
<h2 id="11-마무리">11. 마무리</h2>
<p>Redash를 EC2 위에 직접 올리는 작업은 단순 설치를 넘어
<strong>인프라 전반을 이해해야 가능한 작업</strong>이었다.</p>
<p>이번 경험으로 정리된 핵심은 다음이다.</p>
<ul>
<li>리소스는 넉넉하게 잡는 것이 결국 효율적이다</li>
<li>보안그룹은 SG → SG 구조로 설계한다</li>
<li>문제 발생 시  <strong>컨테이너 → EC2 내부 → 보안그룹 → VPC/라우팅</strong> 순서로 점검한다</li>
</ul>
<p>이 과정을 한 번 겪고 나니,
이후 다른 사내 도구(Grafana, Metabase 등)를 올릴 때도 훨씬 수월해졌다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Data 모듈은 순수 Kotlin 모듈일 수 있을까? ]]></title>
            <link>https://velog.io/@jini_1514/Data-%EB%AA%A8%EB%93%88%EC%9D%80-%EC%88%9C%EC%88%98-Kotlin-%EB%AA%A8%EB%93%88%EC%9D%BC-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@jini_1514/Data-%EB%AA%A8%EB%93%88%EC%9D%80-%EC%88%9C%EC%88%98-Kotlin-%EB%AA%A8%EB%93%88%EC%9D%BC-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Wed, 03 Dec 2025 07:38:53 GMT</pubDate>
            <description><![CDATA[<p>Android 프로젝트를 멀티 모듈 구조로 구성할 때 가장 자주 사용하는 형태는 다음과 같다.
<img src="https://velog.velcdn.com/images/jini_1514/post/d957c492-f083-4b9e-97fa-9dfe16954776/image.png" alt=""></p>
<ul>
<li>Presentation: Android UI 모듈</li>
<li>Domain: 순수 Kotlin 모듈</li>
<li>Data: Android UI 모듈일 수도 있고 순수 Kotlin 모듈일 수도 있다.</li>
</ul>
<p>Domain 모듈이 순수 Kotlin 모듈인 것은 다들 아는 사실일 것이다. </p>
<p>그런데, Data 모듈은 둘 다 될 수도 있다고 한다. 
여기에 추가로 알 수 있는 사항은 Android 의존성 없이 JVM 기반으로 유지하면 테스트 환경이 가벼워지고, 순수한 비즈니스 로직만 다루도록 설계할 수 있다는 점이다. </p>
<p>나 역시 초기에는 Ktor를 사용해 네트워크 통신을 구현했기 때문에 Data 모듈을 Kotlin JVM 모듈로 구성했다.
Ktor는 멀티플랫폼을 지원하고 Android 종속성이 없는 <code>client-core</code>, <code>client-cio</code> 엔진만 사용하면 JVM 환경에서도 문제 없이 동작한다.</p>
<p>그러나 Paging3를 도입하면서 상황이 달라졌다.</p>
<hr>
<h1 id="paging3-도입-이전-data-모듈은-순수-kotlin-jvm-모듈이어도-충분하다">Paging3 도입 이전: Data 모듈은 순수 Kotlin JVM 모듈이어도 충분하다</h1>
<p>Ktor 기반으로 Repository를 구성하던 시점에는 다음과 같은 구조가 가능했다.</p>
<ul>
<li>API 통신: Ktor client-core, cio 엔진 사용</li>
<li>DTO 변환: Kotlin Serialization</li>
<li>Repository, Datasource: Kotlin JVM 환경에서 모두 가능</li>
<li>Domain으로 전달: Result, Model, Flow 등</li>
</ul>
<p>이 경우 Data 모듈에는 Android 종속성이 전혀 없기 때문에 <code>kotlin(&quot;jvm&quot;)</code> 모듈로 구현해도 문제가 없다.</p>
<hr>
<h1 id="paging3-도입-이후-data-모듈은-android-모듈이-될-수밖에-없다">Paging3 도입 이후: Data 모듈은 Android 모듈이 될 수밖에 없다</h1>
<p>하지만 무한 스크롤 기능을 위해 Paging3를 도입하면서 제약이 생긴다.</p>
<p>Paging3는 다음과 같이 세 개의 모듈로 나뉜다.</p>
<ul>
<li>paging-common (Android 의존성 없음)</li>
<li>paging-runtime (Android 의존성 있음)</li>
<li>paging-compose (UI 전용)</li>
</ul>
<p>여기서 핵심은 <code>Pager</code> 클래스가 <strong>paging-runtime</strong>에 포함되어 있다는 점이다.
Repository에서 Pager를 사용해 PagingData 스트림을 생성해야 하는데, 이 시점에서 Android 의존성이 필요해진다.</p>
<p>예시:</p>
<pre><code class="language-kotlin">fun getCharacters(): Flow&lt;PagingData&lt;Character&gt;&gt; {
    return Pager(
        config = PagingConfig(pageSize = 20),
        pagingSourceFactory = { CharacterPagingSource(api) }
    ).flow
}</code></pre>
<p><code>Pager</code>는 내부적으로 AndroidX 라이브러리와 연동되어 있고, paging-runtime 또한 AAR(Android Archive) 형태로 배포된다.
따라서 다음과 같은 문제가 발생한다.</p>
<ul>
<li>JVM 모듈은 AAR 의존성을 받을 수 없다.</li>
<li>pager-runtime 자체가 Android 모듈 전용이다.</li>
<li>Kotlin JVM 모듈에서 Pager를 import할 수 없다.</li>
</ul>
<p>즉, Repository에서 Pager를 생성하는 순간 Data 모듈은 반드시 Android 모듈이어야 한다.</p>
<hr>
<h1 id="왜-pager를-presentation이-아닌-data-계층에서-생성해야-하는가">왜 Pager를 Presentation이 아닌 Data 계층에서 생성해야 하는가</h1>
<p>일부 개발자는 Pager 생성을 Presentation 계층에서 수행하여 Data 모듈을 순수 Kotlin 모듈로 유지할 수 있다고 생각할 수 있다.
그러나 이는 클린 아키텍처 관점에서 좋지 않은 설계다.</p>
<p>Pager는 다음 역할을 수행한다.</p>
<ul>
<li>페이지네이션 로직 정의</li>
<li>PagingSource 생성</li>
<li>네트워크 로드 관리</li>
</ul>
<p>이 로직은 &quot;UI 로직&quot;이 아니라 &quot;데이터 로딩 전략&quot;에 속한다.
즉, Repository가 책임져야 하는 부분이다.
Presentation 계층으로 Pager 로직을 밀어 넣으면 UI와 데이터 로직이 결합되어 아키텍처가 무너진다.</p>
<p>따라서 다음이 가장 자연스러운 구조다.</p>
<ul>
<li>Data: Pager 생성, PagingSource 제공</li>
<li>Domain: PagingData 그대로 전달</li>
<li>Presentation: collect, Paging Compose 사용</li>
</ul>
<p>이 구조를 유지하려면 Data 모듈은 Android Library 모듈이어야 한다.</p>
<hr>
<h1 id="data-모듈을-android-library로-변경한-후의-장점">Data 모듈을 Android Library로 변경한 후의 장점</h1>
<ol>
<li>Repository가 Paging3를 자연스럽게 책임질 수 있다.</li>
<li>PagingSource, Pager, RemoteMediator 등을 모두 Data 계층에서 관리할 수 있다.</li>
<li>Presentation은 paging-compose만 사용하고 로직은 완전히 분리된다.</li>
<li>Domain은 paging-common에 포함된 PagingData, PagingSource를 문제 없이 사용할 수 있다.</li>
</ol>
<p>즉, Paging3를 올바르게 사용하기 위해서는 Data 모듈이 Android 모듈로 변경되는 것이 불가피하며, 오히려 아키텍처 관점에서도 더 자연스러운 선택이다.</p>
<hr>
<h1 id="정리">정리</h1>
<ul>
<li>Ktor만 사용할 때는 Data 모듈을 JVM 기반으로 설계할 수 있었다.</li>
<li>Paging3를 도입하면 Repository에서 Pager를 생성해야 한다.</li>
<li>Pager는 Android 종속성을 가진 paging-runtime에 포함되어 있다.</li>
<li>따라서 Data 모듈은 Android Library 모듈로 전환되어야 한다.</li>
<li>이는 클린 아키텍처의 책임 분리를 지키는 측면에서도 더 적합하다.</li>
</ul>
<p>이 변화는 단순히 모듈 타입을 변경하는 것 이상의 의미가 있다.
Android 환경에서 멀티 모듈 아키텍처를 사용한다면, 기술 스택에 따라 모듈의 성격이 달라질 수 있고 개발자는 그 이유와 구조적 적합성을 이해해야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[멀티모듈 클린아키텍쳐 환경에서 Paging 라이브러리 써보기 ]]></title>
            <link>https://velog.io/@jini_1514/%EB%A9%80%ED%8B%B0%EB%AA%A8%EB%93%88-%ED%81%B4%EB%A6%B0%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-Paging-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EC%8D%A8%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@jini_1514/%EB%A9%80%ED%8B%B0%EB%AA%A8%EB%93%88-%ED%81%B4%EB%A6%B0%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-Paging-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EC%8D%A8%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Wed, 03 Dec 2025 07:02:16 GMT</pubDate>
            <description><![CDATA[<h1 id="개요">개요</h1>
<p>클린 아키텍처를 더 철저하게 지키기 위해 멀티 모듈 기반의 Android 프로젝트 구성을 선호한다.
Data / Domain / Presentation으로 모듈을 분리하면, 각 모듈에서 사용할 수 있는 의존성이 제한되기 때문에 단일 모듈보다 아키텍처 규칙을 강하게 유지할 수 있다.</p>
<p>하지만 Paging3 라이브러리로 무한 스크롤을 구현하면서 다음과 같은 의문이 생겼다.</p>
<blockquote>
</blockquote>
<p>_paging 라이브러리는 androidx로 시작하는데, 그렇다면 Data, Domain처럼 JVM 기반 모듈에서는 사용할 수 없는 것이 아닌가?
_</p>
<p>실제로 멀티 모듈 구조에서 다음과 같은 클린 아키텍쳐 의존성 방향을 사용한다면,</p>
<p>Presentation(안드로이드 모듈) → Data(안드로이드 or JVM 모듈) ← Domain(JVM 모듈)</p>
<p>Android 의존성을 포함한 라이브러리는 JVM 모듈에서 사용이 제한된다.</p>
<h1 id="paging3-분석">Paging3 분석</h1>
<p>Paging3는 이러한 문제를 해결하기 위해 기능별로 분리된 세 가지 모듈을 제공한다.</p>
<ul>
<li><code>androidx.paging:paging-common</code></li>
<li><code>androidx.paging:paging-runtime</code></li>
<li><code>androidx.paging:paging-compose</code></li>
</ul>
<p>아래는 각 모듈의 역할과 클린 아키텍처에서의 적용 방법이다.</p>
<hr>
<h2 id="1-androidxpagingpaging-common-android-의존성-없음">1. <code>androidx.paging:paging-common</code> (Android 의존성 없음)</h2>
<pre><code>implementation &quot;androidx.paging:paging-common:3.x.x&quot;</code></pre><ul>
<li>Android 종속성이 없는 멀티플랫폼용 모듈</li>
<li>Domain 계층에서도 안전하게 사용할 수 있음</li>
<li>UseCase에서 PagingData를 그대로 다룰 수 있음</li>
</ul>
<h3 id="주요-포함-클래스">주요 포함 클래스</h3>
<ul>
<li>PagingData</li>
<li>PagingSource</li>
<li>PagingConfig</li>
<li>LoadResult</li>
<li>RemoteMediator</li>
</ul>
<hr>
<h2 id="2-androidxpagingpaging-runtime-android-의존성-포함">2. <code>androidx.paging:paging-runtime</code> (Android 의존성 포함)</h2>
<pre><code>implementation &quot;androidx.paging:paging-runtime:3.x.x&quot;</code></pre><ul>
<li>Android 의존성을 포함하여 Data, Presentation 계층에서만 사용 가능</li>
<li>Repository에서 Pager 인스턴스를 생성할 때 사용함</li>
<li>PagingDataAdapter도 여기에 포함됨</li>
</ul>
<h3 id="주요-포함-클래스-1">주요 포함 클래스</h3>
<ul>
<li>Pager</li>
<li>PagingDataAdapter</li>
<li>PagingSourceFactory</li>
<li>cachedIn(viewModelScope)</li>
</ul>
<hr>
<h2 id="3-androidxpagingpaging-compose-jetpack-compose-ui-관련">3. <code>androidx.paging:paging-compose</code> (Jetpack Compose UI 관련)</h2>
<pre><code>implementation &quot;androidx.paging:paging-compose:3.x.x&quot;</code></pre><ul>
<li>Compose UI에서 Paging을 사용할 때 필요한 모듈</li>
<li>Presentation(UI) 계층에서만 사용해야 함</li>
</ul>
<h3 id="주요-포함-클래스-2">주요 포함 클래스</h3>
<ul>
<li>LazyPagingItems</li>
<li>collectAsLazyPagingItems()</li>
<li>Paging 관련 Compose DSL</li>
</ul>
<hr>
<h1 id="클린-아키텍처에서-paging-3을-적용하는-방법">클린 아키텍처에서 Paging 3을 적용하는 방법</h1>
<h2 id="1-repository에서-pagingdata-제공-data-layer">1. Repository에서 PagingData 제공 (Data Layer)</h2>
<pre><code class="language-kotlin">class MyRepository(private val apiService: MyApiService) {
    fun getPagedData(): Flow&lt;PagingData&lt;MyData&gt;&gt; {
        return Pager(
            config = PagingConfig(pageSize = 20),
            pagingSourceFactory = { MyPagingSource(apiService) }
        ).flow
    }
}</code></pre>
<ul>
<li>Pager는 Android 의존성이 있으므로 Repository에서 생성해야 한다.</li>
<li>Repository는 Flow<PagingData> 형태로 Presentation과 Domain에 데이터를 제공한다.</li>
</ul>
<hr>
<h2 id="2-usecase에서-pagingdata를-그대로-전달-domain-layer">2. UseCase에서 PagingData를 그대로 전달 (Domain Layer)</h2>
<pre><code class="language-kotlin">class GetPagedDataUseCase(private val repository: MyRepository) {
    operator fun invoke(): Flow&lt;PagingData&lt;MyData&gt;&gt; {
        return repository.getPagedData()
    }
}</code></pre>
<ul>
<li>UseCase에서는 PagingData를 변환하지 않고 그대로 전달할 수 있다.</li>
<li>PagingData와 PagingSource는 Android 의존성이 없어 Domain 계층에서 사용 가능하다.</li>
</ul>
<hr>
<h2 id="3-viewmodel에서-pagingdata-소비-presentation-layer">3. ViewModel에서 PagingData 소비 (Presentation Layer)</h2>
<pre><code class="language-kotlin">@HiltViewModel
class MyViewModel @Inject constructor(
    private val getPagedDataUseCase: GetPagedDataUseCase
) : ViewModel() {
    val pagingData: Flow&lt;PagingData&lt;MyData&gt;&gt; = getPagedDataUseCase()
        .cachedIn(viewModelScope)
}</code></pre>
<ul>
<li>cachedIn(viewModelScope)는 Android 의존성이 있으므로 ViewModel에서 처리해야 한다.</li>
</ul>
<hr>
<h1 id="정리">정리</h1>
<ul>
<li>PagingData, PagingSource, PagingConfig는 Android 의존성이 없기 때문에 Domain 계층에서도 사용할 수 있다.</li>
<li>Pager, PagingDataAdapter, cachedIn() 등 Android 의존성이 있는 기능은 Repository 또는 Presentation 계층에서만 사용해야 한다.</li>
<li>UseCase는 PagingData를 그대로 유지한 채 전달하는 구성이 가장 깔끔하다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[ OpenAI Realtime API WebSocket 써보기]]></title>
            <link>https://velog.io/@jini_1514/Android%EC%97%90%EC%84%9C-OpenAI-Realtime-API-WebSocket%EC%9C%BC%EB%A1%9C-%EC%8D%A8%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@jini_1514/Android%EC%97%90%EC%84%9C-OpenAI-Realtime-API-WebSocket%EC%9C%BC%EB%A1%9C-%EC%8D%A8%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sun, 30 Nov 2025 08:43:23 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>새길 프로젝트에서 AI 대화 연습 기능을 구현하면서, 기존 REST + TTS 방식 대신 OpenAI Realtime API(WebSocket)를 붙여 대화 사용자 발화 이후 AI의 응답 지연 속도를 11초대 → 3초 수준으로 줄였다. 이 글에서는 그 과정에서 사용한 구조와 안드로이드 코드 구성을 정리한다.</p>
</blockquote>
<h2 id="0-이런-기능을-만들고-싶었다">0. 이런 기능을 만들고 싶었다</h2>
<p>AI 전화처럼 가상 대화하는 기능을 만들려고 한다. </p>
<p><img src="https://velog.velcdn.com/images/jini_1514/post/101e54f7-07ee-4f92-bdb3-cce0cc8cd243/image.png" alt=""></p>
<h2 id="1-왜-realtime-api인가">1. 왜 Realtime API인가</h2>
<p>처음에는 다음과 같은 구조였다.</p>
<ol>
<li>사용자가 말하기 종료</li>
<li>m4a 파일을 서버로 업로드 (Multipart)</li>
<li>서버에서 STT + LLM 호출 → 텍스트 응답 생성</li>
<li>응답 텍스트를 다시 TTS로 변환해 m4a 반환</li>
<li>클라이언트에서 재생
<img src="https://velog.velcdn.com/images/jini_1514/post/0d491664-c95c-4e94-baad-dc46f6031d6d/image.png" alt=""></li>
</ol>
<p>이 과정에서</p>
<ol>
<li>LLM 응답 생성 ≒ 8초</li>
<li>TTS 파일 생성 및 전송 ≒ 2초
정도 걸리면서, 한 턴의 대화가 10초 이상 딜레이가 발생했다. 대화 연습용 “전화” 인터랙션으로 쓰기엔 너무 느린 속도였다.
<img src="https://velog.velcdn.com/images/jini_1514/post/bc617399-64df-4d48-a76d-790ffacc50d1/image.png" alt=""></li>
</ol>
<p>기존에도 OpenAI의 Assistants API을 활용해서 기능을 구현했었는데, OPEN AI에서 WebSocket으로 실시간 소통을 할 수 있는 API가 새로 나왔다고 해서 당장 써봤다</p>
<p><a href="https://platform.openai.com/docs/guides/realtime-websocket">OpenAI Realtime API</a>는 WebSocket을 통해 음성·텍스트를 저지연으로 주고받는 인터페이스를 제공한다.
이걸 이용하면 음성 종료 후 서버 응답까지의 체감 딜레이를 3초 정도까지 줄일 수 있다.</p>
<hr>
<h2 id="2-전체-구조-개요">2. 전체 구조 개요</h2>
<p>이번 구현은 “모바일에서 직접 OpenAI에 WebSocket으로 붙되, 인증은 백엔드에서 처리”하는 구조다.</p>
<ol>
<li><p>백엔드</p>
<ul>
<li>OpenAI Realtime Session 생성 REST API 호출</li>
<li>응답으로 내려오는 <code>client_secret.value</code>(ephemeral key)를 모바일에 내려줌 (<a href="https://platform.openai.com/docs/api-reference/realtime-sessions?utm_source=chatgpt.com" title="API Reference">OpenAI Platform</a>)</li>
</ul>
</li>
<li><p>안드로이드</p>
<ul>
<li><code>GET /realtime/token</code> 같은 엔드포인트로 client_secret 요청</li>
<li>이 토큰을 Authorization 헤더에 넣고 <code>wss://api.openai.com/v1/realtime?...</code>로 WebSocket 연결</li>
<li>마이크 입력을 WebSocket으로 보내고, 서버에서 오는 오디오를 받아 재생</li>
</ul>
</li>
</ol>
<p>레이어 별로 보면 Saegil-Android PR 구조랑 비슷하게 나뉜다.</p>
<ul>
<li>data: <code>RealTimeService</code>, <code>RealTimeServiceImpl</code>, <code>RealTimeRepositoryImpl</code></li>
<li>domain: <code>RealTimeRepository</code>, <code>StartRealtimeChatUseCase</code>, <code>EndRealtimeChatUseCase</code>, <code>GetRealTimeTokenUsecase</code></li>
<li>presentation: <code>AiConversationViewModel</code>, <code>AiConversationScreen</code> 등</li>
</ul>
<hr>
<h2 id="3-백엔드에서-client_secret-발급받기">3. 백엔드에서 client_secret 발급받기</h2>
<p>Realtime API는 바로 API 키를 클라이언트에 노출하지 않고, 짧은 수명의 “client_secret(=ephemeral key)”를 발급해 사용하는 방식을 권장한다. (<a href="https://platform.openai.com/docs/api-reference/realtime-sessions?utm_source=chatgpt.com" title="API Reference">OpenAI Platform</a>)</p>
<p>백엔드에서는 대략 다음 흐름으로 동작한다.</p>
<ol>
<li>서버에서 OpenAI Realtime Session API 호출</li>
</ol>
<pre><code class="language-bash">POST https://api.openai.com/v1/realtime/sessions
Authorization: Bearer {SERVER_SIDE_OPENAI_API_KEY}
Content-Type: application/json

{
  &quot;model&quot;: &quot;gpt-4o-realtime-preview-2024-12-17&quot;,
  &quot;voice&quot;: &quot;alloy&quot;
}</code></pre>
<ol start="2">
<li>응답에서 <code>client_secret.value</code>를 꺼냄</li>
</ol>
<pre><code class="language-json">{
  &quot;id&quot;: &quot;session_...&quot;,
  &quot;client_secret&quot;: {
    &quot;value&quot;: &quot;ek_1234567890...&quot;,
    &quot;expires_at&quot;: 1738015688
  }
}</code></pre>
<ol start="3">
<li>이 <code>value</code>를 모바일에 내려주는 간단한 API 구현</li>
</ol>
<pre><code class="language-json">// 예시 응답
{
  &quot;clientSecret&quot;: &quot;ek_1234567890...&quot;
}</code></pre>
<p>Saegil PR에서는 이 응답을 <code>GetRealTimeApiTokenResponse</code> 같은 DTO로 파싱하고, <code>AssistantServiceImpl</code> / <code>RealTimeRepositoryImpl</code>에서 사용하도록 분리해뒀다.</p>
<hr>
<h2 id="4-안드로이드-토큰-가져오기rest">4. 안드로이드: 토큰 가져오기(REST)</h2>
<p>안드로이드 쪽에서는 먼저 Retrofit으로 토큰을 받아오는 부분을 만든다. (패키지 구조는 data/domain/presentation 그대로 유지)</p>
<pre><code class="language-kotlin">// data/remote/AssistantService.kt
interface AssistantService {
    @GET(&quot;/realtime/token&quot;)
    suspend fun getRealTimeToken(): GetRealTimeApiTokenResponse
}

// data/model/GetRealTimeApiTokenResponse.kt
data class GetRealTimeApiTokenResponse(
    @SerializedName(&quot;clientSecret&quot;)
    val clientSecret: String
)</code></pre>
<pre><code class="language-kotlin">// data/repository/RealTimeRepositoryImpl.kt
class RealTimeRepositoryImpl(
    private val assistantService: AssistantService,
    private val realTimeService: RealTimeService
) : RealTimeRepository {

    override suspend fun startRealtimeChat(): Result&lt;Unit&gt; {
        return runCatching {
            val tokenResponse = assistantService.getRealTimeToken()
            realTimeService.connect(tokenResponse.clientSecret)
        }
    }

    override suspend fun endRealtimeChat(): Result&lt;Unit&gt; {
        return runCatching {
            realTimeService.disconnect()
        }
    }
}</code></pre>
<p>domain 레이어에서는 <code>StartRealtimeChatUseCase</code>, <code>EndRealtimeChatUseCase</code>, <code>GetRealTimeTokenUsecase</code> 등으로 감싸 ViewModel에서 호출하기 쉽게 정리한다.</p>
<hr>
<h2 id="5-안드로이드-websocket-연결하기">5. 안드로이드: WebSocket 연결하기</h2>
<p>OpenAI Realtime WebSocket 엔드포인트는 대략 다음과 같은 형태다. (<a href="https://platform.openai.com/docs/guides/realtime-websocket?utm_source=chatgpt.com" title="Realtime API with WebSocket">OpenAI Platform</a>)</p>
<pre><code class="language-text">wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17</code></pre>
<p>여기에 아까 받은 <code>client_secret</code>를 Authorization 헤더에 실어 연결한다.</p>
<h3 id="5-1-okhttp-websocket-예시">5-1. OkHttp WebSocket 예시</h3>
<p>Saegil-Android에서는 Ktor/멀티모듈을 쓰고 있지만, 블로그에서는 OkHttp 기반 예시로 정리해보자.</p>
<pre><code class="language-kotlin">class RealTimeServiceImpl(
    private val okHttpClient: OkHttpClient
) : RealTimeService {

    private var webSocket: WebSocket? = null

    override fun connect(clientSecret: String) {
        val request = Request.Builder()
            .url(&quot;wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17&quot;)
            .addHeader(&quot;Authorization&quot;, &quot;Bearer $clientSecret&quot;)
            .build()

        webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {

            override fun onOpen(webSocket: WebSocket, response: Response) {
                // 연결 성공
            }

            override fun onMessage(webSocket: WebSocket, text: String) {
                // JSON 이벤트 처리 (텍스트 응답 등)
            }

            override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
                // 바이너리 프레임 처리 (오디오 응답 등)
            }

            override fun onFailure(
                webSocket: WebSocket,
                t: Throwable,
                response: Response?
            ) {
                // 에러 처리
            }

            override fun onClosed(
                webSocket: WebSocket,
                code: Int,
                reason: String
            ) {
                // 종료 처리
            }
        })
    }

    override fun disconnect() {
        webSocket?.close(1000, &quot;user closed&quot;)
        webSocket = null
    }
}</code></pre>
<p>PR에서는 이 WebSocket을 직접 쓰는 대신, <code>RealTimeService</code> 인터페이스로 감싸고, 상위 계층에는 콜백/Flow 형태로 이벤트를 올려주는 구조를 사용했다.</p>
<hr>
<h2 id="6-음성-입력-보내기">6. 음성 입력 보내기</h2>
<p>음성 통화 느낌을 내려면, 마이크에서 입력을 받아 Realtime API로 보내야 한다.</p>
<ol>
<li><code>AudioRecord</code>로 PCM 데이터 수집</li>
<li>적당한 크기(예: 20~40ms 단위)의 버퍼로 잘라 WebSocket 바이너리 프레임으로 전송</li>
<li>녹음이 끝났다는 이벤트도 Realtime API 프로토콜에 맞게 JSON으로 보내야 한다</li>
</ol>
<p>예시 코드 흐름은 다음과 같다.</p>
<pre><code class="language-kotlin">fun sendAudioStream() {
    val minBufferSize = AudioRecord.getMinBufferSize(
        SAMPLE_RATE,
        CHANNEL_CONFIG,
        AUDIO_FORMAT
    )

    val audioRecord = AudioRecord(
        MediaRecorder.AudioSource.MIC,
        SAMPLE_RATE,
        CHANNEL_CONFIG,
        AUDIO_FORMAT,
        minBufferSize
    )

    val buffer = ByteArray(minBufferSize)

    audioRecord.startRecording()

    while (isRecording) {
        val read = audioRecord.read(buffer, 0, buffer.size)
        if (read &gt; 0) {
            // 이 부분에서 OpenAI Realtime 프로토콜에 맞게 래핑 후 전송
            webSocket?.send(ByteString.of(buffer, 0, read))
        }
    }

    audioRecord.stop()
    audioRecord.release()
}</code></pre>
<p>실제 PR에서는 OpenAI의 이벤트 규격에 맞는 JSON을 보내고, 오디오 프레임을 특정 이벤트 타입에 맞춰서 전송하는 로직이 들어간다. 이 부분은 Realtime API 공식 문서의 이벤트 포맷을 참고해 구현하면 된다. (<a href="https://platform.openai.com/docs/guides/realtime?utm_source=chatgpt.com" title="Realtime API">OpenAI Platform</a>)</p>
<hr>
<h2 id="7-음성-응답-재생하기">7. 음성 응답 재생하기</h2>
<p>서버에서 오는 오디오는 WebSocket의 <code>onMessage(bytes: ByteString)</code>에서 받는다. 바로 재생하면 버퍼가 꼬이거나, 여러 응답이 겹쳐서 들리기 쉬우므로 Saegil-Android에서는 “재생 큐”를 두고 한 번에 하나씩 재생하는 구조로 풀었다.</p>
<p>대략적인 흐름은 다음과 같다.</p>
<ol>
<li><code>BlockingQueue&lt;ByteArray&gt;</code> 혹은 <code>Channel&lt;ByteArray&gt;</code>로 오디오 조각을 쌓는다</li>
<li>별도의 재생 쓰레드 / 코루틴에서 큐를 소비하면서 <code>AudioTrack</code>으로 재생</li>
<li>끊김을 줄이기 위해 재생 버퍼 사이에 약간의 sleep 또는 버퍼 사이즈 조절</li>
</ol>
<pre><code class="language-kotlin">class AudioPlayer {

    private val audioTrack = AudioTrack(
        AudioAttributes.Builder()
            .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
            .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
            .build(),
        AudioFormat.Builder()
            .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
            .setSampleRate(SAMPLE_RATE)
            .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
            .build(),
        BUFFER_SIZE,
        AudioTrack.MODE_STREAM,
        AudioManager.AUDIO_SESSION_ID_GENERATE
    )

    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
    private val queue = Channel&lt;ByteArray&gt;(Channel.UNLIMITED)

    init {
        scope.launch {
            audioTrack.play()
            for (chunk in queue) {
                audioTrack.write(chunk, 0, chunk.size)
            }
        }
    }

    fun enqueue(bytes: ByteArray) {
        scope.launch {
            queue.send(bytes)
        }
    }

    fun stop() {
        scope.cancel()
        audioTrack.stop()
        audioTrack.release()
    }
}</code></pre>
<p>WebSocket 쪽에서는 <code>onMessage(bytes: ByteString)</code>에서 <code>audioPlayer.enqueue(bytes.toByteArray())</code>를 호출하는 식으로 연결하면 된다.</p>
<hr>
<h2 id="8-viewmodel과-ui에서의-사용">8. ViewModel과 UI에서의 사용</h2>
<p>presentation 레이어에서는 크게 두 가지를 관리한다.</p>
<ol>
<li><p>통화 상태</p>
<ul>
<li>연결 중인지</li>
<li>상대가 말하고 있는지</li>
<li>내가 말하고 있는지</li>
</ul>
</li>
<li><p>에러/로딩 상태</p>
<ul>
<li>토큰 요청 실패</li>
<li>WebSocket 연결 실패</li>
<li>네트워크 끊김</li>
</ul>
</li>
</ol>
<p>예시 ViewModel 흐름:</p>
<pre><code class="language-kotlin">@HiltViewModel
class AiConversationViewModel @Inject constructor(
    private val startRealtimeChatUseCase: StartRealtimeChatUseCase,
    private val endRealtimeChatUseCase: EndRealtimeChatUseCase,
) : ViewModel() {

    private val _uiState = MutableStateFlow(AiConversationUiState())
    val uiState = _uiState.asStateFlow()

    fun startCall() {
        viewModelScope.launch {
            _uiState.update { it.copy(isConnecting = true) }

            startRealtimeChatUseCase()
                .onSuccess {
                    _uiState.update { it.copy(isConnecting = false, isTalking = true) }
                }
                .onFailure { e -&gt;
                    _uiState.update {
                        it.copy(isConnecting = false, errorMessage = e.message ?: &quot;연결 실패&quot;)
                    }
                }
        }
    }

    fun endCall() {
        viewModelScope.launch {
            endRealtimeChatUseCase()
            _uiState.update { it.copy(isTalking = false) }
        }
    }
}</code></pre>
<p>Compose 화면(<code>AiConversationScreen</code>)에서는 이 <code>uiState</code>를 구독하며 통화 버튼, 타이머, 파형 애니메이션 등을 표시한다.</p>
<hr>
<h2 id="9-rest-대비-체감-성능-개선">9. REST 대비 체감 성능 개선</h2>
<p>실제 프로젝트에서 REST + TTS 방식과 Realtime WebSocket 방식의 속도를 비교했을 때:</p>
<ul>
<li>기존 방식: 음성 종료 후 응답 재생까지 약 10~11초</li>
<li>Realtime WebSocket: 음성 종료 후 응답 재생까지 약 3초</li>
</ul>
<p>대략 70% 이상의 딜레이를 줄일 수 있었고, 전화 대화처럼 “말하면 곧바로 답이 오는” 느낌에 훨씬 가까워졌다.</p>
<hr>
<h2 id="10-정리-및-도입-시-팁">10. 정리 및 도입 시 팁</h2>
<p>정리하면, Android에서 OpenAI Realtime API(WebSocket)를 붙일 때 핵심은 세 가지다.</p>
<ol>
<li>백엔드에서 client_secret(ephemeral key)을 발급해주는 얇은 API를 만든다.</li>
<li>안드로이드에서는 이 토큰을 사용해 WebSocket을 열고, 마이크·스피커 처리는 AudioRecord/AudioTrack으로 관리한다.</li>
<li>WebSocket 이벤트를 ViewModel로 올리고, UI는 단순히 상태와 이벤트만 구독하도록 분리한다.</li>
</ol>
<p>도입할 때 생각해 볼 포인트:</p>
<ul>
<li>토큰 만료(기본 30분) 시 재발급/재연결 전략 (<a href="https://platform.openai.com/docs/api-reference/realtime-sessions?utm_source=chatgpt.com" title="API Reference">OpenAI Platform</a>)</li>
<li>네트워크 끊김/재연결 처리</li>
<li>녹음 권한, 블루투스/이어폰 오디오 라우팅</li>
<li>벤치마크(프레임 타이밍, 지연 시간)로 실제 성능 측정</li>
</ul>
<p><a href="https://github.com/saegil-project/Saegil-Android/pull/92">구현 PR</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[안드로이드 에뮬레이터에서 로컬 Spring Boot 서버로 REST API 요청하기]]></title>
            <link>https://velog.io/@jini_1514/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EC%97%90%EB%AE%AC%EB%A0%88%EC%9D%B4%ED%84%B0%EC%97%90%EC%84%9C-%EB%A1%9C%EC%BB%AC-Spring-Boot-%EC%84%9C%EB%B2%84%EB%A1%9C-REST-API-%EC%9A%94%EC%B2%AD%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@jini_1514/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EC%97%90%EB%AE%AC%EB%A0%88%EC%9D%B4%ED%84%B0%EC%97%90%EC%84%9C-%EB%A1%9C%EC%BB%AC-Spring-Boot-%EC%84%9C%EB%B2%84%EB%A1%9C-REST-API-%EC%9A%94%EC%B2%AD%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Wed, 26 Nov 2025 02:57:11 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>안드로이드 스튜디오의 에뮬레이터에서 로컬에서 실행 중인 Spring Boot 서버로 REST API 요청을 보낼려고 한다. 하지만 안드로이드 스튜디오 에뮬레이터에서 <code>localhost</code> 또는 <code>127.0.0.1</code> 로 요청을 보내면 실패한다. 이유와 해결 방법을 정리한다.</p>
<hr>
<h2 id="1-안드로이드-에뮬레이터에서-localhost가-동작하지-않는-이유">1. 안드로이드 에뮬레이터에서 localhost가 동작하지 않는 이유</h2>
<p>에뮬레이터에서 다음 주소로 요청하면:</p>
<pre><code>http://localhost:9000
http://127.0.0.1:9000</code></pre><p>에뮬레이터 내부에서는 이것을 <strong>본인(에뮬레이터 디바이스 자체)</strong> 로 해석한다.
Mac 또는 개발 PC의 Spring Boot 서버가 아닌, 디바이스 내부를 바라본다는 뜻이다.</p>
<p>따라서 아래와 같은 오류가 발생한다.</p>
<pre><code>Failed to connect to localhost/127.0.0.1:9000</code></pre><p>에뮬레이터는 개발 PC를 직접 인식하지 못하기 때문에 다른 접근 방식이 필요하다.</p>
<hr>
<h2 id="2-에뮬레이터에서-pc의-로컬-서버에-접근하는-공식-주소">2. 에뮬레이터에서 PC의 로컬 서버에 접근하는 공식 주소</h2>
<p>에뮬레이터가 개발 PC의 localhost로 접속하기 위해 제공되는 특별 주소가 있다.</p>
<pre><code>10.0.2.2</code></pre><p>Android Emulator에서 이 주소는 개발 PC의 <code>localhost</code>를 의미한다.
따라서 Retrofit 또는 OkHttp의 baseUrl을 다음과 같이 변경해야 한다.</p>
<pre><code>http://10.0.2.2:9000/</code></pre><p>포트 번호는 개발 PC에서 Spring Boot가 실행 중인 포트를 그대로 사용하면 된다.</p>
<hr>
<h2 id="3-retrofit-설정-예시">3. Retrofit 설정 예시</h2>
<pre><code class="language-kotlin">Retrofit.Builder()
    .baseUrl(&quot;http://10.0.2.2:9000/&quot;)
    .addConverterFactory(GsonConverterFactory.create())
    .build()</code></pre>
<p>이렇게 수정하면 에뮬레이터에서 로컬 서버로 정상적으로 API 요청이 전송된다.</p>
<h2 id="4-정리">4. 정리</h2>
<p>안드로이드 에뮬레이터의 <code>localhost</code>는 PC가 아닌 에뮬레이터 자신의 주소이므로 동작하지 않는다.
반드시 <code>10.0.2.2</code> 를 사용해야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Can't connect to local MySQL server through socket '/opt/homebrew/var/mysql/mysql.sock' (2) 오류 해결]]></title>
            <link>https://velog.io/@jini_1514/MacOSHomebrew%EC%97%90%EC%84%9C-MySQL-%EC%84%A4%EC%B9%98-%ED%9B%84-%EC%86%8C%EC%BC%93-%EA%B2%BD%EB%A1%9C-%EB%AC%B8%EC%A0%9C%EB%A1%9C-%EC%A0%91%EC%86%8D-%EB%B6%88%EA%B0%80%ED%95%9C-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0-%EA%B8%B0%EB%A1%9D</link>
            <guid>https://velog.io/@jini_1514/MacOSHomebrew%EC%97%90%EC%84%9C-MySQL-%EC%84%A4%EC%B9%98-%ED%9B%84-%EC%86%8C%EC%BC%93-%EA%B2%BD%EB%A1%9C-%EB%AC%B8%EC%A0%9C%EB%A1%9C-%EC%A0%91%EC%86%8D-%EB%B6%88%EA%B0%80%ED%95%9C-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0-%EA%B8%B0%EB%A1%9D</guid>
            <pubDate>Wed, 26 Nov 2025 02:54:06 GMT</pubDate>
            <description><![CDATA[<p>MacOS에서 Homebrew 기반으로 MySQL을 설치하는 과정에서 다음과 같은 오류가 발생했다.</p>
<pre><code>ERROR 2002 (HY000): Can&#39;t connect to local MySQL server through socket &#39;/opt/homebrew/var/mysql/mysql.sock&#39; (2)</code></pre><p>내부 서버는 실행 중이라고 표기되지만 실제로는 소켓 파일이 존재하지 않아 접속이 불가능한 상태였다. 초기화 실패, 데이터 디렉토리 충돌, 라이브러리 경로 오류까지 다양한 원인이 겹쳐 문제 해결이 예상보다 오래 걸렸다.
본 글에서는 문제 발생 원인과 해결 과정을 정리한다.</p>
<hr>
<h2 id="1-문제-상황">1. 문제 상황</h2>
<p>Homebrew로 MySQL 설치 후 접속을 시도했다.</p>
<pre><code>mysql -u root</code></pre><p>그러나 다음 오류가 발생했다.</p>
<pre><code>Can&#39;t connect to local MySQL server through socket &#39;/opt/homebrew/var/mysql/mysql.sock&#39; (2)</code></pre><p>이 문제는 MySQL 서버가 실제로 실행 중인지 여부와, 서버가 사용하는 소켓 파일의 위치가 일치하지 않아 발생한 것이었다.</p>
<hr>
<h2 id="2-mysql-9x-설치-실패">2. MySQL 9.x 설치 실패</h2>
<p>MySQL 9.x 설치 과정에서 의존성(<code>protobuf</code>) 버전 충돌이 발생했고, 서버 초기화도 실패했다.
특히 <code>mysqld</code> 실행 시 다음과 같은 라이브러리 로딩 오류가 출력되었다.</p>
<pre><code>Library not loaded: /opt/homebrew/opt/protobuf/lib/libprotobuf-lite.29.2.0.dylib</code></pre><p>Homebrew의 rolling 업데이트 특성 상 의존성 버전이 바뀌면서 MySQL이 필요로 하는 버전과 맞지 않는 상황이었다.</p>
<hr>
<h2 id="3-mysql-84로-재설치">3. MySQL 8.4로 재설치</h2>
<p>문제를 해결하기 위해 MySQL을 완전히 제거하고 8.4 버전으로 재설치했다.</p>
<ol>
<li><p>기존 MySQL 정지</p>
<pre><code>brew services stop mysql</code></pre></li>
<li><p>데이터 디렉토리 삭제</p>
<pre><code>sudo rm -rf /opt/homebrew/var/mysql</code></pre></li>
<li><p>MySQL 8.4 설치</p>
<pre><code>brew install mysql@8.4</code></pre></li>
<li><p>데이터베이스 초기화</p>
<pre><code>/opt/homebrew/opt/mysql@8.4/bin/mysqld --initialize-insecure \
  --user=yujin \
  --basedir=/opt/homebrew/opt/mysql@8.4 \
  --datadir=/opt/homebrew/var/mysql</code></pre></li>
<li><p>서비스 시작</p>
<pre><code>brew services start mysql@8.4</code></pre></li>
</ol>
<p>여기까지는 모든 과정이 정상적으로 완료되었다.</p>
<hr>
<h2 id="4-그러나-여전히-접속-불가">4. 그러나 여전히 접속 불가</h2>
<p><code>brew services info mysql@8.4</code> 명령어 결과, 서비스는 Running 상태로 표시되었다.</p>
<pre><code>Running: ✔
PID: 893</code></pre><p>하지만 소켓 파일이 생성되지 않아 접속은 여전히 불가능했다.
이는 MySQL 서버가 <code>/opt/homebrew/.../mysql.sock</code> 대신 다른 위치에 소켓을 생성하고 있었기 때문이다.</p>
<hr>
<h2 id="5-실제-소켓-파일-위치-확인">5. 실제 소켓 파일 위치 확인</h2>
<p>다음 명령으로 소켓 위치를 검색했다.</p>
<pre><code>sudo find / -name &quot;mysql.sock*&quot; 2&gt;/dev/null</code></pre><p>출력 결과는 다음과 같았다.</p>
<pre><code>/System/Volumes/Data/private/tmp/mysql.sock
/System/Volumes/Data/private/tmp/mysql.sock.lock</code></pre><p>즉 MySQL 서버가 소켓을 <code>/tmp/mysql.sock</code> 에 생성하고 있었던 것이다.
이는 macOS의 보안 구조 및 Homebrew MySQL 8.4의 기본 설정 때문에 흔히 발생하는 동작이다.</p>
<hr>
<h2 id="6-임시-해결">6. 임시 해결</h2>
<p>소켓 경로를 직접 지정해 접속하면 문제가 해결된다.</p>
<pre><code>mysql -u root -S /tmp/mysql.sock</code></pre><p>그러나 이 방법은 매번 <code>-S</code> 옵션을 붙여야 하기 때문에 근본적인 해결은 아니다.</p>
<hr>
<h2 id="7-영구-해결-mycnf-수정">7. 영구 해결: my.cnf 수정</h2>
<p>MySQL 서버와 클라이언트가 동일한 소켓 경로를 사용하도록 <code>my.cnf</code> 파일을 수정했다.</p>
<pre><code>sudo nano /opt/homebrew/etc/my.cnf</code></pre><p>아래 설정을 작성했다.</p>
<pre><code class="language-cnf">[client]
socket = /opt/homebrew/var/mysql/mysql.sock

[mysqld]
socket = /opt/homebrew/var/mysql/mysql.sock
pid-file = /opt/homebrew/var/mysql/mysql.pid
datadir = /opt/homebrew/var/mysql</code></pre>
<p>작성 후 MySQL을 재시작했다.</p>
<pre><code>brew services restart mysql@8.4</code></pre><p>소켓 파일 생성 여부 확인:</p>
<pre><code>ls -al /opt/homebrew/var/mysql | grep sock</code></pre><p>소켓 파일이 정상 생성된 것을 확인한 뒤 다시 접속했다.</p>
<pre><code>mysql -u root</code></pre><p>문제 없이 접속되었다.</p>
<hr>
<h2 id="8-정리">8. 정리</h2>
<p>본 문제는 다음 두 가지 요인이 겹쳐 발생했다.</p>
<ol>
<li>Homebrew MySQL 설치 및 초기화 실패</li>
<li>MySQL 서버가 <code>/tmp/mysql.sock</code> 경로를 사용하지만, 클라이언트는 <code>/opt/homebrew/var/mysql/mysql.sock</code> 을 찾는 설정 불일치</li>
</ol>
<p>해결 방법은 다음과 같다.</p>
<ul>
<li>정확한 소켓 파일 위치를 찾는다.</li>
<li>필요하다면 my.cnf를 생성하여 서버와 클라이언트의 소켓 경로를 강제로 통일한다.</li>
<li>이후 MySQL을 재시작한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[FastAPI에서 정적 라우트가 404를 반환한 이유]]></title>
            <link>https://velog.io/@jini_1514/FastAPI%EC%97%90%EC%84%9C-%EC%A0%95%EC%A0%81-%EB%9D%BC%EC%9A%B0%ED%8A%B8%EA%B0%80-404%EB%A5%BC-%EB%B0%98%ED%99%98%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@jini_1514/FastAPI%EC%97%90%EC%84%9C-%EC%A0%95%EC%A0%81-%EB%9D%BC%EC%9A%B0%ED%8A%B8%EA%B0%80-404%EB%A5%BC-%EB%B0%98%ED%99%98%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Thu, 20 Nov 2025 16:39:27 GMT</pubDate>
            <description><![CDATA[<h3 id="code-동적-라우트가-urls를-가로채던-문제-해결-기록"><code>/{code}</code> 동적 라우트가 <code>/urls</code>를 가로채던 문제 해결 기록</h3>
<p>URL Shortener 프로젝트를 만들다가 예상치 못한 상황을 마주했다.
프론트엔드 대시보드에서 전체 URL 목록을 불러오기 위해 <code>/urls</code> 엔드포인트를 호출했는데, 계속 404가 떨어지는 문제가 발생했다.</p>
<pre><code>{
  &quot;detail&quot;: &quot;Short URL not found&quot;
}</code></pre><p>문제는 프론트엔드가 아니라 서버 라우터 쪽에 있었다.</p>
<hr>
<h2 id="1-증상">1. 증상</h2>
<p>Swagger와 curl에서도 동일하게 <code>/urls</code>가 404를 반환했다.</p>
<pre><code class="language-bash">curl https://url-shorter.onrender.com/urls
# → {&quot;detail&quot;: &quot;Short URL not found&quot;}</code></pre>
<p>FastAPI log에서도 <code>/urls</code> 요청이 들어갔는데, 내부에서는 완전히 다른 라우트가 실행되고 있었다.</p>
<hr>
<h2 id="2-원인-파악">2. 원인 파악</h2>
<p>결론부터 말하면 아래 라우트가 문제였다.</p>
<pre><code class="language-python">@app.get(&quot;/{code}&quot;)
def redirect(code: str):
    ...</code></pre>
<p>FastAPI는 라우트를 <strong>정의된 순서대로</strong> 검사한다.
<code>/{code}</code>는 어떤 문자열이든 모두 매칭되기 때문에 아래처럼 동작한다.</p>
<pre><code>GET /urls → code = &quot;urls&quot; 로 매칭됨</code></pre><p>그 결과 FastAPI는 <code>/urls</code>를 정적 라우트로 처리하지 않고, 동적 라우트로 인식한다.</p>
<p>즉,
<strong>&quot;/urls&quot;가 아닌 &quot;/{code}&quot;가 실행되고 있었던 것.</strong></p>
<p>그래서 DB에서 <code>&quot;urls&quot;</code>라는 short code를 찾다가 없어서
404(&quot;Short URL not found&quot;)가 내려온 것이다.</p>
<p>이 상황은 URL Shortener처럼 단일 동적 엔드포인트가 있는 서비스에서 쉽게 발생한다.</p>
<hr>
<h2 id="3-해결-방법--라우트-순서-재정렬">3. 해결 방법 – 라우트 순서 재정렬</h2>
<p>정적인 라우트를 동적 라우트보다 앞에 놓아야 한다.</p>
<p>잘못된 순서:</p>
<pre><code class="language-python">@app.get(&quot;/{code}&quot;)    # 동적
@app.get(&quot;/urls&quot;)      # 정적 (가려짐)</code></pre>
<p>올바른 순서:</p>
<pre><code class="language-python">@app.get(&quot;/urls&quot;)               # 가장 먼저
@app.get(&quot;/stats/{code}&quot;)       # 부분 동적
@app.get(&quot;/{code}&quot;)             # 마지막</code></pre>
<p>FastAPI는 선언 순서가 곧 우선순위이다.
정적 → 부분 동적 → 완전 동적 순으로 정리하면 충돌이 없다.</p>
<hr>
<h2 id="4-왜-이런-방식으로-동작하는가">4. 왜 이런 방식으로 동작하는가</h2>
<p>FastAPI의 라우팅은 Starlette 라우터를 기반으로 한다.
Starlette는 URL 매칭을 다음 기준으로 처리한다.</p>
<ol>
<li><strong>정적 경로</strong>(예: <code>/urls</code>, <code>/health</code>)</li>
<li><strong>부분 동적 경로</strong>(예: <code>/stats/{code}</code>)</li>
<li><strong>완전 동적 경로</strong>(예: <code>/{code}</code>)</li>
<li>와일드카드 (<code>/{path:path}</code>)</li>
</ol>
<p>문제는 “우선순위”가 아니라 <strong>코드 선언 순서가 우선 적용된다</strong>는 점이다.
동적 경로를 맨 위에 선언하면 정적 경로보다 먼저 검사되어 그대로 가로채 버린다.</p>
<hr>
<h2 id="5-실제-수정-후-결과">5. 실제 수정 후 결과</h2>
<p>라우트 순서를 다시 정렬한 뒤에는 다음과 같이 정상적으로 응답이 내려왔다.</p>
<pre><code>GET /urls → 정상 200  
GET /stats/abc123 → 정상 200  
GET /xyz789 → redirect 정상 작동</code></pre><p>정리된 구조는 아래와 같다.</p>
<pre><code class="language-python">@app.get(&quot;/urls&quot;)
def list_urls(): ...

@app.get(&quot;/stats/{code}&quot;)
def stats(code: str): ...

@app.get(&quot;/{code}&quot;)
def redirect(code: str): ...</code></pre>
<hr>
<h2 id="6-마무리">6. 마무리</h2>
<p>이번 문제는 URL Shortener에서 특히 자주 발생하는 라우트 충돌 이슈였다.
프레임워크의 라우팅 매칭 원리를 정확히 알고 있어야 해결할 수 있으며,
동적 라우트가 하나라도 포함되는 프로젝트라면 라우팅 순서를 항상 신경 써야 한다.</p>
<p>개발 과정에서 흔히 겪을 수 있는 오류이지만,
이 원리를 잘 이해해두면 FastAPI 구조 설계가 훨씬 깔끔해진다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js App Router에서 Dynamic Route params가 Promise로 들어오는 문제 해결 기록]]></title>
            <link>https://velog.io/@jini_1514/Next.js-App-Router%EC%97%90%EC%84%9C-Dynamic-Route-params%EA%B0%80-Promise%EB%A1%9C-%EB%93%A4%EC%96%B4%EC%98%A4%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EA%B8%B0%EB%A1%9D</link>
            <guid>https://velog.io/@jini_1514/Next.js-App-Router%EC%97%90%EC%84%9C-Dynamic-Route-params%EA%B0%80-Promise%EB%A1%9C-%EB%93%A4%EC%96%B4%EC%98%A4%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EA%B8%B0%EB%A1%9D</guid>
            <pubDate>Thu, 20 Nov 2025 16:21:39 GMT</pubDate>
            <description><![CDATA[<p>URL Shortener 프로젝트를 만들면서 <code>/stats/[code]</code> 페이지에서 특정 코드의 통계 정보를 불러오는 기능을 구현했다.
해당 부분에서  예상하지 못한 문제가 발생했다.</p>
<p>정리해보면, 다음과 같은 증상이 있었다.</p>
<ul>
<li>페이지는 열리지만 “Stats not found”가 표시된다.</li>
<li>네트워크 탭에서 API 호출을 보면 <code>/stats/undefined</code>로 요청이 나간다.</li>
<li>Vercel 로그에서는 <code>/stats/[code]</code> 라우트가 정상적으로 호출되지만, 실제 코드 값이 전달되지 않는다.</li>
<li>콘솔에서 다음 오류를 확인할 수 있었다:</li>
</ul>
<pre><code>Error: Route &quot;/stats/[code]&quot; used `params.code`. 
`params` is a Promise and must be unwrapped with `await` or `React.use()`</code></pre><p>이 문제의 근본 원인은 Next.js App Router의 params 전달 방식 때문이다.</p>
<hr>
<h2 id="문제-원인-params가-promise로-넘어온다">문제 원인: params가 Promise로 넘어온다</h2>
<p>Next.js 13 이후 App Router를 사용할 경우, 서버 컴포넌트에서 dynamic route의 <code>params</code>가 가끔 <strong>Promise 형태로 전달</strong>된다.
로컬 환경에서는 Promise가 아닌 일반 객체처럼 보이기 때문에 문제를 알아채기 어렵다.</p>
<p>기존에 사용했던 코드:</p>
<pre><code class="language-tsx">export default async function StatsPage({ params }) {
  const { code } = params;   // 여기서 문제 발생
}</code></pre>
<p>배포 후 콘솔을 찍어보면 다음과 같이 나온다.</p>
<pre><code>params = Promise { { code: &#39;qSmO2J&#39; }, ... }</code></pre><p>즉, 구조분해 할당으로 바로 값에 접근할 수 없기 때문에 <code>code</code>가 undefined가 되고, API 요청 역시 <code>/stats/undefined</code>로 나가게 된다.</p>
<hr>
<h2 id="해결-방법-params를-await로-언래핑">해결 방법: params를 await로 언래핑</h2>
<p>문제 해결은 간단하다.
<code>params</code> 자체가 Promise이므로, 먼저 기다린 뒤 값을 꺼내야 한다.</p>
<p>수정된 코드:</p>
<pre><code class="language-tsx">export default async function StatsPage({ params }: { params: Promise&lt;{ code: string }&gt; }) {
  const { code } = await params;

  const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/stats/${code}`, {
    cache: &quot;no-store&quot;,
  });

  if (!res.ok) {
    return &lt;div&gt;Stats not found for {code}&lt;/div&gt;;
  }

  const data = await res.json();

  return &lt;StatsView code={code} data={data} /&gt;;
}</code></pre>
<p>이렇게 수정한 뒤 다시 배포하니 문제는 바로 해결되었다.
더 이상 undefined가 전달되지 않고, API에서 받은 통계 정보가 정상적으로 렌더링된다.</p>
<hr>
<h2 id="추가로-배운-점">추가로 배운 점</h2>
<ol>
<li>App Router 기반에서 서버 컴포넌트의 dynamic params는 항상 Promise일 수 있다고 가정하는 것이 안전하다.</li>
<li>로컬 개발 환경과 Vercel 배포 환경이 완전히 동일하지 않을 수 있으니, params나 searchParams를 사용할 때는 콘솔로 형태를 확인하는 습관이 중요하다.</li>
<li>클라이언트 컴포넌트에서는 이런 문제가 발생하지 않지만, 서버 컴포넌트에서는 비동기 전달 이슈가 자주 등장할 수 있다.</li>
<li>이번 이슈는 Next.js 이슈 트래커에서도 언급될 정도로 흔히 발생하며, 버전에 따라 동작이 달라질 가능성이 있다.</li>
</ol>
<hr>
<h2 id="마무리">마무리</h2>
<p>처음에는 API URL 문제나 환경 변수 설정 오류를 의심했지만, 원인은 Next.js가 넘겨주는 params의 형태였다.
문제가 조금 돌아가는 듯 보였지만, 배포 환경에서 콘솔을 찍어보고 값을 직접 확인하면서 원인을 정확히 잡을 수 있었다.</p>
<p>동일한 구조로 Dynamic Route를 사용하는 경우라면, 서버 컴포넌트에서 <code>await params</code>를 적용하는 것만으로도 비슷한 문제를 예방할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[컴공생의 10시간 SQLD 도전기]]></title>
            <link>https://velog.io/@jini_1514/%EC%A0%84%EA%B3%B5%EC%9E%90%EC%9D%98-10%EC%8B%9C%EA%B0%84-SQLD-%EB%8F%84%EC%A0%84%EA%B8%B0</link>
            <guid>https://velog.io/@jini_1514/%EC%A0%84%EA%B3%B5%EC%9E%90%EC%9D%98-10%EC%8B%9C%EA%B0%84-SQLD-%EB%8F%84%EC%A0%84%EA%B8%B0</guid>
            <pubDate>Thu, 06 Nov 2025 14:10:53 GMT</pubDate>
            <description><![CDATA[<p>공부 방법은 다음과 같다 </p>
<h2 id="1-민트책---유선배-sql개발자sqld-과외노트">1. 민트책 - <a href="https://product.kyobobook.co.kr/detail/S000214793236">유선배 SQL개발자(SQLD) 과외노트</a></h2>
<p>개념서인데 문제도 꽤 많고 문제 해설도 유튜브에 잘 되어있어서 입문자도 보기 좋을듯!</p>
<p><img src="https://velog.velcdn.com/images/jini_1514/post/aa475150-c11b-4424-9425-848899761b05/image.png" alt=""></p>
<h2 id="2-노랭이---sql-자격검정-실전문제">2. 노랭이 - <a href="https://product.kyobobook.co.kr/detail/S000212021705">SQL 자격검정 실전문제</a></h2>
<p>소위 말하는 노랭이는 무려 시험 출제 기관에서 기출로 낸 문제집이니 적중률이 높다! 시험장에서 몇몇 문제는 똑같은것도 봤슨..
하지만... 무턱대로 노랭이를 폈다간 공부의지를 잃을수도 있으니 주의..</p>
<p>노랭이는 인터넷에서 노랭이해설.txt를 구해서 봤다
왜냐면... 내가 도서관에서 빌린 노랭이에 답지가 없었다...
답지에 답만 있는지 해설도 있는진 모르겠으나 저 해설이 꽤 도움이 되었다!</p>
<p><img src="https://velog.velcdn.com/images/jini_1514/post/d0766eac-1f56-4af2-812d-5e891ada90eb/image.png" alt=""></p>
<p>두 책 선정이유는 유명해서도 있지만 이유는 학교 도서관에 있어서이다.. 
참고로 23년도에 시험이 개정되어서 개정된걸로 봐야하지만 나는 그냥 전 걸로 봤다</p>
<h2 id="3-oracel---human-resources-데이터로-쿼리-연습하기">3. <a href="https://freesql.com/">Oracel - Human Resources</a> 데이터로 쿼리 연습하기</h2>
<p>freesql이라고 이미 구비된 데이터베이스에 쿼리 날려서 연습할 수 있는 사이트이다.
내 기억으로는 민트책이랑 노랭이 예시가 저 Oracel에서 무료로 제공하는 Human Resources 데이터에 쿼리를 날려서 이렇게 저렇게 조회하는 문제들이라서 저걸로 실제 해보면 좋다!
무작정 외우는 것보다 훨씬 훨씬 도움된다 </p>
<p>그 이유는 코딩은 같은 결과물이더라도 다른 방법의 코드가 존재하며, <code>선지 4개 중에 출력이 다른 것을 고르시오</code> 와 같은 문제도 나오기 때문!!</p>
<hr>
<h2 id="원트-이야기">원트 이야기</h2>
<p>사실 ... 현장실습을 하던 24-2학기때 시험을 봤었는데... 통과를 못했었다
당시 너무 바빴?고 자만감이 커서 공부를 거의 안하고 봤다
<img src="https://velog.velcdn.com/images/jini_1514/post/10eb2db9-d946-4a61-9f45-012540516293/image.png" alt="">
<del>돈아까운줄 모르고... 뗴잉</del></p>
<p>그때는 아마 개념 한번도 안돌고 기출도 한번도 안돌고 그냥 요약집만 보고 갔었다</p>
<hr>
<h2 id="이트-이야기">이트 이야기</h2>
<p>지금은! 개념도 한번 돌고 기출도 한번 돌아서 가기로~</p>
<p><img src="https://velog.velcdn.com/images/jini_1514/post/dcff5c59-c2ce-41c5-b30d-72a2fc4366e5/image.png" alt=""></p>
<p>계획을 다 지켰는지는 기억이 안난다..
약 열흘을 잡고 하루에 한시간씩 공부했다</p>
<p><img src="https://velog.velcdn.com/images/jini_1514/post/0ad4b814-ce0d-450b-a536-1f4526111606/image.png" alt=""></p>
<p>그럼 이젠 sql 안녕~
<img src="https://velog.velcdn.com/images/jini_1514/post/773dd446-e95a-4807-a5f3-10ba66245a56/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 눈물나는 OutOfMemoryError 해결기]]></title>
            <link>https://velog.io/@jini_1514/Android-%EB%88%88%EB%AC%BC%EB%82%98%EB%8A%94-OutOfMemoryError-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@jini_1514/Android-%EB%88%88%EB%AC%BC%EB%82%98%EB%8A%94-OutOfMemoryError-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Fri, 22 Aug 2025 07:53:22 GMT</pubDate>
            <description><![CDATA[<p>오늘은 안드로이드 앱에서 txt 로그 파일을 처리하다가 마주친 OutOfMemoryError를 해결한 경험을 공유해보려고 한다. </p>
<h1 id="문제의-시작-outofmemoryerror-발생">문제의 시작: OutOfMemoryError 발생</h1>
<p>안드로이드 앱에서 200MB가 넘는 대용량 TXT 파일을 읽어와 특정 GPS 정보만 파싱하는 로직을 만들었다. 그런데 파일을 선택하자마자 앱이 강제 종료되는 사건이 발생하였다. 로그를 확인해보니, java.lang.OutOfMemoryError가 문제였다.</p>
<p>200MB 파일을 한 번에 메모리에 올리려다 보니 앱에 할당된 메모리 한계를 초과하여 발생한 오류였다.
<img src="https://velog.velcdn.com/images/jini_1514/post/b1508d28-ad43-4813-b438-205e5678a748/image.png" alt=""></p>
<h1 id="첫-번째-시도-flow를-사용한-메모리-누적">첫 번째 시도: Flow를 사용한 메모리 누적</h1>
<p><code>OutOfMemoryError</code>를 해결하기 위해 <code>Flow</code>와 <code>collect</code>를 사용한 스트림 처리 방식을 도입했다. 파싱된 데이터를 <code>Flow</code>로 방출하고, <code>collect</code> 블록에서 리스트에 하나씩 추가하는 방식이었다.</p>
<pre><code class="language-kotlin">// FilePickerViewModel.kt (문제의 코드)
    fun readTextFromFileUri(uri: Uri?) {
           viewModelScope.launch {
            try {
                _parsedContent.value = emptyList() // 초기화
                val resultList = mutableListOf&lt;String&gt;()
                readFileAndParse(uri).collect { parsedLine -&gt;
                    resultList.add(parsedLine)
                    _parsedContent.value = resultList.toList() // UI에 업데이트
                }
                Timber.d(&quot;File parsing completed.&quot;)
                Timber.d(&quot;Parsed content: ${_parsedContent.value}&quot;)
            } catch (e: Exception) {
                e.printStackTrace()
                _parsedContent.value = listOf(&quot;파일을 읽는 중 오류가 발생했습니다: ${e.message}&quot;)
            }
        }
    }</code></pre>
<p>하지만 이 방식은 근본적인 문제를 해결하지 못했다. <code>resultList</code>는 메모리에 모든 파싱 데이터를 누적시켰고, <code>resultList.toList()</code>는 매번 새로운 리스트 객체를 생성하며 메모리를 더 많이 사용했다. </p>
<pre><code class="language-kotlin">/**
 * Returns a [List] containing all elements.
 */
public fun &lt;T&gt; Iterable&lt;T&gt;.toList(): List&lt;T&gt; {
    if (this is Collection) {
        return when (size) {
            0 -&gt; emptyList()
            1 -&gt; listOf(if (this is List) get(0) else iterator().next())
            else -&gt; this.toMutableList()
        }
    }
    return this.toMutableList().optimizeReadOnlyList()
}</code></pre>
<p><strong><code>toList()</code>는 어떤 경우든 기존의 리스트를 변경하지 않고, 모든 요소를 복사하여 새로운 리스트를 생성한다.</strong></p>
<p>결국 수십만 개의 라인을 처리하다가 다시 <code>OutOfMemoryError</code>가 발생한 거다.</p>
<p>운이 좋아 메모리가 널널하다면 파싱은 잘된다... 하지만 운이 나빠 메모리가 부족하다면 앱이 터진다. 이는 백퍼 QA에서 뚜까뚜까 맞을 앱이다. </p>
<blockquote>
<p><code>Flow</code>를 사용하더라도 <code>collect</code> 블록 안에서 모든 데이터를 메모리에 쌓는 건 <code>OutOfMemoryError</code>의 지름길이다.</p>
</blockquote>
<h1 id="두-번째-문제-ui-상태-업데이트-지연">두 번째 문제: UI 상태 업데이트 지연</h1>
<p>메모리 문제를 해결하는 과정에서 또 다른 문제가 발견됐다. 파일을 선택하면 화면이 즉시 업데이트되지 않고, 잠시 동안 검은 화면이 나타났다.</p>
<p>_앱 화면 -&gt; 파일 선택기 -&gt; 파일 선택기에서 한참 있다가 -&gt; 검은 화면에서 한참 있다가 -&gt; 앱 화면으로 돌아온다 
_내가 뷰모델에서 설정한 UiState가 전혀 반영되고 있지 않았다. </p>
<p><img src="https://velog.velcdn.com/images/jini_1514/post/752476d3-54c0-48d5-b2a1-ff66d53e64f7/image.png" alt=""></p>
<p>난 바보다. 파일 파싱을 메인 스레드에서 하고 앉아 있었던 것이다. UIstate 자체는 viewModelScope의 메인 스레드에서 업데이트하는게 맞지만, 실제 파일 읽기 작업 또한 메인 스레드에서 하고 있어버리니 <strong>메인스레드가 막혀 버린 것이다.</strong></p>
<p>그래서 급하게 flow에 Dispatchers.IO를 달아준다.</p>
<pre><code class="language-kotlin">// FilePickerViewModel.kt (문제의 코드)
private fun readFileAndParse(uri: Uri): Flow&lt;GpsData&gt; = flow {
    withContext(Dispatchers.IO) { /* ... */ } // ❌ 이 부분이 문제!
}</code></pre>
<p>하지만... 이는 반쪽짜리 해결책이었다. </p>
<p><img src="https://velog.velcdn.com/images/jini_1514/post/a95166ba-aeb9-4591-8937-7b4284cbfdb0/image.png" alt=""></p>
<p><code>Flow</code> 빌더 내부에 <code>withContext(Dispatchers.IO)</code>를 사용하면 <code>emit</code>이 IO 스레드에서 발생하고 <code>collect</code>는 메인 스레드에서 발생하여 <strong><code>Flow</code>의 컨텍스트 보존 규칙을 위반하는 오류가 발생한 것</strong>이다.</p>
<hr>
<h1 id="최종-해결책-flowon을-사용한-올바른-스레드-분리">최종 해결책: <code>flowOn</code>을 사용한 올바른 스레드 분리</h1>
<p><code>withContext</code> 대신 <code>flowOn</code> 연산자를 사용해 문제를 해결했다. <code>flowOn</code>은 <code>Flow</code>의 업스트림(파일 읽기/파싱) 실행 컨텍스트를 백그라운드 스레드로 전환해 준다.</p>
<pre><code class="language-kotlin">// FilePickerViewModel.kt (최종 해결책)
    fun readTextFromFileUri(uri: Uri?) {
       viewModelScope.launch {
            _state.value = UiState.Loading
            try {
                // Flow의 실행 컨텍스트를 IO 스레드로 전환
                val resultList = mutableListOf&lt;GpsData&gt;()
                readFileAndParse(uri)
                    .flowOn(Dispatchers.IO)
                    .onStart { _state.value = UiState.Loading }
                    .collect { gpsData -&gt;
                        resultList.add(gpsData)
                        _state.value = UiState.Success(FilePickerState(resultList.toList()))
                        Timber.d(&quot;Parsed items: ${resultList.size}&quot;)
                    }
            } catch (e: Exception) {
                e.printStackTrace()
                _state.value = UiState.Error(&quot;파일을 읽는 중 오류가 발생했습니다: ${e.message}&quot;)
            }
        }
    }</code></pre>
<h1 id="아직도-미해결">아직도 미해결</h1>
<p>테스트를 해보던 중.. 아직도 OOM이 터지는것을 알게되었다.. 사실 알고 싶지 않았다.</p>
<p>그래서 코드를 gemini한테 주고 문제점을 알려달라고 했더니, distinct가 문제라고 했다. <code>distinct()</code>가 전체 파일을 메모리에 로드하기 때문에 터지는 것으로 파악했다. </p>
<pre><code class="language-kotlin">public fun &lt;T&gt; Sequence&lt;T&gt;.distinct(): Sequence&lt;T&gt; {
    return this.distinctBy { it }
}</code></pre>
<h1 id="결론">결론</h1>
<blockquote>
<ul>
<li>메모리 누적 문제: Flow를 사용하더라도 collect 내부에서 모든 데이터를 메모리에 누적하면 OutOfMemoryError가 발생한다.</li>
</ul>
</blockquote>
<ul>
<li>UI 블로킹 문제: 파일 읽기/쓰기 같은 I/O 작업은 flowOn(Dispatchers.IO)를 사용해 백그라운드 스레드로 분리해야 한다.</li>
<li>UI 상태 관리: Flow의 onStart 연산자를 사용해 로딩 상태를 먼저 발행하고, collect 블록에서 데이터가 들어올 때마다 UI 상태를 업데이트하여 사용자에게 즉각적인 피드백을 제공해야 한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Surface에서는 recomposition이 안돼요]]></title>
            <link>https://velog.io/@jini_1514/Android-Surface%EC%97%90%EC%84%9C%EB%8A%94-recomposition%EC%9D%B4-%EC%95%88%EB%8F%BC%EC%9A%94</link>
            <guid>https://velog.io/@jini_1514/Android-Surface%EC%97%90%EC%84%9C%EB%8A%94-recomposition%EC%9D%B4-%EC%95%88%EB%8F%BC%EC%9A%94</guid>
            <pubDate>Fri, 08 Aug 2025 14:38:56 GMT</pubDate>
            <description><![CDATA[<p>Compose를 이용하면 component로 custom view를 자유자재로 만들 수 있다. material component를 재정의해서 쓸 수도 있으며, 아예 처음부터 만들 수도 있다. 이런 자유로운 뷰 구성이 Compose의 가장 큰 장점이라고 체감한다.</p>
<p>RadioButton 처럼 하나만 클릭되는 버튼들을 커스텀으로 제작하는 중 마주한 오류가 있다. 분명히 클릭 로그는 찍히는데, UI상으로 변화가 없다..! </p>
<p>이는 컴포넌트의 최상단이 Box가 아닌 Surface로 지정해서 생긴 문제였다. 좀 더 자세히 알아보자.</p>
<h2 id="surface의-내부-최적화에-따른-recomposition-누락"><code>Surface</code>의 내부 최적화에 따른 recomposition 누락</h2>
<p>Compose의 <code>Surface</code>는 내부적으로 색상, elevation, 클릭 효과 등을 처리하면서 최적화를 수행한다.
이 과정에서 상태 변화가 있더라도 <strong>UI에 변화가 없다고 판단되면</strong> <code>draw</code>를 생략하여 화면이 갱신되지 않을 수 있다.</p>
<hr>
<h2 id="surface와-box의-차이">Surface와 Box의 차이</h2>
<p>Compose에서 <code>Surface</code>와 <code>Box</code>는 모두 UI를 구성하는 레이아웃 요소지만, 내부 작동 방식과 recomposition 최적화 처리에서 큰 차이를 보인다. 이번 사례에서 <code>Surface</code>는 상태 변화에 따라 UI가 <em>눈에 보이게</em> 변하지 않았으며, 그 이유는 아래와 같다.</p>
<p><code>Surface</code>는 내부적으로 머티리얼 레이어 계층, 색상, 그림자, 클릭 등을 종합적으로 처리한다. 이 과정에서 재조합 최적화가 발생하여, 상태 변화가 시각적으로 드러나지 않을 수 있다.</p>
<p>반면, <code>Box</code>는 단순한 컨테이너로 동작하므로 상태 변화가 곧바로 시각적 변화로 이어진다.</p>
<hr>
<h2 id="surface의-내부-동작">Surface의 내부 동작</h2>
<p><code>Surface</code>는 단순한 박스가 아니라 다음과 같은 기능을 자동으로 포함한다.</p>
<table>
<thead>
<tr>
<th>기능</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>색상 블렌딩</td>
<td>elevation, background color에 따라 색상이 자동으로 섞인다</td>
</tr>
<tr>
<td>그림자 처리</td>
<td>머티리얼 스타일의 그림자(elevation)를 렌더링한다</td>
</tr>
<tr>
<td>Ripple 효과</td>
<td>클릭 시 Ripple 효과를 기본으로 제공한다</td>
</tr>
<tr>
<td>ContentColor 제공</td>
<td>내부의 <code>Text</code>, <code>Icon</code> 등에 <code>LocalContentColor</code>를 주입한다</td>
</tr>
<tr>
<td>CompositionLocal 변경</td>
<td>내부적으로 <code>LocalContentColor</code>, <code>LocalAbsoluteTonalElevation</code> 등의 context 값을 변경한다</td>
</tr>
</tbody></table>
<h3 id="결과적으로">결과적으로</h3>
<ul>
<li>많은 처리를 Compose 내부 composition scope에 위임한다.</li>
<li>상태 변경에 따른 색상 변화가 명확하지 않으면 Compose는 “변화 없음”으로 판단하여 재조합을 생략하거나 draw 단계를 건너뛴다.</li>
</ul>
<hr>
<h2 id="box의-내부-동작">Box의 내부 동작</h2>
<p><code>Box</code>는 단순한 layout container로, 다음과 같은 특징이 있다.</p>
<ul>
<li>배경색, border, 클릭 등을 모두 개발자가 수동으로 제어해야 한다.</li>
<li>상태가 변경되면 Compose는 모든 Modifier를 다시 적용하고 draw 단계도 무조건 실행한다.</li>
</ul>
<hr>
<h2 id="실제-발생한-현상">실제 발생한 현상</h2>
<pre><code class="language-kotlin">Surface(
  color = if (isSelected) X else Y,
  border = ...
)</code></pre>
<p>이 경우, <code>color</code>, <code>border</code>, <code>shape</code>, <code>tonalElevation</code> 등이 종합적으로 처리된다. 이 중 draw layer가 동일하다고 판단되면 Compose는 화면을 다시 그리지 않는다.
따라서 로그는 나오지만 실제 화면에는 아무런 변화도 보이지 않게 된다.</p>
<hr>
<h2 id="실험으로-확인하는-방법">실험으로 확인하는 방법</h2>
<pre><code class="language-kotlin">Surface(
    color = if (isSelected) Color.Red else Color.Blue, // 확실한 색상 차이
    border = BorderStroke(2.dp, Color.Magenta), // 극단적인 변화
    modifier = Modifier.clickable { onSelect() }
) {
    Text(text = &quot;테스트&quot;, modifier = Modifier.padding(20.dp))
}</code></pre>
<p>이렇게 설정했음에도 UI가 변하지 않는다면, 이는 Surface 내부의 최적화 때문이라고 볼 수 있다.</p>
<hr>
<h2 id="결론-surface-vs-box">결론: Surface vs Box</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>Surface</th>
<th>Box</th>
</tr>
</thead>
<tbody><tr>
<td>내부 최적화</td>
<td>많음 (색상, Elevation, Context 등)</td>
<td>거의 없음</td>
</tr>
<tr>
<td>Draw 생략 가능성</td>
<td>높음</td>
<td>낮음</td>
</tr>
<tr>
<td>직접 제어</td>
<td>추상화되어 있음</td>
<td>명확하게 제어 가능</td>
</tr>
<tr>
<td>상태 변화 반영</td>
<td>무시될 가능성 있음</td>
<td>즉시 반영됨</td>
</tr>
</tbody></table>
<p><code>Surface</code>는 복잡한 머티리얼 레이어를 제공하므로, 간단한 상태 기반 UI 커스터마이징에는 <code>Box</code>와 <code>Modifier</code> 조합이 더 안정적이다.</p>
<p>특히 색상이나 border로만 상태를 표현하는 경우, <code>Surface</code>의 내부 최적화로 인해 오히려 recomposition 누락 문제가 발생할 수 있다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>UI 컴포넌트를 만들 때는 <code>Surface</code> 말고 <code>Box</code>를 사용하자!!!! 리컴포지션 해야한다!!! </p>
<blockquote>
<p>결론: Surface는 상태 변화가 있어도 재조합을 생략하는 경우가 많기 때문에, 상태 기반 UI 컴포넌트에는 적합하지 않다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[안드로이드 프로그래밍 Next Step(노재춘) : 2장 메인스레드와 Handler]]></title>
            <link>https://velog.io/@jini_1514/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-Next-Step%EB%85%B8%EC%9E%AC%EC%B6%98-2%EC%9E%A5-%EB%A9%94%EC%9D%B8%EC%8A%A4%EB%A0%88%EB%93%9C%EC%99%80-Handler</link>
            <guid>https://velog.io/@jini_1514/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-Next-Step%EB%85%B8%EC%9E%AC%EC%B6%98-2%EC%9E%A5-%EB%A9%94%EC%9D%B8%EC%8A%A4%EB%A0%88%EB%93%9C%EC%99%80-Handler</guid>
            <pubDate>Fri, 08 Aug 2025 13:20:20 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jini_1514/post/ba7b29e9-badf-4271-8aa3-4671244d5c29/image.png" alt=""></p>
<p>Handler
Looper
Message
MessageQueue</p>
<blockquote>
<p>Handler는 메인 Looper와 연결되어 메인스레드에서 Message를 처리하는 중심 역할을 한다. </p>
</blockquote>
<h2 id="ui-스레드">UI 스레드</h2>
<ul>
<li>UI 업데이트는 단일스레드</li>
<li>메인스레드 = UI스레드</li>
<li>UI 뿐만 아니라 서비스, 리시버, Application도 UI 스레드에서</li>
</ul>
<h2 id="루퍼">루퍼</h2>
<ul>
<li>Looper는 TLS(Thread local storage)에 저장되고 꺼내진다 </li>
<li>Looper는 각각의 MessageQueue를 가진
다</li>
</ul>
<h2 id="메시지와-메시지큐">메시지와 메시지큐</h2>
<ul>
<li>메시지큐는 메시지가 실행 타임스탬프순으로 삽입되고 링크로 연결되어 실행시간이 빠른것부터 순차적으로 꺼내어진다</li>
</ul>
<h2 id="핸들러">핸들러</h2>
<ul>
<li>핸들러는 메시지를 메시지큐에 넣는 기능 / 메시지큐에서 꺼내 처리하는 기능</li>
<li>핸들러와 루퍼는 연결되어있음 </li>
<li>핸들러 생성자 종류가 있다.<ol>
<li>기본 생성자 -&gt; 기본 스레드 -&gt; 메인 루퍼</li>
<li>백그라운드 스레드 -&gt; 기본 생성자 못씀. 그 스레드에서 쓸 루퍼를 따로 준비해아함 </li>
</ol>
</li>
<li>핸들러는 UI 갱신을 위해서 씀<ol>
<li>백그라운드 스레드에서의 UI 업데이트</li>
<li>메인스레드에서 다음 작업 예약</li>
<li>반복 UI 갱신</li>
<li>시간 제한</li>
</ol>
</li>
<li>실행 시점을 보장하지는 않음. 앞선 작업이 오래걸리면 밀림. 단일스레드라서</li>
</ul>
<h2 id="anr">ANR</h2>
<ul>
<li>ANR (Application Not Responding) 어느 동작이 메인스레드를 너무 오랜시간동안 점유하고 있다</li>
<li>ANR 앱과 무관하게 기기의 사양에 따라 발생할 수도 있..음.</li>
<li>명시하지 않으면 1분 </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[안드로이드 프로그래밍 Next Step(노재춘) : 1장 안드로이드 프레임워크]]></title>
            <link>https://velog.io/@jini_1514/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-Next-Step%EB%85%B8%EC%9E%AC%EC%B6%98-1%EC%9E%A5-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC</link>
            <guid>https://velog.io/@jini_1514/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-Next-Step%EB%85%B8%EC%9E%AC%EC%B6%98-1%EC%9E%A5-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC</guid>
            <pubDate>Fri, 08 Aug 2025 12:31:30 GMT</pubDate>
            <description><![CDATA[<p>22년 하반기부터 해서 안드로이드를 한지 어언 3년이 되어간다. 대학생이었고, 중간에 다른 프레임워크 개발(스프링부트, 플러터, 파이썬..)도 했으니 3년 꼬박한건 아니지만 그래도 어느정도 짬이 찼다고 생각한다.</p>
<p>한때 안드로이드에 대해 우매함의 봉우리에 있었던 적이 있었다. 당시는 23년이었던걸로 기억한다. 이제는 겸손함을 겸비해 아직도 내가 부족하고, 깊이 있게 알지 못한다는 사실을 안다. 
<img src="https://velog.velcdn.com/images/jini_1514/post/e6fa53a3-8abb-43d2-97ff-2ffbe1c31d5b/image.png" alt=""></p>
<p>안드로이드 직무로 인턴 근무도 하게 되었고, 좀더 깊이 있는 지식이 있으면 좋을 시기라 생각되어... 안드로이드 딥다이브를 시작한다! </p>
<p>엄재웅님의 매니페스트 안드로이드 인터뷰 책을 볼까 하다가, 그래도 실물 책을 가지고 있는게 있어서 <a href="https://www.yes24.com/product/goods/41085242">이 책</a>으로 진행해보려고 한다. 이 책은 안드로이드 고수 정00에게 추천받은 책이다. 책은 17년도에 출간되어서 코루틴, 컴포즈 등 최신 기술 이야기는 없지만 근본적인 이야기로 구성되어 있다. </p>
<p>그리고 무엇보다도 책이 얇다!! 책이 얇다는 것은 빨리 끝낼 수 있어 성취감을 크게 느낄 수 있고 또 출퇴근 짬을 통해서도 볼 수 있다는 의미다~
<img src="https://velog.velcdn.com/images/jini_1514/post/14be0c09-65bd-475e-a36c-bec52e43ed70/image.png" alt=""></p>
<p>아무튼 시작~</p>
<hr>
<p>다시금 안드로이드 지식 빈곤기를 맞이한 미래의 나를 위해서, 모르는 내용 위주로 기록해보려고 한다!</p>
<hr>
<ul>
<li>Context가 Acvtiviy, Service, Application의 상위클래스임</li>
<li>하드웨어 제어나 빠른 속도가 필요한 것은 JNI을 연결해서 네이티브 C/C++코드를 사용</li>
<li>타겟sdk를 지정하지 않으면 minsdk와 동일하게 된다</li>
<li>타겟sdk 14이상일때 앱 아이콘에 기본 패딩이 들어감</li>
<li>허니콤부터는 AsyncTask가 병렬실행에서 순차 실행으로 바뀌었다. 닉값 못하네;</li>
<li>메인스레드에서 네트워크 통신하면 에러 발생함</li>
<li>명시적 인텐트로 서비스 시작
startService나 bindService를 쓸 때 명시적 인텐트를 써야한다. 암시적은 예외 발생</li>
<li>낮은 버전에서 안되는 코드에 크래시 발생을 막기 위해서 버전에 따른 분기처리를 한다 </li>
<li>SharedPreference에서 apply는 비동기고 commit은 동기 반영이다</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 무슨 일이 있어도 BLE 통신이 살아있게 해주세요]]></title>
            <link>https://velog.io/@jini_1514/Android-Service%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@jini_1514/Android-Service%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Fri, 08 Aug 2025 12:20:00 GMT</pubDate>
            <description><![CDATA[<p>안드로이드의 4대 컴포넌트는 다음과 같다.</p>
<ul>
<li>Activity</li>
<li>Service</li>
<li>BroadcastReceiver</li>
<li>ContentProvider</li>
</ul>
<p>이 중 <strong>Service</strong>는 화면이 없는 컴포넌트다.
사용자와 직접 상호작용하지 않지만, 앱의 “뒤에서 돌아가야 하는 작업”을 담당한다.</p>
<p>이 글에서는 <strong>“무슨 일이 있어도 BLE 통신이 살아 있어야 한다”</strong>라는 실제 개발 요구사항을 통해
Service, 특히 <strong>Foreground Service</strong>가 왜 필요했는지를 정리해본다.</p>
<hr>
<h2 id="개발-요구사항-무슨-일이-있어도-ble-통신이-살아있게-해주세요">개발 요구사항: 무슨 일이 있어도 BLE 통신이 살아있게 해주세요</h2>
<p>Service를 처음 제대로 다뤄본 계기는 스타트업 인턴 시절 받은 첫 번째 과제였다.</p>
<p>요구사항은 대략 다음과 같았다.</p>
<blockquote>
<p>해외 전시회에서 회사의 전기자전거용 속도계를 시연해야 한다.
실제 전기자전거를 해외로 가져갈 수 없으니,
안드로이드 앱에서 전기자전거 신호를 흉내 내어
BLE 통신을 보내는 앱을 만들어라.</p>
<p>단, 부스 운영 중에는 앱이 백그라운드로 가도
<strong>절대 꺼지면 안 된다.</strong></p>
</blockquote>
<p>처음 들었을 때는 다소 추상적인 요구처럼 들렸지만, 이를 개발 용어로 정리하면 훨씬 명확해진다.</p>
<hr>
<h2 id="요구사항을-개발-용어로-풀어보면">요구사항을 개발 용어로 풀어보면</h2>
<p>위 요구사항을 정리하면 다음과 같다.</p>
<blockquote>
<p>기기를 잠그거나 앱이 포커싱되지 않은 상태에서도
BLE subscriber에게 notification이 계속 전달되어야 한다.</p>
</blockquote>
<p>여기서 중요한 포인트는 두 가지다.</p>
<h3 id="1-ble-notification">1. BLE notification</h3>
<ul>
<li>앱이 임베디드 장비(자전거 속도계 프로토타입)에게
주기적으로 신호를 보내는 행위</li>
<li>BLE GATT 통신에서의 <strong>Notify</strong></li>
</ul>
<h3 id="2-포커싱이-없는-상태">2. 포커싱이 없는 상태</h3>
<ul>
<li>Activity 기준으로 <code>onStop()</code> 상태</li>
<li>즉, 화면에 보이지 않는 상태</li>
<li><code>onDestroy()</code>만 아니라면 <strong>계속 BLE 통신이 유지되어야 함</strong></li>
</ul>
<p>이 시점에서 Activity만으로는 요구사항을 만족할 수 없다는 것이 분명해진다.</p>
<hr>
<h2 id="왜-activity로는-안-되는가">왜 Activity로는 안 되는가</h2>
<p>Activity는 사용자 인터페이스를 담당하는 컴포넌트다.</p>
<ul>
<li>화면이 가려지면 <code>onStop()</code></li>
<li>시스템 상황에 따라 언제든 종료될 수 있음</li>
<li>백그라운드 장시간 작업에 부적합</li>
</ul>
<p>즉, <strong>“좀비처럼 살아 있어야 하는 작업”</strong>을 Activity에 두는 것은 구조적으로 잘못된 선택이다.</p>
<p>이때 등장하는 컴포넌트가 바로 <strong>Service</strong>다.</p>
<hr>
<h2 id="service란-무엇인가">Service란 무엇인가</h2>
<p>Service는 다음과 같은 특징을 가진다.</p>
<ul>
<li>화면이 없음</li>
<li>백그라운드에서 작업 수행 가능</li>
<li>Activity와 독립적인 생명주기</li>
</ul>
<p>하지만 여기서 한 가지 더 중요한 사실이 있다.</p>
<blockquote>
<p><strong>일반 Service는 백그라운드에서 오래 살아남지 못한다.</strong></p>
</blockquote>
<p>안드로이드는 배터리와 리소스를 보호하기 위해
백그라운드에서 오래 실행되는 작업을 적극적으로 종료한다.</p>
<p>그래서 BLE처럼 “지속적이고 중요한 작업”에는 <strong>Foreground Service</strong>가 필요하다.</p>
<hr>
<h2 id="좀비처럼-살아있게-하기-foreground-service">좀비처럼 살아있게 하기: Foreground Service</h2>
<p>Foreground Service는 <strong>사용자에게 명시적으로 알리고, 시스템에게도 중요하다고 선언하는 Service</strong>다.</p>
<ul>
<li>알림(Notification)을 반드시 표시해야 함</li>
<li>시스템이 쉽게 종료하지 않음</li>
<li>BLE, 음악 재생, 위치 추적 등에 사용됨</li>
</ul>
<p>이를 위해 사용되는 핵심 API가 <code>startForeground()</code>다.</p>
<hr>
<h2 id="foreground-service-구현">Foreground Service 구현</h2>
<p>BLE 통신을 담당하는 Service는 다음과 같이 구현했다.</p>
<pre><code class="language-kotlin">class BluetoothService(
    context: Context,
) : Service() {

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val notification = createNotification()
        startForeground(1, notification)

        return START_STICKY
    }
}</code></pre>
<h3 id="핵심-포인트-정리">핵심 포인트 정리</h3>
<ul>
<li><code>startForeground()</code>
→ 이 Service를 Foreground Service로 승격</li>
<li>Notification 필수
→ 사용자에게 “이 작업이 실행 중”임을 알림</li>
<li><code>START_STICKY</code>
→ 시스템이 Service를 종료하더라도
가능한 한 다시 재시작</li>
</ul>
<p>이 설정 덕분에,</p>
<ul>
<li>화면이 꺼져도</li>
<li>앱이 백그라운드에 있어도</li>
<li>포커싱이 없어도</li>
</ul>
<p>BLE 통신은 계속 유지될 수 있었다.</p>
<hr>
<h2 id="manifest-등록도-필수">Manifest 등록도 필수</h2>
<p>Foreground Service는 Manifest에도 명시해야 한다.</p>
<pre><code class="language-xml">&lt;service
    android:name=&quot;.bluetooth.BluetoothService&quot;
    android:foregroundServiceType=&quot;dataSync&quot; /&gt;</code></pre>
<ul>
<li><code>foregroundServiceType</code>
→ 서비스의 목적을 시스템에 명시</li>
<li>BLE 통신은 <code>dataSync</code> 유형에 해당</li>
</ul>
<p>이 설정이 없으면 Android 9(API 28) 이상에서 정상 동작하지 않는다.</p>
<hr>
<h2 id="정리하며">정리하며</h2>
<p>이 경험을 통해 Service를 이렇게 정리하게 되었다.</p>
<ul>
<li>Activity는 화면을 위한 컴포넌트다.</li>
<li>백그라운드에서 “계속 살아 있어야 하는 작업”은 Service의 책임이다.</li>
<li>단순 Service로는 부족하고, <strong>사용자와 시스템 모두에게 중요함을 알리는 Foreground Service</strong>가 필요할 때가 있다.</li>
</ul>
<p>Service는 단순히 “백그라운드 작업용” 컴포넌트가 아니라, <strong>안드로이드 시스템과 협상하는 방식</strong>에 가깝다.</p>
<p>BLE 통신 요구사항 하나를 만족시키기 위해
Service, Foreground Service, 생명주기를 함께 고민했던 경험은
이후 안드로이드 구조를 이해하는 데 큰 도움이 되었다.</p>
<hr>
<h3 id="작성한-코드">작성한 코드</h3>
<p>[BluetoothService 전체 코드]
(<a href="https://github.com/HI-JIN2/virtual-bicycle-with-ble/blob/master/app/src/main/java/com/eddy/nrf/bluetooth/BluetoothService.kt">https://github.com/HI-JIN2/virtual-bicycle-with-ble/blob/master/app/src/main/java/com/eddy/nrf/bluetooth/BluetoothService.kt</a>)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] UseCaseModule 만들어야 할까? ]]></title>
            <link>https://velog.io/@jini_1514/Android-UseCaseModule-%EB%A7%8C%EB%93%A4%EC%96%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@jini_1514/Android-UseCaseModule-%EB%A7%8C%EB%93%A4%EC%96%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Fri, 08 Aug 2025 04:24:36 GMT</pubDate>
            <description><![CDATA[<h2 id="의존성-주입에-대해">의존성 주입에 대해</h2>
<p>먼저, 의존성 주입(Dependency Injection, DI)에 대해 설명하겠다.
의존성 주입은 여러 컴포넌트 간 의존성이 강한 개발 환경에서 <strong>클래스 간 결합도를 낮춰주는 멋진 도구</strong>다.</p>
<p>객체를 생성할 때는 자연스럽게 클래스 간 의존성이 생긴다. 예를 들어, A 객체를 만들기 위해 B 객체가 필요하다면, A 객체를 생성하는 시점에 <strong>불필요하게 B 객체까지 알아야 하는</strong> 상황이 발생한다. 이런 식으로 의존 관계가 꼬리를 물고 이어진다.</p>
<pre><code class="language-kotlin">// main.kt

fun main() {
    val b = B()
    val a = A(b)
}

class A(val b: B) {
    // 어쩌구 저쩌구
}

class B {
    // 어쩌구 저쩌구
}</code></pre>
<p>이때 <strong>A 클래스에 의존성 주입을 적용하면</strong>, <code>main</code>에서 B를 몰라도 되는 아름다운 일이 생긴다.
정리하자면, <strong>불필요한 의존 관계를 줄이기 위해 의존성 주입을 쓰는 것</strong>이다.</p>
<pre><code class="language-kotlin">// main.kt

fun main() {
    val a = A()
}

class A @Inject constructor(val b: B) {
    // 어쩌구 저쩌구
}

class B {
    // 어쩌구 저쩌구
}</code></pre>
<p>의존성 주입을 위해 안드로이드에서는 주로 <strong>Hilt</strong>라는 라이브러리를 사용한다.</p>
<hr>
<h2 id="방법-1">방법 1</h2>
<p>오늘 이야기할 주제는 바로 <strong>&quot;UseCaseModule을 꼭 만들어야 할까?&quot;</strong> 이다.</p>
<p>미리 정답을 말하자면, 아니다.</p>
<hr>
<p>의존성 주입을 하려면 보통 아래처럼 <code>@Binds</code>를 이용한 모듈 클래스를 만들고, 빌드 시점에 필요한 의존성들을 한 번에 생성한다.</p>
<pre><code class="language-kotlin">@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {

    @Binds
    internal abstract fun bindsUserRepository(
        oauthRepositoryImpl: UserRepositoryImpl,
    ): UserRepository
}</code></pre>
<p>이렇게 세팅해두면, 아래처럼 <code>@Inject constructor</code>로 바로 꺼내 쓸 수 있다.</p>
<pre><code class="language-kotlin">class GetUserInfoUseCase @Inject constructor(
    private val repo: UserRepository
) {
    suspend operator fun invoke(): Result&lt;User&gt; {
        return repo.getUser()
    }
}</code></pre>
<p><code>@Inject</code>는 Hilt에 포함된 어노테이션이지만,
<strong>domain 계층에서는 안드로이드 라이브러리에 대한 의존성이 없어야 한다.</strong>
그렇다면 어떻게 해야 할까?</p>
<pre><code class="language-kotlin">implementation(&quot;javax.inject:javax.inject:1&quot;)</code></pre>
<p>이렇게 하면 된다.
필요한 건 <code>@Inject</code>뿐이므로, 불필요하게 Hilt 전체를 끌어오지 않아도 된다.</p>
<hr>
<h2 id="방법-2">방법 2</h2>
<p>다른 방법으로는 <strong>UseCase 전용 모듈을 만드는 방식</strong>이 있다.</p>
<pre><code class="language-kotlin">@Module
@InstallIn(SingletonComponent::class)
object UseCaseModule {

    @Provides
    @Singleton
    fun provideGetUserInfoUseCase(
        repo: UserRepository
    ): GetUserInfoUseCase = GetUserInfoUseCase(repo)
}</code></pre>
<p>이렇게 하면 아래과 도메인 모듈에서<code>@Inject</code> 없이도 사용할 수 있지만…
<strong>UseCase를 하나 추가할 때마다 <code>provide</code> 함수를 또 작성해야 한다</strong>는 점이 개인적으로는 마음에 들지 않는다.</p>
<pre><code class="language-kotlin">class GetUserInfoUseCase(
    private val repo: UserRepository
) {
    suspend operator fun invoke(): Result&lt;User&gt; {
        return repo.getUser()
    }
}</code></pre>
<p>그래서 필자는 방법 1. <strong><code>@Inject</code> 의존성만 추가하는 방식</strong>으로 결정했다!</p>
]]></description>
        </item>
    </channel>
</rss>