<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>logging</title>
        <link>https://velog.io/</link>
        <description>고생끝에롹이온다</description>
        <lastBuildDate>Tue, 26 Mar 2024 07:50:53 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>logging</title>
            <url>https://images.velog.io/images/dev_thk28/profile/592725d4-5ea0-4a7d-a169-b202a7ecb6d9/스크린샷 2020-11-12 오후 11.51.01.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. logging. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dev_thk28" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Android] java.lang.ClassCastException]]></title>
            <link>https://velog.io/@dev_thk28/Android-java.lang.ClassCastException</link>
            <guid>https://velog.io/@dev_thk28/Android-java.lang.ClassCastException</guid>
            <pubDate>Tue, 26 Mar 2024 07:50:53 GMT</pubDate>
            <description><![CDATA[<p>티스토리로 옮기게 되었습니다!</p>
<p><a href="https://dev-thk.tistory.com/7">🔗[Android] java.lang.ClassCastException</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Github Action 사용해서 Slack으로 apk 파일 전송]]></title>
            <link>https://velog.io/@dev_thk28/Github-Action-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-Slack%EC%9C%BC%EB%A1%9C-apk-%ED%8C%8C%EC%9D%BC-%EC%A0%84%EC%86%A1</link>
            <guid>https://velog.io/@dev_thk28/Github-Action-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-Slack%EC%9C%BC%EB%A1%9C-apk-%ED%8C%8C%EC%9D%BC-%EC%A0%84%EC%86%A1</guid>
            <pubDate>Mon, 26 Feb 2024 00:49:36 GMT</pubDate>
            <description><![CDATA[<p>회사에서 테스트 배포를 할 때마다 수동으로 signed apk 파일을 만들어내고 있는 중이라
Github를 사용하고 있으니 Action을 써볼까 하는 생각이 들었다.</p>
<p>검색해보니 나는 잘 모르는 스크립트 파일을 작성을 해야하는데 어쩌구 저쩌구....</p>
<p>순식간에 승훈이가 됨</p>
<div>
  <img src="https://velog.velcdn.com/images/dev_thk28/post/cba5f0e8-2ceb-4e89-b4cb-179a767df799/image.jpg" width=400/>
</div>

<p>이 글에서는 기본적인 개념이나 방법 말고 실제로 제가 workflow를 만들면서 겪은 문제만을 서술합니다.</p>
<h1 id="목표">목표</h1>
<ol>
<li>타겟 브랜치에 pull request나 push가 되면 자동으로 signed apk(debug)를 만들어내기</li>
<li>만들어진 signed apk가 자동으로 Slack 채널로 전달됨</li>
</ol>
<h1 id="내가-겪은-문제">내가 겪은 문제</h1>
<h2 id="slack으로-파일-전송">Slack으로 파일 전송</h2>
<h3 id="1-달라지는-apk-파일-이름-처리">1. 달라지는 apk 파일 이름 처리</h3>
<p><code>app.gradle</code>에서 추출하는 apk 파일의 이름을 만들어주는데, 이름 사이에 빌드된 시간이 들어간다.
그래서 파일 이름이 매번 바뀌니까 파일 이름을 하드코딩으로 박아넣을 수 없었다.</p>
<pre><code class="language-yml">- name: Get signed debug apk file path
  id: debugApk
  run: echo &quot;apkfile=$(find app/build/outputs/apk/debug/*.apk)&quot; &gt;&gt; $GITHUB_OUTPUT</code></pre>
<p>이렇게 추출된 apk 파일이 있는 경로에서 파일 이름을 <code>*</code>로 와일드카드?를 써서 찾을 수 있었다.
그리고 경로를 <code>GITHUB_OUTPUT</code>이라는 변수?에 저장하는 듯 싶다.
(아직 뭘 몰라서 눈치껏 파악하고 있습니다)</p>
<p>저렇게 저장하면</p>
<pre><code class="language-yml">file_path: &#39;${{ steps.debugApk.outputs.apkfile }}&#39;</code></pre>
<p>이런식으로 꺼내쓸 수 있는 듯 하다.</p>
<h2 id="apk에-sign하기">apk에 sign하기</h2>
<h3 id="keystore-적용하기">keystore 적용하기</h3>
<p>나는 keystore를 적용할 때 properties 파일을 사용했다.
<strong>1. 로컬에 <code>keystore.properties</code> 파일을 만들어서 로컬에서 빌드할 때 사용함</strong></p>
<pre><code>// ./keystore.properties 파일

storeFile=./keystore.jks
storePassword={비밀번호}
keyAlias={이름}
keyPassword={비밀번호}</code></pre><pre><code class="language-kotlin">// app.gradle.kts 파일

fun getProperties(path: String) = Properties().apply {
    load(FileInputStream(rootProject.file(path)))
}

android {
    // ...

    signingConfigs {
        getByName(&quot;debug&quot;) {
            // keystore.properties 파일을 읽어와서 사용
            val keystoreProp = getProperties(&quot;keystore.properties&quot;)

            storeFile = File(keystoreProp.getProperty(&quot;storeFile&quot;))
            storePassword = keystoreProp.getProperty(&quot;storePassword&quot;)
            keyAlias = keystoreProp.getProperty(&quot;keyAlias&quot;)
            keyPassword = keystoreProp.getProperty(&quot;keyPassword&quot;)
        }
    }

    buildTypes {
        getByName(&quot;debug&quot;) {
            signingConfig = signingConfigs.getByName(&quot;debug&quot;)
        }

        // ...
    }

    // ...
}</code></pre>
<p><strong>2. <code>keystore.properties</code>, <code>keystore.jks</code> 파일은 gitignore에 등록해서 리모트에 올라가지 않음</strong>
<strong>3. Action에서 <code>keystore.jks</code> 파일을 생성한다.</strong></p>
<pre><code class="language-yml">    - name: Decode Keystore
      env: 
        ENCODED_STRING: ${{ secrets.KEYSTORE_BASE_64 }}
      run: |
        echo $ENCODED_STRING &gt; keystore-b64.txt
        base64 -d keystore-b64.txt &gt; keystore.jks
        cp keystore.jks ./app/keystore.jks  # &lt;- 복사하는 이유는 후술..</code></pre>
<p>로컬에 만들어진 <code>.jks</code> 파일을 base64로 인코딩하고, 그걸 secret에 변수로 저장해놓는다.
그러면 Action에서는 인코딩된 문자열을 다시 디코딩해서 <code>keystore.jks</code> 파일로 만든다.</p>
<p><strong>4. Action에서 build하기 전에 <code>keystore.properties</code> 파일을 생성함</strong></p>
<pre><code class="language-yml">    - name: Create keystore.properties file
      env:
        KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
        KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
        KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
      run: |
        echo &quot;storeFile=./keystore.jks&quot; &gt; keystore.properties
        echo &quot;storePassword=$KEYSTORE_PASSWORD&quot; &gt;&gt; keystore.properties
        echo &quot;keyAlias=$KEYSTORE_ALIAS&quot; &gt;&gt; keystore.properties
        echo &quot;keyPassword=$KEY_PASSWORD&quot; &gt;&gt; keystore.properties</code></pre>
<p>리눅스 명령어를 잘 몰라서 그냥 <code>echo</code> 네번 써서 파일에 네줄로 썼습니다..</p>
<p>빌드하기 전에 <code>keystore.properties</code> 파일을 만들어서 빌드할 때 사용될 수 있도록 했다.
만들어지는 위치는 프로젝트의 최상위 경로(./)에 만들어진다.</p>
<p><strong>5. 빌드한다.</strong></p>
<pre><code class="language-yml">    - name: Build signed debug apk
      run: ./gradlew assembleDebug --stacktrace</code></pre>
<h3 id="not-found-for-signing-config-debug">not found for signing config &#39;debug&#39;</h3>
<p>그런데 빌드하는 과정에서 자꾸 에러가 나서 실패를 하는 것이다...</p>
<pre><code class="language-log">Execution failed for task &#39;:app:validateSigningDebug&#39;.
&gt; Keystore file &#39;/home/runner/.gradle/daemon/8.2/./keystore.jks&#39; not found for signing config &#39;debug&#39;.</code></pre>
<p>검색을 해봐도 다들 경로를 확인하라는 답변이 제일 많이 보였다.</p>
<p>그래서 <code>keystore.jks</code>의 경로를 계속 바꿔가면서 해봤는데도 해결이 안됐다.</p>
<p>그러다가 <code>./</code> 경로와 <code>./app/</code> 경로 둘 다에 <code>keystore.jks</code>를 복사해서 넣으니 정상적으로 빌드가 됐다!</p>
<pre><code class="language-yml">    - name: Decode Keystore
      env: 
        ENCODED_STRING: ${{ secrets.KEYSTORE_BASE_64 }}
      run: |
        echo $ENCODED_STRING &gt; keystore-b64.txt
        base64 -d keystore-b64.txt &gt; keystore.jks
        cp keystore.jks ./app/keystore.jks  # &lt;- 왜그런진 모르겠지만 복사해준다</code></pre>
<h2 id="sign-확인하기">sign 확인하기</h2>
<p>apk에 sign이 정상적으로 된건지 확인을 해보고 싶었다.
다들 keytool을 사용해서 확인하라는데 자꾸 제대로 확인이 안됐다.</p>
<pre><code>C:\...\...\...&gt;keytool -printcert -jarfile app-debug.apk
Not a signed jar file</code></pre><p>찾아보니 Android SDK 버전이 올라가면서 버전이 올라갔고?? <code>apksigner</code>라는걸 사용해야하는 듯 싶었다.
(잘 몰라서 정말 미안합니다)</p>
<p><code>apksigner</code>는 Android SDK 경로에 있다.</p>
<pre><code>Tool &gt; SDK Manager &gt; Android SDK Location</code></pre><p> cmd에서 <code>apksigner</code>가 있는 경로로 이동하여 다음과 같이 치면 서명을 확인할 수 있다.</p>
<pre><code> apksigner verify --print-certs {apk 파일 경로}</code></pre><h1 id="전체-workflow">전체 workflow</h1>
<pre><code class="language-yml">name: Android CI

on:
  push:
    branches: [ &quot;main&quot; ]
  pull_request:
    branches: [ &quot;main&quot; ]

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    - name: set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: &#39;17&#39;
        distribution: &#39;temurin&#39;
        cache: gradle

    - name: Grant execute permission for gradlew
      run: chmod +x gradlew

    - name: Decode Keystore
      env: 
        ENCODED_STRING: ${{ secrets.KEYSTORE_BASE_64 }}
      run: |
        echo $ENCODED_STRING &gt; keystore-b64.txt
        base64 -d keystore-b64.txt &gt; keystore.jks
        cp keystore.jks ./app/keystore.jks

    - name: Create keystore.properties file
      env:
        KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
        KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
        KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
      run: |
        echo &quot;storeFile=./keystore.jks&quot; &gt; keystore.properties
        echo &quot;storePassword=$KEYSTORE_PASSWORD&quot; &gt;&gt; keystore.properties
        echo &quot;keyAlias=$KEYSTORE_ALIAS&quot; &gt;&gt; keystore.properties
        echo &quot;keyPassword=$KEY_PASSWORD&quot; &gt;&gt; keystore.properties

    - name: Build signed debug apk
      run: ./gradlew assembleDebug --stacktrace

    - name: Get signed debug apk file path
      id: debugApk
      run: echo &quot;apkfile=$(find app/build/outputs/apk/debug/*.apk)&quot; &gt;&gt; $GITHUB_OUTPUT

    - name: slack upload file
      uses: MeilCli/slack-upload-file@v4.0.0
      with:
        slack_token: ${{ secrets.SLACK_TOKEN }}
        channel_id: ${{ secrets.SLACK_CHANNEL_APK_DELIVERY }}
        content: &#39;content&#39;
        file_path: &#39;${{ steps.debugApk.outputs.apkfile }}&#39;
        if_no_files_found: error
        initial_comment: &#39;create signed apk file: ${{ job.status }}&#39;
</code></pre>
<h1 id="참조">참조</h1>
<ul>
<li><a href="https://medium.com/@dcostalloyd90/automating-android-builds-with-github-actions-a-step-by-step-guide-2a02a54f59cd">Automating Android Builds with GitHub Actions: A Step-by-Step Guide</a></li>
<li><a href="https://stackoverflow.com/questions/7104624/how-do-i-verify-that-an-android-apk-is-signed-with-a-release-certificate">How do I verify that an Android apk is signed with a release certificate?</a></li>
<li><a href="https://yunaaaas.tistory.com/85">[Android] Github Action을 이용하여 apk 파일 Slack으로 내보내기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[앱 내 광고 구조 Refactoring 이야기]]></title>
            <link>https://velog.io/@dev_thk28/%EC%95%B1-%EB%82%B4-%EA%B4%91%EA%B3%A0-%EA%B5%AC%EC%A1%B0-Refactoring-%EC%9D%B4%EC%95%BC%EA%B8%B0</link>
            <guid>https://velog.io/@dev_thk28/%EC%95%B1-%EB%82%B4-%EA%B4%91%EA%B3%A0-%EA%B5%AC%EC%A1%B0-Refactoring-%EC%9D%B4%EC%95%BC%EA%B8%B0</guid>
            <pubDate>Sun, 04 Feb 2024 07:32:14 GMT</pubDate>
            <description><![CDATA[<p>회사에서 서비스하고 있는 앱의 내부 광고 구조를 리팩토링할 수 있는 기회가 생겼다.
글을 쓰는 시점에서 리팩토링은 아직 진행 중이지만, 여러가지 기록해놓으면 좋을 이야기가 있는 것 같아 적는다.</p>
<hr>
<p>광고 구조를 리팩토링하고 싶다고 생각이 들게 된 발단은,
앱 내에 다른 광고들을 더 추가하는 작업을 하면서 하게 되었다.</p>
<p>새로운 곳에 광고가 추가될 때마다 해야하는 코드 작업이 필요 이상으로 많으며,
온갖 곳에 분기처리 코드가 있어서 혹시라도 실수할까봐 이미 한 작업을 몇번이나 확인해야만 했다.</p>
<p>그때부터 남는 시간에 조금씩 광고 구조를 어떻게 개선하면 좋을지 생각하게 되었다.</p>
<h1 id="기존-상태와-문제점">기존 상태와 문제점</h1>
<p>우리의 앱은 실행 시 처음에 서버로부터 광고 정보를 받아온다.</p>
<ol>
<li>어떤 화면에서 2. 광고 업체들을 어떤 순서로 표시할지에 대한 정보이다.</li>
</ol>
<p>광고를 표시하는 화면에 진입하면 저장되어있는 화면 별 광고 순서를 가져와서,
resume 때마다 광고를 순서에 맞게 변경하고 있다.</p>
<p>그리고... 문제가 많다.</p>
<p><strong>1. 별도의 통신 라이브러리를 사용하지 않음</strong>
    서버에서 받은 광고 표시 정보의 응답을 바로 데이터 클래스로 파싱하지 못하고, 
    일일히 응답 xml의 태그들을 하드코딩된 String들로 값을 가져와서 변환해주고 있다. </p>
<p><strong>2. xml을 파싱해서 얻은 데이터를 <code>SharedPreference</code>에 저장함</strong>
    응답으로 오는 광고 정보만 20개 이상인데, 각각 다른 key 값을 사용해서 <code>SharedPreference</code>에 저장하고 있으니 광고의 종류만큼 key로 사용하는 상수들이 생겨난다.</p>
<p><strong>3. 여러 광고 업체의 SDK를 사용하며, 하나의 클래스에서 모든 경우를 다 처리함.</strong>
    앱 내에서 여러 광고 업체를 통해 광고를 표시하고 있는데, 그래서 광고 업체마다의 SDK가 제공하는 인터페이스와 사용 방법이 다 다르다.
    그런데 이걸 하나의 클래스 안에서 지옥의 분기처리를 통해 업체 별로 달라지는 광고 View들을 다루고 있다.
    코드의 양이 너무나 많고, 심지어 클래스 내에서 <code>Activity</code>의 참조도 가지고있다.</p>
<p><strong>4. 무조건 static String 상수를 사용함</strong>
    광고 표시 위치 구분, 어떤 업체의 광고 View를 표시해야 할지 구분하는 요소를 String 상수로만 정의해놓았다.
    그리고 코드에는 <code>equals</code>와 하드코딩된 String 문자열들이 가득하다.</p>
<p>세세하게 따지면 더 많은 문제가 있지만, 대표적으로 이정도인 것 같다.</p>
<h1 id="개선-지향점">개선 지향점</h1>
<p>대표적인 문제점들을 바탕으로 리팩토링의 컨셉을 정해봤다.</p>
<p><strong>1. Retrofit 적용하기</strong>
    이건 전체적으로 적용해나갈 것이라 해당 리팩토링에만 국한된 내용은 아니다.
    적용하게 되면 또 다른 긴 파싱 코드를 추가하지 않고 원하는 데이터 클래스로 변환이 가능할 것이다.</p>
<p><strong>2. <code>SharedPreference</code> 제거하기</strong>
    <code>SharedPreference</code>를 이런 용으로 사용하는 것 자체가 문제지만, 수많은 각각의 상수 key들이 보기 안좋은 분기처리 코드를 유발하는 원인이 되었다.
    그래서 저장하고자 하는 데이터들을 하나의 Entity로 디자인하고 Room을 사용하여 DB에 저장하려 한다.</p>
<p><strong>3. 광고 업체 별 분기처리 제거</strong>
    업체마다 광고 View를 다루는 방법이 다르기 때문에 분기처리를 없애기 위해서는 인터페이스의 통일이 필요하다.
    그리고 광고 업체 별 광고 처리 코드를 인터페이스를 구현하는 개별 클래스들로 분산시킬 것이다.</p>
<p><strong>4. String 상수 제거</strong>
    단순한 String 상수는 문자열이 주는 의미를 제외하고 코드상에서 어떠한 의미나 맥락을 가지기 어렵다는 것을 코드를 보며 깨달았다.
    복잡한 의미가 필요한 상황에서는 부적절하기에 다른 요소로 대체하고자 한다.</p>
<hr>
<p>사실은... 위의 항목들을 달성하여 이루고자 하는 진짜 목표는 따로 있다.
<strong>&quot;광고 추가 작업할 때 최소한의 코드 작업만 하도록 만들기&quot;</strong>가 진짜 목표이다.
지금은 광고를 추가하기 위해 거의 열 군데 쯤 되는 곳을 건드려야 한다.</p>
<p>진짜 목표를 이루고자 하면 가독성이나 유지보수성은 자연스레 좋아질 것이라고 생각한다. </p>
<h1 id="쟁점">쟁점</h1>
<p>커다란 컨셉을 정한 뒤 세부적인 요소들을 결정하는데 있어서 팀원분들과 많은 이야기를 나누었다.</p>
<h2 id="string-상수-대체하기sealed-or-enum">String 상수 대체하기(sealed OR enum)</h2>
<p>앱 내에서 광고는 삽입될 화면과 현재 표시해야할 광고 업체로 구분해주어야 한다.
광고를 표시하는데 필요한 광고 코드가 화면과 업체 별로 달라지기 때문이다.</p>
<p>광고 구분을 위해 코드 상에 산재되어있는 화면과 업체 관련 하드코딩 String들과 상수들을 완벽히 걷어내고, 
앞으로는 그 어디에서도 해당 String들을 사용하지 않도록 만들자고 결정했다.</p>
<p>그래서 앞으로 사용할 구분 요소들을 <code>sealed class</code>로 정의해야 할지, <code>enum</code>으로 정의해야할지 결정해야 했다.</p>
<p><code>sealed class</code>와 <code>enum</code> 둘 다 무언가를 구분하기 위한 용도로 사용하기 적합하며,
단순한 String 상수보다 코드상에서 더 많은 의미를 가질 수 있기 때문에 고민되었다.</p>
<p>하지만 이야기를 통해 다음과 같이 정리할 수 있었다.</p>
<ul>
<li><p><code>sealed class</code>는 다형성을 활용하기에 적합하다.
  -&gt; 서브클래스 별로 어떤 메서드의 동작을 다르게 구현해야한다던가 하는 처리가 필요 없었다.</p>
</li>
<li><p><code>enum</code>은 요소들을 <code>entries</code>를 통해 Array로 묶어줄 수 있다.
  -&gt; 특정 요소를 Array의 <code>find</code>로 빠르게 찾을 수 있다. 
  서버 응답 광고 정보를 DB의 데이터 클래스로 변환할 때 필요했다. </p>
</li>
<li><p><code>Room</code>은 <code>enum</code> 변환을 자동으로 처리해준다.
  -&gt; 코드 상에서 String들을 없애기로 했기 때문에 <code>Room</code>에 저장하고 가져오는 타입을 <code>enum</code>으로 정의하는 것이 좋을 것이다.</p>
</li>
</ul>
<p>결론적으로 <code>enum</code>을 사용하는 것이 적합하다고 판단했다.</p>
<h2 id="광고-처리-클래스의-책임은-어디까지">광고 처리 클래스의 책임은 어디까지?</h2>
<p><img src="https://velog.velcdn.com/images/dev_thk28/post/5e4a9d88-964a-48bb-88a6-016951e70d86/image.png" alt="">
<em>(참고자료)</em></p>
<p>다이어그램에 있는 <code>AdView</code>는 업체 별 광고 View들의 인터페이스를 통일하기 위한 interface이다. 
그리고 <code>AdView</code>를 사용하는 클래스인 <code>AdUtil</code>는 내부적으로 순서에 맞게 광고를 load 해주는 등의 처리를 한다.</p>
<p><code>AdUtil</code>의 설계에 대해 이야기를 하다가 팀원 분이 의문을 제기해주셨다.
<em>&quot;<code>AdUtil</code> 클래스가 처리하는 내용의 범위는 어떻게 되나요?&quot;</em></p>
<p>나는 광고와 관련된 처리를 외부에서 전혀 알지 못했으면 좋겠다고 생각했다.</p>
<ul>
<li>언제 load 해야하고, 언제 resume 해줘야하고, 언제 pause 해주며, 언제 stop 해줘야 하는지에 대해 <code>Activity</code>는 전혀 모르고, 그저 <code>AdUtil</code>을 사용하여 광고를 표시한다.</li>
<li><code>AdUtil</code>은 <code>LifecycleObserver</code> 같은걸 구현하여 <code>Activity</code>에 스스로를 observer로 등록한다.</li>
<li>광고를 표시하려고 하는 <code>Activity</code>마다 lifecycle에 맞춰 광고 View를 resume, pause하는 코드를 일일히 작성 할 필요가 없어진다.</li>
<li><code>Activity</code>와 광고 관련 코드를 분리할 수 있다.</li>
</ul>
<p>반면에 팀원 분은 다음과 같이 생각하고 계셨다.</p>
<ul>
<li>광고를 표시하려고 하는 <code>Activity</code>에서 load, resume, pause 등의 처리를 직접 해주어야 한다고 생각한다.</li>
<li>배너 형태로 표시하는 광고가 <code>RecyclerView</code> 사이사이에 끼어있는 경우도 있으니 <code>Activity</code>의 lifecycle에만 맞추어서 광고 View를 처리하지 못하는 상황이 분명히 있다.</li>
<li>광고 View를 사용하는 곳에서 어떻게 사용할지 직접 결정할 수 있어야 할 것 같다.</li>
</ul>
<p>두 생각 다 납득할만 하다고 생각한다.
다만 어떤 것이 우리의 프로젝트 코드 상황에 더 적합할지 가려내고 결정해야 한다.</p>
<p>사실 이 사항에 대해서 명확하게 결정난 것은 없다.
우리는 아직 많고 복잡한 기존 코드에 대해 빠삭하게 알고있지도 못할 뿐더러,
기획팀의 변화무쌍한 개발요청은 언제나 불확실하기 때문이다. </p>
<p>추구하고자 하는 코드의 기준과 제약사항들을 다시 점검하고 지금 순간에서의 최선의 결정을 내릴 수 있게 해야하지 싶다.</p>
<h1 id="계속">계속...</h1>
<p>두서에도 적어놨지만 이 리팩토링은 진행 중이기 때문에 내용이 더 추가될 수도 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Compose TextField 커스텀]]></title>
            <link>https://velog.io/@dev_thk28/Android-Compose-TextField-%EC%BB%A4%EC%8A%A4%ED%85%80</link>
            <guid>https://velog.io/@dev_thk28/Android-Compose-TextField-%EC%BB%A4%EC%8A%A4%ED%85%80</guid>
            <pubDate>Fri, 12 Jan 2024 15:30:53 GMT</pubDate>
            <description><![CDATA[<p>티스토리로 옮기게 되었습니다!</p>
<p><a href="https://dev-thk.tistory.com/6">🔗[Android] Compose TextField 커스텀</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[오토핫키 스크립트 백업]]></title>
            <link>https://velog.io/@dev_thk28/%EC%98%A4%ED%86%A0%ED%95%AB%ED%82%A4-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EB%B0%B1%EC%97%85</link>
            <guid>https://velog.io/@dev_thk28/%EC%98%A4%ED%86%A0%ED%95%AB%ED%82%A4-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EB%B0%B1%EC%97%85</guid>
            <pubDate>Tue, 05 Dec 2023 05:19:47 GMT</pubDate>
            <description><![CDATA[<pre><code>SetCapsLockState, AlwaysOff    

#If GetKeyState(&quot;Capslock&quot;,&quot;P&quot;)
    i::Up
    j::Left
    k::Down
    l::Right
    h::Home
    SC027::End

    c::CapsLock

    Esc::`

    1::F1
    2::F2
    3::F3
    4::F4
    5::F5
    6::F6
    7::F7
    8::F8
    9::F9
    0::F10
    -::F11
    =::F12


#If
*CapsLock::
KeyWait, CapsLock
If A_ThisHotkey = *CapsLock
    Send, {vk15}
Return


#Left::
Send, {Home}
return

#+Left::
Send, +{Home}
return

#Right::
Send, {End}
return

#+Right::
Send, +{End}
return

#Up::
Send, {PgUp}
return

#+Up::
Send, +{PgUp}
return

#Down::
Send, {PgDn}
return

#+Down::
Send, +{PgDn}
return</code></pre><h3 id="기능">기능</h3>
<ul>
<li>캡스락 + ijkl = 방향키</li>
<li>캡스락 + h = home</li>
<li>캡스락 + ; = end</li>
<li>캡스락 + c = 캡스락</li>
<li>캡스락 + ESC = `(backtick)</li>
<li>캡스락 + 숫자열, -, + = 1F ~ 12F 키 </li>
<li>캡스락 단독 = 한영전환</li>
<li>win + 위아래 방향키 = pgup/pgdn</li>
<li>win + 좌우 방향키 = home/end</li>
<li>win + 방향키 + shift = 블록 잡기 가능</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] LazyColumn안에 LazyVerticalGrid 넣기(nested scroll)]]></title>
            <link>https://velog.io/@dev_thk28/Android-LazyColumn%EC%95%88%EC%97%90-LazyVerticalGrid-%EB%84%A3%EA%B8%B0nested-scroll</link>
            <guid>https://velog.io/@dev_thk28/Android-LazyColumn%EC%95%88%EC%97%90-LazyVerticalGrid-%EB%84%A3%EA%B8%B0nested-scroll</guid>
            <pubDate>Fri, 09 Jun 2023 06:00:34 GMT</pubDate>
            <description><![CDATA[<p>티스토리로 옮기게 되었습니다!</p>
<p><a href="https://dev-thk.tistory.com/5">🔗[Android] LazyColumn안에 LazyVerticalGrid 넣기(nested scroll)</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[super.init(version=4) 다녀오다]]></title>
            <link>https://velog.io/@dev_thk28/%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@dev_thk28/%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Sun, 02 Apr 2023 14:20:55 GMT</pubDate>
            <description><![CDATA[<h1 id="서론">서론</h1>
<p><code>super.init</code>이라는 주니어 안드로이드 개발자들을 위한 행사가 열린다기에 만삼천원 플렉스해서 다녀왔습니다. </p>
<p><img src="https://velog.velcdn.com/images/dev_thk28/post/e4065820-ce3b-4368-a585-38433074cecd/image.png" alt=""> 2시간 걸려 도착한 삼성역^^</p>
<p><img src="https://velog.velcdn.com/images/dev_thk28/post/25ed69ed-86ee-47e8-a365-d31222e1c2eb/image.png" alt=""> 우와 구글이다~</p>
<p>근데 입구를 못찾아서 굳게 닫힌 문 앞에 3분간 서있다가 정신 차리고 입구로 찾아들어갔다.</p>
<p><img src="https://velog.velcdn.com/images/dev_thk28/post/55e9ab35-db5a-48c6-bf65-7fb06362d997/image.jpg" alt=""> 우와 멋지다~</p>
<p>나는 아는 사람이 하나도 없어서 혼자 입에 거미줄 치고 우두커니 앉아있었다^_ㅜ</p>
<p>여튼 기다리다 보니 드디어 행사가 시작됐다. 행사는 총 여섯분의 발표가 준비되어 있었다. 행사 구성을 찍어오면 좋았을껄... 아쉽다. </p>
<h1 id="본론">본론</h1>
<h2 id="1번째-순서">1번째 순서</h2>
<p>발표자분이 본인의 취준기를 발표해주셨다. 1년이라는 시간동안 취준을 하면서 어떤 일을 겪었는지, 무엇을 깨달았는지, 취준 방법 등을 공유해주셨다. </p>
<p>근데 발표를 듣다보니 내가 언젠가 구글 검색하다가 봤었던 블로그의 주인분이라서 놀랐다.</p>
<p>영양가있는 내용들 덕분에 앞으로 면접을 잘 준비할 수 있을 것 같았다. 특히 거꾸로 되짚어 나가면서 개발 지식들을 정리하는 방법이 인상깊었다. </p>
<h2 id="2번째-순서">2번째 순서</h2>
<p><code>Retrofit</code> 대신 사용할 수 있는 <code>Ktor</code>라는 라이브러리를 소개하는 발표였다. </p>
<p>구미가 당기게끔 소개를 잘 하셔서 한번 써 보고 싶어졌다. </p>
<h2 id="3번째-순서">3번째 순서</h2>
<p>Compose를 소개하는 발표였다. </p>
<p>Compose를 소개하는 것이다 보니 기본적인 내용들 위주로 발표가 진행되어 이미 알고있는 내용들을 다시 복습할 수 있었다. </p>
<h2 id="4번째-순서">4번째 순서</h2>
<p>Jira 자동화에 대한 발표였다. </p>
<p>들으면서 지금 회사에서 사용하고 있는 Jira는 그냥 빙산의 일각... 그냥 겉 껍데기만을 핥고있는 것이였구나 하는 생각이 들었다. 신기하고 유익했다. </p>
<h2 id="5번째-순서">5번째 순서</h2>
<p>Design Document를 쓰는 방법에 대한 발표였다.</p>
<p>사실 나는 Design Document라는 것을 이 발표를 들으면서 처음 알게 되었다. Design Document에 대한 개념과 작성하는 법, 왜 작성해야 하는지에 대한 설명을 듣고 나니 정말 유용해 보였다. 회사 팀 내부적으로 사용하지 않더라도 개인적으로 작성하고 사용해봐야겠단 생각이 들었다.</p>
<p>특히 문서를 작성함으로서 구조의 피드백과 코드의 피드백을 분리할 수 있다는게 인상깊었다. </p>
<h2 id="6번째-순서">6번째 순서</h2>
<p>4년차 개발자분이 현재까지의 본인의 커리어 여정을 요약해서 발표해주셨다. </p>
<p>본인을 굉장히 겸손하게 표현하셨는데... 첫 회사로 누구나 아는 대기업에 취직하셨다고 하기에 충격받았다. 너무 겸손하신 듯 싶다... </p>
<p>과한 준비성을 본받고 싶어졌다. </p>
<h1 id="결론">결론</h1>
<p>진득허니 앉아서 발표를 듣고있자니 엉덩이가 조금 아파서 누워버릴 뻔 했지만 여튼 마지막까지 얌전히 잘 들었다. </p>
<p>누군가의 경험을 들을 수 있다는 것은 참 소중하다고 생각한다. 누군가가 직접 몸으로 부딪혀 겪고 깨달은 것을 나는 앉아서 그 이야기를 듣는 것만으로도 조금이나마 얻을 수 있기 때문이다. 그런고로 발표자분들께 감사를 드리고 싶다. </p>
<p>소중한 주말을 이 행사에 내어주었지만... 결과적으론 다녀온 보람이 있다고 느꼈다. 앞으로 기회가 된다면 이런 행사에 적극적으로 참가하고 싶다. 받은 티셔츠는 잠옷으로 아주 잘 쓸 것 같다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[LazyColumn에 항목이 dismissed된 채로 추가되는 현상]]></title>
            <link>https://velog.io/@dev_thk28/LazyColumn%EC%97%90-%ED%95%AD%EB%AA%A9%EC%9D%B4-dismissed%EB%90%9C-%EC%B1%84%EB%A1%9C-%EC%B6%94%EA%B0%80%EB%90%98%EB%8A%94-%ED%98%84%EC%83%81</link>
            <guid>https://velog.io/@dev_thk28/LazyColumn%EC%97%90-%ED%95%AD%EB%AA%A9%EC%9D%B4-dismissed%EB%90%9C-%EC%B1%84%EB%A1%9C-%EC%B6%94%EA%B0%80%EB%90%98%EB%8A%94-%ED%98%84%EC%83%81</guid>
            <pubDate>Fri, 03 Feb 2023 06:32:19 GMT</pubDate>
            <description><![CDATA[<p>LazyColumn + SwipeToDelete를 사용한 Todo 리스트 화면을 만들면서 겪은 일</p>
<hr>
<h1 id="상황">상황</h1>
<p>Room을 사용해서 가져온 Todo 아이템들을 LazyColumn을 통해서 화면에 리스트로 보여주고 있었다. 각 항목을 왼쪽으로 swipe하면 삭제할 수 있고, 삭제했을 때 뜨는 SnackBar 알림의 &#39;UNDO&#39; 버튼을 클릭하면 방금 삭제한 아이템을 다시 DB에 추가해서 화면에 다시 보여줄 수 있는 기능이 있다.</p>
<p>그런데 삭제한 아이템을 다시 DB에 추가해서 화면에 표시할 때, LazyColumn의 아이템이 swipe된 상태(dismissed) 그대로 다시 추가되는 현상이 발생했다.</p>
<h1 id="원인">원인</h1>
<ol>
<li>DB에 방금 삭제한 Todo 데이터를 다시 추가한다고 했는데, 여기서 각 Todo 아이템의 primaryKey인 id도 그대로 DB에 다시 추가하고 있었다.</li>
<li>그리고 LazyColumn에서는 Todo 아이템의 id를 <code>items(...)</code>의 key값으로 사용하고 있었다.</li>
</ol>
<p>다시 추가된 Todo 데이터의 id가 dismissed된 항목의 id와 같기 때문에 마지막 상태(dismissed) 그대로 다시 화면에 항목이 보이게 된 것이다. </p>
<h1 id="해결">해결</h1>
<p>DB에 데이터를 다시 추가할 때 id 값을 0으로 변경하고 넣어줬더니 해당 현상은 사라졌다.</p>
<p>똑같은 key값으로 항목이 다시 추가되면 마지막 상태 그대로 항목이 표시될 줄은 몰랐다......</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SpringBoot + Kotlin + Bootstrap + thymeleaf (작성중)]]></title>
            <link>https://velog.io/@dev_thk28/SpringBoot-Kotlin-%EB%8F%84%EC%A0%84</link>
            <guid>https://velog.io/@dev_thk28/SpringBoot-Kotlin-%EB%8F%84%EC%A0%84</guid>
            <pubDate>Tue, 25 Oct 2022 13:46:39 GMT</pubDate>
            <description><![CDATA[<p>과정을 기록한 글입니다. 틀릴 수도 있음.
구글에 검색하여 나오는 모든 블로그 주인분들께 감사드립니다..</p>
<hr>
<h1 id="springboot-프로젝트-만들기">SpringBoot 프로젝트 만들기</h1>
<p><a href="https://start.spring.io/">https://start.spring.io/</a>
Spring initializr를 사용해서 프로젝트를 생성한다길래 위 링크에서 프로젝트를 생성했다. 
나는 Gradle, Kotlin으로 프로젝트를 만들었다. 
오른쪽에 dependency는 나는 Spring Web만 추가했었는데, 알고보니 thymeleaf라든가 프로젝트에 앞으로 쓸 것들을 미리 추가하는게 편한 것 같다.</p>
<h1 id="bootstrap-적용">BootStrap 적용</h1>
<p><a href="https://getbootstrap.com/docs/5.2/getting-started/download/">https://getbootstrap.com/docs/5.2/getting-started/download/</a>
위 주소에서 Complied CSS와 JS 파일을 다운받았다.</p>
<p><img src="https://velog.velcdn.com/images/dev_thk28/post/0a04be02-b718-4616-a229-bd262cdf09f9/image.png" alt=""></p>
<p>다운로드 버튼 클릭하면 Zip 파일을 받을 수 있는데, 압축 해제하면 안에 css 폴더와 js 폴더가 있다.</p>
<p><img src="https://velog.velcdn.com/images/dev_thk28/post/659b0e1d-23c5-43a8-adea-49fe89e3083e/image.png" alt=""></p>
<p>이 두 폴더를 다음 경로에 넣는다.</p>
<blockquote>
<p>/프로젝트이름/src/main/resources/static</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/dev_thk28/post/3e8c4f79-2f3c-40e9-bc02-77a6f5560e26/image.png" alt=""></p>
<p>그리고 templates 폴더에는 index.html 파일을 만들어줬다.</p>
<p>위에 링크로 다시 돌아가면 Starter template이라는 것이 있는데 이걸 싸악 복사해서 index.html에 붙여넣기 했다. </p>
<p><img src="https://velog.velcdn.com/images/dev_thk28/post/89375236-2a9b-4501-b15c-3b698c0b137e/image.png" alt=""></p>
<p>body 태그 안에 JavaScript 옵션을 선택하라는데 난 잘 모르겠고 그냥 Option 1을 그대로 썼다. 나머지 주석 처리된 것들은 지워버림.</p>
<p><a href="https://getbootstrap.com/docs/5.2/examples/">https://getbootstrap.com/docs/5.2/examples/</a>
위 링크로 들어가면 예제 템플릿들이 있는데, 게시판 모양의 템플릿이 없어서 그냥 밑으로 내리면 있는 NavBar에서 NavBar Static 템플릿을 골랐다. </p>
<p><a href="https://getbootstrap.com/docs/5.2/examples/navbar-static/">https://getbootstrap.com/docs/5.2/examples/navbar-static/</a>
내가 고른 NavBar Static 템플릿의 링크</p>
<p>원하는 템플릿으로 들어가서 마우스 오른쪽 클릭하고 페이지 소스 보기를 하면 템플릿의 소스가 나타난다. 여기서 body 태그 안에 있는 <code>&lt;nav&gt;...&lt;/nav&gt;</code> 부분과 <code>&lt;main&gt;...&lt;/main&gt;</code> 부분만 복사했다. (필요한 알맹이만 가져오기)</p>
<p>CSS가 깨지는 부분이 있다면, 템플릿의 <code>&lt;head&gt;</code> 태그 내부에 해당 템플릿을 위한 css 파일이 link되어 있을텐데 그 링크를 누르면 CSS 파일의 내용이 보인다. 그 파일의 이름 그대로 resources/static/css 폴더에다가 CSS 파일을 만들어 내용을 복사 붙여넣기 하고, 템플릿에 있던 CSS 파일 링크하는 부분을 복사해서 본인 html 파일의 똑같은 위치에 붙여넣기 하면 된다. </p>
<p>이렇게 해서 일단 Bootstap을 적용했다. </p>
<h1 id="thymeleaf-적용">thymeleaf 적용</h1>
<p>build.gradle.kts 파일에 dependencies 블럭에다가 다음과 같이 추가했다. </p>
<pre><code class="language-kotlin">implementation(&quot;org.springframework.boot:spring-boot-starter-thymeleaf&quot;)</code></pre>
<p>gradle 싱크를 맞추고 나서 application.properties 파일에 다음과 같이 설정해주었다. </p>
<p><img src="https://velog.velcdn.com/images/dev_thk28/post/cc3fbce2-5c6e-4d76-9728-4aad2cafcc6a/image.png" alt=""></p>
<pre><code># 참조 경로 설정
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
# thymeleaf 사용 설정 
spring.thymeleaf.enabled=true
# templates 디렉토리에 파일이 없으면 에러 발생 
spring.thymeleaf.check-template-location=true</code></pre><p>다른 설정들도 있었는데 그냥 이렇게만 했다... 이유는 없음</p>
<p>(작성중)</p>
<h1 id="참조">참조</h1>
<ul>
<li><a href="https://thisisprogrammingworld.tistory.com/134">https://thisisprogrammingworld.tistory.com/134</a></li>
<li><a href="https://velog.io/@ddingmun8/SpringBoot-%EB%B6%80%ED%8A%B8%EC%8A%A4%ED%8A%B8%EB%9E%A9-%ED%85%9C%ED%94%8C%EB%A6%BF-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0">https://velog.io/@ddingmun8/SpringBoot-%EB%B6%80%ED%8A%B8%EC%8A%A4%ED%8A%B8%EB%9E%A9-%ED%85%9C%ED%94%8C%EB%A6%BF-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</a></li>
<li><a href="https://www.youtube.com/watch?v=WGIJ4CDUX44&amp;list=PLPtc9qD1979DG675XufGs0-gBeb2mrona&amp;index=5&amp;ab_channel=%EC%BD%94%EB%94%A9%EC%9D%98%EC%8B%A0">https://www.youtube.com/watch?v=WGIJ4CDUX44&amp;list=PLPtc9qD1979DG675XufGs0-gBeb2mrona&amp;index=5&amp;ab_channel=%EC%BD%94%EB%94%A9%EC%9D%98%EC%8B%A0</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin] Flow를 StateFlow로 변환]]></title>
            <link>https://velog.io/@dev_thk28/Kotlin-Flow%EB%A5%BC-StateFlow%EB%A1%9C-%EB%B3%80%ED%99%98</link>
            <guid>https://velog.io/@dev_thk28/Kotlin-Flow%EB%A5%BC-StateFlow%EB%A1%9C-%EB%B3%80%ED%99%98</guid>
            <pubDate>Wed, 08 Jun 2022 13:56:36 GMT</pubDate>
            <description><![CDATA[<p>티스토리로 옮기게 되었습니다!</p>
<p><a href="https://dev-thk.tistory.com/4">🔗[Kotlin] Flow를 StateFlow로 변환</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Git] 새로운 로컬 저장소를 만들고 원격 저장소에 연결하기]]></title>
            <link>https://velog.io/@dev_thk28/Git-%EC%83%88%EB%A1%9C%EC%9A%B4-%EB%A1%9C%EC%BB%AC-%EC%A0%80%EC%9E%A5%EC%86%8C%EB%A5%BC-%EB%A7%8C%EB%93%A4%EA%B3%A0-%EC%9B%90%EA%B2%A9-%EC%A0%80%EC%9E%A5%EC%86%8C%EC%97%90-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_thk28/Git-%EC%83%88%EB%A1%9C%EC%9A%B4-%EB%A1%9C%EC%BB%AC-%EC%A0%80%EC%9E%A5%EC%86%8C%EB%A5%BC-%EB%A7%8C%EB%93%A4%EA%B3%A0-%EC%9B%90%EA%B2%A9-%EC%A0%80%EC%9E%A5%EC%86%8C%EC%97%90-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 29 May 2022 09:34:46 GMT</pubDate>
            <description><![CDATA[<p>할때마다 까먹기 때문에 메모</p>
<hr>
<p>새로운 프로젝트를 만들때에 </p>
<ol>
<li>github에 레포를 만들고, </li>
<li>로컬로 clone한 다음에, </li>
<li>해당 폴더에 새로운 Android 프로젝트를 만들면
늘 이상한 오류가 생겼기 때문에, 앞으로는 새로운 레포와 로컬을 따로 만들고 그 둘을 이어주기로 했다.</li>
</ol>
<h1 id="첫-번째">첫 번째</h1>
<p>github에 새로운 레포지토리를 만든다.
README.md나 .gitignore 등이 추가되면서 Initial commit이 발생한다. </p>
<h1 id="두-번째">두 번째</h1>
<p>로컬에 새로운 Android 프로젝트를 만들고 해당 프로젝트 폴더에서 <code>git init</code>을 해준다.
그리고 로컬에서의 첫번째 커밋을 해준다. Create project같은 제목으로..</p>
<h1 id="세-번째">세 번째</h1>
<p><code>git remote add origin (주소)</code>
원격 저장소의 주소를 remote에 추가한다.</p>
<p>그리고 <code>git pull --rebase</code>를 하면 upstream 설정하라고 뜬다. 하라는대로 한다.
다시 <code>git pull --rebase</code>를 한다. 
충돌이 난다.
충돌 난 상태에서 <code>git add .</code> 한번 해주고, <code>git rebsae --continue</code> 해준다.
별안간 vi 편집기가 뜨면서 커밋 메시지를 작성하라고 한다.
이미 로컬에서 첫번째 커밋할 때 적은 메시지가 적혀있기 때문에 더 작성할 것 없이 <code>:wq</code>로 저장하고 빠져나온다.
원격에 반영되도록 <code>git push</code> 해준다.</p>
<p><code>git log</code>로 확인하면 레포지토리 만들 때의 Initial commit 위에 로컬에서 한 커밋이 잘 얹어져있다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Compose Swipeable ]]></title>
            <link>https://velog.io/@dev_thk28/Android-Compose-Swipeable</link>
            <guid>https://velog.io/@dev_thk28/Android-Compose-Swipeable</guid>
            <pubDate>Wed, 11 May 2022 08:09:43 GMT</pubDate>
            <description><![CDATA[<p>LazyColumn의 아이템을 옆으로 스와이프하면 지워지는 기능을 구현하고 싶었다.
관련 정보를 찾아보다가 Modifier에 <code>swipeable</code>이라는 것이 있길래 이것을 설정하여 스와이프를 구현하기로 했다. </p>
<pre><code class="language-kotlin">@OptIn(ExperimentalMaterialApi::class)
@Composable
fun TodoRow(
    todo: Todo,
    onCheckCompleted: (Todo) -&gt; Unit
) {
    // swipe 상태 저장할 State 변수 
    val swipeableState = androidx.compose.material.rememberSwipeableState(initialValue = 0)
    // 앵커로 사용할 위치
    val point = LocalDensity.current.run { LocalConfiguration.current.screenWidthDp.dp.toPx() }
    // 위치 to 상태
    val anchors = mapOf(0f to 0, -point to 1)

    if (swipeableState.isAnimationRunning) {
        DisposableEffect(Unit) {
            onDispose {
                // -point로 설정한 앵커에 도달하면 currentValue가 1이 됨
                if (swipeableState.currentValue == 1) {
                    // 애니메이션이 끝나고 실행될 코드 
                }
            }
        }
    }

    // Box 안의 Content가 Swipe 됨 
    Box(
        Modifier
            .fillMaxWidth()
            .swipeable(
                state = swipeableState,
                orientation = Orientation.Horizontal,
                anchors = anchors,
                thresholds = { _, _ -&gt; FractionalThreshold(0.8f) },
                velocityThreshold = 1000.dp
            )
    ) {
        // Content 
        Card(
            modifier = Modifier
                .offset { 
                    // swipe했을 때 위치가 움직이도록 offset 설정 
                    IntOffset(swipeableState.offset.value.roundToInt(), 0) 
                  }
                .fillMaxWidth()
                .padding(vertical = 4.dp),
            shape = RoundedCornerShape(16.dp)
        ) {
            // Card 위에 표시될 Composable들...
        }
    }
}</code></pre>
<p>코드 정리가 필요할 것 같지만 일단 되는대로 구현했다.</p>
<p>스와이프 되었으면 하는 컴포저블을 Box로 감싸고, 해당 Box에 Modifier.swipeable을 적용했다.
swipeable의 인자는 차례대로 다음과 같다(이 외에도 더 존재함).</p>
<ul>
<li><code>state</code> - rememberSwipeableState로 얻은 SwipeableState를 넘긴다. </li>
<li><code>orientation</code> - 스와이프 할 방향 지정.</li>
<li><code>anchors</code> - 앵커로 지정할 화면 위치(px값)와 해당 위치에 도달했을 때의 상태를 묶은 Map을 넘긴다.</li>
<li><code>thresholds</code> - 특정한 위치까지 스와이프 하지 않으면 원래 상태로 돌아오는 위치를 지정할 수 있다.</li>
<li><code>velocityThreshold</code> - 지정한 위치까지 스와이프 하지 않아도 특정 빠르기만큼 스와이프하면 스와이프되도록 하는 속도값(dp)을 지정할 수 있다. </li>
</ul>
<p>그리고 <code>swipeable</code>에 사용할 변수들을 상단에 정의했다. 
<code>swipeableState</code>의 <code>initialValue</code>나 <code>anchors</code>에 저장하고 있는 상태에 해당하는 값은 꼭 <code>Int</code>값으로 하지 않고 원하는 타입으로 지정할 수 있다.
본인은 스와이프 했을 때 화면 밖까지 이동해서 사라졌으면 해서 현재 화면의 크기를 음수로 바꾼 값을 앵커로 사용하고, 해당 위치에서 <code>swipeableState</code>의 <code>currentValue</code>가 1이 되도록 저장했다. </p>
<p>변수들 밑의 <code>if</code>문은 애니메이션이 끝났을 때 동작되는 코드이다.. (<a href="https://stackoverflow.com/questions/69101453/how-to-in-compose-implement-swipe-if-i-want-invoke-action-after-swipe">참고한 글</a>)</p>
<p>마지막으로 스와이프 되었으면 하는 컴포저블의 <code>offset</code>을 <code>swipeableState</code>의 <code>offset</code>으로 설정해주면 된다. </p>
<hr>
<p>여담: 얼레벌레 얼추 구현해놓고 <code>SwipeToDismiss</code>라는 컴포저블이 있다는걸 발견했다... 구현한게 아까워서 글로 남김...</p>
<hr>
<p>참조한 글</p>
<ul>
<li><a href="https://developer.android.com/jetpack/compose/gestures?hl=ko#swiping">안드로이드 공식 문서 </a></li>
<li>&#39;android compose swipeable&#39;로 검색하면 나오는 각종 글</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[ViewModel + Composable 화면 업데이트가 안됨 ]]></title>
            <link>https://velog.io/@dev_thk28/ViewModel-Composable-%ED%99%94%EB%A9%B4-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8%EA%B0%80-%EC%95%88%EB%90%A8</link>
            <guid>https://velog.io/@dev_thk28/ViewModel-Composable-%ED%99%94%EB%A9%B4-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8%EA%B0%80-%EC%95%88%EB%90%A8</guid>
            <pubDate>Thu, 28 Apr 2022 13:53:14 GMT</pubDate>
            <description><![CDATA[<pre><code class="language-kotlin">@HiltViewModel
class TodoViewModel @Inject constructor(
    private val getTodoListUseCase: GetTodoListUseCase,
    private val addNewTodoUseCase: AddNewTodoUseCase
) : ViewModel() {

    var todoItems = mutableStateListOf&lt;Todo&gt;()
        private set

    init {
        viewModelScope.launch() {
            todoItems = getTodoListUseCase.invoke().toMutableStateList()
        }
    }

    // 다른 코드들...
}</code></pre>
<p>상황: 
<code>ViewModel</code>이 가지는 <code>mutableStateList</code>를 <code>MainActivity</code>에서 Composable로 넘겨줌. 
Composable은 한번 읽은 State에 대해서 값이 변화했을 때 변화를 감지하여 recompose를 진행한다고 배웠음.
근데 <code>init</code>블럭의 코드를 통해 <code>todoItems</code>의 값이 변화되어도 화면은 업데이트 되지 않았음.</p>
<p>이유:
<code>init</code>블럭의 코드는 <code>todoItems</code>가 참조하고 있는 리스트의 값을 바꾼 것이 아니라 아예 새로운 리스트를 참조하게 만들어버렸기 때문에, composable 입장에서 관찰하고 있는 리스트는 변화가 없으니까 업데이트를 안하는게 당연함.</p>
<p>해결:</p>
<pre><code class="language-kotlin">init {
    viewModelScope.launch {
        todoItems.addAll(getTodoListUseCase.invoke().toMutableStateList())
    }
}</code></pre>
<p>todoItems에 값을 add 해주니까 화면 업데이트가 너무 잘된다. </p>
<p>이런... 허무한 실수.....</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Clean Architecture in Android]]></title>
            <link>https://velog.io/@dev_thk28/Clean-Architecture-in-Android</link>
            <guid>https://velog.io/@dev_thk28/Clean-Architecture-in-Android</guid>
            <pubDate>Sat, 23 Apr 2022 15:04:32 GMT</pubDate>
            <description><![CDATA[<p>개인적인 용도의 정리 글이며, 틀릴 수도 있습니다. </p>
<hr>
<p><img src="https://velog.velcdn.com/images/dev_thk28/post/c4b89eab-19b2-4822-9b46-f3c42a33c3c2/image.png" alt="">
참조글들을 보며 구현하면서 이해한 구조 </p>
<h1 id="domain-레이어">Domain 레이어</h1>
<ul>
<li>UseCase 클래스는 적절한 Repository 구현체를 주입받아 비즈니스 로직을 수행함. </li>
<li>Repository의 구현 클래스는 Data 레이어에 있지만, UseCase는 그저 Repository 인터페이스를 사용하기 때문에 Data 레이어에 대한 의존성이 없음.</li>
<li>Entity는 Domain 레이어 안에서 사용할 데이터 모델.<h1 id="data-레이어">Data 레이어</h1>
</li>
<li>Domain 레이어에 있는 Repository 인터페이스를 구현하는 클래스가 있음. </li>
<li>RepositoryImpl 클래스는 적절한 DataSource를 사용하여(주입받음) 기능을 구현한다. </li>
<li>DataSource는 데이터를 가져올 수 있는 곳의 개수만큼 존재할 수 있음.</li>
<li>DataModel은 Data 레이어 안에서 데이터베이스나 리모트에서 받는 Response에서 사용될 데이터 모델. Domain 레이어의 Entity와 헷갈릴 수 있지만 엄연히 다른 존재임.</li>
<li>Mapper는 DataModel과 Entity를 서로 치환? 시켜주는 존재. Domain 레이어에서 DataModel을 사용할 수 없고, Data 레이어에서 Entity를 그대로 사용할 수 없기 때문에 Mapper가 필요하다. <h1 id="presentation-레이어">Presentation 레이어</h1>
</li>
<li>ViewModel에서 UseCase들을 주입받아 사용한다. UseCase를 통해 데이터를 가져오거나 넘긴다.  </li>
<li>Activity나 Fragment에서는 ViewModel을 주입받아 DataBinding 같은걸 사용해서 화면에 데이터를 보여준다.</li>
</ul>
<h1 id="참조">참조</h1>
<p><a href="https://jungwoon.github.io/android/2021/04/12/Android-CleanArchitecture.html">https://jungwoon.github.io/android/2021/04/12/Android-CleanArchitecture.html</a>
<a href="https://youngest-programming.tistory.com/484">https://youngest-programming.tistory.com/484</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] GlideApp 사용]]></title>
            <link>https://velog.io/@dev_thk28/Android-GlideApp-%EC%82%AC%EC%9A%A9</link>
            <guid>https://velog.io/@dev_thk28/Android-GlideApp-%EC%82%AC%EC%9A%A9</guid>
            <pubDate>Sun, 27 Mar 2022 09:03:58 GMT</pubDate>
            <description><![CDATA[<p>AppGlideModule을 상속받는 클래스 하나 만들고 어노테이션 붙이기</p>
<pre><code class="language-kotlin">@GlideModule
class GlideModule : AppGlideModule()</code></pre>
<p>그다음에 빌드 한번 눌러주면 <code>GlideApp</code> 사용 가능해진다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] buildscript 추가 위치]]></title>
            <link>https://velog.io/@dev_thk28/Android-buildscript-%EC%B6%94%EA%B0%80-%EC%9C%84%EC%B9%98</link>
            <guid>https://velog.io/@dev_thk28/Android-buildscript-%EC%B6%94%EA%B0%80-%EC%9C%84%EC%B9%98</guid>
            <pubDate>Sun, 27 Mar 2022 09:00:11 GMT</pubDate>
            <description><![CDATA[<p>project수준의 build.gradle에다가 buildscript 블록 생성</p>
<pre><code class="language-kotlin">buildscript {
    ext {
        some_version = &#39;1.1.0&#39;
    }

    repositories {
        google()
    }

    dependencies {
        def nav_version = &quot;2.4.1&quot;
        classpath &quot;androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version&quot;
    }
}

plugins {
    // ...
}

task clean(type: Delete) {
    // ...
}</code></pre>
<p>안에다가 classpath랑 ext 써주면됨</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 퍼미션 얻기(kotlin)]]></title>
            <link>https://velog.io/@dev_thk28/Android-%ED%8D%BC%EB%AF%B8%EC%85%98-%EC%96%BB%EA%B8%B0kotlin</link>
            <guid>https://velog.io/@dev_thk28/Android-%ED%8D%BC%EB%AF%B8%EC%85%98-%EC%96%BB%EA%B8%B0kotlin</guid>
            <pubDate>Wed, 16 Mar 2022 08:42:22 GMT</pubDate>
            <description><![CDATA[<h1 id="퍼미션-얻기">퍼미션 얻기</h1>
<h2 id="하나-얻기">하나 얻기</h2>
<p>런처 선언</p>
<pre><code class="language-kotlin">private val permissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { result: Boolean -&gt;
    if (!result) {
        Toast.makeText(this, &quot;거부됨&quot;, Toast.LENGTH_SHORT).show()
    }
}</code></pre>
<p>런처 사용하기</p>
<pre><code class="language-kotlin">permissionLauncher.launch(
    android.Manifest.permission.WRITE_EXTERNAL_STORAGE
)</code></pre>
<h2 id="여러개-얻기">여러개 얻기</h2>
<p>런처 선언</p>
<pre><code class="language-kotlin">private val permissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestMultiplePermissions()
) { result -&gt;
    // 결과 중 하나라도 false면 토스트 표시하기 
    if (result.any { permission -&gt; !permission.value }) {
        Toast.makeText(this, &quot;거부됨&quot;, Toast.LENGTH_SHORT).show()
    }
}</code></pre>
<p>런처 사용하기</p>
<pre><code class="language-kotlin">permissionLauncher.launch(
    arrayOf(
        android.Manifest.permission.READ_EXTERNAL_STORAGE,
        android.Manifest.permission.WRITE_EXTERNAL_STORAGE
    )
)</code></pre>
<p>가끔 쓸때마다 매번 까먹어서 정리 </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Palette로 이미지에서 평균색 알아내기 (Java)]]></title>
            <link>https://velog.io/@dev_thk28/Android-Palette%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80%EC%97%90%EC%84%9C-%ED%8F%89%EA%B7%A0%EC%83%89-%EC%95%8C%EC%95%84%EB%82%B4%EA%B8%B0</link>
            <guid>https://velog.io/@dev_thk28/Android-Palette%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80%EC%97%90%EC%84%9C-%ED%8F%89%EA%B7%A0%EC%83%89-%EC%95%8C%EC%95%84%EB%82%B4%EA%B8%B0</guid>
            <pubDate>Sun, 10 Jan 2021 08:24:45 GMT</pubDate>
            <description><![CDATA[<p>티스토리로 옮기게 되었습니다!</p>
<p><a href="https://dev-thk.tistory.com/3">🔗[Android] Palette로 이미지에서 평균색 알아내기 (Java)</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Retrofit2 Multipart사용하기 (Java)]]></title>
            <link>https://velog.io/@dev_thk28/Android-Retrofit2-Multipart%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-Java</link>
            <guid>https://velog.io/@dev_thk28/Android-Retrofit2-Multipart%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-Java</guid>
            <pubDate>Sat, 09 Jan 2021 15:16:51 GMT</pubDate>
            <description><![CDATA[<p>티스토리로 옮기게 되었습니다!</p>
<p><a href="https://dev-thk.tistory.com/2">🔗[Android] Retrofit2 Multipart사용하기 (Java)</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] RecyclerView와 ListAdapter (Java)]]></title>
            <link>https://velog.io/@dev_thk28/Android-RecyclerView%EC%99%80-ListAdapter-Java</link>
            <guid>https://velog.io/@dev_thk28/Android-RecyclerView%EC%99%80-ListAdapter-Java</guid>
            <pubDate>Sat, 09 Jan 2021 13:05:06 GMT</pubDate>
            <description><![CDATA[<p>티스토리로 옮기게 되었습니다!</p>
<p><a href="https://dev-thk.tistory.com/1">🔗[Android] RecyclerView와 ListAdapter (Java)</a></p>
]]></description>
        </item>
    </channel>
</rss>