<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Jo&amp;atilde;o Jin - 모바일 &amp;middot; 보안 &amp;middot; AI</title>
    <link>https://yjcho9317.tistory.com/</link>
    <description>모바일 &amp;middot; 보안 &amp;middot; AI 기록</description>
    <language>ko</language>
    <pubDate>Tue, 2 Jun 2026 15:34:41 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Jo&amp;atilde;o Jin</managingEditor>
    <image>
      <title>Jo&amp;atilde;o Jin - 모바일 &amp;middot; 보안 &amp;middot; AI</title>
      <url>https://tistory1.daumcdn.net/tistory/8535283/attach/6ab9ee2bb1aa4c5d94b8f40904ab8ad4</url>
      <link>https://yjcho9317.tistory.com</link>
    </image>
    <item>
      <title>Android NDK 입문 (4) - native 크래시 디버깅 방법, addr2line과 Ghidra</title>
      <link>https://yjcho9317.tistory.com/30</link>
      <description>&lt;p&gt;3편까지는 어떤 크래시가 있고 어떻게 막는지를 다뤘다.&lt;/p&gt;
&lt;p&gt;그런데 운영 중 더 자주 부딪히는 건 이미 크래시가 난 상태에서 원인을 찾는 일이다.&lt;/p&gt;
&lt;p&gt;특히 막막한 시나리오가 있다.&lt;/p&gt;
&lt;p&gt;release 빌드, 사용자 단말, Crashlytics에서 받은 native 크래시 로그, 심볼은 stripped 상태.&lt;/p&gt;
&lt;p&gt;손에 있는 건 이렇게 생긴 시그널 번호와 fault 주소, 그리고 주소만 찍힌 backtrace뿐이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
backtrace:
  #00 pc 0000000000041a70  /system/lib64/libc.so
  #01 pc 00000000000123bc  /data/app/com.example/lib/arm64/libnative.so
  #02 pc 0000000000008f10  /data/app/com.example/lib/arm64/libnative.so&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;자바만 보던 사람은 이거 보면 막막하다.&lt;/p&gt;
&lt;p&gt;이 상태에서 원인을 찾으려면 주소를 소스 코드 위치로 매핑해야 한다.&lt;/p&gt;
&lt;p&gt;이번 편은 그 워크플로다.&lt;/p&gt;
&lt;p&gt;logcat 읽기에서 시작해 심볼 매핑, 그게 안 되면 Ghidra까지 간다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/358bd846-90ba-4093-809c-e363f55aee72/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;이 글은 위 흐름도의 1단계부터 3단계까지를 순서대로 따라간다.&lt;/p&gt;
&lt;h2&gt;1단계: logcat 읽기&lt;/h2&gt;
&lt;p&gt;크래시가 나면 가장 먼저 보이는 정보가 logcat임.&lt;/p&gt;
&lt;p&gt;native 크래시의 첫 단서임.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;F libc    : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR),
            fault addr 0x0 in tid 14793 (mqt_js), pid 14724&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 첫 줄에서 읽어야 할 것은 네 가지임.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;시그널 번호와 이름을 봄. SIGSEGV면 메모리 접근 오류, SIGABRT면 의도적 종료, SIGBUS면 정렬 문제.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;code&lt;/code&gt; 값을 봄. &lt;code&gt;SEGV_MAPERR&lt;/code&gt;는 매핑되지 않은 주소, &lt;code&gt;SEGV_ACCERR&lt;/code&gt;는 권한 없음, &lt;code&gt;BUS_ADRALN&lt;/code&gt;은 정렬 오류.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fault addr&lt;/code&gt;를 봄. &lt;code&gt;0x0&lt;/code&gt; 근처면 NULL 역참조 가능성이 큼. 큰 주소면 손상된 포인터를 의심.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tid&lt;/code&gt;와 &lt;code&gt;pid&lt;/code&gt;를 봄. 메인 스레드인지 JNI에서 만든 스레드인지 가려냄.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그다음 줄들이 backtrace임.&lt;/p&gt;
&lt;p&gt;어떤 라이브러리의 어떤 주소에서 죽었는지 보여줌.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;backtrace:
  #00 pc 0000000000008f10  /data/app/com.example/lib/arm64/libnative.so
  #01 pc 0000000000009124  /data/app/com.example/lib/arm64/libnative.so&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;pc&lt;/code&gt; 값이 프로그램 카운터임.&lt;/p&gt;
&lt;p&gt;그 시점에 실행 중이던 명령어의 주소임.&lt;/p&gt;
&lt;p&gt;디버그 빌드면 함수 이름이 같이 찍히고, release 빌드는 주소만 찍힘.&lt;/p&gt;
&lt;p&gt;backtrace는 위에서 아래로 호출 스택임.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;#00&lt;/code&gt;이 가장 안쪽 실제 죽은 위치고, 아래로 갈수록 호출한 쪽임.&lt;/p&gt;
&lt;p&gt;backtrace에서 내 코드를 찾으려면 줄을 세 종류로 갈라 봐야 함.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;libc.so&lt;/code&gt;, &lt;code&gt;libart.so&lt;/code&gt; 같은 시스템 라이브러리 줄임. 시그널 처리나 JVM 런타임이라 그냥 지나감.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;libnative.so&lt;/code&gt; 같은 내 native 라이브러리 줄임. 여기가 진짜 원인임. &lt;code&gt;Java_com_example_...&lt;/code&gt; 같은 함수 이름이 보이면 그게 출발점임.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MainActivity.onClick&lt;/code&gt; 같은 Java/Kotlin 호출 경로임. 내가 어디서 native를 불렀는지 알려줌.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/36f1572d-076c-49aa-a4c0-27ba07c22ab8/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;경험상, 추적은 순서가 있음.&lt;/p&gt;
&lt;p&gt;먼저 내 라이브러리 줄을 찾음.&lt;/p&gt;
&lt;p&gt;그 함수가 무슨 동작이었는지 봄.&lt;/p&gt;
&lt;p&gt;그다음 위쪽 Java 호출 경로로 맥락을 잡음.&lt;/p&gt;
&lt;p&gt;debug 빌드에서는 함수명까지 자동 심볼화되어 &lt;code&gt;libcrash.so (Java_..._triggerHeapOverflow+189)&lt;/code&gt;처럼 찍히는 경우가 많음.&lt;/p&gt;
&lt;p&gt;함수 시작에서 몇 바이트 떨어졌는지(&lt;code&gt;+189&lt;/code&gt;)까지 찍힘.&lt;/p&gt;
&lt;p&gt;이 오프셋이 다음 단계에서 정확한 명령어를 찾는 데 쓰임.&lt;/p&gt;
&lt;p&gt;여기서 한 가지 짚을 게 있음.&lt;/p&gt;
&lt;p&gt;native 크래시가 나면 Android 시스템이 같은 정보를 &lt;code&gt;/data/tombstones/&lt;/code&gt;에 tombstone 파일로 저장함.&lt;/p&gt;
&lt;p&gt;일반 사용자 단말에서는 접근이 막혀 있음.&lt;/p&gt;
&lt;p&gt;대신 Firebase Crashlytics 같은 서비스가 이걸 자동 수집하고 심볼화해서 보여줌.&lt;/p&gt;
&lt;p&gt;사용자 단말 크래시는 보통 Crashlytics 대시보드로 확인함.&lt;/p&gt;
&lt;p&gt;매핑이 깨졌거나 메모리 맵까지 봐야 하는 케이스에서만 tombstone 원본을 직접 엶.(가능하면)&lt;/p&gt;
&lt;h2&gt;2단계: 심볼 매핑 (addr2line, ndk-stack)&lt;/h2&gt;
&lt;p&gt;1단계에서 주소만 손에 쥐었으면, 이제 그 주소를 소스 코드 위치로 매핑할 차례임.&lt;/p&gt;
&lt;p&gt;먼저 디버그 심볼이 어디 있는지부터 알아야 함.&lt;/p&gt;
&lt;p&gt;release 빌드의 &lt;code&gt;.so&lt;/code&gt;는 보통 stripped 상태라 함수 이름도 라인 정보도 없음.&lt;/p&gt;
&lt;p&gt;매핑하려면 strip 전의 unstripped 라이브러리가 따로 필요함.&lt;/p&gt;
&lt;p&gt;AGP(Android Gradle Plugin)로 빌드했다면 이 경로에 unstripped &lt;code&gt;.so&lt;/code&gt;가 있음.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;프로젝트&amp;gt;/build/intermediates/cxx/&amp;lt;build-type&amp;gt;/&amp;lt;hash&amp;gt;/obj/&amp;lt;abi&amp;gt;/&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 파일은 빌드한 사람의 머신에 보관되어 있어야 함.&lt;/p&gt;
&lt;p&gt;빌드 산출물을 안 보관해두면 나중에 같은 빌드를 재현 못 함.&lt;/p&gt;
&lt;p&gt;이게 가장 흔한 실수임.&lt;/p&gt;
&lt;p&gt;그래서 빌드마다 unstripped &lt;code&gt;.so&lt;/code&gt;를 별도 디렉토리에 보관해두는 게 정석임.&lt;/p&gt;
&lt;p&gt;심볼이 준비됐으면 &lt;code&gt;ndk-stack&lt;/code&gt;이 가장 간단함.&lt;/p&gt;
&lt;p&gt;logcat 출력을 받아 심볼화된 backtrace로 바꿔주는 도구임.&lt;/p&gt;
&lt;p&gt;NDK에 기본 포함되어 있음.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;adb logcat | ndk-stack -sym ./obj/arm64-v8a&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;파일로 저장한 뒤 처리할 수도 있음.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;adb logcat &amp;gt; crash.txt
ndk-stack -sym ./obj/arm64-v8a -dump crash.txt&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과로 함수 이름과 파일:라인이 같이 찍힘.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#00 pc 0000000000008f10  /data/app/.../libnative.so
    process_data
    /Users/me/project/src/processor.cpp:142&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;특정 주소 하나만 매핑하고 싶으면 &lt;code&gt;addr2line&lt;/code&gt;을 씀.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line \
    -e ./obj/arm64-v8a/libnative.so \
    -f -C 0x8f10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-e&lt;/code&gt;는 매핑 대상 라이브러리, &lt;code&gt;-f&lt;/code&gt;는 함수 이름까지 출력, &lt;code&gt;-C&lt;/code&gt;는 C++ 심볼 demangle 옵션임.&lt;/p&gt;
&lt;p&gt;backtrace의 &lt;code&gt;pc&lt;/code&gt; 값은 보통 이미 라이브러리 내부 상대 오프셋으로 찍혀 있음.&lt;/p&gt;
&lt;p&gt;logcat이나 tombstone이 라이브러리 시작 주소를 빼서 표시하기 때문임.&lt;/p&gt;
&lt;p&gt;그래서 그 값을 그대로 addr2line에 넘기면 됨.&lt;/p&gt;
&lt;p&gt;대부분의 native 크래시는 ndk-stack 한 번이면 함수와 라인까지 나옴.&lt;/p&gt;
&lt;p&gt;함수 이름이 보이면 그 함수 코드를 보며 어떤 입력이 어떤 사고를 만들었는지 추적하면 됨.&lt;/p&gt;
&lt;h2&gt;3단계: 도구가 안 풀 때 Ghidra&lt;/h2&gt;
&lt;p&gt;다음 상황에서는 2단계 도구가 한계에 부딪힘.&lt;/p&gt;
&lt;p&gt;여기서 막히기 쉬움.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;unstripped 라이브러리를 분실한 경우. 빌드 산출물을 안 보관해 매핑할 심볼이 없음.&lt;/li&gt;
&lt;li&gt;외부 prebuilt 라이브러리에서 죽은 경우. 회사 SDK나 third-party 라이브러리는 우리 빌드 산출물이 아니라 심볼 자체가 없음.&lt;/li&gt;
&lt;li&gt;이미 release로 배포된 SDK라 어디서 죽는지 봐야 하는데 빌드 환경이 없는 경우.&lt;/li&gt;
&lt;li&gt;inline이나 LTO 최적화가 적용된 경우. 함수가 원본 형태로 존재하지 않아 addr2line이 최선의 추측만 함.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이런 경우엔 디스어셈블 단계로 내려가야 함.&lt;/p&gt;
&lt;p&gt;그게 Ghidra 영역.&lt;/p&gt;
&lt;p&gt;Ghidra는 디스어셈블러이자 디컴파일러.&lt;/p&gt;
&lt;p&gt;stripped된 &lt;code&gt;.so&lt;/code&gt;를 디스어셈블하고 pseudo-C 코드로 디컴파일해줌.&lt;/p&gt;
&lt;p&gt;기계어를 읽을 수 있는 C 비슷한 언어로 옮겨주는 번역기에 가까움.&lt;/p&gt;
&lt;p&gt;완벽한 번역은 아니지만 코드 구조는 드러남.&lt;/p&gt;
&lt;p&gt;Ghidra 기본 워크플로는 이 순서임.&lt;/p&gt;
&lt;p&gt;각 단계의 메뉴 위치를 그대로 따라가면 됨.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;APK에서 &lt;code&gt;lib/arm64-v8a/libnative.so&lt;/code&gt;를 추출함. APK는 ZIP이라 unzip으로 풀림.&lt;/li&gt;
&lt;li&gt;Ghidra를 실행하고 &lt;code&gt;File → New Project&lt;/code&gt;로 프로젝트를 생성함.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;File → Import File&lt;/code&gt;로 &lt;code&gt;.so&lt;/code&gt;를 로드함. Format과 Language가 자동 감지됐는지 확인함.&lt;/li&gt;
&lt;li&gt;프로젝트 창에서 import한 파일을 더블 클릭하면 Code Browser가 열리고, Auto-analyze를 진행할지 묻는 프롬프트가 뜸. 진행함.&lt;/li&gt;
&lt;li&gt;크래시 위치로 점프함. 점프 방법은 아래에서 따로 다룸.&lt;/li&gt;
&lt;li&gt;우측 Decompile 패널에서 해당 함수의 디컴파일 결과를 봄.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;크래시 위치로 점프할 때 한 가지 함정이 있음.&lt;/p&gt;
&lt;p&gt;backtrace의 &lt;code&gt;pc&lt;/code&gt; 값을 Ghidra Go To에 그대로 넣으면 보통 못 찾음.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;pc&lt;/code&gt;는 실행 시점의 메모리 주소인데 Ghidra가 보는 건 &lt;code&gt;.so&lt;/code&gt; 파일 안의 주소라 다르기 때문임.&lt;/p&gt;
&lt;p&gt;그래서 함수 시작 주소에 오프셋을 더하는 방식을 씀.&lt;/p&gt;
&lt;p&gt;backtrace가 이렇게 찍혔다고 하면:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#06 pc 0000000000003480  libcrash.so
    (Java_com_example_..._triggerHeapOverflow+189)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;다음 순서로 점프함.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Symbol Tree에서 &lt;code&gt;Java_..._triggerHeapOverflow&lt;/code&gt; 함수를 찾아 더블 클릭함.&lt;/li&gt;
