<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ju-unn.log</title>
        <link>https://velog.io/</link>
        <description>한줄한줄 기록해보자</description>
        <lastBuildDate>Mon, 31 Aug 2026 05:38:17 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ju-unn.log</title>
            <url>https://velog.velcdn.com/images/ju-unn/profile/5352be2d-214b-4fc3-9219-b4583af9d043/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ju-unn.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ju-unn" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Android] 가끔 특정 이미지만 업로드 실패한다 - 원인 추적부터 이미지 압축까지]]></title>
            <link>https://velog.io/@ju-unn/Android-%EA%B0%80%EB%81%94-%ED%8A%B9%EC%A0%95-%EC%9D%B4%EB%AF%B8%EC%A7%80%EB%A7%8C-%EC%97%85%EB%A1%9C%EB%93%9C-%EC%8B%A4%ED%8C%A8%ED%95%9C%EB%8B%A4-%EC%9B%90%EC%9D%B8-%EC%B6%94%EC%A0%81%EB%B6%80%ED%84%B0-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%95%95%EC%B6%95%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@ju-unn/Android-%EA%B0%80%EB%81%94-%ED%8A%B9%EC%A0%95-%EC%9D%B4%EB%AF%B8%EC%A7%80%EB%A7%8C-%EC%97%85%EB%A1%9C%EB%93%9C-%EC%8B%A4%ED%8C%A8%ED%95%9C%EB%8B%A4-%EC%9B%90%EC%9D%B8-%EC%B6%94%EC%A0%81%EB%B6%80%ED%84%B0-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%95%95%EC%B6%95%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Mon, 31 Aug 2026 05:38:17 GMT</pubDate>
            <description><![CDATA[<h2 id="상황">상황</h2>
<p>프로젝트를 손보면서 모임 카드를 만들 때 대표 이미지를 넣을 수 있다. 이미지는 두 경로로 들어온다. 하나는 사용자의 갤러리에서 고른 사진, 다른 하나는 앱 안에서 이미지 검색으로 고른 외부 이미지 URL이다. 어느 쪽이든 앱이 이미지를 서버로 올리고, 서버는 그걸 S3(아마존이 제공하는 파일 저장소)에 저장한 뒤 저장된 주소(URL)를 돌려준다.</p>
<p>그런데 &quot;가끔 특정 이미지만&quot; 업로드에 실패한다는 현상이 있었다. 대부분의 사진은 잘 올라가는데, 어떤 사진은 매번 &quot;이미지 업로드에 실패했습니다. 다시 시도해주세요.&quot;라는 토스트만 뜨고 끝났다.</p>
<p>문제는 <strong>왜 실패하는지 알 방법이 없었다</strong>는 것이다. 실패했을 때 앱이 하는 일은 토스트 한 줄 띄우는 게 전부였고, 어떤 로그도 남기지 않았다. 그래서 &quot;이미지 자체에 문제가 있는 건가?&quot;라는 의심만 있고 근거가 없었다.</p>
<blockquote>
<p>💡 개념 - <strong>토스트(Toast)</strong>: 화면 아래쪽에 잠깐 떴다 사라지는 짧은 알림 메시지. 사용자에게 보여줄 뿐, 개발자가 원인을 추적하는 용도는 아니다.</p>
</blockquote>
<hr>
<h2 id="문제">문제</h2>
<p>두 가지를 해결해야 했다.</p>
<ol>
<li><strong>원인을 눈으로 확인할 수단부터 만든다.</strong> 추측이 아니라 로그로 실패 이유를 잡는다.</li>
<li>원인이 밝혀지면 <strong>재발하지 않게 고친다.</strong></li>
</ol>
<hr>
<h2 id="개념부터">개념부터</h2>
<h3 id="1-먼저-이미지가-올라가는-전체-흐름을-이해한다">1. 먼저 이미지가 올라가는 전체 흐름을 이해한다</h3>
<p>코드를 고치기 전에, 이미지 한 장이 어디를 거쳐 가는지부터 그렸다.</p>
<pre><code>[앱]
 ├─ (갤러리) 기기 저장소에서 사진 읽기
 └─ (검색) 외부 이미지 URL을 OkHttp로 다운로드
        │
        ▼
   임시 파일(.jpg)로 저장
        │
        ▼
   multipart 형식으로 POST /api/meeting/upload_meeting_image.php
        │
        ▼
[서버] 파일 검증(용량·확장자·MIME) → S3에 업로드 → 저장된 URL 응답
        │
        ▼
[앱] 응답 성공이면 그 URL로 모임 생성 / 실패면 토스트</code></pre><blockquote>
<p>💡 개념 - <strong>OkHttp</strong>: 안드로이드에서 네트워크 요청(다운로드/업로드)을 보낼 때 쓰는 대표 라이브러리.
💡 개념 - <strong>multipart</strong>: 파일을 HTTP로 보낼 때 쓰는 전송 형식. 텍스트 폼 값과 파일(바이너리)을 한 요청에 나눠 담는다. 웹에서 <code>&lt;input type=&quot;file&quot;&gt;</code>로 파일 올릴 때 브라우저가 쓰는 것과 같은 방식이다.
💡 개념 - <strong>MIME 타입</strong>: 파일의 종류를 나타내는 표준 문자열. JPEG 이미지는 <code>image/jpeg</code>, PNG는 <code>image/png</code>. 확장자만 믿지 않고 실제 내용으로 종류를 판별할 때 쓴다.</p>
</blockquote>
<h3 id="2-실패-원인이-안-보였던-진짜-이유---실패-분기에-로그가-없었다">2. 실패 원인이 안 보였던 진짜 이유 - 실패 분기에 로그가 없었다</h3>
<p>업로드 응답을 받는 코드는 이렇게 생겨 있었다.</p>
<pre><code class="language-java">if (response.isSuccessful()
        &amp;&amp; response.body() != null
        &amp;&amp; response.body().isSuccess()) {
    String imageUrl = response.body().getData().getImage_url();
    Log.i(TAG, &quot;이미지 S3 업로드 완료: &quot; + imageUrl);
    createMeeting(title, description, date, time, priceRange, imageUrl);
} else {
    onUploadFailed();   // ← 여기서 그냥 토스트만 띄우고 끝
}</code></pre>
<p>한 줄씩 보면:</p>
<ul>
<li><code>response.isSuccessful()</code> - HTTP 응답 코드가 200번대(성공 계열)인지 확인한다.</li>
<li><code>response.body() != null</code> - 응답 본문(내용)이 실제로 왔는지 확인한다.</li>
<li><code>response.body().isSuccess()</code> - 응답 본문 안에 서버가 담아준 <code>success</code> 값이 <code>true</code>인지 확인한다.</li>
<li>세 조건이 모두 참이면 성공 처리, 하나라도 아니면 <code>else</code>로 가서 <code>onUploadFailed()</code> 호출.</li>
</ul>
<p>여기서 중요한 설계 포인트가 하나 있다. <strong>이 서버는 실패해도 HTTP 200을 준다.</strong></p>
<blockquote>
<p>💡 개념 - <strong>HTTP 상태 코드 vs 응답 본문의 success 플래그</strong>
HTTP 상태 코드(200 성공, 400 잘못된 요청, 500 서버 오류)는 &quot;요청이 서버까지 잘 도착해서 처리됐는가&quot;를 나타내는 표준 신호다. 이 서버는 검증 실패(예: 용량 초과) 같은 경우에도 코드는 200으로 주고, 대신 본문에 <code>{&quot;success&quot;: false, &quot;message&quot;: &quot;...&quot;}</code>를 담아 앱이 그 <code>message</code>로 분기하도록 설계돼 있다. 그래서 <code>isSuccessful()</code>(코드 200)은 통과하지만 <code>isSuccess()</code>(본문 플래그)에서 걸러진다.</p>
</blockquote>
<p>문제는 그 <code>else</code>에서 서버가 보내준 <code>message</code>를 <strong>아무도 읽지 않고 버렸다</strong>는 것이다. 서버는 &quot;이미지 크기는 5MB 이하여야 합니다&quot; 같은 친절한 이유를 본문에 담아 보냈는데, 앱이 그걸 로그로 남기지 않으니 개발자 눈엔 안 보였다.</p>
<p>그래서 첫 수정은 <strong>그 버려지던 메시지를 로그로 찍는 것</strong>이었다.</p>
<pre><code class="language-java">} else {
    String failMessage = response.body() != null
            ? response.body().getMessage() : &quot;body=null&quot;;
    Log.e(TAG, &quot;이미지 S3 업로드 실패: httpCode=&quot; + response.code()
            + &quot;, success=&quot; + (response.body() != null &amp;&amp; response.body().isSuccess())
            + &quot;, message=&quot; + failMessage);
    onUploadFailed();
}</code></pre>
<p>한 줄씩 설명하면:</p>
<ul>
<li><code>response.body() != null ? response.body().getMessage() : &quot;body=null&quot;</code>
본문이 있으면 그 안의 <code>message</code>를 꺼내고, 본문 자체가 없으면 <code>&quot;body=null&quot;</code>이라는 표시를 대신 넣는다. <code>? :</code>는 <strong>삼항 연산자</strong>로, <code>조건 ? 참일때값 : 거짓일때값</code> 형태의 짧은 if-else다.</li>
<li><code>Log.e(...)</code> - 에러 로그를 남긴다(<code>e</code>는 error). <code>httpCode</code>, <code>success</code> 플래그, 서버 <code>message</code>를 한 줄에 묶어 찍는다.</li>
<li><code>response.code()</code> - 실제 HTTP 코드 숫자(200, 401 등).</li>
</ul>
<h3 id="3-logcat으로-실제-원인을-확인">3. logcat으로 실제 원인을 확인</h3>
<p>수정한 앱을 빌드하고 문제의 이미지를 다시 올린 뒤 로그를 봤다.</p>
<pre><code>E  이미지 S3 업로드 실패: httpCode=200, success=false, message=이미지 크기는 5MB 이하여야 합니다</code></pre><p>원인이 한 방에 드러났다. <code>httpCode=200</code>(요청은 서버까지 잘 갔다), <code>success=false</code>(하지만 서버가 거부했다), 그리고 이유는 <strong>&quot;이미지 크기는 5MB 이하여야 합니다&quot;</strong>. 이미지가 깨졌거나 포맷이 이상한 게 아니라, 그냥 <strong>용량이 너무 컸다.</strong></p>
<p>그럼 &quot;너무 크다&quot;가 정확히 얼마인지 알아야 대응을 정할 수 있다. 서버 제한만 살짝 올리면 될 크기인지, 아니면 근본적으로 손봐야 할 크기인지. 그래서 업로드 직전에 파일 크기를 찍는 로그를 한 줄 더 넣었다.</p>
<pre><code class="language-java">Log.i(TAG, &quot;업로드 이미지 크기: &quot;
        + String.format(&quot;%.2f&quot;, temporaryFile.length() / 1024.0 / 1024.0) + &quot;MB (&quot;
        + temporaryFile.length() + &quot; bytes)&quot;);</code></pre>
<ul>
<li><code>temporaryFile.length()</code> - 파일 크기를 <strong>바이트(byte)</strong> 단위로 돌려준다.</li>
<li><code>/ 1024.0 / 1024.0</code> - 바이트를 MB로 바꾼다. 1KB = 1024바이트, 1MB = 1024KB이므로 1024로 두 번 나눈다. <code>1024.0</code>처럼 소수점을 붙인 이유는, 정수끼리 나누면 소수 부분이 잘려나가기 때문이다(정수 나눗셈).</li>
<li><code>String.format(&quot;%.2f&quot;, ...)</code> - 소수점 둘째 자리까지만 보기 좋게 표시한다.</li>
</ul>
<p>다시 재현하니 이렇게 찍혔다.</p>
<pre><code>I  업로드 이미지 크기: 7.33MB (7681501 bytes)
E  이미지 S3 업로드 실패: httpCode=200, success=false, message=이미지 크기는 5MB 이하여야 합니다</code></pre><p><strong>7.33MB.</strong> 서버 제한 5MB를 넘긴다. 원인 확정.</p>
<h3 id="4-어떻게-고칠까---서버-제한을-올릴까-앱에서-줄일까">4. 어떻게 고칠까 - 서버 제한을 올릴까, 앱에서 줄일까</h3>
<table>
<thead>
<tr>
<th>방식</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>서버 용량 제한을 키운다 (예: 5MB → 20MB)</td>
<td>한 줄 수정으로 끝</td>
<td>원본이 더 큰 이미지가 오면 또 실패. S3 저장 용량·전송량·앱 트래픽만 계속 늘어남</td>
</tr>
<tr>
<td><strong>앱에서 업로드 전에 이미지를 줄인다(리사이즈+재압축)</strong></td>
<td>원본 크기와 무관하게 항상 작게. 저장·전송 비용 절감, 로딩도 빨라짐</td>
<td>코드가 조금 늘고, 화질을 어디까지 줄일지 정해야 함</td>
</tr>
</tbody></table>
<p>결정적인 이유는 <strong>모임 카드에 들어가는 썸네일에는 7MB짜리 원본 화질이 필요 없다</strong>는 것이다. 카드 이미지는 화면에서 작게 보인다. 원본을 그대로 올리는 건 우표 크기로 보여줄 그림을 전지 크기로 저장하는 셈이다. 서버 제한을 올리는 건 근본 해결이 아니라 한도만 미루는 것이고, 다음에 30MB 원본이 오면 똑같이 실패한다. 그래서 <strong>앱에서 줄여서 올리는 쪽</strong>을 택했다.</p>
<h3 id="5-큰-이미지를-그냥-열면-왜-앱이-죽나">5. 큰 이미지를 그냥 열면 왜 앱이 죽나</h3>
<p>이미지를 줄이려면 먼저 이미지를 &quot;펼쳐서(디코드)&quot; 읽어야 한다. 그런데 여기 큰 함정이 있다.</p>
<blockquote>
<p>💡 개념 - <strong>디코드(decode)와 Bitmap</strong>: JPEG 파일은 <strong>압축된</strong> 상태다. 화면에 그리거나 크기를 바꾸려면 이 압축을 풀어서 픽셀(점) 하나하나로 펼쳐야 하는데, 이 펼친 상태를 <code>Bitmap</code>이라고 한다.</p>
</blockquote>
<p>핵심은 <strong>파일 크기와 펼친 크기가 완전히 다르다</strong>는 것이다. JPEG로 압축된 7MB 파일이라도, 펼치면 메모리를 훨씬 많이 먹는다. 안드로이드 기본 방식(ARGB_8888)에서는 픽셀 하나가 <strong>4바이트</strong>를 쓴다(빨강·초록·파랑·투명도 각 1바이트).</p>
<p>예를 들어 4000 × 3000 픽셀 사진이라면:</p>
<pre><code>4000 × 3000 × 4바이트 = 48,000,000바이트 ≈ 48MB</code></pre><p>파일은 7MB인데 펼치면 48MB다. 앱 하나가 쓸 수 있는 메모리엔 한계가 있어서, 이렇게 통째로 펼치면 <strong>OutOfMemoryError(메모리 부족)로 앱이 죽는다.</strong></p>
<blockquote>
<p>💡 개념 - <strong>OOM (OutOfMemoryError)</strong>: 프로그램이 쓸 수 있는 메모리를 초과해서 더 못 쓰게 될 때 나는 오류. 안드로이드에서 큰 이미지를 통째로 디코드할 때 가장 흔히 만난다.</p>
</blockquote>
<p>그래서 큰 이미지는 <strong>펼치기 전에 미리 줄여서 펼치는</strong> 표준 기법을 쓴다. 그게 <code>inSampleSize</code>다.</p>
<blockquote>
<p>💡 개념 - <strong>inSampleSize (다운샘플링)</strong>: 이미지를 디코드할 때 &quot;몇 분의 1로 줄여서 펼칠지&quot; 지정하는 값. 2를 주면 가로·세로를 각각 절반으로(픽셀 수는 1/4로) 줄여서 펼친다. 4를 주면 1/4 크기(픽셀 수 1/16). <strong>줄인 상태로 펼치기 때문에 메모리를 처음부터 적게 쓴다.</strong> 2의 거듭제곱(1, 2, 4, 8…)을 쓰는 게 권장된다.</p>
</blockquote>
<h3 id="6-구현---compressimage-한-줄씩">6. 구현 - compressImage 한 줄씩</h3>
<p>이 개념들을 담은 함수가 <code>compressImage</code>다. 바이트 배열(원본 파일 내용)을 받아, 줄이고 다시 압축한 바이트 배열을 돌려준다.</p>
<pre><code class="language-java">private byte[] compressImage(byte[] original) {
    final int maxDimension = 1280; // 카드 썸네일에 충분. 필요 시 조정.
    android.graphics.BitmapFactory.Options bounds = new android.graphics.BitmapFactory.Options();
    bounds.inJustDecodeBounds = true;
    android.graphics.BitmapFactory.decodeByteArray(original, 0, original.length, bounds);

    int sample = 1;
    while (bounds.outWidth / sample &gt; maxDimension || bounds.outHeight / sample &gt; maxDimension) {
        sample *= 2;
    }
    android.graphics.BitmapFactory.Options opts = new android.graphics.BitmapFactory.Options();
    opts.inSampleSize = sample;
    android.graphics.Bitmap bitmap =
            android.graphics.BitmapFactory.decodeByteArray(original, 0, original.length, opts);
    if (bitmap == null) return original; // 디코드 실패 시 원본 그대로 (서버가 재검증)

    java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
    bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, out);
    bitmap.recycle();
    return out.toByteArray();
}</code></pre>
<p>한 줄씩 뜯어보면:</p>
<p><strong>(1) 목표 크기 정하기</strong></p>
<pre><code class="language-java">final int maxDimension = 1280;</code></pre>
<p>가로든 세로든 긴 변이 1280픽셀을 넘지 않게 만들겠다는 기준. 카드 썸네일엔 이 정도면 충분하다. <code>final</code>은 &quot;이 값은 바뀌지 않는다&quot;는 표시.</p>
<p><strong>(2) 크기만 먼저 알아내기 (실제로는 펼치지 않고)</strong></p>
<pre><code class="language-java">BitmapFactory.Options bounds = new BitmapFactory.Options();
bounds.inJustDecodeBounds = true;
BitmapFactory.decodeByteArray(original, 0, original.length, bounds);</code></pre>
<p><code>inJustDecodeBounds = true</code>는 &quot;실제 픽셀은 메모리에 펼치지 말고, <strong>가로·세로 크기만 알려줘</strong>&quot;라는 옵션이다. 5단계에서 본 OOM 함정을 피하는 핵심이다. 이 호출 뒤에 <code>bounds.outWidth</code>, <code>bounds.outHeight</code>에 원본의 실제 픽셀 크기가 담긴다. <code>decodeByteArray(원본, 시작위치0, 길이, 옵션)</code>은 바이트 배열을 이미지로 읽는 함수다.</p>
<p><strong>(3) 몇 분의 1로 줄일지 계산</strong></p>
<pre><code class="language-java">int sample = 1;
while (bounds.outWidth / sample &gt; maxDimension || bounds.outHeight / sample &gt; maxDimension) {
    sample *= 2;
}</code></pre>
<p><code>sample</code>을 1에서 시작해, &quot;지금 배율로 줄여도 여전히 1280을 넘는 동안&quot; 계속 2배씩 키운다. 예를 들어 원본이 4000픽셀이면:</p>
<ul>
<li>sample=1 → 4000, 아직 1280 초과 → 2배</li>
<li>sample=2 → 2000, 아직 초과 → 2배</li>
<li>sample=4 → 1000, 1280 이하 → 멈춤</li>
</ul>
<p>결과적으로 <code>sample=4</code>(1/4로 줄이기)가 정해진다. 가로·세로 중 하나라도 초과하면(<code>||</code>, &quot;또는&quot;) 계속 줄인다.</p>
<p><strong>(4) 줄인 상태로 실제 디코드</strong></p>
<pre><code class="language-java">BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inSampleSize = sample;
Bitmap bitmap = BitmapFactory.decodeByteArray(original, 0, original.length, opts);</code></pre>
<p>이번엔 <code>inJustDecodeBounds</code> 없이, 대신 <code>inSampleSize</code>에 아까 계산한 배율을 넣어 진짜로 펼친다. 처음부터 1/4 크기로 펼치므로 메모리를 훨씬 덜 쓴다.</p>
<p><strong>(5) 디코드 실패 방어</strong></p>
<pre><code class="language-java">if (bitmap == null) return original;</code></pre>
<p>어떤 이유로 디코드가 실패하면(<code>bitmap == null</code>) 억지로 진행하지 않고 원본을 그대로 돌려준다. 그러면 서버가 다시 검증한다. 안전장치다.</p>
<p><strong>(6) JPEG로 다시 압축</strong></p>
<pre><code class="language-java">ByteArrayOutputStream out = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out);</code></pre>
<p>펼친 <code>Bitmap</code>을 다시 JPEG로 압축해 <code>out</code>(메모리 위의 바이트 저장통)에 담는다. <code>85</code>는 <strong>품질</strong>로, 0~100 범위다.</p>
<blockquote>
<p>💡 개념 - <strong>JPEG 손실 압축과 quality</strong>: JPEG는 사람 눈이 잘 못 느끼는 정보를 버려서 용량을 줄이는 &quot;손실 압축&quot;이다. quality가 낮을수록 더 많이 버려서 파일은 작아지지만 화질이 떨어진다. 100은 거의 안 버림(용량 큼), 낮을수록 용량은 작지만 뭉개진다. 85는 화질 저하가 눈에 잘 안 띄면서 용량은 확 줄어드는, 실무에서 널리 쓰는 균형점이다.</p>
</blockquote>
<p><strong>(7) 메모리 정리하고 결과 반환</strong></p>
<pre><code class="language-java">bitmap.recycle();
return out.toByteArray();</code></pre>
<p><code>recycle()</code>은 다 쓴 <code>Bitmap</code>이 차지한 메모리를 즉시 반납하라는 뜻이다(이미지 작업에선 메모리 관리가 중요하므로 습관화). 마지막으로 압축된 바이트 배열을 돌려준다.</p>
<h3 id="7-두-갈래를-하나로-모은-리팩터링">7. 두 갈래를 하나로 모은 리팩터링</h3>
<p>압축 함수를 끼워 넣으려면, 갤러리/검색 두 경로가 각자 파일을 쓰던 걸 <strong>먼저 바이트로 모으고 → 압축 한 번 → 파일 쓰기</strong>로 합치는 게 자연스러웠다.</p>
<pre><code class="language-java">byte[] originalBytes;
if (&quot;gallery&quot;.equals(pendingImageSource)) {
    // 갤러리: ContentResolver로 사진 스트림을 읽어 바이트로 모음
    java.io.InputStream inputStream = getContentResolver()
            .openInputStream(android.net.Uri.parse(pendingImageValue));
    if (inputStream == null) { onUploadFailed(); return; }
    java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
    byte[] buffer = new byte[4096];
    int length;
    while ((length = inputStream.read(buffer)) != -1) {
        buf.write(buffer, 0, length);
    }
    inputStream.close();
    originalBytes = buf.toByteArray();
    temporaryFile = File.createTempFile(&quot;meeting_gallery_&quot;, &quot;.jpg&quot;, getCacheDir());
} else {
    // 검색: 외부 이미지 URL을 OkHttp로 다운로드해 바이트로 받음
    OkHttpClient okHttpClient = new OkHttpClient();
    Request httpRequest = new Request.Builder().url(pendingImageValue).build();
    ResponseBody responseBody = okHttpClient.newCall(httpRequest).execute().body();
    if (responseBody == null) { onUploadFailed(); return; }
    originalBytes = responseBody.bytes();
    temporaryFile = File.createTempFile(&quot;meeting_img_&quot;, &quot;.jpg&quot;, getCacheDir());
}

byte[] uploadBytes = compressImage(originalBytes);   // ← 여기서 압축
try (FileOutputStream fileOutputStream = new FileOutputStream(temporaryFile)) {
    fileOutputStream.write(uploadBytes);             // 압축된 결과만 파일로 씀
}</code></pre>
<p>포인트만 짚으면:</p>
<ul>
<li><code>getContentResolver().openInputStream(...)</code> - 갤러리 사진처럼 앱 바깥 저장소의 파일을 안전하게 열어 읽는 안드로이드 표준 통로.</li>
<li><code>byte[] buffer = new byte[4096]</code> + <code>while (read(buffer) != -1)</code> - 파일을 4096바이트씩 조금씩 읽어 옮기는 관용적인 스트림 복사 패턴. 한 번에 다 읽지 않고 나눠 읽어 메모리를 아낀다. <code>read()</code>가 <code>-1</code>을 주면 &quot;더 읽을 게 없다&quot;는 신호라 반복을 끝낸다.</li>
<li><code>File.createTempFile(...)</code> - 앱 캐시 폴더(<code>getCacheDir()</code>)에 임시 파일을 만든다. 업로드 후 지워질 임시 파일이다.</li>
<li><code>compressImage(originalBytes)</code> - 어느 경로로 왔든 여기서 한 번만 압축한다. 두 갈래를 바이트로 통일한 덕분에 압축 로직이 한 군데로 모였다.</li>
</ul>
<blockquote>
<p>💡 개념 - <strong>스트림(Stream)</strong>: 파일이나 네트워크 데이터를 처음부터 끝까지 &quot;흐르듯&quot; 조금씩 읽고 쓰는 방식. 큰 파일을 통째로 메모리에 올리지 않고 조각내어 다루므로 메모리에 안전하다.</p>
</blockquote>
<p>이제 압축 전후 크기도 로그로 남겨 효과를 눈으로 확인했다.</p>
<pre><code class="language-java">Log.i(TAG, &quot;이미지 압축: &quot;
        + String.format(&quot;%.2f&quot;, originalBytes.length / 1024.0 / 1024.0) + &quot;MB → &quot;
        + String.format(&quot;%.2f&quot;, uploadBytes.length / 1024.0 / 1024.0) + &quot;MB&quot;);</code></pre>
<hr>
<h2 id="결과">결과</h2>
<p>문제의 7.33MB 이미지를 다시 올리자 로그가 이렇게 바뀌었다.</p>
<pre><code>I  이미지 압축: 7.33MB → 0.45MB
I  업로드 이미지 크기: 0.45MB (...)
I  이미지 S3 업로드 완료: https://.../meetings/....jpg</code></pre><ul>
<li>7.33MB → <strong>약 0.45MB로 감소</strong> (원본의 약 6% 수준).</li>
<li>서버의 5MB 제한에 더는 걸리지 않아 <strong>업로드 성공.</strong></li>
<li>부수 효과: 올라가는 용량이 작아져 <strong>업로드 속도·S3 저장 비용·앱 트래픽이 함께 줄었고</strong>, 나중에 이 이미지를 내려받아 보여줄 때도 빨라졌다.</li>
</ul>
<p>그리고 이번 일의 진짜 소득은 코드 몇 줄보다 <strong>디버깅 습관</strong>이었다. 실패 분기에서 서버가 준 메시지를 버리지 않고 로그로 남기게 만든 순간, &quot;이미지가 문제인가?&quot;라는 막연한 추측이 &quot;5MB 초과&quot;라는 한 문장으로 확정됐다.</p>
<p><strong>Before / After</strong></p>
<ul>
<li><strong>Before</strong>: 특정 이미지 업로드 시 원인 불명의 실패 토스트만 반복. 실패 분기에 로그가 없어 원인 추적 불가. 원본(예: 7.33MB)을 그대로 올려 서버 5MB 제한에 걸림.</li>
<li><strong>After</strong>: 실패 분기가 서버 메시지·HTTP 코드를 로그로 남김. 업로드 전 긴 변 1280px 다운스케일 + JPEG quality 85 재압축으로 원본과 무관하게 1MB 미만 전송. 용량 초과 실패 제거.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 긴 회원가입 폼을 5스텝 - ViewFlipper를 고른 이유]]></title>
            <link>https://velog.io/@ju-unn/Android-%EA%B8%B4-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%ED%8F%BC%EC%9D%84-5%EC%8A%A4%ED%85%9D-ViewFlipper%EB%A5%BC-%EA%B3%A0%EB%A5%B8-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@ju-unn/Android-%EA%B8%B4-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%ED%8F%BC%EC%9D%84-5%EC%8A%A4%ED%85%9D-ViewFlipper%EB%A5%BC-%EA%B3%A0%EB%A5%B8-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Mon, 31 Aug 2026 05:08:59 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 손보면서 한 화면에 9개 항목이 세로로 쌓여 있던 회원가입 폼을 5스텝 마법사로 쪼갰다. 그 과정에서 &quot;스텝을 뭘로 담을 것인가&quot;(Fragment냐 ViewFlipper냐 ViewPager2냐)를 두고 고민했고, 마지막엔 게이팅 구조 덕분에 <strong>죽은 검증 코드 9개</strong>를 발견해 지웠다. 그 선택의 근거와 정리 과정을 남긴다.</p>
