<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>kwan_hee.log</title>
        <link>https://velog.io/</link>
        <description>Allright!</description>
        <lastBuildDate>Sun, 18 May 2025 13:35:31 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>kwan_hee.log</title>
            <url>https://velog.velcdn.com/images/kwan_hee/profile/8e222b02-2b98-4264-b336-9785bd58d572/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. kwan_hee.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/kwan_hee" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[딥링크(Deep Link) 구현하기]]></title>
            <link>https://velog.io/@kwan_hee/%EB%94%A5%EB%A7%81%ED%81%ACDeep-Link-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@kwan_hee/%EB%94%A5%EB%A7%81%ED%81%ACDeep-Link-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 18 May 2025 13:35:31 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요.</p>
<p>딥링크는 무엇이고, 안드로이드에서는 어떻게 딥링크를 구현할 수 있을 지에 대해서 설명해보려고 합니다.</p>
<blockquote>
<p><strong>우선 딥링크란 무엇일까요?</strong></p>
</blockquote>
<p>딥링크는 사용자가 특정 링크를 클릭하면 특정 앱으로 이동하거나 사용자에게 액션을 유도하는 링크를 딥링크라고 합니다.</p>
<p>카카오톡으로 누군가를 독려하는 메시지를 보냈다고 가정할게요.</p>
<p>독려 메시지에 <strong>[참여하기]</strong>라는 버튼을 클릭했을 때, 어디로 이동하셨으면 좋을 것 같나요?</p>
<p>앱이 존재하면 앱으로 이동하고, 앱이 존재하지 않다면 구글 플레이 스토어로 이동하기를 기대할 수 있습니다.</p>
<p>이처럼 딥링크를 유용하게 사용하면 마케팅에서 좋은 전략이 될 수 있습니다.</p>
<p align=center>
  <img src="https://velog.velcdn.com/images/kwan_hee/post/8f7b028b-b644-4746-945f-3d735b32a8ab/image.png" width=80% align=center />
</p>