&lt;li&gt;Listing 패널이 그 함수 시작 위치로 점프함. 함수 시작 주소를 확인함. 예를 들어 &lt;code&gt;0x10033c0&lt;/code&gt;임.&lt;/li&gt;
&lt;li&gt;backtrace의 오프셋 &lt;code&gt;+189&lt;/code&gt;는 십진수임. 16진수로 바꾸면 &lt;code&gt;0xBD&lt;/code&gt;임.&lt;/li&gt;
&lt;li&gt;함수 시작 주소에 오프셋을 더함. &lt;code&gt;0x10033c0 + 0xBD = 0x100347D&lt;/code&gt;임.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Navigation → Go To...&lt;/code&gt; 메뉴, 단축키 &lt;code&gt;G&lt;/code&gt;로 그 주소를 입력함.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/3871a94e-793a-4b3d-aa5b-b2621acf45e7/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;이렇게 점프하면 backtrace에 찍힌 그 명령어를 정확히 볼 수 있음.&lt;/p&gt;
&lt;p&gt;함수가 짧으면 오프셋 없이 함수 전체 디컴파일로도 추정됨.&lt;/p&gt;
&lt;p&gt;큰 함수에서는 정확한 명령어 위치가 중요해짐.&lt;/p&gt;
&lt;p&gt;디컴파일 결과는 이렇게 나옴.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;void FUN_00008f00(int *param_1) {
    int local_8;
    local_8 = *param_1;     // 0x8f10. param_1이 NULL이면 여기서 SIGSEGV
    process_value(local_8);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;함수 이름은 주소 기반 자동 이름(&lt;code&gt;FUN_00008f00&lt;/code&gt;)으로 나오지만 코드 구조는 보임.&lt;/p&gt;
&lt;p&gt;어떤 변수가 어디서 왔는지, 어떤 함수를 호출하는지, 왜 그 주소에서 죽었는지 추적 가능함.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/aa4b6a86-b15d-4088-90df-4e347c8aa439/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;ASan이 켜진 빌드에서는 디스어셈블 결과에 원본 코드에 없던 코드가 같이 보임.&lt;/p&gt;
&lt;p&gt;메모리 쓰기 한 줄을 분석하려고 어셈블리를 보면, 그 쓰기 명령어 앞에 shadow memory 조회와 안전 검사가 끼어 있음.&lt;/p&gt;
&lt;p&gt;3편에서 설명한 &amp;quot;컴파일러가 모든 메모리 접근에 검사 코드를 삽입한다&amp;quot;는 ASan 원리가 어셈블리 레벨에서 드러나는 모습임.&lt;/p&gt;
&lt;p&gt;JNI 함수에서 죽었다면 추적이 더 쉬워짐.&lt;/p&gt;
&lt;p&gt;native 라이브러리의 JNI 함수는 &lt;code&gt;Java_com_example_MyClass_processData&lt;/code&gt;처럼 Java 클래스명과 메서드명 기반으로 자동 이름이 붙음.&lt;/p&gt;
&lt;p&gt;이 이름이 stripped 후에도 남음.&lt;/p&gt;
&lt;p&gt;JNI 호환 때문임.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;System.loadLibrary()&lt;/code&gt; 이후 Java가 native 메서드를 호출하려면 런타임에 함수 이름으로 찾아 동적 링크해야 함.&lt;/p&gt;
&lt;p&gt;그래서 컴파일러가 JNI 함수를 export 심볼로 박아두고, strip 도구도 이 export 심볼은 안 건드림.&lt;/p&gt;
&lt;p&gt;결국 stripped 라이브러리에서도 JNI 함수 이름은 그대로 남음.&lt;/p&gt;
&lt;p&gt;Ghidra 함수 목록에서 바로 찾을 수 있음.&lt;/p&gt;
&lt;p&gt;이 단계까지 오면 심볼 없는 release 빌드에서도 원인 추적이 됨.&lt;/p&gt;
&lt;p&gt;다만 시간이 더 듦.&lt;/p&gt;
&lt;p&gt;addr2line이 30초라면 Ghidra는 30분 단위임.&lt;/p&gt;
&lt;h2&gt;마무리 메모&lt;/h2&gt;
&lt;p&gt;이번 편에서 알아갈 것은 CLAUDE한테 &amp;#39;해줘&amp;#39; 라고 하지말고. &amp;#39;Ghidra로 분석 해줘&amp;#39; 라고 하면 된다는 것만 알고가자.&lt;br&gt;일반적인 개발자가 이렇게 까지 분석하는 것은 어렵다.&lt;br&gt;나는 보안 개발을 하다보니 리버싱 엔지니어링 할 일이 많고 ghidra가 익숙해서 이렇게 한다.&lt;br&gt;보통 이렇게 안할 수도 있다.&lt;br&gt;이번 편은 내 노하우에 가깝다고 할 수 있다.&lt;/p&gt;
&lt;h2&gt;시리즈를 마치며&lt;/h2&gt;
&lt;p&gt;시리즈에서 다룬 말은 결국 Java/Kotlin이 알아서 해주던 것을 native에서는 내가 의식해야 한다는 것이다.&lt;/p&gt;
&lt;p&gt;Reference 정리는 GC가 하던 일이고, 메모리 안전은 JVM이 하던 일이고, 크래시 정보는 stack trace가 다 찍어주던 것이다.&lt;/p&gt;
&lt;p&gt;생각해보면 NDK가 어렵다는 말은 어느 정도 오해다.&lt;/p&gt;
&lt;p&gt;나도 앱 개발에 더 익숙하고, 자바/코틀린이 좋으며, C/C++ 잘 모르고, NDK 개발도 아직 잘 못한다.&lt;/p&gt;
&lt;p&gt;근데 NDK를 다뤄보면 그냥 자바/코틀린에 익숙한 요즘 개발자가 나약한거 아닌가 생각도 든다.&lt;/p&gt;
&lt;p&gt;그래서 나오는 native 크래시들을 보면 내게 이러는 것 같다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/f280f6d0-c9b5-49c3-a744-63780eecbdf5/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;ndk-stack (Android NDK 공식 문서) - &lt;a href=&quot;https://developer.android.com/ndk/guides/ndk-stack&quot;&gt;https://developer.android.com/ndk/guides/ndk-stack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Ghidra (공식 사이트) - &lt;a href=&quot;https://ghidra-sre.org&quot;&gt;https://ghidra-sre.org&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>모바일</category>
      <category>addr2line</category>
      <category>Android</category>
      <category>Debugging</category>
      <category>ghidra</category>
      <category>JNI</category>
      <category>Native</category>
      <category>Native Crash</category>
      <category>ndk</category>
      <category>ndk-stack</category>
      <category>안드로이드 NDK</category>
      <author>Jo&amp;atilde;o Jin</author>
      <guid isPermaLink="true">https://yjcho9317.tistory.com/30</guid>
      <comments>https://yjcho9317.tistory.com/30#entry30comment</comments>
      <pubDate>Sun, 24 May 2026 00:25:06 +0900</pubDate>
    </item>
    <item>
      <title>Android NDK 입문 (3) - AddressSanitizer, 스택 카나리 알고 네이티브 크래시 미리 막기</title>
      <link>https://yjcho9317.tistory.com/29</link>
      <description>&lt;p&gt;얼마 전 운영중인 앱이 native 코드 문제로 런타임에 죽었다.&lt;/p&gt;
&lt;p&gt;버퍼를 다루는 함수였다.&lt;/p&gt;
&lt;p&gt;Ghidra로 디스어셈블해 들어가다 &lt;code&gt;__stack_chk_fail&lt;/code&gt; 호출 경로를 발견했다.&lt;/p&gt;
&lt;p&gt;스택 카나리가 buffer overflow를 잡아 죽인 거였다.&lt;/p&gt;
&lt;p&gt;크래시는 한 곳에서 막는 게 아니라 여러 층에서 걸러진다.&lt;/p&gt;
&lt;p&gt;카나리가 잡은 건 그중 한 층이었다.&lt;/p&gt;
&lt;p&gt;이번 편은 나도 공부하면서 그 층들을 정리해본다.&lt;/p&gt;
&lt;p&gt;미리 말하자면, 이번 편은 가볍게 읽고 넘어가도 된다. 이유는 이 것들을 본 순간 이미 당신의 앱은 죽어있다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/c9c6528a-d107-41ed-9bc8-bf3138d54662/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;본론으로 들어가, 2편에서 본 크래시들(SIGSEGV, SIGABRT, SIGBUS 등)을 막는 방어는 세 층으로 나뉜다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;1층은 컴파일러와 런타임이 자동으로 해주는 것. NDK 빌드 시 이미 켜져 있음.&lt;/li&gt;
&lt;li&gt;2층은 도구로 잡는 것. 내가 빌드에 추가해야 함.&lt;/li&gt;
&lt;li&gt;3층은 코드 작성 시점에 내가 의식해야 하는 것. 도구로도 못 막는 영역.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;결국 이 방어 계층은 내 앱을 방어해주는게 아니라 시스템을 방어해 주는 방어다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/c0d65f9c-ad8d-4079-b55f-fe061bcbe48e/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;1층: 컴파일러와 런타임이 자동으로 해주는 것&lt;/h2&gt;
&lt;p&gt;NDK 빌드를 하면 내가 설정 안 해도 기본으로 켜지는 안전망이 있음.&lt;/p&gt;
&lt;p&gt;Clang이 자동으로 켜는 것과 Android 플랫폼이 기본 제공하는 것임.&lt;/p&gt;
&lt;p&gt;세 가지를 보자.&lt;/p&gt;
&lt;h3&gt;스택 카나리&lt;/h3&gt;
&lt;p&gt;도입부에서 내 앱을 죽인 게 이것임.&lt;/p&gt;
&lt;p&gt;스택 카나리는 함수 안에서 스택 변수 영역과 리턴 주소 사이에 랜덤 값을 끼워 넣는 안전망임.&lt;/p&gt;
&lt;p&gt;카나리라는 이름은 옛날 광부들이 갱도에 데려간 새에서 왔음.&lt;/p&gt;
&lt;p&gt;새가 유독가스에 먼저 쓰러지면 광부에게 대피 신호였음.&lt;/p&gt;
&lt;p&gt;이 값이 먼저 망가지면 overflow 신호인 것과 똑같음.&lt;/p&gt;
&lt;p&gt;스택 메모리 배치는 이렇게 생겼음.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/fc66f0a5-3ca9-4ed3-98c1-4700d9764cfa/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;함수 안에서 buffer overflow가 나면 스택 변수에서 리턴 주소 방향으로 메모리를 침범함.&lt;/p&gt;
&lt;p&gt;그 사이에 있는 카나리를 먼저 덮어쓰게 됨.&lt;/p&gt;
&lt;p&gt;함수 종료 시 카나리 값이 처음과 다른지 확인함.&lt;/p&gt;
&lt;p&gt;다르면 &lt;code&gt;__stack_chk_fail&lt;/code&gt;을 호출해 즉시 종료시킴.&lt;/p&gt;
&lt;p&gt;2편에서 SIGABRT 원인으로 언급한 &lt;code&gt;Abort message: &amp;#39;stack corruption detected&amp;#39;&lt;/code&gt;가 이것임.&lt;/p&gt;
&lt;p&gt;최근 Android NDK/Clang 환경에서는 &lt;code&gt;-fstack-protector-strong&lt;/code&gt;이 보통 기본 활성화되어 있음.&lt;/p&gt;
&lt;p&gt;빌드 옵션을 신경 안 써도 적용됨.&lt;/p&gt;
&lt;p&gt;다만 한계가 있음.&lt;/p&gt;
&lt;p&gt;스택 영역만 보고 힙 overflow는 못 잡음.&lt;/p&gt;
&lt;p&gt;함수 종료 시점에 검증하므로 함수 실행 중의 다른 메모리 손상은 막지 못함.&lt;/p&gt;
&lt;p&gt;발생을 막는 게 아니라 발생 후 감지해 죽이는 방식임.&lt;/p&gt;
&lt;h3&gt;PIE / ASLR&lt;/h3&gt;
&lt;p&gt;PIE/ASLR은 프로세스를 시작할 때마다 코드와 메모리 영역의 주소를 무작위화하는 기법임.&lt;/p&gt;
&lt;p&gt;특정 주소를 노린 공격을 막고 분석 비용을 올리는 기본 방어망임.&lt;/p&gt;
&lt;p&gt;최근 Android NDK 환경에서는 보통 기본 활성화되어 있음.&lt;/p&gt;
&lt;p&gt;내가 의식할 일은 거의 없음.&lt;/p&gt;
&lt;p&gt;다만 backtrace에 매번 다른 주소가 찍히는 이유가 이것임.&lt;/p&gt;
&lt;h3&gt;FORTIFY_SOURCE&lt;/h3&gt;
&lt;p&gt;FORTIFY_SOURCE는 &lt;code&gt;strcpy&lt;/code&gt;, &lt;code&gt;memcpy&lt;/code&gt;, &lt;code&gt;sprintf&lt;/code&gt; 같은 표준 함수의 경계 위반을 검사하는 기능임.&lt;/p&gt;
&lt;p&gt;컴파일러가 목적지 버퍼 크기를 알 수 있을 때 더 안전한 버전으로 자동 대체함.&lt;/p&gt;
&lt;p&gt;경계를 넘기면 이렇게 잡힘.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;char buf[10];
strcpy(buf, very_long_string);  // FORTIFY가 길이 초과 감지 시 abort&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Android libc(bionic)와 컴파일러 최적화 조건이 맞을 때 자동 적용됨.&lt;/p&gt;
&lt;p&gt;logcat에 &lt;code&gt;FORTIFY: strcpy: prevented write past end of buffer&lt;/code&gt; 같은 메시지와 함께 SIGABRT로 종료됨.&lt;/p&gt;
&lt;h3&gt;1층이 못 잡는 것&lt;/h3&gt;
&lt;p&gt;1층은 자동이라 편하지만 완전하지 않음.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;힙 overflow와 use-after-free는 못 잡음.&lt;/li&gt;
&lt;li&gt;정수 오버플로우가 작은 할당으로 이어지는 연쇄도 못 잡음.&lt;/li&gt;
&lt;li&gt;ARM 정렬 위반, JNI Reference 누수, 논리 오류도 1층 밖임.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;1층은 기본 안전망이지 완전 보호가 아님.&lt;/p&gt;
&lt;p&gt;더 잡고 싶으면 2층 도구를 추가해야 함.&lt;/p&gt;
&lt;h2&gt;2층: 도구로 잡는 것&lt;/h2&gt;
&lt;p&gt;내가 빌드에 추가해야 동작하는 도구들임.&lt;/p&gt;
&lt;p&gt;디버그 빌드에 켜놓으면 1층보다 훨씬 많은 사고를 잡아줌.&lt;/p&gt;
&lt;p&gt;단 성능 오버헤드가 있어 release 빌드엔 안 넣음.&lt;/p&gt;
&lt;p&gt;두 가지를 보자.&lt;/p&gt;
&lt;h3&gt;AddressSanitizer (ASan)&lt;/h3&gt;
&lt;p&gt;ASan은 컴파일러가 메모리 접근마다 검사 코드를 삽입하는 도구임.&lt;/p&gt;
&lt;p&gt;접근 가능 여부를 기록하는 shadow memory를 따로 유지함.&lt;/p&gt;
&lt;p&gt;잘못된 접근이 일어나는 순간 즉시 abort시킴.&lt;/p&gt;
&lt;p&gt;heap buffer overflow, use-after-free, double free, stack buffer overflow를 잡고 일부 메모리 누수도 탐지함.&lt;/p&gt;
&lt;p&gt;ASan을 켜는 전통적인 방법은 CMakeLists.txt에 직접 플래그를 넣는 것임.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cmake&quot;&gt;if(CMAKE_BUILD_TYPE STREQUAL &amp;quot;Debug&amp;quot;)
    target_compile_options(your_lib PRIVATE
        -fsanitize=address
        -fno-omit-frame-pointer
    )
    target_link_options(your_lib PRIVATE -fsanitize=address)
endif()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다만 이 CMake 설정만으로는 부족함.&lt;/p&gt;
&lt;p&gt;Android에서 ASan을 실제로 동작시키려면 ASan 런타임 라이브러리 패키징과 &lt;code&gt;wrap.sh&lt;/code&gt; 설정 같은 추가 단계가 필요함.&lt;/p&gt;
&lt;p&gt;이 수동 단계가 ASan 도입을 번거롭게 만드는 지점임.&lt;/p&gt;
&lt;p&gt;지금은 더 간단한 길이 있음.&lt;/p&gt;
&lt;p&gt;ASan은 2023년부로 deprecated 됐고, HWASan(Hardware-assisted ASan)이 대안으로 권장됨.&lt;/p&gt;
&lt;p&gt;HWASan은 같은 종류의 메모리 버그를 잡으면서 오버헤드가 더 적음.&lt;/p&gt;
&lt;p&gt;대신 ARM64 기기, Android 10 이상 같은 플랫폼 조건이 붙음.&lt;/p&gt;
&lt;p&gt;HWASan은 NDK 27 이상에서 &lt;code&gt;build.gradle&lt;/code&gt;만 고치면 됨.&lt;/p&gt;
&lt;p&gt;CMakeLists.txt는 건드릴 필요가 없음.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;android {
    defaultConfig {
        externalNativeBuild {
            cmake {
                arguments &amp;quot;-DANDROID_SANITIZE=hwaddress&amp;quot;
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;wrap.sh&lt;/code&gt;도 런타임 라이브러리 패키징도 직접 안 해도 됨.&lt;/p&gt;
&lt;p&gt;Android Gradle Plugin이 빌드 variant 설정만으로 처리해줌.&lt;/p&gt;
&lt;p&gt;단 &lt;code&gt;ANDROID_USE_LEGACY_TOOLCHAIN_FILE=false&lt;/code&gt;를 쓰는 프로젝트에서는 이 인자 방식이 동작하지 않음.&lt;/p&gt;
&lt;p&gt;이제 크래시가 나면 logcat에 상세 정보가 찍힘.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/a2d54f92-65d8-4c95-9800-b75d15d19d0e/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;ASan 출력에서 먼저 볼 건 SUMMARY 줄과 영역 설명임.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;heap-buffer-overflow / WRITE of size 1 / 5 bytes after 10-byte region&lt;/code&gt; 형태로 무엇이 어떻게 어디서 잘못됐는지가 한 줄로 정리됨.&lt;/p&gt;
&lt;p&gt;그 다음 backtrace에서 사고 위치를 봄.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;allocated by&lt;/code&gt; 줄에서 그 메모리가 언제 할당됐는지를 같이 봄.&lt;/p&gt;
&lt;p&gt;언제 어디서 할당된 메모리를 어디서 잘못 접근했는지까지 알려주는 것임.&lt;/p&gt;
&lt;p&gt;일반 SIGSEGV가 어디서 죽었는지만 알려주는 것과 차이가 큼.&lt;/p&gt;
&lt;p&gt;아쉬운 점은 디버그 빌드 전용이라는 점임.&lt;/p&gt;
&lt;h3&gt;UBSan&lt;/h3&gt;
&lt;p&gt;UBSan(UndefinedBehaviorSanitizer)은 산술 오버플로우, NULL 역참조, 정렬 위반 같은 위험한 코드 패턴을 런타임에 잡는 도구임.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cmake&quot;&gt;target_compile_options(your_lib PRIVATE
    -fsanitize=undefined
    -fno-sanitize-recover=undefined
)
target_link_options(your_lib PRIVATE -fsanitize=undefined)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ASan이 메모리 접근 자체를 본다면, UBSan은 위험한 산술과 캐스팅을 봄.&lt;/p&gt;
&lt;p&gt;둘 다 켜놓으면 보완 관계임.&lt;/p&gt;
&lt;p&gt;도구를 디버그 빌드에 켜놓는 것 자체가 워크플로 정비임.&lt;/p&gt;
&lt;p&gt;CI에 통합해두면 기존 코드에 숨어 있던 사고가 드러나기 시작함.&lt;/p&gt;
&lt;p&gt;새로 짠 코드뿐 아니라 오래된 코드에서 우연히 안 터지던 버그도 잡힘.&lt;/p&gt;
&lt;p&gt;도구가 잡는 사고는 원인 시점에 즉시 죽으니, 증상이 한참 뒤에 나타나는 사고보다 추적이 훨씬 쉬움.&lt;/p&gt;
&lt;h2&gt;3층: 코드 작성 시점에 의식해야 하는 것&lt;/h2&gt;
&lt;p&gt;1층 자동 안전망과 2층 도구로도 안 잡히는 사고가 있음.&lt;/p&gt;
&lt;p&gt;또는 도구가 잡긴 해도 애초에 안 만드는 게 나은 사고가 있음.&lt;/p&gt;
&lt;p&gt;이런 건 코드를 짤 때 내가 의식하는 수밖에 없음.&lt;/p&gt;
&lt;p&gt;네 가지를 보자.&lt;/p&gt;
&lt;h3&gt;JNI Reference 정리&lt;/h3&gt;
&lt;p&gt;Java/Kotlin 개발자가 native에서 가장 헷갈리는 지점임.&lt;/p&gt;
&lt;p&gt;JNI에서 Java 객체 참조를 만들면 내가 명시적으로 정리해야 함.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;// 위험: 반복문 안에서 누적
for (int i = 0; i &amp;lt; 10000; i++) {
    jstring s = (*env)-&amp;gt;NewStringUTF(env, &amp;quot;x&amp;quot;);
    // DeleteLocalRef 안 하면 Local Reference Table이 가득 참
}

// 안전
for (int i = 0; i &amp;lt; 10000; i++) {
    jstring s = (*env)-&amp;gt;NewStringUTF(env, &amp;quot;x&amp;quot;);
    (*env)-&amp;gt;DeleteLocalRef(env, s);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Local Reference는 JNI 호출 프레임이 끝날 때 자동 정리됨.&lt;/p&gt;
&lt;p&gt;다만 함수 안에서 많이 만들면 그 전에 한도를 초과함.&lt;/p&gt;
&lt;p&gt;Local Reference Table은 크기 제한이 있어 반복문에서 계속 생성하면 overflow가 남.&lt;/p&gt;
&lt;p&gt;Global Reference는 더 위험함.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;NewGlobalRef&lt;/code&gt;로 만든 참조는 &lt;code&gt;DeleteGlobalRef&lt;/code&gt;를 부르기 전까지 영원히 살아 있음.&lt;/p&gt;
&lt;p&gt;정리 안 하면 앱 프로세스 종료 전까지 누수임.&lt;/p&gt;
&lt;p&gt;Global Reference를 캐싱하고 정리하는 짝은 이렇게 둠.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;static jclass g_my_class = NULL;

void cache_class(JNIEnv *env) {
    jclass local = (*env)-&amp;gt;FindClass(env, &amp;quot;com/example/MyClass&amp;quot;);
    g_my_class = (*env)-&amp;gt;NewGlobalRef(env, local);
    (*env)-&amp;gt;DeleteLocalRef(env, local);
}

void cleanup(JNIEnv *env) {
    if (g_my_class) {
        (*env)-&amp;gt;DeleteGlobalRef(env, g_my_class);
        g_my_class = NULL;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cleanup&lt;/code&gt;이 실제로 호출되는 경로에 있는지 확인하는 게 중요함.&lt;/p&gt;
&lt;p&gt;함수만 있고 호출 경로가 없으면 그대로 누수임.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;JNIEnv*&lt;/code&gt;를 잘못 다루는 것도 자주 터짐.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;JNIEnv*&lt;/code&gt;는 스레드별로 다름.&lt;/p&gt;
&lt;p&gt;한 스레드에서 받은 걸 다른 스레드에서 그대로 쓰면 안 됨.&lt;/p&gt;
&lt;p&gt;native에서 새 스레드를 만들거나 다른 스레드에서 JNI를 호출할 때는 &lt;code&gt;AttachCurrentThread&lt;/code&gt;로 그 스레드의 &lt;code&gt;JNIEnv*&lt;/code&gt;를 얻음.&lt;/p&gt;
&lt;p&gt;스레드가 끝나기 전에 &lt;code&gt;DetachCurrentThread&lt;/code&gt;로 정리해야 함.&lt;/p&gt;
&lt;h3&gt;Use-after-free 방지&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;free&lt;/code&gt;한 메모리에 접근하면 안 됨.&lt;/p&gt;
&lt;p&gt;기본 패턴은 &lt;code&gt;free&lt;/code&gt; 후 포인터를 NULL로 만드는 것임.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;free(buf);
buf = NULL;  // 다시 쓰려고 하면 NULL 역참조라 바로 드러남&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다만 이건 같은 메모리를 가리키는 다른 포인터(alias)까지 막아주지는 못함.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;char *a = malloc(64);
char *b = a;        // 두 포인터가 같은 메모리

free(a);
a = NULL;           // a는 정리됨
*b = &amp;#39;X&amp;#39;;           // b는 여전히 해제된 메모리를 가리킴, use-after-free&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결국 해법은 소유자를 명확히 하는 것임.&lt;/p&gt;
&lt;p&gt;누가 해제할지를 코드 구조로 표시함.&lt;/p&gt;
&lt;h3&gt;정수 오버플로우가 메모리 사고로 번지는 경우&lt;/h3&gt;
&lt;p&gt;겉보기엔 평범한 &lt;code&gt;malloc&lt;/code&gt; 한 줄이 사고의 시작점이 될 수 있음.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;void* allocate_array(uint32_t count) {
    void* buf = malloc(count * sizeof(int));
    return buf;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;count&lt;/code&gt;가 크면 &lt;code&gt;count * sizeof(int)&lt;/code&gt;가 32비트 unsigned 곱셈에서 wrap-around됨.&lt;/p&gt;
&lt;p&gt;그 결과 &lt;code&gt;malloc&lt;/code&gt;에 엉뚱하게 작은 값이 들어감.&lt;/p&gt;
&lt;p&gt;호출자는 원래 의도한 큰 크기로 그 버퍼에 씀.&lt;/p&gt;
&lt;p&gt;작은 버퍼에 큰 데이터를 쓰니 heap overflow임.&lt;/p&gt;
&lt;p&gt;해법은 곱셈 결과를 검증하는 builtin을 쓰는 것임.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;size_t total;
if (__builtin_mul_overflow(count, sizeof(int), &amp;amp;total)) {
    return NULL;  // 오버플로우 발생
}
void* buf = malloc(total);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;__builtin_add_overflow&lt;/code&gt;, &lt;code&gt;__builtin_sub_overflow&lt;/code&gt;도 같은 패턴임.&lt;/p&gt;
&lt;p&gt;Java는 &lt;code&gt;Math.multiplyExact&lt;/code&gt;가 같은 역할을 함.&lt;/p&gt;
&lt;p&gt;native에서는 내가 의식해서 builtin을 부르지 않으면 컴파일러가 알아서 검사해주지 않음.&lt;/p&gt;
&lt;h3&gt;ARM 정렬과 바이트 버퍼 캐스팅&lt;/h3&gt;
&lt;p&gt;ARM 계열에서 정렬되지 않은 메모리 접근은 아키텍처와 명령어에 따라 동작이 갈림.&lt;/p&gt;
&lt;p&gt;구형 ARM에서는 SIGBUS로 죽는 경우가 흔함.&lt;/p&gt;
&lt;p&gt;ARM64에서는 일반 load/store는 조용히 동작하다 atomic이나 SIMD 명령어에서 죽기도 함.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;// 위험
uint8_t buf[10];
uint32_t value = *(uint32_t*)(buf + 1);  // buf+1은 4의 배수가 아닐 수 있음&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;해법은 &lt;code&gt;memcpy&lt;/code&gt;로 복사하는 것임.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;uint32_t value;
memcpy(&amp;amp;value, buf + 1, sizeof(value));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;memcpy&lt;/code&gt;는 바이트 단위 복사로 정의돼 정렬 안 된 주소에서도 안전함.&lt;/p&gt;
&lt;p&gt;현대 컴파일러는 작은 크기의 memcpy를 단일 load 명령으로 최적화해 성능 손실도 거의 없음.&lt;/p&gt;
&lt;p&gt;네트워크 패킷이나 파일 포맷 파싱, &lt;code&gt;GetByteArrayElements&lt;/code&gt;로 받은 데이터를 C struct로 캐스팅할 때 자주 만남.&lt;/p&gt;
&lt;h2&gt;어느 사고가 어느 층에서 잡히나&lt;/h2&gt;
&lt;p&gt;여기까지를 한 장으로 정리하면 이렇게 됨.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;사고&lt;/th&gt;
&lt;th&gt;1층 자동&lt;/th&gt;
&lt;th&gt;2층 도구&lt;/th&gt;
&lt;th&gt;3층 의식&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;스택 buffer overflow&lt;/td&gt;
&lt;td&gt;카나리 잡음&lt;/td&gt;
&lt;td&gt;ASan 잡음&lt;/td&gt;
&lt;td&gt;길이 검증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;힙 buffer overflow&lt;/td&gt;
&lt;td&gt;못 잡음&lt;/td&gt;
&lt;td&gt;ASan 잡음&lt;/td&gt;
&lt;td&gt;경계 검사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use-after-free&lt;/td&gt;
&lt;td&gt;못 잡음&lt;/td&gt;
&lt;td&gt;ASan 잡음&lt;/td&gt;
&lt;td&gt;소유자 명확히&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Double free&lt;/td&gt;
&lt;td&gt;일부 잡음&lt;/td&gt;
&lt;td&gt;ASan 잡음&lt;/td&gt;
&lt;td&gt;소유자 명확히&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JNI Reference 누수&lt;/td&gt;
&lt;td&gt;못 잡음&lt;/td&gt;
&lt;td&gt;못 잡음&lt;/td&gt;
&lt;td&gt;Delete 짝 맞추기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;정수 오버플로우&lt;/td&gt;
&lt;td&gt;못 잡음&lt;/td&gt;
&lt;td&gt;UBSan/ASan&lt;/td&gt;
&lt;td&gt;builtin 검증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ARM 정렬 위반&lt;/td&gt;
&lt;td&gt;못 잡음&lt;/td&gt;
&lt;td&gt;UBSan 잡음&lt;/td&gt;
&lt;td&gt;memcpy 패턴&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;표준 함수 경계 위반&lt;/td&gt;
&lt;td&gt;FORTIFY 잡음&lt;/td&gt;
&lt;td&gt;ASan 잡음&lt;/td&gt;
&lt;td&gt;안전한 API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;위쪽 층에서 잡히는 사고는 코드를 잘못 짜도 자동으로 보호됨.&lt;/p&gt;
&lt;p&gt;아래쪽 층은 내가 의식하지 않으면 그대로 사고가 됨.&lt;/p&gt;
&lt;p&gt;도입부의 카나리 케이스가 1층에서 걸린 경우였음.&lt;/p&gt;
&lt;p&gt;만약 그게 힙 overflow였다면 1층은 그냥 통과했을 것임.&lt;/p&gt;
&lt;p&gt;결국 사용자 단말에서 터졌을 것임.&lt;/p&gt;
&lt;h2&gt;마무리 메모&lt;/h2&gt;
&lt;p&gt;복잡하다. 그리고, 이런 문제는 항상 운영에서만 나온다. 방어 해주는건 고마운데, 제발 테스트 단계에서좀 나오라고...&lt;/p&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;AddressSanitizer (Android NDK 공식 문서) - &lt;a href=&quot;https://developer.android.com/ndk/guides/asan&quot;&gt;https://developer.android.com/ndk/guides/asan&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>모바일</category>
      <category>AddressSanitizer</category>
      <category>Android</category>
      <category>ASAN</category>
      <category>JNI</category>
      <category>Native</category>
      <category>ndk</category>
      <category>stack canary</category>
      <category>안드로이드 NDK</category>
      <author>Jo&amp;atilde;o Jin</author>
      <guid isPermaLink="true">https://yjcho9317.tistory.com/29</guid>
      <comments>https://yjcho9317.tistory.com/29#entry29comment</comments>
      <pubDate>Sun, 24 May 2026 00:07:19 +0900</pubDate>
    </item>
    <item>
      <title>Android NDK 입문 (2) - SIGSEGV, SIGABRT, SIGBUS 네이티브 크래시 시그널 정리</title>
      <link>https://yjcho9317.tistory.com/28</link>
      <description>&lt;p&gt;native 크래시를 처음 만났을 때 가장 막막했던 건 어디서 났는지가 안 보인다는 점이었다.&lt;/p&gt;
&lt;p&gt;Java/Kotlin이었으면 &lt;code&gt;NullPointerException&lt;/code&gt;이 어느 파일 몇 번 라인에서 났는지 stack trace에 다 찍힌다.&lt;/p&gt;
&lt;p&gt;native는 &lt;code&gt;signal 11 (SIGSEGV)&lt;/code&gt; 같은 시그널 번호와 메모리 주소만 있었다.&lt;/p&gt;
&lt;p&gt;NDK 코드를 직접 안 짜도 이 로그는 만난다.&lt;/p&gt;
&lt;p&gt;의존하는 라이브러리가 내부적으로 native면 거기서 터진 게 시그널로 올라온다.&lt;/p&gt;
&lt;p&gt;이번 편은 그 시그널 종류를 정리해본다.&lt;/p&gt;
&lt;h2&gt;시그널이 뭔지부터&lt;/h2&gt;
&lt;p&gt;native 크래시는 전부 시그널로 옴.&lt;/p&gt;
&lt;p&gt;그래서 시그널이 뭔지부터 봄.&lt;/p&gt;
&lt;p&gt;시그널은 Unix 계열 OS가 프로세스에게 뭔가 잘못됐다고 알리는 메커니즘임.&lt;/p&gt;
&lt;p&gt;Android도 Linux 기반이라 같은 메커니즘을 씀.&lt;/p&gt;
&lt;p&gt;native 코드에서 잘못된 일이 일어나면 OS가 시그널을 보냄.&lt;/p&gt;
&lt;p&gt;시그널이 처리되지 않으면 프로세스가 종료됨.&lt;/p&gt;
&lt;p&gt;우편함에 꽂히는 통지서에 가까움. OS가 통지서를 꽂고, 응답이 없으면 강제 종료시킴.&lt;/p&gt;
&lt;p&gt;예외 처리가 안됨.&lt;/p&gt;
&lt;p&gt;시그널 자체를 잡아서 하는 방법도 있지만, 대부분 시그널이 나오는 문제는 어차피 앞에 하나 잡는다고 해결이 안되는 경우가 많음.&lt;/p&gt;
&lt;p&gt;Android의 &lt;code&gt;debuggerd&lt;/code&gt;(크래시 처리 데몬)가 치명적 크래시로 잡는 시그널은 여섯 가지임.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SIGSEGV&lt;/code&gt; (11)는 잘못된 메모리 접근임.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SIGABRT&lt;/code&gt; (6)는 의도적 종료임. &lt;code&gt;abort()&lt;/code&gt;가 호출된 것임.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SIGBUS&lt;/code&gt; (7)는 메모리 정렬이나 매핑 오류임.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SIGILL&lt;/code&gt; (4)는 잘못된 명령어임.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SIGFPE&lt;/code&gt; (8)는 산술 예외임.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SIGTRAP&lt;/code&gt; (5)는 디버거 트랩임.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;가장 자주 만나는 건 SIGSEGV와 SIGABRT임.&lt;/p&gt;
&lt;p&gt;나머지는 특정 조건에서 등장함.&lt;/p&gt;
&lt;p&gt;시그널 번호만 보고 어느 범주 사고인지 좁힐 수 있으면 분석 시간이 줄어듦.&lt;/p&gt;
&lt;p&gt;하나씩 보자.&lt;/p&gt;
&lt;h2&gt;SIGSEGV: 잘못된 메모리 접근&lt;/h2&gt;
&lt;p&gt;가장 흔한 native 크래시임.&lt;/p&gt;
&lt;p&gt;프로세스가 접근 권한이 없는 메모리 주소를 읽거나 쓰려고 할 때 발생함.&lt;/p&gt;
&lt;p&gt;logcat에는 이렇게 찍힘.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/6e378674-d089-468c-bcbe-9958a1b65de4/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;code&lt;/code&gt; 값으로 더 세분화됨.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SEGV_MAPERR&lt;/code&gt;는 매핑되지 않은 주소 접근임. NULL 역참조가 대표적임.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SEGV_ACCERR&lt;/code&gt;는 매핑은 있지만 권한이 없는 경우임. 읽기 전용 메모리에 쓰기가 대표적임.&lt;/p&gt;
&lt;p&gt;자주 만드는 코드 패턴은 이런 것들임.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;// 패턴 1: NULL 역참조
Foo *p = NULL;
p-&amp;gt;value = 1;        // SEGV_MAPERR, fault addr 0x0

// 패턴 2: dangling pointer
free(buf);
buf[0] = &amp;#39;x&amp;#39;;        // SEGV. 해제된 메모리 접근

// 패턴 3: 배열 경계 위반
char arr[10];
arr[100] = &amp;#39;x&amp;#39;;      // SEGV. 스택 영역 침범 시

// 패턴 4: 수명이 끝난 메모리 반환
char* get_data() {
    char buf[100];
    return buf;       // 함수 종료 후 buf 수명도 끝남
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;같은 시도가 Java/Kotlin에서는 &lt;code&gt;NullPointerException&lt;/code&gt;이나 &lt;code&gt;ArrayIndexOutOfBoundsException&lt;/code&gt;이 됨.&lt;/p&gt;
&lt;p&gt;JVM이 접근 전에 검사하고 예외를 던지기 때문임.&lt;/p&gt;
&lt;p&gt;native는 그 검사가 없고 OS 레벨에서 시그널로 잡힘.&lt;/p&gt;
&lt;h2&gt;SIGABRT: 의도적 종료&lt;/h2&gt;
&lt;p&gt;프로세스가 스스로 &lt;code&gt;abort()&lt;/code&gt;를 호출했을 때 발생함.&lt;/p&gt;
&lt;p&gt;보통 뭔가 잘못된 걸 감지한 코드가 의도적으로 죽는 경우임.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;signal 6 (SIGABRT), code -6 (SI_TKILL)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;자주 만나는 SIGABRT 원인은 여러 가지임.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;코드가 &lt;code&gt;abort()&lt;/code&gt;를 직접 호출한 경우&lt;/li&gt;
&lt;li&gt;디버그 빌드에서 &lt;code&gt;assert()&lt;/code&gt;가 깨진 경우&lt;/li&gt;
&lt;li&gt;스택 카나리 검증이 실패해 &lt;code&gt;__stack_chk_fail()&lt;/code&gt;이 호출된 경우&lt;/li&gt;
&lt;li&gt;C++ 예외가 안 잡혀 &lt;code&gt;std::terminate()&lt;/code&gt;가 호출된 경우&lt;/li&gt;
&lt;li&gt;&lt;code&gt;strcpy&lt;/code&gt; 같은 함수가 경계 위반을 감지한 경우&lt;/li&gt;
&lt;li&gt;AddressSanitizer가 메모리 오류를 감지하고 abort한 경우&lt;/li&gt;
&lt;li&gt;bionic 내부 검사가 double free나 heap 손상을 감지한 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;SIGABRT는 logcat에 왜 abort 했는지 메시지가 함께 나올 때가 많음.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Abort message: &amp;#39;stack corruption detected&amp;#39;
Abort message: &amp;#39;free(): invalid pointer&amp;#39;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Java/Kotlin으로 치면 uncaught exception과 비슷한 위치.&lt;/p&gt;
&lt;p&gt;다만 native는 시스템이나 런타임이 감지한 오류가 더 많고, 메시지가 짧음.&lt;/p&gt;
&lt;h2&gt;SIGBUS: 정렬 / 매핑 오류&lt;/h2&gt;
&lt;p&gt;SIGSEGV와 비슷하지만 다름.&lt;/p&gt;
&lt;p&gt;주소 자체는 유효한데 다른 이유로 접근에 실패한 경우.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;signal 7 (SIGBUS), code 1 (BUS_ADRALN), fault addr 0x...&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;원인은 크게 둘.&lt;/p&gt;
&lt;p&gt;하나는 정렬 오류(&lt;code&gt;BUS_ADRALN&lt;/code&gt;)임. 일부 프로세서는 정렬되지 않은 주소에서 특정 작업을 거부함.&lt;/p&gt;
&lt;p&gt;바이트 버퍼를 &lt;code&gt;(uint32_t*)&lt;/code&gt;로 캐스팅해 읽을 때 자주 발생함.&lt;/p&gt;
&lt;p&gt;다른 하나는 매핑은 됐지만 물리적으로 접근이 불가능한 경우임. mmap한 파일이 잘려 페이지가 비어 있는 경우 등임.&lt;/p&gt;
&lt;p&gt;정렬 오류 패턴은 이런 형태.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;uint8_t buf[10];
uint32_t value = *(uint32_t*)(buf + 1);  // buf+1은 4의 배수 아님&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SIGBUS는 아키텍처에 따라 동작이 갈림.&lt;/p&gt;
&lt;p&gt;구형 ARM이나 atomic 명령어는 unaligned access에서 SIGBUS를 냄.&lt;/p&gt;
&lt;p&gt;반면 ARM64에서는 SIGBUS가 안 나는 경우가 많음.&lt;/p&gt;
&lt;p&gt;다만 atomic 연산이나 SIMD 명령어는 ARM64에서도 정렬을 요구함.&lt;/p&gt;
&lt;p&gt;그래서 x86 에뮬레이터에서 잘 돌던 코드가 구형 ARM 실기기에서 SIGBUS로 죽기도 함.&lt;/p&gt;
&lt;p&gt;JVM이 메모리 정렬을 추상화해 신경 안 써도 됐던 영역인데, native는 프로세서가 직접 드러남.&lt;/p&gt;
&lt;h2&gt;SIGILL: 잘못된 명령어&lt;/h2&gt;
&lt;p&gt;CPU가 해석할 수 없는 명령어를 실행하려 할 때 발생함.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;signal 4 (SIGILL), code 1 (ILL_ILLOPC)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;자주 만드는 패턴은 이런 것들임.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;함수 포인터가 잘못된 곳을 가리켜 데이터 영역으로 점프한 경우&lt;/li&gt;
&lt;li&gt;스택이 손상돼 리턴 주소가 깨지고 코드 아닌 영역으로 복귀한 경우&lt;/li&gt;
&lt;li&gt;&lt;code&gt;armeabi-v7a&lt;/code&gt;로 빌드한 코드가 호환 안 되는 옛 디바이스에서 실행된 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;상대적으로 드물지만 만나면 뭔가 크게 잘못됐다는 신호.&lt;/p&gt;
&lt;p&gt;메모리 손상 이후 연쇄적으로 나타나기도 해서 원인 추적이 까다로울 때가 있음.&lt;/p&gt;
&lt;h2&gt;SIGFPE: 산술 예외&lt;/h2&gt;
&lt;p&gt;산술 연산 중 CPU가 처리할 수 없는 연산이 발생했을 때 나옴.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;signal 8 (SIGFPE), code 1 (FPE_INTDIV)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;자주 만드는 패턴은 정수 연산 쪽임.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;int z = x / 0;       // 0으로 나누기

int n = INT_MIN;
int r = n / -1;      // 결과가 INT_MAX + 1이라 int 범위 초과&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이름이 Floating-Point Exception이지만 실제로는 정수 연산 오류에서도 발생함.&lt;/p&gt;
&lt;p&gt;부동소수점 연산은 보통 NaN이나 Infinity로 처리돼 시그널까지 안 감.&lt;/p&gt;
&lt;p&gt;SIGFPE는 아키텍처별 동작 차이가 큼.&lt;/p&gt;
&lt;p&gt;x86에서는 &lt;code&gt;int / 0&lt;/code&gt;이 즉시 SIGFPE로 죽음.&lt;/p&gt;
&lt;p&gt;반면 ARM 계열에서는 &lt;code&gt;SDIV/UDIV&lt;/code&gt; 명령이 0으로 나누기를 0을 반환하는 방식으로 처리함.&lt;/p&gt;
&lt;p&gt;시그널이 안 나고 0이 그냥 결과로 나옴.&lt;/p&gt;
&lt;p&gt;그래서 같은 코드를 x86 에뮬레이터에서 돌리면 SIGFPE, ARM 실기기에서 돌리면 조용히 0임.&lt;/p&gt;
&lt;p&gt;하지만 아키텍처에 따라 다르니 코드에서 확실하게 처리하는게 안전함.&lt;/p&gt;
&lt;h2&gt;SIGTRAP: 디버거 트랩&lt;/h2&gt;
&lt;p&gt;대부분 디버거 사용 중에 나오지만 가끔 release 빌드에서도 봄.&lt;/p&gt;
&lt;p&gt;흔한 시그널은 아님.&lt;/p&gt;
&lt;p&gt;만나면 디버거가 attach돼 있는지, 라이브러리가 trap을 의도적으로 넣었는지 확인하면 됨.&lt;/p&gt;
&lt;h2&gt;시그널이 아닌 종료도 있음&lt;/h2&gt;
&lt;p&gt;위는 OS가 시그널로 보낸 프로세스 종료들임.&lt;/p&gt;
&lt;p&gt;native 작업에서 만나는 비정상 종료에는 시그널 아닌 것들도 있음.&lt;/p&gt;
&lt;p&gt;셋을 같이 의식해야 함.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ANR(Application Not Responding): 메인 스레드가 5초 이상 블로킹되면 시스템이 앱 응답 없음으로 판단해 종료함. 무거운 JNI 호출을 메인 스레드에서 하거나, JNI 안에서 락을 오래 대기하면 ANR로 이어짐. 이거는 아마 MainTread 에서 많은걸 처리하던 초기 안드로이드 개발자라면 많이 보던 문제일 것.&lt;/li&gt;
&lt;li&gt;Low Memory Kill임: 시스템 메모리가 부족하면 Android의 &lt;code&gt;lmkd&lt;/code&gt;가 우선순위 낮은 프로세스부터 죽임. native heap 누수나 mmap 누수가 쌓이면 결국 lmkd가 죽임. 직접적인 크래시 시그널이 없어 원인 추적이 어려움.&lt;/li&gt;
&lt;li&gt;Java 측 예외로 인한 종료: native에서 Java 측 예외가 발생하면 Java 레이어로 전파돼 uncaught exception으로 앱이 죽을 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;도구가 일부러 내는 크래시도 있음&lt;/h2&gt;
&lt;p&gt;위 시그널들은 코드가 문제를 일으켜 발생한 것들임.&lt;/p&gt;
&lt;p&gt;추가로 도구가 문제를 감지하고 일부러 abort시키는 경우가 있음.&lt;/p&gt;
&lt;p&gt;디버그 빌드에서만 켜짐.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AddressSanitizer(ASan)가 heap overflow, use-after-free, double free 같은 메모리 오류를 접근 시점에 잡아 abort시킴.&lt;/li&gt;
&lt;li&gt;UndefinedBehaviorSanitizer(UBSan)가 undefined behavior를 런타임에 감지해 abort시킴.&lt;/li&gt;
&lt;li&gt;FORTIFY_SOURCE가 &lt;code&gt;strcpy&lt;/code&gt; 같은 표준 함수의 경계 위반을 감지해 abort시킴.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;셋 다 SIGABRT로 종료되지만, logcat에 왜 abort 했는지 상세 정보가 찍힘.&lt;/p&gt;
&lt;p&gt;이런 의도적 abort는 오히려 디버깅에 유리함.&lt;/p&gt;
&lt;p&gt;원인 시점에 즉시 죽으니, 증상이 한참 뒤에 나타나는 경우보다 추적이 쉬움.&lt;/p&gt;
&lt;p&gt;각 도구를 어떻게 켜고 쓰는지는 3편에서 다룸.&lt;/p&gt;
&lt;h2&gt;시그널 분류표&lt;/h2&gt;
&lt;p&gt;여기까지를 한 장으로 정리하면 이렇게 됨.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;시그널&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;th&gt;자주 보는 원인&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;SIGSEGV&lt;/td&gt;
&lt;td&gt;잘못된 메모리 접근&lt;/td&gt;
&lt;td&gt;NULL deref, dangling pointer, OOB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SIGABRT&lt;/td&gt;
&lt;td&gt;의도적 종료&lt;/td&gt;
&lt;td&gt;&lt;code&gt;__stack_chk_fail&lt;/code&gt;, ASan 검출, double free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SIGBUS&lt;/td&gt;
&lt;td&gt;정렬/매핑 오류&lt;/td&gt;
&lt;td&gt;ARM에서 unaligned access, mmap 오류&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SIGILL&lt;/td&gt;
&lt;td&gt;잘못된 명령어&lt;/td&gt;
&lt;td&gt;함수 포인터 손상, 호환 안 되는 CPU 명령&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SIGFPE&lt;/td&gt;
&lt;td&gt;산술 예외&lt;/td&gt;
&lt;td&gt;정수 0 나누기, INT_MIN / -1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SIGTRAP&lt;/td&gt;
&lt;td&gt;디버거 트랩&lt;/td&gt;
&lt;td&gt;디버거 사용 중&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;시그널이 아닌 종료는 ANR, Low Memory Kill, Java 측 예외 셋임.&lt;/p&gt;
&lt;p&gt;이 분류는 4편에서 크래시를 추적할 때 첫 단서가 됨.&lt;/p&gt;
&lt;p&gt;시그널 번호 하나로 어느 범주 사고인지 좁히는 게 추적의 시작임.&lt;/p&gt;
&lt;h2&gt;마무리 메모&lt;/h2&gt;
&lt;p&gt;native 크래시 종류를 보면 이 어려운걸 왜 쓰나 싶어진다. 내가 봐도 그렇다. 근데 단순한 앱 개발이 아닌 기능 개발을 하다보면 필수적으로 쓰게되는게 native다. 시그널은 어차피 검색하면 다 나오니까 외울 필요는 없다. 그냥 한 번 읽고 시그널이 보통 어떤 케이스에 나오고, 어떻게 접근하면 되는지 이해만 해두면 된다.&lt;/p&gt;</description>
      <category>모바일</category>
      <category>Android</category>
      <category>JNI</category>
      <category>Native</category>
      <category>Native Crash</category>
      <category>native 크래시</category>
      <category>ndk</category>
      <category>SIGABRT</category>
      <category>sigbus</category>
      <category>SIGSEGV</category>
      <category>안드로이드 NDK</category>
      <author>Jo&amp;atilde;o Jin</author>
      <guid isPermaLink="true">https://yjcho9317.tistory.com/28</guid>
      <comments>https://yjcho9317.tistory.com/28#entry28comment</comments>
      <pubDate>Sat, 23 May 2026 23:36:54 +0900</pubDate>
    </item>
    <item>
      <title>Android NDK 입문 (1) - NDK를 왜 쓰는가, JNI와 네이티브 개발 기초</title>
      <link>https://yjcho9317.tistory.com/27</link>
      <description>&lt;p&gt;요새 온디바이스 AI 작업을 하고 있다.&lt;/p&gt;
&lt;p&gt;모델 추론 코드를 iOS와 Android에서 공용으로 쓰려고 native 모듈로 빼는 중.&lt;/p&gt;
&lt;p&gt;HAL 쪽도 native가 필요.&lt;/p&gt;
&lt;p&gt;오랜만에 NDK를 다시 만지다 보니 한동안 잊고 있던 것들이 다시 보였다.&lt;/p&gt;
&lt;p&gt;그래서 정리해봤다. 이번 편은 NDK를 왜 쓰는지, Java/Kotlin과 뭐가 다른지까지이다.&lt;/p&gt;
&lt;h2&gt;NDK를 왜 쓰는가&lt;/h2&gt;
&lt;p&gt;대부분의 Android 개발은 NDK 없이 끝남.&lt;/p&gt;
&lt;p&gt;화면 그리고 네트워크 호출하고 데이터 다루는 데는 Java/Kotlin만으로 부족함이 없음.&lt;/p&gt;
&lt;p&gt;그러다 어느 시점에 NDK가 강제되는 상황을 만남.&lt;/p&gt;
&lt;p&gt;보통 네 가지 중 하나임.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;첫째는 성능. AI 모델 추론, 미디어 인코딩/디코딩, 암호화 같은 무거운 연산은 JVM 위에서 한계가 있음. TensorFlow Lite, ONNX Runtime, PyTorch Mobile이 다 native 코어인 이유임.&lt;/li&gt;
&lt;li&gt;둘째는 기존 C/C++ 오픈소스 사용. OpenSSL, FFmpeg, OpenCV는 이미 C/C++로 짜여 있음. 이걸 Java/Kotlin으로 다시 짜는 건 말이 안 됨. native로 묶어서 JNI로 호출해서 사용.&lt;/li&gt;
&lt;li&gt;셋째는 플랫폼 저수준 API임. Vulkan, AAudio, 카메라/센서 vendor SDK처럼 Java/Kotlin 레이어에 노출 안 된 API에 접근할 때 필요함.&lt;/li&gt;
&lt;li&gt;넷째는 코드 보호. Java/Kotlin 바이트코드는 jadx로 거의 원본에 가깝게 풀리지만, native 코드는 그보다 분석하기 어려운 형태로 배포됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;코드 보호는 한 가지 오해를 짚고 감.&lt;/p&gt;
&lt;p&gt;native라고 분석이 불가능한 건 아님.&lt;/p&gt;
&lt;p&gt;Ghidra로도 충분히 복원됨. 게다가 CLAUDE 쓰면 딸깍으로 복원 가능.&lt;/p&gt;
&lt;p&gt;완전 차단이 아니라 분석 비용을 올리는 효과임.&lt;/p&gt;
&lt;p&gt;내 경우는 첫째와 셋째가 겹쳤음.&lt;/p&gt;
&lt;p&gt;온디바이스 추론 코어를 iOS/Android 공용 C++로 두면서, HAL 접근까지 native가 필요했음.&lt;/p&gt;
&lt;h2&gt;JNI - Java와 native 사이의 통로&lt;/h2&gt;
&lt;p&gt;native 코드를 Java/Kotlin에서 부르려면 JNI를 거쳐야 함.&lt;/p&gt;
&lt;p&gt;먼저 JNI가 뭔지부터 보자.&lt;/p&gt;
&lt;p&gt;JNI는 Java Native Interface의 약자.&lt;/p&gt;
&lt;p&gt;Java 코드가 native 함수를 호출할 때 따르는 규약임.&lt;/p&gt;
&lt;p&gt;Java 쪽은 &lt;code&gt;external&lt;/code&gt; 키워드로 native 메서드를 선언만 함.&lt;/p&gt;
&lt;p&gt;실제 구현은 C/C++ 쪽에 둠.&lt;/p&gt;
&lt;p&gt;통로 양쪽 끝을 각각 선언하는 셈임.&lt;/p&gt;
&lt;p&gt;C 쪽 함수는 &lt;code&gt;Java_&amp;lt;패키지&amp;gt;_&amp;lt;클래스&amp;gt;_&amp;lt;메서드&amp;gt;&lt;/code&gt; 형식의 이름 규칙을 따라야 런타임이 찾을 수 있음.&lt;/p&gt;
&lt;p&gt;JNI 함수가 실제로 어떻게 생겼는지 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;// C 측: native 함수 정의
JNIEXPORT jint JNICALL
Java_com_example_MyClass_processData(JNIEnv *env, jobject thiz, jbyteArray input) {
    jbyte *data = (*env)-&amp;gt;GetByteArrayElements(env, input, NULL);
    jsize len = (*env)-&amp;gt;GetArrayLength(env, input);

    int result = process(data, len);  // 실제 C 로직

    (*env)-&amp;gt;ReleaseByteArrayElements(env, input, data, JNI_ABORT);
    return result;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// Kotlin 측: native 메서드 선언
class MyClass {
    external fun processData(input: ByteArray): Int
    companion object { init { System.loadLibrary(&amp;quot;mylib&amp;quot;) } }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;첫 인자 &lt;code&gt;JNIEnv*&lt;/code&gt;는 Java 객체를 다루는 모든 함수의 진입점임.&lt;/p&gt;
&lt;p&gt;Java 배열을 읽고 문자열을 변환하는 게 다 이걸 거침.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;GetByteArrayElements&lt;/code&gt;로 얻은 데이터는 &lt;code&gt;ReleaseByteArrayElements&lt;/code&gt;로 짝을 맞춰 돌려줘야 함.&lt;/p&gt;
&lt;p&gt;이 짝이 안 맞으면 누수가 됨.&lt;/p&gt;
&lt;p&gt;여기까지가 NDK의 입구임.&lt;/p&gt;
&lt;p&gt;입구만 보면 Java 메서드 하나 더 만드는 것과 비슷해 보임.&lt;/p&gt;
&lt;p&gt;다른 건 그 안쪽임.&lt;/p&gt;
&lt;h2&gt;Java/Kotlin과 뭐가 다른가&lt;/h2&gt;
&lt;p&gt;NDK가 다른 건 문법이 아님.&lt;/p&gt;
&lt;p&gt;JVM이 대신 해주던 일을 내가 직접 한다고 보면 됨.&lt;/p&gt;
&lt;p&gt;이건 개발 난이도를 올리지만 그만큼, GC 등 자유도를 얻는다는 뜻도 됨.&lt;/p&gt;
&lt;p&gt;난이도를 올리는 요인은 크게 네 가지로 갈림.&lt;/p&gt;
&lt;h3&gt;빌드가 복잡해짐&lt;/h3&gt;
&lt;p&gt;Java/Kotlin은 Gradle 한 번이면 끝임.&lt;/p&gt;
&lt;p&gt;native는 CMake나 ndk-build 같은 별도 빌드 시스템을 거침.&lt;/p&gt;
&lt;p&gt;거기다 멀티 ABI 문제가 있음.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;arm64-v8a&lt;/code&gt;, &lt;code&gt;armeabi-v7a&lt;/code&gt;, &lt;code&gt;x86_64&lt;/code&gt;, &lt;code&gt;x86&lt;/code&gt; 네 아키텍처가 각각 따로 빌드됨.&lt;/p&gt;
&lt;p&gt;한 ABI라도 빠지면 그 환경에서 라이브러리 로딩이 실패함.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;System.loadLibrary(&amp;quot;mylib&amp;quot;)&lt;/code&gt;를 쓰면 Android가 실행 기기에 맞는 ABI를 자동으로 골라줌.&lt;/p&gt;
&lt;p&gt;반면 &lt;code&gt;System.load&lt;/code&gt;로 &lt;code&gt;.so&lt;/code&gt; 경로를 직접 넘기면 그 파일이 기기 ABI와 안 맞을 때 그대로 로딩 실패임.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;System.load&lt;/code&gt;로 ABI 안 맞는 파일을 집어 &lt;code&gt;UnsatisfiedLinkError&lt;/code&gt;를 본 적이 있음.&lt;/p&gt;
&lt;p&gt;사소해 보여도 so를 직접 다루는 구조에서는 관리 포인트로 자주 등장함.&lt;/p&gt;
&lt;h3&gt;디버깅이 어려움&lt;/h3&gt;
&lt;p&gt;이게 가장 큰 차이임.&lt;/p&gt;
&lt;p&gt;Java/Kotlin은 &lt;code&gt;NullPointerException&lt;/code&gt;이 나면 stack trace에 클래스명과 라인 번호가 다 찍힘.&lt;/p&gt;
&lt;p&gt;native는 크래시가 나면 시그널 번호만 나옴.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SIGSEGV&lt;/code&gt; 같은 숫자 하나로는 위치를 알 수 없음.&lt;/p&gt;
&lt;p&gt;release 빌드는 심볼이 stripped 되어 backtrace에 함수 이름도 없음. 메모리 주소만 남음.&lt;/p&gt;
&lt;p&gt;이걸 풀려면 &lt;code&gt;addr2line&lt;/code&gt;이나 &lt;code&gt;ndk-stack&lt;/code&gt;으로 주소를 소스 위치에 매핑해야 함.&lt;/p&gt;
&lt;p&gt;그래도 안 되면 Ghidra로 디스어셈블함.&lt;/p&gt;
&lt;h3&gt;메모리를 직접 관리해야 함&lt;/h3&gt;
&lt;p&gt;GC가 없음.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;malloc&lt;/code&gt;/&lt;code&gt;new&lt;/code&gt;로 잡은 메모리는 내가 &lt;code&gt;free&lt;/code&gt;/&lt;code&gt;delete&lt;/code&gt;로 풀어야 함.&lt;/p&gt;
&lt;p&gt;안 풀면 누수임.&lt;/p&gt;
&lt;p&gt;두 번 풀면 double free임.&lt;/p&gt;
&lt;p&gt;푼 다음에 접근하면 use-after-free임.&lt;/p&gt;
&lt;p&gt;JVM이 GC로 가려주던 사고들이 전부 직접 관리 대상이 됨.&lt;/p&gt;
&lt;h3&gt;JNI 호출 속도 문제&lt;/h3&gt;
&lt;p&gt;JNI 호출 한 번은 일반 Java 메서드 호출보다 느림.&lt;/p&gt;
&lt;p&gt;Java &lt;code&gt;String&lt;/code&gt;을 C &lt;code&gt;char*&lt;/code&gt;로 바꿀 때 복사가 따라붙기도 함.&lt;/p&gt;
&lt;p&gt;JNI에서 만든 Java 객체 참조는 내가 정리해야 함.&lt;/p&gt;
&lt;p&gt;특히 Global Reference는 직접 해제 안 하면 프로세스가 끝날 때까지 남음.&lt;/p&gt;
&lt;h2&gt;이 시리즈가 다루는 범위&lt;/h2&gt;
&lt;p&gt;위에서 나열한 차이들을 한 편씩 떼어서 다룬다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;2편은 native에서 만나는 크래시 종류. 어떤 시그널이 어떤 사고를 뜻하는지.&lt;/li&gt;
&lt;li&gt;3편은 그 크래시를 막는 방법. 컴파일러 자동 안전망, 도구, 코드 습관으로 나눠서 본다.&lt;/li&gt;
&lt;li&gt;4편은 크래시가 난 뒤 원인을 추적하는 워크플로우. logcat에서 심볼 매핑, Ghidra까지 진행.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;너무 깊게는 가지 않는다. 쓰다가 삘받으면 심화 시리즈에서 다뤄볼 예정.&lt;/p&gt;
&lt;p&gt;컴파일러 최적화 내부, ARM ABI, 어셈블리 레벨 메모리 모델 같은 영역은 다루지 않음.&lt;/p&gt;
&lt;p&gt;그쪽은 사실상 시스템 프로그래밍 책의 영역.&lt;/p&gt;
&lt;p&gt;이 시리즈는 Java/Kotlin 개발자가 native에 처음 발을 디딜 때 필요한 선까지만 보려고 한다.&lt;/p&gt;
&lt;h2&gt;마무리 메모&lt;/h2&gt;
&lt;p&gt;NDK는 새 언어를 배우는 게 아니다. JAVA 로 개발하던 사람이라면 오랜만에 보는 C 언어의 향수를(악취) 느낄거다. JVM이 대신 해주던 일을 돌려받는 것에 가깝다. 그래서 어렵게 느껴지는 지점은 늘 문법이 아니라 &amp;quot;원래 누가 해주던 일인가&amp;quot;이다. 근데 그만큼 직접 함으로써 얻는 이점을 생각하면 된다.&lt;/p&gt;</description>
      <category>모바일</category>
      <category>Android</category>
      <category>C++</category>
      <category>JNI</category>
      <category>Native</category>
      <category>ndk</category>
      <category>NDK 입문</category>
      <category>네이티브 개발</category>
      <category>안드로이드 NDK</category>
      <author>Jo&amp;atilde;o Jin</author>
      <guid isPermaLink="true">https://yjcho9317.tistory.com/27</guid>
      <comments>https://yjcho9317.tistory.com/27#entry27comment</comments>
      <pubDate>Sat, 23 May 2026 23:10:58 +0900</pubDate>
    </item>
    <item>
      <title>Google Play 데이터 보안: AdMob 사용 시 &amp;quot;기기 또는 기타 ID 선언되지 않음&amp;quot; 경고 해결</title>
      <link>https://yjcho9317.tistory.com/26</link>
      <description>&lt;p&gt;Play Console에 빨간 경고가 떴음.&lt;/p&gt;
&lt;p&gt;&amp;quot;데이터 수집 안 하는데 왜?&amp;quot;라는 생각부터 들었음.&lt;/p&gt;
&lt;p&gt;결론부터 말하면 내가 안 해도 SDK가 함.&lt;/p&gt;
&lt;p&gt;AdMob 하나 붙인 앱이 데이터 보안 선언에서 데이터 유형 네 개를 채우게 된 과정을 정리함.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/58b2ed57-9611-4f4b-9250-2fc96300aa0f/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Play Console에 5월 28일까지 조치하라는 경고 알림이 와 있었음.&lt;/p&gt;
&lt;p&gt;경고 내용은 &amp;quot;사용자 데이터를 수집하거나 공유하는 앱은 데이터 보안 선언에서 모든 데이터 유형을 선언해야 한다&amp;quot;였음.&lt;/p&gt;
&lt;p&gt;셀카픽은 기기 안의 사진을 점수 매겨 베스트샷을 골라주는 앱임. 서버도 회원가입도 없고 사진을 외부로 보내지도 않음.&lt;/p&gt;
&lt;p&gt;경고 내용에 단서가 있었음: &amp;quot;데이터 보안(기기 또는 기타 ID 선언되지 않음)&amp;quot;&lt;/p&gt;
&lt;p&gt;Google Play에 운영 해본 개발자라면 알겠지만, 정책 갱신에도 2, 3일은 족히 걸림.&lt;/p&gt;
&lt;p&gt;경고를 늦게 발견한 탓에 기간이 얼마 남지 않아 최대한 찾아보고 한 번에 해결하도록 그 결과를 정리해봄.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/5c0b8de2-f130-4572-a0b3-f5e479a6efc7/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;원인은 AdMob이었음&lt;/h2&gt;
&lt;p&gt;셀카픽에는 버전 1부터 AdMob 배너가 들어가 있었음. 광고를 띄우려고 Google Mobile Ads SDK를 붙인 게 전부였음.(배너는 돈 안되는거 아는데 있어보이게 붙여보고 싶었음)&lt;/p&gt;
&lt;p&gt;광고 ID를 읽는 코드를 직접 짠 적은 없었음.&lt;/p&gt;
&lt;p&gt;그런데 Google Play 정책에서는 그게 예외가 안 됨.&lt;/p&gt;
&lt;p&gt;Google이 AdMob 개발자용으로 제공하는 데이터 공개 문서에 SDK가 자동 수집하는 데이터가 명시돼 있었음.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;The Google Mobile Ads SDK automatically collects and shares IP address, user product interactions, diagnostic information, and device/account identifiers for advertising, analytics, and fraud prevention.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;개발자가 코드를 한 줄도 안 짜도 SDK가 알아서 수집함.&lt;/p&gt;
&lt;p&gt;Play 데이터 보안 정책은 &amp;quot;앱이 수집하는 데이터&amp;quot;를 앱에 포함된 서드파티 SDK까지 합쳐서 봄.&lt;/p&gt;
&lt;p&gt;즉 AdMob을 붙인 순간 내 앱은 데이터를 수집하고 서드파티(Google)와 공유하는 앱이 됨.&lt;/p&gt;
&lt;p&gt;광고 SDK를 쓰는 앱이라면 출시 시점과 무관하게 똑같이 해당됨.&lt;/p&gt;
&lt;h2&gt;왜 지금 떴나&lt;/h2&gt;
&lt;p&gt;AdMob은 첫 출시 때부터 있었는데 경고는 버전 코드 9에서 떴음.&lt;/p&gt;
&lt;p&gt;이 시점 차이가 의문이었음.&lt;/p&gt;
&lt;p&gt;SDK 버전 변경이나 Play Console 검증 과정에서 기존 선언과 실제 SDK 동작의 불일치가 감지됐을 가능성이 있음.&lt;/p&gt;
&lt;p&gt;다만 Google은 구체적인 감지 기준을 공개하지 않아 정확한 원인은 확인할 수 없음.&lt;/p&gt;
&lt;p&gt;위 데이터 공개 문서도 &amp;quot;SDK는 계속 업데이트되므로 변경사항을 반영해 선언도 갱신하라&amp;quot;고만 안내함.&lt;/p&gt;
&lt;p&gt;원인이 뭐든 결국 선언을 SDK 실제 동작에 맞추는 문제였음.&lt;/p&gt;
&lt;h2&gt;공식 문서가 알려주는 것과 안 알려주는 것&lt;/h2&gt;
&lt;p&gt;데이터 공개 문서에 따르면 AdMob SDK가 자동 수집하는 데이터는 네 가지로 정리돼 있었음.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;SDK가 수집하는 것&lt;/th&gt;
&lt;th&gt;내용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;IP 주소&lt;/td&gt;
&lt;td&gt;기기의 IP. 대략적인 위치 추정에 쓰일 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;사용자 제품 상호작용&lt;/td&gt;
&lt;td&gt;앱 실행, 탭, 동영상 조회 등&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;진단 정보&lt;/td&gt;
&lt;td&gt;앱 실행 시간, 멈춤 비율, 에너지 사용량 등&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기기 및 계정 식별자&lt;/td&gt;
&lt;td&gt;Android 광고 ID, App Set ID, 로그인 계정 관련 식별자&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;문제는 이걸 Play Console 폼의 어느 체크박스에 넣어야 하는지를 이 문서가 안 알려준다는 것이었음.&lt;/p&gt;
&lt;p&gt;문서 자체가 &amp;quot;데이터 보안 폼에 어떻게 답할지는 전적으로 개발자 책임&amp;quot;이라고 못박고 있었음.&lt;/p&gt;
&lt;p&gt;폼 작성은 Android의 데이터 유형 가이드을 보고 알아서 판단하라는 식임.&lt;/p&gt;
&lt;p&gt;AdMob 개발자 그룹 글을 보면 이걸로 몇 년째 같은 질문이 반복됨. &amp;quot;문서는 수집 데이터 종류만 나열하고 폼의 나머지 질문은 하나도 안 알려준다&amp;quot;는 불만임.&lt;/p&gt;
&lt;p&gt;그래서 데이터 공개 문서와 실제 Google Play 승인을 받은 개발자의 폼 작성 공유 글(참고 자료 2)을 같이 놓고 폼을 채웠음.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/7a8d9454-7990-4e65-b560-f52899b23b56/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;데이터 보안 폼은 5단계임. 개요, 데이터 수집 및 보안, 데이터 유형, 데이터 취급 및 처리, 미리보기.&lt;/p&gt;
&lt;p&gt;아래는 단계별로 어떻게 채웠는지임.&lt;/p&gt;
&lt;h2&gt;2단계: 데이터 수집 및 보안&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/36c28e4d-ef21-455e-9bba-71fff3502a67/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&amp;quot;필수 사용자 데이터 유형을 수집하거나 공유하나요?&amp;quot; : &amp;quot;예&amp;quot;.&lt;br&gt;-&amp;gt; AdMob을 쓰면 무조건 &amp;quot;예&amp;quot;이고 &amp;quot;아니요&amp;quot;는 거짓 선언이 됨.&lt;/p&gt;
&lt;p&gt;&amp;quot;수집하는 모든 사용자 데이터를 암호화하여 전송하나요?&amp;quot; : &amp;quot;예&amp;quot;.&lt;br&gt;-&amp;gt; 데이터 공개 문서에 SDK가 수집하는 데이터는 전부 TLS 프로토콜로 전송 중 암호화된다고 적혀 있음.&lt;br&gt;-&amp;gt; 직접 수집하는 데이터가 없으면 전부 AdMob 데이터이고 전부 TLS 암호화임.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/cbc6e1ed-d702-4801-9ed4-952918b4ae77/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&amp;quot;계정 생성 방법&amp;quot;은 &amp;quot;앱에서 사용자가 계정을 만들도록 허용하지 않음&amp;quot;.&lt;br&gt;-&amp;gt; 셀카픽은 로그인 기능이 없음. 구글 로그인 같은 OAuth가 있으면 해당 항목을 체크하면 됨.&lt;/p&gt;
&lt;p&gt;&amp;quot;데이터 삭제를 요청할 방편을 제공하나요?&amp;quot;는 &amp;quot;아니요&amp;quot;.&lt;br&gt;-&amp;gt; 셀카픽에 삭제 요청 기능을 따로 만든 적이 없으니 정직하게 &amp;quot;아니요&amp;quot;임.&lt;/p&gt;
&lt;p&gt;세 번째 선택지 &amp;quot;아니요(90일 이내 자동 삭제됨)&amp;quot;는 고르면 안 됨.&lt;br&gt;-&amp;gt; AdMob 데이터가 90일 안에 자동 삭제된다고 보장할 수 없어 부정확한 선언이 됨.&lt;/p&gt;
&lt;h2&gt;3단계: 데이터 유형&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/505c7ff0-62e0-44bb-8b1a-006f54ba4d3c/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;AdMob 기준으로 선언하게 되는 데이터 유형은 네 가지였음.&lt;/p&gt;
&lt;p&gt;위치, 앱 활동, 앱 정보 및 성능, 기기 또는 기타 ID&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/4ac055f7-f2dd-4d76-87ee-a011982ec67b/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;위치는 하위 항목 중 &amp;quot;대략적인 위치&amp;quot;를 고름.&lt;/p&gt;
&lt;p&gt;IP 주소가 여기 매핑됨. Play 폼에 IP 항목이 따로 없어 위치로 들어감.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/43f6b851-86fa-4999-90c0-1d20fa51e823/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/598e1c3e-64a8-4aff-b460-f205fb86c041/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/af636a55-5237-4475-8861-a648288d2194/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;나머지 셋은 앱 활동의 &amp;quot;앱 상호작용&amp;quot;, 앱 정보 및 성능의 &amp;quot;진단&amp;quot;, 그리고 &amp;quot;기기 또는 기타 ID&amp;quot;임.&lt;/p&gt;
&lt;p&gt;마지막 &amp;quot;기기 또는 기타 ID&amp;quot;가 이번 경고에 직접 찍힌 항목이었음. 광고 ID가 여기 들어감.&lt;/p&gt;
&lt;p&gt;여기서 가장 많이 하는 오해를 짚고 감.&lt;/p&gt;
&lt;p&gt;&amp;quot;대략적인 위치&amp;quot;를 선언한다고 앱에 위치 권한 팝업을 띄울 필요는 없음.&lt;/p&gt;
&lt;p&gt;AdMob이 네트워크 통신 과정에서 IP를 읽어 국가나 도시를 추정하는 것과, Android OS의 위치 권한(ACCESS_COARSE_LOCATION 등)은 완전히 별개임.&lt;/p&gt;
&lt;p&gt;데이터 보안 폼 선언과 OS 권한은 서로 다른 영역임.&lt;/p&gt;
&lt;h2&gt;4단계: 데이터 취급 및 처리&lt;/h2&gt;
&lt;p&gt;4단계에서는 선언한 네 개 데이터 유형마다 각각 세부 질문에 답해야 함.&lt;/p&gt;
&lt;p&gt;폼에서 제일 길게 느껴지는 구간인데, 나와 같은 케이스에는 단순함.&lt;/p&gt;
&lt;p&gt;처음엔 데이터 유형마다 목적을 다르게 체크해야 하나 고민했음. 진단은 분석용이고 광고 ID는 광고용이니까.&lt;/p&gt;
&lt;p&gt;그런데 실제 Google Play 승인 사례를 보면 AdMob만 쓰는 일반적인 앱은 데이터 유형별 답이 거의 동일함.&lt;/p&gt;
&lt;p&gt;아래가 그 일반적인 선택값임.&lt;/p&gt;
&lt;p&gt;2026년 5월 기준이고, Google 데이터 보안 정책과 폼 구성은 수시로 바뀌므로 작성 시점에 공식 문서를 다시 확인하는 게 좋음.&lt;/p&gt;
&lt;p&gt;최종 판단 책임은 개발자에게 있고 앱 구성에 따라 달라질 수 있음.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/2e0c0710-00bc-49e3-8fd6-29025e5e0444/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;보고 따라해도 되지만, 헷갈리기 쉬운 칸은 이유를 알아두는 게 나음.&lt;/p&gt;
&lt;p&gt;먼저 수집과 공유를 둘 다 체크하는 이유. AdMob SDK가 데이터를 읽어 Google 광고 서버로 보내고, Google이 그 데이터를 자기 목적(광고 타게팅과 최적화)으로 씀.&lt;/p&gt;
&lt;p&gt;내 앱 입장에서 데이터가 기기 밖으로 나가니 &amp;quot;수집&amp;quot;이고, 그 데이터를 서드파티인 Google이 쓰니 &amp;quot;공유&amp;quot;임. 광고판만 빌려준 느낌이지만 정책 정의상 둘 다 맞음.&lt;/p&gt;
&lt;p&gt;임시 처리는 &amp;quot;아니요&amp;quot;. &amp;quot;임시 처리&amp;quot;는 데이터가 메모리에 잠깐 있다가 요청 처리 직후 사라지는 경우를 말함.&lt;/p&gt;
&lt;p&gt;AdMob은 광고 타게팅과 분석을 위해 데이터를 서버에 보내고 일정 기간 보관함. 메모리에서 잠깐 쓰고 버리는 게 아니라 &amp;quot;아니요&amp;quot;임.&lt;/p&gt;
&lt;p&gt;필수 여부는 &amp;quot;필수&amp;quot;. 셀카픽 기준에서는 사용자가 앱 안에서 데이터 수집을 따로 끌 방법이 없어서 &amp;quot;필수&amp;quot;로 선택했음.&lt;/p&gt;
&lt;p&gt;폼에서 &amp;quot;필수&amp;quot;와 &amp;quot;선택&amp;quot;을 가르는 기준은 사용자가 그 수집을 끌 수 있는 인앱 설정이 있느냐임. 끌 토글이 없으면 &amp;quot;필수&amp;quot;임.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/d09ec67f-77a5-46a8-9b29-791b0761c30f/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;목적 체크박스는 일곱 개쯤 뜨는데 표에 적은 세 개만 체크함. 데이터 공개 문서가 &amp;quot;advertising, analytics, and fraud prevention&amp;quot;이라고 명시한 그 세 가지임.&lt;/p&gt;
&lt;p&gt;앱 기능, 개발자 커뮤니케이션, 맞춤설정, 계정 관리는 AdMob만 쓰는 앱에는 해당이 없음.&lt;/p&gt;
&lt;p&gt;이 표를 다른 항목(위치, 앱 활동, 앱 정보 및 성능, 기기 또는 기타 ID)에 똑같이 반복함.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/2dcae9e6-dae9-4730-9ca7-b24eec8391b0/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;네 항목 전부 &amp;quot;완료됨&amp;quot;으로 바뀌면 끝임.&lt;/p&gt;
&lt;h2&gt;개인정보처리방침도 일치해야 함&lt;/h2&gt;
&lt;p&gt;경고 문구에 &amp;quot;개인정보처리방침과 데이터 보안 선언의 정보가 일치해야 한다&amp;quot;는 줄이 있었음. 이걸 놓치기 쉬움.&lt;/p&gt;
&lt;p&gt;개인정보처리방침에도 AdMob/Google 광고를 통해 광고 ID, IP 등이 수집되고 공유된다는 내용이 들어가 있어야 함. 앱 콘텐츠 &amp;gt; 개인정보처리방침에서 같이 점검하는 게 좋음.&lt;/p&gt;
&lt;p&gt;글로벌 출시 앱이면 같이 점검해두는 게 좋음.&lt;/p&gt;
&lt;h2&gt;광고 ID를 막아도 데이터 보안 선언은 해야함&lt;/h2&gt;
&lt;p&gt;이거는 참고만 해도 됨.&lt;/p&gt;
&lt;p&gt;선언을 채우는 게 가장 빠른 해결임. 셀카픽도 권한은 그대로 두고 선언만 정확히 채웠음.&lt;/p&gt;
&lt;p&gt;광고 ID 사용을 제한하는 방법도 있음.&lt;/p&gt;
&lt;p&gt;데이터 공개 문서에도 나오듯 AndroidManifest에서 AD_ID 권한을 제거하면 됨.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;uses-permission android:name=&amp;quot;com.google.android.gms.permission.AD_ID&amp;quot;
    tools:node=&amp;quot;remove&amp;quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그런데 AD_ID를 제거해도 &amp;quot;기기 또는 기타 ID&amp;quot; 선언 의무는 사라지지 않음. 이유는 App Set ID에 있음.&lt;/p&gt;
&lt;p&gt;App Set ID는 같은 개발자가 배포한 앱들을 묶어서 식별하는 ID임. 원래 광고 ID의 대안으로 만들어진 별도 식별자로, 광고 ID가 광고 추적용이라면 App Set ID는 그 외 용도를 위한 것임. Play Console 도움말(참고 자료 5번)에서는 분석과 사기 방지 같은 비광고 용도에는 광고 ID 대신 App Set ID를 쓰라고 안내함.&lt;/p&gt;
&lt;p&gt;핵심은 이것임. AD_ID 권한은 광고 ID에만 걸리고, App Set ID는 그 권한 체계와 무관한 별도 API임.&lt;/p&gt;
&lt;p&gt;그래서 AD_ID 권한 제거만으로는 App Set ID 전송이 막히지 않음.&lt;/p&gt;
&lt;p&gt;App Set ID 역시 Google 정책상 &amp;quot;기기 또는 기타 ID&amp;quot;에 해당함.&lt;/p&gt;
&lt;p&gt;그래서 AD_ID를 날려 광고 ID를 막아도 App Set ID가 남아 있는 한 &amp;quot;기기 또는 기타 ID&amp;quot;는 계속 선언해야 함.&lt;/p&gt;
&lt;p&gt;4단계 표에서 목적으로 &amp;quot;애널리틱스&amp;quot;와 &amp;quot;사기 예방 보안&amp;quot;을 체크한 것도 이 App Set ID와 연결됨. 광고 ID는 광고용이지만 App Set ID 같은 식별자는 분석과 사기 방지 용도로 쓰이기 때문임.&lt;/p&gt;
&lt;p&gt;권한 제거는 트레이드오프가 있음. 광고 맞춤화 기능에 영향을 줄 수 있으므로 적용 전 광고 수익에 미치는 영향을 검토하는 게 좋음.&lt;/p&gt;
&lt;p&gt;셀카픽은 광고 맞춤화를 유지하기로 하고 선언을 채우는 쪽을 택했음.&lt;/p&gt;
&lt;h2&gt;마무리 메모&lt;/h2&gt;
&lt;p&gt;아래는 해결된 화면.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/f5632c9b-c343-4668-a073-fc76fc5a4958/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;서드파티 SDK를 하나 붙인다는 건 기능 하나를 추가하는 게 아니라 그 SDK가 수집하는 데이터 전부를 내 앱의 수집 범위로 끌어들이는 것임. AdMob, Firebase Analytics, Crashlytics, AppsFlyer 다 마찬가지임. 코드를 직접 짜지 않았어도 데이터 책임은 앱에 남으니, SDK를 고를 때는 기능 명세보다 데이터 공개 문서를 먼저 봐야 함.&lt;/p&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Google Play data disclosure (Google Mobile Ads SDK 공식 데이터 공개 문서) - &lt;a href=&quot;https://developers.google.com/admob/android/privacy/play-data-disclosure&quot;&gt;https://developers.google.com/admob/android/privacy/play-data-disclosure&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;How to fill out Google Play&amp;#39;s Data safety section for apps using AdMob (Gameljne Games) - &lt;a href=&quot;https://gameljne.games/how-to-fill-out-google-plays-data-safety-section-for-apps-using-admob/&quot;&gt;https://gameljne.games/how-to-fill-out-google-plays-data-safety-section-for-apps-using-admob/&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Google AdMob 개발자 그룹 - &lt;a href=&quot;https://groups.google.com/g/google-admob-ads-sdk/c/ZCE9pVAto28&quot;&gt;https://groups.google.com/g/google-admob-ads-sdk/c/ZCE9pVAto28&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Android 데이터 유형 가이드 (Play Console 데이터 보안 도움말) - &lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/10787469&quot;&gt;https://support.google.com/googleplay/android-developer/answer/10787469&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Advertising ID (Play Console 도움말) - &lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/6048248&quot;&gt;https://support.google.com/googleplay/android-developer/answer/6048248&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>모바일</category>
      <category>Admob</category>
      <category>Google Play</category>
      <category>play console</category>
      <category>데이터 보안 선언</category>
      <author>Jo&amp;atilde;o Jin</author>
      <guid isPermaLink="true">https://yjcho9317.tistory.com/26</guid>
      <comments>https://yjcho9317.tistory.com/26#entry26comment</comments>
      <pubDate>Sat, 23 May 2026 22:13:06 +0900</pubDate>
    </item>
    <item>
      <title>Android 개발자 인증 정리 - Play Console 등록 가이드</title>
      <link>https://yjcho9317.tistory.com/25</link>
      <description>&lt;p&gt;Google Play Console에서 Android 개발자 인증 관련 메일이 왔다. 2025년 8월에 발표된 제도가 Play Console에 실제 등록 UI로 열렸길래, 앱 개발자 입장에서 뭘 해야 하는지만 추려서 정리했다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/ae421f27-f0ef-475f-94e1-bff95c45792c/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;핵심 3줄&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;2026년 9월부터 브라질·싱가포르·인도네시아·태국에서는 등록 안 된 앱은 인증 안드로이드 기기에 설치할 수 없다. 한국 포함 나머지는 2027년 이후.&lt;/li&gt;
&lt;li&gt;Play 스토어에만 배포하면 자동 등록이라 거의 할 게 없다.&lt;/li&gt;
&lt;li&gt;Play 외부(사이드로딩·제3자 스토어)로도 배포한다면 직접 등록해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;왜 이 제도가 생겼나 (짧게)&lt;/h2&gt;
&lt;p&gt;Google &lt;a href=&quot;https://android-developers.googleblog.com/2025/08/elevating-android-security.html&quot;&gt;발표문&lt;/a&gt;은 &amp;quot;Play 외부 사이드로딩 소스의 멀웨어가 Play 대비 50배 이상&amp;quot;이라는 숫자를 근거로 든다. 이 격차의 상당 부분은 Play의 권한 심사에서 나온다.&lt;/p&gt;
&lt;p&gt;Google Play는 악성 앱이 주로 악용하는 권한들을 엄격하게 걸러낸다. 예를 들어 &lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/10208820?hl=ko&quot;&gt;SMS·통화 기록 권한&lt;/a&gt;은 기본 문자·전화 앱으로 지정된 앱만 쓸 수 있다. &lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/16558241?hl=ko&quot;&gt;화면 접근성(Accessibility)이나 다른 앱 설치 권한(REQUEST_INSTALL_PACKAGES)&lt;/a&gt;도 &amp;quot;이 앱의 핵심 기능이 정말 이 권한을 필요로 하는가&amp;quot;를 별도 심사로 확인한다.&lt;/p&gt;
&lt;p&gt;뱅킹 트로이목마가 Accessibility로 화면을 덮거나, 드로퍼가 추가 APK를 몰래 까는 식의 전형적 공격 패턴이 Play 입점 단계에서 상당수 걸러진다.&lt;/p&gt;
&lt;p&gt;사이드로딩 APK는 이 필터 자체를 건너뛴다. Android 개발자 인증은 이 빈 구멍에 &amp;quot;개발자 신원&amp;quot;이라는 층을 끼워 넣는 장치다. 익명으로 악성 앱을 뿌리고 이름만 바꿔 다시 올리는 수법을 막자는 거다.&lt;/p&gt;
&lt;h2&gt;Case A — Play 스토어에만 배포&lt;/h2&gt;
&lt;p&gt;자동 등록 메일을 받았다면 거의 다 끝났다. 이 섹션만 봐도 된다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/e73db018-8e0c-4366-9103-731cb05489c8/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;확인 체크리스트:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Play Console → &lt;strong&gt;Android 개발자 인증&lt;/strong&gt; 페이지에서 앱 목록 상태가 모두 &lt;strong&gt;등록됨&lt;/strong&gt;인지 확인&lt;/li&gt;
&lt;li&gt;Play 앱 서명(Play App Signing)의 &lt;strong&gt;앱 서명 키&lt;/strong&gt;와 &lt;strong&gt;외부 배포 APK 서명 키가 같은지&lt;/strong&gt; 확인. 다르면 외부 배포 쪽 키를 따로 등록해야 한다&lt;/li&gt;
&lt;li&gt;둘 다 OK면 Case B는 안 봐도 된다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;자동 등록이 안 되고 &lt;strong&gt;draft&lt;/strong&gt; 상태로 남은 앱이 있다면, 같은 패키지 이름을 다른 키로 배포한 이력이 있는 경우다. 이럴 때만 Case B 절차로 넘어가면 된다.&lt;/p&gt;
&lt;h2&gt;Case B — Play 외부 배포가 있는 경우&lt;/h2&gt;
&lt;p&gt;Play Console → &lt;strong&gt;Android 개발자 인증&lt;/strong&gt; 페이지 → &lt;strong&gt;패키지 이름 등록&lt;/strong&gt; 버튼.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/2d7c6343-fc4b-4123-a219-676cbd801534/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3&gt;Step 1 — 패키지 이름 입력&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/e621407c-b2a4-4cdf-aa3c-95c90356271c/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;applicationId를 그대로 넣고, 내부용 이름은 식별만 되면 된다.&lt;/p&gt;
&lt;h3&gt;Step 2 — 공개 키 추가&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/2cd3d9bd-ca72-4214-b997-24181ee7299f/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;임시 상태로 패키지가 만들어진다. &lt;strong&gt;키 추가&lt;/strong&gt; 버튼으로 넘어간다.&lt;/p&gt;
&lt;h3&gt;Step 3 — SHA-256 지문 입력&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/b67b0559-3cf1-4d29-b60c-c87294a352dc/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;입력하기 전에 먼저 체크할 게 있다. 어느 키의 SHA-256을 넣을 거냐.&lt;/p&gt;
&lt;h3&gt;어느 키의 SHA-256을 넣을 것인가&lt;/h3&gt;
&lt;p&gt;Play Console → 앱 선택 → &lt;code&gt;테스트 및 출시 &amp;gt; 앱 무결성 &amp;gt; 앱 서명&lt;/code&gt; 페이지로 간다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/b4926cda-65e8-416f-83a5-5a6e6ad340aa/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;여기에 &amp;quot;앱 서명 키 인증서&amp;quot; 섹션과 함께 아래 같은 안내가 뜬다면, 이 앱은 &lt;strong&gt;Play 앱 서명&lt;/strong&gt;을 쓰는 것이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Google에서 각 출시 버전에 서명할 때 사용하는 앱 서명 키에 대한 공개 인증서입니다. 앱 서명 키 자체는 액세스할 수 없으며 Google 서버에 안전하게 보관됩니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;사용자 기기에 실제로 깔리는 APK는 Google이 이 키로 재서명한 거다. 그래서 등록할 SHA-256도 이 페이지 하단의 &amp;quot;SHA-256 인증서 지문&amp;quot; 값을 써야 한다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.android.com/studio/publish/app-signing?hl=ko&quot;&gt;Android Studio 공식 문서&lt;/a&gt;에 따르면 AAB 배포는 Play 앱 서명이 필수이고, 2021년 8월 이후 만든 신규 앱은 AAB가 필수다. 즉 최근 몇 년 안에 만든 앱은 거의 다 이 경우다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;예외.&lt;/strong&gt; 2021년 8월 이전에 만들어 APK로 직접 업로드하는 앱은 Play 앱 서명을 안 쓸 수 있다. 이때는 업로드 키 개념이 따로 없고, 로컬 키 저장소(keystore)가 그대로 기기에 깔리는 APK의 서명 키가 된다. 로컬에서 지문을 뽑는다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./gradlew signingReport&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;정리하면:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;배포 경로&lt;/th&gt;
&lt;th&gt;등록할 키&lt;/th&gt;
&lt;th&gt;SHA-256 어디서&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Play 앱 서명 사용 (대부분 여기)&lt;/td&gt;
&lt;td&gt;앱 서명 키&lt;/td&gt;
&lt;td&gt;Play Console &lt;code&gt;앱 무결성 &amp;gt; 앱 서명&lt;/code&gt;의 &amp;quot;앱 서명 키 인증서&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Play 앱 서명 미사용 (2021-08 이전 + APK 업로드)&lt;/td&gt;
&lt;td&gt;로컬 키 저장소&lt;/td&gt;
&lt;td&gt;&lt;code&gt;./gradlew signingReport&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Play 외부 배포 전용&lt;/td&gt;
&lt;td&gt;로컬 키 저장소&lt;/td&gt;
&lt;td&gt;&lt;code&gt;./gradlew signingReport&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;둘 다 배포&lt;/td&gt;
&lt;td&gt;양쪽 다 등록&lt;/td&gt;
&lt;td&gt;각각 뽑아서 추가 키로 붙이기&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;CI 환경처럼 Gradle을 쓰기 애매한 데라면 &lt;code&gt;keytool&lt;/code&gt;도 같이 알아두면 편하다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;keytool -list -v -keystore app/release.keystore -alias release&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;키 저장소 경로랑 alias는 예시니까 본인 프로젝트 값으로 바꿔 넣는다. 보통 &lt;code&gt;gradle.properties&lt;/code&gt;의 &lt;code&gt;RELEASE_KEY_ALIAS&lt;/code&gt; 같은 데 들어 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;⚠️ &lt;strong&gt;헷갈리는 부분&lt;/strong&gt;&lt;br&gt;Play 앱 서명을 쓰는데 &lt;code&gt;./gradlew signingReport&lt;/code&gt; 결과를 등록하면 &lt;strong&gt;업로드 키&lt;/strong&gt; 지문이 들어간다. 실제 기기에 깔리는 APK는 Google의 &lt;strong&gt;앱 서명 키&lt;/strong&gt;로 재서명된 거라서 검증이 어긋난다. 2021년 8월 이후 앱이면 거의 다 Play 앱 서명이니까, 앱 무결성 페이지의 &amp;quot;앱 서명 키 인증서&amp;quot; 값을 쓰는 게 맞다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;Step 4 — 기존 패키지면 APK 업로드로 소유권 증명&lt;/h3&gt;
&lt;p&gt;새 패키지 이름이면 SHA-256 입력으로 끝난다. 반면 &lt;strong&gt;이미 설치 이력이 있는 기존 패키지&lt;/strong&gt;라면 &amp;quot;이 키의 주인이 진짜 나&amp;quot;라는 증명을 한 번 더 해야 한다 (&lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/16761053?hl=ko&quot;&gt;공식 가이드&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;절차는 이렇다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Play Console에서 &lt;strong&gt;snippet 복사&lt;/strong&gt; (내 개발자 계정에 묶인 고유 문자열)&lt;/li&gt;
&lt;li&gt;앱 프로젝트의 &lt;code&gt;app/src/main/assets/&lt;/code&gt; 아래에 &lt;strong&gt;&lt;code&gt;adi-registration.properties&lt;/code&gt;&lt;/strong&gt; 파일을 만든다 (파일명은 오타 없이 정확히)&lt;/li&gt;
&lt;li&gt;파일에 snippet을 그대로 붙여넣는다&lt;/li&gt;
&lt;li&gt;평소대로 release APK를 빌드한다. Gradle &lt;code&gt;signingConfigs&lt;/code&gt;가 세팅돼 있으면 &lt;code&gt;./gradlew assembleRelease&lt;/code&gt;만 치면 서명까지 자동으로 된다&lt;/li&gt;
&lt;li&gt;Play Console 업로드 영역에 방금 만든 APK를 올린다. Google이 서명을 검증한다&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;실제 배포용 APK가 아니어도 된다.&lt;/strong&gt; Google이 보는 건 &amp;quot;APK 서명 = 등록한 공개 키&amp;quot; 일치 여부뿐이라, 빈 프로젝트에 applicationId와 서명 키만 맞춰서 만든 APK로도 통과한다. 샘플 구조는 &lt;a href=&quot;https://github.com/android/security-samples/tree/main/AndroidDeveloperVerificationAPKSigningExample&quot;&gt;security-samples 리포&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;참고 — 갤럭시 스토어처럼 스토어가 서명하는 경우&lt;/strong&gt;&lt;br&gt;갤럭시 스토어에 AAB를 올리면 스토어가 최종 APK를 재서명한다. 이 경우 개발자 본인에게는 최종 APK의 비공개 키가 없다. &lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/16761055?hl=ko&quot;&gt;공식 가이드&lt;/a&gt;는 이럴 때 &lt;strong&gt;스토어에서 서명 완료된 APK를 내려받아 Play Console에 올리라&lt;/strong&gt;고 안내한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;서명 키가 여러 개면 같은 패키지에 &lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/16762301?hl=ko&quot;&gt;추가 키 등록&lt;/a&gt;으로 붙이면 된다.&lt;/p&gt;
&lt;h3&gt;Step 5 — 등록 완료&lt;/h3&gt;
&lt;p&gt;등록이 끝나면 아래처럼 표시된다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/345cbe76-b545-4169-ac71-715977380263/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/developer-verification?hl=ko&quot;&gt;Android 개발자 인증 공식 페이지&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://android-developers.googleblog.com/2025/08/elevating-android-security.html&quot;&gt;A new layer of security for certified Android devices (2025-08-25)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/16984799?hl=ko&quot;&gt;Play 패키지 이름 등록&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/16761053?hl=ko&quot;&gt;Android 패키지 이름 등록&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/16762301?hl=ko&quot;&gt;추가 키 등록&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/9842756?hl=ko&quot;&gt;Play 앱 서명 사용하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/studio/publish/app-signing?hl=ko&quot;&gt;앱에 서명하기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>모바일</category>
      <category>Android</category>
      <category>Google Play</category>
      <category>Play App Signing</category>
      <category>play console</category>
      <category>개발자인증</category>
      <category>앱 서명</category>
      <author>Jo&amp;atilde;o Jin</author>
      <guid isPermaLink="true">https://yjcho9317.tistory.com/25</guid>
      <comments>https://yjcho9317.tistory.com/25#entry25comment</comments>
      <pubDate>Sun, 19 Apr 2026 02:30:47 +0900</pubDate>
    </item>
    <item>
      <title>온디바이스 AI 경량화 (3) &amp;mdash; LiteRT로 INT8 모델 Android 배포와 GPU Delegate의 함정</title>
      <link>https://yjcho9317.tistory.com/24</link>
      <description>&lt;p&gt;&lt;a href=&quot;https://yjcho9317.tistory.com/22&quot;&gt;1편에서 양자화로 모델을 92% 줄였고&lt;/a&gt;, &lt;a href=&quot;https://yjcho9317.tistory.com/23&quot;&gt;2편에서 프루닝과 지식 증류까지 실험해봤다&lt;/a&gt;. 경량화는 끝났다.&lt;/p&gt;
&lt;p&gt;그러면 주제인 온디바이스는?&lt;/p&gt;
&lt;p&gt;Python에서 77.88% 나온 INT8 모델이 Android 단말에서도 같은 정확도와 속도를 낼지, 직접 올려봤다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;프레임워크 선택&lt;/h2&gt;
&lt;p&gt;내가 고른 건 LiteRT(구 TensorFlow Lite)다. 이유는 단순했다. 학습을 Keras로 했으니 변환이 &lt;code&gt;.tflite&lt;/code&gt; 한 번으로 끝나고, INT8 양자화된 모델을 그대로 들고 올 수 있고, GPU Delegate까지 바로 붙는다. 선택이라기보다는 기본값이었다.&lt;/p&gt;
&lt;p&gt;선택지를 비교하긴 했다. Android 모바일 배포에서는 LiteRT / ONNX Runtime / ExecuTorch 셋이 주로 꼽힌다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;LiteRT&lt;/th&gt;
&lt;th&gt;ONNX Runtime&lt;/th&gt;
&lt;th&gt;ExecuTorch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;개발사&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;Microsoft&lt;/td&gt;
&lt;td&gt;Meta&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모델 형식&lt;/td&gt;
&lt;td&gt;.tflite&lt;/td&gt;
&lt;td&gt;.onnx&lt;/td&gt;
&lt;td&gt;.pte&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;강점&lt;/td&gt;
&lt;td&gt;모바일 생태계 성숙&lt;/td&gt;
&lt;td&gt;프레임워크 중립&lt;/td&gt;
&lt;td&gt;PyTorch 네이티브, LLM 특화&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;규칙은 간단하다. &lt;strong&gt;학습한 프레임워크의 네이티브 런타임을 쓰면 된다.&lt;/strong&gt; TensorFlow → LiteRT, PyTorch → ExecuTorch, 프레임워크가 섞이는 팀이면 ONNX Runtime. 모바일 런타임 자체 성능은 벤치마크마다 LiteRT가 앞선다는 결과가 많다. Samsung S21 벤치마크 기준 LiteRT 23ms, ONNX Runtime 31ms, PyTorch Mobile 38ms (&lt;a href=&quot;https://www.digitalocean.com/community/tutorials/ai-model-deployment-optimization&quot;&gt;DigitalOcean 비교 자료&lt;/a&gt;). 물론 NNAPI나 vendor delegate 붙이면 순위가 바뀌기도 하니까 타깃 디바이스에서 직접 재보는 게 맞다.&lt;/p&gt;
&lt;p&gt;ONNX를 &amp;quot;주류&amp;quot;라고 부르는 건 모델 교환 포맷 얘기지 모바일 런타임 얘기가 아니다. 이걸 구분 안 하면 프레임워크 고르는 데 시간을 잘못 쓴다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;프로젝트 설정&lt;/h2&gt;
&lt;p&gt;모델 파일을 &lt;code&gt;app/src/main/assets/&lt;/code&gt;에 넣고, Gradle을 설정한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;android {
    androidResources {
        noCompress += &amp;quot;tflite&amp;quot; // 압축 방지 (필수!)
    }
}

dependencies {
    implementation(&amp;quot;org.tensorflow:tensorflow-lite:2.14.0&amp;quot;)
    implementation(&amp;quot;org.tensorflow:tensorflow-lite-support:0.4.4&amp;quot;)
    implementation(&amp;quot;org.tensorflow:tensorflow-lite-gpu:2.14.0&amp;quot;)
    implementation(&amp;quot;org.tensorflow:tensorflow-lite-gpu-api:2.14.0&amp;quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;noCompress&lt;/code&gt;를 안 넣으면 모델 로드가 실패한다. Android Gradle 플러그인 4.1부터는 &lt;code&gt;.tflite&lt;/code&gt;가 기본으로 noCompress에 들어가 있긴 한데, 명시적으로 넣어두는 게 안전하다.&lt;/p&gt;
&lt;p&gt;ML 모델은 일반 앱보다 메모리를 많이 쓴다. 큰 모델(수백 MB 이상)을 올릴 거면 &lt;code&gt;AndroidManifest.xml&lt;/code&gt;에 대규모 힙 설정을 넣어준다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;application
    android:largeHeap=&amp;quot;true&amp;quot;
    android:hardwareAccelerated=&amp;quot;true&amp;quot;&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;내 모델은 1.1MB라 사실 필요 없지만, 실서비스에서는 모델이 더 클 수 있으니 미리 넣어두는 편이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;모델 로드와 추론&lt;/h2&gt;
&lt;p&gt;모델을 로드하고 추론하는 코드는 크게 복잡하지 않다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;class TFLiteClassifier(
    private val context: Context,
    private val modelPath: String = &amp;quot;base_int8.tflite&amp;quot;,
    private val numThreads: Int = 4
) {
    private var interpreter: Interpreter? = null

    init {
        val options = Interpreter.Options()
        options.setNumThreads(numThreads)
        interpreter = Interpreter(loadModelFile(), options)
    }

    private fun loadModelFile(): MappedByteBuffer {
        val fileDescriptor = context.assets.openFd(modelPath)
        val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
        val fileChannel = inputStream.channel
        return fileChannel.map(
            FileChannel.MapMode.READ_ONLY,
            fileDescriptor.startOffset,
            fileDescriptor.declaredLength
        )
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MappedByteBuffer로 모델을 메모리에 올리고, Interpreter를 만들면 된다. 추론이 끝나면 &lt;code&gt;interpreter.close()&lt;/code&gt;로 메모리를 해제해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;INT8 모델의 입력 처리&lt;/h2&gt;
&lt;p&gt;여기가 제일 헷갈릴 수 있는 부분이다.&lt;/p&gt;
&lt;p&gt;Python에서 학습할 때는 이미지를 0&lt;del&gt;255에서 0&lt;/del&gt;1로 정규화해서 Float로 넣었다. 근데 INT8 양자화된 모델은 입력을 UINT8(0~255)로 받는다. Android에서는 Float를 다시 UINT8로 바꿔서 넣어야 한다.&lt;/p&gt;
&lt;p&gt;처음엔 단순하게 생각했다. 학습 때 &lt;code&gt;/255&lt;/code&gt;로 줄였으니까 반대로 &lt;code&gt;*255&lt;/code&gt; 해주면 된다고.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// 처음 쓴 코드
val uint8Value = (pixel * 255f).toInt().coerceIn(0, 255)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이번 모델에서는 이게 맞았다. Python과 Android 정확도가 똑같이 78.90%로 나왔다. 그런데 돌아보면 이건 이 모델에 한해 맞은 거였다. 이번 모델은 학습할 때 입력을 0~1로 정규화했고 &lt;code&gt;inference_input_type=tf.uint8&lt;/code&gt;로 변환했다. 그래서 입력 tensor의 &lt;code&gt;scale=1/255&lt;/code&gt;, &lt;code&gt;zero_point=0&lt;/code&gt;이 나온다. 이 경우에만 &lt;code&gt;pixel * 255&lt;/code&gt;가 정답이다.&lt;/p&gt;
&lt;p&gt;다른 모델(예를 들어 ImageNet mean/std 정규화로 학습된 모델)을 가져오면 scale/zero_point가 전혀 다른 값이 된다. 그러면 &lt;code&gt;*255&lt;/code&gt;는 그냥 틀린 값이다.&lt;/p&gt;
&lt;p&gt;원칙은 1편에서 봤던 양자화 수식이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;INT8_value = round(FP32_value / scale) + zero_point&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/815c8187-e0cc-4283-99ba-92547409ae12/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;그래서 쓸 때는 모델에서 scale/zero_point를 직접 읽어서 변환하는 게 안전하다. 이번 모델에서는 이 식이 &lt;code&gt;pixel * 255&lt;/code&gt;로 단순화될 뿐이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// 입력 tensor의 quantization 파라미터를 먼저 읽는다
val inputParams = interpreter.getInputTensor(0).quantizationParams()
val inScale = inputParams.scale
val inZeroPoint = inputParams.zeroPoint

val inputBuffer = ByteBuffer.allocateDirect(1 * 32 * 32 * 3).apply {
    order(ByteOrder.nativeOrder())
}

for (i in imageFloats.indices) {
    val pixel = imageFloats[i]
    val quantized = ((pixel / inScale) + inZeroPoint).toInt().coerceIn(0, 255)
    inputBuffer.put(quantized.toByte())
}
inputBuffer.rewind()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;출력도 UINT8로 나온다. 이쪽은 같은 식을 역으로 풀어서(&lt;code&gt;FP32 ≈ scale × (INT8 - zero_point)&lt;/code&gt;) FP32로 되돌린다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;val rawOutput = ByteArray(10)
outputBuffer.get(rawOutput)

val outParams = interpreter.getOutputTensor(0).quantizationParams()
val outScale = outParams.scale
val outZeroPoint = outParams.zeroPoint

val probabilities = FloatArray(10)
for (i in 0 until 10) {
    val uint8 = rawOutput[i].toInt() and 0xFF
    probabilities[i] = (uint8 - outZeroPoint) * outScale
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 Softmax를 또 태우면 안 된다. base 모델 마지막 레이어가 이미 softmax라 확률이 그대로 나오니까. 이중 적용하면 argmax 순위는 유지되는데 confidence만 평평해진다. &amp;quot;예측은 맞는데 자신감이 없는&amp;quot; 애매한 버그가 이렇게 생긴다.&lt;/p&gt;
&lt;p&gt;모델이 logits를 뱉으면(이번 시리즈의 Teacher/Student 같은) 반대로 이 자리에서 softmax를 꼭 걸어야 한다. 배포 전에 모델 출력이 확률인지 logits인지부터 확인하는 게 먼저다.&lt;/p&gt;
&lt;p&gt;실무에서 주의할 점이 하나 있다. OpenCV나 Camera Preview에서 이미지를 가져오면 BGR 순서로 오는 경우가 있다. 모델이 RGB로 학습됐으면 순서를 바꿔야 한다. 안 바꾸면 정확도가 크게 떨어지는데, 원인 찾기가 어렵다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;GPU를 켜면 빨라질까?&lt;/h2&gt;
&lt;p&gt;당연히 빨라질 거라고 생각했다. GPU Delegate를 켜봤다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;val compatList = CompatibilityList()
if (compatList.isDelegateSupportedOnThisDevice) {
    // INT8 양자화 모델도 GPU에서 돌릴 수 있게 허용
    val delegateOptions = GpuDelegate.Options().apply {
        setQuantizedModelsAllowed(true)
    }
    val gpuDelegate = GpuDelegate(delegateOptions)
    options.addDelegate(gpuDelegate)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;INT8 모델은 GPU에서 돌릴 때 &lt;code&gt;setQuantizedModelsAllowed(true)&lt;/code&gt;를 켜줘야 한다. GPU가 INT8 연산을 네이티브로 지원하지 않으면 이 옵션이 내부적으로 dequantize → FP 연산 → quantize 과정을 처리해준다.&lt;/p&gt;
&lt;p&gt;Galaxy S22+에서 측정한 결과:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/c4e625ea-c680-488d-b9aa-4cf7ff5c5924/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CPU: 0.59ms
GPU: 1.47ms&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;GPU가 2.5배 느렸다. 직관과 반대다.&lt;/p&gt;
&lt;p&gt;이해가 안 돼서 회사 AI 전문가 동료한테 물어봤다. 답은 단순했다. CPU에서 GPU로 입출력 텐서를 복사하는 오버헤드가 있는데, 모델 연산이 0.59ms밖에 안 걸리면 이 전송 비용이 연산보다 커진다. 거기에 INT8 → FP 변환 비용까지 얹히니까 양자화 모델인데 양자화가 발목을 잡는 그림이 된다. 작은 모델에서는 GPU 연산마다 붙는 kernel launch 오버헤드도 무시 못 한다. 연산 자체가 0.59ms짜리면 이 고정 비용의 비중이 확 커진다.&lt;/p&gt;
&lt;p&gt;모델이 크면 얘기가 달라진다. 연산량이 전송 오버헤드를 압도하면 GPU가 이긴다. 작은 모델에선 CPU가 이긴다. 모델이 커져서 연산량이 전송 비용을 넘기 시작하면 GPU가 이긴다. &amp;quot;GPU 켜면 빨라진다&amp;quot;가 보편 법칙이 아니라는 건 이번에 수치로 확인했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Python vs Android 정확도 비교&lt;/h2&gt;
&lt;p&gt;경량화한 모델을 Android에 올렸으면, Python에서 나온 정확도가 그대로 나오는지 확인해야 한다. Android용으로 CIFAR-10 test set 앞 1000장을 바이너리로 만들어서 Python과 Android 양쪽에서 돌려봤다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Python:  78.90%
Android: 78.90%&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;차이 0%p. 같은 &lt;code&gt;.tflite&lt;/code&gt;에 전처리와 후처리가 양쪽에서 동일하게 구현됐다는 뜻이다. 1편에서 본 77.88%와 숫자가 다른 건 샘플 수 차이(10000장 전체 vs 앞 1000장)고, 1000장 기준 95% 신뢰구간(76.37% ~ 81.43%) 안에 들어온다. 중요한 건 절대값이 아니라 &lt;strong&gt;Python과 Android가 같은 데이터에서 같은 결과를 내는지&lt;/strong&gt;다.&lt;/p&gt;
&lt;p&gt;정확도가 벌어지면 정규화 방식(양자화 스케일, RGB 순서)과 후처리(Softmax 유무)가 일치하는지부터 본다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;추론 속도&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;Python CPU (Mac):      0.17ms
Android CPU (S22+):    0.59ms&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Python이 3.5배 빠르다. Mac 데스크톱 CPU가 모바일 AP보다 빠른 게 당연하니까 이게 정상이다. 사실 속도 자체보다 중요한 건 &amp;quot;모바일에서 서브밀리초로 돌아간다&amp;quot;는 점이다. 0.59ms면 초당 1700번 추론이 가능하니까, 실시간 UI에 전혀 문제가 없다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;모델 배포&lt;/h2&gt;
&lt;p&gt;모델을 앱에 넣는 방법은 간단하다. APK에 내장하면 된다. &lt;code&gt;assets/&lt;/code&gt; 폴더에 &lt;code&gt;.tflite&lt;/code&gt; 파일을 넣으면 끝이다.&lt;/p&gt;
&lt;p&gt;다만 이 방식은 모델을 업데이트할 때마다 앱 전체를 재배포해야 한다. 실서비스에서는 모델 버전 관리와 서빙 파이프라인이 필요한데, 그 부분은 &lt;a href=&quot;https://velog.io/@yjcho9317/series/MLflow%EB%A1%9C-%EC%9E%91%EC%9D%80-MLOps-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B0&quot;&gt;MLflow로 작은 MLOps 파이프라인 만들어보기&lt;/a&gt;에서 다뤘다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;다시 보는 온디바이스&lt;/h2&gt;
&lt;p&gt;세 편을 쓰고 나서야 보이는 게 있다. 시작할 때는 경량화가 문제인 줄 알았다. 양자화/프루닝/증류 중 뭘 쓸지 고르는 게 핵심이라고 생각했다. 끝내고 보니 아니었다.&lt;/p&gt;
&lt;p&gt;돌아보면 경량화 기법 고르기는 진짜 문제가 아니었다. 문제는 연산량이랑 오버헤드 중에 뭐가 더 큰지를 먼저 봤어야 하는 거였다.&lt;/p&gt;
&lt;p&gt;2편에서 프루닝한 모델은 계산량을 반으로 줄이고도 1.4배 느려졌다. 3편에서 GPU는 CPU보다 2.5배 느렸다. 두 결과의 원인이 같다. 연산 자체가 169μs / 0.59ms 수준으로 이미 충분히 작으니까, sparse 분기 오버헤드와 GPU 텐서 전송 비용이 연산을 넘어선다. 크기를 줄이는 데는 성공했는데 줄어든 연산보다 붙는 오버헤드가 더 비싼 지점에 모델이 도달해버린 거다.&lt;/p&gt;
&lt;p&gt;그래서 &amp;quot;경량화 기법 뭐 쓸까&amp;quot;보다 먼저 던져야 할 질문이 있다. &lt;strong&gt;지금 병목이 연산인가, 아닌가.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;연산이 병목이면 교과서 순서대로 가면 된다. INT8 양자화로 시작, 부족하면 증류, 프루닝은 하드웨어가 받쳐줄 때만. GPU도 켜는 게 이득이다.&lt;/p&gt;
&lt;p&gt;문제는 병목이 연산이 아닐 때다. 이번 실험처럼 모델이 이미 서브밀리초로 도는 상황이면, 경량화를 더 하는 게 오히려 역효과를 낸다. GPU도 꺼두는 편이 빠르다. &amp;quot;GPU 먼저 켜자&amp;quot;가 보편 법칙이 아닌 이유다.&lt;/p&gt;
&lt;p&gt;이 질문을 미리 못 던진 게 작년에 온디바이스를 어렵게 느낀 이유였다. 경량화는 공짜에 가까웠다. 어려웠던 건 모델 크기와 하드웨어 사이 어디에 병목이 있는지 감을 잡는 일이었다.&lt;/p&gt;
&lt;p&gt;다시 만든다면 순서를 바꿀 것 같다. 경량화 기법을 전부 걸어보기 전에 INT8 양자화만 먼저 하고 타깃 디바이스에서 돌려본다. 거기서 연산이 병목이라고 확인되면 그때 구조를 건드린다. 아니면 그대로 출고한다. 이 사이클이 2~3일짜리라 &amp;quot;우선 다 해보고 최적 조합 찾자&amp;quot;보다 훨씬 싸다.&lt;/p&gt;
&lt;p&gt;Gemma 4 E2B 같은 경량 모델이 나오고 TurboQuant 같은 압축 기술이 발표되는 지금, 모델 쪽 여유는 점점 넓어진다. 그만큼 병목도 연산에서 메모리/전송으로 옮겨간다. 결국 이 블로그 시리즈의 결론은 기법 순서가 아니라 &lt;strong&gt;병목을 먼저 측정하자&lt;/strong&gt;다.&lt;/p&gt;
&lt;p&gt;다음에 보이스피싱 탐지 KoBERT를 다시 집어 든다면 시작점은 정해져 있다. DistilKoBERT + INT8을 타깃 단말에 올리고, 거기서 병목을 본다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;소스 코드&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/yjcho9317/CIFAR10_OnDevice&quot;&gt;https://github.com/yjcho9317/CIFAR10_OnDevice&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;LiteRT (TensorFlow Lite) Documentation: &lt;a href=&quot;https://www.tensorflow.org/lite&quot;&gt;https://www.tensorflow.org/lite&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;ExecuTorch Documentation: &lt;a href=&quot;https://pytorch.org/executorch/stable/index.html&quot;&gt;https://pytorch.org/executorch/stable/index.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;ONNX Runtime Documentation: &lt;a href=&quot;https://onnxruntime.ai/docs/&quot;&gt;https://onnxruntime.ai/docs/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Android Developers - ML Kit Documentation: &lt;a href=&quot;https://developers.google.com/ml-kit&quot;&gt;https://developers.google.com/ml-kit&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;</description>
      <category>AI</category>
      <category>Android</category>
      <category>GPUDelegate</category>
      <category>gpu가속</category>
      <category>kotlin</category>
      <category>LiteRT</category>
      <category>TensorFlow Lite</category>
      <category>tflite</category>
      <category>모바일배포</category>
      <category>양자화모델</category>
      <category>온디바이스AI</category>
      <author>Jo&amp;atilde;o Jin</author>
      <guid isPermaLink="true">https://yjcho9317.tistory.com/24</guid>
      <comments>https://yjcho9317.tistory.com/24#entry24comment</comments>
      <pubDate>Fri, 17 Apr 2026 11:40:52 +0900</pubDate>
    </item>
    <item>
      <title>온디바이스 AI 경량화 (2) &amp;mdash; 프루닝과 지식 증류 실전 비교 (AGP, Temperature, KL Divergence)</title>
      <link>https://yjcho9317.tistory.com/23</link>
      <description>&lt;p&gt;지난번에 &lt;a href=&quot;https://yjcho9317.tistory.com/22&quot;&gt;INT8 양자화로 크기를 92% 줄여봤다&lt;/a&gt;. INT8로 바꾸니까 크기 75% 줄고, 속도 4배 빨라지고, 정확도는 그대로였다. 너무 잘 돼서 의심스러울 정도였다.&lt;/p&gt;
&lt;p&gt;그래서 다른 경량화 기법도 해보기로 했다. 프루닝이랑 지식 증류. 둘 다 이름은 많이 들어봤는데 직접 해본 적은 없었다.&lt;/p&gt;
&lt;p&gt;결론부터 말하면, 양자화만큼 쉽지 않았다. 프루닝은 50%를 날리고도 오히려 느려졌고, 지식 증류는 정확도 13%p를 내줬다. 두 기법이 왜 교과서대로 안 가는지, 그리고 그럼 언제 써야 하는지 정리했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;프루닝: 불필요한 가중치 잘라내기&lt;/h2&gt;
&lt;p&gt;프루닝은 별거 없다. 딥러닝 모델의 가중치 중에서 값이 작은 것들은 결과에 별로 영향을 안 준다. 그러니까 그냥 0으로 만들어버리자. Pruning이라는 단어 자체가 &amp;quot;가지치기&amp;quot;라는 뜻이다. 식물 가지치기에서 그대로 가져온 용어다.&lt;/p&gt;
&lt;p&gt;크게 두 가지 방식이 있다.&lt;/p&gt;
&lt;p&gt;Unstructured Pruning은 개별 weight를 하나씩 0으로 만든다. 유연하긴 한데, 실제로 속도가 빨라지려면 하드웨어가 sparse 연산을 지원해야 한다.&lt;/p&gt;
&lt;p&gt;Structured Pruning은 뉴런이나 채널 단위로 통째로 잘라낸다. 네트워크 구조 자체가 바뀌니까 확실히 빨라지는데, 정확도 손실이 더 크다.&lt;/p&gt;
&lt;p&gt;일단 Unstructured로 해보기로 했다. TensorFlow Model Optimization 라이브러리에서 지원하니까.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;프루닝 알고리즘 선택&lt;/h2&gt;
&lt;p&gt;프루닝 알고리즘도 여러 가지가 있다.&lt;/p&gt;
&lt;p&gt;One-shot Pruning은 한 번에 확 잘라버린다. 구현은 가장 간단한데 정확도가 많이 깎인다.&lt;/p&gt;
&lt;p&gt;Iterative Magnitude Pruning은 조금 자르고 Fine-tuning, 또 조금 자르고 Fine-tuning을 반복한다. Lottery Ticket 논문(Frankle &amp;amp; Carbin, 2019)에서 쓴 방식이다. 정확도는 좋은데 학습 시간이 10배쯤 뛴다.&lt;/p&gt;
&lt;p&gt;Automated Gradual Pruning(AGP)은 학습하면서 점진적으로 자른다. 3차 함수 스케줄로 초반엔 많이, 후반엔 조금씩. 하이퍼파라미터가 적어서 TensorFlow Model Optimization이 기본값으로 권장한다.&lt;/p&gt;
&lt;p&gt;시간 대비 효과만 보고 AGP를 골랐다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;50% 가중치 제거 실험&lt;/h2&gt;
&lt;p&gt;목표는 가중치의 50%를 0으로 만드는 거다. 절반을 날려도 정확도가 유지되면 대성공이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;pruning_params = {
    &amp;#39;pruning_schedule&amp;#39;: tfmot.sparsity.keras.PolynomialDecay(
        initial_sparsity=0.0,   # 0%에서 시작
        final_sparsity=0.5,     # 50%까지 점진적 증가
        begin_step=0,
        end_step=3000           # 3000 스텝에 걸쳐 증가
    )
}

pruned_model = tfmot.sparsity.keras.prune_low_magnitude(
    base_model, 
    **pruning_params
)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;base 모델을 10 에포크 학습시켜서 76.03% 정확도를 만들고, 거기에 프루닝을 적용하면서 10 에포크 더 Fine-tuning 했다.&lt;/p&gt;
&lt;p&gt;결과가 좀 의외였다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/9dbb23c4-56f4-4397-a043-d8c4f0794240/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;50% 잘라냈는데 정확도가 3%p 올라간다. 잘못 측정한 줄 알았는데 반복해도 같았다.&lt;/p&gt;
&lt;p&gt;중요도 낮은 weight를 치우면 regularization처럼 작용해서 overfitting이 줄어든다. 거기에 Fine-tuning 10 에포크가 얹혀서 남은 weight가 더 조여진 영향도 있을 거다. CIFAR-10이 비교적 단순한 데이터셋이라서 이게 크게 먹혔다. 복잡한 데이터에서는 다르게 나올 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;그런데 파일 크기가 안 줄었다&lt;/h2&gt;
&lt;p&gt;H5 파일로 저장해봤더니 크기가 똑같았다. 13MB 그대로.&lt;/p&gt;
&lt;p&gt;왜 그런지 찾아보니까, H5 형식은 sparse matrix를 지원하지 않는다. 0인 weight도 그냥 0.0이라는 값으로 저장한다. 공간을 똑같이 차지하는 거다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;H5 형식:
[1.2, 0.0, 0.0, 3.4, 0.0, 0.0, 2.1, 0.0, ...]
→ 모든 값이 4바이트씩 저장됨&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;TFLite로 변환하면 압축이 된다고 해서 해봤다. 4.3MB. FP32 TFLite와 똑같다. 양자화까지 적용해야 줄어드는 것 같다.&lt;/p&gt;
&lt;p&gt;INT8 양자화를 추가로 적용했더니 1.1MB가 됐다. 근데 이건 프루닝 안 한 모델도 마찬가지다. 프루닝 자체로는 크기 감소 효과가 없는 셈이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;속도까지 반대로 갔다&lt;/h2&gt;
&lt;p&gt;크기는 그렇다 치고, 속도라도 빨라졌을 거라고 기대했다. 50%를 계산 안 해도 되니까.&lt;/p&gt;
&lt;p&gt;측정해봤다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/4a4cfc73-8f12-4368-ba4e-d0827ddc0153/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;약 1.4배 느려졌다. 드라마틱한 5배는 아니지만, 계산량을 반으로 줄였는데 오히려 느려진 건 맞다. 이론대로면 2배는 빨라져야 한다. 한 줄로 정리하면 FLOPs는 절반으로 줄었는데 실제 latency는 오히려 늘었다.&lt;/p&gt;
&lt;p&gt;한참을 고민했다. 왜 이런 결과가 나왔을까.&lt;/p&gt;
&lt;p&gt;결국 문제는 런타임이다. Dense 연산은 메모리를 쭉 연속으로 읽으니까 캐시가 잘 붙는다. sparse 연산은 0이 아닌 값만 골라야 하니까 조건문이 끼고, 메모리 접근도 들쭉날쭉이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Dense
for i in range(N):
    result += weight[i] * input[i]

# Sparse (개념적으로)
for i in range(N):
    if weight[i] != 0:
        result += weight[i] * input[i]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;조건 체크 오버헤드와 캐시 미스가 같이 온다. 프루닝의 문제가 아니라 런타임의 문제다. sparse 연산을 네이티브로 처리하는 하드웨어/라이브러리가 없으면 오히려 느려진다.&lt;/p&gt;
&lt;p&gt;추가로 짚을 게 하나 있다. 프루닝 후에는 &lt;code&gt;tfmot.sparsity.keras.strip_pruning()&lt;/code&gt;을 호출해서 학습용 wrapper를 제거해야 한다. 이걸 빼먹으면 추론 시에도 wrapper 오버헤드가 남는다. 이 영향도 느려지는 데 한몫했을 수 있다.&lt;/p&gt;
&lt;p&gt;한 줄로 정리하자면, 50% 계산량을 날리고도 역효과가 났다.&lt;/p&gt;
&lt;p&gt;정확도는 78.93%로 올랐지만, 이건 보너스에 가깝다. 목표였던 크기는 그대로였고, 속도는 오히려 1.4배 느려졌으니까. 논문에서 말하는 속도 이점을 실제로 얻으려면 sparse 연산을 네이티브로 지원하는 하드웨어/런타임이 있어야 한다. 일반 CPU + TFLite 조합에서는 프루닝은 &amp;quot;썼다&amp;quot;와 &amp;quot;효과 봤다&amp;quot; 사이의 간격이 크다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;지식 증류: 선생님 모델의 지식을 전달하기&lt;/h2&gt;
&lt;p&gt;기분을 바꿔서 지식 증류를 해보기로 했다.&lt;/p&gt;
&lt;p&gt;크고 정확한 Teacher 모델을 먼저 학습시킨다. 그 다음에 작은 Student 모델이 Teacher를 따라하면서 학습한다. 혼자 학습하는 것보다 Teacher의 &amp;quot;지식&amp;quot;을 전달받아서 더 좋은 성능을 낼 수 있다.&lt;/p&gt;
&lt;p&gt;근데 Student가 대체 뭘 배우는 걸까.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Hard Target vs Soft Target&lt;/h2&gt;
&lt;p&gt;일반 학습은 정답 레이블을 쓴다. 고양이면 &lt;code&gt;[0, 0, 1, 0, 0, 0, 0, 0, 0, 0]&lt;/code&gt;. Hard Target이다.&lt;/p&gt;
&lt;p&gt;지식 증류는 Teacher의 출력을 쓴다. &lt;code&gt;[0.02, 0.01, 0.70, 0.15, 0.05, ...]&lt;/code&gt;. &amp;quot;고양이 70%, 개 15%, 사슴 5%&amp;quot;. 이게 Soft Target이다.&lt;/p&gt;
&lt;p&gt;Hard Target이 &amp;quot;고양이다&amp;quot; 한 마디라면, Soft Target은 &amp;quot;고양이인데 개랑 좀 비슷하고 자동차랑은 전혀 다르다&amp;quot;까지 담고 있다. Teacher가 학습하면서 쌓은 클래스 간 거리 감각이 이 확률 분포에 녹아 있다. Student는 그걸 따라가면서 Teacher의 판단 방식을 배운다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Temperature: 확률 분포를 부드럽게&lt;/h2&gt;
&lt;p&gt;근데 문제가 있다. Teacher가 너무 확신하면 Soft Target도 Hard Target이랑 비슷해진다.&lt;/p&gt;
&lt;p&gt;[0.99, 0.01, 0.00, 0.00, ...]&lt;/p&gt;
&lt;p&gt;99%면 사실상 &amp;quot;고양이다!&amp;quot;랑 다를 게 없다. 다른 클래스 정보가 거의 없다.&lt;/p&gt;
&lt;p&gt;그래서 Temperature라는 걸 쓴다. Softmax 연산에서 로짓을 T로 나눠준다. T가 크면 확률 분포가 평평해진다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;T=1 (일반):  [0.66, 0.24, 0.10]
T=3 (높음):  [0.44, 0.32, 0.24]
T=5 (매우):  [0.40, 0.33, 0.27]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;학습할 때는 T=3 정도로 부드럽게 만들어서 정보를 최대한 전달하고, 실제 추론할 때는 T=1로 명확하게 예측한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;두 가지 Loss 조합&lt;/h2&gt;
&lt;p&gt;지식 증류의 Loss는 두 가지를 섞는다.&lt;/p&gt;
&lt;p&gt;한쪽은 Student 예측과 정답 레이블 차이(Hard Loss, Cross-Entropy). &amp;quot;정답 방향&amp;quot;을 알려준다. 다른 한쪽은 Student와 Teacher Soft Target의 차이(Soft Loss, KL Divergence). &amp;quot;Teacher 따라하기&amp;quot;다. 두 Loss를 가중치로 묶어서 같이 학습시킨다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;Total Loss = α × Hard Loss + (1-α) × Soft Loss&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;보통 α=0.1을 쓴다. Hard Loss 10%, Soft Loss 90%. Teacher의 지식을 주로 배우되, 정답도 참고하는 식이다.&lt;/p&gt;
&lt;p&gt;Hinton 논문은 &amp;quot;Hard Loss 쪽 가중치를 훨씬 작게 주면 좋은 결과가 나왔다&amp;quot;고만 언급한다. 실험에 따라 구체적 최적값은 다르니까, 표준 관행처럼 쓰이는 α=0.1을 초깃값으로 잡고 필요하면 조정하는 식으로 쓰면 된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Teacher와 Student 설계&lt;/h2&gt;
&lt;p&gt;Teacher는 크고 복잡하게 만들었다. Conv 레이어 6개에 BatchNorm 넣고, Dense도 512 유닛. 파라미터 약 320만 개. CIFAR-10 기준 80% 이상 나올 수 있는 최소 복잡도를 노린 거다.&lt;/p&gt;
&lt;p&gt;한 가지 설계 포인트가 있다. 두 모델 모두 마지막 Dense 레이어에 softmax를 빼고 logits로 출력하게 했다. 이유는 뒤에 증류 학습 구현에서 같이 설명한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def create_teacher_model():
    &amp;quot;&amp;quot;&amp;quot;Teacher 모델 - ~3.2M 파라미터&amp;quot;&amp;quot;&amp;quot;
    model = keras.Sequential([
        keras.layers.Conv2D(64, 3, padding=&amp;#39;same&amp;#39;, activation=&amp;#39;relu&amp;#39;),
        keras.layers.BatchNormalization(),
        keras.layers.Conv2D(64, 3, padding=&amp;#39;same&amp;#39;, activation=&amp;#39;relu&amp;#39;),
        keras.layers.MaxPooling2D(),
        # ... Block 2, 3 ...
        keras.layers.Flatten(),
        keras.layers.Dense(512, activation=&amp;#39;relu&amp;#39;),
        keras.layers.Dense(10, activation=None)  # logits 출력
    ])
    return model&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Student는 반대로 모바일에 올릴 수 있는 선에서 최대한 줄였다. Conv 레이어 2개에 Dense 128 유닛. 파라미터 약 27만 개. Teacher의 1/12 크기다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def create_student_model():
    &amp;quot;&amp;quot;&amp;quot;Student 모델 - ~270K 파라미터&amp;quot;&amp;quot;&amp;quot;
    model = keras.Sequential([
        keras.layers.Conv2D(16, 3, padding=&amp;#39;same&amp;#39;, activation=&amp;#39;relu&amp;#39;),
        keras.layers.MaxPooling2D(),
        keras.layers.Conv2D(32, 3, padding=&amp;#39;same&amp;#39;, activation=&amp;#39;relu&amp;#39;),
        keras.layers.MaxPooling2D(),
        keras.layers.Flatten(),
        keras.layers.Dense(128, activation=&amp;#39;relu&amp;#39;),
        keras.layers.Dense(10, activation=None)  # logits 출력
    ])
    return model&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;증류 학습 구현&lt;/h2&gt;
&lt;p&gt;TensorFlow에는 증류용 API가 따로 없다. train_step을 직접 짜야 한다.&lt;/p&gt;
&lt;p&gt;여기서 앞에서 남긴 숙제를 풀고 가자. 두 모델의 마지막 Dense를 softmax 없이 logits로 출력하게 한 이유다. 증류 loss는 &lt;code&gt;softmax(logits / T)&lt;/code&gt;를 넣어야 Temperature가 제 역할을 한다. 만약 모델이 이미 softmax가 적용된 확률을 반환하면, 그 위에 다시 &lt;code&gt;softmax(확률 / T)&lt;/code&gt;를 씌우는 꼴이 된다. 확률은 이미 0~1 범위고 그걸 T로 나눠봤자 범위가 더 좁아질 뿐이라, 한 번 더 softmax를 태우면 분포가 거의 균등(uniform)에 가까워진다. Soft Target의 클래스 간 정보가 사라지는 거다.&lt;/p&gt;
&lt;p&gt;그래서 모델은 logits를 뱉고, 학습 loss 쪽만 &lt;code&gt;from_logits=True&lt;/code&gt;로 받는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class DistillationModel(keras.Model):
    def train_step(self, data):
        x, y = data

        # Teacher 예측 (학습 안함) — logits 반환
        teacher_predictions = self.teacher(x, training=False)

        with tf.GradientTape() as tape:
            # Student 예측 — logits 반환
            student_predictions = self.student(x, training=True)

            # Hard Loss (student_loss_fn은 from_logits=True로 생성)
            student_loss = self.student_loss_fn(y, student_predictions)

            # Soft Loss (logits / T → softmax)
            distillation_loss = self.distillation_loss_fn(
                tf.nn.softmax(teacher_predictions / self.temperature),
                tf.nn.softmax(student_predictions / self.temperature)
            ) * (self.temperature ** 2)

            # 최종 Loss
            total_loss = self.alpha * student_loss + (1 - self.alpha) * distillation_loss

        # Student만 업데이트
        gradients = tape.gradient(total_loss, self.student.trainable_variables)
        self.optimizer.apply_gradients(zip(gradients, self.student.trainable_variables))

        return {&amp;quot;total_loss&amp;quot;: total_loss, ...}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;T²를 곱하는 이유는 기울기 크기를 보정하기 위해서다. Temperature로 나누면 기울기가 작아지니까 T²로 보상해준다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;실험 결과&lt;/h2&gt;
&lt;p&gt;Teacher를 20 에포크 학습시켜서 82.79% 정확도를 만들었다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/443a9cfe-3c95-459c-b8a0-21184eae6acd/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Student를 두 가지 방식으로 학습시켰다. 하나는 단독으로 20 에포크 학습. 다른 하나는 Teacher한테 증류 받으면서 20 에포크 학습.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Teacher:         82.79%
Student (단독):  67.08%
Student (증류):  69.76%&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/ff4cc5fc-a9f5-42f4-aa53-cffe35caf78d/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;증류 효과는 +2.68%p. 단독 학습보다 확실히 나았다.&lt;/p&gt;
&lt;p&gt;근데 Teacher랑 비교하면 13%p나 낮다. 파라미터 1/12로 줄였으니 담을 수 있는 정보량 자체가 빠진다. 그런데 Transformer 쪽은 DistilBERT가 레이어를 반 토막 내도 원본 97% 성능을 유지한다(뒤에서 다시 얘기). 증류가 모델 구조에 따라 결과가 크게 갈린다는 얘기다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;지식 증류의 trade-off&lt;/h2&gt;
&lt;p&gt;파라미터는 확실히 줄었다. Teacher가 약 325만 개(H5 기준 38MB), Student가 약 27만 개(H5 기준 1.05MB). 파라미터 기준 91.7% 감소다. 실제 배포용 INT8 TFLite로 바꾸면 Student가 529KB까지 내려간다.&lt;/p&gt;
&lt;p&gt;속도도 빨라졌다. Teacher 23.18ms, Student 19.95ms. 다만 파라미터가 1/12인데 속도는 그만큼 줄진 않았다. 레이어 수가 적어서 병렬 처리 이득이 제한적인 것 같고, 배치 크기나 모바일 환경에서는 또 다를 수 있다.&lt;/p&gt;
&lt;p&gt;정확도 손실이 문제다. 13%p면 적지 않다. 응용 분야에 따라서는 치명적일 수도 있다.&lt;/p&gt;
&lt;p&gt;그리고 학습 시간이 2배다. Teacher 20 에포크 + Student 20 에포크. 단독으로 Student만 40 에포크 학습시키면 어떨까 싶기도 하다. 그것도 해봐야 할 것 같다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;세 가지 경량화 기법 비교&lt;/h2&gt;
&lt;p&gt;CIFAR-10 실험 결과를 정리하면 이렇다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;정확도&lt;/th&gt;
&lt;th&gt;파라미터&lt;/th&gt;
&lt;th&gt;TFLite 크기&lt;/th&gt;
&lt;th&gt;TFLite 속도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Teacher (원본)&lt;/td&gt;
&lt;td&gt;82.79%&lt;/td&gt;
&lt;td&gt;3.25M&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base (중간)&lt;/td&gt;
&lt;td&gt;77.88%&lt;/td&gt;
&lt;td&gt;1.12M&lt;/td&gt;
&lt;td&gt;4,367 KB&lt;/td&gt;
&lt;td&gt;754.7μs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ FP16 양자화&lt;/td&gt;
&lt;td&gt;77.88%&lt;/td&gt;
&lt;td&gt;1.12M&lt;/td&gt;
&lt;td&gt;2,188 KB&lt;/td&gt;
&lt;td&gt;755.4μs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ INT8 양자화&lt;/td&gt;
&lt;td&gt;77.88%&lt;/td&gt;
&lt;td&gt;1.12M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,108 KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;169.8μs (4.44x)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ 프루닝 50%&lt;/td&gt;
&lt;td&gt;78.93%&lt;/td&gt;
&lt;td&gt;1.12M&lt;/td&gt;
&lt;td&gt;1,104 KB&lt;/td&gt;
&lt;td&gt;238.4μs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Student (단독)&lt;/td&gt;
&lt;td&gt;67.08%&lt;/td&gt;
&lt;td&gt;0.27M&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Student (증류)&lt;/td&gt;
&lt;td&gt;69.76%&lt;/td&gt;
&lt;td&gt;0.27M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;529 KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;68.0μs&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;INT8 양자화는 가성비 최고다. 5분이면 구현이 끝나고 효과가 확실하다.&lt;/p&gt;
&lt;p&gt;프루닝은 속도가 느려진 게 문제다. 드라마틱한 저하는 아니지만, 계산량을 반으로 줄이고 오히려 느려졌다는 자체가 의미가 없다는 뜻이다. 하드웨어 지원 없이는 실용성이 떨어진다.&lt;/p&gt;
&lt;p&gt;지식 증류는 극단적인 경량화가 필요할 때 쓸 수 있지만 정확도 타협이 크다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;모델마다 주류가 다르다&lt;/h2&gt;
&lt;p&gt;이건 전제를 하나 깔고 봐야 한다. 내가 실험한 건 CNN이다. Transformer나 LLM에 그대로 적용되는 결론이 아니다.&lt;/p&gt;
&lt;p&gt;CNN에서는 양자화가 제일 깔끔하게 먹힌다. Conv 연산이 단순해서 INT8 최적화가 잘 붙고, 필터 간 중복이 많아서 프루닝 여지도 있다(하드웨어가 받쳐주면).&lt;/p&gt;
&lt;p&gt;Transformer 쪽은 무게 중심이 다르다. DistilBERT가 대표 사례인데, BERT의 12개 레이어를 6개로 줄이면서 GLUE 벤치마크에서 원본 대비 97% 성능을 유지했다. 내 CNN 실험에서 1/12로 줄였을 때 13%p가 빠진 것과 비교하면 격차가 크다. 레이어 단위로 반 토막을 내도 버티는 구조 자체가 증류와 궁합이 좋다는 뜻이다.&lt;/p&gt;
&lt;p&gt;그래서 요즘 LLM 경량화 흐름도 증류가 중심이다. GPT-4 급 모델의 지식을 작은 모델로 옮기고, 거기에 INT8 양자화를 한 번 더 얹는다. 경량화 기법은 서로 배타적인 게 아니라 쌓아 올리는 거다. 모델 종류마다 어느 층을 먼저 쌓는지가 달라질 뿐이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;셋을 다 해보니 선택의 기준이 분명해졌다.&lt;/p&gt;
&lt;p&gt;양자화는 거의 무조건이다. 구현 쉽고, 효과 확실하고, 손실도 미미했다. 안 할 이유가 없다. 반면 프루닝은 이번 실험에서 본 것처럼 하드웨어와 런타임이 sparse 연산을 네이티브로 안 받치면 역효과가 난다. 조건이 맞는지 확인 못 한 상태로 꺼내면 계산량만 줄어들고 속도는 오히려 깎인다.&lt;/p&gt;
&lt;p&gt;지식 증류는 다른 축이다. 크기를 극단적으로 줄여야 할 때, 그리고 모델 구조를 바꿀 수 있을 때 의미가 있다. CNN에서는 13%p 손실을 감수해야 했지만 Transformer 계열이면 얘기가 달라진다. 정확도 손실을 어디까지 감수할 수 있는지부터 정해야 한다.&lt;/p&gt;
&lt;p&gt;내가 쓰는 순서는 단순하다. 양자화 먼저 — 거의 공짜라서. 그래도 부족하면 증류. 프루닝은 마지막 선택지이자, 조건 맞을 때만 꺼낸다. 보이스피싱 탐지 KoBERT라면 양자화 + DistilKoBERT 조합이 현실적인 첫 시도가 될 것 같다.&lt;/p&gt;
&lt;p&gt;경량화 기법은 정리됐다. 다음 글에서는 INT8 모델을 Android에 올려서 진짜로 돌아가는지 확인해본다&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;소스 코드&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/yjcho9317/CIFAR10_OnDevice&quot;&gt;https://github.com/yjcho9317/CIFAR10_OnDevice&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Hinton et al. (2015) - Distilling the Knowledge in a Neural Network&lt;/li&gt;
&lt;li&gt;Frankle &amp;amp; Carlin (2019) - Lottery Ticket Hypothesis&lt;/li&gt;
&lt;li&gt;Zhu &amp;amp; Gupta (2017) - To prune, or not to prune&lt;/li&gt;
&lt;li&gt;Sanh et al. (2019) - DistilBERT&lt;/li&gt;
&lt;li&gt;HMG Developers 프루닝 블로그: &lt;a href=&quot;https://developers.hyundaimotorgroup.com/blog&quot;&gt;https://developers.hyundaimotorgroup.com/blog&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;</description>
      <category>AI</category>
      <category>cifar-10</category>
      <category>DistilBERT</category>
      <category>knowledgedistillation</category>
      <category>pruning</category>
      <category>Temperature</category>
      <category>TFMOT</category>
      <category>모델경량화</category>
      <category>온디바이스AI</category>
      <category>지식증류</category>
      <category>프루닝</category>
      <author>Jo&amp;atilde;o Jin</author>
      <guid isPermaLink="true">https://yjcho9317.tistory.com/23</guid>
      <comments>https://yjcho9317.tistory.com/23#entry23comment</comments>
      <pubDate>Fri, 17 Apr 2026 11:39:11 +0900</pubDate>
    </item>
    <item>
      <title>온디바이스 AI 경량화 (1) &amp;mdash; INT8 양자화로 CIFAR-10 모델 92% 줄이기 (TFLite PTQ)</title>
      <link>https://yjcho9317.tistory.com/22</link>
      <description>&lt;p&gt;작년부터 온디바이스 AI에 관심이 많았다. 딥페이크 탐지 프로젝트에서 모델을 단말에 올리려고 경량화를 시도해봤고, 보이스피싱 탐지는 Qwen 경량 모델까지 써봤지만 온디바이스를 목표로 한 연구 단계에서 멈췄다. 하드웨어 제약이 너무 컸다. 딥페이크 쪽은 결국 클라우드로 돌렸다.&lt;/p&gt;
&lt;p&gt;그때 양자화, 프루닝, 지식 증류를 직접 실험해봤다. 어디까지 줄일 수 있는지, 정확도는 얼마나 버틸 수 있는지 확인하고 싶었다.&lt;/p&gt;
&lt;p&gt;당시엔 정리만 해두고 글로 올리진 않았다.&lt;/p&gt;
&lt;p&gt;그런데 최근에 상황이 바뀌었다. 구글이 온디바이스용 경량 모델 Gemma 4(E2B/E4B)를 내놓고, KV 캐시를 6배 압축하는 TurboQuant까지 발표했다. 경량화가 다시 화두다.&lt;/p&gt;
&lt;p&gt;그때 정리해둔 걸 공유해보려 한다. 이번 글은 양자화다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;양자화가 뭔데&lt;/h2&gt;
&lt;p&gt;숫자 정밀도를 낮추는 거다.&lt;/p&gt;
&lt;p&gt;딥러닝 모델의 가중치가 보통 FP32로 저장되어 있다. 32비트 부동소수점. 0.123456789... 이런 식으로 소수점 7자리까지 표현할 수 있다.&lt;/p&gt;
&lt;p&gt;INT8로 바꾸면 256개 정수만 표현할 수 있다. signed면 -128&lt;del&gt;127, unsigned(UINT8)면 0&lt;/del&gt;255. 소수점은 아예 없어진다. 대신 메모리는 1바이트만 쓴다. FP32의 1/4.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/da2f62bf-36d2-464c-9089-193a0fdb9d1c/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;소수점 값들이 정수로 바뀐다. FP32 범위를 몇 단계(256개)로 쪼개서 가장 가까운 정수에 매핑하는 식이다. 구체적인 매핑 값은 scale과 zero_point로 결정된다(뒤에서 설명).&lt;/p&gt;
&lt;p&gt;이론적으로:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;FP32 → FP16: 메모리 50% 감소&lt;/li&gt;
&lt;li&gt;FP32 → INT8: 메모리 75% 감소&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;진짜 이만큼 줄어드는지, 정확도는 얼마나 떨어지는지 직접 확인해보고 싶었다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;실험 설계&lt;/h2&gt;
&lt;p&gt;CIFAR-10으로 테스트하기로 했다. 이미지 분류 문제라 간단하고, 데이터셋도 바로 쓸 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;CIFAR-10&lt;/strong&gt;: 비행기, 자동차, 새, 고양이 등 10개 클래스를 분류하는 문제. 딥러닝 입문용으로 많이 쓴다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def create_base_model():
    model = keras.Sequential([
        # Block 1
        keras.layers.Conv2D(32, 3, padding=&amp;#39;same&amp;#39;, activation=&amp;#39;relu&amp;#39;, 
                           input_shape=(32, 32, 3)),
        keras.layers.Conv2D(32, 3, padding=&amp;#39;same&amp;#39;, activation=&amp;#39;relu&amp;#39;),
        keras.layers.MaxPooling2D(),
        keras.layers.Dropout(0.2),

        # Block 2
        keras.layers.Conv2D(64, 3, padding=&amp;#39;same&amp;#39;, activation=&amp;#39;relu&amp;#39;),
        keras.layers.Conv2D(64, 3, padding=&amp;#39;same&amp;#39;, activation=&amp;#39;relu&amp;#39;),
        keras.layers.MaxPooling2D(),
        keras.layers.Dropout(0.3),

        # Classifier
        keras.layers.Flatten(),
        keras.layers.Dense(256, activation=&amp;#39;relu&amp;#39;),
        keras.layers.Dropout(0.4),
        keras.layers.Dense(10, activation=&amp;#39;softmax&amp;#39;)
    ])
    return model&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;20 epoch로 학습시키니까 77.88% 정확도가 나왔다. 양자화 테스트하기엔 충분하다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/6bda3cd4-0828-46f2-84be-e0838ceb4fbe/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;TFLite 변환부터&lt;/h2&gt;
&lt;p&gt;TFLite를 사용하면 비교적 간단하게 양자화가 가능하다. 그래서 양자화 전에 일단 TFLite로 변환부터 해봤다. 양자화 없이.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;converter = tf.lite.TFLiteConverter.from_keras_model(base_model)
tflite_model = converter.convert()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;모델이 13MB였는데, TFLite로 바꾸니까 4.3MB가 됐다. 양자화 안 했는데도 66% 줄었다. H5 파일은 추론에 필요한 가중치 외에도 옵티마이저 상태(Adam의 m, v 같은 학습용 변수)를 함께 저장한다. TFLite는 추론에 필요한 것만 남기니까 그만큼 줄어든 거다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;FP16 양자화&lt;/h2&gt;
&lt;p&gt;첫 번째 시도. FP16으로 바꿔봤다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;converter = tf.lite.TFLiteConverter.from_keras_model(base_model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.target_spec.supported_types = [tf.float16]
tflite_fp16 = converter.convert()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과: 2.2MB. FP32 TFLite 대비 딱 50% 줄었다. 이론대로다.&lt;/p&gt;
&lt;p&gt;정확도는? 77.88%. 똑같다. 손실 0%.&lt;/p&gt;
&lt;p&gt;근데 속도는 사실상 변화가 없다. 754μs → 755μs. 측정 노이즈 수준이다. FP16 연산을 네이티브로 가속하는 하드웨어가 아니면 CPU에서는 이득이 거의 없다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;INT8 양자화&lt;/h2&gt;
&lt;p&gt;INT8 변환은 좀 다르다. representative_dataset이라는 게 필요하다.&lt;/p&gt;
&lt;p&gt;처음엔 왜 이게 필요한지 감이 안 왔다. 가중치는 이미 값이 정해져 있으니 그냥 양자화하면 되는데, Activation은 입력에 따라 값이 달라진다. 그러니까 실제 데이터 몇 개를 넣어보고 범위를 측정해야 한다. 이걸 Calibration이라고 부른다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def representative_dataset():
    for i in range(100):
        yield [x_train[i:i+1]]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;100개 샘플을 넣어주면 TensorFlow가 각 레이어의 Activation 범위를 측정한다. 그걸로 scale이랑 zero_point를 계산한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;converter = tf.lite.TFLiteConverter.from_keras_model(base_model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.uint8
converter.inference_output_type = tf.uint8
tflite_int8 = converter.convert()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과: 1.1MB. 원본 FP32 TFLite 대비 75% 감소. 여기까지는 교과서 그대로다.&lt;/p&gt;
&lt;p&gt;변환하고 나서 궁금해서 내부를 열어봤다. 양자화된 가중치에는 scale이랑 zero_point라는 값이 붙어 있다.&lt;/p&gt;
&lt;p&gt;양자화 수식은 이렇다:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;INT8_value = round(FP32_value / scale) + zero_point

역변환:
FP32_value ≈ scale × (INT8_value - zero_point)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;역변환이 중요하다. 추론할 때는 INT8로 돌리고, 결과 꺼낼 때 이 식으로 다시 FP32로 되돌린다. 3편 Android 코드에서 이 역변환이 다시 나온다.&lt;/p&gt;
&lt;p&gt;scale은 압축 비율이다. FP32 범위를 INT8 범위로 얼마나 쪼갤지 정하는 값.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/21d88735-36c8-4cae-b9d7-16ec16c70d79/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;zero_point는 한동안 왜 필요한지 감이 안 왔다. 간단히 말하면 FP32의 0.0이 INT8에서 몇이 되는지 알려주는 값이다. 가중치가 -0.5 ~ 2.0처럼 한쪽으로 치우쳐 있으면 0.0이 INT8의 0이 아닌 다른 값에 매핑되는데, 그 &amp;quot;다른 값&amp;quot;이 zero_point다. 대칭 범위면 0, 비대칭이면 0이 아닌 숫자. 실무에선 TensorFlow가 알아서 계산한다. 이런 게 있구나 정도만 알고 넘어갔다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;결과 정리&lt;/h2&gt;
&lt;p&gt;최종 결과를 정리하면 이렇다.&lt;/p&gt;
&lt;p&gt;크기:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;H5 원본: 13.1MB&lt;/li&gt;
&lt;li&gt;FP32 TFLite: 4.3MB (67% 감소)&lt;/li&gt;
&lt;li&gt;FP16 TFLite: 2.1MB (83% 감소)&lt;/li&gt;
&lt;li&gt;INT8 TFLite: 1.1MB (92% 감소)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;속도 (데스크톱 CPU 기준):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;FP32: 754.7μs&lt;/li&gt;
&lt;li&gt;FP16: 755.4μs (1.00배, 변화 없음)&lt;/li&gt;
&lt;li&gt;INT8: 169.8μs (4.44배)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;INT8이 빨라지는 건 메모리 대역폭이 1/4로 줄고, CPU의 SIMD 명령어가 한 번에 더 많은 숫자를 처리할 수 있어서다. FP16이 거의 안 빨라진 거랑 대비된다. FP16은 네이티브로 가속하는 CPU가 아니면 이득이 없는데, INT8은 그렇지 않다.&lt;/p&gt;
&lt;p&gt;정확도:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;전부 77.88%. 손실 없음.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CIFAR-10이 비교적 단순한 문제라서 그런 것 같다. ImageNet 같은 복잡한 데이터셋에서는 0.5~2%p 정도 손실이 생긴다고 한다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/acdc8094-0074-4cc8-ab81-67789f4fbfcc/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;양자화는 생각보다 쉬웠다. 코드 몇 줄이면 되고, 이 실험에서는 정확도 손실이 없었다. 다만 이건 CIFAR-10 기준이다. 복잡한 모델에서는 1~2%p 손실을 감수해야 할 때가 있고, 그게 서비스 지표에 치명적이면 얘기가 달라진다.&lt;/p&gt;
&lt;p&gt;그래서 &amp;quot;양자화는 무조건 해라&amp;quot;가 아니라 &amp;quot;먼저 해봐라&amp;quot;가 맞는 말인 것 같다. 이만한 비용으로 이 정도 효과를 내는 기법은 드물다.&lt;/p&gt;
&lt;p&gt;이번 실험은 학습 끝난 모델을 양자화하는 PTQ(Post-Training Quantization)로 충분했다. 손실이 안 나왔으니까. QAT(Quantization-Aware Training)는 학습 과정에 양자화를 섞는 방식이라 비용이 더 드는데, PTQ에서 정확도가 유의미하게 깎일 때만 꺼내는 카드다. CIFAR-10처럼 단순한 문제에 굳이 쓸 이유는 없었다.&lt;/p&gt;
&lt;p&gt;작년에 클라우드로 돌릴 수밖에 없었던 건 모델이 무거워서였다. 그때 이걸 먼저 해봤으면 단말에서 돌리는 선택지가 있었을지도 모른다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;소스 코드&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/yjcho9317/CIFAR10_OnDevice&quot;&gt;https://github.com/yjcho9317/CIFAR10_OnDevice&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;딜로이트 온디바이스 AI 분석: &lt;a href=&quot;https://www.deloitte.com/kr/ko/Industries/technology/analysis/on-device-ai.html&quot;&gt;https://www.deloitte.com/kr/ko/Industries/technology/analysis/on-device-ai.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;IBM 양자화 설명: &lt;a href=&quot;https://www.ibm.com/kr-ko/think/topics/quantization&quot;&gt;https://www.ibm.com/kr-ko/think/topics/quantization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PyTorch INT8 양자화: &lt;a href=&quot;https://pytorch.kr/blog/2023/int8-quantization/&quot;&gt;https://pytorch.kr/blog/2023/int8-quantization/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;우아한형제들 양자화 인식 훈련: &lt;a href=&quot;https://techblog.woowahan.com/21176/&quot;&gt;https://techblog.woowahan.com/21176/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;</description>
      <category>AI</category>
      <category>cifar-10</category>
      <category>fp16</category>
      <category>int8</category>
      <category>ptq</category>
      <category>QUANTIZATION</category>
      <category>TensorFlow Lite</category>
      <category>tflite</category>
      <category>모델경량화</category>
      <category>양자화</category>
      <category>온디바이스AI</category>
      <author>Jo&amp;atilde;o Jin</author>
      <guid isPermaLink="true">https://yjcho9317.tistory.com/22</guid>
      <comments>https://yjcho9317.tistory.com/22#entry22comment</comments>
      <pubDate>Fri, 17 Apr 2026 11:37:21 +0900</pubDate>
    </item>
    <item>
      <title>NAVER WORKS CLI + MCP 서버 개발기 (3) &amp;mdash; MCP 서버 보안 설계, AI가 만드는 입력을 검증해야 하는 이유</title>
      <link>https://yjcho9317.tistory.com/21</link>
      <description>&lt;p&gt;내가 만든 MCP 서버는 AI가 보내는 파라미터를 검증하고 있을까?&lt;/p&gt;
&lt;p&gt;코드를 열어봤다. 하나도 안 하고 있었다.&lt;/p&gt;
&lt;p&gt;nworks는 LINE WORKS API를 CLI와 MCP(Model Context Protocol) 서버로 감싸는 도구다.&lt;br&gt;v1.1.0에서 기능은 거의 완성됐고, 다국어 README 정리하고 Glama에 등록하면서 마무리하는 중이었다.&lt;/p&gt;
&lt;p&gt;그 즈음 별도로 MCP 보안 프록시를 설계하고 있었다. MCP 프로토콜의 보안 구조를 조사하면서 공격 표면들을 정리하는데, 자연스럽게 의문이 들었다. 내가 만든 nworks는 괜찮은가?&lt;/p&gt;
&lt;p&gt;괜찮지 않았다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;먼저 — AI가 secret을 받으면 안 된다&lt;/h2&gt;
&lt;p&gt;본격적인 보안 패치 전에 구조적으로 잘못된 게 하나 있었다.&lt;/p&gt;
&lt;p&gt;v1.1.0까지 nworks_setup 도구는 clientSecret을 파라미터로 받았다.&lt;br&gt;AI 에이전트가 사용자에게 &amp;quot;Client Secret을 알려주세요&amp;quot;라고 물어보고, 받은 값을 tool 파라미터로 전달하는 구조였다.&lt;/p&gt;
&lt;p&gt;이러면 clientSecret이 AI 대화 히스토리에 평문으로 남는다.&lt;br&gt;민감 정보가 중간에 평문으로 지나가면 어딘가에서 새는 건 시간 문제다. 중간에 AI가 끼어있는 구조라면 더더욱.&lt;/p&gt;
&lt;p&gt;clientSecret과 privateKeyPath 파라미터를 아예 삭제했다.&lt;br&gt;이제 이 값들은 MCP 설정 파일의 env 필드로만 전달할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;mcpServers&amp;quot;: {
    &amp;quot;nworks&amp;quot;: {
      &amp;quot;command&amp;quot;: &amp;quot;npx&amp;quot;,
      &amp;quot;args&amp;quot;: [&amp;quot;-y&amp;quot;, &amp;quot;nworks&amp;quot;, &amp;quot;mcp&amp;quot;],
      &amp;quot;env&amp;quot;: {
        &amp;quot;NWORKS_CLIENT_SECRET&amp;quot;: &amp;quot;&amp;lt;secret&amp;gt;&amp;quot;,
        &amp;quot;NWORKS_PRIVATE_KEY_PATH&amp;quot;: &amp;quot;&amp;lt;path&amp;gt;&amp;quot;
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;AI가 볼 수 없는 경로로 민감 정보가 전달된다.&lt;br&gt;환경변수가 없으면 에러를 내고, 설정 방법을 JSON 예시와 함께 안내한다.&lt;/p&gt;
&lt;p&gt;이건 보안 &amp;quot;패치&amp;quot;라기보다 설계 수정이다. 이 구조가 잡힌 후에 나머지 보안 강화를 진행했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;AI도 입력이다&lt;/h2&gt;
&lt;p&gt;이 글 전체를 관통하는 문제는 하나다. MCP 서버에서 AI는 신뢰할 수 있는 중간자가 아니라, 예측 불가능한 입력 생성기다.&lt;/p&gt;
&lt;p&gt;일반적인 웹 서비스에서는 사용자가 입력을 만든다.&lt;br&gt;폼에 값을 넣고, URL을 입력하고, 파일을 업로드한다.&lt;br&gt;사용자 입력을 검증하면 끝이다.&lt;/p&gt;
&lt;p&gt;MCP는 다르다. 사용자가 &amp;quot;파일 다운로드해줘&amp;quot;라고 말하면, AI가 tool 파라미터를 구성한다.&lt;br&gt;파일 경로, 사용자 ID, 폴더 ID — 전부 AI가 만든 값이다.&lt;/p&gt;
&lt;p&gt;AI는 환각(hallucination)을 한다. prompt injection에 취약하다.&lt;br&gt;악의적이지 않더라도, AI가 &lt;code&gt;../../etc/passwd&lt;/code&gt; 같은 경로를 만들어낼 수 있다.&lt;br&gt;웹 서비스에서 사용자 입력을 외부 공격자의 입력으로 취급하듯, MCP 서버에서는 AI 입력을 같은 수준으로 취급해야 한다. 기존 보안 모델이 전제하는 &amp;quot;입력 주체 = 사용자&amp;quot;가 MCP에서는 성립하지 않는다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/26a8efe8-772a-4313-8139-961083809936/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;현재 MCP SDK는 Zod 스키마로 파라미터 타입은 검증하지만, 경로 안전성이나 값의 의미까지는 검증하지 않는다. 다른 MCP 서버들도 사정은 비슷하다. 대부분 타입 체크만 하고 값 자체는 신뢰한다. 그건 개발자 몫이다.&lt;/p&gt;
&lt;p&gt;기존 코드는 AI가 보내는 파라미터를 그대로 신뢰하고 있었다. 이 전제가 깨지면 어떤 일이 벌어지는지, 하나씩 확인해봤다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. Path Traversal — API 경로에 ../를 넣으면&lt;/h2&gt;
&lt;p&gt;드라이브 API 호출 코드가 이랬다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const url = `${BASE_URL}/users/${userId}/drive/files/${fileId}/download`;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;userId&lt;/code&gt;에 &lt;code&gt;../../admin&lt;/code&gt;이 들어오면? URL이 의도와 다른 경로로 바뀐다.&lt;br&gt;단순한 경로 오류가 아니다. 다른 사용자의 리소스에 접근하거나, API의 권한 경계를 넘을 수 있다는 뜻이다.&lt;br&gt;이건 드라이브뿐 아니라 캘린더, 메일, 태스크, 게시판 — API 경로에 파라미터가 들어가는 모든 곳에서 같은 문제였다.&lt;/p&gt;
&lt;p&gt;위험한 문자만 골라서 막는 블랙리스트 방식은 우회 가능성이 항상 남는다. URL 인코딩된 &lt;code&gt;%2F&lt;/code&gt;나 유니코드 정규화 같은 변형을 전부 잡으려면 끝이 없다. 그래서 화이트리스트 방향으로 갔다. 안전한 것만 통과시키고 나머지는 인코딩한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;export function sanitizePathSegment(value: string): string {
  if (!value || typeof value !== &amp;quot;string&amp;quot;) {
    throw new Error(&amp;quot;Path segment must be a non-empty string&amp;quot;);
  }
  if (value === &amp;quot;me&amp;quot;) return value;
  if (/[/\\]/.test(value) || value.includes(&amp;quot;..&amp;quot;)) {
    throw new Error(`Invalid path segment: &amp;quot;${value}&amp;quot;`);
  }
  return encodeURIComponent(value);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;슬래시, 백슬래시, &lt;code&gt;..&lt;/code&gt;이 있으면 거부. 나머지는 encodeURIComponent로 인코딩.&lt;br&gt;&lt;code&gt;me&lt;/code&gt;는 LINE WORKS API에서 &amp;quot;현재 사용자&amp;quot;를 뜻하는 예약어라 그대로 통과시켰다.&lt;/p&gt;
&lt;p&gt;이걸 API 호출 코드 전체에 적용했다. board, calendar, drive, mail, message, task — 6개 API 모듈, 23개 경로 조립 지점.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. SSRF(Server-Side Request Forgery) — 서버가 리다이렉트하면 어디로 가나&lt;/h2&gt;
&lt;p&gt;드라이브 파일 다운로드는 2단계로 동작한다.&lt;br&gt;LINE WORKS API에 다운로드 요청을 보내면, 실제 파일이 있는 스토리지 URL로 리다이렉트한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const location = redirectRes.headers.get(&amp;quot;location&amp;quot;);
const downloadRes = await authedFetch(location, { method: &amp;quot;GET&amp;quot; }, profile);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;location&lt;/code&gt;이 &lt;code&gt;https://evil.com/steal&lt;/code&gt;이면?&lt;br&gt;인증 토큰이 붙은 채로 외부 서버에 요청이 나간다.&lt;br&gt;단순한 외부 요청 문제가 아니다. 서버가 내부 네트워크를 대신 때리는 구조가 될 수 있다. 클라우드 환경이라면 인스턴스 메타데이터(IMDS) 접근이나 VPC 내부 스캔까지 가능해진다.&lt;/p&gt;
&lt;p&gt;현실적으로 LINE WORKS가 악의적인 리다이렉트를 보낼 일은 없다. 하지만 그걸 전제로 코드를 짜는 건 다른 문제다. API 서버가 침해당하거나, DNS rebinding 같은 시나리오는 가능하다. 방어 비용이 URL 검증 함수 하나니까, 안 할 이유도 없었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const ALLOWED_HOSTS = [
  &amp;quot;storage.worksmobile.com&amp;quot;,
  &amp;quot;www.worksapis.com&amp;quot;,
  &amp;quot;worksapis.com&amp;quot;,
];

export function validateRedirectUrl(location: string, allowedHosts: string[]): string {
  let parsed: URL;
  try {
    parsed = new URL(location);
  } catch {
    throw new Error(`Invalid redirect URL: &amp;quot;${location}&amp;quot;`);
  }

  if (parsed.protocol !== &amp;quot;https:&amp;quot;) {
    throw new Error(`Redirect URL must use HTTPS: &amp;quot;${location}&amp;quot;`);
  }

  const isAllowed = allowedHosts.some(
    (host) =&amp;gt; parsed.hostname === host || parsed.hostname.endsWith(&amp;quot;.&amp;quot; + host)
  );

  if (!isAllowed) {
    throw new Error(`Redirect to untrusted host: &amp;quot;${parsed.hostname}&amp;quot;`);
  }

  return location;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HTTPS만 허용하고, 호스트를 화이트리스트로 검증한다. 서브도메인도 허용하는 이유는 LINE WORKS가 &lt;code&gt;cdn.storage.worksmobile.com&lt;/code&gt; 같은 CDN 경로를 쓸 수 있기 때문이다.&lt;/p&gt;
&lt;p&gt;그리고 하나 더. 기존 코드는 리다이렉트된 스토리지 URL에도 인증 토큰을 붙여서 요청하고 있었다. 스토리지 다운로드에는 인증이 필요 없다. 불필요한 토큰이 외부로 나가는 것 자체가 위험이다. &lt;code&gt;authedFetch&lt;/code&gt;를 일반 &lt;code&gt;fetch&lt;/code&gt;로 교체했다.&lt;/p&gt;
&lt;p&gt;고치는 데 5분이었다. 발견하는 데가 더 오래 걸렸다.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;여기까지는 외부에서 들어오는 값에 대한 방어였다. 다음은 좀 다른 문제다. 서버가 밖으로 내보내는 값.&lt;/p&gt;
&lt;h2&gt;3. MCP 응답이 AI의 context에 남는다&lt;/h2&gt;
&lt;p&gt;&amp;quot;방금 설정한 내용 요약해줘.&amp;quot;&lt;/p&gt;
&lt;p&gt;nworks_setup 직후에 이렇게 물어보면 어떻게 될까. 기존 코드에서는 clientId가 응답에 평문으로 들어갔다. nworks_doctor를 호출하면 privateKeyPath 전체 경로가 나왔다. 이 정보는 AI의 context window에 남는다. context가 길어지면 AI가 이 정보를 다른 맥락에서 참조하거나, 요약에 포함시킬 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const mask = (s: string) =&amp;gt;
  s.length &amp;lt;= 4 ? &amp;quot;****&amp;quot; : `****${s.slice(-Math.min(4, Math.floor(s.length / 3)))}`;

// nworks_setup 응답
clientId: mask(clientId),

// nworks_doctor 응답
if (r.check === &amp;quot;privateKey&amp;quot; &amp;amp;&amp;amp; r.status === &amp;quot;OK&amp;quot;) {
  return { ...r, detail: &amp;quot;OK (path hidden)&amp;quot; };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;에러 응답도 문제였다. stack trace가 MCP 응답에 그대로 나가고 있었다. 내부 경로, 라이브러리 버전, 파일 구조가 다 드러난다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Before — stack이 AI context에 노출
content: [{ type: &amp;quot;text&amp;quot;, text: `${mcpErrorHint(err, &amp;quot;drive.upload&amp;quot;)}${detail}` }]

// After — stack은 서버 로그로, AI에게는 힌트만
if (process.env[&amp;quot;NWORKS_VERBOSE&amp;quot;] === &amp;quot;1&amp;quot;) {
  console.error(`[nworks] drive upload error: ${(err as Error).stack}`);
}
content: [{ type: &amp;quot;text&amp;quot;, text: mcpErrorHint(err, &amp;quot;drive.upload&amp;quot;) }]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;웹 서비스에서 에러 페이지에 stack trace를 노출하지 않는 것과 같은 원리다. MCP에서는 AI가 &amp;quot;에러 페이지&amp;quot;를 읽는다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. OAuth — Math.random()과 토큰 revoke&lt;/h2&gt;
&lt;p&gt;OAuth state 파라미터를 이렇게 만들고 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const state = Math.random().toString(36).substring(2);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Math.random()은 암호학적으로 안전하지 않다. 48비트 시드에서 나오는 값이라 예측 가능하다. 예측 가능한 state는 CSRF 공격에 취약하고, OAuth 스펙(RFC 6749)에서도 state는 추측 불가능해야 한다고 명시한다. MCP 환경에서는 AI가 OAuth flow를 자동으로 트리거하기 때문에, state 검증이 빠지면 자동화된 공격 시나리오가 열린다. &lt;code&gt;crypto.randomBytes(32)&lt;/code&gt;로 교체하면 256비트. 엔트로피 차이가 크다.&lt;/p&gt;
&lt;p&gt;콜백 서버에서 state 일치 여부를 검증하도록 추가했다. 이전에는 state를 보내기만 하고 돌아오는 값은 확인하지 않았다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// state 생성: Math.random() → crypto.randomBytes
export function generateSecureState(): string {
  return randomBytes(32).toString(&amp;quot;hex&amp;quot;);
}

// 콜백에서 state 검증 추가
if (state !== expectedState) {
  reject(new AuthError(&amp;quot;OAuth state mismatch — possible CSRF attack.&amp;quot;));
  return;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;로그아웃도 손봤다. 기존 로직은 로컬 토큰 파일만 삭제했다. 서버에 토큰이 살아있으면, 토큰이 탈취된 경우 공격자가 만료 전까지 계속 사용할 수 있다. 로그아웃 시 서버측 revoke를 먼저 호출하고, 실패해도 로컬 삭제는 진행하는 best-effort 방식으로 처리했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;패치 결과&lt;/h2&gt;
&lt;p&gt;전체 변경: 25개 파일, 1,478줄 추가, 312줄 삭제.&lt;br&gt;테스트: sanitize 유틸리티에 21개 테스트 추가, 전체 31개 통과.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm run test

 ✓ tests/mcp/tools.test.ts (2 tests)
 ✓ tests/utils/sanitize.test.ts (21 tests)
 ✓ tests/api/message.test.ts (6 tests)
 ✓ tests/auth/jwt.test.ts (2 tests)

 Test Files  4 passed (4)
      Tests  31 passed (31)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/4ad6872a-7fcc-4b60-8043-092fe6fe65c8/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;보안 검증은 수동으로도 돌렸다. sanitize 함수에 공격 페이로드를 직접 넣어서 차단되는지 확인하는 스크립트.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/yjcho9317/post/9a22a613-ff0b-495d-bbb8-6bbd58cfe51e/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;전부 차단. 기존 정상 동작도 영향 없음을 CLI와 MCP 양쪽에서 확인했다.&lt;/p&gt;
&lt;p&gt;정리하면 이번 패치의 핵심은 하나다. AI가 보내는 파라미터를 사용자 입력이 아니라, 외부 공격자의 입력과 동일하게 취급하는 것. 이 전제만 바꾸면 나머지는 기존 보안 패턴을 그대로 적용할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;돌아보면&lt;/h2&gt;
&lt;p&gt;MCP 서버를 만들면서 기능에만 집중했다. 26개 도구가 돌아가고, CLI도 잘 되고, Glama에서 AAA도 받았다.&lt;br&gt;하지만 &amp;quot;AI가 만든 파라미터를 검증해야 한다&amp;quot;는 관점은 빠져 있었다.&lt;/p&gt;
&lt;p&gt;웹 서비스에서는 사용자 입력 검증이 기본이다. 누구나 안다.&lt;br&gt;MCP에서는 AI 입력 검증이 기본이어야 한다. 그런데 이걸 놓치기 쉽다.&lt;br&gt;AI가 보내는 값은 대부분 정상적이니까. 문제는 대부분이 아니라 가끔이다.&lt;/p&gt;
&lt;p&gt;이 경험이 이후 MCP 보안 프록시를 만드는 데도 이어졌다. 개별 서버에서 일일이 방어하는 것도 중요하지만, 프로토콜 레벨에서 공통으로 잡아야 할 것들이 있다.&lt;/p&gt;
&lt;p&gt;MCP 서버를 만들고 있다면, 이 질문을 해보면 좋겠다.&lt;br&gt;내 서버는 AI가 &lt;code&gt;../../etc/passwd&lt;/code&gt;를 보내면 어떻게 되는가.&lt;/p&gt;
&lt;p&gt;이번 패치가 반영된 코드는 &lt;a href=&quot;https://github.com/yjcho9317/nworks&quot;&gt;nworks GitHub 레포&lt;/a&gt;에서 볼 수 있다.&lt;/p&gt;</description>
      <category>개발기</category>
      <category>AI 에이전트 보안</category>
      <category>LINE WORKS</category>
      <category>LLM 보안</category>
      <category>MCP</category>
      <category>MCP 서버 보안</category>
      <category>model context protocol</category>
      <category>nworks</category>
      <category>OAuth 보안</category>
      <category>path traversal</category>
      <category>ssrf</category>
      <author>Jo&amp;atilde;o Jin</author>
      <guid isPermaLink="true">https://yjcho9317.tistory.com/21</guid>
      <comments>https://yjcho9317.tistory.com/21#entry21comment</comments>
      <pubDate>Tue, 14 Apr 2026 18:03:52 +0900</pubDate>
    </item>
  </channel>
</rss>