<h2 id="상황">상황</h2>
<p>회원가입이 한 화면에 이메일·인증·비밀번호·닉네임·전화·프로필·생년월일·성별·지역을 전부 세로로 쌓아둔 긴 폼이었다. 문제는 두 가지.</p>
<ul>
<li><strong>검증 피드백이 전부 Toast였다.</strong> 닉네임 중복, 이메일 형식, 비밀번호 불일치 같은 걸 Toast로 알려주는데, Toast는 몇 초 뜨고 사라져서 &quot;무슨 항목이 왜 틀렸는지&quot;가 화면에 안 남는다. 긴 폼에서 여러 개가 동시에 틀리면 뭘 고쳐야 하는지 모른다.</li>
<li><strong>제출 시점에 한꺼번에 검증</strong>하니, 맨 아래 버튼을 누르고 나서야 맨 위 이메일이 틀렸다는 걸 안다. 스크롤을 되짚어 올라가야 한다.</li>
</ul>
<h2 id="문제">문제</h2>
<p>한 화면을 스텝별로 쪼개서 <strong>스텝마다 즉시 인라인 검증</strong>하고, <strong>통과해야 다음으로 진행</strong>(게이팅)하게 만든다. 단, 걸림돌이 하나 있었다 - 3분 인증 타이머, <code>isEmailVerified</code>/<code>isNicknameChecked</code> 같은 검증 플래그들이 <strong>스텝을 넘나들며 교차 참조</strong>된다. 이 상태 공유를 어떻게 처리하느냐가 구조 선택을 좌우했다.</p>
<h2 id="개념부터">개념부터</h2>
<h3 id="4-1-개념부터-멀티스텝-마법사와-스텝-게이팅">4-1. 개념부터: 멀티스텝 마법사와 스텝 게이팅</h3>
<blockquote>
<p>💡 <strong>멀티스텝 폼(마법사)</strong> - 긴 입력을 여러 단계로 쪼개, 한 번에 하나의 관심사만 묻는 UI 패턴. <strong>스텝 게이팅</strong>은 현재 스텝을 통과해야만 다음으로 넘어가게 막는 것.</p>
</blockquote>
<blockquote>
<p><strong>쉽게 말하면</strong> - 큰 퍼즐을 한 번에 다 맞추라고 하면 막막하지만, 조각을 한 묶음씩 나눠 주면 훨씬 쉽다. 게이팅은 &quot;이 조각을 맞춰야 다음 조각을 준다&quot;는 규칙이라, 순서가 꼬여서 잘못 진행되는 걸 처음부터 막는다.</p>
</blockquote>
<p>긴 폼은 인지 부하가 크고, 오류가 나도 &quot;어느 항목이 문제인지&quot;가 묻힌다. 스텝으로 쪼개면 각 화면이 한 가지만 검증하면 되고, 오류도 그 자리에서 바로 보여줄 수 있다. 게이팅은 잘못된 상태로 진행되는 걸 원천 차단한다 - 이게 나중에 죽은 코드 정리(4-4)로 이어진다.</p>
<h3 id="4-2-무엇으로-담을-것인가-대안-비교---핵심-결정">4-2. 무엇으로 담을 것인가 (대안 비교) - 핵심 결정</h3>
<table>
<thead>
<tr>
<th>방식</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>Fragment 5개 + FragmentManager</td>
<td>스텝별 격리 깔끔, 표준적</td>
<td><strong>스텝 간 상태 공유가 번거로움</strong>(타이머·플래그가 스텝을 넘나듦), 생명주기 크래시 표면 넓음, 코드량 많음</td>
</tr>
<tr>
<td><strong>ViewFlipper 단일 Activity</strong> (채택)</td>
<td>상태를 Activity 필드로 공유(별도 홀더 불필요), 크래시 표면 좁음, <strong>기존 뷰 id 그대로 보존 → API 로직 무변경</strong></td>
<td>한 Activity가 커짐</td>
</tr>
<tr>
<td>ViewPager2</td>
<td>스와이프 제스처 공짜</td>
<td>게이팅으로 <strong>일부러 못 넘어가게</strong> 막아야 하는데 스와이프가 그걸 방해</td>
</tr>
</tbody></table>
<p><strong>ViewFlipper를 골랐다.</strong> 결정적이었던 건 상태 공유였다. 3분 인증 타이머, 검증 플래그들이 스텝을 교차 참조하는데, Fragment로 쪼개면 이걸 넘기는 보일러플레이트가 기능보다 커진다. <code>ViewFlipper</code>는 그냥 한 Activity 안에서 뷰만 갈아끼우는 거라, <strong>상태는 Activity 필드로 자연히 공유</strong>된다. 게다가 기존 단일 화면의 뷰 id를 그대로 유지하니 검증·API 코드를 한 줄도 안 고치고 레이아웃만 재편할 수 있었다.</p>
<p><code>ViewPager2</code>는 게이팅과 정면충돌한다. 넘기면 안 되는데 스와이프로 넘어가버리니까.</p>
<blockquote>
<p>💡 <strong>ViewFlipper</strong> - 여러 자식 뷰 중 하나만 보여주는 컨테이너. <code>showNext()</code>/<code>showPrevious()</code>로 다음/이전 자식으로 전환한다. 원래 이미지 슬라이드쇼용이지만, &quot;한 번에 한 스텝만 보이는&quot; 마법사에 그대로 들어맞는다.</p>
</blockquote>
<blockquote>
<p><strong>쉽게 말하면</strong> - 카드 여러 장을 겹쳐 들고 맨 위 한 장만 보여주는 것과 같다. 다음으로 넘기면 그 위 카드로 바뀌는데, 카드들은 계속 한 손(하나의 화면)에 들려 있어서 서로 정보를 주고받기가 쉽다.</p>
</blockquote>
<h3 id="4-3-구현-전환--게이팅--인라인-검증">4-3. 구현: 전환 + 게이팅 + 인라인 검증</h3>
<p><strong>① 스텝 전환은 한 줄.</strong> 경계 체크만 붙였다.</p>
<pre><code class="language-java">private static final int STEP_COUNT = 5;

private void goNext() {
    ViewFlipper flipper = registerBinding.viewFlipper;
    if (flipper.getDisplayedChild() &gt;= STEP_COUNT - 1) return;
    flipper.showNext();
    updateStepUI();
}
private void goPrev() {
    ViewFlipper flipper = registerBinding.viewFlipper;
    if (flipper.getDisplayedChild() == 0) return;
    flipper.showPrevious();
    updateStepUI();
}</code></pre>
<p><strong>② 게이팅.</strong> 각 스텝의 통과 여부를 검증 플래그로 판정하고, 통과해야만 &quot;다음&quot; 버튼을 활성화한다.</p>
<pre><code class="language-java">// 현재 스텝의 검증 통과 여부 (통과해야 &#39;다음&#39; 활성화)
private boolean isCurrentStepValid() {
    switch (registerBinding.viewFlipper.getDisplayedChild()) {
        case 0: return isEmailVerified;      // 이메일 인증 완료
        case 1: return isPasswordConfirmed;  // 비밀번호 확인 일치
        case 2: return isNicknameChecked;    // 닉네임 중복확인 통과
        case 3: return isPhoneChecked;       // 전화번호 중복확인 통과
        default: return true;                // 스텝5는 performRegister에서 최종 검증
    }
}

private void updateNextButton() {
    // 비활성 색은 backgroundTint selector(disabled→gray)가 처리
    registerBinding.btnNext.setEnabled(isCurrentStepValid());
}</code></pre>
<p>이 플래그들이 바로 Activity 필드다. Fragment였다면 스텝마다 이걸 주고받는 배관이 필요했겠지만, 단일 Activity라 그냥 필드 하나로 끝난다.</p>
<p><strong>③ 뒤로가기 정책.</strong> 스텝 안에 있으면 이전 스텝으로, 첫 스텝이면 &quot;가입 그만두기&quot; 확인. 시스템 백버튼도 이 흐름에 태운다.</p>
<pre><code class="language-java">private void handleBack() {
    if (registerBinding.viewFlipper.getDisplayedChild() &gt; 0) {
        goPrev();          // 스텝 안 → 이전 스텝
    } else {
        confirmQuit();     // 첫 스텝 → 가입 중단 확인
    }
}</code></pre>
<p><strong>④ 인라인 검증.</strong> Toast를 없애고 입력창 바로 아래 초록/빨강 텍스트로 결과를 남겼다(닉네임·전화 자동 중복확인은 debounce로 처리 - 아래 링크). 탈퇴했던 계정이 같은 이메일로 다시 오면 <strong>복원 플로우</strong>로 분기해, 서버가 기존값을 유지하는 생년월일·성별 스텝을 숨긴다.</p>
<h3 id="4-4-흥미로운-정리-도달-불가능한-검증-코드-9개">4-4. 흥미로운 정리: 도달 불가능한 검증 코드 9개</h3>
<p>제출 함수(<code>performRegister</code>)에 검증 Toast가 <strong>11개</strong> 있었다. 그런데 게이팅 구조를 따라가 보니 그중 <strong>9개가 절대 도달할 수 없는 죽은 코드</strong>였다.</p>
<p>이유는 간단하다. 스텝0~3을 통과 못 하면 애초에 스텝5(제출)까지 못 온다. 그러니 스텝5에서 하는 &quot;이메일 인증 필요&quot;, &quot;닉네임 중복확인 필요&quot; 같은 검사는 <strong>항상 참</strong>이라 조건이 절대 안 걸린다. 게이팅이 이미 그 앞에서 다 막았기 때문이다.</p>
<pre><code>스텝0 통과(isEmailVerified) ─┐
스텝1 통과(isPasswordConfirmed)─┤ 다 통과해야만
스텝2 통과(isNicknameChecked) ─┤ 스텝5 도달
스텝3 통과(isPhoneChecked)  ───┘
                              ▼
        스텝5의 &quot;이메일 인증 필요?&quot; 검사 → 항상 통과됨 (죽은 코드)</code></pre><p>그 9개를 지웠다. 남긴 2개(지역·생년월일 미입력)만 스텝5에서 실제로 발생 가능한데, 이건 &quot;그 자리에서 값만 채우면 되는 단순 알림&quot;이라 Toast로 남겼다. 근거 있게 유지한 것이다.</p>
<h2 id="결과">결과</h2>
<ul>
<li>검증 피드백: <strong>Toast → 인라인 텍스트(초록/빨강)</strong>. 어느 항목이 왜 틀렸는지 화면에 남는다.</li>
<li>제출 함수 검증 분기 <strong>11개 → 2개</strong> (도달 불가능한 9개 제거). 남긴 2개도 근거 있게 유지.</li>
<li>상태 홀더 클래스 <strong>0개 추가</strong> (Activity 필드 재사용). <strong>API 로직 변경 0줄</strong> (기존 뷰 id 보존).</li>
<li>5스텝 전환·진행바(n/5)·게이팅·복원 분기 실기기 검증 완료.</li>
</ul>
<p><strong>Before / After</strong></p>
<ul>
<li><strong>Before</strong>: 긴 단일 폼, 제출해야 검증, 사라지는 Toast, 중복확인 수동 버튼.</li>
<li><strong>After</strong>: 5스텝, 스텝마다 즉시 인라인 검증, 통과해야 진행(게이팅), 죽은 검증 코드 제거로 제출 경로 정리.</li>
</ul>
<h2 id="qa">Q&amp;A</h2>
<p><strong>Q. Fragment가 더 표준적인 방식 아닌가요? 왜 ViewFlipper를 골랐나요?</strong>
스텝 간 상태 공유 때문입니다. 3분 인증 타이머, <code>isEmailVerified</code> 같은 검증 플래그가 여러 스텝을 넘나들며 참조됩니다. Fragment로 쪼개면 이 상태를 주고받는 배관(ViewModel·Bundle 등)이 기능 자체보다 커집니다. 단일 Activity + ViewFlipper는 상태를 그냥 Activity 필드로 공유하고, 기존 뷰 id를 그대로 유지해 검증·API 코드를 한 줄도 안 고쳐도 됐습니다. 스텝이 훨씬 많거나 각 스텝이 독립적으로 재사용된다면 Fragment가 나을 수 있습니다.</p>
<p><strong>Q. ViewPager2는 스와이프가 공짜인데 왜 안 썼나요?</strong>
이 화면은 게이팅이 핵심입니다. 현재 스텝을 통과 못 하면 다음으로 넘어가면 안 되는데, ViewPager2는 스와이프로 사용자가 임의로 넘겨버릴 수 있습니다. &quot;일부러 못 넘어가게 막는&quot; 요구와 &quot;스와이프로 자유롭게 넘김&quot;이 정면충돌합니다.</p>
<p><strong>Q. 죽은 코드 9개는 어떻게 찾았나요?</strong>
게이팅 구조를 위에서부터 따라갔습니다. 스텝0~3을 통과해야만 제출 스텝(스텝5)에 도달하는데, 그렇다면 스텝5에서 다시 하는 &quot;이메일 인증 필요?&quot; 같은 검사는 조건이 항상 참이라 절대 실행되지 않습니다. 게이팅이 이미 앞에서 다 걸러냈기 때문입니다. 이렇게 &quot;논리적으로 도달 불가능한&quot; 분기 9개를 확인해 지웠습니다.</p>
<p><strong>Q. 남긴 2개는 왜 지우지 않았나요?</strong>
지역·생년월일 미입력 검사는 마지막 스텝에서 <strong>실제로 발생 가능</strong>하기 때문입니다(그 스텝에서 값을 안 채우고 제출할 수 있음). 도달 가능하고, &quot;그 자리에서 값만 채우면 되는 단순 알림&quot;이라 Toast로 남기는 게 적절했습니다. 즉 도달 가능 여부를 기준으로 지울 것과 남길 것을 나눴습니다.</p>
<p><strong>Q. 검증 결과를 Toast에서 인라인 텍스트로 바꾼 이유는?</strong>
Toast는 몇 초 뒤 사라져서 &quot;어느 항목이 왜 틀렸는지&quot;가 화면에 남지 않습니다. 긴 폼에서 여러 개가 동시에 틀리면 무엇을 고쳐야 할지 놓칩니다. 입력창 바로 아래 초록/빨강 텍스트로 남기면 오류가 그 자리에 계속 보여서 사용자가 바로 대응할 수 있습니다.</p>
<p><strong>Q. 뒤로가기(백버튼)는 어떻게 처리했나요?</strong>
스텝 안에 있으면 이전 스텝으로 이동하고, 첫 스텝에서 뒤로가기를 누르면 &quot;가입을 그만두시겠어요?&quot; 확인을 띄웁니다. 시스템 백버튼도 같은 흐름을 타게 해서, 사용자가 실수로 가입 화면을 통째로 벗어나 입력한 내용을 잃는 걸 막습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] PagerSnapHelper + LinearSmoothScroller
]]></title>
            <link>https://velog.io/@ju-unn/Android-PagerSnapHelper-LinearSmoothScroller</link>
            <guid>https://velog.io/@ju-unn/Android-PagerSnapHelper-LinearSmoothScroller</guid>
            <pubDate>Mon, 31 Aug 2026 03:57:22 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 손보면서 홈 화면 상단 &quot;지금 뜨고 있는 모임&quot; 캐러셀을 3초마다 자동으로 넘어가게 만들었다. 새 라이브러리 없이 이미 쓰던 <code>RecyclerView</code>에 <code>PagerSnapHelper</code> 한 줄을 얹고 <code>Handler</code>로 돌렸는데, 그 과정에서 스냅과 스크롤이 싸우는 버그를 하나 밟았다. 그 버그를 <code>LinearSmoothScroller</code>로 잡은 이야기까지 정리한다.</p>
<h2 id="상황">상황</h2>
<p>핫모임 캐러셀이 정적이었다. 인기 모임을 가로로 넘겨볼 수 있는데 <strong>사용자가 직접 스와이프해야만</strong> 다음 카드가 보였다. 첫 화면에서 &quot;여기 활발하다&quot;는 인상을 주려면 자동으로 넘어가는 움직임이 필요했다.</p>
<h2 id="문제">문제</h2>
<p>이미 <code>RecyclerView</code>로 렌더링 중인 캐러셀을 <strong>3초마다 한 카드씩 자동 순환</strong>시킨다. 단,</p>
<ul>
<li>사용자가 손을 대면 멈추고, 떼면 재개.</li>
<li>화면을 벗어나면 백그라운드에서 안 돌게 정리(누수 방지).</li>
<li>새 의존성 0개.</li>
</ul>
<h2 id="개념부터">개념부터</h2>
<h3 id="3-1-개념부터-snaphelper가-뭔가">3-1. 개념부터: SnapHelper가 뭔가</h3>
<blockquote>
<p>💡 <strong>SnapHelper</strong> - <code>RecyclerView</code>의 스크롤이 멈출 때, 아무 위치에서나 서지 않고 <strong>항목 경계에 딱 맞춰(snap) 정지</strong>하게 해주는 도우미.</p>
</blockquote>
<blockquote>
<p><strong>쉽게 말하면</strong> - 서랍을 살짝 밀면 끝까지 딸깍 닫히는 것과 같다. 어중간한 위치에서 멈추지 않고 항상 &quot;제자리&quot;에 붙는다. 캐러셀도 카드가 반쯤 걸친 채 멈추지 않고, 한 카드가 화면에 딱 맞게 정렬된 상태로 선다.</p>
</blockquote>
<p>그냥 <code>RecyclerView</code>는 스크롤하면 관성으로 아무 데서나 멈춘다. 카드가 반쯤 걸친 채로 서는 것이다. 캐러셀은 &quot;한 카드가 딱 보이는&quot; 상태로 멈춰야 하는데, 그걸 <code>SnapHelper</code>가 해준다. 종류가 둘 있다.</p>
<table>
<thead>
<tr>
<th></th>
<th>LinearSnapHelper</th>
<th>PagerSnapHelper</th>
</tr>
</thead>
<tbody><tr>
<td>스냅 위치</td>
<td>화면 <strong>중앙</strong>에 가장 가까운 항목</td>
<td><strong>한 번에 한 페이지</strong>(뷰포트 단위)</td>
</tr>
<tr>
<td>느낌</td>
<td>여러 개 흘러가다 중앙 정렬</td>
<td>뷰페이저처럼 한 장씩</td>
</tr>
<tr>
<td>캐러셀에 적합</td>
<td>갤러리/추천 리스트</td>
<td><strong>한 장씩 넘기는 캐러셀</strong> ← 이번 케이스</td>
</tr>
</tbody></table>
<p>핫모임은 &quot;한 번에 한 카드&quot;라 <code>PagerSnapHelper</code>가 맞다. (참고로 <code>ViewPager2</code>를 썼다면 스냅은 공짜지만, 아래 대안 비교에서 탈락시킨 이유가 있다.)</p>
<h3 id="3-2-어떻게-만들까-대안-비교">3-2. 어떻게 만들까 (대안 비교)</h3>
<table>
<thead>
<tr>
<th>방식</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>ViewPager2 + 타이머</td>
<td>페이지 개념 내장, 인디케이터 쉬움</td>
<td>이미 RecyclerView로 만든 걸 갈아엎어야 함, 세로 스크롤 안에 가로 ViewPager2 넣으면 스크롤 충돌</td>
</tr>
<tr>
<td>캐러셀 라이브러리</td>
<td>오토슬라이드·순환 공짜</td>
<td>새 의존성, 기존 어댑터 재작성</td>
</tr>
<tr>
<td><strong>RecyclerView + PagerSnapHelper + Handler</strong> (채택)</td>
<td>쓰던 RecyclerView 그대로, 스냅만 얹음, 의존성 0</td>
<td>오토슬라이드·터치정지·순환을 직접 배선</td>
</tr>
</tbody></table>
<p>핫모임은 이미 <code>RecyclerView</code>로 렌더링 중이었다. <code>ViewPager2</code>로 갈아엎으면 어댑터를 다시 짜야 하고, 세로 스크롤 화면 안에 가로 <code>ViewPager2</code>를 넣는 스크롤 충돌까지 떠안는다. <code>PagerSnapHelper</code>는 <strong>기존 RecyclerView에 한 줄로 붙어</strong> 한 카드씩 스냅을 만들어주고, 자동 넘김은 <code>Handler</code> 루프면 충분했다.</p>
<h3 id="3-3-구현-실코드">3-3. 구현 (실코드)</h3>
<p><strong>① 스냅 부착 + 터치 정지/재개.</strong> <code>PagerSnapHelper</code>는 정말 한 줄이다.</p>
<pre><code class="language-java">// 한 카드씩 스냅 + 터치 시 오토슬라이드 정지/재개
new PagerSnapHelper().attachToRecyclerView(binding.rvHotMeetings);
binding.rvHotMeetings.setOnTouchListener((v, event) -&gt; {
    switch (event.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:
            stopAutoSlide();
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            startAutoSlide();
            break;
    }
    return false; // RecyclerView가 스크롤을 계속 처리하게 둠
});</code></pre>
<p><code>return false</code>가 중요하다. 터치 이벤트를 소비해버리면(<code>true</code>) RecyclerView가 스크롤을 못 한다. <code>false</code>로 흘려보내야 &quot;오토슬라이드만 멈추고 사용자 스와이프는 정상 동작&quot;이 된다.</p>
<p><strong>② 3초 자동 순환 루프.</strong> <code>Handler</code> + <code>postDelayed</code>로 자기 자신을 다시 예약하는 러너블을 만든다.</p>
<pre><code class="language-java">private final Handler autoSlideHandler = new Handler(Looper.getMainLooper());
private static final long AUTO_SLIDE_INTERVAL = 3000L;

private final Runnable autoSlideRunnable = new Runnable() {
    @Override
    public void run() {
        if (binding != null) {
            int count = hotMeetingListAdapter.getItemCount();
            if (count &gt; 1) {
                LinearLayoutManager lm = (LinearLayoutManager) binding.rvHotMeetings.getLayoutManager();
                int cur = lm.findFirstCompletelyVisibleItemPosition();
                if (cur == RecyclerView.NO_POSITION) cur = lm.findFirstVisibleItemPosition();
                int next = (cur + 1 &gt;= count) ? 0 : cur + 1;   // 끝 → 처음 순환

                // ↓↓ 여기가 버그를 잡은 핵심 (아래 설명)
                LinearSmoothScroller scroller =
                        new LinearSmoothScroller(binding.rvHotMeetings.getContext()) {
                    @Override protected int getHorizontalSnapPreference() { return SNAP_TO_START; }
                    @Override protected float calculateSpeedPerPixel(android.util.DisplayMetrics dm) {
                        return super.calculateSpeedPerPixel(dm) * 2f; // 애니메이션 2배 느리게
                    }
                };
                scroller.setTargetPosition(next);
                lm.startSmoothScroll(scroller);
            }
            autoSlideHandler.postDelayed(this, AUTO_SLIDE_INTERVAL); // 자기 자신 재예약
        }
    }
};</code></pre>
<p><code>count &gt; 1</code> 가드로 카드가 0~1개면 아무것도 안 한다(순환할 게 없으니 no-op, 크래시 방지). <code>next</code> 계산의 <code>(cur + 1 &gt;= count) ? 0 : cur + 1</code>이 끝→처음 순환을 만든다.</p>
<p><strong>③ 밟은 버그: peek 카드에서 제자리 회전.</strong> 처음엔 평범하게 <code>smoothScrollToPosition(next)</code>를 썼다. 그런데 옆 카드가 살짝 보이는 <strong>peek 레이아웃</strong>에서, 스크롤이 다음 카드로 가려는 순간 <code>PagerSnapHelper</code>가 &quot;가장 가까운 스냅 위치&quot;로 되밀어서 <strong>제자리에서 도는</strong> 현상이 났다. 스크롤과 스냅이 서로 목표가 달라 싸운 것이다.</p>
<p>해결은 스크롤 목표를 <strong>명시</strong>하는 것이다. <code>LinearSmoothScroller</code>의 <code>getHorizontalSnapPreference()</code>를 <code>SNAP_TO_START</code>로 오버라이드하면 &quot;다음 카드를 시작 지점에 붙인다&quot;가 목표로 고정돼, 스냅이 되밀 여지가 없어진다.</p>
<pre><code>smoothScrollToPosition:  다음 카드로 →  ← 스냅이 되밈  = 제자리 회전 (버그)
LinearSmoothScroller(SNAP_TO_START):  다음 카드를 START에 고정 → 스냅과 목표 일치 (해결)</code></pre><p>덤으로 <code>calculateSpeedPerPixel</code>을 <code>super * 2f</code>로 오버라이드해서 픽셀당 이동 시간을 2배로 늘렸다 - 자동 슬라이드 애니메이션이 2배 느려져 더 부드럽다. 인터벌(3초)은 그대로.</p>
<p><strong>④ 생명주기 정리(누수 방지).</strong> 러너블은 화면을 참조하므로, 화면을 벗어나면 반드시 예약을 제거해야 한다. 중복 루프를 막기 위해 시작할 땐 항상 <code>remove</code> 후 <code>post</code>한다.</p>
<pre><code class="language-java">// 시작/정지 (중복 방지 위해 항상 remove 후 post)
private void startAutoSlide() {
    autoSlideHandler.removeCallbacks(autoSlideRunnable);
    autoSlideHandler.postDelayed(autoSlideRunnable, AUTO_SLIDE_INTERVAL);
}
private void stopAutoSlide() {
    autoSlideHandler.removeCallbacks(autoSlideRunnable);
}</code></pre>
<p><code>onResume</code>에서 <code>startAutoSlide()</code>, <code>onPause</code>/<code>onDestroyView</code>에서 <code>stopAutoSlide()</code>를 호출해 화면이 안 보일 땐 루프가 돌지 않게 한다.</p>
<h2 id="결과">결과</h2>
<ul>
<li>핫모임 <strong>3초 간격 자동 순환</strong>, 끝→처음 wrap, 터치 시 정지/재개, 화면 이탈 시 정리.</li>
<li>peek 카드 <strong>제자리 회전 버그</strong>를 <code>LinearSmoothScroller(SNAP_TO_START)</code>로 제거.</li>
<li>애니메이션 속도는 <code>calculateSpeedPerPixel</code> 오버라이드로 조절(인터벌은 유지).</li>
<li>새 의존성 <strong>0개</strong> - <code>PagerSnapHelper</code>/<code>LinearSmoothScroller</code>는 <code>androidx.recyclerview</code>에 이미 포함.</li>
</ul>
<p><strong>Before / After</strong></p>
<ul>
<li><strong>Before</strong>: 정적 캐러셀. 사용자가 직접 스와이프해야만 다음 카드가 보임.</li>
<li><strong>After</strong>: 3초 자동 순환(터치는 존중), 스냅 안정적으로 한 카드씩 이동.</li>
</ul>
<h2 id="qa">Q&amp;A</h2>
<p><strong>Q. <code>PagerSnapHelper</code>와 <code>LinearSnapHelper</code>는 언제 뭘 쓰나요?</strong>
<code>PagerSnapHelper</code>는 뷰페이저처럼 &quot;한 번에 한 페이지&quot;를 스냅합니다. <code>LinearSnapHelper</code>는 화면 중앙에 가장 가까운 항목을 스냅합니다. 한 장씩 넘기는 캐러셀은 <code>PagerSnapHelper</code>, 여러 개가 흐르다 중앙 정렬되는 갤러리형 리스트는 <code>LinearSnapHelper</code>가 어울립니다.</p>
<p><strong>Q. 그냥 <code>ViewPager2</code>를 쓰면 스냅이 공짜인데 왜 안 썼나요?</strong>
핫모임이 이미 <code>RecyclerView</code>로 렌더링되고 있었습니다. <code>ViewPager2</code>로 바꾸면 어댑터를 다시 짜야 하고, 세로 스크롤 화면 안에 가로 <code>ViewPager2</code>를 넣으면 스크롤 방향 충돌을 별도로 처리해야 합니다. 기존 구조에 <code>PagerSnapHelper</code> 한 줄을 얹는 편이 훨씬 적은 변경으로 같은 결과를 냅니다.</p>
<p><strong>Q. peek 카드에서 제자리 회전 버그는 정확히 왜 났나요?</strong>
기본 <code>smoothScrollToPosition</code>은 &quot;다음 카드로 스크롤&quot;이 목표인데, 옆 카드가 살짝 보이는 peek 레이아웃에서는 <code>PagerSnapHelper</code>가 &quot;가장 가까운 스냅 위치&quot;로 되밀려 합니다. 스크롤 목표와 스냅 목표가 어긋나 서로 당기면서 제자리에서 도는 현상이 생깁니다. <code>LinearSmoothScroller</code>에서 <code>SNAP_TO_START</code>로 목표를 &quot;다음 카드를 시작 지점에 붙임&quot;으로 못 박으면 두 목표가 일치해 해결됩니다.</p>
<p><strong>Q. <code>Handler</code> 루프가 백그라운드에서도 계속 도나요?</strong>
아니요. <code>onResume</code>에서 시작하고 <code>onPause</code>/<code>onDestroyView</code>에서 <code>removeCallbacks</code>로 멈춥니다. 화면이 안 보이면 루프가 돌지 않습니다. 또 시작할 때 항상 <code>remove</code> 후 <code>post</code>를 해서 루프가 중복으로 두 개 돌지 않도록 합니다.</p>
<p><strong>Q. 터치 리스너에서 <code>return false</code>를 하는 이유는?</strong>
<code>true</code>를 반환하면 터치 이벤트를 소비해버려 <code>RecyclerView</code>가 스크롤을 못 합니다. <code>false</code>로 흘려보내야 &quot;오토슬라이드만 멈추고, 사용자의 스와이프 스크롤은 정상 동작&quot;하게 됩니다.</p>
<p><strong>Q. 애니메이션 속도는 어떻게 조절했나요?</strong>
<code>LinearSmoothScroller</code>의 <code>calculateSpeedPerPixel</code>을 오버라이드해 기본값의 2배를 반환하도록 했습니다. 픽셀당 이동 시간이 2배가 되어 슬라이드가 더 느리고 부드러워집니다. 넘어가는 간격(3초)은 그대로 두고 애니메이션 속도만 바꾼 것입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 앱을 우회한 대량 생성 막기 - 테이블 없이 서버 rate limit
]]></title>
            <link>https://velog.io/@ju-unn/Android-%EC%95%B1%EC%9D%84-%EC%9A%B0%ED%9A%8C%ED%95%9C-%EB%8C%80%EB%9F%89-%EC%83%9D%EC%84%B1-%EB%A7%89%EA%B8%B0-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%97%86%EC%9D%B4-%EC%84%9C%EB%B2%84-rate-limit</link>
            <guid>https://velog.io/@ju-unn/Android-%EC%95%B1%EC%9D%84-%EC%9A%B0%ED%9A%8C%ED%95%9C-%EB%8C%80%EB%9F%89-%EC%83%9D%EC%84%B1-%EB%A7%89%EA%B8%B0-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%97%86%EC%9D%B4-%EC%84%9C%EB%B2%84-rate-limit</guid>
            <pubDate>Mon, 31 Aug 2026 02:47:01 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 손보면서 클릭 연타를 막는 클라이언트 가드를 다 깔고 나서도 찜찜한 구멍이 하나 남았다. <strong>앱을 거치지 않고 서버 API를 직접 때리는</strong> 경우다. 이건 클라 코드로는 절대 못 막는다. 서버에서 rate limit으로 막은 과정을 정리한다. 새 테이블도, 정리 크론도 없이.</p>
<h2 id="상황">상황</h2>
<p>모임 생성 API가 두 개 있다. 일반 모임(<code>api/meeting/create.php</code>)과 체험단(<code>api/campaign/campaign_create.php</code>).</p>
<p>클라이언트에는 이미 &quot;제출 버튼 누르면 비활성화 → 응답 오면 복원&quot;하는 인플라이트 가드가 있다. 하지만 이건 <strong>실수로 더블탭하는 것</strong>만 막는다. 누군가 앱을 거치지 않고 <code>curl</code>로 생성 API를 100번 직접 호출하면? 클라 가드는 아무 소용이 없다. 요청이 애초에 앱을 통과하지 않으니까.</p>
<p>엔드포인트별로 노출도를 따져봤다.</p>
<table>
<thead>
<tr>
<th>엔드포인트</th>
<th>기존 방어</th>
<th>대량 호출 노출</th>
</tr>
</thead>
<tbody><tr>
<td><code>meeting/approve.php</code></td>
<td>상태가 &#39;대기&#39; 아니면 거부</td>
<td>안전 (중복 무시됨)</td>
</tr>
<tr>
<td><code>chat/kick.php</code></td>
<td>&#39;승인&#39;만 + <code>FOR UPDATE</code> 잠금</td>
<td>안전</td>
</tr>
<tr>
<td><strong><code>meeting/create.php</code></strong></td>
<td><strong>없음</strong></td>
<td><strong>노출 - 진짜 구멍</strong></td>
</tr>
<tr>
<td><code>campaign_create.php</code></td>
<td>등록권(티켓) 1개 차감</td>
<td>자연 제한됨 (아래 설명)</td>
</tr>
</tbody></table>
<p>체험단 생성은 매번 유료 등록권을 1개 깎으므로 <strong>티켓 수만큼만 생성 가능</strong> - 자연스러운 rate limit이 이미 걸려 있다. 문제는 일반 모임 생성이었다. 여긴 아무 제한이 없어서 무한정 만들 수 있었다.</p>
<h2 id="문제">문제</h2>
<p><strong>새 테이블이나 정리 크론 없이</strong>, 앱을 우회한 스크립트성 대량 생성만 막는 서버측 백스톱을 만든다. 정상 유저는 절대 걸리지 않아야 한다.</p>
<h2 id="개념부터">개념부터</h2>
<h3 id="2-1-개념부터-rate-limit이-뭔가">2-1. 개념부터: rate limit이 뭔가</h3>
<blockquote>
<p>💡 <strong>rate limit</strong> - &quot;한 주체(유저/IP 등)가 일정 시간 안에 할 수 있는 요청 수&quot;에 상한을 두고, 넘으면 거부(보통 HTTP 429)하는 기법.</p>
</blockquote>
<blockquote>
<p><strong>쉽게 말하면</strong> - 무한리필 식당에서 &quot;10분에 접시 다섯 개까지만 주문 가능&quot;이라고 정해두는 것과 같다. 손님이 아무리 빨리 시켜도 그 이상은 잠깐 기다렸다 시켜야 한다. 서버도 한 사람이 짧은 시간에 너무 많이 요청하면 &quot;잠시 후 다시&quot; 하고 돌려보낸다.</p>
</blockquote>
<p>클라 가드가 &quot;실수 더블탭&quot;을 막는다면, rate limit은 &quot;의도적 대량 호출&quot;을 막는다. 방어 계층이 다르다. 이 둘에 앞서 debounce(클라)까지 합치면 3계층이 된다.</p>
<pre><code>[클라] debounce        → 입력 폭주 줄이기
[클라] 인플라이트 가드  → 실수 더블탭 막기
[서버] rate limit       → 앱 우회 대량 호출 막기  ← 이 글</code></pre><h3 id="2-2-어떻게-셀-것인가-알고리즘-비교">2-2. 어떻게 셀 것인가 (알고리즘 비교)</h3>
<p>rate limit 구현 방식은 크게 셋이다.</p>
<table>
<thead>
<tr>
<th>알고리즘</th>
<th>원리</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Fixed window (고정창)</strong></td>
<td>&quot;정해진 시간창 안의 요청 수&quot;를 센다</td>
<td>구현 단순, 상태 최소</td>
<td>창 경계에서 순간 2배 허용 가능</td>
</tr>
<tr>
<td>Sliding window (슬라이딩창)</td>
<td>요청 시각을 저장하고 &quot;지금부터 과거 W초&quot; 범위를 센다</td>
<td>경계 문제 없음, 정확</td>
<td>요청 타임스탬프 저장·정리 필요</td>
</tr>
<tr>
<td>Token bucket</td>
<td>토큰을 일정 속도로 채우고 요청마다 1개 소비</td>
<td>순간 버스트 허용 조절 유연</td>
<td>버킷 상태 저장·리필 로직 필요</td>
</tr>
</tbody></table>
<pre><code>Fixed window:  |―――― W초 창 ――――|―――― 다음 창 ――――|
               요청요청요청…             (창 넘어가면 카운트 리셋)
               이 창 안에서 N번째 넘으면 → 429</code></pre><p>정교함만 보면 sliding window나 token bucket이 낫다. 하지만 그것들은 <strong>요청 상태를 어딘가에 저장하고 관리</strong>해야 한다(별도 테이블 + 오래된 기록 정리 크론). 내 요구는 &quot;대량 생성 스크립트를 막는 백스톱&quot; 하나뿐이라, 창 경계에서 2배가 잠깐 허용되는 건 실질적으로 문제가 안 된다. <strong>fixed window</strong>로 충분하고, 그게 제일 게으른 정답이었다.</p>