<blockquote>
<p><strong>그렇다면 딥링크는 어떻게 구성되나요?</strong></p>
</blockquote>
<p>링크라고 한다면 다들 아시는 웹 사이트의 주소를 떠올릴겁니다.</p>
<p>딥링크도 그 주소의 구성과 유사합니다.
<img src="https://velog.velcdn.com/images/kwan_hee/post/4d142fc2-c901-47a4-b794-5f712ea75734/image.png" alt=""></p>
<p>흔히 사용되는 구성은 위의 그림과 같습니다.</p>
<p>이렇게 구성을 한 이유는 여러가지 있습니다.</p>
<ul>
<li><strong>웹 주소와의 유사함을 가지기 위함입니다.</strong><ul>
<li>scheme://host 는 <a href="https://host">https://host</a> 와 구조가 동일합니다.</li>
</ul>
</li>
<li><strong>URI 설계 원칙인 RFC-3986 를 따릅니다.</strong><ul>
<li>scheme:// [userinfo@] host[:port][/path][?query][#fragment] 의 구조를 가집니다.</li>
<li>“슬래시 구분자(//)는 계층 관계를 나타내는데 사용한다”, “하이픈(-)은 URI 가독성을 높이는데 사용한다” 등 여러 규칙이 존재합니다.</li>
</ul>
</li>
</ul>
<p>여러가지 이유가 더 있겠지만, 위에서 말한 이유로 위 구조를 따르게 하면 딥링크 사용 시 Android, iOS, 웹 등 다양한 플랫폼에서 동일한 규칙으로 파싱할 수 있습니다.</p>
<p>하지만, 여기에서도 문제점이 존재합니다.</p>
<p>만약 안드로이드 OS에서 같은 스킴(Scheme)을 가진 앱이 여러개 존재한다면, 무슨 앱을 열어야 하는 지 알 수 없습니다.</p>
<p>그래서 앱을 선택할 수 있는 UI가 나타납니다.</p>
<p>(iOS에서는 해결할 수 있는 방법이 없다고 합니다.)</p>
<p align=center>
  <img src="https://velog.velcdn.com/images/kwan_hee/post/97c332d1-9633-44fc-8aaa-bfb159cb3d6f/image.png" width=80% />
</p>


<blockquote>
<p><strong>iOS에서는 해결할 수 없고, Android 에서 특정 앱을 열 수 없다니... 그렇다면 다른 방법이 없을까요?</strong></p>
</blockquote>
<p>다른 방법이 존재합니다.</p>
<ul>
<li><strong>Android - App Link</strong></li>
<li><strong>iOS - Universal Link</strong></li>
</ul>
<p>를 사용하면 됩니다.</p>
<p>App Link와 Universal Link는 2015년에 커스텀 스킨의 한계를 보안했고, 표준 웹링크 형태(<a href="https://www.xxx">https://www.xxx</a>) 를 가집니다. 그렇기 때문에 이 방법으로 위에 문제들을 해결할 수 있게 됩니다.</p>
<p align=center>
  <img src="https://velog.velcdn.com/images/kwan_hee/post/4f055bf7-0336-4b65-afe5-138b02e5ab7e/image.png" width=80%/>
</p>


<p>위에서 딥링크가 무엇인지 알아봤으니, 실제로 적용해볼까요?</p>
<p>iOS는 진행하지 않고 Android에서 실무에서 어떻게 적용할 수 있는 지 알아보려고 합니다.</p>
<blockquote>
<p><strong>안드로이드에서 간단하게 딥링크를 테스트할 수 있는 방법은 무엇인가요?</strong></p>
</blockquote>
<p>우선, 커스텀 스킴을({scheme}://{host}) 설정해주어야 합니다.</p>
<p>요구사항은 다음과 같습니다.</p>
<ul>
<li><code>matee://open</code> 으로 MainActivity를 열어야 한다.</li>
</ul>
<p>그렇기 때문에 아래와 같이 AndroidManifest.xml에 intent-filter를 설정해줍니다.</p>
<pre><code class="language-kotlin"> &lt;activity
  android:name=&quot;com.kwanhee.example.MainActivity&quot;
  ...&gt;
    &lt;intent-filter&gt;
      &lt;action android:name=&quot;android.intent.action.VIEW&quot;/&gt;
      &lt;category android:name=&quot;android.intent.category.DEFAULT&quot;/&gt;
      &lt;category android:name=&quot;android.intent.category.BROWSABLE&quot;/&gt;
      &lt;data
          android:scheme=&quot;matee&quot;
          android:host=&quot;open&quot;/&gt;
  &lt;/intent-filter&gt;
&lt;/activity&gt;</code></pre>
<p>위 처럼 설정한 뒤, 애뮬레이터를 실행시킵니다.</p>
<p>그 이후에 터미널에 아래 명령어를 입력하면, 해당 matee 스킴을 가진 앱을 실행시키며, open 호스트 데이터를 가진 Activity를 열게 됩니다.</p>
<pre><code class="language-kotlin">adb shell am start \
  -a android.intent.action.VIEW \
  -d &quot;matee://open&quot;</code></pre>
<p align=center>
  <img src="https://velog.velcdn.com/images/kwan_hee/post/608b2221-9e3b-42a4-8f9d-6d5a5400dccb/image.gif" width=50% />
</p>

<p>간단하게 커스텀 스킴을 사용하여 테스트 하는 방법입니다.</p>
<p>링크 전달도 다음과 같이 할 수 있습니다.</p>
<p>저는 슬랙이라는 메신저 툴을 사용했고, 슬랙에서 matee://app-launch 커스텀 링크를 메시지로 입력하면 하이퍼링크가 되어 등록된 앱의 커스텀 스킴이 존재한다면 이동하는 것을 알 수 있습니다.</p>
<p align=center>
  <img src="https://velog.velcdn.com/images/kwan_hee/post/9bc772ff-25a0-4ae5-82af-e873d9f22bf8/image.gif" width=30% />
</p>

<p>그렇다면 해당 앱이 존재하지 않으면 어떻게 될까요?</p>
<p>슬랙에서는 해당 링크를 열 수 없다고 알림 메시지가 나옵니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/47df9ec7-e375-4140-9857-ee8be74e93d0/image.png" alt=""></p>
<p>그렇기 때문에 앱이 설치되지 않다면, 구글 플레이 스토어로 이동시키는 전략을 세워 앱의 다운로드를 유도해보려고 합니다. </p>
<p>그러기 위해서는 표준 웹링크를 사용하는 App Link 를 사용할 수 있어야 합니다.</p>
<blockquote>
<p><strong>Android에서 App Link는 어떻게 구현하나요?</strong></p>
</blockquote>
<p>우선, 안드로이드 manifest 설정은 아래와 같습니다.</p>
<p>딥링크와 앱링크 방식 모두 데이터로 추가해둡니다.</p>
<pre><code class="language-kotlin">&lt;activity
            android:name=&quot;com.livinai.presentation.MainActivity&quot;
            android:exported=&quot;true&quot;
            android:label=&quot;@string/app_name&quot;
            android:screenOrientation=&quot;portrait&quot;
            android:windowSoftInputMode=&quot;adjustResize&quot;
            android:theme=&quot;@style/Theme.AppCompat.NoActionBar&quot;&gt;
            &lt;intent-filter&gt;
                &lt;action android:name=&quot;android.intent.action.MAIN&quot; /&gt;

                &lt;category android:name=&quot;android.intent.category.LAUNCHER&quot; /&gt;
            &lt;/intent-filter&gt;
            &lt;intent-filter
                android:autoVerify=&quot;true&quot;&gt;
                &lt;action android:name=&quot;android.intent.action.VIEW&quot; /&gt;
                &lt;category android:name=&quot;android.intent.category.DEFAULT&quot; /&gt;
                &lt;category android:name=&quot;android.intent.category.BROWSABLE&quot; /&gt;

                &lt;data android:scheme=&quot;http&quot; /&gt;
                &lt;data android:scheme=&quot;https&quot; /&gt;
                &lt;data android:host=&quot;matee.livincare.kr&quot; /&gt;
                &lt;data android:pathPrefix=&quot;/app-launch&quot; /&gt; &lt;!-- 예: https://matee.livincare.kr/app-launch --&gt;
            &lt;/intent-filter&gt;

            &lt;intent-filter&gt;
                &lt;action android:name=&quot;android.intent.action.VIEW&quot;/&gt;
                &lt;category android:name=&quot;android.intent.category.DEFAULT&quot;/&gt;
                &lt;category android:name=&quot;android.intent.category.BROWSABLE&quot;/&gt;
                &lt;data android:scheme=&quot;matee&quot;/&gt;
                &lt;data android:host=&quot;app-launch&quot;/&gt; &lt;!-- 예: matee://app-launch --&gt;
            &lt;/intent-filter&gt;
        &lt;/activity&gt;</code></pre>
<p>Android App Link에서 사용하는 디지털 인증 에셋 JSON 파일 (<code>assetlinks.json</code>)은 <strong>앱과 도메인 간의 신뢰 관계를 증명</strong>하는 핵심 요소입니다. 이 파일을 통해 Android는 “이 도메인은 이 앱에서 공식적으로 소유하고 있다”고 판단하게 되는 것입니다.</p>
<p>형식은 아래와 같습니다. </p>
<pre><code class="language-kotlin">[{
  &quot;relation&quot;: [&quot;delegate_permission/common.handle_all_urls&quot;],
  &quot;target&quot;: {
    &quot;namespace&quot;: &quot;android_app&quot;,
    &quot;package_name&quot;: &quot;[your-package-name]&quot;,
    &quot;sha256_cert_fingerprints&quot;:
    [&quot;[your-application-sha256]&quot;]
  }
}]</code></pre>
<p><em>*구글 플레이 스토어 등록 시, sha256키 변경되니 구글 플레이 스토어 올릴 경우, sha256 키로 변경해야 합니다.</em></p>
<p>만약에 SHA256키 추출을 원한다면, 터미널에 아래 명령어를 입력해주세요!</p>
<pre><code class="language-kotlin">./gradlew signingReport</code></pre>
<p>이제 assetlinks.json 파일 내용을 작성하셨다면, 웹에 호스팅(게시)해야 합니다. </p>
<p>웹 사이트의 아래 path처럼 json 파일이 들어가 있어야 합니다. </p>
<pre><code class="language-kotlin">https://[your-domain-name]/.well-known/assetlinks.json</code></pre>
<p>저는 Nginx로 정적 빌드 리액트 파일을 서빙했고, 링크 접속 시 아래처럼 나오게 됩니다. </p>
<p align=center>
  <img src="https://velog.velcdn.com/images/kwan_hee/post/4d9d2cc6-f4b9-40fc-b9e2-95e8e3e838df/image.png" width=70%/>
</p>


<p>그런데 여기서 중요한 부분은 에셋 파일도 호스팅해야 한다는 부분입니다.</p>
<p>nginx 설정 시, 정적 파일만 서빙하면 안되고, 위처럼 .well-knwon/assetlinks.json 경로로 웹사이트 접속시 json 파일이 보여야 합니다.</p>
<pre><code class="language-kotlin">server {
    location / { // 웹 정적 파일 서빙
        root /home/livinai/matee_front/dist;
        try_files $uri $uri/ /index.html =404;
    }
    location /.well-known/ { // JSON Digital Asset 인증 파일 호스팅
    root /home/livinai/matee_front;
    try_files $uri $uri/ /index.html =404;
    }
}</code></pre>
<blockquote>
<p><strong>웹 코드도 작성해야하나요?</strong></p>
</blockquote>
<p>네 맞습니다.</p>
<p>저는 React + Vite 로 프로젝트를 셋팅하고 단순한 코드를 작성했습니다.</p>
<p>초기 셋팅하는 코드는 아래와 같습니다.</p>
<pre><code class="language-kotlin">npm create vite@latest my-app -- --template react
cd my-app
npm install</code></pre>
<p>그 이후, react-routing으로 라우팅 처리를 해줍니다.</p>
<pre><code class="language-kotlin">npm install react-router-dom</code></pre>
<pre><code class="language-kotlin">// src/main.jsx
import React from &#39;react&#39;;
import ReactDOM from &#39;react-dom/client&#39;;
import { BrowserRouter } from &#39;react-router-dom&#39;;
import App from &#39;./App&#39;;
import &#39;./index.css&#39;;

ReactDOM.createRoot(document.getElementById(&#39;root&#39;)).render(
  &lt;React.StrictMode&gt;
    &lt;BrowserRouter&gt;
      &lt;App /&gt;
    &lt;/BrowserRouter&gt;
  &lt;/React.StrictMode&gt;
);

// src/App.jsx
import { Routes, Route, Link } from &#39;react-router-dom&#39;;
import Home from &#39;./pages/Home&#39;;
import AppLaunch from &#39;./pages/AppLaunch&#39;;

function App() {
  return (
    &lt;div&gt;
      &lt;Routes&gt;
        &lt;Route path=&quot;/&quot; element={&lt;Home /&gt;} /&gt;
        &lt;Route path=&quot;/app-launch&quot; element={&lt;AppLaunch /&gt;} /&gt;
      &lt;/Routes&gt;
    &lt;/div&gt;
  );
}

export default App;</code></pre>
<p>최종적으로 코드를 아래처럼 작성하면 됩니다.</p>
<p>앱이 존재하면 앱으로 이동시키고, 앱이 존재하지 않다면 플레이 스토어로 이동시키는 로직을 작성하면 됩니다.</p>
<pre><code class="language-kotlin">import { useEffect } from &quot;react&quot;;

// src/pages/AppLaunch.jsx
const AppLaunch = () =&gt; {
    // React useEffect 예시
    useEffect(() =&gt; {
        // 1) Intent URI 에 앱 패키지명·fallback URL·스킴 등을 한 번에 담는다
        const intentUri =
            &#39;intent://app-launch#Intent;&#39; +
            &#39;scheme=matee;&#39; +                                              // custom scheme
            &#39;package=com.livinai.mateeapp;&#39; +                             // 앱 패키지
            &#39;S.browser_fallback_url=&#39; +
            encodeURIComponent(
                &#39;https://play.google.com/store/apps/details?id=com.livinai.mateeapp&#39;
            ) + &#39;;&#39; +
            &#39;end&#39;;

        // 2) 한 번만 리다이렉트
        window.location.replace(intentUri);
    }, []);

    return (
        &lt;&gt;&lt;/&gt;
    );
};

export default AppLaunch;</code></pre>
<p>저는 위 코드에서 Intent 스킴을 사용했습니다.</p>
<p>디버그에서 테스트할 때, <a href="https://matee.livincare.kr/app-launch">https://matee.livincare.kr/app-launch</a> 링크를 클릭하니 앱을 열어주지 않더라구요.. 그래서 앱으로 이동하지 않길래 추가해두었습니다.</p>
<blockquote>
<p><strong>Intent 스킴은 뭔가요?</strong></p>
</blockquote>
<p>Intent 스킴은 Android App Link가 나오기 전에 Android 웹뷰에서 사용했던 딥링크 유형입니다. iOS에서는 Intent 스킴을 사용할 수 없기 때문에 구분하는 로직이 필요할 것 같습니다.</p>
<blockquote>
<p><strong>꼭 Intent 스킴을 사용해야 하나요?</strong></p>
</blockquote>
<p>꼭 그렇지 않은 것 같습니다.</p>
<p>개발 환경에서는 <a href="https://matee.livincare.kr/app-launch">https://matee.livincare.kr/app-launch</a> 링크를 클릭하니 앱을 열어주지 않은 이유가 앱에서 설정을 따로 해주어야 하더라구요.</p>
<p>아래 처럼 앱 설정에서 등록한 웹 주소를 지원되게 허용해주어야 한다고 합니다. </p>
<table>
  <tr>
    <td><img src="https://velog.velcdn.com/images/kwan_hee/post/a2ae8435-6522-405a-8b5c-224ea2d15ba7/image.png" width="100%"/></td>
    <td><img src="https://velog.velcdn.com/images/kwan_hee/post/4dd9827a-3f62-4dd3-ad64-a6bff6fe5b77/image.png" width="100%"/></td>
    <td><img src="https://velog.velcdn.com/images/kwan_hee/post/a3a58f45-2fce-4304-b8aa-eb6377072e88/image.png" width="100%"/></td>
  </tr>
</table>

<p>플레이 스토어 배포 후 설치된 앱을 확인해보니 위 웹 주소 허용이 되어있는 것을 확인했고, Intent 스킴을 사용하지 않더라도 잘 작동하는 것을 확인했습니다. </p>
<p>최종적으로 테스트하기 위한 설정이 완료되었다면, 아래 명령어를 터미널에 넣어주면 동작합니다.</p>
<pre><code class="language-kotlin">adb shell am start -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE \
  -d &quot;https://matee.livincare.kr/app-launch&quot;</code></pre>
<p align=center>
  <img src="https://velog.velcdn.com/images/kwan_hee/post/5f69786d-c422-4a6c-b5ae-bbb96519660a/image.gif" width=50%/>
</p>



<p>웹링크를 클릭하더라도 <a href="https://matee.livincare.kr/app-launch">https://matee.livincare.kr/app-launch</a> 링크를 클릭해도 잘 동작합니다.</p>
<p align=center>
  <img src="https://velog.velcdn.com/images/kwan_hee/post/0f571b49-5ede-41f6-903d-32c32bee9648/image.gif" width=30%/>
</p>


<h3 id="ref">Ref.</h3>
<hr>
<p><a href="https://developer.android.com/training/app-links?hl=ko">Android 앱 링크 처리하기  |  App architecture  |  Android Developers</a></p>
<p><a href="https://docs.tosspayments.com/resources/glossary/deep-link">딥링크(Deep Link) | 토스페이먼츠 개발자센터</a></p>
<p><a href="https://docs.tosspayments.com/blog/android-ios-webview-deeplink">Android, iOS 웹뷰에서 딥링크 열기 | 토스페이먼츠 개발자센터</a></p>
<p><a href="https://docs.tosspayments.com/blog/how-to-use-deep-links">딥링크 실전에서 잘 사용하는 방법 | 토스페이먼츠 개발자센터</a></p>
<p><a href="https://medium.com/prnd/%EB%94%A5%EB%A7%81%ED%81%AC%EC%9D%98-%EB%AA%A8%EB%93%A0%EA%B2%83-feat-app-link-universal-link-deferred-deeplink-61d6cf63a0a5">딥링크의 모든것(feat. App Link, Universal Link, Deferred DeepLink) | 헤이딜러 기술블로그</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[오픈 그래프(Open Graph)에 대해서 with Next.js]]></title>
            <link>https://velog.io/@kwan_hee/%EC%98%A4%ED%94%88-%EA%B7%B8%EB%9E%98%ED%94%84Open-Graph%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C-with-Next.js</link>
            <guid>https://velog.io/@kwan_hee/%EC%98%A4%ED%94%88-%EA%B7%B8%EB%9E%98%ED%94%84Open-Graph%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C-with-Next.js</guid>
            <pubDate>Sun, 20 Apr 2025 23:06:45 GMT</pubDate>
            <description><![CDATA[<p>웹 링크를 공유하다보면 링크 속 컨텐츠를 미리보기로 볼 수 있는 경험이 있으실거에요. 그렇다면 어떻게 내부 컨텐츠를 미리보기를 할 수 있는 지 이론과 실습을 함께 배워보려고 합니다.</p>
<blockquote>
<p><strong>링크 속 컨텐츠를 어떻게 미리볼 수 있을까요?</strong></p>
</blockquote>
<p>오픈 그래프(Open Graph)라는 프로토콜을 사용하면 웹페이지의 미리보기 정보를 다른 플랫폼(카카오톡, 슬랙 등)에 공유하여 링크 속 컨텐츠를 미리볼 수 있습니다.</p>
<p>그러면 여기서 우리는 오픈 그래프에 대한 개념을 알아야 합니다.</p>
<blockquote>
<p><strong>오픈 그래프란 무엇인가요?</strong></p>
</blockquote>
<p>오픈 그래프는 Facebook(메타)이 처음 만들었고, 웹페이지를 소셜 객체(Social Object)로 표현하기 위해 만들어진 메타데이터 표준 프로토콜입니다. 즉, 웹사이트가 어떤 컨텐츠인지 설명하기 위한 통신 규칙이자 SNS (페이스북, 인스타그램 등)가 그 정보를 읽어서(크롤링) 미리보기를 꾸밀 수 있도록 정의한 명세서라고 보면 됩니다.</p>
<p>아래 처럼 웹 링크를 공유한 경우에 웹 내부 컨텐츠를 확인해본 경험이 있을겁니다.</p>
<p>(예시는 저의 velog 블로그 포스팅 하나를 가져와 슬랙에 링크를 넣었을 경우입니다.)</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/2548b883-b9a5-4b80-8999-cc537e651ae1/image.png" alt=""></p>
<p>위처럼 웹 링크를 공유했을 때, 공유된 링크의 컨텐츠가 보인다면 클릭율이나 사용자에게 더 나은 경험을 제공할 수 있습니다.</p>
<p>결국 이는 사용자의 트래픽을 증가시킬 수 있는 바이럴 유도의 좋은 전략이 될 수 있습니다.</p>
<p>그렇다면 또 다른 장점은 없을까요? 다양한 장점이 존재합니다.</p>
<blockquote>
<p><strong>오픈 그래프를 사용해서 얻는 장점이 무엇일까요?</strong></p>
</blockquote>
<p>오픈 그래프라는 프로토콜을 만들면서 모든 웹페이지가 같은 형식으로 자신을 설명할 수 있는 <strong>통일된 구조</strong>를 가질 수 있습니다.</p>
<p>그렇다면, 어떤 구조를 가질까요?</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/f2d8c5b2-bc8b-40af-bb6b-0316249958f8/image.png" alt=""></p>
<p>위 사진을 살펴보면, <meta> 태그의 og:XXX 라는 이름을 가진 데이터들을 설정할 경우, 그 웹사이트 링크의 컨텐츠를 제공할 수 있습니다. 기본적으로 <code>og:title</code>, <code>og:image</code>, <code>og:description</code>, <code>og:url</code> 만 넣으면 기본적으로 정보 전달이 가능합니다.</p>
<blockquote>
<p><strong>컨텐츠를 어떻게 각 플랫폼이 읽어서 미리보기로 보여줄 수 있을까요?</strong></p>
</blockquote>
<p>여러 플랫폼(페이스북, 슬랙, 디스코드 등) 이 같은 방식으로 처리 가능하기 때문에 플랫폼의 일관성을 가질 수 있는 장점도 있습니다.</p>
<p>다시 질문으로 돌아와서 미리보기를 어떻게 보여주게 되는걸까요? 그 이유는 플랫폼별 크롤러가 존재하기 때문입니다. 아래의 예시로 설명해볼게요.</p>
<p>처음에 조씨는 링크를 카카오톡에 이씨에게 <code>https://example.com/study/1</code> 전달합니다.</p>
<p>이 경우에 클라이언트 앱(카카오톡)은 해당 URL을 감지하고 바로 서버에 전달합니다.</p>
<p>이때 플랫폼별 크롤러(카카오톡 크롤러)는 HTML 요청을 통해 <head> 태그를 분석합니다.</p>
<pre><code class="language-tsx">&lt;head&gt;
  &lt;meta property=&quot;og:title&quot; content=&quot;오늘은 스터디 데이&quot; /&gt;
  &lt;meta property=&quot;og:description&quot; content=&quot;오픈 그래프에 대해서 알아봅니다.&quot; /&gt;
  &lt;meta property=&quot;og:image&quot; content=&quot;https://example.com/thumb.jpg&quot; /&gt;
  &lt;meta property=&quot;og:url&quot; content=&quot;https://example.com/study/1&quot; /&gt;
&lt;/head&gt;</code></pre>
<p>그렇게 위 처럼 HTML <head> 태그를 살펴보며, <meta> 태그이면서 property=”og:XXX” 속성을 가진 데이터를 찾습니다. </p>
<p>이제는 템플릿에 맞게 데이터를 구성합니다. title, description, image, url 을 웹 링크 컨텐츠에 맞게 설정하고, 각 플랫폼 별 레이아웃에 맞게 컨텐츠를 동적으로 렌더링하여 보여주게 됩니다.</p>
<p>  <img src="https://velog.velcdn.com/images/kwan_hee/post/ab2b0410-93fa-4a6b-a344-68a657d96b39/image.png" alt=""></p>
<p>그런데 이런 말이 있습니다. Open Graph를 설정하면, SEO(search engine optimization, 검색 엔진 최적화)에 도움이 된다고 합니다.</p>
<p>왜 도움이 될까요?</p>
<blockquote>
<p><strong>Open Graph가 SEO에 영향이 가는 이유는?</strong></p>
</blockquote>
<p>실제로 직접적인 영향은 제한적이지만, 간접적으로 상당히 중요한 역할을 한다고 합니다.</p>
<p>직접적인 영향이 제한된다는 것은 구글의 검색엔진이 있죠? 그 검색엔진이 OG 태그를 직접 SEO 랭킹 기준으로 삼지 않는다는 것이에요.</p>
<p>그런데 왜 SEO에 도움이 된다고하는 것일까요?</p>
<p>그 이유는 간접적으로 상당히 중요한 역할을 하기 때문입니다.</p>
<p>우선, OG 설정으로 링크 공유 시, 클릭율을 높일 수 있습니다. 이는 <strong>SEO에 긍정적 신호</strong>라고 볼 수 있습니다. 왜냐하면, 클릭율(CTR, Click-through rate)은 검색 엔진이 <strong>“어? 이 컨텐츠는 유용한데?”</strong> 라고 판단하고 지표가 되기 때문입니다. 즉, 랭킹 개선에 긍정적으로 영향을 끼칩니다.</p>
<p>그래서 OG 설정이 SEO에 어떻게 영향이 가는 지 대충 알아봤습니다.</p>
<p>그러면 실제 적용하기 위해서는 어떻게 해볼 수 있을까요?</p>
<p>Next.js 에서 어떻게 OG 메타태그를 서버사이드 렌더링(SSR, Server Side Rendering)으로 구성하는 지 알아보려고 합니다.</p>
<blockquote>
<p><strong>Next.js에서 메타 태그를 SSR 으로 구성하는 방법</strong></p>
</blockquote>
<p>우선 저는 Next.js 버전은 15로 설정하고 시작합니다.</p>
<p>메타데이터는 정적/동적 할당하는 2가지의 방법이 존재합니다. 저는 공유 랜딩페이지의 컨텐츠를 OG 메타 태그 데이터에 넣어서 링크 공유 시, 컨텐츠를 미리보기로 노출시키고 싶었습니다.</p>
<p>링크는 아래와 같습니다.</p>
<p><strong><code>https://exmaple.com/share/ABC</code></strong> </p>
<p>그리고 저는 디렉토리 구조는 아래와 같이 진행해서 앱 라우터(App Router) 방식으로 동적 라우팅 처리를 했습니다.</p>
<pre><code class="language-tsx">app
└─ share
   └─ [token]
      ├─ layout.tsx
      ├─ page.tsx
      └─ ...</code></pre>
<p>코드는 아래와 같습니다.</p>
<pre><code class="language-tsx">// layout.tsx

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    &lt;ThemeProvider attribute=&quot;class&quot; defaultTheme=&quot;dark&quot; enableSystem disableTransitionOnChange&gt;
      {children}
    &lt;/ThemeProvider&gt;
  )
}</code></pre>
<pre><code class="language-tsx">// page.tsx

// 라우트 세그먼트에서 받는 Props 타입 정의
type Props = {
  params: { token: string }
  searchParams?: { [key: string]: string | string[] | undefined }
}

// 동적으로 메타데이터 생성
export async function generateMetadata({ params }: Props): Promise&lt;Metadata&gt; {
  const { token } = params.token

  const res = await fetch(`${process.env.API_URL}/share?token=${token}`)

  const json = await res.json()
  const meeting: MeetingData = json.data
  return {
    title: meeting.meetingNote.title,
    openGraph: {
      description: meeting.meetingNote.summary,
      images: meeting.meetingNote.moodImageUrl,
      locale: `ko_KR`
    }
  }
}

// 실제 컴포넌트
export default async function SharePage({ params }: Props) {
    const { token } = params.token
}</code></pre>
<p><strong><em>* 주의할 부분이 있습니다.</em></strong></p>
<p>버전마다 로직 부분이 다를 수 있으므로, Next.js 15인지 확인하고 참고해주세요. 다른 버전이라면 공식문서를 참고해주시기 바랍니다! ☺️</p>
<p>또한, Next.js 환경에서는 서버 사이드 렌더링을 하기 때문에 API 서버를 스프링 부트를 사용하거나 다른 서버를 사용한 경우에는 해당 API 호출을 위해서 포트를 잘 확인해주세요! (이를 같은 네트워크에서 웹 서버를 띄울 경우에 Next.js 로 시작된 노드 서버를 인식할 수 있기 때문입니다.)</p>
<p>그래서 위처럼 추가할 경우에 아래처럼 잘 컨텐츠를 미리볼 수 있는 것을 알 수 있습니다.</p>
<p>(아래 내용은 실제 개발한 웹 사이트 OG 미리보기 형식입니다.)</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/41093550-837e-4b5d-bf44-8d16132fb1ea/image.png" alt=""></p>
<h3 id="ref">Ref.</h3>
<hr>
<p><a href="https://ogp.me/">Open Graph protocol</a></p>
<p><a href="https://idearabbit.co.kr/%EA%B2%80%EC%83%89-%EC%97%94%EC%A7%84-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B0%A9%EB%B2%95/open-graph/">오픈 그래프(Open Graph) 개념부터 적용 후 확인까지 총정리 - 오픈타임</a></p>
<p><a href="https://www.heropy.dev/p/n7JHmI#h3_%EB%A9%94%ED%83%80%EB%8D%B0%EC%9D%B4%ED%84%B0">Next.js 15 핵심 정리</a></p>
<p><a href="https://nextjs-ko.org/docs/app/api-reference/functions/generate-metadata">generateMetadata – Nextjs 한글 문서</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[기억장치 표기 용량에 대해서 (GB와 GiB 차이점)]]></title>
            <link>https://velog.io/@kwan_hee/%EA%B8%B0%EC%96%B5%EC%9E%A5%EC%B9%98-%ED%91%9C%EA%B8%B0-%EC%9A%A9%EB%9F%89%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C-GB%EC%99%80-GiB-%EC%B0%A8%EC%9D%B4%EC%A0%90</link>
            <guid>https://velog.io/@kwan_hee/%EA%B8%B0%EC%96%B5%EC%9E%A5%EC%B9%98-%ED%91%9C%EA%B8%B0-%EC%9A%A9%EB%9F%89%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C-GB%EC%99%80-GiB-%EC%B0%A8%EC%9D%B4%EC%A0%90</guid>
            <pubDate>Mon, 24 Mar 2025 11:52:01 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요 🙇🏻</p>
<p>기억장치 표기 용량에 대해서 알아보려고 합니다.</p>
<p>A라는 친구는 아래와 같이 말하고 있어요.</p>
<p><strong>💁‍♂️ : “내 컴퓨터는 1GB(기가바이트) 용량이라, 1MB 게임을 1000개 설치할 수 있어!”</strong></p>
<p>라는 기대감으로 1000개의 게임을 처리하려고 하는데, 막상  설치해보니 953개의 게임만 설치할 수 있더라구요.</p>
<p>왜 그럴까요?</p>
<p>이에 대해서 간단히 알아보기 위해서 기억장치 표기 용량에 대한 개념 이해가 필요합니다.</p>
<blockquote>
<p><strong>기억장치 표기 용량은 어떻게 표기되나요?</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/32b64be2-2cc3-4239-be22-4b95b995c257/image.png" alt=""></p>
<p>윈도우에서는 위와 같이 931GB 용량을 지원하고 있습니다. 하지만 왼쪽에 실제 용량은 훨씬 크죠?</p>
<p>931,000,000,000 bytes 로 표기되었음을 기대했을거에요.</p>
<p>이유는 윈도우 운영체제는 GB(SI 표기법, SI : 국제단위계, Système international d’unités, 영어: International System Units 약칭 <strong>SI)</strong> 라고 표시하지만 실제로는 GiB(이진 표기법) 사용해서 생기는 현상입니다.</p>
<p>즉, 원래는 다음과 같은거죠. 931GB는 원래 931GiB 입니다.</p>
<blockquote>
<p><strong>GB와 GiB의 차이점은 무엇인가요?</strong></p>
</blockquote>
<p>현실세계에서 미터법은 1km(킬로미터)에 얼마인가요? 1000m(미터)이죠? 실제로 컴퓨터는 바이너리 형식으로 저장되며 1KB(킬로바이트)는 1024B(바이트)를 나타냅니다.</p>
<p>하지만 미터법을 파생하여 현실세계에서 보여지는 단위를 이해하기 쉽게! 편의상 1024B(바이트)를 1KB(킬로바이트)라고 부르기 시작했습니다.</p>
<p>조금 어색하죠? </p>
<p>하지만 데이터의 용량이 커진다면 그 격차는 매우 커질거에요!</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/495af953-2a48-4ea3-8d47-568c40ea6c9d/image.png" alt=""></p>
<p>위에 표시를 보면 알 수 있지만, 단위가 커질 수록 Difference(차이)가 더 커지는 것을 알 수 있습니다.</p>
<p>간단하게 이해를 돕기위해 계산해보죠.</p>
<p>100TB = 100,000,000,000,000바이트</p>
<p>100TiB = 109,951,162,777,600바이트</p>
<p>입니다. 만약에 100TiB 계산되는 컴퓨터에 100TB로 표기해서 90.95TiB 만 사용하게 되어 사용자 입장에서는 9.05TiB 즉, 9,950,580,231,372 바이트를 사용하지 못하지? 라고 느끼게 됩니다.</p>
<p>그러면 혼동을 야기하겠죠?</p>
<p>그래서 IEC(국제 전기 표기 회의)에서는 표준을 개발하여 KiB(키비바이트), MiB(메비바이트), GiB(지비바이트) 등 단위를  개발하였습니다. 단위를 명확하게 표기해서 단위에 맞는 바이트를 제공하는거죠.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/5849fc6d-7d0c-4cab-a1a0-a1477329e926/image.png" alt=""></p>
<p>근데 일반적으로 GiB 라고 한다면 일반사람들이 1024바이트로 계산해야지~ 라고 생각할까요?</p>
<blockquote>
<p><strong>그래서 어떤 단위를 쓰라는거야? GB? GiB?</strong></p>
</blockquote>
<p>정답은 없습니다. 하지만 1024 거듭제곱으로 사용하는 컴퓨터 계산방식은 KiB, MiB를 사용하는 것이 올바른 표기방법입니다.</p>
<p>반면, SI(국제단위계) 기준으로는 1000의 거듭제곱으로 사용하는 KB, MB를 사용하는게 맞겠죠.</p>
<p>주로 일반 사용자에게는 GB, MB가 익숙합니다.</p>
<p>그래서 실제로 내부적으로는 GiB단위로 1024 거듭제곱으로 계산하고 보여지는 단위를 GB, MB 인 SI 표기법을 사용합니다.</p>
<p>즉, 앱의 파일크기가 100MB 가 필요하다면 실제로는 100MiB(=104.86MB)를 사용하고 UI적으로 사용자 혼동을 줄이기 위해 MB로 표기하는거에요.</p>
<p>그래서 결론은 다음과 같아요.</p>
<ul>
<li><strong>사용자나 마케팅용(소비자 친화적 정보)</strong>: 10진 기준(GB, MB)</li>
<li><strong>기술 문서·시스템 내부 계산(정확성 강조)</strong>: 2진 기준(GiB, MiB)</li>
</ul>
<blockquote>
<p><strong>이런 내용을 소개하는 이유는?</strong></p>
</blockquote>
<p>이사님이 말씀하셨죠.</p>
<p><strong>🤔 : 우리 파일 단위 정해진 규칙이 있었나?</strong></p>
<p><strong>왜 규칙이 필요한가?</strong></p>
<aside>

<p>조씨가 1GB 하드 드라이브를 구매했다고 가정해보죠.</p>
<p>미터법 측정 단위는 1000MB 저장 용량이 있을 것이라고 예상했고, 조씨는 게임을 1MB 1000개를 설치하길 기대하고 빨리 집에서 1000개를 설치해보려고 했습니다.</p>
<p>그런데! 각 용량이 1MB인 953개의 게임만 설치가 되어지고, 이제 용량은 꽉 차버렸어요..</p>
<p>화가난 조씨는 신고들어가야겠지? 라며 소송합니다.</p>
<aside>

<ul>
<li><strong>왜 953개 설치가 되는가?</strong></li>
</ul>
<p>1GB → MiB 단위로 나타내면, </p>
<p>1,000,000,000 bytes / 1024 / 1024 = 953 MiB</p>
<p>실제 1MB 게임은 1 MiB를 사용했고, 조씨가 구입한 1GB 하드 드라이브는 953 MiB 이므로 953개의 게임을 설치할 수 있었음.</p>
<p>(게임 1MB는 1MiB로 계산되며 보여지기로는 MB를 표기함)</p>
</aside>

</aside>

<p>실제로 이러한 소송에 대한 부분이 있었고, 오늘날에도 여전히 혼란은 이어진다고하네요…</p>
<p><a href="https://www.crn.com/news/channel-programs/189602434/western-digital-settles-hard-drive-capacity-lawsuit">https://www.crn.com/news/channel-programs/189602434/western-digital-settles-hard-drive-capacity-lawsuit</a></p>
<p>위의 문제점으로 우리만의 규칙과 정의가 필요합니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/a72b3d8a-1a5d-41da-8f83-4a9e8edc9252/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/d54ee0ba-4f4c-406e-a022-99170df0e5c1/image.png" alt=""></p>
<p>위의 그림을 보여드린 이유는 만약에 대용량 파일을 처리할 때, 요금제를 두어 월 1만원 요금제 사용 시, 2KB 제공을 해준다면 첫 번째 방법을 사용했을 경우 유저는 48bytes 손해볼 수 있습니다.</p>
<p>하지만 아래의 방법은 유저가 2000bytes를 기대했겠지만 실제로는 48bytes를 제공하는 입장에서 더 제공해주어서 올바른 동작을 기대할 수 있겠죠.</p>
<p>위의 문제는 미터표기법이 일반적이기 때문에 유저들의 기대에 부합하기 위해서는 후자가 올바르지 않을까라는 생각이 있습니다.</p>
<p>즉, 파일 단위를 보여줄 때, 1024(이진 표기법, KiB)로 나눌 것인가? 1000(SI 표기법, KB)로 나눌 것인가? 를 정하면 좋을 것 같아요.</p>
<p>프로젝트를 진행하면서 파일의 수는 많아지고 용량을 차지하고 있어서 이러한 부분을 우리만의 정책을 만들어 약속하는 것은 어떨까요?</p>
<h3 id="ref">Ref.</h3>
<hr>
<p><a href="https://www.ibm.com/docs/en/storage-insights?topic=overview-units-measurement-storage-data">https://www.ibm.com/docs/en/storage-insights?topic=overview-units-measurement-storage-data</a></p>
<p><a href="https://namu.wiki/w/%EA%B8%B0%EC%96%B5%EC%9E%A5%EC%B9%98/%ED%91%9C%EA%B8%B0%20%EC%9A%A9%EB%9F%89%EA%B3%BC%20%EC%8B%A4%EC%A0%9C#s-3">https://namu.wiki/w/기억장치/표기 용량과 실제#s-3</a></p>
<p><a href="https://massive.io/ko/file-transfer/gb-vs-gib-whats-the-difference/">https://massive.io/ko/file-transfer/gb-vs-gib-whats-the-difference/</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android Storage #2 - Internal Storage & External Storage]]></title>
            <link>https://velog.io/@kwan_hee/Android-Storage-2-Internal-Storage-External-Storage</link>
            <guid>https://velog.io/@kwan_hee/Android-Storage-2-Internal-Storage-External-Storage</guid>
            <pubDate>Mon, 17 Mar 2025 11:52:38 GMT</pubDate>
            <description><![CDATA[<p>이전 챕터에서는 안드로이드 저장소인 SharedPreferences와 DataStore에 대해서 알아봤습니다. 이번 챕터는 내부 저장소와 외부 저장소에 대해서 배워보려고 합니다.
들어가기 앞서, 안드로이드 저장소의 패러다임 변화를 알아보고 내부 저장소와 외부 저장소에 대해서 더 자세히 알아보려고 합니다.</p>
<p>Android 10(API 29) 기준으로 안드로이드 저장소 패러다임이 바뀝니다!</p>
<p>기존 저장소 방식이 새로운 저장소 방식으로 바뀌는데, Legacy Storage 에서 Scope Storage로 변경되게 됩니다.</p>
<h2 id="legacy-storage">Legacy Storage</h2>
<hr>
<ul>
<li><strong>Android 9(API 28)</strong> 이하 기기(또는 Android 10에서 <code>requestLegacyExternalStorage=&quot;true&quot;</code>로 선언한 앱)에서 적용되는 <strong>기존(전통) 외부 저장소 접근 방식</strong> 입니다.</li>
<li><strong>READ_EXTERNAL_STORAGE</strong>, <strong>WRITE_EXTERNAL_STORAGE</strong> 권한만 획득하면 /storage/emulated/0/ 아래 거의 모든 폴더에 직접 파일 경로로 접근할 수 있었습니다.</li>
<li>여러 앱이 동일한 디렉터리에 자유롭게 쓰고 읽을 수 있어, <strong>보안/프라이버시</strong> 문제가 발생할 수 있었습니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/49098d2a-c5d2-4dd0-96e9-7fc7ab406486/image.png" alt=""></p>
<p><a href="https://brunch.co.kr/@huewu/8">https://brunch.co.kr/@huewu/8</a></p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/f42d4e83-dda9-4174-814f-33c276049192/image.png" alt=""></p>
<h2 id="scoped-storage">Scoped Storage</h2>
<hr>
<ul>
<li><strong>Android 10(API 29)</strong> 부터 도입되고, <strong>Android 11(API 30)</strong> 이상에서 <strong>강제 적용</strong>되는 <strong>새로운 외부 저장소 접근 모델</strong>입니다.</li>
<li>앱이 “자신이 만든 파일” 외에는 <strong>임의 경로</strong>에 직접 접근할 수 없게 제한하고, 보안/개인정보 보호 강화하였습니다.</li>
<li><strong>미디어 파일</strong>은 <strong>MediaStore</strong> <strong>API</strong>, 일반 파일은 <strong>SAF(Storage Access Framework)</strong> 등을 통해 제한적으로 접근합니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/3fc453cf-dd3d-495b-ab61-a22766dbec0a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/e678e3d7-7597-475a-8093-7c84e463a0c3/image.png" alt=""></p>
<h3 id="결론">결론</h3>
<hr>
<p>따라서, <strong>Android 10(API 29)</strong> 이상의 기기(특히 11(API 30) 이상)에서는 “Scoped Storage”가 적용되어 <strong>경로 기반 접근</strong>이 어려워졌으며, “MediaStore / SAF”를 사용해 필요한 파일에만 제한적으로 접근하는 구조가 표준이 되었습니다. 이는 사용자 입장에서 <strong>개인정보 보호</strong>와 <strong>앱 간 간섭 최소화</strong>라는 큰 이점을 제공합니다.</p>
<h1 id="내부-저장소와-외부-저장소">내부 저장소와 외부 저장소</h1>
<p>Legacy Storage와 Scope Storage에 대해서 간단히 살펴보았고, 이제는 내부 저장소와 외부 저장소에 대해서 알아볼게요!</p>
<p><a href="https://developer.android.com/training/data-storage">https://developer.android.com/training/data-storage</a></p>
<h2 id="내부-저장소-internal-storage">내부 저장소 (Internal Storage)</h2>
<hr>
<p><a href="https://developer.android.com/training/data-storage/app-specific?hl=ko">https://developer.android.com/training/data-storage/app-specific?hl=ko</a></p>
<p>내부 저장소는 앱에서 보유하는 내부적인 저장소입니다. 다른 앱에서 내부 저장소에 접근하지 못하고 Android 10(API 29)부터는 위치가 암호화되기 때문에 민감한 정보를 저장하기에 적절합니다! 보안성이 높은 저장소입니다.</p>
<ul>
<li><strong>추가적으로 앱을 제거하면 내부 저장소에 있는 파일 모두 삭제됩니다.</strong></li>
<li><strong>접근하기 위해서 권한은 필요 없습니다.</strong></li>
</ul>
<p>내부 저장소에 액세스하기 위해서는 아래 코드로 가능합니다.</p>
<pre><code class="language-kotlin">context.filesDir // 영구 파일 경로
context.cacheDir // 캐시 파일 경로</code></pre>
<p>경로는 아래와 같은 경로에 저장되며, Device Explorer 에서 확인 가능합니다.</p>
<pre><code class="language-kotlin">/data/data/com.example.appname/files/{fileName} // 영구 파일 경로
/data/data/com.example.appname/cache/{fileName} // 캐시 파일 경로</code></pre>
<h3 id="영구-파일persistent-file"><strong>영구 파일(persistent file)</strong></h3>
<hr>
<p>우선 <strong>영구 파일(persistent file) 액세스</strong>하는 방법은 다음과 같습니다.</p>
<pre><code class="language-kotlin">// 1) 영구 파일에 접근하기
val file = File(context.filesDir, filename)

// 2) 스트림을 사용하여 파일 쓰기
val filename = &quot;myfile&quot;
val fileContents = &quot;Hello world!&quot;
context.openFileOutput(filename, Context.MODE_PRIVATE).use {
  it.write(fileContents.toByteArray())
}

// 3) 스트림을 사용하여 파일 읽기
context.openFileInput(filename).bufferedReader().useLines { lines -&gt;
  lines.fold(&quot;&quot;) { some, text -&gt;
    &quot;$some\n$text&quot;
  }
}</code></pre>
<p><strong>filesDir</strong>에 어떤 파일들이 존재하는지 <strong>목록</strong>을 살펴볼 수도 있습니다.</p>
<pre><code class="language-kotlin">var files: Array&lt;String&gt; = context.fileList()</code></pre>
<h3 id="캐시-파일cache-file"><strong>캐시 파일(cache file)</strong></h3>
<hr>
<p><strong>캐시 파일(cache file)</strong>에도 액세스할 수 있습니다.</p>
<pre><code class="language-kotlin">// 1) 캐시 파일에 접근하기
val cacheFile = File(context.cacheDir, filename)</code></pre>
<p>캐시 파일 사용할 때는 <strong>주의할 점</strong>이 있습니다.</p>
<p><strong>바로 기기의 내부 저장소 공간이 부족해지면 캐시 파일을 삭제하여 공간을 복구한다고 합니다. 그래서 “캐시 파일에 저장했으니, 열어봐야지” 을 하기전에 “캐시 파일이 있나?” 를 물어본 뒤 확인하면 좋습니다.</strong></p>
<pre><code class="language-kotlin">if (cacheFile.exists()) // &quot;캐시 파일 존재하나요?&quot;</code></pre>
<p><strong>추가적으로 캐시 파일을 정리할 때, “안드로이드 시스템 너가 알아서 공간 부족하면 정리해!” 라는 관점보다는 “필요하지 않는 파일을 삭제해야겠다” 관점으로 사용하면 좋습니다.</strong></p>
<p>그래서 아래 코드처럼 파일도 삭제할 수 있습니다.</p>
<pre><code class="language-kotlin">cacheFile.delete()</code></pre>
<h3 id="공부하다보니-궁금해요-🤔">공부하다보니 궁금해요! 🤔</h3>
<blockquote>
<p><strong>Android 10(API 29)부터 위치 암호화는 어떻게 진행되나요?</strong></p>
</blockquote>
<p>Android 10 부터는 FBE(File-Based Encryption) 방식의 암호화가 필수 요건이 되며 파일 기반 암호화가 진행됩니다. (앱 개발자가 별도로 암호화 로직을 구현하지 않아도 파일들이 암호화됩니다.)</p>
<p><strong>fscrypt</strong>(Linux 커널의 파일 암호화 기능)로 구현하며, 안드로이드에선 이를 <strong>Direct Boot</strong> 모드, <strong>CE/DE 구역</strong> 등과 결합해 사용한다고 합니다.</p>
<aside>

<p><strong>fscrypt :</strong> 리눅스 커널 4.x 이상에서 제공하는 <strong>파일 시스템 암호화</strong>(EXT4/F2FS 기반) 기능</p>
<p><strong>Direct Boot</strong> <strong>모드</strong> : 기기가 재부팅된 후 “아직 사용자 인증 전” 상태에서도 필요한 최소한의 기능(알람, 전화 수신 등)을 동작시켜야 하므로, DE 파티션을 활용</p>
<p><strong>CE 영역</strong> : CE(Credential Encrypted, 자격 증명 암호화) 디렉터리, 사용자가 “기기를 재부팅하고 잠금화면 해제(로그인)” 해야 비로소 접근 가능한 영역</p>
<p><strong>DE 영역</strong> : DE(Device Encrypted, 기기 암호화) 디렉터리, 기기가 켜져 있기만 하면(잠금화면을 풀지 않아도) OS가 최소한으로 접근 가능한 영역.</p>
<p>자세한 부분은 공식 문서를 참고해주세요! <a href="https://source.android.com/docs/security/features/encryption/file-based?hl=ko">https://source.android.com/docs/security/features/encryption/file-based?hl=ko</a></p>
</aside>

<p>결론으로 Android 10(API 29)부터는 <strong>“내부 저장소가 암호화된다!”</strong> → <strong>“사용자가 별도로 설정을 하지 않아도 /data 파티션이 기본적으로 FBE 처리된다”</strong> 라고 알고 있으면 됩니다!</p>
<blockquote>
<p><strong>영구 파일(persistent file) 과 캐시 파일(cache file)의 차이점은 무엇인가요?</strong></p>
</blockquote>
<p>두 차이점은 크게 <strong>용도</strong>와 <strong>보존 기간</strong>이 다릅니다. (저장 위치도 다르구요 ☺️)</p>
<ul>
<li><strong>영구 파일</strong><ul>
<li>앱이 삭제되기 전까지 시스템에서 임의로 삭제하지 않습니다! 그래서 중요한 데이터나 사용자 정보, 설정값 등 오랫동안 보존해야하는 데이터를 저장합니다.</li>
</ul>
</li>
<li><strong>캐시 파일</strong><ul>
<li>임시 데이터 보관을 위한 공간입니다. 즉, 시스템이 자동으로 또는 앱이 필요할 때 자유롭게 삭제할 수 있습니다. 용량이 부족하다? → 가장 먼저 정리되는 파일입니다!</li>
</ul>
</li>
</ul>
<p>즉, </p>
<ul>
<li><strong>오랫동안 꼭 보존해야 하는 데이터</strong> → 영구 파일에 저장</li>
<li><strong>임시적이고 언제든 재생성 가능</strong> → 캐시 파일에 저장</li>
</ul>
<p>분리해서 적절한 상황에 사용하면 좋습니다! ☺️</p>
<blockquote>
<p><strong>Device Explorer 저장된 파일을 확인하려고 하니, 영구 파일과 캐시 파일 경로가 같은데요?</strong></p>
</blockquote>
<p>음 경로가 같지는 않아요! 혹시 이 부분을 지적할 수 있을 것 같아요!</p>
<p><strong>openFileOutput API를 사용하지 않았나요?</strong></p>
<p>해당 API는 기본적으로 filesDir 즉, 영구 파일로 저장됩니다! </p>
<pre><code class="language-kotlin">openFileOutput(...).use { ... }</code></pre>
<p>그렇기 때문에 아래와 같이 바꿔보세요!</p>
<p>FileOutputStream API를 사용해서 file name 이 아닌 File을 직접 파라미터로 넣어주면 됩니다!</p>
<pre><code class="language-kotlin">val persistentFile = File(filesDir, &quot;persistent file&quot;)
val cacheFile = File(cacheDir, &quot;cache file&quot;)

FileOutputStream(persistentFile).use {
  it.write(&quot;persistent&quot;.toByteArray())
}
FileOutputStream(cacheFile).use {
  it.write(&quot;cache&quot;.toByteArray())
}</code></pre>
<pre><code class="language-kotlin">/data/data/&lt;packagename&gt;/
├── cache/
|            └── cache file  
└── files/
     └── persistent file  </code></pre>
<p>Device Explorer를 살펴보면, 다른 경로를 나타내고 있는 것을 알 수 있습니다. ☺️</p>
<img src="https://velog.velcdn.com/images/kwan_hee/post/5bdef686-b5cd-42a4-85d8-c27d4c15b765/image.png" width = 250px height = 100px>


<blockquote>
<p>간단한 질문타임~!</p>
</blockquote>
<ul>
<li>파일에 “a” 데이터를 쓰고, 바이트로 읽으면 무엇이 나올까요!? (답은 알아서! ☺️)</li>
</ul>
<h2 id="외부-저장소-external-storage">외부 저장소 (External Storage)</h2>
<hr>
<p>외부 저장소도 내부 저장소와 마찬가지로 영구 파일과 캐시 파일로 나뉩니다. 파일을 저장하는데, 내부 저장소가 공간이 충분하지 않다면, 대신해서 외부 저장소를 사용할 수 있습니다!</p>
<ul>
<li><strong>저장된 파일은 앱을 제거할 때 삭제됩니다. (앱 전용 외부 저장소 파일을 말하는 것입니다. 공유 저장공간은 아닙니다..)</strong></li>
</ul>
<p><strong>TMI!</strong></p>
<p>그런데 다른 앱에서 액세스할 수 있는 파일을 만들려면 앱에서 이러한 파일을 외부 저장소의 공유 저장공간 부분에 대신 저장해야한다고 합니다. 예를 들어, 사용자가 “아.. 앱 지울래” 하고 삭제한 경우, 앱의 저장소에 저장된 파일이 삭제됩니다. 앱에서 사용자가 사진 캡처를 허용하여 저장한 경우 앱을 제거한 후에도 사진을 액세스할 수 있다고 예상합니다. 이 경우 공유 저장소를 사용해 대신 저장해야한다고 합니다. ☺️</p>
<p>TMI를 한 이유는 공유 저장공간도 외부 저장소에 한 부분입니다. 하지만 말할 부분이 많아서 따로 섹션을 나누려고 해요!</p>
<p>(공유 저장소에 대한 자세한 언급은 다음에 할게요!)</p>
<p>들어가기 앞서, <strong>외부 저장소는 사용하기 전에 사용할 수 있는지 확인</strong>하라고 권장하고 있습니다.</p>
<pre><code class="language-kotlin">fun isExternalStorageWritable(): Boolean {
    return Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED
}

fun isExternalStorageReadable(): Boolean {
     return Environment.getExternalStorageState() in
        setOf(Environment.MEDIA_MOUNTED, Environment.MEDIA_MOUNTED_READ_ONLY)
}</code></pre>
<p>안드로이드 기기에서는 물리적인 SD 카드가 없더라도 <code>/storage/emulated/0/</code> 같은 <strong>가상(논리적) 외부 저장소</strong>가 존재하기 때문에, 일반적인 스마트폰·태블릿에서는 사실상 “외부 저장소가 항상 있다”라고 생각했습니다. 그렇기 때문에 위 함수로 구분을 해주어야하는가? 에 대한 고민을 해봤습니다.</p>
<p>하지만 실제로는 기기 종류나 OS 상태(마운트 여부 등)에 따라 다양한 예외 상황이 생길 수 있기 때문에, 안드로이드 공식 가이드에서도 “외부 저장소에 접근하기 전 <strong>마운트 상태</strong>를 확인하라”고 권장하고 있습니다.</p>
<p>구형 태블릿, TV, Wear OS 기기, 또는 물리적 SD 카드가 삽입되지 않은 기기 등 다양한 상황을 커버하기 위해서 권장된다고 합니다. ☺️</p>
<p>방어적 설계는 안전성 측면에서 좋은 설계 같습니다. ☺️</p>
<blockquote>
<p><strong>마운트란 무엇일까요?</strong></p>
</blockquote>
<p>운영체제(특히 리눅스/유닉스 계열)가 디스크 파티션이나 외부 저장 장치(SD 카드, USB 등)를 특정 디렉터리와 연결해, 파일 시스템에 접근할 수 있게 만드는 과정입니다. 안드로이드는 리눅스 기반이므로 파일 시스템(디렉토리도 파일이죠 ㅎ, 디렉토리 = 특수 파일!) 접근 시 마운트라는 과정이 필요합니다.</p>
<p>즉, <strong>안드로이드에서 외부 저장소가 마운트되었다 말</strong>은</p>
<p><strong>⇒ 운영체제가 ‘외부 저장소’ 를 정상적으로 인식했다! 라고 할 수 있습니다.</strong></p>
<p>경로는 아래와 같습니다!</p>
<pre><code class="language-kotlin">/storage/emulated/0/Android/data/{packageName}/files</code></pre>
<p>또는 sdcard가 있는 경우, 다음과 같습니다.</p>
<pre><code class="language-kotlin">/sdcard</code></pre>
<p>실제로 외부 경로를 가져오는 방법은 아래와 같습니다.</p>
<pre><code class="language-kotlin">context.getExternalFilesDir(null)</code></pre>
<p>Enviroment.DIRECTORY_PICTURES, DIRECTORY_MUSIC, DIRECTORY_DOCUMENTS 등 인자로 줄 수도 있습니다. null 인경우 files/ 경로를 가져오게 되는 것입니다.</p>
<p>예를 들어,  DIRECTORY_PICTURES 인자를 가진 경우 아래 경로를 가지게 됩니다.</p>
<pre><code class="language-kotlin">/storage/emulated/0/Android/data/{packageName}/files/Pictures</code></pre>
<h3 id="영구-파일">영구 파일</h3>
<p>외부 저장소 내 영구 파일을 추가하려면 아래 코드를 참고해주세요!</p>
<pre><code class="language-kotlin">// 1) 영구 파일 액세스
val appSpecificExternalDir = File(context.getExternalFilesDir(null), filename)</code></pre>
<h3 id="캐시-파일">캐시 파일</h3>
<p>외부 저장소 내 캐시에 앱 파일을 추가하려면 아래 코드를 참고해주세요.</p>
<pre><code class="language-kotlin">// 1) 캐시 파일 액세스
val externalCacheFile = File(context.externalCacheDir, filename)</code></pre>
<h3 id="tmi--여유-공간">TMI : 여유 공간</h3>
<p>대부분 기기에 사용 가능한 저장공간이 충분하지 않으므로 앱에서 공간을 신중하게 사용해야 합니다!</p>
<p>저장하는 데이터 양을 미리 알면 여유 공간을 확인하고 판단할 수 있습니다.</p>
<pre><code class="language-kotlin">// 1) 앱이 필요한 용량: 10MB
const val NUM_BYTES_NEEDED_FOR_MY_APP = 1024 * 1024 * 10L

// 2) StorageManager 인스턴스 가져오기
val storageManager = applicationContext.getSystemService&lt;StorageManager&gt;()!!

// 3) &#39;filesDir&#39;가 속한 볼륨의 UUID 구하기
// (안드로이드 기기가 내부적으로 여러 파티션으로 관리하기 때문에 fileDir가 어디에 
//  위치해야하는 지 알아야 정확한 할당 가능한 바이트를 계산할 수 있습니다.)
val appSpecificInternalDirUuid: UUID = storageManager.getUuidForPath(filesDir)

// 4) 현재 해당 볼륨에서 &#39;할당 가능한 바이트(Allocatable Bytes)&#39; 조회
val availableBytes: Long = storageManager.getAllocatableBytes(appSpecificInternalDirUuid)

// 5) 충분한 공간이 있으면 allocateBytes()로 예약(할당)
if (availableBytes &gt;= NUM_BYTES_NEEDED_FOR_MY_APP) {
    storageManager.allocateBytes(appSpecificInternalDirUuid, NUM_BYTES_NEEDED_FOR_MY_APP)
} else {
    // 6) 부족하면 저장소 관리 화면으로 이동하는 Intent
    //    (또는 ACTION_CLEAR_APP_CACHE 등 다른 액션 사용 가능)
    val storageIntent = Intent().apply {
        action = ACTION_MANAGE_STORAGE
    }
    // startActivity(storageIntent) 등으로 사용자에게 안내 가능
}
</code></pre>
<p><strong>매번 사용 가능한 공간을 확인해야할까요? 🤔</strong></p>
<p>아닙니다. 대신 파일을 곧바로 쓴 후 IOException 을 발생하라고 공식문서에서 말하고 있습니다.</p>
<p>필요한 공간을 정확히 모르면 이 방법을 사용해야한다는 것입니다.</p>
<p>예시로 파일 저장 시, PNG 이미지를 JPEG로 변환하여 인코딩하는 경우 파일의 크기를 미리 알 수 없으니.. 이런 경우에는 IOException 을 발생시켜 알 수 없는 이슈에 대한 I/O 예외를 던져주면 됩니다!</p>
<h3 id="버전-별-권한">버전 별 권한</h3>
<p>안드로이드 버전별로 권한 관련해서 외부 저장소 접근 모델은 어떠한지? 주요 변환 내용은 어떻게 되는 지 살펴보면 좋습니다. 아래 표로 확인해보겠습니다!</p>
<p><strong>(✅ : 권한 필요, ❌ : 권한 필요없음)</strong></p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/9a30671a-8b4f-4d3e-a18f-494ccbc6fb98/image.png" alt=""></p>
<p>Android 버전이 올라가면서 저장소 접근이 강화되고 있습니다. 실제로 구글 보안 강화로 이미지를 가져오는 <strong>READ_MEDIA_IMAGES</strong> 권한도 Picker를 사용하도록 SAF 사용을 권장하고 있습니다. (권한 사용하면 허락 받아야 됩니다.. 😥)</p>
<p><a href="https://support.google.com/googleplay/android-developer/answer/14115180?hl=ko">https://support.google.com/googleplay/android-developer/answer/14115180?hl=ko</a></p>
<h2 id="공유-저장소-shared-storage">공유 저장소 (Shared Storage)</h2>
<p><a href="https://developer.android.com/training/data-storage/shared?hl=ko">https://developer.android.com/training/data-storage/shared?hl=ko</a></p>
<hr>
<p>외부 저장소의 한 부분입니다.</p>
<p>공유 저장소는 기기 내 <strong>/storage/emulated/0/</strong> (또는 실제 SD 카드)의 공용 디렉터리들(예: DCIM, Pictures, Music, Downloads)을 가리킵니다.</p>
<ul>
<li><strong>여러 앱</strong>이 같은 영역에 접근할 수 있고,</li>
<li>사용자가 직접 파일을 열람·복사·삭제할 수 있는 영역이기도 합니다.</li>
</ul>
<p>하지만 <strong>안드로이드 10+</strong>(특히 11(API 30)+)부터는 기존처럼 <strong>READ/WRITE_EXTERNAL_STORAGE</strong> 권한만으로 <strong>임의 경로</strong>에 자유롭게 접근하기가 어렵습니다.</p>
<p>(Legacy → Scope Storage 적용 때문!!)</p>
<ul>
<li><strong>미디어 파일</strong>(이미지, 동영상, 오디오) → <strong>MediaStore API</strong> 사용</li>
<li><strong>기타 일반 파일</strong> → <strong>SAF(Storage Access Framework)</strong> 사용</li>
<li>앱 전용 외부 디렉터리(<code>/Android/data/&lt;패키지명&gt;</code> 등)는 공유 저장소와 구분되며, 해당 앱만 직접 접근 가능합니다(앱 삭제 시 함께 삭제).</li>
</ul>
<h3 id="mediastore-api">MediaStore API</h3>
<p><a href="https://developer.android.com/training/data-storage/shared/media">https://developer.android.com/training/data-storage/shared/media</a></p>
<hr>
<p>미디어 파일(사진, 동영상, 오디오)을 관리하고, 시스템 갤러리(또는 음악 플레이어 등)에서 인덱싱하는 <strong>DB 테이블</strong>을 제공하는 안드로이드 컴포넌트입니다.</p>
<p>그렇기 때문에 Android 10+ 에서는 ContentResolver를 통해서 <strong>insert(),</strong> <strong>update(), delete(), query()</strong> 등을 수행하게 됩니다.</p>
<p><strong>자세한 내용은 공식문서 봐주세요 ☺️ (내용이 너무 많아요 😅)</strong></p>
<h3 id="saf-storage-access-framework">SAF (Storage Access Framework)</h3>
<p><a href="https://developer.android.com/training/data-storage/shared/documents-files">https://developer.android.com/training/data-storage/shared/documents-files</a></p>
<hr>
<p><strong>일반 파일</strong>(문서, PDF, ZIP, txt 등)이나 사용자가 임의로 보관한 폴더/파일에 접근하려면, <strong>Storage Access Framework</strong>를 사용해 <strong>시스템 파일 선택기</strong>(문서 선택·폴더 선택 등) 또는 <strong>문서 생성기</strong>를 띄우고, 사용자가 “어떤 파일(폴더)에 접근할지”를 직접 지정하면, <strong>해당 URI</strong>에 대해 <strong>앱이 권한을 일시적으로 얻게 되는 모델</strong>입니다.</p>
<p><strong>안드로이드 4.4(API 19)</strong> 에 처음 도입되었고, <strong>Scoped Storage</strong>가 강화된 Android 10+ 환경에서도 여전히 권장 방식입니다.</p>
<p>이 부분도 자세한 내용은 공식문서 참고!!</p>
<h2 id="참고자료">참고자료</h2>
<hr>
<p><a href="https://kimdabang.tistory.com/entry/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EC%A0%80%EC%9E%A5%EC%86%8C-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-1-Legacy-Storage">https://kimdabang.tistory.com/entry/안드로이드-저장소-사용하기-1-Legacy-Storage</a></p>
<p><a href="https://turagabhupathi.medium.com/scoped-storage-for-above-android-10-using-java-upload-to-server-using-multipart-form-retrofit-75c0b613e292">https://turagabhupathi.medium.com/scoped-storage-for-above-android-10-using-java-upload-to-server-using-multipart-form-retrofit-75c0b613e292</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android Storage #1 - SharedPreferences & DataStore]]></title>
            <link>https://velog.io/@kwan_hee/Android-Storage-1-SharedPreferences-DataStore</link>
            <guid>https://velog.io/@kwan_hee/Android-Storage-1-SharedPreferences-DataStore</guid>
            <pubDate>Sun, 23 Feb 2025 11:41:32 GMT</pubDate>
            <description><![CDATA[<p>안드로이드의 모든 저장소를 한 눈에 살펴보려고 합니다.</p>
<p>저장소 설명, 정책, 이슈를 설명하고 어떻게 동작하는 알아보려고 합니다. ☺️</p>
<h2 id="sharedpreferences--datastore">SharedPreferences &amp; DataStore</h2>
<h3 id="sharedpreferences">SharedPreferences</h3>
<p><a href="https://developer.android.com/training/data-storage/shared-preferences?hl=ko">https://developer.android.com/training/data-storage/shared-preferences?hl=ko</a></p>
<p>안드로이드에서 제공하는 키-값 쌍 데이터 저장소이고, 기본적으로 XML 형태로 관리합니다. </p>
<p>직접 파일을 읽고 쓰는 것보다 훨씬 쉽고 빠른 접근 방법입니다. 안드로이드 내부 전용 파일에 저장됩니다.</p>
<p>간단하게 예시코드와 주석으로 설명합니다.</p>
<pre><code class="language-kotlin">/* 
1) 파일 이름을 지정합니다. 파일 이름(infomation) 앞에 
애플리케이션 id (com.example.kwanhee)를 붙여주는 것이 좋습니다.
*/
val fileName = &quot;com.example.kwanhee.infomation&quot;

/*
2) 공유 파일을 가져옵니다. mode의 경우, MODE_PRIVATE을 사용하여 앱 외부에서 접근이 불가능하도록 지정합니다.
*/
val sharedPref = getSharedPreferences(fileName, MODE_PRIVATE)

/*
3) 에디터를 사용해 데이터를 쓸 수 있습니다.
*/
val editor = sharedPref.edit()
editor.putString(&quot;name&quot;, &quot;JoKwanhee&quot;)

/*
4) 데이터를 저장합니다. 
apply() | commit() 
  비동기  |  동기
(editor 에서 edit {} 람다를 사용해서 default 로 apply() 를 사용할 수 있습니다.)
*/
editor.apply() // 또는 .commit()

/*
5) 데이터 읽기
*/
val userName = sharedPref.getString(&quot;name&quot;, &quot;&quot;)</code></pre>
<p><strong>*주의사항 : commit() 으로 데이터를 쓰는 경우, UI 스레드를 차단하는 것을 피해야합니다. (UI 렌더링이 일시정지 될 수 있습니다.)</strong></p>
<p>만약에 중요한 정보를 저장하려면, <strong>EncryptedSharedPreferences (Android Security)</strong>를 적용하는 것도 좋은 방법입니다.</p>
<ul>
<li><strong>commit() 는 동기적으로 apply() 는 비동기적으로 동작합니다. 그렇다면 apply()를 기본으로 사용할텐데, commit() 은 언제 사용하면 좋을까요?</strong></li>
</ul>
<p>commit()은 스레드를 블록킹할 위험이 있어서 apply()를 권장하비다. 하지만 해당 스레드가 UI 스레드가 아닌 백그라운드 스레드이고, commit() 반환형은 boolean 타입이기 때문에 명확하게 성공/실패를 알기 위해서 좋은 api 입니다.</p>
<ul>
<li><strong>어디에 저장되는 지 확인하고 싶은데, 방법이 있을까요?</strong></li>
</ul>
<p>앱 내부 저장소에 저장됩니다. SharedPrferences를 사용하고 실행된 앱의 아이디는 <strong>com.example.kwnahee</strong> 라고 가정하겠습니다.</p>
<p>Android Studio Tool 중에서 <strong>Device Explorer</strong>가 존재합니다. 해당 Tool을 열고, 아래 경로를 찾아주면 됩니다.</p>
<pre><code class="language-kotlin">/data/data/com.example.kwanhee/shared_prefs/com.example.kwanhee.infomation.xml</code></pre>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/edbd12d9-dede-4e73-ad53-7ac4d2e3777e/image.png" alt=""></p>
<ul>
<li><strong>SharedPreferences 파일 이름 앞에 앱 아이디를 적어주는 이유가 뭘까요?</strong></li>
</ul>
<p>별도의 라이브러리나 다른 모듈과의 충돌을 방지하기 위해, 고유하게 관리가 필요합니다.</p>
<p>즉, 외부 라이브러리가 SharedPreferences를 사용하면서 중복되는 파일 이름일 수 있으므로 고유한 이름을 만들어주기 위함입니다. 추가적으로 앱의 아이디로 파일 이름을 설정해놓았다면 추적관 관리에 용이하게 됩니다.</p>
<p>com.example.kwanhee 앱이 있다면, 해당 앱에서의 SharedPreferences 저장소 파일은 모두 com.example.kwanhee.{name} 으로 시작하기 때문입니다. 😎</p>
<ul>
<li><strong>File I/O 와 SharedPreferences I/O 중 SharedPreferences 가 경량이고, 빠른 이유가 뭔가요?</strong></li>
</ul>
<p>우선 key-value 형식이기 때문입니다. 일반 텍스트 파일이라면 파싱하여 원하는 값을 찾아야겠지만, 원하는 값을 key를 통해 가져올 수 있으므로 처리가 간단합니다.</p>
<p>key-value 값을 가져오는 것은 알겠지만, 디스크 I/O 발생은 여전하지 않은가? 그렇지 않습니다. 내부 구현체에서는 이를 Map 자료구조를 활용하여 캐싱하고 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/fd679083-6ba2-44ab-bb69-cb708cbe1bcd/image.png" alt=""></p>
<p>위 그림을 살펴보면 간단한 구조로 캐싱을 진행하고 있습니다.</p>
<p>내부 구현을 어떻게 되어있을까요?</p>
<p>context.getSharedPreferences() API 가 호출되면 내부적으로 SharedPrefencesImpl 클래스가 호출됩니다. 생성자로 startLoadFromDisk() 함수가 호출되어지고, 백그라운드 스레드에서 loadFromDisk() 함수가 호출됩니다. XML key-value 데이터를 Map 데이터로 할당하여 캐싱역할을 해주도록 로직이 구현되어 있습니다.</p>
<pre><code class="language-kotlin">final class SharedPreferencesImpl implements SharedPreferences {
    ...
  @GuardedBy(&quot;mLock&quot;)
  private boolean mLoaded = false;

    private void loadFromDisk() {
    synchronized (mLock) {
            // 호출한 적이 있다면, return
            // return =&gt; xml 읽지 않음
        if (mLoaded) {
            return;
        }
        ...
    }

    Map&lt;String, Object&gt; map = null;
    ...
    // 실제로 SharedPreferences XML 파일을 읽어옴
    if (mFile.canRead()) {
        try (BufferedInputStream str = new BufferedInputStream(new FileInputStream(mFile), 16 * 1024)) {
            map = (Map&lt;String, Object&gt;) XmlUtils.readMapXml(str);
        } catch (Exception e) {
            Log.w(TAG, &quot;Cannot read &quot; + mFile.getAbsolutePath(), e);
        }
    }
    ...

    synchronized (mLock) {
        mLoaded = true;
        ...
        if (map != null) {
                // SharedPreferences XML 데이터를 mMap 데이터로 할당
            mMap = map;
        } else {
            // 파일이 없는 경우, 새 HashMap으로 초기화
            mMap = new HashMap&lt;&gt;();
        }
    }
}

    ...
}</code></pre>
<p>데이터를 읽어올 때는 어떻게 되어있을까요?</p>
<p>(데이터를 쓸 때는 apply() 또는 commit() 을 사용하여 XML과 mMap 모두 갱신합니다. 디스크 I/O가 발생하는 부분은 쩔 수 없음. 그래도 데이터를 읽을 때 캐시되니깐 다행입니다. ☺️)</p>
<p>간단하게 String을 가져오는 함수입니다. awaitLoadedLocked 함수가 보이네요. </p>
<p>getString 을 빠르게 맨 처음 호출한 경우, XML에서 데이터를 로딩하는게 마무리되지 않아 awaitLoadedLocked() 로 동기화 대기를 걸어주게 되는 것입니다.</p>
<pre><code class="language-kotlin">@Override
public String getString(String key, @Nullable String defValue) {
    synchronized (mLock) {
        awaitLoadedLocked();   
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}

// mLoaded가 false면 load가 끝날 때까지 대기합니다.
@GuardedBy(&quot;mLock&quot;)
private void awaitLoadedLocked() {
        ...
    while (!mLoaded) {
        try {
            mLock.wait();
        } catch (InterruptedException unused) {
        }
    }
    if (mThrowable != null) {
        throw new IllegalStateException(mThrowable);
    }
}</code></pre>
<p>즉, <strong>mLoaded</strong>가 true인 상태라면, 이미 디스크 로딩이 끝났으므로 다시 디스크를 읽지 않고 <strong>mMap</strong>을 바로 사용합니다.</p>
<p>자세한 내용은 <strong>SharedPreferencesImpl.java</strong> 구현체를 확인해주세요!</p>
<p>(<a href="https://android.googlesource.com/platform/frameworks/base.git/+/master/core/java/android/app/SharedPreferencesImpl.java">https://android.googlesource.com/platform/frameworks/base.git/+/master/core/java/android/app/SharedPreferencesImpl.java</a>) </p>
<p>하지만 Kotlin Coroutine과 Flow를 기반으로 하는 DataStore에 등장으로 SharedPreferences 역할을 대신하고 있습니다. 뿐만 아니라 공식 문서에서도 권장하고 있습니다.</p>
<p>다음은 DataStore에 대해서 알아보도록 하겠습니다.</p>
<h3 id="datastore">DataStore</h3>
<p><a href="https://developer.android.com/topic/libraries/architecture/datastore?hl=ko">https://developer.android.com/topic/libraries/architecture/datastore?hl=ko</a></p>
<p>DataStore는 Coroutine-Flow를 지원하고 있으며, 원자성을 보장하고 충돌이 발생하지 않아 데이터를 안전하게 지킬 수 있습니다. 스레드 Safety한 장점도 있습니다.</p>
<p>그렇다면, SharedPreferences 는 버리고 모두 마이그레이션 해야할까요? 그건 아닙니다.</p>
<p>팀 내에서 적절한 판단과 논의로 결정하면 됩니다.</p>
<ul>
<li><strong>아주 간단하게 로컬 저장소를 사용하고 있는가?</strong> ⇒ 그렇다면 굳이 DataStore로 마이그레이션하는 것은 오버 엔제니어링이 될 수 있습니다. (하지만 학습이 목표라면 너무너무 긍정적으로 진행해봐도 좋을 것 같아요 ☺️)</li>
<li><strong>기존 레거시인 경우</strong> ⇒ 이미 SharedPreferences 의 영향이 여러 곳에 연결되어 있다면, 사이드 이펙트를 고려해보며 마이그레이션을 신중하게 결정해야합니다. 그래서 억지로 마이그레이션을 진행하지 않아도 괜찮은 거죠.</li>
</ul>
<p>DataStore에는 어떤 방식이 존재할까요?</p>
<ul>
<li><strong>Preferences Datastore</strong></li>
<li><strong>Proto Datastore</strong></li>
</ul>
<h3 id="preferences-datastore"><strong>Preferences Datastore</strong></h3>
<p><strong>Preferences Datastore ,</strong> SharedPreferences와 같은 key-value 방식입니다.</p>
<p>초기화, 데이터 읽기/쓰기는 공식문서를 확인해주세요! </p>
<p>그런데 SharedPreferences와 마찬가지로 궁금합니다.</p>
<ul>
<li><strong>Preferences Datastore 에서는 어디에 저장이 될까요?</strong></li>
</ul>
<p>아래 경로인 내부 저장소에 저장되게 됩니다.</p>
<pre><code class="language-kotlin">/data/data/com.example.kwanhee/files/datastore/[파일이름].preferences_pb</code></pre>
<p>조금 특이한 부분은 확장자가 preferences_pb 라는 점입니다. xml이 아닙니다. </p>
<p>Preferences DataStore는 내부적으로 <strong>프로토콜 버퍼(Protocol Buffers)</strong> 를 사용합니다. 그렇기 때문에 일반 텍스트가 아니라 바이너리 형태로 저장되어지고, 텍스트 에디터로 열어도 내용을 이해하기 어렵습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/8111bad2-5601-4453-83d5-a9db331ddf73/image.png" alt=""></p>
<p>그래서 내부적으로 DataStore는 해당 데이터를 읽거나 쓸 때, 내부 API가 안전하게 직렬화/역직렬화 처리를 해줍니다.</p>
<ul>
<li><strong>프로토콜 버퍼가 무엇이고, 사용하는 이유가 뭔가요?</strong></li>
</ul>
<h3 id="프로토콜-버퍼">프로토콜 버퍼</h3>
<p>구글이 개발한 <strong>직렬화(Serialization) 포맷</strong>이자 <strong>라이브러리입니다.</strong></p>
<p>Protobuf ↔ Json 으로 데이터를 변환할 수 있습니다.</p>
<p>그래서 사용하는 이유가 뭘까요? 크기가 작고, 속도가 빠릅니다. 이유는 <strong>이진(Binary) 형태로 직렬화</strong>되기 때문입니다.</p>
<p><strong>*여담 : 최근 STT 기술을 사용해보면서, 구글에서 지원하는 STT API를 적용할 때 gRPC(Google Remote Procedure Call) 를 사용했고, 여기서 빠른 통신을 위해 프로토콜 버퍼를 사용합니다.</strong></p>
<p>.proto 확장자로 파일을 만들 수 있습니다.</p>
<p>만약에 Json 객체는 어떻게 구성되어질까요? 그리고 그 Json 데이터는 proto에서 어떻게 작성될까요?</p>
<p>우선 Json 예시를 들겠습니다.</p>
<pre><code class="language-kotlin">{
    &quot;name&quot; : &quot;Jokwanhee&quot;,
    &quot;id&quot; : 1,
}</code></pre>
<p>위 Json 데이터는 proto로 나타낼 때, 아래와 같이 나타낼 수 있습니다.</p>
<p>(아래 코드를 실제로 안드로이드에서 .proto 파일을 만들 때 예시입니다.)</p>
<pre><code class="language-kotlin">syntax = &quot;proto3&quot;;

option java_package = &quot;com.example.myapp&quot;; // 생성되는 Java 클래스가 위치할 패키지
option java_outer_classname = &quot;PersonOuterClass&quot;; // 생성되는 Outer class 이름

message Person {
  string name = 1;
  int32 id = 2;
}</code></pre>
<p>위 Json 데이터를 직렬화하면 아래처럼 됩니다. 그렇게 될 경우, 데이터의 크기는 총 27Byte가 됩니다.</p>
<pre><code class="language-kotlin">{&quot;name&quot;:&quot;Jokwanhee&quot;,&quot;id&quot;:1}</code></pre>
<p>그렇다면, ProtoBuf에서는 직렬화 시, 데이터가 어떻게 될까요?</p>
<p>Json과 다르게 ProtoBuf에 데이터 규칙이 존재합니다. </p>
<p>데이터는 Tag + Length + Value 구조를 가지고 있습니다. (즉, 태그 + 길이 + 값)</p>
<ul>
<li><strong>name 필드 직렬화</strong></li>
</ul>
<p>name 부터 살펴봅시다. </p>
<ul>
<li>name 필드 번호 : 1</li>
<li>타입 : string</li>
</ul>
<p>태그(Tag) 계산은 (필드 번호 &lt;&lt; 3) | type 으로 계산합니다.</p>
<p>(1 &lt;&lt; 3) = 8 : 왼쪽 시프트 연산을 3번 진행합니다. (3번 진행하는 것은 약속입니다.)</p>
<p>type = 2</p>
<p>type은 어떻게 2라는 것을 알 수 있을까요? 아래 표를 보시면 string은 id가 2라는 것을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/05e9668c-6b9b-4528-89db-988b75159503/image.png" alt="">
<a href="https://protobuf.dev/programming-guides/encoding/">https://protobuf.dev/programming-guides/encoding/</a></p>
<p>결국 name의 태그(Tag)값은 10이 됩니다. ⇒ </p>
<p>길이(Length)를 구할 수 있는 string은 Jokwanhee 의 길이의 값이 길이(Length)가 됩니다. 즉, 9가 됩니다.</p>
<p>마지막으로 값(Value)입니다. Jokwanhee 를 UTF-8로 인코딩하여 값을 얻을 수 있습니다. 아래 표에서 원하는 문자의 인코딩 값을 찾습니다.</p>
<p><strong>J : 4A / o : 6F / k : 6B / w : 77 / a : 61 / n : 6E / h : 68 / e : 65 / e : 65</strong></p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/d6377075-238f-4725-903d-bcd71a644b79/image.png" alt="">
<a href="https://en.wikipedia.org/wiki/UTF-8">https://en.wikipedia.org/wiki/UTF-8</a></p>
<p>결과적으로, proto 값 중 name 의 데이터는 <strong>총 11 Byte가 됩니다.</strong></p>
<p>Tag(태그) : <strong>10 ⇒ 0A</strong></p>
<p>Length(길이) : <strong>9 ⇒ 09</strong></p>
<p>Value(값) : <strong>J : 4A / o : 6F / k : 6B  / w : 77 / a : 61 / n : 6E / h : 68 / e : 65 / e : 65</strong></p>
<p>proto 값 중 id도 똑같이 동작하지만, 여기서 Length는 없기 때문에 없이 진행하여 Byte를 구합니다.</p>
<p>구하면 id의 Byte는 Tag(태그) : 16 ⇒ 10 + Value(값) : 1 ⇒ 01 , 최종 10 과 01 ⇒ 2Byte입니다.</p>
<p>proto 의 name과 id를 직렬화하면 총 13Byte로 json의 27Byte 보다 적은 것을 알 수 있습니다.</p>
<p>아래 그림으로 이해하기 쉽게 설명해봤습니다! ☺️
<img src="https://velog.velcdn.com/images/kwan_hee/post/cdae3364-f793-4606-8260-4fd217eeb686/image.png" alt="">
<img src="https://velog.velcdn.com/images/kwan_hee/post/892314f8-9646-4794-b8dd-92eedb947cc3/image.png" alt="">
<img src="https://velog.velcdn.com/images/kwan_hee/post/70f09379-76fa-4969-902d-17711cac4b31/image.png" alt="">
<img src="https://velog.velcdn.com/images/kwan_hee/post/5c1e700c-cdac-4b9f-a8ce-a3d9d5c2006a/image.png" alt=""></p>
<p>즉, 결론은 DataStore를 사용할 경우, 저장되는 형식은 .proto 입니다. I/O 속도와 용량을 더 줄이기 위한 노력이 보입니다. 이로써, SharedPreferences 말고 DataStore를 사용해야하는 이유가 하나 더 추가되었네요 ☺️</p>
<p>Google의 노력은 끝이 없다..</p>
<h3 id="proto-datastore"><strong>Proto Datastore</strong></h3>
<p>자세한 설명은 공식문서를 참고해주세요.</p>
<p>저는 구현 코드보다는 왜 Proto를 사용해야하고, 이점이 무엇이며 Preferences Datastore와의 차이점은 무엇인가에 대해서 초점을 맞춰서 알아보았습니다.</p>
<ul>
<li><strong>복잡한 데이터 구조</strong></li>
</ul>
<p>우선, 복잡한 데이터 구조를 다룰 수 있습니다. Preferences 는 key-value로 값을 가지기 때문에 아래와 같은 복잡한 데이터 구조는 할 수 없습니다. 하지만 proto는 할 수 있습니다.</p>
<pre><code class="language-kotlin">message User {
    string name = 1;
    int32 age = 2;
    bool isPremiumUser = 3;
}</code></pre>
<ul>
<li><strong>타입 안전성</strong></li>
</ul>
<p>Proto DataStore는 타입 안전성을 제공합니다. .proto 파일에서 필드 타입을 명확히 정의하고, 이를 기반으로 객체를 직렬화하므로 타입 오류를 사전에 방지할 수 있습니다. 예를 들어, name 필드는 반드시 string이어야 하며, age는 int32이어야 합니다. 이는 Preferences DataStore에서는 구현하기 어렵습니다.</p>
<p>사용할 일이 별로 없어서 Proto 에 대해서는 확 와닿는 느낌이 없네요. </p>
<p>단순히 gRPC를 사용할 때, proto 를 사용한 것이라서 구조적으로는 같아서 나름 이해는 잘 되었던 것 같습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CameraX 회전 및 자르기(rotate & crop)]]></title>
            <link>https://velog.io/@kwan_hee/CameraX-%ED%9A%8C%EC%A0%84-%EB%B0%8F-%EC%9E%90%EB%A5%B4%EA%B8%B0rotate-crop</link>
            <guid>https://velog.io/@kwan_hee/CameraX-%ED%9A%8C%EC%A0%84-%EB%B0%8F-%EC%9E%90%EB%A5%B4%EA%B8%B0rotate-crop</guid>
            <pubDate>Thu, 16 Jan 2025 15:51:36 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요.</p>
<p>CameraX를 사용하면서 이미지 회전과 잘림 이슈에 대해서 어떻게 해결했는 지 설명하려고 합니다.</p>
<h2 id="crop-rect">Crop Rect</h2>
<p><a href="https://developer.android.com/media/camera/camerax/configuration#crop"><strong>https://developer.android.com/media/camera/camerax/configuration#crop</strong></a></p>
<p>공식문서에서도 Camera 화면이 보이는 영역인 Preview 영역을 줄였을 때, 아래와 같이 자르는 가이드 라인을 제공합니다.</p>
<pre><code class="language-kotlin">val viewPort =
  ViewPort.Builder(Rational(width, height), imageCapture.targetRotation).build()

val useCaseGroup = UseCaseGroup.Builder()
  .addUseCase(preview)
  .addUseCase(imageCapture)
  .addUseCase(imageAnalysis)
  .setViewPort(viewPort)
  .build()

  cameraProvider.bindToLifecycle(
  lifecycleOwner = lifecycleOwner,
  cameraSelector = cameraSelector,
  useCaseGroup,
)</code></pre>
<h3 id="why-width-is-0">why width is 0?</h3>
<p>이미지를 크롭하기 위해서 width와 height를 카메라 영역의 크기를 가져와야 합니다. Preview의 가로와 세로길이를 가져오면 되는 간단한 문제입니다.</p>
<p>하지만 width, height의 값이 0으로 전달 받는 것을 겪을 수 있을 겁니다. 해당 논의는 스택오버플로우에서도 논의되어지고 있었습니다. 기본적으로 가로와 세로길이를 할당해줄 때는 onCreate에서 해줍니다. </p>
<p>이 문제는 xml 사용 시, wrap_content &amp; match_parent을 사용하면서 뷰가 렌더링되어지는 과정이 일반적으로 onResume 까지 완료되지 않는다고 합니다. 그렇기 때문에 당연히 0의 값을 오는 겁니다.</p>
<p> <a href="https://stackoverflow.com/questions/3591784/views-getwidth-and-getheight-returns-0">https://stackoverflow.com/questions/3591784/views-getwidth-and-getheight-returns-0</a> </p>
<p>저는 아래 코드처럼 문제를 해결했습니다. (자세한 내용은 링크에서 확인부탁드려요!)</p>
<pre><code class="language-kotlin">binding.cameraView.viewTreeObserver.addOnGlobalLayoutListener(object :
  OnGlobalLayoutListener {
  override fun onGlobalLayout() {
      binding.cameraView.viewTreeObserver.removeOnGlobalLayoutListener(this)

      cameraX.startCamera(
          this@CameraXActivity,
          binding.cameraView.measuredWidth.dpToPx(this@CameraXActivity).toInt(),
          binding.cameraView.measuredHeight.dpToPx(this@CameraXActivity).toInt()
      )
  }
})

private fun Int.dpToPx(context: Context) = this / context.resources.displayMetrics.density</code></pre>
<hr>
<h3 id="onimagecapturedcallback-is-not-crop-"><strong>OnImageCapturedCallback is not crop !!</strong></h3>
<p>CameraX를 사용해서 사진을 촬영하는 방법은 두 가지 입니다.</p>
<ul>
<li><strong>OnImageSavedCallback : 갤러리에 사진을 저장한 뒤, 저장된 이미지 Uri를 가져옵니다.</strong></li>
<li><strong>OnImageCapturedCallback : 이미지 데이터를 가져옵니다. 비트맵, 이미지 메타데이터 등</strong></li>
</ul>
<p><strong>OnImageSavedCallback</strong> 콜백을 사용하면, 회전도 잘 되고 잘림 이슈도 없이 촬영된 화면과 똑같은 사진이 저장되어지고 가져와 사용할 수 있습니다.</p>
<hr>
<p>하지만, 갤러리에 저장되는 것을 기대하지 않습니다. 어쩔 수 없이 <strong>OnImageCapturedCallback</strong> 을 사용해야 합니다.</p>
<p>이 과정에서 겪은 내용을 차례차례 <strong>OnImageCapturedCallback</strong> 챕터에서 설명하도록 하겠습니다.</p>
<h2 id="onimagecapturedcallback"><strong>OnImageCapturedCallback</strong></h2>
<p><a href="https://developer.android.com/reference/androidx/camera/core/ImageCapture.OnImageCapturedCallback">https://developer.android.com/reference/androidx/camera/core/ImageCapture.OnImageCapturedCallback</a></p>
<p>사진 촬영 후 이미지의 비트맵을 가져와 보여줄 수 있습니다.</p>
<p>하지만 여기서 이슈는 회전과 잘림 이슈입니다.</p>
<ul>
<li><strong>기본적으로 OnImageCapturedCallback 은 반시계방향으로 90도 회전되어있습니다.</strong></li>
<li><strong>이미지가 잘리지 않고, 기존 이미지의 Rect 값과 잘린 이미지의 Rect 값을 전달받습니다.</strong></li>
</ul>
<p>위 두 가지 주제를 좀 더 자세하게 알아보겠습니다.</p>
<h3 id="rect">Rect</h3>
<p>우선 매개변수로 image에서 회전과 잘림에 대해서 사용할 객체를 가져올 수 있습니다.</p>
<p>참고 하고자 사진도 첨부해놓겠습니다! 아래에서 부터 본격적인 이슈 해결입니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/e13f9a50-3f26-4a2b-a6b0-70ad5481fe27/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/31c2a25e-d82e-4239-bf12-658698dd8c6f/image.png" alt=""></p>
<h3 id="회전rotate">회전(rotate)</h3>
<p>사진을 촬영하게 되면, 단순히 비트맵으로 보여줄 때, 아래처럼 반시계방향으로 90도 회전하게 되어있습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/bbe5ee20-6d99-448d-a1e0-d6d98de173c1/image.png" alt=""></p>
<p>이 부분을 해결하기 위해서는 간단하게 해결할 수 있습니다.</p>
<p>아래 rotate 함수에 주목해주고, image.imageInfo.rotationDegrees 값에 주목해주세요.</p>
<ul>
<li><strong>image.imageInfo.rotationDegrees 는 위에서 정상적으로 찍었을 때, 90을 전달 받습니다. 즉, 해당 값만큼 시계방향으로 회전하라는 의미입니다.</strong></li>
<li><strong>rotate함수는 비트맵을 전달받은 각도만큼 시계방향으로 회전합니다.</strong></li>
</ul>
<pre><code class="language-kotlin">override fun onCaptureSuccess(image: ImageProxy) {
  super.onCaptureSuccess(image)

  var rotatedBitmap = image.toBitmap().rotate(image.imageInfo.rotationDegrees.toFloat())
}       

private fun Bitmap.rotate(degrees: Float): Bitmap =
    Bitmap.createBitmap(this, 0, 0, width, height, Matrix().apply { postRotate(degrees) }, true)
</code></pre>
<p>추가로 알아두면 좋은 점은 전면 카메라인 <strong>CameraSelector.LENS_FACING_FRONT</strong> 을 사용할 경우(내 얼굴 보이는 시점)에 아래 reverse함수를 사용하면 됩니다.</p>
<pre><code class="language-kotlin">private fun Bitmap.reverse(): Bitmap =
  Bitmap.createBitmap(this, 0, 0, width, height, Matrix().apply { setScale(-1f, 1f) }, false)</code></pre>
<p>위 처럼 잘 진행되었다면, 아래 사진처럼 원하는 방향으로 이미지의 비트맵을 받을 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/0cfac07a-ebb2-485c-8432-576ae1ad071d/image.png" alt=""></p>
<p>하지만, 뭔가 이상하죠?</p>
<p>위에 여백이 많이 늘었어요. 그 이유는 <strong>OnImageCapturedCallback 은 OnImageSavedCallback 처럼</strong> <strong>이미지를 잘라서 주지 않습니다.</strong> 밑에서 더 자세히 설명합니다.</p>
<h3 id="이미지-자르기-crop">이미지 자르기 (crop)</h3>
<p>우선 이해를 돕고자 차근차근 위에도 내용도 복기하며 설명해보도록 하겠습니다.</p>
<p>아래 사진처럼 일반적으로 사진을 촬영했는데, 반시계방향으로 90도 회전하면서 이미지의 위 영역도 넓어집니다.</p>
<p>여기서의 이미지 크기 데이터는 <strong><em>image.image?.cropRect</em></strong>를 보게되면 아래 디버깅에서 Rect 객체의 값을 볼 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/8921a33d-4963-40a7-ab3f-650ec928b1fc/image.png" alt=""></p>
<p>그 다음에는 시계방향으로 90도 회전하게 됩니다. 위에서 설명했듯이 위에 여백이 늘었습니다. 그리고 회전의 값은 아래 디버깅 결과처럼  <strong><em>image</em>.<em>imageInfo</em>.<em>rotationDegrees</em></strong> 값이 90으로 설정되어있는 것을 확인할 수 있습니다. 시계 방향으로 90도 회전하라는 의미이죠.</p>
<img src = "https://velog.velcdn.com/images/kwan_hee/post/160d3a91-07c1-435c-b8d0-b1de8fff9731/image.png" height=300px width=300px  />


<p>그 다음으로 이미지를 자르기 이전에 기본 이미지에 대한 사이즈 말고, 잘리는 영역에 대한 정보도 제공해줍니다.</p>
<p><strong><em>image.cropRect</em></strong>를 참조해보면 아래의 디버깅 결과를 확인할 수 있고, 그림처럼 처음 시점에서의 crop하는 설정이기 때문에 아래의 그림을 보면 더 이해하기 쉽습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/79d0bfe2-28b5-41a6-b3ac-f1efa10cbb69/image.png" alt=""></p>
<p>이제는 실제로 이미지를 자를 시간이 왔습니다. 우선 Rect에 대한 이해를 돕고자 Canvas 동작원리를 가져왔고, createBitmap에서 x,y 좌표 그리고 width, height으로 그려지는 과정을 쉽게 이해하실 수 있을 겁니다.</p>
<p>아래처럼 (x,y) 좌표가 (0, 882)이기 때문에 위에 영역이 잘리고, 전체 높이에서 y 값을 빼면 높이가 설정되며 가로 길이는 전체 가로길이가 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/d99d6524-6930-46ba-b008-9c6b54d0f2d7/image.png" alt=""></p>
<p>결과적으로 우리가 사진 촬영 후 똑같은 사진의 결과물을 얻을 수 있습니다.</p>
<p>참 쉽죠? 
<img src="https://velog.velcdn.com/images/kwan_hee/post/2a969967-47ef-44c0-a4e6-433c129c1a45/image.png" alt=""></p>
<p>마지막으로 결과물에 대한 코드도 첨부해놓겠습니다.</p>
<pre><code class="language-kotlin">override fun onCaptureSuccess(image: ImageProxy) {
  super.onCaptureSuccess(image)
  val imageCropRect = image.image?.cropRect
  val imageProxyCropRect = image.cropRect

    val cropBitmap: Bitmap?
    val cropX: Int
    val cropY: Int
    val cropWidth: Int
    val cropHeight: Int

    when (image.imageInfo.rotationDegrees) {
      90 -&gt; { // 정방향 (현재 회전된 상태),
          cropX = 0
          cropY = imageProxyCropRect.left
          cropWidth = rotatedBitmap.width
          cropHeight = imageProxyCropRect.right - imageProxyCropRect.left
      }
      ... // 다른 각도도 설정
    }
}</code></pre>
<h3 id="사진-촬영-시-회전">사진 촬영 시 회전</h3>
<p>위에서 정상적으로 사진 촬영 방법에 대한 예시였습니다.</p>
<p>근데 생각해보면 저렇게 사진찍는 사람만 있다면 위에 방법으로 모든 문제가 해결됩니다. 하지만 아래처럼 사진 촬영의 방법은 4가지의 각도를 가지고 구분됩니다.</p>
<p>image.imageInfo.rotationDegrees 값은 차례대로 90, 180, 270, 0 으로 구분됩니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/2080bba5-7319-48ba-b45a-b46c5a98e254/image.png" alt=""></p>
<p>이 부분도 참고해서 회전과 이미지를 자르는 과정에서 분기 처리하여 진행해주시면 됩니다.</p>
<h2 id="camera-촬영-사운드">Camera 촬영 사운드</h2>
<p>안드로이드 관련 문서를 검색하다보면, 자주 보이시는 분께서 너무 멋진 글을 작성해주셔서, 따로 설명하지 않아요! 해당 아티클에서 정말 잘 소개해주고 있기 때문에 링크 남겨놓을게요! <a href="https://velog.io/@mraz3068/Android-%EC%B9%B4%EB%A9%94%EB%9D%BC-%EC%B4%AC%EC%98%81%EC%8B%9C-%EC%85%94%ED%84%B0-%EC%86%8C%EB%A6%AC%EC%B0%B0%EC%B9%B5-%EC%B6%94%EA%B0%80%ED%95%98%EA%B8%B0">https://velog.io/@mraz3068/Android-%EC%B9%B4%EB%A9%94%EB%9D%BC-%EC%B4%AC%EC%98%81%EC%8B%9C-%EC%85%94%ED%84%B0-%EC%86%8C%EB%A6%AC%EC%B0%B0%EC%B9%B5-%EC%B6%94%EA%B0%80%ED%95%98%EA%B8%B0</a></p>
<p>위 아티클에 짧게 요약하자면,</p>
<ul>
<li><strong>CameraX는 기본적으로 촬영 소리에 대한 기능을 제공하지 않는다.</strong></li>
<li><strong>자체적으로 안드로이드에서 소리를 낼 수 있도록 시스템에서 소리를 내어 “찰칵” 소리를 만들 수 있다.</strong><ul>
<li><strong>하지만 문제점이 있다. 소리도 크고, 소리를 키우면 엄청크고 작게 줄이면 안들린다.</strong></li>
</ul>
</li>
<li><strong>결론은 MediaPlayer를 이용해서 원하는 찰칵.raw 파일을 만들어 촬영 시 소리를 입히면 됩니다~!</strong></li>
</ul>
<h2 id="결과">결과</h2>
<p>결과적으로 CameraX를 사용하면서 발생했던 이슈인 회전, 잘림 이슈를 해결해보았습니다.</p>
<p>아래의 사진은 결과물이고, 레이아웃은 어떻게 구성했는 지 스펙도 같이 첨부했어요! </p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/12fbdaf5-a86d-4ea2-95a4-998f7680e540/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/3f0e29fe-ee27-4ec4-99e5-eba96b878351/image.png" alt=""></p>
<p>마지막으로 예시 코드를 참고하시려면 아래 깃허브 링크에 들어와서 확인해주세요!</p>
<p>감사합니다!!</p>
<p><a href="https://github.com/Jokwanhee/camerax-sample?tab=readme-ov-file">https://github.com/Jokwanhee/camerax-sample?tab=readme-ov-file</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[textMeasurer 에 대한 고찰]]></title>
            <link>https://velog.io/@kwan_hee/textMeasurer-%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0</link>
            <guid>https://velog.io/@kwan_hee/textMeasurer-%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0</guid>
            <pubDate>Mon, 13 Jan 2025 10:33:14 GMT</pubDate>
            <description><![CDATA[<p>textMeasurer는 Jetpack Compose에서 텍스트 크기를 측정하기 위한 API입니다. 텍스트를 측정해서 객체로 생성하고 기억할 수 있습니다.</p>
<ul>
<li><strong>어떻게 기억할까요?</strong></li>
</ul>
<p>아래 코드처럼 remember를 통해서 텍스트 사이즈를 측정하고 기억할 수 있습니다.</p>
<pre><code class="language-kotlin">val textMeasurer = rememberTextMeasurer()</code></pre>
<pre><code class="language-kotlin">private val DefaultCacheSize: Int = 8

@Composable
fun rememberTextMeasurer(
    cacheSize: Int = DefaultCacheSize
): TextMeasurer {
    ...
    return remember(fontFamilyResolver, density, layoutDirection, cacheSize) {
    TextMeasurer(fontFamilyResolver, density, layoutDirection, cacheSize)
  }
}</code></pre>
<p>이 부분에서 중요한 부분은 캐싱을 하고 있다는 점입니다. 기본 8개의 캐시 사이즈를 가지고 있습니다. 텍스트 사이즈를 측정하고 기억할 객체인 <strong>TextMeasurer</strong>를 생성했다면, 텍스트를 측정해줄 친구가 필요합니다. 그게 바로 <strong>measure()</strong> 메서드입니다.</p>
<p>[참고] 만약에 캐시를 사용하지 않고 싶다면, cacheSize의 값을 0을 사용할 경우 TextLayoutCache를 사용하지 않습니다.</p>
<pre><code class="language-kotlin">private val textLayoutCache: TextLayoutCache? = if (cacheSize &gt; 0) {
    TextLayoutCache(cacheSize)
} else null</code></pre>
<p>measure 함수를 아래와 같이 호출하여 결과를 받아올 수 있습니다.</p>
<p><strong>Jokwanhee</strong> 라는 텍스트의 <strong>16sp</strong>를 가진 텍스트를 측정하고 있습니다.</p>
<pre><code class="language-kotlin">val textLayoutResult = textMeasurer.measure(
    text = &quot;Jokwanhee&quot;,
    style = TextStyle(fontSize = 16.sp)
)</code></pre>
<ul>
<li><strong>위 measure 함수는 어떻게 동작할까요?</strong></li>
</ul>
<p>위에서 캐싱이 된다고 설명했습니다. 캐싱 역할을 <strong>measure</strong> 함수 내부에서 그 역할을 담당하고 처리해줍니다.</p>
<p>동작은 간단합니다. </p>
<ul>
<li>skipCache : 캐시를 스킵할건지 유무입니다. (기본값은 false 입니다.)</li>
<li>textLayoutCache : 위에서 cacheSize가 0 보다 크다면 TextLayoutCache 객체를 생성하게 됩니다.</li>
</ul>
<p>위 두 변수를 AND 연산을 통해 캐시에서 가져올 건지 null를 반활할 것인지에 대해서 판단하게 됩니다.</p>
<pre><code class="language-kotlin">val cacheResult = if (!skipCache &amp;&amp; textLayoutCache != null) {
  textLayoutCache.get(requestedTextLayoutInput)
} else null</code></pre>
<p>초반이라면 skipCache는 당연하게 false이고, textLayoutCache는 null 이므로 캐시에 담을 객체를 생성해주어야 합니다.</p>
<p>그래서 null일 경우 만드는 로직은 아래와 같습니다.</p>
<p>layout 함수를 사용하여 TextLayoutResult를 가져옵니다. 그 이후에는 캐싱을 하게 됩니다. </p>
<pre><code class="language-kotlin">layout(requestedTextLayoutInput).also { textLayoutResult -&gt;
    textLayoutCache?.put(requestedTextLayoutInput, textLayoutResult)
}</code></pre>
<p>위처럼 캐싱을 진행하면, measure 함수를 재호출하는 경우 같은 객체일 경우에는 캐시에 있는 값을 가져와 아래의 cacheResult객체를 복사하여 새로운 <strong>TextLayoutResult</strong> 값을 반환합니다. </p>
<pre><code class="language-kotlin">return if (cacheResult != null) cacheResult.copy(
  layoutInput = requestedTextLayoutInput,
  size = constraints.constrain(
      IntSize(
          cacheResult.multiParagraph.width.ceilToInt(),
          cacheResult.multiParagraph.height.ceilToInt()
      )
  )
)</code></pre>
<p>추가적으로 textLayoutCache는 LRU 알고리즘을 사용해서 캐싱을 좀 더 효율적으로 관리하게 됩니다. 
<img src="https://velog.velcdn.com/images/kwan_hee/post/9d75c6eb-bce4-40bc-81a8-82cd12470af7/image.png" alt=""></p>
<p>저는 여기서 궁금증이 들었습니다. 리컴포지션으로 상태변화가 발생하면 불필요한 measure 함수가 불리면서 객체를 만드는 것 자체가 리소스 낭비이지 않을까?</p>
<p>그렇다면, 새로운 객체를 만드는 것을 개선해보고자 테스트를 진행해봤습니다.</p>
<p>우선 textMeasurer를 사용하는 이유는 아래의 UI처럼 테스트라는 텍스트의 영역만큼 그대로 가져오기 위해서 사용하게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/c1f45872-a444-44ec-affa-948fa211d053/image.png" alt=""></p>
<p>아래 코드는 기본적으로 텍스트를 측정하기 위한 방법입니다. 아래의 과정에서 리컴포지션이 발생하고 measure함수가 계속 불린다면 어떻게 될까요?</p>
<pre><code class="language-kotlin">val textMeasurer = rememberTextMeasurer()

val textLayoutResult = textMeasurer.measure(
  text = &quot;테스트&quot;,
  style = TextStyle(fontSize = 12.sp),
)</code></pre>
<p>위 measure 함수가 처음 호출될 때는 null이기 때문에 캐시에서 가져오지 않고 직접 객체를 만듭니다. 하지만 리컴포지션이 100번 발생했다면, 100번의 measure 함수가 불리며 캐싱되어있던 객체를 가져오는 방식으로 로직이 흘러갑니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/9fa13efa-4873-4a3d-95b6-f23dbfba3a0d/image.png" alt=""></p>
<p>그렇다면, measure 함수를 기억한다면? 현재 위에서 보여드린 “테스트”라는 텍스트를 변하지않고 정적이며, 텍스트 측정의 리컴포지션 대상이 되지 않아도 됩니다.</p>
<p>그래서 measure 함수를 사용하는 부분을 remember를 사용하여 기억하고 있으면 됩니다.</p>
<pre><code class="language-kotlin">val textMeasurer = rememberTextMeasurer()

val textLayoutResult = remember(Unit) {
  textMeasurer.measure(
    text = &quot;테스트&quot;,
    style = TextStyle(fontSize = 12.sp),
  )
}</code></pre>
<p>아래의 사진은 위 코드를 사용한 부분이 리컴포지션이 발생하는 위치인데도 불구하고 맨 처음에 호출된 이후에는 호출되지 않는 모습을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/e522274a-485c-41f2-b62a-ee9db15c027c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/2c8d0fab-cea0-4673-afaf-8a229abd6793/image.png" alt=""></p>
<p>불필요한 measure 함수 호출을 개선해보았습니다.</p>
<p>그런데 이런 질문을 할 수 있을 것 같아요. 왜 갑지가 텍스트 측정에 대한 호출을 개선해보았을까요?</p>
<p>Drawing Modifier 에 대해서 공부하면서 알게된 개념이 있습니다. (Drawing Modifier에 대해서 자세한 설명은 공식 문서를 참고해주세요. <a href="https://developer.android.com/develop/ui/compose/graphics/draw/modifiers">https://developer.android.com/develop/ui/compose/graphics/draw/modifiers</a>)</p>
<p>바로 <strong>drawWithCache</strong> 입니다.  (참고 문서 : 
<a href="https://developer.android.com/develop/ui/compose/graphics/draw/overview#measure-text">https://developer.android.com/develop/ui/compose/graphics/draw/overview#measure-text</a>)</p>
<p>위 링크에서 자세한 설명이 있지만, 이야기해보자면 텍스트를 측정할 때 measure 작업의 비용이 많이들어서Canvas를 사용할 때, drawWithCache를 사용하여 measure를 그리기 영역의 크기가 변경될 때까지 호출되지 않도록 해당 람다에 배치합니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/3020ec5a-e3aa-4dd0-85fb-40479aee1199/image.png" alt=""></p>
<pre><code class="language-kotlin">val textMeasurer = rememberTextMeasurer()

Spacer(
    modifier = Modifier
        .drawWithCache {
            val measuredText =
                textMeasurer.measure(
                    AnnotatedString(longTextSample),
                    constraints = Constraints.fixedWidth((size.width * 2f / 3f).toInt()),
                    style = TextStyle(fontSize = 18.sp)
                )

            onDrawBehind {
                drawRect(pinkColor, size = measuredText.size.toSize())
                drawText(measuredText)
            }
        }
        .fillMaxSize()
)</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[kapt 에서 ksp로 마이그레이션 해보기]]></title>
            <link>https://velog.io/@kwan_hee/kapt-%EC%97%90%EC%84%9C-ksp%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98-%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@kwan_hee/kapt-%EC%97%90%EC%84%9C-ksp%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98-%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Fri, 13 Dec 2024 16:10:46 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가기-앞서">들어가기 앞서</h2>
<p>kapt에서 ksp로 마이그레이션 하려는 이유가 뭘까요?</p>
<ul>
<li><strong>빌드 속도 향상</strong> : Java 스텁을 만드는 kapt에서는 ksp 보다 빌드 속도가 늦어질 수 밖에 없습니다. 기본적으로 Android Studio 컴파일은 kotlinc 입니다. 그렇기 때문에 Kotlin 코드를 Java 스텁으로 변환하는 과정의 과정이 필요해집니다. 즉, ksp 는 Java 스텁으로 변환해야하는 과정이 필요없는 것입니다.</li>
<li><strong>Kotlin 언어 사용</strong> : ksp는 Kotlin 언어에 맞게 설계되어 더 잘 이해하고 활용할 수 있습니다.</li>
</ul>
<p>이제 실제로 어떻게 ksp를 적용하고 시행착오들이 있었는지 아래에서 설명하도록 하겠습니다.</p>
<h2 id="hilt">Hilt</h2>
<p>Hilt를 기준으로 kapt에서 ksp를 변경할 경우를 예시로 들어보려고 합니다.</p>
<pre><code class="language-kotlin">// build.gradle(:project)

id(&quot;com.google.dagger.hilt.android&quot;) version &quot;2.48&quot; apply false</code></pre>
<pre><code class="language-kotlin">// build.gradle(:app)

plugins {
    id (&quot;kotlin-kapt&quot;)
    id (&quot;com.google.dagger.hilt.android&quot;)
}

dependencies {
    implementation (&quot;com.google.dagger:hilt-android:2.48&quot;)
    kapt (&quot;com.google.dagger:hilt-compiler:2.48&quot;)
}</code></pre>
<p>kapt를 사용하는 경우에는 위처럼 진행됩니다.</p>
<p>하지만 ksp를 사용할 때는 어떤점을 고려하고 어떤 종속성을 추가해야할까요?</p>
<p><a href="https://developer.android.com/build/migrate-to-ksp?hl=ko#kts">https://developer.android.com/build/migrate-to-ksp?hl=ko#kts</a> 자세한 설명은 공식문서를 참고해주세요.</p>
<p>우선 대표적으로 Dagger, Room, Glide, Moshi 라이브러리는 ksp를 지원하고 있습니다. 하지만 더 많은 라이브러리들이 ksp를 지원하고 있으며, 어떤 라이브러리가 KSP를 지원하는 지 확인하려면 <a href="https://kotlinlang.org/docs/ksp-overview.html#supported-libraries">https://kotlinlang.org/docs/ksp-overview.html#supported-libraries</a> 해당 링크를 들어가보면 됩니다.</p>
<pre><code class="language-kotlin">// build.gradle(:project)

pulgins {
    id(&quot;org.jetbrains.kotlin.android&quot;) version &quot;1.9.21&quot; apply false
    id(&quot;com.google.devtools.ksp&quot;) version &quot;1.9.21-1.0.16&quot; apply false
}</code></pre>
<p>KSP 플러그인 등록 시 버전을 등록할 때는 <a href="https://github.com/google/ksp/releases?page=1">https://github.com/google/ksp/releases?page=1</a> 릴리즈 노트를 확인하여 버전을 맞춰줘야 합니다. 특히 코틀린 버전과 맞추어야 하기 때문에 관련된 코틀린 버전에 대응되는 버전을 사용해주면 됩니다.</p>
<p>추가로, Hilt ksp는 해당 요구사항을 따라야 합니다. 업데이트 될 수 있으니 <a href="https://dagger.dev/dev-guide/ksp">https://dagger.dev/dev-guide/ksp</a> 해당 문서를 참고해주세요!</p>
<ul>
<li><strong>Hilt 버전 2.48 이상</strong></li>
<li><strong>Kotlin 버전 1.9.0 이상</strong></li>
<li><strong>KSP 버전 1.9.0-1.0.12 이상</strong></li>
</ul>
<pre><code class="language-kotlin">// build.gradle(:app)

plugins {
  id (&quot;com.google.devtools.ksp&quot;)
  id (&quot;com.google.dagger.hilt.android&quot;)
}

dependencies {
  implementation (&quot;com.google.dagger:hilt-android:2.48&quot;)
  ksp (&quot;com.google.dagger:hilt-compiler:2.48&quot;)
}</code></pre>
<p>하지만 아직 Hilt와 Dagger는 Alpha &amp; In progress 이기 때문에 Officially supported 될 때까지 상황을 지켜봐야할 것 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/66f678c5-e471-4032-b50e-8ac7eccbe235/image.png" alt=""></p>
<p>실제로 위쪽은 kapt 빌드, 아래쪽은 ksp 빌드 시 generated 는 폴더를 살펴보았습니다. kapt는 hilt에 대한 java 파일을 생성한다. 하지만 ksp는 kotlin 파일을 생성하는 것을 알 수 있습니다.</p>
<p align="center">
    <img src="https://velog.velcdn.com/images/kwan_hee/post/c8fd343a-838a-4ac5-9134-2f07001ae229/image.png" align="center" width="30%">
    <img src="https://velog.velcdn.com/images/kwan_hee/post/bd504475-0ca0-43cf-9388-c3489010c367/image.png" align="center" width="30%">
</p>


<h2 id="glide">Glide</h2>
<p>Glide ksp를 추가하기 위해서는 조금 다른 라이브러리를 추가해야합니다.</p>
<p>기존에 kapt를 사용한 경우에는 glide:compiler를 추가하지만, ksp를 사용할 경우에는 glide:ksp를 사용해야합니다.</p>
<p><a href="https://sjudd.github.io/glide/doc/download-setup.html#kotlin---ksp">https://sjudd.github.io/glide/doc/download-setup.html#kotlin---ksp</a></p>
<pre><code class="language-kotlin">// build.gradle(:app)

dependencies {
    implementation(&quot;com.github.bumptech.glide:glide:4.14.2&quot;)
    kapt(&quot;com.github.bumptech.glide:compiler:4.14.2&quot;)
    ksp(&quot;com.github.bumptech.glide:ksp:4.14.2&quot;)
}</code></pre>
<p>Glide 버전을 해당 링크에서 살펴볼 수 있습니다. <a href="https://github.com/bumptech/glide/releases">https://github.com/bumptech/glide/releases</a></p>
<h3 id="glide-490-버전-사용-시-ksp-를-적용할-때-문제가-발생했습니다">Glide 4.9.0 버전 사용 시, KSP 를 적용할 때 문제가 발생했습니다.</h3>
<p>필자는 Glide <code>4.9.0</code> 버전을 사용하였고, KSP 는 <code>1.9.22-1.0.16</code> 버전을 사용했습니다.</p>
<p>4.9.0 ~ 4.13.0 버전까지 아래와 같은 에러가 발생했습니다. </p>
<p>task는 4개이지만 모듈별로 겹치는 것이 있어서 총 3개의 task 에서 오류가 발생했습니다.</p>
<ul>
<li>javaPreCompileDebug : 자바 컴파일이 실패했습니다.</li>
<li>kspDebugKotlin : ksp 를 사용하여 처리하는 과정이 실패했습니다.</li>
<li>mergeExtDexDebug : 외부 라이브러리에서 DEX 파일을 병합하는 과정에서 실패했습니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/1abba692-36a3-4a58-9529-8ded362dab64/image.png" alt=""></p>
<p>하지만, 4.14.0 버전부터는 빌드가 성공했습니다! 어떤 일이 벌어진 걸까요?</p>
<p>이유는 단순합니다. 해당 버전부터 KSP를 지원하기 시작했습니다. 해당 버전부터 KSP 를 지원하기 시작했습니다!!</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/dd3a5c55-0ea6-482f-9f66-cc288f1c7752/image.png" alt=""></p>
<p>근데 저는 4.14.2 버전으로 사용해야 제대로 동작하더라구요.. </p>
<p>아마도 이유는 여러 모듈에서 Glide를 사용하다보니 4.14.2 패치에서 버그 중 해결된 하나의 문장을 가져와봤습니다. 해당 이슈이지 않을까 싶습니다. Bugs 첫 번째 문장에 <strong>Allow LibraryGlideModules to be processed in separate code modules when using KSP</strong> 라고 말하고 있습니다. <strong>KSP를 사용할 때, LibraryGlideModules 가 모듈 별 분리된 코드에 적용되도록 동작을 허용한다고 합니다.</strong> 저는 core 모듈과 koin(app) 모듈 두 곳에서 라이브러리를 추가하고 사용하기 때문에 발생한 이슈같습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/6fccdec7-1894-4d32-adf8-df96a8e91c14/image.png" alt=""></p>
<p>그런데 또 다른 이슈가 발생했습니다. 다른 이슈는 무엇일까요? </p>
<h3 id="illegalstateexception-generatedappglidemoduleimpl-is-implemented-incorrectly-에러-발생">IllegalStateException: GeneratedAppGlideModuleImpl is implemented incorrectly. (에러 발생!)</h3>
<p>위에서 KSP 를 사용하기 위해서 Glide 버전을 맞추어 줬는데, 아래와 같은 에러가 발생했습니다. 어떤 에러인지 확인해보니 generated API를 사용할 경우에 발생하는 에러인 것 같습니다.</p>
<pre><code class="language-kotlin">java.lang.IllegalStateException: GeneratedAppGlideModuleImpl is implemented incorrectly. 
If you&#39;ve manually implemented this class, remove your implementation. The Annotation processor 
will generate a correct implementation. (Ask Gemini)
    at com.bumptech.glide.Glide.throwIncorrectGlideModule(Glide.java:292)
    at com.bumptech.glide.Glide.getAnnotationGeneratedGlideModules(Glide.java:284)
    at com.bumptech.glide.Glide.get(Glide.java:128)
    at com.bumptech.glide.Glide.getRetriever(Glide.java:510)
    at com.bumptech.glide.Glide.with(Glide.java:623)</code></pre>
<p>내부 구현을 살펴보면 GeneratedAppGlideModuleImpl 함수에서 NoSuchMethodException 예외를 catch 하여 에러를 던지고 있습니다. 저는 단순히 Glide만 사용했는데.. 어떤 이유일까요?</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/db3eb2db-825a-4dbb-a751-e2f70f972e64/image.png" alt=""></p>
<p>작성된 Glide 코드는 apply 정적 메소드를 사용하고 있으며, generated API 이기 때문에 발생하는 에러 같습니다. 거의 모든 Glide 로직에서는 apply를 사용하고 있습니다. 즉, generated API를 사용하고 있습니다.</p>
<pre><code class="language-kotlin">private val glideOptions: RequestOptions = RequestOptions()
  .fitCenter()
  .error(R.drawable.image_no_image)
  .placeholder(R.color.white)


Glide.with(...)
    .apply(glideOptions)</code></pre>
<p>위에 문제를 해결할 수 있는 대답은 Glide 공식문서에서 살펴볼 수 있습니다.</p>
<p>아래의 Note를 살펴보면, KSP 프로세서는 Glide 의 deprecated 된 generated API를 사용하지 않는다고 하고 있습니다. 예를 들면, GlideApp, GlideRequests 등등 말하며, non-generated 된 것으로 대체가 필요하다고 말하고 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/aa27be14-8a44-4883-8a78-0fd2f6c001ba/image.png" alt=""></p>
<p>파란색 링크 들어가봐아겠죠? Generated API가 뭔지 확인해봐야겠습니다.</p>
<p>들어가보니 떡하니 이런 말이 쓰여있습니다. Glide 4.14.0 버전부터는 generated API는 deprecated 되었다고 합니다. 사용하지 않겠다는 것이죠. 추가적인 Glide의 어노테이션 프로세서를 확인하려면 configuration 문서를 확인해보라고 합니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/7584e930-1c31-446a-90bd-4cb0fbaf56bd/image.png" alt=""></p>
<p>그렇다면 generated class 는 어떤 것들이 있을까요?</p>
<p>GlideApp, GlideRequests, GlideRequest, GlideOptions 4가지가 존재합니다. 대신 사용되어야할 API도 설명해주고 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/744888ff-bca7-4ba7-b5f0-8283c3e7b824/image.png" alt=""></p>
<p>근데 왜 generated API를 사용하지 않을까요? 이유에 대해서 공식문서에서는 4가지를 설명하고 있습니다.</p>
<ul>
<li>Glide 4.9.0 버전부터 상속을 사용하며 RequestOptions를 RequestBuilder 내부로 통합했기 때문입니다.</li>
<li>generated API에 존재하는 Extensions 들은 드물게 사용되는 것이 확인되었기 때문입니다. (거의 사용되지 않는다는 것이죠)</li>
<li>Extensions can be trivially replicated in Kotlin using extension functions with no additional support from Glide.<ul>
<li>세 번째 이유가 위의 문장인데, 뭔 뜻인지 모르겠어서…</li>
<li>번역만 하자면, <strong>Glide의 추가 지원없이 확장을 사용하여 코틀린에서 Extensions을 간단하게 복제할 수 있다</strong>고 하는데, 기존 generated class 에서는 Glide의 지원으로 확장함수를 사용할 수 있었나 봅니다. 즉, RequestOptions가 RequestBuild 내부로 통합하면서 RequestOptions 을 호출하지 않고 RequestBuilder 만 사용해서 API를 호출할 수 있다는 건지.. 모르겠네요.</li>
</ul>
</li>
<li>Generate Class 인 GlideApp, GlideRequests 등등 클래스를 생성하면, Glide의 빌드 프로세서의 빌드 시간과 복잡도가 올라가기 때문입니다.</li>
</ul>
<p>그래서 위와 같은 4가지 이유로 generated API를 사용하지 않는다고 합니다.</p>
<p>공식문서에서는 실제로 Glide의 자바 기반의 어노테이션 프로세서를 지울 계획은 없다고 합니다. 하지만 generated API를 KSP에 추가하거나 관련 기능은 추가할 계획이 없다고 합니다. KSP 에서 Glide 의 빌드 프로세스를 단축하기 위한 모습이 보입니다.</p>
<p>우선, 처음에 설명한 Glide 4.9.0 버전의 패치 노트를 살펴보면 아래와 같습니다.</p>
<p>apply 로 정적 메소드를 사용하여 RequestOptions 인 ceneterCropTransfrom 을 사용합니다. 추가적으로 GlideApp 어노테이션 프로세서를 사용하는 방법을 이전 버전에서는 사용했습니다. </p>
<p>하지만 RequestOptions를 RequestBuilder 내부로 통합하면서 ceneterCrop 만 호출하여 generated API 또는 어노테이션 프로세서를 사용하지 않고 호출할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/38f124fa-86ab-4b56-b251-02be5c2e9c90/image.png" alt=""></p>
<p>일단 Glide KSP를 적용하기 위해서 generated API를 지원하지 않아서 사용하면 안된다는 것은 알겠는데, 그렇다고 발생한 에러를 어떻게 해결하나요?</p>
<p>Glide v4의 어노테이션 프로세서와 생성된 API는 Glide의 API를 확장하는 데 유용한 도구였습니다. </p>
<p>하지만 Kotlin 확장 함수가 등장하면서 생성된 API는 더 이상 권장되지 않습니다. Glide는 향후 생성된 API를 제거할 계획이지만,  configuration option은 계속 사용됩니다. </p>
<p>그래서 KSP 를 사용할 경우에는 configuration option 이 사용되어야 해서 Configurtion 을 설정해주어야 해당 에러가 나타나지 않습니다.</p>
<p><a href="https://bumptech.github.io/glide/doc/configuration.html#avoid-appglidemodule-in-libraries">https://bumptech.github.io/glide/doc/configuration.html#avoid-appglidemodule-in-libraries</a></p>
<p>우선 애플리케이션에는 단 하나의 <strong>AppGlideModule</strong> 을 상속하는 GlideModule을 만들어주어야 합니다.</p>
<p>만약에 애플리케이션이 아니라 라이브러리라면 <strong>LibraryGlideModule</strong> 을 상속하는 GlideModule을 만들어주면 됩니다.</p>
<p>코드는 아래와 같습니다.</p>
<pre><code class="language-kotlin">@GlideModule
class GlideModule: AppGlideModule()</code></pre>
<pre><code class="language-kotlin">@GlideModule
class GlideLibraryModule: LibraryGlideModule()</code></pre>
<p>실제로 위에 GlideModule을 추가해주고 에러가 나지 않으며, 빌드해보면 아래처러 ksp 폴더가 생기고 GlideModuleImpl 구현체가 만들어집니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/1ed4e481-fe7a-405e-8bfc-f6dd0945ae70/image.png" alt=""></p>
<h3 id="난독화r8-문제가-발생했습니다">난독화(R8) 문제가 발생했습니다.</h3>
<pre><code class="language-kotlin">isMinifyEnabled = true</code></pre>
<p>GeneratedAppGlideModuleImpl is defined multiple times 에러 문구가 뜨면서, 난독화를 할 때 GeneratedAppGlideModuleImpl 클래스가 여러 번 정의되어있기 때문에 발생하는 에러였습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/9cd982e9-1521-4855-bd35-25b7b09535cf/image.png" alt=""></p>
<p>위에서 어노테이션 프로세서를 만들기 위해서 Empty GlideModule을 만들어주었습니다. GlideModule을 만들어주기 때문에 또 다른 GeneratedAppGlideModuleImpl 클래스가 생성되어서 생기는 문제였습니다.</p>
<p>문제는 기존에 자바 레거시를 가지고 있던 코드에서 사용된 라이브러리 문제였습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/a74898fd-9bf6-4a7e-9adb-a5115454a1c0/image.png" alt=""></p>
<pre><code class="language-kotlin">implementation(&quot;com.github.irshulx:laser-native-editor:3.0.4&quot;)</code></pre>
<p>라이브러리에서 또 다른 라이브러리를 사용한게 문제였는데, 이게 뭔소리냐면 irshulx 라이브러리에서 glide 라이브러리를 사용했기 때문에 예기치 못한 에러를 만날 수 있게 되었습니다.</p>
<p>현재 프로젝트에서 레거시 코드 중 theme, drawable 부분에서 irshulx 라이브러리를 사용하여 작성된 코드가 있었고, 그 부분을 수정한 뒤 irshulx 라이브러를 제거하니 multiple times 에러를 나오지 않았으며, R8 컴파일러도 정상적으로 동작했습니다.</p>
<h3 id="kapt-에서-ksp로-migration-은-결과적으로-빌드-속도를-낮추는가">kapt 에서 ksp로 Migration 은 결과적으로 빌드 속도를 낮추는가?</h3>
<p>제 프로젝트에서는 NO 입니다. kapt 플러그인이 빌드 속도를 늦추는 건 맞습니다. kapt 플러그인을 사용하지 않고 ksp 로 마이그레이션한다면, 성능적으로 이점이 있을 것 입니다. 하지만 kapt 와 ksp를 같이 사용하다면, 오히려 빌드 속도를 늦춥니다. 당연한 결과입니다.</p>
<p>두 개의 플러그인을 동시에 사용하니 늘어날 수 밖에 없습니다. hilt 를 사용하기 때문에 kapt 플러그인이 사용되어지고, glide를 사용하여 ksp 플러그인을 사용했는데, 오히려 이 부분이 빌드 속도를 늦추는 결과가 만들어졌습니다.</p>
<p>내부적으로 KAPT Task를 속도는 더 빠른가? 기대해봤지만, 대부분 KSP를 추가했을 때 더 느리게 동작했습니다.</p>
<p>Task도 256 → 258개로 KSP를 추가했을 때, KSP Task가 2개 추가되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/10620500-33b0-4ff1-9e8f-19a2ffa33dfa/image.png" alt=""></p>
<h3 id="kapt-플러그인을-모두-지우고-ksp로-대체한다면-어떨까">kapt 플러그인을 모두 지우고, ksp로 대체한다면 어떨까?</h3>
<p>그래서 hilt를 사용할 때, kapt 플러그인을 사용해야 하는데 그 부분을 삭제하고 ksp로 대체해봤습니다. 결과는 어떨까요?</p>
<p>결과는 아래와 같습니다. KAPT 보다는 KSP가 훨씬 빠르다는 것을 알 수 있습니다. 추가로 Task도 많이 줄었습니다. 각 모듈의 할당된 KAPT Task가 줄었기 때문이겠죠? 추가적으로 build clean 하지 않고 rebuild 한 경우에 속도차이는 크게 별 차이가 없었습니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/d2cd6b27-3d82-4679-8e35-743b6e5361fc/image.png" alt=""></p>
<h3 id="결론">결론</h3>
<p>hilt ksp는 현재 in progress 입니다. 진행중인 Dagger는 Alpha 단계입니다. 즉, 이 부분을 사용해도 되나? 라는 질문에는 명확한 답을 하지 못할 것 같습니다. </p>
<p>몇 가지 부분을 고려해야할 것 같습니다.</p>
<ul>
<li>빌드 속도가 느려서 빌드 속도를 개선하고 싶은가?</li>
<li>Hilt KSP 는 In progress 단계로 안정적이지 못하니 충분한 테스트를 했는가?</li>
<li>향후 Hilt, Dagger 버전과의 호환성 보장이 지켜지지 않을 수 있다는 불안감이 드는가?</li>
<li>최신 자료이기 때문에 커뮤니티에서 관련 레퍼런스를 찾기 어려울 것 같은가?</li>
</ul>
<p>등 여러 가지를 고려해야겠지만, 빌드 속도를 개선한다는 장점이 있어서 이 부분은 큰 장점이라고 생각합니다. 하지만 팀 내에서 논의 후 판단하여 프로젝트에 도입하는 것이 좋을 것 같습니다. 혹시 모를 사이드이펙트가 있을 수 있으니 그 부분도 고민해봐야할 것 같습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[안드로이드 웹 브릿지 통신 & Clickable SVG]]></title>
            <link>https://velog.io/@kwan_hee/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EC%9B%B9-%EB%B8%8C%EB%A6%BF%EC%A7%80-%ED%86%B5%EC%8B%A0-Clickable-SVG</link>
            <guid>https://velog.io/@kwan_hee/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EC%9B%B9-%EB%B8%8C%EB%A6%BF%EC%A7%80-%ED%86%B5%EC%8B%A0-Clickable-SVG</guid>
            <pubDate>Wed, 04 Sep 2024 23:55:08 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가기-앞서">들어가기 앞서</h3>
<p>디프만 15기 안드로이드팀에서 야구장 시야 서비스를 제공하는 SPOT 앱에서 야구장 구역을 클릭하고, 줌이 가능하도록 구현해야 했습니다. 네이티브에서 야구장 UI, 거기에다가 구역별로 클릭 이벤트 리스너를 다는 것을 구현하는 것은 복잡한 과정이였습니다.
디자이너의 요청은 아래의 야구장을 확대할 수 있어야 하며, 구역별로 클릭할 수 있는 클릭 리스너를 구현해야했습니다.
<img src="https://velog.velcdn.com/images/kwan_hee/post/1f8d2656-6afe-4c0d-8760-99ebe42cd743/image.png" width="400px" height ="400px"/></p>
<p>위 화면은 디자이너의 요청이였을 뿐 실제로 개발팀원들끼리 논의했을 때는 구현하기 어렵고, 레퍼런스가 없다면 디자인이 바뀌어야하지 않을까? 라는 논의를 했습니다. 
하지만 유저에게 야구장 화면이 나오고, 줌인줌아웃하여 블럭들을 클릭할 수 있게 하는 것이 가장 좋을 것 같아 구현하기 위해 많은 레퍼런스를 찾아봤습니다.</p>
<p>실제로 이 화면을 구현하기 위해서 다양한 레퍼런스를 참조해봤습니다. </p>
<ul>
<li>&quot;자리어때&quot; 서비스에서도 야구장 시야를 제공해줍니다. 야구장 화면은 구현되어있는데, 어떻게 그렸는 지 이해하기 위해서 개발자 도구를 열어서 확인해본 결과 SVG 이미지로 되어있었습니다.</li>
<li>&quot;인터파크&quot; 예매 서비스도 확인해보면서 좌석 예약 화면은 어떻게 그렸는 지 확인해봤는데, 이 부분은 HTML 태그를 사용해서 UI 를 그려놨습니다.</li>
</ul>
<p>우선 위 레퍼런스를 참조하면서 네이티브 언어를 사용하는 모바일 환경 안드로이드에서는 UI 구현이 어려울 것 같다는 생각이 들었습니다. 위 레퍼런스는 웹으로 구현되어있었기 때문입니다.</p>
<p>저는 레퍼런스를 찾아보며, 아래와 같이 제 자신에게 질문하며 고민해봤습니다.</p>
<ul>
<li><strong>웹 개발자는 위 UI를 요청받았을 때, 어떤 고민을 했으며 해결책은 어떤것일까?</strong></li>
<li><strong>실제로 네이티브에서 구현될려면 어떻게 구현하지?</strong></li>
<li><strong>레퍼런스에서 구현해놨는데, 진짜로 구현하지 못하는건가?</strong></li>
</ul>
<p>그래서 이 화면을 어떻게 구현해야할 줄 모르겠고, 집단 지성으로 해결해보자며 동아리 프론트엔드 톡방에 아래와 같이 구현가능성에 대해서 질문을 해봤습니다.
<img src="https://velog.velcdn.com/images/kwan_hee/post/98d80aaf-ead6-4147-90b0-314d9df63367/image.png" width="400px" height ="400px"/></p>
<p>&quot;저걸 어떻게 다 그리지?&quot;, &quot;구현하기 어려울 것 같다&quot; 등등 UI 를 직접 그리기에는 어려울 것 같다는 이야기를 해주었습니다.</p>
<p>하지만 어떤 분께서 <strong>Clickable svg map</strong> 키워드를 사용하면 구현할 수 있을 것 같다는 의견을 주셨습니다. 실제로 구글링을 해본 결과 다음과 같은 화면을 SVG 이미지로 대체해서 SVG 속성의 id를 설정한 뒤, javascript로 이벤트 로직을 구현하고 css로 스타일을 관리할 수 있었습니다.</p>
<p>또 하나의 문제는 그러면 네이티브 개발도 하면서 웹까지 구현해야하는가...? 
구현 난이도와 공수까지 생각했을 때 일정 내에 할 수 없다는 생각이 들었습니다. 그래서 네이티브에서 Clickkable svg map 키워드를 사용한 레퍼런스가 없을 까 찾던 도중에...
<a href="https://medium.com/@scode43/interactive-svg-image-in-android-app-using-kotlin-and-javascript-6715c16397bb">IInteractive SVG image in Android app using Kotlin and JavaScript</a> 아티클을 발견했습니다. 제가 찾던 녀석이였습니다. 해당 아티클의 예제 깃허브가 있어서 해당 깃허브 프로젝트를 살펴보았습니다.</p>
<p>직접 웹을 구현하지 않고, SVG 이미지 링크를 가져오고 HTML 코드는 문자열로 하드코딩하여 SVG 이미지 링크를 HTML 문자열에 직접 넣어주고 있었습니다.
그리고 웹뷰로 웹 링크를 load하는 것이 아닌 HTML 문자열을 인코딩하여 웹 링크와 같은 형식으로 loadDataWithBaseURL 함수를 사용하여 웹뷰를 사용할 수 있었습니다.
또한, 로직 구현을 하기 위한 js 코드는 안드로이드의 에셋으로 관리했습니다.</p>
<p>이제는 웹뷰와 통신해야합니다.</p>
<h3 id="브릿지-통신">브릿지 통신</h3>
<p>브릿지 통신은 @JavascriptInterface 어노테이션을 사용해서 웹의 Javscript 와 안드로이드 네이티브 언어와 통신할 수 있는 방법입니다.</p>
<pre><code class="language-kotlin">@JavascriptInterface</code></pre>
<p>브릿지 통신의 흐름도는 아래의 사진과 같습니다.
<img src="https://velog.velcdn.com/images/kwan_hee/post/d18b2d81-2ce6-4986-b1a1-beb6f07c4d0e/image.png" alt=""></p>
<p>안드로이드 브릿지 코드는 아래와 같습니다. 데이터를 받아올 때 아래코드를 사용합니다.</p>
<pre><code class="language-kotlin">class AndroidBridge(
    private val callback: (sectionId: String) -&gt; Unit
) {
    companion object {
        const val JAVASCRIPT_OBJ = &quot;javascript_obj&quot;
        const val INJECT_STADIUM_BLOCK_NUMBER =
            &quot;javascript: window.androidObj.getStadiumBlockNumber = function(sectionId) { $JAVASCRIPT_OBJ.getStadiumBlockNumber(sectionId) }&quot;
    }

    @JavascriptInterface
    fun getStadiumBlockNumber(sectionId: String) {
        callback(sectionId)
    }
}</code></pre>
<p>javscript 코드에서 네이티브로 보낼 때는 아래와 같이 코드를 작성합니다.</p>
<pre><code class="language-js">window.androidObj = function AndroidClass() {};

function getStadiumBlockNumber(sectionId) {
    window.androidObj.getStadiumBlockNumber(sectionId)
}</code></pre>
<p>이러한 방식으로 웹뷰에서 안드로이드 네이티브와 웹 JS는 브릿지를 두어서 통신할 수 있습니다.
JS 코드에서 SVG 이미지의 데이터를 파싱하여 적절하게 CSS 스타일을 건들이면 야구장 화면에 대한 이벤트를 구현해볼 수 있을 것 같습니다.</p>
<h3 id="svg-의-속성-id-를-가져와라">SVG 의 속성 ID 를 가져와라</h3>
<p>피그마를 이용해서 SVG를 추출할 수 있습니다.
또한, 피그마를 사용하다보면 도형, 프레임 등 피그마 요소들의 이름을 지어줄 수 있습니다. 그리고 추출 시, id를 추출하겠다고 설정하면 SVG의 각 요소들의 id 속성이 부여되는 것을 알 수 있습니다.</p>
<p>레이어에 아래와 같이 이름을 부여할 수 있습니다. (한글로 설정하면 SVG 속성 id가 깨질 수 있으니 사용하지 말아주세요.)
<img src="https://velog.velcdn.com/images/kwan_hee/post/bc475c4e-2e33-43eb-bfeb-2981bc9c2b79/image.png" width="300px"/></p>
<p>추출 시, 아래 사진처럼 <strong>include &quot;id&quot; attribute</strong> 라는 목록을 체크해서 추출하면 id 속성이 들어와 있는 것을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/kwan_hee/post/52efa29d-7d3c-4b66-8964-65dfe05e745c/image.png" width="300px"/></p>
<p>실제로 어떻게 확인하는가? SVG 이미지를 URL로 만들어봐야 합니다. 
<a href="https://svgshare.com/">SVG Share</a> 웹 사이트에서 간단하게 SVG 이미지를 URL로 만들어볼 수 있습니다. 이렇게 URL 을 만들고 개발자 도구를 열어서 속성이 잘 들어왔는 지 확인합니다.</p>
<p>아래 사진을 확인해보면, 요소(태그)별로 id 속성이 부여된 것을 알 수 있습니다.
<img src="https://velog.velcdn.com/images/kwan_hee/post/6be66c35-45d4-4c53-b6d2-af6ad02a8839/image.png" alt=""></p>
<p>그렇다는 것은 id를 적절하게 사용해서 Javascript 로직을 작성하고 css 스타일을 부여한다면? 다양한 처리가 가능할 것 같습니다.
딱 기다려라... 야구장 화면...</p>
<h3 id="실제-구현화면">실제 구현화면</h3>
<p>위 문제들을 하나하나 해결하면서 안드로이드가 아닌 아래와 같이 다른 곳(피그마, 웹, SVG 등)에 대한 이해가 필요했었습니다.</p>
<p>배움의 끝은 없고, 이를 통해 하나를 또 알아가는 것 같아 흥미로운 과정이였습니다.</p>
<p>그렇게 결국 태어났습니다. 저의 야구장 화면이...
아래는 디자이너의 요청에 맞게 최종 구현한 화면입니다.</p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/c287083a-b343-4ba4-93a6-1fc81a7d8833/image.gif" alt=""></p>
<p>해당 서비스는 디프만 15기 6팀이 진행했으며, 다양한 정보를 확인해보고 싶으시다면, 아래의 링크들을 확인해주세요!
<a href="https://github.com/depromeet/SPOT-Android">안드로이드 개발자 깃허브 보러가기!</a>
<a href="https://play.google.com/store/apps/details?id=com.dpm.spot&amp;hl=ko">&quot;SPOT! - 내가 만들어가는 야구장 시야 서비스&quot;  플레이스토어에서 다운받기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPG vs JPEG, JPG vs PNG 비교하기]]></title>
            <link>https://velog.io/@kwan_hee/JPG-vs-JPEG-JPG-vs-PNG-%EB%B9%84%EA%B5%90%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@kwan_hee/JPG-vs-JPEG-JPG-vs-PNG-%EB%B9%84%EA%B5%90%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 28 Jun 2024 00:52:26 GMT</pubDate>
            <description><![CDATA[<h1 id="jpg-vs--jpeg"><a href="https://www.indeed.com/career-advice/career-development/jpg-vs-jpeg"><strong>JPG vs  JPEG</strong></a></h1>
<ul>
<li><p>이 둘의 차이점은 <a href="https://www.indeed.com/career-advice/career-development/what-is-file-extension"><strong><em>file extensions</em></strong></a> 문자 수가 다른 것 이외에는 없다.</p>
<ul>
<li>file extensions 은 [file name].[file extensions] 라고 생각하면 된다.</li>
</ul>
</li>
<li><p>JPG 와 JPEG는 pixel 단위로 이미지를 나타낸다. 그렇기 때문에 확대를 하면 이미지 품질이 낮아질 수 있다. (즉, 이미지가 깨질 수 있다.)</p>
</li>
<li><p>이미지 품질 손실</p>
<ul>
<li>JPG와 JPEG로 이미지를 압축할 때마다 이미지의 품질이 낮아진다. 낮아지는 정도의 변화는 미미하지만 반복되면 기본 이미지와의 차이를 느낄 수 있다.</li>
<li>고화질의 이미지를 전송하게 되면 전송 속도를 빠르게 하려고 압축한다. 그럼 위와같이 이미지의 품질이 낮아진다.</li>
</ul>
</li>
</ul>
<h3 id="jpeg">JPEG</h3>
<ul>
<li><p>블로그 게시물, 소셜 미디어 게시물 등 다양한 인터넷 매체에 사용되는 이미지 형</p>
</li>
<li><p>인터넷에 고속 업로드 및 다운로드에 최적화</p>
</li>
<li><p>디지털 카메라 사진, 이미지 캡처 사진 등</p>
</li>
<li><p>JPEG는 다른 이에게 빠르게 이미지를 보여주고 싶을 때 사용하는 방식입니다. 데이터 압축방식을 사용하며 이미지 품질 손실이 이루어집니다.</p>
</li>
<li><p>손실 압축 방법은 손실 파일에서 불필요한 메타데이터를 제거합니다.</p>
<ul>
<li>ex) JPEG</li>
</ul>
</li>
<li><p>무손실 압축 방법은 이미지를 줄이는 방법을 사용하지 않고 원본 그대로의 이미지를 압축합니다.</p>
<ul>
<li>ex) GIF, PNG …</li>
</ul>
</li>
</ul>
<p>JPEG 의 손실 압축방법은 편집이 여러 번 될 수록 이미지 품질저하를 일으킬 수 있기 때문에 여러 번 하는 것은 좋지 않지만 편집 수가 적거나 전송을 여러 번하지 않으면 사용해도 무관할 정도의 손실이 일어나니 진행해도 좋다.</p>
<h3 id="jpg">JPG</h3>
<ul>
<li>JPG는 JPEG와 동일하며, 다른 점은 file extension 문자 수가 하나 적다는 것이다. JPEG 는 4개, JPG는 3개이다.<ul>
<li>Window 운영체제에서 file extension을 3자를 넘기지 말라고 제한하였기에 JPG가 탄생하였다.</li>
<li>여러 편집 프로그램에서 JPEG로 저장하면 Window와 MAC에서 JPG로 저장되는 것을 알 수 있다.</li>
</ul>
</li>
</ul>
<h3 id="용어-정리">용어 정리</h3>
<p>Raster Image : pixel-based Image</p>
<h1 id="jpg-vs-png"><a href="https://www.adobe.com/creativecloud/file-types/image/comparison/jpeg-vs-png.html">JPG vs PNG</a></h1>
<ul>
<li>JPEG가 PNG 보다 저장공간이 더 적다.</li>
</ul>
<h3 id="png">PNG</h3>
<ul>
<li>JPEG 보다 저장공간을 많이차지한다.<ul>
<li>고화질 사진보다는 웹 그래픽, 로고, 차트 등 저장</li>
</ul>
</li>
<li>JPEG 와 다른 점은 투명 배경의 그래픽을 처리할 수 있는 기능</li>
</ul>
<h3 id="손실-압축-vs-무손실-압축">손실 압축 vs 무손실 압축</h3>
<ul>
<li>JPEG는 손실 압축에 해당한다. 편집을 여러 번 하거나 여러번 압축된 파일을 전송하면 그 만큼 파일을 압축한다. 즉, 이미지의 품질이 저하될 수 있다.</li>
<li>PNG는 무손실 압축이다. 기존에 파일이 그대로 전송되며, 압축되지 않으니 이미지 품질 저하를 일으키지도 않는다.</li>
</ul>
<h3 id="파일-사이즈">파일 사이즈</h3>
<ul>
<li>JPEG는 파일을 압축하므로 큰 이미지를 작은 이미지로 사용할 수 있다. 이는 웹 페이지에서 이미지를 로딩할 때 오래 걸리지 않는다. 즉, 유저에게 이미지를 잘 보여줄 수 있다.</li>
<li>PNG는 파일을 압축하지 않으므로 파일의 사이즈가 크다면 웹에서 해당 이미지의 로딩 시간은 오래걸릴 수 있다. 예를 들면, GIF를 생각해보자.</li>
</ul>
<h3 id="투명도">투명도</h3>
<ul>
<li>JPEG는 투명 배경을 지원하지 않습니다.</li>
<li>PNG는 투명 배경을 지원하므로, 여러 색상의 배경과 어울러질 수 있습니다. 또한, 텍스트도 읽기 수월합니다.</li>
</ul>
<h3 id="디지털-사진-vs-웹-그래픽">디지털 사진 vs 웹 그래픽</h3>
<ul>
<li>디지털 사진은 JPEG를 사용합니다. 파일의 크기가 작기 때문에 여러 디지털 사진을 동시에 공유하고 다운로드할 수 있습니다.</li>
<li>PNG는 실제로 고품질 사진을 저장하도록 제작되지 않습니다.</li>
</ul>
<h1 id="파일-크기-단위">파일 크기 단위</h1>
<table>
<thead>
<tr>
<th></th>
<th>비트</th>
<th>바이트</th>
<th>킬로바이트</th>
<th>메가바이트</th>
<th>기가바이트</th>
<th>테라바이트</th>
<th>페타바이트</th>
<th>엑사바이트</th>
</tr>
</thead>
<tbody><tr>
<td>크기</td>
<td>0.125</td>
<td>1</td>
<td>1,024B</td>
<td>1,024KB</td>
<td>1,024MB</td>
<td>1,024GM</td>
<td>1,024TB</td>
<td>1,024PB</td>
</tr>
</tbody></table>
<p>1KB (킬로바이트) = 1,024B (바이트)</p>
<p>1MB (메가바이트) = 1,024(킬로바이트)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[이미지 리사이징 및 비트맵 압축]]></title>
            <link>https://velog.io/@kwan_hee/%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A6%AC%EC%82%AC%EC%9D%B4%EC%A7%95-%EB%B0%8F-%EB%B9%84%ED%8A%B8%EB%A7%B5-%EC%95%95%EC%B6%95</link>
            <guid>https://velog.io/@kwan_hee/%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A6%AC%EC%82%AC%EC%9D%B4%EC%A7%95-%EB%B0%8F-%EB%B9%84%ED%8A%B8%EB%A7%B5-%EC%95%95%EC%B6%95</guid>
            <pubDate>Fri, 28 Jun 2024 00:50:07 GMT</pubDate>
            <description><![CDATA[<h1 id="들어가기-앞서">들어가기 앞서</h1>
<p>“모티부”라는 앱 서비스에서 운동을 완료한 뒤, 사진을 저장하여 유저에게 보여줍니다. 하지만 개발측에서는 이미지를 서버에 바로 저장하지 않고 AWS S3를 이용하여 우회하여 이미지를 클라우드 서버 DB에 직접적으로 저장합니다. 이는 서버의 트래픽을 줄입니다. S3 라는 데이터 저장소에도 용량이 존재합니다. 큰 이미지를 가져다가 넣으면 그 만큼 더 많은 이미지를 얻어올 수 없고 추가적으로 서버에서 이미지를 가져와서 사용할 때도 효율적이 못한 방법입니다. 클라단에서도 이미지를 봐야하는데, 용량이 큰 이미지를 보니 그만큼 이미지 로드되는 시간도 증가합니다. 이는 유저에게 좋지 않은 경험을 주는 요소 중 하나입니다.
<a href="https://github.com/Team-Motivoo/Motivoo-Android/pull/142">모티부 깃허브 코드 확인해보기~</a></p>
<p>그래서 사진을 리사이징하여서 S3에 저장하였습니다. 안드로이드 시선에서 글을 작성한 것이니 참고해주세요!</p>
<ul>
<li>개선된 점<ul>
<li>3.3MB → 77.5KB 로 용량 감소<ul>
<li>약 42배 절감</li>
</ul>
</li>
<li>이미지 로드 속도 감소<ul>
<li>2s → 0.2~3s</li>
<li>압축 포맷인 jpg 이미지를 로드할 때, 용량이 크면 느려지니 용량을 줄임으로써, 속도 개선</li>
</ul>
</li>
</ul>
</li>
</ul>
<h1 id="bitmap-resize">Bitmap Resize</h1>
<p><a href="https://medium.com/android-news/loading-large-bitmaps-efficiently-in-android-66826cd4ad53">Loading Large Bitmaps Efficiently in Android</a></p>
<h2 id="custom-convert-uri-to-bitmap">Custom convert Uri to Bitmap</h2>
<p> 이미지에 대한 Uri가 존재한다고 가정합시다. 해당 Uri를 Bitmap으로 전환할 때, 옵션을 주어서 Bitmap의 크기를 커스텀할 수 있습니다.</p>
<p>우선 BitmapFactory 의 Options API를 사용하여 비트맵 옵션 객체를 가져옵니다.</p>
<pre><code class="language-kotlin">val options = BitmapFactory.Options()</code></pre>
<p>그리고 옵션에 대해서 몇몇 속성을 설정합니다.</p>
<pre><code class="language-kotlin">options.apply {
    inJustDecodeBounds = false
    inSampleSize = 3
}</code></pre>
<ul>
<li><p><strong>inJustDeocdeBoundes</strong></p>
<ul>
<li><p>비트맵을 메모리에 할당할 지를 판단합니다.</p>
<ul>
<li>true : 비트맵이 메모리에 할당됩니다.</li>
<li>false : 비트맵이 메모리에 할당되지 않습니다.</li>
</ul>
<p>만약에 inJustDeocdeBoundes 값이 false 이면 이미지는 보이지 않습니다. 반대로 true 시에는 잘 보입니다.</p>
<pre><code class="language-kotlin">val stream = contentResolver.openInputStream(photoUri)
val bitmap = BitmapFactory.decodeStream(stream, null, options)

binding.imageView.setImageBitmap(bitmap)</code></pre>
</li>
</ul>
</li>
<li><p><strong>inSampleSize</strong></p>
<ul>
<li><p>설정된 값으로 비율을 적용하여 이미지를 반홥합니다.</p>
<ul>
<li><p>inSampleSize = 4 라고 가정합니다.</p>
<ul>
<li>원본 크기 너비/높이 = 1/4</li>
<li>픽셀 수 = 1/16</li>
<li>위와 같이 1000x1000 이미지가 250x250 인 축소된 이미지를 반환합니다.</li>
</ul>
<p>간단하게 예시를 살펴보면, BitmapFactory.Options 멤버로 확인할 수 있습니다. </p>
<pre><code class="language-kotlin">// if, inSampleSize = 1
options.outHeight
options.outWidth

&gt;&gt;&gt;
1000
1000

// if, inSampleSize = 4
options.outHeight
options.outWidth

&gt;&gt;&gt;
250
250</code></pre>
</li>
</ul>
</li>
</ul>
</li>
</ul>
<pre><code>inSampleSize를 사용할 때, 원래 사진의 바이트를 확인해서 작으면 inSampleSize 에 작은 값을 넣고, 사진의 바이트가 크다면 inSampeSize 에 큰 값을 넣어서 분기처리한다면 더 깔끔한 코드가 될 것 같다.</code></pre><p>이 비트맵을 사용하여 이미지를 압축하려고 합니다. 이미지를 압축하고 이미지 사이즈도 확인해보려고 합니다. 이럴 때는 어떻게 해야할까요?</p>
<p>아래의 코드를 통해 확인할 수 있습니다.</p>
<pre><code class="language-kotlin">val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
val byteArray = outputStream.toByteArray()

// size
byteArray.size</code></pre>
<h2 id="use-imagedecoder">Use ImageDecoder</h2>
<p>이미지에 대한 Uri를 간단하게 ImageDecoder로 복호화하여 비트맵을 만들 수 있습니다. </p>
<pre><code class="language-kotlin">val source = ImageDecoder.createSource(contentResolver, photoUri)</code></pre>
<p><strong><em>*주의사항은 위 Android API 28이상에서 호환되는 방식이므로 하위 호환성을 위해서는 아래의 코드를 참고해주세요</em></strong></p>
<pre><code class="language-kotlin">fun Context.createUriToBitmap(photoUri: Uri): Bitmap =
    if (Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.P) {
        val source =
            ImageDecoder.createSource(contentResolver, photoUri)
        ImageDecoder.decodeBitmap(source)
    } else {
        MediaStore.Images.Media.getBitmap(
            contentResolver,
            photoUri
        )
    }
</code></pre>
<p>다음으로 이미지의 비트맵을 만들어주면 됩니다.</p>
<pre><code class="language-kotlin">val bitmap = ImageDecoder.decodeBitmap(source)</code></pre>
<p>해당 비트맵을 원하는 이미지로 압축한 후 이미지의 크기를 가져오기 위해서는 어떻게 해야할까요?</p>
<p>그렇게 하기 위해서는 OutpuStream을 만들어주어야 합니다. 그 이후, 비트맵 압축 시 OutputStream에 데이터를 받아서 ByteArray로 변환 후 크기를 확인할 수 있습니다. 예시 코드는 아래와 같습니다.</p>
<pre><code class="language-kotlin">val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
val byteArray = outputStream.toByteArray()

// size
byteArray.size</code></pre>
<h2 id="bitmap-compress">Bitmap Compress</h2>
<p>위에서 BitmapFactory 비트맵 추출과 ImageDecoder 비트맵 추출에 대해서 알아보았습니다. </p>
<p>알아보면서 해당 비트맵을 이미지로 압축하는 과정은 동일하게 진행되었고, 해당 과정에 대해서 살펴보겠습니다.</p>
<p>다음과 같이 비트맵을 압축하기 위해서는 Bitmap의 compress 함수를 사용하게 됩니다. 압축을 성공하면 compress는 true를 반환합니다.</p>
<pre><code class="language-kotlin">bitmap.compress(
    format = Bitmap.CompressFormat.JPEG, 
    quality = 100, 
    stream = outputStream
)</code></pre>
<ul>
<li><strong>format</strong><ul>
<li>압축된 이미지의 품질 형식<ul>
<li>ex) JPEG, PNG, WEBG</li>
</ul>
</li>
</ul>
</li>
<li><strong>quality</strong><ul>
<li>압축할 이미지의 품질<ul>
<li>이미지 형식마다 상이</li>
<li>0~100 값을 가짐</li>
</ul>
</li>
</ul>
</li>
<li><strong>stream</strong><ul>
<li>압축된 데이터를 쓰기 위한 출력 스트림</li>
</ul>
</li>
</ul>
<h3 id="imagedecoder-방법">ImageDecoder 방법</h3>
<p>간단한 예시로, 3.35MB 이미지를 갤러리에서 가져와서 압축해보겠습니다. quality는 100으로 진행한다.</p>
<pre><code class="language-kotlin">val source =
    if (Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.P) {
        ImageDecoder.createSource(contentResolver, photoUri)
    } else {
        TODO(&quot;VERSION.SDK_INT &lt; P&quot;)
    }
val newBitmap = ImageDecoder.decodeBitmap(source)

&gt;&gt;&gt; newBitmap
53673984</code></pre>
<pre><code class="language-kotlin">val outputStream = ByteArrayOutputStream()
newBitmap.compress(Bitmap.CompressFormat.WEBP, 100, outputStream)
val byteArray = outputStream.toByteArray()

&gt;&gt;&gt; byteArray.size
if JPEG, 53673984
if PNG, 12702333
if WEBP, 9301538</code></pre>
<p><strong>사진 원래 크기 : 3.35MB</strong> </p>
<ul>
<li><strong>JPEG</strong></li>
</ul>
<pre><code>|  | bitmap.byteCount | byteArray.size  |
| --- | --- | --- |
| quality = 100  | 53673984 (5.36MB) | 5433395 (5.4MB) |
| quality = 50 | 53673984 (5.36MB) | 534970 (534KB) |
| quality = 0 | 53673984 (5.36MB) | 77290 (77KB) |
- 53.6MB → 5.4MB (약 9.8배 줄어듬) 
    - *quality = 50 ⇒ 5.4MB → 534KB로 줄어듬 (품질저하는 별로 없음)*
- 소요시간 : 1.2s</code></pre><ul>
<li><strong>PNG</strong></li>
</ul>
<pre><code>|  | bitmap.byteCount | byteArray.size  |
| --- | --- | --- |
| quality = 100  | 53673984 (53.6MB) | 12702333 (12.7MB) |
| quality = 50 | 53673984 (53.6MB) | 12702333 (12.7MB) |
| quality = 0 | 53673984 (53.6MB) | 12702333 (12.7MB) |
- 53.6MB → 12.7MB (약 4.3배 줄어듬)
    - *quality = 50 ⇒ 12.7MB 유지*
- 소요시간 : 4s</code></pre><ul>
<li><strong>WEBP</strong></li>
</ul>
<pre><code>|  | bitmap.byteCount | byteArray.size  |
| --- | --- | --- |
| quality = 100  | 53673984 (53.6MB) | 9301538 (9.3MB) |
| quality = 50 | 53673984 (53.6MB) | 241888 (241KB) |
| quality = 0 | 53673984 (53.6MB) | 42976 (42KB) |
- 53.6MB → 9.3MB (약 5.7배 줄어듬)
    - *quality = 50 ⇒ 9.3MB → 241KB로 줄어듬 (품질저하는 별로 없음)*
- 소요시간 : 4.8s</code></pre><p><strong><em>*compress quality 를 작게하면 이미지 크기를 줄일 수 있다.</em></strong></p>
<p><strong><em>*PNG 사용은 좋지 않다. 이미지를 로드할 때, 이미지 크기가 크다면 메모리를 차지하고 로딩되는 시간도 오래걸린다.</em></strong> </p>
<p><strong><em>*WEBP는 Deprecated 되었다.</em></strong></p>
<h3 id="bitmapfactory-방법">BitmapFactory 방법</h3>
<p>ImageDecoder와 똑같은 사진으로 진행합니다. quality는 100으로 진행했습니다.</p>
<p><strong>사진 원래 크기 : 3.35MB</strong> </p>
<ul>
<li><strong>JPEG</strong></li>
</ul>
<pre><code>|  | bitmap.byteCount | byteArray.size  |
| --- | --- | --- |
| qulity = 100 / inSampleSize = 1 | 48771072 (48.7MB) | 5435293 (5.34MB) |
| qulity = 100 / inSampleSize = 2 | 12192768 (12.2MB) | 1774780 (1.7MB) |
| qulity = 100 / inSampleSize = 4 | 3048192 (3MB) | 423594 (423KB) |
| qulity = 50 / inSampleSize = 1 | 48771072 (48.7MB) | 534970 (534KB) |
| qulity = 50 / inSampleSize = 2 | 12192768 (12.1MB) | 94848 (94KB) |
| qulity = 50 / inSampleSize = 4 | 3048192 (3MB) | 25111 (25KB) |</code></pre><p><strong><em>*PNG와 WEBP는 고려하지 않습니다.</em></strong></p>
<h1 id="이미지-회전하는-이슈-발생">이미지 회전하는 이슈 발생!</h1>
<p>사진촬영 후, 갤러리에서 사진 uri를 가져와 BitmapFactory로 비트맵 전환 후, 이미지 바인딩 시 회전되어 보임.
<img src="https://velog.velcdn.com/images/kwan_hee/post/d3699f5e-b2fe-4f0b-a6cc-ab45deeec727/image.png" width =200px height=200px></p>
<p>이유는 단순하다. 카메라로 사진을 촬영할 때, 센서의 방향으로 사진을 저장하기 때문에 해당 사진을 비트맵으로 반환하니 그대로 보여주어서 회전된 사진이 보여집니다. 해당 코드는 BitmapFactory를 다룬 목차를 확인해주세요.</p>
<p>이슈의 해결은 다음과 같습니다. 비트맵의 메타데이터를 가져와 회전 속성을 확인합니다. 회전 속성이 올바르지 않다면 즉, 90도 회전, 180도 회전 등 회전되어있다면 올바르게 돌려야 합니다.
해결하기 위해서 ExifInterface API를 사용합니다. 이는 스트림을 통해 메타데이터를 가져올 수 있습니다.</p>
<p>아래의 코드는 이미지 uri에 대한 stream을 시작하여 사진의 orientation이 어떤 값을 갖는지 반환하는 함수입니다.</p>
<pre><code class="language-kotlin">fun loadOrientation(photoUri: Uri): Int {
    var orientation: Int = 0
    try {
        val stream = contentResolver.openInputStream(photoUri)
        val exifInterface = ExifInterface(stream!!)
        orientation = exifInterface.getAttributeInt(
            ExifInterface.TAG_ORIENTATION,
            ExifInterface.ORIENTATION_NORMAL
        )
        stream.close()
    } catch (e: Exception) {
    }
    return orientation
}</code></pre>
<p>다음으로는 이미지 uri에 대한 비트맵을 반환해야 합니다.</p>
<pre><code class="language-kotlin">fun decodeUriToBitmap(uri: Uri, bounds: Boolean = false, size: Int = 1): Bitmap? {
    var bitmap: Bitmap? = null
    val options = BitmapFactory.Options().apply {
        inJustDecodeBounds = bounds
        inSampleSize = size
    }

    try {
        val stream = contentResolver.openInputStream(uri)
        bitmap = BitmapFactory.decodeStream(stream, null, options)

        stream?.close()
    } catch (e: Exception) {
    }

    return bitmap
}</code></pre>
<p><strong><em>*주의해야할 점이 존재합니다. openInputStream 으로 스트림을 열고 있습니다. 하나의 스트림을 사용할 때, 여러 곳에서 해당 스트림을 사용하려고 하면 첫 번째에서 사용된 곳에서 사용되는 것이지 다음 사용되는 곳에서는 null 이 사용됩니다.</em></strong></p>
<p><strong><em>예를 들어서, 다음과 같이 이미지 회전 속성 orientation 을 가져올 때와 비트맵을 만들 때 사용할 수 있습니다. 이 경우에는 ExifInterface 에서만 사용되고, BitmapFactory.decodeStream 부분에서는 stream은 null을 가지고 있습니다.</em></strong> </p>
<pre><code class="language-kotlin">val stream = contentResolver.openInputStream(uri)
val exifInterface = ExifInterface(stream!!)
val bitmap = BitmapFactory.decodeStream(stream, null, options)</code></pre>
<p>그 후에 이미지 회전 방향(orientation) 과 이미지 비트맵을 만들었으니 이미지 어떤 방향으로 얼마나 회전시킬 지 정해야 합니다. 다음과 같이 ExifInterface 멤버로 회전 방향이 정의되어 있습니다. </p>
<pre><code class="language-kotlin">fun rotateBitmap(orientation: Int, bitmap: Bitmap?): Bitmap? = when (orientation) {
    ExifInterface.ORIENTATION_ROTATE_90 -&gt; rotateImage(bitmap, 90f)
    ExifInterface.ORIENTATION_ROTATE_180 -&gt; rotateImage(bitmap, 180f)
    ExifInterface.ORIENTATION_ROTATE_270 -&gt; rotateImage(bitmap, 270f)
    else -&gt; bitmap
} </code></pre>
<p>그 다음으로 이미지를 어떤 방향으로 회전시켜야할 지 정해졌으니, 실제로 이미지를 돌려야 합니다.</p>
<pre><code class="language-kotlin">fun rotateImage(bitmap: Bitmap?, angle: Float): Bitmap? {
    val matrix = Matrix().apply { postRotate(angle) }
    return bitmap?.let {
        Bitmap.createBitmap(it, 0, 0, it.width, it.height, matrix, true)
    }
}</code></pre>
<p>그래서 다음과 같은 이미지를 원하는 이미지 품질로 압축하면 회전된 이미지를 사용하지 않고 정상적으로 사용할 수 있습니다.</p>
<pre><code class="language-kotlin">val orientation = loadOrientation(photoUri)
val bitmap = rotateBitmap(orientation, decodeUriToBitmap(photoUri))

val output = ByteArrayOutputStream()
bitmap?.compress(Bitmap.CompressFormat.JPEG, 100, output)
val byteArr = output.toByteArray()
&gt;&gt;&gt; bytrArr.size 로 압축된 이미지의 크기 판별

binding.imageView.setImageBitmap(bitmap)</code></pre>
<img src="https://velog.velcdn.com/images/kwan_hee/post/dd7875ae-f5fc-4509-8443-ab4c4d8f7776/image.png" width=200px height=200px>

<h3 id="이미지-리사이즈-방법-고르기">이미지 리사이즈 방법 고르기</h3>
<p>보여지는 이미지가 크지 않다면, BitmapFactory를 이용해서 커스텀하여 이미지를 리사이징하는 방법이 좋아보인다. 확실하게 이미지의 크기를 많이줄일 수 있기 때문이다. 하지만 보이는 이미지의 크기가 어느정도 있다면 이미지의 품질을 고려해 ImageDecoder 방법이 좋아보인다. </p>
<p>추가적으로 ImageDecoder의 방법은 코드가 짧고 간단하기 때문에 이미지의 크기를 많이 줄이지 않아도 괜찮다면 좋은 방법일거라 생각합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Canvas를 사용한, 원형 프로그래스 바 제작기(6) - 마지막]]></title>
            <link>https://velog.io/@kwan_hee/Canvas%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9B%90%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EC%8A%A4-%EB%B0%94-%EC%A0%9C%EC%9E%91%EA%B8%B06-%EB%A7%88%EC%A7%80%EB%A7%89</link>
            <guid>https://velog.io/@kwan_hee/Canvas%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9B%90%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EC%8A%A4-%EB%B0%94-%EC%A0%9C%EC%9E%91%EA%B8%B06-%EB%A7%88%EC%A7%80%EB%A7%89</guid>
            <pubDate>Mon, 04 Mar 2024 11:27:58 GMT</pubDate>
            <description><![CDATA[<h2 id="전개-5-원형-프로그래스-바와-같이-자녀와-부모-아이콘-이미지를-어떻게-움직이게하지"><strong>전개 5: 원형 프로그래스 바와 같이 자녀와 부모 아이콘 이미지를 어떻게 움직이게하지?</strong></h2>
<hr>
<p>이제 가장 중요한 부분입니다. 원형 프로그래스 바가 증가할 때, 그 증가에 따라서 이미지도 이동해야 합니다. 이 과정을 고민할 때는 크게 리소스를 사용하지 않았습니다. 간단하게 원의 테두리 좌표를 구하면 되지 않을까 생각하면 삼각함수를 꺼내서 진행했습니다.</p>
<h3 id="원-테두리-좌표-구하기">원 테두리 좌표 구하기</h3>
<hr>
<ul>
<li><strong>참고 자료</strong></li>
</ul>
<p><a href="https://mygumi.tistory.com/346">원의 좌표를 구하는 공식을 활용한 애니메이션 :: 마이구미</a></p>
<p>미리 알고 있어야 하는 값들이 존재합니다.</p>
<ul>
<li><strong>각도 (θ)</strong></li>
<li><strong>반지름 (r)</strong></li>
</ul>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/4b59b87e-a6ab-4a2b-b11b-ed13b82d6142/image.png" />
</p>



<p>반지름과 각도만 안다면 원 테두리의 좌표는 쉽게 구할 수 있습니다. 우선 x 좌표를 구하는 공식을 알아봅시다.</p>
<ul>
<li><p><strong>x 좌표 구하기</strong></p>
<p align="center">
<img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/5cdd4f92-d007-418c-b9ca-81bf6fa751ba/image.png" />
</p>
</li>
<li><p><strong>y 좌표 구하기</strong></p>
</li>
</ul>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/beb5f0e0-72c5-4d48-80b8-dfb019614056/image.png" />
</p>



<p>다음과 같이 구하게 되면 원 테두리의 좌표는 다음과 같은 공식이 나옵니다.</p>
<ul>
<li><strong>x 좌표 = cos(θ) x r</strong></li>
<li><strong>y 좌표 = sin(θ) x r</strong></li>
</ul>
<p>이제 이것을 응용하여 이미지를 원형 프로그래스 바에 접근하게 하도록 해보겠습니다.</p>
<h3 id="좌표에-이미지-넣어보기">좌표에 이미지 넣어보기</h3>
<hr>
<ul>
<li><strong>라디안 공식 알아보기</strong></li>
</ul>
<p>삼각함수를 사용해서 좌표를 가져오기 때문에 라디안이라는 것이 무엇인지 다시 알아보자.</p>
<p><a href="https://truecode.tistory.com/18">호도법(Radian)</a></p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/267a4ef5-bbb0-46a2-a30d-f9db12dfd4af/image.png" alt=""></p>
<ul>
<li><strong>계산하기</strong><ul>
<li>원하는 각도에서 30도를 뺀 값을 기준으로 분기 처리해주어야 합니다.</li>
<li>(1) 과 (2) 의 방법으로 좌표를 구해야 합니다. (2) 방법같은 경우에는 y 값이 증가하는 것을 알 수 있습니다.</li>
<li>다음과 같은 공식으로 (1) 번 방법의 (x, y) 좌표와 (2) 번 방법의 (x, y) 좌표를 얻을 수 있습니다.</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/d4bc03ce-fd82-4647-b9dc-a930f0495ccd/image.png" alt=""></p>
<p><strong>1번 방법)</strong></p>
<ul>
<li><p><strong>x = 뷰 너비의 반 - cos(라디안) * 반지름</strong></p>
<pre><code> **= 180 (pixel) - cos(radian) * 140 (pixel)**</code></pre></li>
<li><p><strong>y = 뷰 높이의 반 - sin(라디안) * 반지름</strong></p>
<pre><code> **= 180 (pixel) - sin(radian) * 140 (pixel)**</code></pre></li>
</ul>
<p><strong>2번 방법)</strong></p>
<ul>
<li><p><strong>x = 뷰 너비의 반 - cos(라디안) * 반지름</strong></p>
<pre><code> **= 180 (pixel) - cos(radian) * 140 (pixel)**</code></pre></li>
<li><p><strong>y = 뷰 높이의 반 + sin(라디안) * 반지름</strong></p>
<pre><code> **= 180 (pixel) + sin(radian) * 140 (pixel)**</code></pre></li>
</ul>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/e8ec82af-5ff5-4b2b-af3f-d1d723244c81/image.gif" />
</p>

<p>그런데 이미지를 보니 조금 이상합니다. 안쪽으로 밀려난 이미지가 나타나는 것을 알 수 있습니다. 이러한 문제점은 canvas의 원리를 이해하면 쉽습니다. 좌표를 기준으로 아래와 오른쪽으로 그림을 그리게 되는데, 그렇게 되면 당연하게 영상처럼 안쪽으로 이미지를 그리게 됩니다. 그렇기 때문에 이미지의 크기인 너비와 높이를 알아서 너비를 x 좌표 값에서 빼고, 높이를 y 좌표 값에서 뺀다면 왼쪽 위로 이미지를 올릴 수 있습니다. 즉, 원 테두리 좌표에 정확하게 넣을 수 있는 것 입니다.</p>
<p><strong>1번 방법)</strong></p>
<ul>
<li><p><strong>x = 뷰 너비의 반 - cos(라디안) * 반지름 - 이미지 너비의 반</strong></p>
<pre><code> **= 180 (pixel) - cos(radian) * 140 (pixel) - 24.px**</code></pre></li>
<li><p><strong>y = 뷰 높이의 반 - sin(라디안) * 반지름 - 이미지 높이의 반</strong></p>
<pre><code> **= 180 (pixel) - sin(radian) * 140 (pixel) - 24.px**</code></pre></li>
</ul>
<p><strong>2번 방법)</strong></p>
<ul>
<li><p><strong>x = 뷰 너비의 반 - cos(라디안) * 반지름 - 이미지 너비의 반</strong></p>
<pre><code> **= 180 (pixel) - cos(radian) * 140 (pixel) - 24.px**</code></pre></li>
<li><p><strong>y = 뷰 높이의 반 + sin(라디안) * 반지름 - 이미지 높이의 반</strong></p>
<pre><code> **= 180 (pixel) + sin(radian) * 140 (pixel) - 24.px**</code></pre></li>
</ul>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/2f86c980-b3de-413f-8e1c-fe383b720c3c/image.gif" />
</p>

<p>코드로 알아보면 다음과 같습니다. percent 값이 증가함으로써, degree 값이 변경되어지고, setImageBitmap 메서드가 호출됨으로써 이미지를 다시 그리게 되는 것입니다.</p>
<pre><code class="language-kotlin">private var percent = 0F
private var degree = 0.0
private var imageBitmap: Bitmap? = null
private var x: Double = 0.0
private var y: Double = 0.0

override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)
    ...

  imageBitmap?.let {
      canvas.drawBitmap(it, x.toFloat(), y.toFloat(), null)
  }
}

