<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>IOS · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/ios/</link>
    <description>소프트웨어 엔지니어링과 개발 조직 리더십에 대한 이야기. 성과 리뷰, 채용과 팀 확장, CI/CD, 데브옵스 모니터링, 에이전틱 AI. 서울에서 씁니다.</description>
    <generator>Hugo</generator>
    <language>ko</language>
    <copyright>© 2026 인재덕</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ko/tags/ios/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Sentry 셀프 호스팅 없이: 오픈소스 SDK로 React Native 크래시 리포트를 우리 서버에서 받기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-10-09-sentry-sdk-own-server/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-10-09-sentry-sdk-own-server/</guid>
      <pubDate>Fri, 09 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>Sentry</category>
      <category>Django</category>
      <category>크래시 리포팅</category>
      <category>셀프 호스팅</category>
      <category>iOS</category>
      <category>Android</category>
      <category>개인정보</category>
      <description>Sentry를 셀프 호스팅하지도, sentry.io 비용을 내지도 않고 React Native 앱의 크래시 리포트를 받는 방법입니다. Sentry 오픈소스 SDK는 그대로 두고 DSN만 우리 Django API로 돌려, 묶기와 알림, 개인정보 제거까지 직접 합니다.</description>
      <content:encoded><![CDATA[<p>React Native 앱의 크래시 리포트를 직접 받고 싶다면 Sentry 서버를 셀프 호스팅할 필요도, sentry.io에 비용을 낼 필요도 없습니다. Sentry SDK는 오픈소스이고, DSN은 어떤 호스트든 가리킬 수 있습니다. 저는 우리 Django API의 엔드포인트로 돌려서 그곳에서 리포트를 받고, 묶고, 알림을 보내고, 보관합니다. 원래 돌던 API 말고는 따로 드는 비용이 없습니다. 아직 대체하지 못한 건 심볼리케이션이라, 스택 트레이스만으로는 소스의 어느 줄인지 알 수 없습니다.</p>
<p>이번 주 전까지 우리 앱이 크래시 났다는 걸 아는 방법은 누군가 알려 주는 것뿐이었습니다. 그런데 대부분은 알려 주지 않습니다. 앱을 다시 열고, 또 꺼지면 그냥 안 쓰게 되죠. API 쪽은 대비가 돼 있었습니다(ERROR 로그는 전부 팀 채팅 채널로 갑니다). 하지만 휴대폰에서 일어난 크래시는 제가 볼 수 있는 어디에도 흔적을 남기지 않았습니다.</p>
<p>사실 앱에 리포터가 하나 있긴 했습니다. 앱이 사용자에게 오류 메시지를 보여 주면 그 메시지를 API의 작은 엔드포인트로 보내고, 서버가 이를 ERROR로 기록하니 채팅 채널에도 올라옵니다. 삭제가 실제로는 <em>성공</em>했는데 목록에 행이 그대로 남아 있었고, 그걸 다시 탭하자 &ldquo;No Item matches the given query&quot;가 뜬 일이 있었습니다. 그때 만든 장치입니다. 서버는 평범한 404 두 개만 기록하고 아무 말도 하지 않았거든요. 이건 앱이 잡아서 보여 주는 오류만 다룹니다. 크래시는 다릅니다. 리포트를 보내야 할 JavaScript 자체가 방금 죽어 버린 거니까요.</p>
<p>뻔한 해법은 Sentry입니다. 저는 Sentry SDK를 꽤 좋아합니다. React Native에서 크래시 리포팅이 이미 해결된 문제처럼 느껴지는 건 이 SDK 덕분이죠. 다만 사용자 데이터를 쥐고 있는 업체가 하나 더 늘고, 개인정보 처리방침에 한 줄이 더 붙고, 우리 규모로는 하루에 많아야 몇 건인 일에 청구서가 또 하나 생기는 건 원하지 않았습니다. 그래서 SDK는 남기고 서비스만 바꿨습니다. 앱의 DSN은 우리 API를 가리키고, 그 Django 앱의 작은 한 부분이 SDK가 보내는 걸 받아서 묶고, 알리고, 보관합니다.</p>
<p>이 글은 그 구조와, 실제 네이티브 크래시가 정말 끝까지 도착하는지 확인한 과정에 대한 이야기입니다.</p>

<h2 class="relative group">DSN 트릭
    <div id="dsn-트릭" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#dsn-%ed%8a%b8%eb%a6%ad" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Sentry DSN은 <code>https://&lt;public-key&gt;@&lt;host&gt;/&lt;project-id&gt;</code> 형태입니다. SDK는 이를 <code>https://&lt;host&gt;/api/&lt;project-id&gt;/envelope/</code>로 가는 POST로 바꾸고, 키는 <code>X-Sentry-Auth</code> 헤더에 넣습니다. sentry.io에만 해당하는 부분은 하나도 없습니다. 그래서 앱에 박아 둔 DSN은 이렇습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://&lt;CRASH_REPORT_KEY&gt;@api.example.com/1</span></span></code></pre></div></div>
<p>그리고 Django에는 SDK가 호출할 주소와 똑같은 라우트를 둡니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">path</span><span class="p">(</span><span class="s2">&#34;api/&lt;int:project_id&gt;/envelope/&#34;</span><span class="p">,</span> <span class="n">crash_envelope</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s2">&#34;crash_envelope&#34;</span><span class="p">),</span></span></span></code></pre></div></div>
<p>키는 모든 바이너리에 들어 있으니 비밀이 아닙니다. 엔드포인트에 쓰레기가 몰리기 시작하면 바꿀 수 있게, 그리고 서버에 키가 설정되지 않았을 때 아무거나 다 받는 대신 전부 거절하게 하려고 있는 겁니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">crash_envelope</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">project_id</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">expected</span> <span class="o">=</span> <span class="n">settings</span><span class="o">.</span><span class="n">CRASH_REPORT_KEY</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">expected</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Crash reporting is not configured&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">503</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">_key</span><span class="p">(</span><span class="n">request</span><span class="p">)</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">expected</span><span class="o">.</span><span class="n">encode</span><span class="p">()):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Unknown key&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">...</span></span></span></code></pre></div></div>
<p><code>_key</code>는 <code>X-Sentry-Auth</code> 헤더에서 <code>sentry_key=</code>를 읽고, 없으면 쿼리 스트링을 봅니다. 일부 SDK는 쿼리 스트링을 쓰기 때문입니다.</p>

<h2 class="relative group">SDK가 보내는 것: envelope
    <div id="sdk가-보내는-것-envelope" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sdk%ea%b0%80-%eb%b3%b4%eb%82%b4%eb%8a%94-%ea%b2%83-envelope" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Sentry의 전송 형식은 <a href="https://develop.sentry.dev/sdk/data-model/envelopes/"  target="_blank" rel="noreferrer">envelope</a>입니다. JSON 헤더 한 줄 뒤에 아이템이 여러 개 이어지고, 각 아이템은 JSON 헤더 한 줄과 페이로드로 이루어집니다. 아이템 헤더에 <code>length</code>가 있으면 페이로드는 정확히 그 바이트 수만큼이고, 없으면 다음 줄바꿈까지입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;event_id&#34;:&#34;9ec79c33...&#34;,&#34;dsn&#34;:&#34;https://...@api.example.com/1&#34;}
</span></span><span class="line"><span class="cl">{&#34;type&#34;:&#34;event&#34;,&#34;length&#34;:4123}
</span></span><span class="line"><span class="cl">{ ...the event JSON, 4123 bytes... }
</span></span><span class="line"><span class="cl">{&#34;type&#34;:&#34;client_report&#34;}
</span></span><span class="line"><span class="cl">{&#34;timestamp&#34;:1791400000.5,&#34;discarded_events&#34;:[]}</span></span></code></pre></div></div>
<p>여기서 두 가지에 걸려 넘어졌습니다.</p>
<ul>
<li><strong>네이티브 SDK는 본문을 gzip으로 압축하고, JavaScript SDK는 하지 않습니다.</strong> <code>@sentry/react-native</code>가 잡은 JS 오류는 평문으로 오지만, sentry-cocoa나 sentry-android가 보낸 네이티브 크래시는 <code>Content-Encoding: gzip</code>으로 옵니다. 디코더는 gzip, zlib로 감싼 deflate, raw deflate를 모두 처리하고, 압축 해제는 1MB에서 멈춥니다. 크래시 이벤트는 몇 KB 수준이라 그보다 크면 크래시 리포트가 아니니까요.</li>
<li><strong>길이가 붙은 페이로드에는 줄바꿈이 들어 있을 수 있습니다.</strong> <code>\n</code>으로 쪼개면 네이티브 SDK가 보내는 이벤트를 전부 망가뜨립니다. <code>length</code>가 있으면 반드시 그걸 따라야 합니다.</li>
</ul>
<p>파서는 짧습니다. <code>event</code> 아이템만 남기고, 세션·트랜잭션·첨부 파일·클라이언트 리포트는 받기만 하고 버립니다.</p>

<h2 class="relative group">SDK가 알아듣는 방식으로 응답하기
    <div id="sdk가-알아듣는-방식으로-응답하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sdk%ea%b0%80-%ec%95%8c%ec%95%84%eb%93%a3%eb%8a%94-%eb%b0%a9%ec%8b%9d%ec%9c%bc%eb%a1%9c-%ec%9d%91%eb%8b%b5%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>생각보다 중요한 부분이었습니다. SDK는 디스크에 큐를 두고, 전달됐는지 확신이 없는 건 다시 보냅니다. 그러니 서버의 응답이 SDK가 계속 재시도할지를 결정합니다.</p>
<ul>
<li><strong>200</strong>: 저장했을 때, <em>또는 일부러 버렸을 때</em>. 세션 아이템에 4xx로 답하면 SDK는 그걸 붙들고 영원히 재시도합니다.</li>
<li><strong>429와 <code>Retry-After</code></strong>: 스로틀에 걸렸을 때. 엔드포인트에는 IP당 스로틀(시간당 120건)이 있습니다. 크래시 루프에 빠진 폰은 실행할 때마다 리포트를 하나씩 보내기 때문이고, SDK는 429를 받으면 기대한 대로 물러납니다.</li>
<li><strong>400</strong>: envelope가 깨졌을 때. 이건 에러가 아니라 경고로 기록합니다. 깨진 리포트는 클라이언트가 고장 났다는 뜻이고, 다음에 오는 정상 리포트가 무엇이 크래시 났는지 알려 줄 테니까요.</li>
</ul>
<p>SDK는 첫 전송이 도착했는지 확신이 없으면 같은 이벤트를 다시 보내기도 합니다. 그래서 <code>event_id</code>에 unique 제약을 걸었고, 중복은 조용히 무시됩니다.</p>

<h2 class="relative group">소스 맵 없이 묶기
    <div id="소스-맵-없이-묶기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%86%8c%ec%8a%a4-%eb%a7%b5-%ec%97%86%ec%9d%b4-%eb%ac%b6%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>직접 만든 수신기라면 Sentry가 대신 해 주던 선택을 직접 해야 하는 지점입니다. Sentry는 스택 트레이스로 크래시를 묶습니다. 하지만 릴리스용 Hermes 번들은 minify돼 있어서 프레임 이름은 한 글자이고, 줄·열 위치는 빌드할 때마다 바뀝니다. 프레임으로 묶으면 같은 버그가 릴리스마다 새 이슈로 열리죠.</p>
<p>소스 맵이 없는 상태에서(이건 마지막에 다시 얘기합니다) JavaScript 오류에서 가장 안정적인 건 타입과 메시지입니다. 그래서 핑거프린트는 예외 타입과 메시지로 만들고, 발생할 때마다 달라지는 부분은 가립니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">_MASKS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;0x[0-9a-fA-F]+&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;addr&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\b[0-9a-fA-F]</span><span class="si">{8}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{12}</span><span class="s2">\b&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;uuid&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\b[0-9a-fA-F]{16,}\b&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;hex&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\d+&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;n&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span></span></span></code></pre></div></div>
<p><code>Cannot read property 'title' of undefined (reading index 3)</code>와 <code>(reading index 17)</code>은 같은 이슈가 됩니다. SDK가 <code>{{ default }}</code>가 들어 있지 않은 자체 <code>fingerprint</code>를 보내면 그게 우선이라, 기본 묶음이 틀렸을 때는 클라이언트에서 강제로 묶을 수 있습니다. 예외가 없는 이벤트는 가린 로그 메시지로, 둘 다 없으면 앱 코드의 맨 위 프레임으로 묶습니다. 네이티브 시그널 크래시는 메시지가 <code>Signal 6, Code 0</code> 같은 식이라 시그널별로 묶입니다. 거칠긴 하지만, 심볼리케이션이 생기기 전까지는 정직한 방식입니다.</p>

<h2 class="relative group">채널을 도배하지 않는 알림
    <div id="채널을-도배하지-않는-알림" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%b1%84%eb%84%90%ec%9d%84-%eb%8f%84%eb%b0%b0%ed%95%98%ec%a7%80-%ec%95%8a%eb%8a%94-%ec%95%8c%eb%a6%bc" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>핑거프린트 하나가 이슈 하나가 되고, 리포트 하나하나는 그 아래 이벤트로 저장됩니다. 이슈는 정확히 다음 경우에만 ERROR로 기록합니다(채팅 알림이 듣는 게 이 레벨입니다).</p>
<ul>
<li><strong>새</strong> 이슈</li>
<li><strong>해결됨</strong>으로 표시한 이슈가 <strong>다시 나타났을 때</strong>. 이때 이슈가 다시 열립니다.</li>
<li><strong>10번째, 100번째, 1,000번째, 10,000번째</strong> 발생</li>
</ul>
<p>평범한 반복은 카운터만 올립니다. <em>무시</em>로 표시한 이슈는 계속 세기만 하고 알림은 보내지 않습니다. 여기에 채팅 핸들러 자체의 속도 제한까지 겹치니, 잘못된 릴리스로 크래시 루프가 돌아도 메시지 벽이 아니라 몇 줄로 끝납니다.</p>
<p>저장 공간도 같은 식으로 제한합니다. 이슈마다 <strong>최근 50개</strong> 이벤트만 남기고, 새 이벤트가 들어오면 오래된 건 지웁니다. 그런데 이것 때문에 첫 차트가 망가졌습니다. 이벤트 50개로는 지난주에 얼마나 자주 일어났는지 알 수 없으니까요. 그래서 이슈마다 90일치 일별 횟수를 따로 두고, 카운터를 올리는 것과 같은 트랜잭션 안에서 갱신합니다. 차트는 이 횟수로 그리고, 이벤트는 읽는 용도로만 씁니다.</p>
<p>이런 걸 직접 만든다면 작은 경쟁 상태 두 개는 챙겨 둘 만합니다. 완전히 새로운 크래시가 여러 폰에서 동시에 나면 두 리포트가 동시에 이슈를 만들려고 할 수 있으니, 핑거프린트에 unique 제약을 걸고 진 쪽이 기존 행을 다시 읽게 하세요. 그리고 90일 범위는 가장 최근 리포트가 아니라 서버 기준 오늘부터 재야 합니다. 시계가 잘못 맞춰진 폰은 리포트 날짜를 아무 날로나, 심지어 내년으로도 찍을 수 있거든요.</p>
<p>페이로드는 128KB로 제한합니다. 이보다 큰 이벤트는 breadcrumb과 디버그 메타데이터부터 버리고, 그래도 크면 예외, 메시지, 릴리스, 플랫폼만 남긴 뒤 잘렸다는 표시를 붙입니다.</p>

<h2 class="relative group">개인정보 걷어 내기
    <div id="개인정보-걷어-내기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b0%9c%ec%9d%b8%ec%a0%95%eb%b3%b4-%ea%b1%b7%ec%96%b4-%eb%82%b4%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>SDK는 <code>sendDefaultPii: false</code>로 돌기 때문에, 원칙적으로는 IP 주소, 쿠키, 요청 본문이 오지 않습니다. 서버는 거기에 기대지 않습니다. 저장하기 전에 <code>user</code>에서는 <code>id</code>와 <code>username</code>만 남기고, <code>request</code>는 통째로 버립니다. 나중에 SDK 버전이나 설정이 바뀌어서 이런 걸 보내기 시작할 수도 있으니까요.</p>
<p>실제 기기로 테스트해 보니 걷어 내야 할 게 두 가지 더 나왔습니다.</p>
<ul>
<li><strong>breadcrumb은 쿼리 스트링까지 포함한 전체 URL을 기록합니다.</strong> 앱의 검색 요청은 검색어를 쿼리 스트링에 담아 보내기 때문에, 검색 직후에 크래시가 나면 그 사람이 뭘 검색했는지가 저장됐을 겁니다. 이제 서버가 모든 breadcrumb URL을 <code>?</code>에서 자릅니다.</li>
<li><strong>콘솔 breadcrumb에는 <code>console.log</code>에 넘긴 인자가 그대로 들어 있습니다.</strong> 앱이 마침 로그로 찍은 객체라면 무엇이든 될 수 있죠. 메시지 텍스트는 쓸모가 있으니 남기고, 인자는 버립니다.</li>
</ul>
<p>이 둘은 실제 리포트를 들여다보고 나서야 발견했습니다. 뒤에 나올 섹션이 필요한 가장 큰 이유가 이겁니다.</p>

<h2 class="relative group">클라이언트 쪽
    <div id="클라이언트-쪽" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%81%b4%eb%9d%bc%ec%9d%b4%ec%96%b8%ed%8a%b8-%ec%aa%bd" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>앱에서는 모든 게 작은 모듈 하나를 거칩니다. OTA 업데이트 SDK를 감싸 둔 것과 같은 이유입니다. OTA 번들은 네이티브 모듈이 생기기 전에 빌드된 바이너리에서도 돌아가거든요. <code>RNSentry</code>가 없는 바이너리에서 최상단에 <code>import * as Sentry from &quot;@sentry/react-native&quot;</code>를 쓰면 import 시점에 예외가 나고, 그러면 빈 화면입니다. 그래서 SDK는 지연 로딩하고, 그것도 네이티브 모듈이 실제로 있는지 확인한 다음에만 불러옵니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">NativeModules</span><span class="p">,</span> <span class="nx">Platform</span><span class="p">,</span> <span class="nx">TurboModuleRegistry</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">&#34;react-native&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">Constants</span> <span class="kr">from</span> <span class="s2">&#34;expo-constants&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">DSN</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">EXPO_PUBLIC_CRASH_DSN</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">nativeModulePresent</span><span class="p">()</span><span class="o">:</span> <span class="kr">boolean</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">Boolean</span><span class="p">(</span><span class="nx">TurboModuleRegistry</span><span class="p">.</span><span class="kr">get</span><span class="p">(</span><span class="s2">&#34;RNSentry&#34;</span><span class="p">)</span> <span class="o">??</span> <span class="nx">NativeModules</span><span class="p">.</span><span class="nx">RNSentry</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="kd">function</span> <span class="nx">initCrashReporting</span><span class="p">()</span><span class="o">:</span> <span class="kr">boolean</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">DSN</span> <span class="o">||</span> <span class="nx">Platform</span><span class="p">.</span><span class="nx">OS</span> <span class="o">===</span> <span class="s2">&#34;web&#34;</span> <span class="o">||</span> <span class="o">!</span><span class="nx">nativeModulePresent</span><span class="p">())</span> <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">Sentry</span> <span class="o">=</span> <span class="kr">require</span><span class="p">(</span><span class="s2">&#34;@sentry/react-native&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">Sentry</span><span class="p">.</span><span class="nx">init</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">dsn</span>: <span class="kt">DSN</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">release</span>: <span class="kt">Constants.expoConfig?.version</span> <span class="o">??</span> <span class="s2">&#34;unknown&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">sendDefaultPii</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">tracesSampleRate</span>: <span class="kt">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">enableAutoSessionTracking</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ...turn off the other tracing options the same way
</span></span></span><span class="line"><span class="cl">    <span class="nx">maxBreadcrumbs</span>: <span class="kt">50</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">beforeSend</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* tag the OTA bundle */</span> <span class="k">return</span> <span class="nx">event</span><span class="p">;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>오류만 보냅니다. 서버가 트레이스, 세션, 프로파일, 리플레이를 저장하지 않으니 그중 아무것도 켜지 않았습니다. DSN이 없으면(개발 빌드, E2E 빌드) SDK는 아예 시작하지 않습니다. 빌드 프로필에서 DSN과 environment(<code>staging</code> 또는 <code>production</code>)를 지정하기 때문에, 두 종류의 빌드가 같은 엔드포인트로 보내면서도 서로 구분됩니다. 로그인한 사용자는 계정 ID로만 지정하고, 이름이나 이메일은 절대 넣지 않습니다.</p>
<p>에러 바운더리에는 명시적인 호출이 필요했습니다. Expo Router의 루트 <code>ErrorBoundary</code>는 렌더링 오류를 잡아서 자기 화면을 보여 주기 때문에, 그 오류는 SDK가 걸어 둔 전역 핸들러까지 가지 않습니다. 해결책은 작은 래퍼입니다. <code>useEffect</code> 안에서 <code>captureException</code>을 호출한 다음, 예전처럼 라우터의 바운더리를 렌더링하고, 루트와 라우트별 바운더리에 똑같이 씁니다. 렌더링 중이 아니라 effect 안에서 부르는 건, 다시 렌더링될 때마다가 아니라 한 번만 보고하게 하려는 겁니다. 루트 컴포넌트는 <code>Sentry.wrap</code>으로도 감싸는데, 이게 예외를 던지면 감싸지 않은 컴포넌트를 그대로 씁니다.</p>
<p>웹용으로는 모든 함수를 꺼 둔 <code>.web.ts</code> 버전의 모듈이 있습니다. Metro가 플랫폼별 파일을 고르기 때문에 SDK는 웹 번들에 아예 들어가지 않습니다. 웹 앱에는 돌릴 네이티브 모듈이 없으니까요.</p>

<h2 class="relative group">네이티브 크래시가 정말 도착하는지 확인하기
    <div id="네이티브-크래시가-정말-도착하는지-확인하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%84%a4%ec%9d%b4%ed%8b%b0%eb%b8%8c-%ed%81%ac%eb%9e%98%ec%8b%9c%ea%b0%80-%ec%a0%95%eb%a7%90-%eb%8f%84%ec%b0%a9%ed%95%98%eb%8a%94%ec%a7%80-%ed%99%95%ec%9d%b8%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>단위 테스트는 손으로 쓴 envelope를 보냅니다. 파서가 SDK가 보낼 거라고 <em>제가 생각하는</em> 걸 처리한다는 것까지만 증명하죠. 출시 이틀 뒤, 프로덕션에 들어온 크래시 리포트는 0건이었습니다. 업데이트한 사람이 아직 세 명뿐이라 0건이어도 이상하지는 않았지만, 서버 입장에서는 &ldquo;아무도 크래시가 안 났다&quot;와 &ldquo;크래시가 도착하지 않는다&quot;가 똑같아 보입니다. 진짜 크래시를 하나 보고 싶었습니다.</p>
<p>그래서 DSN을 8000번 포트의 로컬 Django와 임시 키로 바꾼 Release 시뮬레이터 바이너리를 빌드했습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">EXPO_PUBLIC_CRASH_DSN</span><span class="o">=</span>http://localcrashtestkey0000@localhost:8000/1 <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="nv">EXPO_PUBLIC_CRASH_ENVIRONMENT</span><span class="o">=</span>local-verify <span class="se">\
</span></span></span><span class="line"><span class="cl">xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release <span class="se">\
</span></span></span><span class="line"><span class="cl">  -destination <span class="s1">&#39;platform=iOS Simulator,id=&lt;udid&gt;&#39;</span> -derivedDataPath /tmp/iosbuild</span></span></code></pre></div></div>
<p>(Debug가 아니라 Release입니다. Debug 빌드는 dev launcher가 링크돼서 동작이 다르고, 사람들이 실제로 쓰는 건 Release니까요.) 그다음 설치하고, 실행하고, 몇 초 기다렸다가, 실제 결함이 하듯이 죽였습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">xcrun simctl install booted /tmp/iosbuild/Build/Products/Release-iphonesimulator/MyApp.app
</span></span><span class="line"><span class="cl">xcrun simctl launch booted com.example.myapp      <span class="c1"># prints the pid</span>
</span></span><span class="line"><span class="cl"><span class="nb">kill</span> -ABRT &lt;pid&gt;</span></span></code></pre></div></div>
<p>시뮬레이터 앱은 평범한 macOS 프로세스라서, 시그널은 기기에서와 똑같이 SentryCrash의 핸들러에 도달합니다. 핸들러는 리포트를 디스크에 쓰기만 하고, 프로세스가 죽어 가는 동안에는 아무것도 보내지 않습니다. <strong>다음 실행</strong> 때 SDK가 그걸 찾아서 보냅니다. 다시 실행하자 1초 뒤 시뮬레이터 네트워크 로그에 <code>:8000</code>로의 연결이 찍혔습니다.</p>
<p>그런데 Django 로그에는 아무것도 없어서 잠깐 헷갈렸습니다. 제가 설정해 둔 레벨에서는 개발 서버가 2xx가 아닌 응답만 기록하더군요. 데이터베이스에는 들어와 있었습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">SIGABRT: Signal 6, Code 0 | cocoa 1.4.0 iOS 27.1 iPhone19,4 handled=False
</span></span><span class="line"><span class="cl">received 09:19:53, crashed 09:19:38
</span></span><span class="line"><span class="cl">contexts: app, device, os, ota_updates, trace · 24 breadcrumbs</span></span></code></pre></div></div>
<p>크래시부터 저장까지 15초였고, 대부분은 제가 앱을 다시 켜는 데 걸린 시간입니다. 그리고 실제 페이로드를 들여다보니 테스트로는 알 수 없던 것들이 보였습니다.</p>
<ul>
<li><strong>사용자 ID가 계정 ID가 아니었습니다.</strong> 테스트 빌드는 실제로 로그인한 상태가 아니라서, sentry-cocoa가 자체 설치별 ID로 대신 채웠습니다. 알아 둘 만한 점이고, 개인정보 안내에도 적어야 할 점입니다. 로그인했다면 리포트에는 계정 ID가, 아니라면 무작위 설치 ID가 담깁니다.</li>
<li><strong>시간대가 없습니다.</strong> culture 컨텍스트가 있을 줄 알았는데 없었습니다. device 컨텍스트에는 <code>locale</code>, 메모리, 화면 크기, 저전력 모드, 커널 버전은 있지만 시간대는 없습니다.</li>
<li><strong>네이티브 크래시는 <code>beforeSend</code>를 거치지 않습니다.</strong> JS 쪽 <code>beforeSend</code>가 실행 중인 OTA 번들로 <code>ota</code> 태그를 붙이는데, 네이티브 크래시는 다음 실행 때 네이티브 SDK가 조립하기 때문에 JS 훅을 전혀 지나지 않습니다. 네이티브 크래시에 태그를 붙이려면, 번들을 알게 된 시점에 <code>setTag</code>로 스코프에 태그를 걸어서 네이티브 레이어까지 동기화되게 해야 합니다.</li>
<li><strong>breadcrumb을 보면 앱이 뭘 하는지 보입니다.</strong> 콘솔 경고, 뷰 컨트롤러 생명주기, HTTP 호출이 있었는데, 그중에는 분석 SDK가 자기 수집 엔드포인트로 보내는 요청도 있었습니다. 앱이 그 밖에 어디와 통신하는지 다시 상기하게 되더군요. 그리고 URL에 쿼리 스트링이 붙어 있었고, 앞에서 말한 검색어 문제를 찾은 게 바로 여기서였습니다.</li>
</ul>

<h2 class="relative group">리포트 읽기
    <div id="리포트-읽기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a6%ac%ed%8f%ac%ed%8a%b8-%ec%9d%bd%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>리포트는 Django 관리자에서 읽습니다. Sentry를 느슨하게 본뜬 이슈 페이지인데, 상단에 횟수와 Resolve / Ignore / Reopen 버튼, 일별 막대, 릴리스·OS·기기·OTA 번들별 비율이 있고, 그 아래로 이벤트를 하나씩 예외, 프레임(앱 프레임은 강조, 라이브러리 프레임은 접힘), breadcrumb, 기기 컨텍스트와 함께 보여 줍니다. 새 의존성은 필요 없었습니다.</p>
<p>만들다가 고친 게 하나 있습니다. 네이티브 크래시에서 &ldquo;culprit&rdquo; 프레임이 기기 안의 바이너리 경로로 나왔는데, 여기에 설치마다 다른 UUID가 들어 있어서 같은 크래시가 폰마다 다르게 보였습니다. 이제는 <code>0x1000a1b2c (MyApp)</code>처럼 주소와 바이너리 이름만 나옵니다.</p>

<h2 class="relative group">개인정보 처리방침, 전부 다
    <div id="개인정보-처리방침-전부-다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b0%9c%ec%9d%b8%ec%a0%95%eb%b3%b4-%ec%b2%98%eb%a6%ac%eb%b0%a9%ec%b9%a8-%ec%a0%84%eb%b6%80-%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>크래시 리포트는 사실상 개인정보입니다. 계정 ID, 휴대폰 모델, 그리고 앱이 죽기 직전 1분 동안 그 사람이 한 일이 들어 있으니까요. 그래서 개인정보 처리방침에 한 단락이 필요했습니다. 제대로 쓰려면 리포트에 정확히 뭐가 담기는지 말해야 했고, 방금 들여다본 페이로드를 보면서 목록을 만들었습니다. 오류, 앱 버전과 OTA 업데이트, 휴대폰 모델, OS와 언어, 남은 메모리와 화면 크기, 직전 수십 단계(열어 본 화면, <code>?</code> 뒤를 지운 웹 주소, 앱 자체의 로그 메시지), 그리고 계정 ID 또는 무작위 설치 ID입니다. 리포트는 우리 서버로 오고 Sentry나 다른 크래시 리포팅 회사로는 가지 않는다는 것도 처리방침에 적었습니다. 보관 기간은 표에 따로 한 줄을 뒀습니다. 문제별로 최근 50건의 리포트, 그리고 90일치 일별 횟수입니다.</p>
<p>한 단락을 코드와 대조하기 시작하니 나머지를 안 볼 수가 없었습니다. 기존 처리방침은 버티지 못했습니다. 계정 삭제 페이지에는 계정을 닫으면 푸시 알림 토큰과 패스키가 즉시 삭제되고, 서버 로그는 90일 안에 사라진다고 적혀 있었습니다. 셋 다 사실이 아니었습니다. 계정을 닫으면 로그인 수단의 연결이 끊기고 프로필이 익명화되지만, 푸시 토큰과 패스키는 누군가 손으로 지울 때까지 남아 있고, 로그 보관소에는 아직 만료 기간이 없습니다. 그래서 처리방침 전체를 코드가 실제로 하는 일의 목록을 바탕으로 다시 썼고, 그 줄들은 이제 &ldquo;아직 자동으로 삭제되지 않음, 이메일로 요청해 주세요&quot;라고 적혀 있습니다. 덜 안심되지만, 정확합니다.</p>
<p>이제 저장소에 이에 대한 규칙이 있습니다. 처리방침은 우리가 하려는 일이 아니라 코드가 하는 일을 설명하고, 개인정보를 새로운 곳에서 수집하거나 보내거나 보관하는 변경은 같은 커밋에서 처리방침도 고친다는 규칙입니다. 앱 안의 요약 화면, 계정 삭제 페이지, 그리고 스토어 콘솔에만 있는 앱 스토어 개인정보 양식까지 포함해서요. 이걸 글로 적어 두게 만든 변경이 크래시 리포팅이었습니다.</p>

<h2 class="relative group">아직 없는 것
    <div id="아직-없는-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%95%84%ec%a7%81-%ec%97%86%eb%8a%94-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><strong>심볼리케이션.</strong> 이게 진짜 빈자리이고, sentry.io가 해 주는 일 중 제가 대체하지 못한 단 하나입니다. JavaScript 스택은 minify돼 있고, 네이티브 스택은 심볼리케이션이 안 돼 있습니다.</p>
<ul>
<li><strong>JavaScript:</strong> 프레임이 Hermes 번들 안을 가리킵니다. 고치려면 모든 스토어 빌드와 모든 OTA 번들의 합성된 Hermes 소스 맵을 릴리스와 번들별로 보관해 두고, 페이지를 렌더링할 때 JS 프레임에 적용해야 합니다. 우리 크래시는 대부분 JavaScript일 테니 이게 다음 작업입니다.</li>
<li><strong>iOS 네이티브:</strong> 명령어 주소를 풀려면 해당 빌드의 dSYM이 필요합니다.</li>
<li><strong>Android:</strong> minify를 켜면 Java 프레임은 R8로 난독화되고(<code>mapping.txt</code>가 필요합니다), C++ 프레임은 NDK 심볼이 필요합니다.</li>
</ul>
<p>위에서 발견한 더 작은 빈자리도 있습니다. 네이티브 크래시에는 아직 OTA 태그가 붙지 않습니다. <code>beforeSend</code>에서만 붙이고 있기 때문이죠. 실행 중인 번들이 확정되면 스코프에 태그를 거는 건 몇 줄이면 되는데, 아직 쓰지 않았습니다.</p>
<p>지금으로서는 이슈를 보면 <em>무엇이</em> 크래시 났는지, 어떤 릴리스와 OTA 번들과 기기에서였는지, 그 사람이 직전에 뭘 하고 있었는지는 알 수 있습니다. 소스의 <em>어디에서</em>인지는 알 수 없습니다. 일주일에 몇 번 배포하는 작은 앱에서는 지금까지 타입, 메시지, breadcrumb만으로도 원인을 찾기에 충분했습니다.</p>
<p>수신기와 관리자 페이지, 테스트를 합쳐도 코드는 많지 않고, 이미 돌아가던 API 위에 추가로 드는 비용은 없습니다. 어려운 일은 SDK가 합니다. 휴대폰에서 크래시를 잡고, 다음 실행 때 그 리포트를 밖으로 내보내는 일이죠.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-09-sentry-sdk-own-server/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Xcode로 iOS 앱 디버깅하는 방법: 브레이크포인트, LLDB, Instruments, 시뮬레이터</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-02-26-debugging-ios-mobile-apps/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-02-26-debugging-ios-mobile-apps/</guid>
      <pubDate>Thu, 26 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>iOS</category>
      <category>Xcode</category>
      <category>Debugging</category>
      <category>Swift</category>
      <category>iOS Simulator</category>
      <category>Mobile Development</category>
      <description>Xcode로 iOS 앱을 디버깅하는 방법을 정리했습니다. 조건부·심볼릭 브레이크포인트, 알아 두면 좋은 LLDB 명령어, 뷰 디버거와 메모리 디버거, Instruments, sanitizer, 크래시 로그, 그리고 시뮬레이터에서는 놓치고 실기기에서만 잡히는 버그까지 다룹니다.</description>
      <content:encoded><![CDATA[<p>Xcode에서 iOS 앱을 디버깅하려면 먼저 예외 브레이크포인트를 걸어 두세요. 크래시가 나면 예외를 throw한 바로 그 줄에서 멈춥니다. 그다음 버그를 재현하고, LLDB 콘솔에서 <code>po</code>로 상태를 확인합니다. 나머지는 증상에 맞는 도구를 고르는 일입니다. 레이아웃 문제는 뷰 디버거, 누수와 느려짐은 메모리 그래프와 Instruments, 가끔씩만 나는 크래시는 Address Sanitizer와 Thread Sanitizer. 마무리는 실제 iPhone에서 합니다. 시뮬레이터에서는 아예 드러나지 않는 버그가 한 무더기 있거든요.</p>
<p>디버깅에 쓰는 시간이 정말 많습니다. 누구나 그렇죠. 요즘은 제 앱의 버그를 쫓느라 Xcode와 시뮬레이터 안에서 보내는 시간이 대부분인데, 그럴 때마다 새삼 느낍니다. Xcode에는 거의 아무도 쓰지 않는 강력한 디버깅 기능이 정말 많다는 걸요. 이 글은 오랜 시간에 걸쳐 자리 잡은 제 디버깅 워크플로우 중에서, 실제로 시간을 아껴주는 부분들을 정리한 것입니다.</p>

<h2 class="relative group">브레이크포인트는 생각보다 강력합니다
    <div id="브레이크포인트는-생각보다-강력합니다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b8%8c%eb%a0%88%ec%9d%b4%ed%81%ac%ed%8f%ac%ec%9d%b8%ed%8a%b8%eb%8a%94-%ec%83%9d%ea%b0%81%eb%b3%b4%eb%8b%a4-%ea%b0%95%eb%a0%a5%ed%95%a9%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>대부분은 거터를 클릭해 브레이크포인트를 하나 찍고 끝냅니다. 그런데 우클릭해 보면 디버깅 방식 자체를 바꿔놓을 옵션이 잔뜩 나옵니다.</p>

<h3 class="relative group">조건부 브레이크포인트
    <div id="조건부-브레이크포인트" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a1%b0%ea%b1%b4%eb%b6%80-%eb%b8%8c%eb%a0%88%ec%9d%b4%ed%81%ac%ed%8f%ac%ec%9d%b8%ed%8a%b8" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>1000개 항목을 처리하는 루프에서 347번째만 말썽이라고 해봅시다. 브레이크포인트에 346번 걸려가며 기다릴 수는 없죠.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="k">for</span> <span class="n">i</span> <span class="k">in</span> <span class="mf">0.</span><span class="p">.&lt;</span><span class="n">users</span><span class="p">.</span><span class="bp">count</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">processUser</span><span class="p">(</span><span class="n">users</span><span class="p">[</span><span class="n">i</span><span class="p">])</span>  <span class="c1">// 여기에 브레이크포인트 설정, 조건: i == 347</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>브레이크포인트를 우클릭해 조건에 <code>i == 347</code>을 넣으면 Xcode는 조건이 참일 때만 멈춥니다. <code>userId == &quot;abc123&quot;</code>이나 <code>error != nil</code> 같은 조건도 똑같이 됩니다.</p>

<h3 class="relative group">심볼릭 브레이크포인트
    <div id="심볼릭-브레이크포인트" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8b%ac%eb%b3%bc%eb%a6%ad-%eb%b8%8c%eb%a0%88%ec%9d%b4%ed%81%ac%ed%8f%ac%ec%9d%b8%ed%8a%b8" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>메서드가 어디서 호출되는지 모를 때 씁니다. 브레이크포인트 네비게이터(Cmd+8)에서 + 버튼을 누르고 &ldquo;Symbolic Breakpoint&quot;를 선택합니다.</p>
<p>뷰 컨트롤러가 로드될 때마다 전부 보고 싶다면 심볼을 <code>viewDidLoad</code>로 설정하면 됩니다. Xcode가 모든 호출에서 멈춥니다. 너무 시끄러우면 <code>MyViewController.viewDidLoad</code>처럼 좁히면 되고요.</p>
<p>제가 상시로 걸어두는 건 이 세 가지입니다.</p>
<ul>
<li><code>UIViewAlertForUnsatisfiableConstraints</code>: Auto Layout 충돌을 그 자리에서 잡아줍니다</li>
<li><code>objc_exception_throw</code>: 크래시가 나기 전에 Objective-C 예외에서 멈춥니다</li>
<li><code>malloc_error_break</code>: 메모리 할당 문제를 찾아냅니다</li>
</ul>

<h3 class="relative group">예외 브레이크포인트
    <div id="예외-브레이크포인트" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%98%88%ec%99%b8-%eb%b8%8c%eb%a0%88%ec%9d%b4%ed%81%ac%ed%8f%ac%ec%9d%b8%ed%8a%b8" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>새 프로젝트에서 가장 먼저 해야 할 설정입니다. 예외 브레이크포인트를 추가해 두면(브레이크포인트 네비게이터에서 + → Exception Breakpoint) 앱이 이미 죽은 뒤가 아니라 뭔가 throw되는 그 순간에 Xcode가 멈춥니다.</p>
<p>콘솔에 정체불명 크래시로만 찍히던 nil 언래핑 버그를 이걸로 수도 없이 잡았습니다.</p>

<h3 class="relative group">브레이크포인트 액션
    <div id="브레이크포인트-액션" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b8%8c%eb%a0%88%ec%9d%b4%ed%81%ac%ed%8f%ac%ec%9d%b8%ed%8a%b8-%ec%95%a1%ec%85%98" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>브레이크포인트가 꼭 실행을 멈춰야 하는 건 아닙니다. 브레이크포인트를 편집해서 &ldquo;Add Action&quot;을 누르면 이런 게 됩니다.</p>
<ul>
<li>멈추지 않고 변수 출력: <code>&quot;User count: @(users.count)@&quot;</code></li>
<li>LLDB 명령 자동 실행</li>
<li>셸 스크립트 실행</li>
<li>소리 재생(저는 드물게 타는 코드 경로를 확인할 때 씁니다)</li>
</ul>
<p>&ldquo;Automatically continue after evaluating actions&quot;에 체크하면, 흐름을 끊지 않는 로거가 됩니다.</p>

<h2 class="relative group">LLDB: 실제로 쓰게 되는 콘솔 명령어
    <div id="lldb-실제로-쓰게-되는-콘솔-명령어" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#lldb-%ec%8b%a4%ec%a0%9c%eb%a1%9c-%ec%93%b0%ea%b2%8c-%eb%90%98%eb%8a%94-%ec%bd%98%ec%86%94-%eb%aa%85%eb%a0%b9%ec%96%b4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>브레이크포인트에서 멈췄을 때 아래쪽 콘솔은 크래시 로그나 들여다보는 곳이 아닙니다. 그게 LLDB고, 정말 유용합니다.</p>

<h3 class="relative group">기본
    <div id="기본" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b8%b0%eb%b3%b8" aria-label="앵커">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) po user
▿ User
  - id: &#34;123&#34;
  - name: &#34;John Doe&#34;
  - email: &#34;john@example.com&#34;</code></pre></div>
<p><code>po</code>는 객체를 읽기 좋은 형태로 출력합니다. 제 LLDB 사용의 90%가 이겁니다.</p>
<p>더 자세히 보고 싶으면 <code>p</code>를 씁니다.</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) p user.name
(String) $R0 = &#34;John Doe&#34;</code></pre></div>
<p>로컬 변수를 한 번에 다 보려면 이렇게 하고요.</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) frame variable</code></pre></div>

<h3 class="relative group">런타임에 값 바꾸기
    <div id="런타임에-값-바꾸기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%9f%b0%ed%83%80%ec%9e%84%ec%97%90-%ea%b0%92-%eb%b0%94%ea%be%b8%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>LLDB가 진짜 밥값을 하는 지점입니다. 재컴파일 없이 변수를 바꿀 수 있거든요.</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) expr user.name = &#34;Jane Doe&#34;
(lldb) expr index = 0</code></pre></div>
<p>버그를 찾았는데 리빌드 없이 수정을 시험해 보고 싶다면? 변수를 바꾸고 계속 실행하면 됩니다. 엣지 케이스를 좁혀갈 때 늘 이렇게 값을 바꿔가며 확인합니다.</p>
<p>메서드 호출도 됩니다.</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) po self.refreshUI()
(lldb) expr navigationController?.popViewController(animated: true)</code></pre></div>

<h3 class="relative group">네비게이션
    <div id="네비게이션" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%84%a4%eb%b9%84%ea%b2%8c%ec%9d%b4%ec%85%98" aria-label="앵커">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) bt              # 콜 스택 표시
(lldb) frame select 3  # 다른 스택 프레임으로 이동
(lldb) continue        # 실행 계속 (또는 단순히 &#39;c&#39;)
(lldb) n               # 스텝 오버 (다음 줄)
(lldb) s               # 함수로 스텝 인</code></pre></div>

<h3 class="relative group">워치포인트
    <div id="워치포인트" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%9b%8c%ec%b9%98%ed%8f%ac%ec%9d%b8%ed%8a%b8" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>변수가 언제 바뀌는지 알고 싶으면 워치포인트를 겁니다.</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) watchpoint set variable user.isLoggedIn</code></pre></div>
<p>이제 코드 어디에서 값이 바뀌든 그 순간 실행이 멈춥니다. 예상 밖의 상태 변경을 추적할 때 이만한 게 없습니다.</p>

<h2 class="relative group">Console.app: 숨은 디버깅 도구
    <div id="consoleapp-숨은-디버깅-도구" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#consoleapp-%ec%88%a8%ec%9d%80-%eb%94%94%eb%b2%84%ea%b9%85-%eb%8f%84%ea%b5%ac" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Xcode 콘솔로 충분할 때가 많지만, 시스템 로그며 크래시 리포트며 전부 봐야 할 때는 Console.app을 엽니다.</p>
<p>OSLog를 쓰고 있다면(써야 합니다) Console.app에서 로그를 검색하고 필터링할 수 있습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">os</span><span class="p">.</span><span class="nc">log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">logger</span> <span class="p">=</span> <span class="n">Logger</span><span class="p">(</span><span class="n">subsystem</span><span class="p">:</span> <span class="s">&#34;com.example.app&#34;</span><span class="p">,</span> <span class="n">category</span><span class="p">:</span> <span class="s">&#34;networking&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="s">&#34;Starting API request&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">debug</span><span class="p">(</span><span class="s">&#34;Request URL: </span><span class="si">\(</span><span class="n">url</span><span class="p">.</span><span class="n">absoluteString</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">error</span><span class="p">(</span><span class="s">&#34;Failed to decode: </span><span class="si">\(</span><span class="n">error</span><span class="p">.</span><span class="n">localizedDescription</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Console.app에서는 이렇게 합니다.</p>
<ol>
<li>시뮬레이터나 연결된 기기를 선택</li>
<li>서브시스템으로 필터: <code>subsystem:com.example.app</code></li>
<li>레벨로 필터: <code>subsystem:com.example.app AND level:error</code></li>
</ol>
<p>자주 쓰는 필터 술어는 저장해둘 수 있습니다. 저는 네트워크 요청용, 데이터베이스 작업용, 오류와 경고만 보여주는 것, 이렇게 세 개를 갖고 있습니다.</p>

<h3 class="relative group">print() 대신 OSLog를 쓰는 이유
    <div id="print-대신-oslog를-쓰는-이유" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#print-%eb%8c%80%ec%8b%a0-oslog%eb%a5%bc-%ec%93%b0%eb%8a%94-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>OSLog는 <code>print()</code>를 여기저기 뿌리는 것보다 훨씬 낫습니다. 구조화되어 있어서 레벨과 카테고리로 필터링할 수 있고, 로깅 자체가 최적화되어 있어 앱을 느리게 만들지도 않습니다. 프로덕션에서는 민감한 데이터를 자동으로 가려주고, 앱이 죽은 뒤에도 로그가 남습니다.</p>
<p>급하게 확인할 때는 <code>print()</code>도 괜찮습니다. 나중에 다시 볼 가능성이 있는 건 OSLog로 남기세요.</p>

<h3 class="relative group">Console.app 필터 고급 활용
    <div id="consoleapp-필터-고급-활용" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#consoleapp-%ed%95%84%ed%84%b0-%ea%b3%a0%ea%b8%89-%ed%99%9c%ec%9a%a9" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Console.app은 꽤 강력한 술어 쿼리를 지원합니다. 제가 자주 쓰는 것들입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># 앱의 모든 오류와 결함
</span></span><span class="line"><span class="cl">subsystem:com.example.app AND (level:error OR level:fault)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># 실패한 네트워크 요청
</span></span><span class="line"><span class="cl">subsystem:com.example.app AND category:networking AND eventMessage CONTAINS &#34;failed&#34;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># 지난 5분간의 모든 것
</span></span><span class="line"><span class="cl">subsystem:com.example.app AND timestamp &gt;= now(-5m)</span></span></code></pre></div></div>
<p>로그를 내보내서 버그 리포트에 첨부할 수도 있습니다. Xcode 콘솔에서 복사해 붙여넣는 것보다 훨씬 낫죠.</p>

<h2 class="relative group">뷰 디버깅
    <div id="뷰-디버깅" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b7%b0-%eb%94%94%eb%b2%84%ea%b9%85" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>UI가 깨졌는데 이유를 모르겠을 때 가장 빠른 길은 뷰 디버거입니다.</p>
<p>앱을 실행하고 문제의 화면으로 이동한 다음, Xcode 디버그 바의 &ldquo;Debug View Hierarchy&rdquo; 버튼을 누릅니다(또는 Cmd+Shift+D). 뷰 계층 전체가 3D로 분해되어 나타납니다.</p>
<p>돌려보면 이런 게 눈에 들어옵니다.</p>
<ul>
<li>다른 뷰 뒤에 가려진 채 렌더링되는 뷰</li>
<li>화면 저 바깥에 나가 있는 뷰</li>
<li>크기가 0인 뷰</li>
<li>선택한 뷰의 전체 제약 체인</li>
</ul>
<p>저는 이걸로 탭 이벤트를 삼키던 투명 버튼, 0x0으로 렌더링되던 레이블, 어쩌다 폭이 10,000픽셀이 된 이미지를 찾아냈습니다.</p>

<h3 class="relative group">Auto Layout 디버깅
    <div id="auto-layout-디버깅" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#auto-layout-%eb%94%94%eb%b2%84%ea%b9%85" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>뷰 계층에 보라색 경고 아이콘이 보이면 제약 충돌입니다. 클릭하면 어떤 제약끼리 싸우고 있는지 Xcode가 정확히 보여줍니다.</p>
<p>제약에는 식별자를 붙여두세요.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">heightConstraint</span><span class="p">.</span><span class="n">identifier</span> <span class="p">=</span> <span class="s">&#34;ProfileImageHeight&#34;</span></span></span></code></pre></div></div>
<p>제약이 깨졌을 때 오류에 메모리 주소 대신 <code>&quot;ProfileImageHeight&quot;</code>가 찍힙니다. 로그 읽기가 완전히 달라집니다.</p>
<p>LLDB에서 제약 트리를 출력할 수도 있습니다.</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) po view.hasAmbiguousLayout
(lldb) po view._autolayoutTrace()</code></pre></div>

<h2 class="relative group">시간을 아껴주는 시뮬레이터 기능
    <div id="시간을-아껴주는-시뮬레이터-기능" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8b%9c%ea%b0%84%ec%9d%84-%ec%95%84%ea%bb%b4%ec%a3%bc%eb%8a%94-%ec%8b%9c%eb%ae%ac%eb%a0%88%ec%9d%b4%ed%84%b0-%ea%b8%b0%eb%8a%a5" aria-label="앵커">#</a>
    </span>
    
</h2>

<h3 class="relative group">느린 애니메이션
    <div id="느린-애니메이션" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%8a%90%eb%a6%b0-%ec%95%a0%eb%8b%88%eb%a9%94%ec%9d%b4%ec%85%98" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Debug → Slow Animations를 켜면 모든 게 1/10 속도로 움직입니다. 전환 중에 무슨 일이 벌어지는지, 애니메이션이 왜 이상해 보이는지 확인하기 딱 좋습니다.</p>

<h3 class="relative group">메모리 경고 시뮬레이션
    <div id="메모리-경고-시뮬레이션" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a9%94%eb%aa%a8%eb%a6%ac-%ea%b2%bd%ea%b3%a0-%ec%8b%9c%eb%ae%ac%eb%a0%88%ec%9d%b4%ec%85%98" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Debug → Simulate Memory Warning. 메모리가 부족할 때 앱이 어떻게 버티는지 테스트합니다. 이걸로 이미지 캐싱 버그를 꽤 찾았습니다. 경고가 오기 전까지는 멀쩡하다가, 오는 순간 이미지가 전부 사라지는 종류죠.</p>

<h3 class="relative group">Network Link Conditioner
    <div id="network-link-conditioner" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#network-link-conditioner" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Xcode → Open Developer Tools → Network Link Conditioner</p>
<p>3G, LTE, 심한 패킷 손실, 완전 오프라인까지 시뮬레이션할 수 있습니다. WiFi에서는 잘 되던 API 요청이 셀룰러에서 타임아웃된 적이 있다면, 바로 이걸로 재현할 수 있습니다.</p>
<p>저는 지연 500ms에 패킷 손실 10%짜리 &ldquo;Bad Network&rdquo; 프로필을 상비해 둡니다. 빠른 사무실 WiFi에서는 절대 안 보이는 타임아웃 버그와 로딩 상태 문제가 여기서 걸려 나옵니다.</p>

<h3 class="relative group">상태 표시줄 오버라이드
    <div id="상태-표시줄-오버라이드" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%83%81%ed%83%9c-%ed%91%9c%ec%8b%9c%ec%a4%84-%ec%98%a4%eb%b2%84%eb%9d%bc%ec%9d%b4%eb%93%9c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>시뮬레이터 상태 표시줄을 우클릭하면 시간, 배터리 잔량, 신호 세기, 통신사를 바꿀 수 있습니다. 스크린샷을 통일하거나 배터리 잔량별 UI를 확인할 때 유용합니다.</p>

<h3 class="relative group">위치 시뮬레이션
    <div id="위치-시뮬레이션" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%9c%84%ec%b9%98-%ec%8b%9c%eb%ae%ac%eb%a0%88%ec%9d%b4%ec%85%98" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Debug → Location으로 책상에 앉은 채 온갖 위치를 재현할 수 있습니다. 커스텀 좌표, 도시 산책, 고속도로 주행까지 다 있습니다. 위치 기반 기능을 테스트하거나 특정 지역에서만 나는 문제를 잡을 때 아주 요긴합니다.</p>

<h2 class="relative group">메모리 디버깅
    <div id="메모리-디버깅" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a9%94%eb%aa%a8%eb%a6%ac-%eb%94%94%eb%b2%84%ea%b9%85" aria-label="앵커">#</a>
    </span>
    
</h2>

<h3 class="relative group">Debug Memory Graph
    <div id="debug-memory-graph" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#debug-memory-graph" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Debug Memory Graph 버튼(Cmd+Shift+M)을 누르면 메모리에 있는 모든 객체와 참조 관계, 그리고 정작 궁금한 것, 즉 어떤 게 누수되고 있는지를 Xcode가 보여줍니다.</p>
<p>보라색 느낌표가 누수입니다. 클릭하면 retain cycle이 보입니다.</p>
<p>제가 가장 자주 보는 누수는 이겁니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">class</span> <span class="nc">ViewController</span><span class="p">:</span> <span class="n">UIViewController</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">onComplete</span><span class="p">:</span> <span class="p">(()</span> <span class="p">-&gt;</span> <span class="nb">Void</span><span class="p">)?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">func</span> <span class="nf">setupHandler</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">onComplete</span> <span class="p">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="kc">self</span><span class="p">.</span><span class="n">dismiss</span><span class="p">(</span><span class="n">animated</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>  <span class="c1">// ❌ self를 강하게 캡처</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>weak self로 고칩니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">onComplete</span> <span class="p">=</span> <span class="p">{</span> <span class="p">[</span><span class="kr">weak</span> <span class="kc">self</span><span class="p">]</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="kc">self</span><span class="p">?.</span><span class="n">dismiss</span><span class="p">(</span><span class="n">animated</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>  <span class="c1">// ✓ retain cycle 없음</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>delegate도 weak이어야 합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kr">weak</span> <span class="kd">var</span> <span class="nv">delegate</span><span class="p">:</span> <span class="n">ManagerDelegate</span><span class="p">?</span>  <span class="c1">// 단순한 &#39;var&#39;가 아님</span></span></span></code></pre></div></div>

<h3 class="relative group">Instruments
    <div id="instruments" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#instruments" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>메모리를 제대로 파고들 때는 Instruments를 씁니다(Product → Profile, 또는 Cmd+I).</p>
<p>Allocations 인스트루먼트로는 모든 객체 할당, 시간에 따른 메모리 증가, 메모리를 가장 많이 쓰는 클래스, 할당이 일어난 위치의 스택 트레이스까지 볼 수 있습니다.</p>
<p>제가 주로 찾는 건 시간에 비례해 계속 늘어나는 메모리(거의 누수입니다), 예상 밖의 큰 할당(이미지 1000장을 한 번에 다 읽어버린 건 아닌지?), 해제됐어야 하는데 남아 있는 객체, 이 세 가지입니다.</p>
<p>동작 전후로 세대를 표시해 두면(작은 깃발 버튼) 남아 있으면 안 되는 게 뭐가 남았는지 확인할 수 있습니다.</p>

<h2 class="relative group">성능 프로파일링
    <div id="성능-프로파일링" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%84%b1%eb%8a%a5-%ed%94%84%eb%a1%9c%ed%8c%8c%ec%9d%bc%eb%a7%81" aria-label="앵커">#</a>
    </span>
    
</h2>

<h3 class="relative group">Time Profiler
    <div id="time-profiler" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#time-profiler" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Product → Profile → Time Profiler. 앱에서 느린 동작을 하면서 기록한 뒤 호출 트리를 읽습니다.</p>
<p>&ldquo;Self Weight&quot;로 정렬하면 병목이 보입니다. 어떤 함수가 self weight 40%를 차지한다면, 시간이 거기로 새고 있는 겁니다.</p>
<p>예전에 JSON 파싱 함수가 초당 1000번 호출되는 걸 발견한 적이 있습니다. 백그라운드 큐로 옮겼더니 UI 버벅임이 사라졌죠.</p>

<h3 class="relative group">Main Thread Checker
    <div id="main-thread-checker" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#main-thread-checker" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>백그라운드 스레드에서의 UI 업데이트는 Xcode가 알아서 잡아줍니다. 이런 메시지가 보이면,</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Main Thread Checker: UI API called on a background thread: -[UILabel setText:]</span></span></code></pre></div></div>
<p>이런 짓을 한 겁니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">URLSession</span><span class="p">.</span><span class="n">shared</span><span class="p">.</span><span class="n">dataTask</span><span class="p">(</span><span class="n">with</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span> <span class="p">{</span> <span class="n">data</span><span class="p">,</span> <span class="n">response</span><span class="p">,</span> <span class="n">error</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="kc">self</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">text</span> <span class="p">=</span> <span class="s">&#34;Loaded&#34;</span>  <span class="c1">// ❌ 크래시!</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>고치면 이렇게 됩니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">URLSession</span><span class="p">.</span><span class="n">shared</span><span class="p">.</span><span class="n">dataTask</span><span class="p">(</span><span class="n">with</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span> <span class="p">{</span> <span class="n">data</span><span class="p">,</span> <span class="n">response</span><span class="p">,</span> <span class="n">error</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="n">DispatchQueue</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="kc">self</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">text</span> <span class="p">=</span> <span class="s">&#34;Loaded&#34;</span>  <span class="c1">// ✓</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>

<h2 class="relative group">런타임 진단
    <div id="런타임-진단" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%9f%b0%ed%83%80%ec%9e%84-%ec%a7%84%eb%8b%a8" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Edit Scheme → Run → Diagnostics. 여기 늘어선 체크박스들이 손으로는 절대 못 찾을 버그를 잡아줍니다.</p>

<h3 class="relative group">Address Sanitizer
    <div id="address-sanitizer" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#address-sanitizer" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>메모리 손상을 찾아냅니다. use-after-free, 버퍼 오버플로, 메모리 누수. 앱이 2~3배 느려지지만, 다른 방법으로는 사실상 추적이 불가능한 버그를 잡아줍니다. 가끔씩만 나는 크래시를 쫓을 때 켭니다.</p>

<h3 class="relative group">Thread Sanitizer
    <div id="thread-sanitizer" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#thread-sanitizer" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>데이터 레이스와 스레딩 문제를 잡습니다. 켜놓고 앱을 평소처럼 쓰기만 하면 됩니다. 경쟁 상태가 있다면 Thread Sanitizer가 찾아냅니다.</p>
<p>Address Sanitizer와 Thread Sanitizer는 동시에 못 켜기 때문에, 이상한 크래시를 잡을 때는 둘을 번갈아 씁니다.</p>

<h3 class="relative group">Zombie Objects
    <div id="zombie-objects" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#zombie-objects" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>해제된 객체로 보낸 메시지를 잡습니다. 켜두면 해제된 메모리를 건드리는 순간 Xcode가 정확히 알려줍니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">*** -[MyViewController viewDidLoad]: message sent to deallocated instance 0x600001234000</span></span></code></pre></div></div>
<p>계속 켜두면 안 됩니다. 객체가 해제되지 않아서 메모리가 계속 불어나거든요. 하지만 특정 use-after-free 버그를 집어낼 때는 최고입니다.</p>

<h2 class="relative group">흔한 디버깅 시나리오
    <div id="흔한-디버깅-시나리오" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%9d%94%ed%95%9c-%eb%94%94%eb%b2%84%ea%b9%85-%ec%8b%9c%eb%82%98%eb%a6%ac%ec%98%a4" aria-label="앵커">#</a>
    </span>
    
</h2>

<h3 class="relative group">실행하자마자 크래시
    <div id="실행하자마자-크래시" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8b%a4%ed%96%89%ed%95%98%ec%9e%90%eb%a7%88%ec%9e%90-%ed%81%ac%eb%9e%98%ec%8b%9c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>일단 예외 브레이크포인트부터 추가합니다. 보통 그걸로 잡힙니다.</p>
<p>안 잡히면 Info.plist에 빠진 키, 초기화 코드의 강제 언래핑, 의존성 주입 실패 순으로 의심해 봅니다.</p>

<h3 class="relative group">UI가 업데이트되지 않음
    <div id="ui가-업데이트되지-않음" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ui%ea%b0%80-%ec%97%85%eb%8d%b0%ec%9d%b4%ed%8a%b8%eb%90%98%ec%a7%80-%ec%95%8a%ec%9d%8c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>매번. 매번 이 중 하나입니다.</p>
<ol>
<li>업데이트가 메인 스레드에서 일어나지 않음</li>
<li>outlet이 연결되지 않음</li>
<li>뷰가 실제로는 화면에 없음(LLDB에서 <code>po view.window</code>로 확인, nil이면 계층에 없는 겁니다)</li>
</ol>
<p>테이블 뷰나 컬렉션 뷰라면 <code>reloadData()</code> 호출을 잊은 거고요.</p>

<h3 class="relative group">메모리 경고로 앱이 죽음
    <div id="메모리-경고로-앱이-죽음" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a9%94%eb%aa%a8%eb%a6%ac-%ea%b2%bd%ea%b3%a0%eb%a1%9c-%ec%95%b1%ec%9d%b4-%ec%a3%bd%ec%9d%8c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Instruments Allocations로 뭐가 메모리를 붙잡고 있는지 봅니다. 보통은 캐시에서 해제되지 않는 이미지, 해제 안 되는 뷰 컨트롤러(retain cycle), 끝없이 자라는 배열이나 딕셔너리 중 하나입니다.</p>
<p>시뮬레이터에서 메모리 경고를 시뮬레이션해서 정리 코드를 테스트해 두세요.</p>

<h3 class="relative group">원인 모를 크래시
    <div id="원인-모를-크래시" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%9b%90%ec%9d%b8-%eb%aa%a8%eb%a5%bc-%ed%81%ac%eb%9e%98%ec%8b%9c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>sanitizer를 전부 켭니다. 간헐적이면 대개 스레딩 문제니 Thread Sanitizer로 돌리고, 시스템 프레임워크 안에서 죽는다면 대개 메모리 손상이니 Address Sanitizer로 돌립니다.</p>

<h3 class="relative group">재현되지 않는 크래시
    <div id="재현되지-않는-크래시" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%9e%ac%ed%98%84%eb%90%98%ec%a7%80-%ec%95%8a%eb%8a%94-%ed%81%ac%eb%9e%98%ec%8b%9c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>크래시가 남의 폰에서 났더라도 리포트는 대개 어딘가에 남아 있습니다. TestFlight와 App Store 빌드라면 Xcode의 Organizer(Window → Organizer → Crashes)에 분석 데이터 공유에 동의한 사용자들의 크래시 리포트가 모입니다. 빌드와 함께 dSYM을 올렸다면 심볼리케이션까지 된 상태로요. 테스터의 폰이라면 리포트가 기기 안에 그대로 있습니다. 설정 → 개인정보 보호 및 보안 → 분석 및 향상 → 분석 데이터에 앱 이름으로 시작하는 <code>.ips</code> 파일이 있으니, AirDrop으로 하나 보내 달라고 하면 됩니다. 그다음은 크래시가 난 스레드의 스택 트레이스에서 출발해 위의 시나리오를 차례로 짚어 보세요.</p>

<h3 class="relative group">시뮬레이터에서 OTA 업데이트 디버깅
    <div id="시뮬레이터에서-ota-업데이트-디버깅" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8b%9c%eb%ae%ac%eb%a0%88%ec%9d%b4%ed%84%b0%ec%97%90%ec%84%9c-ota-%ec%97%85%eb%8d%b0%ec%9d%b4%ed%8a%b8-%eb%94%94%eb%b2%84%ea%b9%85" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>제 앱은 OTA(over-the-air) 업데이트를 내보내는데, 이 플로우 전체를 디버깅하기에는 시뮬레이터가 제일 편합니다.</p>
<p>먼저 네트워크 트래픽부터 봅니다. 앱 서브시스템으로 필터링한 Console.app에 매니페스트 요청과 응답이 실시간으로 흘러가니, 어떤 헤더가 나가고 서버가 뭘 돌려주는지 정확히 보입니다.</p>
<p>다음엔 일부러 네트워크를 나쁘게 만듭니다. Network Link Conditioner로 느린 연결이나 패킷 손실에서 업데이트 플로우가 어떻게 버티는지 확인합니다. 앱이 멈추는지, 로딩 인디케이터는 뜨는지, 제대로 폴백하는지.</p>
<p>업데이트 체크 로직 주변에는 로그를 심어둡니다. 체크 시작, 업데이트 발견, 다운로드 시작, 새 번들 로드, 각 시점마다 남깁니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">os</span><span class="p">.</span><span class="nc">log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">logger</span> <span class="p">=</span> <span class="n">Logger</span><span class="p">(</span><span class="n">subsystem</span><span class="p">:</span> <span class="s">&#34;com.example.app&#34;</span><span class="p">,</span> <span class="n">category</span><span class="p">:</span> <span class="s">&#34;updates&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="s">&#34;Checking for updates...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">update</span> <span class="p">=</span> <span class="k">try</span> <span class="n">await</span> <span class="n">Updates</span><span class="p">.</span><span class="n">checkForUpdateAsync</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="s">&#34;Update available: </span><span class="si">\(</span><span class="n">update</span><span class="p">.</span><span class="n">isAvailable</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>마지막으로 매니페스트 엔드포인트 자체도 따로 테스트해 봅니다. 앱이 보내는 것과 같은 헤더로 직접 curl 하면 됩니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -H <span class="s2">&#34;expo-protocol-version: 1&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     -H <span class="s2">&#34;expo-platform: ios&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     -H <span class="s2">&#34;expo-runtime-version: 1.0.0&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     https://updates.example.com/manifest</span></span></code></pre></div></div>
<p>클라이언트 쪽을 파기 전에 서버가 올바른 데이터를 주고 있는지 여기서 먼저 확인할 수 있습니다.</p>

<h2 class="relative group">서드파티 도구
    <div id="서드파티-도구" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%84%9c%eb%93%9c%ed%8c%8c%ed%8b%b0-%eb%8f%84%ea%b5%ac" aria-label="앵커">#</a>
    </span>
    
</h2>

<h3 class="relative group">Charles Proxy / Proxyman
    <div id="charles-proxy--proxyman" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#charles-proxy--proxyman" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>네트워크 트래픽 전체 보기, 요청과 응답 수정, 오류 조건 테스트. API 디버깅에는 필수입니다.</p>
<p>Proxyman이 Charles보다 UI가 낫고 macOS에서 더 자연스럽습니다. 작년에 갈아탄 뒤로 돌아갈 생각이 없습니다.</p>

<h3 class="relative group">Reveal
    <div id="reveal" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#reveal" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Xcode 뷰 디버거와 비슷하지만 더 강력합니다. Xcode에 연결하지 않아도 실기기에서 동작해서, 테스터 기기의 레이아웃 문제를 잡을 때 좋습니다.</p>

<h2 class="relative group">시뮬레이터와 실기기의 차이
    <div id="시뮬레이터와-실기기의-차이" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8b%9c%eb%ae%ac%eb%a0%88%ec%9d%b4%ed%84%b0%ec%99%80-%ec%8b%a4%ea%b8%b0%ea%b8%b0%ec%9d%98-%ec%b0%a8%ec%9d%b4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>디버깅은 대부분 시뮬레이터에서 합니다. 빠르거든요. 케이블도, 서명도, 설치 대기도 없습니다. 하지만 시뮬레이터는 Mac의 프로세서 위에서 Mac의 메모리와 Mac의 네트워크로 돌아가는 iOS 빌드일 뿐이고, 어떤 버그는 폰에만 있습니다.</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>시뮬레이터</th>
					<th>실제 iPhone</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>속도</td>
					<td>Mac CPU로 돌아서 대개 폰보다 빠름</td>
					<td>실제 수치. 성능 프로파일링은 여기서</td>
			</tr>
			<tr>
					<td>메모리</td>
					<td>사실상 한도가 없어서 메모리 경고를 시뮬레이션해야 함</td>
					<td>너무 많이 쓰면 시스템이 앱을 종료</td>
			</tr>
			<tr>
					<td>네트워크</td>
					<td>Mac의 연결 그대로</td>
					<td>셀룰러, 약한 신호, Wi-Fi와 모바일 데이터 사이의 전환</td>
			</tr>
			<tr>
					<td>카메라, 블루투스, NFC</td>
					<td>쓸 수 없음</td>
					<td>쓸 수 있음</td>
			</tr>
			<tr>
					<td>푸시 알림</td>
					<td><code>xcrun simctl push</code>로 흉내 낼 수 있음</td>
					<td>사용자가 받는 그대로 실제 전달</td>
			</tr>
			<tr>
					<td>반복 속도</td>
					<td>몇 초</td>
					<td>느림: 서명, 설치, 케이블이나 Wi-Fi</td>
			</tr>
	</tbody>
</table>
<p>그래서 로직은 시뮬레이터에서 만들고 디버깅하고, 릴리스 전에는 아직 지원하는 가장 오래된 iPhone에서 앱을 돌려 봅니다. 성능, 메모리 압박, 하드웨어를 건드리는 기능은 기기에서 돌려 보기 전까지는 제대로 테스트했다고 할 수 없습니다.</p>

<h3 class="relative group">실기기에서 실행하기
    <div id="실기기에서-실행하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8b%a4%ea%b8%b0%ea%b8%b0%ec%97%90%ec%84%9c-%ec%8b%a4%ed%96%89%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>iPhone을 연결해 실행 대상으로 고르고, Signing &amp; Capabilities에서 팀을 지정합니다(내 폰에 설치하는 정도면 무료 Apple ID로 충분합니다). iOS 16부터는 폰에서도 설정 → 개인정보 보호 및 보안에서 개발자 모드를 켜야 하고, 처음 켤 때 한 번 재시동됩니다. 개발자 모드가 켜지면 폰의 설정 → 개발자 메뉴에 Network Link Conditioner가 생기는데, Mac 버전과 똑같은 나쁜 네트워크 프로필이 들어 있습니다.</p>

<h3 class="relative group">무선 디버깅
    <div id="무선-디버깅" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%ac%b4%ec%84%a0-%eb%94%94%eb%b2%84%ea%b9%85" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>USB로 기기를 한 번만 연결하고, Window → Devices and Simulators에서 &ldquo;Connect via network&quot;에 체크한 뒤 선을 뽑습니다. 같은 WiFi에 있는 한 기기는 Xcode에 계속 잡혀 있습니다.</p>

<h3 class="relative group">Device Console
    <div id="device-console" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#device-console" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Window → Devices and Simulators → Open Console. 시스템 로그, 앱 로그, 크래시 리포트까지 전부 보입니다. Xcode 콘솔보다 훨씬 상세합니다.</p>
<p>테스터가 &ldquo;앱이 죽었어요&quot;라고 하면 저는 꼭 기기를 연결해서 콘솔 로그를 받아봅니다. 모든 걸 설명해 주는 시스템 레벨 오류가 남아 있는 경우가 많거든요.</p>

<h2 class="relative group">실제 디버깅 순서
    <div id="실제-디버깅-순서" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8b%a4%ec%a0%9c-%eb%94%94%eb%b2%84%ea%b9%85-%ec%88%9c%ec%84%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<ol>
<li>예외 브레이크포인트가 없으면 추가</li>
<li>버그 재현</li>
<li>문제로 의심되는 곳 근처에 브레이크포인트 설정</li>
<li>LLDB에서 <code>po</code>로 값 확인</li>
<li>변수를 바꿔가며 재컴파일 없이 수정 시험</li>
<li>UI 문제면 뷰 계층 확인</li>
<li>성능 문제면 Instruments로 프로파일</li>
<li>메모리나 스레딩 문제면 sanitizer 켜기</li>
</ol>
<p>요령은 증상에서 거꾸로 짚어가는 겁니다. 크래시? 예외 브레이크포인트. 느림? Time Profiler. 메모리? Instruments. UI가 이상함? 뷰 디버거.</p>

<h2 class="relative group">더 일찍 알았으면 좋았을 것들
    <div id="더-일찍-알았으면-좋았을-것들" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%8d%94-%ec%9d%bc%ec%b0%8d-%ec%95%8c%ec%95%98%ec%9c%bc%eb%a9%b4-%ec%a2%8b%ec%95%98%ec%9d%84-%ea%b2%83%eb%93%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>assertion을 쓰세요. 로직 오류가 버그가 되기 전에 잡힙니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="bp">assert</span><span class="p">(</span><span class="n">users</span><span class="p">.</span><span class="bp">count</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span> <span class="s">&#34;Users array should never be empty here&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>디버깅을 시작하기 전에 커밋해 두세요. 가설을 시험한다고 이것저것 고치다 보면 반드시 되돌리고 싶어집니다.</p>
<p>끝나면 print 문은 지우세요. 최소한 <code>#if DEBUG</code>로 감싸두기라도 하고요. 묵은 디버그 로그는 다음 버그를 가립니다.</p>
<p>LLDB는 제대로 익혀두세요. 앱을 스무 번 리빌드하는 것보다 빠릅니다.</p>
<p>그리고 시뮬레이터는 진짜 폰이 아닙니다. 기기에서만 나는 버그는 거의 항상 스레딩 아니면 메모리 문제입니다. 아니면 성능 문제거나요. 시뮬레이터는 진짜 iPhone 칩이 아니라 Mac CPU로 돌아가니까요.</p>

<h2 class="relative group">늘 열어두는 도구
    <div id="늘-열어두는-도구" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%8a%98-%ec%97%b4%ec%96%b4%eb%91%90%eb%8a%94-%eb%8f%84%ea%b5%ac" aria-label="앵커">#</a>
    </span>
    
</h2>
<ul>
<li>Xcode(당연히)</li>
<li>앱 서브시스템으로 필터링한 Console.app</li>
<li>네트워크 디버깅용 Proxyman</li>
<li>상황이 심각해지면 Instruments</li>
</ul>
<hr>
<p>어느 도구도 만능은 아닙니다. 뷰 디버거는 메모리 누수를 못 찾고, Instruments는 Auto Layout 충돌을 풀어주지 못합니다. 결국 실력은 증상에 맞는 도구를 고르는 데서 갈리고, 그건 횟수를 쌓아야 늡니다. retain cycle을 몇 번 밟다 보면 스택 트레이스만 봐도 냄새가 나기 시작하죠. 디버깅이 재미있어지는 날은 아마 안 오겠지만, 적어도 고역은 아니게 됩니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-02-26-debugging-ios-mobile-apps/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