<h3 id="2-3-구현-이미-있는-컬럼을-세다">2-3. 구현: 이미 있는 컬럼을 세다</h3>
<p>핵심 아이디어는 이거다. <strong>별도 rate-limit 테이블을 만들지 않고, 이미 있는 <code>meetings</code> 테이블의 생성 시각 컬럼(<code>meeting_created_at</code>)을 직접 센다.</strong> 최근 W초 안에 이 유저가 만든 모임이 N개 이상이면 막는다.</p>
<p>이 방식의 장점:</p>
<ul>
<li><strong>새 테이블 불필요</strong> - 이미 저장하는 데이터를 재사용.</li>
<li><strong>정리 크론 불필요</strong> - &quot;최근 W초&quot;라는 시간창이 자동으로 오래된 걸 밀어낸다. 스스로 만료된다.</li>
<li><strong>성공한 생성만 카운트</strong> - 정확히 막고 싶은 대상(실제로 생성된 모임)만 센다.</li>
</ul>
<p>두 생성 API가 공유하는 헬퍼 파일(<code>meeting_create_common.php</code>)에 함수를 추가했다.</p>
<pre><code class="language-php">// ── 생성 rate limit 설정 ──
// 클라 인플라이트 가드를 우회한 스크립트성 대량 생성만 막는 서버측 백스톱.
// 정상 유저는 W초 안에 모임을 N개씩 만들지 않으므로 오탐 없음. 필요 시 값만 조정.
const MEETING_CREATE_RATE_WINDOW_SECONDS = W;   // 시간창 (초)
const MEETING_CREATE_RATE_MAX            = N;   // 창 안 최대 생성 수

function isMeetingCreateRateLimited(PDO $databaseConnection, int $hostUserId): bool {
    $cutoff = date(&#39;Y-m-d H:i:s&#39;, time() - MEETING_CREATE_RATE_WINDOW_SECONDS);

    $rateStatement = $databaseConnection-&gt;prepare(
        &quot;SELECT COUNT(*) FROM meetings
         WHERE meeting_host_id = :host_id
           AND meeting_created_at &gt; :cutoff&quot;
    );
    $rateStatement-&gt;bindParam(&#39;:host_id&#39;, $hostUserId, PDO::PARAM_INT);
    $rateStatement-&gt;bindParam(&#39;:cutoff&#39;,  $cutoff);
    $rateStatement-&gt;execute();

    return (int)$rateStatement-&gt;fetchColumn() &gt;= MEETING_CREATE_RATE_MAX;
}</code></pre>
<p><strong>설계 결정 두 가지</strong>를 짚어둔다.</p>
<ol>
<li><strong>cutoff를 PHP에서 계산해 datetime으로 바인딩.</strong> SQL의 <code>INTERVAL</code> 안에 파라미터를 바인딩하는 건 드라이버마다 동작이 불안정하다. 그래서 &quot;지금 - W초&quot;를 PHP에서 계산해 완성된 datetime 문자열로 넘긴다. 단, 이게 정확하려면 PHP 타임존과 DB 세션 타임존이 일치해야 한다(둘 다 KST=+9:00로 맞춰둠).</li>
<li><strong>호출 위치는 트랜잭션 시작 전.</strong> 잠금이나 등록권 차감 같은 무거운 작업에 들어가기 전에 먼저 거른다.</li>
</ol>
<p>호출부는 두 파일 모두 동일하게, DB 연결 직후·트랜잭션 전에 넣었다.</p>
<pre><code class="language-php">// ── rate limit: 대량 생성 스크립트 백스톱 (트랜잭션 전에 검사) ──
if (isMeetingCreateRateLimited($databaseConnection, $hostUserId)) {
    sendError(&#39;요청이 너무 잦습니다. 잠시 후 다시 시도해주세요&#39;, 429);
}</code></pre>
<p>체험단 쪽은 등록권 차감이라는 자연 제한이 이미 있지만, 같은 헬퍼를 공용으로 얹어 이중으로 막았다.</p>
<blockquote>
<p>참고: 이 프로젝트의 <code>sendError()</code>는 관례상 HTTP 200 바디에 <code>{success:false, message}</code>를 담아 앱이 바디로 분기한다. 그래서 rate-limit 거부를 추가해도 <strong>클라이언트 코드는 한 줄도 안 고쳐도</strong> 기존 에러 처리에 그대로 걸린다.</p>
</blockquote>
<h3 id="2-4-검증">2-4. 검증</h3>
<ul>
<li>정상 유저 시나리오: W초 안에 N개 미만 생성 → 통과.</li>
<li>대량 호출 시나리오: 같은 유저로 N개 초과 생성 시도 → N번째부터 429 거부, 트랜잭션 진입 전 차단.</li>
<li>함수가 세는 건 <code>meeting_host_id</code>가 본인이고 <code>meeting_created_at</code>이 창 안인 행뿐 → 남의 생성이나 오래된 생성은 영향 없음.</li>
</ul>
<h2 id="결과">결과</h2>
<ul>
<li>일반/체험단 두 생성 API에 <strong>공용 헬퍼 1개</strong>로 rate limit 적용.</li>
<li><strong>새 테이블 0, 정리 크론 0</strong> - 기존 <code>meeting_created_at</code> 컬럼을 세는 것으로 해결(시간창 자동 만료).</li>
<li>성공한 생성만 카운트하므로 <strong>정상 유저 오탐 0</strong>.</li>
<li>클라이언트 코드 변경 <strong>0줄</strong> (기존 에러 처리 관례에 그대로 편승).</li>
</ul>
<p><strong>Before / After</strong></p>
<ul>
<li><strong>Before</strong>: 일반 모임 생성 API에 아무 제한 없음 → 앱 우회 대량 생성 가능.</li>
<li><strong>After</strong>: 창 W초 안에 N개 초과 생성 시 429 거부. 클라 가드(더블탭)와 서버 rate limit(대량 호출)이 방어 계층을 나눠 맡음.</li>
</ul>
<h2 id="qa">Q&amp;A</h2>
<p><strong>Q. 클라이언트에서 이미 버튼을 막는데 서버 rate limit이 왜 또 필요한가요?</strong>
클라이언트 가드는 &quot;앱을 통해 들어오는 요청&quot;만 막습니다. 공격자는 앱을 켜지 않고 <code>curl</code> 같은 도구로 서버 API를 직접 호출할 수 있고, 이 요청은 클라 코드를 아예 거치지 않습니다. 서버에서 막지 않으면 대량 생성을 통제할 방법이 없습니다. &quot;신뢰 경계는 서버&quot;라는 원칙입니다.</p>
<p><strong>Q. 왜 별도 rate-limit 테이블을 안 만들고 기존 컬럼을 셌나요?</strong>
막으려는 대상이 &quot;실제로 생성된 모임&quot;이고, 그 생성 시각(<code>meeting_created_at</code>)이 이미 <code>meetings</code> 테이블에 있기 때문입니다. 이미 있는 데이터를 세면 새 테이블도, 오래된 기록을 지우는 정리 크론도 필요 없습니다. &quot;최근 W초&quot;라는 시간창이 지나간 기록을 자동으로 밀어내므로 스스로 만료됩니다.</p>
<p><strong>Q. fixed window의 &quot;경계에서 2배 허용&quot; 문제는 괜찮나요?</strong>
창이 리셋되는 순간에 걸쳐 요청하면 짧은 순간 최대 2배까지 통과할 수 있습니다. 하지만 이 방어의 목적은 &quot;스크립트성 대량 생성 차단&quot;이고, 순간적으로 N이 2N이 되는 것은 실질적 위협이 아닙니다. 정확도가 더 필요하면 sliding window로 올리면 되지만, 그만큼 상태 저장·정리 비용이 붙습니다. 목적에 맞는 가장 단순한 선택을 한 것입니다.</p>
<p><strong>Q. cutoff를 SQL의 <code>INTERVAL</code>로 계산하지 않고 PHP에서 만든 이유는?</strong>
<code>WHERE created_at &gt; NOW() - INTERVAL :sec SECOND</code>처럼 <code>INTERVAL</code> 안에 파라미터를 바인딩하는 건 드라이버·DB마다 동작이 갈립니다. 그래서 &quot;지금 - W초&quot;를 PHP에서 계산해 완성된 datetime 문자열로 바인딩합니다. 대신 PHP 타임존과 DB 세션 타임존이 반드시 일치해야 합니다(둘 다 KST로 맞춤).</p>
<p><strong>Q. rate limit에 걸리면 정상 유저도 불편하지 않나요?</strong>
임계값을 &quot;정상 사용에서는 절대 도달하지 않는&quot; 수준으로 잡아 오탐을 0에 가깝게 만듭니다. 사람이 W초 안에 모임을 N개씩 만들 일은 없기 때문입니다. 그래서 이 장치는 정상 유저에겐 보이지 않고, 스크립트성 남용에만 반응합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 중복확인 버튼"을 없앤 방법 - debounce로 자동 검사하기]]></title>
            <link>https://velog.io/@ju-unn/Android-%EC%A4%91%EB%B3%B5%ED%99%95%EC%9D%B8-%EB%B2%84%ED%8A%BC%EC%9D%84-%EC%97%86%EC%95%A4-%EB%B0%A9%EB%B2%95-debounce%EB%A1%9C-%EC%9E%90%EB%8F%99-%EA%B2%80%EC%82%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ju-unn/Android-%EC%A4%91%EB%B3%B5%ED%99%95%EC%9D%B8-%EB%B2%84%ED%8A%BC%EC%9D%84-%EC%97%86%EC%95%A4-%EB%B0%A9%EB%B2%95-debounce%EB%A1%9C-%EC%9E%90%EB%8F%99-%EA%B2%80%EC%82%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 31 Aug 2026 02:34:05 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 손보다가 앱 전체에 퍼져 있던 &quot;중복확인&quot; 버튼을 전부 걷어냈다. 대신 <strong>입력을 멈추면 알아서 검사</strong>하게 만들었는데, 그 뒤에 있는 기술이 debounce다. 이 글은 debounce가 뭔지, 왜 필요한지, 안드로이드에서 라이브러리 없이 어떻게 구현했는지를 정리한다.</p>
<h2 id="상황">상황</h2>
<p>닉네임·전화번호 입력창 옆에 각각 &quot;중복확인&quot; 버튼이 달려 있었다. 이 패턴이 회원가입·프로필 편집·온보딩까지 앱 곳곳에 똑같이 반복됐다.</p>
<p>사용자 입장에서 흐름은 이렇다.</p>
<ol>
<li>닉네임을 입력하고</li>
<li>&quot;중복확인&quot; 버튼을 누르고</li>
<li>결과를 본다</li>
</ol>
<p><strong>조작이 3단계</strong>다. 게다가 버튼 누르는 걸 깜빡하면, 통과 안 된 상태로 &quot;다음&quot;을 눌러서 &quot;중복확인을 해주세요&quot; 잔소리를 듣는다. 흔한 UX인데, 흔하다고 좋은 건 아니었다.</p>
<h2 id="문제">문제</h2>
<p>버튼을 없애고 <strong>입력하는 동안 알아서 검사</strong>하게 만들고 싶었다. 그런데 단순히 &quot;입력할 때마다 서버에 물어보기&quot;로 바꾸면 새로운 문제가 생긴다.</p>
<h2 id="행동">행동</h2>
<h3 id="1-1-개념부터-debounce가-뭔가">1-1. 개념부터: debounce가 뭔가</h3>
<blockquote>
<p>💡 <strong>debounce</strong> - 이벤트가 연속으로 쏟아질 때, &quot;마지막 이벤트 뒤 일정 시간 조용하면&quot; 그때 딱 한 번만 실행하는 기법.</p>
</blockquote>
<blockquote>
<p><strong>쉽게 말하면</strong> - 자동문과 같다. 사람들이 계속 드나드는 동안엔 문이 안 닫히고, 아무도 안 들어온 지 몇 초 지나야 그제야 닫힌다. debounce도 입력이 이어지는 동안은 기다리다가, 손을 멈춘 지 잠깐 지나야 &quot;이제 다 쳤구나&quot; 하고 딱 한 번 실행한다.</p>
</blockquote>
<p>왜 필요한지부터 보자. 버튼을 없애고 &quot;입력할 때마다 서버에 중복확인&quot;을 하면 어떻게 될까? 닉네임 <code>honbab</code>을 치는 동안 이런 요청이 나간다.</p>
<pre><code>h    → 서버 요청
ho   → 서버 요청
hon  → 서버 요청
honb → 서버 요청
honba→ 서버 요청
honbab→ 서버 요청</code></pre><p><strong>키 입력 6번에 서버 요청 6번.</strong> 앞의 5개는 아직 다 안 친 중간 글자라 검사할 의미도 없고, 서버만 두들겨 맞는다. 게다가 두 글자 쳤을 때 &quot;너무 짧습니다&quot;를 띄우면 한 글자마다 잔소리가 되어 UX도 최악이다.</p>
<p>debounce는 &quot;입력이 <strong>멈춘 뒤 일정 시간</strong>이 지나면 그때 한 번만&quot; 실행한다. 타이핑을 이어가는 동안은 실행 예약을 계속 취소·재예약하다가, 손을 멈추면 그제야 실행한다.</p>
<pre><code>타이핑: h o n b a b …………(입력 계속 = 예약 취소·재예약 반복)
                          │
                    [손 멈춤 400ms]
                          │
                          ▼
                   checkNicknameDuplicate() 1회 실행</code></pre><h4 id="debounce-vs-throttle-헷갈리는-개념-정리">debounce vs throttle (헷갈리는 개념 정리)</h4>
<p>둘 다 &quot;이벤트 폭주를 줄이는&quot; 기법이지만 목적이 다르다.</p>
<table>
<thead>
<tr>
<th></th>
<th>debounce</th>
<th>throttle</th>
</tr>
</thead>
<tbody><tr>
<td>언제 실행</td>
<td>이벤트가 <strong>멈춘 뒤</strong> 한 번</td>
<td>이벤트 도중 <strong>일정 간격마다</strong></td>
</tr>
<tr>
<td>관심사</td>
<td>&quot;<strong>끝났다</strong>&quot; 시점</td>
<td>&quot;<strong>진행 중</strong>&quot; 상태의 주기적 갱신</td>
</tr>
<tr>
<td>예시</td>
<td>검색어 자동완성, 중복확인, 입력 완료 감지</td>
<td>스크롤 위치 추적, 무한스크롤, 리사이즈 대응</td>
</tr>
</tbody></table>
<p>닉네임 중복확인은 &quot;<strong>입력을 끝냈다</strong>&quot;는 순간이 중요하므로 debounce가 맞다. 스크롤처럼 진행 중에도 계속 반응해야 하는 거였다면 throttle이었을 것이다.</p>
<blockquote>
<p><strong>쉽게 말하면</strong> — throttle은 &quot;아무리 요청해도 1분에 한 번씩만 처리할게&quot;처럼 진행 중에도 정해진 간격으로 계속 실행하는 것이고, debounce는 &quot;조용해지면 그때 한 번 처리할게&quot;처럼 멈춰야 실행하는 것이다.</p>
</blockquote>
<h3 id="1-2-어떻게-구현할까-대안-비교">1-2. 어떻게 구현할까 (대안 비교)</h3>
<p>RxJava의 <code>debounce()</code> 연산자나 별도 debounce 라이브러리를 쓸 수도 있었다. 하지만 필요한 건 &quot;입력 멈추면 400ms 뒤 한 번 실행&quot;이 전부다. 안드로이드 기본 <code>Handler</code>의 <code>removeCallbacks</code> + <code>postDelayed</code>면 정확히 그 동작이 나온다. 새 의존성 0개로 끝냈다.</p>
<h3 id="1-3-구현-실코드">1-3. 구현 (실코드)</h3>
<p>핵심은 세 가지다. <strong>핸들러/러너블을 필드로 재사용</strong>하고, 입력이 바뀔 때마다 <strong>이전 예약을 취소하고 다시 예약</strong>하고, 화면이 죽을 때 <strong>예약을 비운다.</strong></p>
<pre><code class="language-java">// 닉네임·전화번호 입력 멈춤 후 자동 중복확인 (debounce 400ms)
private static final long CHECK_DEBOUNCE_MS = 400L;
private final Handler debounceHandler = new Handler(Looper.getMainLooper());
private final Runnable nicknameCheckRunnable = this::checkNicknameDuplicate;
private final Runnable phoneCheckRunnable = this::checkPhoneDuplicate;</code></pre>
<p><code>Runnable</code>을 매번 <code>new</code> 하지 않고 필드로 하나씩 재사용하는 게 포인트다. <code>removeCallbacks(runnable)</code>이 &quot;그 러너블의 예약을 취소&quot;하는 방식이라, 취소하려면 예약할 때와 <strong>같은 인스턴스</strong>를 넘겨야 한다.</p>
<p>닉네임 <code>TextWatcher</code>:</p>
<pre><code class="language-java">@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
    isNicknameChecked = false; // 닉네임 변경시 재확인 필요
    updateNextButton();

    // 입력 멈추면 자동 중복확인 (2자 미만이면 대기, 서버 스팸 방지)
    debounceHandler.removeCallbacks(nicknameCheckRunnable);
    if (s.toString().trim().length() &lt; 2) {
        registerBinding.tvNicknameCheckResult.setVisibility(View.GONE);
        return;
    }
    debounceHandler.postDelayed(nicknameCheckRunnable, CHECK_DEBOUNCE_MS);
}</code></pre>
<p>입력이 바뀔 때마다 <code>removeCallbacks</code>로 <strong>직전 예약을 취소</strong>하고, <code>postDelayed</code>로 <strong>400ms 뒤 실행을 다시 예약</strong>한다. 타이핑이 계속되면 매번 취소→재예약이라 실제로는 안 돌아간다. 손을 멈춰야 400ms 타이머가 살아남아 <code>checkNicknameDuplicate()</code>가 딱 한 번 호출된다.</p>
<p>여기에 <strong>발화 조건 게이팅</strong>을 얹었다. 시간만 갖고 무조건 서버를 때리는 게 아니다.</p>
<ul>
<li><strong>닉네임은 2자 이상</strong>일 때만. (한 글자 상태의 무의미한 요청·잔소리 차단)</li>
<li><strong>전화번호는 형식이 완성됐을 때만.</strong></li>
</ul>
<pre><code class="language-java">// 형식(010-XXXX-XXXX)이 완성됐을 때만 자동 중복확인
debounceHandler.removeCallbacks(phoneCheckRunnable);
if (s.toString().trim().matches(&quot;^010-\\d{4}-\\d{4}$&quot;)) {
    debounceHandler.postDelayed(phoneCheckRunnable, CHECK_DEBOUNCE_MS);
}</code></pre>
<p>즉 &quot;<strong>멈췄고</strong>(debounce) + <strong>검사할 가치가 있는 값일 때</strong>(게이팅)&quot; 두 조건이 모두 맞아야 서버 왕복이 일어난다. 결과적으로 서버 요청은 사용자가 실제로 확인받고 싶은 순간에만, 항목당 사실상 1회로 수렴한다.</p>
<p>실제 검사 함수는 서버 호출 전에 클라에서 길이·특수문자·숫자전용 여부를 먼저 거른다(서버 왕복을 한 번 더 아낀다).</p>
<pre><code class="language-java">private void checkNicknameDuplicate() {
    String nickname = registerBinding.etNickname.getText().toString().trim();

    if (nickname.isEmpty() || nickname.length() &lt; 2) {
        showNicknameResult(false, &quot;닉네임은 2자 이상 입력해주세요&quot;);
        return;
    }
    if (!nickname.matches(&quot;^[가-힣a-zA-Z0-9]+$&quot;)) {
        showNicknameResult(false, &quot;특수문자는 사용할 수 없습니다&quot;);
        return;
    }
    // … 통과하면 서버 중복확인 (apiService.checkNickname) …
}</code></pre>
<p>마지막으로, <strong>누수 방지</strong>가 debounce에서 제일 놓치기 쉬운 부분이다. 예약된 콜백은 화면(Activity)을 참조하는데, 화면이 죽은 뒤 콜백이 터지면 죽은 뷰를 건드려 크래시가 나거나 메모리 누수가 된다. <code>onDestroy</code>에서 전부 비운다.</p>
<pre><code class="language-java">@Override
protected void onDestroy() {
    super.onDestroy();
    cancelVerificationTimer();
    debounceHandler.removeCallbacksAndMessages(null); // 예약된 debounce 콜백 전부 제거
}</code></pre>
<h4 id="왜-400ms인가">왜 400ms인가</h4>
<p>너무 짧으면(예: 100ms) 타이핑 리듬의 자연스러운 멈칫에도 서버가 나가서 debounce 효과가 약해진다. 너무 길면(예: 1초) &quot;다 쳤는데 왜 반응이 없지&quot; 하고 답답하다. 400ms는 &quot;입력을 끝냈다&quot;고 볼 만한 최소 정지 구간이면서 체감 지연이 거의 없는 관용값이다. (검색 자동완성류에서 보통 300~500ms를 쓴다.)</p>
<h2 id="결과">결과</h2>
<ul>
<li>중복확인 <strong>버튼 클릭 → 자동(debounce 400ms)</strong>. 사용자 조작이 2단계(입력→버튼)에서 1단계(입력)로 줄었다.</li>
<li>서버 요청이 <strong>키 입력마다 → 항목당 사실상 1회</strong>로 수렴. &quot;멈춤 + 게이팅&quot; 이중 조건 덕분.</li>
<li>이 패턴을 회원가입뿐 아니라 <strong>프로필 편집·온보딩의 중복확인 버튼까지 같은 방식으로 통일</strong>해 걷어냈다.</li>
<li>새 의존성 <strong>0개</strong> (안드로이드 기본 <code>Handler</code>만 사용).</li>
</ul>
<p><strong>Before / After</strong></p>
<ul>
<li><strong>Before</strong>: 입력 → &quot;중복확인&quot; 버튼 클릭 → 결과. 3단계, 버튼 깜빡하면 진행 막힘.</li>
<li><strong>After</strong>: 입력하고 손 멈추면 400ms 뒤 자동 검사. 1단계, 버튼 자체가 사라짐.</li>
</ul>
<h2 id="qa">Q&amp;A</h2>
<p><strong>Q. debounce와 throttle, 실무에서 뭘 기준으로 고르나요?</strong>
&quot;마지막 상태 한 번만 필요하면 debounce, 진행 중에도 주기적으로 반응해야 하면 throttle&quot;로 나눕니다. 검색어 자동완성·중복확인·입력 완료 감지는 debounce, 스크롤 위치 추적·무한스크롤·리사이즈 대응은 throttle이 맞습니다.</p>
<p><strong>Q. 왜 RxJava나 debounce 라이브러리를 안 썼나요?</strong>
필요한 동작이 &quot;입력 멈추면 400ms 뒤 한 번 실행&quot;이 전부였기 때문입니다. 안드로이드 기본 <code>Handler</code>의 <code>removeCallbacks</code> + <code>postDelayed</code>로 정확히 그 동작이 나오는데, 이걸 위해 새 의존성을 추가하는 건 과합니다. 이미 RxJava를 쓰는 프로젝트라면 <code>debounce()</code> 연산자가 더 깔끔할 수 있습니다.</p>
<p><strong>Q. <code>Runnable</code>을 필드로 재사용하는 게 왜 중요한가요?</strong>
<code>removeCallbacks(runnable)</code>은 &quot;그 러너블 인스턴스의 예약&quot;을 취소합니다. 매번 <code>new Runnable</code>을 만들면 취소하려는 대상과 예약한 대상이 달라져서 취소가 안 됩니다. 예약할 때와 같은 인스턴스를 넘겨야 하므로 필드로 하나씩 들고 재사용합니다.</p>
<p><strong>Q. <code>onDestroy</code>에서 콜백을 안 지우면 어떻게 되나요?</strong>
예약된 콜백은 Activity(화면)를 참조합니다. 화면이 사라진 뒤 콜백이 실행되면 이미 죽은 뷰를 건드려 크래시가 나거나, GC가 화면을 회수하지 못해 메모리 누수가 됩니다. <code>removeCallbacksAndMessages(null)</code>로 남은 예약을 전부 비워야 합니다.</p>
<p><strong>Q. 400ms는 고정값인가요?</strong>
관용값이라 상황에 맞게 조정합니다. 100ms처럼 짧으면 타이핑 중 멈칫에도 요청이 나가 효과가 약하고, 1초처럼 길면 반응이 느리게 느껴집니다. 검색 자동완성류에서 보통 300~500ms를 씁니다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] BroadcastReceiver, 매니페스트에 등록했는데 왜 안 될까]]></title>
            <link>https://velog.io/@ju-unn/Android-BroadcastReceiver-%EB%A7%A4%EB%8B%88%ED%8E%98%EC%8A%A4%ED%8A%B8%EC%97%90-%EB%93%B1%EB%A1%9D%ED%96%88%EB%8A%94%EB%8D%B0-%EC%99%9C-%EC%95%88-%EB%90%A0%EA%B9%8C</link>
            <guid>https://velog.io/@ju-unn/Android-BroadcastReceiver-%EB%A7%A4%EB%8B%88%ED%8E%98%EC%8A%A4%ED%8A%B8%EC%97%90-%EB%93%B1%EB%A1%9D%ED%96%88%EB%8A%94%EB%8D%B0-%EC%99%9C-%EC%95%88-%EB%90%A0%EA%B9%8C</guid>
            <pubDate>Wed, 26 Aug 2026 06:57:21 GMT</pubDate>
            <description><![CDATA[<p>안드로이드 4대 컴포넌트를 간단히 언급하면서 BroadcastReceiver 이름만 짚고 넘어갔는데, 이번에는 그걸 제대로 파고든다. 개념 자체는 어렵지 않은데, 실제로 코드를 짜보면 &quot;분명히 매니페스트에 등록했는데 왜 안 받아지지&quot;라는 벽에 부딪히는 경우가 많다. 그 이유가 최신 안드로이드 버전에서 생긴 정책 변화 때문이라는 걸 하나씩 풀어본다.</p>
<h2 id="broadcastreceiver란-무엇인가">BroadcastReceiver란 무엇인가</h2>
<p>BroadcastReceiver는 안드로이드 시스템이나 다른 앱이 보내는 이벤트(브로드캐스트)를 수신해서 처리하는 컴포넌트다. 배터리 상태가 바뀌었다거나, 네트워크 연결이 끊겼다거나, 기기가 부팅됐다거나 하는 시스템 이벤트를 앱이 직접 폴링(주기적으로 확인)하지 않아도, 시스템이 &quot;이런 일이 생겼다&quot;고 방송(broadcast)하면 그걸 듣고 있다가 반응하는 구조다.</p>
<p>이 컴포넌트의 특징은 두 가지 등록 방식을 모두 지원한다는 점이다. AndroidManifest.xml에 미리 등록해두면 앱이 아예 실행되지 않은 상태에서도 시스템이 앱을 깨워서 반응하게 만들 수 있고(정적 등록), 코드 안에서 필요한 시점에만 등록해서 앱이 실행 중일 때만 반응하게 만들 수도 있다(동적 등록). 또한 브로드캐스트를 받은 뒤에는 그 정보를 바탕으로 Service를 실행하거나 알림을 띄우는 등 다른 컴포넌트와 연동하는 것도 가능하다.</p>
<h2 id="사용법--클래스-정의와-onreceive-구현">사용법 — 클래스 정의와 onReceive() 구현</h2>
<p>BroadcastReceiver를 쓰려면 이 클래스를 상속받은 클래스를 만들고, <code>onReceive()</code> 메서드를 오버라이드하면 된다. 브로드캐스트가 도착하면 시스템이 이 메서드를 호출해준다.</p>
<pre><code class="language-kotlin">class MyReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == Intent.ACTION_SCREEN_ON) {
            // 화면이 켜졌을 때 처리할 로직
        }
    }
}</code></pre>
<p><code>onReceive()</code>의 파라미터를 보면 <code>Context</code>와 <code>Intent</code>가 들어온다. <code>Intent</code>의 <code>action</code> 값을 보고 &quot;어떤 브로드캐스트가 왔는지&quot;를 구분하는 게 기본 패턴이다. 하나의 리시버가 여러 액션을 처리하도록 등록해뒀다면, <code>onReceive()</code> 안에서 <code>intent.action</code>을 분기해서 처리하면 된다.</p>
<p>여기서 반드시 기억해야 할 제약이 하나 있다. <code>onReceive()</code>는 메인 스레드(UI를 그리는 스레드)에서 실행된다. 그래서 이 안에서 네트워크 요청처럼 시간이 걸리는 작업을 직접 돌리면 안 된다. 안드로이드는 리시버가 일정 시간(대략 10초) 안에 처리를 끝내지 못하면 ANR(앱이 응답하지 않음) 오류를 띄우는데, <code>onReceive()</code>도 예외가 아니다. 무거운 작업이 필요하면 <code>goAsync()</code>를 써서 처리 시간을 약간 연장하거나, WorkManager 같은 백그라운드 작업 도구에 위임하고 <code>onReceive()</code> 자체는 최대한 빨리 끝내야 한다.</p>
<h2 id="정적-등록매니페스트-방법과-특징">정적 등록(매니페스트) 방법과 특징</h2>
<p>정적 등록은 AndroidManifest.xml에 리시버를 선언해두는 방식이다.</p>
<pre><code class="language-xml">&lt;receiver
    android:name=&quot;.MyReceiver&quot;
    android:enabled=&quot;true&quot;
    android:exported=&quot;false&quot;&gt;
    &lt;intent-filter&gt;
        &lt;action android:name=&quot;com.example.app.ACTION_UPDATE_DATA&quot; /&gt;
    &lt;/intent-filter&gt;