fun setPercent(percent: Float) {
  this.percent = percent
  degree = (percent * 120.0)
  invalidate()
}

fun setImageBitmap(@DrawableRes image: Int) {
  var radian: Double

  radian = if (degree - 30 &lt; 0) { // 1번 방법
      Math.toRadians(30.0 - degree)
  } else { // 2번 방법
      Math.toRadians(degree - 30.0)
  }

  x = 180.px - cos(radian) * 140.px - 24.px
  y = if (degree - 30 &lt; 0) { // 1번 방법
      180.px + sin(radian) * 140.px - 24.px
  } else { // 2번 방법
      180.px - sin(radian) * 140.px - 24.px
  }

  imageBitmap = ContextCompat.getDrawable(context, image)?.run {
      toBitmap(48.px.toInt(), 48.px.toInt())
  }
  invalidate()
}</code></pre>
<p><strong>이러한 방법과 똑같이 반대쪽도 진행한다면 진행했던 “모티부” 서비스의 원형 프로그래스 바 커스텀 뷰 작성을 완성하게 된다.</strong></p>
<h2 id="결론">결론</h2>
<hr>
<blockquote>
<p><strong>디자인이 나왔을 때, 당황을 했다. 처음보는 프로그래스 바 디자인이었고, 커스텀 뷰를 해봤지만 canvas를 사용하여 흰 도화지에 그림 그려본 적은 없었기 때문이다. 잘해낼 수 있을까.. 고민을 많이 했지만, 나는 디자인이 해당 디자인을 할 수 있냐고 물어봤을 때, 당시에는 고민하지도 않고 “네 할 수 있습니다.” 라고 답했다. 자신 있었다. 할 수 있을 것 같은 자신감이 그 순간에는 계속 들었다. 실제로 진행하면서 좌우반전, 이미지 canvas로 그리기, 이미지 움직이기 등 다양한 문제를 직면했지만 계속해서 고민하고 레퍼런스를 찾아보면서 해결할 수 있었다. 
개발에 있어서 걸림돌이 되는 것은 실력인 것 같다. 지금도 많이 부족한 원형 프로그래스 바라고 생각한다. 만약에 실력이 더 출중했다면, 디자인 요구에 맞는 완벽한 원형 프로그래스 바를 만들 수 있을 것 같았다. 그래도 이번 원형 프로그래스 바를 제작하면서 느낀점은 해보지 않고 포기하려는 것은 실패하는 것보다 참담하다고 생각한다. 실패를 겪고 그 문제점을 고민해보는 것이 개발자라면 항상해야하는 고민이라고 생각한다.  이번 “모티부”라는 서비스를 만들면서 한 단계 성장할 수 있는 경험이여서 좋은 경험이었다.</strong></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Canvas를 사용한, 원형 프로그래스 바 제작기(5)]]></title>
            <link>https://velog.io/@kwan_hee/Canvas%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9B%90%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EC%8A%A4-%EB%B0%94-%EC%A0%9C%EC%9E%91%EA%B8%B05</link>
            <guid>https://velog.io/@kwan_hee/Canvas%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9B%90%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EC%8A%A4-%EB%B0%94-%EC%A0%9C%EC%9E%91%EA%B8%B05</guid>
            <pubDate>Mon, 04 Mar 2024 11:19:50 GMT</pubDate>
            <description><![CDATA[<h2 id="전개-4-자녀와-부모-아이콘-이미지는-어떻게-넣지"><strong>전개 4: 자녀와 부모 아이콘 이미지는 어떻게 넣지?</strong></h2>
<hr>
<h3 id="문제-발생">문제 발생</h3>
<p>지금 원형 프로그래스 바를 제작을 진행하고, 원형 프로그래스 바를 따라다니는 이미지가 존재합니다. 이 이미지를 canvas로 어떻게 만들어야하는 가? 에 대해서 깊은 고민에 빠졌습니다. 찾아보니 오래되다보니 레퍼런스도 많이 없었습니다. canvas 메서드를 찾아보다가 drawBitmap이라는 메서드가 눈에 띄였고 바로 진행했습니다.</p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/48a31ae8-c59a-4314-b3cc-427d708ae414/image.png" />
</p>


<h3 id="drawbitmap">drawBitmap</h3>
<p>Image를 리소스 파일에서 가져온다면, Int 타입으로 가져옵니다. 이를 비트맵으로 변환하면 됩니다.</p>
<p>이미지 리소스 파일을 가져온 뒤, 비트맵으로 변환하는 코드는 다음과 같습니다.</p>
<pre><code class="language-kotlin">val bitmap = ContextCompat.getDrawable(context, R.drawable.image)?.run {
  toBitmap(48.px, 48.px)
}</code></pre>
<p>해당 값을 drawBitmap 에 넣어주면 될 것 같습니다. bitmap 값은 null-safety 하게 만들어주어야 합니다.</p>
<pre><code class="language-kotlin">bitmap?.let { it -&gt;
    canvas.drawBitmap(it, 0.px, 0.px, null)
}</code></pre>
<p>다음과 같이 (0, 0) 좌표에서 그려지는 것을 볼 수 있습니다. 그림의 크기는 48x48 pixel 입니다.</p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/e834996f-e904-451c-a907-3c6e0d324df5/image.png" />
</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Canvas를 사용한, 원형 프로그래스 바 제작기(4)]]></title>
            <link>https://velog.io/@kwan_hee/Canvas%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9B%90%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EC%8A%A4-%EB%B0%94-%EC%A0%9C%EC%9E%91%EA%B8%B04</link>
            <guid>https://velog.io/@kwan_hee/Canvas%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9B%90%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EC%8A%A4-%EB%B0%94-%EC%A0%9C%EC%9E%91%EA%B8%B04</guid>
            <pubDate>Mon, 04 Mar 2024 11:18:12 GMT</pubDate>
            <description><![CDATA[<h2 id="전개-3-반-시계방향-원형-프로그래스-바">전개 3: 반 시계방향 <strong>원형 프로그래스 바</strong></h2>
<hr>
<h3 id="반-시계방향에-대한-고민">반 시계방향에 대한 고민</h3>
<hr>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/63ef2eaf-ff87-4684-8f47-93182154989e/image.gif"/>
</p>


<p>디자인에서 원하는 뷰를 만들기 위해서는 시계방향으로 올라오는 것과 반 시계방향으로 올라오는 원형 프로그래스 바를 제작해야하는 점이 었습니다. </p>
<p>기존에 사용되는 drawArc 메서드는 시계 방향으로 그려지며, 왼쪽 원형 프로그래스 바는 0~120 비율만큼 시계방향으로 움직이면 된다는 점을 쉽게 이해할 수 있었습니다. 하지만 오른쪽 원형 프로그래스 바는 반대로 움직입니다. 이러한 문제점을 개선하기 위해서 생각해낸 방법은 다음과 같습니다.</p>
<ul>
<li><strong>좌우 반전을 진행해보자! 즉, 기존 시계방향의 원형 프로그래스 바를 좌우로 뒤집으면 된다는 점!</strong></li>
</ul>
<h3 id="좌우-반전">좌우 반전</h3>
<hr>
<p>먼저 결과물을 보여드린다면 다음과 같습니다. 시계방향으로 원형 프로그래스 바를 그리고 있는 왼쪽 검정부분, 반 시계방향으로 원형 프로그래스 바를 그리고 있는 오른쪽 빨강부분입니다. </p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/c295fdba-7fe5-4eb9-88ec-f91bb2e0f575/image.png"/>
</p>

<p>코드는 간단하게 onDraw 메서드 내에서 설정할 수 있습니다. (좌우 반전을 하는 방법입니다!) </p>
<p><strong>검정 부분 코드</strong></p>
<pre><code class="language-kotlin">// CustomView.kt

override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)

  canvas.drawArc(40.px,40.px,320.px,320.px,150F,120f,false,paint)
}</code></pre>
<p><strong>빨강 부분 코드</strong></p>
<pre><code class="language-kotlin">// CustomViewOther.kt

