<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>woo.log</title>
        <link>https://velog.io/</link>
        <description>어제보다 나은 개발자가 되자</description>
        <lastBuildDate>Sat, 05 Sep 2026 05:16:02 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>woo.log</title>
            <url>https://velog.velcdn.com/images/dev_hyunwoo/profile/f8c4851d-0cf3-4528-8500-96ce1d46e7ba/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. woo.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dev_hyunwoo" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[KMP] Maestro로 iOS/Android UI 테스트를 스크립트 한 벌로 끝내기]]></title>
            <link>https://velog.io/@dev_hyunwoo/KMP-Maestro%EB%A1%9C-iOSAndroid-UI-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%ED%95%9C-%EB%B2%8C%EB%A1%9C-%EB%81%9D%EB%82%B4%EA%B8%B0</link>
            <guid>https://velog.io/@dev_hyunwoo/KMP-Maestro%EB%A1%9C-iOSAndroid-UI-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%ED%95%9C-%EB%B2%8C%EB%A1%9C-%EB%81%9D%EB%82%B4%EA%B8%B0</guid>
            <pubDate>Sat, 05 Sep 2026 05:16:02 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/bb3538d4-d4b2-410f-9b28-dd0502803f16/image.png" alt=""></p>
<hr>
<h1 id="kmp-maestro로-iosandroid-ui-테스트를-스크립트-한-벌로-끝내기">[KMP] Maestro로 iOS/Android UI 테스트를 스크립트 한 벌로 끝내기</h1>
<h2 id="들어가며-🙋🏻♂️">들어가며 🙋🏻‍♂️</h2>
<p>KMP로 앱을 개발하면서 제일 좋았던 건 역시 &quot;코드 한 벌로 iOS/Android 둘 다&quot;였다.
그런데 코드가 한 벌이면 뭐하나... <strong>QA는 두 벌</strong>이었다.</p>
<p>배포 전마다 하는 스모크 테스트가 대충 이랬다.</p>
<ul>
<li>Android 폰 들고 로그인 → 홈 → 리스트 탐색 → 핵심 기능 → 채팅 한 바퀴</li>
<li>iOS 시뮬레이터 켜서 <strong>똑같은 거 한 바퀴 더</strong></li>
</ul>
<p>게다가 우리 서비스는 유저 타입이 여러 개로 나뉘어 있어서, 한 바퀴가 사실 세 바퀴다. 그걸 플랫폼별로 두 번씩. 기능 하나 고칠 때마다 이 짓을 반복하다 보니 어느 순간 현타가 왔다. 🤦🏻‍♂️</p>
<p>UI 코드는 Compose Multiplatform이라 100% 공유하는데, 왜 검증은 내 손가락이 여섯 바퀴를 돌고 있지?</p>
<p>그래서 도입한 게 <strong>Maestro</strong>다. 그리고 여기에 <strong>Claude(+ Figma MCP)</strong> 를 얹었더니 생각보다 훨씬 재밌는 물건이 됐다.</p>
<h2 id="maestro가-뭔데-🔍">Maestro가 뭔데? 🔍</h2>
<p><a href="https://maestro.mobile.dev/">Maestro</a>는 모바일 UI 테스트 프레임워크다.
테스트를 코드가 아니라 <strong>yaml 파일</strong>로 작성하는 게 특징이다. 실제로 쓰고 있는 스모크 테스트가 이렇게 생겼다.</p>
<pre><code class="language-yaml">appId: com.example.app.dev
---
# 스모크 테스트: 비로그인 상태에서 앱이 뜨고 로그인 화면이 보이는지 확인
- launchApp:
    clearState: true
    permissions:
      all: allow
- tapOn:
    text: &quot;허용&quot;
    optional: true
- extendedWaitUntil:
    visible: &quot;이메일로 시작하기&quot;
    timeout: 20000
- takeScreenshot: smoke-login</code></pre>
<p>이게 끝이다. 진짜 이게 끝이다.</p>
<p>기존 선택지들과 비교하면 이렇다.</p>
<table>
<thead>
<tr>
<th></th>
<th>플랫폼</th>
<th>작성 언어</th>
<th>러닝커브</th>
</tr>
</thead>
<tbody><tr>
<td>Espresso</td>
<td>Android 전용</td>
<td>Kotlin</td>
<td>중</td>
</tr>
<tr>
<td>XCUITest</td>
<td>iOS 전용</td>
<td>Swift</td>
<td>중</td>
</tr>
<tr>
<td>Appium</td>
<td>둘 다</td>
<td>아무거나</td>
<td><strong>높음</strong> (환경 세팅부터 고통)</td>
</tr>
<tr>
<td>Maestro</td>
<td>둘 다</td>
<td>yaml</td>
<td>낮음</td>
</tr>
</tbody></table>
<p>Espresso + XCUITest 조합은 같은 시나리오를 두 언어로 두 번 짜야 해서 탈락. Appium은 세팅 난이도로 탈락. 😇
Maestro는 CLI 하나 깔면 끝이고, 접근성 트리 기반이라 화면에 보이는 <strong>텍스트로 요소를 찾는다</strong>. 셀렉터 찾을 땐 <code>maestro studio</code> 열어서 눌러보면 된다.</p>
<h2 id="근데-이게-kmp랑-만나면-진짜-물건이다-✨">근데 이게 KMP랑 만나면 진짜 물건이다 ✨</h2>
<p>Maestro는 화면의 텍스트/contentDescription으로 동작하는데, KMP의 특성을 생각해보자.</p>
<ul>
<li>UI가 commonMain에 있으니 <strong>iOS든 Android든 화면이 완전히 똑같다</strong></li>
<li>버튼 문구도, 화면 순서도, contentDescription도 똑같다</li>
<li>우리는 appId까지 양 플랫폼 동일하게 맞춰놨다</li>
</ul>
<p>즉, <strong>yaml 플로우 파일 하나가 두 플랫폼에서 그대로 돈다.</strong></p>
<pre><code class="language-sh"># Android 실물폰에서
./.maestro/run_all.sh &lt;adb serial&gt;

# iOS 시뮬레이터에서 — 스크립트도, 플로우도 전부 그대로
./.maestro/run_all.sh &lt;시뮬레이터 UDID&gt;</code></pre>
<p>기기 ID만 바꾸면 끝. 네이티브를 따로 개발하는 팀이라면 &quot;iOS는 버튼 문구가 미묘하게 다른데...&quot; 같은 분기가 생기기 마련인데, KMP는 같은 Composable이 그리는 화면이라 구조적으로 그럴 일이 없다.</p>
<p>물론 플랫폼 차이가 아예 없진 않다. 그런 부분만 Maestro의 <code>when: platform</code> 조건으로 처리한다. 실제 공용 로그인 플로우의 한 조각이다.</p>
<pre><code class="language-yaml"># hideKeyboard 는 iOS 에서 불안정 — Android 에서만 수행
- runFlow:
    when:
      platform: Android
    commands:
      - hideKeyboard
# iOS 는 알림 권한 다이얼로그가 런타임에 뜨므로 허용 탭
- runFlow:
    when:
      platform: iOS
    commands:
      - tapOn:
          text: &quot;허용&quot;
          optional: true</code></pre>
<p>플로우 파일 60개 중에 플랫폼 분기는 이런 시스템 다이얼로그/키보드 정도가 전부다.</p>
<h2 id="플로우는-유저-타입별-×-피쳐별로-쪼갰다-📂">플로우는 유저 타입별 × 피쳐별로 쪼갰다 📂</h2>
<p>플로우가 늘어나면서 파일 구조를 잡았는데, 서비스의 유저 타입을 최상위로, 그 아래를 피쳐 단위로 나눴다. 대충 이런 모양이다.</p>
<pre><code>.maestro/
├── run_all.sh              # 읽기(탐색) 스위트 — 데이터 변경 없음
├── run_write.sh            # 쓰기 시나리오 — 테스트 서버 데이터 변경됨!
├── smoke.yaml              # 비로그인 스모크
├── common/                 # 공용 서브플로우 (로그인, 탭 순회 등)
├── user_a/                 # 유저 타입 A: 탐색/신청/작성/마이페이지 ...
├── user_b/                 # 유저 타입 B: 검색/제안/검토/결제 직전 ...
└── user_c/                 # 유저 타입 C: 콘텐츠 등록 (유형별로 파일 분리)</code></pre><p>이렇게 나누니 좋은 점:</p>
<ul>
<li><strong>읽기/쓰기 스위트 분리</strong> — 탐색만 하는 플로우는 언제든 돌려도 안전하고, 신청/등록처럼 테스트 서버 데이터를 건드리는 플로우는 <code>run_write.sh</code>로 격리</li>
<li><strong>공용 플로우 재사용</strong> — 로그인은 <code>runFlow: common/login.yaml</code> 한 줄로 모든 플로우에서 재사용</li>
<li><strong>yaml이 곧 QA 시나리오 문서</strong> — 파일명과 스텝만 읽어도 시나리오가 그대로 보여서, 비개발자도 읽을 수 있다</li>
</ul>
<p>깨알 팁 하나: 매 플로우마다 정식 로그인을 태우면 느리고 잘 깨진다. 그래서 디버그 빌드에만 <strong>숨은 디버그 로그인</strong>을 뚫어놨다 — 로그인 화면에서 <strong>로고 더블탭 → 숨은 입력창에 테스트 닉네임 입력 → 로고 롱프레스</strong>하면 테스트 API가 닉네임만으로 토큰을 발급해준다. 모든 플로우가 <code>runFlow</code> 한 줄로 이 공용 로그인을 타니까 빠르고 안정적이다. E2E 하려면 이런 테스트 전용 통로에 대한 투자가 은근 중요하다.</p>
<h2 id="여기서-한-발-더---스크립트를-claude한테-쓰게-했다-🤖">여기서 한 발 더 - 스크립트를 Claude한테 쓰게 했다 🤖</h2>
<p>사실 이 글의 진짜 주제는 여기부터다. 플로우 60개를 내가 다 손으로 짰냐? <strong>아니다. Claude Code한테 시켰다.</strong></p>
<p>방식은 이렇다.</p>
<ol>
<li><strong>Claude가 앱 소스를 읽고 플로우를 쓴다.</strong> Maestro는 텍스트 셀렉터 기반이라, 화면의 버튼 문구가 곧 셀렉터다. Claude는 Composable 소스에서 라벨을 직접 대조해가며 yaml을 짠다. 사람이 짜면 &quot;문구가 &#39;로그인&#39;이었나 &#39;로그인하기&#39;였나&quot; 하며 앱을 켜보게 되는데, 소스를 읽는 에이전트는 그럴 필요가 없다.</li>
<li><strong>Figma MCP로 고도화한다.</strong> 화면 개편이 들어오면 Claude가 피그마 MCP로 디자인 명세를 직접 조회해서, 바뀐 문구/구조/여백을 플로우와 앱 코드 양쪽에 반영한다.</li>
<li><strong><code>maestro hierarchy</code> / 스크린샷 덤프를 Claude가 읽는다.</strong> 플로우가 실패하면 Maestro가 남기는 UI 계층 덤프와 스크린샷을 에이전트가 읽고 셀렉터를 스스로 고친다. &quot;같은 라벨이 두 개라 index를 줘야 한다&quot; 같은 것도 계층 덤프 보고 알아서 처리한다.</li>
</ol>
<p>그 결과물이 재밌는 게, 에이전트가 삽질하며 배운 내용이 <code>.maestro/README.md</code>에 계속 축적된다. 예를 들면 이런 것들이다.</p>
<blockquote>
<ul>
<li>iOS에서 <code>hideKeyboard</code>는 불안정 → 키보드 해제는 &quot;항상 보이는 상단 타이틀 탭&quot;으로. 키보드가 덮은 좌표를 탭하면 자판이 눌려 필드에 글자가 들어가는 사고가 남 (실제 발생)</li>
<li>상세 화면의 지도 뷰가 화면 중앙에 오면 기본 scroll을 지도가 삼켜 멈춤 → 스와이프 좌표를 지정한 공용 스크롤 플로우 사용</li>
<li>iOS <code>eraseText</code>는 빈 문자열 붙여넣기로 구현이라, Mac 클립보드가 시뮬레이터와 동기화돼 있으면 복사해둔 텍스트가 필드에 들어감 → 플로우 전에 클립보드 비우기</li>
</ul>
</blockquote>
<p>이 지식이 문서로 남으니, 다음에 플로우를 추가할 때 에이전트가 같은 삽질을 반복하지 않는다. <strong>에이전트의 경험치가 레포에 쌓이는 느낌</strong>이라 꽤 신선했다.</p>
<h2 id="진짜-백미---테스트가-버그를-찾으면-claude가-고친다-🔧">진짜 백미 - 테스트가 버그를 찾으면 Claude가 고친다 🔧</h2>
<p>E2E를 돌리는 주체가 &quot;앱 소스를 전부 읽을 수 있는 에이전트&quot;라는 게 어떤 의미냐면, <strong>테스트 실패가 곧바로 버그 수정으로 이어진다</strong>는 거다. 실제로 이 조합으로 찾아서 고친 것들이다.</p>
<ul>
<li><strong>특정 화면 진입 시 iOS 크래시(SIGSEGV)</strong> — 서버에서 내려온 대용량 HTML을 가공하는 정규식이 Kotlin/Native 정규식 엔진에서 글자마다 재귀해서 스택 오버플로가 났다. E2E가 크래시를 재현했고, 에이전트가 원인 정규식을 찾아 제거했다. (JVM에선 멀쩡한데 Native에서만 죽는, KMP 특유의 함정)</li>
<li><strong>등록 요청이 서버 400</strong> — 요청 DTO에 서버가 필수로 요구하는 필드 하나가 빠져 있었다. 플로우 실패 → 서버 에러 메시지 → DTO 수정까지 한 사이클에 끝.</li>
<li><strong>목록 화면 진입 크래시</strong> — 특정 케이스에서 서버가 필드를 null로 주는데 DTO가 non-null이라 파싱에서 죽었다. nullable 처리 + 파싱 방어 코드 추가.</li>
<li><strong>앱/서버 검증 불일치</strong> — 금액 입력의 최솟값 검증 기준이 앱과 서버가 서로 달랐다. 앱에서 통과한 값을 서버가 거절 → 에이전트가 양쪽 기준을 대조해서 앱 상수를 수정.</li>
<li><strong>소셜 연동이 영구적으로 재연결 안내에 걸리는 문제</strong> — OAuth scope 하나를 빼먹고 요청해서, 재연결을 해도 계속 재연결하라고 뜨는 상태. E2E가 차단 지점을 발견했고 Android/iOS 양쪽 연동 코드에 scope를 추가했다.</li>
</ul>
<p>수동 QA였으면 유저 제보를 받고 나서야 디버깅을 시작했을 것들이다. 지금은 <strong>밤에 스위트 돌려놓으면 아침에 &quot;이런 버그 찾아서 이렇게 고쳤다&quot;는 리포트가 와 있는</strong> 흐름이 된다. 이게 되니까 QA 자동화가 아니라 거의 QA 엔지니어를 한 명 앉혀놓은 느낌이다.</p>
<h2 id="한계도-있다-🥲">한계도 있다 🥲</h2>
<p>솔직하게 아쉬운 점도 적어본다.</p>
<ul>
<li><strong>외부 SDK/웹뷰 화면은 어렵다.</strong> 소셜 로그인이나 결제창(PG)처럼 접근성 트리가 우리 통제 밖인 화면은 플로우가 자주 깨진다. 그래서 결제는 결제창 직전까지만 검증하고 복귀하도록 설계했다.</li>
<li><strong>쓰기 시나리오는 데이터 관리가 필요하다.</strong> 신청/등록 플로우는 테스트 서버 데이터를 실제로 바꾸니까, 멱등하게(이미 신청돼 있으면 취소 후 재신청) 설계하고 읽기 스위트와 분리해야 한다. 이 설계가 은근 품이 든다.</li>
<li><strong>시각적 검증은 못 한다.</strong> &quot;버튼이 눌린다&quot;는 보장해도 &quot;버튼이 예쁘게 나온다&quot;는 보장 못 한다. 레이아웃 깨짐은 스크린샷 찍어두고 사람(또는 에이전트) 눈으로 봐야 한다.</li>
<li><strong>wasmJs는 대상이 아니다.</strong> Maestro는 모바일용이라 KMP의 웹 타겟까지는 커버 못 한다.</li>
</ul>
<h2 id="마무리-🙏">마무리 🙏</h2>
<p>정리하면 이렇다.</p>
<blockquote>
<p><strong>KMP로 UI를 한 벌로 만들었다면, QA도 yaml 한 벌로 만들 수 있다.</strong>
그리고 그 yaml을 쓰고, 돌리고, 실패 원인을 고치는 것까지 에이전트에게 맡길 수 있다.</p>
</blockquote>
<ul>
<li>Maestro 덕분에 iOS/Android 스모크·시나리오 테스트가 스크립트 한 벌, 명령어 한 줄이 됐고</li>
<li>플로우를 유저 타입별 × 피쳐별로 쪼개니 그 자체로 살아있는 QA 시나리오 문서가 됐고</li>
<li>Claude + Figma MCP 조합으로 화면 개편이 와도 플로우 갱신이 따라가고</li>
<li>E2E가 찾은 크래시/서버 불일치를 에이전트가 수정까지 하면서, 배포 전 불안감이 확 줄었다</li>
</ul>
<p>KMP 하는 팀이라면 Maestro는 진짜 도입 비용 대비 효율이 미쳤다. 거기에 코드 읽을 줄 아는 에이전트까지 붙이면 QA의 차원이 달라진다. 끝!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Compose Layout의 기초를 알아보자]]></title>
            <link>https://velog.io/@dev_hyunwoo/Compose-Layout%EC%9D%98-%EA%B8%B0%EC%B4%88%EB%A5%BC-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@dev_hyunwoo/Compose-Layout%EC%9D%98-%EA%B8%B0%EC%B4%88%EB%A5%BC-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Sat, 15 Aug 2026 08:40:32 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/000d30d4-3111-40b8-b54d-300a05333b25/image.png" alt=""></p>
<p>Compose 레이아웃 문서를 다시 읽다가 첫 문단에서 걸렸다.
&quot;Compose는 상태를 UI로 바꾸는 과정을 3단계로 나눠서 처리한다&quot;는 문장이었는데, 그동안 나는 리컴포지션만 신경 쓰고 살았지 그 뒤에 무슨 일이 벌어지는지는 제대로 생각해본 적이 없었다.</p>
<p><code>Modifier.offset(x)</code>랑 <code>Modifier.offset { }</code>이 왜 성능 차이가 나는지도 이 3단계를 알아야 설명이 되는 거였다. 그래서 정리해봤다.</p>
<hr>
<h2 id="🌀-composition-→-layout-→-drawing">🌀 Composition → Layout → Drawing</h2>
<pre><code>데이터 → Composition → Layout → Drawing → 화면
        (무엇을)      (어디에)   (어떻게)</code></pre><p>Compose는 한 프레임을 그릴 때 항상 이 순서로 흐른다. 그리고 중요한 건 <strong>역방향 의존이 없다</strong>는 점이다.</p>
<p>즉 한 프레임 안에서 Layout 단계의 결과가 Composition으로 다시 돌아가는 일은 없다. 단방향이다.
이게 왜 중요하냐면, 역방향이 허용되는 순간 &quot;자식 크기를 보고 부모가 다시 구성 → 그럼 자식도 다시&quot; 같은 순환이 생기고 프레임이 언제 끝날지 보장할 수 없기 때문이다.</p>
<hr>
<h2 id="1️⃣-composition--무엇을-보여줄지">1️⃣ Composition — 무엇을 보여줄지</h2>
<p>Compose 런타임이 <code>@Composable</code> 함수들을 실행하는 단계다.
결과물은 화면 픽셀이 아니라 <strong>레이아웃 노드 트리(layout node tree)</strong> 다. 뒤의 두 단계가 쓸 재료를 만들어두는 셈이다.</p>
<pre><code class="language-kotlin">var padding by remember { mutableStateOf(8.dp) }

