<?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>Sun, 21 Jun 2026 04:34:43 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[[Kotlin] by lazy와 lateinit]]></title>
            <link>https://velog.io/@jini_1514/Kotlin-by-lazy%EC%99%80-lateinit</link>
            <guid>https://velog.io/@jini_1514/Kotlin-by-lazy%EC%99%80-lateinit</guid>
            <pubDate>Sun, 21 Jun 2026 04:34:43 GMT</pubDate>
            <description><![CDATA[<p>둘다 초기화를 나중에 한다는 공통점이 있지만, 구체적인 쓰임이 다르다.</p>
<table>
<thead>
<tr>
<th>lateinit</th>
<th>by lazy</th>
</tr>
</thead>
<tbody><tr>
<td>var 재할당 가능</td>
<td>val 재할당 불가능</td>
</tr>
<tr>
<td>개발자가 직접 초기화</td>
<td>접근시 자동으로 초기화</td>
</tr>
</tbody></table>
<h2 id="lateinit">lateinit</h2>
<pre><code class="language-kotlin">class MainActivity : ComponentActivity() {

    private lateinit var analytics: AnalyticsLogger

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        analytics = AnalyticsLogger(this)

        setContent {
            HomeScreen(
                onClick = {
                    analytics.log(&quot;home_button_click&quot;)
                }
            )
        }
    }
}</code></pre>
<p>onCreate 함수 안에서 직접 초기화 한다
그렇게 하는 이유는 선언 시점에는 context가 없기 때문에, activity가 onCreate 되고 나서 ActivityContext(this)를 넣기 위함이다. </p>
<p>lateinit 변수를 초기화 전에 가져다 쓰게 되면 <code>UninitializedPropertyAccessException</code>가 발생한다 </p>
<h2 id="by-lazy">by lazy</h2>
<pre><code class="language-kotlin">class MainActivity : ComponentActivity() {

    private val menuItems by lazy {
        listOf(
            &quot;홈&quot;,
            &quot;리뷰&quot;,
            &quot;마이페이지&quot;
        )
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            HomeScreen(menuItems = menuItems)
        }
    }
}</code></pre>
<p>해당 변수가 미리 만들어지는 것이 아니라 처음 접근할 때에 만들어진다.
val이기 때문에 한 번 만들어지고 재할당은 안된다</p>
<h2 id="remember">remember</h2>
<pre><code class="language-kotlin">@Composable
fun HomeScreen() {
    val tabs = remember {
        listOf(&quot;전체&quot;, &quot;진행중&quot;, &quot;완료&quot;)
    }

    TabRow(selectedTabIndex = 0) {
        tabs.forEach { title -&gt;
            Tab(
                selected = title == &quot;전체&quot;,
                onClick = {},
                text = { Text(title) }
            )
        }
    }
}</code></pre>
<p>compose에서는 remember로도 사용한다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] BLE 통신]]></title>
            <link>https://velog.io/@jini_1514/BLE</link>
            <guid>https://velog.io/@jini_1514/BLE</guid>
            <pubDate>Sat, 20 Jun 2026 07:16:50 GMT</pubDate>
            <description><![CDATA[<h2 id="블루투스">블루투스</h2>
<table>
<thead>
<tr>
<th>Classic Bluetooth</th>
<th>Bluetooth Low Energy</th>
</tr>
</thead>
<tbody><tr>
<td>오디오나 미디어 등 대용량 데이터를 지속적으로 전송</td>
<td>IoT 센서 값, 상태 정보와 같이 데이터가 작고 단거리 저전력 연결</td>
</tr>
<tr>
<td>연결 후 계속 통신 하는 구조</td>
<td>필요할 때만 짧게 통신하는 구조</td>
</tr>
<tr>
<td>스트리밍에 적합</td>
<td>이벤트 기반</td>
</tr>
<tr>
<td>페어링 기반</td>
<td>GATT 기반 구조</td>
</tr>
<tr>
<td>무선 이어폰, 헤드셋, 키보드, 마우스</td>
<td>스마트 워치, 헬스케어 센서, 비콘</td>
</tr>
</tbody></table>
<p>미러링이나 캐스팅과 같이 미디어 데이터 전송에서는 Wifi 계열 통신을 사용하는 경우가 많고, 초기 페어링, 기기 발견 등은 Bluetooth를 사용한다. </p>
<h2 id="ble">BLE</h2>
<p>기본적으로 비동기 콜백으로 동작</p>
<p>scan -&gt; connect -&gt; service 발견 -&gt; GATT 통신</p>
<h3 id="권한">권한</h3>
<p>Android 12 이상 부터는 
BLUETOOTH_ADVERTISE, BLUETOOTH_SCAN, BLUETOOTH_CONNECT 권한이 3개로 나누어져있음</p>
<h2 id="gatt">GATT</h2>
<ul>
<li>GAP(General Access Profile): GATT보다 더 로우레벨의 상호작용을 정의
  Advertising(광고, 게시)과 connection(연결)을 제어
  특정 장치가 다른 장치들에게 어떻게 보여지도록 할 것인가, 어떻게 두 장치를 연결할 것인가
  Advertising을 하는 Peripheral장치와 Connection을 하는 Centeral로 구분해 설명</li>
<li>GATT: BLE 통신에서 장치 간에 데이터를 주고 받을때 쓰는 프로토콜. 
데이터 전송 절차와 데이터 형식을 다룸. 
서비스와 특성으로 정의됨
모든 표준 BLE Profile은 GATT를 기반으로 해야함<ul>
<li>GATT Client: 서버한테 요청 보내고 응답 받음. 서버의 특성에 대해 알지 못함으로 먼저 서비스 검색을 수행하고, 해당 특성의 존재 여부를 조회해야함.</li>
<li>GATT Server: 요청 수신하고 응답 모냄. </li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jini_1514/post/0b9e7bc2-98ee-48bb-ac16-2311e774318f/image.png" alt=""></p>
<ul>
<li>Profile 최상위 개념. 한 기기의 기능 묶음 전체</li>
<li>Service 서비스: 실제 기능 단위
  프라이빗/퍼블릭으로 나뉨</li>
<li>Characteristic 특성: 데이터 정의<ul>
<li>Properties: 값에 대해 가능한 동작 (read/write/notify/indicate)<ul>
<li>value : 실제 데이터 값</li>
<li>descriptor: 부가 정보(설명, 설정, 포맷)</li>
</ul>
</li>
</ul>
</li>
</ul>
<h2 id="기기-연결">기기 연결</h2>
<h3 id="central---peripheral">Central &lt;-&gt; Peripheral</h3>
<p>기기 연결 단계에서는 Client &lt;-&gt; Server가 아니라 
Central &lt;-&gt; Peripheral로 구분된다.</p>
<p>센서 장치가 Peripheral
센서 장치를 스캔하는 것이 Central</p>
<p>Peripheral은 하나의 Central 장치에만 연결할 수 있다.
Peripheral이 Central에 연결되면 Advertising을 중지 하기 때문이다.</p>
<h3 id="advertising-광고-게시">advertising (광고, 게시)</h3>
<p>BLE Peripheral 역할로 자신을 주변에 광고
다른 기기가 스캔했을때 기기 이름이랑 Service UUID를 확인하게 한다. 
.setConnectable(true)를 통해서 다른 기기가 연결하는 하게 한다</p>
<ul>
<li>val adapter = BluetoothAdapter.getDefaultAdapter()
스마트폰 안에 있는 블루투스 모듈 관리자 </li>
</ul>
<ul>
<li>bluetoothLeAdvertiser = adapter.bluetoothLeAdvertiser
BluetoothAdapter 안에 BluetoothLeAdvertiser가 있다. 
BluetoothLeAdvertiser는 Advertising packet을 송출하는 클래스</li>
</ul>
<pre><code class="language-kotlin">package com.eddy.nrf.bluetooth

import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.BluetoothLeAdvertiser
import android.os.ParcelUuid
import com.eddy.nrf.utils.Uuid
import timber.log.Timber

class AdvertisingManager(private val bluetoothAdapter: BluetoothAdapter) {

    private var bluetoothLeAdvertiser: BluetoothLeAdvertiser? = null

    @SuppressLint(&quot;MissingPermission&quot;)
    fun startAdvertising() {
        // 블루투스 어댑터의 이름을 &quot;trimm e-bike&quot;로 변경
        bluetoothAdapter.name = &quot;trimm e-bike&quot;

        bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser

        bluetoothLeAdvertiser?.let {
            val settings = AdvertiseSettings.Builder()
                .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
                .setConnectable(true)
                .setTimeout(0)
                .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
                .build()

            val data = AdvertiseData.Builder()
                .setIncludeDeviceName(true)
                .setIncludeTxPowerLevel(false)
                .addServiceUuid(ParcelUuid(Uuid.HEART_RATE_SERVICE))
                .build()

            it.startAdvertising(settings, data, advertiseCallback)
        } ?: Timber.w(&quot;Failed to create advertiser&quot;)
    }

    @SuppressLint(&quot;MissingPermission&quot;)
    fun stopAdvertising() {
        bluetoothLeAdvertiser?.stopAdvertising(advertiseCallback)
    }

    private val advertiseCallback = object : AdvertiseCallback() {
        override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
            Timber.i(&quot;LE Advertise Started.&quot;)
        }

        override fun onStartFailure(errorCode: Int) {
            Timber.w(&quot;LE Advertise Failed: $errorCode&quot;)
        }
    }
}</code></pre>
<h3 id="scan">scan</h3>
<p>BLE Advertising 패킷을 수신해 주변 기기를 탐색한다.
스캔 결과를 보고 기기 이름, MAC 주소, 서비스 UUID 등을 보고 원하는 기기를 찾으면 scan을 중지하고 connectGatt()로 GATT연결을 시도한다 </p>
<pre><code class="language-kotlin">private val bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner
private var scanning = false
private val handler = Handler()

// Stops scanning after 10 seconds.
private val SCAN_PERIOD: Long = 10000

private fun scanLeDevice() {
    if (!scanning) { // Stops scanning after a pre-defined scan period.
        handler.postDelayed({
            scanning = false
            bluetoothLeScanner.stopScan(leScanCallback)
        }, SCAN_PERIOD)
        scanning = true
        bluetoothLeScanner.startScan(leScanCallback)
    } else {
        scanning = false
        bluetoothLeScanner.stopScan(leScanCallback)
    }
}</code></pre>
<p>timeout을 걸기 위해서 핸들러를 사용</p>
<h3 id="connect">connect</h3>
<pre><code class="language-kotlin">  fun connectToFixedMac(onConnected: () -&gt; Unit, onFailed: () -&gt; Unit) {
        val macAddress = &quot;C8:47:8C:70:73:F0&quot;
        val adapter = BluetoothAdapter.getDefaultAdapter()
        val device = adapter?.getRemoteDevice(macAddress)

        if (device == null) {
            Log.e(&quot;BLE&quot;, &quot;MAC 주소로 디바이스 찾기 실패&quot;)
            return
        }

        gatt?.disconnect()
        gatt?.close()
        gatt = null

        gatt = if (Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.M) {
            device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
        } else {
            device.connectGatt(context, false, gattCallback)
        }

        this.onConnectedCallback = onConnected
        this.onFailedCallback = onFailed
    }</code></pre>
<ol>
<li>scan을 안하고 Mac 주소를 기반으로 BluetoothDevice 객체를 얻는다.</li>
<li>해당 디바이스에 GATT 연결 시도 </li>
<li>연결 성공 후 MTU 요청</li>
<li>MTU 설정이 끝나면 UUID를 기준으로 원하는 Service 찾음</li>
<li>그 서비스 안에서 UUID를 기준으로 원하는 Characteristic이 있는지를 찾는다.</li>
<li>Notify Characteristic은 CCCD를 설정해서 Notification을 구독한다</li>
<li>이후 서버가 Nofity를 보내면 onCharacteristicChanged()에서 수신한다.</li>
<li>앱에서 데이터를 보낼 때는 writeCharacteristic()을 호출</li>
<li>writeCharacteristic()의 callback으로 전송 성공 여부 확인</li>
</ol>
<h2 id="통신">통신</h2>
<h3 id="gatt-client---gatt-server">GATT Client &lt;-&gt; GATT Server</h3>
<p>장치가 연결되면 GATT Client &lt;-&gt; GATT Server의 역할로 구분된다</p>
<p>GATT서버의 경우 advertising 을 통해 상대 기기 (client)에서 연결 요청을 받는다.</p>
<p>GATT클라이언트의 경우 Scan이나 MAC 주소 등을 기반으로 연결할 기기를 찾고 connectGatt()를 통해 연결을 맺는다. </p>
<hr>
<p>패킷 단위로 통신하며 기본 MTU는 20이고 최대 517까지 설정 가능 </p>
<h3 id="read---gattreadcharacteristic">Read - gatt.readCharacteristic()</h3>
<h3 id="write---gattwritecharacteristic">Write - gatt.writeCharacteristic()</h3>
<h3 id="notify---gattsetcharacteristicnotification">Notify - gatt.setCharacteristicNotification()</h3>
<p>Notify는 클라이언트가 서버한테 구독 요청을 보내면 클라이언트가 매번 READ 요청을 보내지 않아도 서버가 클라이언트에게 Characteristic 값 변경을 알려준다.</p>
<p>클라이언트는 먼저 Service Discovery를 통해 원하는 Characteristic을 찾고 CCCD를 설정해 Notification 수신을 구독한다. 이후 서버는 값이 변경될때 마다 클라이언트에게 데이터를 전송한다. </p>
<p>Notification, Indication 둘 다 서버 -&gt; 클라이언트 데이터 전송 방식이다.</p>
<table>
<thead>
<tr>
<th>Notification</th>
<th>Indication</th>
</tr>
</thead>
<tbody><tr>
<td>Ack 없음</td>
<td>Ack 필수</td>
</tr>
<tr>
<td>서버-&gt; 클라이언트 데이터 전송 후 바로 완료</td>
<td>서버 -&gt; 클라이언트 데이터 전송 후 클라이언트로부터 확인 응답을 받아야 다음 데이터를 보냄</td>
</tr>
<tr>
<td>빠르고 전력 소모 적음</td>
<td>통신 주기가 길어지고 전력 소모가 상대적으로 큼</td>
</tr>
<tr>
<td>데이터 유실 가능성 있음</td>
<td>데이터 유실 없이 확실한 수신이 보장</td>
</tr>
</tbody></table>
<p>Android GATT Server API에서는 <code>notifyCharacteristicChanged(device, characteristic, confirm)</code>의 confirm 값이 false면 Notification, true면 Indication 방식으로 동작한다.</p>
<pre><code class="language-kotlin">package com.eddy.nrf.bluetooth

import android.os.Handler
import android.os.Looper
import com.eddy.nrf.presentation.ui.BikeUiState
import com.eddy.nrf.utils.Util.floatTo4ByteArray
import com.eddy.nrf.utils.Uuid
import kotlinx.coroutines.flow.StateFlow
import timber.log.Timber
import java.nio.ByteBuffer

class HeartRateNotificationManager(
    private val gattServerManager: GattServerManager,
    private val uiStateFlow: StateFlow&lt;BikeUiState&gt;
) {

    private val notificationHandler = Handler(Looper.getMainLooper())

    fun startNotifications() {
        notificationHandler.post(notificationRunnable)
    }

    fun stopNotifications() {
        notificationHandler.removeCallbacks(notificationRunnable)
    }

    private val notificationRunnable = object : Runnable {
        override fun run() {
            val distance = uiStateFlow.value.distance
            val speed = uiStateFlow.value.speed
            val gear = uiStateFlow.value.gear
            val battery = uiStateFlow.value.battery

            val buffer = ByteBuffer.allocate(13)//4+4+1+4
            buffer.put(floatTo4ByteArray(distance))
            buffer.put(floatTo4ByteArray(speed))
            buffer.put(gear.toByte())
            buffer.put(floatTo4ByteArray(battery))
            val resultArray = buffer.array()

            notifyHeartRate(resultArray)
            notificationHandler.postDelayed(this, 1000)
        }
    }

    private fun notifyHeartRate(heartRate: ByteArray) {
        if (gattServerManager.registeredDevices.isEmpty()) return

        Timber.i(
            &quot;Sending heart rate update to ${gattServerManager.registeredDevices.size} subscribers&quot;
        )
        val heartRateCharacteristic = gattServerManager.bluetoothGattServer
            ?.getService(Uuid.HEART_RATE_SERVICE)
            ?.getCharacteristic(Uuid.HEART_RATE_MEASUREMENT)
        heartRateCharacteristic?.value = heartRate

        gattServerManager.notifyCharacteristicChanged(heartRateCharacteristic!!)
    }
}</code></pre>
<ul>
<li><p>Handler와 Looper는 1초마다 Notification을 보내기 위한 주기적 작업 스케줄링에 사용했다.
Handler는 Runnable을 메시지 큐에 등록하고, Looper는 큐에서 작업을 꺼내 실행한다.
현재 코드는 MainLooper를 사용하므로 해당 Runnable은 메인 스레드에서 실행된다.</p>
</li>
<li><p>ByteArray는 BLE로 보낼 데이터를 바이트 단위로 표현한 배열이다.
ByteBuffer는 여러 값을 순서대로 ByteArray에 넣기 위해 사용했다.</p>
</li>
</ul>
<h2 id="reference">reference</h2>
<p><a href="https://developer.android.com/develop/connectivity/bluetooth/ble/find-ble-devices?hl=ko">https://developer.android.com/develop/connectivity/bluetooth/ble/find-ble-devices?hl=ko</a>
<a href="https://odomm.tistory.com/entry/Android-%ED%81%B4%EB%9E%98%EC%8B%9D-%EB%B8%94%EB%A3%A8%ED%88%AC%EC%8A%A4%EB%9E%91-ble-%EC%B0%A8%EC%9D%B4%EC%A0%90#google_vignette">https://odomm.tistory.com/entry/Android-%ED%81%B4%EB%9E%98%EC%8B%9D-%EB%B8%94%EB%A3%A8%ED%88%AC%EC%8A%A4%EB%9E%91-ble-%EC%B0%A8%EC%9D%B4%EC%A0%90#google_vignette</a>
<a href="https://wowon.tistory.com/375">https://wowon.tistory.com/375</a>
<a href="https://seosh817.tistory.com/415#GATT%20%ED%94%84%EB%A1%9C%ED%95%84%20%EC%98%88%EC%8B%9C-1-14">https://seosh817.tistory.com/415#GATT%20%ED%94%84%EB%A1%9C%ED%95%84%20%EC%98%88%EC%8B%9C-1-14</a>
<a href="https://dev-ahn.tistory.com/91">https://dev-ahn.tistory.com/91</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[HackerRank] Chocolate in Box 풀이 - Kotlin]]></title>
            <link>https://velog.io/@jini_1514/HackerRank-Chocolate-in-Box-%ED%92%80%EC%9D%B4-Kotlin</link>
            <guid>https://velog.io/@jini_1514/HackerRank-Chocolate-in-Box-%ED%92%80%EC%9D%B4-Kotlin</guid>
            <pubDate>Wed, 27 May 2026 12:08:19 GMT</pubDate>
            <description><![CDATA[<h2 id="문제">문제</h2>
<p>HackerRank의 <a href="https://www.hackerrank.com/challenges/chocolate-in-box/problem?isFullScreen=true"><strong>Chocolate in Box</strong></a> 문제는 게임 이론 문제다.</p>
<p>여러 개의 박스가 있고, 각 박스에는 초콜릿이 들어 있다.<br>두 명이 번갈아 가면서 게임을 한다.</p>
<p>한 번의 턴에는 다음 행동을 할 수 있다.</p>
<ul>
<li>비어 있지 않은 박스 하나를 고른다.</li>
<li>그 박스에서 초콜릿을 1개 이상 가져간다.</li>
<li>더 이상 가져갈 초콜릿이 없는 사람이 진다.</li>
</ul>
<p>Dexter가 먼저 시작할 때, 문제에서 구해야 하는 값은 다음과 같다.</p>
<blockquote>
<p>Dexter가 첫 번째 수를 어떻게 두면, 이후 둘 다 최적으로 플레이했을 때 Dexter가 이길 수 있는지<br>그런 첫 번째 수의 개수</p>
</blockquote>
<p>처음에는 모든 경우를 직접 세야 하나 싶었는데, 제한을 보면 그렇게 풀 수 없다.</p>
<pre><code class="language-text">1 ≤ N ≤ 10^6
1 ≤ A[i] ≤ 10^9</code></pre>
<p>박스도 많고, 각 박스 안의 초콜릿 수도 크다.
그래서 모든 제거 경우를 시도하면 시간 초과가 난다.</p>
<hr>
<h2 id="핵심-아이디어">핵심 아이디어</h2>
<p>이 문제는 전형적인 <strong>Nim Game</strong>이다.</p>
<p>Nim Game은 여러 개의 더미가 있고, 한 턴에 하나의 더미에서 원하는 만큼 제거하는 게임이다.
이 문제에서는 초콜릿 박스가 더미 역할을 한다.</p>
<p>Nim Game의 중요한 규칙은 다음과 같다.</p>
<pre><code class="language-text">전체 XOR 값이 0이면 지는 상태
전체 XOR 값이 0이 아니면 이기는 상태</code></pre>
<p>즉, 모든 박스의 초콜릿 개수를 XOR 했을 때 결과가 0이면 선공에게 불리하고,
0이 아니면 선공이 이길 수 있는 수가 있다.</p>
<p>Kotlin에서 XOR은 이렇게 쓴다.</p>
<pre><code class="language-kotlin">a xor b</code></pre>
<p>Python의 <code>^</code>와 같은 역할이다.</p>
<hr>
<h2 id="예제-이해하기">예제 이해하기</h2>
<p>예제 입력은 다음과 같다.</p>
<pre><code class="language-text">2
2 3</code></pre>
<p>박스 상태는 다음과 같다.</p>
<pre><code class="language-text">[2, 3]</code></pre>
<p>전체 XOR을 구하면 다음과 같다.</p>
<pre><code class="language-text">2 xor 3 = 1</code></pre>
<p>전체 XOR이 0이 아니므로, Dexter는 이길 수 있는 수가 있다.</p>
<p>이제 어떤 박스를 줄이면 전체 XOR을 0으로 만들 수 있는지 확인하면 된다.</p>
<hr>
<h2 id="첫-번째-박스-확인">첫 번째 박스 확인</h2>
<p>첫 번째 박스의 값은 2다.</p>
<pre><code class="language-text">target = 2 xor 1 = 3</code></pre>
<p>즉, 첫 번째 박스를 3개로 만들면 전체 XOR이 0이 된다는 뜻이다.</p>
<p>하지만 초콜릿은 가져갈 수만 있고 늘릴 수는 없다.</p>
<pre><code class="language-text">2 → 3</code></pre>
<p>이건 불가능하다.</p>
<hr>
<h2 id="두-번째-박스-확인">두 번째 박스 확인</h2>
<p>두 번째 박스의 값은 3이다.</p>
<pre><code class="language-text">target = 3 xor 1 = 2</code></pre>
<p>3개짜리 박스를 2개로 줄일 수 있다.</p>
<pre><code class="language-text">[2, 3] → [2, 2]</code></pre>
<p>이때 전체 XOR은 다음과 같다.</p>
<pre><code class="language-text">2 xor 2 = 0</code></pre>
<p>상대에게 지는 상태를 넘겨줄 수 있으므로, 이 움직임은 Dexter가 이길 수 있는 첫 수다.</p>
<p>따라서 정답은 1이다.</p>
<hr>
<h2 id="공식">공식</h2>
<p>전체 XOR 값을 <code>totalXor</code>라고 하자.
현재 박스의 초콜릿 수를 <code>chocolate</code>이라고 하면, 이 박스를 줄였을 때 목표값은 다음과 같다.</p>
<pre><code class="language-kotlin">val target = chocolate xor totalXor</code></pre>
<p>이 <code>target</code> 값이 현재 초콜릿 수보다 작으면, 그 박스는 줄일 수 있다.</p>
<pre><code class="language-kotlin">target &lt; chocolate</code></pre>
<p>그래서 이기는 첫 수의 개수는 다음 조건을 만족하는 박스의 개수다.</p>
<pre><code class="language-kotlin">(chocolate xor totalXor) &lt; chocolate</code></pre>
<hr>
<h2 id="풀이-코드">풀이 코드</h2>
<pre><code class="language-kotlin">fun chocolateInBox(arr: Array&lt;Int&gt;): Int {
    var totalXor = 0

    for (chocolate in arr) {
        totalXor = totalXor xor chocolate
    }

    if (totalXor == 0) {
        return 0
    }

    var answer = 0

    for (chocolate in arr) {
        val target = chocolate xor totalXor

        if (target &lt; chocolate) {
            answer += 1
        }
    }

    return answer
}</code></pre>
<hr>
<h2 id="코드-설명">코드 설명</h2>
<p>먼저 전체 XOR 값을 구한다.</p>
<pre><code class="language-kotlin">var totalXor = 0

for (chocolate in arr) {
    totalXor = totalXor xor chocolate
}</code></pre>
<p>전체 XOR이 0이면 이미 선공이 불리한 상태다.
따라서 Dexter가 이기는 첫 수는 없다.</p>
<pre><code class="language-kotlin">if (totalXor == 0) {
    return 0
}</code></pre>
<p>전체 XOR이 0이 아니라면, 각 박스에 대해 줄일 수 있는지 확인한다.</p>
<pre><code class="language-kotlin">for (chocolate in arr) {
    val target = chocolate xor totalXor

    if (target &lt; chocolate) {
        answer += 1
    }
}</code></pre>
<p><code>target &lt; chocolate</code>이면 그 박스를 <code>target</code>개로 줄일 수 있다는 뜻이다.
이렇게 줄이면 전체 XOR이 0이 되기 때문에 상대에게 불리한 상태를 넘길 수 있다.</p>
<hr>
<h2 id="python-풀이와-비교">Python 풀이와 비교</h2>
<p>Python으로 쓰면 이런 느낌이다.</p>
<pre><code class="language-python">def chocolateInBox(arr):
    xor_sum = 0

    for x in arr:
        xor_sum ^= x

    if xor_sum == 0:
        return 0

    answer = 0

    for x in arr:
        if (x ^ xor_sum) &lt; x:
            answer += 1

    return answer</code></pre>
<p>Python에서는 <code>^</code>를 쓰고, Kotlin에서는 <code>xor</code>를 쓴다는 차이만 기억하면 된다.</p>
<hr>
<h2 id="시간-복잡도">시간 복잡도</h2>
<p>배열을 두 번 순회한다.</p>
<pre><code class="language-text">전체 XOR 계산: O(N)
가능한 첫 수 계산: O(N)</code></pre>
<p>따라서 전체 시간 복잡도는 다음과 같다.</p>
<pre><code class="language-text">O(N)</code></pre>
<p>추가로 큰 배열을 만들지 않기 때문에 공간 복잡도는 다음과 같다.</p>
<pre><code class="language-text">O(1)</code></pre>
<hr>
<h2 id="정리">정리</h2>
<p>처음에는 게임 문제라서 어렵게 느껴졌는데, Nim Game이라는 걸 알면 풀이가 꽤 간단해진다.</p>
<p>핵심은 이거다.</p>
<pre><code class="language-text">전체 XOR이 0이면 지는 상태
전체 XOR이 0이 아니면 이기는 상태</code></pre>
<p>그리고 Dexter가 이길 수 있는 첫 수의 개수는 다음 조건으로 구할 수 있다.</p>
<pre><code class="language-kotlin">(chocolate xor totalXor) &lt; chocolate</code></pre>
<p>즉, 이 문제는 모든 경우를 직접 시도하는 문제가 아니라
<strong>XOR로 현재 게임 상태를 판단하는 문제</strong>였다.</p>
<pre><code></code></pre>]]></description>
        </item>
        <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>
    </channel>
</rss>