override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)

  scaleX = -1f // -1f 라는 값을 할당하면, 좌우반전합니다. scaleY 값은 상하반전을 해줍니다. 
  canvas.drawArc(40.px,40.px,320.px,320.px,150F,120f,false,paint)
}</code></pre>
<p>해당 부분은 두 개의 커스텀 뷰 클래스를 만들고, XML에서 검정 부분이 위로 오도록 뷰를 쌓으며 그렸습니다.</p>
<pre><code class="language-kotlin">&lt;com.android.test_motivoo.pie_chart.custom_view.CustomView
    android:id=&quot;@+id/custom_view&quot;
    android:layout_width=&quot;wrap_content&quot;
    android:layout_height=&quot;wrap_content&quot;
    app:layout_constraintStart_toStartOf=&quot;parent&quot;
    app:layout_constraintTop_toTopOf=&quot;parent&quot;
    app:layout_constraintEnd_toEndOf=&quot;parent&quot;
    app:layout_constraintBottom_toBottomOf=&quot;parent&quot;/&gt;

&lt;com.android.test_motivoo.pie_chart.custom_view.CustomViewOther
    android:layout_width=&quot;wrap_content&quot;
    android:layout_height=&quot;wrap_content&quot;
    app:layout_constraintStart_toStartOf=&quot;parent&quot;
    app:layout_constraintTop_toTopOf=&quot;parent&quot;
    app:layout_constraintEnd_toEndOf=&quot;parent&quot;
    app:layout_constraintBottom_toBottomOf=&quot;parent&quot;/&gt;</code></pre>