Text(
    text = &quot;Hello&quot;,
    // padding을 Composition 단계에서 읽는다.
    // Modifier 객체를 만들 때 값이 필요하기 때문.
    modifier = Modifier.padding(padding)
)</code></pre>
<p>여기서 <code>padding</code>이 바뀌면 이 <code>Text</code>는 <strong>리컴포지션 → 레이아웃 → 드로잉</strong>을 전부 다시 탄다.
가장 앞 단계에서 읽었으니 뒤에 딸린 게 다 따라오는 것이다.</p>
<hr>
<h2 id="2️⃣-layout--어디에-놓을지">2️⃣ Layout — 어디에 놓을지</h2>
<p>Composition이 만든 노드 트리를 가지고 각 노드의 크기와 위치를 정하는 단계다. 안에서 다시 두 개의 서브 스텝으로 나뉜다.</p>
<h3 id="측정measurement">측정(Measurement)</h3>
<ol>
<li>부모가 자식들에게 &quot;너 얼마나 필요해?&quot;라고 물어본다</li>
<li>자식이 없는 노드(leaf)는 자기 크기를 바로 정해서 보고한다</li>
<li>부모는 자식들 크기를 취합해서 자기 크기를 정한다</li>
</ol>
<h3 id="배치placement">배치(Placement)</h3>
<p>크기가 다 정해지고 나면 부모가 각 자식을 자기 기준 상대 좌표에 놓는다. 최종적으로 모든 노드가 width, height, (x, y)를 갖게 된다.</p>
<p><code>Row { Image(); Column { Text(); Text() } }</code> 를 예로 들면 이런 순서다.</p>
<pre><code>Row 측정 시작
 ├─ Image 측정 → 자식 없음, 크기 확정해서 Row에 보고
 └─ Column 측정
     ├─ Text 1 측정 → 크기 확정, Column에 보고
     ├─ Text 2 측정 → 크기 확정, Column에 보고
     ├─ Column 크기 확정 (너비 = max, 높이 = 합)
     └─ Column이 자식들을 세로로 배치
Row 크기 확정 (너비 = 합, 높이 = max) → 자식들 배치</code></pre><h3 id="🔑-단일-패스-측정single-pass-measurement">🔑 단일 패스 측정(Single-pass measurement)</h3>
<p>여기가 Compose 레이아웃의 핵심이다. <strong>각 노드는 측정 단계에서 딱 한 번만 방문된다.</strong></p>
<p>기존 Android View는 <code>onMeasure</code>가 여러 번 호출될 수 있었다. <code>RelativeLayout</code>처럼 자식을 두 번 재는 레이아웃을 중첩하면 깊이에 따라 측정 횟수가 지수적으로 늘어났다. 뷰 계층을 깊게 만들지 말라는 얘기가 나온 이유가 이거다.</p>
<p>Compose는 아예 자식을 두 번 측정하는 걸 막아버린다. 커스텀 레이아웃에서 같은 자식에게 <code>measure()</code>를 두 번 호출하면 예외가 난다.</p>
<p>덕분에 얻는 게 3가지다.</p>
<ol>
<li>노드 수에 대해 <strong>O(n)</strong> 이 보장된다</li>
<li>깊게 중첩해도 성능 페널티가 없다 (그래서 Compose는 뷰 계층 깊이를 걱정하지 않아도 된다)</li>
<li>측정 로직이 항상 한 번만 도니까 부수효과를 예측하기 쉽다</li>
</ol>
<p>물론 &quot;자식을 재봐야 내 크기를 정할 수 있는&quot; 케이스는 실제로 존재한다. 그건 <code>IntrinsicSize</code> 같은 별도 장치로 풀도록 분리해뒀다.</p>
<h3 id="상태-읽기">상태 읽기</h3>
<p>측정이나 배치 단계에서 상태를 읽으면 <strong>레이아웃과 드로잉만</strong> 다시 돈다. 리컴포지션은 건너뛴다.</p>
<pre><code class="language-kotlin">var offsetX by remember { mutableStateOf(8.dp) }

Text(
    text = &quot;Hello&quot;,
    modifier = Modifier.offset {
        // 이 람다는 배치 스텝에서 실행된다.
        // offsetX가 바뀌면 레이아웃부터 다시 시작.
        IntOffset(offsetX.roundToPx(), 0)
    }
)</code></pre>
<hr>
<h2 id="3️⃣-drawing--어떻게-그릴지">3️⃣ Drawing — 어떻게 그릴지</h2>
<p>확정된 크기와 좌표를 가지고 실제로 픽셀을 찍는 단계다. 트리를 위에서 아래로 순회하면서 각 노드가 자기 자신을 그린다.</p>
<pre><code>Row 가 배경을 그림
 ├─ Image 가 자기를 그림
 └─ Column 이 자기를 그림
     ├─ Text 1
     └─ Text 2</code></pre><pre><code class="language-kotlin">var color by remember { mutableStateOf(Color.Red) }

Canvas(modifier = modifier) {
    // 드로잉 단계에서 읽는다.
    // color가 바뀌어도 다시 그리기만 한다.
    drawRect(color)
}</code></pre>
<p><code>color</code>가 바뀌어도 크기나 위치는 그대로다. 그러니 Composition도 Layout도 건드릴 이유가 없다. 드로잉만 다시 돈다.</p>
<hr>
<h2 id="🔍-결국-핵심은-상태를-어느-단계에서-읽느냐">🔍 결국 핵심은 &quot;상태를 어느 단계에서 읽느냐&quot;</h2>
<p>세 단계를 굳이 외워야 하는 이유가 여기 있다. Compose는 <strong>상태를 읽은 그 단계부터 뒤쪽만</strong> 다시 실행한다.</p>
<table>
<thead>
<tr>
<th>상태를 읽는 위치</th>
<th>다시 도는 단계</th>
</tr>
</thead>
<tbody><tr>
<td>Composable 함수 본문 / Modifier 인자</td>
<td>리컴포지션 → 레이아웃 → 드로잉</td>
</tr>
<tr>
<td><code>offset { }</code>, 커스텀 <code>Layout</code>의 측정·배치 블록</td>
<td>레이아웃 → 드로잉</td>
</tr>
<tr>
<td><code>Canvas</code>, <code>drawBehind</code>, <code>graphicsLayer { }</code></td>
<td>드로잉</td>
</tr>
</tbody></table>
<p>그래서 최적화 방향은 하나다. <strong>상태 읽기를 최대한 뒤 단계로 미룬다.</strong></p>
<p>스크롤에 맞춰 이미지를 패럴랙스로 움직이는 흔한 코드를 보자.</p>
<pre><code class="language-kotlin">// ❌ 값을 직접 넘기는 방식
Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        modifier = Modifier.offset(
            with(LocalDensity.current) {
                // Composition 단계에서 스크롤 값을 읽어버린다
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) { /* ... */ }
}</code></pre>
<p><code>offset(x: Dp)</code>는 값을 인자로 받는다. 그래서 이 값을 계산하려면 Composition 단계에서 스크롤 오프셋을 읽어야 한다.
결과적으로 <strong>스크롤 픽셀이 1 움직일 때마다 Box 전체가 리컴포지션된다.</strong> 실제로 바뀐 건 이미지 위치 하나뿐인데.</p>
<pre><code class="language-kotlin">// ✅ 람다로 넘기는 방식
Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        modifier = Modifier.offset {
            // 배치 스텝에서 읽는다
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) { /* ... */ }
}</code></pre>
<p>람다로 넘기면 상태를 읽는 시점이 배치 스텝으로 밀린다. 리컴포지션은 통째로 건너뛰고 레이아웃과 드로잉만 다시 돈다.
<code>graphicsLayer { }</code>도 같은 이유로 존재한다. alpha나 rotation을 자주 바꿀 거면 인자 버전 대신 람다 버전을 쓰면 드로잉만 다시 돈다.</p>
<p><strong>Modifier에 값 대신 람다를 받는 오버로드가 있으면, 그건 대부분 &quot;상태 읽기를 뒤로 미루라&quot;는 신호다.</strong></p>
<hr>
<h2 id="⚠️-반대로-단방향을-거스르면-생기는-일">⚠️ 반대로, 단방향을 거스르면 생기는 일</h2>
<p>레이아웃 단계에서 얻은 값을 Composition 상태로 되돌리면 순환이 생긴다.</p>
<pre><code class="language-kotlin">// ❌ 하지 말 것
Box {
    var imageHeightPx by remember { mutableIntStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = null,
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size -&gt;
                // 레이아웃 단계의 결과가 Composition 상태를 건드린다
                imageHeightPx = size.height
            }
    )

    Text(
        text = &quot;이미지 아래에 놓고 싶다&quot;,
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}</code></pre>
<p>한 프레임 안에서는 역방향이 불가능하니, 이 코드는 <strong>프레임을 넘겨가며</strong> 순환한다.</p>
<ul>
<li>1프레임: <code>imageHeightPx</code>가 0이라 Text가 이미지 위에 겹쳐서 그려짐 → <code>onSizeChanged</code>가 상태 갱신</li>
<li>2프레임: 리컴포지션되면서 그제서야 제자리로 감</li>
</ul>
<p>결국 사용자 눈에는 UI가 한 번 튀는 게 보인다. 이런 건 <code>Column</code>이나 커스텀 <code>Layout</code>처럼 한 패스 안에서 크기를 조율해주는 도구로 풀어야 한다.</p>
<hr>
<h2 id="후기">후기</h2>
<p>그동안 &quot;리컴포지션 = 비싼 것&quot;이라고만 뭉뚱그려 생각했는데, 사실 리컴포지션은 3단계 중 첫 단계일 뿐이었다.
그리고 진짜 최적화 포인트는 리컴포지션을 막는 게 아니라 <strong>애초에 그 단계에서 상태를 읽지 않는 것</strong>이라는 게 이번에 제일 크게 남았다.</p>
<p><code>offset { }</code>은 그냥 람다를 받는 편한 오버로드인 줄 알았는데, 사실은 &quot;이 값은 배치 스텝에서 읽겠다&quot;는 선언이었다는 것도 이제야 이해됐다. Compose API를 보는 눈이 좀 달라진 느낌이다.</p>
<p>다만 실제 프로젝트에서 이 차이가 체감될 만큼 무거운 화면을 아직 만들어본 적은 없다. 레이아웃 인스펙터로 리컴포지션 카운트를 찍어가며 직접 비교해봐야 확실히 와닿을 것 같다. 그건 다음에 한 번 제대로 측정해보려 한다.</p>
<blockquote>
<p>참고</p>
<ul>
<li><a href="https://developer.android.com/develop/ui/compose/phases">Jetpack Compose phases</a></li>
<li><a href="https://developer.android.com/develop/ui/compose/layouts/basics">Compose layout basics</a></li>
</ul>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[kmp앱 개발 일기 #2]]></title>
            <link>https://velog.io/@dev_hyunwoo/kmp%EC%95%B1-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EA%B8%B0-2</link>
            <guid>https://velog.io/@dev_hyunwoo/kmp%EC%95%B1-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EA%B8%B0-2</guid>
            <pubDate>Sat, 11 Jul 2026 08:18:21 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/2a0cbe12-6251-4554-a71c-c9d970c17612/image.png" alt=""></p>
<p><a href="https://velog.io/@dev_hyunwoo/kmp%EC%95%B1-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EA%B8%B0-1">이전 글</a>에는 초기 셋팅을 해보았다. 
이제 기존 base를 만들차례이다. 나는 MVI를 사용 할 예정이고 라이브러리고 orbit을 사용할 것이다.</p>
<hr>
<h2 id="🛰️-oribit이란">🛰️ Oribit이란?</h2>
<p>Orbit이란 Android에서 MVI 패턴을 간단하게 구현할 수 있도록 도와주는 Kotlin 기반 라이브러리다. 
Redux나 MVI의 핵심 개념을 가조오되, 러닝커브와 보일러플레이트를 최소화한 것이다.</p>
<hr>
<h2 id="1️⃣-viewmodel에서-사용법">1️⃣ ViewModel에서 사용법</h2>
<p>기존의 MVI랑 같은 작업인 것 같다. 
우선 UI에서 쓰일 Model을 정의 해야된다.</p>
<pre><code class="language-kotlin">
data class MainState(
    val query : String = &quot;&quot;,
    val isLoading : Boolean = false,
    val results : List&lt;Book&gt; = emptyList()
)</code></pre>
<p>UI에서 상태를 가진 값들을 정의 해준 것이다.</p>
<p>이제 SideEffect를 정의 해보자</p>
<pre><code class="language-kotlin">
sealed interface MainSideEffect {
    data class ShowToast(val message : String) : MainSideEffect
    data class NavigateToDetail(val bookId : Long) : MainSideEffect
}</code></pre>
<p>이렇게 정의를 한 뒤</p>
<p>viewModel에서 ContainerHost라는 interface를 상속만 해주면 된다.</p>
<pre><code class="language-kotlin">class MainViewModel(
    private val repository : SearchRepository
) : ViewModel(), Containter&lt;MainState, MainSideEffect&gt; {

    // 컨테이너 생성 : 상태 초기화 
    override val container = container&lt;MainState, MainSideEffect&gt;(MainState())

     // ② 입력창 텍스트 변경
    fun onQueryChange(query: String) = intent {
        reduce { state.copy(query = query) }
    }

    fun search() = intent {
        if(state.query.isBlank()) {
            postSideEffect(MainSideEffect.ShowToast(&quot;검색어를 입력하세요&quot;))
            return@intent
        }

        reduce { state.copy(isLoading = true)}

        runCatching { repository.search(state.query) }
            .onSuccess { books -&gt;
                reduce { state.copy(isLoading = false, results = books )} 
            }
            .onFailure {
                reduce { state.copy(isLoading = false) }
                postSideEffect(MainSideEffect.ShowToast(&quot;검색에 실패했어요.&quot;))
            }
    }

    fun onBookClick(bookId : Long) = intent {
        postSideEffect(MainSideEffect.NavigateToDetail(bookId))
    }
}</code></pre>
<p>기존 MVI랑 거의 비슷하지만 intent{} 괄호가 보일 것 이다.
intent 괄호안에서는 기본적으로 coroutineScope으기 때문에 suspend 함수를 호출 할 수 있다.
그리고 intent{}안에서만 reduce와 postSideEffect를 사용할 수 있다.</p>
<p>reduce는 기존 MVI에서 update의 역할과 비슷하다. state의 상태를 변경할 때 copy보단 update를 권장하는 이유는 CAS(compare-and-set)로 원자성을 보장해주기 때문이다. orbit에서도 reduce가 그런 역할이다.</p>
<p>postSideEffect는 내부에 channel로 사이드이펙트를 소비해주는 역할이다.
channel인 이유는 크게 3가지 인데</p>
<ol>
<li>딱 한번만 소비되어야 된다.</li>
<li>유실되면 안된다. (SharedFlow는 백그라운드에서 발생한 이벤트를 유실한다)</li>
<li>최신값 재발충이 없어야 된다. (StateFlow는 값을 가지고 있으면 현재 값을 재방출한다)</li>
</ol>
<p>이 3가지를 만족하기 때문에 SharedFlow가 아닌 Channel을 권장하는 이유이다.</p>
<hr>
<h2 id="2️⃣-ui-사용법">2️⃣ UI 사용법</h2>
<p>UI(구독하는 쪽)에서의 사용법을 알아보자</p>
<pre><code class="language-kotlin">
@Composable
fun MainScreen(
    viewModel: MainViewModel = koinViewModel(),
    onNavigatgeToDetail : (Long) -&gt; Unit
) {
    val state by viewModel.collectAsState() // State가 바뀔 때마다 리컴포지션

    viewModel.collectSideEffect { effect -&gt;
        when(effect) {
             is MainSideEffect.ShowToast -&gt;
                Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
            is MainSideEffect.NavigateToDetail -&gt;
                onNavigateToDetail(effect.bookId)
        }
    }

    Column(Modifier.padding(16.dp)) {
        OutlinedTextField(
            value = state.query,
            onValueChange = viewModel::onQueryChange,   // 이벤트는 ViewModel 함수 호출
            label = { Text(&quot;책 제목 검색&quot;) }
        )

        Button(
            onClick = viewModel::search,
            enabled = !state.isLoading
        ) {
            Text(&quot;검색&quot;)
        }

        if (state.isLoading) {
            CircularProgressIndicator()
        }

        LazyColumn {
            items(state.results, key = { it.id }) { book -&gt;
                BookItem(
                    book = book,
                    onClick = { viewModel.onBookClick(book.id) }
                )
            }
        }
    }
}
</code></pre>
<p>collectedAsState()는 orbit에서 제공해주는 확장함수이다. 컨테이너의 StateFlow를 Compose State로 변환해주고 라이프사이클도 알아서 처리해준다.</p>
<p>collectSideEffect{} 내부적으로 Channel 기반이라서 이벤트가 딱 한번만 소비된다. </p>
<hr>
<h2 id="후기">후기</h2>
<p>동작 방식 자체는 기존에 사용하던 MVI와 완전히 동일하다. 결국 Orbit도 &quot;단일 State + 단방향 흐름 + reduce를 통한 상태 변경&quot;이라는 MVI의 원칙 위에서 움직이기 때문이다. </p>
<p>다만 Intent 클래스 정의, 스트림 배선 같은 보일러플레이트 코드가 확실히 줄어들었다.Orbit은 프레임워크가 아니라 라이브러리에 가깝다는 점도 마음에 들었다. </p>
<p>ViewModel, Compose 등 기존 Jetpack 라이브러리 위에 자연스럽게 얹어 쓸 수 있어서, MVI 패턴 그 자체에만 집중할 수 있었다.기존 방식과 달랐던 점은 UI → ViewModel 이벤트 전달 방식이다. </p>
<p>이전에는 Event sealed interface를 정의하고 onEvent 함수 하나로 모든 이벤트를 받아 처리했지만, Orbit에서는 ViewModel의 함수를 직접 호출하는 방식을 권장하는 것으로 보인다. </p>
<p>직접 호출해보니 Event 클래스와 when 분기가 사라지면서 코드가 확실히 더 깔끔해졌다. 아직 뚜렷한 단점은 찾지 못했는데, 화면이 커져서 액션이 많아지면 함수 직접 호출 방식의 트레이드오프가 보일지 좀 더 써봐야 알 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[kmp앱 개발 일기 #1]]></title>
            <link>https://velog.io/@dev_hyunwoo/kmp%EC%95%B1-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EA%B8%B0-1</link>
            <guid>https://velog.io/@dev_hyunwoo/kmp%EC%95%B1-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EA%B8%B0-1</guid>
            <pubDate>Sun, 28 Jun 2026 08:57:30 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/07cc7804-22e5-407a-ae97-737b9a6a2368/image.png" alt=""></p>
<p>지금 나의 회사에선 kmp로 앱을 개발하고 있다.
지금 회사를 오기전에느 kmp는 아직 상용화하기엔 너무 이르다고 생각했었지만,
이 회사에 와서 Kmp를 사용해보니깐 생각이 바뀌었다.</p>
<p>뭔가 점점...flutter를 제치고 kmp가 자리를 잡아갈 것 같았다.</p>
<p>물론 지금 flutter가 너무 강력하긴 하지만 kmp도 굉장히 매력있었다.</p>
<p>그래서 kmp앱을 개발하면서 출시까지 도전해볼려고 한다.</p>
<hr>
<h2 id="⚙️-초기설정">⚙️ 초기설정</h2>
<p>항상 느끼는 거지만 프로젝트를 시작할 때 가장 귀찮은 작업이 초기설정이다.
프로젝트 이름을 어떻게 지을까...부터 시작해서 어떤 라이브러리를 쓰고
그 라이브러리를 쓰기 위해 초기설정까지.. </p>
<p>그래도 초기설정만 하면 순수개발은 즐겁기 때문에 좀만 참아보자!</p>
<h3 id="di">DI</h3>
<p>DI는 대부분 koin을 쓴다고 생각하면 된다. dagger hilt를 납두고 koin을 쓴다고 하면 안드로이드 개발자들은 별로 달갑지는 않을 것이다. 하지만 hilt는 JVM 전용이기 때문에 쓸 수 없다. </p>
<h3 id="client">Client</h3>
<p>Client도 안드로이드 개발자라면 Retrofit을 썼겠지만 Retrofit도 JVM 전용이기 때문에, 멀티플랫폼을 지원하는 Ktor Client를 사용하고 Retrofit의 어노테이션 방식이 익숙해서 그 위에 Ktorfit을 얹어 적용했다.</p>
<h3 id="local-storage">Local Storage</h3>
<p>로컬 저장소도 SharedPreferences를 많이 썼겠지만 역시 그것도 안드로이드 전용이라 KMP에서는 Multiflatform Settings를 주로 사용한다.</p>
<h3 id="navigation">Navigation</h3>
<p>드디어 희소식을 전할 수 있다. 이제 Jetpack Navigation Compose가 KMP를 지원하기 때문에, 안드로이드에 친숙한 젯팩 네비게이션을 그대로 쓰면 된다!</p>
<h3 id="상태관리">상태관리</h3>
<p>상태 관리는 고민을 많이 했다. Slack에서 나온 Circuit을 써볼까, 아니면 직접 커스텀해서 구현해볼까… 고민하다가 결국 Orbit을 써보기로 했다.
Circuit은 상태관리에 더해 Navigation까지 함께 지원하는데, 그만큼 프레임워크에 종속되는 정도가 커서 부담스러웠다. 커스텀 방식은 이미 실무에서 직접 구현해 쓰고 있다 보니 새로울 게 없어 흥미가 가지 않았다. 그래서 예전부터 자주 들어왔던 Orbit을 한번 써보기로 했다. Orbit은 종속성이 낮아 상태관리에만 집중해서 쓸 수 있다는 점이 좋았다.</p>
<p>다음에는 orbit을 사용했던 내용으로 작성을 할 계획이다.</p>
<p>초기설정만으로 힘이 빠져버렸다...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] compositionLocal란?]]></title>
            <link>https://velog.io/@dev_hyunwoo/Android-compositionLocal%EB%9E%80</link>
            <guid>https://velog.io/@dev_hyunwoo/Android-compositionLocal%EB%9E%80</guid>
            <pubDate>Sat, 13 Jun 2026 13:02:18 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/916ba891-7c50-46c9-a774-80ff4a11b9d7/image.png" alt=""></p>