&lt;/receiver&gt;</code></pre>
<p>이렇게 등록해두면 앱 프로세스가 아예 떠 있지 않은 상태에서도 시스템이 필요할 때 앱을 깨워서 <code>onReceive()</code>를 실행시켜준다. 앱을 미리 실행해두지 않아도 이벤트를 받을 수 있다는 게 정적 등록의 가장 큰 장점이다.</p>
<p>그런데 이게 동시에 단점이기도 하다. 같은 브로드캐스트를 구독하는 앱이 기기에 많이 깔려 있으면, 그 브로드캐스트 하나가 발생할 때마다 시스템이 여러 앱을 한꺼번에 깨워서 실행시켜야 한다. 이게 뒤에서 다룰 Android 8.0 제한의 핵심 배경이다. 참고로 <code>android:exported</code> 속성은 &quot;다른 앱이 이 리시버로 브로드캐스트를 보낼 수 있는가&quot;를 결정하는데, 우리 앱 내부에서만 쓰는 브로드캐스트라면 <code>false</code>로 두는 게 안전하다.</p>
<h2 id="동적-등록코드-방법과-특징--android-13-이상에서-달라진-점">동적 등록(코드) 방법과 특징 — Android 13 이상에서 달라진 점</h2>
<p>동적 등록은 코드 안에서 <code>registerReceiver()</code>를 호출해서 그 시점부터 리시버를 활성화하는 방식이다. 앱(또는 특정 화면)이 살아있는 동안에만 브로드캐스트를 받고, 필요 없어지면 명시적으로 등록을 해제한다.</p>
<pre><code class="language-kotlin">val myReceiver = MyReceiver()
val filter = IntentFilter(&quot;com.example.app.ACTION_UPDATE_DATA&quot;)

val receiverFlags = if (listenToBroadcastsFromOtherApps) {
    ContextCompat.RECEIVER_EXPORTED
} else {
    ContextCompat.RECEIVER_NOT_EXPORTED
}
ContextCompat.registerReceiver(context, myReceiver, filter, receiverFlags)

