<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>day_park_94.log</title>
        <link>https://velog.io/</link>
        <description>#Mobile #Android #iOS #Flutter #개발문화</description>
        <lastBuildDate>Mon, 05 Oct 2026 12:17:18 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>day_park_94.log</title>
            <url>https://velog.velcdn.com/images/day_park_94/profile/da950047-def5-4134-8dbc-ead359049330/image.svg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. day_park_94.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/day_park_94" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[갤럭시 테마에서 우리 앱 아이콘만 네모였던 이유]]></title>
            <link>https://velog.io/@day_park_94/%EA%B0%A4%EB%9F%AD%EC%8B%9C-%ED%85%8C%EB%A7%88%EC%97%90%EC%84%9C-%EC%9A%B0%EB%A6%AC-%EC%95%B1-%EC%95%84%EC%9D%B4%EC%BD%98%EB%A7%8C-%EB%84%A4%EB%AA%A8%EC%98%80%EB%8D%98-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@day_park_94/%EA%B0%A4%EB%9F%AD%EC%8B%9C-%ED%85%8C%EB%A7%88%EC%97%90%EC%84%9C-%EC%9A%B0%EB%A6%AC-%EC%95%B1-%EC%95%84%EC%9D%B4%EC%BD%98%EB%A7%8C-%EB%84%A4%EB%AA%A8%EC%98%80%EB%8D%98-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Mon, 05 Oct 2026 12:17:18 GMT</pubDate>
            <description><![CDATA[<h2 id="테마-바꾸니까-우리-앱만-네모예요">&quot;테마 바꾸니까 우리 앱만 네모예요&quot;</h2>
<p>어느 날 다른 팀에서 리포트가 하나 들어왔습니다. 갤럭시 테마 앱(Galaxy Themes)으로 아이콘 모양을 바꿨더니, 다른 앱들은 스쿼클이나 원형으로 바뀌는데 우리 앱은 각진 정사각형 그대로 남는다는 내용이었습니다. 기기는 갤럭시 Z 폴드5, OS는 Android 16(One UI 8)이었습니다. 홈 화면을 보면 우리 앱 말고도 네모로 남은 앱이 몇 개 더 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/day_park_94/post/4c7c4697-f490-413c-8281-8664c5e5e517/image.png" alt="레거시 PNG 아이콘은 테마 모양을 따르지 않고 정사각형으로 남는다 (TEST 더미 아이콘으로 재현)"></p>
<p>리포트를 읽으면서 크게 놀라지는 않았습니다. 원인은 이미 알고 있었기 때문입니다. 우리 앱은 런처 아이콘을 512×512 이미지 한 장으로 쓰고 있었고 적응형 아이콘(Adaptive Icon)이 아니었습니다. 언젠가 바꿔야 한다는 것도 알고 있었습니다. 다만 그동안 계속 뒤로 밀려 있었을 뿐입니다.</p>
<p>이 글은 그 미뤄 둔 일을 다시 꺼내서 끝내기까지의 이야기입니다.</p>
<h2 id="왜-네모로-남는가">왜 네모로 남는가</h2>
<p>Android 8.0(API 26)부터 런처 아이콘은 적응형 아이콘으로 만들 수 있습니다. 적응형 아이콘은 이미지 한 장이 아니라 <code>background</code>와 <code>foreground</code> 두 레이어로 이뤄집니다. 아이콘의 최종 모양은 앱이 정하지 않고 기기 쪽에서 정한 마스크를 레이어 위에 씌워서 잘라냅니다. 공식 문서에도 각 기기 제조사가 마스크를 제공하고 시스템이 그 마스크로 모든 적응형 아이콘을 같은 모양으로 그린다고 나와 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/day_park_94/post/b1e2b43b-c680-4ef6-a692-36feeede7364/image.png" alt="background와 foreground 레이어에 마스크를 씌워 아이콘 모양이 정해진다 (TEST 더미 아이콘)"></p>
<p>갤럭시 테마에서 아이콘 모양을 바꾸는 것도 이 마스크를 바꾸는 일입니다. 그런데 레거시 아이콘, 즉 PNG 한 장짜리 아이콘에는 마스크를 씌울 레이어가 없습니다. 런처 입장에서는 이미지를 받은 그대로 보여 줄 수밖에 없고, 우리 앱처럼 꽉 찬 정사각형 이미지를 넣어 두었다면 테마를 무엇으로 바꾸든 네모로 남습니다. 리포트에 나온 다른 네모 앱들도 아마 같은 이유였을 겁니다.</p>
<p>공식 문서는 이 상황을 이렇게 경고합니다. 런처 아이콘을 필요한 레이어로 업데이트하지 않으면 시스템이 표시하는 다른 아이콘들과 일관되게 보이지 않고 시각 효과도 지원하지 않는다고요. 우리가 겪은 게 정확히 그 장면이었습니다.</p>
<h2 id="처음에-이미지-한-장으로-간-이유">처음에 이미지 한 장으로 간 이유</h2>
<p>우리 앱은 처음 개발할 때부터 구조 때문에 아이콘을 이미지 한 장으로 처리했습니다. 우리는 하나의 코드베이스에서 여러 앱을 product flavor로 나눠 만듭니다. flavor마다 아이콘이 달라야 하는데 당시에는 이걸 가장 단순하게 처리하는 방법이 이미지 한 장을 갈아 끼우는 것이었습니다.</p>
<p>그 상태로 꽤 오래 갔습니다. 아이콘이 깨지거나 안 보이는 것도 아니었으니 굳이 손댈 이유가 없었습니다.</p>
<h2 id="넣을-자리는-먼저-만들어-뒀다">넣을 자리는 먼저 만들어 뒀다</h2>
<p>시간이 지나 제가 flavor별로 필요한 리소스를 각각 분리하는 작업을 했습니다. <code>src/&lt;flavor&gt;/res/</code> 아래에 flavor마다 자기 리소스를 두는 구조로 정리했습니다. 이렇게 해 두면 flavor별로 <code>mipmap-anydpi-v26/ic_launcher.xml</code>을 따로 둘 수 있어서 적응형 아이콘을 넣을 수 있는 상태가 됩니다.</p>
<pre><code class="language-text">app/src/
├── main/res/               # 공통 리소스
├── flavorA/res/
│   ├── mipmap-anydpi-v26/  # 적응형 아이콘 XML
│   └── mipmap-*/           # 레거시 PNG
└── flavorB/res/
    └── ...</code></pre>
<p>그때 디자이너에게도 적응형 아이콘 이야기를 했습니다. 다만 이 앱은 외부 제휴사의 앱이기도 해서 일단은 기존처럼 512 이미지 방식으로 진행하기로 했습니다. 디자이너에게 요청은 해 뒀지만 중요도에서 밀렸고 작업은 그대로 펜딩됐습니다.</p>
<h2 id="이번엔-왜-진행됐나">이번엔 왜 진행됐나</h2>
<p>이번 리포트는 내부의 다른 팀에서 들어왔습니다. 제가 혼자 &quot;언젠가 해야 한다&quot;고 말할 때와는 무게가 달랐습니다. 실제로 그 모습을 보고 이상하다고 느낀 사람이 있다는 게 근거가 됐습니다.</p>
<p>그래서 원인과 리포트를 같이 들고 PO와 디자이너를 직접 설득했습니다. 앞세운 근거는 하나였습니다. 홈 화면에서 다른 앱들은 다 같은 모양으로 바뀌는데 우리 앱만 튀어 보인다는 것이었습니다.</p>
<h2 id="foreground만-다시-받은-이유">foreground만 다시 받은 이유</h2>
<p>디자이너에게는 foreground만 다시 요청했습니다. 기존 아이콘의 배경이 단순한 단색이었기 때문입니다. background는 이미지 없이 색 하나로 처리하면 됐습니다.</p>
<pre><code class="language-xml">&lt;!-- src/&lt;flavor&gt;/res/mipmap-anydpi-v26/ic_launcher.xml --&gt;
&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;adaptive-icon xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;&gt;
    &lt;background android:drawable=&quot;@color/ic_launcher_background&quot; /&gt;
    &lt;foreground android:drawable=&quot;@mipmap/ic_launcher_foreground&quot; /&gt;
    &lt;monochrome android:drawable=&quot;@mipmap/ic_launcher_monochrome&quot; /&gt;
&lt;/adaptive-icon&gt;</code></pre>
<p>여기서 하나 주의할 게 있습니다. 우리 앱은 minSdk가 24라서 Android 7.x 기기도 지원합니다. <code>mipmap-anydpi-v26</code>은 API 26 이상에서만 쓰이기 때문에, 그 아래 기기를 위해 기존 <code>mipmap-*</code> 밀도별 PNG는 지우지 않고 남겨 둬야 합니다.</p>
<p>monochrome 레이어도 같이 넣었습니다. Android 13(API 33)부터는 사용자가 테마 아이콘을 켜면 시스템이 배경화면과 테마 색으로 아이콘을 칠하는데, 이때 쓰는 게 monochrome 레이어입니다. 이번에 손대는 김에 함께 처리했습니다.</p>
<h2 id="로고가-작아지는-문제">로고가 작아지는 문제</h2>
<p>적응형 아이콘으로 바꾸니 로고가 작아 보였습니다. 적응형 아이콘은 레이어 전체가 108×108dp인데 마스크를 씌운 뒤에도 잘리지 않고 보장되는 영역은 가운데 66×66dp뿐입니다. 공식 문서는 로고를 최소 48×48dp로 하고 66×66dp를 넘기지 말라고 안내합니다. 꽉 찬 512 이미지에 익숙한 눈으로 보면, 이 안전 영역 안에 로고를 넣었을 때 확실히 작아 보입니다.</p>
<p><img src="https://velog.velcdn.com/images/day_park_94/post/27a69c04-4770-49f3-89b6-3fa46ca83b2c/image.png" alt="108dp 캔버스, 72dp 표시 영역, 66dp 안전 영역 (TEST 더미 아이콘)"></p>
<p>제가 한 일은 Android 공식 가이드 링크를 공유하고 구두로 설명한 것까지였습니다. 그 안에서 아이콘을 어떻게 만들지는 디자이너가 정했습니다.</p>
<h2 id="확인">확인</h2>
<p>리포트와 같은 방법으로 확인했습니다. 갤럭시 테마 앱에서 아이콘 모양을 바꿔 가며 우리 앱 아이콘이 다른 앱들과 같은 모양으로 바뀌는지 봤고 OS 버전별로 나눠서 체크했습니다.</p>
<p>버전을 나눠 보는 이유는 구간마다 쓰이는 리소스가 다르기 때문입니다. minSdk 24 앱이라면 대략 이렇게 나뉩니다.</p>
<table>
<thead>
<tr>
<th>구간</th>
<th>쓰이는 리소스</th>
<th>볼 것</th>
</tr>
</thead>
<tbody><tr>
<td>Android 7.x (API 24~25)</td>
<td><code>mipmap-*</code> 밀도별 PNG</td>
<td>기존 아이콘이 그대로 나오는지</td>
</tr>
<tr>
<td>Android 8.0<del>12L (API 26</del>32)</td>
<td><code>mipmap-anydpi-v26</code>의 적응형 아이콘</td>
<td>테마 모양에 맞춰 잘리는지, 로고가 잘리지 않는지</td>
</tr>
<tr>
<td>Android 13 이상 (API 33~)</td>
<td>적응형 아이콘 + <code>monochrome</code></td>
<td>위 항목에 더해 테마 아이콘을 켰을 때 단색으로 잘 나오는지</td>
</tr>
</tbody></table>
<h2 id="레거시-아이콘을-쓰고-있다면">레거시 아이콘을 쓰고 있다면</h2>
<p>이번 일은 어려운 문제가 아니었습니다. 원인도 알았고 고치는 방법도 알았습니다. 넣을 자리까지 만들어 뒀는데도, 눈에 띄는 문제가 아니라는 이유로 한참을 미뤘습니다. 결국 다른 팀 사람이 테마를 바꿔 보고 나서야 진행됐습니다.</p>
<p>아직 런처 아이콘을 PNG 한 장으로 쓰고 있다면, 지금 한 번 점검해 보세요.</p>
<ul>
<li><code>res/mipmap-anydpi-v26/ic_launcher.xml</code>(flavor를 쓴다면 flavor별 경로까지)이 있는지</li>
<li>그 안에 <code>background</code>, <code>foreground</code>가 있고 로고가 가운데 66dp 안에 들어가는지</li>
<li><code>monochrome</code> 레이어가 있는지</li>
<li>minSdk가 26 미만이라면 밀도별 PNG가 그대로 남아 있는지</li>
<li>갤럭시 테마나 런처 설정에서 아이콘 모양을 바꿨을 때 다른 앱들과 같이 바뀌는지</li>
</ul>
<p>우리 앱이 네모로 남아 있다면, 누군가 이미 그걸 보고 있을지도 모릅니다.</p>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://developer.android.com/develop/ui/views/launch/icon_design_adaptive">Android Developers — Adaptive icons</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI끼리 리뷰를 시켰더니 기획까지 고쳐 놓았습니다]]></title>
            <link>https://velog.io/@day_park_94/AI%EB%81%BC%EB%A6%AC-%EB%A6%AC%EB%B7%B0%EB%A5%BC-%EC%8B%9C%EC%BC%B0%EB%8D%94%EB%8B%88-%EA%B8%B0%ED%9A%8D%EA%B9%8C%EC%A7%80-%EA%B3%A0%EC%B3%90-%EB%86%93%EC%95%98%EC%8A%B5%EB%8B%88%EB%8B%A4</link>
            <guid>https://velog.io/@day_park_94/AI%EB%81%BC%EB%A6%AC-%EB%A6%AC%EB%B7%B0%EB%A5%BC-%EC%8B%9C%EC%BC%B0%EB%8D%94%EB%8B%88-%EA%B8%B0%ED%9A%8D%EA%B9%8C%EC%A7%80-%EA%B3%A0%EC%B3%90-%EB%86%93%EC%95%98%EC%8A%B5%EB%8B%88%EB%8B%A4</guid>
            <pubDate>Sun, 04 Oct 2026 14:28:16 GMT</pubDate>
            <description><![CDATA[<p>PR 하나를 열었는데 diff가 이상하게 컸습니다. 제가 고친 건 화면 하나뿐인데 리뷰를 거치고 나니 기획에서 정한 로직까지 다르게 바뀌어 있었습니다. 리뷰도 AI가 했고 그 지적을 반영한 것도 AI였습니다.</p>
<p>이 글은 AI를 이미 쓰고 있는 개발자분들께 드리는 이야기입니다. AI를 쓸지 말지보다는 어디까지 맡기고 어디서 멈추게 할지에 대한 이야기입니다.</p>
<hr>
<h2 id="저는-거의-모든-단계에서-ai를-씁니다">저는 거의 모든 단계에서 AI를 씁니다</h2>
<p>지금 제 주력 도구는 Claude Code(Max 요금제)이고 여러 에이전트를 동시에 돌려야 할 때는 Orca로 오케스트레이션합니다. 설계, 코드 작성, 리뷰, 문서, 리포트까지 개발 흐름의 거의 모든 단계에 AI가 들어가 있습니다.</p>
<p>물론 모든 문제에 AI가 들어간 건 아닙니다. 이전 글의 Android 16 WebView 크래시 때는 AI를 쓰지 못했고 <a href="https://velog.io/@day_park_94/%EC%9E%90%EA%B3%A0-%EC%9D%BC%EC%96%B4%EB%82%98%EB%A9%B4-%EC%B4%88%EA%B8%B0%ED%99%94%EB%90%98%EB%8A%94-%ED%99%94%EB%A9%B4-%EB%B2%94%EC%9D%B8%EC%9D%80-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C%EA%B0%80-%EC%95%84%EB%8B%88%EC%97%88%EC%8A%B5%EB%8B%88%EB%8B%A4">화면 초기화 이슈</a> 때는 테스트 자동화로 모은 데이터를 근거로 수정했습니다.</p>
<p>효과는 분명했습니다. 개발 플랜을 세우는 속도와 실제 개발 속도가 눈에 띄게 빨라졌습니다. 그런데 빨라진 건 저만이 아니었습니다. 팀원 모두가 AI로 작업하다 보니 각자 만들어내는 코드 양이 늘었고 서로 리뷰해야 할 PR도 같이 많아졌습니다.</p>
<p>그래서 리뷰도 AI에게 맡기기로 했습니다. 저는 로컬에서 쓰는 코드 리뷰 스킬을 만들었고 팀 CI에도 AI 리뷰가 붙었습니다.</p>
<hr>
<h2 id="무슨-일이-있었나">무슨 일이 있었나</h2>
<p>처음엔 잘 돌아가는 것처럼 보였습니다. AI가 리뷰 코멘트를 남기고 AI가 그 코멘트를 반영해 코드를 고치고 다시 리뷰가 돌았습니다. 사람이 손대지 않아도 PR이 깔끔해지는 것 같았습니다.</p>
<p>그런데 결과물을 열어 보니 문제가 섞여 있었습니다.</p>
<ul>
<li>이번 작업과 관련 없는 파일이 수정돼 있었습니다.</li>
<li>요청하지 않은 리팩터링이 들어가 있었습니다.</li>
<li>기획에서 정한 로직이 다르게 바뀌어 있었습니다.</li>
</ul>
<p>앞의 두 개는 번거로운 정도로 끝났는데 마지막은 위험했습니다. 리뷰어 입장의 AI는 &quot;이 코드가 더 나아 보인다&quot;고 판단했을 뿐입니다. 그 로직이 왜 그렇게 짜여 있는지, 기획의 의도가 무엇인지는 알 수 없었습니다. AI끼리 리뷰와 수정을 주고받는 동안 그 맥락을 아는 사람이 루프에 없었던 겁니다.</p>
<p>다행히 머지 전에, 제가 마지막으로 diff를 훑어보다가 발견했습니다.</p>
<hr>
<h2 id="원인은-ai보다-저에게-있었습니다">원인은 AI보다 저에게 있었습니다</h2>
<p>돌아보면 AI가 특별히 이상한 일을 한 건 아니었습니다. &quot;리뷰해서 개선해 줘&quot;라고 하면 개선할 수 있는 건 다 개선하는 게 자연스럽습니다. 제가 수정 스코프를 정해 주지 않았던 겁니다. 어디까지 고쳐도 되고 어디부터는 건드리면 안 되는지를 말하지 않으면 AI는 자기가 보기에 좋은 방향으로 계속 나아갑니다. 그 방향이 기획과 다를 수 있다는 걸 이번에 직접 겪었습니다.</p>
<hr>
<h2 id="그래서-바꾼-세-가지">그래서 바꾼 세 가지</h2>
<h3 id="1-수정-스코프를-먼저-적습니다">1. 수정 스코프를 먼저 적습니다</h3>
<p>작업을 맡길 때 &quot;무엇을 고칠지&quot;와 함께 &quot;무엇은 고치지 말지&quot;도 적습니다. 수정해도 되는 파일과 모듈, 건드리면 안 되는 기획 로직을 플랜 단계에서 명시합니다.</p>
<h3 id="2-리뷰는-지적만-합니다">2. 리뷰는 지적만 합니다</h3>
<p>AI 리뷰가 직접 코드를 고치지 않게 했습니다. 리뷰는 문제를 지적하는 데서 멈추고 그 지적을 반영할지는 사람이 정합니다. 이렇게 해서 리뷰하는 쪽과 고치는 쪽이 모두 AI인 루프를 끊었습니다.</p>
<h3 id="3-마지막-점검은-제가-합니다">3. 마지막 점검은 제가 합니다</h3>
<p>플랜도 AI에게 검토를 시키고 리뷰도 AI로 돌리지만 플랜의 최종 판단과 리뷰의 마지막 점검은 제가 합니다. 이번에도 결국 문제를 잡은 건 제 마지막 diff 확인이었습니다. 그 뒤로 이 원칙만큼은 양보하지 않고 있습니다.</p>
<p>팀 CI 리뷰도 비슷한 방향으로 다듬어지고 있습니다. 우리 팀 리뷰 파이프라인에는 &quot;이 PR에 사람 리뷰가 필요한가&quot;를 판정하는 단계가 있습니다. 기준은 변경 규모가 아니라 틀렸을 때 되돌리는 비용입니다. 20줄짜리 정산 로직 수정이 500줄짜리 화면 리팩터링보다 위험할 수 있다는 관점입니다. 그리고 판단이 서지 않으면 기본값은 &quot;사람이 본다&quot;입니다.</p>
<hr>
<h2 id="지금-제가-ai를-쓰는-방식">지금 제가 AI를 쓰는 방식</h2>
<p>사고 이후 정리된 방식을 조금 더 구체적으로 소개하겠습니다.</p>
<h3 id="claudemd는-라우터로-둡니다">CLAUDE.md는 라우터로 둡니다</h3>
<p>CLAUDE.md 하나에 규칙을 계속 쌓으면 파일이 길어지고 AI가 지금 작업과 상관없는 규칙까지 읽게 됩니다. 그래서 루트 CLAUDE.md는 &quot;라우터&quot;로 씁니다. 무엇을 어디서 찾는지와 절대 어기면 안 되는 규칙만 두고 상세 규칙은 도메인별 문서와 모듈별 CLAUDE.md로 나눴습니다.</p>
<pre><code class="language-text">CLAUDE.md            # 라우터: 문서 지도 + 절대 규칙
AGENTS.md            # CLAUDE.md 심볼릭 링크 (Codex·Gemini 등도 같은 규칙을 읽음)
docs/_index.json     # 작업 영역 → 읽을 문서, 코드 변경 → 갱신할 문서 매핑
docs/ai/             # AI 작업 절차, 모듈 의존성 지도
docs/&lt;도메인&gt;/        # 도메인별 상세 규칙
&lt;모듈&gt;/CLAUDE.md     # 모듈 특화 규칙만</code></pre>
<p><code>docs/_index.json</code>에는 작업마다 읽어야 할 문서와 코드를 바꿀 때 같이 고쳐야 할 문서를 기계가 읽을 수 있게 정리해 뒀습니다. AI가 필요한 문서만 골라 읽도록 하려는 겁니다.</p>
<h3 id="바로-코딩하지-않게-합니다">바로 코딩하지 않게 합니다</h3>
<p>AI 작업 절차도 문서로 정해 두었습니다.</p>
<ol>
<li>요청을 받으면 바로 코딩하지 않고 범위와 의도부터 파악합니다.</li>
<li>의존성 지도를 보고 변경이 영향을 주는 모듈과 파일을 뽑습니다.</li>
<li>관련 문서와 현재 코드를 읽고 변경 계획을 세웁니다.</li>
<li>변경할 파일, 수정 내용, 영향 범위를 보고하고 승인을 받습니다.</li>
<li>승인된 범위 안에서만 실행하고 문서 갱신도 확인을 받고 반영합니다.</li>
<li>영향받는 빌드 변형과 다크모드 등을 검증합니다.</li>
</ol>
<p>자동 커밋과 푸시, 문서 자동 업데이트는 금지해 두었습니다. 앞에서 말한 &quot;수정 스코프를 먼저 적는다&quot;는 4번의 승인 단계로 절차에 넣었습니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>AI를 쓰기 시작하면 빨라지는 건 금방 체감합니다. 그런데 어디까지 맡기고 어디서 멈추게 할지, 마지막에 누가 판단할지 정하는 일은 그보다 어렵고 여전히 사람 몫이었습니다.</p>
<p>AI를 이미 잘 쓰고 계신 분이라면 다음 PR에서는 AI에게 &quot;고치지 말아야 할 것&quot;을 한 줄 적어 보시길 권합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[자고 일어나면 초기화되는 화면, 범인은 안드로이드가 아니었습니다]]></title>
            <link>https://velog.io/@day_park_94/%EC%9E%90%EA%B3%A0-%EC%9D%BC%EC%96%B4%EB%82%98%EB%A9%B4-%EC%B4%88%EA%B8%B0%ED%99%94%EB%90%98%EB%8A%94-%ED%99%94%EB%A9%B4-%EB%B2%94%EC%9D%B8%EC%9D%80-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C%EA%B0%80-%EC%95%84%EB%8B%88%EC%97%88%EC%8A%B5%EB%8B%88%EB%8B%A4</link>
            <guid>https://velog.io/@day_park_94/%EC%9E%90%EA%B3%A0-%EC%9D%BC%EC%96%B4%EB%82%98%EB%A9%B4-%EC%B4%88%EA%B8%B0%ED%99%94%EB%90%98%EB%8A%94-%ED%99%94%EB%A9%B4-%EB%B2%94%EC%9D%B8%EC%9D%80-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C%EA%B0%80-%EC%95%84%EB%8B%88%EC%97%88%EC%8A%B5%EB%8B%88%EB%8B%A4</guid>
            <pubDate>Sat, 18 Jul 2026 12:21:36 GMT</pubDate>
            <description><![CDATA[<h1 id="자고-일어나면-초기화되는-화면-범인은-안드로이드가-아니었습니다">자고 일어나면 초기화되는 화면, 범인은 안드로이드가 아니었습니다</h1>
<p>저녁에 쓰던 앱을 그대로 꺼두고 다음 날 아침 다시 켜보면 어김없이 로그인 화면이나 초기 화면부터 다시 떠 있었습니다. 처음 몇 번은 우연이겠거니 하고 넘겼는데 비슷한 사용자 리포트가 쌓이면서 예사롭지 않다는 걸 깨달았습니다. 신기하게도 이 현상은 모든 사람에게 똑같이 나타나지 않았습니다. <strong>백그라운드에 여러 앱을 쌓아두는 사람들에게서만 유독 반복</strong>됐고 앱을 자주 정리하는 사람에게는 거의 보이지 않았습니다.</p>
<hr>
<h2 id="문제-현상">문제 현상</h2>
<p>앱을 켜서 어느 화면에 머물다가 홈 버튼으로 백그라운드에 보낸 뒤 다음 날 아침 다시 실행하면 이전 화면이 아니라 처음 진입했던 상태로 돌아가 있었습니다. 스크롤 위치도, 입력하던 값도 다 사라진 채로 <code>onCreate</code>부터 다시 시작되는 식이었습니다.</p>
<p>문의가 쌓이면서 하나의 패턴이 보이기 시작했습니다. 실행 중인 앱을 습관적으로 스와이프해서 종료하는 사람에게는 문제가 없었습니다. 여러 앱을 동시에 켜두고 백그라운드에 계속 쌓아두는 사람에게서만 반복해서 발생하고 있었습니다. 특정 기기나 OS 버전 탓은 아니었습니다. 재현 여부를 가르는 변수는 백그라운드에 살아있는 프로세스 개수였습니다.</p>
<p>정작 이걸 재현하기가 쉽지 않았습니다. 개발하면서 여러 앱을 밤새 켜둔 채로 방치할 수는 없으니 실제 사용자 리포트로만 존재를 확인할 수 있는 현상이었습니다.</p>
<hr>
<h2 id="원인-분석">원인 분석</h2>
<p>재현 방법을 고민하다 개발자 옵션에 있는 <strong>&quot;활동 유지 안함&quot;</strong> 설정을 떠올렸습니다. 이 옵션을 켜면 액티비티가 백그라운드로 넘어가는 즉시 시스템이 <code>onDestroy</code>를 호출해주니 액티비티가 저장된 상태로부터 다시 만들어지는 상황을 홈 버튼 한 번으로 그 자리에서 만들 수 있습니다.</p>
<p>다만 이 옵션은 액티비티만 파괴하고 프로세스는 그대로 살려 둡니다. 싱글톤이나 static 변수에 들고 있던 값은 이 옵션으로는 사라지지 않습니다. 그러니 밤새 방치해서 프로세스까지 회수되는 상황과 완전히 같지는 않습니다. 프로세스 종료까지 재현하려면 앱을 홈 버튼으로 백그라운드에 보낸 뒤 <code>adb shell am kill &lt;패키지명&gt;</code>을 실행하고 다시 들어가 보면 됩니다.</p>
<p>켜보니 실제로 사용자 리포트와 같은 증상이 그대로 재현됐습니다. 왜 특정 사용자군에서만 발생하는지는 별개로 설명이 필요했습니다.</p>
<p>백그라운드에 앱을 여러 개 쌓아두면 시스템은 메모리를 확보하려고 우선순위가 낮은 프로세스부터 정리합니다. 이때 우리 앱의 프로세스가 통째로 사라집니다. 그래서 다음 날 런처 아이콘을 눌러 다시 들어가는 순간 시스템은 새 프로세스를 띄우고, 태스크에 남아 있던 액티비티를 저장해 둔 상태(<code>Bundle</code>)로부터 다시 만듭니다.</p>
<p>반대로 백그라운드 앱 개수가 적으면 메모리 압박이 거의 없어 프로세스가 오래 살아남습니다. 이 경우엔 런처로 다시 들어가도 기존 프로세스와 액티비티 스택이 그대로 남아 있어 <code>onCreate</code>가 다시 불릴 일이 없고 화면 상태도 자연히 보존됩니다. 지금까지 이 문제가 눈에 띄지 않았던 것도 이 때문이었을 겁니다.</p>
<p>프로세스가 쌓인 상태에서 다시 실행될 때 로그를 보면 인텐트에 <code>FLAG_ACTIVITY_NEW_TASK</code>, <code>FLAG_ACTIVITY_RESET_TASK_IF_NEEDED</code> 플래그가 같이 실려 옵니다. 그런데 이 두 플래그는 런처가 앱을 실행할 때마다 붙여 보내는 값이라 프로세스가 살아 있을 때도 똑같이 들어옵니다. 플래그는 양쪽이 똑같으니 차이는 프로세스가 죽었느냐에서 생깁니다. 프로세스와 함께 메모리에 있던 화면 상태도 사라지고, 다시 만들어진 액티비티에는 <code>onSaveInstanceState</code>로 저장해 둔 값만 남습니다.</p>
<p>덧붙이면 <code>FLAG_ACTIVITY_RESET_TASK_IF_NEEDED</code>는 태스크를 루트 액티비티만 남기고 정리할 수도 있는 플래그입니다. 매니페스트의 <code>alwaysRetainTaskState</code> 문서에는 사용자가 태스크를 오래 방문하지 않으면(예: 30분) 시스템이 이렇게 정리할 수 있다고 적혀 있습니다. 다음 날 아침 보이는 화면이 아예 루트 액티비티(첫 화면)라면 이 동작도 같이 의심해 볼 만합니다.</p>
<hr>
<h2 id="진짜-원인은-상태-저장-누락이었습니다">진짜 원인은 상태 저장 누락이었습니다</h2>
<p>처음엔 &quot;액티비티가 재생성된다&quot;는 사실 자체를 문제로 봤습니다. 하지만 찾아보니 재생성 자체는 안드로이드가 원래 그렇게 설계된 정상 동작이었습니다. 원인은 따로 있었습니다. 화면의 주요 상태값들을 <code>onSaveInstanceState</code>에 저장해두지 않았던 것입니다. 재생성은 시스템 설계상 언제든 일어날 수 있습니다. 그걸 전제로 구현하지 않은 게 문제였던 셈입니다.</p>
<hr>
<h2 id="단계별-대처-과정">단계별 대처 과정</h2>
<p><strong>Phase 1 · 1일차 — 사용자 리포트로 이슈를 처음 알게 됨</strong></p>
<p>사용자 리포트를 통해 &quot;다음 날 재실행 시 화면이 초기화된다&quot;는 현상을 알게 됐습니다. 개발 환경에서는 곧바로 재현되지 않아 원인 파악은 잠시 보류했습니다.</p>
<p><strong>Phase 2 · 2일차 — &quot;활동 유지 안함&quot; 설정으로 강제 재현</strong></p>
<p>개발자 옵션을 켜고 홈 버튼으로 백그라운드 전환 직후 재진입해보니 사용자 리포트와 동일하게 화면 상태가 초기화되는 걸 바로 확인할 수 있었습니다.</p>
<p><strong>Phase 3 · 2~3일차 — Intent Flag와 생명주기 로그를 분석</strong></p>
<p><code>onCreate</code>, <code>onDestroy</code> 호출 시점에 로그를 심어 확인해 보니 런처 재진입 시 전달되는 인텐트에 <code>FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_RESET_TASK_IF_NEEDED</code> 조합이 들어 있었습니다. 앞에서 말했듯 이 조합은 런처 실행 때마다 붙는 값이라 원인은 아니었습니다.</p>
<p><strong>Phase 4 · 3~4일차 — 상태 저장 로직을 추가하고 검증</strong></p>
<p>누락돼 있던 화면 상태를 <code>onSaveInstanceState</code>에 저장하도록 수정했습니다. 그다음 &quot;활동 유지 안함&quot; 설정을 켠 채로 반복 테스트해 상태가 정상 복원되는 걸 확인했습니다.</p>
<hr>
<h2 id="재현-및-검증-코드">재현 및 검증 코드</h2>
<p>저장해야 할 상태값이 여러 개면 <code>putInt</code>, <code>putString</code>을 화면 곳곳에 흩뿌려 호출하기보다 상태를 하나의 객체로 묶어 한 번에 저장하고 복원하는 편이 낫습니다. 코드도 읽기 쉽고 누락도 줄여줍니다.</p>
<pre><code class="language-kotlin">// 화면에서 재생성 후에도 유지되어야 하는 값들을 한 곳에 모아둔 상태 객체
// Parcelable 구현은 @Parcelize가 자동 생성 (kotlin-parcelize 플러그인 필요)
@Parcelize
data class ScreenState(
    val scrollPosition: Int,
    val inputText: String
) : Parcelable

class MainActivity : AppCompatActivity() {

    private var screenState = ScreenState(scrollPosition = 0, inputText = &quot;&quot;)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 재생성된 경우에만 savedInstanceState에 값이 들어있음
        // API 33부터 getParcelable(key)는 deprecated라 BundleCompat으로 타입을 지정해 꺼냄
        savedInstanceState
            ?.let { BundleCompat.getParcelable(it, KEY_SCREEN_STATE, ScreenState::class.java) }
            ?.let { screenState = it }
        restoreUi(screenState)
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        // 화면이 백그라운드로 내려갈 때(API 28+ 기준 onStop 이후) 호출되므로 그 시점의 최신 상태를 스냅샷으로 저장
        outState.putParcelable(KEY_SCREEN_STATE, currentScreenState())
    }

    // 현재 UI 값을 읽어 ScreenState로 변환
    private fun currentScreenState(): ScreenState =
        screenState.copy(
            scrollPosition = scrollView.scrollY,
            inputText = editText.text.toString()
        )

    // 저장된 ScreenState를 UI에 다시 반영
    private fun restoreUi(state: ScreenState) {
        scrollView.post { scrollView.scrollTo(0, state.scrollPosition) }
        editText.setText(state.inputText)
    }

    companion object {
        private const val KEY_SCREEN_STATE = &quot;key_screen_state&quot;
    }
}</code></pre>
<p>여기서 챙길 게 두 가지 있습니다. <code>onSaveInstanceState</code>는 호출되는 시점의 값을 다시 읽어서 저장해야 합니다. 이 콜백은 파괴 직전이 아니라 화면이 백그라운드로 내려갈 때 불리고, 이후 프로세스가 언제 죽을지는 알 수 없기 때문입니다. 그리고 <code>onCreate</code>에서는 <code>savedInstanceState</code>가 있을 때만 복원 로직을 태워야 최초 실행과 재생성을 구분할 수 있습니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>안드로이드를 처음 배울 때 들었던 &quot;액티비티는 언제든 재생성될 수 있다&quot;는 원칙을 이번 이슈로 다시 확인했습니다. 프로세스 회수 정책은 기기와 OS 버전에 따라 계속 강화되는 추세입니다. 이를 전제로 하지 않은 화면 설계는 언젠가는 반드시 터집니다.</p>
<p>며칠간의 재현과 분석 과정이 번거롭게 느껴지기도 했습니다. 그래도 그동안 눈에 띄지 않았던 상태 관리 누락을 짚어냈으니 나름 의미 있는 시간이었습니다. 비슷하게 &quot;가끔씩만 재현되는 화면 초기화&quot; 문제를 마주한 분들께 &quot;활동 유지 안함&quot; 옵션과 <code>adb shell am kill</code>, 그리고 상태 저장 로직 점검이 도움이 되길 바랍니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[OS 트러블 슈팅] Android 15~16 WebView Cold Start 크래시 분석 및 대응]]></title>
            <link>https://velog.io/@day_park_94/OS-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-Android-1516-WebView-Cold-Start-%ED%81%AC%EB%9E%98%EC%8B%9C-%EB%B6%84%EC%84%9D-%EB%B0%8F-%EB%8C%80%EC%9D%91</link>
            <guid>https://velog.io/@day_park_94/OS-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-Android-1516-WebView-Cold-Start-%ED%81%AC%EB%9E%98%EC%8B%9C-%EB%B6%84%EC%84%9D-%EB%B0%8F-%EB%8C%80%EC%9D%91</guid>
            <pubDate>Wed, 01 Jul 2026 13:29:22 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요. 최근 Android 16 업데이트 기기(특히 삼성 One UI 8.0 기반 기기)에서 Cold Start 시 WebView 초기화 단계에서 앱이 즉시 종료(FC)되는 크래시 이슈를 마주했습니다.</p>
<p>Play Console Vitals 상에서 크래시 지수가 <strong>평소 대비 약 150,000% 급등</strong>할 정도로 심각했던 사안이었는데요. 앱 자체의 코드 변경이 없었던 상황에서 어떤 가설을 세우고 원인을 추적했는지, 그리고 제조사에 기술 문의를 하고 앱 레벨에서 방어 코드를 작성한 과정을 공유합니다.</p>
<p><img src="https://velog.velcdn.com/images/day_park_94/post/0d817882-e7ed-4a04-aea9-3a639645caeb/image.png" alt=""></p>
<hr>
<h2 id="1-문제-현상">1. 문제 현상</h2>
<p>Android 16 업데이트 기기에서 프로세스가 새로 시작되는 Cold Start 시점에 앱이 즉시 종료되는 현상이 감지되었습니다.</p>
<ul>
<li><strong>크래시 로그:</strong> <code>android.app.ActivityThread.performLaunchActivity</code> 단계에서 <code>InflateException</code> 및 <code>NotFoundException</code> 발생</li>
<li><strong>발생 시점:</strong> 레이아웃 XML에 선언된 커스텀 WebView가 인플레이션(Inflation)되어 즉시 초기화될 때 발생</li>
<li><strong>특이 사항:</strong> &#39;영향을 받는 사용자&#39;와 &#39;이벤트&#39;가 1:1로 매칭되며 대개 OS 업데이트 후 최초 1회성으로 집중 발생</li>
</ul>
<hr>
<h2 id="2-가설-수립-및-정밀-분석">2. 가설 수립 및 정밀 분석</h2>
<p>처음 로그를 파악했을 때 <code>ResourcesImpl</code> 리다이렉션 키워드가 눈에 띄었습니다. 앱 코드는 동일한 상태인데 OS 버전이나 기기 환경에 따라 현상이 갈렸습니다. 그래서 내부 로직 결함보다는 OS 프레임워크와 리소스 매핑 쪽에 가설을 세우고 접근했습니다.</p>
<h3 id="🔍-진짜-원인-android-16의-리소스-참조-위반-정책-강화">🔍 진짜 원인: Android 16의 리소스 참조 위반 정책 강화</h3>
<p>구글과 제조사에 기술 문의를 해서 확인한 정확한 메커니즘은 이렇습니다.</p>
<ol>
<li>GMS(Google Mobile Services) 업데이트가 런타임에 일어날 때, 앱이 갱신된 리소스 경로를 즉각 반영하지 못하고 과거의 경로를 참조하는 일시적인 위반(<code>Violation</code>) 현상이 발생합니다.</li>
<li>Android 15 이전까지는 이런 Race Condition이 발생해도 유연하게 넘어갔는데 <strong>Android 16부터는 리소스 참조 위반 정책이 강화</strong>되었습니다.</li>
<li>결과적으로 <code>ResourcesImpl</code> 리다이렉션이 실패하면 예외 처리 없이 프레임워크 단에서 앱 프로세스를 즉시 크래시(FC) 처리해 버리는 것이 문제의 본질이었습니다.</li>
</ol>
<hr>
<h2 id="3-단계별-대처-과정">3. 단계별 대처 과정</h2>
<p>문제 포착부터 제조사 소통, 내부 데이터 검증까지의 대처 흐름입니다.</p>
<ul>
<li><strong>[Phase 1] 이슈 최초 포착 및 리포트 (2025.09 ~ 12)</strong>
Android 16이 처음 배포된 2025년 6월 무렵에는 크래시 수치가 높지 않았습니다. 그러다 9월부터 이상 수치가 보여서 Google Issue Tracker에 최초 리포트를 올리고 장기 모니터링을 시작했습니다.</li>
<li><strong>[Phase 2] 기술 문의 및 정밀 분석 (2026.01 ~ 02)</strong>
정식 업데이트 기기가 늘어나며 크래시가 급증하자 삼성 개발자 지원팀에 1차 기술 문의를 했습니다. 단순 앱 오류가 아님을 설명하려고 과거 관련 QA 리포트 분석 데이터를 근거로 제시했습니다.</li>
<li><strong>[Phase 3] 로그 제출 및 이슈 공식화 (2026.03)</strong>
내부에서 의도적으로 발생 시킨 <code>dumpstate</code> 로그를 함께 제출해 기술 문의를 구체화했고 플랫폼 단의 리소스 매핑 결함이라는 최종 피드백을 확인받았습니다.</li>
<li><strong>[Phase 4] 효과 검증 (2026.03 ~ 현재)</strong>
제조사의 수정 패치(One UI 8.5 이상)가 반영된 기기들을 위주로 Play Console 데이터를 모니터링해 크래시가 해소된 것을 확인했습니다.</li>
</ul>
<hr>
<h2 id="4-해결-방법-및-방어-코드">4. 해결 방법 및 방어 코드</h2>
<p>제조사 펌웨어 업데이트로 근본적으로 해소되는 문제입니다. 다만 사용자의 OS 업데이트 지연이나 파편화 환경을 고려해 앱 레벨에서 안전하게 구동될 수 있도록 방어 코드를 추가했습니다.</p>
<h3 id="application-레벨-방어-코드">Application 레벨 방어 코드</h3>
<p>Android 16에서는 GMS가 업데이트되면 GMS 패키지 경로가 랜덤하게 바뀝니다. 그런데 앱 프로세스는 이 바뀐 경로를 찾지 못한 채로 WebView 내부에서 GMS 리소스를 참조하다가 오류가 납니다.</p>
<p>그래서 Application 클래스의 <code>onCreate()</code> 시점에 <code>createPackageContext</code>로 GMS 패키지를 한 번 다시 로드합니다. GMS의 최신 패키지 경로를 앱 프로세스에 반영하려고 부르는 것이라 이 호출이 돌려주는 <code>Context</code>는 따로 쓰지 않습니다. 호출만 해도 경로가 재로드되기 때문입니다.</p>
<pre><code class="language-kotlin">@SuppressLint(&quot;CheckResult&quot;)
override fun onCreate() {
    super.onCreate()
    initGmsPackageDependency() // GMS 의존성 컨텍스트 갱신 유도
    // ..기존 초기화 로직
}

/**
 * Android 16 대응 GMS 패키지 의존성 강제 등록
 * 런타임 리소스 참조 위반(Violation)으로 인한 크래시 방지용 방어 코드
 */
private fun initGmsPackageDependency() {
    // Android 16(SDK 36) 환경에서만 작동하도록 제한
    // VERSION_CODES.BAKLAVA = 36 (compileSdk 36 이상 필요). VANILLA_ICE_CREAM(V)은 35, 즉 Android 15입니다.
    if (Build.VERSION.SDK_INT != Build.VERSION_CODES.BAKLAVA) {
        DebuggingLog.d(&quot;[Application] GMS 의존성 등록 불필요 - SDK: ${Build.VERSION.SDK_INT}&quot;)
        return
    }

    try {
        // GMS 패키지 명을 기반으로 컨텍스트를 강제 로드하여 컨텍스트 갱신 유도
        // 반환값은 사용하지 않음: 바뀐 GMS 패키지 경로를 재로드하는 것이 목적
        createPackageContext(
            &quot;com.google.android.gms&quot;, // GMS_PACKAGE_NAME
            CONTEXT_IGNORE_SECURITY or CONTEXT_INCLUDE_CODE
        )
        DebuggingLog.d(&quot;[Application] GMS 패키지 의존성 등록 성공&quot;)
    } catch (e: Throwable) {
        DebuggingLog.d(&quot;[Application] GMS 패키지 의존성 등록 실패: ${e.message}&quot;)
    }
}
</code></pre>
<blockquote>
<p>처음 공개한 코드에는 이 분기가 <code>Build.VERSION_CODES.V</code>로 되어 있었습니다. <code>V</code>는 Android 15(SDK 35)라서 주석과 달리 Android 16 기기에서는 방어 코드가 실행되지 않습니다. 같은 코드를 쓰고 계셨다면 <code>BAKLAVA</code>로 바꿔 주세요.</p>
</blockquote>
<h2 id="마치며">마치며</h2>
<p>새 안드로이드 플랫폼이 나올 때마다 리소스/보안 정책도 바뀌기 때문에 이번처럼 앱 코드를 건드리지 않았는데도 예측하기 어려운 사이드 이펙트가 생기곤 합니다.</p>
<p>이번 이슈에서는 원인 불명의 크래시 앞에서 정확한 로그(dumpstate) 분석과 기술 문의로 플랫폼 단의 변화를 빠르게 캐치했고 앱 레벨 방어 코드도 추가했습니다. targetSdk 36 전환이나 Android 16 환경에 대응하고 계신 개발자분들께 작은 도움이 되었으면 좋겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[싱글톤 패턴(Singleton Pattern)은 언제나 옳다?]]></title>
            <link>https://velog.io/@day_park_94/%EC%8B%B1%EA%B8%80%ED%86%A4-%ED%8C%A8%ED%84%B4Singletone-Pattern%EC%9D%80-%EC%96%B8%EC%A0%9C%EB%82%98-%EC%98%B3%EB%8B%A4</link>
            <guid>https://velog.io/@day_park_94/%EC%8B%B1%EA%B8%80%ED%86%A4-%ED%8C%A8%ED%84%B4Singletone-Pattern%EC%9D%80-%EC%96%B8%EC%A0%9C%EB%82%98-%EC%98%B3%EB%8B%A4</guid>
            <pubDate>Fri, 19 May 2023 06:53:02 GMT</pubDate>
            <description><![CDATA[<h2 id="1-개요">1. 개요</h2>
<blockquote>
<p><strong>A:</strong> &quot;싱글톤 패턴은 무엇인가요?&quot;
<strong>B:</strong> &quot;객체를 단 하나의 인스턴스로만 생성하여, 프로세스가 돌아가는 동안 전역에서 접근할 수 있도록 하는 디자인 패턴입니다.&quot;
<strong>A:</strong> &quot;그렇다면 이 패턴의 위험성이나 단점에 대해 말씀해주세요.&quot;
<strong>B:</strong> &quot;...&quot;</p>
</blockquote>
<p>개발자라면 누구나 싱글톤 패턴을 다뤄봤거나 알고 있을 것입니다.</p>
<p>하지만 <strong>&quot;싱글톤 패턴의 위험성을 충분히 고민하고 쓰고 계시나요?&quot;</strong>라는 질문에는 선뜻 답하지 못하는 경우가 많습니다. 편리함 속에 가려진 싱글톤의 이면과 올바른 구현 방식을 두고 고민한 내용을 기록으로 남겨둡니다.</p>
<hr>
<h2 id="2-싱글톤-패턴이란">2. 싱글톤 패턴이란?</h2>
<p>전 세계 개발자들의 교과서인 <em>&#39;Head First: Design Pattern&#39;</em>에서는 싱글톤을 다음과 같이 정의합니다.</p>
<blockquote>
<p><strong>싱글톤 패턴(Singleton Pattern)</strong>은 해당 클래스의 인스턴스가 하나만 만들어지고 어디서든 그 인스턴스에 접근할 수 있도록 하기 위한 패턴이다.</p>
</blockquote>
<h3 id="왜-사용할까-장점">왜 사용할까? (장점)</h3>
<ul>
<li><strong>생성 코스트 절감:</strong> 객체를 생성할 때마다 힙 영역에 메모리를 할당해야 합니다. 인스턴스 생성 비용이 크거나 공유해야 하는 리소스라면 단 한 번만 만들어 두고 재사용하면 반복 생성 비용과 GC 부담을 줄일 수 있습니다.</li>
<li><strong>공유 리소스 관리 및 접근:</strong> 데이터베이스 연결 pool, 로깅, 전역 설정 정보 등 앱 전반에서 일관된 상태를 유지해야 하는 시스템을 효율적으로 관리할 수 있습니다.</li>
</ul>
<hr>
<h2 id="3-싱글톤-패턴의-단점과-위험성">3. 싱글톤 패턴의 단점과 위험성</h2>
<p>사용하기엔 참 편리하지만 오남용하면 객체지향 아키텍처를 망치기도 합니다.</p>
<ol>
<li><strong>높은 결합도와 DIP·OCP 위배:</strong> 다른 클래스들이 추상화가 아니라 구체 클래스인 이 전역 객체를 직접 참조합니다. 그래서 클래스 간 결합도가 극도로 높아집니다. 의존성 역전 원칙(DIP)을 어기는 구조라 구현을 갈아끼우기 어렵고 그 탓에 개방-폐쇄 원칙(OCP, Open-Closed Principle)도 지키기 힘들어집니다.</li>
<li><strong>테스트의 어려움:</strong> 전역 상태를 공유하기 때문에 독립적인 단위 테스트를 수행하기 어렵습니다. 테스트마다 상태를 초기화해 줘야 하는 번거로움이 생깁니다.</li>
<li><strong>멀티스레드 환경에서 깨지는 원자성:</strong> 동기화 처리가 제대로 되지 않으면 인스턴스가 단 하나만 존재해야 한다는 대전제가 깨지고 <strong>n개의 인스턴스가 생성되는 치명적인 결함</strong>이 발생할 수 있습니다.</li>
</ol>
<hr>
<h2 id="4-kotlin-싱글톤-구현과-한계-분석">4. Kotlin 싱글톤 구현과 한계 분석</h2>
<p>싱글톤 구현 기법은 멀티스레드 환경을 고려하며 발전해 왔습니다. 그 기법들의 장단점을 하나씩 짚어보겠습니다.</p>
<h3 id="①-무늬만-싱글톤-잘못된-예시">① 무늬만 싱글톤 (잘못된 예시)</h3>
<p><code>companion object</code> 안에서 호출할 때마다 새로운 객체를 반환한다면 싱글톤의 정의가 무너집니다.</p>
<pre><code class="language-kotlin">class FakeSingleton private constructor() {
    companion object {
        // 호출할 때마다 새로운 객체를 인스턴스화함
        fun getInstance(): FakeSingleton = FakeSingleton()
    }
}</code></pre>
<h3 id="②-synchronized와-double-checked-locking">② Synchronized와 Double-Checked Locking</h3>
<p>먼저 <code>@Synchronized</code>로 메서드 전체를 동기화해 스레드 안전성을 보장하는 방식입니다.</p>
<pre><code class="language-Kotlin">class SynchronizedSingleton private constructor() {
    companion object {
        private var instance: SynchronizedSingleton? = null

        @Synchronized
        fun getInstance(): SynchronizedSingleton {
            if (instance == null) {
                instance = SynchronizedSingleton()
            }
            return instance!!
        }
    }
}</code></pre>
<p>단점: 인스턴스가 이미 만들어진 뒤에도 <code>getInstance()</code>를 호출할 때마다 락을 획득해야 합니다. 호출이 잦고 여러 스레드가 경합하면 성능 저하의 원인이 됩니다.</p>
<p>이 비용을 줄이려고 나온 게 Double-Checked Locking(DCL)입니다. 인스턴스가 없을 때만 락을 잡고, 락 안에서 한 번 더 확인합니다.</p>
<pre><code class="language-Kotlin">class DclSingleton private constructor() {
    companion object {
        @Volatile
        private var instance: DclSingleton? = null

        fun getInstance(): DclSingleton =
            instance ?: synchronized(this) {
                instance ?: DclSingleton().also { instance = it }
            }
    }
}</code></pre>
<p>주의: DCL에서는 <code>@Volatile</code>을 반드시 붙여야 합니다. 이게 빠지면 다른 스레드가 초기화가 덜 끝난 객체를 볼 수 있습니다.</p>
<h3 id="③-kotlin-by-lazy-기법-위임-활용">③ Kotlin by lazy 기법 (위임 활용)</h3>
<p>호출되는 시점에 초기화(Lazy Initialization)되며 스레드 동기화는 코틀린이 내부에서 보장해 줍니다.</p>
<pre><code class="language-Kotlin">class LazySingleton private constructor() {
    companion object {
        val instance: LazySingleton by lazy { LazySingleton() }
    }
}</code></pre>
<p>특징: 기본적으로 LazyThreadSafetyMode.SYNCHRONIZED로 동작하므로 멀티스레드 환경에서도 안전합니다. 다만 모드를 바꾸면 이야기가 달라집니다. <code>PUBLICATION</code>은 초기화 블록이 여러 스레드에서 동시에 실행될 수 있습니다. 그래도 최종 값은 하나로 정해집니다. <code>NONE</code>은 동기화를 아예 하지 않습니다.</p>
<h3 id="④-bill-pugh-solution-initialization-on-demand-holder-기법">④ Bill Pugh Solution (Initialization-on-demand holder 기법)</h3>
<p>JVM의 클래스 로딩 시점을 이용해 Java에서 전통적으로 널리 쓰여 온 기법입니다. JVM이 클래스를 초기화하는 과정에서 원자성을 보장하는 특성을 활용합니다.</p>
<pre><code class="language-Kotlin">class HolderSingleton private constructor() {
    companion object {
        val instance: HolderSingleton
            get() = Holder.INSTANCE
    }

    private object Holder {
        val INSTANCE = HolderSingleton()
    }
}</code></pre>
<p>장점: HolderSingleton 클래스가 로드될 때 Holder 클래스는 아직 로드되지 않은 상태입니다. instance가 최초로 호출되는 시점에만 내부 Holder 클래스가 로드되면서 인스턴스가 생성됩니다. 구조적으로 Thread-safe 하고 성능 저하도 없습니다. 참고로 코틀린의 <code>object</code> 선언도 JVM의 클래스 초기화 보장에 똑같이 기대어 동작합니다. 그래서 코틀린에서는 Holder를 직접 만들 일이 거의 없습니다.</p>
<h2 id="5-결론-어떻게-써야-할까">5. 결론: 어떻게 써야 할까?</h2>
<p>가장 좋은 방법은 두 가지입니다. 하나는 코틀린이 언어 차원에서 제공하는 <code>object</code> 키워드를 쓰는 것입니다. 다른 하나는 수동 싱글톤보다는 Hilt, Koin 같은 DI(의존성 주입) 프레임워크를 활용해서 객체의 생명주기를 컨테이너가 싱글톤으로 관리하도록 위임하는 것입니다.</p>
<p>객체를 싱글톤으로 설계하기 전에 &quot;이 객체가 정말 전역 상태를 가져야만 하는가?&quot;를 스스로에게 꼭 질문해 봐야 합니다. 구조를 깊이 고민하지 않고 싱글톤을 무분별하게 사용하면 결국 거대한 기술 부채로 돌아옵니다.</p>
]]></description>
        </item>
    </channel>
</rss>