<h3 id="움직이는-프로그래스-바-그리고-색상-넣기">움직이는 프로그래스 바 그리고 색상 넣기</h3>
<hr>
<p>이제는 디자인에서 요구하는 색상과 프로그래스 바가 잘 적용되는 지 SeekBar를 사용해서 테스트해보겠습니다.</p>
<p>필자가 사용한 색상을 넣는 방법은 다음과 같습니다.</p>
<ul>
<li><strong>style 속성 사용하기</strong></li>
</ul>
<pre><code class="language-kotlin">init {
  paint.apply {
      color = ContextCompat.getColor(context, R.color.red)
  }
}</code></pre>
<p>다음과 같이 color resource를 사용해서 color를 설정할 수도 있지만, XML에서 속성을 사용하여 넣어보도록 하겠습니다. attrs XML 파일을 만들어서, 적용한 스타일 클래스를 설정하고 어떤 속성을 추가할 것인지 명시합니다.</p>
<pre><code class="language-kotlin">// res/attrs.xml

&lt;resources&gt;
    &lt;declare-styleable name=&quot;CustomView&quot;&gt;
        &lt;attr name=&quot;progressBackgroundColor&quot; format=&quot;reference|color&quot;/&gt;
    &lt;/declare-styleable&gt;
&lt;/resources&gt;</code></pre>
<p>속성을 추가했다면, XML에서 원하는 뷰를 렌더링했을 때 명시한 속성 이름을 사용할 수 있을 겁니다.</p>
<pre><code class="language-kotlin">&lt;com.android.test_motivoo.pie_chart.custom_view.CustomView
  app:progressBackgroundColor=&quot;@color/blue&quot;/&gt;</code></pre>