// 화면이나 컴포넌트가 사라질 때 반드시 해제
override fun onDestroy() {
    super.onDestroy()
    unregisterReceiver(myReceiver)
}</code></pre>
<p>여기서 눈여겨봐야 할 부분이 세 번째 인자인 <code>receiverFlags</code>다. Android 13(API 33)부터는 동적으로 리시버를 등록할 때 <code>RECEIVER_EXPORTED</code>나 <code>RECEIVER_NOT_EXPORTED</code> 중 하나를 반드시 지정해야 한다. 이 플래그를 지정하지 않고 예전 방식대로 <code>registerReceiver(receiver, filter)</code>처럼 두 개짜리 인자만 넘기면, 타깃 SDK를 34(Android 14) 이상으로 잡은 앱에서는 실행 중에 <code>SecurityException</code>이 터진다.</p>
<p>두 플래그의 차이는 간단하다. <code>RECEIVER_EXPORTED</code>는 다른 앱이나 시스템이 보내는 브로드캐스트까지 받겠다는 뜻이고, <code>RECEIVER_NOT_EXPORTED</code>는 우리 앱 내부에서 보낸 브로드캐스트만 받겠다는 뜻이다. 대부분의 경우 앱 내부 이벤트만 다루기 때문에 <code>RECEIVER_NOT_EXPORTED</code>가 더 안전한 기본 선택이고, 공식 문서도 이쪽을 권장한다. 외부 앱이나 시스템 브로드캐스트를 꼭 받아야 하는 경우에만 <code>RECEIVER_EXPORTED</code>를 쓰면 된다.</p>
<p>공식 문서는 아예 &quot;매니페스트 선언보다 컨텍스트(동적) 등록을 우선하라&quot;고 명시하고 있다. 심지어 <code>CONNECTIVITY_ACTION</code>처럼 아예 매니페스트 등록으로는 받을 수 없고 동적 등록으로만 받을 수 있게 막아둔 브로드캐스트도 있다. 정적 등록이 &quot;앱이 꺼져 있어도 동작한다&quot;는 장점을 갖고 있긴 하지만, 요즘 안드로이드 개발 흐름에서는 동적 등록이 기본값에 더 가깝다고 보면 된다.</p>
<h2 id="왜-android-80부터-매니페스트-등록이-제한됐는가">왜 Android 8.0부터 매니페스트 등록이 제한됐는가</h2>
<p>Android 8.0(Oreo, API 26)부터 타깃 SDK를 26 이상으로 잡은 앱은, 몇 가지 예외를 제외한 대부분의 암시적 브로드캐스트를 매니페스트 등록만으로는 받을 수 없게 됐다. 여기서 &quot;암시적 브로드캐스트&quot;란 특정 앱을 지정하지 않고 시스템 전체에 뿌려지는 브로드캐스트를 말한다(반대로 우리 앱만 대상으로 지정해서 보내는 브로드캐스트는 &quot;명시적 브로드캐스트&quot;라고 부르고, 이건 여전히 매니페스트 등록으로도 받을 수 있다).</p>
<p>이 제한이 생긴 이유는 공식 문서에 명확히 나와 있다. 같은 브로드캐스트를 구독하는 앱이 기기에 많이 깔려 있으면, 그 브로드캐스트가 발생할 때마다 시스템이 한꺼번에 여러 앱을 깨워서 실행시켜야 한다. 이게 기기 성능과 배터리 소모, 그리고 결국 사용자 경험에 안 좋은 영향을 준다는 것이다. 예를 들어 &quot;네트워크 연결 상태 변경&quot; 같은 흔한 이벤트를 수백 개의 앱이 매니페스트로 구독하고 있다고 상상해보면, 네트워크가 바뀔 때마다 그 수백 개 앱이 동시에 깨어나는 셈이니 기기가 버티기 힘들다.</p>
<p>이 배경을 이해하면 정적 등록과 동적 등록의 관계가 좀 더 명확해진다. 정적 등록은 &quot;앱이 꺼져 있어도 반드시 반응해야 하는, 정말 소수의 중요한 이벤트&quot;에만 남겨두고, 그 외 대부분은 &quot;앱이 실행 중일 때만 필요에 따라 구독하는&quot; 동적 등록으로 넘긴 것이다. 시스템 입장에서는 불필요하게 앱을 깨우는 상황을 줄이는 합리적인 절충안이라고 볼 수 있다.</p>
<h2 id="그래도-매니페스트로-받을-수-있는-예외-브로드캐스트">그래도 매니페스트로 받을 수 있는 예외 브로드캐스트</h2>
<p>모든 암시적 브로드캐스트가 막힌 건 아니다. 공식 문서는 예외 목록을 별도로 관리하고 있는데, 대표적인 것만 꼽아보면 다음과 같다.</p>
<ul>
<li>부팅/사용자 관련: <code>ACTION_BOOT_COMPLETED</code>(기기 부팅 완료), <code>ACTION_LOCKED_BOOT_COMPLETED</code></li>
<li>시간/로케일 관련: <code>TIME_SET</code>, <code>ACTION_TIMEZONE_CHANGED</code>, <code>ACTION_LOCALE_CHANGED</code></li>
<li>USB 관련: <code>ACTION_USB_ACCESSORY_ATTACHED</code>, <code>ACTION_USB_DEVICE_ATTACHED</code></li>
<li>블루투스 연결 상태 변경 관련 액션들</li>
<li>계정/패키지 관련: <code>ACTION_ACCOUNT_REMOVED</code>, <code>ACTION_PACKAGE_FULLY_REMOVED</code></li>
</ul>
<p>이 외에도 더 많은 항목이 있는데, 전부 외울 필요는 없고 공식 문서(<code>developer.android.com/guide/components/broadcast-exceptions</code>)에서 필요할 때 찾아보면 된다. 다만 이 목록을 보면 공통점이 하나 보인다. 전부 &quot;앱이 꺼져 있어도 시스템이 반드시 알려줘야 하는&quot; 성격의 이벤트라는 점이다. 기기가 방금 부팅됐다는 사실이나 시간대가 바뀌었다는 사실은, 특정 앱이 실행 중이 아니라는 이유로 놓쳐서는 안 되는 정보이기 때문에 예외로 남겨둔 것이다.</p>
<h2 id="헷갈리는-포인트">헷갈리는 포인트</h2>
<p>여기서는 실제로 이 개념을 처음 배울 때 걸려 넘어지기 쉬운 지점들을 정리해본다.</p>
<p><strong>&quot;매니페스트에 등록했는데 왜 안 받아지지?&quot;</strong> 라는 질문이 가장 흔하다. 앞서 설명한 것처럼 타깃 SDK 26 이상인 앱은 예외 목록에 없는 암시적 브로드캐스트를 매니페스트 등록으로는 받을 수 없다. 반면 자기 앱을 대상으로 명시적으로 보낸 브로드캐스트는 매니페스트 등록으로도 여전히 잘 받아진다. 이 둘을 구분하지 못하면 &quot;매니페스트 등록 자체가 고장 났다&quot;고 오해하기 쉽다.</p>
<p><strong>&quot;예제 코드 그대로 썼는데 앱이 죽는다&quot;</strong> 도 자주 겪는 문제다. <code>registerReceiver(receiver, filter)</code>처럼 인자 두 개만 넘기는 예전 방식의 코드는 Android 13 이상에서 문제가 생긴다. 오래된 블로그나 강의 자료를 그대로 따라 하다가 이 부분에서 막히는 경우가 많으니, 항상 세 번째 인자로 <code>RECEIVER_EXPORTED</code>나 <code>RECEIVER_NOT_EXPORTED</code>를 넘기는 최신 방식을 써야 한다.</p>
<p><strong>&quot;정적 등록이 무조건 좋은 거 아니야? 앱이 꺼져 있어도 동작하니까&quot;</strong> 라는 생각도 흔하다. 하지만 이 장점은 동시에 리스크이기도 하다. 앱이 꺼져 있어도 시스템 자원을 써서 깨어난다는 뜻이고, 이게 배터리와 성능에 부담을 준다. 그래서 공식 문서도 컨텍스트(동적) 등록을 우선하라고 권장하는 것이다.</p>
<p><strong>&quot;브로드캐스트로 화면(Activity)도 띄울 수 있는 거 아냐?&quot;</strong> 라는 오해도 있다. <code>Intent</code>를 쓴다는 공통점 때문에 헷갈리기 쉬운데, <code>sendBroadcast()</code>와 <code>startActivity()</code>는 완전히 다른 메커니즘이다. 브로드캐스트를 받았다고 자동으로 화면이 뜨지 않는다. 화면을 띄우고 싶다면 <code>onReceive()</code> 안에서 별도로 <code>startActivity()</code>를 호출해야 한다.</p>
<p><strong>&quot;onReceive 안에서 네트워크 요청하면 되지 않나?&quot;</strong> 라는 접근도 위험하다. 앞서 언급했듯 <code>onReceive()</code>는 메인 스레드에서 짧게 끝나야 하고, 무거운 작업을 넣으면 ANR로 이어질 수 있다.</p>
<p>마지막으로 <strong>동적으로 등록만 하고 해제(<code>unregisterReceiver()</code>)를 빼먹는 실수</strong>도 흔하다. 등록과 해제는 항상 쌍으로 맞춰야 하고, 보통은 컴포넌트의 생명주기(예: <code>onCreate</code>에서 등록, <code>onDestroy</code>에서 해제)에 맞춰 처리한다. 해제를 빼먹으면 이미 사라진 화면을 리시버가 계속 참조하면서 메모리 누수가 생기거나, 경우에 따라 크래시로 이어질 수 있다.</p>
<h2 id="사용-시-주의사항">사용 시 주의사항</h2>
<p>정리하자면 BroadcastReceiver를 쓸 때 신경 써야 할 부분은 크게 세 가지다.</p>
<p>첫째, 성능이다. 필요 이상으로 많은 브로드캐스트를 구독하면 시스템 리소스를 계속 소모하게 되고, 이게 앱 전체 성능에 영향을 줄 수 있다. 정말 필요한 이벤트만 골라서 구독하는 게 기본이다.</p>
<p>둘째, 보안이다. 암시적 브로드캐스트는 등록된 모든 앱이 받아볼 수 있는 구조이기 때문에, 민감한 정보를 담아서 브로드캐스트로 보내면 다른 앱이 그 내용을 읽어갈 위험이 있다. 특정 앱에만 정보를 전달하고 싶다면 <code>setPackage()</code>로 대상을 제한하거나, 권한(permission) 체계를 활용해서 아무나 못 받게 막아야 한다.</p>
<p>셋째, 배터리 소모다. 백그라운드에서 이벤트에 반응해서 무언가 작업을 수행할 수 있다는 게 BroadcastReceiver의 장점이지만, 이걸 과도하게 쓰면 그만큼 백그라운드 작업이 늘어나면서 배터리를 빠르게 소모시킬 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Espresso로 안드로이드 UI 테스트하기]]></title>
            <link>https://velog.io/@ju-unn/Android-Espresso%EB%A1%9C-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-UI-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ju-unn/Android-Espresso%EB%A1%9C-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-UI-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 26 Aug 2026 05:17:10 GMT</pubDate>
            <description><![CDATA[<h2 id="테스트-왜-해야-할까">테스트, 왜 해야 할까</h2>
<p>코드를 짜다 보면 &quot;이거 진짜 되는지 확인해봐야지&quot; 하는 순간이 계속 온다. 그런데 그 확인을 매번 손으로 하고 있다면, 앱이 커질수록 확인해야 할 항목도 같이 늘어난다. 버튼 하나 눌러보고, 입력창에 글자 쳐보고, 화면 전환되는지 보고를 반복하다 보면 어느 순간 &quot;코드를 짜는 시간&quot;보다 &quot;손으로 확인하는 시간&quot;이 더 길어지는 지점이 온다.</p>
<p>테스트 코드는 이 반복 확인 작업을 코드로 대신 시키는 것이다. 한 번 짜두면 그다음부터는 실행 버튼만 누르면 몇 초 만에 같은 확인을 다시 해준다. 여기서 얻는 이점은 크게 세 가지로 정리할 수 있다.</p>
<p>첫째, 장애를 빨리 알아챌 수 있다. 코드를 수정한 직후에 바로 테스트를 돌려보면, 문제가 생겼을 때 &quot;방금 건드린 부분&quot; 안에서 원인을 찾으면 된다. 반면 한참 뒤에 수동으로 확인하다가 문제를 발견하면, 그사이 쌓인 변경 사항 전체를 뒤져야 한다.</p>
<p>둘째, 리팩터링을 겁내지 않게 된다. 리팩터링이란 겉으로 보이는 동작은 그대로 두고 코드 내부 구조만 정리하는 작업인데, &quot;혹시 내가 뭔가 망가뜨리진 않았을까&quot; 하는 불안이 항상 따라온다. 테스트가 있으면 리팩터링 후에 테스트를 돌려서 초록불이 뜨는지만 확인하면 되니까, 이 불안이 훨씬 줄어든다.</p>
<p>셋째, 개발 속도가 안정적으로 유지된다. 테스트 없이 개발하면 초반에는 빠르지만, 기능이 쌓일수록 &quot;이거 고치면 저게 깨지지 않을까&quot; 하는 걱정 때문에 점점 손이 느려진다. 테스트가 이 걱정을 대신 감당해주기 때문에, 프로젝트가 커져도 개발 속도가 크게 떨어지지 않는다.</p>
<h2 id="ui-테스트가-유독-번거로운-이유">UI 테스트가 유독 번거로운 이유</h2>
<p>일반적인 단위 테스트(Unit Test)는 함수 하나에 값을 넣고 결과가 맞는지 확인하는 정도라 JVM 위에서 빠르게 돈다. 그런데 UI 테스트는 다르다. 실제로 화면이 그려지고, 사용자가 버튼을 누르고, 그 결과로 화면이 어떻게 바뀌는지까지 확인해야 한다. 그래서 UI 테스트는 실제 안드로이드 기기나 에뮬레이터 위에서 앱을 실제로 띄워놓고 동작을 검증하는 계측 테스트(Instrumentation Test) 방식으로 이루어진다. 이 방식은 실제 기기 동작에 충실한 대신, 단위 테스트보다 느리다.</p>
<p>문제는 안드로이드 앱이 확인해야 할 조합의 수가 너무 많다는 데 있다. 같은 화면이라도 API 레벨이 다른 기기, 화면 크기가 다른 기기, 언어 설정이 다른 기기, 세로 모드와 가로 모드, 폰과 태블릿과 폴더블까지 조합하면 사람이 손으로 다 눌러보고 확인하는 건 사실상 불가능에 가깝다. 화면 하나를 고쳤는데 그게 다른 화면 크기에서도 잘 보이는지, 회전했을 때도 문제없는지를 매번 손으로 확인한다고 생각해보면 왜 이 작업을 자동화해야 하는지 감이 온다.</p>
<p>그래서 필요한 게 &quot;코드로 사용자의 행동을 흉내 내고, 그 결과를 코드로 검증하는&quot; UI 테스트 자동화다. 안드로이드에서는 이 역할을 Espresso가 맡는다.</p>
<h2 id="espresso란-무엇인가">Espresso란 무엇인가</h2>
<p>Espresso는 안드로이드 스튜디오에 기본으로 포함된 UI 테스트 자동화 프레임워크다. 별도로 무거운 설정을 하지 않아도 바로 쓸 수 있다는 점이 특징이다.</p>
<p>Espresso가 하는 일을 한 문장으로 요약하면, 실제 사용자가 화면에서 하는 행동(버튼 클릭, 텍스트 입력, 스크롤)을 코드로 그대로 재현하고, 그 결과 화면이 기대한 대로 바뀌었는지를 코드로 검증하는 것이다. UI가 조금이라도 바뀔 때마다 사람이 다시 손으로 눌러보고 확인할 필요 없이, 테스트 코드를 한 번 돌리면 같은 확인을 자동으로 반복해준다.</p>
<p>여기서 눈여겨볼 부분은 &quot;간결한 문법&quot;이다. Espresso는 뒤에서 다룰 <code>onView</code>, <code>perform</code>, <code>check</code> 세 개의 함수 조합만 익히면 대부분의 UI 동작을 검증할 수 있도록 설계되어 있다. 문법이 단순한 편이라 진입 장벽이 낮은 편이다.</p>
<h2 id="테스트-환경-준비하기--activityscenariorule">테스트 환경 준비하기 — ActivityScenarioRule</h2>
<p>Espresso 테스트 코드는 일반 코드와 다른 폴더에 들어간다. <code>app/src/androidTest/java</code> 경로다. 여기 코드는 실제 기기나 에뮬레이터 위에서 앱을 띄워서 실행하는 계측 테스트이기 때문에, JVM에서 도는 일반 단위 테스트가 들어가는 <code>app/src/test</code> 폴더와는 완전히 다른 자리다.</p>
<p>UI 테스트를 하려면 먼저 테스트 대상이 되는 Activity를 화면에 띄워야 한다. 이 역할을 해주는 것이 <code>ActivityScenarioRule</code>이다.</p>
<pre><code class="language-kotlin">@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)</code></pre>
<p><code>@get:Rule</code>이 붙은 이 코드는 JUnit의 Rule 메커니즘을 이용한다. 테스트 메서드가 시작되기 전에 지정한 Activity(여기서는 <code>MainActivity</code>)를 자동으로 실행하고, 테스트 메서드가 끝나면 그 Activity를 자동으로 정리한다. 왜 테스트 메서드마다 이 과정을 반복할까? 한 테스트에서 건드린 화면 상태가 다음 테스트로 넘어가면, 테스트끼리 서로 영향을 주고받게 되어 결과를 신뢰할 수 없게 되기 때문이다. 테스트 메서드마다 항상 깨끗한 상태의 Activity에서 시작하게 만들어주는 것이 이 Rule의 핵심 역할이다.</p>
<h2 id="기본-패턴--onview-perform-check">기본 패턴 — onView, perform, check</h2>
<p>Espresso 코드는 거의 항상 아래와 같은 3단 구조를 따른다.</p>
<pre><code class="language-kotlin">onView(withId(R.id.text_view))
    .check(matches(withText(&quot;Hello World!&quot;)))</code></pre>
<p>이 구조를 세 조각으로 나눠보면 이렇게 읽을 수 있다.</p>
<ul>
<li><code>onView(withId(...))</code>: 화면에서 테스트하고 싶은 UI 요소를 찾는다. <code>withId</code>는 그 요소를 어떤 기준으로 찾을지 정하는 매칭 조건(Matcher)이다.</li>
<li><code>.perform(...)</code>: 찾은 요소에 어떤 행동을 수행시킨다. 클릭, 텍스트 입력, 스크롤 같은 동작이 여기 들어간다.</li>
<li><code>.check(...)</code>: 찾은 요소가 기대한 상태와 일치하는지 검증한다. <code>matches</code>는 실제 값과 기대 값을 비교하는 조건이다.</li>
</ul>
<p>즉 &quot;화면에서 뷰를 찾고 → 그 뷰에 행동을 시키고 → 결과를 검증한다&quot;는 세 단계가 그대로 함수 이름에 드러나 있는 구조다.</p>
<p>여기서 한 가지 흥미로운 설계 원칙이 있다. Espresso는 테스트 코드가 Activity나 View 객체에 직접 접근하지 못하게 만들어져 있다. <code>findViewById</code>로 뷰 객체를 직접 가져와서 상태를 확인하는 대신, 반드시 <code>onView</code>라는 창구를 통해서만 뷰에 접근하도록 강제한다. 왜 이렇게 불편하게 만들었을까? 만약 테스트 코드가 View 객체를 직접 붙잡고 있으면, UI 스레드가 그 View를 다시 그리는 도중에 테스트 코드가 동시에 그 View를 건드리는 상황이 생길 수 있다. 이러면 테스트가 어떤 날은 성공하고 어떤 날은 실패하는 불안정한 결과를 낸다. Espresso는 <code>onView</code> 뒤에서 UI 스레드와 테스트 스레드 사이의 동기화를 대신 관리해줌으로써 이런 불안정성을 줄인다. 즉 문법이 다소 제한적으로 보이는 이유는 &quot;테스트를 안정적으로 만들기 위한 설계&quot;라는 목적이 있기 때문이다.</p>
<h2 id="뷰-찾고-조작하기--id텍스트로-찾기-클릭-입력">뷰 찾고 조작하기 — ID/텍스트로 찾기, 클릭, 입력</h2>
<p>뷰를 찾는 방법부터 보자. 가장 흔한 방식은 ID로 찾는 것이다.</p>
<pre><code class="language-kotlin">// ID로 찾기
onView(withId(R.id.button))

// Text로 찾기
onView(withText(&quot;Hello World&quot;))</code></pre>
<p><code>withId</code>는 XML 레이아웃에 지정한 ID를 기준으로 뷰를 찾고, <code>withText</code>는 화면에 표시된 텍스트를 기준으로 뷰를 찾는다. ID가 없는 뷰이거나, 텍스트만으로 구분이 되는 상황이라면 <code>withText</code>가 더 편할 때가 있다.</p>
<p>뷰를 클릭하는 코드는 이렇다.</p>
<pre><code class="language-kotlin">onView(withId(R.id.button))
    .perform(click())</code></pre>
<p>텍스트를 입력하는 코드는 이렇다.</p>
<pre><code class="language-kotlin">onView(withId(R.id.edit_text))
    .check(matches(isDisplayed()))
    .perform(typeText(&quot;hi&quot;))</code></pre>
<p>여기서 <code>check(matches(isDisplayed()))</code>가 먼저 붙어 있는 걸 눈여겨볼 만하다. 입력 동작을 수행하기 전에 &quot;이 입력창이 실제로 화면에 보이는 상태인가&quot;를 먼저 확인한 것이다. Espresso의 <code>perform</code>은 실제 사용자처럼 동작하기 때문에, 화면에 보이지 않는 요소에는 입력이나 클릭을 수행할 수 없다. 이 점은 뒤에서 다룰 헷갈리는 포인트와도 이어진다.</p>
<h2 id="매처-조합하기와-스크롤">매처 조합하기와 스크롤</h2>
<p>화면에 같은 ID나 같은 텍스트를 가진 뷰가 여러 개 있으면, <code>withId</code> 하나만으로는 어떤 뷰를 말하는 건지 구분이 안 될 수 있다. 이럴 때는 <code>allOf</code>로 여러 조건을 동시에 걸어서 매칭 범위를 좁힌다.</p>
<pre><code class="language-kotlin">onView(allOf(withId(R.id.text_view), withText(&quot;text&quot;)))</code></pre>
<p>이 코드는 &quot;ID가 <code>text_view</code>이면서 동시에 텍스트가 <code>text</code>인 뷰&quot;만 정확히 골라낸다. 조건을 하나만 걸었을 때 여러 개가 걸려서 에러가 나는 상황을 이렇게 조건을 더해서 해결하는 것이다.</p>
<p>스크롤이 필요한 경우도 있다. 화면 아래쪽에 있어서 지금 당장은 안 보이는 요소를 확인하고 싶을 때다.</p>
<pre><code class="language-kotlin">// when: 사용자가 스크롤하면
onView(withId(R.id.scroll_view))
    .perform(swipeUp())

// then: 화면에 버튼이 표시된다
onView(withId(R.id.button))
    .check(matches(isDisplayed()))</code></pre>
<p>먼저 스크롤 동작을 수행해서 화면을 아래로 넘기고, 그 다음에 원하는 뷰가 화면에 나타났는지를 검증하는 순서다. 실제 사용자가 스크롤한 다음에 버튼을 발견하는 흐름을 그대로 코드로 옮긴 것이라고 보면 된다.</p>
<h2 id="헷갈리는-포인트">헷갈리는 포인트</h2>
<p>Espresso를 처음 만지면 문법 자체보다 아래와 같은 지점에서 막히는 경우가 많다. 하나씩 짚어보겠다.</p>
<p><strong><code>test</code> 폴더와 <code>androidTest</code> 폴더를 헷갈린다.</strong> Espresso 테스트는 반드시 <code>app/src/androidTest/java</code>에 넣어야 한다. <code>app/src/test</code>는 Mockito 같은 걸로 순수 로직만 검증하는 JVM 단위 테스트 자리이고, <code>androidTest</code>는 실제 기기·에뮬레이터에서 도는 계측 테스트 자리다. 폴더를 잘못 넣으면 테스트가 아예 인식되지 않거나 엉뚱하게 동작한다.</p>
<p><strong>에뮬레이터나 기기가 켜져 있어야 한다는 걸 모른다.</strong> Espresso 테스트는 계측 테스트라서 JVM 단위 테스트처럼 실행 버튼만 누른다고 바로 도는 게 아니다. AVD가 떠 있거나 실제 기기가 연결되어 있어야 실행된다. 이걸 모르고 있으면 &quot;왜 테스트가 시작조차 안 되지?&quot;에서 한참을 헤매게 된다.</p>
<p><strong><code>NoMatchingViewException</code>이 뜬다.</strong> <code>onView(withId(...))</code>로 찾으려는 뷰가 화면에 없을 때 나는 에러다. 이 에러를 보면 대부분 &quot;ID를 잘못 썼나?&quot;부터 의심하는데, 실제로는 ID가 맞는데도 그 시점에 그 뷰가 아직 화면에 그려지지 않았거나, 다른 화면에 있거나, 스크롤을 해야 보이는 위치에 있는 경우가 더 흔하다. ID 철자보다 &quot;지금 이 시점에 이 뷰가 실제로 화면에 떠 있는가&quot;를 먼저 의심해보는 게 낫다.</p>
<p><strong><code>AmbiguousViewMatcherException</code>이 뜬다.</strong> 같은 ID나 같은 텍스트를 가진 뷰가 화면에 여러 개 있을 때 발생한다. 앞서 본 <code>allOf</code>로 조건을 좁혀야 하는데, 이걸 모르면 왜 에러가 나는지조차 파악하기 어렵다.</p>
<p><strong>Espresso도 &quot;실제 사용자처럼&quot; 동작한다는 점을 놓친다.</strong> 화면 밖에 있어서 실제 사용자 눈에도 안 보이는 뷰는 Espresso도 건드릴 수 없다. 실제 사람이 안 보이는 버튼을 못 누르는 것과 똑같은 원리다. 그래서 리스트 아래쪽에 있는 항목을 클릭하려면 <code>scrollTo()</code>나 뒤에서 다룰 <code>RecyclerViewActions</code> 없이는 바로 클릭할 수 없다.</p>
<p><strong>테스트가 됐다 안 됐다 한다(flaky test).</strong> 단독으로 실행하면 성공하는데 다른 테스트와 같이 돌리면 실패하는 현상이다. 이 경우 대부분 코드 로직 문제가 아니라, 화면 전환 애니메이션이 채 끝나기도 전에 Espresso가 다음 동작을 시도해서 생기는 타이밍 문제다. 이 원인은 다음 섹션에서 좀 더 다룬다.</p>
<h2 id="리스트recyclerview-테스트는-조금-다르다">리스트(RecyclerView) 테스트는 조금 다르다</h2>
<p>지금까지 본 방식은 화면에 뷰가 하나씩 고정되어 있을 때는 잘 맞는다. 그런데 RecyclerView처럼 항목이 스크롤되면서 화면에 나타났다 사라졌다 하는 리스트는 기본 Espresso만으로는 다루기 어렵다. 화면 밖으로 나간 항목은 아예 그려지지 않은 상태라서 <code>onView</code>로 바로 찾을 수 없기 때문이다.</p>
<p>이럴 때 필요한 게 <code>espresso-contrib</code> 의존성이다.</p>
<pre><code class="language-kotlin">androidTestImplementation &#39;androidx.test.espresso:espresso-contrib:$espressoVersion&#39;</code></pre>
<p>이 의존성을 추가하면 <code>RecyclerViewActions</code>라는 도구를 쓸 수 있게 된다. 특정 위치까지 스크롤하는 <code>scrollToPosition()</code>이나, 특정 위치의 항목에 클릭 같은 동작을 바로 수행하는 <code>actionOnItemAtPosition()</code> 같은 함수들이 여기 포함되어 있다. 이 함수들을 쓰면 &quot;몇 번째 항목까지 스크롤한 다음 클릭한다&quot;는 동작을 한 번에 처리할 수 있다.</p>
<p>여기서 하나 짚고 넘어갈 함정이 있다. 예전 방식의 리스트인 ListView에는 <code>onData()</code>라는 함수로 항목에 접근하는 방법이 있는데, 이 <code>onData()</code>는 AdapterView 계열에서만 동작하고 RecyclerView에서는 동작하지 않는다. RecyclerView를 테스트하는데 <code>onData()</code>를 찾아서 쓰려고 하면 계속 막히게 되니, RecyclerView는 처음부터 <code>RecyclerViewActions</code>를 쓴다고 기억해두는 게 낫다.</p>
<p>버전 번호는 계속 바뀌기 때문에 본문에 특정 숫자를 못 박기보다는, 프로젝트의 <code>build.gradle</code>에서 현재 쓰고 있는 Espresso 버전에 맞춰 넣는다고 이해하면 된다.</p>
<h2 id="애니메이션-때문에-테스트가-흔들릴-때">애니메이션 때문에 테스트가 흔들릴 때</h2>
<p>앞서 잠깐 언급한 flaky test 문제를 좀 더 자세히 보자. 안드로이드는 화면이 전환되거나 버튼을 누를 때 기본적으로 애니메이션 효과가 들어간다. 사람 눈에는 자연스러운 이 효과가, Espresso 입장에서는 문제가 될 수 있다. 애니메이션이 진행 중인 동안에는 뷰가 아직 최종 위치나 최종 상태에 도달하지 않은 상태이기 때문에, 그 타이밍에 <code>perform</code>이나 <code>check</code>를 시도하면 뷰를 못 찾거나 엉뚱한 상태로 판단해서 테스트가 실패할 수 있다.</p>
<p>이 문제를 줄이는 가장 흔한 방법은 애니메이션을 아예 꺼버리는 것이다.</p>
<pre><code class="language-kotlin">android {
    testOptions {
        animationsDisabled = true
    }
}</code></pre>
<p><code>build.gradle</code>에 이 설정을 추가하면 테스트를 실행할 때 애니메이션이 비활성화된다. 다만 이 옵션에는 알려진 한계가 있다. 커맨드라인에서 테스트를 실행할 때는 확실히 적용되지만, 안드로이드 스튜디오에서 직접 실행 버튼을 눌러서 돌릴 때는 이 설정이 제대로 먹히지 않을 수 있다는 점이 알려져 있다.</p>
<p>이 옵션만으로 해결이 안 될 때 쓸 수 있는 대안은, 테스트를 돌리는 기기나 에뮬레이터의 설정 앱에서 직접 애니메이션을 끄는 것이다. 개발자 옵션으로 들어가서 &quot;창 애니메이션 배율&quot;, &quot;전환 애니메이션 배율&quot;, &quot;Animator 시간 배율&quot; 세 항목을 모두 0배로 맞춰두면, <code>testOptions</code> 설정과 무관하게 기기 자체가 애니메이션 없이 동작하기 때문에 테스트가 훨씬 안정적으로 돈다. flaky test 때문에 계속 막혀서 원인을 찾기 어려울 때는 코드보다 이 설정부터 의심해보는 게 시간을 아끼는 방법이다.</p>
<h2 id="마무리-요약">마무리 요약</h2>
<p>테스트 코드는 손으로 반복하던 확인 작업을 자동화해서, 장애를 빨리 발견하고 리팩터링을 겁내지 않게 해주는 도구다. 그중에서도 UI 테스트는 실제 화면 동작까지 검증해야 하기 때문에 손이 많이 가는 영역인데, Espresso는 이 과정을 <code>onView</code>로 뷰를 찾고, <code>perform</code>으로 동작을 수행하고, <code>check</code>로 결과를 검증하는 단순한 3단 구조로 정리해준다. 이 구조가 View에 직접 접근하지 못하게 제한되어 있는 이유는, 테스트를 안정적으로 만들기 위한 설계라는 점을 기억해두면 문법을 그냥 외우는 것보다 훨씬 이해하기 쉬워진다.</p>
<p>실제로 코드를 짜다 보면 문법보다 <code>androidTest</code> 폴더 위치, 에뮬레이터 실행 여부, <code>NoMatchingViewException</code> 같은 주변 함정에서 더 많이 막힌다. 이런 함정들은 대부분 &quot;Espresso도 실제 사용자처럼 동작한다&quot;는 원칙 하나로 설명이 된다. 화면에 없는 뷰는 못 찾고, 안 보이는 뷰는 못 누르고, 애니메이션이 끝나기 전에는 화면이 아직 바뀌지 않은 상태라는 것만 기억해도 에러 메시지를 마주쳤을 때 원인을 훨씬 빠르게 좁혀나갈 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Date/Calendar 대신 time을 쓰는 이유]]></title>
            <link>https://velog.io/@ju-unn/Android-DateCalendar-%EB%8C%80%EC%8B%A0-time%EC%9D%84-%EC%93%B0%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@ju-unn/Android-DateCalendar-%EB%8C%80%EC%8B%A0-time%EC%9D%84-%EC%93%B0%EB%8A%94-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Wed, 26 Aug 2026 05:16:27 GMT</pubDate>
            <description><![CDATA[<p>안드로이드 UI 자체보다는 Kotlin/Java에서 날짜와 시간을 다루는 방법에 관한 이야기다. 그래도 실무 코드에서 자주 마주치고, 오래된 방식과 새 방식의 차이를 원리로 알아두면 실수를 줄일 수 있는 주제라 이 시리즈에 포함했다.</p>
<h2 id="javautildatecalendar-대신-javatime을-쓰는-이유">java.util.Date/Calendar 대신 java.time을 쓰는 이유</h2>
<p>날짜와 시간을 다루는 이야기로 시작하자. Java 초기 버전부터 있었던 <code>java.util.Date</code>와 <code>java.util.Calendar</code>는 지금도 코드에서 종종 보이지만, JDK 1.8(Java 8) 이후로는 <code>java.time</code> 패키지에 있는 <code>LocalDate</code>, <code>LocalTime</code>, <code>LocalDateTime</code> 같은 클래스를 쓰는 게 권장된다. 단순히 &quot;옛날 거라서&quot;가 아니라 두 가지 구체적인 이유가 있다.</p>
<p>첫 번째는 가변성(mutable) 문제다. <code>Date</code>와 <code>Calendar</code> 객체는 한 번 만든 뒤에도 값이 계속 바뀔 수 있는 구조다. 문제는 여러 스레드가 같은 <code>Date</code>나 <code>Calendar</code> 객체를 동시에 읽고 수정하려고 할 때 생긴다. 한 스레드가 값을 바꾸는 도중에 다른 스레드가 그 값을 읽으면 예측할 수 없는 결과가 나올 수 있다. 이런 값이 바뀔 수 있는 객체를 여러 곳에서 공유하면 방어적 복사본을 만드는 등 신경 써야 할 게 늘어나서 유지보수도 어려워진다. 반면 <code>java.time</code>의 <code>LocalDate</code>, <code>LocalTime</code>, <code>LocalDateTime</code>은 불변(immutable) 객체다. 한 번 생성하면 그 값은 절대 바뀌지 않고, 값을 바꾸는 것처럼 보이는 메서드를 호출해도 실제로는 기존 객체는 그대로 두고 새로운 객체를 만들어서 반환한다. 그래서 여러 스레드가 같은 객체를 참조해도 값이 예기치 않게 바뀔 걱정이 없다.</p>
<p>두 번째는 API 설계가 직관적이지 않다는 점이다. 대표적인 예가 <code>Calendar.MONTH</code>다. 월(month) 값이 0부터 시작해서 1월이 0, 12월이 11이다.</p>
<pre><code class="language-kotlin">val calendar: Calendar = Calendar.getInstance()
val month: Int = calendar.get(Calendar.MONTH) + 1 // 실제 월을 얻으려면 +1을 해줘야 한다</code></pre>
<p>사람이 &quot;1월&quot;이라고 생각하는 것과 코드가 다루는 숫자 사이에 항상 1의 차이가 있다 보니, 이 +1을 깜빡하는 실수가 실무에서도 자주 나온다. <code>java.time</code>은 이런 함정 없이 1월이 1로 시작해서 사람이 생각하는 방식과 그대로 일치한다.</p>
<pre><code class="language-kotlin">val now = LocalDateTime.now()
val dayOfWeek = now.dayOfWeek
println(&quot;Today is $dayOfWeek&quot;) // Today is WEDNESDAY</code></pre>
<p><code>SimpleDateFormat</code>으로 <code>Date</code>를 원하는 형식의 문자열로 바꾸는 것도 예전에는 흔히 쓰던 방식이다.</p>
<pre><code class="language-kotlin">val date = Date()
val format = SimpleDateFormat(&quot;HH:mm:ss&quot;, Locale.getDefault())
val timeString = format.format(date)</code></pre>
<p>이런 코드가 아예 동작하지 않는 건 아니지만, 앞서 본 불변성과 API 설계 문제 때문에 새로 작성하는 코드라면 <code>java.time</code> 쪽 클래스를 쓰는 게 낫다. 안드로이드에서는 core library desugaring이라는 기능을 통해 프로젝트의 minSdk 버전과 무관하게 <code>java.time</code> API를 쓸 수 있도록 지원하고 있어서, 오래된 기기를 지원해야 한다는 이유로 <code>java.time</code>을 포기할 필요는 없다.</p>
<h2 id="마무리-정리">마무리 정리</h2>
<p><code>java.time</code>을 쓰는 이유는 불변성(멀티스레드 환경에서 안전)과 직관적인 API(1월이 1로 시작) 두 가지로 요약된다. &quot;그냥 옛날 거라서 안 쓴다&quot;는 막연한 이해보다, 이 두 가지 구체적인 이유를 알아두면 새 코드에서 왜 <code>LocalDate</code>/<code>LocalDateTime</code>을 골라야 하는지 스스로 판단할 수 있다. 새 프로젝트에서는 <code>java.time</code>을 기본으로 쓰고, <code>Date</code>/<code>Calendar</code>는 레거시 코드와의 호환이 필요한 경우에만 마주치는 존재로 이해하면 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] ListView와 Adapter, ViewHolder 패턴]]></title>
            <link>https://velog.io/@ju-unn/Android-ListView%EC%99%80-Adapter-ViewHolder-%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@ju-unn/Android-ListView%EC%99%80-Adapter-ViewHolder-%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Wed, 26 Aug 2026 05:03:16 GMT</pubDate>
            <description><![CDATA[<p>여러 개의 데이터를 리스트 형태로 화면에 보여줘야 할 때 쓰는 게 ListView다. 이번 글에서는 ListView의 기본 구성, 스크롤 성능을 위한 ViewHolder 패턴, 그리고 지금도 이걸 배워야 하는 이유를 정리한다.</p>
<h2 id="listview-구성-요소와-adapter-만들기">ListView 구성 요소와 Adapter 만들기</h2>
<p>ListView는 크게 세 요소로 이루어진다. 화면에 실제로 그려지는 View, 그 View 하나하나에 들어갈 데이터인 Item, 그리고 이 둘을 연결해주는 다리 역할을 하는 Adapter다.</p>
<p>ListView를 실제로 만드는 과정은 대략 이렇다.</p>
<ol>
<li>레이아웃 XML에 ListView를 추가한다.</li>
<li>리스트의 아이템 한 개가 어떻게 생겼는지 정의하는 아이템 레이아웃 XML을 따로 만든다.</li>
<li>리스트에 뿌릴 실제 데이터를 준비한다 (리스트나 배열 형태).</li>
<li>데이터와 아이템 뷰를 연결해주는 커스텀 어댑터를 만든다.</li>
<li>Activity(또는 Fragment)에서 이 어댑터를 ListView에 연결한다.</li>
</ol>
<p>어댑터를 만들 때는 <code>BaseAdapter</code>를 상속받아서 네 개의 메서드를 구현해야 한다. <code>getCount()</code>는 전체 아이템 개수를 반환하고, <code>getItem(position)</code>은 특정 위치의 데이터를 반환하고, <code>getItemId(position)</code>은 해당 아이템의 고유 ID를 반환한다. 그리고 가장 중요한 <code>getView(position, convertView, parent)</code>가 실제로 화면에 그릴 View를 만들어서 반환하는 역할을 한다.</p>
<pre><code class="language-kotlin">class MovieAdapter(
    private val items: List&lt;Movie&gt;
) : BaseAdapter() {
    override fun getCount(): Int = items.size
    override fun getItem(position: Int): Movie = items[position]
    override fun getItemId(position: Int): Long = position.toLong()
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View? {
        // 여기서 아이템 뷰를 만들고 데이터를 채워 넣는다
    }
}</code></pre>
<p>리스트의 특정 아이템이 클릭됐을 때 반응하고 싶다면 <code>setOnItemClickListener</code>로 콜백을 등록하면 된다. 사용자가 아이템을 클릭하는 이벤트가 감지된 이후에 실행할 로직을 이 콜백 안에 작성하는 구조다.</p>
<pre><code class="language-kotlin">listView.setOnItemClickListener { _, _, position, _ -&gt;
    // position번째 아이템이 클릭됐을 때 실행할 로직
}</code></pre>
<h2 id="viewholder-패턴과-findviewbyid-최적화">ViewHolder 패턴과 findViewById 최적화</h2>
<p>위에서 만든 <code>getView()</code>를 그대로 두면 문제가 생긴다. ListView는 스크롤될 때마다 화면에 보여야 할 아이템에 대해 <code>getView()</code>를 계속 호출하는데, 이 함수 안에서 매번 <code>findViewById()</code>로 TextView나 ImageView 같은 뷰를 새로 찾고 있다면 스크롤할 때마다 이 탐색 작업이 반복된다.</p>
<p><code>findViewById()</code>가 왜 비용이 큰 작업인지부터 짚고 넘어가자. 이 메서드는 호출될 때마다 뷰 계층 구조 전체를 순회하면서 원하는 ID를 가진 뷰를 찾는다. 뷰 계층이 복잡할수록, 그리고 이 호출이 반복될수록 누적되는 비용이 커진다. 리스트 아이템 하나에 뷰가 서너 개만 있어도, 스크롤할 때마다 그 서너 개를 매번 처음부터 다시 찾는 셈이다.</p>
<p>이 문제를 해결하는 게 ViewHolder 패턴이다. 원리는 간단하다. &quot;한 번 찾은 뷰는 다시 찾지 말고 어딘가에 저장해두고 재사용하자&quot;는, 캐싱의 가장 기본적인 형태다. <code>getView()</code>가 처음 호출될 때만 <code>findViewById()</code>로 뷰들을 찾아서 ViewHolder 객체 안에 저장해두고, 이후 같은 아이템 뷰가 재사용될 때는 저장해둔 참조를 그대로 꺼내 쓴다.</p>
<p>이 재사용을 가능하게 해주는 재료가 <code>convertView</code>다. ListView는 아이템이 스크롤되어 화면 밖으로 나가면 그 뷰 인스턴스를 버리지 않고 보관해뒀다가, 새로운 아이템을 그려야 할 때 <code>getView()</code>의 <code>convertView</code> 매개변수로 다시 넘겨준다. 그러면 새 뷰를 처음부터 inflate하는 대신, 기존 뷰 껍데기를 그대로 재활용하면서 데이터만 새로 채워 넣을 수 있다. ViewHolder는 이 재활용된 뷰 껍데기 안에 들어 있는 자식 뷰들의 참조를 캐싱해두는 역할을 한다.</p>
<p>정리하면 이렇다. <code>convertView</code>가 &quot;뷰 껍데기 자체를 재사용&quot;하는 것이고, ViewHolder가 &quot;그 껍데기 안의 자식 뷰 참조를 재사용&quot;하는 것이다. 둘이 합쳐지면 스크롤 중 반복되는 <code>findViewById()</code> 호출을 사실상 없앨 수 있다.</p>
<h2 id="listview는-지금도-쓰나---recyclerview와의-관계">ListView는 지금도 쓰나? - RecyclerView와의 관계</h2>
<p>여기까지 읽으면 자연스럽게 이런 의문이 든다. &quot;이거 요즘도 실무에서 쓰는 거 맞나요?&quot; 애매하게 넘어가면 나중에 헷갈리니 분명히 짚고 가겠다.</p>
<p>결론부터 말하면, 새 프로젝트를 시작할 때는 ListView 대신 RecyclerView를 쓰는 게 사실상 표준이다. ListView가 API 문서에 &quot;이제 쓰지 마세요&quot;라고 명시적으로 박제되어 있는 건 아니지만, 공식 RecyclerView 가이드를 포함해 커뮤니티 전반에서 신규 개발 시에는 RecyclerView를 쓰도록 권장하고 있다. 실제로 ListView는 레거시 코드베이스나 아주 단순한 화면 정도에서만 여전히 보이는 수준이다.</p>
<p>그렇다면 왜 이미 대체된 걸로 취급받는 ListView와 Adapter/ViewHolder를 지금 배우고 있는 걸까. 이유는 RecyclerView가 등장한 배경 자체가 ViewHolder 패턴과 맞닿아 있기 때문이다. ListView 시절에는 방금 위에서 본 것처럼 ViewHolder 패턴 적용이 &quot;선택 사항&quot;이었다 — 개발자가 직접 신경 써서 구현해야 하는 최적화 기법이었고, 안 써도 일단 앱은 돌아갔다(다만 스크롤 성능이 나빴을 뿐이다). RecyclerView는 이 최적화 기법을 아예 필수 구조로 강제해버린 버전이다. RecyclerView를 쓸 때는 <code>RecyclerView.ViewHolder</code>를 상속하는 ViewHolder 클래스를 만드는 게 선택이 아니라 필수이고, 화면 밖으로 나간 아이템의 뷰를 파괴하지 않고 재사용(recycle)하는 것이 RecyclerView 이름 자체에 들어 있는 핵심 성능 개선 포인트다. (참고: <a href="https://developer.android.com/develop/ui/views/layout/recyclerview">RecyclerView 개요</a>)</p>
<p>즉 이번 글에서 ListView를 배운 건 &quot;옛날 기술이니까 알아두자&quot;가 아니라 &quot;RecyclerView가 왜 그렇게 설계됐는지 이해하기 위한 사전 지식&quot;으로 보는 게 맞다. ListView의 한계(뷰 재사용을 개발자가 직접 신경 써야 하고, <code>findViewById()</code>를 매번 호출하는 게 기본값이었던 구조)를 먼저 이해하고 나면, RecyclerView가 왜 ViewHolder를 필수로 만들었는지가 바로 납득이 된다. 취업 준비 관점에서도 마찬가지다. 실무에서는 RecyclerView를 쓰겠지만, RecyclerView의 ViewHolder가 정확히 어떤 문제를 해결하기 위해 나온 개념인지 설명할 수 있어야 원리를 이해하고 쓰는 사람으로 보인다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] px, dp, sp와 뷰 계층 - 안드로이드 레이아웃 기초]]></title>
            <link>https://velog.io/@ju-unn/Android-px-dp-sp%EC%99%80-%EB%B7%B0-%EA%B3%84%EC%B8%B5-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EA%B8%B0%EC%B4%88</link>
            <guid>https://velog.io/@ju-unn/Android-px-dp-sp%EC%99%80-%EB%B7%B0-%EA%B3%84%EC%B8%B5-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EA%B8%B0%EC%B4%88</guid>
            <pubDate>Wed, 26 Aug 2026 04:54:20 GMT</pubDate>
            <description><![CDATA[<p>안드로이드 레이아웃을 처음 짤 때 가장 먼저 헷갈리는 게 단위다. XML에 크기를 쓸 때 어떤 곳엔 <code>dp</code>가 붙어 있고 어떤 곳엔 <code>sp</code>가 붙어 있는데, 둘 다 그냥 &quot;크기 단위&quot;로만 알고 넘어가면 나중에 꼭 실수를 하게 된다. 이번 글에서는 px/dp/sp 단위와, 레이아웃을 짤 때 자주 저지르는 뷰 계층 문제를 다룬다.</p>
<h2 id="px-dp-sp---화면-단위-제대로-이해하기">px, dp, sp - 화면 단위 제대로 이해하기</h2>
<p>먼저 px는 화면의 실제 물리적 픽셀 하나를 의미한다. 문제는 기기마다 같은 물리적 크기 안에 들어가는 픽셀 수(화면 밀도, density)가 다르다는 점이다. 저가형 기기와 고급형 기기가 같은 1인치 안에 서로 다른 개수의 픽셀을 욱여넣고 있다고 생각하면 된다. 그래서 같은 100px짜리 버튼이라도 밀도가 낮은 화면에서는 크게, 밀도가 높은 화면에서는 작게 보인다. 이런 이유로 px는 실무 레이아웃에서 거의 쓰지 않는다.</p>
<p>이 문제를 해결하기 위해 나온 게 dp(density-independent pixel)다. dp는 화면 밀도와 무관하게 물리적으로 일정한 크기를 유지하도록 설계된 단위다. 기준은 mdpi(160dpi) 화면인데, 이 기준 화면에서는 1dp = 1px이다. 다른 밀도의 화면에서는 안드로이드 시스템이 아래 공식으로 자동 변환해서 그려준다.</p>
<pre><code>px = dp * (dpi / 160)</code></pre><p>예를 들어 밀도가 2배인 xhdpi(320dpi) 화면이라면 1dp는 실제로는 2px로 그려진다. 개발자는 dp로만 지정하면 되고, 실제 픽셀 변환은 시스템이 알아서 처리한다. 그래서 <code>layout_width</code>, <code>layout_height</code>, <code>margin</code>, <code>padding</code>처럼 레이아웃 크기와 관련된 값은 전부 dp를 쓰는 게 원칙이다. (참고: <a href="https://developer.android.com/training/multiscreen/screendensities">화면 밀도별 지원</a>)</p>
<p>그럼 sp는 뭘까. sp(scale-independent pixel)는 크기 자체는 dp와 똑같이 계산되지만, 딱 하나 다른 점이 있다. 사용자가 기기 설정에서 &quot;글자 크기 키우기&quot; 옵션을 켰을 때, sp로 지정한 값만 그 설정에 반응해서 커진다는 것이다. dp로 지정한 값은 사용자가 글자 크기를 아무리 키워도 그대로 유지된다. 그래서 sp는 &quot;글자 크기 설정에 반응하는 dp&quot;라고 외우면 된다.</p>
<p>왕초보가 가장 자주 하는 실수가 바로 이 둘을 바꿔 쓰는 것이다. <code>textSize</code>에 dp를 쓰면 사용자가 접근성 설정에서 글자를 키워도 그 텍스트만 그대로 남아서 앱 전체 글자 크기와 안 맞게 되고, 반대로 <code>layout_width</code>나 <code>margin</code>에 sp를 쓰면 사용자가 글자 크기를 키울 때 레이아웃 여백까지 같이 늘어나면서 화면이 깨진다. 규칙은 단순하다. 텍스트 크기만 sp, 그 외 너비/높이/여백/패딩은 전부 dp.</p>
<pre><code class="language-xml">&lt;TextView
    android:layout_width=&quot;50dp&quot;
    android:layout_height=&quot;wrap_content&quot;
    android:textSize=&quot;16sp&quot;
    android:text=&quot;Hello World!&quot; /&gt;</code></pre>
<h2 id="뷰-계층이-깊어지면-생기는-문제-장풍-레이아웃">뷰 계층이 깊어지면 생기는 문제 (&quot;장풍&quot; 레이아웃)</h2>
<p>복잡한 화면을 만들다 보면 <code>LinearLayout</code> 안에 <code>LinearLayout</code>을 넣고 그 안에 또 <code>LinearLayout</code>을 넣는 식으로 레이아웃을 중첩시키게 되는 경우가 있다. 이렇게 뷰를 계속 겹쳐 쌓아 올리는 모양을 개발자들 사이에서는 흔히 &quot;장풍&quot;이라고 부른다. 코드가 옆으로 계속 밀려나가는 모양이 장풍을 쏘는 것처럼 보여서 붙은 별명이다.</p>
<pre><code class="language-xml">&lt;LinearLayout&gt;
    &lt;LinearLayout&gt;
        &lt;LinearLayout&gt;
            &lt;!-- 계속 중첩... --&gt;
        &lt;/LinearLayout&gt;
    &lt;/LinearLayout&gt;
&lt;/LinearLayout&gt;</code></pre>
<p>이게 왜 문제가 될까. 화면에 뷰를 그릴 때 시스템은 measure(크기 측정) → layout(위치 배치) → draw(그리기) 세 단계를 거치는데, 이 과정에서 뷰 트리 전체를 순회해야 한다. 뷰 계층이 한 단계 깊어질 때마다 순회해야 할 노드 수가 늘어나고, 자식 뷰의 크기를 계산하려면 부모의 계산이 먼저 끝나야 하는 경우도 많아서 계층이 깊을수록 이 과정이 느려진다. 화면 하나에 뷰 개수가 많아지면 그만큼 메모리 사용량도 늘어난다. 결과적으로 뷰 계층이 깊어질수록 UI 응답성이 떨어지고 스크롤이나 화면 전환 시 버벅임이 생길 가능성이 커진다.</p>
<p>그래서 나온 대안이 <code>ConstraintLayout</code>이다. <code>LinearLayout</code>을 겹겹이 중첩하는 대신, <code>ConstraintLayout</code> 하나 안에서 각 뷰가 다른 뷰나 부모를 기준으로 제약 조건(constraint)을 걸어 위치를 잡는 방식이다. 계층을 평평하게(flat) 유지하면서도 복잡한 배치를 표현할 수 있어서, 뷰 개수는 비슷해도 계층 깊이 자체를 줄일 수 있다. 결론은 간단하다. 레이아웃을 짤 때 &quot;이거 굳이 이렇게 겹쳐야 하나?&quot;를 한 번씩 의심해보고, 가능하면 계층을 얕고 평평하게 유지하는 게 좋다.</p>
<h2 id="마무리-정리">마무리 정리</h2>
<p>이번 글에서는 dp와 sp를 &quot;사용자 폰트 설정에 반응하느냐&quot;의 차이로 구분하는 법, 그리고 뷰 계층이 깊어지면 measure-layout-draw 순회 비용이 늘어나서 UI 응답성이 떨어지는 이유를 정리했다. 두 주제 모두 &quot;왜 이렇게 써야 하는지&quot; 원리를 알면 실수를 줄이고 오래 기억하게 된다는 공통점이 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 화면 회전해도 내 데이터는 어디 안 감 - SavedInstanceState]]></title>
            <link>https://velog.io/@ju-unn/%ED%99%94%EB%A9%B4-%ED%9A%8C%EC%A0%84%ED%95%B4%EB%8F%84-%EB%82%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%8A%94-%EC%96%B4%EB%94%94-%EC%95%88-%EA%B0%90-SavedInstanceState</link>
            <guid>https://velog.io/@ju-unn/%ED%99%94%EB%A9%B4-%ED%9A%8C%EC%A0%84%ED%95%B4%EB%8F%84-%EB%82%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%8A%94-%EC%96%B4%EB%94%94-%EC%95%88-%EA%B0%90-SavedInstanceState</guid>
            <pubDate>Wed, 26 Aug 2026 04:44:57 GMT</pubDate>
            <description><![CDATA[<p>화면을 회전시키면 Activity가 <code>onDestroy</code> → <code>onCreate</code> 순서로 재생성된다는 걸 봤다. 이번 글에서는 그 재생성 과정에서 화면에 입력해둔 데이터가 왜 사라지는지, 그리고 그걸 어떻게 지킬 수 있는지를 다룬다. 3-1편에서 다룬 &quot;서로 다른 두 Activity 사이의 데이터 전달&quot;과는 달리, 이번 글은 <strong>같은 Activity가 재생성될 때</strong> 이전 상태를 이어받는 방법이라는 점을 먼저 짚고 시작한다.</p>
<h2 id="화면을-회전하면-왜-데이터가-날아갈까">화면을 회전하면 왜 데이터가 날아갈까</h2>
<p>화면을 회전시키면 시스템은 현재 Activity를 완전히 소멸시키고 새로 만든다. 이게 왜 필요하냐면, 화면 방향이 바뀌면 가로용/세로용으로 따로 준비된 레이아웃 리소스처럼 적용해야 할 리소스 자체가 달라질 수 있기 때문이다. 안드로이드 공식 문서는 이렇게 설명한다. &quot;시스템은 구성 변경이 발생하면 Activity를 다시 만듭니다. 이를 위해 시스템은 <code>onDestroy()</code>를 호출하고 기존 Activity 인스턴스를 소멸시킵니다. 그런 다음 <code>onCreate()</code>를 사용하여 새 인스턴스를 만듭니다.&quot; 화면 방향 전환은 이런 <strong>구성 변경(configuration change)</strong>의 대표적인 예시이고, 언어 변경이나 다크모드 전환도 같은 범주에 들어간다.</p>
<p>여기서 중요한 건, 재생성된 새 Activity 인스턴스는 이전 인스턴스가 메모리에 들고 있던 변수들(EditText에 입력해둔 값, 스크롤 위치 같은 화면 상태)을 전혀 알지 못하는 완전히 새로운 객체라는 점이다. 이전 인스턴스는 <code>onDestroy()</code>가 호출되면서 메모리에서 사라지고, 그 안에 있던 값들도 같이 사라진다. 따로 저장해두지 않으면 그대로 날아가는 게 당연한 결과다.</p>
<p>한 가지 더 알아두면 좋은 게 프로세스 종료(process death)다. 앱이 백그라운드에 있을 때 시스템이 메모리를 확보하려고 앱의 프로세스 자체를 통째로 종료하는 경우가 있다. 이후 사용자가 다시 앱으로 돌아오면 시스템은 새 프로세스로 앱을 재기동하면서 이전에 보고 있던 Activity를 복원해준다. 이때도 뒤에서 볼 <code>onSaveInstanceState</code>에 저장해둔 Bundle이 있으면 복원에 그대로 쓰인다. 즉 이 메커니즘은 화면 회전에만 한정된 게 아니라, &quot;시스템이 이유 불문 Activity를 죽였다가 다시 살릴 가능성이 있는 모든 상황&quot;에 적용되는 더 넓은 개념이다.</p>
<h2 id="onsaveinstancestate--onrestoreinstancestate로-상태-지키기">onSaveInstanceState / onRestoreInstanceState로 상태 지키기</h2>
<p>Activity가 재생성되기 전에 시스템은 <code>onSaveInstanceState(Bundle outState)</code>를 호출해서 Activity에게 &quot;지금 상태를 Bundle에 저장해두라&quot;는 기회를 준다.</p>
<pre><code class="language-java">protected void onSaveInstanceState (Bundle outState)</code></pre>
<p>여기서 호출 시점을 정확히 알아둘 필요가 있다. API 28(Android 9, <code>Build.VERSION_CODES.P</code>) 이상을 타깃으로 하면 <code>onStop()</code> <strong>이후</strong>에 호출된다. 반면 API 28 미만을 타깃으로 하면 <code>onStop()</code> <strong>이전</strong>에 호출되고, <code>onPause()</code>보다 전인지 후인지는 보장되지 않는다. 그래서 &quot;onSaveInstanceState는 항상 onPause 직후, onStop 직전에 불린다&quot;처럼 순서를 고정해서 외우면 최신 타깃 SDK 기준으로는 틀린 이해가 된다. 호출 순서는 타깃 API 레벨에 따라 달라진다는 게 핵심이다.</p>
<p>또 하나 중요한 건, 이 메서드가 항상 호출되는 게 아니라는 점이다. 시스템이 메모리 부족 등의 이유로 Activity를 강제 종료할 가능성이 있을 때만 호출된다. 사용자가 뒤로가기 버튼을 눌러서 Activity를 명시적으로 종료(<code>finish()</code>)하는 경우에는 호출되지 않는다. 사용자가 &quot;이 화면은 이제 필요 없다&quot;고 직접 끝낸 것이므로, 시스템 입장에서는 복원할 필요가 없는 종료이기 때문이다.</p>
<p>복원은 <code>onRestoreInstanceState(Bundle savedInstanceState)</code>가 담당한다.</p>
<pre><code class="language-java">protected void onRestoreInstanceState (Bundle savedInstanceState)</code></pre>
<p>이 메서드는 <code>onStart()</code> 이후에 호출되며, 이전에 저장된 상태로부터 재생성되는 경우에만 호출된다. 그래서 이 메서드가 넘겨받는 <code>savedInstanceState</code>는 항상 non-null이다. 그런데 실무에서는 이 메서드를 따로 오버라이드하는 경우가 많지 않다. 대신 <code>onCreate(Bundle savedInstanceState)</code>가 매개변수로 받는 Bundle이 null인지 아닌지를 검사해서 복원 로직을 처리하는 방식을 더 많이 쓴다. 사실 이 두 곳에 넘어오는 Bundle은 같은 데이터다. 차이는 <code>onCreate</code> 쪽은 null일 수 있어서 체크가 필요하고, <code>onRestoreInstanceState</code>는 애초에 값이 있을 때만 호출되니 체크가 필요 없다는 것뿐이다.</p>
<p>여기서 실무적으로 조심해야 할 점이 하나 있다. <code>Bundle</code>에 담기는 데이터는 3-1편에서 설명했듯 Binder IPC 구조를 거치기 때문에 크기 제한이 있다. 이미지 원본이나 긴 리스트처럼 크기가 큰 데이터를 여기에 담으면 <code>TransactionTooLargeException</code>이 발생할 수 있다. <code>onSaveInstanceState</code>는 &quot;가볍고 임시적인 UI 상태&quot;만 담는 용도로 쓰고, 무거운 데이터는 다른 방법(ViewModel, 서버 재요청, 로컬 저장소 등)으로 관리해야 한다.</p>
<p>최근 공식 가이드는 Jetpack Compose를 전제로 <code>ViewModel</code>(비즈니스 로직과 복잡한 상태)과 <code>rememberSaveable</code>(경량 UI 상태)의 조합을 권장 패턴으로 제시하고, <code>onSaveInstanceState</code>를 직접 다루는 저수준 설명은 점점 줄이는 방향으로 문서를 개편하고 있다. 다만 View 시스템(XML) 기반에서는 지금 본 <code>onSaveInstanceState</code>/<code>onRestoreInstanceState</code>를 직접 오버라이드하는 방식이 여전히 기본기로 유효하다. 이 글에서 이 메커니즘을 먼저 다룬 이유이기도 하다. 요즘은 여기에 ViewModel과 rememberSaveable이 한 겹 더해지는 흐름이라고 생각하면 된다.</p>
<h2 id="왕초보가-헷갈리는-포인트">왕초보가 헷갈리는 포인트</h2>
<p><strong>&quot;onSaveInstanceState는 화면을 회전하면 무조건 불린다&quot;는 착각.</strong> 정확히는 시스템이 알아서 Activity를 죽였다가 살릴 가능성이 있을 때 불린다. 사용자가 뒤로가기로 Activity를 직접 <code>finish()</code>하면 호출되지 않는다.</p>
<p><strong>onSaveInstanceState에 아무 데이터나 넣어도 된다는 착각.</strong> Bundle은 크기 제한이 있어서 큰 데이터를 넣으면 <code>TransactionTooLargeException</code>이 발생할 수 있다. 가볍고 임시적인 UI 상태만 담아야 한다.</p>
<p><strong>onCreate의 savedInstanceState와 onRestoreInstanceState가 서로 다른 데이터라는 착각.</strong> 사실 둘 다 같은 Bundle을 받는다. onCreate 쪽은 null일 수 있어 체크가 필요하고, onRestoreInstanceState는 값이 있을 때만 호출되므로 체크가 필요 없다는 차이가 있을 뿐이다.</p>
<p><strong>ViewModel이 있으면 onSaveInstanceState가 필요 없다는 착각.</strong> ViewModel은 구성 변경에는 살아남지만, 시스템이 메모리 부족으로 프로세스 자체를 종료하면 ViewModel도 함께 사라진다. 프로세스 종료 이후의 복원까지 대비하려면 여전히 onSaveInstanceState(또는 이를 감싼 SavedStateHandle)가 필요하다.</p>
<h2 id="마무리-정리">마무리 정리</h2>
<p>이번 글에서는 화면 회전 같은 구성 변경으로 Activity가 재생성될 때 왜 데이터가 사라지는지, 그리고 <code>onSaveInstanceState</code>/<code>onRestoreInstanceState</code>로 그 상태를 어떻게 지키는지를 다뤘다. 재생성 자체는 막을 수 없는 시스템의 동작 방식이고, 우리가 할 수 있는 건 그 재생성 앞뒤로 필요한 데이터를 제대로 저장하고 복원하는 것뿐이다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Activity 데이터 전달과 직렬화 - Serializable vs Parcelable]]></title>
            <link>https://velog.io/@ju-unn/Android-Activity-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%84%EB%8B%AC%EA%B3%BC-%EC%A7%81%EB%A0%AC%ED%99%94-Serializable-vs-Parcelable</link>
            <guid>https://velog.io/@ju-unn/Android-Activity-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%84%EB%8B%AC%EA%B3%BC-%EC%A7%81%EB%A0%AC%ED%99%94-Serializable-vs-Parcelable</guid>
            <pubDate>Wed, 26 Aug 2026 04:24:59 GMT</pubDate>
            <description><![CDATA[<p>Intent가 컴포넌트 사이를 이어주는 메시지라고 정리했다. 이번 글에서는 그 Intent에 실제 데이터를 실어서 Activity끼리 주고받는 방법, 그리고 커스텀 객체를 넘길 때 필요한 직렬화(Serializable/Parcelable)를 다룬다.</p>
<h2 id="activity끼리-데이터-주고받기">Activity끼리 데이터 주고받기</h2>
<p>안드로이드에서 하나의 화면(Activity)에서 다른 화면으로 넘어갈 때는 <code>Intent</code>를 사용한다. 이때 단순히 화면만 전환하는 게 아니라, 데이터도 같이 실어 보낼 수 있다. 예를 들어 목록 화면에서 항목을 하나 클릭해서 상세 화면으로 넘어간다고 하면, &quot;어떤 항목을 클릭했는지&quot;에 대한 정보를 같이 넘겨줘야 상세 화면이 뭘 보여줄지 알 수 있다.</p>
<pre><code class="language-kotlin">Intent(this, DetailActivity::class.java)
    .apply {
        putExtra(&quot;movie&quot;, dummy[position])
    }</code></pre>
<p><code>putExtra</code>에 키(&quot;movie&quot;)와 값(<code>dummy[position]</code>)을 넣어서 Intent에 데이터를 실은 다음, 이 Intent로 <code>startActivity</code>를 호출하면 새로 열리는 <code>DetailActivity</code>가 이 데이터에 접근할 수 있다. 받는 쪽에서는 넘겨받은 데이터의 타입에 맞는 <code>get&lt;타입&gt;Extra</code> 메서드를 쓴다.</p>
<pre><code class="language-kotlin">val movie: Movie = intent?.getParcelableExtra(&quot;movie&quot;) ?: throw IllegalArgumentException()</code></pre>
<p>여기서 짚고 넘어가야 할 게 있다. 지금 다루는 <code>Intent</code>를 통한 데이터 전달은 <strong>서로 다른 두 Activity 사이의 데이터 전달</strong>이다. 다음편에서 다룰 <code>onSaveInstanceState</code>는 <strong>같은 Activity가 재생성될 때</strong> 이전 상태를 이어받는 것이다. 전달 대상도 다르고 시점도 다르다. 나도 처음 배울 때 이 둘을 같은 걸로 뭉뚱그려서 이해했다가 나중에 헷갈렸던 경험이 있어서, 이름이 비슷해 보여도 목적이 다른 개념이라는 걸 먼저 못박고 시작하겠다.</p>
<h2 id="객체를-통째로-넘기려면---직렬화가-필요한-이유">객체를 통째로 넘기려면? - 직렬화가 필요한 이유</h2>
<p>위 코드에서 <code>Movie</code>라는 커스텀 객체를 통째로 <code>putExtra</code>에 넣고, 받는 쪽에서는 <code>getParcelableExtra</code>로 꺼냈다. 그런데 왜 그냥 <code>Movie</code> 객체를 변수에 담아 넘기듯이 넘길 수 없고, <code>Parcelable</code>이라는 걸 거쳐야 할까?</p>
<p><code>Intent</code>는 내부적으로 <code>Bundle</code>이라는 데이터 컨테이너를 사용한다. <code>Bundle</code>은 단순히 메모리 안에서 값을 담아두는 자료구조가 아니라, 프로세스 경계를 넘나들 수 있는 <strong>Binder IPC(Inter-Process Communication, 프로세스 간 통신)</strong> 구조를 전제로 설계되어 있다. Activity를 시작할 때 실제로는 안드로이드 시스템(정확히는 <code>ActivityManagerService</code>)이 중간에 개입해서 새 Activity를 띄우는데, 이 과정이 프로세스 경계를 넘는 경우가 있다. 그래서 <code>Bundle</code>에 담기는 데이터는 &quot;메모리 주소를 그대로 복사&quot;하는 방식으로는 전달할 수 없고, 바이트 단위로 직렬화(serialize, 객체를 전송·저장 가능한 형태로 변환하는 것)해서 보낸 다음 반대편에서 역직렬화(deserialize)해서 복원하는 절차를 거쳐야 한다.</p>
<p>일반 자바/코틀린 객체는 이런 직렬화 규칙이 없다. 그래서 안드로이드는 &quot;이 객체는 직렬화가 가능하다&quot;는 걸 명시적으로 보장하는 인터페이스를 요구한다. 그게 바로 <code>Serializable</code>과 <code>Parcelable</code>이다. 둘 중 하나를 구현해야만 <code>Bundle</code>(따라서 <code>Intent</code>)에 커스텀 객체를 담아 넘길 수 있다.</p>
<h2 id="serializable-vs-parcelable">Serializable vs Parcelable</h2>
<p><code>Serializable</code>과 <code>Parcelable</code>은 둘 다 &quot;이 객체는 직렬화할 수 있다&quot;는 걸 표시하는 인터페이스라는 점에서 기능은 같다. 하지만 구현 방식과 성능에서 차이가 크다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Serializable</th>
<th>Parcelable</th>
</tr>
</thead>
<tbody><tr>
<td>구현 방법</td>
<td>인터페이스만 구현하면 끝</td>
<td>인터페이스 구현 + 추가 코드 작성 필요</td>
</tr>
<tr>
<td>성능</td>
<td>상대적으로 느림</td>
<td>상대적으로 빠름</td>
</tr>
<tr>
<td>코드 작성량</td>
<td>추가 코드 작성 불필요</td>
<td><code>writeToParcel()</code>, <code>describeContents()</code>, <code>CREATOR</code> 등 보일러플레이트 필요</td>
</tr>
<tr>
<td>의존성</td>
<td>Java 표준 API</td>
<td>안드로이드 의존성 필요</td>
</tr>
</tbody></table>
<p>표만 외우면 &quot;그냥 Serializable이 편해 보이는데 왜 Parcelable을 쓰라는 거지&quot;라는 생각이 들 수 있다. 그래서 성능 차이가 왜 나는지 원리를 짚어보겠다.</p>
<p><code>Serializable</code>은 리플렉션(reflection, 런타임에 클래스의 필드나 구조를 분석하는 기법)을 이용해서 객체를 분석하고 직렬화한다. 클래스에 어떤 필드가 있는지, 타입이 뭔지를 실행 중에 하나하나 들여다보면서 처리하기 때문에 느리고, 이 과정에서 임시 객체가 많이 만들어져서 가비지 컬렉션(GC, 더 이상 쓰지 않는 메모리를 회수하는 작업) 부담도 커진다. 반면 <code>Parcelable</code>은 리플렉션을 쓰지 않는다. 클래스를 만들 때 &quot;이 필드를 이 순서로 읽고 쓴다&quot;는 로직을 직접 정의해두기 때문에, 실행 시점에 구조를 분석할 필요 없이 메모리에 필드를 바로 읽고 쓴다. 그만큼 빠르다.</p>
<p><code>Serializable</code>은 원래 자바 표준 API로, 안드로이드 전용으로 최적화된 게 아니다. 그런데 안드로이드는 Activity 간, 프로세스 간 데이터 전달이 매우 빈번한 플랫폼이라서, 이 경로에 최적화된 자체 인터페이스로 <code>Parcelable</code>을 따로 만들어 제공한다. 그래서 안드로이드 공식 문서도 IPC 상황에서는 <code>Parcelable</code> 사용을 권장한다.</p>
<p>다만 표에서 본 것처럼 <code>Parcelable</code>은 직접 구현하면 <code>writeToParcel()</code>, <code>describeContents()</code>, <code>CREATOR</code> 필드까지 보일러플레이트 코드를 다 작성해야 한다는 단점이 있다. 이 단점을 없애주는 게 다음에 볼 <code>@Parcelize</code> 플러그인이다.</p>
<h2 id="parcelize로-보일러플레이트-없애기">Parcelize로 보일러플레이트 없애기</h2>
<p><code>Parcelable</code>을 직접 구현하면 코드가 이렇게 길어진다.</p>
<pre><code class="language-kotlin">// Before: Parcelable 직접 구현
class Movie(val title: String, val year: Int) : Parcelable {
    constructor(parcel: Parcel) : this(
        parcel.readString() ?: &quot;&quot;,
        parcel.readInt()
    )

    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeString(title)
        parcel.writeInt(year)
    }

    override fun describeContents(): Int = 0

    companion object CREATOR : Parcelable.Creator&lt;Movie&gt; {
        override fun createFromParcel(parcel: Parcel): Movie = Movie(parcel)
        override fun newArray(size: Int): Array&lt;Movie?&gt; = arrayOfNulls(size)
    }
}</code></pre>
<p>필드가 두 개뿐인 클래스인데도 이만큼 써야 한다. 필드가 늘어날수록 이 보일러플레이트도 같이 늘어난다. 코틀린은 이 문제를 <code>kotlin-parcelize</code>라는 공식 플러그인으로 해결한다. <code>build.gradle.kts</code>에 플러그인을 추가한다.</p>
<pre><code class="language-kotlin">plugins {
    id(&quot;kotlin-parcelize&quot;)
}</code></pre>
<p>그리고 클래스에 <code>@Parcelize</code> 어노테이션을 붙이고 <code>Parcelable</code>을 상속하기만 하면, <code>writeToParcel()</code>이나 <code>CREATOR</code> 같은 코드를 컴파일 시점에 자동으로 만들어준다.</p>
<pre><code class="language-kotlin">// After: @Parcelize 적용
import kotlinx.parcelize.Parcelize
import android.os.Parcelable

@Parcelize
data class Movie(val title: String, val year: Int) : Parcelable</code></pre>
<p>코드 한 줄로 줄었다. 여기서 주의할 점은, 직렬화 대상 프로퍼티는 반드시 <strong>주 생성자(primary constructor)</strong>에 선언되어야 한다는 것이다. 클래스 본문에 따로 선언한 프로퍼티는 자동으로 직렬화되지 않고 경고가 뜬다. 원시 타입, <code>String</code>, <code>CharSequence</code>, <code>List</code>/<code>Set</code>/<code>Map</code> 같은 컬렉션, <code>Bundle</code>, 그리고 이미 <code>Serializable</code>이나 <code>Parcelable</code>을 구현한 타입이라면 nullable 버전이나 배열까지도 기본으로 지원한다. 이 기본 지원 범위를 벗어나는 커스텀 타입을 다뤄야 한다면 <code>Parceler</code> 인터페이스를 직접 구현해서 <code>@TypeParceler</code>로 연결하는 방법도 있는데, 이건 심화 내용이라 이런 게 있다는 정도만 알아두면 된다.</p>
<h2 id="헷갈리는-포인트">헷갈리는 포인트</h2>
<p><strong>Intent로 데이터 넘기는 것과 onSaveInstanceState로 상태 저장하는 것을 같은 것으로 착각.</strong> 앞서 못박았듯, 전자는 서로 다른 두 Activity 사이의 데이터 전달이고 후자는 같은 Activity가 재생성될 때 이전 상태를 이어받는 것이다. 목적도 시점도 다르다.</p>
<p><strong>Serializable과 Parcelable 이름이 비슷해서 아무거나 골라도 되는 줄 아는 착각.</strong> 둘 다 직렬화를 위한 인터페이스라는 점은 같지만, 리플렉션 사용 여부 때문에 성능 차이가 있고, 안드로이드 공식 권장은 Parcelable, 그중에서도 <code>@Parcelize</code>다.</p>
<h2 id="마무리-정리">마무리 정리</h2>
<p>이번 글에서는 서로 다른 두 Activity 사이에서 Intent로 데이터를 주고받는 방법과, 커스텀 객체를 넘길 때 필요한 Serializable/Parcelable 직렬화를 정리했다. Parcelable이 리플렉션 없이 직접 정의한 읽기/쓰기 로직으로 동작해서 더 빠르고, <code>@Parcelize</code>를 쓰면 그 보일러플레이트도 거의 없앨 수 있다는 게 핵심이었다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Activity 생명주기 - 종료, 회전, 그리고 보장되지 않는 콜백]]></title>
            <link>https://velog.io/@ju-unn/Android-Activity-%EC%83%9D%EB%AA%85%EC%A3%BC%EA%B8%B0-%EC%A2%85%EB%A3%8C-%ED%9A%8C%EC%A0%84-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%B3%B4%EC%9E%A5%EB%90%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%BD%9C%EB%B0%B1</link>
            <guid>https://velog.io/@ju-unn/Android-Activity-%EC%83%9D%EB%AA%85%EC%A3%BC%EA%B8%B0-%EC%A2%85%EB%A3%8C-%ED%9A%8C%EC%A0%84-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%B3%B4%EC%9E%A5%EB%90%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%BD%9C%EB%B0%B1</guid>
            <pubDate>Wed, 26 Aug 2026 04:01:23 GMT</pubDate>
            <description><![CDATA[<p>앱이 처음 실행될 때 <code>onCreate → onStart → onResume</code> 순서로 화면이 뜬다는 것까지 살펴봤다. 이번 글에서는 그 이후에 벌어지는 일들 - 화면을 벗어날 때, 화면이 회전할 때, 다른 화면이 위에 뜰 때 Activity가 어떻게 되는지를 다룬다. 
Context와는 별개로, Activity가 시간에 따라 어떤 상태를 거쳐가는지에 집중한다.</p>
<h2 id="activity-생명주기-왜-존재하는가">Activity 생명주기, 왜 존재하는가</h2>
<p>Activity 생명주기는 Activity가 시간에 따라 거쳐가는 상태들과, 그 상태가 바뀔 때마다 시스템이 호출해주는 콜백 함수들을 말한다. 개발자는 이 콜백 함수들을 오버라이드해서 &quot;이 상태가 됐을 때 뭘 할지&quot;를 정의하기만 하면 된다.</p>
<pre><code class="language-kotlin">class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // onCreate 상태일 때 뷰를 그린다
        setContentView(R.layout.activity_main)
    }
}</code></pre>
<p>이게 왜 필요한지는 실제 시나리오를 떠올려보면 이해가 쉽다. 예를 들어 사용자가 앱을 쓰다가 홈 버튼을 눌러 백그라운드로 보냈다가, 잠시 뒤 다시 앱으로 돌아왔다고 하자. 이때 어떤 데이터는 새로 불러와야 하고, 어떤 애니메이션은 다시 재생해야 할 수도 있다. 만약 안드로이드가 이런 상태 전환 시점을 개발자에게 전혀 알려주지 않는다면, 개발자는 직접 &quot;지금 사용자가 화면을 보고 있나 아닌가&quot;를 알아낼 방법을 스스로 만들어야 한다. 안드로이드는 이 상태 변화를 시스템 레벨에서 이미 감지하고 있고, 그 감지 결과를 <code>onPause()</code>, <code>onResume()</code> 같은 콜백으로 그대로 넘겨준다. 개발자는 그 콜백 안에 필요한 로직만 채우면 되는 것이다. 생명주기 콜백은 결국 &quot;지금 이 화면이 사용자에게 어떤 상태로 보이고 있는가&quot;를 코드로 감지할 수 있게 해주는 장치다.</p>
<h2 id="activity-생명주기-콜백-살펴보기">Activity 생명주기 콜백 살펴보기</h2>
<p>Activity 생명주기 콜백은 <code>onCreate</code>, <code>onStart</code>, <code>onResume</code>, <code>onPause</code>, <code>onStop</code>, <code>onDestroy</code>, 그리고 <code>onRestart</code>까지 총 일곱 개다. 1-2편에서 다룬 <code>onCreate → onStart → onResume</code>을 포함해서 전체를 코드로 보면 다음과 같다.</p>
<pre><code class="language-kotlin">class MainActivity : AppCompatActivity() {
    private val TAG = &quot;LifecycleDemo&quot;

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        Log.d(TAG, &quot;onCreate called&quot;)
    }

    override fun onStart() {
        super.onStart()
        Log.d(TAG, &quot;onStart called&quot;)
    }

    override fun onResume() {
        super.onResume()
        Log.d(TAG, &quot;onResume called&quot;)
    }

    override fun onPause() {
        super.onPause()
        Log.d(TAG, &quot;onPause called&quot;)
    }

    override fun onStop() {
        super.onStop()
        Log.d(TAG, &quot;onStop called&quot;)
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d(TAG, &quot;onDestroy called&quot;)
    }

    override fun onRestart() {
        super.onRestart()
        Log.d(TAG, &quot;onRestart called&quot;)
    }
}</code></pre>
<p>각 콜백의 역할을 간단히 정리하면, <code>onCreate</code>는 화면이 처음 생성될 때 레이아웃을 설정하고 초기화 작업을 하는 자리다. <code>onStart</code>는 화면이 사용자 눈에 보이기 시작하는 시점이고, <code>onResume</code>은 사용자가 실제로 그 화면과 상호작용할 수 있는 상태(포커스를 가진 상태)가 됐을 때다. <code>onPause</code>는 다른 화면이 위에 뜨기 시작해서 포커스를 잃는 순간, <code>onStop</code>은 화면이 아예 사용자에게 보이지 않게 된 순간, <code>onDestroy</code>는 Activity가 메모리에서 완전히 제거되기 직전이다. <code>onRestart</code>는 <code>onStop</code> 상태였던 Activity가 다시 화면에 보이게 될 때, <code>onStart</code> 직전에 한 번 호출되는 콜백이다.</p>
<h2 id="생명주기는-항상-보장될까">생명주기는 항상 보장될까</h2>
<p>여기서 놓치기 쉬운 중요한 함정이 하나 있다. <code>onStop()</code>이나 <code>onDestroy()</code>가 &quot;언젠가는 반드시 호출되는 마지막 콜백&quot;이라고 믿는 것이다. 실제로는 그렇지 않다.</p>
<p>안드로이드 시스템은 메모리가 부족해지면 우선순위가 낮은 백그라운드 프로세스를 강제로 종료시켜서 메모리를 확보한다. 그런데 이때 시스템은 Activity 하나만 콕 집어서 정중하게 <code>onStop()</code>, <code>onDestroy()</code>를 호출해준 뒤 종료하는 게 아니라, 그 Activity가 속한 프로세스 자체를 통째로 죽여버릴 수 있다. 프로세스가 그대로 죽으면 그 안에서 실행되고 있던 콜백 호출도 함께 끊긴다. 결과적으로 <code>onStop()</code>이나 <code>onDestroy()</code>가 아예 호출되지 않은 채로 Activity가 사라지는 상황이 생긴다.</p>
<p>이게 실무에서 왜 중요하냐면, &quot;사용자 데이터를 영구 저장해야 하니까 onDestroy에서 저장하면 되겠다&quot;는 접근이 위험하기 때문이다. onDestroy가 호출되지 않을 수도 있으므로, 반드시 저장해야 하는 데이터는 더 일찍 호출되는 <code>onStop()</code> 시점(혹은 그 이전)에 미리 저장해두는 것이 원칙이다. <code>onStop()</code>도 백 퍼센트 보장되는 건 아니지만, 시스템이 Activity를 종료시키는 일반적인 흐름에서는 <code>onPause() → onStop()</code>까지는 대체로 호출된 뒤에 프로세스가 정리되기 때문에, <code>onDestroy()</code>보다는 <code>onStop()</code>을 저장 시점으로 잡는 게 안전하다.</p>
<h2 id="상황별-호출-순서-정리">상황별 호출 순서 정리</h2>
<p>지금까지 다룬 콜백들이 실제로 어떤 순서로 호출되는지, 상황별로 정리하면 다음과 같다.</p>
<ul>
<li>Activity가 처음 시작될 때: <code>onCreate → onStart → onResume</code></li>
<li>Activity가 완전히 종료될 때: <code>onPause → onStop → onDestroy</code></li>
<li>화면이 회전될 때: <code>onPause → onStop → onDestroy → onCreate → onStart → onResume</code></li>
<li>다른 Activity가 위에 불투명하게 뜰 때: <code>onPause → onStop</code></li>
<li>다른 Activity가 투명하게 뜨거나 화면 일부만 가릴 때: <code>onPause</code>만 호출</li>
</ul>
<p>화면 회전이 눈에 띄는 이유는, 단순히 상태만 바뀌는 게 아니라 Activity 자체가 완전히 파괴됐다가 처음부터 다시 만들어지기 때문이다. 이건 화면 회전만의 특수한 동작이 아니라, 안드로이드가 &quot;configuration change(구성 변경)&quot;라고 부르는 상황 전반에 적용되는 기본 동작이다. 화면 회전 외에도 configuration change로 분류되어 Activity를 처음부터 재생성시키는 상황에는 다음과 같은 것들이 있다.</p>
<ul>
<li>멀티윈도우(분할 화면)나 폴더블 기기에서 화면 크기가 바뀔 때</li>
<li>물리 키보드를 연결하거나 분리할 때</li>
<li>시스템 언어(로케일)를 변경할 때</li>
<li>다크 모드와 라이트 모드를 전환할 때</li>
<li>시스템 글꼴 크기(폰트 스케일)를 변경할 때</li>
</ul>
<p>&quot;화면 회전 때만 데이터가 초기화된다&quot;고 알고 있으면, 언어 설정을 바꾸거나 다크 모드를 켰을 때 화면이 처음부터 다시 그려지는 걸 보고 당황하게 된다. 실제로는 전부 같은 원리 configuration change가 발생하면 시스템은 기본적으로 Activity를 파괴하고 재생성한다로 설명되는 현상이다.</p>
<p>이렇게 재생성될 때마다 변수에 담아둔 값이 날아가는 문제를 실무에서는 어떻게 다룰까. 그 정답을 이 글에서 구현까지 다루지는 않지만(다른편에서 SavedInstanceState로 자세히 다룬다), 방향만 짚어두면 두 갈래가 있다. 하나는 Activity가 재생성돼도 그 값을 잃지 않도록 상태를 저장했다가 복원하는 방법이고, 다른 하나는 애초에 Activity 재생성과 무관하게 살아남는 별도의 객체(<code>ViewModel</code> 같은)에 데이터를 들고 있게 하는 방법이다.</p>
<p>한 가지 더, 실무에서 종종 쓰이는 판별 도구도 알아두면 좋다. <code>onDestroy()</code>가 호출됐을 때 그게 &quot;사용자가 화면을 완전히 닫아서&quot;인지 &quot;화면 회전 때문에 재생성되는 중이라서&quot;인지 구분하고 싶을 때가 있는데, 이럴 때 <code>isFinishing()</code>이라는 메서드를 쓴다. 이 값이 <code>true</code>면 Activity가 진짜로 종료되는 중이라는 뜻이고, <code>false</code>면 설정 변경으로 인한 재생성 과정 중이라는 뜻이다.</p>
<h2 id="이걸-다-외워야-할까--로그로-직접-확인하기">이걸 다 외워야 할까 — 로그로 직접 확인하기</h2>
<p>위에 정리한 순서를 전부 암기하려고 하면 오히려 헷갈린다. 케이스가 다섯 가지나 되고, 앞으로 더 복잡한 상황(예: 다이얼로그가 떠 있는 상태에서 화면을 끄는 경우 등)도 마주치게 될 텐데, 그때마다 표를 찾아보는 것보다 직접 로그를 찍어서 눈으로 확인하는 습관을 들이는 편이 훨씬 오래 남는다.</p>
<pre><code class="language-kotlin">class MainActivity : AppCompatActivity() {
    private val TAG = &quot;LifecycleDemo&quot;

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d(TAG, &quot;onCreate called&quot;)
    }
    override fun onStart() {
        super.onStart()
        Log.d(TAG, &quot;onStart called&quot;)
    }
    override fun onResume() {
        super.onResume()
        Log.d(TAG, &quot;onResume called&quot;)
    }
    override fun onPause() {
        super.onPause()
        Log.d(TAG, &quot;onPause called&quot;)
    }
    override fun onStop() {
        super.onStop()
        Log.d(TAG, &quot;onStop called&quot;)
    }
    override fun onDestroy() {
        super.onDestroy()
        Log.d(TAG, &quot;onDestroy called&quot;)
    }
    override fun onRestart() {
        super.onRestart()
        Log.d(TAG, &quot;onRestart called&quot;)
    }
}</code></pre>
<p>이 코드를 붙여넣고 앱을 실행한 뒤, 홈 버튼을 눌러보고, 화면을 회전시켜보고, 다른 화면을 위에 띄워보면서 Logcat에 찍히는 순서를 직접 확인해보면 위에서 정리한 표가 그대로 눈앞에서 재현되는 걸 볼 수 있다. 순서를 외우는 것보다 &quot;왜 이 순서로 찍히는지&quot;를 한 번이라도 직접 관찰하고 나면, 나중에 새로운 상황을 마주쳤을 때도 스스로 추론할 수 있는 힘이 생긴다.</p>
<h2 id="헷갈리는-포인트">헷갈리는 포인트</h2>
<p><strong>onDestroy()가 호출되면 항상 정리 작업을 할 수 있는 거 아닌가?</strong> 아니다. 시스템이 메모리 확보를 위해 프로세스를 강제 종료하면 onDestroy조차 호출되지 못한 채 Activity가 사라질 수 있다. &quot;언젠가 꼭 불리는 마지막 콜백&quot;이 아니라 &quot;불릴 수도, 안 불릴 수도 있는 콜백&quot;이라고 생각해야 한다.</p>
<p><strong>화면 회전만 Activity를 다시 만드는 거 아닌가?</strong> 아니다. 다크 모드 전환, 시스템 언어 변경, 폴더블 기기의 화면 크기 변화, 키보드 연결·해제도 전부 configuration change로 취급되어 Activity가 처음부터 다시 만들어진다. 회전만 테스트해보고 생명주기를 다 이해했다고 생각하면, 다른 상황에서 데이터가 날아가는 버그를 나중에 만나게 된다.</p>
<p><strong>onPause와 onStop은 항상 같이 붙어 다니는 거 아닌가?</strong> 아니다. 멀티윈도우(분할 화면)나 투명한 다이얼로그·Activity가 위에 떠서 화면 일부만 가려질 때는 onPause만 호출되고 onStop은 호출되지 않는다. &quot;onPause 다음엔 항상 onStop&quot;이라고 외워두면 분할 화면 환경에서 틀린 가정을 하게 된다.</p>
<p><strong>onPause에서 네트워크 요청이나 DB 저장 같은 무거운 작업을 해도 되는 거 아닌가?</strong> 안 된다. onPause는 다음 화면이 뜨기 직전에 아주 짧게 실행되어야 하는 콜백이다. 여기에 무거운 작업을 넣으면 화면 전환 자체가 버벅이는 게 사용자 눈에 그대로 보인다. CPU나 시간이 걸리는 저장 작업은 화면이 완전히 보이지 않게 된 뒤인 onStop에서 하는 것이 원칙이다.</p>
<h2 id="정리">정리</h2>
<p>Activity 생명주기는 시스템이 Activity의 상태 변화를 콜백으로 알려주는 장치이고, 시작·종료·화면 회전·다른 화면이 위에 뜨는 상황마다 호출 순서가 달라진다. 다만 onStop과 onDestroy는 항상 보장되는 콜백이 아니라는 점, 화면 회전 외에도 여러 configuration change가 Activity를 재생성시킨다는 점은 놓치기 쉬운 만큼 꼭 기억해둘 만하다. 순서를 통째로 암기하기보다 로그를 찍어 직접 관찰하면서 이해하는 쪽이 훨씬 오래 남는다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Context란 무엇인가 - 상속이 아니라 조합]]></title>
            <link>https://velog.io/@ju-unn/Android-Context%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80-%EC%83%81%EC%86%8D%EC%9D%B4-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%A1%B0%ED%95%A9</link>
            <guid>https://velog.io/@ju-unn/Android-Context%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80-%EC%83%81%EC%86%8D%EC%9D%B4-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%A1%B0%ED%95%A9</guid>
            <pubDate>Wed, 19 Aug 2026 06:48:41 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 Activity를 포함한 여러 컴포넌트가 공통으로 의존하는 개념인 Context를 정리한다.</p>