<p>CompositionLocal가 무엇이며 어떻게 쓰이는 지 알아볼려고 한다.</p>
<hr>
<h2 id="1️⃣-compositionlocal이란">1️⃣ CompositionLocal이란?</h2>
<p>컴포저블 트리에서 데이터를 파라미터로 일일이 넘기지 않고, 하위 트리 전체에 전달하는 메커니즘이다. 예를 들어 테마 색상, 화면 설정 같은 걸 모든 컴포저블에 일일이 내려주게 되는 일은 매우 귀찮은 작업이 될 것이다. 그래서 특정 하위 컴포저블에게 필요할 때 바로 사용할 수 있게 해준다.</p>
<h3 id="초기화하는-법">초기화하는 법</h3>
<pre><code class="language-kotlin">val LocalElevation = compositionLocalOf { 4.dp }

val LocalElevation = staticCompositionLocalOf { 4.dp }

val LocalUser = staticCompositionLocalOf&lt;User&gt; {
    error(&quot;No user provided&quot;)
}
</code></pre>
<h3 id="사용법">사용법</h3>
<pre><code class="language-kotlin">CompositionLocalProvider(LocalElevation provides 8.dp) {
    MyContent() // 이 하위 트리에서 사용 가능
}

@Composable
fun MyContent(){
    val elevation = LocalElevation.current
    Card(elevation = elevation) { /* ... */ }
}</code></pre>
<hr>
<h2 id="2️⃣-compositionlocalof와-staticcompositionlocalof-차이점">2️⃣ compositionLocalOf와 staticCompositionLocalOf 차이점</h2>
<p>값이 바뀔 때 재구성이 되냐 안되냐의 차이점이다.
staticCompositionLocalOf은 말 그대로 정적인 CompositionLocal을 만들 때 사용된다. 만약 값이 바뀐다면 CompositionLocalProvider에 블록 전체가 재구성된다.</p>
<p>그래서 값이 자주 바뀐다면 compositionLocalOf를 사용하는게 유리하다.
compositionLocalOf는 읽은 곳만 재구성 되기 때문이다.</p>
<p>하지만 그렇다고 정적인 값을 생성할 때도 compositionLocalOf를 남발하면 안된다. 
compositionLocalOf는 값이 사용될 때마다 어디서 읽혔는지 추적하기 때문에 약간의 오버헤드가 발생할 수 있기 때문이다.</p>
<p>반대로 staticCompositionLocalOf는 어디서 읽히는 지 추적을 안하기 때문에 오버헤드가 없다. </p>
<p>그래서 가장 중요한 건 값이 바뀌는지 안바뀌는지 스스로 판단하고 
compositionLocalOf를 쓸지 staticCompositionLocalOf를 쓸지
잘 판단해서 사용하는 것이다</p>
<hr>
<h2 id="결론">결론</h2>
<p>프로젝트를 할 때 상태 호이스팅을 유지할려다보니 파라미터 지옥에 빠지게 되었는데
compositionLocal을 잘 활용하다보니 코드가 점점 개선되는 게 눈으로 보였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[kotlin] Class Discriminator  사용 경험담]]></title>
            <link>https://velog.io/@dev_hyunwoo/kotlin-Class-Discriminator-%EC%82%AC%EC%9A%A9-%EA%B2%BD%ED%97%98%EB%8B%B4</link>
            <guid>https://velog.io/@dev_hyunwoo/kotlin-Class-Discriminator-%EC%82%AC%EC%9A%A9-%EA%B2%BD%ED%97%98%EB%8B%B4</guid>
            <pubDate>Fri, 01 May 2026 09:25:36 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/22adea8c-1a9b-49b3-857a-40c25f6ab189/image.png" alt=""></p>
<p>kotlin으로 개발을 하는 사람이라면 sealed interface나 sealed class를 많이 써봤을 것이다. 단지 상속 가능한 타입을 컴파일 타임에서 알 수 있다는 장점만으로도 충분히 많이 잘 사용하는 거라고 생각한다. Class Discriminator를 사용해서 좋았던 경험을 적어볼려고 한다.</p>
<h2 id="목차">목차</h2>
<h3 id="1sealed란-무엇일까">1.sealed란 무엇일까?</h3>
<h3 id="2sealed-interface-vs-sealed-class에-차이점">2.sealed interface vs sealed class에 차이점</h3>
<h3 id="3class-discriminator-사용법">3.Class Discriminator 사용법</h3>
<hr>
<h2 id="1️⃣-sealed란-무엇일까">1️⃣ sealed란 무엇일까?</h2>
<p>sealed는 상속 가능한 타입의 집합을 컴파일 타임에서 고정한다는 키워드이다. </p>
<h3 id="왜-자주-쓰일까">왜 자주 쓰일까?</h3>
<p>우선 when을 사용할 때 exhaustive check가 가능하다. 새로운 하위 타입이 추가되면 컴파일 에러가 나서 빠트린 곳을 찾기 쉽다.</p>
<hr>
<h2 id="2️⃣-sealed-interface-vs-sealed-class에-차이점">2️⃣ sealed interface vs sealed class에 차이점</h2>
<p>이건 sealed만 붙었지 사실 interface vs class라고 봐도 무방하다. </p>
<h3 id="class">class</h3>
<ul>
<li>class는 생성자를 가질 수 있다.</li>
<li>단일 상속만 가능하다.</li>
<li>추상 메서드뿐 아니라 구현된 메서드도 가질 수 있음.</li>
</ul>
<h3 id="interface">interface</h3>
<ul>
<li>생성자를 가질 수 없다.</li>
<li>다중 구현이 가능하다.</li>
<li>기본 구현이 있는 메서드는 가질 수 있다.</li>
</ul>
<h3 id="그럼-어떤-걸-써야될까">그럼 어떤 걸 써야될까?</h3>
<p>sealed class를 써야될 때는 공통 프로퍼티를 생성자로 강제할 때 주로 사용하는 것 같다.</p>
<p>사실 그외에선 sealed interface를 사용하는게 맞는 것 같다.</p>
<p>우선 sealed interface는 다중구현이 가능하고 공통 프로퍼티를 유연하게 상속하여 사용할 수 있어서 범용성 있게 사용할 수 있기 때문이다.</p>
<hr>
<h2 id="3️⃣-class-discriminator-사용법">3️⃣ Class Discriminator 사용법</h2>
<h3 id="class-discriminator란">Class Discriminator란?</h3>
<p>타입 판별자라는 뜻이다.
kotlinx.serialization는 다형적 타입을 직렬화/역직렬화 할때 discriminator라는 특별한 필드를 사용한다. </p>
<h3 id="경험담">경험담</h3>
<p>서버에서 내려준 값들을 역직렬화 할 때 보통 data class에 @Serializable 조합으로 많이 사용한다. 나도 그렇게 사용해왔다. 근데 작업을 하다보면 하나의 API인데 타입에 따라 값이 다른 경우가 있다. 그런 경우에서 data class를 정의할 때 타입에 따라 모든 값을 고려하여 필드를 정의해준 경험이 있다면 내가 알려주는 방식을 사용하면 좀 더 깔끔하게 타입을 나눌 수 있을 것이다.</p>
<h3 id="기존에-했던-방식">기존에 했던 방식</h3>
<p>우선 예를 들어보면 SNS관리하는 API가 있다고 가정하자. 각 SNS별로 화면에 필요한 값들이 공통적으로 있는데 몇개는 각 SNS별로 필드가 다른 상황이다. 그래서 각 사용자가 연결된 SNS에 따라 값들이 달라지는데 그걸 모두 정의했다고 생각해보자</p>
<pre><code class="language-kotlin">
@Serializable
data class SNSDetailResponse(
    // 공통
    val type : SNS,
    val title : String,
    val content : String,
    val name : String,

    // naver
    val todayCount : Int?,

    // instargram
    val followerCount : Int?,

    // youtube
    val subscriber : Int?,
)

@Serializable
enum class SNS {
    @SerialName(&quot;naver&quot;)
    Naver,
    @SerialName(&quot;instagram&quot;)
       Instagram,
    @SerialName(&quot;youtube&quot;)
    Youtube
}
</code></pre>
<p>보통 이렇게 정의를 하여 공통인 부분들은 non nullable로 하고 타입마다 다르게 내려주는 값들은 nullable로 하여 역직렬화시 크래시를 방지해준다.</p>
<h3 id="class-discriminator를-사용한-방식">Class Discriminator를 사용한 방식</h3>
<pre><code class="language-kotlin">
sealed interface SNSDetailResponse {
    val title : String
    val content : String
    val name : String

    @Serializable
    @SerialName(&quot;naver&quot;)
    data class Naver(
        override val title: String,
        override val content: String,
        override val name: String,
        val today : Int
    ) : SNSDetailResponse

    @Serializable
    @SerialName(&quot;instagram&quot;)
    data class Naver(
        override val title: String,
        override val content: String,
        override val name: String,
        val followerCount : Int
    ) : SNSDetailResponse

    @Serializable
    @SerialName(&quot;youtube&quot;)
    data class Naver(
        override val title: String,
        override val content: String,
        override val name: String,
        val subscriber : Int
    ) : SNSDetailResponse
}
</code></pre>
<p>이렇게 정의하면 끝난다! 지금처럼 한개만 별도 필드를 가지고 있을 때는 모든 필드를 정의하는 게 더 간단하게 보일지라도 각 SNS별로 각 필드가 많아진다면 당연히 가독성이 떨어질 것 이다.</p>
<p>@SerialName에서 타입을 정의해주면 디폴트가 type에 따라 나눠주기 때문에 서버에서 type으로 내려주면 된다.</p>
<pre><code>{
  &quot;type&quot;: &quot;instagram&quot;,
  &quot;title&quot;: &quot;오늘의 일상&quot;,
  &quot;content&quot;: &quot;맛집 다녀왔어요&quot;,
  &quot;name&quot;: &quot;김철수&quot;,
  &quot;hashtags&quot;: [&quot;맛집&quot;, &quot;일상&quot;],
  &quot;imageUrl&quot;: &quot;https://...&quot;
}</code></pre><p> 하지만 다른 값으로 판별을 하고 싶다면 </p>
<pre><code class="language-kotlin">val json = Json {
    classDiscriminator = &quot;name&quot;  
}</code></pre>
<p>json을 생성할 때 바꿔주면 된다. 서버와 협의하여 편한걸로 하는게 좋을 것 같다.</p>
<hr>
<h2 id="후기">후기</h2>
<p>기존에는 모든 필드를 고려했다면 저렇게 타입을 나눠서 분리하니깐 가독성이 좋아졌다.
물론 초기 생성할 때는 번거로울 수 있지만 나중에 유지보수를 할 때에는 도움이 많이 될 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[kotlin] Generic 맛만 보기]]></title>
            <link>https://velog.io/@dev_hyunwoo/kotlin-Generic-%EB%A7%9B%EB%A7%8C-%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@dev_hyunwoo/kotlin-Generic-%EB%A7%9B%EB%A7%8C-%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sat, 28 Mar 2026 07:48:03 GMT</pubDate>
            <description><![CDATA[<p>안드로이드 개발을 하면서 Generic를 써봤지만 제대로 이해하고 쓴 적이 없는 것 같다.
그래서 이번 기회에 무슨 맛인지만 볼려고 한다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/18c7d0f0-42f2-49a5-b429-62ec2e551543/image.png" alt=""></p>
<hr>
<h2 id="🥸-제네릭generic이란">🥸 제네릭(Generic)이란?</h2>
<p>제네릭이란 간단하게 말하자면 <strong>타입을 일반화해서 재사용성을 높이는 문법</strong>이다.
특히 타입 안정성과 유연성이 좋기 때문에 많이 사용된다.</p>
<h3 id="😓-그래서-어떻게-사용하는건데">😓 그래서 어떻게 사용하는건데..?</h3>
<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/e6b59a8f-65eb-4a18-875e-c34f2f2f06b1/image.png" alt="">
제네릭은 총 5가지에 타입이 있다. </p>
<p>기본 형태는 </p>
<pre><code class="language-kotlin">class Box&lt;T&gt;(val value : T)</code></pre>
<p>이런식으로 사용한다. </p>
<h3 id="😇-그래서-장점이-뭔데">😇 그래서 장점이 뭔데...</h3>
<p>간단한 예시를 들자면 </p>
<p>내가 Log를 남기기 위해 Log 남기는 함수를 만든다고 생각해보자.
근데 파라미터의 값이 Int도 있고 String도 있다고 가정해보자.
그럼 타입별로 함수를 만들어야된다.</p>
<pre><code class="language-kotkin">// Int형
fun printInt(value : Int) { 
    println(value
}

// String형
fun printInt(value : String) { 
    println(value
}</code></pre>
<p>하지만 제네릭을 쓰면 </p>
<pre><code class="language-kotlin">fun &lt;T&gt;print(value : T) {
    println(value)
}</code></pre>
<p>하나로 해결이 가능하다.</p>
<p>하지만 별도에 타입별로 처리를 안해줘도 되는 이유는</p>
<p>println의 파라미터 타입이 Any?이기 때문이다.</p>
<pre><code class="language-kotlin">/** Prints the given [message] and the line separator to the standard output stream. */
public actual fun println(message: Any?) {
    printlnImpl(message?.toString())
}</code></pre>
<p>그렇기 때문에 타입 캐스팅을 안해줘도 되는 것이다. </p>
<p>만약 파라미터 타입이 정해져있으면 타입 캐스팅을 별도 처리 해줘야된다.</p>
<h3 id="👮🏼-타입-제한-upper-bound">👮🏼 타입 제한 (Upper Bound)</h3>
<p>제네릭을 사용할 때 타입을 제한할 수도 있다. 예를 들면</p>
<pre><code class="language-kotlin">fun &lt;T : Number&gt; sun(a : T, b : T) : Double {
    return a.toDouble() + b.toDouble()
}</code></pre>
<p><T : Number> Number의 하위 타입만 받을 수 있다는 뜻이다. 그래서 
Number을 상속받는 Int, Long, Float, Double만 받을 수 있고 나머지 타입은 모두 컴파일 에러가 뜬다.</p>
<p>공변성과 반공변성에 대해서도 알고 싶으면 부족하지만 나의 <a href="https://velog.io/@dev_hyunwoo/Kotlin-%EA%B3%B5%EB%B3%80%EC%84%B1-%EB%B0%98%EA%B3%B5%EB%B3%80%EC%84%B1-%EA%B7%B8%EA%B2%8C-%EB%AD%94%EB%8D%B0">이전글</a>을 확인해도 좋다</p>
<hr>
<h2 id="🥳-그래서-실무에서-어떻게-사용될까">🥳 그래서 실무에서 어떻게 사용될까?</h2>
<p>지금 현재 회사에서는 Composable한 함수를 재사용되기 위해 많이 사용되는데 </p>
<p>  보통 앱을 만들 때 각 회사마다 디자인 시스템이 있다. 지금 만들고 있는 앱도 디자인 시스템이 있다. 그래서 공통적으로 쓰는 컴포넌트가 있는데 대표적으로 Tabs에 대해 소개해보고 싶다.</p>
<p>앱 개발자라면  Tabs를 많이 사용해봤을 것이다. 
<img src="https://velog.velcdn.com/images/dev_hyunwoo/post/b93c0c8e-870f-4bbf-bd42-77990d481da4/image.png" alt=""></p>
<p>  요런 느낌인데 이런 컴포넌트들이 앱내에 공통적으로 사용된다.
  근데 각 화면에 같은 디자인들을 매번 코드를 작성하기엔 비효율적이다.
  그래서 보통 이런 디자인시스템에서 공통 컴포넌트들은 재사용 가능하게끔 만든다.</p>
<h3 id="☝️-우선-interface로-보여줄-값들을-정의한다">☝️ 우선 Interface로 보여줄 값들을 정의한다.</h3>
<pre><code class="language-kotlin">interface Tabs {
      val title : String // Tab 화면에 보여질 텍스트
  }</code></pre>
<p>Tabs에선 보통 title하나면 충분하기 때문에 하나만 정의했다.</p>
<h3 id="𝟚-제너릭-공통-함수-생성">𝟚 제너릭 공통 함수 생성</h3>
<pre><code class="language-kotlin">@Composable
  fun &lt;T : Tabs&gt; TabBar(
      tabs: List&lt;T&gt;,
      selectedTab : T,
      onSelectedTab : (T) -&gt; Unit
  ) {
   ...
  }</code></pre>
<p>제네릭 타입을 T를 받되 타입 제한을 지정하여 Tabs를 상속받은 구현체만 받도록 했다.</p>
<h3 id="3️⃣-구현하여-사용해보자">3️⃣ 구현하여 사용해보자.</h3>
<pre><code class="language-kotlin">  // Tabs 구현
enum class MainTabs(override val title : String) : Tabs {
      HOME(&quot;홈&quot;),
      MY(&quot;마이&quot;),
      SEARCH(&quot;검색&quot;)
  }


 // MainScreen.kt
  TabBar(
      tabs = MainTabs.entries,    
    selectedTab = MainTabs.HOME,
      onSelectedTab = viewModel::onSelectedTab
  )</code></pre>
<p>이렇게 하면 Tabs를 각 화면에 맞게 생성만 해준다면 번거롭게 매번 로직을 생성 안해도 재사용 할 수 있게된다.
  그게 바로 제너릭에 장점인 것 같다.</p>
<hr>
<h2 id="👨🏼💻-후기">👨🏼‍💻 후기</h2>
<p>  제너릭은 개발을 하면서 꼭 필요한 문법인 것 같다. 화면을 만들때도 최대한 재사용을 할려고 노력한다. 앞으로 제너릭을 더 활용하는 개발자가 되고싶다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] derivedStateOf의 진짜 사용법]]></title>
            <link>https://velog.io/@dev_hyunwoo/Android-derivedStateOf%EC%9D%98-%EC%A7%84%EC%A7%9C-%EC%82%AC%EC%9A%A9%EB%B2%95</link>
            <guid>https://velog.io/@dev_hyunwoo/Android-derivedStateOf%EC%9D%98-%EC%A7%84%EC%A7%9C-%EC%82%AC%EC%9A%A9%EB%B2%95</guid>
            <pubDate>Sat, 28 Feb 2026 07:11:54 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/0395b7c4-40f5-4d67-ad63-2c77312866f6/image.png" alt=""></p>
<h2 id="개요">개요</h2>
<p>derivedStateOf를 실무에서 종종 사용한 적이 있다.
최근에 사용했던 경험은 배송지를 등록하는 화면에서 조건에 맞을 때 다음 버튼이 활성화 되면 되는 작업이였다.</p>
<pre><code class="language-kotlin">// AddAddressScreen.kt
val canGoNext by remember {
    derivedStateOf {
        uiModel.addressName.isNotEmpty &amp;&amp; // 주소가 빈값이 아닌지
        uiModel.regionCode.isNotEmpty &amp;&amp; // 지역코드가 빈값이 아닌지
        uiModel.addressName.length &lt;= 5 &amp;&amp; // 주소가 5글자 이내인지
    }
}</code></pre>
<p>라는 로직을 작성한 뒤 테스트 해보왔다. 근데 동작을 제대로 안헀다.
부끄럽게도 난 이유를 몰라서 찾아봤었다. </p>
<p>그래서 이참에 안됐던 이유와 함께 derivedStateOf를 알아볼려고 한다.</p>
<hr>
<h2 id="🦷-안됐던-이유는">🦷 안됐던 이유는?</h2>
<p>안됐던 이유는 부끄럽게도 remember에 key값을 안줬기때문이다. 현재 derivedStateOf 안에 값들은 uiModel로 이루어져있는 값들인데 state한 값들이 아니여서 자동으로 추적이 안됐기때문이다.</p>
<pre><code class="language-kotlin">// AddAddressScreen.kt
val canGoNext by remember(uiModel) {
    derivedStateOf {
        uiModel.addressName.isNotEmpty &amp;&amp; // 주소가 빈값이 아닌지
        uiModel.regionCode.isNotEmpty &amp;&amp; // 지역코드가 빈값이 아닌지
        uiModel.addressName.length &lt;= 5 &amp;&amp; // 주소가 5글자 이내인지
    }
}</code></pre>
<p>그래서 이게 올바른 구현이다.</p>
<hr>
<h2 id="🌞-그럼-이해완료">🌞 그럼 이해완료?</h2>
<p>위에 로직에서 이상한 점을 눈치 못챘다면 당신은 제대로 이해하지 못하고 있는 것이다.. (나포함ㅠ) </p>
<blockquote>
<p>최근에 사용했던 경험은 배송지를 등록하는 화면에서 조건에 맞을 때 다음 버튼이 활성화 되면 되는 작업이였다.</p>
</blockquote>
<p>해당 조건을 만족시키기 위해서는 derivedStateOf를 굳이 사용할 필요가 없다.
다시말해 derivedStateOf를 사용해도 최적화 시키는데 전혀 도움이 안되고 있다는 소리다.</p>
<h3 id="🤦🏻♂️-왜">🤦🏻‍♂️ 왜?</h3>
<p>derivedStateOf는 state로부터 파생된 결과값을 만드는데 이때 state값이 빈번하게 변할 때 사용하는 것이 좋다.</p>
<p>예를 들자면</p>
<pre><code class="language-kotlin">val addressTextFieldState = TextFieldState() // state한 값

val enableButton by remember(uiModel.regionCode) {
    derivedStateOf {
        addressTextFieldState.text.isNotEmpty &amp;&amp; // 주소가 빈값이 아닌지
        uiModel.regionCode.isNotEmpty &amp;&amp; // 지역코드가 빈값이 아닌지
        addressTextFieldState.text.length &lt;= 5 &amp;&amp; // 주소가 5글자 이내인지
    }
}</code></pre>
<p>이 경우이다. 주소를 입력할 때 초당 수차례 발생하는 걸 방지할 수 있다는 점에서 충분히 derivedStateOf를 잘쓰고있다고 생각한다.</p>
<p>그래서 내가 하고자 했던 로직은 사실 의미가 없는 것이고 사실</p>
<pre><code class="language-kotlin">val canGoNext =
    uiModel.addressName.isNotEmpty() &amp;&amp;
    uiModel.regionCode.isNotEmpty() &amp;&amp;
    uiModel.addressName.length &lt;= 5</code></pre>
<p>이렇게 사용하면 되는 것이다...!!</p>
<hr>
<h2 id="결론">결론</h2>
<p>derivedStateOf는 텍스트필드나 스크롤값들처럼 빈번하게 변하는 state한 값들로부터 파생된 결과값을 도출할 때 제일 효율적으로 사용할 수 있는 것 같다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Toast는 어떻게 동작할까?]]></title>
            <link>https://velog.io/@dev_hyunwoo/Android-Toast%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8F%99%EC%9E%91%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@dev_hyunwoo/Android-Toast%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8F%99%EC%9E%91%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Sat, 14 Feb 2026 05:33:15 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/63537b42-074a-460e-94d3-da818ac21fbb/image.png" alt=""></p>