<p>해당 속성을 적용하기 위해서는 다음과 같이, obtainStyledAttributes 메서드를 사용해서 typedArray를 사용하여 속성의 타입을 get 할 수 있습니다.</p>
<pre><code class="language-kotlin">init {
  context.theme.obtainStyledAttributes(
    attributeSet, R.styleable.CustomView, defStyleAttr, defStyleAttr
  ).let { typedArray -&gt;
    paint.apply {
        style = Paint.Style.STROKE
        strokeWidth = 10.px
        color = typedArray.getColor(R.styleable.CustomView_progressBackgroundColor, 0)
    }
  }
}</code></pre>
<p>이와 같은 방법으로, 반 시계방향 원형 프로그래스 바에도 적용하면 될 것 같습니다.</p>
<p>다음과 같이 결과물을 살펴보면, 색상이 잘 적용됨을 확인할 수 있습니다.</p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/136dd98a-16a1-4826-9efb-c7f92126aea1/image.png"/>
</p>


<p>💙 <strong>주의할 점!</strong>
: 속성 이름을 설정할 때, 동일한 이름으로 사용된다면 컴파일 에러가 발생할 수 있으니 주의하자.</p>
<pre><code class="language-kotlin">&lt;declare-styleable name=&quot;CustomView&quot;&gt;
  &lt;attr name=&quot;progressBackgroundColor&quot; format=&quot;reference|color&quot;/&gt;