<h2 id="context란-무엇인가">Context란 무엇인가</h2>
<p>Context는 한마디로 &quot;지금 내가 어떤 실행 환경에 있는지&quot;에 대한 정보를 담고 있는 창구다. 안드로이드 앱을 만들다 보면 리소스 파일(문자열, 이미지, 색상 등)을 읽어야 하고, 시스템이 제공하는 서비스(알림, 위치, 진동 등)를 써야 하고, 다른 화면을 띄우거나 브로드캐스트를 보내야 하는 순간이 온다. 이런 일들을 하려면 지금 내 코드가 어떤 앱, 어떤 프로세스, 어떤 컴포넌트 안에서 실행되고 있는지에 대한 정보가 필요한데, 그 정보와 접근 권한을 한데 묶어서 제공하는 것이 Context다.</p>
<p>Context는 추상 클래스(abstract class)다. 즉 Context 자체는 직접 인스턴스를 만들 수 없고, 이를 구현한 다른 클래스를 통해 사용하게 된다. Activity, Service, BroadcastReceiver, ContentProvider 같은 컴포넌트들은 모두 이 Context를 상속하거나 내부에 갖고 있어서, <code>getSystemService()</code>로 시스템 서비스를 얻거나 <code>getResources()</code>로 리소스에 접근하거나 <code>startActivity()</code>로 다른 화면을 띄우는 등의 동작을 할 수 있다.</p>
<p>여기서 흔히 하는 오해가 하나 있다. Context를 그냥 Activity의 다른 이름 정도로 생각하는 것이다. 하지만 실제로는 Activity, Service, Application이 각각 자기만의 Context 인스턴스를 따로 갖고 있다. Activity는 그중 하나의 종류일 뿐이고, 뒤에서 다루겠지만 Activity Context와 Application Context는 서로 다른 인스턴스이기 때문에 아무 데서나 바꿔 써도 되는 게 아니다.</p>
<pre><code class="language-kotlin">class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // this는 현재 Activity의 Context다
        val activityContext: Context = this

        // getApplicationContext()는 앱 전체 생명주기를 따르는 별개의 Context다
        val appContext: Context = applicationContext
    }
}</code></pre>
<p><code>this</code>와 <code>applicationContext</code>가 겉보기엔 둘 다 &quot;Context 타입 객체&quot;라서 똑같아 보이지만, 실제로는 서로 다른 대상을 가리킨다. 이 차이가 왜 생기는지, 그리고 왜 중요한지를 이해하려면 Context가 내부적으로 어떻게 만들어지는지부터 봐야 한다.</p>
<h2 id="context는-어떻게-만들어지는가--상속이-아니라-조합">Context는 어떻게 만들어지는가 — 상속이 아니라 조합</h2>
<p>Activity가 Context의 기능(리소스 접근, 시스템 서비스 호출 등)을 쓸 수 있는 이유를 &quot;Activity가 Context를 상속받아서&quot;라고만 설명하면 절반만 맞은 얘기다. 더 정확히 들어가 보면, Activity는 <code>ContextThemeWrapper → ContextWrapper → Context</code> 순서로 상속하는 클래스다. 그런데 실제로 리소스를 읽고 시스템 서비스를 호출하는 진짜 구현체는 <code>ContextImpl</code>이라는 별도의 클래스다. <code>ContextImpl</code>도 Context를 구현하지만, Activity가 상속하는 클래스 계층과는 다른 갈래에 있다. 즉 Activity와 ContextImpl은 둘 다 &quot;Context 계열&quot;이지만 서로 부모-자식 관계가 아니라 형제뻘에 가깝다.</p>
<p>그러면 Activity는 어떻게 ContextImpl의 기능을 쓸 수 있을까? 여기서 등장하는 게 조합(composition) 구조다. <code>ContextWrapper</code>는 <code>mBase</code>라는 필드를 갖고 있는데, 이 필드에 실제 일을 처리하는 <code>ContextImpl</code> 인스턴스를 담아둔다. 그리고 <code>ContextWrapper</code>가 제공하는 메서드들(<code>getResources()</code>, <code>getSystemService()</code> 등)은 사실 자기가 직접 로직을 수행하는 게 아니라, 전부 <code>mBase</code>에 담긴 <code>ContextImpl</code>에게 그대로 위임(delegate)한다. Activity가 시스템에 의해 생성되는 시점에 프레임워크가 <code>attachBaseContext()</code>라는 메서드를 통해 이 <code>ContextImpl</code> 인스턴스를 Activity(정확히는 그 조상인 ContextWrapper)에게 심어준다.</p>
<p>비유하자면 이렇다. Activity는 &quot;리셉션 데스크&quot;이고, ContextImpl은 그 뒤에서 실제로 서류를 처리하는 &quot;직원&quot;이다. 손님(개발자 코드)이 리셉션 데스크에 요청을 하면, 데스크는 자기가 직접 서류를 처리하는 게 아니라 뒤에 있는 직원에게 그대로 넘기고, 직원이 처리한 결과를 다시 손님에게 전달해줄 뿐이다. &quot;Activity가 Context 기능을 상속받아서 쓴다&quot;보다는 &quot;Activity가 내부에 진짜 일꾼을 하나 데리고 있다가, 요청이 오면 그 일꾼에게 시킨다&quot;에 가까운 그림이다.</p>
<p>이 구조를 알고 나면 Activity Context와 Application Context가 왜 다른 인스턴스인지도 이해가 된다. Activity, Service, Application처럼 시스템이 개별적으로 생성하는 컴포넌트는 각각 자기 몫의 <code>ContextImpl</code>을 새로 발급받는다. 그래서 한 앱 안에 Activity가 여러 개 떠 있으면, 그만큼 서로 다른 <code>ContextImpl</code> 인스턴스가 존재하는 것이고, Application을 대표하는 Context는 또 별도의 인스턴스다. <code>this</code>(Activity Context)와 <code>applicationContext</code>(Application Context)가 다른 값을 가리키는 이유가 바로 이것이다.</p>
<pre><code class="language-kotlin">class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 서로 다른 컴포넌트가 서로 다른 ContextImpl을 갖고 있기 때문에
        // 아래 두 Context는 같은 객체가 아니다
        println(this == applicationContext) // false
    }
}</code></pre>
<h2 id="application-context-vs-activity-context-언제-뭘-써야-할까">Application Context vs Activity Context, 언제 뭘 써야 할까</h2>
<p>이 둘의 차이를 알고 나면 자연스럽게 세 가지 질문이 따라온다. 첫째, 무조건 Application Context를 쓰면 안 되는 이유가 뭘까. 둘째, Application Context가 지원하지 않는 기능은 뭘까. 셋째, 생명주기에 의존적인 Activity Context를 써야 하는 순간은 언제일까. 세 질문 모두 결국 같은 원리 하나로 답이 나온다 — Application Context는 UI 작업을 하도록 설계되지 않았다는 것이다.</p>
<p>Application Context는 앱이 실행되는 동안 하나만 존재하고, 특정 화면(윈도우)에 소속되어 있지 않다. 반면 다이얼로그를 띄우거나, 테마가 적용된 레이아웃을 inflate하거나, 화면 전환 애니메이션을 보여주는 작업은 전부 &quot;지금 화면에 떠 있는 특정 윈도우&quot;를 전제로 한다. 이 전제가 깨지면 문제가 생긴다. 가장 자주 겪는 예가 <code>AlertDialog</code>다.</p>
<pre><code class="language-kotlin">// 크래시가 나는 예시
fun showBadDialog(context: Context) {
    AlertDialog.Builder(context)
        .setTitle(&quot;알림&quot;)
        .setMessage(&quot;Application Context로 다이얼로그를 띄우면?&quot;)
        .show()
}