<h2 id="🏁-시작하기-앞서">🏁 시작하기 앞서..</h2>
<p><a href="https://velog.io/@skydoves/manifest-android-interview">skydoves님의 글을</a> 읽으며, 마치 나의 치부를 정확히 짚어낸 것 같은 기분이 들었다.
그동안 나는 면접을 준비하거나 공부를 할 때, 어떤 기술을 사용해 왔는지, 그리고 그 기술을 어떻게 활용했는지에만 집중해왔다.</p>
<p>솔직히 말하면, 그 기술이 내부적으로 어떻게 동작하는지에 대해서 깊이 고민하며 공부한 적은 많지 않았다.
“나는 이 기술을 사용할 수 있다”는 사실을 보여주는 데에만 신경 썼을 뿐, 그 이면에 있는 구조나 원리에 대해서는 크게 신경 쓰지 않았던 것이다.</p>
<p>지금 돌아보면, 그것이 나의 분명한 약점이었다.
분명 실무에서는 익숙하게 사용하던 기술들이었지만, 막상 면접에서 “이 기술은 내부적으로 어떻게 동작하나요?”라는 질문을 받으면 선뜻 대답하지 못했던 기억이 적지 않다.
사용은 하고 있었지만, 이해하고 있다고 말하기에는 부족한 상태였던 것이다.</p>
<p>최근에는 내부 동작을 항상 살펴보려고 한다.
그래서 오늘은 안드로이드에서 흔히 사용하지만 어떻게 동작하는지는 잘 몰랐던 Toast에 대해 알아보려한다.</p>
<hr>
<h2 id="1️⃣-toast는-context가-왜-필요할까">1️⃣ Toast는 Context가 왜 필요할까</h2>
<h3 id="☝️-system-service에-접근하기-위함">☝️ System Service에 접근하기 위함</h3>
<p>Toast는 WindowManager를 통해 화면에 표시되낟. 이때 Context에서 지원하는 getSystemService() 함수를 통해 WindowManager 객체를 가져와서 사용한다.</p>
<pre><code class="language-kotlin">private WindowManager getWindowManager(View view) {
    Context context = mContext.get();
    if (context == null &amp;&amp; view != null) {
        context = view.getContext();
    }
    if (context != null) {
        return context.getSystemService(WindowManager.class);
    }
    return null;
}

// e.g.
WindowManager.addView(toastView, layoutParams)</code></pre>
<blockquote>
<p>Window는 최상위 UI 컨테이너로 우리가 보는 모든 UI ( Activity, Dialog, Toast)등 모두 Window에 붙어 보여지게 된다.</p>
</blockquote>
<h3 id="✌️-resource에-접근하기-위함">✌️ Resource에 접근하기 위함</h3>
<p>Toast의 기본 위치, Gravity값, 레이아웃 등은 모두 System Resource에서 가져온다. System Resource는 Android OS 내부 리소스를 의미하며ㅡ 시스템 전체에서 공유한다. </p>
<pre><code class="language-java">mTN.mY = context.getResources().getDimensionPixelSize(
    com.android.internal.R.dimen.toast_y_offset);
mTN.mGravity = context.getResources().getInteger(
    com.android.internal.R.integer.config_toastDefaultGravity);</code></pre>
<h3 id="🤟-layout을-inflation하기-위함">🤟 Layout을 Inflation하기 위함</h3>
<p>Toast의 기본 텍스트 뷰를 생성할 때 LayoutInflater가 필요하며, 이 역시 Context를 통해 접근한다. </p>
<pre><code class="language-java">public static View getTextToastView(Context context, CharSequence text) {
    View view = LayoutInflater.from(context).inflate(TEXT_TOAST_LAYOUT, null);
    TextView textView = view.findViewById(com.android.internal.R.id.message);
    textView.setText(text);
    return view;
}</code></pre>
<hr>
<h2 id="2️⃣-io-스레드에서-toast를-띄우면">2️⃣ I/O 스레드에서 Toast를 띄우면?</h2>
<blockquote>
<p>결론부터 말하면 I/O 스레드에서 Toast를 띄우면 RuntimeException이 나면서 크래시가 발생한다.</p>
</blockquote>
<h3 id="💁-왜그럴까">💁 왜그럴까?</h3>
<pre><code class="language-java">/**
 * Constructs an empty Toast object.  If looper is null, Looper.myLooper() is used.
 * @hide
 */
public Toast(@NonNull Context context, @Nullable Looper looper) {
    mContext = context;
    mToken = new Binder();
    looper = getLooper(looper);
    mHandler = new Handler(looper);
    mCallbacks = new ArrayList&lt;&gt;();
    mTN = new TN(context, context.getPackageName(), mToken,
            mCallbacks, looper);
    mTN.mY = context.getResources().getDimensionPixelSize(
            com.android.internal.R.dimen.toast_y_offset);
    mTN.mGravity = context.getResources().getInteger(
            com.android.internal.R.integer.config_toastDefaultGravity);
}

private Looper getLooper(@Nullable Looper looper) {
    // Looper 검증 로직!
    if (looper != null) {
        return looper;
    }
    return checkNotNull(Looper.myLooper(),
            &quot;Can&#39;t toast on a thread that has not called Looper.prepare()&quot;);
}</code></pre>
<p>토스트는 내부적으로 Handler를 사용하여 처리하고 있다. Handler는 Looper가 있는 스레드에서 동작할 수 있는데, Main 스레드의 Looper는 앱이 시작되는 시점에 초기화가 이뤄지는 반면, I/O 스레드는 기본적으로 Looper가 초기화 되지 않는다. </p>
<pre><code class="language-java">public static @NonNull &lt;T&gt; T checkNotNull(
        final T reference,
        final @NonNull @CompileTimeConstant String messageTemplate,
        final Object... messageArgs) {
    if (reference == null) {
        throw new NullPointerException(String.format(messageTemplate, messageArgs));
    }
    return reference;
}</code></pre>
<p>그래서 Looper.myLooper()가 null을 반환하여 checkNotNull()에서 RuntimeException을 던진다.</p>
<p>따라서 Toast를 띄울려면 I/O 스레드에서 Main 스레드로 전환해야된다.</p>
<hr>
<h2 id="3️⃣-여러-앱에서-동시에-toast를-띄우면">3️⃣ 여러 앱에서 동시에 Toast를 띄우면?</h2>
<blockquote>
<p>결론부터 말하자면 여러앱에서 동시에 Toast를 띄우면 시스템이 전부 받아서 순서대로 보여준다. 즉, 겹쳐서 동시에 뜨지는 않는다.</p>
</blockquote>
<h3 id="왜-겹치지-않을까">왜 겹치지 않을까?</h3>
<p>Toast는 앱마다 따로 관리되는 게 아니라, 
시스템 레벨에서 중앙 관리되기 때문이다.</p>
<p>즉, 각 앱이 Toast를 호출하면 시스템에서 하나의 큐로 들어오게되고 
순차적으로 표시가 된다. 
이걸 관리하는 주체가 Android 시스템이다.</p>
<p>그래서 앱이 담당하는게 아니여서 앱이 종료가 되도 Toast는 유지가 될 수 있었던 것이다.</p>
<hr>
<h2 id="🌜-결론">🌜 결론</h2>
<p>내가 흔히 사용하던 Toast조차, 익숙함에 속아 더 깊이 알아보려 하지 않았다는 사실이 부끄럽게 느껴졌다.
Toast는 단순하고 쉬워 보이는 API였지만, 그 내부에는 Window, Looper, Handler 등 복잡한 시스템 레벨의 로직이 촘촘히 엮여 있었다.</p>
<p>이번 정리를 통해 다시 한 번 느낀 것은,
캡슐화는 복잡함을 없앤 것이 아니라, 잘 숨겨두었을 뿐이라는 사실이다.
그리고 그 안을 들여다보려는 노력이 결국 개발자의 깊이를 만든다는 것도.</p>
<p><strong>참조</strong>
<a href="https://velog.io/@mraz3068/Android-Toast-Deep-Dive">https://velog.io/@mraz3068/Android-Toast-Deep-Dive</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Effective Kotlin] 아이팀27 - 변화로부터 코드를 보호하려면 추상화를 사용하라]]></title>
            <link>https://velog.io/@dev_hyunwoo/Effective-Kotlin-%EC%95%84%EC%9D%B4%ED%8C%8027-%EB%B3%80%ED%99%94%EB%A1%9C%EB%B6%80%ED%84%B0-%EC%BD%94%EB%93%9C%EB%A5%BC-%EB%B3%B4%ED%98%B8%ED%95%98%EB%A0%A4%EB%A9%B4-%EC%B6%94%EC%83%81%ED%99%94%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%9D%BC</link>
            <guid>https://velog.io/@dev_hyunwoo/Effective-Kotlin-%EC%95%84%EC%9D%B4%ED%8C%8027-%EB%B3%80%ED%99%94%EB%A1%9C%EB%B6%80%ED%84%B0-%EC%BD%94%EB%93%9C%EB%A5%BC-%EB%B3%B4%ED%98%B8%ED%95%98%EB%A0%A4%EB%A9%B4-%EC%B6%94%EC%83%81%ED%99%94%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%9D%BC</guid>
            <pubDate>Sun, 18 Jan 2026 06:43:29 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/bde1f070-7afa-4767-a51c-f0b1e42966d4/image.png" alt=""></p>
<p><em>물 위를 걷는 것과 명세서로 소프트웨어를 개발하는 것은 쉽다. 둘 다 동결되어 있다면...</em>
<em>- 에드워드 V.베라드(Edward V. Berard)</em></p>
<p>함수와 클래스 등의 추상화로 실질적인 코드를 숨기면, 사용자가 세부 사항을 알지 못해도 괜찮다는 장점이 있다. 그리고 이후에 실질적인 코드를 원하는대로 수정할 수도 있다. 예를 들면 정렬 알고리즘을 함수로 추출하면, 이를 사용하는 코드에 어떠한 영향도 주지 않고, 함수의 성능을 최적화할 수 있다.</p>
<p>이번 절에서는 추상화를 통해 변화로부터 코드를 보호하는 행위가 어떤 자유를 가져오는지 살펴보자.</p>
<hr>
<h2 id="1️⃣-상수">1️⃣ 상수</h2>
<p>리터럴은 아무것도 설명하지 않는다. 따라서 코드에서 반복적으로 등장할때 문제가 된다. 
이러한 리터럴을 상수 프로퍼티로 변경하면 해당 값에 의미있는 이름을 붙일 수 있다.</p>
<p>비밀번호 유효성 검사하는 간단한 예를 살펴보자</p>
<pre><code class="language-kotlin">fun isPasswordValid(text : String) : Boolean {
    if(text.length &lt; 7) return false
    //...
}</code></pre>
<p>여기서 숫자 7은 아마도 &#39;비밀번호의 최소 길이&#39;를 나타내겠지만, 이해하는 데 시간이 걸린다. 상수로 빼내면 훨씬 쉽게 이해할 수 있을 겁니다. </p>
<pre><code class="language-kotlin">const val MIN_PASSWORD_LENGTH = 7

fun isPasswordValid(text : String) : Boolean {
    if(text.length &lt; MIN_PASSWORD_LENGTH) return false
    //...
}</code></pre>
<p>이렇게 하면 &#39;비밀번호의 최소 길이&#39;를 변경하기도 쉽다. 함수의 내부 로직을 전혀 이해하지 못해도, 상수의 값만 변경하면 된다.</p>
<hr>
<h2 id="2️⃣-함수">2️⃣ 함수</h2>
<p>애플리케이션을 개발하고 있는데, 사용자에게 토스트 메시지를 자주 출력해야하는 상황이 발생했다고 하자. 기본적으로 다음과 같은 코드를 사용해서 토스트 메시지를 출력한다.</p>
<pre><code class="language-kotlin">Toast.makeText(this, message, Toast.LENGTH_LONG).show()</code></pre>
<p>이렇게 많이 사용되는 알고리즘은 간단한 확장 함수로 만들어서 사용할 수 있다.</p>
<pre><code class="language-kotlin">fun Context.toast(
    message: String,
    duration: Int = Toast.LENGTH_LONG
) {
    Toast.makeText(this, messagem duration).show()
}

// 사용
context.toast(message)

// 액티비티 또는 컨텍스트의 서브클래스에서 사용할 경우
toast(message)</code></pre>
<p>이렇게 일반적인 알고리즘을 추출하면, 토스트를 출력하는 코드를 항상 기억해 두지 않아도 괜찮다. 또한 이후에 토스트를 출력하는 방법이 변경되어도, 확장 함수 부분만 수정하면 되므로 유지보수성이 향상된다.</p>
<p>만약 토스트가 아니라 스낵바라는 다른 형태의 방식으로 출력해야 한다면, 다음과 같이 스낵바를 출력하는 확장 함수를 만들고, 기존의 Context.toast()를 Context.snackbar()로 한꺼번에 수정하면 된다.</p>
<pre><code class="language-kotlin">fun Context.snackbar(
    message: String,
    length: Int = Toast.LENGTH_LONG
) {
    //...
}</code></pre>
<p>하지만 이러한 해결 방법은 좋지 않다. 내부적으로만 사용하더라도 함수의 이름을 직접 바꾸는 것은 위험할 수 있기 때문이다. 함수의 이름은 한꺼번에 바꾸기는 쉽지만, 파라미터는 한꺼번에 바꾸기가 어렵기 때문에 Toast.LENGTH_LONG이 계속 사용되는 문제도 있다.</p>
<p>따라서 메시지를 출력하는 더 추상적인 방법이 필요해보인다. 토스트 출력을 토스트라는 개념과 무관한 showMessage라는 높은 레벨의 함수로 옮겨보자</p>
<pre><code class="language-kotlin">fun Context.showMessage(
    message: String,
    duration: MessageLength = MessageLength.LONG
) {
    val toastDuration = when(duration) {
        SHORT -&gt; Length.LENGTH_SHORT
           LONG -&gt; Length.LENGTH_LONG
    }
    Toast.makeText(this, message, toastDuration).show()
}

enum class MessageLength { SHORT, LONG }</code></pre>
<p>가장 큰 변화는 이름이다. 일부 개발자는 이름 변경은 그냥 레이블을 붙이는 방식의 변화이므로, 큰 차이가 없다고 생각하지만 이러한 관점은 사실 컴파일러의 관점에서만 유효하다. 사람의 관점에서는 이름이 바뀌면 큰 변화가 일어난 것이다.</p>
<p>함수는 매우 단순한 추상화지만, 제한이 많다. 예를 들면 함수는 상태를 유지하지 않는다. 또한 함수 시그니처를 변경하면 프로그램 전체에 큰 영향을 줄 수 있다. 구현을 추상화 할 수 있는 더 강력한 방법으로는 클래스가 있다.</p>
<hr>
<h2 id="3️⃣-클래스">3️⃣ 클래스</h2>
<pre><code class="language-kotlin">class MessageDisplay(val context: Context) {
    fun show(
        message: String,
        duration: MessageLength = MessageLength.LONG
    ) {
        val toastDuration = when(duration) {
            SHORT -&gt; Length.SHORT
            LONG -&gt; Length.LONG
        }
        Toast.makeText(context, message, toastDuration).show()
    }

    enum class MessageLength { SHORT, LONG }

    // 사용
    val messageDisplay = MessageDisplay(context)
    messageDisplay.show(&quot;Message&quot;)
}</code></pre>
<p>클래스가 함수보다 더 강력한 이유는 상태를 가질 수 있으며, 많은 함수를 가질 수 있다는 점 때문이다. </p>
<p>의존성 주입 프레임워크를 사용하면, 클래스 생성을 위임할 수도 있다.</p>
<pre><code class="language-kotlin">@Inject lateinit var messageDisplay: MessageDisplay</code></pre>
<p>게다가 메시지를 출력하는 더 다양한 종류의 메서드를 만들 수도 있다.</p>
<pre><code class="language-kotlin">messageDisplay.setChristmasMode(true)</code></pre>
<p>이처럼 클래스는 훨씬 더 많은 자유를 보장해준다. 하지만 여전히 한계가 있다. 예를 들면 클래스가 final이라면, 해당 클래스 타입 아래에 어떤 구현이 있는지 알 수 있다. open 클래스를 활용하면 조금은 더 자유를 얻을 수 있다. open 클래스는 서브클래스를 대신 제공할 수 있기 때문이다. 더 많은 자유를 얻으려면, 더 추상적이게 만들면 된다. 바로 인터페이스 뒤에 클래스를 숨기는 방법이다.</p>
<hr>
<h2 id="4️⃣-인터페이스">4️⃣ 인터페이스</h2>
<p>코틀린 표준 라이브러리를 읽어보면, 거의 모든 것이 인터페이스로 표현된다는 것을 확인할 수 있을 것이다. 예를 들면 </p>
<ul>
<li>listOf 함수는 List를 리턴한다. 여기서 List는 인터페이스이다. listOf는 팩토리 메서드라고 할 수 있다.</li>
<li>컬렉션 처리 함수는 Iterable 또는 Collection의 확장 함수로서, List, Map등을 리턴한다. </li>
</ul>
<p>이것들이 모두 인터페이스 이다.</p>
<p>라이브러리를 만드는 사람은 내부 클래스의 가시성을 제한하고, 인터페이스를 통해 이를 노출하는 코드를 많이 사용한다. 이렇게 하면 사용자가 클래스를 직접 사용하지 못하므로, 라이브러리를 만드는 사람은 인터페이스만 유지한다면, 별도의 걱정 없이 자신이 원하는 형태로 그 구현을 변경할 수 있다. <strong>즉, 인터페이스 뒤에 객체를 숨김으로써 실질적인 구현을 추상화하고, 사용자가 추상화된 것에만 의존하게 만들 수 있다는 것이다. 즉, 결합(coupling)을 줄일 수 있는 것이다.</strong> </p>
<pre><code class="language-kotlin">interface MessageDisplay {
    fun show(
        message: String,
        duration: MessageLength = LONG
    )
}

class ToastDisplay(val context: Context) : MessageDisplay {
    override fun show(
        message: String,
        duration: MessageLength
    ) {
        val toastDuration = when(duration) {
            SHORT -&gt; Length.SHORT
            LONG -&gt; Length.LONG
        }
        Toast.makeText(context, message, tostDuration).show()
    }
}

enum class MessageLength { SHORT, LONG }</code></pre>
<p>이렇게 구성하면 더 많은 자유를 얻을 수 있다. 이러한 클래스는 태블릿에서 토스트를 출력하게 만들 수도 있고, 스마트폰에서도 출력하게 만들 수 있다. 또한 안드로이드, iOS, 웹에서 공유해서 사용하는 공통 모듈에서도 MessageDisplay를 사용할 수 있다. </p>
<p>마지막으로 선언과 사용이 분리되어 있으므로, ToastDisplay 등의 실제 클래스를 자유롭게 변경할 수 있다. 다만 사용 방법을 변경할려면, MessageDisplay 인터페이스를 변경하고, 이를 구현하는 모든 클래스를 변경해야 한다.</p>
<hr>
<h2 id="😅-후기">😅 후기</h2>
<p>추상화는 단순하게 중복성을 제거해서 코드를 구성하기 위한 것은 아닌 것 같다.
추상화는 코드를 변경해야 할 때 도움이 많이 된다고 생각하기 때문에, 
추상화를 사용하는 것은 굉장히 어렵지만, 이를 배우고 이해해야 추상화의 장단점의 균형을 찾을 수 있을 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Effective Kotlin] 아이템26 - 함수 내부의 추상화 레벨을 통일하라]]></title>
            <link>https://velog.io/@dev_hyunwoo/Effective-Kotlin-%EC%95%84%EC%9D%B4%ED%85%9C26-%ED%95%A8%EC%88%98-%EB%82%B4%EB%B6%80%EC%9D%98-%EC%B6%94%EC%83%81%ED%99%94-%EB%A0%88%EB%B2%A8%EC%9D%84-%ED%86%B5%EC%9D%BC%ED%95%98%EB%9D%BC</link>
            <guid>https://velog.io/@dev_hyunwoo/Effective-Kotlin-%EC%95%84%EC%9D%B4%ED%85%9C26-%ED%95%A8%EC%88%98-%EB%82%B4%EB%B6%80%EC%9D%98-%EC%B6%94%EC%83%81%ED%99%94-%EB%A0%88%EB%B2%A8%EC%9D%84-%ED%86%B5%EC%9D%BC%ED%95%98%EB%9D%BC</guid>
            <pubDate>Sun, 04 Jan 2026 07:58:23 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/b27387d7-d824-4185-ae40-af32a759a694/image.png" alt=""></p>