&lt;/declare-styleable&gt;
&lt;declare-styleable name=&quot;CustomViewOther&quot;&gt;
  &lt;attr name=&quot;progressOtherBackgroundColor&quot; format=&quot;reference|color&quot;/&gt;
&lt;/declare-styleable&gt;</code></pre>
<p>이제 색상을 넣었으니, 움직임을 확인하기 위해서 SeekBar를 추가하고 테스트해보도록 하겠습니다. 여기서 목표 걸음 수의 값의 차이가 있더라도 0~120의 비율로 측정하면 되니 상관없는 로직을 구성해야합니다.</p>
<p>우선 SeekBar 를 추가해줍니다. (시계방향과 반시계방향으로 증가해야하니 SeekBar를 두 개 뷰를 추가해주면 됩니다.)</p>
<pre><code class="language-kotlin">&lt;SeekBar
  android:id=&quot;@+id/left_seek_bar&quot;
  android:layout_width=&quot;match_parent&quot;
  android:layout_height=&quot;wrap_content&quot; /&gt;</code></pre>
<p>SeekBar가 증가할 때마다 증가하는 과정을 보기위해서 이벤트 리스너를 사용하여 이벤트를 넘겨줄 것 입니다. SeekBar는 0~100 의 값을 확인할 수 있습니다.</p>
<p>seekValue 의 값이 변경될 때마다 0~100 의 값을 가지고 있으며, 이를 100으로 나눈 값을 percent로 우리가 만든 커스텀 뷰에 전달해주면 됩니다. 해당 메서드는 커스텀 뷰 클래스에서 만든 메서드입니다. </p>
<pre><code class="language-kotlin">binding.leftSeekBar.setOnSeekBarChangeListener(object: OnSeekBarChangeListener {
  override fun onProgressChanged(p0: SeekBar?, seekValue: Int, p2: Boolean) {
      binding.customView.setPercent(seekValue / 100f)
  }
  ...
})</code></pre>
<p>넘어온 percent는 아래의 코드에서 사용될 수 있습니다. sweepAngle의 값이 호를 그리는 역할을 해줍니다. 그러므로 해당 sweepAngle 의 최대 값은 120이며, 목표 값의 0~1 의 비율을 가진 값을 넘겨 받아 계산해주면 끝입니다.</p>
<p>하지만 중요한 메서드가 존재합니다. 움직이게 보이게하려면 그림을 다시 그려야겠죠? 그러므로 invalidate() 메서드를 호출함으로써, onDraw() 메서드를 다시호출하게 됩니다. 이 메서드를 자주 호출하는 것은 프레임 드랍을 할 수 있기 때문에 좋지 않을 수 있습니다. (적절하게 사용한다면 굳!)</p>
<pre><code class="language-kotlin">// CustomView.kt

private var percent = 0F

override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)

  canvas.drawArc(40.px, 40.px, 320.px, 320.px, 150F, (percent * 120F), false, paint)
}

fun setPercent(percent: Float) {
  this.percent = percent
  invalidate()
}</code></pre>
<p>즉, 저의 앱 서비스에 적용한다면 다음과 같은 결론이 나옵니다.</p>
<ul>
<li><strong>현재 걸음 수 / 목표 걸음 수 ⇒ percent를 커스텀 뷰 클래스에 넘겨줍니다.</strong></li>
<li><strong>invalidate() 메서드를 호출하면서, 뷰를 다시 그린다.</strong></li>
<li><strong>percent * 120F ⇒ sweepAngle 인자로 넣어줍니다.</strong></li>
</ul>
<p>결과적으로 잘 그려지는 것을 볼 수 있습니다.</p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/a4cdeb75-4a09-4505-b63d-598c7016813c/image.gif"/>
</p>


<p>마지막으로 해야하는 문제가 있습니다. 디자인에서 요구하는 원형 프로그래스 바는 채워지지 않을 때, 뒤에 원형 프로그래스 바가 또 존재합니다. 이는 고정된 뒷 배경 원형 프로그래스 바이며, 만드는 것은 어렵지 않습니다. 하지만 XML의 트리구조를 생각하며 어디에서 넣어주어야 할 지 고민해야 합니다. 오른쪽 원형 프로그래스 바를 그린 커스텀 뷰에서 지금 원하는 그림을 넣어준다면 적절할 것 같습니다. 왜냐하면 왼쪽 원형 프로그래스 바를 상단에 올려야하기 때문입니다.</p>
<p>이 방법은 어렵지 않으니 여러분도 해봤으면 좋겠네요. 결과물의 사진은 다음과 같습니다. 뷰가 렌덩링되는 순서에 대해서 이해하고 XML 트리구조를 잘 알고 있다면, 쉽게 해결할 수 있습니다.</p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/e858f148-6bc0-472f-877a-7bc160c9ea76/image.png"/>
</p>]]></description>
        </item>
        <item>
            <title><![CDATA[Canvas를 사용한, 원형 프로그래스 바 제작기(3)]]></title>
            <link>https://velog.io/@kwan_hee/Canvas%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9B%90%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EC%8A%A4-%EB%B0%94-%EC%A0%9C%EC%9E%91%EA%B8%B03</link>
            <guid>https://velog.io/@kwan_hee/Canvas%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9B%90%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EC%8A%A4-%EB%B0%94-%EC%A0%9C%EC%9E%91%EA%B8%B03</guid>
            <pubDate>Mon, 04 Mar 2024 11:14:50 GMT</pubDate>
            <description><![CDATA[<h2 id="전개-2-canvas-사용하기">전개 2: canvas 사용하기</h2>
<hr>
<h3 id="canvas-동작방법">canvas 동작방법</h3>
<hr>
<p>canvas는 onDraw 함수에서 사용할 수 있습니다. 흰 도화지에서 그림을 그려넣는 것입니다.</p>
<p>간단하게 drawRect 메서드를 사용해서 canvas 동작에 대한 이해를 도우려고 합니다.</p>
<h3 id="drawrect"><strong>drawRect()</strong></h3>
<hr>
<pre><code class="language-kotlin">override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)
    val paint = Paint()
    val rectF = RectF(10.px, 10.px, 20.px, 20.px)

    canvas.drawRect(rectF, paint)
    // canvas.drawRect(10f, 10f, 20f, 20f, paint) 동일한 코드
    // RectF 는 좌표를 나타내는 객체이므로, 독립적으로 객체를 만들어서 사용하는 편
}</code></pre>
<pre><code class="language-kotlin">&lt;com.android.test_motivoo.pie_chart.custom_view.CustomView
  android:layout_width=&quot;match_parent&quot;
  android:layout_height=&quot;match_parent&quot;/&gt;</code></pre>
<p>이렇게하고 실행해보면, 하얀 화면만 나옵니다. 그 이유는 하얀 사각형을 보여주니 당연하게 하얀 화면밖에 안보이는 이유입니다. 사각형의 검은색 배경을 넣어보도록 하겠습니다. 코드는 다음과 같습니다.</p>
<pre><code class="language-kotlin">val paint = Paint().apply {
  color = ContextCompat.getColor(context, R.color.black)
}</code></pre>
<p> 실제로 두 가지를 테스트해보겠습니다.</p>
<ul>
<li><strong>Dp to Px 크기 변환이 잘 이루어지는 지 테스트</strong></li>
<li><strong>화면에 좌표로 사각형이 잘 그려지는 지 테스트</strong></li>
</ul>
<p><strong>검은색 (커스텀 뷰)</strong></p>
<p><strong>회색 (기본 뷰인 View)</strong> </p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/daea578d-7bd5-4d30-9b31-336c3bc0e8e8/image.png"/>
</p>


<p>커스텀 뷰 옆에 View를 넣어보았습니다. 둘의 크기가 동일한 것을 알 수 있습니다. 둘의 크기는 동일하게 10dp 입니다. 커스텀 뷰가 그려지는 방식은 좌표를 사용하는 데, 다음과 같은 사각형을 그리는 것을 알 수 있습니다.</p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/f2916154-c03f-4fa1-aecd-3003c06232c6/image.png"/>
</p>

<p>💙 <strong>Paint() 객체와 RectF() 객체는 최대한 onDraw() 에서 호출을 지양해야 합니다. 
그 이유는 onDraw는 그림이 다시 그려지는 구간에서 계속해서 호출되기 때문에, 쓸데 없는 객체를 생성하여 메모리 누수가 발생할 수 있습니다. 다음과 같이 사용해주면 좋을 것 같네요.</strong></p>
<pre><code class="language-kotlin">private var paint:Paint = Paint()
private var rectF: RectF = RectF(10f, 10f, 20f, 20f)

override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)

    canvas.drawRect(rectF, paint)
}</code></pre>
<h3 id="drawarc">drawArc()</h3>
<hr>
<blockquote>
<p>이제는 원형 프로그래스 바를 그릴 차례입니다. drawArc() 메서드를 사용하면 만들 수 있습니다.</p>
</blockquote>
<pre><code class="language-kotlin">canvas.drawArc(oval, startAngle, sweepAngle, useCenter, paint)</code></pre>
<ul>
<li><strong>oval</strong> : RectF 객체를 가지며, 그림을 그리기 위한 4개의 좌표를 설정하여 사각형을 그린 뒤 내부에 원을 그린다.</li>
<li><strong>startAngle</strong> : 시작 각도를 나타내며, 단위는 도(degree)이다. 3시 방향이 0도이며, 시계 방향으로 증가</li>
<li><strong>sweepAngle</strong> : 호를 그릴 각도를 나타내며, 단위는 도(degree)이다. startAngle에서 시계 방향으로 시작하여 sweepAngle 만큼의 각도로 호를 그린다.</li>
<li><strong>useCenter</strong><ul>
<li><strong>true</strong> : 시작점과 끝점을 연결하는 선을 그린다.</li>
<li><strong>false</strong> : 그리지 않는 default 값이다.</li>
</ul>
</li>
<li><strong>paint</strong> : 호의 색상, 스타일 등 정의한다.</li>
</ul>
<pre><code class="language-kotlin">private var paint:Paint = Paint()
private var rectF: RectF = RectF(10f, 10f, 110f, 110f)

override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)

  canvas.drawArc(rectF,-90f,360f,false,paint)
}</code></pre>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/a8cf52c0-8256-43a7-bc47-6bccb4c724eb/image.png"/>
</p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/0d591e3a-2987-48a6-8d9e-9551c1875fc4/image.png"/>
</p>


<h3 id="커스텀-원형-프로그래스-바-틀-제작하기">커스텀 원형 프로그래스 바 틀 제작하기</h3>
<hr>
<p>커스텀으로 만들어야 하는 호는 다음과 같이 180도의 반원을 가지며, 밑에 부분이 30도 만큼 증가되어있는 호와 같은 모양을 만들어야 한다.</p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/c364c6f7-1e92-4e78-8947-65c7a0663c72/image.png"/>
</p>


<p>이 부분은 간단하게 만들 수 있을 것 같다.</p>
<p>startAngle = 150f / sweepAngle = 240f 의 값을 설정한다면 간단하게 만들어질 것 같다.</p>
<pre><code class="language-kotlin">canvas.drawArc(rectF, 150f, 240f, false, paint)</code></pre>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/478ef54e-84b7-40d3-baab-286d83865f71/image.png"/>
</p>


<p>그런데 내가 생각하는 것은 테두리만 그려지고 내부는 색상이 채워지지 않는 것을 생각했는데, 좀 다르다. 이것은 paint의 설정 중 style 을 STORKE로 주어야 한다.</p>
<pre><code class="language-kotlin">init {
  paint.apply {
      style = Paint.Style.STROKE
            strokeWidth = 1.px
  }
}</code></pre>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/b6caeaa2-e1bd-4acf-b5fb-abd30d982cad/image.png"/>
</p>


<h3 id="요구사항에-맞게-커스텀-원형-프로그래스-바-제작하기">요구사항에 맞게 커스텀 원형 프로그래스 바 제작하기</h3>
<hr>
<p>기본적인 틀을 제작하고 디자인에서 요구하는 요구사항에 맞게 디자인해보려고 한다.</p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/2ad5947c-ac73-4cfe-a38a-b71248ff9a7e/image.png"/>
</p>



<p>해당 원형 프로그래스 바의 요구사항은 다음과 같다.</p>
<ul>
<li><strong>지름 : 280 pixel</strong></li>
<li><strong>테두리 넓이 : 10 pixel</strong></li>
</ul>
<p>우선 뷰의 크기를 360 pixel 로 설정하였다. 이는 <strong>전개 1: 커스텀 뷰/onMeasure로 뷰의 크기(너비와 높이) 설정하기</strong> 의 과정을 보면 이해할 수 있다.</p>
<p>그 다음 원의 지름이 280 pixel 로 맞추고 원이 360x360pixel 뷰의 가운데에 오도록 맞추었다. 여기서 우선 테두리 넓이는 1 pixel 진행했다. </p>
<pre><code class="language-kotlin">init {
  paint.apply {
      style = Paint.Style.STROKE
      strokeWidth = 1.px
  }
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
  super.onMeasure(widthMeasureSpec, heightMeasureSpec)

  setBackgroundColor(ContextCompat.getColor(context, R.color.red))

  if (measureMode(widthMeasureSpec) &amp;&amp; measureMode(heightMeasureSpec)) {
      setMeasuredDimension(
          MeasureSpec.getMode(widthMeasureSpec),
          MeasureSpec.getMode(heightMeasureSpec)
      )
  } else {
      setMeasuredDimension(360.px.toInt(), 360.px.toInt())
  }
}

private fun measureMode(measureSpec: Int): Boolean =
  MeasureSpec.getMode(measureSpec) == MeasureSpec.EXACTLY

override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)

  canvas.drawArc(40.px,40.px,320.px,320.px,150F,240f,false,paint)
}</code></pre>
<pre><code class="language-kotlin">&lt;com.android.test_motivoo.pie_chart.custom_view.CustomView
  android:id=&quot;@+id/custom_view&quot;
  android:layout_width=&quot;wrap_content&quot;
  android:layout_height=&quot;wrap_content&quot;
  app:layout_constraintStart_toStartOf=&quot;parent&quot;
  app:layout_constraintTop_toTopOf=&quot;parent&quot;
  app:layout_constraintEnd_toEndOf=&quot;parent&quot;
  app:layout_constraintBottom_toBottomOf=&quot;parent&quot;/&gt;</code></pre>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/1f005eac-7ff4-4cb3-82d4-95594deed9e1/image.png"/>
</p>

<p>이제 테두리 넓이의 요구사항에 맞게 제작해보겠습니다. 테두리가 어떻게 그려지는 지 보여주기 위해서 따로 그림으로 나타내어 보았습니다.</p>
<p>만약에, 테두리가 80 pixel이라고 한다고 가정하겠습니다. 그리고 호와 뷰의 크기의 거리 차이는 40 pixel 만 남은 상황이라고 생각한다면, 아래의 그림처럼 보입니다. 테두리가 80 pixel이면, 뷰의 크기를 넘어가는 것이 아닌가? 라는 의문점이 생겼지만 아래의 그림처럼 가운데를 기준으로 테두리가 반반 늘어나는 것을 볼 수 있습니다. 그러므로 사이의 크기가 40 pixel이 남았지만 테두리 넓이의 반인 80/2 = 40 pixel 로 뷰 크기를 넘지 않는 것을 알 수 있었습니다.</p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/dd9d2f27-7cfd-4373-b45d-7f79d24d0ba3/image.png"/>
</p>

<pre><code class="language-kotlin">strokeWidth = 10.px</code></pre>
<p>그래서 strokeWidth를 10 pixel로 바꾸어서 렌더링해보면, 다음과 같이 잘 나오지만 커스텀 뷰를 제작할 때, 이러한 개념을 이해하고 접근해야 원하는 뷰를 제작할 수 있을 겁니다.</p>
<p align="center">
  <img width=300px height=300px src="https://velog.velcdn.com/images/kwan_hee/post/f02c40c4-0366-47b0-ae01-f625cea439dd/image.png"/>
</p>

]]></description>
        </item>
        <item>
            <title><![CDATA[Canvas를 사용한, 원형 프로그래스 바 제작기(2)]]></title>
            <link>https://velog.io/@kwan_hee/Canvas%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9B%90%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EC%8A%A4-%EB%B0%94-%EC%A0%9C%EC%9E%91%EA%B8%B02-x8prtakf</link>
            <guid>https://velog.io/@kwan_hee/Canvas%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9B%90%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EC%8A%A4-%EB%B0%94-%EC%A0%9C%EC%9E%91%EA%B8%B02-x8prtakf</guid>
            <pubDate>Mon, 04 Mar 2024 11:09:19 GMT</pubDate>
            <description><![CDATA[<h2 id="전개-1-커스텀-뷰">전개 1: 커스텀 뷰</h2>
<hr>
<p>커스텀 뷰를 만드는 것은 어렵지 않다. 하지만 해당 뷰는 canvas를 사용해서 직접 원형 프로그래스바를 그리며, 이미지도 넣어야 하니 View를 상속받는 커스텀 뷰 클래스를 만들어보려고 한다.</p>
<pre><code class="language-kotlin">class CustomView @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0
):  View(context, attributeSet, defStyleAttr) { ... }</code></pre>
<h3 id="다양한-기기의-대응하기-위환-dp-to-px">다양한 기기의 대응하기 위환 Dp to Px</h3>
<hr>
<p>모티부라는 앱 서비스는 부모가 함께 사용하는 서비스이기 때문에 여러 기기에서 사용된다는 가정을 세울 수 있었습니다. 그래서 다양한 해상도에서 대응되기 위해서 Dp(Density-independent Pixel)를 Px(Pixel)로 변환하는 작업을 진행했습니다.</p>
<p>코드는 다음과 같습니다.</p>
<pre><code class="language-kotlin">val Int.px: Float
    get() = this * Resources.getSystem().displayMetrics.density</code></pre>
<p>💙 <strong>density 를 곱하는 이유는?</strong>
: 핸드폰의 크기는 다양하고 해당 해상도가 존재합니다. 안드로이드에서는 표준으로 160dpi를 기준으로 상대적 크기를 나타냅니다. 즉, 160dpi의 1dp = 1px 과 같다는 의미입니다. 
여기서 잘 생각해야하는 부분은 디자이너들이 주는 피그마의 자료는 픽셀 단위로 이루어집니다. 그렇기 때문에 동적으로 뷰의 너비와 높이를 지정하려면 Dp를 Px로 변환해야하는 이유가 분명합니다.</p>
<p><strong>픽셀로 변환하는 방법 : dp * 배율(density)</strong></p>
<table>
<thead>
<tr>
<th>크기</th>
<th>설명</th>
<th>픽셀(가로x세로)</th>
<th>배율(x)</th>
</tr>
</thead>
<tbody><tr>
<td>ldpi</td>
<td>저밀도 화면, ~120dpi</td>
<td>240 x 320px</td>
<td>0.75</td>
</tr>
<tr>
<td>mdpi (표준)</td>
<td>중밀도 화면, ~160dpi</td>
<td>360 x 480px</td>
<td>1</td>
</tr>
<tr>
<td>hdpi</td>
<td>고밀도 화면, ~240dpi</td>
<td>480 x 800px</td>
<td>1.5</td>
</tr>
<tr>
<td>xhdpi</td>
<td>초고밀도 화면, ~320dpi</td>
<td>720 x 1280px</td>
<td>2</td>
</tr>
<tr>
<td>xxhdpi</td>
<td>초초고밀도 화면, ~480dpi</td>
<td>1080 x 1920px</td>
<td>3</td>
</tr>
<tr>
<td>xxxhdpi</td>
<td>초초초고밀도 화면, ~640dpi</td>
<td>1440 x 2560px</td>
<td>4</td>
</tr>
</tbody></table>
<p>디자인에서 해준 기본 뷰는 360x780 이다. 즉, canvas를 그릴 때, 우선적으로 해주어야 하는 부분이 있다. 그것은 뷰를 담는 레이아웃의 크기를 지정해야한다. 왜냐하면 XML에서 사용할 때, 간단하게 해당 View를 렌더링하면 되는 것이기 때문이다. 해당 부분은 뷰 생명주기 중 onMeasure 에서 처리할 수 있다.</p>
<h3 id="onmeasure로-뷰의-크기너비와-높이-설정하기">onMeasure로 뷰의 크기(너비와 높이) 설정하기</h3>
<hr>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/1b03f6b7-4133-4cde-b546-af90532cb992/image.png" alt=""></p>
<p>위 뷰의 너비와 높이를 설정할 수 있다. 코드는 다음과 같다.</p>
<p>💙 <strong>코드 설명 
: 코드는 XML에 렌더링할 때, 뷰의 크기를 자체적으로 결정하게하기 위해서 wrap_content를 주었다. 그렇기 때문에 만약 뷰 크기 모드가 XML에서 특정 값을 설정했다면, 해당 값으로 뷰의 크기를 그린다. 하지만 그렇지 않다면 wrap_content라면, 내가 원하는 뷰의 너비와 높이(크기)를 설정할 수 있다.</strong></p>
<pre><code class="language-kotlin">override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)

    if (measureMode(widthMeasureSpec) &amp;&amp; measureMode(heightMeasureSpec)) {
        setMeasuredDimension(
            MeasureSpec.getMode(widthMeasureSpec),
            MeasureSpec.getMode(heightMeasureSpec)
        )
    } else {
        setMeasuredDimension(layoutSize, layoutSize)
    }
}

private fun measureMode(measureSpec: Int): Boolean =
        MeasureSpec.getMode(measureSpec) == MeasureSpec.EXACTLY</code></pre>
<pre><code class="language-kotlin">&lt;com.android.motivoo.custom_view.CustomView
    ...
  android:layout_width=&quot;wrap_content&quot;
  android:layout_height=&quot;wrap_content&quot;/&gt;</code></pre>
<ul>
<li><code>MeasureSpec.getMode(int measureSpec)</code> : 3가지 종류의 크기 모드를 확인할 수 있다.<ul>
<li><strong>MeasureSpec.AT_MOST</strong> : 뷰 내부에서 자체적으로 뷰의 크기를 결정하라는 의미 (XML에서 wrap_content 시 적용된다.)</li>
<li><strong>MeasureSpec.EXACTLY</strong> : XML에서 설정된 값으로 뷰의 크기가 설정된다. (특정 값, match_parent…)</li>
<li><strong>MeasureSpec.UNSPECIFIED</strong> : 설정된 값 없음.</li>
</ul>
</li>
<li><code>setMeasuredDimension(int measuredWidth, int measuredHeight)</code><ul>
<li>해당 메소드를 호출함으로써, 뷰의 크기를 원하는 만큼 설정할 수 있다.</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Canvas를 사용한, 원형 프로그래스 바 제작기(1)]]></title>
            <link>https://velog.io/@kwan_hee/Canvas%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9B%90%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EC%8A%A4-%EB%B0%94-%EC%A0%9C%EC%9E%91%EA%B8%B01</link>
            <guid>https://velog.io/@kwan_hee/Canvas%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9B%90%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EC%8A%A4-%EB%B0%94-%EC%A0%9C%EC%9E%91%EA%B8%B01</guid>
            <pubDate>Mon, 04 Mar 2024 10:47:41 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“모티부”라는 서비스의 앱 프로젝트를 진행하면서, 커스텀 뷰를 만들어야 했던 과정에 대해서 설명하고 진행하려고 합니다. 자녀와 부모의 걸음을 원형 프로그래스 바로 나타내야했던 모든 과정을 낱낱이 파헤쳐 보도록 하겠습니다.</p>
</blockquote>
<p><strong>아래는 &quot;모티부&quot; 깃허브입니다!</strong>
<a href="https://github.com/Team-Motivoo/Motivoo-Android">https://github.com/Team-Motivoo/Motivoo-Android</a></p>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/08c91121-fddb-4e06-a140-cf28f5964033/image.png" alt=""></p>
<p>필자는 해당 주제를 발단, 전개, 결론 순서대로 총 6개의 글을 작성하려고 합니다. </p>
<ul>
<li>첫 번째는 문제의 발단의 주제로 해당 문제가 발생하고 이 부분을 해석하기 위해 고민한 흔적을 담습니다.</li>
<li>두 번째부터 다섯 번째는 전개로 이어지면서 원형 프로그래스 바를 구현하면서 겪은 문제점과 그에 따른 해결책, 사용된 다양한 기능들을 설명합니다.</li>
<li>다섯 번째 블로그에서 마지막 전개를 다루면서 결론을 블로그 마지막에서 설명하려고 합니다. 결론에는 필자의 해당 문제를 해결하면서 겪은 점과 느낀 점에 대해서 서술하려고 합니다.</li>
</ul>
<p>많관부!</p>
<h2 id="문제의-발단">문제의 발단</h2>
<ul>
<li>진행한 앱 프로젝트는 2주라는 시간 내 결과물 만들어야 했다.</li>
</ul>
<pre><code>**1) 커스텀 뷰?**  

**2) canvas?**

**3) 반대로 올라오는 원형 프로그래스 바? (반 시계방향)**

**4) 자녀와 부모 아이콘 이미지는 어떻게 넣지?**

