<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>_suho_.log</title>
        <link>https://velog.io/</link>
        <description>처음부터 다시 시작!!</description>
        <lastBuildDate>Wed, 04 Mar 2026 07:06:51 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. _suho_.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/_suho_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[NexPay 모바일 개발 회고 - AI 협업 개발기]]></title>
            <link>https://velog.io/@_suho_/NexPay-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EA%B0%9C%EB%B0%9C-%ED%9A%8C%EA%B3%A0-AI-%ED%98%91%EC%97%85-%EA%B0%9C%EB%B0%9C%EA%B8%B0-s88zdtsi</link>
            <guid>https://velog.io/@_suho_/NexPay-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EA%B0%9C%EB%B0%9C-%ED%9A%8C%EA%B3%A0-AI-%ED%98%91%EC%97%85-%EA%B0%9C%EB%B0%9C%EA%B8%B0-s88zdtsi</guid>
            <pubDate>Wed, 04 Mar 2026 07:06:51 GMT</pubDate>
            <description><![CDATA[<h3 id="1-프로젝트-소개">1. 프로젝트 소개</h3>
<ul>
<li><p>NexPay 앱 개요 및 목표</p>
<p>  → 빠른 시일 내 앱 개발 및 가능성 확보</p>
</li>
<li><p>기술 스택</p>
<ul>
<li>Kotlin</li>
<li>Compose</li>
<li>Clean Architecture</li>
<li>MVI</li>
</ul>
</li>
</ul>
<h3 id="2-ai-협업-개발-도입-배경">2. AI 협업 개발 도입 배경</h3>
<ul>
<li><p>왜 Claude Code를 도입했는가?</p>
<p>  → 원래 Android Team은 Gemini를 사용할 목적이였다. (Android Studio안 기본 AI가 Gemini) Gemini 연동이 생각보다 가이드가 잘 되어있지 않고 MCP 연동이 제대로 되지  않았음.</p>
</li>
<li><p>AI에게 기대했던 효과</p>
<ul>
<li>처음에는 “Figma MCP 연동 → 화면 개발 시간 단축”이 목적이였다.</li>
<li>나머지는 기존에 사용하던 AI와 같이 물어보면서 작업을 하려고 생각하고 있었다.</li>
</ul>
</li>
</ul>
<h3 id="3-claude-code-도입-후">3. Claude Code 도입 후</h3>
<ul>
<li>Figma MCP 연동 → 화면 개발 시간 단축 + 각각의 Navigation 연결</li>
<li>비즈니스 로직 및 테스트 코드 개발 시간 단축</li>
</ul>
<h3 id="4-개발-워크플로우">4. 개발 워크플로우</h3>
<ul>
<li>기술 선정 ( <del>java + xml</del> or kotlin + compose ) / 아키텍처 선정 ( Clean Architecture ) / 패턴 선정 ( <del>MVVM</del> or MVI )</li>
<li>prompt 작성 → 검증 → 회의 → 채택<ul>
<li>UI : Figma 디자인 → Compose UI / viewModel / UIState / UiEffect / UiEvent 자동 생성 파이프라인</li>
<li>비즈니스 로직 : domain / data / remote / test API 연동 자동 생성 파이프라인</li>
</ul>
</li>
<li>Claude 계획 → 개발 → 검증 → Commit 사이클</li>
</ul>
<h3 id="5-잘-된-점">5. 잘 된 점</h3>
<ul>
<li>prompt 명령으로 화면 및 비즈니스 로직이 제대로 구현</li>
<li>Commit Message 자동 생성으로 시간 절약</li>
<li>테스트/빌드 검증까지 포함한 자동화된 결과물</li>
</ul>
<h3 id="6-어려웠던-점">6. 어려웠던 점</h3>
<ul>
<li><p>초기 prompt 설계</p>
<ul>
<li><p>UI</p>
<ul>
<li>아이콘 직접 넣을 생각 → 아이콘 생성 가능 확인 후 prompt 변경</li>
<li>figma 고정 dp 화면 찌그러짐 문제 → 반응형 레이아웃 리팩터링 규칙 추가 prompt 변경</li>
</ul>
</li>
<li><p>비즈니스 로직</p>
<ul>
<li>domain / data / remote / test 생성 → viewModel 자동 연동 추가 ( 기존은 viewModel 변경 불가 )</li>
</ul>
<p>→ 초기 설정에서 내가 원하는 prompt가 나올 때까지 반복적으로 테스트 진행이 필요 </p>
</li>
</ul>
</li>
<li><p>Context 사용량 체크</p>
<ul>
<li>standard / premium 모두 둘다 내가 Context를 어떻게 사용하고 있는지 정확한 체크가 힘듬.</li>
</ul>
</li>
</ul>
<h3 id="7-배운-점--노하우">7. 배운 점 / 노하우</h3>
<ul>
<li>/context 또는 /insights 같은 명령어를 사용해서 행동 패턴을 분석해서 Context 개선하는 것이 좋습니다.<ul>
<li>Context 관리 방법<ul>
<li>/clear 항상 초기화 해준다.</li>
<li>/compact 만약 내용을 이어가야 한다면 압축을 해주면 된다.</li>
<li>/rename /resume → /clear 하기전 만약 저장해둬야 한다면 rename으로 정의하고 나중에 resume 으로 불러오면 된다.</li>
</ul>
</li>
</ul>
</li>
<li>AI에게 명확한 지시를 내리는 방법<ul>
<li>prompt 설계 방법 - 아직 미흡하지만<ul>
<li>명확한 규칙 ( <strong>강제성을</strong> 주는것이 좋은 것 같다 )</li>
<li>샘플 가이드 ( 샘플을 주면 잘 만들어 준다 )</li>
</ul>
</li>
</ul>
</li>
<li>Command를 사용해라.. 기존은 파일을 바꾸면서 작업을 하던가 @해서 파일을 찾아서 작업하던가 했는데 Command 등록 후 업무 효율이 올라감</li>
<li>Opus 모델을 사용해라… 돈이 쵝오…<ul>
<li>Plan 모드는 당연 Opus로 사용하는 것이 좋습니다. ( 나오는 퀄리티가 다름 )</li>
<li>실제 개발 단계에서도 Sonnet 보단 Opus가 정확한 추론과 이해하는 내용의 범위가 다르다고 느껴집니다.</li>
</ul>
</li>
</ul>
<h3 id="8-before--after-비교">8. Before &amp; After 비교</h3>
<ul>
<li>AI 도입 전후 개발 생산성 변화</li>
<li>코드 품질 및 일관성 상승</li>
<li>git commit message 관리 깔끔</li>
</ul>
<h3 id="9-향후-개선-방향">9. 향후 개선 방향</h3>
<ul>
<li>자동화 파이프라인 구축 ( UI → ViewModel → Domain → Data → Remote / Local → Test → Commit → Push )</li>
<li>커스텀 스킬 / 훅 / 커멘드 등 활용으로 반복 작업 최소화</li>
<li>UI 및 Presentation 부분 테스트 코드 도입</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Unit Test - Android]]></title>
            <link>https://velog.io/@_suho_/Unit-Test-Android</link>
            <guid>https://velog.io/@_suho_/Unit-Test-Android</guid>
            <pubDate>Mon, 19 Jan 2026 05:37:12 GMT</pubDate>
            <description><![CDATA[<ol>
<li><p>JUnit을 사용한 이유</p>
<p> → 안드로이드 테스트의 기본 테스트 프레임 워크</p>
</li>
<li><p>Android 테스트 방법</p>
<ul>
<li><p>Local Unit Test (JVM Test)</p>
<p>  → 빠르고 가벼우며, 비즈니스 로직 / ViewModel / UseCase / Mapper 등의 순수 Kotlin 코드 테스트에 적합</p>
</li>
<li><p>Instrumentation Test (Android Test)</p>
<p>  → 실제 Android Framework 위에서 실행되는 테스트, UI / Lifecycle / Room DB / Navigation 등 Android API가 포함된 코드 테스트에 적합</p>
</li>
</ul>
</li>
<li><p>JUnit 구성 요소</p>
<ul>
<li><p>@Test - 테스트 메서드</p>
<p>  → JUnit의 가장 기본적인 구성 요소</p>
<pre><code class="language-kotlin">  @Test
  fun test() {
      val result = 2+3
      assertEquals(5, result)
  }</code></pre>
</li>
<li><p>@Before / @BeforeEach - 테스트 전 초기화</p>
<p>  → 테스트 실행 전에 공통으로 필요한 세팅을 해줄 때 사용</p>
<pre><code class="language-kotlin">  @Before
  fun setup(){
      test = Test()
  }</code></pre>
</li>
<li><p>@After / @AfterEach - 테스트 후 정리</p>
<p>  → 테스트 수행 후 자원(Resource)을 정리할 때 사용</p>
<pre><code class="language-kotlin">  @After
  fun tearDown(){
      // DB 닫기, MockServer 종료 등
  }</code></pre>
</li>
<li><p>Assert 계열 메서드 - 검증</p>
<p>  → 예상 값과 실제 결과를 비교해 테스트의 성공/실패를 판단</p>
<table>
<thead>
<tr>
<th>assertEquals(expected, actual)</th>
<th>두 값이 동일한지 확인</th>
</tr>
</thead>
<tbody><tr>
<td>assertNotEqualse(expected, actual)</td>
<td>두 값이 다른지 확인</td>
</tr>
<tr>
<td>assertTrue(condition)</td>
<td>조건이 true인지 확인</td>
</tr>
<tr>
<td>assertFalse(condition)</td>
<td>조건이 false인지 확인</td>
</tr>
<tr>
<td>assertNull(value)</td>
<td>값이 null인지 확인</td>
</tr>
<tr>
<td>assertNotNull(value)</td>
<td>값이 null이 아닌지 확인</td>
</tr>
<tr>
<td>assertThrows(Exception::class.java) {…}</td>
<td>특정 예외가 발생하는지 확인</td>
</tr>
</tbody></table>
</li>
<li><p>@Ignore - 테스트 제외</p>
<p>  → 일시적으로 테스트를 실행하지 않도록 설정</p>
<pre><code class="language-kotlin">  @Ignore(&quot;API 수정 중으로 테스트 제외&quot;)
  @Test
  fun apiTest() {...}</code></pre>
</li>
<li><p>@Rule - 테스트 규칙 정의</p>
<p>  → 특정 테스트 환경이나 설정을 적용할 때 사용</p>
<p>  → Android에서는 특히 LiveData 테스트, Hilt 테스트, Compose 테스트 등에서 자주 사용</p>
<pre><code class="language-kotlin">  @get:Rule
  val testRule = TestRule()</code></pre>
</li>
</ul>
</li>
</ol>
<ol>
<li><p>Mock</p>
<ul>
<li><p>왜 Mock이 필요한가?</p>
<p>  → JUnit은 “테스트 실행”과 “결과 검증”을 담당하지만, 의존성(Dependencies)이 있는 코드를 직접 제어 못함</p>
<pre><code class="language-kotlin">  class UserService(private val userRepository: UserRepository) {
      fun getUserName(id: String) : String {
          return userRepository.findById(id).name
      }
  }</code></pre>
<p>  → 이 경우 UserRepository는 DB와 통신할 수 있고, 네트워크를 호출할 수 있다. 그럼 테스트 시 실제 DB연결이나 서버 호출이 일어날 수 있으므로 단위 테스트(Unit Test)의 목적(”하나의 로직만 검증”)에 어긋난다.</p>
<p>  → 그래서 “진짜 객체” 대신 “가짜(Mock) 객체”를 만들어서 외부 의존성을 대채한다.</p>
</li>
<li><p>Mockito / MockK</p>
</li>
</ul>
</li>
</ol>
<pre><code>    |  | Mockito | MockK |
    | --- | --- | --- |
    | 주요 언어 | Java 기반  | Kotlin 전용 |
    | Mock 생성 | Mockito.mock() | mockk() |
    | 동작 설정 | when(…).thenReturn(…) | every {…} returns … |
    | 호출 검증 | verify(…) | verify {…} |
    | 특징 | 전통적인 Mocking 라이브러리 | 코루틴, suspend 함수, DSL 친화적 |
1. 그래서 우리팀은?
    - **JUnit4 vs JUnit5 : JUnit5를 사용할 예정입니다.**

        → 우리팀의 Unit Test 목적 순수 비즈니스 로직만 테스트 하기 위함 

        → 대부분 Kotlin 기반으로 MockK / 코루틴 테스트와 호환 목적

        → UI/Andorid 테스트 코드 작성 시 JUnit4를 사용해도 됩니다. (강제성 x) 

    - **Mockito vs MockK :  MockK를 사용 할 예정입니다.**

        → 우리 프로젝트는 Kotlin 기반 / Java 테스트가 필요할 경우 Mockito 사용 가능</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[왜 Kotlin 코드에서 Java AtomicReference 를 사용하는가?]]></title>
            <link>https://velog.io/@_suho_/%EC%99%9C-Kotlin-%EC%BD%94%EB%93%9C%EC%97%90%EC%84%9C-Java-AtomicReference-%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94%EA%B0%80</link>
            <guid>https://velog.io/@_suho_/%EC%99%9C-Kotlin-%EC%BD%94%EB%93%9C%EC%97%90%EC%84%9C-Java-AtomicReference-%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94%EA%B0%80</guid>
            <pubDate>Mon, 12 Jan 2026 09:15:37 GMT</pubDate>
            <description><![CDATA[<p>Kotlin 프로젝트에서도 AtomicReference 가 자주 등장하는데,</p>
<p>그 이유는 <strong>Kotlin이 Java 기반 JVM 위에서 동작하며, 완벽한 대체 기능이 없기 때문</strong>이다.</p>
<hr>
<h2 id="🔒-atomicreference란"><strong>🔒 AtomicReference란?</strong></h2>
<p>AtomicReference<T> 는 <strong>락을 사용하지 않고(lock-free)</strong></p>
<p>여러 스레드에서 동시에 안전하게 읽기/쓰기가 가능한 원자적 참조(Reference) 저장 객체이다.</p>
<hr>
<h2 id="✅-kotlin에서-atomicreference-를-사용하는-이유"><strong>✅ Kotlin에서 AtomicReference 를 사용하는 이유</strong></h2>
<h3 id="1-kotlin에는-완전한-대체품이-없음"><strong>1. Kotlin에는 완전한 대체품이 없음</strong></h3>
<p>Kotlin은 JVM 언어라 다음 Java concurrency primitives 를 그대로 사용한다.</p>
<ul>
<li>AtomicReference</li>
<li>AtomicBoolean</li>
<li>AtomicInteger</li>
<li>AtomicLong</li>
</ul>
<p>Kotlin 자체에는 이와 동일한 기능을 제공하는 클래스가 없다.</p>
<hr>
<h3 id="2-lock-free라서-빠르고-경량"><strong>2. Lock-free라서 빠르고 경량</strong></h3>
<p>Mutex 또는 synchronized는 락(lock)을 걸기 때문에 성능 오버헤드가 있다.</p>
<p>반면 AtomicReference는:</p>
<ul>
<li><strong>락을 사용하지 않고도 thread-safe</strong></li>
<li>읽기/쓰기가 매우 빠름</li>
</ul>
<hr>
<h3 id="3-여러-스레드코루틴-환경에서도-안전"><strong>3. 여러 스레드/코루틴 환경에서도 안전</strong></h3>
<p>코루틴은 스레드를 옮겨가며 실행되므로</p>
<p><strong>공유 데이터를 안전하게 관리해야 한다.</strong></p>
<p>AtomicReference 없이는 다음 문제가 발생할 수 있다:</p>
<ul>
<li>레이스 컨디션(race condition)</li>
<li>값 읽기와 쓰기 순서가 보장되지 않음</li>
<li>스레드 전환 중 데이터가 꼬임</li>
</ul>
<hr>
<h3 id="4-불변-패턴immutable-state-유지에-유리"><strong>4. 불변 패턴(Immutable state) 유지에 유리</strong></h3>
<p>암호화 관련 파라미터(passphrase, salt, IV)는:</p>
<ul>
<li>한 번 생성되면 변경되면 안 되고</li>
<li>여러 스레드에서 읽히며</li>
<li>원자적으로 교체(CAS)되어야 함</li>
</ul>
<p>AtomicReference는 이 패턴에 정확히 맞는다.</p>
<hr>
<h3 id="5-base64-decode-실패-등에서-안정적으로-롤백-가능"><strong>5. Base64 decode 실패 등에서 안정적으로 롤백 가능</strong></h3>
<p>파라미터 세팅 중 실패하면:</p>
<pre><code>clear()  // 전부 null로 초기화</code></pre><p>이 과정을 Atomic하게 처리할 수 있다는 장점도 있다.</p>
<hr>
<h2 id="🆚-atomicreference-대체-옵션-비교"><strong>🆚 AtomicReference 대체 옵션 비교</strong></h2>
<table>
<thead>
<tr>
<th><strong>대체제</strong></th>
<th><strong>사용 가능?</strong></th>
<th><strong>한계</strong></th>
</tr>
</thead>
<tbody><tr>
<td>volatile</td>
<td>❌</td>
<td>원자적 업데이트가 불가능</td>
</tr>
<tr>
<td>MutableStateFlow</td>
<td>⚠️ 가능하나 부적합</td>
<td>옵저버 패턴이라 오버헤드 큼</td>
</tr>
<tr>
<td>Mutex</td>
<td>⚠️ 사용 가능</td>
<td>락 기반이라 느리고 무거움</td>
</tr>
<tr>
<td>synchronized</td>
<td>⚠️ 사용 가능</td>
<td>Java-style 락, 비효율적</td>
</tr>
<tr>
<td>AtomicReference</td>
<td>✅ 최적</td>
<td>가장 빠르고 안전</td>
</tr>
</tbody></table>
<p>결론: <strong>AtomicReference가 가장 적합하고 효율적이다.</strong></p>
<hr>
<h2 id="📌-cryptoparametermemorystore-에서-atomicreference-를-쓰는-이유-요약"><strong>📌 CryptoParameterMemoryStore 에서 AtomicReference 를 쓰는 이유 요약</strong></h2>
<p>CryptoParameterMemoryStore 구조:</p>
<pre><code>private val passphraseRef = AtomicReference&lt;String?&gt;(null)
private val saltRef = AtomicReference&lt;ByteArray?&gt;(null)
private val initializationVectorRef = AtomicReference&lt;ByteArray?&gt;(null)</code></pre><h3 id="필요한-특성이-모두-atomicreference-에-들어있다"><strong>필요한 특성이 모두 AtomicReference 에 들어있다:</strong></h3>
<ul>
<li>thread-safe</li>
<li>lock-free</li>
<li>값의 원자적 교체</li>
<li>null 허용</li>
<li>실패 시 rollback 가능</li>
<li>암호 파라미터처럼 민감한 데이터에 적합</li>
</ul>
<p>즉, multiprocessing / coroutine 환경에서 <strong>가장 안전하고 빠른 방식</strong>이다.</p>
<hr>
<h2 id="📝-결론"><strong>📝 결론</strong></h2>
<blockquote>
<p>Kotlin에서는 Java의 AtomicReference를 사용하는 것이 정상적이며,
특히 암호 파라미터처럼 thread-safe 하고 lock-free 메모리 저장이 필요할 때
AtomicReference가 가장 적합한 선택이다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project] Socket 통신 (VAN 연동)]]></title>
            <link>https://velog.io/@_suho_/Project-Socket-%ED%86%B5%EC%8B%A0-VAN-%EC%97%B0%EB%8F%99</link>
            <guid>https://velog.io/@_suho_/Project-Socket-%ED%86%B5%EC%8B%A0-VAN-%EC%97%B0%EB%8F%99</guid>
            <pubDate>Mon, 22 Dec 2025 23:27:30 GMT</pubDate>
            <description><![CDATA[<h2 id="큰-흐름">큰 흐름</h2>
<ol>
<li><p>connect → startReader → send → (프레임 수신 콜백) → 모드 기준으로 계속/종료</p>
</li>
<li><p>데이터는 STX(0x02) ~ ETX(0x03) + LRC(1byte) 프레이밍</p>
</li>
<li><p>두 가지 수신 기준을 모두 지원:</p>
</li>
</ol>
<ul>
<li>길이 헤더 기반 (두 번째 바이트가 ‘I’ 이고, 3..6에 ASCII 4자리 길이)</li>
<li>ETX 도발 기반 (길이 헤더가 없을 때)</li>
</ul>
<ol start="4">
<li><p>LRC(XOR)은 STX 포함/제외 두 방식 모두 검증(벤더 사양 혼재 대응)</p>
</li>
<li><p>매 프레임마다 ACK(0x06) 자동 전송(기존 Kovan 리더가 프레임마다 ACK 요구하는 패턴 대응)</p>
</li>
<li><p>ReceiveMode로 종료 조건을 제어:</p>
</li>
</ol>
<ul>
<li>Continuous: 앱이 close() 할 때까지 계속</li>
<li>StopAfterEndFrame: *E…(혹은 E…) 프레임 받으면 종료(가맹점 다운로드 용)</li>
<li>StopAfterFirstFrame: 유효 프레임 1개 받자마자 종료(단건 응답용)</li>
</ul>
<h2 id="public-api-요약">Public API 요약</h2>
<ul>
<li><p>suspend fun connect(host, post)</p>
<p>  → 소켓 연결만 수행(블로킹 읽기, soTimeout=0). 스트림 래핑</p>
</li>
<li><p>fun startReader()</p>
<p>  → IO dispatchers에서 수신루프 시작. (메인스레드에서 네트워크 호출 피하려고 UNDISPATCHED를 쓰지 않음 → NetworkOnMainThreadException 회피)</p>
</li>
<li><p>suspend fun send(data: ByteArray)</p>
<p>  → byte를 서버에 전송</p>
</li>
<li><p>fun setOnFrame(listener: (ByteArray) → Boolean)</p>
<p>  → 완성 프레임(STX..ETX..LRC 전체) 가 올 때마다 호출</p>
<p>  → 리스너가 false를 반환하면 “리스너 의사”로 루프를 끌낼 수 있음.</p>
</li>
<li><p>fun setReceiveMode(mode)</p>
<p>  → 수신 종료 정책 지정 (Continuous / StopAfterEndFrame / StopAfterFirstFrame)</p>
</li>
<li><p>suspend fun close()</p>
<p>  → reader 취소 - 스트림/소켓 닫기</p>
</li>
</ul>
<blockquote>
<p>단건 조회는 StopAfterFirstFrame, 가맹점 다운로드는 StopAfterEndFrame(서버가 *E…로 끝내줌), 스트리밍은 Continuous</p>
</blockquote>
<h2 id="수신-루프프레이밍-상세">수신 루프(프레이밍) 상세</h2>
<pre><code class="language-kotlin">while (isReaderRunning &amp;&amp; isOpen()) {
    // 1바이트 블로킹 읽기 (IO 디스패처)
    localInputStream.read(singleByteBuffer)

    // STX를 기다렸다가, STX가 오면 프레임 버퍼 초기화+수집 시작
    // &#39;I&#39; 포맷이면 3..6 ASCII 길이(예: &quot;0123&quot;)를 파싱해 STX..ETX 길이를 알 수 있음
    // 길이를 알면 그 길이만큼 딱 모은 뒤 LRC 1바이트를 추가로 읽어서 &quot;완성 프레임&quot;
    // 길이를 못 알면, ETX를 만나면 LRC 1바이트 추가로 읽어서 &quot;완성 프레임&quot;
}</code></pre>
<ol>
<li><p>길이 헤더 파싱</p>
<p> → specFormatByte == ‘I’ 이고 바이트가 7개 이상 모이면 collected[3..6]을 ASCII 정수로 변환 - STX..ETX 길이로 해석. 그 길이만큼 모이면 LRC 1byte를 추가로 읽어 완성</p>
</li>
<li><p>ETX 기반</p>
<p> → 길이를 못 얻은 경우 ETX 도착 시 LRC 1Byte를 추가로 읽어 완성</p>
</li>
</ol>
<h2 id="rlc-검증과-ack">RLC 검증과 ACK</h2>
<pre><code class="language-kotlin">val etxIndex = fullFrameBytes.indexOf(ETX)
val readLrc   = fullFrameBytes.last() &amp; 0xFF
val lrcInclStx = xor(fullFrame, from=0, to=etxIndex)   // STX 포함
val lrcExclStx = xor(fullFrame, from=1, to=etxIndex)   // STX 제외

val valid = readLrc == lrcInclStx || readLrc == lrcExclStx</code></pre>
<ol>
<li><p>장비마다 LRC 정의가 STX 포함/제외 혼용되는 경우가 있어 둘다 허용</p>
</li>
<li><p>유효/무효와 관계 없이 (프로토콜 요구에 맞춰) ACK(0x06)를 전송하도록 구현되어 있음</p>
<p> (무효 LRC 시에도 계속 받을지 여부는 ReceiveMode에 따름)</p>
</li>
</ol>
<h2 id="종료-조건receivemode--isendframe">종료 조건(ReceiveMode + isEndFrame)</h2>
<ul>
<li><p>isEndFrame(payload)</p>
<p>  → payload[0] == ‘*’ &amp;&amp; payload[1] == ‘E’ - 종료(예: *E0)</p>
<p>  → 또는 payload[0] == ‘E’ - 예비 종료 패턴</p>
</li>
<li><p>StopAfterEndFrame 에서는 위 패턴을 받을 때까지 계속</p>
</li>
<li><p>StopAfterFirstFrame 은 첫 유효 프레임에서 바로 종료</p>
</li>
<li><p>Continuous 는 앱이 close() 할 때까지 종료하지 않음</p>
</li>
</ul>
<h2 id="startreader가-따로-있는-이유는">startReader()가 따로 있는 이유는?</h2>
<ul>
<li>connect() 는 소켓 연결만 담당</li>
<li>startReader() 는 IO 디스패처에서 백그라운드 수신을 시작</li>
<li>이렇게 분리하면, 연결 직후 초기화/전송 타이밍을 제어하기 쉽고, 메인 스레드에서 수신 루프가 즉시 시작되며 네트워크를 건드리는 상황(=UNDISPATCHED)을 피할 수 있어서 NetworkOnMainThreadException을 막을 수 있다.</li>
</ul>
<h2 id="궁금한거">궁금한거</h2>
<ol>
<li>이건 어떻게 사용되는거야?</li>
</ol>
<pre><code class="language-kotlin">private val supervisorJob = SupervisorJob()
private val ioCoroutineScope = CoroutineScope(Dispatchers.IO + supervisorJob)</code></pre>
<p>→ 뭐가 만들어지나?</p>
<ul>
<li>SupervisorJob()<ul>
<li>이 스코프의 “부모 잡(Job)” 이다.  한 자식 코루틴이 실패해도 다른 자식들/스코프 전체가 같이 취소되지 않도록 한다.(일반 Job이면 한 자식의 예외가 스코프 전체로 전파되어 모두 취소됨)</li>
</ul>
</li>
<li>CoroutineScope(Dispatchers.IO + supervisroJob) 코루틴 컨텍스트를 합친 것<ul>
<li><a href="http://Dispatchers.IO">Dispatchers.IO</a>: 네트워크/파일/IO 같은 블로킹 작업을 위한 백그라운드 스레드 풀 → StricMode의 “NetworkOnMainThread” 위반을 피함</li>
<li>supervisorJob: 위에서 말한 실패 고립(subpervion) 역할</li>
</ul>
</li>
<li>이 스코프에서 launch{ … } 하면:<ul>
<li>작업은 항상 IO 스레드풀에서 돌아가고(메인 스레드 아님)</li>
<li>어느 하나의 지식(예: 수신 루프)이 예외로 죽어도 다른 자식(예: 추후 보낼 heartbeat, 재시도 타이밍 등)은 유지된다.</li>
</ul>
</li>
</ul>
<p>요즘은 계속해서 개발 진행을 하고 있는데 재미있다! 하지만 가끔 블로그를 써야하는데 주제를 뭐로 할 지 항상 고민인 것 같다. 그래서 앞으로는 개발하면서 궁금하거나 개발한 건에 대해서 작성을 해볼 예정이다!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[외부 카메라 SDK를 Compose/모듈화 환경에서 안전하게 사용하는 방법]]></title>
            <link>https://velog.io/@_suho_/%EC%99%B8%EB%B6%80-%EC%B9%B4%EB%A9%94%EB%9D%BC-SDK%EB%A5%BC-Compose%EB%AA%A8%EB%93%88%ED%99%94-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@_suho_/%EC%99%B8%EB%B6%80-%EC%B9%B4%EB%A9%94%EB%9D%BC-SDK%EB%A5%BC-Compose%EB%AA%A8%EB%93%88%ED%99%94-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Tue, 16 Dec 2025 23:38:45 GMT</pubDate>
            <description><![CDATA[<p>최근 카메라 인식 기능을 개발하면서 <strong>외부 카메라 SDK</strong>를 사용해야 하는 일이 생겼다.</p>
<p>문제는, 현재 프로젝트는 거의 모든 기능이 <strong>모듈화(Modularization)</strong> 되어 있어,</p>
<p>기존처럼 Activity/Fragment의 onResume, onPause, onDestroy에 직접 접근하기가 쉽지 않았다는 점이다.</p>
<p>특히 <strong>Compose 기반 화면</strong>에서는 Activity나 Fragment에 직접 의존하지 않고 UI를 그리기 때문에,</p>
<p>SDK의 생명주기(Lifecycle)를 정확하게 제어하는 것이 더 까다로워진다.</p>
<p>그래서 여러 사례와 문서를 찾아본 끝에, 가장 적합한 해결책을 찾았다.</p>
<p>바로 <strong>DefaultLifecycleObserver</strong> 를 활용하는 방식이다.</p>
<h1 id="🧭-defaultlifecycleobserver란"><strong>🧭 DefaultLifecycleObserver란?</strong></h1>
<p>DefaultLifecycleObserver는 Android Jetpack의 Lifecycle 라이브러리가 제공하는 인터페이스로,</p>
<p><strong>Activity나 Fragment의 생명주기를 관찰</strong>할 수 있는 기능을 제공한다.</p>
<p>필요한 메서드만 override할 수 있고, 코틀린의 default method 덕분에 아주 간단하게 사용할 수 있다.</p>
<pre><code>lifecycleObserver = object : DefaultLifecycleObserver {
    override fun onResume(owner: LifecycleOwner) {
        // SDK 재시작
    }

    override fun onPause(owner: LifecycleOwner) {
        // SDK 일시정지
    }

    override fun onDestroy(owner: LifecycleOwner) {
        // 자원 해제
    }
}</code></pre><p>이렇게 등록만 해두면 Activity/Fragment 생명주기에 따라 자동으로 호출된다.</p>
<h1 id="🔍-왜-defaultlifecycleobserver가-필요했을까"><strong>🔍 왜 DefaultLifecycleObserver가 필요했을까?</strong></h1>
<p>내가 직면했던 문제는 크게 두 가지였다.</p>
<h3 id="1-모듈-구조-때문에-activityfragment-접근이-쉽지-않음"><strong>1) 모듈 구조 때문에 Activity/Fragment 접근이 쉽지 않음</strong></h3>
<p>모듈화를 진행하다 보면:</p>
<ul>
<li>UI 모듈(Compose 화면)</li>
<li>Feature 모듈</li>
<li>SDK Wrapper 모듈</li>
</ul>
<p>이 각각 분리되어,</p>
<p>Activity/Fragment의 lifecycle 함수에 직접 접근하기가 매우 어렵다.</p>
<p>하지만 외부 카메라 SDK는 대부분 이렇게 요구한다.</p>
<ul>
<li>activity.onResume() 시점 → <strong>카메라 재시작</strong></li>
<li>activity.onPause() 시점 → <strong>카메라 중지</strong></li>
<li>activity.onDestroy() 시점 → <strong>자원 해제</strong></li>
</ul>
<p>이 흐름을 놓치면:</p>
<ul>
<li>카메라가 멈추지 않아서 릭 발생</li>
<li>화면 이동 시 crash 발생</li>
<li>카메라 Preview가 블랙스크린으로 뜸</li>
<li>일부 기기에서 전면/후면 전환 오류 발생</li>
</ul>
<p>즉, <strong>정확한 lifecycle 제어는 필수</strong>였다.</p>
<p>DefaultLifecycleObserver를 사용하면,</p>
<p>Activity나 Fragment에 직접 접근하지 않아도</p>
<p>lifecycle만 넘겨 받아 안전하게 제어할 수 있다.</p>
<h3 id="2-sdk-제어-코드를-viewmodel-또는-controller로-분리하고-싶었음"><strong>2) SDK 제어 코드를 ViewModel 또는 Controller로 분리하고 싶었음</strong></h3>
<p>Activity/Fragment에 카메라 SDK 호출 코드를 넣으면:</p>
<ul>
<li>코드가 길어진다</li>
<li>테스트하기 어렵다</li>
<li>UI와 로직이 강하게 결합된다</li>
</ul>
<p>그래서 카메라 동작을 <strong>ViewModel도 아니고, UI도 아닌</strong>,</p>
<p>별도의 Controller 클래스로 분리하고 싶었다.</p>
<p>하지만 이 Controller에서 lifecycle 이벤트를 받기 위해서는</p>
<p><strong>LifecycleOwner에 직접 붙을 필요</strong>가 있다.</p>
<p>이 역할을 정확하게 수행할 수 있는 것이</p>
<p>바로 DefaultLifecycleObserver다.</p>
<h1 id="📌-마무리"><strong>📌 마무리</strong></h1>
<p>외부 카메라 SDK를 Compose + 모듈 구조에서 안정적으로 사용하려면</p>
<p>Lifecycle 제어가 매우 중요하다.</p>
<p>그리고 그중에서 가장 깔끔하고 안전한 방식이 바로 <strong>DefaultLifecycleObserver</strong>다.</p>
<p>덕분에:</p>
<ul>
<li>SDK 코드 분리</li>
<li>모듈 의존성 감소</li>
<li>UI/로직 결합도 해소</li>
<li>유지보수성 극대화</li>
<li>lifecycle 버그 감소</li>
</ul>
<p>라는 큰 장점을 얻을 수 있었다.</p>
<p>앞으로 외부 SDK(카메라, OCR, 센서, 결제기 등)를 다룰 때는</p>
<p>이 패턴을 적극적으로 활용하게 될 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[git commit 쪼개기]]></title>
            <link>https://velog.io/@_suho_/git-commit-%EC%AA%BC%EA%B0%9C%EA%B8%B0</link>
            <guid>https://velog.io/@_suho_/git-commit-%EC%AA%BC%EA%B0%9C%EA%B8%B0</guid>
            <pubDate>Wed, 10 Dec 2025 04:43:56 GMT</pubDate>
            <description><![CDATA[<p>📌 PR 사고(?)와 해결 과정 정리</p>
<p>이번에 PR을 올리던 중,
한 commit에 build 관련 변경과 feat 기능 구현이 섞여 있는 문제를 뒤늦게 발견했다.
이미 원격 저장소에 push까지 완료하고 PR까지 생성한 상태라 더 아쉬움이 컸다.</p>
<p>결국 팀장님께 상황을 설명드렸고,
PR은 Decline 처리한 뒤 작업을 다시 정리하기로 결정했다.</p>
<p>다행히 커밋 수가 많지 않아 수정하는 데 오래 걸리진 않았다.
가장 최근 feat commit을 기준으로:
    •    build 관련 파일
    •    feat 관련 파일</p>
<p>이렇게 나누어 커밋을 분리했고,
이 기회에 함께 기존 commit message도 전체적으로 정리했다.</p>
<p>정리된 상태로 다시 PR을 올렸고,
팀장님이 OK를 주셔서 develop 브랜치에 merge까지 완료!</p>
<p>git add -p 로 부분 스테이징하여 커밋 쪼개기</p>
<ol>
<li>변경확인</li>
</ol>
<pre><code class="language-kotlin">git status

git reset HEAD^

╭─ ~/Desktop/git/beaverpay-mobile-android/beaverpay-mobile-android feature/MTT-81-QR 
╰─❯ git reset HEAD^
리셋 뒤에 스테이징하지 않은 변경 사항:
M    app/src/main/java/com/bw/bp/navigation/AppNavGraph.kt
M    gradle/libs.versions.toml
M    presentation/src/main/java/com/bp/presentation/viewmodel/QRViewModel.kt
M    reader/build.gradle.kts
D    reader/src/main/java/com/bp/reader/ErrorCode.kt
M    ui/src/main/java/com/bp/ui/view/QRScreen.kt</code></pre>
<ol>
<li>부분적으로 스테이징 (patch 모드)</li>
</ol>
<pre><code class="language-kotlin">git add -p

작업 내용
╭─ ~/Desktop/git/beaverpay-mobile-android/beaverpay-mobile-android feature/MTT-81-QR ⇣1⇡1 !5
╰─❯ git add -p reader/build.gradle.kts gradle/libs.versions.toml
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 35b8d49..f00be0a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -25,6 +25,7 @@ navCompose = &quot;2.9.5&quot;
 # Navigation 적용 [-]

 # Room 적용 [+]
+playServicesCodeScanner = &quot;16.1.0&quot;
 roomKtx = &quot;2.8.4&quot;
 roomRuntime = &quot;2.8.4&quot;
 ksp = &quot;2.2.20-2.0.4&quot;
(1/2) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? y
@@ -96,6 +97,7 @@ androidx-navigation-compose = { module = &quot;androidx.navigation:navigation-compose
 # Retrfit 적용 [+]
 logging-interceptor = { module = &quot;com.squareup.okhttp3:logging-interceptor&quot;, version.ref = &quot;loggingInterceptor&quot; }
 okhttp = { module = &quot;com.squareup.okhttp3:okhttp&quot;, version.ref = &quot;okhttp&quot; }
+play-services-code-scanner = { module = &quot;com.google.android.gms:play-services-code-scanner&quot;, version.ref = &quot;playServicesCodeScanner&quot; }
 retrofit = { module = &quot;com.squareup.retrofit2:retrofit&quot;, version.ref = &quot;retrofit&quot; }
 # Retrfit 적용 [-]

(2/2) Stage this hunk [y,n,q,a,d,K,g,/,e,?]? y

diff --git a/reader/build.gradle.kts b/reader/build.gradle.kts
index 683db27..a34c763 100644
--- a/reader/build.gradle.kts
+++ b/reader/build.gradle.kts
@@ -19,6 +19,7 @@ dependencies {

     // ML Kit Barcode Scanning (권장)
     implementation(libs.barcode.scanning)
+    implementation(libs.play.services.code.scanner)

     //Camera
     implementation(libs.androidx.camera.core)
(1/1) Stage this hunk [y,n,q,a,d,e,?]? y

╭─ ~/Desktop/git/beaverpay-mobile-android/beaverpay-mobile-android feature/MTT-81-QR ⇣1⇡1 +2 !3
╰─❯ git commit -m &quot;build: QR Scanner lib 추가&quot;
[feature/MTT-81-QR b81a6dd] build: QR Scanner lib 추가
 2 files changed, 3 insertions(+)</code></pre>
<ol>
<li>이제 hunk 마다 질문받게 됨</li>
</ol>
<pre><code class="language-kotlin">Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]?</code></pre>
<table>
<thead>
<tr>
<th></th>
<th><strong>의미</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>y</strong></td>
<td>이 부분을 커밋에 포함</td>
</tr>
<tr>
<td><strong>n</strong></td>
<td>포함하지 않음 (다음 hunk로 넘어감)</td>
</tr>
<tr>
<td><strong>s</strong></td>
<td>hunk를 더 작은 조각으로 split</td>
</tr>
<tr>
<td><strong>e</strong></td>
<td>직접 편집</td>
</tr>
<tr>
<td><strong>q</strong></td>
<td></td>
</tr>
</tbody></table>
<ol>
<li>원하는 부분만 스테이징한 후</li>
</ol>
<pre><code class="language-kotlin">git commit -m &quot;first commit message&quot;</code></pre>
<ol>
<li>남은 변경 다시 분할해서 커밋</li>
</ol>
<pre><code class="language-kotlin">git add -p
git commit -m &quot;second commit message&quot;</code></pre>
<p>필요한 만큼 반복.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🚧 다시 Android 단독 프로젝트로 전환한 이유]]></title>
            <link>https://velog.io/@_suho_/%EB%8B%A4%EC%8B%9C-Android-%EB%8B%A8%EB%8F%85-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@_suho_/%EB%8B%A4%EC%8B%9C-Android-%EB%8B%A8%EB%8F%85-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Fri, 05 Dec 2025 05:59:47 GMT</pubDate>
            <description><![CDATA[<h3 id="2주간의-kmp-개발기--회고"><strong>2주간의 KMP 개발기 &amp; 회고</strong></h3>
<hr>
<h2 id="🎯-결론"><strong>🎯 결론</strong></h2>
<p>약 <strong>2주간</strong> Kotlin Multiplatform(KMP)을 적용하며 개발을 진행해봤지만,</p>
<p>여러 기술적 제약과 시간 압박으로 인해</p>
<p>👉 <strong>Android / iOS 분리 개발(2 Track)</strong> 전략으로 최종 결정했다.</p>
<p>KMP를 포기한 건 아니지만,</p>
<p><strong>지금 시점에서 KMP를 채택하는 것보다 일정 안정성이 더 중요</strong>하다는 판단이었고,</p>
<p>팀장님도 이를 이해해주셔서 과감히 방향을 바꿀 수 있었다.</p>
<hr>
<h2 id="🧩-kmp-진행-중-겪은-주요-이슈-정리"><strong>🧩 KMP 진행 중 겪은 주요 이슈 정리</strong></h2>
<h3 id="1️⃣"><strong>1️⃣</strong></h3>
<h3 id="제한된-라이브러리--잦은-breaking-changes"><strong>제한된 라이브러리 + 잦은 Breaking Changes</strong></h3>
<ul>
<li><p>KMP는 아직도 <strong>빠르게 진화 중</strong></p>
</li>
<li><p>JetBrains의 잦은 업데이트로 인해</p>
<p>  → 기존 라이브러리들과 <strong>버전 호환성 문제</strong> 및</p>
<p>  → 예상치 못한 운영 이슈가 발생할 가능성이 높다고 판단</p>
</li>
<li><p>Android 위주로만 한다면 괜찮지만</p>
<p>  <strong>정말로 cross-platform 개발을 하기엔 아직 리스크가 컸다.</strong></p>
</li>
</ul>
<hr>
<h3 id="2️⃣"><strong>2️⃣</strong></h3>
<h3 id="정형화된-구조expectactual--sqldelight"><strong>정형화된 구조(Expect/Actual &amp; SQLDelight)</strong></h3>
<p>KMP는 원하는 대로 “설계 자유도”가 높지 않다.</p>
<h3 id="🔹-예시-sqldelight"><strong>🔹 예시: SQLDelight</strong></h3>
<ul>
<li><p>DB 관련된 모든 코드를</p>
<p>  sqldelight 패키지 안에 넣어야 함</p>
</li>
<li><p>구조적 자유도가 낮고 모듈화가 제한됨</p>
</li>
</ul>
<h3 id="🔹-예시-expect--actual"><strong>🔹 예시: Expect / Actual</strong></h3>
<ul>
<li><p>commonMain/androidMain/iosMain</p>
<p>  동일 패키지, 동일 네이밍</p>
</li>
<li><p>정해진 룰을 반드시 지켜야 해서</p>
<p>  처음엔 상당히 헷갈리고 생산성이 떨어짐</p>
</li>
</ul>
<blockquote>
<p>⚠️ 개발할 때마다 “이건 어디에 넣어야 하지?” 라고 찾아야 하는 상황이 반복됨
→ 고민 시간 증가
→ 유지보수 난이도 증가</p>
</blockquote>
<hr>
<h3 id="3️⃣"><strong>3️⃣</strong></h3>
<h3 id="ios-빌드-문제"><strong>iOS 빌드 문제</strong></h3>
<p>KMP를 도입한 가장 큰 목적은 <strong>iOS 공용 코드 재사용</strong>이었는데…</p>
<ul>
<li>iOS에서 공통 모듈 함수가 인식이 안 되거나</li>
<li>빌드는 되지만 런타임에서 함수 이름 변경 문제 발생</li>
<li>KMP + Swift 연결 과정도 쉽지 않음</li>
</ul>
<blockquote>
<p>❗ 이슈 분석 시간이 길어지다 보니, 데드라인 안에 기능 개발을 끝낼 수 없다는 판단이 생겼다.</p>
</blockquote>
<hr>
<h2 id="🧠-최종-판단"><strong>🧠 최종 판단</strong></h2>
<p>이런 문제들을 매번 해결하면서 개발을 진행하기에는</p>
<ul>
<li>일정 압박</li>
<li>기능 요구 증가</li>
<li>KMP 경험 부족</li>
<li>실서비스 안정성</li>
</ul>
<p>이 네 가지 조건이 너무 부담이었다.</p>
<p>그래서 팀장님께 현재 상황을 솔직하게 공유했고,</p>
<p><strong>Android 우선 개발 → iOS는 뒤에서 별도로 진행</strong></p>
<p>이렇게 “Two Track 전략”으로 가기로 결정했다.</p>
<p>팀장님이 이해해주신 것도 너무 감사했고,</p>
<p>덕분에 과도한 리스크 없이 빠르게 방향을 잡을 수 있었다.</p>
<hr>
<h2 id="🔙-2주간의-회고"><strong>🔙 2주간의 회고</strong></h2>
<p>솔직히 말하면…</p>
<blockquote>
<p>“2주 동안 난 뭘 한 거지…?” 라는 생각이 잠깐 스쳤다. 하지만 곧 생각이 바뀌었다.</p>
</blockquote>
<h3 id="✔-kmp라는-기술을-직접-체감해봤고"><strong>✔ KMP라는 기술을 직접 체감해봤고</strong></h3>
<h3 id="✔-구조적-제약과-문제점을-몸으로-이해했고"><strong>✔ 구조적 제약과 문제점을 몸으로 이해했고</strong></h3>
<h3 id="✔-앞으로-kmp를-언제-적용해야-하는지-기준을-세웠고"><strong>✔ 앞으로 KMP를 언제 적용해야 하는지 기준을 세웠고</strong></h3>
<h3 id="✔-팀-방향성에-도움을-줄-판단을-내릴-수-있었다"><strong>✔ 팀 방향성에 도움을 줄 판단을 내릴 수 있었다</strong></h3>
<p>이건 결코 헛된 시간이 아니었다.</p>
<p>오히려 더 튼튼한 Android 구조를 만들 수 있을 것 같은 자신감이 생겼다.</p>
<hr>
<h2 id="🔥-앞으로의-목표"><strong>🔥 앞으로의 목표</strong></h2>
<p>Android 앱이 현재 회사의 핵심이다.</p>
<p>팀장님의 신뢰에 보답하기 위해</p>
<h3 id="→-더-완성도-높은-아키텍처"><strong>→ 더 완성도 높은 아키텍처</strong></h3>
<h3 id="→-더-안정적인-코드"><strong>→ 더 안정적인 코드</strong></h3>
<h3 id="→-더-빠른-기능-구현"><strong>→ 더 빠른 기능 구현</strong></h3>
<p>을 목표로 다시 Android 개발에 집중할 예정이다.</p>
<hr>
<h2 id="✨-마무리"><strong>✨ 마무리</strong></h2>
<p>돌고 돌아 다시 Android 단독 개발로 돌아왔지만,</p>
<p>지식도 늘었고, 실전 감각도 좋아졌고,</p>
<p>앞으로 KMP를 적용할 때 실패 없이 할 수 있는 경험을 쌓은 셈이다.</p>
<p>이제 다시 Android 코드 쓰러 간다.</p>
<p>🔥 “다시 화이팅!”</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DI] Hilt -> Koin Change]]></title>
            <link>https://velog.io/@_suho_/DI-Hilt-Koin-Change</link>
            <guid>https://velog.io/@_suho_/DI-Hilt-Koin-Change</guid>
            <pubDate>Sun, 30 Nov 2025 23:47:38 GMT</pubDate>
            <description><![CDATA[<h3 id="1-koin이-의존성-주입을-하는-방식">1. Koin이 의존성 주입을 하는 방식</h3>
<p>→ Koin = 런타임(Dynamic) DI 컨테이너</p>
<p>→ Hilt/Dagger = 컴파일 타임(Code-Generated) DI 컨테이너</p>
<p>→ 즉, Koin은 코드를 생성하지 않고, 앱 실행 중에 Service Locator 방식 + DSL 모듈 등록으로 DI를 수행함</p>
<h3 id="2-koin의-기본-원리">2. Koin의 기본 원리</h3>
<ol>
<li><p>Koin 시작</p>
<pre><code class="language-kotlin"> startKoin{
     androidContext(this@BeaverPayApp)
   modules(
       appModule,
       presentersModule,
       domainModule,
       dataModule,
       localModule,
       remoteModule,
       payonModule
   )
 }</code></pre>
<p> → 이렇게 하면 KoinApplication 객체가 생성되며 내부에 Instance Registry가 만들어짐</p>
</li>
</ol>
<ol>
<li><p>정의된 module 등록</p>
<pre><code class="language-kotlin"> val dataModule = module {
     includes(localModule) // local Module 포함
     single&lt;UserRepository&gt; {
         UserRepositoryImpl(get()) // get() -&gt; LocalUserDataSource
     }
     single&lt;PayonCardReaderRepository&gt; {
         PayonCardReaderRepositoryImpl(get())
     }
 }

 val domainModule = module {
     // UseCase
     factory { CheckUserUseCase(get()) }
     factory { InsertUserUseCase(get()) }
     factory { PayonCardReaderUseCase(get()) }
 }

 val presentersModule = module {
     viewModel { ReaderViewModel(get()) }
     viewModel { LoginViewModel(get(), get()) }
     viewModel { MainViewModel() }
 }</code></pre>
<p> → 모듈에 등록되면 Koin은 이를 Key-Value 형태로 저장함</p>
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>UserRepository</td>
<td>정의된 Provider 함수</td>
</tr>
<tr>
<td>ReaderViewModel</td>
<td>Provider 함수</td>
</tr>
</tbody></table>
</li>
<li><p>DI 발생 (get(), Inject(), viewModel() 호출 순간)</p>
<pre><code class="language-kotlin"> val repo: UserRepository = get()</code></pre>
<p> → 이 순간 Koin은 다음 절차를 수행</p>
<p> ex) </p>
<pre><code class="language-kotlin"> val dataModule = module {
         single&lt;UserRepository&gt; { UserRepositoryImpl(get()) }
 }

 /*
 * 1. single&lt;UserRepository&gt; → &quot;UserRepository 타입이 필요할 때, 이걸 써라&quot; 라는 정의
 * 2. {UserRepositoryImpl(get())} → &quot;생성 시 LocalUserDataSource 같은 의존성을 get() 해서 넣어라&quot;
 * 3. 즉, UserRepository가 필요한 클래스의 생성자에서 요청될 때,
 *    Koin이 알아서 UserRepositoryImpl 인스턴스를 만들어 넣습니다.
 * 4. class ReaderViewModel(private val userRepository: UserResposigory)을 호출할 때 
 *    Koin 내부에서 get()이 자동으로 실행되는 것이다.
 */</code></pre>
<p> → Koin의 DI 동작 흐름</p>
<pre><code class="language-kotlin"> get&lt;UserRepository&gt;() 요청
                 **↓**
 Koin Registry에 등록된 UserRepository Provider 찾기
                 **↓**
 싱글톤? → 이미 instance 있으면 반환
                 ↓
 없으면 Provider 함수 실행
                 ↓
 UserRepositoryImpl(get()) 실행
                 ↓
 내부 get() 으로 의존성 탐색 반복
                 ↓
 UserRepositoryImpl 인스턴스 생성
                 ↓
 Registry에 캐싱(싱글톤이면)
                 ↓
 결과 반환</code></pre>
<p> : 런타임에 필요한 객체를 모듈에서 찾아 직접 생성 → 반환하는 방식</p>
</li>
</ol>
<ol>
<li><p>Koin의 DI 특징</p>
<ul>
<li><p>런타임 해결</p>
<p>  → Koin은 실행 중에 객체 그래프를 계산함</p>
</li>
<li><p>코드 생성 없음</p>
<p>  → Hilt/Dagger처럼 코드가 자동 생성되지 않음.</p>
<p>  → 그래서 빌드가 빠르고 구조가 단순</p>
</li>
<li><p>DSL 중심</p>
<p>  → 모든 DI 설정은 module {} DSL로 구성</p>
</li>
</ul>
</li>
</ol>
<ol>
<li>Hilt와 비교하면?</li>
</ol>
<pre><code>| **항목** | **Koin** | **Hilt** |
| --- | --- | --- |
| DI 방식 | 런타임 | 컴파일 타임 |
| 성능 | 느릴 수 있음(런타임 분석) | 빠름(코드 생성) |
| 안정성 | 런타임 오류 ↑ | 컴파일 오류로 조기 발견 |
| 코드량 | 적음 | 많음(Annotation 필요) |
| KMP 지원 | 🌟 좋음 | 없음 |
| 멀티 모듈 | 쉬움 | 복잡함 |
| 학습 난이도 | 쉬움 | 어려움 |</code></pre><ol start="2">
<li><p>Compose에서 ViewModel 주입</p>
<ul>
<li><p>Navigaton 내 :</p>
<pre><code class="language-kotlin">  val viewModel: LoginViewModel = koinViewModel()</code></pre>
</li>
<li><p>뒤에서 Koin은 :</p>
<ul>
<li>현재 NavBackStackEntry Scope 확인</li>
<li>ViewModelFactory 생성</li>
<li>모듈에서 LoginViewModel 정의 찾음</li>
<li>필요 의존성 get() + 인스턴스 생성</li>
<li>Lifecycle에 바인딩</li>
</ul>
</li>
</ul>
</li>
<li><p>왜 Koin이 KMP에 적합한가?</p>
<ul>
<li><p>Koin:</p>
<ul>
<li>Kotlin Multiplatform 공식 지원</li>
<li>IOS에서도 Kotlin 객체를 주입 가능</li>
<li>Annotation Processor(KSP/KAPT) 필요 없음</li>
</ul>
</li>
<li><p>Hilt</p>
<ul>
<li>Android Studio용 → KMP 지원 불가능</li>
<li>코드 생성이 플랫폼 종속이라 IOS 미지원</li>
</ul>
<p>→ 결국 KMP 프로젝트는 Hilt 대신 Koin 사용이 정답.</p>
</li>
</ul>
</li>
</ol>
<h3 id="요약">요약</h3>
<p>→ Koin은 런타임 DI 컨테이너로, 등록된 module에서 객체를 찾아 직접 생성하여 의존성을 해결한다.</p>
<p>코드 생성 없이 DSL 기반이라 구조가 단순하고 KMP 지원이 매우 좋다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Hilt에서 Koin으로??]]></title>
            <link>https://velog.io/@_suho_/Hilt%EC%97%90%EC%84%9C-Koin%EC%9C%BC%EB%A1%9C</link>
            <guid>https://velog.io/@_suho_/Hilt%EC%97%90%EC%84%9C-Koin%EC%9C%BC%EB%A1%9C</guid>
            <pubDate>Fri, 28 Nov 2025 00:23:14 GMT</pubDate>
            <description><![CDATA[<h3 id="🚀-우리-팀의-기술-선택-여정"><strong>🚀 우리 팀의 기술 선택 여정</strong></h3>
<p>우리 팀은 Android와 iOS 두 플랫폼의 앱을 동시에 개발하는 것을 목표로 하고 있었다.
초기 계획은 Android는 Kotlin 기반의 <strong>네이티브 코드(Native Code)</strong> 로 개발하고,
iOS는 <strong>React Native</strong> 를 활용해 크로스 플랫폼으로 구현하는 방식이었다.</p>
<p>하지만 조사 과정에서 <strong>KMP(Kotlin Multiplatform)</strong> 과 <strong>CMP(Compose Multiplatform)</strong> 을 알게 되었다.
Kotlin과 Compose만으로 iOS까지 개발이 가능하다는 점은,
iOS 전문 인력이 없는 우리 팀에게 매우 매력적인 선택지였다.
새로운 프레임워크(RN)를 학습하는 대신, 이미 익숙한 Kotlin과 Compose를 그대로 사용할 수 있었기 때문이다.</p>
<p>이에 따라 팀은 <strong>KMP 기반 개발로 전환하기로 결정</strong>하였다.
그러나 예상치 못한 문제가 하나 있었다 —
지금까지 Android에서 의존성 주입(Dependency Injection)을 위해 사용하던 <strong>Hilt</strong>가
KMP 환경에서는 지원되지 않는다는 점이었다.</p>
<p>결국 우리는 <strong>Hilt → Koin</strong>으로의 전환을 결정하게 되었고,
이 과정을 통해 KMP 환경에 맞는 구조와 의존성 주입 방식으로 프로젝트를 새롭게 정비하게 되었다.</p>
<p><strong>❌ 왜 Hilt(=Dagger)는 KMP에서 쓸 수 없나?</strong></p>
<ol>
<li><p>Hilt/Dagger는 KSP/Annotation Processing 기반</p>
<p> → KMP의 commonMain은 annotation processor를 사용할 수 없음</p>
<p> → 즉, 공유 코드에서 Hilt 사용 자체가 불가능함</p>
</li>
<li><p>IOS에서는 Hilt가 동장할 수 없음</p>
<p> → Hilt는 안드로이드 프레임워크 의존</p>
<p> → IOS Swift에서는 Hilt 객체를 생성할 수 없음</p>
<p> → 그래서 KMP에서 IOS가 사용하는 DI 구조 자체가 깨짐</p>
</li>
</ol>
<p><strong>⭐️ KMP 공식 추천 DI = Koin</strong></p>
<ol>
<li><p>Koin이 KMP를 공식 지원하는 이유</p>
<p> → 공통 모듈(commonMain)에서 DI가 된다.</p>
<ul>
<li><p>UseCase, Repository, Network 모두 공유 가능</p>
</li>
<li><p>Android/IOS 양쪽에서 같은 DI 구성 사용</p>
<p>→ IOS에서도 Kotlin 객체를 DI로 받아 사용 가능</p>
</li>
<li><p>Swift에서 바로 Koin으로 resolve 가능</p>
<p>→ Annotation Processor 없음 (런타임 DI)</p>
</li>
<li><p>KMP 환경에서 가장 안정적으로 동작</p>
</li>
</ul>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Hilt 코드 추가/변경 및 구조 수정]]></title>
            <link>https://velog.io/@_suho_/Hilt-%EC%BD%94%EB%93%9C-%EC%B6%94%EA%B0%80%EB%B3%80%EA%B2%BD-%EB%B0%8F-%EA%B5%AC%EC%A1%B0-%EC%88%98%EC%A0%95</link>
            <guid>https://velog.io/@_suho_/Hilt-%EC%BD%94%EB%93%9C-%EC%B6%94%EA%B0%80%EB%B3%80%EA%B2%BD-%EB%B0%8F-%EA%B5%AC%EC%A1%B0-%EC%88%98%EC%A0%95</guid>
            <pubDate>Mon, 24 Nov 2025 00:20:34 GMT</pubDate>
            <description><![CDATA[<h3 id="변경-이유">변경 이유</h3>
<p>→ 같은 타입의 의존성이 2개 이상 있을 경우 Hilt가 자동으로 분류를 못 해준다. 그래서 Hilt에게 해당 부분은 이거야 라고 이름표를 붙여주는 것 “Qualifier” 입니다. 현재는 Payon부분만 구현되어있어 이러한 에러가 발생하지 않았지만 추후 결제 수단이 추가 될 경우 해당 부분은 필수로 작업해야한다.</p>
<ol>
<li>구조 변경</li>
</ol>
<pre><code class="language-kotlin">1. core:data ReaderStrategy.kt -&gt; core:domain ReaderStrategy.kt 코드 위치 변경
→ 인터페이스는 항상 상위 계층에 있어야 함
→ Clean Architecutre 원칙 : 상위 계층(domain)은 하위 계층(data, implementation)에 의존하지 않는다.
                                                     하위 계층이 상위 계청(interface)에 의존해야 한다.</code></pre>
<ol>
<li>코드 추가/변경</li>
</ol>
<pre><code class="language-kotlin">core:data:Qualifier.kt

/**
 * Qualifier(이름표) Annotation 만들기
 */

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class PayonReaderQualifier

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SamsungPayReaderQualifier</code></pre>
<pre><code class="language-kotlin">core:data:ReaderFactory.kt

class ReaderFactory @Inject constructor(
    @PayonReaderQualifier private val payonReader: ReaderStrategy,
    @SamsungPayRederQualifier private val samsungReader: ReaderStrategy
) {
    fun getReader(type: ReaderType): ReaderStrategy = when (type) {
        ReaderType.PAYON -&gt; payonReader
        ReaderType.SAMSUNG_PAY -&gt; samsungReader
    }
}</code></pre>
<pre><code class="language-kotlin">core:payon:ReaderBindModule.kt

@Module
@InstallIn(SingletonComponent::class)
abstract class ReaderBindModule {

    @Binds
    @PayonReaderQualifier
    abstract fun bindPayonReader(
        impl: PayonReader
    ): ReaderStrategy

}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Hilt는 어떻게 주입하고 동작할까??]]></title>
            <link>https://velog.io/@_suho_/Hilt%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%A3%BC%EC%9E%85%ED%95%98%EA%B3%A0-%EB%8F%99%EC%9E%91%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@_suho_/Hilt%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%A3%BC%EC%9E%85%ED%95%98%EA%B3%A0-%EB%8F%99%EC%9E%91%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Mon, 24 Nov 2025 00:20:18 GMT</pubDate>
            <description><![CDATA[<h3 id="코드를-작성하고-있는-중-hilt는-어떻게-동작을-하는지-갑자기-엄청난-궁금증이-생겼다-그래서-현재-코드를-가지고-어떻게-hilt가-동작하는지-알아보자">코드를 작성하고 있는 중 Hilt는 어떻게 동작을 하는지 갑자기 엄청난 궁금증이 생겼다. 그래서 현재 코드를 가지고 어떻게 Hilt가 동작하는지 알아보자.</h3>
<ol>
<li>전체 흐름 요약</li>
</ol>
<p><code>ViewModel → UseCase → Repository → ReaderFactory → ReaderStrategy(Payon or Samsung)</code></p>
<ol>
<li>UseCase</li>
</ol>
<pre><code class="language-kotlin">class CardReaderUseCase @Inject constructor(
    private val repository: CardReaderRepository
) {
    suspend operator fun invoke(type: ReaderType, tag: Tag): Flow&lt;CardResult&gt; {
        return repository.readCard(type, tag)
    }
}</code></pre>
<p>→ Hilt는 생성자에 있는 CardReadRepository를 자동으로 주입해 준다.</p>
<p>→ 여기서 Repository는 실제 구현체가 아닌 Interface Type</p>
<ol>
<li>Hilt가 Repository 구현체를 찾는 방법</li>
</ol>
<pre><code class="language-kotlin">@Module
@InstallIn(SingletonComponent::class)
object ReaderModule {

    @Provides
    @Singleton
    fun provideCardReaderRepository(
        dataSource: CardReaderDataSource
    ): CardReaderRepository = CardReaderRepositoryImpl(dataSource)

}</code></pre>
<blockquote>
<p>CardReaderRepository 타입이 필요하면, CardReaderRepositoryImpl(dataSource)를 생성해서 제공해</p>
</blockquote>
<ol>
<li>Repository → DataSource</li>
</ol>
<pre><code class="language-kotlin">class CardReaderRepositoryImpl @Inject constructor(
    private val dataSource: CardReaderDataSource
) : CardReaderRepository {
    override suspend fun readCard(type: ReaderType, tag: Tag): Flow&lt;CardResult&gt; {
        return dataSource.readCard(type, tag)
    }
}</code></pre>
<p>→ Hilt는 RepositoryImpl 객체를 만들 때 자동으로 DataSource를 넣어준다.</p>
<ol>
<li>DataSource → ReaderFactory</li>
</ol>
<pre><code class="language-kotlin">class CardReaderDataSource @Inject constructor(
    private val factory: ReaderFactory
) {
    suspend fun readCard(type: ReaderType, tag: Tag): Flow&lt;CardResult&gt; {
        val reader = factory.getReader(type)
        return reader.readCard(tag)
    }
}</code></pre>
<p>→ DataSource을 만들 때 Hilt는 ReaderFactory를 넣어준다.</p>
<ol>
<li>ReaderFactory → ReaderStrategy 구현체 선택</li>
</ol>
<pre><code class="language-kotlin">class ReaderFactory @Inject constructor(
    private val payonReader: ReaderStrategy,
    private val samsungReader: ReaderStrategy
) {
    fun getReader(type: ReaderType): ReaderStrategy = when (type) {
        ReaderType.PAYON -&gt; payonReader
        ReaderType.SAMSUNG_PAY -&gt; samsungReader
    }
}

interface ReaderStrategy {
    suspend fun readCard(tag: Tag): Flow&lt;CardResult&gt;
}</code></pre>
<aside>
💡

<p>Hilt는 어떻게 PayonReader와 SamsungReader를 구분할까?</p>
</aside>

<pre><code class="language-kotlin">class ReaderFactory @Inject constructor(
    @PayonReaderQualifier private val payonReader: ReaderStrategy,
    @SamsungReaderQualifier private val samsungReader: ReaderStrategy
)</code></pre>
<p>→ 보통 각각 @Named 또는 @Qualifier로 구분해야 한다.</p>
<p>→ 현재는 Payon만 있어서 오류가 나지 않지만 SamsungPay까지 있으면 에러가 발생할 것이다.</p>
<ol>
<li>ReaderStrategy(전략 객체) 실행</li>
</ol>
<pre><code class="language-kotlin">class PayonReader @Inject constructor(
    private val nfcAdapter: PayonNfcAdapter,
    private val api: RetrofitPayonApi
) : ReaderStrategy {

    override suspend fun readCard(tag: Tag): Flow&lt;CardResult&gt; = flow {
        ...
    }
}</code></pre>
<p>→ Hilt는 PayonReader를 만들 때 필요한 의존성을 넣어준다.</p>
<ol>
<li>최종 결과 Flow 반환</li>
</ol>
<p><code>ReaderStrategy → DataSource → Repository → UseCase → ViewModel → UI</code></p>
<h3 id="🧩-전체-di-구성도"><strong>🧩</strong> 전체 DI 구성도</h3>
<pre><code class="language-kotlin">ViewModel
    ↑
CardReaderUseCase  (Hilt inject)
    ↑
CardReaderRepository (Interface)
    ↑
CardReaderRepositoryImpl  (Hilt @Provides binds)
    ↑
CardReaderDataSource  (Hilt inject)
    ↑
ReaderFactory  (Hilt inject)
    ↑
ReaderStrategy 구현체 2개 (payonReader, samsungReader)</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Sample] 카드 인식 모듈화 작업(3)]]></title>
            <link>https://velog.io/@_suho_/Sample-%EC%B9%B4%EB%93%9C-%EC%9D%B8%EC%8B%9D-%EB%AA%A8%EB%93%88%ED%99%94-%EC%9E%91%EC%97%853</link>
            <guid>https://velog.io/@_suho_/Sample-%EC%B9%B4%EB%93%9C-%EC%9D%B8%EC%8B%9D-%EB%AA%A8%EB%93%88%ED%99%94-%EC%9E%91%EC%97%853</guid>
            <pubDate>Thu, 20 Nov 2025 23:12:09 GMT</pubDate>
            <description><![CDATA[<h3 id="payon-moduel-flow">Payon Moduel Flow</h3>
<pre><code class="language-kotlin">class PayonReader @Inject constructor(
    private val nfcAdapter: PayonNfcAdapter,
    private val payonApiService: RetrofitPayonApi
) : ReaderStrategy {

    companion object {
        // Payon SDK 접근
        const val clientType = &quot;****&quot;
        const val osVer = &quot;**
        const val osName = &quot;&quot;****&quot;
        const val appId = &quot;&quot;*********&quot;
        const val validKey = &quot;&quot;*************&quot;
        const val envCode = &quot;&quot;****&quot;
        const val deviceId = &quot;&quot;*************&quot;
    }

    override suspend fun readCard(tag: Tag): Flow&lt;CardResult&gt; = flow {

        emit(CardResult.Loading(true))

        val result = runCatching {

            // 인증 요청 + 실패 처리
            val auth = safeAuthRequest()

            // keySeed 생성 및 salt, vi, passphrase 생성
            TagCryptoConverter().extractCryptoParamsFromKeySeed(auth.key)

            // cid 추출
            val cid = nfcAdapter.getMifareCid(tag) ?: throw CardException.CidError(&quot;CID is null&quot;)

            // sector0 key 요청 데이터 만들기
            val sector0KeyRequestData = TagCryptoConverter().encryptMifareCid(cid)

            // sector0 key 요청 + 실패 처리
            val sector0Key = sector0KeyRequest(auth.trId ?: &quot;&quot;, sector0KeyRequestData)

            // certifyCode 추출
            val certifyCode = nfcAdapter.getCertifyCode(tag, sector0Key) ?: throw CardException.CertifyCodeError(&quot;CertifyCode is null&quot;)

            // sector12 key 요청 데이터 만들기
            val sector12KeyRequestData = TagCryptoConverter().encryptCertifyCode(sector0KeyRequestData.payonCard.chipSerialNumber, certifyCode)

            // sector12 key 요청 + 실패 처리
            val sector12Key = sector12KeyRequest(auth.trId ?: &quot;&quot;, sector12KeyRequestData)

            // encrypt card data 추출
            val encCardData = nfcAdapter.getEncCardData(tag, sector12Key) ?: throw CardException.CidError(&quot;Enc Card Data is null&quot;)

            CardResult.Success(CardData(encCardData,byteArrayOf(),byteArrayOf()))

        }.getOrElse { exception -&gt;
            handleCardException(exception)
        }

        emit(CardResult.Loading(false))
        emit(result)
    }

    /**
     * 인증 요청 (예외 → CardException.NetworkError)
     */
    private suspend fun safeAuthRequest(): PayonAuthResponse {

        val response = payonApiService.auth(
            clientType = clientType,
            osVer = osVer,
            osName = osName,
            appId = appId,
            validKey = validKey,
            envCode = envCode,
            deviceId = deviceId
        )

        if (!response.isSuccessful) {
            val error = parseError(response)
            throw CardException.NetworkError(
                code = error?.code ?: response.code().toString(),
                message = error?.message ?: &quot;Unknown server error&quot;
            )
        }

        return response.body() ?: throw CardException.AuthError(&quot;Auth body null&quot;)

    }

    /**
     * Sector0Key 요청 (예외 → CardException.NetworkError)
     */
    private suspend fun sector0KeyRequest(transactionId: String, sector0KeyRequestData: SectorKeyDataRequest): String {

        val response = payonApiService.getSector0Key(
            clientType = clientType,
            osVer = osVer,
            osName = osName,
            appId = appId,
            validKey = validKey,
            envCode = envCode,
            deviceId = deviceId,
            trId = transactionId,
            body = sector0KeyRequestData
        )

        if (!response.isSuccessful) {
            val error = parseError(response)
            throw CardException.NetworkError(
                code = error?.code ?: response.code().toString(),
                message = error?.message ?: &quot;Unknown server error&quot;
            )
        }

        return TagCryptoConverter().decryptSector0Key(response.body()?.payonCard?.sector0KeyA ?: &quot;&quot;)
    }

    /**
     * Sector0Key 요청 (예외 → CardException.NetworkError)
     */
    private suspend fun sector12KeyRequest(transactionId: String, sector12KeyRequestData: SectorKeyDataRequest): String {

        val response = payonApiService.getSector12Key(
            clientType = clientType,
            osVer = osVer,
            osName = osName,
            appId = appId,
            validKey = validKey,
            envCode = envCode,
            deviceId = deviceId,
            trId = transactionId,
            body = sector12KeyRequestData
        )

        if (!response.isSuccessful) {
            val error = parseError(response)
            throw CardException.NetworkError(
                code = error?.code ?: response.code().toString(),
                message = error?.message ?: &quot;Unknown server error&quot;
            )
        }

        return TagCryptoConverter().decryptSector12Key(response.body()?.payonCard?.sector12KeyA ?: &quot;&quot;)
    }

}</code></pre>
<pre><code class="language-kotlin">class PayonNfcAdapter @Inject constructor(
    private val context: Context
) {
    private val nfcAdapter: NfcAdapter = NfcAdapter.getDefaultAdapter(context)

    fun getMifareCid(tag: Tag): ByteArray? {

        val mifare = MifareClassic.get(tag)

        return try {
            if(!mifare.isConnected){
                mifare.connect()
            }
            var id = mifare.tag.id
            if(id.isEmpty()){
                id = mifare.readBlock(0)
            }
            id
        } catch (e: Exception) {
            Timber.d(&quot;getMifareCid Exception : $e&quot;)
            null
        } finally {
            try {
                mifare.close()
            } catch (e: Exception) {
                Timber.d(&quot;mifare close Exception : $e&quot;)
            }
        }

    }

    fun getCertifyCode(tag: Tag, sector0Key: String): ByteArray?{

        val mifare = MifareClassic.get(tag)

        return try {
            if(!mifare.isConnected){
                mifare.connect()
            }

            val authenticateSector0 = mifare.authenticateSectorWithKeyA(0, Util().hexToByte(sector0Key))
            Timber.d(&quot;authenticateSector0 : $authenticateSector0&quot;)
            var certifyCode: ByteArray? = null
            if(authenticateSector0){
                certifyCode = mifare.readBlock(2)
                Timber.d(&quot;certifyCode : $certifyCode&quot;)
            }
            certifyCode

        } catch (e: Exception) {
            Timber.d(&quot;getCertifyCode Exception : $e&quot;)
            null
        } finally {
            try {
                mifare.close()
            } catch (e: Exception) {
                Timber.d(&quot;mifare close Exception : $e&quot;)
            }
        }
    }

    fun getEncCardData(tag: Tag, sector12Key: String): ByteArray?{

        val mifare = MifareClassic.get(tag)

        return try {

            if(!mifare.isConnected){
                mifare.connect()
            }

            val authenticateSector12 = mifare.authenticateSectorWithKeyA(12, Util().hexToByte(sector12Key))
            Timber.d(&quot;authenticateSector12 : $authenticateSector12&quot;)

            var encCardData: ByteArray? = null
            if(authenticateSector12){
                encCardData = mifare.readBlock( 48)
                Timber.d(&quot;encCardData : $encCardData&quot;)
            }
            encCardData

        } catch (e: Exception) {
            Timber.d(&quot;getEncCardData Exception : $e&quot;)
            null
        } finally {
            try {
                mifare.close()
            } catch (e: Exception) {
                Timber.d(&quot;mifare close Exception : $e&quot;)
            }
        }
    }

}</code></pre>
<pre><code class="language-kotlin">class TagCryptoConverter {

    companion object {
        private var keySeed: ByteArray?= null
        private var passphrase: String?= null
        private var salt: ByteArray?= null
        private var iv: ByteArray?= null
    }

    fun extractCryptoParamsFromKeySeed(key: String?){
        try {
            keySeed = Base64.decode(key, Base64.NO_WRAP)
            keySeed?.let { seed -&gt;
                passphrase = CipherUtil.getPassphraseFromKeySeed(seed)
                salt = CipherUtil.getSaltFromKeySeed(seed)
                iv = CipherUtil.getIvFromKeySeed(seed)
            }
        } catch (e: Exception){
            CardException.TagCryptoError(
                message = &quot;extractCryptoParamsFromKeySeed Error : ${e.message}&quot;
            )
        }
    }

    fun encryptMifareCid(
        cid: ByteArray
    ): SectorKeyDataRequest {

        try {
            val encryptCid = CipherUtil.encrypt(Util().bytesToHex(cid).toByteArray(), passphrase, salt, iv)
            val base64Encoder = Base64.encodeToString(encryptCid, Base64.NO_WRAP)
            Timber.d(&quot;encryptMifareCid : $base64Encoder&quot;)

            return SectorKeyDataRequest(SectorPayonData(base64Encoder,null),&quot;K1&quot;)
        } catch (e: Exception){
            throw CardException.TagCryptoError(
                message = &quot;encryptMifareCid Error : ${e.message}&quot;
            )
        }
    }

    fun decryptSector0Key(
        encryptSector0Key: String
    ): String {
        try {
            val base64Sector0Key = Base64.decode(encryptSector0Key, Base64.NO_WRAP)
            val sector0Key = String(CipherUtil.decrypt(base64Sector0Key, passphrase!!, salt, iv), StandardCharsets.UTF_8)

            return sector0Key
        } catch (e: Exception){
            throw CardException.TagCryptoError(
                message = &quot;decryptSector0Key Error : ${e.message}&quot;
            )
        }
    }

    fun encryptCertifyCode(
        base64Cid: String,
        certifyCode: ByteArray
    ): SectorKeyDataRequest{
        try {
            val encryptCertifyCode = CipherUtil.encrypt(Util().bytesToHex(certifyCode).toByteArray(), passphrase!!, salt, iv)
            val base64Encoder = Base64.encodeToString(encryptCertifyCode, Base64.NO_WRAP)

            return SectorKeyDataRequest(SectorPayonData(base64Cid,base64Encoder),&quot;K1&quot;)
        } catch (e: Exception){
            throw CardException.TagCryptoError(
                message = &quot;encryptCertifyCode Error : ${e.message}&quot;
            )
        }
    }

    fun decryptSector12Key(
        encryptSector12Key: String
    ): String {
        try {
            val base64Sector12Key = Base64.decode(encryptSector12Key, Base64.NO_WRAP)
            val sector12Key = String(CipherUtil.decrypt(base64Sector12Key, passphrase!!, salt, iv), StandardCharsets.UTF_8)

            return sector12Key
        } catch (e: Exception){
            throw CardException.TagCryptoError(
                message = &quot;decryptSector0Key Error : ${e.message}&quot;
            )
        }
    }
}</code></pre>
<pre><code class="language-kotlin">/**
 * 공통 에러 처리 → CardResult.Error 변환
 */
fun handleCardException(exception: Throwable): CardResult.Error {
    return when (exception) {
        is CardException.NetworkError -&gt;
            CardResult.Error(
                code = exception.code,
                message = exception.message ?: &quot;Network error&quot;,
                throwable = exception
            )

        is CardException.AuthError -&gt;
            CardResult.Error(
                code = &quot;AUTH_ERROR&quot;,
                message = exception.message ?: &quot;Auth error&quot;,
                throwable = exception
            )

        is CardException.CidError -&gt;
            CardResult.Error(
                code = &quot;CID_ERROR&quot;,
                message = exception.message ?: &quot;CID read fail&quot;,
                throwable = exception
            )

        is CardException.CertifyCodeError -&gt;
            CardResult.Error(
                code = &quot;CERTIFY_CODE_ERROR&quot;,
                message = exception.message ?: &quot;Certify Code read fail&quot;,
                throwable = exception
            )

        is CardException.EncCardDataError -&gt;
            CardResult.Error(
                code = &quot;ENC_CARD_DATA_ERROR&quot;,
                message = exception.message ?: &quot;Enc Card Data read fail&quot;,
                throwable = exception
            )

        else -&gt;
            CardResult.Error(
                code = &quot;UNKNOWN&quot;,
                message = exception.message ?: &quot;Unknown error&quot;,
                throwable = exception
            )
    }
}

sealed class CardException(message: String) : Exception(message) {
    class NetworkError(val code: String, message: String) : CardException(message)
    class AuthError(message: String) : CardException(message)
    class CidError(message: String) : CardException(message)
    class CertifyCodeError(message: String) : CardException(message)
    class EncCardDataError(message: String) : CardException(message)
    class TagCryptoError(message: String) : CardException(message)
    class UnknownError(message: String) : CardException(message)
}</code></pre>
<ol>
<li>코드를 작성하면서 생각한 것 → 확장성을 생각한 공통 cardData, errorData 정의</li>
<li>중복코드 제거 목표</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Sample] 카드 인식 모듈화 작업(2)]]></title>
            <link>https://velog.io/@_suho_/Sample-%EC%B9%B4%EB%93%9C-%EC%9D%B8%EC%8B%9D-%EB%AA%A8%EB%93%88%ED%99%94-%EC%9E%91%EC%97%852</link>
            <guid>https://velog.io/@_suho_/Sample-%EC%B9%B4%EB%93%9C-%EC%9D%B8%EC%8B%9D-%EB%AA%A8%EB%93%88%ED%99%94-%EC%9E%91%EC%97%852</guid>
            <pubDate>Wed, 19 Nov 2025 22:51:43 GMT</pubDate>
            <description><![CDATA[<h3 id="payon-module-호출-전-flow">Payon Module 호출 전 Flow</h3>
<pre><code class="language-kotlin">feature/reader/viewmodel/ReaderViewModel.kt

 cardReaderUseCase(ReaderType.PAYON, tag).collect { result -&gt;
      when(result){
          is CardResult.Loading -&gt; {
              _uiEvent.emit(UiEvent.Loading(result.isLoading))
          }
          is CardResult.Success -&gt; {
              _uiEvent.emit(
                  UiEvent.ShowDialog(
                      &quot;카드인식 성공&quot;,
                      &quot;Card : ${Util().bytesToHex(result.data.track2Data)}&quot;
                  )
              )
          }
          is CardResult.Error -&gt; {
              _uiEvent.emit(UiEvent.ShowDialog(result.code, result.message))
          }
      }
  }</code></pre>
<pre><code class="language-kotlin">core/domain/usecase

class CardReaderUseCase @Inject constructor(
    private val repository: CardReaderRepository
) {
    suspend operator fun invoke(type: ReaderType, tag: Tag): Flow&lt;CardResult&gt; {
        return repository.readCard(type, tag)
    }
}</code></pre>
<pre><code class="language-kotlin">core/domain/repository

interface CardReaderRepository {
    suspend fun readCard(type: ReaderType, tag: Tag): Flow&lt;CardResult&gt;
}

enum class ReaderType {
    PAYON,
    SAMSUNG_PAY
}</code></pre>
<pre><code class="language-kotlin">core/data/repository

class CardReaderRepositoryImpl @Inject constructor(
    private val dataSource: CardReaderDataSource
) : CardReaderRepository {
    override suspend fun readCard(type: ReaderType, tag: Tag): Flow&lt;CardResult&gt; {
        return dataSource.readCard(type, tag)
    }
}</code></pre>
<pre><code class="language-kotlin">core/data/source

class CardReaderDataSource @Inject constructor(
    private val factory: ReaderFactory
) {
    suspend fun readCard(type: ReaderType, tag: Tag): Flow&lt;CardResult&gt; {
        val reader = factory.getReader(type)
        return reader.readCard(tag)
    }
}</code></pre>
<pre><code class="language-kotlin">core/data/source/reader

interface ReaderStrategy {
    suspend fun readCard(tag: Tag): Flow&lt;CardResult&gt;
}

class ReaderFactory @Inject constructor(
    private val payonReader: ReaderStrategy,
    private val samsungReader: ReaderStrategy
) {
    fun getReader(type: ReaderType): ReaderStrategy = when (type) {
        ReaderType.PAYON -&gt; payonReader
        ReaderType.SAMSUNG_PAY -&gt; samsungReader
    }
}</code></pre>
<pre><code class="language-kotlin">core/data/di

@Module
@InstallIn(SingletonComponent::class)
object ReaderModule {

    @Provides
    @Singleton
    fun provideCardReaderRepository(
        dataSource: CardReaderDataSource
    ): CardReaderRepository = CardReaderRepositoryImpl(dataSource)

}</code></pre>
<ol>
<li>payon 결제를 추가하면서 제일 먼저 생각한 것 → 확장성(추후 결제 로직)</li>
<li>흐름</li>
</ol>
<blockquote>
<p>viewModel → useCase 호출 → repository 호출 → dataSource 호출 → readerFactory에서 type에 따라 payon or samsungPay reader 선택</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Sample] 카드 인식 모듈화 작업(1)]]></title>
            <link>https://velog.io/@_suho_/Sample-%EC%B9%B4%EB%93%9C-%EC%9D%B8%EC%8B%9D-%EB%AA%A8%EB%93%88%ED%99%94-%EC%9E%91%EC%97%851</link>
            <guid>https://velog.io/@_suho_/Sample-%EC%B9%B4%EB%93%9C-%EC%9D%B8%EC%8B%9D-%EB%AA%A8%EB%93%88%ED%99%94-%EC%9E%91%EC%97%851</guid>
            <pubDate>Wed, 19 Nov 2025 01:04:29 GMT</pubDate>
            <description><![CDATA[<h3 id="차세대를-시작하기-전-clean-architecture-구조로-payon-인식-모듈화-작업-샘플-프로젝트-개발">차세대를 시작하기 전 Clean Architecture 구조로 payon 인식 모듈화 작업 샘플 프로젝트 개발</h3>
<ol>
<li>추가된 구조</li>
</ol>
<pre><code class="language-kotlin">추가된 구조
core/common
    - util/Util.kt (byte→String / String→byte 등 공통으로 쓰는 함수 정리)
    - NFCEventBus (NFC Tag 값 전달 → 다른 모듈에서 알 수 있는 방법 flow로 처리)
core/network
    → 전체적인 network 관리를 여기서 할 예정
core/payon
    → payon sector 접근 및 network통신 처리 (NFC를 읽는 기능은 MainActivity 공통으로 처리)
feature/reader
    → nfc 읽는 화면</code></pre>
<pre><code class="language-kotlin">@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    private val nfcAdapter by lazy { NfcAdapter.getDefaultAdapter(this) }
    val currentRouteFlow = MutableStateFlow&lt;String?&gt;(null)
    val currentReadTypeFlow = MutableStateFlow&lt;String?&gt;(null)

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

        setContent {
            val appState = rememberBeaverAppState()
            val navController = appState.navController
            BeaverPaySampleTheme {
                NavGraph(appState)
            }

            // NavRoute 변경 감지
            val navBackStack by navController.currentBackStackEntryAsState()

            LaunchedEffect(navBackStack) {
                val destination = navBackStack?.destination
                val args = navBackStack?.arguments

                val route = destination?.route
                val readType = args?.getString(&quot;readType&quot;)

                Timber.e(&quot;🔥 route changed = $route, readType = $readType&quot;)

                currentRouteFlow.value = route
                currentReadTypeFlow.value = readType
            }

        }

        // 📌 여기서 routeFlow 변화 감지하고 NFC on/off 처리
        lifecycleScope.launch {
            currentReadTypeFlow.collect { type -&gt;
                Timber.e(&quot;🔄 routeFlow collect → $type&quot;)

                if (type == &quot;payon&quot;) {
                    enableReaderMode()
                } else {
                    disableReaderMode()
                }
            }
        }
    }

    override fun onResume() {
        super.onResume()
    }

    override fun onPause() {
        super.onPause()
    }

    fun enableReaderMode() {
        Timber.e(&quot;🔵 enableReaderMode() 실행&quot;)
        nfcAdapter?.enableReaderMode(
            this,
            { tag -&gt;
                Timber.e(&quot;ReaderMode Tag detected: ${tag.id.joinToString { &quot;%02X&quot;.format(it) }}&quot;)
                NFCEventBus.onTagDetected(tag)
            },
            NfcAdapter.FLAG_READER_NFC_A or
                    NfcAdapter.FLAG_READER_NFC_B or
                    NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
            null
        )
    }

    fun disableReaderMode() {
        Timber.e(&quot;🟥 disableReaderMode() 실행&quot;)
        nfcAdapter?.disableReaderMode(this)
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)

        Timber.e(&quot;🔥 onNewIntent 호출, ACTION = ${intent.action}&quot;)

        if (intent.action == NfcAdapter.ACTION_TECH_DISCOVERED ||
            intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED ||
            intent.action == NfcAdapter.ACTION_TAG_DISCOVERED
        ) {
            val tag = if (Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.TIRAMISU) {
                intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
            } else {
                @Suppress(&quot;DEPRECATION&quot;)
                intent.getParcelableExtra&lt;Tag&gt;(NfcAdapter.EXTRA_TAG)
            }

            if (tag == null) {
                Timber.d(&quot;Tag is NULL&quot;)
                return
            }

            Timber.e(&quot;✅ Tag detected! techList = ${tag.techList.joinToString()}&quot;)

            NFCEventBus.onTagDetected(tag)
        }
    }
}</code></pre>
<pre><code class="language-kotlin">object NFCEventBus {
    private val _nfcFlow = MutableSharedFlow&lt;Tag&gt;()
    val nfcFlow = _nfcFlow

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    fun onTagDetected(tag: Tag) {
        scope.launch {
            _nfcFlow.emit(tag)
        }
    }
}</code></pre>
<pre><code class="language-kotlin">@HiltViewModel
class ReaderViewModel @Inject constructor(
    val cardReaderUseCase: CardReaderUseCase
) : ViewModel() {

    private val _tagInfo = MutableStateFlow(&quot;&quot;)
    val tagInfo = _tagInfo

    private val _uiEvent = MutableSharedFlow&lt;UiEvent&gt;()
    val uiEvent: MutableSharedFlow&lt;UiEvent&gt; = _uiEvent

    init {
        viewModelScope.launch {
            NFCEventBus.nfcFlow.collect { tag -&gt;
                val id = tag.id.joinToString(&quot;&quot;) { &quot;%02X&quot;.format(it) }
                _tagInfo.value = &quot;Tag ID: $id&quot;

                cardReaderUseCase(ReaderType.PAYON, tag).collect { result -&gt;
                    when(result){
                        is CardResult.Loading -&gt; {
                            _uiEvent.emit(UiEvent.Loading(result.isLoading))
                        }
                        is CardResult.Success -&gt; {
                            _uiEvent.emit(
                                UiEvent.ShowDialog(
                                    &quot;카드인식 성공&quot;,
                                    &quot;Card : ${Util().bytesToHex(result.data.track2Data)}&quot;
                                )
                            )
                        }
                        is CardResult.Error -&gt; {
                            _uiEvent.emit(UiEvent.ShowDialog(result.code, result.message))
                        }
                    }
                }

            }
        }
    }
}</code></pre>
<ol>
<li><p>Compose에서 RouteFlow 변화를 감지하여 NFC ON/OFF 처리 담당 </p>
<p> → 현재는 reader/payon 일 경우 enable, 나머지일 경우 disable</p>
</li>
<li><p>NFC 인식 시 NewIntent에서 Tag 값을 <code>*MutableSharedFlow*</code>로 가지고 있음</p>
</li>
<li><p>ViewModel에서 collect해서 Tag값을 feature/reader 모듈로 가져올 수 있음</p>
</li>
</ol>
<p><strong>🔥 emit vs collect</strong></p>
<table>
<thead>
<tr>
<th><strong>목</strong></th>
<th><strong>emit</strong></th>
<th><strong>collect</strong></th>
</tr>
</thead>
<tbody><tr>
<td>역할</td>
<td>Flow에 값 넣기</td>
<td>Flow로부터 값 받기</td>
</tr>
<tr>
<td>위치</td>
<td>보통 Activity, Repository, EventBus</td>
<td>보통 ViewModel, UI</td>
</tr>
<tr>
<td>종류</td>
<td>Producer</td>
<td>Consumer</td>
</tr>
<tr>
<td>실행 시기</td>
<td>이벤트가 발생할 때</td>
<td>Flow를 collect하는 동안 계속</td>
</tr>
<tr>
<td>suspend 여부</td>
<td>suspend (SharedFlow에서는 tryEmit 사용 가능)</td>
<td>suspend</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[Java Code Coverage 추가]]></title>
            <link>https://velog.io/@_suho_/Java-Code-Coverage-%EC%B6%94%EA%B0%80</link>
            <guid>https://velog.io/@_suho_/Java-Code-Coverage-%EC%B6%94%EA%B0%80</guid>
            <pubDate>Mon, 17 Nov 2025 00:15:56 GMT</pubDate>
            <description><![CDATA[<p>→ 테스트 코드가 실제로 애플리케이션 코드의 어느 부분까지 실행되었는지를 측정하는 도구</p>
<p>→ 즉, 테스트의 품질을 수치로 시각화 해주는 도구이다.</p>
<p><strong>🧩 Jacoco로 할 수 있는 것들</strong></p>
<table>
<thead>
<tr>
<th><strong>기능</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td>🧩 커버리지 측정</td>
<td>코드 실행 비율 계산</td>
</tr>
<tr>
<td>📊 리포트 생성</td>
<td>HTML / XML / CSV 결과물 생성</td>
</tr>
<tr>
<td>⚙️ 커버리지 기준선</td>
<td>80% 이하 빌드 실패 등 설정 가능</td>
</tr>
<tr>
<td>🤖 CI/CD 통합</td>
<td>Jenkins, GitHub, GitLab, SonarQube 연동</td>
</tr>
<tr>
<td>🧱 모듈별 리포트</td>
<td>각 계층 커버리지 따로 관리</td>
</tr>
<tr>
<td>🔍 누락 탐지</td>
<td>테스트되지 않은 코드 자동 표시</td>
</tr>
<tr>
<td>📈 추세 관리</td>
<td>버전별 커버리지 추적 및 비교</td>
</tr>
<tr>
<td>📱 Android 대응</td>
<td>Multi-module 환경에서도 사용 가능</td>
</tr>
</tbody></table>
<p><strong>🔍 왜 사용하나?</strong></p>
<table>
<thead>
<tr>
<th><strong>목적</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>1. 테스트 품질 측정</strong></td>
<td>단순히 테스트가 ‘존재한다’가 아니라, 실제 코드 중 어느 부분이 실행되는지를 확인할 수 있습니다.</td>
</tr>
<tr>
<td><strong>2. 미검증 코드 식별</strong></td>
<td>테스트가 닿지 않은 코드(예: 예외처리, 분기문 등)를 찾아내어 테스트 보완이 가능해집니다.</td>
</tr>
<tr>
<td><strong>3. 리팩토링 안정성 확보</strong></td>
<td>테스트 커버리지가 높을수록, 리팩토링 시 기존 기능이 깨지지 않았다는 자신감을 가질 수 있습니다.</td>
</tr>
<tr>
<td><strong>4. CI/CD 자동화에 활용</strong></td>
<td>GitHub Actions, Jenkins 같은 CI 툴과 연동해 “테스트 커버리지 80% 이상 유지” 같은 품질 기준을 자동으로 검증할 수 있습니다.</td>
</tr>
</tbody></table>
<p><strong>⚙️ 측정 방식</strong></p>
<table>
<thead>
<tr>
<th><strong>종류</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Line Coverage</strong></td>
<td>몇 개의 코드 줄이 테스트 중 실행되었는가</td>
</tr>
<tr>
<td><strong>Branch Coverage</strong></td>
<td>if/else, when 등의 분기문 중 몇 개가 테스트되었는가</td>
</tr>
<tr>
<td><strong>Method Coverage</strong></td>
<td>전체 메서드 중 몇 개가 호출되었는가</td>
</tr>
<tr>
<td><strong>Class Coverage</strong></td>
<td>전체 클래스 중 몇 개가 테스트 중 인스턴스화되었는가</td>
</tr>
</tbody></table>
<h3 id="jacoco-적용-코드">Jacoco 적용 코드</h3>
<p>JacocoPlugin.kt</p>
<pre><code class="language-kotlin">import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.register
import org.gradle.kotlin.dsl.withType
import org.gradle.testing.jacoco.plugins.JacocoPluginExtension
import org.gradle.testing.jacoco.tasks.JacocoReport

/*=======================================================================================
 *                                                                                      *
 *              Copyright(c) 2025, Beaverworks Inc., All rights reserved.               *
 *                                                                                      *
 *      Unauthorized copying of this file, via any medium is strictly prohibited        *
 *      Proprietary and confidential.                                                   *
 *                                                                                      *
========================================================================================*/

class JacocoPlugin : Plugin&lt;Project&gt; {
    override fun apply(target: Project) {
        target.plugins.apply(&quot;jacoco&quot;)

        target.extensions.configure&lt;JacocoPluginExtension&gt; {
            toolVersion = &quot;0.8.10&quot;
        }

        target.tasks.withType&lt;Test&gt;().configureEach {
            finalizedBy(&quot;jacocoTestReport&quot;)
        }

        target.tasks.register&lt;JacocoReport&gt;(&quot;jacocoTestReport&quot;) {
            dependsOn(target.tasks.withType&lt;Test&gt;())

            // ✅ 커버리지 제외 파일 패턴
            val fileFilter = listOf(
                &quot;**/R.class&quot;,
                &quot;**/R$*.class&quot;,
                &quot;**/BuildConfig.*&quot;,
                &quot;**/Manifest*.*&quot;,
                &quot;**/*Test*.*&quot;,
                &quot;android/**/*.*&quot;,
                &quot;**/*Module*.*&quot;,
                &quot;**/*Database*.*&quot;,
                &quot;**/*Dao*.*&quot;
            )

            // ✅ 클래스 파일 경로 (AGP 8 대응)
            val kotlinDebug = target.layout.buildDirectory.dir(&quot;tmp/kotlin-classes/debug&quot;).get().asFile
            val javacDebug = target.layout.buildDirectory.dir(&quot;intermediates/javac/debug/classes&quot;).get().asFile

            classDirectories.setFrom(
                target.files(
                    target.fileTree(kotlinDebug) { exclude(fileFilter) },
                    target.fileTree(javacDebug) { exclude(fileFilter) }
                )
            )

            // ✅ 소스 디렉토리
            sourceDirectories.setFrom(
                target.files(&quot;src/main/java&quot;, &quot;src/main/kotlin&quot;)
            )

            // ✅ 실행 데이터 (exec 파일 경로)
            executionData.setFrom(
                target.fileTree(target.layout.buildDirectory.asFile) {
                    include(
                        &quot;jacoco/testDebugUnitTest.exec&quot;,
                        &quot;outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec&quot;,
                        &quot;**/*.ec&quot;
                    )
                }
            )

            // ✅ 리포트 옵션
            reports {
                html.required.set(true)
                xml.required.set(true)
                csv.required.set(false)

                html.outputLocation.set(target.layout.buildDirectory.dir(&quot;reports/jacoco/html&quot;))
                xml.outputLocation.set(target.layout.buildDirectory.file(&quot;reports/jacoco/jacoco.xml&quot;))
            }

            // ✅ 파일 존재 여부 체크 (없을 경우 SKIP)
            onlyIf {
                val hasClass = classDirectories.files.any { it.exists() }
                val hasExec = executionData.files.any { it.exists() }
                if (!hasClass) println(&quot;⚠️ [JaCoCo] No class files found for ${target.path}&quot;)
                if (!hasExec) println(&quot;⚠️ [JaCoCo] No execution data found for ${target.path}&quot;)
                hasClass &amp;&amp; hasExec
            }

            doLast {
                println(&quot;✅ Jacoco report → ${target.layout.buildDirectory.dir(&quot;reports/jacoco/html&quot;).get().asFile}&quot;)
            }
        }
    }
}</code></pre>
<h3 id="동작-결과">동작 결과</h3>
<p><img src="https://velog.velcdn.com/images/_suho_/post/82a1945c-8950-4d6b-a315-cf65f7bcb8aa/image.png" alt="">
<img src="https://velog.velcdn.com/images/_suho_/post/b0a347d2-12be-4e70-9c9a-c03ba107d5f2/image.png" alt="">
<img src="https://velog.velcdn.com/images/_suho_/post/7e37ed3f-e2aa-4e0d-96ae-86bc0cf24ab1/image.png" alt="">
<img src="https://velog.velcdn.com/images/_suho_/post/b2ba135c-db9e-485b-aa0b-7cd3f33883d9/image.png" alt="">
<img src="https://velog.velcdn.com/images/_suho_/post/a62d3520-d7c1-4189-b58f-f1a952ccce55/image.png" alt=""></p>
<h3 id="🧩-요약--현재-커버리지-현황"><strong>🧩 요약 — 현재 커버리지 현황</strong></h3>
<table>
<thead>
<tr>
<th><strong>패키지</strong></th>
<th><strong>커버리지</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td>com.bw.core.data.local.dao</td>
<td>❌ <strong>0%</strong></td>
<td>DAO (Room 인터페이스) — 테스트 불가 / 불필요</td>
</tr>
<tr>
<td>com.bw.core.data.local.db</td>
<td>❌ <strong>0%</strong></td>
<td>Room Database — 직접 테스트할 필요 거의 없음</td>
</tr>
<tr>
<td>com.bw.core.data.di</td>
<td>❌ <strong>0%</strong></td>
<td>Hilt Module — 단위 테스트로 커버 불가능</td>
</tr>
<tr>
<td>com.bw.core.data.repository</td>
<td>✅ <strong>100%</strong></td>
<td>Repository 구현체 — 완벽히 테스트됨 👍</td>
</tr>
<tr>
<td>com.bw.core.data.local.entity</td>
<td>✅ <strong>100%</strong></td>
<td>Entity (데이터 클래스) — 모두 커버됨</td>
</tr>
<tr>
<td>com.bw.core.data.local.source</td>
<td>✅ <strong>100%</strong></td>
<td>Local DataSource — 모두 커버됨</td>
</tr>
<tr>
<td>com.bw.core.data.mapper</td>
<td>✅ <strong>100%</strong></td>
<td>Mapper — 모두 커버됨</td>
</tr>
</tbody></table>
<p><strong>🔍 2️⃣ 왜 0%가 나왔을까?</strong></p>
<table>
<thead>
<tr>
<th><strong>패키지</strong></th>
<th><strong>이유</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>dao</strong></td>
<td>Room DAO 인터페이스는 @Dao + @Query로만 구성되어 있어서 <strong>바이트코드 실행이 없어요</strong>. 즉, 테스트가 실행될 코드 자체가 없기 때문에 커버리지 0%.</td>
</tr>
<tr>
<td><strong>db</strong></td>
<td>UserDatabase는 @Database + abstract class 구조로 <strong>테스트 코드가 실제 실행되지 않음</strong>.</td>
</tr>
<tr>
<td><strong>di</strong></td>
<td>Hilt Module은 <strong>컴파일 타임에 Dagger가 생성</strong>하므로 런타임 실행 코드가 없음. 따라서 JaCoCo에서 커버 불가.</td>
</tr>
</tbody></table>
<p><strong>🧠 3️⃣ 진짜 의미 있는 커버리지</strong></p>
<table>
<thead>
<tr>
<th><strong>패키지</strong></th>
<th><strong>역할</strong></th>
<th><strong>상태</strong></th>
</tr>
</thead>
<tbody><tr>
<td>repository</td>
<td>비즈니스 로직 (UseCase에 가까운 영역)</td>
<td>완벽하게 테스트됨</td>
</tr>
<tr>
<td>local.source</td>
<td>데이터 소스 계층 (Room DAO 래핑)</td>
<td>테스트 정상 수행됨</td>
</tr>
<tr>
<td>mapper, entity</td>
<td>단순 데이터 변환 계층</td>
<td>테스트 혹은 실행 시 커버됨</td>
</tr>
</tbody></table>
<p>→ 즉, “앱 로직 핵심부는 모두 커버되고 있다”는 뜻이다.</p>
<p>→ dao/db/di 이 세 가지는 대부분의 프로젝트에서 <strong>커버리지 제외 대상(fileFilter)</strong> 으로 지정했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ 프로젝트 회고 및 다음 단계 준비 요약]]></title>
            <link>https://velog.io/@_suho_/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0-%EB%B0%8F-%EB%8B%A4%EC%9D%8C-%EB%8B%A8%EA%B3%84-%EC%A4%80%EB%B9%84-%EC%9A%94%EC%95%BD</link>
            <guid>https://velog.io/@_suho_/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0-%EB%B0%8F-%EB%8B%A4%EC%9D%8C-%EB%8B%A8%EA%B3%84-%EC%A4%80%EB%B9%84-%EC%9A%94%EC%95%BD</guid>
            <pubDate>Thu, 13 Nov 2025 02:19:01 GMT</pubDate>
            <description><![CDATA[<p>🧱 프로젝트 회고 및 다음 단계 준비 요약</p>
<p>🔹 현재 상황
    •    샘플 프로젝트의 구조 및 기능이 Clean Architecture 기반으로 안정화됨
    •    모듈화(core, feature, domain, data, testing)와 DI(Hilt), DB(Room) 적용 완료
    •    Test Code 적용 완료</p>
<p>🔹 남은 과제
    •    테스트 코드 작성이 아직 익숙하지 않음 → 추후 점진적으로 보완 예정
    •    기능별 시퀀스 다이어그램 작성을 통해 전체 데이터 및 흐름 정리 예정
    •    차세대 개발 준비 단계로, 설계 검증 및 보완 중</p>
<p>🔹 앞으로의 방향
    •    “지금은 기초를 단단히 쌓는 단계”
→ 이후 유지보수와 확장이 쉬운 구조를 위해, 설계 품질 확보에 집중
    •    모든 기능의 동작 플로우를 다이어그램으로 정리 후
→ 본격적인 기능 단위 개발(Feature 단위 진입) 예정
    •    실제 개발(코딩) 단계로 넘어가기 전 완벽한 기반 확보 목표</p>
<p>💪 마무리 다짐</p>
<p>“아직 테스트나 자동화는 어렵지만,
구조를 내 것으로 만들고 나면 분명히 편해질 거야.
지금은 단단한 기초를 쌓는 시간, 아자아자!”</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[테스트 코드 작성?!]]></title>
            <link>https://velog.io/@_suho_/%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%9E%91%EC%84%B1</link>
            <guid>https://velog.io/@_suho_/%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%9E%91%EC%84%B1</guid>
            <pubDate>Tue, 11 Nov 2025 02:15:55 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_suho_/post/745ba77a-09e2-4c9a-9726-6d3ef69636c0/image.png" alt=""></p>
<h3 id="1-ttdtest-driven-development-란">1. TTD(Test-Driven Development) 란?</h3>
<p>→ TDD는 켄트 벡(Kent Back) 이 고안한 소프트웨어 개발 방법론으로, “테스트 주도 개발” 이라는 이름 그대로 테스트를 먼저 작성하고 이를 기반으로 코드를 작성하는 방식이다. TDD는 다음과 같은 반복적인 과정을 통해 이루어진다.</p>
<ul>
<li>Red: 실패하는 테스트 작성<ul>
<li>먼저 작성한 테스트 케이스가 실패하도록 설계한다. 이는 구현되지 않은 기능을 명확히 정의하는 단계이다.</li>
</ul>
</li>
<li>Green: 테스트를 통과하는 최소한의 코드 작성<ul>
<li>테스트가 성공하도록 간단하게 코드를 구현한다. 이 단계에서는 최소한의 구현에 집중한다.</li>
</ul>
</li>
<li>Refactor: 코드 개선<ul>
<li>테스트가 통과한 후, 코드를 리팩터링하여 가동성과 유지보수성을 개선한다. 이때 테스트가 다시 실패하지 않도록 주의한다.</li>
</ul>
</li>
</ul>
<h3 id="2-tdd의-기대-효과">2. TDD의 기대 효과</h3>
<ul>
<li>버그 감소: 테세트를 기반으로 코드를 작성하기 때문에 코드의 안정성이 높아진다.</li>
<li>리팩터링 안정성: 코드 변경 시 테스트가 올바르게 동작하는지 보장</li>
<li>명확한 설계: 테스트 작성 과정에서 인터페이스와 로직이 자연스럽게 설계된다.</li>
</ul>
<h3 id="3-어디까지-테스트를-진행할-것인지">3. 어디까지 테스트를 진행할 것인지?</h3>
<ul>
<li>아직 정해지진 않았지만 우리팀은 비즈니스 로직까지만 테스트 코드를 진행하면 좋을 것 같다. </li>
</ul>
<h3 id="4-테스트-코트-작성-방식">4. 테스트 코트 작성 방식</h3>
<p>core:data:source / LocalUserDataSourceTest</p>
<pre><code class="language-kotlin">package com.bw.data.local.source

import com.bw.data.local.dao.FakeUserDao
import com.bw.core.data.local.db.UserDatabase
import com.bw.core.data.local.entity.UserEntity
import com.bw.core.data.local.source.LocalUserDataSource
import junit.framework.TestCase
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals

class LocalUserDataSourceTest {

    private lateinit var dataSource: LocalUserDataSource

    @Before
    fun setup(){
        // Room DB 대신 Fake DAO 사용
        val fakeDao = FakeUserDao()
        dataSource = LocalUserDataSource(fakeDao)
    }

    @Test
    fun insert_and_get_user() = runTest {
        val user = UserEntity(user_phone = &quot;01012345678&quot;, user_name = &quot;홍길동&quot;)
        dataSource.insertUser(user)

        val count = dataSource.getUserByPhoneAndName(&quot;01012345678&quot;, &quot;홍길동&quot;).first()
        TestCase.assertEquals(1, count)

        val users = dataSource.getAllUsers()

        // ✅ 성공 로그 출력
        println(&quot;✅ LocalUserDataSourceTest success → user not found, count: $count / users : ${users.toList()}&quot;)

    }

}</code></pre>
<p>core:data:source / UserRepositoryImplTest</p>
<pre><code class="language-kotlin">package com.bw.data.local.repository

import com.bw.core.data.repository.UserRepositoryImpl
import com.bw.core.domain.model.User
import com.bw.data.local.source.FakeLocalUserDataSource
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Test

class UserRepositoryImplTest {

    private val repository = UserRepositoryImpl(FakeLocalUserDataSource())

    @Test
    fun insert_and_fetch_user_success() = runTest {
        val user = User(phone = &quot;01012345678&quot;, name = &quot;홍길동&quot;)
        repository.insertUser(user)

        val count = repository.getUserByPhoneAndName(&quot;01012345678&quot;, &quot;홍길동&quot;).first()
        assertEquals(1, count)

        val users = repository.getAllUsers()

        // ✅ 성공 로그 출력
        println(&quot;✅ UserRepositoryImplTest success → user not found, count: $count / users : ${users.toList()}&quot;)
    }
}</code></pre>
<p>core:domain:usecase / CheckUserUseCaseTest</p>
<pre><code class="language-kotlin">package com.bw.domain.usecase

import com.bw.core.domain.model.User
import com.bw.core.domain.usecase.CheckUserUseCase
import com.bw.core.testing.repository.FakeUserRepository
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test

class CheckUserUseCaseTest {

    private lateinit var fakeUserRepository: FakeUserRepository
    private lateinit var checkUserUseCase: CheckUserUseCase

    @Before
    fun setup(){
        fakeUserRepository = FakeUserRepository()
        checkUserUseCase = CheckUserUseCase(fakeUserRepository)
    }

    // ✅ 성공 로그 출력
    @Test
    fun `when user exists then return count 1`() = runTest { // User가 존재할 경우 -&gt; 1을 반환
        // given (준비)
        val user = User(phone = &quot;01012345678&quot;, name = &quot;홍길동&quot;)
        fakeUserRepository.insertUser(user)

        // when (실행)
        val count = checkUserUseCase(&quot;01012345678&quot;, &quot;홍길동&quot;).first()

        // then (검증)
        assertEquals(1, count)
        println(&quot;✅ CheckUserUseCaseTest success → user count: $count&quot;)
    }

    @Test
    fun `when user does not exist then return count 0`() = runTest { // User가 존재하지 않을 경우 -&gt; 0을 반환
        // given
        fakeUserRepository.insertUser(User(phone = &quot;01099999999&quot;, name = &quot;이순신&quot;))

        // when
        val count = checkUserUseCase(&quot;01012345678&quot;, &quot;홍길동&quot;).first()

        // then
        assertEquals(0, count)
        println(&quot;✅ CheckUserUseCaseTest success → user not found, count: $count&quot;)
    }

}</code></pre>
<p>feature:login:ui / LoginViewModelTest</p>
<pre><code class="language-kotlin">package com.bw.feature.login.ui

import app.cash.turbine.test
import com.bw.core.domain.model.User
import com.bw.core.domain.usecase.CheckUserUseCase
import com.bw.core.domain.usecase.InsertUserUseCase
import com.bw.core.testing.repository.FakeUserRepository
import com.bw.core.ui.event.UiEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals

@OptIn(ExperimentalCoroutinesApi::class)
class LoginViewModelTest {

    private lateinit var viewModel: LoginViewModel
    private lateinit var fakeRepository: FakeUserRepository

    @Before
    fun setup() {

        fakeRepository = FakeUserRepository()

        val fakeCheckUserUseCase = CheckUserUseCase(fakeRepository)
        val fakeInsertUserUseCase = InsertUserUseCase(fakeRepository)

        viewModel = LoginViewModel(fakeInsertUserUseCase,fakeCheckUserUseCase)
    }

    @Test
    fun `전화번호 미입력시 토스트 이벤트 발생`() = runTest {
        viewModel.updatePhone(&quot;&quot;)
        viewModel.updateName(&quot;홍길동&quot;)

        viewModel.uiEvent.test {
            viewModel.signIn()
            val event = awaitItem() as UiEvent.ShowToast
            assertEquals(&quot;전화번호를 입력해주세요.&quot;, event.message)
            println(&quot;💡 event.message = $event&quot;)
            println(&quot;✅ 전화번호 미입력 테스트 성공&quot;)
        }
    }

    @Test
    fun `전화번호 자릿수 오류시 토스트 이벤트 발생`() = runTest {
        viewModel.updatePhone(&quot;0101234&quot;)
        viewModel.updateName(&quot;홍길동&quot;)

        viewModel.uiEvent.test {
            viewModel.signIn()
            val event = awaitItem() as UiEvent.ShowToast
            assertEquals(&quot;휴대폰 자릿수를 확인해주세요.&quot;, event.message)
            println(&quot;💡 event.message = $event&quot;)
            println(&quot;✅ 전화번호 자릿수 오류 테스트 성공&quot;)
        }
    }

    @Test
    fun `이름 미입력시 토스트 이벤트 발생`() = runTest {
        viewModel.updatePhone(&quot;01012345678&quot;)
        viewModel.updateName(&quot;&quot;)

        viewModel.uiEvent.test {
            viewModel.signIn()
            val event = awaitItem() as UiEvent.ShowToast
            assertEquals(&quot;이름을 입력해주세요.&quot;, event.message)
            println(&quot;💡 event.message = $event&quot;)
            println(&quot;✅ 이름 미입력 테스트 성공&quot;)
        }
    }

    @Test
    fun `존재하지 않는 유저면 다이얼로그 이벤트 발생`() = runTest {
        viewModel.updatePhone(&quot;01000000000&quot;)
        viewModel.updateName(&quot;없는사람&quot;)

        viewModel.uiEvent.test {
            viewModel.signIn()
            val event = awaitItem() as UiEvent.ShowDialog
            assertEquals(&quot;로그인 실패&quot;, event.title)
            println(&quot;💡 event.message = $event&quot;)
            println(&quot;✅ 존재하지 않는 유저 테스트 성공: ${event.message}&quot;)
        }
    }

    @Test
    fun `존재하는 유저면 Navigate 이벤트 발생`() = runTest {
        fakeRepository.insertUser(User(0,&quot;01012345678&quot;, &quot;홍길동&quot;))
        viewModel.updatePhone(&quot;01012345678&quot;)
        viewModel.updateName(&quot;홍길동&quot;)

        viewModel.uiEvent.test {
            viewModel.signIn()
            val event = awaitItem()
            assert(event is UiEvent.Navigate)
            println(&quot;💡 event.message = $event&quot;)
            println(&quot;✅ 로그인 성공 테스트 성공 (Navigate 이벤트 발생)&quot;)
        }
    }

}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Convention Plugin 작업 ]]></title>
            <link>https://velog.io/@_suho_/Convention-Plugin-%EC%9E%91%EC%97%85</link>
            <guid>https://velog.io/@_suho_/Convention-Plugin-%EC%9E%91%EC%97%85</guid>
            <pubDate>Wed, 05 Nov 2025 00:38:32 GMT</pubDate>
            <description><![CDATA[<h3 id="convetion-plugin을-도입하게-된-이유">Convetion Plugin을 도입하게 된 이유</h3>
<p>샘플 프로젝트에서 모듈화를 진행하면서 모듈 수가 8개로 늘어났습니다.
각 모듈마다 build.gradle.kts 파일을 따로 관리해야 했는데,
이 파일들을 각각 수정하고 버전을 맞추는 과정이 점점 번거로워졌습니다.</p>
<p>특히, 공통 라이브러리 의존성을 모든 모듈에 중복으로 작성해야 한다는 점이 가장 큰 문제였습니다.
어떤 모듈에 어떤 라이브러리가 포함되어 있는지 한눈에 파악하기 어렵고,
버전이 달라지는 경우 모든 파일을 일일이 수정해야 하는 비효율이 발생했습니다.</p>
<p>이러한 문제를 해결하기 위해 공통 설정을 한 곳에서 집중적으로 관리하고,
모듈에서는 필요한 플러그인만 간단히 선언하도록 하기 위해
Gradle Convention Plugin을 도입하게 되었습니다.</p>
<ol>
<li>build-logic/build.gradle.kts<pre><code>plugins {
 `kotlin-dsl`   // ✅ 반드시 필요
 kotlin(&quot;jvm&quot;) version &quot;1.9.10&quot;              // Kotlin JVM 플러그인 (버전은 프로젝트에 맞게 지정)
 id(&quot;java-gradle-plugin&quot;)                   // Gradle 플러그인 개발 플러그인
}
</code></pre></li>
</ol>
<p>dependencies {
    implementation(gradleApi())     // ✅ Gradle의 Project, Plugin, Extension 등 인식 가능
    implementation(localGroovy())   // ✅ Groovy DSL 접근용</p>
<pre><code>compileOnly(libs.android.gradlePlugin)     // Android Gradle Plugin API (AGP) 의존성
compileOnly(libs.kotlin.gradlePlugin)      // Kotlin Gradle Plugin API 의존성

implementation(libs.hilt.gradlePlugin)        // Hilt Gradle Plugin API 의존성 (필요 시)
implementation(libs.ksp.gradlePlugin)         // KSP Gradle Plugin API 의존성 (필요 시)</code></pre><p>}</p>
<p>gradlePlugin {
    plugins {
        register(&quot;androidApplication&quot;) {
            id = &quot;beaver.android.application&quot;
            implementationClass = &quot;AndroidApplicationConventionPlugin&quot;
        }
        register(&quot;androidLibrary&quot;) {
            id = &quot;beaver.android.library&quot;
            implementationClass = &quot;AndroidLibraryConventionPlugin&quot;
        }
        register(&quot;androidApplicationCompose&quot;) {
            id = &quot;beaver.android.application.compose&quot;
            implementationClass = &quot;AndroidApplicationComposeConventionPlugin&quot;
        }
        register(&quot;androidLibraryCompose&quot;) {
            id = &quot;beaver.android.library.compose&quot;
            implementationClass = &quot;AndroidLibraryComposeConventionPlugin&quot;
        }
        register(&quot;hilt&quot;) {
            id = &quot;beaver.android.hilt&quot;
            implementationClass = &quot;HiltConventionPlugin&quot;
        }
        register(&quot;room&quot;) {
            id = &quot;beaver.android.room&quot;
            implementationClass = &quot;RoomConventionPlugin&quot;
        }
    }
}</p>
<p>```</p>
<p><img src="https://velog.velcdn.com/images/_suho_/post/1c9f7b6a-85ca-410d-a1dd-801933d1656d/image.png" alt=""></p>
<p>요즘은 하루하루 시간이 잘 가는 것 같다. 내가 적용하고 싶었던 기술들을 도입하면서 거기서 더 필요한 기능들을 찾게 되고 그것들을 배워가는 것이 너무 즐겁다.</p>
<p>이제 마지막 테스트 코드 작성을 하면 본 차세대 프로젝트를 시작하기 위한 준비는 끝이다!</p>
<p>남은 준비까지 화이팅!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Project 모듈화 작업 1단계 완료?!]]></title>
            <link>https://velog.io/@_suho_/Project-%EB%AA%A8%EB%93%88%ED%99%94-%EC%9E%91%EC%97%85-1%EB%8B%A8%EA%B3%84-%EC%99%84%EB%A3%8C</link>
            <guid>https://velog.io/@_suho_/Project-%EB%AA%A8%EB%93%88%ED%99%94-%EC%9E%91%EC%97%85-1%EB%8B%A8%EA%B3%84-%EC%99%84%EB%A3%8C</guid>
            <pubDate>Mon, 03 Nov 2025 09:16:05 GMT</pubDate>
            <description><![CDATA[<h3 id="proejct-모듈화-작업-1단계-완료">Proejct 모듈화 작업 1단계 완료!</h3>
<p>샘플 프로젝트를 모듈 단위로 분리하는 작업을 드디어 완료했다.
작업을 진행할 때는 Google의 Now in Android 프로젝트를 참고했으며,
Git에 정리된 모듈화 관련 문서들도 계속 참고하면서 진행했다.
중간중간 ChatGPT에도 여러 번 물어보며 방향을 잡았다.</p>
<p>작업을 하면서 정말 많은 고민을 했다.
어떤 파일을 공통 모듈로 둘지, DI는 어디에 두는 게 맞을지,
그리고 각 파일이 어떤 레이어에 속해야 할지 등 세세한 부분 하나하나가 쉽지 않았다.</p>
<p>지금은 기본적인 모듈 구조를 완성했지만,
아직도 내가 설계한 구조가 완전히 올바른지 확신은 없다.
앞으로도 계속 고민하고, 필요할 때마다 구조를 다듬어가며 지속적으로 업데이트할 예정이다.</p>
<h3 id="간단한-설명">간단한 설명</h3>
<p><img src="https://velog.velcdn.com/images/_suho_/post/cdb1796f-9169-4da0-b22e-1b1d3a7ed618/image.png" alt=""></p>
<h3 id="모듈화-후-project-structure">모듈화 후 Project Structure</h3>
<p><img src="https://velog.velcdn.com/images/_suho_/post/4cde103e-a60e-46ab-83d9-1551a1403453/image.png" alt="">
<img src="https://velog.velcdn.com/images/_suho_/post/6b55bfe9-8ab0-4880-96c5-8c2888d871c2/image.png" alt="">
<img src="https://velog.velcdn.com/images/_suho_/post/5d958ab3-ef64-460f-b89a-232e9427a8ac/image.png" alt="">
<img src="https://velog.velcdn.com/images/_suho_/post/bd678426-b3c3-42f3-960b-055138832779/image.png" alt="">
<img src="https://velog.velcdn.com/images/_suho_/post/4d544868-c771-41dc-81b1-c4f80d0cf2c0/image.png" alt="">
<img src="https://velog.velcdn.com/images/_suho_/post/a6302431-1a70-4055-a954-19bb67962e83/image.png" alt="">
<img src="https://velog.velcdn.com/images/_suho_/post/1154fc1d-0519-405f-8f61-01a747b7c5d7/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[내가 만든 Sample Project는 Single Module Project?]]></title>
            <link>https://velog.io/@_suho_/%EB%82%B4%EA%B0%80-%EB%A7%8C%EB%93%A0-Sample-Project%EB%8A%94-Single-Module-Project</link>
            <guid>https://velog.io/@_suho_/%EB%82%B4%EA%B0%80-%EB%A7%8C%EB%93%A0-Sample-Project%EB%8A%94-Single-Module-Project</guid>
            <pubDate>Thu, 30 Oct 2025 23:08:51 GMT</pubDate>
            <description><![CDATA[<h3 id="간단한-login-sample-project-structure">간단한 Login sample project structure</h3>
<p><img src="https://velog.velcdn.com/images/_suho_/post/e94683e9-aa97-4a0e-89e7-cd7cb849dc36/image.png" alt=""></p>
<p>차세대 프로젝트를 시작하기 전에 프로젝트의 뼈대를 잡는 샘플 프로젝트 개발을 맡게 되었다.</p>
<p>나는 나름대로 아키텍처 원칙을 지키며 레이어를 나누고 모듈화를 고려해 개발했다고 생각했다.
구조를 설계하면서 꽤 오랜 시간 고민도 했고, 각 역할에 맞게 분리했다고 자부했다.</p>
<p>하지만 팀장님이 생각하는 “모듈화”와 내가 생각했던 “모듈화”는 완전히 다른 방향이었다.
ChatGPT에게 물어보면서도, 내가 만든 프로젝트가 사실상 Single Module Project였다는 걸 깨닫게 되었다..ㅠㅠ</p>
<p>즉, 아키텍처는 잘 지켰지만 물리적인 모듈 분리가 전혀 이루어지지 않은 상태였던 것이다.
그래서 이제는 이 프로젝트를 진짜 모듈화된 구조로 재설계하기로 마음먹었다.</p>
]]></description>
        </item>
    </channel>
</rss>