<h2 id="📚-서론">📚 서론</h2>
<p>컴퓨터는 굉장히 복잡한 장치이다. 이러한 복잡함이 여러 계층에 다양한 요소로서 분할되어 있으므로 쉽게 사용할 수 있는 것이다. 
즉, 개발자 관점에서 컴퓨터에서 가장 낮은 추상화 계층은 하드웨이다.
제어 명령은 0과 1로 이루어지지만 이를 쉽게 읽을 수 있게 일대일로 대응되는 어셈블리라는 언어로 표현한다. 하지만 어셈블리 언어로 프로그래밍하는 것은 굉장히 어렵고, 오늘날 우리가 사용하는 것과 같은 애플리케이션을 만드는 일은 상상도 못한다. 프로그래밍을 간단하게 할 수 있게, 엔지니어는 한 언어를 다른 언어로 변환해주는 프로그램인 컴파일러를 만들었다. 최초의 컴파일 언어는 어셈블리로 만들어졌다. 최초의 언어는 또 다른 고수준에 언어를 만들어냈고, 만들어낸 고수준에 언어는 또 더 높은 고수준에 언어를 만들어냈다. 그렇게 C, C++ 등의 높은 레벨 언어들이 만들어 진 것이다.</p>
<hr>
<h2 id="❕본론">❕본론</h2>
<h3 id="1️⃣-추상화-레벨">1️⃣ 추상화 레벨</h3>
<p>일반적으로 컴퓨터 과학자들은 어떤 계층이 높은 레벨인지 낮은 레벨인지를 구분한다. 높은 레벨로 갈수록 물리 장치로부터 멀어진다. 높은 레벨일수록 걱정해야 하는 세부적인 내용들은 적어진다. 하지만 좋은 것만은 아니다. 왜냐하면 단순함은 얻지만, 제어력은 잃게되기 때문이다. 예를 들어 C 언어는 메모리 관리를 직접 할수 있다. 하지만 자바는 GC가 자동으로 메모리를 관리해주기 때문에 최적화 하는것이 굉장히 힘들다.</p>
<h3 id="2️⃣-추상화-레벨-통일">2️⃣ 추상화 레벨 통일</h3>
<p>함수도 높은 레벨과 낮은 레벨을 구분해서 사용해야 한다는 원칙이 있다. 이를 <strong>추상화 레벨 통일 원칙</strong>이라고 부른다. 예를 들어 버튼 하나만 누르면 커피를 만들 수 있는 커피 머신을 생각해보자</p>
<pre><code class="language-kotlin">class CoffeeMachine {
    fun makeCoffee() {
        // 수백 개의 변
        // 복잡한 로직
        // 낮으 수준의 최적화 잔뜩!
    }
}</code></pre>
<p>이러한 코드는 오래된 프로그램에서 많이 볼 수 있다. 오늘날 우리는 모두 이런 코드의 문제점을 아주 잘 알고있다고 생각한다. 가장 큰 어려움은 유지보수이다. 저 코드에서 물의 온도를 수정해달라는 요청이 들어온다고 생각하면 끔찍하다. 모든 로직을 살펴본 뒤 정확히 그 부분을 수정해야된다. 그래서 최근에는 우리는 함수를 계층처럼 나누어서 사용하고 있다.</p>
<pre><code class="language-kotlin">class CoffeeMachine {
    fun makeCoffee() {
        boilWater()
        brewCoffee()
        pourCoffee()
        pourMilk()
    }
}</code></pre>
<p> 이제 이 함수가 대체 어떤 식으로 동작하는 지 확실하게 알 수 있다.
 누군가는 낮은 레벨을 이해해야 한다며느 해당 부분의 코드만 살펴보면 된다.
 매우 간다한 추상화를 추출해서 가독성을 크게 향상 시킨 것이다.</p>
<p> 이는 <strong>함수는 작아야하며, 최소한의 책임만을 가져야 한다</strong>라는 일반적인 규칙이다. 
 또한 어떤 함수가 다른 함수보다 좀 복잡하다면 일부 부분을 추출해서 추상화하는 것이 좋다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>입출력이 저수준의 모듈이고, 그걸 처리하는 비지니스 로직이 고수준의 모듈이라고 생각해보니깐
UI로직과 비지니스 로직을 얼마나 잘 분리할 수록 좋은 프로젝트 인것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Effective Kotlin] 2장 가독성]]></title>
            <link>https://velog.io/@dev_hyunwoo/Effective-Kotlin-2%EC%9E%A5-%EA%B0%80%EB%8F%85%EC%84%B1</link>
            <guid>https://velog.io/@dev_hyunwoo/Effective-Kotlin-2%EC%9E%A5-%EA%B0%80%EB%8F%85%EC%84%B1</guid>
            <pubDate>Sat, 20 Dec 2025 08:58:55 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/ad408376-7e93-4b84-bc75-f8b70b513b0e/image.png" alt=""></p>
<p>Item 11 ~ Item 18 까지 읽고 정리한 글 이다.
주로 가독성을 다룬 내용이고 코더 보다는 읽는 사람을 기준으로 좋은 코드를 설명하고 있다.</p>
<hr>
<h2 id="1️⃣-가독성을-목표로-설계하라">1️⃣ 가독성을 목표로 설계하라</h2>
<blockquote>
<p>개발자가 코드를 작성하는 데는 1분 걸리지만, 이를 읽는 데는 10분이 걸린다</p>
</blockquote>
<p>로버트 마틴의 <code>클린 코드</code>라는 책에서 나오는 유명한 이야기이다.</p>
<pre><code class="language-kotlin">// 구현 A
if (person != null &amp;&amp; person.isAdult) {
    view.showPerson(person)
} else {
    view.showError()
}

// 구현 B
person?.takeIf { it.isAdult }
    ?.let(view::showPerson)
    ? : view.showError()
</code></pre>
<p>두 코드를 살펴보면 극과 극이다. 난 솔직히 B를 보면 깔끔하군.. 이런 생각을 했었다.
하지만 A코드는 투박하지만 이해하기는 정말 쉬웠다. 
짧다고 B를 좀 더 선호했다면 좋은 대답이 아니다. </p>
<p>참고로 두 코드 같은 결과가 나올 것 같지만 <strong>아니다.</strong></p>
<p>B의 코드에서는 showPerson()이 null을 반환한다면 showError()가 호출될 것이기 때문이다.</p>
<p>B의 코드를 보면 직접 코드를 작성한 사람이 아니라면 이러한 오류는 찾기 힘들 것 이다. 그렇기 때문에 구현 A처럼 가독성을 중요시 하는 게 좋을 것 같다. 
(나부터 노력해야겠다..)</p>
<hr>
<h2 id="2️⃣-unit을-리턴하지-말라">2️⃣ Unit?을 리턴하지 말라</h2>
<p>마치 Boolean이 true 또는 false를 갖는 것처럼 Unit?도 Unit? 또는 null이라는 값을 가질 수 있다. 따라서 Boolean과 Unit?은 서로 바꿔서 사용할 수 있다. </p>
<pre><code class="language-kotlin">fun keyIsCorrect(key : String) : Boolean = //...

if(!keyIsCorrect(key)) return


----

fun verifyKey(key : String) : Unit? = //...
verifyKey(key) ?: return</code></pre>
<p>이러한 코드는 멋있게 보일 수도 있겠지만, 읽을 때는 전혀 그렇지 않다. Unit?으로 불을 표현하는 것은 오해의 소지가 있으며 예측하기 어렵다.</p>
<pre><code class="language-kotlin">getData()?.let{ view.showData(it) } ?: view.showError()</code></pre>
<p>getData()가 null이 아닌 값일 때 showData()가 null이면 showError()도 같이 호출되기 때문에 이런 코드보단 if-else 조건문을 사용하는 것이 훨씬 이해하기 쉽고 깔끔하다.</p>
<hr>
<h2 id="3️⃣변수-타입--리시버를-명시적으로-지정하라">3️⃣변수 타입 &amp; 리시버를 명시적으로 지정하라</h2>
<p>이 부분은 실무에서 정말 공감되는 이야기이다.
대다수의 개발자들이 처음부터 설계하고 작성한 개발자는 거의 없을 것이다.
회사를 옮기다보면 누군가 남겨놓은 레거시 코드를 다뤄야 한다.
이 때 대다수의 레거시 코드는 변수타입과 리비서를 명시적으로 지정하지 않아 읽기가 참 불편했던 경험이 많았다.</p>
<h3 id="변수-타입">변수 타입</h3>
<pre><code class="language-kotlin">
// 보통 실무에선 compose를 다룬다고 가정하면 이렇게 사용하는 게 많다.
@Composable
fun MainScreen() {
    val uiModel by viewModel.uiModel.collectAsStateWithLifecycle()
}

@Composable
fun SecondScreen() {
    val uiModel by viewModel.uiModel.collectAsStateWithLifecycle()
}</code></pre>
<p>다른 화면일지어도 정의하는 방식이 모두 동일하게 uiModel 또는 state 이런식으로 받는 경우가 많다.
그래서 해당 객체가 어떤 타입인지는 당연히  viewModel 들어가서 uiModel의 타입을 확인해야된다. </p>
<h3 id="리시버">리시버</h3>
<p>리시버는 진짜 지정해주지 않으면 내가 작성한 코드지만 며칠뒤에 보면 나도 헷갈린다.
그럼 다른 사람이 본다면 정말 하나도 이해하지 못할 것이며 분석하느라 힘을 다쓸 것이다.</p>
<pre><code class="language-kotlin">// 보통 나는 mapper에서 많이 경험 했던 것 같다.
fun Response.toPerson(
    friends : List&lt;String&gt;
) : Person {
    return Person(
        name = friends.find { it == this.name },
        ...
    )
}
</code></pre>
<p>실무에서 이렇게 간단하게 맵핑하진 않겠지만 정말 간단하게 예시를 들어봐도 
벌써 가독성이 떨어진다. 
it이 무엇일까를 볼려면 수신객체가 무엇인지를 봐야되고 수신객체의 타입을 볼려면 파라미터를 봐야된다. </p>
<pre><code class="language-kotlin">fun Response.toPerson(
    friends: List&lt;String&gt;
): Person {
    val me = this
    return Person(
        name = friends.find { friend -&gt; friend == me.name }
        ...
    )
}</code></pre>
<p>복잡한 코드였다면 리시버가 여러개일 것이다. 그때 리시버를 하나씩 지정해준다면
가독성이 훨씬 좋아질 것이다.</p>
<hr>
<h2 id="👮🏼-2장-가독성을-읽으면서">👮🏼 2장 가독성을 읽으면서..</h2>
<p>한국어를 못하는 사람에게 있어보이는 말을 해봤자 상대방은 하나도 못알아들을 것이다. 상대방이 이해할 수 있게 말하는 게 진짜 멋있는 거고 잘하는 것이라고 생각한다.
코드도 마찬가지인 것 같다. 
코드를 줄인다고 좋은 코드가 아니고, 멋있는 척을 하면 할수록 나는 좋은 코드에서 멀어지고 있었다. 오늘부터라도 이해하기 쉽게  <code>좋은 코드</code>를 작성하도록 노력해야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Effective Kotlin] 아이템3 - 최대한 플랫폼 타입을 사용하지 말라]]></title>
            <link>https://velog.io/@dev_hyunwoo/Effective-Kotlin-%EC%95%84%EC%9D%B4%ED%85%9C3-%EC%B5%9C%EB%8C%80%ED%95%9C-%ED%94%8C%EB%9E%AB%ED%8F%BC-%ED%83%80%EC%9E%85%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%A7%80-%EB%A7%90%EB%9D%BC</link>
            <guid>https://velog.io/@dev_hyunwoo/Effective-Kotlin-%EC%95%84%EC%9D%B4%ED%85%9C3-%EC%B5%9C%EB%8C%80%ED%95%9C-%ED%94%8C%EB%9E%AB%ED%8F%BC-%ED%83%80%EC%9E%85%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%A7%80-%EB%A7%90%EB%9D%BC</guid>
            <pubDate>Wed, 19 Nov 2025 09:04:53 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/0df473a4-cdd7-41fc-8e4f-2f55285a5f06/image.png" alt=""></p>