// Application Context를 넘기면 WindowManager.BadTokenException 발생
showBadDialog(applicationContext)

// Activity Context를 넘기면 정상 동작
showBadDialog(this) // this: Activity</code></pre>
<p><code>AlertDialog</code>는 특정 윈도우에 자신을 붙여야 화면에 표시할 수 있는데, Application Context에는 붙일 윈도우 자체가 없다. 그래서 <code>WindowManager.BadTokenException</code>이라는 예외가 발생한다. 마찬가지로 Application Context의 <code>LayoutInflater</code>로 뷰를 inflate하면, Activity에 적용된 테마(다크 모드, 커스텀 스타일 등)가 반영되지 않는 경우가 생긴다. Application Context는 앱 전역 테마만 알고 있지, 특정 Activity에 설정된 테마는 모르기 때문이다. 화면 전환도 마찬가지다. Application Context에서 <code>startActivity()</code>를 호출하려면 <code>FLAG_ACTIVITY_NEW_TASK</code> 플래그를 반드시 붙여야 하는데, 이는 새로운 태스크로 화면을 띄워야 하기 때문이다. 그 결과 원래 Activity에서 <code>startActivity()</code>를 호출했을 때와는 화면 전환 애니메이션이나 백스택 동작이 달라진다.</p>
<p>그래서 기준은 명확하다. 다이얼로그 표시, 레이아웃 inflate, 화면 전환처럼 &quot;지금 보이는 화면&quot;과 관련된 작업, 그리고 화면이 사라지면 같이 정리되어야 하는 짧은 수명의 작업은 Activity Context를 쓴다. 반대로 싱글턴 객체, 리포지토리, 데이터베이스 인스턴스처럼 앱이 켜져 있는 동안 계속 살아 있어야 하고 특정 화면에 종속되면 안 되는 작업은 Application Context를 쓴다. &quot;메모리 누수가 무서우니까 무조건 Application Context&quot;라는 규칙은 절반만 맞는 얘기다 — UI 작업에까지 그 규칙을 적용하면 크래시나 예상치 못한 동작으로 이어진다.</p>
<h2 id="context를-잘못-쓰면-생기는-문제--bad-practice-3가지">Context를 잘못 쓰면 생기는 문제 — Bad Practice 3가지</h2>
<p>첫 번째는 Activity Context를 전역으로 저장해두는 것이다. static 필드나 싱글턴 객체에 Activity Context(예: <code>this</code>)를 담아두면, 그 Activity가 화면에서 사라져 <code>onDestroy()</code>가 호출된 뒤에도 static 필드가 여전히 그 Activity를 참조하고 있기 때문에 가비지 컬렉터가 회수하지 못한다. Activity 하나가 통째로 메모리에 남으면 그 안에 딸려 있는 뷰 트리, 리소스까지 전부 함께 새어나간다(메모리 누수). 화면 하나를 들어갔다 나올 때마다 이 현상이 반복되면 앱이 점점 무거워지다가 결국 <code>OutOfMemoryError</code>로 죽는다.</p>
<p>두 번째는 앞서 다룬 것처럼 Application Context를 UI 작업에 무분별하게 쓰는 것이다. 메모리 누수를 피하려고 습관적으로 Application Context만 쓰다가, 다이얼로그가 필요한 순간에도 그대로 넘겨서 크래시를 만나는 경우다.</p>
<p>세 번째는 Context 객체를 직접 생성하려고 시도하는 것이다. Context는 추상 클래스이고, 실제 구현체인 <code>ContextImpl</code>은 프레임워크 내부에서만 다루는 클래스라서 애초에 개발자가 직접 인스턴스화할 방법이 마땅치 않다. 그래서 이 항목은 &quot;실수로 직접 만들다가 사고가 난다&quot;기보다는, Context가 필요할 때는 항상 시스템이 이미 만들어서 건네준 것(Activity 자신, <code>applicationContext</code>, <code>getSystemService()</code>가 반환하는 값 등)을 재사용해야 한다는 원칙으로 이해하는 게 맞다. 리플렉션이나 편법으로 Context를 새로 찍어내려는 시도는 애초에 안드로이드가 의도한 사용법이 아니다.</p>
<p>세 가지를 관통하는 공통점은 하나다. Context는 &quot;누가 만들었고 얼마나 오래 살아야 하는지&quot;가 이미 정해져 있는 객체이기 때문에, 그 수명과 용도를 무시하고 아무 데서나 아무거나 갖다 쓰면 메모리 누수나 크래시로 이어진다는 것이다.</p>
<h2 id="헷갈리는-포인트">헷갈리는 포인트</h2>
<p><strong>Context가 곧 Activity 아닌가?</strong> 아니다. Activity, Service, Application, BroadcastReceiver 등은 각각 자기만의 Context 인스턴스를 따로 갖고 있다. Activity는 Context를 쓸 수 있는 여러 컴포넌트 중 하나일 뿐이지, Context 자체와 동의어가 아니다.</p>
<p><strong>Application Context를 쓰면 무조건 안전한 거 아닌가?</strong> 아니다. 메모리 누수를 피하려고 무조건 Application Context를 쓰는 습관을 들이면, 다이얼로그 표시나 테마 적용 레이아웃 inflate 같은 UI 작업에서 크래시나 예상치 못한 동작을 만나게 된다. 수명이 짧고 UI와 관련된 작업은 Activity Context, 앱 전역에서 오래 살아야 하는 객체(싱글턴, 리포지토리 등)에 넘겨줄 Context는 Application Context, 이게 기준이다.</p>
<p><strong>Activity가 ContextImpl을 상속받아서 Context 기능을 쓰는 거 아닌가?</strong> 아니다. 앞서 봤듯 Activity(정확히는 그 조상 클래스인 ContextWrapper)는 ContextImpl을 상속하는 게 아니라, ContextImpl 인스턴스를 필드로 들고 있다가 모든 요청을 그쪽에 위임하는 구조다. 상속으로 기능을 물려받는 게 아니라, 내부에 진짜 일을 처리하는 담당자를 하나 두고 그 담당자에게 시키는 구조에 가깝다.</p>
<h2 id="정리">정리</h2>
<p>Context는 앱이 리소스와 시스템 서비스에 접근하는 창구이고, 그 실체는 상속이 아니라 조합 구조로 만들어진다 — Activity는 ContextImpl이라는 실제 일꾼을 내부에 담아두고 위임할 뿐이다. 이 구조를 알면 Activity Context와 Application Context가 왜 다른 인스턴스이고 왜 용도가 다른지가 자연스럽게 이해된다. static 필드에 Activity Context를 담아두거나, Application Context를 UI 작업에 무분별하게 쓰거나, Context를 직접 생성하려는 세 가지 실수만 피해도 Context 관련 메모리 누수와 크래시는 대부분 예방할 수 있다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Intent란 무엇인가 - 명시적 인텐트와 암시적 인텐트]]></title>
            <link>https://velog.io/@ju-unn/Android-Intent%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80-%EB%AA%85%EC%8B%9C%EC%A0%81-%EC%9D%B8%ED%85%90%ED%8A%B8%EC%99%80-%EC%95%94%EC%8B%9C%EC%A0%81-%EC%9D%B8%ED%85%90%ED%8A%B8</link>
            <guid>https://velog.io/@ju-unn/Android-Intent%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80-%EB%AA%85%EC%8B%9C%EC%A0%81-%EC%9D%B8%ED%85%90%ED%8A%B8%EC%99%80-%EC%95%94%EC%8B%9C%EC%A0%81-%EC%9D%B8%ED%85%90%ED%8A%B8</guid>
            <pubDate>Wed, 19 Aug 2026 06:31:23 GMT</pubDate>
            <description><![CDATA[<p>4대 컴포넌트가 서로 직접 참조하지 않고 Intent를 통해 상호작용한다고 정리했다. 이번 글에서는 그 Intent가 정확히 무엇이고, 명시적 인텐트와 암시적 인텐트가 어떻게 다른지를 다룬다.</p>
<h2 id="intent란-무엇인가">Intent란 무엇인가</h2>
<p>Intent는 안드로이드 컴포넌트끼리 상호작용할 때 사용하는 메시지 객체다. 앞서 4대 컴포넌트의 공통 특징에서 &quot;인텐트를 통해 서로 상호작용한다&quot;고 했는데, 그 인텐트가 바로 이것이다.</p>
<p>Intent를 쓰면 내 앱 안의 다른 Activity를 열 수도 있고, 다른 앱의 컴포넌트를 호출할 수도 있다. 예를 들면 이런 식이다.</p>
<ul>
<li>&quot;다른 Activity를 열어줘&quot;</li>
<li>&quot;이 메시지를 전달할거야&quot;</li>
<li>&quot;웹 페이지를 열어줘&quot;</li>
<li>&quot;문자 메시지 화면을 띄워줘&quot;</li>
<li>&quot;지도를 보여줘&quot;</li>
</ul>
<p>Intent가 왜 필요한지는 컴포넌트의 독립성과 연결해서 보면 이해하기 쉽다. 컴포넌트는 서로 직접 참조하지 않고 독립적으로 존재한다고 했는데, 그러면 A 화면에서 B 화면으로 어떻게 넘어갈까. 개발자가 <code>new</code>로 B Activity 인스턴스를 직접 만드는 게 아니라, &quot;B를 실행해줘&quot;라는 의도(Intent)를 시스템에 전달하면 시스템이 대신 B의 인스턴스를 만들고 실행해주는 방식이다. 그래서 Intent는 컴포넌트 사이를 이어주는 비동기 메시지라고 이해하면 된다.</p>
<h2 id="명시적-인텐트-vs-암시적-인텐트">명시적 인텐트 vs 암시적 인텐트</h2>
<p>Intent는 크게 두 종류로 나뉜다.</p>
<p><strong>명시적 인텐트(Explicit Intent)</strong>는 실행할 컴포넌트의 클래스명을 코드에서 직접 지정하는 방식이다. 주로 내 앱 안의 다른 Activity를 실행할 때 쓴다.</p>
<pre><code class="language-kotlin">val intent = Intent(this, NewActivity::class.java)
startActivity(intent)</code></pre>
<p><strong>암시적 인텐트(Implicit Intent)</strong>는 &quot;무엇을 하고 싶다&quot;는 동작만 명시하고, 그 동작을 실제로 어떤 앱이 처리할지는 시스템(OS)이 결정하게 하는 방식이다. 주로 다른 앱의 기능을 빌려 쓸 때 사용한다.</p>
<pre><code class="language-kotlin">val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(&quot;https://woowacourse.github.io/&quot;)
startActivity(intent)</code></pre>
<p>위 코드는 &quot;이 URL을 보여줘(ACTION_VIEW)&quot;라는 의도만 전달할 뿐, 어떤 앱이 열지는 지정하지 않는다. 시스템은 이 URL을 처리할 수 있는 앱(보통 브라우저)을 찾아서 대신 실행해준다. 만약 이 URL을 처리할 수 있는 앱이 여러 개 설치되어 있다면 사용자에게 선택 창을 띄워주기도 한다.</p>
<p>이 둘을 &quot;내 앱이면 명시적, 남의 앱이면 암시적&quot;이라고 외우면 절반만 맞는 설명이다. 더 정확한 구분 기준은 &quot;내가 실행할 컴포넌트의 클래스를 정확히 알고 있는가&quot;이다. 명시적 인텐트는 클래스명을 안다는 전제가 있어야 쓸 수 있고, 그 조건을 만족하는 경우가 대부분 내 앱 내부이다 보니 결과적으로 &quot;명시적=내 앱, 암시적=남의 앱&quot;이라는 경향이 생기는 것뿐이다. 실제로 앱 내부 화면 전환에는 명시적 인텐트가 압도적으로 많이 쓰이고, 암시적 인텐트는 &quot;다른 앱의 기능을 빌려 쓴다&quot;는 상황에 주로 쓰인다.</p>
<h2 id="헷갈리는-포인트">헷갈리는 포인트</h2>
<p><strong>명시적/암시적 인텐트 구분을 &quot;내 앱이냐 남의 앱이냐&quot;로만 외우면 절반만 맞는다.</strong> 정확한 기준은 &quot;실행할 컴포넌트의 클래스명을 내가 직접 아는가&quot;이다. 결과적으로 명시적은 대부분 내 앱 내부에, 암시적은 다른 앱 호출에 쓰이지만 개념적인 기준 자체는 &quot;안다/모른다&quot;에 있다는 걸 기억해두자.</p>
<h2 id="마무리-요약">마무리 요약</h2>
<p>이번 글에서는 컴포넌트끼리 서로를 직접 참조하지 않고 Intent라는 메시지를 통해 상호작용한다는 것, 그리고 실행할 컴포넌트를 직접 지정하는 명시적 인텐트와 동작만 지정하는 암시적 인텐트로 나뉜다는 것을 정리했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Activity 기본기 - 선언 방법과 최초 실행 흐름]]></title>
            <link>https://velog.io/@ju-unn/Activity-%EA%B8%B0%EB%B3%B8%EA%B8%B0-%EC%84%A0%EC%96%B8-%EB%B0%A9%EB%B2%95%EA%B3%BC-%EC%B5%9C%EC%B4%88-%EC%8B%A4%ED%96%89-%ED%9D%90%EB%A6%84</link>
            <guid>https://velog.io/@ju-unn/Activity-%EA%B8%B0%EB%B3%B8%EA%B8%B0-%EC%84%A0%EC%96%B8-%EB%B0%A9%EB%B2%95%EA%B3%BC-%EC%B5%9C%EC%B4%88-%EC%8B%A4%ED%96%89-%ED%9D%90%EB%A6%84</guid>
            <pubDate>Wed, 19 Aug 2026 06:29:24 GMT</pubDate>
            <description><![CDATA[<p>4대 컴포넌트 중 Activity가 사용자와 상호작용하는 화면 단위 컴포넌트라고 소개했다. 이번 글에서는 Activity가 정확히 어떤 일을 하는지, 어떻게 선언하는지, 그리고 앱을 처음 실행했을 때 어떤 순서로 화면이 뜨는지를 자세히 다룬다.</p>
<h2 id="activity---사용자와-상호작용하는-컴포넌트">Activity - 사용자와 상호작용하는 컴포넌트</h2>
<p>Activity는 4대 컴포넌트 중 개발을 시작하면서 가장 먼저, 그리고 가장 자주 마주치는 컴포넌트다. 공식 문서는 Activity 클래스를 &quot;안드로이드 앱의 핵심적인 구성요소&quot;라고 설명하는데, 실제로 하는 일을 정리하면 다음과 같다.</p>
<ol>
<li><strong>UI를 화면에 노출하고 데이터를 처리한다.</strong> 사용자가 보는 화면 하나하나가 보통 Activity 단위로 관리된다.</li>
<li><strong>사용자의 이벤트를 처리한다.</strong> 버튼 터치, 텍스트 입력 같은 사용자 동작에 반응하는 코드가 Activity 안에 들어간다.</li>
<li><strong>새로운 Activity를 시작한다.</strong> 화면 전환, 즉 다음 화면으로 넘어가는 동작도 Activity가 담당한다.</li>
</ol>
<p>여기서 한 가지 짚고 넘어갈 오해가 있다. &quot;액티비티 = 화면 전체&quot;라고 생각하기 쉬운데, 정확히는 액티비티는 &quot;화면 하나를 책임지는 컨트롤러&quot;에 가깝다. 실제로 화면에 그려지는 텍스트, 버튼, 이미지 같은 UI 요소는 View나 ViewGroup(레이아웃)이 담당하고, Activity는 그 View들을 붙잡고 생명주기와 이벤트를 관리하는 역할을 한다. 심지어 Activity 하나 안에 Fragment를 여러 개 넣어서 화면을 더 잘게 쪼갤 수도 있다. 그래서 &quot;액티비티 하나 = 화면 하나&quot;가 항상 딱 맞아떨어지는 공식은 아니다.</p>
<p>또 하나 중요한 개념이 있다. 안드로이드에는 &quot;앱 전체를 실행한다&quot;는 개념 자체가 없다. 우리가 흔히 &quot;카카오톡을 실행한다&quot;고 말하지만, 실제로 안드로이드 시스템 입장에서는 카카오톡 앱의 특정 Activity(보통 <code>LAUNCHER</code>로 지정된 Activity) 하나를 실행하는 것이다. 데스크톱 프로그램처럼 프로그램 전체가 통째로 켜지는 방식이 아니라, 항상 &quot;어떤 Activity를 실행할지&quot;를 지정해서 그 Activity를 통해 앱에 들어가는 구조라고 이해하면 된다.</p>
<h2 id="activity-선언-방법">Activity 선언 방법</h2>
<p>Activity를 쓰려면 두 가지가 필요하다. 하나는 <code>AppCompatActivity</code>를 상속받는 Kotlin 클래스를 만드는 것이고, 다른 하나는 그 클래스를 매니페스트(<code>AndroidManifest.xml</code>)에 등록하는 것이다.</p>
<p>먼저 클래스 코드다.</p>
<pre><code class="language-kotlin">class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}</code></pre>
<p><code>onCreate()</code>를 오버라이드하고, 그 안에서 <code>setContentView()</code>로 이 Activity가 어떤 레이아웃 파일을 사용할지 연결해준다. <code>activity_main.xml</code> 같은 XML 레이아웃 파일에 실제 화면 구성(버튼, 텍스트뷰 등)이 들어 있고, <code>setContentView()</code>가 그걸 이 Activity 화면에 붙여주는 역할을 한다.</p>
<p>다음은 매니페스트 등록이다.</p>
<pre><code class="language-xml">&lt;activity
    android:name=&quot;.MainActivity&quot;
    android:exported=&quot;true&quot;&gt;
    &lt;intent-filter&gt;
        &lt;action android:name=&quot;android.intent.action.MAIN&quot; /&gt;
        &lt;category android:name=&quot;android.intent.category.LAUNCHER&quot; /&gt;
    &lt;/intent-filter&gt;
&lt;/activity&gt;</code></pre>
<p><code>intent-filter</code> 안의 <code>MAIN</code> 액션과 <code>LAUNCHER</code> 카테고리 조합이 바로 &quot;이 Activity가 앱을 처음 켰을 때 실행되는 진입점 화면이다&quot;라는 표시다. 위에서 &quot;앱을 실행한다&quot;는 게 결국 &quot;LAUNCHER로 지정된 Activity를 실행한다&quot;는 뜻이라고 했는데, 그 지정이 바로 이 부분에서 이루어진다.</p>
<p><code>android:exported=&quot;true&quot;</code>는 처음 보면 왜 있는지 헷갈릴 수 있는 속성이다. 이건 &quot;이 컴포넌트를 다른 앱에서도 실행할 수 있게 허용할지&quot;를 정하는 값이다. 안드로이드 12(API 31) 이상을 타겟팅하는 앱은 <code>intent-filter</code>가 있는 Activity, Service, Broadcast Receiver에 대해 이 값을 반드시 명시적으로 적어야 하고, 생략하면 빌드 자체가 실패한다. <code>MainActivity</code>처럼 런처(홈 화면 앱)가 실행해줘야 하는 Activity는 <code>true</code>로, 앱 내부에서만 쓰이고 외부에서 직접 실행될 필요가 없는 컴포넌트는 <code>false</code>로 설정하는 게 원칙이다.</p>
<h2 id="activity-생명주기-콜백은-언제-호출될까">Activity 생명주기 콜백은 언제 호출될까</h2>
<p>일반적인 프로그램은 코드를 위에서 아래로 순서대로 실행하지만, Activity는 다르다. 안드로이드 시스템이 생명주기의 각 단계에 맞춰 정해진 콜백 메서드를 호출해주고, 개발자는 그 콜백 안에 원하는 코드를 채워 넣는 방식으로 동작한다. 공식 문서 표현을 빌리면 &quot;안드로이드 시스템은 생명주기의 특정 단계에 해당하는 콜백 메서드를 호출하여 Activity 인스턴스에서 코드를 시작한다.&quot;</p>
<p>앱을 처음 실행했을 때를 기준으로, 호출되는 순서와 각 시점에서 벌어지는 일을 정리하면 다음과 같다.</p>
<ul>
<li><strong><code>onCreate()</code></strong> — Activity 인스턴스가 처음 생성될 때 딱 한 번 호출된다. 여기서 <code>setContentView()</code>로 레이아웃을 연결하고, 뷰를 초기화하는 등 &quot;최초 설정&quot;을 담당한다.</li>
<li><strong><code>onStart()</code></strong> — Activity가 화면에 보이기 시작하는 시점에 호출된다. 다만 이 시점에는 아직 사용자가 터치 같은 조작을 직접 할 수 있는 상태는 아니다.</li>
<li><strong><code>onResume()</code></strong> — <code>onStart()</code> 직후 곧바로 호출되며, 이 시점부터 Activity가 화면 맨 앞(foreground)에서 사용자와 실제로 상호작용 가능한 상태(Resumed)가 된다.</li>
</ul>
<p>정리하면 최초 실행 흐름은 <strong>onCreate → onStart → onResume</strong> 순서다. 여기서 한 가지 자주 하는 오해를 짚고 싶다. <code>onCreate()</code>가 &quot;앱을 켤 때마다 매번&quot; 호출된다고 생각하기 쉬운데, 정확히는 Activity 인스턴스가 &quot;새로 생성될 때&quot; 딱 한 번만 호출되는 콜백이다. 예를 들어 앱을 백그라운드로 보냈다가 다시 돌아오는 경우처럼 Activity 인스턴스가 살아있는 상태로 재개되는 상황에서는 <code>onCreate()</code>가 다시 불리지 않고 <code>onStart()</code>, <code>onResume()</code>만 다시 호출될 수 있다. 이 재개(resume) 흐름과 종료 흐름(<code>onPause</code>, <code>onStop</code>, <code>onDestroy</code>)은 내용이 꽤 많아서 2-2편(Activity 생명주기)에서 따로 다루고, 이번 글에서는 &quot;최초 생성 시 onCreate가 한 번만 불린다&quot;는 점만 확실히 짚고 넘어가면 충분하다.</p>
<p>또, &quot;액티비티 2개를 동시에 화면에 띄울 수 없다&quot;는 설명을 종종 보게 되는데, 이건 정확히는 절반만 맞는 말이다. 일반적인 단일 창 환경에서는 맞지만, 안드로이드 7.0(Nougat) 이상이 지원하는 분할 화면(split-screen) 멀티 윈도우 모드에서는 두 개의 Activity가 동시에 화면에 &quot;보일&quot; 수 있다. 다만 그중에서 실제로 사용자 입력을 받는(Resumed 상태인) Activity는 하나뿐이고, 나머지는 화면에 보이기만 할 뿐 Paused 상태로 대기한다. 그래서 &quot;동시에 화면에 뜰 수 없다&quot;보다는 &quot;동시에 완전히 활성화(포커스)될 수 있는 Activity는 하나뿐이다&quot;라고 이해하는 게 더 정확하다.</p>
<h2 id="헷갈리는-포인트">헷갈리는 포인트</h2>
<p><strong>&quot;액티비티 = 화면 전체&quot;라는 오해</strong>는 정확하지 않다. 액티비티는 화면 하나를 담당하는 컨트롤러이고, 실제 UI는 View/ViewGroup이 그린다. Fragment로 화면을 쪼갤 수도 있으니 &quot;액티비티 하나 = 화면 하나&quot;가 항상 1:1은 아니다.</p>
<p><strong>&quot;앱을 실행한다&quot;와 &quot;액티비티를 실행한다&quot;는 사실 같은 말이다.</strong> 안드로이드에는 앱 전체를 통째로 실행하는 개념이 없고, 항상 특정 액티비티(보통 LAUNCHER 카테고리가 붙은 것)를 실행하는 방식으로 동작한다는 점이 데스크톱 프로그램과의 근본적인 차이다.</p>
<p><strong><code>onCreate()</code>가 앱을 켤 때마다 매번 호출된다는 오해</strong>도 흔하다. <code>onCreate()</code>는 액티비티 인스턴스가 &quot;새로 생성될 때&quot; 한 번만 불리는 콜백이고, 백그라운드에서 돌아왔을 때는 <code>onStart()</code>, <code>onResume()</code>만 다시 호출될 수 있다. 자세한 재개 흐름은 2-2편에서 살펴본다.</p>
<h2 id="마무리-요약">마무리 요약</h2>
<p>이번 글에서는 Activity가 사용자와 상호작용하는 화면 단위 컴포넌트로서 어떤 일을 하는지, <code>onCreate()</code>에서 레이아웃을 연결하고 매니페스트에 등록하는 과정으로 어떻게 선언하는지를 정리했다. 최초 실행 시에는 <code>onCreate → onStart → onResume</code> 순서로 콜백이 호출되며, 이 콜백들은 개발자가 직접 호출하는 게 아니라 시스템이 생명주기 단계에 맞춰 대신 호출해준다는 점이 일반적인 프로그램 실행 방식과 가장 다른 부분이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 안드로이드 4대 컴포넌트란 무엇인가]]></title>
            <link>https://velog.io/@ju-unn/Android-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-4%EB%8C%80-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80</link>
            <guid>https://velog.io/@ju-unn/Android-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-4%EB%8C%80-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80</guid>
            <pubDate>Wed, 19 Aug 2026 06:19:38 GMT</pubDate>
            <description><![CDATA[<p>안드로이드 개발을 처음 공부하다 보면 &quot;컴포넌트&quot;라는 단어를 정말 자주 만난다. 나도 처음에는 이게 그냥 &quot;클래스&quot;를 어렵게 부르는 말인 줄 알았는데, 공부를 하면서 그게 아니라는 걸 알게 됐다. 이번 글에서는 안드로이드 앱을 이루는 4가지 핵심 구성요소가 무엇인지, 그리고 이 4개가 공통으로 갖는 성격이 무엇인지를 정리한다. 그중 가장 비중이 큰 Activity와 컴포넌트 간 통신 수단인 Intent는 각각 1-2편, 1-3편에서 따로 자세히 다룬다.</p>
<h2 id="안드로이드-4대-컴포넌트란-무엇인가">안드로이드 4대 컴포넌트란 무엇인가</h2>
<p>컴포넌트(Component)는 말 그대로 &quot;구성요소&quot;라는 뜻이다. 안드로이드 4대 컴포넌트란 안드로이드 앱을 만드는 데 필요한 4개의 핵심 구성요소를 말하며, 다음과 같다.</p>
<ul>
<li><strong>액티비티(Activity)</strong> — 사용자와 상호작용하는 화면 단위 컴포넌트</li>
<li><strong>서비스(Service)</strong> — 백그라운드에서 오래 걸리는 작업을 처리하는 컴포넌트</li>
<li><strong>방송 수신자(Broadcast Receiver)</strong> — 시스템이나 다른 앱이 보내는 이벤트를 수신하는 컴포넌트</li>
<li><strong>콘텐트 제공자(Content Provider)</strong> — 앱의 데이터를 다른 앱과 공유할 수 있게 관리하는 컴포넌트</li>
</ul>
<p>안드로이드 공식 문서는 이 4가지를 &quot;앱의 필수 구성요소이며, 각각은 시스템이나 앱으로 들어갈 수 있는 진입점(entry point)&quot;이라고 설명한다. 여기서 &quot;진입점&quot;이라는 표현이 핵심이다. 일반적인 프로그램은 <code>main()</code> 함수 하나에서 시작해서 순차적으로 실행되지만, 안드로이드 앱은 그렇지 않다. 시스템이나 사용자, 혹은 다른 앱이 이 4개 컴포넌트 중 하나를 호출하면 그 컴포넌트가 살아나면서 앱의 특정 부분이 실행된다. 즉 안드로이드 앱에는 정해진 &quot;시작 지점&quot; 하나가 없고, 여러 개의 입구가 동시에 존재하는 셈이다.</p>
<h2 id="4대-컴포넌트의-공통-특징">4대 컴포넌트의 공통 특징</h2>
<p>4개 컴포넌트는 각자 하는 일은 다르지만, 아래 세 가지 특징을 공통으로 가진다.</p>
<p><strong>첫째, 각 컴포넌트는 독립적으로 존재한다.</strong> Activity 하나가 죽어도 Service는 계속 돌아갈 수 있고, 반대의 경우도 마찬가지다. 서로 강하게 묶여 있지 않다.</p>
<p><strong>둘째, 각 컴포넌트는 고유의 기능을 수행한다.</strong> Activity는 화면을 보여주는 일, Service는 백그라운드 작업, Broadcast Receiver는 이벤트 수신, Content Provider는 데이터 공유라는 역할이 명확히 분리되어 있다. 그래서 &quot;이 작업을 어디에 구현해야 하나&quot;를 고민할 때 이 역할 구분이 기준이 된다.</p>
<p><strong>셋째, 각 컴포넌트는 인텐트(Intent)를 통해 서로 상호작용한다.</strong> 컴포넌트끼리 서로를 직접 <code>new</code>로 생성해서 호출하는 게 아니라, Intent라는 메시지 객체를 시스템에 던지면 시스템이 알맞은 컴포넌트를 찾아 실행해주는 방식이다. Intent가 정확히 뭔지는 1-3편에서 자세히 다룬다.</p>
<h2 id="activity를-제외한-세-컴포넌트-훑어보기">Activity를 제외한 세 컴포넌트 훑어보기</h2>
<p>Activity는 비중이 커서 1-2편에서 따로 다루고, 여기서는 나머지 세 컴포넌트를 간단히만 짚고 넘어간다.</p>
<p><strong>서비스(Service)</strong>는 사용자와 직접 상호작용하지 않고 백그라운드에서 작업을 처리하는 컴포넌트다. Activity가 화면에서 사라져도 Service는 계속 동작할 수 있다는 점이 특징이다. Service는 크게 포그라운드 서비스와 백그라운드 서비스로 나뉘는데, 포그라운드 서비스는 사용자가 실행 중임을 알 수 있도록 알림(notification) 표시가 필수다. 한 가지 오해하기 쉬운 부분은, Service가 &quot;무조건 별도 스레드에서 돈다&quot;고 생각하는 것이다. Service는 기본적으로 앱의 메인 스레드에서 실행되며 별도 프로세스가 아니다. 오래 걸리는 작업을 Service 안에서 처리하려면 개발자가 직접 스레드를 분리해줘야 한다.</p>
<p>작업 예약과 관련해서는 <strong>WorkManager</strong>와 <strong>AlarmManager</strong>를 구분해서 알아둘 필요가 있다. &quot;즉시 실행이면 WorkManager, 지연 실행이면 AlarmManager&quot;라고 알고 있는 경우가 있는데, 공식 가이드 기준으로는 이보다는 다음 기준으로 나누는 게 정확하다. WorkManager는 &quot;정확히 언제&quot;보다는 &quot;반드시 실행되긴 해야 하는&quot; 지연 가능한(deferrable) 작업, 예를 들어 서버로 로그를 올리거나 데이터를 동기화하는 작업에 권장된다. 배터리나 시스템 상황을 고려해서 적절한 시점에 실행해준다. 반면 AlarmManager는 알람 시계나 특정 시각 알림처럼 &quot;정확히 지정한 시각&quot;에 실행되어야 하는 작업에 권장되며, 기기가 절전 모드에 들어가 있어도 깨워서 실행할 수 있다. 즉 &quot;시점이 유연해도 되지만 실행 보장이 중요한가(WorkManager)&quot; 대 &quot;정확한 시각이 중요한가(AlarmManager)&quot;로 나누는 게 더 정확한 기준이다.</p>
<p><strong>방송 수신자(Broadcast Receiver)</strong>는 안드로이드 OS나 다른 앱이 보내는 이벤트(부팅 완료, 배터리 부족, 네트워크 끊김 등)를 수신해서 처리하는 컴포넌트다. 거의 UI를 갖지 않는다는 게 특징이다. 다만 한 가지 최근 제약을 알아두면 좋은데, 안드로이드 8.0(Oreo) 이상을 타겟팅하는 앱은 대부분의 암시적 브로드캐스트를 매니페스트에 등록하는 방식으로는 받을 수 없다(백그라운드 실행을 제한하기 위한 정책이다). <code>ACTION_BOOT_COMPLETED</code>(부팅 완료)처럼 일부 예외를 빼면, 앱이 실제로 실행 중일 때만 동작하는 &quot;컨텍스트 등록 리시버&quot; 방식을 써야 한다. &quot;매니페스트에 등록만 하면 어떤 브로드캐스트든 항상 받을 수 있다&quot;고 생각하면 안 되는 이유가 여기에 있다. (이 컴포넌트는 8편에서 훨씬 자세히 다룬다.)</p>
<p><strong>콘텐트 제공자(Content Provider)</strong>는 앱이 관리하는 데이터(SQLite DB, 파일, 웹 데이터 등)를 다른 앱과 공유할 수 있게 해주는 컴포넌트다. <code>ContentResolver</code>를 통해 query, insert, update, delete라는 4가지 메서드로 CRUD(생성/조회/수정/삭제)를 수행하는 원칙을 따른다. 다른 앱이 내 데이터에 함부로 접근하지 못하도록 매니페스트에 읽기/쓰기 권한(<code>android:readPermission</code>, <code>android:writePermission</code>)을 선언해서 접근을 제어할 수 있다.</p>
<h2 id="헷갈리는-포인트">헷갈리는 포인트</h2>
<p><strong>&quot;컴포넌트&quot;와 &quot;클래스&quot;를 같은 것으로 오해하기 쉽다.</strong> Activity나 Service는 그냥 상속받아서 쓰는 일반 클래스가 아니라, 안드로이드 OS가 생명주기를 직접 관리해주는 &quot;진입점&quot;이라는 점이 일반 자바/코틀린 클래스와 근본적으로 다르다. 개발자가 직접 <code>MainActivity()</code>처럼 인스턴스를 생성하지 않고, 시스템이 Intent를 받아서 대신 인스턴스를 만들어준다는 점을 기억하면 다른 헷갈림도 자연스럽게 풀린다.</p>
<p><strong>Service는 무조건 별도 스레드에서 돈다는 오해</strong>도 실무에서 흔히 하는 착각이다. Service 자체는 기본적으로 메인 스레드에서 실행되므로, 오래 걸리는 작업이라면 개발자가 직접 스레드를 분리해야 한다.</p>
<h2 id="마무리-요약">마무리 요약</h2>
<p>이번 글에서는 안드로이드 앱을 이루는 4대 컴포넌트(Activity, Service, Broadcast Receiver, Content Provider)가 각각 무엇이고, 왜 &quot;진입점&quot;이라고 불리는지 정리했다. 4개 컴포넌트는 독립적으로 존재하면서 각자 고유한 역할을 수행하고, Intent라는 메시지를 통해 서로 연결된다는 공통점이 있었다. Service, Broadcast Receiver, Content Provider의 기본 개념도 함께 훑었다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Compose의 특징, 장단점 그리고 Jetpack과의 관계]]></title>
            <link>https://velog.io/@ju-unn/Compose%EC%9D%98-%ED%8A%B9%EC%A7%95-%EC%9E%A5%EB%8B%A8%EC%A0%90-%EA%B7%B8%EB%A6%AC%EA%B3%A0-Jetpack%EA%B3%BC%EC%9D%98-%EA%B4%80%EA%B3%84</link>
            <guid>https://velog.io/@ju-unn/Compose%EC%9D%98-%ED%8A%B9%EC%A7%95-%EC%9E%A5%EB%8B%A8%EC%A0%90-%EA%B7%B8%EB%A6%AC%EA%B3%A0-Jetpack%EA%B3%BC%EC%9D%98-%EA%B4%80%EA%B3%84</guid>
            <pubDate>Sun, 16 Aug 2026 08:04:00 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>Jetpack이 뭔지, Compose가 뭔지, State/remember와 리컴포지션까지 정리했으니 이번 글에서는 지금까지 본 내용을 특징으로 묶어보고, 장단점과 Jetpack 전체와의 관계까지 정리하면서 마무리하려고 한다.</p>