**5) 원형 프로그래스 바와 같이 자녀와 부모 아이콘 이미지를 어떻게 움직이게하지?**</code></pre><p>와 같은 5가지의 문제가 생겨났습니다. 이 문제를 어떻게 해결했으며, 전개 부분에서 풀어보도록 하겠습니다.</p>
<p>이해를 돕고자 완성된 뷰를 아래에서 볼 수 있습니다.</p>
<p align ="center">
<img  width="250px" height="500px" src="https://velog.velcdn.com/images/kwan_hee/post/d0f80f81-a882-4f19-abac-0e4f3c41b884/image.gif" />
</p>

]]></description>
        </item>
        <item>
            <title><![CDATA[[SOPT] CDS 합동세미나를 통해 배운 안드로이드 지식]]></title>
            <link>https://velog.io/@kwan_hee/SOPT-CDS-%ED%95%A9%EB%8F%99%EC%84%B8%EB%AF%B8%EB%82%98%EB%A5%BC-%ED%86%B5%ED%95%B4-%EB%B0%B0%EC%9A%B4-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EC%A7%80%EC%8B%9D</link>
            <guid>https://velog.io/@kwan_hee/SOPT-CDS-%ED%95%A9%EB%8F%99%EC%84%B8%EB%AF%B8%EB%82%98%EB%A5%BC-%ED%86%B5%ED%95%B4-%EB%B0%B0%EC%9A%B4-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EC%A7%80%EC%8B%9D</guid>
            <pubDate>Fri, 08 Dec 2023 01:52:39 GMT</pubDate>
            <description><![CDATA[<p>SOPT 세미나를 진행하고, 세미나에서 배운 지식을 디자인과 서버와 협업하면서 적용할 수 있는 기회가 있습니다. 그것이 Client, Design, Server 합동세미나 CDS 합동세미나입니다. 저는 합동세미나를 하면서 해보고 싶은 스스로의 도전과제는 아키텍처 패턴을 적용해보고 싶었습니다. 구글에서 권장하고 있는 UI Layer와 Data Layer를 나누고 UDF 흐름을 갖는 패턴을 적용해보았습니다. </p>
<h1 id="액티비티와-프래그먼트-선택의-고민">액티비티와 프래그먼트 선택의 고민</h1>
<p>앱의 UI를 보고 뷰를 나누기위해서 초기 설정을 진행하던 도중에 뷰의 흐름을 고민 없이 Activity 와 Fragment를 자유롭게 사용했습니다. 생각해보니 단일 Activity 를 사용해서 Fragment를 붙이고 사용할 지 여러 Activity를 사용할 지 초기에 뷰의 흐름을 보고 장단점을 고려해서 초기 설정을 다루는 것도 중요하다고 생각합니다.</p>
<p>그래서 합동세미나의 화면 플로우를 구성하기 위해서 네비게이터 셋팅을 진행했다.</p>
<p>기본 Main 액티비티는 Fragment 와 BottomNavigation 으로 구성되어있고,
4개의 뷰를 나타내면 끝이다.
(뷰 : 동네생활 / 모임 더 둘러보기 / 모임 가입하기 / 모임 프로필 만들기)</p>
<p>[동네생활 &gt; 모임 더 둘러보기 &gt; 모임 가입하기 ] 의 흐름을 가진다.</p>
<p>액티비티를 사용할까? 프래그먼트를 사용할까? 고민했다.</p>
<p>Jetpack Navigation 사용했더라면 그냥 프래그먼트를 사용했을 것 같다. 이유는 그저 Fragment back stack 관리가 쉬울 것 같아서?
하지만 Jetpack Navigation 은 사용하지 않는다.
그렇다고 해서 액티비티를 사용하냐? 그건 아니지만 개인적으로 동네 생활 페이지는 Fragment 로 작성되지만 다음 뷰는 액티비티로 가져가야겠다 생각했다. 이유는 액티비티의 linux 기반 IPC 데이터 통신의 문제점? 액티비티 메모리 측면에서 무거운데? 등 프래그먼트를 사용하는 관점이 더 좋아보인다.</p>
<p>하지만 우리 합동세미나에서 4개의 뷰를 나타내는데, 액티비티를 사용해도 상관없을 듯하다. 큰 데이터를 전달하지도 않고 인도에서 사용하는 어플아니면 메모리 측면에서도 크게 문제될 것 같지 않다.</p>
<p>참고 자료 : <a href="https://www.charlezz.com/?p=44128">https://www.charlezz.com/?p=44128</a></p>
<h1 id="합세에서-구현한-구글-권장-아키텍처-한눈에-보기">합세에서 구현한 구글 권장 아키텍처 한눈에 보기!</h1>
<blockquote>
<p>안드로이드에서 권장하는 <strong>구글 권장 아키텍처</strong>에 근거하여 이번 프로젝트를 진행해보았다. 서버통신이 있어서 <strong>수동 DI</strong> 와 <strong>서비스 로케이터</strong> 개념도 추가해서 진행하고, <strong>코루틴</strong>을 사용하여 <strong>동시성 프로그래밍</strong>을 진행했다.</p>
</blockquote>
<p><strong>프로젝트 폴더 &amp; 파일 (하나의 app 모듈을 사용)</strong></p>
<pre><code>├─ 📁 app
│   ├─ 📁 data
│   │   ├─ 📁 api
│   │       ├─ 📄 ApiService.kt
│   │       └─ 📄 RetrofitManager.kt
│   │   ├─ 📁 dataSource
│   │       └─ 📁 remote            // 서버통신만 진행하므로 local 폴더는 다루지 않는다.
│   │           └─ 📄 SampleRemoteDataSource.kt
│   │   ├─ 📁 repository
│   │       └─ 📄 SampleRepository.kt
│   │   └─ 📁 model
│   │       ├─ 📁 request
│   │           └─ 📄 SampleRequest.kt 
│   │       └─ 📁 response
│   │           └─ 📄 SampleResponse.kt
│   ├─ 📁 ui
│       ├─ 📄 SampleActivity.kt
│       ├─ 📄 SampleViewModel.kt
│       └─ 📄 SampleViewModelProvider.kt
└─  └─ 📄 Application.kt</code></pre><p><img src="https://velog.velcdn.com/images/kwan_hee/post/0a450e09-1cc4-41c5-99b7-451a5f3c9b8e/image.png" alt=""></p>
<h2 id="구글-권장-아키텍처"><a href="https://developer.android.com/topic/architecture?hl=ko"><strong>구글 권장 아키텍처</strong></a></h2>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/d5b7de57-57ef-460d-a207-50c898ade10f/image.png" alt=""></p>
<p>구글에서는 앱의 규모가 커질 것을 생각해서 앱을 확장하고 견고하게 관리하기 위해서 아래와 같은 아키텍처를 권장하고 있습니다. 이는 <strong>관심사 분리와 UDF(단방향 데이터 흐름( 패턴으로 데이터 일관성을 강화하고, 오류가 발생 확률을 줄이며 디버그하기 쉽게 만들어줍니다.</strong></p>
<ul>
<li>어플리케이션 화면에서 데이터를 표시하는 <strong>UI 레이어</strong></li>
<li>앱의 비즈니스 로직을 포함하고 어플리케이 데이터를 노출하는 <strong>데이터 레이어</strong></li>
</ul>
<p><strong><a href="https://developer.android.com/topic/architecture/recommendations?hl=ko">앱 권장 아키텍처 기법</a></strong></p>
<ul>
<li>반응형 및 계층형 아키텍처</li>
<li>앱의 모든 레이어에서의 단방향 데이터 흐름 (UDF)<ul>
<li>사용자 상호작용(버튼 누르기, 이벤트) → 데이터 변경으로 변경사항을 UI에 반영</li>
<li>UI Layer → Data Layer (이벤트) ⇒ 사용자의 버튼 클릭과 같은 이벤트 발생</li>
<li>Data Layer → UI layer (상태) ⇒ 변경된 상태를 반영하기 위해 UI 업데이트</li>
</ul>
</li>
<li>상태 홀더가 있는 UI 레이어로 UI 의 복잡성 관리<ul>
<li>ViewModel 클래스</li>
</ul>
</li>
<li>코루틴 및 Flow<ul>
<li>비동기 처리</li>
</ul>
</li>
<li>종속 항목 삽입 권장사항 (DI)</li>
</ul>
<h2 id="data-layer">Data Layer</h2>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/eea4efde-a14a-4da6-878f-92e47b58fd8c/image.png" alt=""></p>
<blockquote>
<p>앱 데이터 레이어는 비즈니스 로직을 포함하고 있으며, 앱 데이터 생성, 저장, 변경 방식을 결정하는 규칙으로 구성된다.</p>
</blockquote>
<h3 id="dataapi">data/api</h3>
<ul>
<li><strong>RetrofitManage.kt</strong><ul>
<li>HttpLogginInterceptor 를 사용해서 api 로그를 트래킹할 수 있다.</li>
<li>서비스 통신을 위한 Retrofit 객체를 만들고 관리한다. 다른 패캐지에서는 <code>RetrofitServicePool.carrotService</code> 를 참조해서 사용할 수 있다.</li>
</ul>
</li>
</ul>
<pre><code class="language-kotlin">object RetrofitManager {
    private const val BASE_URL = BuildConfig.BASE_URL

    private val httpLoggingInterceptor = HttpLoggingInterceptor()
        .setLevel(HttpLoggingInterceptor.Level.BODY)

    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(httpLoggingInterceptor)
        .build()

    val retrofit: Retrofit =
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(Json.asConverterFactory(&quot;application/json&quot;.toMediaType()))
            .build()

    inline fun &lt;reified T&gt; create(): T = retrofit.create&lt;T&gt;(T::class.java)
}

object RetrofitServicePool {
    val carrotService = RetrofitManager.create&lt;CarrotService&gt;()
}</code></pre>
<ul>
<li><strong>ApiService.kt</strong><ul>
<li>HTTP 통신을 위한 인터페이스를 만든다.</li>
<li>동시성 프로그래밍을 위해서 suspend 함수를 만들어 코루틴을 사용할 수 있게 한다.</li>
</ul>
</li>
</ul>
<pre><code class="language-kotlin">interface CarrotService {
    @POST(&quot;api/clubs/profile&quot;)
    suspend fun postProfile(
        @Body profileRequest: ProfileRequest
    ): BaseResponse&lt;String&gt;
}</code></pre>
<h3 id="datadatasource">data/dataSource</h3>
<ul>
<li>remote/<strong>SampleRemoteDataSource.kt</strong><ul>
<li>데이터 변경사항에 대한 비즈니스 로직을 작성하게 된다. API와 연결되므로 비동기 처리 해주기 위해 suspend 함수를 사용해 코루틴을 사용한다.</li>
</ul>
</li>
</ul>
<pre><code class="language-kotlin">class ProfileRemoteDatasource(
    private val carrotService: CarrotService
) {
    suspend fun postProfile(id: Int, nickname: String, information: String): BaseResponse&lt;String&gt; =
        carrotService.postProfile(id, ProfileRequest(nickname, information))
}</code></pre>
<h3 id="datarepository">data/repository</h3>
<blockquote>
<p>Repository 를 두어서 진행하는 방법은 로컬 데이터와 네트워크 데이터를 관리하는데 관심사 분리를 해줄 뿐만 아니라 계층 간에 데이터 레이어 접근을 위해서 다른 레이어가 데이터 소스에 접근하지 않고 저장소(Repository) 클래스를 주입 받아 실행하게 된다.</p>
</blockquote>
<ul>
<li><strong>SampleRepository.kt</strong><ul>
<li>계층 구조에서 다른 레이어는 데이터 소스에 직접 액세스해서는 안된다. 그러므로 데이터 영역의 진입점으로 이는 항상 저장소(Repository) 클래스여야 한다. 데이터 소스가 직접 종속 항목이 있으면 안되므로, 저장소 클래스를 두어 따로 종속 항목을 주입 받는다.</li>
</ul>
</li>
</ul>
<pre><code class="language-kotlin">class ProfileRepository(
    private val profileRemoteDatasource: ProfileRemoteDatasource
) {
    suspend fun postProfile(id: Int, nickname: String, information: String): BaseResponse&lt;String&gt; =
        profileRemoteDatasource.postProfile(id, nickname, information)
}</code></pre>
<h3 id="datamodel">data/model</h3>
<ul>
<li><p>request/<strong>SampleRequest.kt</strong></p>
<pre><code class="language-kotlin">  @Serializable
  data class ProfileRequest(
      @SerialName(&quot;nickname&quot;)
      val nickname: String,
      @SerialName(&quot;information&quot;)
      val information: String,
  )</code></pre>
</li>
<li><p>response/<strong>SampleResponse.kt</strong></p>
<pre><code class="language-kotlin">  @Serializable
  data class BaseResponse&lt;T&gt; (
      @SerialName(&quot;code&quot;)
      val code: Int,
      @SerialName(&quot;message&quot;)
      val message: String,
      @SerialName(&quot;data&quot;)
      val data: T?
  )</code></pre>
</li>
<li><p>서버로 요청하는 Request Data</p>
<pre><code class="language-json">{
  &quot;nickname&quot;: &quot;조돌이&quot;,
  &quot;information&quot;: &quot;정보 정보 정보 정보&quot;
}</code></pre>
</li>
<li><p>서버에서 응답하는 Response Data</p>
</li>
</ul>
<pre><code class="language-json">{
  &quot;code&quot;: 201,
  &quot;message&quot;: &quot;모임 프로필 생성 성공&quot;,
  &quot;data&quot;: null
}</code></pre>
<h2 id="ui-layer">UI Layer</h2>
<p><img src="https://velog.velcdn.com/images/kwan_hee/post/e6c698e2-cdb5-4f41-b271-5ce827359b05/image.png" alt=""></p>
<blockquote>
<p>UI 레이어는 데이터의 상태를 받아와 어플리케이션 화면에 데이터를 표시하는 레이어입니다.</p>
</blockquote>
<p><strong>UI elements</strong> : 화면에 보이는 UI 요소 (Activity, Fragment)</p>
<p><strong>State holders</strong> : 데이터를 보유하고 이를 UI에 노출하며 로직을 처리하는 상태 홀더 (ViewModel 클래스)</p>
<h3 id="uiviewmodel">ui/ViewModel</h3>
<ul>
<li><p><strong>ProfileViewModel.kt</strong></p>
<ul>
<li><p>ViewModel 에서는 데이터를 관리하고 Configuration Change 에 대응할 수 있다. 여기서 viewModelScope 를 사용하면서 코루틴 스코프를 사용하고 있다. 이 스코프는 ViewModel이 clear 될 때 취소될 수 있다.</p>
<p>  <strong>viewModelScope : “This scope will be canceled when ViewModel will be cleared.”</strong></p>
</li>
<li><p>Kotlin Result 클래스를 사용해서 runCatching 으로 결과에 대한 성공과 실패에 대한 분기처리를 해주었다.</p>
</li>
</ul>
</li>
</ul>
<pre><code class="language-kotlin">class ProfileViewModel(
    private val profileRepository: ProfileRepository
): ViewModel() {
    private val _responseSuccess = MutableLiveData&lt;BaseResponse&lt;String&gt;&gt;()
    val responseSuccess: LiveData&lt;BaseResponse&lt;String&gt;&gt; = _responseSuccess

    fun postProfile(id: Int = 1, nickname: String, information: String, throwMessage: (String) -&gt; Unit) {
        viewModelScope.launch {
            runCatching {
                profileRepository.postProfile(id, nickname, information)
            }.onSuccess {
                _responseSuccess.value = it
            }.onFailure {
                throwMessage(it.message.toString())
            }
        }
    }
}</code></pre>
<ul>
<li><strong>ProfileViewModelProvider.kt</strong><ul>
<li>ViewModel 객체를 만들어주고 생성자의 인자를 주입해주기 위해서는 별도의 ViewModelProvider 클래스를 만들었고, 이는 ViewModelProvider.Factory 를 반환하여 뷰(Activity, Fragment) 에서 사용되는 viewModel 객체에 할당할 수 있다.</li>
</ul>
</li>
</ul>
<pre><code class="language-kotlin">class ProfileViewModelProvider(
    private val profileRepository: ProfileRepository
) : ViewModelProvider.Factory {
    override fun &lt;T : ViewModel&gt; create(modelClass: Class&lt;T&gt;): T {
        return modelClass.getConstructor(ProfileRepository::class.java)
            .newInstance(profileRepository)
    }
}</code></pre>
<p>여기서 사용된 것이 DI 이다. 의존성 주입으로 방법은 위처럼 생성자 주입과 필드(setter) 주입이 존재한다.</p>
<h3 id="uiactivity">ui/Activity</h3>
<ul>
<li><strong>ProfileActivity.kt</strong></li>
</ul>
<p>viewModel 객체를 전역으로 선언한다.</p>
<pre><code class="language-kotlin">private lateinit var profileViewModel: ProfileViewModel</code></pre>
<p>ViewModel 객체를 할당하기 위해서 생성자 주입을 사용하기 때문에 ViewModelProvider 를 사용할 수 있다. 여기서 DI 를 수동으로 처리하지만 해당 레포지터로에 대한 인스턴스는 서비스 로케이터를사용한다. 이 부분은 Application 에서 좀 더 다루어볼 예정이다. </p>
<pre><code class="language-kotlin">profileViewModel = ProfileViewModelProvider(
    CarrotApp.getProfileRepositoryInstance()
).create(ProfileViewModel::class.java)</code></pre>
<p>버튼을 클릭하면 <code>postProfile()</code> 함수가 호출되면서 API 통신이 이루어진다.</p>
<pre><code class="language-kotlin">binding.btnJoinMeeting.setOnClickListener {
    profileViewModel.postProfile(
        nickname = binding.carrotInputLayoutNickname.getEditText(),
        information = binding.carrotInputLayoutSelfIntroduce.getEditText()
    ) {
        snackBar(binding.root) { it }
    }
}</code></pre>
<p>이제 API 통신으로 Response 값을 받으면, 해당 값을 옵저빙하여 UI 를 나타낸다.</p>
<pre><code class="language-kotlin">profileViewModel.responseSuccess.observe(this) {
    snackBar(binding.root) {
        it.message
    }
}</code></pre>
<h2 id="application">Application</h2>
<ul>
<li><strong>Application.kt</strong><ul>
<li>앱이 처음 시작될 때, 서버 통신을 위해서 사용되는 Respository 객체를 전역으로 사용할 수 있게 넣어 주어야 했다. 이유는 뷰모델(레포지토리(데이터소스(API 서비스))) 와 같이 뷰모델에서 레포지토리 객체를 만들어주는 것은 올바르지 못하다. 왜냐하면 서버 통신 객체를  뷰모델이 만들어질때마다 매번 만들어줄 것인가? 그건 아니다. 그래서 우리는 서비스 로케이터 개념을 사용할 수 있다.</li>
</ul>
</li>
</ul>
<pre><code class="language-kotlin">class CarrotApp : Application() {
        //...
    companion object {
        private lateinit var profileRepository: ProfileRepository

        @Synchronized
        fun getProfileRepositoryInstance(): ProfileRepository {
            if (!::profileRepository.isInitialized) {
                try {
                    profileRepository = ProfileRepository(
                        ProfileRemoteDatasource(RetrofitServicePool.carrotService)
                    )
                } catch (e: ExceptionInInitializerError) {
                    Log.e(&quot;로그&quot;, &quot;${e.message}&quot;)
                }
            }
            return profileRepository
        }
    }
}</code></pre>
<h3 id="서비스-로케이터"><strong>서비스 로케이터</strong></h3>
<p>안드로이드에서는 Activity 보다 상위 모듈인 Application 에서 객체를 제어하고 사전에 삽입합니다. 특히, UI → Data 간 통신을 위해서 서버 API 객체를 전역으로 선언할 때 사용됩니다.</p>
<p><strong>DI 와 차이점</strong></p>
<ul>
<li>코드 테스트가 어렵다. 모든 테스트가 동일한 전역 서비스 로케이터와 상호작용하기 때문이다.</li>
</ul>
<p>참조 : <a href="https://developer.android.com/topic/architecture?hl=ko">https://developer.android.com/topic/architecture?hl=ko</a></p>
<h1 id="결론">결론</h1>
<p>Activity와 Fragment 에 대해서 사용하는 방법이 아닌 기능적으로 어떻게 적용해서 좀 더 좋은 앱을 만들 수 있는 지 고민해보는 시간을 가질 수 있었고, 구글 권장 아키텍처를 적용해보면서 Hilt 라이브러리를 사용하지 않는 DI와 서비스 로케이터의 개념을 알아보고 ViewModelProvider 를 사용해서 수동 DI를 진행해보는 과정을 배웠다. 로컬 데이터베이스를 다루는 DataSource 가 있었다면 Repository의 기능이 더 확장되어서 좋을 듯 하다. 
추후에는 TDD 개발을 해보고 싶다. 아키텍처 패턴으로 설계하면 좋은 장점은 테스트 용이성이다. 그래서 테스트 케이스를 작성하고 테스트 후 코드 구현을 해보고 싶다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[회고록] SOPT 33th 솝커톤]]></title>
            <link>https://velog.io/@kwan_hee/%ED%9A%8C%EA%B3%A0%EB%A1%9D-SOPT-33th-%EC%86%9D%EC%BB%A4%ED%86%A4</link>
            <guid>https://velog.io/@kwan_hee/%ED%9A%8C%EA%B3%A0%EB%A1%9D-SOPT-33th-%EC%86%9D%EC%BB%A4%ED%86%A4</guid>
            <pubDate>Tue, 28 Nov 2023 01:31:58 GMT</pubDate>
            <description><![CDATA[<p>2023년 11월 25일 토요일 SOPT 에서 진행하는 해커톤 솝커톤이 개최된 날입니다. 저는 해커톤이라는 것을 솝커톤을 통해서 처음으로 경험하게되었습니다. 해커톤은 정해진 시간내에 팀 빌딩하여 주제에 맞는 프로젝트를 진행하여 산출물을 내놓는 것으로 알고 있었습니다. 솝커톤은 첫 해커톤이기 때문에 길이길이 기억하고자 기록하기로 생각했습니다. 그래서 회고를 진행해보려고 하는데, 회고는 처음 써보고 어떻게 작성해야할 지도 모르겠네요... 그래서 KPT 회고 방법과 해커톤을 하게된 계기와 소감을 추가해서 SOPT 33기 솝커톤 회고록을 작성해보려고 합니다!!</p>
<blockquote>
<p>KPT 란?</p>
</blockquote>
<p>Keep, Problem, Try의 약자로 Keep은 잘 한 것, Problem은 아쉬운 것, Try는 K와 P 기반으로 무엇을 할지에 대해 작성합니다
    - K : 잘 해와서 유지하고 싶은 것
    - P : 어려움을 느껴서 개선하고 싶은 것
    - T : 구체적인 시도할 내용</p>
<h3 id="해커톤을-하게된-계기">해커톤을 하게된 계기</h3>
<p>저는 공부나 개인 취미 활동을 할 때, 항상 혼자 활동하는 것을 좋아하곤 합니다. 누군가 함께 활동하는 것을 싫어하지는 않지만 운동도 혼자 헬스하는 것을 좋아하고 공부도 모각공이나 카페, 도서관 공부보다는 집에서 혼자 공부하는 것을 좋아합니다. 이번년도에 처음으로 SOPT라는 대외활동을 함께하면서 나말고 다른 사람들과 네트워킹하는 것을 여러모로 좋은 점이 많았습니다. 내가 모르는 정보를 얻을 수도 있고, 같이 밥도 먹으면서 친해질수도 있고 같은 목적으로 공부하기 위해 모였지만 같이 공부하는 것이 재미있다는 것을 느꼈습니다. 같이 공부하는 것이 좋아서 공부라는 목적으로 그 날을 기다리고 기대하는 일은 처음이었습니다. 그래서 이번에 개최된 SOPT 의 해커톤 솝커톤을 하게되었습니다.</p>
<blockquote>
<p>솝커톤이란?</p>
</blockquote>
<p>솝커톤은 10~11 명으로 기획, 디자인, 서버, 클라이언트(iOS, 안드로이드, Web) 로 팀을 이루어서 주어진 시간 내 주제가 주어지고, 프로덕트를 함께 으쌰으쌰하며 만드는 것을 말합니다.</p>
<p>솝커톤은 10~11 명으로 기획, 디자인, 서버, 클라이언트(iOS, 안드로이드, Web) 로 팀을 이루어서 주어진 시간 내 주제가 주어지고, 프로덕트를 함께 으쌰으쌰하며 만드는 것을 말합니다.</p>
<h3 id="✨-keep-솝커톤에서-잘해왔고-유지하고-싶은-것은-무엇일까요">✨ Keep, 솝커톤에서 잘해왔고 유지하고 싶은 것은 무엇일까요?</h3>
<p>저는 솝커톤에서  팀 내 안드로이드 개발자로 유저에게 보여주는 프로덕트를 만드는 담당을 하였습니다. 우리는 4명의 안드로이드 개발자가 있었고, 저는 함께 프로그래밍하는 것이 처음이었습니다. 그래서 페어 프로그래밍 경험이 있는 개발자분께서 각자 프로그래밍하는 것이 아닌 몹 프로그래밍이나 페어 프로그래밍으로 진행하는 것이 어떠냐는 의견에 저는 동의하였습니다. <strong>몹 프로그래밍은 2~3 명의 개발자가 드라이버 또는 네비게이터가 되어서 함께 프로그래밍하는 방법입니다.</strong> 즉, 한 프로그래머가 드라이버가 되어 코딩을 진행하면 남은 개발자는 네비게이터가 되어서 옆에서 함께 코드를 보며 코딩을 진행합니다. 옆에서 훈수두어도 인정입니다. 프로그래밍 방법을 결정하고 기획과 디자인이 완료되어가면 안드로이드 앱을 만들기 위해서 화면 구현과 기능 구현을 위해서 안드로이드 개발자는 달릴 시간입니다. 저는 시간을 나타내기 위한 파이 차트와 내가 한 일을 작성하는 ToDo 리스트를 작성하는 것을 팀 내 안드로이드 리드 개발자분이랑 함께 몹 프로그래밍을 진행하였습니다. 초반에는 리드 개발자분이 드라이버를 진행하고 저는 네비게이터를 하였습니다. 남의 코드를 보는 것은 도움이 많이 되었습니다. 온라인에서는 깃허브로 코드 코멘트를 남기며 코드 리뷰를 해왔지만, 오프라인으로 코드 리뷰를 하는 느낌이었습니다. 내가 모르는 부분을 질문하면서 모르는 부분을 알아갈 수 있었고, 오타나 오류에 대해서 빠르게 캐치하여 문제를 해결할 수도 있었습니다. 시간이 좀 지나고 다음으로 제가 드라이버가 되어서 코드를 작성하였습니다. 네비게이터인 리드 개발자분의 좋은 훈수로 코드를 빠르게 작성해나아갔습니다. 해커톤은 타임어택 방식이므로 빠르게 코드를 작성해어야 했는데, 네비게이터가 있어서 더 함께 프로그래밍하기 좋았다고 생각합니다.</p>
<p> 끝으로 몹 프로그래밍은 처음해봤고 추후에 함께 프로그래밍 해야하는 일이 온다면, 이런 방식을 사용하고 싶습니다. 저는 훈수를 좋아하지 않지만 이번에 몸 프로그래밍 훈수는 좋은 기억만 남습니다. 그리고 드라이버와 네비게이터를 두어서 서로 시간을 분배하며 진행하니 코드를 작성하는 피로도 줄일 수 있는 좋은 방법이었고, 만약에 같이 코드를 작성했다면 코드 충돌이 나거나 무엇을 하고 있는 지 잘 모를 수 있겠다고 생각했습니다.</p>
<p> 제일 좋았던 것은 같이 몹 프로그래밍 한 리드 개발자분과 정말 친해졌다고 생각합니다. 남자는 목욕탕을 같이가면 친해진다고 하는데, 개발자는 몹 프로그래밍을 하면 친해질 것이라고 크게 이야기 할 수 있을 것 같습니다.</p>
<blockquote>
<p>개발자가 친해지는 방법은 몹 프로그래밍 방법일지도…? 훈수 인정입니다~</p>
</blockquote>
<h3 id="😒-problem-솝커톤에서-어려움을-느끼고-개선하고-싶은-점은-무엇일까요">😒 Problem, 솝커톤에서 어려움을 느끼고 개선하고 싶은 점은 무엇일까요?</h3>
<p>저는 솝커톤을 하면서 디자인과의 소통을 어렵게 생각했습니다. 초반에 디자인이 결정되어서 개발을 진행하다보면 타임어택이라는 특성을 가진 해커톤에서는 시간 내에 개발을 하지 못하겠다라는 생각도 들고 하지 못할 것이라는 생각에 부담이 커졌습니다. 이런 부분들은 해커톤의 경험이 많은 개발자 분이 디자인분과 이야기하고 좀 더 쉬운 디자인으로 개발자의 부담을 덜 수 있었습니다. 저는 개발의 부담감을 소통으로 해결할 수 있었고, 그 부분은 확실하게 이야기하고 넘어가야 문제를 해결할 수 있었습니다. 이번에 소통을 위해서 자신감을 가지고 어떤 문제를 어떻게 해결할 수 있는 지 이야기할 수 있는 개발자가 되어야겠다고 생각했습니다. </p>
<blockquote>
<p>소통을 위해서 필요한 것은 자신감!</p>
</blockquote>
<h3 id="🫂-try-솝커톤에서-구체적으로-시도한-내용은-무엇일까요">🫂 Try, 솝커톤에서 구체적으로 시도한 내용은 무엇일까요?</h3>
<p>솝커톤이란 처음으로 진행한 해커톤이면서 누군가와 함께 프로젝트 설계부터 구현까지 한 경험이었습니다. 저에게는 솝커톤을 도전하는 것이었습니다. 저는 도전을 좋아하는 편입니다. 실패는 곧 성공으로 가기위한 과정이고, 도전은 그 과정에서 꼭 필요한 부분이니깐요. 도전을 위해서 혼자가 아닌 함께 하는 것은 너무나도 뜻 깊은 시간이라고 생각합니다. 솝커톤에서는 팀 빌딩부터 주제에 맞는 프로젝트를 결정하고 개발의 프로그래밍 방식, 서버와 API 설계, 디자인과의 UI, 추가로 서로 응원해주고 독려해주며 저의 해커톤 첫 시도는 좋은 도전이었습니다. </p>
<blockquote>
<p>솝커톤은 좋은 도전이었다.</p>
</blockquote>
<h3 id="회고를-마치며">회고를 마치며</h3>
<p>회고를 작성해보는 것은 처음이었는데, 그때 그 순간을 기억하며 글로 적는 것이 저에게는 좋은 경험으로 남을 것 같습니다. 여러분들도 기억하고 싶고 잊고 싶지 않는 순간을 글로 적어보는 습관을 들여보는 것을 어떠신가요? 저도 처음으로 써보는데 재밌네요! 함께한 솝커톤 팀원들 다시 보고싶습니다! 표현은 잘 못했지만 항상 기억할게요! 😊</p>
]]></description>
        </item>
    </channel>
</rss>