<p>Effective Kotlin을 읽고 정리하는 글이다.</p>
<h3 id="주제--💔-플랫폼-타입을-믿지말자">주제 : 💔 플랫폼 타입을 믿지말자</h3>
<h2 id="오늘의-목차">오늘의 목차</h2>
<h3 id="1플랫폼-타입이란">1.플랫폼 타입이란?</h3>
<h3 id="2-왜-사용하면-안될까">2. 왜 사용하면 안될까?</h3>
<hr>
<h2 id="一-플랫폼-타입이란">一 플랫폼 타입이란?</h2>
<h4 id="플랫폼-타입platform-type이란-다른-프로그래밍-언어에서-전달되어서-nullable인지-아닌지-알-수-없는-타입을-말한다">플랫폼 타입(platform type)이란, 다른 프로그래밍 언어에서 전달되어서 nullable인지 아닌지 알 수 없는 타입을 말한다.</h4>
<p>가끔 어떤 타입인지 볼 때 예를 들어 
String이라고 하면 2가지 정도에 타입으로 나눌 수 있다.
String은 notNull타입으로 볼 수 있고, String?은  nullable타입으로 볼 수 있다.
하지만 그것 이외에 String!을 봤던 적이 있었을 것이다.
이 타입이 플랫폼 타입이다.</p>
<hr>
<h2 id="⼆-왜-사용하면-안될까">⼆ 왜 사용하면 안될까?</h2>
<p>보통은 코틀린에서 자바 함수를 사용 할 때 </p>
<pre><code class="language-java">public class UserRepo {
    public class UserRepo {
        public @NotNull User getUser1() {
            //...
        } 
        public @Nullable User getUser2() {
            //...
        }
    }
}</code></pre>
<h4 id="notnull-또는-nullable이라는-어노테이션을-통해-코틀린에서-사용할-때-nullable타입인지-아닌지를-파악할-수-있다-하지만-이런-어노테이션과-주석-설명이-없으면-사용자-입장에선-어떤타입인지-알기-힘들다">@NotNull 또는 @Nullable이라는 어노테이션을 통해 코틀린에서 사용할 때 nullable타입인지 아닌지를 파악할 수 있다. 하지만 이런 어노테이션과 주석 설명이 없으면 사용자 입장에선 어떤타입인지 알기 힘들다.</h4>
<h4 id="그럴-때-npe를-조심해야된다">그럴 때 NPE를 조심해야된다.</h4>
<pre><code class="language-java">public class JavaClass {
    public String getValue() {
        return null;
    }
}</code></pre>
<pre><code class="language-kotlin">fun statedType() {
    val value : String = JavaClass().value // 여기서 NPE 발생 !!
    //...
    println(value.length)
}


fun platformType() {
    val value = JavaClass().value
    //...
    println(value.length) // 여기서 NPE 발생 !!
}</code></pre>
<p>두가지 모두 NPE가 발생하지만 위치에 차이가 있다.</p>
<p>statedType에서는 자바에서 값을 가져올 때 NPE가 발생한다. 그래서 이 위치에서 오류가 발생하면 nullable 타입이라는 걸 쉽게 알 수 있다. </p>
<p>하지만 platformType에서는 값을 활용할 때 NPE가 발생한다. 예시에서는 간단한 표현식이지만
복잡한 표현식에서는 현재 변수가 nullable인지 파악하기 힘들 것 이다. 현재 변수를 안전하게 한두 번 사용했더라도, 이후에 다른 개발자가 사용할 때 nullable인지 인지를 못하면 또 NPE가 발새될 것이기 때문이다.</p>
<hr>
<h2 id="후기">후기</h2>
<p>플랫폼 타입을 사용하는 코드가 있다면 제거하는게 좋아보인다.
현재에는 인지하더라도 추후 유지보수하는 개발자가 인지하기가 어려워보이기 때문이다.
그리고 자바 코드에 @NotNull, @Nullable 같은 어노테이션을 활용하는 것도 좋아보인다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Effective Kotlin] 아이템1 - 가변성을 제한하라]]></title>
            <link>https://velog.io/@dev_hyunwoo/Effective-Kotlin-%EC%95%84%EC%9D%B4%ED%85%9C1-%EA%B0%80%EB%B3%80%EC%84%B1%EC%9D%84-%EC%A0%9C%ED%95%9C%ED%95%98%EB%9D%BC</link>
            <guid>https://velog.io/@dev_hyunwoo/Effective-Kotlin-%EC%95%84%EC%9D%B4%ED%85%9C1-%EA%B0%80%EB%B3%80%EC%84%B1%EC%9D%84-%EC%A0%9C%ED%95%9C%ED%95%98%EB%9D%BC</guid>
            <pubDate>Sun, 16 Nov 2025 08:19:08 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/aeeb69ef-7efd-4df2-9c48-0665a8d085a6/image.png" alt=""></p>
<p>Effective Kotlin을 읽고 정리하는 글이다.</p>
<h3 id="주제--😇-코틀린에서-가변성을-제한하는-방법이-있다">주제 : 😇 코틀린에서 가변성을 제한하는 방법이 있다.</h3>
<h2 id="오늘의-목차">오늘의 목차</h2>
<h3 id="1-읽기-전용-프로퍼티-val">1. 읽기 전용 프로퍼티 (val)</h3>
<h3 id="2-가변-컬렉션과-읽기-전용-컬렉션-구분하기">2. 가변 컬렉션과 읽기 전용 컬렉션 구분하기</h3>
<h3 id="3-데이터-클래스의-copy">3. 데이터 클래스의 copy</h3>
<hr>
<h2 id="㆒-읽기-전용-프로퍼티">㆒ 읽기 전용 프로퍼티</h2>
<p>코틀린에서는 val를 사용하면 읽기 전용 프로퍼티를 만들수 있다. </p>
<pre><code class="language-kotlin">val a = 10
a = 20 // 오류!</code></pre>
<p>하지만 읽기 전용 프로퍼티가 완전히 변경 불가능한 것은 아니라는 것을 주의해야된다.
읽기 전용 프로퍼티가 mutable 객체를 담고 있다면, 내부적으로 변할 수 있다.</p>
<pre><code class="language-kotlin">val list = mutableListOf(1,2,3)
list.add(4)

print(list) // [1,2,3,4]</code></pre>
<p>또 다른 경우가 있는데 게터를 정의할 때 var 프로퍼티를 사용하면 var 프로퍼티가 변경될 때 변할 수 있다.</p>
<pre><code class="language-kotlin">var name : String = &quot;Woo&quot;
var SurName : String = &quot;Kim&quot;

val fullName 
    get() = &quot;$SurName $name&quot;

println(fullName) // &quot;Kim Woo&quot;
name = &quot;Boo&quot;
println(fullName) // &quot;Kim Boo&quot;</code></pre>
<hr>
<h2 id="二-가변-컬렉션과-읽기-전용-컬렉션-구분하기">二 가변 컬렉션과 읽기 전용 컬렉션 구분하기</h2>
<p>위에 설명과 마찬가지로 코틀린에선 읽고 쓸 수 있는 컬렉션과 읽기 전용 컬렉션이 있다.
<img src="https://velog.velcdn.com/images/dev_hyunwoo/post/5a09c8cf-90c7-4c77-88f3-91a6bd9285ab/image.png" alt="">
코틀린을 공부하다가 이런 이미지는 많이 봐왔었다.</p>
<p>mutable이 붙은 인터페이스는 대응되는 읽기 전용 인터페이스를 상속 받아서, 변경을 위한 메서드를 추가한 것이다. </p>
<pre><code class="language-kotlin">inline fun &lt;T,R&gt; Iterable&lt;T&gt;.map(
    transformation: (T) -&gt; R
) : List&lt;R&gt; {
    val list = ArrayList&lt;R&gt;()
    for (elem in this) {
        list.add(transformaiton(elem))
    }
    return list
}</code></pre>
<p>Iterable.map() 코드를 보면 진짜 불변하게 만들지 않고 읽기 전용으로 설계하였다.
이로 인해서 더 많은 자유를 얻을 수 있다. 
가변으로 동작하여 메모리나 접근 자체에 대한 효율을 높이고
사용시에는 읽기 전용으로 안정성을 높일 수 있기 때문이다.</p>
<p>하지만 다운캐스팅을 위반해서 사용하는 일이 있을 수 있다. </p>
<pre><code class="language-kotlin">val list = listOf(1,2,3)

// 이렇게 사용 금지!!
if(list is MutableList) {
    list.add(4)
}</code></pre>
<h4 id="읽기전용으로-리턴하면-그-객체는-읽기-전용으로만-사용해야-된다">읽기전용으로 리턴하면 그 객체는 읽기 전용으로만 사용해야 된다!</h4>
<hr>
<h2 id="㆔-데이터-클래스의-copy">㆔ 데이터 클래스의 copy</h2>
<p>String이나 Int처럼 내부적인 상태를 변경하지 않는 immutable 객체를 많이 사용하면 여러 장점이 있다.</p>
<h4 id="1️⃣-한-번-정의된-상태가-유지되므로-코드를-이해하기-쉽다">1️⃣ 한 번 정의된 상태가 유지되므로, 코드를 이해하기 쉽다.</h4>
<h4 id="2️⃣-immutable-객체는-공유했을-때도-충돌이-따로-이루어지지-않으므로-병렬-처리를-안전하게-할-수-있다">2️⃣ immutable 객체는 공유했을 때도 충돌이 따로 이루어지지 않으므로, 병렬 처리를 안전하게 할 수 있다.</h4>
<h4 id="3️⃣-immutable-객체에-대한-참조는-변경되지-않으므로-쉽게-캐시할-수-있다">3️⃣ immutable 객체에 대한 참조는 변경되지 않으므로, 쉽게 캐시할 수 있다.</h4>
<h4 id="4️⃣-immutable-객체는-방어적-복사본을-만들-필요가-없다">4️⃣ immutable 객체는 방어적 복사본을 만들 필요가 없다.</h4>
<h4 id="5️⃣-immutable-객체는-다른-객체를-만들-때-활용하기-좋다">5️⃣ immutable 객체는 다른 객체를 만들 때 활용하기 좋다.</h4>
<h4 id="6️⃣-immutable-객체는-set-or-map의-키로-사용할-수-있다">6️⃣ immutable 객체는 set or map의 키로 사용할 수 있다.</h4>
<ul>
<li>mutable객체는 사용 불가능하다. set 과 map이 내부적으로 해시 테이블을 사용하고, 해시 테이블은 처음 요소를 넣을 때 요소의 값을 기반으로 버킷을 결정하기 때문에 수정이 발생하면 해시 테이블 내부에서 요소를 찾지 못한다.</li>
</ul>
<pre><code class="language-kotlin">data class User(
    val name : String,
    val surName : String
)

var user = User(&quot;Woo&quot;, &quot;Kim&quot;)
user = user.copy(surName = &quot;Park&quot;)
print(user) // User(name=woo, surName=Park)</code></pre>
<p>이렇게 data 한정자는 copy라는 메서드를 만들어 주기 때문에 데이터 모델 클래스를 만들어 immutable 객체로 만드는 것이 많은 장점을 가지므로 기본적으로 이렇게 사용하는 것이 좋다.</p>
<hr>
<h2 id="＋추가-변경-가능-지점을-줄여라">＋추가) 변경 가능 지점을 줄여라</h2>
<pre><code class="language-kotlin">val list1 : MutableList&lt;Int&gt; = mutableListOf()
var list2 : List&lt;Int&gt; = listOf()

list1.add(1)
list2 = list2 + 1</code></pre>
<p>list1과 list2의 동작은 모두 정상적이지만 장단점이 있다.
두가지 모두 변경 가능 지점이 있지만 그 위치가 다르다.</p>
<p>list1은 리스트 구현 내부에 변경 가능 지점이 있고 멀티스레드 처리가 이루어질 경우, 내부적으로 적절한 동기화가 되어 있는지 확실하게 알 수 없다.</p>
<p>list2는 프로퍼티 자체가 변경 가능 지점이다. 따라서 멀티스레드 처리에 안정성이 더 좋다고 할 수 있다. </p>
<p>mutable 컬렉션을 사용하는 것이 더 간단하지만, mutable 프러퍼티를 사용하면 객체 변경을 제어하기 더 쉽다.</p>
<h3 id="❌--그래서-최악에-방식은-프로퍼티와-컬렉션-모두-변경-가능한-지점을-만드는-것이다">❌  그래서 최악에 방식은 프로퍼티와 컬렉션 모두 변경 가능한 지점을 만드는 것이다.</h3>
<pre><code class="language-kotlin">// 최악 !!!
var list3 = mutableListOf&lt;Int&gt;()</code></pre>
<h2 id="추가-변경-가능-지점을-노출하지-말기">+추가) 변경 가능 지점을 노출하지 말기</h2>
<pre><code class="language-kotlin">data class User(val name : String)

class UserRepository {
    private val storedUser : MutableMap&lt;Int, String&gt; = 
        mutableMapOf()

    fun loadAll() : MutableMap&lt;Int, String&gt; {
        return storedUsers
    }
}</code></pre>
<p>상태를 나타내는 mutable 객체를 외부에 노출하는 것은 굉장히 위험하다.</p>
<pre><code class="language-kotlin">val userRepository = UserRepository()

val storedUsers = userRepository.loadAll()
storedUsers[4] = &quot;Woo&quot;

print(userRepository.loadAll()) // {4=Woo}</code></pre>
<p>이렇게 loadAll()을 사용해서 private 상태인 UserRepository를 외부에서 변경할 수 있게된다.</p>
<h2 id="이를-방지하는-방법">이를 방지하는 방법</h2>
<h3 id="방어적-복제">방어적 복제</h3>
<pre><code class="language-kotlin">class UserHolder {
    private val user : MutableUser()

    fun get() : MutableUser {
        return user.copy()
    }
}</code></pre>
<h3 id="업캐스트">업캐스트</h3>
<pre><code class="language-kotlin">data class User(val name : String)

class UserRepository {
    private val storedUser : MutableMap&lt;Int, String&gt; = 
        mutableMapOf()

    fun loadAll() : Map&lt;Int, String&gt; {
        return storedUsers
    }
}</code></pre>
<p>loadAll()의 반환값을 MutableMap -&gt; Map으로 업캐스트하여 가변성을 제어할 수 있다.</p>
<hr>
<h2 id="후기">후기</h2>
<p>내가 개발을 하면서 이러한 부분은 고려하지 않은 것 같다.
var보단 val을 선호하며
mutable컬렉션을 선호하기보단 mutable프로퍼티를 선호해야겠다.</p>
<p>끝.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android]TFT API 이용해서 전적 검색 앱 만들기 - 5 (마무리)]]></title>
            <link>https://velog.io/@dev_hyunwoo/AndroidTFT-API-%EC%9D%B4%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%A0%84%EC%A0%81-%EA%B2%80%EC%83%89-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-5-%EB%A7%88%EB%AC%B4%EB%A6%AC</link>
            <guid>https://velog.io/@dev_hyunwoo/AndroidTFT-API-%EC%9D%B4%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%A0%84%EC%A0%81-%EA%B2%80%EC%83%89-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-5-%EB%A7%88%EB%AC%B4%EB%A6%AC</guid>
            <pubDate>Tue, 11 Nov 2025 04:38:08 GMT</pubDate>
            <description><![CDATA[<p>TFTLog앱에 관한 마지막 글이다. 앞으로 기능개발이나 유지보수는 꾸준히 하겠지만 한동한은 플러터로 앱을 만드느라 당분간은 쉽지 않을 것 같다 ㅎㅎ</p>
<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/66431f14-cc21-4ba6-8f16-587d3d35ab8b/image.png" alt=""></p>
<hr>
<h2 id="🔍-기능-추가">🔍 기능 추가</h2>
<p>우선 <a href="https://velog.io/@dev_hyunwoo/AndroidTFT-API-%EC%9D%B4%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%A0%84%EC%A0%81-%EA%B2%80%EC%83%89-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-4">이전 글</a>을 기준으로 배포를 할려고 했으나 다소 완성도가 낮아 기본적으로 내가 쓰면서 불편했던 점을 보완 및 추가하여 배포하기로 하였다.</p>
<h3 id="1-전적-검색-기록-추가">1. 전적 검색 기록 추가</h3>
<p>내가 검색했던 유저 또는 내가 클릭해서 들어간 유저의 프로필을 맨 첫번째 화면(검색 전)에 보여주기로 하였다. 그래서 여기선 Room을 이용하여 저장하고 가져오는 방식으로 정했다. 그리고 해당 유저의 프로필을 클릭하면 전적 검색을 할 수 있도록 구현하였다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/67bf3521-abaa-4c80-b8ee-1c92a2855e60/image.gif" alt=""></p>
<h3 id="2-뒤로가기-추가">2. 뒤로가기 추가</h3>
<p>첫번째 화면, 즉 검색 전 화면이 추가됐으므로 사용자 입장에선 2가지 화면이 존재하는 것이다. (물론 개발자에 입장에선 하나의 화면이다.)
<strong>1.검색했던 유저 프로필 히스토리 화면
2.전적검색 화면</strong>
그래서 2번화면에서 1번화면으로 갈 수 있는 방법을 추가해야겠다고 생각했다. 원래 os백키를 생각했겠지만 것보다 더 직관적으로 만들수 있는 방법을 찾다가 FAB로 간단하면서 직관적인 방법을 택했다. </p>
<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/83812d89-3b8c-486e-bb88-8db35659e971/image.gif" alt=""></p>
<hr>
<h2 id="🧑💻-배포까지">🧑‍💻 배포까지..</h2>
<p>배포를 많이 해왔던 입장에선 딱히 어려움은 없었다. 
개인정보처리방침 링크를 생성하는 것도, 스크린샷을 만드는 것도 모두 순조롭게 처리해갔다.</p>
<p>TFT로그에는 Riot의 api key가 필요하다. 이 api key가 없으면 말그대로 깡통 같은 앱이다. 근데 나는 서버를 구축하지 않았기에 api key가 서버 드리븐이 아닌 하드코딩으로 넣어버렸다...
여기서 한가지 문제가 생겼다. 
Riot에서는 테스트 api key를 주는데 24시간만 유효한다는 점이다. 그래서 테스트 api key 대신 프로덕트 key를 받아야되는데 Riot에선 앱을 등록하고 그 url을 전달해야만 프로덕트 key를 발급해줬다.... 
근데 플레이스토어에 올리기 위해선 심사를 거쳐야되는데 거의 24시간 이상이다. 그래서 24시간 이내 심사까지 완료되야 배포를 할 수 있다..  결론은 리젝을 당했고 방법을 찾다가
비공개 테스트를 먼저 올리면 나중에 심사할 때 빨라진다는 글을 보고 비공개 테스트부터 릴리즈하고 그 다음 api key를 갈아끼운 후 심사를 넣었더니 거의 12시간만에 심사 통과가 되었다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/d422b005-647f-41e0-b4fd-9d24d7e44df3/image.png" alt=""></p>
<h4 id="⭐️드디어-게시완료⭐️">⭐️드디어 게시완료..!!⭐️</h4>
<p><a href="https://play.google.com/store/apps/details?id=com.woo.tft_log">스토어 링크</a> 입니다 많관부!</p>
<p><del>(프로덕트 키 받기전까진 안될거에요.. 거의 일주일 걸린다고 합니다. 현재 날짜 기준 11/11)</del></p>
<hr>
<h2 id="😊-후기">😊 후기</h2>
<p>앱을 처음부터 만들다보니 내가 미흡했던 부분도 많이 배웠던 것 같다.
특히 mvi 아키텍쳐랑 앱 아키텍쳐를 직접 설계하고 써보니 왜 이렇게 사용하는지를 파악할 수 있었고 다른 jetpack 기술들도 많이 접해보니 아직 나는 부족한 것 같다.</p>
<p>더욱더 발전해나아가야겠다는 원동력을 얻은 기회였던 것 같다.</p>
<h3 id="끝">끝</h3>
]]></description>
        </item>
        <item>
            <title><![CDATA[[플러터] Stateless Widget 과 Stateful Widget 이란?]]></title>
            <link>https://velog.io/@dev_hyunwoo/%ED%94%8C%EB%9F%AC%ED%84%B0-Stateless-Widget-%EA%B3%BC-Stateful-Widget-%EC%9D%B4%EB%9E%80</link>
            <guid>https://velog.io/@dev_hyunwoo/%ED%94%8C%EB%9F%AC%ED%84%B0-Stateless-Widget-%EA%B3%BC-Stateful-Widget-%EC%9D%B4%EB%9E%80</guid>
            <pubDate>Sun, 02 Nov 2025 04:47:00 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/6d38b82d-7529-4018-9617-1b3c6661ede2/image.png" alt=""></p>