<h2 id="compose의-주요-특징">Compose의 주요 특징</h2>
<p>관심사 분리 측면에서는 UI와 로직을 모두 Kotlin으로 작성하기 때문에, XML과 코드 사이에 있던 암묵적인 의존 관계가 명시적으로 드러난다.</p>
<p>동적 UI 구성 측면에서는 if문이나 반복문 같은 언어 기능을 그대로 활용해 상태에 반응하는 화면을 구성할 수 있다. XML에서는 조건 분기를 표현하려면 뷰를 미리 다 만들어두고 visibility를 코드에서 바꾸는 식으로 우회해야 했는데, Compose에서는 Kotlin 코드로 그대로 표현하면 된다.</p>
<pre><code class="language-kotlin">@Composable
fun Greeting(isLoggedIn: Boolean) {
    if (isLoggedIn) {
        Text(&quot;환영합니다&quot;)
    } else {
        Text(&quot;로그인이 필요합니다&quot;)
    }
}</code></pre>
<p>성능 측면에서는 앞선 글에서 정리한 리컴포지션, 위치 기반 메모이제이션, Slot Table 덕분에 바뀐 부분만 다시 그린다.</p>
<p>테스트 측면에서는 Composable 함수가 입력(상태)에 따라 출력(화면)이 정해지는 구조에 가깝기 때문에 단위 테스트를 작성하기 수월하다.</p>
<h2 id="장점과-단점">장점과 단점</h2>
<p>장점은 관심사 분리, 동적 UI 구성의 편의성, 성능 최적화, 테스트 용이성이다. 코드량도 줄고 유지보수도 상대적으로 쉬워진다. Navigation, ViewModel, 코루틴 같은 기존 Jetpack 라이브러리와도 자연스럽게 함께 쓸 수 있다.</p>
<p>단점은 학습 곡선이다. Slot Table, 리컴포지션, 메모이제이션 같은 내부 동작이나 State와 remember를 이용한 상태 관리 개념을 새로 익혀야 한다. 이번에 직접 코드를 짜보면서도 remember 하나 이해하는 데 세 가지 버전을 비교해봐야 했을 정도였다. 또한 이미 XML로 만들어진 대규모 기존 프로젝트라면 한 번에 Compose로 전환하기 어렵기 때문에, 기존 View와 Compose를 함께 쓰면서 점진적으로 마이그레이션하는 전략이 필요하다.</p>
<h2 id="jetpack과-compose의-관계">Jetpack과 Compose의 관계</h2>
<p>정리하면 Jetpack은 안드로이드 개발 전반을 돕는 라이브러리 모음이고, Compose는 그 안에서 UI를 만드는 방식을 담당하는 라이브러리다. Compose를 쓴다고 해서 ViewModel이나 Room 같은 다른 Jetpack 라이브러리를 안 쓰는 게 아니라, 오히려 이런 라이브러리들과 조합해서 쓰는 경우가 대부분이다. ViewModel이 상태를 들고 있고 Compose 화면이 그 상태를 구독해서 그리는 구조가 실무에서 가장 흔하게 쓰이는 패턴이라고 한다.</p>
<h2 id="마무리">마무리</h2>
<p>Jetpack이 라이브러리 모음이라는 것부터 시작해서, 그 안의 대표 라이브러리들, Compose의 선언형 UI, State와 remember, 리컴포지션과 Slot Table까지 순서대로 정리해봤다. 개념만 읽었을 때는 잘 와닿지 않던 부분들(특히 remember의 필요성, &quot;바뀐 부분만 다시 그린다&quot;는 말)이 직접 코드를 짜고 Logcat으로 확인해보면서 훨씬 명확해졌다. 다음에 Compose로 화면을 만들 때는 이번에 확인한 것처럼 State/remember의 스코프와 리컴포지션 범위를 의식하면서 짜야겠다는 생각이 들었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Recomposition이란 - Slot Table과 Gap Buffer]]></title>
            <link>https://velog.io/@ju-unn/Android-Recomposition%EC%9D%B4%EB%9E%80-Slot-Table%EA%B3%BC-Gap-Buffer</link>
            <guid>https://velog.io/@ju-unn/Android-Recomposition%EC%9D%B4%EB%9E%80-Slot-Table%EA%B3%BC-Gap-Buffer</guid>
            <pubDate>Sun, 16 Aug 2026 08:03:25 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서 remember가 있어야 리컴포지션이 일어나도 값이 유지된다는 것까지 확인했다. 그런데 리컴포지션이라는 말 자체를 좀 더 정확히 알아야 할 것 같아서, 이번엔 리컴포지션의 동작 원리와 그걸 가능하게 하는 내부 구조까지 정리해봤다.</p>
<h2 id="리컴포지션이란">리컴포지션이란</h2>
<p>State 값이 바뀌면 Composable 함수가 다시 실행되면서 화면이 갱신된다. 이렇게 상태 변화에 따라 Composable 함수를 다시 실행해서 화면을 다시 그리는 과정을 리컴포지션이라고 부른다. 카운터 앱에서 버튼을 눌러 count가 1 늘어나면, count를 보여주던 Text 부분이 리컴포지션을 거쳐 새 값으로 다시 그려지는 식이다.</p>
<p>문제는 화면 하나에 Composable이 수십, 수백 개 있을 수 있다는 점이다. 값 하나가 바뀔 때마다 화면 전체를 처음부터 다시 그리면 느려지고 배터리도 많이 먹는다. 그래서 Compose는 바뀐 부분만 골라서 다시 그리려고 하는데, 이때 쓰는 기법이 메모이제이션(Memoization)이다.</p>
<h2 id="위치-기반-메모이제이션">위치 기반 메모이제이션</h2>
<p>메모이제이션은 한 번 계산한 결과를 저장해두었다가 같은 입력이 다시 들어오면 계산을 반복하지 않고 저장해둔 결과를 재사용하는 최적화 기법이다. 피보나치수를 재귀로 구할 때 이미 구한 값을 저장해두면 같은 계산을 건너뛸 수 있는 것과 같은 원리다.</p>
<p>Compose는 값 자체가 아니라 &quot;이 Composable이 코드 안에서 어느 위치에서, 어떤 순서로 호출됐는지&quot;를 기준으로 이전 결과를 재사용할지 판단한다. 그래서 위치 기반 메모이제이션(Positional Memoization)이라고 부른다. 위치가 같고 입력값도 그대로면 다시 계산하지 않고 이전 결과를 그대로 화면에 쓴다.</p>
<h2 id="slot-table과-gap-buffer">Slot Table과 Gap Buffer</h2>
<p>이 위치 기반 메모이제이션을 실제로 가능하게 해주는 내부 자료구조가 Slot Table이다.</p>
<p>Slot Table은 Compose가 화면을 그릴 때마다 &quot;어떤 Composable 함수가 어떤 순서로 호출됐고, 그때 어떤 값을 가지고 있었는지&quot;를 기록해두는 자료구조다. 공연장 좌석표를 떠올리면 이해하기 쉬웠다. 좌석표에 몇 번 자리에 누가 앉았는지 적어두면, 다음번에 자리를 다시 확인할 때 처음부터 모든 좌석을 돌아보지 않고 표만 보고도 바뀐 자리를 바로 찾을 수 있다. Slot Table도 마찬가지로, 이전 리컴포지션 때 기록해둔 정보를 보고 실제로 값이 바뀐 Composable만 골라낸다.</p>
<p>이 Slot Table은 갭 버퍼(Gap Buffer)라는 자료구조를 기반으로 만들어져 있다. Gap Buffer는 원래 텍스트 에디터에서 문자를 삽입/삭제할 때 자주 쓰이는 자료구조다. 배열 중간에 비어 있는 공간(gap)을 미리 마련해두고, 데이터를 추가/삭제할 때 이 gap 근처에서만 처리하기 때문에 배열 전체를 앞뒤로 밀어내지 않고도 빠르게 삽입/삭제할 수 있다. Compose 화면에서도 리스트 항목이 추가/삭제되는 것처럼 Composable이 새로 생기거나 없어지는 상황이 자주 발생하는데, Slot Table이 Gap Buffer 구조를 쓰는 이유도 이런 삽입/삭제를 효율적으로 처리하기 위해서다.</p>
<h2 id="바뀐-부분만-다시-그린다는-걸-직접-확인해보고-싶었다">&quot;바뀐 부분만 다시 그린다&quot;는 걸 직접 확인해보고 싶었다</h2>
<p>개념은 이해했는데, &quot;화면 전체가 아니라 바뀐 부분만 다시 그린다&quot;는 말을 실제로 눈으로 보고 싶어서 독립된 카운터 두 개를 만들어봤다.</p>
<pre><code class="language-kotlin">@Composable
fun ScopedCounters(modifier: Modifier = Modifier) {
    Column(modifier) {
        CounterA()
        CounterB()
    }
}

@Composable
private fun CounterA(modifier: Modifier = Modifier) {
    var count by remember { mutableStateOf(0) }
    Log.d(TAG, &quot;CounterA 재구성됨 count=$count&quot;)

    Column(modifier) {
        Text(&quot;A: $count&quot;)
        Button(onClick = { count++ }) { Text(&quot;+1&quot;) }
    }
}

@Composable
private fun CounterB(modifier: Modifier = Modifier) {
    var count by remember { mutableStateOf(0) }
    Log.d(TAG, &quot;CounterB 재구성됨 count=$count&quot;)

    Column(modifier) {
        Text(&quot;B: $count&quot;)
        Button(onClick = { count++ }) { Text(&quot;+1&quot;) }
    }
}</code></pre>
<p><code>CounterA</code>와 <code>CounterB</code>는 각각 자기 자신의 <code>remember</code> 상태를 들고 있는 별개의 Composable이다. Logcat을 &quot;Recomposition&quot; 태그로 필터링해두고 A의 +1 버튼만 눌러봤다.</p>
<p>찍히는 로그는 &quot;CounterA 재구성됨 count=1&quot; 한 줄뿐이었다. B는 화면에 그대로 떠 있는데도 로그가 전혀 찍히지 않았다. B의 +1을 눌렀을 때도 마찬가지로 B 쪽 로그만 찍혔다. 두 카운터가 하나의 화면(같은 <code>Column</code>) 안에 같이 있는데도, 값이 바뀐 쪽만 리컴포지션이 일어나고 나머지는 건드려지지 않는다는 걸 로그로 직접 확인할 수 있었다.</p>
<h2 id="정리">정리</h2>
<p>글로 읽을 때는 &quot;Compose는 바뀐 부분만 다시 그린다&quot;는 문장이 다소 추상적으로 느껴졌는데, 두 개의 독립된 Composable을 만들어서 Logcat으로 확인해보니 훨씬 구체적으로 와닿았다. 각 Composable이 자기 범위(scope) 안의 State만 구독하고 있고, 그 State가 바뀔 때만 해당 범위가 다시 실행된다는 게 핵심이었다. Slot Table이 각 Composable의 호출 위치와 값을 따로 기록해두기 때문에 가능한 구조라는 것도 이번에 코드로 확인하면서 제대로 이해가 됐다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Jetpack Compose란 - State와 remember]]></title>
            <link>https://velog.io/@ju-unn/Android-Jetpack-Compose%EB%9E%80-State%EC%99%80-remember</link>
            <guid>https://velog.io/@ju-unn/Android-Jetpack-Compose%EB%9E%80-State%EC%99%80-remember</guid>
            <pubDate>Sun, 16 Aug 2026 08:00:51 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>Jetpack 라이브러리들을 정리하고 나니, 그중 UI를 담당하는 Compose를 좀 더 깊게 이해하고 싶어졌다. 특히 State와 remember는 이름만 봐서는 감이 잘 안 왔던 개념이라, 직접 코드를 짜서 동작을 눈으로 확인해보기로 했다.</p>
<h2 id="compose는-선언형이다">Compose는 선언형이다</h2>
<p>Jetpack Compose는 안드로이드 네이티브 UI를 만들기 위한 선언형 UI 툴킷이다. XML 없이 Kotlin 함수만으로 화면을 정의한다.</p>
<p>선언형이라는 게 처음엔 낯설었는데, &quot;화면을 어떻게 바꿀지&quot;가 아니라 &quot;지금 상태가 주어졌을 때 화면이 어떤 모습이어야 하는지&quot;만 코드로 적는다는 뜻이었다. 상태가 바뀌었을 때 화면을 실제로 어떻게 다시 그릴지는 Compose가 대신 계산한다.</p>
<p>기존 View 방식과 비교하면 차이가 뚜렷하다.</p>
<pre><code class="language-xml">&lt;!-- activity_main.xml --&gt;
&lt;TextView
    android:id=&quot;@+id/countText&quot;
    android:layout_width=&quot;wrap_content&quot;
    android:layout_height=&quot;wrap_content&quot; /&gt;
&lt;Button
    android:id=&quot;@+id/increaseButton&quot;
    android:text=&quot;증가&quot; /&gt;</code></pre>
<pre><code class="language-kotlin">// MainActivity.kt
val countText = findViewById&lt;TextView&gt;(R.id.countText)
val button = findViewById&lt;Button&gt;(R.id.increaseButton)
var count = 0

button.setOnClickListener {
    count++
    countText.text = count.toString() // 화면 갱신을 직접 호출
}</code></pre>
<p>기존 방식은 &quot;count가 바뀌었으니 countText.text를 바꿔라&quot;라고 매번 직접 지시한다. Compose는 다르다.</p>
<pre><code class="language-kotlin">@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Column {
        Text(text = count.toString())
        Button(onClick = { count++ }) {
            Text(&quot;증가&quot;)
        }
    }
}</code></pre>
<p>&quot;count 값이 이것이면 화면에 이 값을 보여줘라&quot;라고 선언만 하고, count가 바뀌면 화면을 다시 그리는 일은 Compose가 알아서 처리한다.</p>
<h2 id="remember가-왜-필요한지-헷갈렸다">remember가 왜 필요한지 헷갈렸다</h2>
<p>Compose에서 화면이 자동으로 갱신되려면 그 값이 Compose가 추적할 수 있는 &quot;State&quot;여야 한다. 그냥 <code>var count = 0</code>처럼 선언하면 값은 바뀌어도 Compose는 그 변화를 알지 못해서 화면이 갱신되지 않는다.</p>
<p>그래서 <code>mutableStateOf</code>로 값을 감싸서 State로 만들고, <code>remember</code>로 값을 기억하게 만든다.</p>
<pre><code class="language-kotlin">var count by remember { mutableStateOf(0) }</code></pre>
<p>문제는 여기서 &quot;remember 없이 mutableStateOf만 쓰면 어떻게 되는지&quot;가 글로만 봐서는 잘 와닿지 않았다는 점이다. &quot;리컴포지션마다 초기화된다&quot;는 설명은 이해했는데, 실제로 화면에서 어떻게 보이는지 감이 안 왔다.</p>
<h2 id="세-단계로-나눠서-직접-실험해봤다">세 단계로 나눠서 직접 실험해봤다</h2>
<p>그래서 세 가지 버전을 나란히 만들어서 비교해보기로 했다.</p>
<p>첫 번째는 State 자체를 안 쓰는 경우다.</p>
<pre><code class="language-kotlin">@Composable
fun PlainVarCounter(modifier: Modifier = Modifier) {
    var count = 0
    Log.d(TAG, &quot;PlainVarCounter 재구성됨&quot;)

    Column(modifier) {
        Text(&quot;count: $count (절대 안 바뀜)&quot;)
        Button(onClick = { count++ }) { Text(&quot;+1&quot;) }
    }
}</code></pre>
<p>두 번째는 State는 쓰지만 remember가 없는 경우다.</p>
<pre><code class="language-kotlin">@Suppress(&quot;UnrememberedMutableState&quot;)
@Composable
fun NoRememberCounter(modifier: Modifier = Modifier) {
    var count by mutableStateOf(0)
    val recomposeCount = remember { intArrayOf(0) }
    recomposeCount[0]++
    Log.d(TAG, &quot;NoRememberCounter 재구성 #${recomposeCount[0]} count=$count&quot;)

    Column(modifier) {
        Text(&quot;count: $count (재구성 ${recomposeCount[0]}회, 매번 리셋됨)&quot;)
        Button(onClick = { count++ }) { Text(&quot;+1&quot;) }
    }
}</code></pre>
<p>세 번째는 정석대로 State + remember다.</p>
<pre><code class="language-kotlin">@Composable
fun RememberCounter(modifier: Modifier = Modifier) {
    var count by remember { mutableStateOf(0) }
    val recomposeCount = remember { intArrayOf(0) }
    recomposeCount[0]++
    Log.d(TAG, &quot;RememberCounter 재구성 #${recomposeCount[0]} count=$count&quot;)

    Column(modifier) {
        Text(&quot;count: $count (재구성 ${recomposeCount[0]}회, 값 유지됨)&quot;)
        Button(onClick = { count++ }) { Text(&quot;+1&quot;) }
    }
}</code></pre>
<p>두 번째와 세 번째에는 <code>recomposeCount</code>라는 값을 하나 더 넣었다. 이 값 자체는 State가 아니라 <code>remember</code>로 감싼 배열(<code>intArrayOf</code>)이라서 화면 갱신을 직접 트리거하지는 않지만, 함수가 몇 번 재실행됐는지는 remember 덕분에 계속 누적된다. 이걸 넣어두면 &quot;리컴포지션이 일어나긴 했는지&quot;와 &quot;값이 유지되는지&quot;를 동시에 눈으로 구분할 수 있다.</p>
<h2 id="실제로-눌러보니">실제로 눌러보니</h2>
<p><code>PlainVarCounter</code>는 버튼을 아무리 눌러도 화면 숫자가 절대 바뀌지 않았다. Logcat에도 최초 composition 시점 로그 한 줄만 찍히고, 버튼을 눌러도 추가로 찍히지 않았다. Compose가 이 값의 변화를 아예 감지하지 못한다는 뜻이다.</p>
<p><code>NoRememberCounter</code>는 버튼을 누르면 Logcat에 재구성 로그가 계속 찍혔다. 즉 리컴포지션 자체는 매번 일어나고 있었다. 그런데 화면의 숫자는 항상 0에 머물러 있었다. 클릭할 때마다 <code>mutableStateOf(0)</code>이 새로 생성되면서 값이 다시 0으로 시작하기 때문이었다. 로그 상의 재구성 횟수는 계속 올라가는데 count는 그대로 0인 걸 보고서야 &quot;아, remember가 없으면 값 자체가 초기화되는구나&quot;가 확실히 이해됐다.</p>
<p><code>RememberCounter</code>는 버튼을 누른 만큼 숫자가 그대로 누적됐다. 재구성 횟수도 같이 올라가는 걸 보면, remember가 있다고 리컴포지션 자체가 안 일어나는 게 아니라 &quot;리컴포지션이 일어나도 이전 값을 그대로 들고 있는다&quot;는 게 핵심이라는 걸 알 수 있었다.</p>
<h2 id="정리">정리</h2>
<p>세 버전을 나란히 두고 비교해보니 개념이 훨씬 명확해졌다.</p>
<ul>
<li>State가 아니면: 값이 바뀌어도 리컴포지션 자체가 안 일어난다.</li>
<li>State지만 remember가 없으면: 리컴포지션은 일어나지만 값이 매번 초기화된다.</li>
<li>State + remember면: 리컴포지션이 일어나도 값이 유지된다.</li>
</ul>
<p>글로만 읽었을 때는 &quot;remember는 값을 기억하는 것&quot;이라는 정도로만 이해했는데, 직접 세 가지를 나란히 실행해보니 remember가 정확히 어느 시점에 무엇을 지켜주는지가 훨씬 뚜렷해졌다. 다음 글에서는 이 리컴포지션이 내부적으로 어떻게 &quot;바뀐 부분만&quot; 골라서 다시 그리는지, Slot Table과 Gap Buffer까지 정리해보려고 한다.</p>
]]></description>
        </item>
    </channel>
</rss>