<p>안드로이드를 네이티브로 3년동안 개발하면서 플러터에 존재는 강하게 느껴왔다.
그래서 일을 하면서도 플러터로 앱을 취미로 개발하기도 하였다. 
근데 정작 개념을 알기보다는 무작정 앱을 만들려고 하다보니 개념쪽으로 더 알아가고 싶다고 느껴서 이번 기회에 플러터에 대해 하나씩 공부해볼려고 한다.</p>
<hr>
<h2 id="👁️-widget이란">👁️ Widget이란?</h2>
<p>플러터에서 Widget은 Jetpack Compose의 @Composable 함수에서 반환되는 UI 단위와 비슷한 개념이다. 플러터에 모든 ui는 Widget으로 이루어져 있다.</p>
<hr>
<h2 id="🤔-stateless-widget이란">🤔 Stateless Widget이란?</h2>
<p>Stateless Widget이란 상태를 가지지 않는 위젯이다.
데이터가 변하지 않고 사용자와의 인터렉션이 필요없는 경우 사용한다.</p>
<p><code>StatelessWidget</code>을 상속받아서 <code>build()</code>라는 메서드를 오버라이드 해야된다.</p>
<p><code>build()</code>에선 화면에 표시될 렌더링을 반환한다.</p>
<hr>
<h2 id="🥹-stateful-widget이란">🥹 Stateful Widget이란?</h2>
<p>Stateful Widget은 반대로 상태를 가지는 위젯이다.
사용자와 인터렉션을 통해 ui가 변경되기도 하며 또 데이터가 변할때도 ui가 변한다.</p>
<p>Stateful Widget을 사용할 때는 우선 <code>StatefulWidget</code>을 상속을 받는다.
그 다음 클래스는 <code>State&lt;T&gt;</code> 상속을 받는다.
첫번째 클래스가 상속받는 <code>StatefulWidget</code>은 <code>State&lt;T&gt;</code> 객체를 생성하는  <code>createState()</code> 메서드를 오버라이드 해야된다.</p>
<p>두번째 클래스에서 상속받는 <code>State&lt;T&gt;</code>에서는 <code>build()</code>라는 메서드를 오버라이드 해야되는데 이때 두번째 클래스는 <code>State&lt;T&gt;</code>을 반환하기 때문에 <code>createState()</code>자리에 들어가서 2개의 클래스가 연결된다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android]TFT API 이용해서 전적 검색 앱 만들기 - 4]]></title>
            <link>https://velog.io/@dev_hyunwoo/AndroidTFT-API-%EC%9D%B4%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%A0%84%EC%A0%81-%EA%B2%80%EC%83%89-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-4</link>
            <guid>https://velog.io/@dev_hyunwoo/AndroidTFT-API-%EC%9D%B4%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%A0%84%EC%A0%81-%EA%B2%80%EC%83%89-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-4</guid>
            <pubDate>Mon, 20 Oct 2025 07:58:17 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@dev_hyunwoo/AndroidTFT-API-%EC%9D%B4%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%A0%84%EC%A0%81-%EA%B2%80%EC%83%89-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-3">이전글</a>
4탄에서 해결하겠다고한 문제점은 해결 못했다. 방법을 모르겠다. 공식 문서에 있는 메타데이터를 이용하여 챔피언 ID값을 매칭시켜서 가져온 것 뿐인데 없는 정보들이 있다. 그건 아마 라이엇에 실수라고 생각할려고 한다.. </p>
<h3 id="이번-글에-목차">이번 글에 목차</h3>
<ul>
<li>JetpackPaging3를 적용</li>
<li>서치바를 Sticky Header에서 반응형으로 변경</li>
<li>유저의 프로필 추가</li>
</ul>
<hr>
<h2 id="📃-jetpack-paging3">📃 Jetpack Paging3</h2>
<h3 id="💉-적용한-계기">💉 적용한 계기</h3>
<p>왜 모든 전적검색 앱들이 웹뷰를 사용하는 지 알 것 같다. 왜냐하면 매치정보를 보여주는 화면에서 보여줘야될게 너무 많았다. 챔피언, 특성, 아이템등등 렌더링 할 게 너무 많으니 앱에서 구동하기엔 너무 벅찼다. 그래서 나는 모든 기능과 화면을 앱으로 만들고 있어서 최대한 렌더링을 줄일 수 있는 방법을 찾았지만 꼭 필요한 기능이라 줄일 수가 없었다. 그래서 초기에 렌더링에 방해되지 않게 최소한에 데이터를 가져오는 게 나의 최선인 것 같았다. 그래서 페이징을 이용하여 최소한에 데이터를 초기에 보여주기 위해 적용했다.</p>
<h3 id="👿-적용">👿 적용</h3>
<p>매치id를 list로 가져오는 api가 있고 그 매치id로 매치정보를 가져오는 api가 별도로 있었다. 그래서 나는 구조적으로 매치id를 가져오는 기능에 페이징을 적용하여 그 값으로 페이징에서 load를 할 때 마다 매치정보를 가져온 후 매핑까지 해줘서 그 값을 페이징 데이터로 Flow형태로 내보내기로 하였다.</p>
<h3 id="matchitempagingsourcekt">MatchItemPagingSource.kt</h3>
<pre><code class="language-kotlin">   override suspend fun load(params: LoadParams&lt;Int&gt;): LoadResult&lt;Int, MatchEntity&gt; {
        return try {
            val currentPage = params.key ?: 1
            val matchIds = tftRepository.getMatchIdsByPuuid(
                start = (currentPage - 1) * PAGE_SIZE, count = PAGE_SIZE, puuid = puuid
            )
            val nextPage = if (matchIds.size != 5) null else currentPage + 1
            val prevPage = if (currentPage == 1) null else currentPage - 1
            val data = matchIds.mapNotNull { matchId -&gt;
                tftRepository.getMatchEntity(puuid = puuid, matchId = matchId)
            }
            LoadResult.Page(
                data = data, nextKey = nextPage, prevKey = prevPage
            )
        } catch (t: Throwable) {
            LoadResult.Error(throwable = t)
        }
    }</code></pre>
<p>load만 가져와봤다.PAGE_SIZE는 5로 설정하였다.  puuid로 매치Id를 리스트로 가져온 후
매치Id로 매치정보를 가져오는 로직이다. getMatchEntity에서는 매치정보를 가져온 후 Entity로 맵핑하여 반환해주는 로직이다.</p>
<h3 id="pagingrepositoryimplkt">PagingRepositoryImpl.kt</h3>
<pre><code class="language-kotlin">override suspend fun getMatchPagingData(puuid: String): Flow&lt;PagingData&lt;MatchEntity&gt;&gt; {
        return Pager(
            config = PagingConfig(
                pageSize = PAGE_SIZE,
                initialLoadSize = PAGE_SIZE,
                prefetchDistance = 1,
                enablePlaceholders = false
            ), pagingSourceFactory = {
                MatchItemPagingSource(
                    tftRepository = tftRepository, puuid = puuid
                )
            }
        ).flow
    }</code></pre>
<p>Pager를 만들어 반환하는 로직이다. 우선 pageSize는 아까 정의한 5를 넣고,
초기 로드하는 크기도 5로 하였다. 어차피 화면에 보이는 아이템은 기기마다 다르겠지만 5를 안넘길 것 같아서 그렇게 설정하였다. prefetchDistance 이 속성은 몇개 남았을 때 다음 페이지를 불러올 것인가를 정의하는 건데 나는 최소한에 페이지를 불러오는 게 목표이기에 최소값인 1를 설정해주었다. </p>
<h3 id="mainviewmodelkt">MainViewModel.kt</h3>
<pre><code class="language-kotlin">    private val _puuid = MutableStateFlow&lt;String?&gt;(null)

    @OptIn(ExperimentalCoroutinesApi::class)
    val matchListFlow: Flow&lt;PagingData&lt;MatchEntity&gt;&gt; = _puuid.filterNotNull().flatMapLatest {
        pagingRepository.getMatchPagingData(it)
    }.cachedIn(viewModelScope)</code></pre>
<p>이제 viewModel에서 소비 할 차례이다. 우선 _puuid가 바뀔 때 마다 불러오도록하였고 마지막에 cachedIn이라는 메서드를 통해 viewModelScope의 생명주기동안에 캐싱할 수 있도록 설정해주었다. 그렇게 해야 화면회전을 해도 재생성되지 않고 유지할 수 있다.</p>
<h3 id="mainscreenkt">MainScreen.kt</h3>
<pre><code class="language-kotlin">     val matchPagingData = viewModel.matchListFlow.collectAsLazyPagingItems()
    val isLoadingAppend = matchPagingData.loadState.append is LoadState.Loading
    val isLoadingRefresh by remember {
        derivedStateOf {
            matchPagingData.loadState.refresh is LoadState.Loading &amp;&amp; state.hasSearch
        }
    }

// MatchItemComponent.kt
fun LazyListScope.matchItemsComponent(
    matchItems: LazyPagingItems&lt;MatchEntity&gt;,
    onClickID: (Participant) -&gt; Unit
) {
    items(
        count = matchItems.itemCount,
        key = matchItems.itemKey { it.gameId },
        contentType = { matchItems[it] }) { index -&gt;
        val matchItem = remember { matchItems[index] }
        matchItem?.let {
            MatchItem(matchItem = matchItem, onClickID = onClickID)
        }
    }
}</code></pre>
<p>페이징 데이터를 LazyPagingItems으로 변환시켜주는 collectAsLazyPagingItems을 사용하여 
LazyColumn안에서 적용하면 된다. pagingData로 초기 로딩과 아이템을 새로 가져올 때를 알 수 있기에 적절하게 로딩을 띄워 줄 수가 있다. items를 이용할 때 아직은 lazyPagingItems를 지원해주는 게 없기 때문에 count를 이용하여 index를 받아와서 직접 가져와야 된다. </p>
<hr>
<h2 id="🔍-searchbar-sticky-header---반응형으로-변경">🔍 SearchBar Sticky Header -&gt; 반응형으로 변경</h2>
<h3 id="🤦🏻♂️-변경한-이유">🤦🏻‍♂️ 변경한 이유</h3>
<p>흠.. 그냥 sticky header를 썼는데 뒤에 남은 필요없는 공간들이 답답하게 느껴졌다. 그래서 공부도 할겸 반응형으로 변경하기로 하였다 </p>
<h3 id="💉-적용">💉 적용</h3>
<p>우선 기존에 Sticky Header를 그냥 item으로 바꿔서 column에 최상단에 위치하도록 하였다.</p>
<pre><code class="language-kotlin">                item {
                    Box(
                        modifier = Modifier
                            .background(color = AppColors.PrimaryColor)
                            .padding(bottom = 10.dp)
                    ) {
                        MainTopbar(
                            onClickSearch = {
                                viewModel.setEvent(
                                    MainContract.Event.OnClickSearch(
                                        it
                                    )
                                )
                            },
                            textFieldState = textFieldState,
                            currentText = currentText
                        )
                    }
                }</code></pre>
<p>그 뒤에 이제 맨 상단에 아이템이 기존에 서치바이기 때문에 나는 이 서치바가 안보이면 반응형 서치바를 보이도록 하고 서치바가 화면에 보이는 순간 반응형 서치바를 숨기도록 할 것이다.
즉, 2개의 서치바가 있다고 생각하면 된다. 그래서 같은 상태를 보여주기위해 기존에 MainTopbar에 있던 로컬 변수들을 모두 상태 호이스팅하여 최상단에서 관리해줬다. 그래서 같은 상태를 유지할 수 있도록 하였다.</p>
<pre><code class="language-kotlin">    val lazyListState = rememberLazyListState()
    val showReactiveBar by remember {
        derivedStateOf { lazyListState.firstVisibleItemIndex &gt; 0 }
    }</code></pre>
<p>lazyListState를 생성한 뒤 최상단에 있는 서치바가 안보이면 showReactiveBar를 true로 하여 반응형 바를 노출시켜줄 것이다.</p>
<pre><code class="language-kotlin">         Box(contentAlignment = Alignment.TopCenter) {
            LazyColumn(
                modifier = Modifier.padding(paddingValues),
                state = lazyListState,
                contentPadding = PaddingValues(vertical = 10.dp)
            ) {
            // ...
            } // LazyColumn
            if (showReactiveBar) {
                MainTopbar(
                    modifier = Modifier.padding(top = 10.dp),
                    onClickSearch = {
                        viewModel.setEvent(
                            MainContract.Event.OnClickSearch(
                                it
                            )
                        )
                    },
                    textFieldState = textFieldState,
                    currentText = currentText
                )
            }
        } // Box
    }
</code></pre>
<p>로직을 LazyColumn밖에 Box 스코프를 넣고 코드 맨 밑에 적용하면 된다.
그럼 최상단 Layer에서 보여주게 된다. 최상단에 item에선 반응형 바가 노출될 때 안보여주도록 구현하지 않은 이유는 LazyColumn에 item이 갑자기 안보여지면 위치가 위로 땡겨지기 때문에 부자연스러운 움직임이 된다. 그래서 기존 서치바는 항상 노출시켜준 상태에서 반응형 서치바만 노출/미노출 시켜주는 게 특징이다.</p>
<p><strong><em>동작 영상은 마지막에 올리도록 하겠다.</em></strong></p>
<hr>
<h2 id="🙋♂️-프로필-적용">🙋‍♂️ 프로필 적용</h2>
<h3 id="💉-적용-1">💉 적용</h3>
<p>프로필이라면 소환사의 아이콘, 레벨, 전적, 아이디, 승률, 티어정도로 생각했다. 그래서 이정보들을 가져올려면 3개의 api들이 필요하다.</p>
<h3 id="1-riotaccountv1accountsby-puuidpuuid">1. /riot/account/v1/accounts/by-puuid/{puuid}</h3>
<p>우선 puuid를 통해 아이디를 가져오는 api이다. 기존엔 아이디랑 태그로 가져오고 있어서 새로 필요했다.
 response는 puuid, gameName, tagline이다. 
 기존에 검색한 값을 쓰면 안되냐?라는 궁금증이 있을 것 같은데 기존에는 소문자 대문자를 구별하지 않았기에 정확한 아이디와 태그값을 가져오기를 원해서 귀찮더라도 api를 통해 가져오고 싶었다.</p>
<h3 id="2-tftleaguev1by-puuidpuuid">2. tft/league/v1/by-puuid/{puuid}</h3>
<p><em>++ 기존에는 BASE_URL이 모두 <code>https://asia.api.riotgames.com/</code>    였다면 
이 api는 <code>https://kr.api.riotgames.com/</code> 로 써야된다. 
그래서 할 수 없이 ApiService를 새로 만들어서 di를 써서 retrofit 객체를 새로 생성해주었다.</em></p>
<p>이 api의 response값은 </p>
<pre><code class="language-kotlin">@Serializable
data class LeagueByPuuidResponse(
    @SerialName(&quot;tier&quot;)
    val tier: Tier,
    @SerialName(&quot;rank&quot;)
    val rank: Rank,
    @SerialName(&quot;leaguePoints&quot;)
    val leaguePoints: Int,
    @SerialName(&quot;wins&quot;)
    val wins: Int,
    @SerialName(&quot;losses&quot;)
    val losses: Int,
)</code></pre>
<p>전적, lp, 티어등에 정보를 가져올 수 있다. 여기서 Tier와 Ranks는 enum class로 따로 정의하여 맵핑시켜주고 있다.</p>
<pre><code class="language-kotlin">@Serializable
enum class Tier(val displayName: String) {
    @SerialName(&quot;IRON&quot;)
    IRON(&quot;아이언&quot;),

    @SerialName(&quot;BRONZE&quot;)
    BRONZE(&quot;브론즈&quot;),

    @SerialName(&quot;SILVER&quot;)
    SILVER(&quot;실버&quot;),

    @SerialName(&quot;GOLD&quot;)
    GOLD(&quot;골드&quot;),

    @SerialName(&quot;PLATINUM&quot;)
    PLATINUM(&quot;플래티넘&quot;),

    @SerialName(&quot;EMERALD&quot;)
    EMERALD(&quot;에메랄드&quot;),

    @SerialName(&quot;DIAMOND&quot;)
    DIAMOND(&quot;다이아몬드&quot;),

    @SerialName(&quot;MASTER&quot;)
    MASTER(&quot;마스터&quot;),

    @SerialName(&quot;GRANDMASTER&quot;)
    GRANDMASTER(&quot;그랜드마스터&quot;),

    @SerialName(&quot;CHALLENGER&quot;)
    CHALLENGER(&quot;챌린저&quot;);
}

@Serializable
enum class Rank(val number: Int) {
    @SerialName(&quot;I&quot;)
    I(1),

    @SerialName(&quot;II&quot;)
    II(2),

    @SerialName(&quot;III&quot;)
    III(3),

    @SerialName(&quot;IV&quot;)
    IV(4)
}</code></pre>
<p>굳이 enum으로 맵핑 안시켜주고 맵퍼에서 entity로 변경할 때 해도 상관 없을 것 같다. 근데 유지보수하기엔 이게 깔끔해보여서 적용해봤다.</p>
<h3 id="3tftsummonerv1summonersby-puuidpuuid">3.tft/summoner/v1/summoners/by-puuid/{puuid}</h3>
<p>_++이 api도 BASE_URL이 <code>https://kr.api.riotgames.com/</code> 로 써야된다. _</p>
<pre><code class="language-kotlin">@Serializable
data class SummonerByPuuidResponse(
    @SerialName(&quot;profileIconId&quot;)
    val profileIconId: Int,
    @SerialName(&quot;summonerLevel&quot;)
    val summonerLevel: Long,
)</code></pre>
<p>이 api는 puuid로 프로필 id와 레벨을 가져올 수 있다.</p>
<p>저 profileIconId를 이용하여 cdn으로 잘 조합해서 아이콘 이미지를 가져올 수있는데 </p>
<pre><code class="language-kotlin">// ImageUtils.kt
    fun createImageUrl(id: String, type: String, version: String): String {
        val regex = Regex(&quot;&quot;&quot;\d+\.\d+&quot;&quot;&quot;)
        val match = regex.find(version)?.value
        val currentVersion = &quot;$match.1&quot;
        val imageName = when (type) {
            ImageType.ITEM.type, ImageType.PROFILE.type -&gt; {
                &quot;$id.png&quot;
            }

            else -&gt; {
                if (id.contains(&quot;png&quot;)) {
                    id
                } else {
                    val season = Regex(&quot;&quot;&quot;TFT(\d+)&quot;&quot;&quot;).find(id.uppercase())?.groupValues?.get(1)
                    &quot;$id.TFT_Set$season.png&quot;
                }
            }
        }
        return &quot;https://ddragon.leagueoflegends.com/cdn/$currentVersion/img/$type/$imageName&quot;
    }</code></pre>
<p>기존에 챔피언과 아이템에 조합처럼 id를 마지막에 넣고 png를 붙여주면 된다. 근데 version에 대한 값은 어디서 가져오는 지 몰라서 무난하게 15.1.1을 넣어주고 있다. </p>
<hr>
<h2 id="🎬-동작-영상">🎬 동작 영상</h2>
<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/944c09a9-ae95-44c8-bb5a-731559ebcef7/image.gif" alt=""></p>
<p>아직도 버벅임이 조금 있지만 렌더링할 게 너무 많아서 어쩔 수 없는 것 같다. ㅠㅠ 
좀 더 개선할 방법을 찾아봐야될 것 같다.</p>
<p>아마 5탄은 마지막으로 마무리 및 배포까지 포스팅 할 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin In Action] 5장 람다로 프로그래밍 정리]]></title>
            <link>https://velog.io/@dev_hyunwoo/Kotlin-In-Action-5%EC%9E%A5-%EB%9E%8C%EB%8B%A4%EB%A1%9C-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@dev_hyunwoo/Kotlin-In-Action-5%EC%9E%A5-%EB%9E%8C%EB%8B%A4%EB%A1%9C-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Mon, 13 Oct 2025 06:30:22 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/a80defc4-7c03-4ff1-9a9e-f07268890471/image.png" alt=""></p>
<p><code>Kotlin In Action - 5장 람다로 프로그래밍</code>부분을 읽다가 도움이 될만한 것들을 정리하는 글이다.</p>
<hr>
<h3 id="1-멤버참조">1. 멤버참조</h3>
<p>kotlin에서는 람다를 사옹하면 가독성을 향상시키고 보일러플레이트 코드를 줄일 수 있기 때문에 많이 사용한다.</p>
<pre><code class="language-kotlin">val person = listOf(Person(14, 홍길동), Person(24, 둘리), Person(54, 허준)) 
val oldestPerson = person.maxBy { it.age } 



data class Person(val age : Int, val name : String)</code></pre>
<p>많이들 이런식으로 최대값을 찾을 것이다. 또한 멤버참조를 이용하여 많이 넘기기도 한다. </p>
<p>만약 어떤 함수에 람다를 넘겨야 할 때가 생겼는데 이미 정의된 함수라면 다시 정의된 함수에 로직을 다시 쓰는 건 비효율적일 것이다. 이때 멤버참조를 사용하면 된다.</p>
<hr>
<h3 id="2-count-vs-size">2. count vs size</h3>
<p>개발을 하다보면 컬렉션 api를 사용한 후 크기를 알아야 할 때가 많은데 이때 크기만 알면 된다면 앞으로는 size대신 count를 사용하는걸 권장드린다.
그 이유는 filter를 사용하여 남겨진 원소들에 크기를 구할때 만약 size를 이용한다면 filter에 만족하는 컬렉션이 중간에 하나 더 생기게 된다. 반면 count는 조건에 만족하는 원소를 따로 저장하지 않기 때문에 훨씬 더 효율적이다.</p>
<pre><code class="language-kotlin">val size = people.filter { it.age &gt; 27 }.size // x
val count = people.count { it.age &gt; 27 } // o</code></pre>
<hr>
<h3 id="3지연-계산lazy-컬렉션-연산">3.지연 계산(lazy) 컬렉션 연산</h3>
<p>map이나 filter 같은 몇 가지 컬렉션 함수들은 결과 컬렉션을 <strong>즉시</strong> 생성한다. 이는 컬렉션 함수를 연쇄하면 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는다는 말이다. 원소가 1,2개 일 때는 상관이 없지만 몇백, 몇천개 일때는 성능차이가 달라진다.</p>
<pre><code class="language-kotlin">val list = listOf(1,2,3,4)

val first = list.map { it * it}.find { it &gt; 3 } // 4 

val firstWithSequence = list.asSequence()
                            .map { it * it}
                            .find { it &gt; 3 } // 4
</code></pre>
<p>이 때 asSequence로 변환하여 컬렉션 api들을 수행한다면 성능이 향상 될 것이다. </p>
<h3 id="왜그럴까">왜그럴까?</h3>
<p>동작차이에 있다.</p>
<p>시퀀스를 사용안하면 1, 4, 9 ,16이라는 컬렉션을 하나 임시로 만들 것이다.
그 후 find로 3이상인 원소를 찾을 것이다. 그럼 9, 16까지 굳이 필요했을까?</p>
<p>아니다. 시퀀스는 그 점을 해결해준다.</p>
<p>시퀀스를 사용하면 map에선 1을 먼저 계산한다. 그 뒤 find가 수행되지만 조건에 맞지않아 반환되지 않는다. 그 다음 map에서는 4가 생성되고 find에서 조건이 맞아 4가 반환된다. 결국 map에서는 나머지 3,4에 대한 작업을 수행하지 않아도 되기 때문에 여기서 성능 이점을 찾을 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android]TFT API 이용해서 전적 검색 앱 만들기 - 3]]></title>
            <link>https://velog.io/@dev_hyunwoo/AndroidTFT-API-%EC%9D%B4%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%A0%84%EC%A0%81-%EA%B2%80%EC%83%89-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-3</link>
            <guid>https://velog.io/@dev_hyunwoo/AndroidTFT-API-%EC%9D%B4%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%A0%84%EC%A0%81-%EA%B2%80%EC%83%89-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-3</guid>
            <pubDate>Mon, 06 Oct 2025 09:09:05 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@dev_hyunwoo/AndroidTFT-API-%EC%9D%B4%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%A0%84%EC%A0%81-%EA%B2%80%EC%83%89-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-2">이전글</a>에서 말했었던 문제점들을 해결하기위해 꽤 많은 작업이 필요했었다..</p>
<p>우선 가장 큰 문제점은 버전별로 챔피언에 사진이 다르다는 것이다. 예를 들자면 빅토르라는 챔피언은 넷플릭스 시리즈인 아케인이 나온 시점 이후 버전부터는 이미지 이름이 그냥 <code>빅토르</code>가 아닌 <code>진화된 빅토르</code>로 나오기 때문에 사진 cdn링크를 수작업으로 하드코딩하는 건 무리가 있었다. 그리고 추가적으로 cdn에 필요한 정보는 이미지 이름도 있지만 버전도 넣어줘야된다. </p>
<pre><code class="language-kotlin">return &quot;https://ddragon.leagueoflegends.com/cdn/$currentVersion/img/$type/$imageName&quot;</code></pre>
<p>이해하기 쉽게 코드를 보여주자면 이런 느낌이다. 그래서 저 버전에 맞는 imageName을 가져와야된다. </p>
<p>기존에 있던 앱들을 확인해보면 웹으로 만들어서 웹뷰로 보여주는 것 같다. 
그 이유는 로컬에 정보를 저장하여 이미지 링크를 포맷해서 가져오기엔 앱 하나론 꽤 무거운 작업인 것 같았다. 그래도 웹을 할 줄 모르고.. 그렇다고 서버를 두기엔 너무 일이 커질 것 같아서 죽이되든 밥이되든 일단 앱하나로 가기로 결정했다.</p>
<hr>
<h2 id="💿-데이터-로컬-저장">💿 데이터 로컬 저장</h2>
<p>우선 데이터를 서버가 아닌 로컬에 저장해주기로 하였다. 그래서 원래는 스플래시 화면이 없었지만 스플래시가 필요할 것 같아서 추가해주기로 했다. </p>
<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/56ed2432-8913-46bb-980b-88d78f1ee3db/image.gif" alt="">
<del>(로고는 내가 gpt 이용해서 만든거다..)</del></p>
<p>그래서 스플래시에서 해주는 건 크게 2가지다.</p>
<h3 id="1️⃣-최신버전-비교">1️⃣ 최신버전 비교</h3>
<pre><code class="language-kotlin">  val localLatestVersion: String = 
  datastoreRepository.getLatestVersion().first().ifEmpty { &quot;0.0.0&quot; }
  val latestVersion: String = result.data.first()
  if (localLatestVersion.compareVersion(latestVersion) &lt; 0) {
      // TODO 버전별로 챔피언 데이터 가져오기
  }</code></pre>
<p>비교하는 부분의 로직을 보여주자면 간단하게 로컬에 저장한 최신버전과 api를 통해 가져온 최신버전이 일치한다면 패스해주고 만약 최신버전이 업데이트가 됐다면 모든 버전의 챔피언 데이터를 가져온다.</p>
<h3 id="2️⃣챔피언-데이터-저장">2️⃣챔피언 데이터 저장</h3>
<pre><code class="language-kotlin">  private suspend fun getChampion(version: String) {
        when (val result = dragonRepository.getChampions(version = version)) {
            is ApiResult.Success -&gt; {
                db.setChampionEntities(championEntities = result.data.toChampionEntity())
            }

            is ApiResult.Error -&gt; {
                setEffect { SplashContract.Effect.ShowMessage(message = result.message) }
            }
        }
    }</code></pre>
<p>우선 버전을 파라미터로 넣어주면 거기에 맞는 챔피언 메타데이터를 가져올 수 있다. </p>
<pre><code class="language-kotlin">fun TFTChampionResponse.toChampionEntity(): List&lt;ChampionEntity&gt; {
    return this.data.map {
        ChampionEntity(
            championId = it.value.id.lowercase(),
            imageName = it.value.image.full
        )
    }
}</code></pre>
<p>결과값을 로컬 db에 저장해주는 로직도 보이는데 그냥 dto의 값을 넣어주기보단 entity로 필요한 부분으로 mapper를 이용하여 맵핑하여 저장해준다. 나는 챔피언 id와 이미지이름만 있으면 돼서 2개로 맵핑을 해줬다. lowercase를 해준 이유는 종종 챔피언 값이 소문자로 줄때가 있어서 로컬에 저장한 값과 매칭이 안되는 이슈가 있었다.</p>
<hr>
<h2 id="🌆-이미지-가져오기">🌆 이미지 가져오기</h2>
<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/f16a5cef-8746-494a-812d-4cb72b0028d8/image.png" alt=""></p>
<h3 id="👿구조적-문제점">👿구조적 문제점</h3>
<p>이제 로컬에 저장된 메타데이터를 이용하여 챔피언 id에 맞는 이미지 이름을 가져와야된다. 근데 여기서 많은 고민을 했다. 원래는 viewModel에서 맵핑을 해주고 있었다. 근데 이제 db에 접근하여 이미지를 가져온 뒤 그 이미지에 맞는 값을 맵핑해줘야 된다. 그 작업은 viewModel에서 해주기엔 너무 무거운 작업이였다. 그리고 내가 지금 이용한 앱 아키텍쳐는 domain layer가 없다. 그래서 모든 작업은 data layer에서 해줘야된다. 그래서 어쩔수 없이 구조를 과감히 변경할 필요가 있었다. </p>
<h3 id="data-layer에서는-ui-layer의-의존성이-있으면-안된다">data layer에서는 ui layer의 의존성이 있으면 안된다.</h3>
<p>그래서 기존에 ui Layer에 있던 맵핑 관련 로직들을 모두 data layer로 옮겼다. 예를들면 api 결과값을 가져와서 성공 or 실패로 맵핑해주는 역할을 모두 repository에서 해주다보니 오히려 내가 이전까지 잘못 썼다는 걸 깨달았다. data layer에서 맵핑을 해주는게 엄청 깔끔해보였다. ui layer에서는 단순히 그 가공된 값을 사용하기만 해주는 게 맞았다.</p>
<pre><code class="language-kotlin">// TFTRepositoryImpl.kt
override suspend fun getMatchByMatchId(puuid: String, matchId: String): ApiResult&lt;MatchEntity&gt; {
        return when (val result =
            safeApiCall { riotApiService.getMatchByMatchId(matchId = matchId) }) {
            is ApiResult.Success -&gt; {
                val ids =
                    result.data.info.participants.flatMap { participant -&gt; participant.units.map { unit -&gt; unit.characterId.lowercase() } }
                        .distinct()
                val images = db.getImages(ids)?.associate { it.championId to it.imageName }
                ApiResult.Success(
                    data = result.data.toMatchEntity(
                        puuid = puuid,
                        images = images ?: hashMapOf()
                    )
                )
            }

            is ApiResult.Error -&gt; {
                result
            }
        }
    }</code></pre>
<hr>
<h2 id="😂새로운-문제점">😂새로운 문제점..</h2>
<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/fcd223d8-48ca-476c-8a0e-ab84ab52f1ca/image.jpg" alt="">
하드코딩이 아닌 공식사이트에서 제공해주는 데이터를 가져와서 cdn링크를 가져와도 없는 챔피언이 있다.. ㅠㅠ 믿고싶지 않았다. 단순히 검색해서 챔피언 이미지를 가져오는 것도 힘들었다. 오늘따라 백엔드분들이 너무 보고싶어졌다..</p>
<h3 id="해결해서-4탄으로-돌아오겠습니다">해결해서 4탄으로 돌아오겠습니다..</h3>
<hr>
<h2 id="번외로-jetpack-navigation3-후기">번외로 Jetpack Navigation3 후기</h2>
<p>스플래시가 추가된 이후에 스플래시에서 메인으로 이동을 시켜줘야됐다. 근데 이동 후에  메인화면에서 뒤로가기를 눌렀을 때 스플래시 화면이 아닌 앱종료가 됐어야했기에 스플래시 스택을 지워야됐다.</p>
<p>그래서 지우는 방법이 별도로 있나해서 문서를 뒤져봤지만 아직 알파단계라 정보가 거의 없었다. 그래서 그냥 backstack을 스택으로 관리중이니 Splash에 대한 스택을 지우면 되지않을까 했다.</p>
<pre><code class="language-kotlin">   onNavigateToMain = {
                        backStack.add(Route.Main)
                        backStack.remove(Route.Splash)
                    }</code></pre>
<p>이렇게 remove로 Splash를 지워주니 정상 동작하긴 했다. 하지만 맞게 쓰고 있는지는 잘 모르겠다. 나중에 정보가 더 나오면 찾아봐야겠다.</p>
<h2 id="끝">끝!</h2>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android]TFT API 이용해서 전적 검색 앱 만들기 - 2]]></title>
            <link>https://velog.io/@dev_hyunwoo/AndroidTFT-API-%EC%9D%B4%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%A0%84%EC%A0%81-%EA%B2%80%EC%83%89-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-2</link>
            <guid>https://velog.io/@dev_hyunwoo/AndroidTFT-API-%EC%9D%B4%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%A0%84%EC%A0%81-%EA%B2%80%EC%83%89-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-2</guid>
            <pubDate>Tue, 23 Sep 2025 06:07:13 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dev_hyunwoo/post/ecc62d64-2ad9-4f6f-9464-dd5c8d361c4f/image.png" alt=""></p>
<p>이번에 혼자 개발을 하다보니 팀의 중요성을 점점 깨닫고 있다..</p>
<p>저 구린 디자인을 보면 생각나는 디자이너분들...(나의 최선이다ㅠㅠ)
가공되지않은 데이터를 프론트에서 사용할 수 있게끔 잘 가공해준 백엔드분들..</p>
<p>많은 분들이 떠올라진다.. 혼자 개발을 하다보니 순전히 개발하는 시간보다 문서를 찾고 데이터 가공하는 시간이 더 오래걸리는 것 같다..</p>
<p>아직 갈길이 멀었지만 현재까지 진행상황과 앞으로의 진행예정인 상황들을 간단하게 공유할려고 한다.</p>
<hr>
<h2 id="🧑💻-api-호출">🧑‍💻 API 호출</h2>
<p>기초작업을 끝낸 후 서치바를 생성한 뒤 이제 api호출을 하여 데이터를 가져올 일만 남았다. 근데 문서가 모두 영어로 되어있어서 어디서 어떻게 가져와야되는지 찾기가 어려웠다. 그래서 문서를 막 뒤지다가 </p>
<blockquote>
<p><code>/riot/account/v1/accounts/by-riot-id/{gameName}/{tagLine}</code>
gameName - 롤에서의 닉네임이다. e.g. hideonbush
tagLine - 롤에서의 태그명이다. e.g. KR1</p>
</blockquote>
<p>이 api를 이용하면 해당유저의 uuid를 준다. uuid는 78자의 고유값이며 이걸로 해당 유저의 매치정보를 조회 할 수 있다.</p>
<p>그래서 나의 계획은 매치리스트를 가져와서 해당 매치에 대한 정보를 다시 api를 호출하여 가져온 뒤 보여줄 생각이였다.</p>
<blockquote>
<p><code>tft/match/v1/matches/by-puuid/{puuid}/ids</code></p>
</blockquote>
<p>그래서 우선 매치리스트를 가져오는 api를 사용했다. 해당 결과값은<code>list&lt;String&gt;</code>이다. 안에는 matchId가 들어있고 해당 matchId로</p>
<blockquote>
<p><code>tft/match/v1/matches/{matchId}</code></p>
</blockquote>
<p>이 api를 호출하여 정보를 가져오면 된다.</p>
<hr>
<h2 id="🏙️-이미지">🏙️ 이미지</h2>
<p>데이터는 모두 정상적으로 가져올 수 있었고 문제는 이미지였다.
매치정보에선 이미지 url을 주는 것이 아닌 이미지 id를 줬다.
그 이미지 id를 라이엇이 제공해주는 json파일에서 매칭시켜서 가져와야된다.
이 과정이 난 너무 복잡하다고 느껴서 비정상적인 방법으로 해결하고자 했다.</p>
<pre><code class="language-kotlin">  fun createImageUrl(id: String, type: String, version: String): String {
        val regex = Regex(&quot;&quot;&quot;\d+\.\d+&quot;&quot;&quot;)
        val match = regex.find(version)?.value
        val currentVersion = &quot;$match.1&quot;
        val imageName = when (type) {
            ImageType.CHAMPION.type -&gt; {
                val season = Regex(&quot;&quot;&quot;TFT(\d+)&quot;&quot;&quot;).find(id)?.groupValues?.get(1)
                &quot;$id.TFT_Set$season&quot;
            }

            else -&gt; id
        }
        return &quot;https://ddragon.leagueoflegends.com/cdn/$currentVersion/img/$type/$imageName.png&quot;
    }
}</code></pre>
<p>이 로직을 잠깐 설명하자면 매치정보에서 주는 id값과 version값을 어떻게 파싱하여 url로 만들어줬다. 그래서 성공인 줄? 알았지만...</p>
<p>맨위에 보여지는 화면처럼 변수가 있었다. 몇몇의 챔피언들은 저 형식이 아닌 다른 형식이 있다는 점이였다. 그래서 그 예외되는 챔피언들만 예외처리를 해줄수도 있었지만 앞으로 버전이 늘어나면서 그러한 예외처리를 매번 해줄 수는 없을 것 같아서 방법을 바꾸기로 했다.</p>
<hr>
<h2 id="😵💫-해결방법">😵‍💫 해결방법</h2>
<p>내가 생각한 해결방법은 우선 최신 버전들을 모아논 json파일이 있다.
<code>https://ddragon.leagueoflegends.com/api/versions.json</code>
이 파일을 스플래시에서 호출하여 최신버전이 있는 지 체크한다. (아마 sharedPreference로?)
체크 한 후 없으면 그대로 메인화면 이동하고 만약 있다면 그 버전의 asset.json파일을 호출하여 다운받는다. 다운을 받고 data class로 파싱한 후 room을 이용하여 로컬에 저장하고 이미지를 가져올 때 room에서  json파일 꺼내 이미지 id를 가져올 예정이다. </p>
<p>물론 3탄에서..</p>
]]></description>
        </item>
    </channel>
</rss>