<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>cocoa.log</title>
        <link>https://velog.io/</link>
        <description>안드로이드 개발자를 꿈꾸는 학생입니다</description>
        <lastBuildDate>Wed, 21 Jan 2026 22:50:15 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>cocoa.log</title>
            <url>https://velog.velcdn.com/images/nahy-512/profile/a13394a3-031a-4a19-b634-3db867fc1ada/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. cocoa.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/nahy-512" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Jetpack Compose] Route 분기 전략 - 하나의 Screen 파일로 여러 화면 처리하기]]></title>
            <link>https://velog.io/@nahy-512/Jetpack-Compose-Route-%EB%B6%84%EA%B8%B0-%EC%A0%84%EB%9E%B5-%ED%95%98%EB%82%98%EC%9D%98-Screen-%ED%8C%8C%EC%9D%BC%EB%A1%9C-%EC%97%AC%EB%9F%AC-%ED%99%94%EB%A9%B4-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@nahy-512/Jetpack-Compose-Route-%EB%B6%84%EA%B8%B0-%EC%A0%84%EB%9E%B5-%ED%95%98%EB%82%98%EC%9D%98-Screen-%ED%8C%8C%EC%9D%BC%EB%A1%9C-%EC%97%AC%EB%9F%AC-%ED%99%94%EB%A9%B4-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 21 Jan 2026 22:50:15 GMT</pubDate>
            <description><![CDATA[<h1 id="✍🏻-요구사항-분석">✍🏻 요구사항 분석</h1>
<p>공통 컴포넌트 작업, 화면 퉁치기를 좋아하는 사람으로서 이번에 맡은 화면들에서 재밌는 포인트들을 찾아 가지고 왔습니다ㅎㅎ</p>
<p>Compose Navigation에서 UI가 동일하지만, 데이터 소스나 구성 요소가 조금 다른 화면을 하나의 Screen으로 처리한 경험을 적어보려 합니다.</p>
<p>최근 프로젝트를 진행하며 &#39;이 화면을 어떻게 퉁치지?&#39;를 고민했던 화면이 2개 있어서, 오늘은 요구사항 분석이 두 차례에 걸쳐 이루어질 예정입니다.</p>
<hr>
<h2 id="1️⃣-profilescreen">1️⃣ ProfileScreen</h2>
<blockquote>
<p>💡Nullable 파라미터를 활용한 내 프로필/타인 프로필 분기</p>
</blockquote>
<h3 id="문제-상황">문제 상황</h3>
<blockquote>
<p>🧐 기본적으로 내 프로필과 타인 프로필 화면의 UI가 동일함 (들어가는 내용들)</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/3ffea9e6-bab0-423e-9f38-6f54de861ca5/image.png" alt=""></p>
<table>
<thead>
<tr>
<th align="left">공통점</th>
<th align="left">차이점</th>
</tr>
</thead>
<tbody><tr>
<td align="left">기본적으로 내 프로필과 타인 프로필 화면의 UI가 동일함 (들어가는 내용들)</td>
<td align="left">1. 내 프로필(My)에만 BottomBar 표시 </br> 2. 타인 프로필(Other)에만 뒤로가기 버튼 표시</br>3. My/Other에 따른 다른 API 호출 (userId 처리 방식)</td>
</tr>
</tbody></table>
<h3 id="해결-전략">해결 전략</h3>
<ol>
<li><p>동일한 ProfileScreen 사용</p>
</li>
<li><p><code>userId: String? = null</code>로 정의하여 <strong>null이면 내 프로필, 값이 있으면 타인 프로필</strong>로 구분</p>
</li>
<li><p>API 호출: userId를 받아 Repository에서 처리</p>
<p> <img src="https://velog.velcdn.com/images/nahy-512/post/e0f6affa-af23-49aa-bec6-69c40675b228/image.png" alt=""></p>
</li>
</ol>
<pre><code>*~~흑흑.. 같은 API로 퉁치고 싶었는데~~*

** 사용자 프로필 조회 시 userId를 Path로 넘기는 방식에서 착안</code></pre><h4 id="route-정의">Route 정의</h4>
<ol>
<li>My</li>
</ol>
<pre><code class="language-kotlin">@Serializable
data object Profile : MainTabRoute // 바텀바 표시 필요</code></pre>
<ol>
<li>Other</li>
</ol>
<pre><code class="language-kotlin">@Serializable
data class Profile(
    val userId: String? = null,  // null = 내 프로필
) : Route</code></pre>
<h4 id="navigation-함수">Navigation 함수</h4>
<pre><code class="language-kotlin">// ProfileNavigation.kt

// 내 프로필 (MainTab에서 사용)
fun NavController.navigateToMyProfile(navOptions: NavOptions? = null) {
    navigate(MainTabRoute.Profile, navOptions)
}

// 타인 프로필 (userId 전달)
fun NavController.navigateToOtherProfile(userId: String?, navOptions: NavOptions? = null) {
    navigate(Route.Profile(userId = userId), navOptions)
}
</code></pre>
<h4 id="navgraph-분리">NavGraph 분리</h4>
<pre><code class="language-kotlin">// 내 프로필용 NavGraph (MainTab)
fun NavGraphBuilder.myProfileNavGraph(...) {
    composable&lt;MainTabRoute.Profile&gt; {
        ProfileRoute(navigateUp = {}, ...)  // 뒤로가기 불필요
    }
}

// 타인 프로필용 NavGraph
fun NavGraphBuilder.otherProfileNavGraph(navigateUp: () -&gt; Unit, ...) {
    composable&lt;Route.Profile&gt; {
        ProfileRoute(navigateUp = navigateUp, ...)
    }
}
</code></pre>
<p>** ProfileRoute는 동일하게 사용</p>
<h4 id="viewmodel에서-userid-추출">ViewModel에서 userId 추출</h4>
<pre><code class="language-kotlin">// ProfileViewModel.kt
@HiltViewModel
class ProfileViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    ...
) : ViewModel() {
    val userId = savedStateHandle.toRoute&lt;Route.Profile&gt;().userId // 타인 프로필에서 넘겨받은 userId

    private val _uiState = MutableStateFlow(ProfileUiState(userId = userId))
}
</code></pre>
<h4 id="repository의-api-분기">Repository의 API 분기</h4>
<pre><code class="language-kotlin">// UserRepository.kt
class UserRepository @Inject constructor(
    private val api: UserApi,
    private val preferencesManager: PreferencesManager,
) {
        // 로그인 후 저장소에서 저장된 userId
    private suspend fun myUserId(): String {
        return preferencesManager.getString(USER_ID).first()
    }

    // ... 
}</code></pre>
<pre><code class="language-kotlin">// 같은 API를 쓰는 경우
suspend fun getUserProfile(userId: String?): Result&lt;UserProfileResponseModel&gt; =
    suspendRunCatching {
        apiService.getUserProfile(userId ?: myUserId()).data.toModel()
}

// 다른 API를 쓰는 경우 (일부 API는 내 것과 타인 것의 엔드포인트가 다름)
suspend fun getUserCreatedCollections(userId: String?): Result&lt;CollectionListModel&gt; =
    suspendRunCatching {
        if (userId == null) { // My
            apiService.getMyCreatedCollections().data.toModel()  // 내 컬렉션
        } else { // Other
            apiService.getUserCreatedCollections(userId).data.toModel()  // 타인 컬렉션
        }
    }
</code></pre>
<p>내 프로필/타인 프로필 모두 서버로부터 동일한 형태의 Dto를 받아오고, Model도 같은 것을 쓴다.</p>
<h4 id="screen의-조건부-ui">Screen의 조건부 UI</h4>
<pre><code class="language-kotlin">// ProfileScreen.kt
@Composable
private fun ProfileScreen(uiState: ProfileUiState, ...) {
    Box(...) {
        // 타인 프로필일 때만 뒤로가기 버튼 표시
        if (uiState.userId != null) {
            FlintBackTopAppbar(onClick = onBackClick)
        }

        ~~// 내 프로필에서만 이스터에그 기능 활성화 (궁금하면 데모데이 때 클릭해보세요ㅎㅎ)
        ProfileTopSection(
            onEasterEggWithdraw = {
                if (uiState.userId == null) onEasterEggWithdraw()
            }
        )~~
    }
}
</code></pre>
<h3 id="데이터-흐름-요약">데이터 흐름 요약</h3>
<pre><code>┌─────────────────────────────────────────────────────────────┐
│ [내 프로필]                                                   │
│ MainTabRoute.Profile → ProfileRoute(navigateUp={})          │
│   → ViewModel(userId=null)                                   │
│   → Repository: apiService.getMyCreatedCollections()        │
│   → Screen: 뒤로가기 버튼 숨김                                 │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ [타인 프로필]                                                 │
│ Route.Profile(userId=&quot;123&quot;) → ProfileRoute(navigateUp=...)  │
│   → ViewModel(userId=&quot;123&quot;)                                  │
│   → Repository: apiService.getUserCreatedCollections(&quot;123&quot;) │
│   → Screen: 뒤로가기 버튼 표시                                 │
└─────────────────────────────────────────────────────────────┘
</code></pre><hr>
<h2 id="2️⃣-collectionlistscreen">2️⃣ CollectionListScreen</h2>
<blockquote>
<p>💡 Enum 타입을 활용한 다중 분기</p>
</blockquote>
<h3 id="문제-상황-1">문제 상황</h3>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/9af30051-8d6f-4e49-ada5-b43d7e7ce728/image.png" alt=""></p>
<ul>
<li>&quot;생성한 컬렉션&quot;, &quot;저장한 컬렉션&quot;, &quot;최근 컬렉션&quot; 3가지 화면이 필요</li>
<li>UI 구조는 동일하나 <strong>AppBar 제목</strong>과 <strong>호출 API</strong>가 다름</li>
</ul>
<h3 id="해결-전략-1">해결 전략</h3>
<ol>
<li><code>CollectionListRouteType</code> enum으로 화면 타입을 명시적으로 구분</li>
<li>동일한 Screen, UiState 활용</li>
<li>TopAppBar 타이틀 및 호출 API 변경</li>
</ol>
<h4 id="enum-타입-정의">Enum 타입 정의</h4>
<pre><code class="language-kotlin">// CollectionListRouteType.kt
enum class CollectionListRouteType(val title: String) {
    CREATED(title = &quot;전체 컬렉션&quot;),
    SAVED(title = &quot;저장 컬렉션&quot;),
    RECENT(title = &quot;눈여겨보고 있는 컬렉션&quot;),
}
</code></pre>
<h4 id="route-정의-1">Route 정의</h4>
<pre><code class="language-kotlin">// Route.kt
@Serializable
data class CollectionList(
    val routeType: CollectionListRouteType,
    val userId: String? = null  // Profile과 조합하여 사용 (내 프로필이라면 userId = null)
) : Route</code></pre>
<h4 id="uistate">UiState</h4>
<pre><code class="language-kotlin">data class CollectionListUiState(
    val appbarTitle: String = &quot;&quot;,
    val collectionList: UiState&lt;CollectionListModel&gt; = UiState.Loading
)</code></pre>
<h4 id="navigation-함수-1">Navigation 함수</h4>
<pre><code class="language-kotlin">// CollectionListNavigation.kt
fun NavController.navigateToCollectionList(
    routeType: CollectionListRouteType,
    userId: String?,
    navOptions: NavOptions? = null
) {
    navigate(
        Route.CollectionList(userId = userId, routeType = routeType),
        navOptions,
    )
}
</code></pre>
<h4 id="viewmodel에서-분기-처리">ViewModel에서 분기 처리</h4>
<pre><code class="language-kotlin">// CollectionListViewModel.kt
@HiltViewModel
class CollectionListViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val userRepository: UserRepository,
    private val collectionRepository: CollectionRepository,
    ...
) : ViewModel() {

    init {
        val routeReceiveData = savedStateHandle.toRoute&lt;Route.CollectionList&gt;()
        setAppBarTitle(routeReceiveData.routeType.title)  // enum의 title 사용
        getCollectionList(routeReceiveData)
    }

    private fun getCollectionList(data: Route.CollectionList) {
        viewModelScope.launch {
            // routeType에 따라 다른 API 호출
            when (data.routeType) {
                CollectionListRouteType.CREATED -&gt;
                    userRepository.getUserCreatedCollections(userId = data.userId)
                CollectionListRouteType.SAVED -&gt;
                    userRepository.getUserBookmarkedCollections(userId = data.userId)
                CollectionListRouteType.RECENT -&gt;
                    collectionRepository.getRecentCollectionList()  // userId 불필요
            }.onSuccess { ... }.onFailure { ... }
        }
    }
}
</code></pre>
<p>** Repository 데이터 호출은 앞선 Profile과 동일</p>
<h4 id="screen에서의-활용">Screen에서의 활용</h4>
<p>Screen은 분기 로직을 알 필요 없이, 전달받은 데이터만 표시</p>
<pre><code class="language-kotlin">// CollectionListScreen.kt
@Composable
fun CollectionListRoute(viewModel: CollectionListViewModel = hiltViewModel(), ...) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    CollectionListScreen(
        title = uiState.appbarTitle,  // ViewModel에서 결정된 제목
        collectionList = uiState.collectionList,
        ...
    )
}
</code></pre>
<h4 id="호출-예시-profilescreen에서">호출 예시 (ProfileScreen에서)</h4>
<pre><code class="language-kotlin">// ProfileScreen.kt
ProfileRoute(
    navigateToCollectionList = { routeType, userId -&gt;
        navController.navigateToCollectionList(routeType, userId)
    }
)

// Route 내부
onCreatedCollectionMoreClick = {
    navigateToCollectionList(CollectionListRouteType.CREATED, uiState.userId)
}
onSavedCollectionMoreClick = {
    navigateToCollectionList(CollectionListRouteType.SAVED, uiState.userId)
}
</code></pre>
<h3 id="데이터-흐름-요약-1">데이터 흐름 요약</h3>
<pre><code>┌──────────────────────────────────────────────────────────────────┐
│ [생성한 컬렉션]                                                    │
│ Route.CollectionList(CREATED, userId=&quot;123&quot;)                      │
│   → ViewModel: title=&quot;전체 컬렉션&quot;                                 │
│   → API: userRepository.getUserCreatedCollections(&quot;123&quot;)         │
└──────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────┐
│ [저장한 컬렉션]                                                    │
│ Route.CollectionList(SAVED, userId=&quot;123&quot;)                        │
│   → ViewModel: title=&quot;저장 컬렉션&quot;                                 │
│   → API: userRepository.getUserBookmarkedCollections(&quot;123&quot;)      │
└──────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────┐
│ [최근 컬렉션 - 홈에서 진입]                                         │
│ Route.CollectionList(RECENT, userId=null)                        │
│   → ViewModel: title=&quot;눈여겨보고 있는 컬렉션&quot;                        │
│   → API: collectionRepository.getRecentCollectionList()          │
└──────────────────────────────────────────────────────────────────┘
</code></pre><hr>
<h2 id="3️⃣-두-패턴의-비교">3️⃣ 두 패턴의 비교</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>ProfileScreen</th>
<th>CollectionListScreen</th>
</tr>
</thead>
<tbody><tr>
<td><strong>분기 기준</strong></td>
<td><code>userId: String?</code> (nullable)</td>
<td><code>CollectionListRouteType</code> (enum)</td>
</tr>
<tr>
<td><strong>분기 개수</strong></td>
<td>2가지 (My/Other)</td>
<td>3가지 (CREATED/SAVED/RECENT)</td>
</tr>
<tr>
<td><strong>분기 위치</strong></td>
<td>Repository + Screen</td>
<td>ViewModel</td>
</tr>
<tr>
<td><strong>UI 분기</strong></td>
<td>조건부 컴포넌트 표시</td>
<td>없음 (title만 다름)</td>
</tr>
<tr>
<td><strong>API 분기</strong></td>
<td>Repository에서 처리 (userId 기반)</td>
<td>ViewModel의 when문</td>
</tr>
<tr>
<td><strong>확장성</strong></td>
<td>단순 (있다/없다)</td>
<td>타입 추가로 확장 용이</td>
</tr>
</tbody></table>
<h3 id="선택-기준">선택 기준</h3>
<ul>
<li><strong>Nullable 파라미터</strong>: 이진 분기(내 것/남의 것)이고 UI 차이가 미미할 때</li>
<li><strong>Enum 타입</strong>: 3개 이상의 분기가 필요하고, 각 타입별 동작이 명확히 다를 때</li>
</ul>
<h3 id="공통-패턴">공통 패턴</h3>
<ol>
<li><code>SavedStateHandle.toRoute&lt;T&gt;()</code>로 Navigation Arguments 추출</li>
<li>Screen은 분기 로직을 모름 (ViewModel/Repository에서 처리)</li>
<li>Route 정의 시 <code>@Serializable</code> 필수 (Type-safe Navigation)</li>
</ol>
<hr>
<h1 id="📱-실행-영상">📱 실행 영상</h1>
<table>
<thead>
<tr>
<th align="center">Profile</th>
<th align="center">CollectionList</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><img width=375 src="https://cdn.discordapp.com/attachments/1326772343632298015/1471148140710072351/Route-ProfileScreen.mp4?ex=698de0f9&is=698c8f79&hm=ca67b02cf2ca2c7e450663d92346c6e34e934b087c555dc42aa919f8aaacc722&"></td>
<td align="center"><img width=375 src="https://velog.velcdn.com/images/nahy-512/post/7d338b8f-985f-4357-b7f8-516601a07e2d/image.mp4"></td>
</tr>
</tbody></table>
<p><img src="blob:https://velog.io/6f996d9c-9990-42ee-91ec-a6d50a6e011f" alt="업로드중.."></p>
<h1 id="🔥-마치며">🔥 마치며</h1>
<p>어디까지를 공통으로 볼 것인가? 앞으로 얼마나 달라질 것인가?의 관점에서 화면을 최대한 나누는 게 좋을 수도 있을 겁니다.</p>
<p>그러나!!!!!!!!!!</p>
<p>저희는 5주동안 진행되는 앱잼이라는 특수한 상황상, 기존 것을 최대한 재활용하기 위해 일부러 디자인/서버 분들과 많은 논의를 했습니다.</p>
<p>앞으로 <code>Profile</code>과 <code>CollectionList</code>가 서로 UI가 달라질 수 있겠지만, 현재 단계에서는 충분히 통합해서 사용할만하다고 생각했고(<del>뷰모델이랑 스크린 또 만들기 넘 귀찮잖아요</del>) 그래서 분기랑 네비게이션 관리를 열심히 해봤습니다.</p>
<p>어떻게 보면 꼼수를 사용한 건데, 지금 단계에서는 그대로 두고 나중에 필요성을 느낀다면 분리시킬 것 같습니다.
유지보수성에 대해서는 대답하기 힘들지 몰라도, 같은 Screen, ViewModel을 사용하며 Route를 다르게 가져가보는 것이 재밌는 경험이었습니다😄</p>
<p>무엇보다 이번에 작업하면서 클로드 코드도 써보고, AI를 적극적으로 활용했음에도 이번 작업에는 AI의 도움 없이 스스로 생각하고 작업해보았고, 실제로 생각했던 대로 구현이 잘 되어 너무 뿌듯했던 기억이 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Jetpack Compose] buildAnnotatedString으로 텍스트 하이라이팅 컴포넌트 개발]]></title>
            <link>https://velog.io/@nahy-512/jetpack-compose-text-highligting</link>
            <guid>https://velog.io/@nahy-512/jetpack-compose-text-highligting</guid>
            <pubDate>Mon, 21 Jul 2025 14:44:50 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요, 오늘은 동아리 프로젝트에서 <code>buildAnnotatedString</code>으로 &#39;텍스트 하이라이팅&#39; 기능을 개발했던 과정을 적어보려고 합니다!
단순 하이라이팅 코드 뿐만 아니라 컴포넌트를 구현하면서 어떤 점들을 고민했는지 같이 적어볼게요.</p>
<h1 id="✍🏻-요구사항-분석">✍🏻 요구사항 분석</h1>
<blockquote>
<p><strong>기획 &amp; 디자인</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/8525fcaa-9fa5-4f84-9bf4-5111df06b518/image.png" alt=""></p>
<p>앱에서 구현해야하는 기능 중 <strong>텍스트 하이라이팅 기능</strong>이 있었습니다.
내가 일기를 쓰면 AI가 해당 일기를 보고 피드백을 해주는데요, <strong>‘AI가 고쳐준 문장은 하이라이팅 표시를 해줘야한다!’</strong>가 요구사항이었습니다.</p>
<p>디자인을 보면 피드백 된 문장은 <strong>주황색 + 살짝의 볼드</strong>가 들어간 걸 확인할 수 있었습니다.</p>
<p>기획과 디자인은 살펴봤으니,, 이제 어떤 형태로 응답이 올지 API 명세서를 봐야겠죠? </p>
<blockquote>
<p><strong>서버 응답</strong></p>
</blockquote>
<p>아직 API가 배포되기 전이었지만, 개발 파트 리드(안드-아요-서버)끼리 이미 기능 구현 관련해서 여러 번 회의를 진행한 상태였고, 이를 바탕으로 서버 측에서는 API 명세서를 미리 만들어 주셨습니다. (서쌤들 최고!!)</p>
<p>텍스트 하이라이팅이 필요한 API의 응답값은 아래와 같았습니다.</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/e4bda9b6-c3df-4c56-9de0-11ce84887a97/image.png" alt=""></p>
<p>서버 및 iOS 측과 논의한 결과, 서버에서는 하이라이팅이 필요한 부분은 <code>diffRanges</code>로,
rewriteText(AI가 피드백해준 문장)에서 고쳐진 부분의 <strong>start-end index</strong>를 넘겨주기로 했습니다.</p>
<p>이에 하이라이팅의 관건은 <strong>‘index 범위를 통해 텍스트에서 스타일을 바꿔주는 것’</strong>이라고 생각했습니다.</p>
<p>안드 리드님이 컨펌하신 구조이니 ‘index를 통해 텍스트를 하이라이팅할 방법은 무조건 있을 것이다’라는 믿음으로 저는 컴포넌트부터 구현해보기로 했습니다.</p>
<p>저는 우선 아래와 같은 과정으로 구현 계획을 세웠어요.</p>
<p><em><del>아래 과정을 보시면 아시겠지만 어려워보이는 건 최대한 미뤄두는 편..</del></em></p>
<blockquote>
<ol>
<li>기본 컴포넌트 구현 (이미지 및 텍스트, 글자수)</li>
<li>스위치의 토글값(isAIWritten)에 따른 분기 처리
 a. <code>maxLength</code> 변경
 b. <code>isAIWritten</code>에 따른 찐 하이라이팅 적용 코드 추가</li>
</ol>
</blockquote>
<p>자, 이제 분석이 끝났으니 이제 진짜 구현을 하러 가볼까요??</p>
<h1 id="💻-구현-과정">💻 구현 과정</h1>
<h2 id="1-기본-컴포넌트-구현">1. 기본 컴포넌트 구현</h2>
<p>originat/rewrittenText에 따라 달라질 게 없는, 기본 요구사항을 먼저 반영했어요.</p>
<blockquote>
<p><strong>[⚙️ 공통 요구사항]</strong></p>
</blockquote>
<ol>
<li><strong>이미지 유무</strong>
 a. 유저가 일기에 이미지를 첨부했을 경우<pre><code> - 원본 사진 비율과 관계없이 가로 100% 기준 세로, 60%로 미리보기 표시(중앙 정렬 crop)
 - 이미지 클릭 시 원본 이미지를 볼 수 있음</code></pre> b. 유저가 일기에 이미지를 첨부하지 않은 경우<pre><code> - 이미지를 제외한 내용 교정만 제공</code></pre></li>
<li><strong>일기 내용 텍스트 글자수 표시</strong><ul>
<li>내가 쓴 일기의 텍스트 필드 최대 글자수는 1000자</li>
</ul>
</li>
</ol>
<p>우선은 ‘내가 쓴 일기’ 기준으로 코드를 작성해보았습니다!</p>
<pre><code class="language-kotlin">@Composable
fun DiaryCard(
    content: String,
    modifier: Modifier = Modifier,
    imageUrl: String? = null
) {
    Column(
        modifier = modifier
            .clip(RoundedCornerShape(8.dp))
            .background(HilingualTheme.colors.white)
            .fillMaxWidth()
            .padding(12.dp)
    ) {
        if (imageUrl != null) { // 이미지
            NetworkImage(
                imageUrl = imageUrl,
                shape = RoundedCornerShape(8.dp),
                contentScale = ContentScale.Crop,
                modifier = Modifier
                    .fillMaxWidth()
                    .aspectRatio(1f / 0.6f)
            )
        }
        Text( // 일기 내용
            text = content,
            style = HilingualTheme.typography.bodyR16,
            color = HilingualTheme.colors.black,
            modifier = Modifier.fillMaxWidth()
        )
        Text( // 글자수
            text = &quot;${content.length}/1000&quot;,
            style = HilingualTheme.typography.captionR12,
            color = HilingualTheme.colors.gray400,
            textAlign = TextAlign.End,
            modifier = Modifier.fillMaxWidth()
        )
    }
}</code></pre>
<p>이미지 유무에 따라 아래처럼 나오는 모습을 확인할 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/5d6a6d53-759d-4962-bf49-650355671a4c/image.png" alt=""></p>
<ul>
<li><p><em>Preview 코드</em></p>
<pre><code class="language-kotlin">  @Preview(showBackground = true, backgroundColor = 0x000000)
  @Composable
  private fun FeedbackContentPreview() {
      HilingualTheme {
          Column(
              verticalArrangement = Arrangement.spacedBy(4.dp)
          ) {
              DiaryContentCard(
                  imageUrl = &quot;&quot;,
                  content = &quot;텍스트&quot;,
              )
              DiaryContentCard(
                  content = &quot;I want to become a teacher future. Because I like child.&quot;
              )
          }
      }
  }</code></pre>
</li>
</ul>
<h2 id="2-스위치의-토글값isaiwritten에-따른-분기-처리">2. 스위치의 토글값(isAIWritten)에 따른 분기 처리</h2>
<p>토글 여부에 따라 바꿔주어야 할 값은 아래의 두 가지 부분이었습니다.</p>
<blockquote>
<p>💡 토글에 따른 변경</p>
</blockquote>
<ol>
<li>maxLength 변경 (AI: 1500자, MY: 1000자)</li>
<li>diffRanges에 해당하는 텍스트 하이라이팅</li>
</ol>
<h3 id="a-maxlength-변경">a. maxLength 변경</h3>
<p>텍스트 하이라이팅은 조금 더 미뤄두고, maxLength부터 적용을 해보기로 했습니다.</p>
<blockquote>
<p>⚙️ <strong>[추가한 요구사항] - 텍스트 필드 글자수 표시</strong></p>
</blockquote>
<ol>
<li><code>토글 active</code>: AI 수정 피드백<ul>
<li>AI가 수정한 일기의 텍스트 필드 최대 글자수는 1500자</li>
<li>1500자 이상 생성 시 1500자 초과된 글자는 표시하지 않음</li>
</ul>
</li>
<li><code>토글 inactive</code>: 내가 쓴 일기<ul>
<li>내가 쓴 일기의 텍스트 필드 최대 글자수는 1000자</aside>

</li>
</ul>
</li>
</ol>
<p>이를 위해 우선은 DiaryCard에 <code>isAIWritten</code> 값을 추가해 보았습니다.</p>
<pre><code class="language-kotlin">@Composable
fun DiaryCard(
    content: String,
    isAIWritten: Boolean,
    modifier: Modifier = Modifier,
    imageUrl: String? = null
) {
    val maxContentLength = if (isAIWritten) 1500 else 1000

        val clipContent = diaryContent.run {
        if (length &gt; maxContentLength) this.take(maxContentLength) else this
    }

    Column(
        modifier = modifier
            .clip(RoundedCornerShape(8.dp))
            .background(HilingualTheme.colors.white)
            .fillMaxWidth()
            .padding(12.dp)
    ) {
        if (imageUrl != null) {
            NetworkImage(
                imageUrl = imageUrl,
                shape = RoundedCornerShape(8.dp),
                modifier = Modifier
                    .fillMaxWidth()
                    .aspectRatio(1f / 0.6f)
            )
        }
        Text(
            text = clipContent,
            style = HilingualTheme.typography.bodyR16,
            color = HilingualTheme.colors.black,
            modifier = Modifier
                .heightIn(min = 45.dp)
                .fillMaxWidth()
        )
        Text(
            text = &quot;${clipContent.length}/${maxContentLength}&quot;,
            style = HilingualTheme.typography.captionR12,
            color = HilingualTheme.colors.gray400,
            textAlign = TextAlign.End,
            modifier = Modifier.fillMaxWidth()
        )
    }
}</code></pre>
<p><code>isAIWritten</code>에 따른 일기 내용의 최대 글자수인 <code>maxContentLength</code>(AI: 1500자, MY: 1000자)를 선언해주고, ‘1500자 이상 생성 시 1500자 초과된 글자는 표시하지 않음’ 이라는 요구사항에 맞게 텍스트 표시 시에 <code>maxContentLength</code>를 넘어가는 텍스트는 <code>take</code>를 통해 잘라주게끔 구현했습니다.</p>
<p>AI 피드백에 대한 처리는 서버에서 진행했어요.
즉,  프론트에서는 서버에서 넘겨준 텍스트를 그대로 받아서 사용해야 했습니다.</p>
<p>그래서 저는 서버 측에서도 DB 저장 시에 AI 피드백 일기를 1500자로 잘라서 저장할지가 궁금했고, 바로 문의를 해봤었는데요, 
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/36ba14db-9d29-4a47-97c7-c86a72a5f008/image.png"></p>
<p>서버에서도 1500자를 잘라서 저장한다는 답변이 돌아왔습니다!</p>
<p>그래도 혹시 모르니.. <code>clipContent</code>를 통해 안드에서도 1000자/15000자를 한 번 더 잘라서 표시하게끔 했습니다.</p>
<p>.</p>
<p>.</p>
<p>.</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/17b7bb31-0699-4ede-a21a-90d06e0ecf8c/image.png" alt=""></p>
<p>이제 진짜………………………</p>
<p>하이라이팅을…….적용할………………..시간입니다.</p>
<p>과연,,,, 순탄하게 구현할 수 있을 것인가?</p>
<p>두려운 마음으로…. 일단은 시작~!</p>
<h3 id="b-텍스트-하이라이팅-적용">b. 텍스트 하이라이팅 적용</h3>
<blockquote>
<p>🤚🏻  잠깐!!</p>
</blockquote>
<p>아까의 요구사항을 잠시 복기하면서 어떻게 구현할지 잠시 생각을 해봅시다.</p>
<ol>
<li><p>서버에서는 하이라이팅이 필요한 문장의 start와 endIndex(+ 고쳐진 부분)을 리스트로 내려준다.</p>
<p> → forEach로 <code>diffRanges</code>를 돌리면 변화된 부분을 하이라이팅 시킬 수 있지 않을까?</p>
</li>
<li><p>하이라이팅 문장은 mainColor로 표시해줘야 한다.</p>
<p> → 솝커톤에서 텍스트 더보기 기능 구현한다고 사용해봤던 <code>buildAnnotatedString</code>을 활용할 수 있으려나?</p>
</li>
</ol>
</aside>

<p>위처럼 대략적으로 구조를 생각해보고,
바로 제가 생각한 방식으로 구현이 될지 검색을 해봤습니다.</p>
<p>Let’s go 구글랑<del>!</del>!!</p>
<blockquote>
<p>🔎 ‘compose text highlighting’</p>
</blockquote>
</aside>

<p>이라는 이름으로 검색을 해줄게요.</p>
<p>저는
<a href="https://stackoverflow.com/questions/66932188/how-to-highlight-specific-word-of-the-text-in-jetpack-compose">How to highlight specific word of the text in jetpack compose?</a>
이 스택오버플로 글이 제일 처음 나왔는데요.</p>
<p>달린 답변 중에 아래 코드를 발견했고,</p>
<pre><code class="language-kotlin">val annotatedString = buildAnnotatedString {
    val str = &quot;Hello World&quot; // or stringResource(id = R.string.hello_world)
    val boldStr = &quot;Hello&quot; // or stringResource(id = R.string.hello)
    val startIndex = str.indexOf(boldStr)
    val endIndex = startIndex + boldStr.length
    append(str)
    addStyle(style = SpanStyle(color = Color.Red), start = startIndex, end = endIndex)
}
Text(
    text = annotatedString,
)</code></pre>
<p>startIndex, endIndex가 있는 걸 보고 ‘이거구나!!’ 싶었습니다. (역시 갓택오버플로우~!)</p>
<p><code>buildAnnotatedString</code>에서 원본 텍스트를 <code>append</code>하고, 하이라이팅 시킬 부분만 <code>addStyle</code>안에 작성해주면 금방 구현할 수 있을 것 같았어요. <em><del>(그럼 forEach 바로 돌려도 될 거 같은데?? 야호 ٩( ᐛ )و)</del></em></p>
<p>위에서는 볼드 처리를 시켜줄 텍스트를 따로 String으로 받고 있었지만, 실질적으로 addStyle에 들어가야 할 값은 startIndex, endIndex일 뿐이니까 아래의 diffRanges 서버 응답 중 correctedText는 안 써도 되겠다 싶었습니다. (서버에서도 그냥 확인 편하게 하려는 용도로 주는 값이라고 했었어요.)</p>
<pre><code class="language-kotlin">&quot;diffRanges&quot;: [
      {
        &quot;start&quot;: 79,
        &quot;end&quot;: 140,
        &quot;correctedText&quot;: &quot;arrive at 1:30 p.m., but I ended up getting there at 2:20&quot;
      },
      {
      //...
      },
]</code></pre>
<p>그래서 <code>diffRanges</code>를 <code>List&lt;Pair&lt;Int, Int&gt;&gt;</code>로 넘겨줘야겠다! 생각했습니다.</p>
<pre><code class="language-kotlin">diffRanges: ImmutableList&lt;Pair&lt;Int, Int&gt;&gt; = persistentListOf(),</code></pre>
<p>아래 코드를 작성해서 테스트를 진행해봤는데, 하이라이팅이 잘 되는 것을 확인할 수 있었습니다.</p>
<pre><code class="language-kotlin">val annotatedString = buildAnnotatedString {
        append(content)
        diffRanges.forEach {
            addStyle(
                style = SpanStyle(color = HilingualTheme.colors.hilingualOrange),
                start = it.first,
                end = it.second
            )
        }
    }</code></pre>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/49f8343b-4305-48b1-a276-73c7f7a0e5fb/image.png" alt=""></p>
<p><em><del>너무 잘 되지용??</del></em></p>
<p>테스트가 끝났으니~ 코드를 완성해봅시다.</p>
<p><code>DiaryCard</code>에서 텍스트 하이라이팅이 적용된 코드입니다!</p>
<p><code>getAnnotatedString()</code>에서 <code>diffRanges</code> 대로 하이라이팅을 적용한 전체 <code>buildAnnotatedString</code>을 반환하고,
<code>isAIWritten</code> 여부에 따라 하이라이팅을 적용한 텍스트를 보여줄지, 아니면 그냥 텍스트를 보여줄지 <code>displayText</code>로 분기 처리를 해줍니다.</p>
<pre><code class="language-kotlin">@Composable
internal fun DiaryCard(
    isAIWritten: Boolean,
    diaryContent: String,
    modifier: Modifier = Modifier,
    diffRanges: ImmutableList&lt;Pair&lt;Int, Int&gt;&gt; = persistentListOf(),
    imageUrl: String? = null
) {
    val maxContentLength = if (isAIWritten) 1500 else 1000

    val displayText: AnnotatedString = if (isAIWritten) {
        getAnnotatedString(clipContent, diffRanges)
    } else {
        AnnotatedString(clipContent)
    }

    Column(
        modifier = modifier
            .clip(RoundedCornerShape(8.dp))
            .background(HilingualTheme.colors.white)
            .fillMaxWidth()
            .padding(12.dp)
    ) {
        // ... 이미지

        Text(
            text = displayText,
            style = HilingualTheme.typography.bodyR16,
            color = HilingualTheme.colors.black,
            modifier = Modifier.fillMaxWidth()
        )

        // ... 글자수 표시 텍스트
    }
}

@Composable
private fun getAnnotatedString(
    content: String,
    diffRanges: ImmutableList&lt;Pair&lt;Int, Int&gt;&gt;
): AnnotatedString {
    return buildAnnotatedString {
        append(content)
        diffRanges.forEach {
            addStyle(
                style = SpanStyle(
                    color = HilingualTheme.colors.hilingualOrange,
                    fontFamily = SuitMedium
                ),
                start = it.first,
                end = it.second
            )
        }
    }
}</code></pre>
<h1 id="📱-완성-화면">📱 완성 화면</h1>
<p><code>PreviewParameterProvider</code>를 활용해 여러 케이스를 확인해 본 모습입니다!</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/8bfaff1c-9b10-45eb-999b-8eec40ed8b32/image.png" alt=""></p>
<p>이미지 및 diffRanges, isAIWritten에 따라 달라지는 모습을 확인할 수 있었습니다.</p>
<p><del>끝</del></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin] Enum class]]></title>
            <link>https://velog.io/@nahy-512/Kotlin-Enum-class</link>
            <guid>https://velog.io/@nahy-512/Kotlin-Enum-class</guid>
            <pubDate>Sun, 08 Jun 2025 03:32:27 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요, 오늘은 Kotlin에서의 enum class에 대해 다뤄보려고 합니다.
저는 개인적으로 프로젝트를 진행하며 이 enum class를 다양한 상황에서 요긴하게 써먹었던 기억이 있습니다!</p>
<h2 id="enum-class란">Enum class란?</h2>
<blockquote>
<p><strong>🔗 공식 문서</strong></p>
</blockquote>
<ul>
<li><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-enum/">https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-enum/</a></li>
<li><a href="https://kotlinlang.org/docs/enum-classes.html">https://kotlinlang.org/docs/enum-classes.html</a></li>
</ul>
<p>Enum은 Enumrated type의 준말로, 번역하면 열거형입니다.</p>
<p>Enum은 클래스 내에 상수들을 열거해 관리하는 문법으로, 각 상수들은 각각 상태를 나타내는 &#39;객체&#39;로, property와 function을 가질 수 있는 특징이 있습니다. 
열거형을 정의하려면 enum 키워드를 정의하고, 열거형 멤버의 이름을 지정할 수 있습니다.
Enum 클래스는 타입에 안전한 열거형 클래스로, 관련된 상수들을 그룹화할 때 주로 사용됩니다.</p>
<p>Enum 클래스는 크게 아래의 두 역할로 활용될 수 있습니다.</p>
<blockquote>
<p><strong>💡 Enum의 역할</strong></p>
</blockquote>
<ol>
<li>타입을 분류하는 Flag 역할</li>
<li>상수를 저장하는 역할</li>
</ol>
<p>공식 문서에 따르면 열거형 클래스 사용에는 아래와 같은 예시가 있네요.</p>
<h4 id="1-기본적-사용-사례---type-safe-열거형을-구현하는-것">1) 기본적 사용 사례 - <code>type-safe</code> 열거형을 구현하는 것</h4>
<pre><code class="language-kotlin">enum class Direction {
    NORTH, SOUTH, WEST, EAST
}</code></pre>
<p><code>NORTH</code>, <code>SOUTH</code> 등을 열거 상수(enum constant)라고 부르며, 앞서 말했듯이 각 열거 상수는 객체입니다. 열거 상수는 쉼표로 구분됩니다.
기본적으로 Enum 클래스의 객체들은 대문자로 작성합니다.
이 경우, Enum의 역할 중 1번으로 사용된 경우일 겁니다.
우리가 일반적으로 생각하는 방향으로는 동서남북 방향이 있을텐데, 이를 하나의 flag로 보고 <code>Direction</code>이라는 열거형 클래스로 묶어준 거죠.</p>
<h4 id="2-프로퍼티를-사용해-초기화">2) 프로퍼티를 사용해 초기화</h4>
<p>각 열거형은 열거형 클래스의 인스턴스이므로 다음과 같이 초기화할 수 있습니다.</p>
<pre><code class="language-kotlin">enum class Color(val rgb: Int) {
    RED(0xFF0000),
    GREEN(0x00FF00),
    BLUE(0x0000FF)
}</code></pre>
<p>여기서 <code>enum class Color(val rgb: Int)</code> 코드가 보이실 텐데요,
Enum 클래스 선언 시에 <code>rgb</code>를 생성자로 명시한 걸 볼 수 있습니다. 이 <code>rgb</code>를 열거 상수의 프로퍼티라고 부릅니다.</p>
<p>그리고 앞선 예제와는 다르게 열거 상수를 </p>
<pre><code class="language-kotlin">RED, GREEN, BLUE</code></pre>
<p>식으로 정의하지 않고</p>
<pre><code class="language-kotlin">RED(0xFF0000), GREEN(0x00FF00), BLUE(0x0000FF)</code></pre>
<p>로 정의한 것은 각 상수 생성 시 그에 대한 프로퍼티 값(여기서는 Int 형식의 rgb)을 지정한 것입니다.
<code>RED(rgb = 0xFF0000)</code> 식으로 프로퍼티 이름을 명시적으로 작성해줄 수도 있습니다.
Color 클래스에 접근할 때 <code>Color.RED.rgb</code>를 쓰면 RED의 rgb 색상을 가져올 수 있습니다.
아까의 Enum 역할 중 2번, 상수를 저장하는 역할을 수행함을 확인할 수 있죠.</p>
<p>기본 개념과 예제에 대해 알아봤으니 이번에는 Enum의 멤버 변수와 함수에 대해 알아보겠습니다.</p>
<h2 id="enum-class-멤버">Enum class 멤버</h2>
<blockquote>
<p>enum class는 추상 클래스이기 때문에, 각 enum 별로 멤버 변수/함수가 존재함</p>
</blockquote>
<p>내부 구조를 한 번 살펴볼까요?
<img src="https://velog.velcdn.com/images/nahy-512/post/fa3de42b-93d3-448e-a5f8-10c73a16c0fa/image.png" alt=""></p>
<ul>
<li><p>변수</p>
<ul>
<li><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-enum/#-372974862%2FProperties%2F447362634"><code>name</code></a> : 호출하는 Enum value의 이름을 반환 (String)</li>
<li><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-enum/#-739389684%2FProperties%2F447362634"><code>ordinal</code></a> : 호출하는 Enum value의 인덱스를 반환 (Int)</li>
</ul>
</li>
<li><p>함수: 자체적인 함수는 없고, 상속받은 함수들만 존재</p>
<ul>
<li>Cloneable -&gt; <a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-enum/clone.html"><code>clone()</code></a> : enum 상수는 복제할 수 없음 → 예외 발생</li>
<li>Comparable -&gt; <a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-enum/#1123800221%2FFunctions%2F447362634"><code>compareTo()</code></a> : 두 Enum value 간 인덱스의 차를 반환</li>
<li>Any -&gt; <a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-enum/#-1009559292%2FFunctions%2F447362634"><code>equals()</code></a>, <a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-enum/#446421858%2FFunctions%2F447362634"><code>hashCode()</code></a>, <a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-enum/#268255793%2FFunctions%2F447362634"><code>toString()</code></a></li>
</ul>
</li>
</ul>
<p>이제 예시를 통해 각 프로퍼티와 함수를 확인해봅시다!</p>
<p>제가 이전에 작성했던 <a href="https://velog.io/@nahy-512/Android-strings.xml%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%B4-%EB%8B%A4%EA%B5%AD%EC%96%B4-%EC%A7%80%EC%9B%90%ED%95%98%EA%B8%B0">[Android] strings.xml을 활용해 다국어 지원하기</a> 글에서의, 아래 티빙 홈 화면에서의 탭 레이아웃 장르들을 Enum 클래스로도 저장해줄 수 있을 겁니다.
<image widt=365 src="https://velog.velcdn.com/images/nahy-512/post/4797282f-7914-49ad-b068-2e05d5fe56d6/image.png"></p>
<blockquote>
<p>Enum class 정의 (예시)</p>
</blockquote>
<pre><code class="language-kotlin">enum class TabGenreType(val title: String) {
    DRAMA(&quot;드라마&quot;),
    VARIETY(&quot;예능&quot;),
    MOVIE(&quot;영화&quot;),
    SPORTS(&quot;스포츠&quot;),
    ANIMATION(&quot;애니메이션&quot;),
    NEWS(&quot;뉴스&quot;);
}</code></pre>
<!-- `DRAMA`, `VARIETY`는 열거 상수, title -->

<h3 id="기본-멤버-변수-property">기본 멤버 변수 (Property)</h3>
<blockquote>
<p>name &amp; ordinal</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/dbbb9625-e45c-4222-8696-1b2f99b25150/image.png" alt=""></p>
<ul>
<li><code>ordinal</code>은 호출하는 Enum value의 인덱스를 반환하기에 <code>DRAMA</code>의 ordinal은 <code>0</code>이 나옵니다.</li>
<li><code>name</code>은 호출하는 Enum value의 이름을 반환하기에 <code>&quot;DRAMA&quot;</code>가 출력됩니다.</li>
<li>미리 정의한 상수의 프로퍼티인 <code>title</code>을 <code>DRAMA.title</code>로 출력하면 <code>DRAMA</code>의 title인 <code>&quot;드라마&quot;</code>가 출력됩니다.</li>
</ul>
<h3 id="function">Function</h3>
<blockquote>
<p>clone, compareTo, equals, hashCode, toString</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/88540534-3888-4580-bad7-e4dedeb38d1b/image.png" alt=""></p>
<ul>
<li><code>clone</code>은 protented 되어있기 때문에 접근할 수 없다고 나옵니다.</li>
<li><code>compareTo</code>를 사용하면 두 상수 간의 인덱스 차를 얻을 수 있습니다.</li>
<li><code>equals</code>로 두 상수가 같은지를 비교해줄 수 있습니다.</li>
<li><code>hashCode</code>로 객체의 해시코드를 구할 수 있습니다.</li>
<li><code>toString</code>으로 객체 이름을 얻을 수 있습니다.</li>
</ul>
<h3 id="열거-상수를-나열하고-이름으로-열거-상수를-찾는-방법">열거 상수를 나열하고, 이름으로 열거 상수를 찾는 방법</h3>
<!--
Kotlin 1.1부터는 Enum과 관련된 확장 함수인 enumValues, enumValueOf가 추가됐습니다.
- [`enumValues<Enum>()`](https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/enum-values.html) : 해당 Enum의 value를 배열로 만들어 반환
- [`enumValueOf<Enum>(name : String`)](https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/enum-value-of.html) : 해당 enum에 name의 이름을 가진 요소를 찾아 반환 -

하지만 위의 두 함수 외에도 동일한 역할을 수행하는 방법이 있습니다. -->

<h4 id="enum-상수를-배열로-만들어-반환하는-메소드">Enum 상수를 배열로 만들어 반환하는 메소드</h4>
<blockquote>
<p><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.enums/-enum-entries/"><code>entries</code></a> &amp; <a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/enum-values.html"><code>enumValues&lt;Enum&gt;()</code></a></p>
</blockquote>
<p>둘 다 모든 Enum 상수를 배열 또는 컬렉션으로 반환한다는 점에서 기능은 유사합니다. (TabGenreType 안에 정의된 모든 값들을 가져옴)
또한, 선언 순서대로 컬렉션을 반환한다는 공통된 특징이 있습니다.</p>
<image width=500 src="https://velog.velcdn.com/images/nahy-512/post/3ee14b04-4a50-4271-a2e4-357f3273a335/image.png">

<p>실제로 코드를 작성해 보면 위와 같은 출력 결과가 나옵니다.
entries는 자체를 출력하면 Enum class의 상수가 컬렉션 형태로 잘 출력되지만, </p>
<p>차이점은 아래와 같습니다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th><code>entries</code></th>
<th><code>enumValues&lt;T&gt;()</code></th>
</tr>
</thead>
<tbody><tr>
<td>타입 ⭐️</td>
<td><code>EnumEntries&lt;T&gt;</code> (불변 리스트)</td>
<td><code>Array&lt;T&gt;</code> (가변 배열)</td>
</tr>
<tr>
<td>선언 방식</td>
<td>컴파일러가 생성한 <code>entries</code> 프로퍼티</td>
<td>함수 (리플렉션 사용)</td>
</tr>
<tr>
<td>도입</td>
<td>Kotlin 1.9</td>
<td>Kotlin 1.1</td>
</tr>
<tr>
<td>사용 예</td>
<td><code>TabGenreType.entries</code></td>
<td><code>enumValues&lt;TabGenreType&gt;()</code></td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/fdac3b12-a018-44ba-b2f1-6fbaf5771f2e/image.png" alt=""></p>
<p>사용하는 코틀린 버전이 1.9 이상이라면, 가능하면 <code>entries</code>를 사용하는 게 성능이나 타입 안정성, 불변성 측면에서 더 좋습니다.</p>
<p>Kotlin 1.9 이전에는 <code>entries</code>이 없이 <code>values</code>를 사용했습니다.
<img src="https://velog.velcdn.com/images/nahy-512/post/6fc81e9c-84b6-4d0d-a5f0-b8390776437a/image.png" alt=""></p>
<p>하지만 values는 아래와 같은 문제점이 있기에, entries로 바꿔 사용하는 것이 권장됩니다. Kotlin 1.9 이상에서 values를 사용한다! 그러면 위와 같이 주의 문구가 표시됩니다.</p>
<p><strong>&lt;values의 문제점&gt;</strong></p>
<ul>
<li>values()를 호출할 때마다 배열을 할당, 복제하기 때문에 성능 이슈의 원인이 됨</li>
<li>기본적으로 변경 가능하고(mutable) 사용자가 수동으로 배열을 리스트로 바꾸게 하는 컬렉션보다 덜 유연한 Array<E>를 리턴함</li>
<li>Array<E>를 리턴하기 때문에 enum에 대한 확장 함수 작성이 어려움</li>
</ul>
<blockquote>
<p>🔗 values() 사용 관련 문서</p>
</blockquote>
<ul>
<li><a href="https://kotlinlang.org/docs/whatsnew19.html#stable-data-objects-for-symmetry-with-data-classes">https://kotlinlang.org/docs/whatsnew19.html#stable-data-objects-for-symmetry-with-data-classes</a></li>
<li><a href="https://github.com/Kotlin/KEEP/blob/master/proposals/enum-entries.md#examples-of-performance-issues">https://github.com/Kotlin/KEEP/blob/master/proposals/enum-entries.md#examples-of-performance-issues</a></li>
</ul>
<h4 id="name을-기반으로-해당하는-enum-상수를-찾아-반환하는-메소드">name을 기반으로 해당하는 Enum 상수를 찾아 반환하는 메소드</h4>
<blockquote>
<p><code>valueOf()</code> &amp; <a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/enum-value-of.html"><code>enumValueOf&lt;Enum&gt;(name : String)</code></a></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/37d10477-1991-45e8-b617-753820baa64c/image.png" alt=""></p>
<p>둘 다 일치하는 열거 상수를 찾는 역할을 하며, 결과는 동일합니다.
enumValueOf<T>() 함수는 제네릭 방식으로 Enum class의 상수에 접근할 수 있다.</p>
<p>name에 해당하는 Enum 상수를 찾지 못한다면 <code>IllegalArgumentException</code>이 발생합니다.
<img src="https://velog.velcdn.com/images/nahy-512/post/6669f4e3-8551-42b1-8e5c-4dd80c30ad5f/image.png" alt=""></p>
<h2 id="enum-클래스-활용">Enum 클래스 활용</h2>
<h3 id="when">when</h3>
<p>Enum 클래스와 When 표현식을 함께 활용하면 가독성과 타입 안전성 보장 측면에서 많은 이점을 얻을 수 있습니다. </p>
<pre><code class="language-kotlin">fun getMnemonic(color: Color) =
    when (color) {
        Color.RED -&gt; &quot;Richard&quot;
        Color.GREEN -&gt; &quot;Gave&quot;
        Color.BLUE -&gt; &quot;Battle&quot;
    }

println(getMnemonic(Color.BLUE) // Battle</code></pre>
<p>Enum으로 여관된 상수들이 그룹화되기 때문에 When으로 분기 처리 시에 코드를 읽고 이해하기 쉽게 만들 수 있습니다.
또한, When 식에서 단 한 개의 Enum이라도 빠져 있다면 컴파일러가 타입을 검사해 오류를 발생시키기 때문에 타입 안전성을 보장하기 수월합니다.</p>
<h3 id="메소드-작성">메소드 작성</h3>
<p>enum class에서 커스텀 메소드를 작성할 수 있습니다.
저는 주로 companion object로 필요한 메소드를 정의하곤 하는데요, 아래는 그 예시입니다.</p>
<pre><code class="language-kotlin">enum class TabGenreType(val title: String) {
    DRAMA(&quot;드라마&quot;),
    VARIETY(&quot;예능&quot;),
    MOVIE(&quot;영화&quot;),
    SPORTS(&quot;스포츠&quot;),
    ANIMATION(&quot;애니메이션&quot;),
    NEWS(&quot;뉴스&quot;);

    companion object {
        // id를 통해 TabGenreType 얻기
        fun getGenreById(index: Int) = entries.find { // 반환 타입: TabGenreType
            it.ordinal == index
            } ?: DRAMA

          // 각 상수의 title을 목록으로 받아오기
        fun getGenreTitles() = entries.map { // 반환 타입: List&lt;String&gt;
            it.title
        }
    }
}</code></pre>
<p>** 참고) Enum 클래스 안에 메소드를 사용하는 경우, 반드시 Enum 상수 목록과 메소드 정의 사이에 세미콜론(;)을 넣어줘야 함</p>
<p>entries는 컬렉션 타입이므로, map, filter, find 등을 사용해서 여러 커스텀 메소드를 작성하기 용이합니다.</p>
<pre><code class="language-kotlin">TabGenreType.getGenreById(1) // VARIETY
TabGenreType.getGenreTitles() // [드라마, 예능, 영화, 스포츠, 애니메이션, 뉴스]</code></pre>
<p>식으로 활용해줄 수 있습니다.</p>
<p>예시를 하나 더 살펴보자면, 앱에서 회원가입 시에 아이디, 비밀번호, 닉네임 입력 등의 과정이 별개의 화면으로 나눠져 있다면 이런 식으로도 Enum 클래스를 작성해줄 수 있습니다.</p>
<pre><code class="language-kotlin">enum class SignUpStep(var order: Int) {
    ID(1),
    PASSWORD(2)
      NICKNAME(3);

    companion object {
        fun getPrevStep(currentStep: SignUpStep): SignUpStep? { // 뒤로가기
            return entries.find {
                it.order == currentStep.order - 1
            }
        }

        fun getNextStep(currentStep: SignUpStep): SignUpStep? { // 다음
            return entries.find {
                it.order == currentStep.order + 1
            }
        }
    }
}</code></pre>
<p>order 대신 Enum 클래스의 기본 프로퍼티인 ordinal을 사용하게끔 해도 무방합니다.</p>
<p>마지막으로 예제로 앱 내에 Enum 클래스로 색상을 저장해서 사용한 예시인데, 제가 예전에 작성한 <a href="https://velog.io/@nahy-512/AndroidKotlin-%EC%95%B1-%EB%82%B4%EB%B6%80%EC%97%90-%EC%83%89%EC%83%81-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0">[Android/Kotlin] 앱 내부에 서비스 색상 저장해서 사용하기</a> 글을 참고해보시면 좋을 것 같습니다. </p>
<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li>enum<ul>
<li><a href="https://banziha104.github.io/2020/05/05/kotlin-enum/">https://banziha104.github.io/2020/05/05/kotlin-enum/</a></li>
<li><a href="https://velog.io/@highlaw00/Kotlin-Enum-class">https://velog.io/@highlaw00/Kotlin-Enum-class</a></li>
</ul>
</li>
<li>entries<pre><code>- https://onlyfor-me-blog.tistory.com/768</code></pre><ul>
<li><a href="https://jaeryo2357.tistory.com/148">https://jaeryo2357.tistory.com/148</a></li>
<li><a href="https://velog.io/@shjung53/kotlin-kotlin-1.9.0%EC%9D%98-enum-class%EC%9D%98-entries">https://velog.io/@shjung53/kotlin-kotlin-1.9.0%EC%9D%98-enum-class%EC%9D%98-entries</a></li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android/Kotlin] Jetpack Navigation으로 Bottom Navigation 구현하기]]></title>
            <link>https://velog.io/@nahy-512/AndroidKotlin-Jetpack-Navigation%EC%9C%BC%EB%A1%9C-BottomNavigation-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@nahy-512/AndroidKotlin-Jetpack-Navigation%EC%9C%BC%EB%A1%9C-BottomNavigation-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 22 May 2025 21:59:54 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요, 또텀네비로 돌아왔습니다~!
바텀네비 관련해서만 벌써 몇 번쨰 포스트인지ㅎㅎ 모르겠네요.
그치만 할 때마다 조금씩 새롭게 하고 있어서,
오늘은 제가 거의 정착형으로 사용하고 있는 <strong>Jetpack Navigation을 활용한 구현 방법</strong>을 정리해보려 합니다.</p>
<p>Jetpack Navigation을 사용하지 않은 구현은 아래 포스트를 참고해주시면 됩니다.</p>
<blockquote>
<p>🔗 <a href="https://velog.io/@nahy-512/AndroidKotlin-%EC%BB%A4%EC%8A%A4%ED%85%80-BottomNavigation-%EB%A7%8C%EB%93%A4%EA%B8%B0">커스텀 BottomNavigation 만들기</a></p>
</blockquote>
</br>

<h2 id="✍🏻-요구사항-분석">✍🏻 요구사항 분석</h2>
<image width=365 src="https://velog.velcdn.com/images/nahy-512/post/e2614cd8-777e-4cb8-8c41-b1d810d71381/image.png"/>
제가 구현할 바텀네비는 위 사진과 같았습니다.

<p>뭐.. 기본적인 바텀네비게이션이랑 크게 다른 부분은 없으니, 요구사항은 아래와 같을 겁니다.</p>
<blockquote>
<p><strong>[요구사항]</strong></p>
</blockquote>
<ol>
<li>선택된 탭은 아이콘 &amp; 텍스트 색상이 바뀐다</li>
<li>선택된 탭에 따라 다른 화면을 보여줘야 한다</li>
</ol>
<p>그나마 selected/unselected 아이콘은 아이콘 자체가 아니라 색상만 다르다는 점에서 구현이 조금 쉬워질 수 있겠네요.
</br></p>
<h2 id="💻-코드-작성">💻 코드 작성</h2>
<h3 id="1️⃣-의존성-추가">1️⃣ 의존성 추가</h3>
<p>기존 Groovy DSL 말고 최근에 Kotlin DSL을 사용하고 있어서, 아래와 같이 버전 관리를 진행해 주었습니다.</p>
<h4 id="libsversiontoml">libs.version.toml</h4>
<pre><code class="language-toml">[versions]
navigation = &quot;2.9.0&quot;

[libraries]
navigation-fragment-ktx = { group = &quot;androidx.navigation&quot;, name = &quot;navigation-fragment-ktx&quot;, version.ref = &quot;navigation&quot;}
navigation-ui-ktx = { group = &quot;androidx.navigation&quot;, name = &quot;navigation-ui-ktx&quot;, version.ref = &quot;navigation&quot;}

[bundles]
navigation = [
    &quot;navigation-fragment-ktx&quot;,
    &quot;navigation-ui-ktx&quot;
]</code></pre>
<h4 id="모듈-수준의-gradle">모듈 수준의 gradle</h4>
<pre><code class="language-kts">dependencies {
    implementation(libs.bundles.navigation)
}</code></pre>
<hr>
<p>Groovy DSL을 사용한다면 모듈 수준의 gradle에 아래 코드를 바로 넣을 수 있습니다.</p>
<h4 id="buildgradle-project">build.gradle (Project)</h4>
<pre><code class="language-gradle">ext {
    navigationVersion = &quot;2.7.7&quot;
}</code></pre>
<h4 id="buildgradle-module">build.gradle (Module)</h4>
<pre><code class="language-gradle">dependencies {
    implementation &quot;androidx.navigation:navigation-fragment-ktx:$navigationVersion&quot;)
    implementation &quot;androidx.navigation:navigation-ui-ktx:$navigationVersion&quot;
}</code></pre>
<h3 id="2️⃣-아이콘-리소스-추가--fragment-생성">2️⃣ 아이콘 리소스 추가 + Fragment 생성</h3>
<h4 id="icon">icon</h4>
<p>svg로 추출한, 바텀네비에 들어가는 아이콘을 <code>res &gt; drawable</code> 폴더 안에 추가해 줍니다.
<img src="https://velog.velcdn.com/images/nahy-512/post/f7d686c6-c80d-4c3e-b51d-f949c443d242/image.png" alt=""></p>
<p>select/unselect 아이콘의 디자인 자체가 다르다면 <code>ic_nav_translator_selected</code>, <code>ic_nav_translator_unselected</code> 식으로 아이콘을 2개를 추가해야겠지만ㅎㅎ 저는 선택 시 아이콘의 색상만 바뀌었기에 흰색으로 하나만 추가해 줬습니다.</p>
<h4 id="selector">selector</h4>
<p>select와 unselect 시의 색상 변경을 위해 마찬가지로 res &gt; drawable 폴더에 selector 코드를 작성합니다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;selector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;&gt;
    &lt;item android:color=&quot;@color/white&quot; android:state_checked=&quot;true&quot; /&gt;
    &lt;item android:color=&quot;#4DFFFFFF&quot; /&gt;
&lt;/selector&gt;</code></pre>
<p>selected에서는 흰색, unselected에서는 투명도 30의 흰색이기에 <code>state_checked</code>에 따른 컬러 설정을 해줍니다. 색상은 <code>#4DFFFFFF</code>처럼 하드코딩으로 넣을 수도 있고, <code>@color/white</code>처럼 컬러 리소스의 색상을 불러올 수도 있습니다.
<img src="https://velog.velcdn.com/images/nahy-512/post/ed1a69b5-6b71-4c45-b53a-c360369f30a6/image.png" alt=""></p>
<p>👇🏻 투명도 변환은 아래 아티클을 참고했습니다.
<a href="https://developer-eungb.tistory.com/31">[Android] 투명도 - Hex값 정리</a></p>
<h4 id="fragment-코드-작성">Fragment 코드 작성</h4>
<p>각 탭을 선택했을 때 나오는 프래그먼트를 각각 만들어줍니다. (예: translator)</p>
<ul>
<li>layout
<img src="https://velog.velcdn.com/images/nahy-512/post/8d251be5-c7b7-4911-9107-68276890e21a/image.png" alt=""></li>
<li>fragment
<img src="https://velog.velcdn.com/images/nahy-512/post/df8bccfc-0833-4dcc-b324-776925817a49/image.png" alt=""></li>
</ul>
<p>약식으로 화면 이동이 잘 이루어지는지만 확인하고자 레이아웃에는 텍스트뷰 하나만 넣었습니다.</p>
<h3 id="3️⃣-navgraph-추가">3️⃣ navGraph 추가</h3>
<p>번역기, 퀴즈, 토론방, 트랜드 친구들을 &#39;나 바텀네비에 속해있소!&#39;하고 묶어주는 작업이라고도 볼 수 있을 거 같아요.
<code>res &gt; navigation</code> 폴더에 nav_maintab이라는 이름으로 xml 파일을 추가합니다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;navigation xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;
    android:id=&quot;@+id/nav_maintab&quot;
    app:startDestination=&quot;@id/translatorFragment&quot;&gt;

    &lt;!-- Translator --&gt;
    &lt;fragment
        android:id=&quot;@+id/translatorFragment&quot;
        android:name=&quot;com.nahyun.mz.ui.translator.TranslatorFragment&quot;
        android:label=&quot;TranslatorFragment&quot;
        tools:layout=&quot;@layout/fragment_translator&quot;/&gt;

    &lt;!-- Quiz --&gt;
    &lt;fragment
        android:id=&quot;@+id/quizFragment&quot;
        android:name=&quot;com.nahyun.mz.ui.quiz.QuizFragment&quot;
        android:label=&quot;QuizFragment&quot;
        tools:layout=&quot;@layout/fragment_quiz&quot;/&gt;

    &lt;!-- Discussion --&gt;
    &lt;fragment
        android:id=&quot;@+id/discussionFragment&quot;
        android:name=&quot;com.nahyun.mz.ui.discussion.DiscussionFragment&quot;
        android:label=&quot;DiscussionFragment&quot;
        tools:layout=&quot;@layout/fragment_discussion&quot;/&gt;

    &lt;!-- Trend --&gt;
    &lt;fragment
        android:id=&quot;@+id/trendFragment&quot;
        android:name=&quot;com.nahyun.mz.ui.trend.TrendFragment&quot;
        android:label=&quot;TrendFragment&quot;
        tools:layout=&quot;@layout/fragment_trend&quot;/&gt;

&lt;/navigation&gt;</code></pre>
<p>navigation의 id를 지정해주고, 아래에 해당 네비게이션에 들어가는 프래그먼트를 적어줍니다.
startDestination는 아래에 적은 프래그먼트 중, 시작점으로 설정할 프래그먼트의 id를 적어주면 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/c7859b05-f60c-481a-b330-e8b7922b0347/image.png" alt=""></p>
<h3 id="4️⃣-menu-추가">4️⃣ menu 추가</h3>
<p><code>res &gt; menu</code> 폴더에 바텀 네비에 들어갈 아이템들을 넣어 줍니다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;menu xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;&gt;

    &lt;item
        android:id=&quot;@+id/translatorFragment&quot;
        android:icon=&quot;@drawable/ic_nav_translator&quot;
        android:title=&quot;@string/menu_translator&quot;
        android:enabled=&quot;true&quot;
        app:showAsAction=&quot;always&quot;/&gt;

    &lt;item
        android:id=&quot;@+id/quizFragment&quot;
        android:icon=&quot;@drawable/ic_nav_quiz&quot;
        android:title=&quot;@string/menu_quiz&quot;
        android:enabled=&quot;true&quot;
        app:showAsAction=&quot;ifRoom&quot;/&gt;

    &lt;item
        android:id=&quot;@+id/discussionFragment&quot;
        android:icon=&quot;@drawable/ic_nav_discussion&quot;
        android:title=&quot;@string/menu_discussion&quot;
        android:enabled=&quot;true&quot;
        app:showAsAction=&quot;ifRoom&quot;/&gt;

    &lt;item
        android:id=&quot;@+id/trendFragment&quot;
        android:icon=&quot;@drawable/ic_nav_trend&quot;
        android:title=&quot;@string/menu_trend&quot;
        android:enabled=&quot;true&quot;
        app:showAsAction=&quot;always&quot;/&gt;
&lt;/menu&gt;</code></pre>
<p>id는 이전 navigation에 작성했던 fragment의 id로 적어줍니다.
아이템 안에는 아이콘, 타이틀(탭 이름)을 적어줍니다.</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/980f4cf1-6953-4707-a6ea-336269c1a39c/image.png" alt=""></p>
<h3 id="5️⃣-bottomnavigationview-추가">5️⃣ BottomNavigationView 추가</h3>
<p>이제 바텀네비가 들어갈 화면인, MainActivity에서 코드를 작성해 줄 차례입니다.</p>
<h4 id="layout">layout</h4>
<p>액티비티 하단에 material의 BottomNavigationView로 바텀네비의 영역을 만들어줍니다.</p>
<pre><code class="language-xml">&lt;com.google.android.material.bottomnavigation.BottomNavigationView
            android:id=&quot;@+id/main_nav_bar&quot;
            android:layout_width=&quot;match_parent&quot;
            android:layout_height=&quot;70dp&quot;
            android:background=&quot;@color/main&quot;
            android:paddingHorizontal=&quot;25dp&quot;
            app:itemIconTint=&quot;@drawable/selector_nav_color&quot;
            app:itemTextColor=&quot;@drawable/selector_nav_color&quot;
            app:menu=&quot;@menu/nav_menu&quot;
            app:itemIconSize=&quot;30dp&quot;
            app:labelVisibilityMode=&quot;labeled&quot;
            app:itemActiveIndicatorStyle=&quot;@android:color/transparent&quot;
            app:itemRippleColor=&quot;@null&quot;
            app:layout_constraintBottom_toBottomOf=&quot;parent&quot;
            app:layout_constraintEnd_toEndOf=&quot;parent&quot;
            app:layout_constraintStart_toStartOf=&quot;parent&quot;
            app:layout_constraintTop_toBottomOf=&quot;@id/main_nav_host&quot; /&gt;</code></pre>
<p>배경색, 아이콘 사이즈, 라벨 표시 여부, 리플 효과 등 코드를 지정할 수 있습니다.</p>
<pre><code class="language-xml">app:itemIconTint=&quot;@drawable/selector_nav_color&quot;
app:itemTextColor=&quot;@drawable/selector_nav_color&quot;</code></pre>
<p>여기에 아까 2번 단계에서 만들어준 selector 코드를 넣는 것이 아이콘/텍스트 색상 변경의 핵심입니다.
</br></p>
<p>바텀네비 위는 선택한 탭에 따라 프래그먼트 교체를 해주어야 하는 영역입니다.</p>
<pre><code class="language-xml">&lt;fragment
            android:id=&quot;@+id/main_nav_host&quot;
            android:name=&quot;androidx.navigation.fragment.NavHostFragment&quot;
            android:layout_width=&quot;match_parent&quot;
            android:layout_height=&quot;0dp&quot;
            app:navGraph=&quot;@navigation/nav_maintab&quot;
            app:defaultNavHost=&quot;true&quot;
            app:layout_constraintBottom_toTopOf=&quot;@id/main_nav_bar&quot;
            app:layout_constraintEnd_toEndOf=&quot;parent&quot;
            app:layout_constraintStart_toStartOf=&quot;parent&quot;
            app:layout_constraintTop_toTopOf=&quot;parent&quot; /&gt;</code></pre>
<p>navGraph로 3에서 만들었던 nagigation의 id를 적어줍니다.
</br></p>
<p>activity_main.xml의 전체 코드는 아래와 같습니다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;layout
    xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;&gt;

    &lt;androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;match_parent&quot;
        android:fitsSystemWindows=&quot;true&quot;
        tools:context=&quot;.MainActivity&quot;&gt;

        &lt;fragment
            android:id=&quot;@+id/main_nav_host&quot;
            android:name=&quot;androidx.navigation.fragment.NavHostFragment&quot;
            android:layout_width=&quot;match_parent&quot;
            android:layout_height=&quot;0dp&quot;
            app:navGraph=&quot;@navigation/nav_maintab&quot;
            app:defaultNavHost=&quot;true&quot;
            app:layout_constraintBottom_toTopOf=&quot;@id/main_nav_bar&quot;
            app:layout_constraintEnd_toEndOf=&quot;parent&quot;
            app:layout_constraintStart_toStartOf=&quot;parent&quot;
            app:layout_constraintTop_toTopOf=&quot;parent&quot; /&gt;

        &lt;com.google.android.material.bottomnavigation.BottomNavigationView
            android:id=&quot;@+id/main_nav_bar&quot;
            android:layout_width=&quot;match_parent&quot;
            android:layout_height=&quot;70dp&quot;
            android:background=&quot;@color/main&quot;
            android:paddingHorizontal=&quot;25dp&quot;
            app:itemIconTint=&quot;@drawable/selector_nav_color&quot;
            app:itemTextColor=&quot;@drawable/selector_nav_color&quot;
            app:menu=&quot;@menu/nav_menu&quot;
            app:itemIconSize=&quot;30dp&quot;
            app:labelVisibilityMode=&quot;labeled&quot;
            app:itemActiveIndicatorStyle=&quot;@android:color/transparent&quot;
            app:itemRippleColor=&quot;@null&quot;
            app:layout_constraintBottom_toBottomOf=&quot;parent&quot;
            app:layout_constraintEnd_toEndOf=&quot;parent&quot;
            app:layout_constraintStart_toStartOf=&quot;parent&quot;
            app:layout_constraintTop_toBottomOf=&quot;@id/main_nav_host&quot; /&gt;

    &lt;/androidx.constraintlayout.widget.ConstraintLayout&gt;
&lt;/layout&gt;</code></pre>
<h3 id="6️⃣-bottombar--navcontroller-연결">6️⃣ BottomBar + NavController 연결</h3>
<p>이제 마지막으로 선택된 탭이랑 navController를 연결해 줄 차례입니다.
액티비티 코드 안에 추가해야하는 코드는 간단합니다.</p>
<pre><code class="language-kotlin">private fun initNavigation() {
    NavigationUI.setupWithNavController(binding.mainNavBar, findNavController(R.id.main_nav_host))
}</code></pre>
<p>setupWithNavController에 <code>navigationBarView</code>, <code>navController</code>를 넣어 선택 탭에 따라 프래그먼트도 바뀔 수 있게 합니다.
위 코드를 추가하지 않으면 바텀네비 아이템을 선택했을 때 탭의 select 상태는 변경되지만, 바텀바 위의 프래그먼트는 바뀌지 않게 됩니다.</p>
<p>이렇게 작성한 <code>initNavigation()</code> 함수를 MainActivity의 onCreate 안에 넣어줍니다.</p>
<h2 id="📱-완성-화면">📱 완성 화면</h2>
<image width=375 src="https://velog.velcdn.com/images/nahy-512/post/1c178825-28e1-4775-8328-c6ef0a353f9d/image.gif"/>
실행해보면 선택한 탭에 따라 프래그먼트가 잘 바뀌는 모습을 확인할 수 있습니다~!

<p></br></br></p>
<h2 id="번외-만약-아이콘-자체가-selectedunselected-아이콘으로-나뉘어진다면">번외) 만약 아이콘 자체가 selected/unselected 아이콘으로 나뉘어진다면?</h2>
<p>프로젝트를 하다보면 종종 이렇게 선택/미선택 시의 아이콘 디자인 자체가 달라지는 경우도 보실 텐데요,</p>
<table>
<thead>
<tr>
<th align="center"><img src="https://velog.velcdn.com/images/nahy-512/post/68691e20-4d8c-4693-8a9a-e23743eaee21/image.png" alt=""></th>
<th align="center"><img src="https://velog.velcdn.com/images/nahy-512/post/b400b429-2c71-44d7-bfd7-6d72cc55624e/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td align="center">아이콘 자체가 다른 경우</td>
<td align="center">색상 커스텀이 어려운 경우</td>
</tr>
</tbody></table>
<p>그럴 땐 selected/unselected 아이콘 두 개를 모두 drawable에 추가하고,
selector의 drawable에 아이콘 자체를 넣어서 <code>state_checked</code>에 대한 처리를 해줄 수도 있습니다. (2번 과정)
<img src="https://velog.velcdn.com/images/nahy-512/post/6ed2f50e-3988-4c28-80bc-dbf90a563ce8/image.png" alt=""></p>
<p>그리고 menu 코드를 작성할 때, icon의 drawable에 위에서 만든 selector 자체를 넣어줍니다. 이 selector도 바텀네비에 들어갈 아이템마다 추가해서 넣어주면 됩니다. (4번 과정)
<img src="https://velog.velcdn.com/images/nahy-512/post/21e2b080-ef2f-474e-935a-3dd2f0e10d0e/image.png" alt=""></p>
<p>아이콘 이미지 자체를 이미 menu에서 바꾸어주었기 때문에,
activity_main.xml에서 BottomNavigationView 코드를 작성할 떄 <code>itemIconTint</code>는 따로 설정할 필요가 없습니다. (5번 과정)</p>
<p>언급한 부분 외에 나머지 코드는 동일합니다.</p>
<p></br></br></p>
<h2 id="️-마치며">‼️ 마치며</h2>
<p>오늘은 이렇게 Jetpack Navigation으로 바텀바를 구현하는 방법에 대해 정리해 보았는데요,
Jetpack Navigation을 활용하면 어떤 이점이 있느냐! 가 궁금하실 수 있을 것 같아요.</p>
<blockquote>
<p>🔗 <a href="https://velog.io/@nahy-512/AndroidKotlin-%EC%BB%A4%EC%8A%A4%ED%85%80-BottomNavigation-%EB%A7%8C%EB%93%A4%EA%B8%B0">커스텀 BottomNavigation 만들기</a></p>
</blockquote>
<p>포스트 초반에 언급한, Jetpack Navigation을 사용하지 않고 바텀바를 구현했을 때와 비교해보면, 3번과 6번 과정이 주요한 차이점인데요.
navGraph와 BottomNavigationView를 아래 코드 하나만으로 손쉽게 연결해줌으로써 관리가 무척 편리해집니다.
<strong>&lt;Jetpack Navigation 활용 시 코드&gt;</strong></p>
<pre><code class="language-kotlin">NavigationUI.setupWithNavController(binding.mainNavBar, findNavController(R.id.main_nav_host))</code></pre>
<p>Jetpack Navigation 사용 전에는 MainActivity에 아래와 같이 상당히 긴 코드가 들어갔거든요.
아이템이 클릭되었을 때 어떤 프래그먼트를 보여줄 것인지, 최초 프래그먼트는 뭘로 할지.. Jetpack Navigation에서는 <code>navGraph</code>랑 <code>setupWithNavController</code>에서 정의해줄 수 있는 부분을 액티비티 코드에서 직접 다뤄줬어야 했습니다.
<strong>&lt;Jetpack Navigation 활용X 코드&gt;</strong></p>
<pre><code class="language-kotlin">private fun initBottomNav() {
        binding.mainLayoutBottomNavigation.itemIconTintList = null

        binding.mainLayoutBottomNavigation.setOnItemSelectedListener {
            when(it.itemId) {
                R.id.main_bottom_nav_home -&gt; {
                    HomeFragment().changeFragment()
                }

                R.id.main_bottom_nav_friends -&gt; {
                    FriendsFragment().changeFragment()
                }

                R.id.main_bottom_nav_record -&gt; {
                    RecordFragment().changeFragment()
                }
            }
            return@setOnItemSelectedListener true
        }

        binding.mainLayoutBottomNavigation.setOnItemReselectedListener {  } // 바텀네비 재클릭시 화면 재생성 방지
    }

private fun Fragment.changeFragment() {
        manager.beginTransaction().replace(R.id.main_layout_container, this).commit()
}

fun showInit() {
        val transaction = manager.beginTransaction()
            .add(R.id.main_layout_container, HomeFragment())
        transaction.commit()
}</code></pre>
<p>이번 포스트에서는 Jetpack Navigation으로 BottomNavigation을 구현하는 코드만 집중적으로 살펴봤는데요, Jetpack Navigation의 사용 장점으로 이게 끝이라면 &#39;굳이 왜 사용해야할까?&#39;하는 부분이 크게 와닿지 않을 수도 있어요.</p>
<p>당연합니다!
이번 포스트에서는 navGraph를 어떤 식으로 활용할 수 있는지에 대해서는 다루지 않았으니까요ㅎㅎ</p>
<p>Jetpack Navigation을 사용하면 <strong>한 액티비티 내에서 화면 이동이 이루어지는, 모든 과정을 navGraph 하나로도 손쉽게 관리</strong>하는 게 가능합니다.
<strong>화면 이동 시에 bundle이나 intent를 이용하지 않고도 데이터를 쉽게 전달하는 방법도 있고요.</strong></p>
<p>아무튼 잘 사용하면 정말 편한 친구입니다ㅎㅎ <del>(제가 그만큼 잘 사용하고 있는지는 자신이 없지만요)</del></p>
<p>모든 내용을 하나의 포스트에 다 다루기에는 무리가 있으니, 이번에는 딱 Bottom Navigation 내용만 정리해 보았는데요,
Jetpack Navigation 개념과 활용은 조만간 다른 포스트로 찾아뵙겠습니다!</p>
<p>읽어주셔서 감사합니다.</p>
<p></br></br></p>
<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li>공식 문서: <a href="https://developer.android.com/guide/navigation">https://developer.android.com/guide/navigation</a></li>
<li>코드랩: <a href="https://developer.android.com/codelabs/android-navigation?hl=ko#0">https://developer.android.com/codelabs/android-navigation?hl=ko#0</a></li>
<li><a href="https://velog.io/@hoyaho/Jetpack-Navigation-Component">Jetpack Navigation 에 대해 알아보자! ｜ Android Study</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin in Action] 1. 코틀린이란 무엇이며, 왜 필요한가?]]></title>
            <link>https://velog.io/@nahy-512/kotlin-in-action-ch1</link>
            <guid>https://velog.io/@nahy-512/kotlin-in-action-ch1</guid>
            <pubDate>Sun, 11 May 2025 08:25:55 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>[Kotlin in Action] 1장을 읽고, 정리한 내용입니다.</p>
</blockquote>
<h2 id="11-코틀린-맛보기">1.1) 코틀린 맛보기</h2>
<p><a href="https://play.kotlinlang.org/">https://play.kotlinlang.org/</a></p>
<p>⬆️ <a href="http://try.kotl.in">http://try.kotl.in</a> 입력하면 아래 코틀린 플레이그라운드 링크로 이동함.</p>
<pre><code class="language-kotlin">data class Person(val name: String,
                                    val age: Int? = null)

fun main(args: Array&lt;String&gt; {
    val persons = listOf(Person(&quot;영희&quot;),
                                             Person(&quot;철수&quot;, age=29))
    val oldest = persons.maxBy { it.age ?: 0 }
    println(&quot;나이가 가장 많은 사람: $oldest&quot;)
}</code></pre>
<ul>
<li><p>언급한 개념</p>
<ul>
<li>데이터 클래스</li>
<li>nullable type</li>
<li>최상위 함수</li>
<li>람다식</li>
<li>엘비스 연산자 (?:)</li>
<li>문자열 템플릿</li>
</ul>
<p>→ 후에 모든 내용을 자세히 알게될 수 있음</p>
</li>
</ul>
<hr>
<h2 id="12-코틀린의-주요-특성">1.2) 코틀린의 주요 특성</h2>
<h3 id="121-대상-플랫폼">1.2.1 대상 플랫폼</h3>
<blockquote>
<p>서버, 안드로이드 등 자바가 실행되는 모든 곳</p>
</blockquote>
<h3 id="122-정적-타입-지정-언어">1.2.2 정적 타입 지정 언어</h3>
<ul>
<li>코틀린은 자바와 마찬가지로 정적(static) 타입 지정 언어</li>
</ul>
<blockquote>
<p>💡 “정적 타입 지정”</p>
<ul>
<li>모든 프로그래밍 구성 요소의 타입을 컴파일 시점에 알 수 있음</li>
</ul>
</blockquote>
<ul>
<li>프로그램 안에서 객체의 필드나 메소드를 사용할 때마다 컴파일러가 타입을 검증해 줌
↔ 동적 타입 지정</li>
</ul>
<blockquote>
<p>JVM에서는 Groovy, JRuby가 대표적인 동적 타입 지정 언어</p>
</blockquote>
<ul>
<li><p>타입과 관계없이 모든 값을 변수에 넣을 수 있음</p>
</li>
<li><p>메소드나 필드 접근에 대한 검증이 런타임에 일어남</p>
</li>
<li><p>코드가 더 짧아지고, 데이터 구조를 더 유연하게 생성하고 사용할 수 있음</p>
</li>
<li><p>이름을 잘못 입력하는 등의 실수를 컴파일 시 걸러내지 못함 → 런타임에서 오류 발생</p>
</aside>
</li>
<li><p>정적 타입 지정 ↔ 동적 타입 지정</p>
</li>
</ul>
<pre><code>- 정적 타입 지정

    | 예시 언어 | C, C++, Java, Kotlin, Swift |
    | --- | --- |
    | 특징 / 장점 | - 강력한 타입 체크 → 런타임 오류 가능성을 줄임&lt;/br&gt;- 컴파일 최적화가 용이&lt;/br&gt;- 실행속도가 빠르고 효율적인 프로그램 작성 가능 |
    | 사용 분야 | - 대규모 프로젝트&lt;/br&gt;- 애플리케이션 개발 (성능 중요)  |

- 동적 타입 지정

    | 예시 언어 | Python, JavaScript, Ruby |
    | --- | --- |
    | 특징 / 장점 | - 변수 타입을 런타임에 결정 → 타입 선언이 간결하고, 코드의 양이 작음&lt;/br&gt;- 빠른 프로토타이핑과 스크립팅에 유리&lt;/br&gt;- 타입 변환 유연성↑ → 다양한 타입의 데이터를 쉽게 처리할 수 있음 |
    | 사용 분야 | - 작은 규모의 프로젝트&lt;/br&gt;- 데이터 과학, 웹 개발 등 |
    참고: https://f-lab.kr/insight/programming-language-type-systems</code></pre><ul>
<li><p>타입 추론 (type inference)</p>
<blockquote>
<p>컴파일러가 문맥을 고려해 변수 타입을 결정하는 기능</p>
</blockquote>
<pre><code class="language-kotlin">  var x = 1</code></pre>
</li>
<li><p>변수를 정의하면서 정수 값으로 초기화 → 코틀린이 이 변수의 타입이 Int임을 자동으로 알아냄</p>
</li>
<li><p>정적 타입 지정의 장점</p>
</li>
</ul>
<table>
<thead>
<tr>
<th>성능</th>
<th>실행 시점에 어떤 메소드를 호출할지 알아내는 과정이 필요 없음 → 메소드 호출이 더 빠름</th>
</tr>
</thead>
<tbody><tr>
<td>신뢰성</td>
<td>컴파일러가 프로그램의 정확성 검증</br> → 실행 시 프로그램이 오류로 중단될 가능성↓</td>
</tr>
<tr>
<td>유지보수성</td>
<td>코드에서 다루는 객체가 어떤 타입에 속하는지 알 수 있음</br>→ 처음 보는 코드를 다룰 때 더 쉬움</td>
</tr>
<tr>
<td>도구 지원</td>
<td>- 더 안전한 리팩토링</br>- 도구가 더 정확한 코드 완성 기능 제공 가능</br>- IDE의 다른 자원 기능도 더 잘 만들 수 있음</td>
</tr>
</tbody></table>
<ul>
<li><p>코틀린의 타입 시스템</p>
<ul>
<li><p>class, interface, generics는 모두 자바와 비슷하게 작동</p>
</li>
<li><p>nullable type 지원</p>
<p>  → 컴파일 시점에 null pointer exception이 발생할 수 있는지 여부를 검사 가능 (신뢰성↑)</p>
</li>
<li><p>함수 타입에 대한 지원</p>
</li>
</ul>
</li>
</ul>
<h3 id="123-함수형-프로그래밍과-객체지향-프로그래밍">1.2.3 함수형 프로그래밍과 객체지향 프로그래밍</h3>
<ul>
<li><p>함수형 프로그래밍의 핵심 개념</p>
<ul>
<li><p>일급 시민(first-class) 함수</p>
<ul>
<li>함수를 일반 값처럼 다룰 수 있음<pre><code> a. 함수를 변수에 저장</code></pre>  b. 함수를 인자로 다른 함수에 전달
  c. 함수에서 새로운 함수를 만들어 반환</li>
<li><ul>
<li><a href="https://readystory.tistory.com/96">코틀린(Kotlin)과 함수형 프로그래밍</a></li>
</ul>
</li>
</ul>
</li>
<li><p>불변성 (immutability)</p>
<ul>
<li><p>함수형 프로그래밍에서는 일단 만들어지고 나면 내부 상태가 절대로 바뀌지 않는 불변 객체를 사용해 프로그램을 작성함</p>
</li>
<li><p>Kotlin에서 가변성을 제한하는 방법</p>
<ol>
<li><p>읽기 전용 프로퍼티 val</p>
</li>
<li><p>Mutable 컬렉션과 read-only 컬렉션 구분</p>
<p> ex) ArrayList ↔ List</p>
<ul>
<li>읽기 전용: Iterable, Collection, Set, List</li>
<li>읽기&amp;쓰기: MutableInterable, MutableCollection, MutableSet, MutableList</li>
</ul>
</li>
<li><p>data class의 copy()</p>
</li>
</ol>
<p>  <em>참고:</em> <a href="https://velog.io/@wlsrhkd4023/Kotlin-%EB%B6%88%EB%B3%80%EC%84%B1Immutability%EA%B3%BC-%EA%B0%80%EB%B3%80%EC%84%B1Mutability">[Kotlin] 불변성(Immutability)과 가변성(Mutability)</a></p>
</li>
</ul>
</li>
<li><p>부수 효과(side effect) 없음</p>
<ul>
<li>함수형 프로그래밍에서는 <strong>순수 함수(pure function)</strong>를 사용<ul>
<li>입력이 같으면 항상 같은 출력이 나옴</li>
<li>함수 외부나 다른 바깥 환경과 상호작용하지 않음 (부수 효과 X)</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li><p>함수형 프로그래밍 장점</p>
<ol>
<li><p>간결성</p>
<ul>
<li><p>함수를 값으로 활용할 수 있으면 더 강력한 추상화 가능 → 이를 통해 코드 중복을 막을 수 있음</p>
</li>
<li><p>람다식(무명 함수 구문) → 공통 부분을 따로 함수로 뽑아내고, 서로 다른 세부사항을 인자로 전달할 수 있음</p>
<pre><code class="language-kotlin">  fun findAlice() = findPerson { it.name == &quot;Alice&quot; }
  fun findBob() = findPerson { it.name == &quot;Bob&quot; }</code></pre>
</li>
</ul>
</li>
<li><p>다중 스레드를 사용해도 안전함 (safe multi-threading) ← 감이 잘 안 잡힌다</p>
<ul>
<li><p>다중 스레드에서 문제가 생기는 경우: 여러 스레드가 같은 데이터를 적절한 동기화 없이 변경할 때</p>
</li>
<li><p>불변 데이터 구조 사용 &amp; 순수 함수를 그 데이터 구조에 적용하면</p>
<p>  → 다중 스레드 환경에서 여러 스레드가 같은 데이터를 변경할 수 없음</p>
<p>  → 복잡한 동기화를 적용하지 않아도 됨</p>
</li>
</ul>
</li>
<li><p>테스트 용이성</p>
<ul>
<li>부수 효과가 있는 함수는 준비 코드가 필요함<ul>
<li>그 함수를 실행할 때 필요한 전체 환경을 구성하는 것</li>
</ul>
</li>
<li>순수 함수는 그런 코드 없이 독립적으로 테스트할 수 있음</li>
</ul>
</li>
</ol>
</li>
<li><p>코틀린에서의 함수형 프로그래밍</p>
<ul>
<li>일반적으로 언어와 관계없이 함수형 스타일을 활용할 수 있음. 그러나 코틀린은 함수형 프로그래밍을 편하게 사용하기 위한 충분한 라이브러리와 문법을 지원함</li>
</ul>
</li>
</ul>
<blockquote>
<p>💡<strong>“코틀린은 아래와 같은 함수형 프로그래밍을 지원해요”</strong></p>
</blockquote>
<ul>
<li>함수 타입을 지원함에 따라 어떤 함수가 다른 함수를 파라미터로 받거나 함수가 새로운 함수를 반환할 수 있다.</li>
<li>람다 식을 지원함에 따라 번거로운 준비 코드를 작성하지 않아도 코드 블록을 쉽게 정의하고 여기저기 전달할 수 있다.</li>
<li>데이터 클래스는 불변적인 값 객체(value object)를 간편하게 만들 수 있는 구문을 제공한다 .</li>
<li>코틀린 표준 라이브러리는 객체와 컬렉션을 함수형 스타일로 다룰 수 있는 API를 제공한다.</li>
</ul>
<hr>
<h2 id="13-코틀린-응용">1.3) 코틀린 응용</h2>
<h3 id="132-코틀린-안드로이드-프로그래밍">1.3.2 코틀린 안드로이드 프로그래밍</h3>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/818a4a98-7648-45e2-b805-a222066950ff/image.png" alt=""></p>
<p>안드로이드 개발에 코틀린을 사용한 예제</p>
<ul>
<li>코틀린을 사용하면 애플리케이션의 신뢰성이 더 높아짐</li>
<li>NPE 문제를 줄여줌</li>
</ul>
<h2 id="14-코틀린의-철학">1.4) 코틀린의 철학</h2>
<blockquote>
<p>코틀린은 자바와의 1) 상호운용성에 초점을 맞춘 2) 실용적이고, 3) 간결하며, 4) 안전한 언어!</p>
</blockquote>
<h3 id="141-실용성">1.4.1 실용성</h3>
<ul>
<li><p>코틀린은 실제 문제를 해결하기 위해 만들어진 실용적인 언어</p>
</li>
<li><p>다년간의 IT 업계 경험을 바탕으로 이루어진 설계</p>
</li>
<li><p>다른 프로그래밍 언어가 채택한, 이미 성공적으로 검증된 해법&amp;기능에 의존</p>
<p>  → 언어 복잡도 ↓, 학습 러닝커브 ↓</p>
</li>
<li><p>도구를 강조 (IDE 지원)</p>
<ul>
<li>코틀린은 IntelliJ의 아이디어 &amp; 컴파일러 개발과 맞물려 이루어져옴</li>
<li>코틀린 언어의 특성은 항상 도구의 활용을 염두에 두고 설계되어 옴</li>
</ul>
</li>
</ul>
<h3 id="142-간결성">1.4.2 간결성</h3>
<ul>
<li><p>코드가 간단하고 간결할수록 내용을 파악하기 더 쉬움</p>
</li>
<li><p>코틀린은 프로그래머가 작성하는 코드에서 의미없는 부분을 줄이고, 부수적인 요소를 줄이기 위해 많은 노력을 기울임</p>
<p>  ex) getter, setter, 생성자 파라미터를 필드에 대입하기 위한 로직 등의 준비 코드를 코틀린은 묵시적으로 제공 (↔ 자바)</p>
</li>
<li><p>기능이 다양한 표준 라이브러리 제공 → 라이브러리 호출로 반복 / 길어질 수 있는 코드를 대치</p>
<ul>
<li>람다 지원 →  라이브러리 함수에 코드 블록을 쉽게 전달 가능</li>
</ul>
</li>
</ul>
<h3 id="143-안정성">1.4.3 안정성</h3>
<blockquote>
<p>💡“프로그래밍 언어가 안전하다” ⇒ 프로그래밍에서 발생할 수 있는 오류 중 일부 유형의 오류를 프로그램 설계가 원천적으로 방지해 줌</p>
</blockquote>
<ul>
<li><p>코틀린은 컴파일 시점에 오류를 많이 방지해줌 (컴파일 &gt; 런타임)</p>
</li>
<li><p><strong><code>NullPointerException</code></strong></p>
<ul>
<li><p>null 객체에 접근해서 메소드를 호출하는 경우 발생하는 에러</p>
<ul>
<li><p>null 허용을 위해서는 <code>?</code> 만 추가하면 됨</p>
<pre><code class="language-kotlin"> val s: String? = null // nullable
 val s2: String = &quot;&quot;</code></pre>
</li>
<li><p>코틀린 타입 시스템은 null이 될 수 없는 값을 추적</p>
</li>
<li><p>실행 시점에 NPE가 발생할 수 있는 연산을 사용하는 코드를 금지함</p>
</li>
</ul>
</li>
</ul>
</li>
<li><p><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-class-cast-exception/"><strong><code>ClassCastException</code></strong></a></p>
<ul>
<li><p>특정 클래스의 객체를 이와 호환되지 않는 다른 클래스의 객체로 전환하려 할 때 발생하는 에러</p>
</li>
<li><p>코틀린에서는 타입 검사와 캐스트가 한 연산자에 의해 이루어짐</p>
<pre><code class="language-kotlin">if (value is String) // 타입을 검사
  println(value.toUpperCase()) // 해당 타입의 메소드를 사용</code></pre>
</li>
</ul>
</li>
</ul>
<h3 id="144-상호운용성">1.4.4 상호운용성</h3>
<ul>
<li>코틀린은 자바의 기존 라이브러리를 그대로 사용할 수 있음</li>
<li>자바 &amp; 코틀린 소스 파일을 병행하더라도 제대로 프로그램 컴파일할 수 있음</li>
</ul>
<hr>
<h2 id="15-코틀린-도구-사용">1.5 코틀린 도구 사용</h2>
<h3 id="151-코틀린-코드-컴파일">1.5.1 코틀린 코드 컴파일</h3>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/582e84d3-d0ef-425d-82e1-c5a433a9b362/image.png" alt=""></p>
<ul>
<li>소스코드: <code>.kt</code> 확장자</li>
<li>코틀린 컴파일러: 코틀린 소스코드 분석 → <code>.class</code> 파일 생성</li>
<li>코틀린 컴파일러로 컴파일한 코드: 코틀린 런타임 라이브러리에 의존<ul>
<li>런타임 라이브러리: 코틀린 자체 표준 라이브러리 클래스와 코틀린에서 자바 API의 기능을 확장한 내용이 들어있음</li>
<li>코틀린으로 컴파일한 애플리케이션 배포 시에는 런타임 라이브러리도 함께 배포해야 함</li>
</ul>
</li>
<li>실제 개발 진행 시 프로젝트를 컴파일하기 위해 사용하는 빌드 시스템: maven, gradle, ant 등<ul>
<li>maven, gadle: 애플리케이션을 패키지할 때 알아서 코틀린 런타임을 포함시켜줌</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Jetpack Compose] 배너 캐러셀 구현하기]]></title>
            <link>https://velog.io/@nahy-512/Compose-carousel</link>
            <guid>https://velog.io/@nahy-512/Compose-carousel</guid>
            <pubDate>Sun, 04 May 2025 13:41:06 GMT</pubDate>
            <description><![CDATA[<h2 id="✍🏻-요구사항-분석">✍🏻 요구사항 분석</h2>
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/d0d13319-4c19-4f10-a7d0-43f8b6145359/image.png">

<p>Tving 앱 클론 코딩 중 &quot;홈 화면 구현&quot;이라는 과제를 받았습니다.</p>
<p>실제 티빙을 참고해보면 배너가 아래의 조건을 만족하고 있습니다.</p>
<blockquote>
<ol>
<li>슬라이딩을 통해 다음 포스터로 넘어갈 수 있어야 한다.</li>
<li>포스터 하나가 화면을 꽉 채우지 않고, 이전/이후 포스터가 가장자리에 조금 표시되어야 한다.</li>
</ol>
</blockquote>
<p>뭐.. 이외에도 티빙에서는 시간이 지나면 포스터를 자동으로 넘겨주기도 하는데, 저는 약식으로 위의 두 조건에 대해서만 고려해보려 했습니다.</p>
<p>아무튼 위의 기능 구현을 위해서 <code>캐러셀</code>이라는 키워드로 자료를 찾아 구현해보았습니다.
그래서 이번 포스트로는 배너 캐러셀 구현 방법에 대해 다뤄보겠습니다.</p>
<h2 id="💻-코드-작성">💻 코드 작성</h2>
<pre><code class="language-kotlin">@Composable
fun BannerCarousel(
    bannerImageList: ImmutableList&lt;Int&gt;,
    modifier: Modifier = Modifier
) {
    val pagerState = rememberPagerState(
        initialPage = 0,
        pageCount = { bannerImageList.size }
    )

    Column(
        modifier = modifier.fillMaxSize()
    ) {
        HorizontalPager(
            state = pagerState,
            contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.screen_padding_horizontal)),
            pageSpacing = dimensionResource(R.dimen.content_default_spacing),
            modifier = Modifier
        ) { pageIndex -&gt;
            Image(
                painter = painterResource(bannerImageList[pageIndex]),
                contentDescription = &quot;banner&quot;,
                contentScale = ContentScale.FillWidth,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(450.dp)
                    .clip(RoundedCornerShape(12.dp)),
            )
        }
    }
}</code></pre>
<p>다른 라이버러리 일절 없이 코드로 간단하게 구현할 수 있었어요.
<code>pageState</code>에 initialPage와 pageCount를 설정해 줍니다.
<code>HorizontalPager</code>에 state를 넘겨주고, contentPading과 pageSpacing을 설정해줍니다.
<code>contentPadding</code>은 가장 첫번쨰와 마지막 아이템을 화면에서 얼마나 띄워줄지의 간격이고, <code>pageSpacing</code>은 아이템 간의 간격입니다.</p>
<p><code>contentScale</code>은 <code>ContentScale.FillWidth</code>로 설정해 아이템이 화면을 꽉 채울 수 있게끔 해주었습니다.</p>
<p>컴포넌트로 만든 BannerCarousel을 사용처에서 불러오면 작업은 끝납니다!</p>
<pre><code class="language-kotlin">val bannerImageList = listOf&lt;Int&gt;(
        R.drawable.img_poster_ex1,
        R.drawable.img_poster_ex2,
        R.drawable.img_poster_ex3,
        R.drawable.img_poster_ex4,
)

LazyColumn(
    modifier = Modifier.fillMaxSize()
    ) {
    // TopBar, TabLayout 등 위치
    item {
        BannerCarousel(
            bannerImageList = bannerImageList
        )
    }
}</code></pre>
<p>티빙의 홈 화면은 복잡하게 구성되어 있었기에 저는 LazyColumn의 item으로 BanerCarousel을 넣어줬어요.</p>
<h2 id="📱-완성-화면">📱 완성 화면</h2>
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/4fb8295a-3648-43ae-83e1-58041c82d5dc/image.gif">

<p>약식으로 포스터만 넣고, 직접 슬라이드로 넘길 수 있게 구현했습니다!</p>
<p>추가 기능으로는 앞서 잠깐 언급했듯이 시간이 지나면 페이지를 자동으로 넘기거나, 아니면 실제 티빙처럼 포스트 이미지 말고도 콘텐츠 설명을 포스터 위로 표시해준다던가, 할 수도 있을 거 같아요.</p>
<p>모쪼록 제가 작성한 포스터가 누군가에게는 도움이 되기를 바라봅니다.</p>
<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li><a href="https://youtu.be/XyEOxwkObJc?feature=shared">https://youtu.be/XyEOxwkObJc?feature=shared</a></li>
<li><a href="https://developer.android.com/develop/ui/compose/layouts/pager?hl=ko">https://developer.android.com/develop/ui/compose/layouts/pager?hl=ko</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] strings.xml을 활용해 다국어 지원하기]]></title>
            <link>https://velog.io/@nahy-512/Android-strings.xml%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%B4-%EB%8B%A4%EA%B5%AD%EC%96%B4-%EC%A7%80%EC%9B%90%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@nahy-512/Android-strings.xml%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%B4-%EB%8B%A4%EA%B5%AD%EC%96%B4-%EC%A7%80%EC%9B%90%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 27 Apr 2025 21:04:03 GMT</pubDate>
            <description><![CDATA[<p>안드로이드 개발을 하다보면 <code>strings.xml</code>에 앱에서 사용하는 텍스트를 넣고 쓰곤 합니다.
<code>strings.xml</code> 사용 이유로는 문자열 관리와, 다국어를 번역을 간편하게 할 수 있다는 점이 있습니다.</p>
<p>저는 그간 안드로이드 개발을 하면서 이 <code>strings.xml</code>을 포함해 리소스 파일을 많이 활용했는데요,</p>
<p>제가 리소스 관련해 작성한 포스트를 몇 개 첨부해 봅니다. </p>
<blockquote>
<ol>
<li><a href="https://velog.io/@nahy-512/AndroidKotlin-String-%EB%A6%AC%EC%86%8C%EC%8A%A4-%ED%8C%8C%EC%9D%BC-%EC%82%AC%EC%9A%A9%EB%B2%95">[Android/Kotlin] strings.xml 문자열 리소스 제대로 활용하기 (feat. 데이터바인딩)</a></li>
<li><a href="https://velog.io/@nahy-512/AndroidKotlin-%EB%A6%AC%EC%86%8C%EC%8A%A4color-string-drawable-array-%EC%82%AC%EC%9A%A9">[Android/Kotlin] 리소스(color, string, drawable) 배열 사용</a></li>
<li><a href="https://velog.io/@nahy-512/AndroidKotlinMySQLwithJDBC2">[Android/Kotlin] 안드로이드 스튜디오에서 JDBC를 이용해 MySQL 연동하기 (2/3) - 폴더 구조화편</a></li>
</ol>
</blockquote>
<p>&#39;이렇게 활용하는 방법이 있구나&#39; 정도로 참고해 주시면 좋을 것 같습니다.
</br></p>
<h2 id="✔️-기본-사용법">✔️ 기본 사용법</h2>
<p>아무튼, 위의 포스트들을 작성하면서 &#39;문자열 관리&#39; 측면에서는 그간 strings.xml 사용의 필요성을 잘 느껴왔습니다.
당장 xml에서 문자열을 text에 그대로 때려박으면 경고를 뱉기도 하구요.. </p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/343da0b9-680d-49e5-8639-a42a74182ee7/image.png" alt=""></p>
<p><del>이렇게 하드코딩이라며 씅을 낸답니다</del></p>
<p>저기서 <code>Extract string resource</code>를 누르면
<img src="https://velog.velcdn.com/images/nahy-512/post/8566e836-97e8-41ea-a0d8-20c8d154c73e/image.png" alt=""></p>
<p>기존에 입력한 하드코딩 텍스트를 무슨 이름으로 리소스 지정을 할 건지, Resource name을 입력하라고 나옵니다.
Source set, File name은 디폴트로 res 폴더의 strings.xml로 지정되어 있습니다.
문자열을 다른 곳에서 관리하고 싶다면 저 부분을 자유롭게 변경해주시면 됩니다.</p>
<p>Resource name에 &#39;schedule_new&#39;를 입력하고, 추가를 해보겠습니다.
<image width=800 src="https://velog.velcdn.com/images/nahy-512/post/0d0a7994-094b-42b0-8568-a9bb1233f35c/image.png"></p>
<p>TextView를 보면 &#39;새 일정&#39;이던 text 속성값에 새로이 추가한 리소스명이 자동으로<code>@string/{리소스명}</code> 형태로 들어가고, 
<image width=500 src="https://velog.velcdn.com/images/nahy-512/post/986b869a-ca10-4faa-ba43-8f2b2e597f93/image.png"></p>
<p>아까 Extract Resource에서 File name으로 지정했던, strings.xml에 아래 코드가 추가됩니다.
<image width=600 src="https://velog.velcdn.com/images/nahy-512/post/1d5b061f-df12-4f4f-80a8-e4084a91adcc/image.png"></p>
<p>그럼 앞으로는 이 strings.xml에 앱 내에서 사용하는 문자열을 저장해놓고, 리소스명으로 접근해서 간편히 사용할 수 있는 것이죠!</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/b9209dfa-bc84-4801-9f52-11d1a748ef70/image.png" alt=""></p>
<p>티빙 클론 코딩을 진행하면서 strings.xml에 추가한 리소스인데요, 실제 서비스를 만드려면 이것보다도 훨씬 많은 리소스를 필요로 하게 될 겁니다.</p>
<p>화면 여러 곳에서 한 곳에 사용하는 문자열의 경우 이런 식으로 문자열 리소스로 관리하면 편합니다. 문자열이 화면이 아니라 리소스 파일에 속한다는 점도 관리 측면에서 이점이 있을 수 있겠죠.</p>
<p>그런데, 여기서만 끝나면 좀 아쉽겠죠?
<strong>문자열 리소스를 활용해 다국어 지원을 어떻게 손 쉽게 할 수 있는지!</strong>
바로 이어서 알아가 보겠습니다.</p>
<h2 id="🇰🇷-다국어-지원하기">🇰🇷 다국어 지원하기</h2>
<p>앞선 이 부분들을 한 번 한국어를 지원하도록 해볼게요!
<img src="https://velog.velcdn.com/images/nahy-512/post/2d67b974-97da-4d46-8a67-677efeb9f89b/image.png" alt=""></p>
<p>상단에 수상쩍은 Edit 뭐시기 배너에 <code>Open editor</code> 버튼을 눌러줍니다.
<img src="https://velog.velcdn.com/images/nahy-512/post/48321f1d-d4db-4be5-a5e3-ed5c0308006b/image.png" alt=""></p>
<p>그럼 이런 화면이 뜨게 될 거예요!</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/cf585eb1-84dc-4d52-8557-f04d087c3364/image.png" alt=""></p>
<p>상단에 지구본 모양 아이콘을 클릭해 지원을 원하는 국가의 언어를 찾아 클릭합니다.</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/c0170e2f-179f-4003-9f65-8536c4de15e7/image.png" alt=""></p>
<p>이전과는 달리 저장한 문자열 표에서 <code>Default Value</code> 옆에 앞서 선택한 언어가 추가된 걸 볼 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/ebec841b-530a-4ea1-9d78-9a51c7ce4d52/image.png" alt=""></p>
<p>strings.xml도 (ko-rKR) 번역본으로 하나 더 만들어졌네요~</p>
<p>이제 tab_뭐시기 친구들에 한국어 번역본을 제공해보겠습니다.
<img src="https://velog.velcdn.com/images/nahy-512/post/5d738711-1f4e-4e7b-b613-d36897436c90/image.png" alt="">
Translation Editor에서 한국어.ver 텍스트를 적어줍니다.</p>
<p>기존 strings.xml에서는 달라진 게 없고, &#39;strings.xml (ko-rKR)&#39;에서 아래 내용이 추가된 걸 볼 수 있어요.
<img src="https://velog.velcdn.com/images/nahy-512/post/289bb5ca-691f-4a7f-820c-3cce65db1543/image.png" alt=""></p>
<blockquote>
<p>하지만, 번역을 지원하고 싶지 않은 경우도 있겠죠?</p>
</blockquote>
<p>한 예로 앱 이름을 들어볼게요!
예를 들어 앱 이름 자체가 영어라면 굳이 번역을 지원할 필요가 없을 거 같아요.
<img src="https://velog.velcdn.com/images/nahy-512/post/cde0ba87-ae9a-4bc8-80a3-f99986eaef09/image.png" alt=""></p>
<p>그럴 경우에는 <code>Untranslatable</code>를 체크 표시 해주면 됩니다!</p>
<p>이렇게 해주면 &#39;strings.xml (ko-rKR)&#39;에 따로 번역본 문자열을 추가할 필요가 없고, 아래 이미지처럼 기본 strings.xml에 <code>translatable=&quot;false&quot;</code> 속성이 추가돼요. 문자열 추가 시에 strings.xml에 바로 <code>translatable=&quot;false&quot;</code> 속성을 작성해주시는 것도 물론 가능합니다.
<img src="https://velog.velcdn.com/images/nahy-512/post/548610d0-bea8-4afd-b114-06f8b6e67059/image.png" alt=""></p>
<h2 id="📱-테스트">📱 테스트</h2>
<p>&#39;설정 &gt; 일반 &gt; 언어 및 입력 방식 &gt; 언어&#39;로 들어가게 되면 언어를 변경할 수 있는데요,
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/13660c7c-66dc-4a4a-ae45-3d25ef099b79/image.jpg"></p>
<p>구현한 화면을 한국어, 영어로 설정했을 때 각각 화면입니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/621b6871-81c0-4a53-ba3b-8f70f4bb843b/image.jpg" alt=""> 한국어 설정</th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/480ba04e-7f6c-4053-90fb-5181e575099c/image.jpg" alt=""> 영어 설정</th>
</tr>
</thead>
</table>
<p>설정 언어에 따라 텍스트가 잘 바뀌어 보이는 걸 확인할 수 있었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SOPT] 36기 Android 최종 합격 후기]]></title>
            <link>https://velog.io/@nahy-512/SOPT-36th-Android-%EC%B5%9C%EC%A2%85-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@nahy-512/SOPT-36th-Android-%EC%B5%9C%EC%A2%85-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Mon, 31 Mar 2025 06:55:30 GMT</pubDate>
            <description><![CDATA[<h2 id="🤔-지원-동기">🤔 지원 동기</h2>
<blockquote>
<p><strong>💡 지원자 기본 정보 요약</strong></p>
</blockquote>
<ol>
<li>UMC 2~5기까지 2년간 활동함. 첫 시작은 Android였지만, UMC 하면서 이것저것 많이 찍먹해봄 (iOS, Server 등)</li>
<li>제법 많은 프로젝트 해 봄. (Android, iOS, Flutter로 앱 출시 경험 보유)</li>
</ol>
<p> 저는 22년도에 대학교에 입학하고부터 UMC에 들어갔어요. 처음 배웠던 건 Android로, 제 코드로 눈에 보이는 앱을 만들 수 있다는 점에서 앱 개발에 매료됐습니다. 2기 UMC 데모데이 때, 제가 기획하고 개발한 앱을 다른 사람들에게 시연하면서 설명했던, 그 기분은 살면서 잊지 못할 것 같아요.</p>
<p> 당시 UMC에서는 제가 가장 어렸고(아무래도 UMC는 대학생 연합 동아리였고, 저는 스무살부터 들어갔으니까요), 개발에 흥미를 처음 느끼고 나니 다양한 파트를 공부해보고 싶었어요. 해당 분야의 공부 자체에 대한 흥미보다는.. &#39;어쨌든 다른 사람보다 어린 나이에 동아리에 들어간 만큼 해보고 싶은 건 모두 해보자! 그리고 나에게 가장 맞는 파트로 세부 진로를 정하자!&#39;라는 생각이 컸습니다. 그래서 iOS, Design, Server(Spring Boot) 파트로 스터디를 하기도, 프로젝트를 하기도 했어요. 정말 쉬지 않고 프로젝트를 했습니다.</p>
<p> 23년도까지는 그런 제 모습에 만족했던 것 같아요. 어쨌든 간에 무언가 새로운 것을 계속해서 하고 있었으니까, &#39;나 정도면 열심히 살지 않나?&#39; 싶기도 했고. &#39;또래보다는 더 앞서가고 있지 않나?&#39; 하는 <del>멍청한</del> 자만에 사로잡혀 있었습니다.
 이런 제 생각이 무너졌던 건 2024년이 끝나갈 무렵이었어요. 어느 순간에 &#39;이렇게 개발하는 게 맞나? 내가 이런 태도로 개발하는데도 개발자라고 할 수 있나?&#39;하는 생각이 들었죠.
 발전, 도전을 갈구한다고 말했으면서 어느새 현실에 안주하고, 자꾸만 요령을 부리려 하고, 뒤를 돌아보지 못하는 사람이 되어 있었습니다. 이제는 제가 가진 강점이 뭐인지조차 모르겠다는 생각에 무척 답답한 기분으로 하루하루를 보냈답니다.</p>
<p> 어쨌든 제가 안드로이드 개발은 UMC 사람들과만 해보았기에, 프로젝트를 여러 개 해도 항상 비슷한 환경에서 비슷한 개발만 하고 있는 거 같았어요. 안드로이드 개발자 자체가 주변에 많이 없기도 했고요. 기술에 대한 심화적인 이해 없이 &#39;요새 좋다고 하니까&#39; 도입하고, &#39;다른 선택지도 있는데 왜 그 기술을 사용했는가?&#39;에 대한 답변을 생각하지 못했습니다.</p>
<p> 그래서 <strong>새로운 환경의 사람들과 교류하며 안드로이드를 제대로 공부하고 싶다</strong>는 생각에 SOPT에 지원하자는 마음을 먹었습니다. 그리고 그간 xml로만 프로젝트를 진행해봤기에, <strong>Compose를 배우고 이를 사용해 프로젝트를 해보는 경험</strong>도 얻고 싶었습니다.
 그동안 이것저것 넓게 해오기만 했으니 <strong>Android 개발로 더욱 개발을 잘하고 싶다</strong>는 생각도 컸습니다. 진행하는 프로젝트도 다 정리하고 SOPT에만 올인하자는 마음을 먹게 되었어요.</p>
<p> 그리고 지원 동기에 추가적인 큰 이유가 하나 더 있는데요.
 그동안 활동한 지인들을 보면 SOPT는 네트워킹 및 모임이 굉장히 활발한 것 같았는데, 저는 대학생 때밖에 지원하지 못하는 동아리이니만큼 <strong>&#39;체력이 더 깎이기 전에 열심히 놀자!!!!&#39;</strong> 라는 생각으로 지원했던 것도 있습니다. 2주간 합숙하며 같이 프로젝트 한다는 점도 굉장히 재밌을 것 같았습니다.</p>
<blockquote>
<p><strong>[지원 동기 3줄 요약]</strong></p>
</blockquote>
<ol>
<li>Compose 공부 &amp; 프로젝트</li>
<li>안드로이드 개발자들과의 네트워킹</li>
<li><del>대학생의 체력으로 많이 놀기</del> 많은 추억 쌓기</li>
</ol>
<blockquote>
<p><strong>[모집 일정]</strong>
<img src="https://velog.velcdn.com/images/nahy-512/post/e65db7e9-c87c-46c1-9d15-297f01876ea9/image.png" alt=""></p>
</blockquote>
<p>36기 모집 일정은 위와 같이 이루어졌어요!
서류 합격자 발표 후 면접 평가까지 텀이 그리 길지 않기에, 나 정말 SOPT에 붙고 싶다! 서류는 무조건 통과다! 하시면 서류 제출 후에 면접까지 바로 준비하시는 게 좋을 듯합니다.. <em><del>따흐흑. 저도 알고싶지 않았습니다.</del></em></p>
</br>




<!--
- 그간은 협업에만 자신 있는 개발자였는데.. 개발을 잘하고 싶다는 생각이 커졌음
    - 지금 내가 짜고 있는 코드가 맞나? 이렇게 해서 취업을 할 수 있을까?
    - 포폴을 쓰려고 하면서 고민이 깊어짐. 대체 뭘 써야함?
    - 제대로 몰입하는 경험을 하고 싶었다.
        - 그동안 UMC에서는 여러 파트 깔짝대기만 하고.. 하나를 깊게 판 적이 없었음
        - 이해하고 코드를 짠다는 건.. 그렇게 개발한다는 건 어떤 기분일까?
- 네트워킹. 주변에 안드 관련 지식을 나눌 사람이 너무 적다. 다들 UMC 출신이라 생각하는 것/쓰는 기술이 다 거기서 거기
    - 기술에 대한 심화적인 이해 없이 막 쓰고 있음
    - 왜 해당 기술을 골랐지?에 대한 고민이 없음
-->


<h2 id="📄-지원서">📄 지원서</h2>
<blockquote>
<p><strong>[공통 질문]</strong></p>
</blockquote>
<ol>
<li>목표를 이루기 위해 반복적인 시도나 긴 시간이 필요했던 경험이 있다면 설명해 주세요. 단기간에 성과를 내기 어려운 상황에서도 지속적으로 노력할 수 있었던 이유와 함께, 이러한 과정을 통해 어떤 성장을 이루었는지 구체적으로 작성해 주세요. (700자) </br></li>
<li>지원자님께서 생각하는 ‘좋은 팀’에 대한 조건을 2가지 이상 작성해 주세요. 이러한 조건을 바탕으로 ‘좋은 팀’을 만들기 위해 지원자님께서 어떠한 노력을 기울일 수 있는지 구체적인 경험을 바탕으로 작성해 주세요. (800자) </br></li>
<li>주어진 환경에 안주하지 않고 스스로 새로운 기회를 찾아 나섰던 경험이 있다면 공유해 주세요. 그 과정에서 무엇을 고민했으며, 이를 위해 어떤 행동을 했는지, 그리고 그 결과 어떤 성장을 이루었는지 구체적으로 서술해 주세요. (700자) </br></li>
<li>지원자님께서 팀 프로젝트나 협업 과정에서 팀원들에게 들었던 가장 인상적인 피드백은 무엇인가요? 해당 피드백을 받게 된 배경과 피드백을 통해 깨달은 점이나 변화한 점을 구체적으로 작성해 주세요. (700자)</li>
</ol>
<blockquote>
<p><strong>[파트별 질문 (Android)]</strong></p>
</blockquote>
<ol>
<li>지원자님을 가장 잘 나타낼 수 있는 한 가지 키워드는 무엇인가요? 해당 키워드를 선택한 이유와 함께, 그 키워드가 드러나는 실제 경험이나 사례를 구체적으로 공유해주세요. (700자) </br></li>
<li>실패를 경험했지만, 좌절하지 않고 다시 도전해 보았던 경험과 그 경험으로 얻을 수 있었던 교훈에 대해서 구체적으로 서술해 주세요. (800자) </br></li>
<li>협업을 할때, 타인과 차별화되는 본인만의 장점은 무엇이라고 생각하나요? 구체적인 사례와 함께 서술해 주세요. (700자) </br></li>
<li>지원자님이 사용해 본 프레임워크나 언어의 활용 정도에 대해 0~10의 점수로 적어주시고, 그중 가장 자신 있는 프레임워크나 언어를 학습한 과정을 구체적으로 설명해 주세요. 그리고 추후 안드로이드 학습 계획을 구체적으로 말씀해 주세요.
개발 경험이 없는 경우, 안드로이드 파트에서 충분히 성장할 수 있음을 적절한 근거를 통해 설명해 주세요. 그리고 추후 안드로이드 학습 계획을 구체적으로 말씀해 주세요. (800자)</br></li>
<li>위 내용을 증빙할 수 있는 자료를 자유롭게 공유해주세요. 해당 항목은 선택 사항이며, 면접 참고 자료로만 활용됩니다. (1000자)
* <em>Github, Notion, Blog 링크 등 자유롭게 선택하여 제출해 주세요. (Notion의 접근 권한 등 참고 자료의 열람 권한을 확인해 주시기를 바랍니다.)</em></li>
</ol>
<p>SOPT 지원서에 대한 악명을 이전 기수에 활동했던 학교 선배로부터 전해들어서 마음의 준비는 하고 있었지만.. 실제로 마주봤을 때는 숨이 조금 막히는 느낌이었습니다.</p>
<p>공통 질문 4개, 파트별 질문 5개였는데 7~800자씩 채우는 게 무척... 쉽지 않았습니다.
5번은 사실 선택 사항이라 글자수 채워야한다는 부담은 없었고, 나머지 문항을 다 채운다고 하면 도합 5,900자가 되겠네요!! 🫠
그렇지만 지원서에서 문항이 요구하는 글자수를 꽉꽉 채우는 건 기본 중에 기본이라고 생각합니다. 저도 그래서 최대한 글자수를 다 맞추려 노력했답니다..</p>
<p>위의 모든 문항을 개발 쪽으로 풀어내기는 쉽지 않겠다 싶었고, <strong>살면서 인상 깊었던 경험과도 많이 연관</strong>지으려고 했던 것 같아요.</p>
<p>예시로 저는 &lt;공통 질문의 1번 문항&gt;의, &#39;목표를 이루기 위해 반복적인 시도나 긴 시간이 필요했던 경험&#39;에서는 발표 공포증 극복을 위해 스피치 학원을 다니고, 동아리 컨퍼런스에서 학생 연사자로 발표했던 경험을 이야기했습니다.
그리고 &lt;파트별 질문의 2번 문항&gt; &#39;실패를 경험했지만, 좌절하지 않고 다시 도전해 보았던 경험&#39;으로는 작년 학교 해외 연수 프로그램에서 2번 떨어졌던 경험을 풀어냈어요. 비록 실패만 2번이었지만 합격보다 더 값진 교훈을 얻었다며 깨달은 점이 많았다는 걸 어필했습니다.</p>
<p>SOPT 서류 문항을 살펴보면, 모든 문항이 주제에 대한 질문을 던진 후에 이 과정에서 내가 어떻게 행동했는지, 나에게 어떤 변화가 있었는지를 &#39;구체적&#39;으로 작성하라고 써져 있습니다.
이에 그냥 지어낸 말이 아닌, 내가 실제로 경험한 일임을 예시로 들어 증명하고자 했습니다.</p>
<p>파트별 질문 5번 문항에서는 Github, 노션 포트폴리오, Velog 링크를 제출했고, 노션으로 지원서에서 답한 내용을 증빙할 수 있는 자료를 작성하여 링크를 제출했습니다. </p>
<p>그 결과..
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/ad680dce-b4d0-4d56-95ec-2971b7b37a3f/image.PNG"></p>
<p>서류 합격~!! 이라는 결과를 받을 수 있었습니다.</p>
<p>사전에 공지됐던 3/20(목) 오후 4시에 맞춰 &#39;YB 서류 결과 확인&#39;이라는 내용으로 문자가 도착했는데요,
합격 안내는 SOPT recruit 페이지에서 개인 정보와 비밀번호를 입력하고, 합격 결과 조회 버튼을 눌러서 확인할 수 있었어요.
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/7ca948f7-bfcc-47dc-962d-e5a54088f8f6/image.PNG"></p>
<p><em>이렇게 생겼어요ㅎ.ㅎ</em></p>
<p>면접은 <strong>서류 결과가 나온 주의 주말, 토요일과 일요일 이틀에 걸쳐서 진행</strong>됐는데요,
서류 합격을 하고 나면 바로 면접 시간표가 나오는 게 아니라, 서류 합격 페이지의 구글폼 링크를 통해 가능한 면접 시간을 응답하면 나중에 메일로 다시 면접 시간표와 면접 안내를 해주는 구조였습니다.</p>
<h2 id="🗣️-면접">🗣️ 면접</h2>
<p>면접 안내와 시간표는 서류 발표 하루 뒤인, 3/21(금) 오후 2시에 메일로 왔어요!
메일에는 면접 안내(장소, 일시), 면접 절차가 적혀 있었어요.</p>
<p>저는 토요일 면접으로 배정받았는데, 토요일 하루 함께 면접을 보는 분들의 이름과 파트가 모두 적혀있었습니다. 면접은 9:00 ~ 19:30 동안 진행된다고 적혀있었는데, 시간표 상으로 한 팀마다 55분의 시간이 배정되었고, 회장단과 파트장 면접 두 종류로 나눠지다 보니 타임 별로 30분의 간격이 있었습니다.</p>
<!-- 세어보니 토요일은 총 18팀이었어요. 한 팀당 배정된 인원은 6명 씩이었습니다.-->

<p><strong>면접 30분 전에는 면접 장소에 도착</strong>해야한다는 것과 <strong>실물 재학증명서를 꼭 가져오라는</strong> 안내도 받았습니다.</p>
<p>그리고 아래와 같이 면접 절차에 대한 설명도 있었어요!</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/4e7323de-ac63-46e6-bca7-f940a5b6a9ed/image.png" alt=""></p>
<h3 id="면접-준비">면접 준비</h3>
<ol>
<li><p><strong>지원서 다시 읽어보기</strong>
면접 후기에서 지원서 기반으로 개인 질문이 많이 들어온다고 보았기에, 저는 제가 작성했던 지원서를 쭉 읽어보면서 &#39;어떤 부분에서 질문이 나올까?&#39;를 많이 생각해봤습니다. 따로 답변을 적어서 달달 외우려고 하진 않았어요. 예상 질문 내에서 무조건 질문이 나올 거라 생각하지 않았기에 준비했던 질문이 아니라면 당황할 것 같았고, 그간의 면접 경험 상 저는 답변을 외워가면 긴장으로 답변이 생각나지 않을 때가 많았거든요.</p>
</li>
<li><p><strong>핵심 가치 머리속에 집어넣기</strong>
SOPT의 핵심 가치는 기수마다 달라지는데, 이번 36기 AT SOPT의 핵심 가치는 <code>끈기</code>, <code>연결</code>, <code>도약</code> 이 3가지였습니다. 어쨌든 내가 이 집단에서 원하는 인재상이라는 점을 어필하는 게 중요하다고 생각해서, 이 3가지 가치를 답변 속에 잘 녹여내야겠다고 생각했습니다.
<img src="https://velog.velcdn.com/images/nahy-512/post/678d35a9-0173-46ac-b275-97926d062a25/image.png" alt=""></p>
</li>
<li><p><strong>사용 기술 복습</strong>
&lt;파트별 질문 4번 문항&gt;의, 안드로이드 공부 경험을 묻는 문항에서 제가 사용해봤다고 적었던 기술의 개념을 조금 복습했어요. 그리고 제가 지원서에 클린 아키텍처로 리팩토링해 본 경험이 있다고 적었기에, &#39;이 부분에선 무조건 질문 들어올 것 같다&#39;는 생각에 면접 당일, 일어난 뒤에 기존에 들었던 클린 아키텍처 강의에서 중요한 부분을 속성으로 다시 듣고 갔습니다. <del>(근데 이 과정에서 프로젝트에 완전히 잘못 적용했다는 걸 알게돼서 면접에서 질문 들어오면 이실직고해야겠다, 마음을 먹게 되었습니다.)</del></p>
</li>
</ol>
</br>

<h3 id="면접-당일">면접 당일</h3>
<p>저는 토요일 17:35 ~ 18:30 면접 타임이었는데, 집에서 일찍 출발해서 1시간이나 빨리 도착했습니다.
면접 장소였던 <code>동국대학교 신공학관</code>으로 가니 1층에 책상이 하나 놓여져있고, SOPT 면접 안내위원 한 분이 앉아계셔서 제게 SOPT 면접을 보러 왔냐고 물으시고, 몇 시 면접인지를 확인하셨습니다. 제가 면접 시간을 말하고 1시간 일찍 왔는데 괜찮냐고 여쭤보니 상관없다고, 위쪽으로 가면 된다면서 안내를 해주셨습니다.</p>
<p>중간중간 붙어있는 SOPT 대기실 안내 종이?를 따라 위쪽으로 올라갔습니다.
대기실은 하나만 쓰는 것 같았어요. 들어가서 이름과 재학증명서를 보여드리고 자리에 앉았습니다.</p>
<p>아이스브레이킹은 약 10~15분 정도 진행됐어요. 대기실에 있던 6명이 같이 한 방으로 안내받았고, 그 곳엔 4분이 앉아서 저희의 긴장을 풀어주셨습니다. 사실 그거 답변하면서도 떨렸어요..</p>
<!-- 6명이 돌아가면서 오늘의 TMI 이야기하고, -->

<p>아이스브레이킹이 끝나고는 3명씩 두 팀으로 나뉘어서 각각 임원진 면접의 방으로 이동했습니다.</p>
<h3 id="회장단-면접-30분">회장단 면접 (30분)</h3>
<blockquote>
<p>多:多 면접</p>
</blockquote>
<p>방으로 들어가니 임원진 두 분이 앉아계셨습니다. 즉, <code>면접관 2 : 지원자 3</code> 면접을 하게 되었습니다.
제가 봤던 이전 기수 후기에서는 프론트 분들이 주로 가운데에 앉으셨다고 했는데, 저는 PM, Server 분과 함께 면접봤는데 제가 가장 오른쪽(첫 순서)이었어요. (엉엉)</p>
<p>질문 한 번이 끝날 때마다 계속해서 방향이 바뀌긴 합니다. (이전 질문에서 첫 순서였다면, 다음 질문에서는 마지막에 답변하는 구조)</p>
<blockquote>
</blockquote>
<ul>
<li>자기소개</li>
<li>(개인) 발표 울렁증 극복을 위해 스피치 학원을 다녔다고 했는데, 발표를 어려워하는 팀원이 있다면 어떤 조언을 해줄지</li>
<li>(개인) 프로젝트에서 깃허브 PR 템플릿을 제안한 경험이 있다고 했는데, PR 내용 중 인상깊었던 게 있는지?</li>
<li>(공통) 팀의 목표와 개인의 목표가 상충한다면 어떻게 조율할지</li>
<li>(공통) 리드와 팔로워 중 나는 어느 쪽인지</li>
<li>(공통) SOPT에서 여러 활동을 하면 바쁠텐데, 어떻게 조율할지. 그리고 그랬던 경험이 있다면 구체적으로</li>
<li>(공통) SOPT에서 이루고 싶은 목표</li>
<li>마지막으로 하고싶은 말</li>
</ul>
<p>최소한 자기소개와 마지막 하고 싶은 말은 준비하자! 싶어서 면접 대기실에서도 자기소개를 열심히 외웠었는데요..,,
막상 제가 첫 순서로 말을 해야되니까 너무 떨리더라구요.
자기소개는 망쳤다 싶었고, 대신에 다른 질문에 열심히 답변하고자 다른 분들이 자기소개하는 동안 심호흡하면서 마인드 컨트롤을 했습니다.</p>
<p>자기소개 후 반대 순서로 각자 개인 질문을 받았는데, 저한테 온 질문이 &#39;발표 울렁증 극복을 위해 스피치 학원을 다녔다고 했는데, 발표를 어려워하는 팀원이 있다면 어떤 조언을 해줄지?&#39;였습니다. 자기소개에서 이미 발표 울렁증을 완전히 극복하진 못했음이 드러나서 좀 민망하긴 했지만, 그래도 제 심각한 발표 울렁증만큼은 진실임을 증명했다 싶은 생각으로 대답했습니다. 이후 이루어진 질문들에는 아무말이나 했던 것 같긴 한데.. 아무튼 자기소개 이후 마인드 컨트롤 덕인지 최소한 떨지는 않았다는 것에 의의를 두기로 했습니다.</p>
<p>중간중간 임원진 분들과 눈 마주칠 때가 있었는데, 절 보며 간간이 웃음 지어주셔서.. 이 점도 심신의 안정을 되찾는 데 도움이 되었던 것 같아요. (따수운 임원진 분들..🥹)</p>
<p>개인 질문은 제 지원서를 기반으로 진행됐는데, 예상보다는 것보다는 가볍게 들어왔어요. 그냥 나라는 사람이 어떤 사람인지 더 알고 싶다거나, 실제로 경험한 일이 맞는지 확인차 물어보는 질문이란 느낌이었습니다. 그래서 지원서 내용에 거짓이 없으면 개인별 질문은 크게 걱정하지 않아도 될 것 같다, 라는 팁을 조심스레 전해봅니다. <del>(지원서에 실제 경험을 녹여내기 힘들다고 해서 창작하시는 분들은 없으시겠지만요)</del></p>
<!-- 마지막으로 하고 싶은 말에서는 연합 동아리는 개인이 하는 만큼 얻어가는 게 다르다고 생각하기에, SOPT에서 다양한 분들과 네트워킹을 하고-->

<p>임원진 면접이 다 끝나고 복도에서 대기하는 시간이 잠깐 있었습니다.
<del>파트장 면접 방에 들어가기 전, 같이 면접을 본 2명의 지원자분과 스몰토크를 했던 기억이 남습니다. 저는 다들 답변을 너무 잘하시더라, 하는 감탄의 말씀을 전했습니다.</del></p>
<h3 id="파트장-면접-25분">파트장 면접 (25분)</h3>
<blockquote>
<p>1:1 면접 </p>
</blockquote>
<p>파트장 면접은 다른 임원진 면접 방에 들어갔던 세 분도 합쳐서, 같은 면접 타임에 배치된 6명이 한 방에서 진행했어요.</p>
<p>전혀 생각 못 했던 구조인데, 넓은 방 하나에 파트장 님들이 간격을 띄워 둥글게 배치되어 있었어요.
들어가기 전 문에 붙어있는 각 파트 파트장님의 위치를 확인하고, 다함께 입장해 본인 파트의 파트장님 앞에 가서 앉는 구조였습니다. <del>이래서 면접 후기에 카페같았다는 후기가 있었나? 싶었답니다.</del></p>
<p>같은 타임에 같은 파트 개발자가 있다면 1:2로 면접을 보는 것 같았는데요. 제 타임에 안드로이드 지원자는 저밖에 없어서 저는 독대를 진행했답니다.</p>
<p>받았던 질문은 아래와 같았습니다.</p>
<blockquote>
</blockquote>
<ol>
<li>자기소개</li>
<li><code>기술 질문</code></li>
</ol>
<ul>
<li>리사이클러뷰와 리스트뷰의 차이점</li>
<li>코틀린 고차함수가 뭔지 <em>-&gt; 바로 모른다고 대답함</em></li>
<li>(지원서) 프로젝트에서 클린 아키텍처를 사용했다고 했는데, 클린 아키텍처가 뭔지 설명해줄 수 있는지</li>
</ul>
<ol start="3">
<li><code>기타 질문</code></li>
</ol>
<ul>
<li>SOPT의 6개 파트 중 안드로이드에 지원한 이유</li>
<li>안드로이드 커리큘럼 중 가장 기대되는 점</li>
<li>SOPT에서 가장 기대되는 활동</li>
<li>최근에 무언가에 열정적으로 도전했던 경험이 있는지, 있다면 도전에서 뭘 배웠는지</li>
<li>프로젝트를 함에 있어서 본인의 장단점</li>
<li>(지원서) 학교 해외연수 프로그램에서 호주에서 한 번 탈락하고 미국을 재지원한 이유. 그리고 도전 끝에 뭘 배웠는지</li>
<li>새로운 환경에서 새로운 사람들과 어떤 방법으로 친해지는지</li>
<li>무언가에 압박을 느끼는 상황이 생긴다면 어떻게 해결하는지</li>
<li>SOPT에서 이루고 싶은 목표</li>
<li>목표를 이루기 위해 현재 노력중인 게 있다면?</li>
<li>마지막 하고 싶은 말</li>
</ul>
<p>사실 처음 파트장님과 마주보고 이야기했을 때는 조금 무섭고 떨렸지만, 질문에 답변하다보니 점차 편해졌어요.
면접 후 질문을 다시 복기해보면서 &#39;질문이 이렇게 많았었나?&#39; 싶은 생각이 들었는데.. 실제로 면접했을 때 제가 느꼈던 건 파트장님이 하신 질문은 굉장히 빨리 끝났고, 이런저런 사담을 오래 나눈 느낌이었습니다.
못해도 사담 시간이 10분 정도는 됐던 것 같다? 위에 적어놓은 질문 목록 중에 어쩌면 사담 중 이야기했던 것도 끼어있을 수 있습니다.</p>
<p>아무튼 제 기억 상으로는 자기 소개 이후 <strong>기술 질문</strong>이 바로, 연달아서 쭉 이루어졌던 걸로 기억하는데요.
첫 질문이 리사이클러뷰와 리스트뷰의 차이에 대한 질문으로, 그래도 가볍게 시작해서 다행이라고 생각했습니다.
코틀린 고차함수가 뭔지 아냐는 질문이 들어왔을 때는 난생 처음 들어보는 용어라 그냥 모른다고 바로 대답했구요.
지원서를 기반으로 클린 아키텍처 질문이 들어왔을 때는 &#39;올 게 왔구나!&#39;하는 생각이었습니다. 우선은 6개 레이어로 나뉜다.로 시작해서 레이어 이름을 말하면서 개념을 잠깐 설명하다가.. &#39;최근에 스터디 하면서 클린 아키텍처로 공부하다 보니까 프로젝트에 적용했던 클린 아키텍처에 부족함이 많음을 알게되었다. 혹시 질문에 대한 답변 대신 제가 발견한 저희 프로젝트에서 적용한 클아 개선점을 말씀드려도 되겠냐.&#39;를 조심스럽게 여쭤보았습니다. 파트장님이 흔쾌히 승낙해주셔서 변명의 시간을 가질 수 있었습니다. 이 점이 긍정적으로 보여졌을까?는 자신이 많이 없었지만, 그래도 제대로 모르는 걸 아는 척 얘기하는 것보다는 솔직하게 털어놓고 싶었습니다.</p>
<p>어쨌든 기술 질문은.. 전체적으로 걱정했던 것보다 기술 질문이 많고 딥하진 않다! 싶어서 너무너무 다행이었습니다. <del><em>이 부분은 기수별 파트장님 방침에 따라 정말정말 천차만별일 것 같긴 합니다.</em></del></p>
<p>마지막 하고 싶은 말까지 마친 뒤에는 찐 사담이 이루어졌습니다.
오는 데 얼마나 걸렸냐, 점심은 먹었냐. 이런 가벼운 말부터 시작해서 아까 기술 질문으로 나왔던 클린 아키텍처 얘기도 잠깐 했습니다. 파트장님의 클린 아키텍처에 대한 생각도 잠깐 들었고, 강의에서 뭐가 제일 인상 깊었느냐는 질문도 받았습니다. 개발 토크지만 아까의 기술 질문보다는 훨씬 가벼운 분위기로 말할 수 있었던 시간이었습니다.
이외에 뭐 SOPT 어떻게 알게 됐는지, SOPT 안드로이드 팀원들이랑 하고 싶은 게 있는지. 이런 질문도 받았던 것 같습니다. 이 때는 파트장님도 따로 기록 없이 편하게 들으셨기에 면접보다는 커피챗을 하는 듯한 기분이었어요.</p>
<p>사담을 나누면서 저도 SOPT에 궁금했던 점을 이것저것 물어볼 수도 있었어요. 안드로이드 스터디는 어떤 게 열렸는지나 이번에 OB는 몇 명이나 되는지, 파트장 님께선 어떤 스터디를 해보셨을지. 대강 그런 거 질문드렸던 것 같습니다.</p>
<p>마지막 어필 때 말했던, &quot;SOPT에서 누구보다 많은 걸 얻어가는 사람이 되고 싶습니다.&quot;라는 저의 말에 &quot;SOPT에 들어오시면 많이 배우게 되실 거예요.&quot;라는 파트장님의 말이 머리속에서 계속 맴돌았어요.</p>
<!-- 의례 하는 말이 아니라, 제 상황을 말씀드렸을 때 진심으로 그렇게 믿으신다는 듯이 개발에 관해 제가 가진 고민들을 SOPT에서는 해소할 수 있겠다!-->

<p>면접까지 보고 난 이후에는 &#39;이 동아리에 너무 들어가고 싶다&#39;는 생각이 더더욱 커졌습니다.</p>
<h2 id="📢-최종-결과-발표">📢 최종 결과 발표</h2>
<p>파트장 면접 때 사담 나눴던 걸로는 분위기가 괜찮았던 거 같은데, 임원진 면접을 대답을 제대로 못해 괜찮을까?하는 걱정으로 떨리는 마음으로 결과를 기다렸습니다.</p>
<p>그렇게 대망의 최종 결과 발표 날...!!!!
<img width=400 src="https://velog.velcdn.com/images/nahy-512/post/6531f110-24d0-4ab7-a6f7-4b97b6978170/image.jpg"></p>
<p><strong>합격!!!!!!</strong>을 받았습니다.
학교 수업이 끝나자마자 확인했는데 너무 행복했습니다. 집 가는 길에도 계속 기분 좋았던 것 같아요.</p>
<p>앞으로 SOPT에서 어떤 활동들이 저를 기다리고 있을지, 어떤 사람들을 만나 어떤 경험과 성장을 하게 될지 벌써부터 너무 기대가 됩니다.</p>
<p>SOPT 활동 기간만큼은 SOPT에만 최선을 다할 생각입니다. 스터디도 많이 참여하고, 솝커톤도 하고. YB로서 할 수 있는 건 이것저것 다 하고 싶어요. 팀원들과 합숙하면서 프로젝트를 진행하는 앱잼도 너무 기대하고 있어요.</p>
<p>SOPT에 대한 내용은 36기 종무식 이후 SOPT 활동 후기로 다시 찾아오겠습니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android/Kotlin] WebView 연동]]></title>
            <link>https://velog.io/@nahy-512/AndroidKotlin-WebView-%EC%97%B0%EB%8F%99</link>
            <guid>https://velog.io/@nahy-512/AndroidKotlin-WebView-%EC%97%B0%EB%8F%99</guid>
            <pubDate>Tue, 11 Feb 2025 05:55:10 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요, 오늘은 프로젝트에서 웹뷰 연동을 했던 내용을 정리해보려고 합니다!</p>
<p>1) 홈과 마이페이지를 웹뷰로 띄우고,
2) 웹 개발자 분들이 주신 프로토콜에 맞춰 앱에 저장한 토큰을 넘기고,
3) 웹에서 네이티브로 넘기는 메시지를 통해 홈에서의 화면 이동 및 마이페이지에서의 로그아웃, 회원탈퇴 처리를 한 과정 전반을 공유해보겠습니다.</p>
<h2 id="✍🏻-요구사항-분석">✍🏻 요구사항 분석</h2>
<p>진행하던 공모전용 프로젝트에서 <strong>홈</strong>과 <strong>마이페이지</strong>는 웹뷰로 보여주어야 한다는 요구사항이 있었습니다.</p>
<p>진행해야 하는 작업은 크게 아래의 4가지였습니다.</p>
<blockquote>
<ol>
<li>WebView 띄우기</li>
<li>웹으로 토큰을 넘기기</li>
<li>웹뷰 내 컴포넌트 클릭 시 화면 이동 시키기 (앱에서 지원하는 화면)</li>
<li>마이페이지 로그아웃, 회원탈퇴 시 앱 내 저장된 토큰 삭제 + 로그인 화면으로 이동</li>
</ol>
</blockquote>
<p>이를 위해서는 웹-네이티브 통신 프로토콜을 맞춰야 했고, 웹 개발자 분들이 브릿지 관련 코드를 정리해서 <a href="https://github.com/Route-Box/route_box_web/blob/66bb58c267b92a8a30e35679a96de03af6058971/docs/web-native-protocol.md">문서</a>로 제공해 주셨습니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/627c2621-d2a8-4753-9dac-5e230c820e24/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/dcfb086b-6396-473f-a613-2641b47a1939/image.png" alt=""></th>
</tr>
</thead>
</table>
<p>그럼, 각 작업에 대해 순차적으로 코드를 설명드려 보겠습니다!</p>
</br>

<h2 id="💻-코드-작성">💻 코드 작성</h2>
<h3 id="1️⃣-webview-띄우기">1️⃣ WebView 띄우기</h3>
<p>기본 설정에 관한 내용입니다.</p>
<p>Constants에 아래와 같이 웹 주소와 endPoint 설정을 해줬습니다.</p>
<pre><code class="language-kotlin">object Constants {
    const val WEB_BASE_URL = BuildConfig.WEB_BASE_URL // 웹 주소

    // 웹 endPoint
    const val ENDPOINT_HOME = &quot;/&quot; // 홈
    const val ENDPOINT_MY = &quot;/my-page&quot; // 마이페이지
}</code></pre>
<p>그리고, 기본 웹뷰를 띄우는 코드는 아래와 같이 작성해 줬어요.</p>
<pre><code class="language-kotlin">@SuppressLint(&quot;SetJavaScriptEnabled&quot;)
private fun initWebViewSetting() {

        binding.homeWebView.apply {
            // 세팅
            settings.javaScriptEnabled = true // JavaScript를 사용한 웹뷰를 로드한다면 활성화 필요
            settings.loadWithOverviewMode = true // 컨텐츠의 크기가 WebView 보다 클 경우, 스크린에 맞게 자동 조정

            webViewClient = WebViewClient()

            loadUrl(&quot;$WEB_BASE_URL$ENDPOINT_HOME&quot;)

            // WebView 뒤로가기 설정
            setOnKeyListener(View.OnKeyListener { _, keyCode, event -&gt;
                if (event.action != KeyEvent.ACTION_DOWN) return@OnKeyListener true
                // 뒤로가기 버튼을 눌렀을 때, WebView에서 뒤로가기가 된다면 뒤로가고 아니라면 종료
                if (keyCode == KeyEvent.KEYCODE_BACK) {
                    if (this.canGoBack()) {
                        this.goBack()
                    } else {
                        requireActivity().onBackPressedDispatcher.addCallback(object :
                            OnBackPressedCallback(true) {
                            override fun handleOnBackPressed() {}
                        })
                    }
                    return@OnKeyListener true
                }
                false
            })
        }
}</code></pre>
<p>웹뷰에서의  뒤로가기 지원을 하기 위해 <code>setOnKeyListener</code>를 추가해 줍니다.</p>
<h3 id="2️⃣-토큰-전송하기">2️⃣ 토큰 전송하기</h3>
<h4 id="프로토콜">프로토콜</h4>
<pre><code class="language-kotlin">enum class MessageType {
    TOKEN, PAGE_CHANGE, TOKEN_EXPIRED;

    companion object {
        fun findMessageType(messageTypeString: String): MessageType {
            return MessageType.valueOf(messageTypeString)
        }
    }
}

data class TokenPayload(
    val token: String
)

data class NativeTokenRequestMessage(
    val type: String = MessageType.TOKEN.name,
    val payload: TokenPayload
)</code></pre>
<p>웹 측에서 제공한 문서대로 <code>MessageType</code>를 enum class로 미리 만들었습니다.
그리고 아래 형식대로 토큰을 전달할 때 사용할 data class도 미리 만들어 줍니다.
<img src="https://velog.velcdn.com/images/nahy-512/post/d6dc141f-5091-42a3-9fea-70626e92a1bb/image.png" alt=""></p>
<h4 id="콜백-관련-인터페이스-구현">콜백 관련 인터페이스 구현</h4>
<pre><code class="language-kotlin">interface NativeMessageCallback {
    fun onReactComponentLoaded(boolean: Boolean) // 페이지 로드가 완료됐을 때
}</code></pre>
<h4 id="브릿지">브릿지</h4>
<pre><code class="language-kotlin">abstract class WebViewBridge(private val callback: NativeMessageCallback) {
    @JavascriptInterface
    fun sendMessageToNative(message: String) {
        Log.d(&quot;WebViewBridge&quot;, message)
        if (message.contains(&quot;loaded&quot;)) {
            callback.onReactComponentLoaded(true)
        }
    }

    companion object {
        const val INTF = &quot;Android&quot;
    }
}</code></pre>
<h4 id="fragment">Fragment</h4>
<pre><code class="language-kotlin">class HomeFragment : Fragment(), NativeMessageCallback {
    @SuppressLint(&quot;SetJavaScriptEnabled&quot;)
    private fun initWebViewSetting() {
        // 웹뷰 설정
        binding.myWebView.apply {
            // ... 세팅 
            settings.domStorageEnabled = true // 웹뷰에서 LocalStorage를 사용해야 하는 경우 활성화 필요

            addJavascriptInterface(object : WebViewBridge(this@MyFragment) {}, WebViewBridge.INTF)
            webViewClient = WebViewClient()
            loadUrl(&quot;$WEB_BASE_URL$ENDPOINT_HOME&quot;) // 웹 주소

            // ... WebView 뒤로가기 설정
        }
    }

    // 네이티브 앱에서 웹뷰로 TOKEN 메시지 보내기
    private fun sendTokenToWebView() {
        binding.homeWebView.evaluateJavascript(&quot;javascript:sendMessageToWebView(${getRequestMessage()})&quot;, null)
    }

    private fun getRequestMessage(): String {
        val nativeMessage = NativeTokenRequestMessage(
            payload = TokenPayload(getSavedAccessToken())
        )

        return Gson().toJson(nativeMessage)
    }

    // 앱 내 저장된 토큰 정보 가져오기
    private fun getSavedAccessToken(): String = runBlocking {
        dsManager.getAccessToken().first().orEmpty()
    }

    override fun onReactComponentLoaded(boolean: Boolean) {
        // 0.5초 지연 후 토큰 전송
        Handler(Looper.getMainLooper()).postDelayed({
            sendTokenToWebView()
        }, 500)
    }

}</code></pre>
<p>사실 이 토큰을 보내는 과정이 많이 힘들었는데요,,
브릿지 코드를 작성하고 난 뒤에도 토큰을 제대로 처리하지 못해 찾아보니 웹뷰 세팅에 <code>domStorageEnabled = true</code> 설정을 해줘야 했습니다. 웹뷰에서 LocalStorage를 사용해야 하는 경우 활성화해야 한다고 합니다.</p>
<p>그리고 페이지 로드가 완료된 이후 토큰을 전송해야지 제대로 보내지더라구요!
이를 위해 브릿지 코드에서 message로 &#39;load&#39;가 왔을 때 콜백 처리를 해주었습니다.
페이지 로드 후 바로 토큰을 전송하면 또 잘 안 먹어서, 0.5초 delay를 준 뒤 토큰을 전송해 줬습니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/973a6aa0-6537-4cab-af7e-053c0daa13fd/image.png" alt=""> 토큰 전송 X</th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/991118c7-e657-477c-9d68-7258638610ad/image.jpeg" alt=""> 토큰 전송 O</th>
</tr>
</thead>
<tbody><tr>
<td>웹 개발자 분께서 토큰이 잘 전달되었는지 확인할 용도로 작업해주신 부분인데요, 우측 화면을 보면 상단에 웹으로 전달한 토큰이 잘 뜨는 것을 볼 수 있고, 웹에서 토큰을 잘 받아서 &#39;오늘의 추천 루트&#39;, &#39;오늘의 인기 루트&#39; 콘텐츠도 잘 불러오는 것을 확인할 수 있었습니다.</td>
<td></td>
</tr>
</tbody></table>
<h3 id="3️⃣-화면-이동-구현하기">3️⃣ 화면 이동 구현하기</h3>
<p>홈에서의 화면 이동을 예시로 들어보겠습니다.</p>
<h4 id="프로토콜-1">프로토콜</h4>
<pre><code class="language-kotlin">enum class WebViewPage(val viewName: String) {
    MY_ROUTE(&quot;MY_ROUTE&quot;),
    SEARCH(&quot;SEARCH&quot;),
    ROUTE(&quot;ROUTE&quot;),
    COUPON(&quot;COUPON&quot;),
    LOGOUT(&quot;LOGOUT&quot;),
    WITHDRAW(&quot;WITHDRAW&quot;);

    companion object {
        fun findPage(pageString: String): WebViewPage {
            return entries.find { it.viewName == pageString }
                ?: throw IllegalArgumentException(&quot;Invalid page name: $pageString&quot;)
        }
    }
}</code></pre>
<p>앞선 토큰 전달 시에 <code>MessageType</code>을 미리 정의해뒀었는데요,
이번에는 MessageType이 <code>&#39;PAGE_CHANGE&#39;</code>이라면 화면을 이동시켜 줄, 페이지 이름을 저장해 둡니다.
웹에서 주는 문자열 그대로 전환 코드를 작성해도 무방하지만, 저는 프래그먼트 단에서 when문으로 명확하게 처리해주고 싶어서 enum class를 만들어 주었어요!</p>
<h4 id="인터페이스">인터페이스</h4>
<pre><code class="language-kotlin">interface NativeMessageCallback {
    //...
    fun onMessageReceived(type: MessageType, page: WebViewPage, id: String?) // 웹에서 메시지를 받았을 때
}</code></pre>
<pre><code class="language-json">{
  &quot;type&quot;: &quot;PAGE_CHANGE&quot;,
  &quot;payload&quot;: {
    &quot;page&quot;: &quot;&lt;페이지_식별자&gt;&quot;,
    &quot;id&quot;: &quot;&lt;선택적_ID&gt;&quot;
  }
}</code></pre>
<p>메시지 유형이 <code>&#39;PAGE_CHANGE&#39;</code>인 경우 기본적인 구조입니다.
이에, 콜백으로 전달할 인터페이스 내에 <code>onMessageReceived</code> 함수를 만들어 메시지와 페이지 유형, 그리고 (선택적으로) id를 전달하게끔 설정해 줍니다.</p>
<h4 id="브릿지-1">브릿지</h4>
<pre><code class="language-kotlin">abstract class WebViewBridge(private val callback: NativeMessageCallback) {
    @JavascriptInterface
    fun sendMessageToNative(message: String) {
        // ...
        try {
            // 전달된 문자열을 JSONObject로 변환
            val jsonObject = JSONObject(message)

            // type 필드 값 가져오기
            val type = jsonObject.getString(&quot;type&quot;)

            // payload 객체가 존재하는지 체크
            if (jsonObject.has(&quot;payload&quot;)) {
                // payload가 있을 경우 처리
                val payload = jsonObject.getJSONObject(&quot;payload&quot;)
                val page = payload.getString(&quot;page&quot;)
                val id = if (payload.has(&quot;id&quot;)) payload.getString(&quot;id&quot;) else null

                Log.d(&quot;WebViewBridge&quot;, &quot;Type: $type, Page: $page, ID: $id&quot;)

                // 콜백 호출하여 데이터를 전달
                callback.onMessageReceived(MessageType.findMessageType(type), WebViewPage.findPage(page), id)
            } 
        } catch (e: JSONException) {
            e.printStackTrace()
        }
    }

    companion object {
        const val INTF = &quot;Android&quot;
    }
}</code></pre>
<p>웹에서는 json 형태로 데이터를 주기 때면에 JSONObject를 파싱해서 원하는 형태로 바꿔주고, 콜백으로 전달해줍니다.</p>
<table>
<thead>
<tr>
<th><img width=500 src="https://velog.velcdn.com/images/nahy-512/post/e18ddba7-4133-42a3-aa77-ebb6df628ba1/image.png"></th>
<th><img width=400 src="https://velog.velcdn.com/images/nahy-512/post/f626cca1-7900-4966-a2f3-14a8da9420ee/image.png"> 예시</th>
</tr>
</thead>
</table>
<p>웹에서 주는 메시지의 예시입니다. 크게 <code>type</code>과 <code>payload</code>로 나뉘는 걸 볼 수 있습니다.
때문에 payload가 있다면 값을 가져오는 코드도 작성해 주었습니다.</p>
<h4 id="fragment-1">Fragment</h4>
<pre><code class="language-kotlin">override fun onMessageReceived(type: MessageType, page: WebViewPage, id: String) {
        when (page) {
            WebViewPage.MY_ROUTE -&gt; { // 내 루트 탭으로 이동
                selectBottomNavTab(R.id.myRouteFragment)
                findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToMyRouteFragment())
            }

            WebViewPage.SEARCH -&gt; { // 탐색 탭으로 이동
                selectBottomNavTab(R.id.seekFragment)
                findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToSeekFragment())
            }

            WebViewPage.ROUTE -&gt; { // 루트 조회 화면으로 이동
                startActivity(
                    Intent(
                        requireActivity(),
                        RoutePreviewDetailActivity::class.java
                    ).putExtra(&quot;routeId&quot;, id?.toInt())
                )
            }

            else -&gt; Log.d(&quot;HomeFragment&quot;, &quot;Unknown page: $page&quot;)
        }
    }</code></pre>
<p>웹뷰의 콘텐츠를 클릭하면 웹에서 이동할 화면을 메시지로 보내주는데, 웹이 전달해 준 페이지 정보대로 화면을 이동해줄 수 있습니다.</p>
<p>해당 페이지들은 웹에서 구현하지 않고, 네이티브에서 구현했기 때문에 이처럼 웹과 약속한 메시지를 통해 앱 내에서 화면 이동 코드를 작성해야 했습니다.</p>
<h3 id="4️⃣-로그아웃-회원탈퇴-처리">4️⃣ 로그아웃, 회원탈퇴 처리</h3>
<p>웹과 논의했을 때, 웹뷰로 구현된 마이페이지 내에서 로그아웃 및 회원탈퇴를 하면 payload 없이 바로 type에 LOGOUT, WITHDRAW를 적어 보내주기로 했어요.</p>
<p>응답 예)</p>
<pre><code class="language-json">{&quot;type&quot;:&quot;LOGOUT&quot;} // WITHDRAW</code></pre>
<p>웹에서 구현한 화면 상으로는 아래 모습입니다!</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/bba46d04-8ab5-41b5-b66d-f428a10e2d45/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/b60beecd-a178-42bc-a905-3a46d232f7cf/image.png" alt=""></th>
</tr>
</thead>
</table>
<p>이 부분은 마이페이지와 설정 모두 웹에서 구현한 부분이기에, 설정으로 이동하는 경우는 <code>PAGE_CHANGE</code> 전달 없이 웹뷰 아이콘을 클릭하면 바로 설정 화면으로 이동합니다.</p>
<p>웹뷰로 띄워주는 설정창에서 &#39;로그아웃&#39;, &#39;회원탈퇴&#39; 버튼을 클릭했을 때 API 호출도 웹에서 진행합니다.
다만, 웹에서 로그아웃과 회원탈퇴 버튼을 클릭했을 때</p>
<blockquote>
<p>1) 앱 내 저장된 토큰도 삭제해 주어야 함
2) 로그인 화면으로 이동시켜 줘야 함</p>
</blockquote>
<p>이 두 가지 처리를 해줘야 했기에, 이 부분도 웹으로부터 메시지를 전달받아야 했습니다.</p>
<h4 id="인터페이스-1">인터페이스</h4>
<pre><code class="language-kotlin">interface NativeMessageCallback {
    // ...
    fun onMyPageMessageReceive(page: WebViewPage) // 로그아웃, 회원탈퇴 용
}</code></pre>
<h4 id="브릿지-2">브릿지</h4>
<pre><code class="language-kotlin">abstract class WebViewBridge(private val callback: NativeMessageCallback) {
    @JavascriptInterface
    fun sendMessageToNative(message: String) {
        // ...
        try {
            // 전달된 문자열을 JSONObject로 변환
            val jsonObject = JSONObject(message)

            // type 필드 값 가져오기
            val type = jsonObject.getString(&quot;type&quot;)

            if (jsonObject.has(&quot;payload&quot;)) { // payload 객체가 존재하는지 체크
                // ...
            } else { // payload가 없을 경우 (e.g., LOGOUT, WITHDRAW)
                callback.onMyPageMessageReceive(WebViewPage.findPage(type)) // 마이페이지 처리
            }
        } catch (e: JSONException) {
            e.printStackTrace()
        }
    }
}</code></pre>
<h4 id="마이페이지-코드">마이페이지 코드</h4>
<pre><code class="language-kotlin">override fun onMyPageMessageReceive(page: WebViewPage) {
        when (page) {
            WebViewPage.LOGOUT -&gt; { // 로그아웃
                lifecycleScope.launch {
                    deleteToken()
                    moveToLoginActivity()
                }
            }
            WebViewPage.WITHDRAW -&gt; { // 회원탈퇴
                lifecycleScope.launch {
                    deleteToken()
                    moveToLoginActivity()
                }
            }
            else -&gt; Log.d(&quot;MyFragment&quot;, &quot;Unknown page: $page&quot;)
        }
}

private fun moveToLoginActivity() {
        requireActivity().startActivity(Intent(requireActivity(), LoginActivity::class.java))
        requireActivity().finish()
}

private suspend fun deleteToken() {
        dsManager.clearTokens()
}</code></pre>
<p>Home과 마찬가지로 <code>NativeMessageCallback</code>를 상속하고, 웹뷰 기본 세팅 코드를 작성해 준 뒤 <code>onMyPageMessageReceive</code>를 오버라이드 해 로그아웃과 회원탈퇴 처리를 해줄 수 있었습니다.</p>
<h2 id="📱-결과">📱 결과</h2>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/423cfcee-1f5d-4002-87b9-2d00d23f1794/image.gif" alt=""> <code>네이티브 -&gt; 웹</code> 토큰 전달</th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/38af540e-7547-4a1c-a1af-2882351fc646/image.gif" alt=""> <code>웹 -&gt; 네이티브</code> 화면 이동</th>
</tr>
</thead>
<tbody><tr>
<td>아직 보완할 부분은 조금 있지만, 어쨌든 앱에서 웹으로 토큰을 저장해서 웹뷰를 불러오는 것과 웹뷰에서의 클릭 이벤트를 받아와 화면을 이동시키는 것까지 잘 동작하는 모습을 확인할 수 있었습니다!</td>
<td></td>
</tr>
</tbody></table>
<h2 id="️-마치며">‼️ 마치며</h2>
<p>오늘은 이렇듯 웹뷰를 띄우며 웹에 메시지를 전달하고 받아오는 전반을 정리해 보았습니다.
브릿지의 개념도 처음 알게 되었고.., 항상 서버와만 통신했지, 웹과 이렇게 프로토콜에 맞춰 통신해 본 경험은 처음이라 무척 새로웠습니다.</p>
<p>기존에는 WebView로 브라우저를 띄우는 정도만 해봤지, 통신 코드를 작성했던 적은 없었는데요. 웹과 통신을 위해 필요한 기본 개념이 정말 많더라구요.. 기본 개념조차 몰랐던 기능을 개발하는 과정은 정말 쉽지 않았습니다. 구글에 이런저런 자료는 많이 나왔지만, 자료마다 코드 형태가 꽤 다양해서 &#39;어떤 게 지금 내 상황에 맞는 코드지?&#39;를 판단하고, 채택하는 게 어려웠던 것 같습니다.</p>
<p>특히, 참고했던 문서에서 웹 코드가 어떻게 동작하는 지를 확인하기 힘들었어서.. 제 프로젝트의 웹과 iOS 레포지토리에 가서 코드를 참고해 보기도 했습니다. &#39;이걸 이 형태로 보내는 게 맞나? 이 형태로 코드를 작성하면 보내/받아지는 건가?&#39;가 가장.. 헷갈리지 않았나ㅜㅜ 싶어요.</p>
<p>연동 과정에서 많은 어려움이 있었고, 웹 담당자 분과도 많이 이야기하고, 웹 개발자 분이 직접 안드로이드에서 연동을 어떻게 하는지 테스트해봐 주시기도 했습니다. 특히 토큰 전달이 어려웠는데, 세팅에서 <code>domStorageEnabled</code>를 설정해준 뒤에 잘 되는 것을 확인하자, 화면 이동과 로그아웃 같은 나머지 부분은 조금 수월했어요.</p>
<p>급하게 구현한 코드라 마음에 안 드는 부분이 많아서, 언제 한 번 코드를 조금 다듬고 다시 정리해 보겠습니다.
어쨌든 간에, 간만에 완전히 새로운 개념과 기술을 구현하게 되어 어려웠지만 정말 즐거웠던 작업이었습니다ㅎㅎ</p>
<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li><a href="https://velog.io/@yuuuzzzin/Android-WebView-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0-1%ED%83%84">[Android] WebView 파헤치기 1탄</a></li>
<li><a href="https://velog.io/@jybyun33/9">[안드로이드] Webview Bridge 구현</a></li>
<li><a href="https://tempodivalse.tistory.com/8">[Android] Web에서 App으로 데이터를 받아보자</a></li>
<li><a href="https://itssweetrain.tistory.com/5">[Android] 웹과 앱의 통신 모델 만들기</a></li>
<li><a href="https://velog.io/@jybyun33/9">[안드로이드] Webview Bridge 구현</a></li>
<li><a href="https://42kchoi.tistory.com/380">[Kotlin][안드로이드]웹뷰-네이티브 간 통신하기.</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[2024년 회고록]]></title>
            <link>https://velog.io/@nahy-512/2024%EB%85%84-%ED%9A%8C%EA%B3%A0%EB%A1%9D</link>
            <guid>https://velog.io/@nahy-512/2024%EB%85%84-%ED%9A%8C%EA%B3%A0%EB%A1%9D</guid>
            <pubDate>Sat, 28 Dec 2024 17:00:32 GMT</pubDate>
            <description><![CDATA[<h2 id="🙈-2024년을-되돌아보며">🙈 2024년을 되돌아보며...</h2>
<p>2024년에서 가장 중요한 포인트를 꼽으라면 <code>휴학</code>과 <code>복학</code>이었다.</p>
<p>2023년도 말에, 나는 &#39;다음 학기는 무조건 휴학해야겠다&#39;고 생각했었는데,
휴학을 결정한 이유는 무엇인지, 반 년의 휴학 기간동안 무엇을 했는지, 또 2학기에 복학하고서는 어떤 일들이 있었는지에 대해서 적어보면서 2024년 회고를 진행해 보겠다.</p>
<p>시행착오를 겪은 해라고도 평가할 수 있을 것 같다. 빠르게 지나가 버린 한 해지만, 이 지점이 내 인생의 변곡점이 되겠다 싶을 정도로 많은 고민이 있었던 해였다.</p>
<!-- 내 2024년을 제대로 정리해보고자 이것저것 많이 적어보게 되었는데, 개인적인 -->

<h2 id="🏫-휴학">🏫 휴학</h2>
<p>나는 학교에서 2-2를 마치고 휴학을 결정했다.</p>
<p>나의 휴학 결정의 가장 큰 이유는 <strong>&#39;프로젝트를 심도 있게 진행해보고 싶다.&#39;</strong> 였다.</p>
<p>23년에 말은 나한테 조금 힘든 시기였다. 프로젝트도 진행하고 있었고, 학교 팀플로도 스트레스를 많이 받았고, 동아리 운영진과 스터디, 학교 TA, 튜터링 튜티 등 정말 이것저것 다 했던 거 같다.</p>
<blockquote>
<p>참고: <a href="https://velog.io/@nahy-512/2023%EB%85%84-%ED%9A%8C%EA%B3%A0%EB%A1%9D">✍🏻 2023년 회고록</a></p>
</blockquote>
<p>나름대로 다 잘 마무리되기는 했지만..
무엇 하나 제대로 할 정신이 없는 시간을 보내다 보니, <strong>정리의 시간이 필요함</strong>을 느꼈다.</p>
<p>내게 지금 가장 필요할 게 뭔지 생각해보고, <strong>선택과 집중</strong>을 하고 싶었다.
</br></p>
<p>나는 1학년 새내기로 입학하자마자 연합 개발 동아리인 UMC에 들어갔었고,
앱 런칭 세계에 한 번 빠져든 이후로 2년간 쉬지 않고 프로젝트를 진행했다. 당연히 출시까지 진행한 프로젝트도 몇 개 있었다.</p>
<p>출시라는 건 어쨌든 &#39;내가 만든 앱을 스토어에 올리는 일&#39;이라는, 명확하게 보이는 결과물이 있기 때문에, 프로젝트 진행 시에 1차 목표로 많이 잡는 거 같다.
실제로 나도 &#39;어떻게든 출시까지만이라도 하자&#39;라는 마음가짐으로 1년 넘게 진행한 프로젝트를 23년의 마지막 날 출시에 성공한 적이 있었다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/78327421-72c1-48bd-bfe6-56b00953bb8a/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/e57d88f8-4716-47e8-8c14-25b9e81b7803/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td><del><em>진짜 2023년 마지막 날에 스토어에 올라간 모습</em></del></td>
<td></td>
</tr>
<tr>
<td></br></td>
<td></td>
</tr>
</tbody></table>
<p>그렇지만,, <strong>출시가 끝인가?</strong></p>
<p>막상 출시까지 가긴 했어도,
출시한 후에 뭐가 남았나?를 생각해 봤을 때는 아무것도 남지 않았다는 생각이 들었다.</p>
<p>&#39;손 볼 부분이 너무 많으니 차라리 처음부터 얼른 만들고, 유지보수까지 하자.&#39;며 앱 런칭 프로젝트 이후 세운 당시 우리의 목표가 무색하게도.. 학업과 프로젝트를 병행하며 프로젝트 기간은 끝을 모르고 늘어났고, 오랜 개발 기간에 팀원들은 지칠 대로 지친 상태였다.</p>
<p>마무리는 짓고 끝내고 싶다는 생각에 어떻게든 출시까지는 이루어냈지만, 늘어진 프로젝트에서 지쳐버린 팀원들은 유지보수까지는 못 하겠다는 의사를 보였다.
오래 끌던 프로젝트가 출시했다는 후련함도 잠시, &#39;내가 이 프로젝트로 얻어간 게 뭐가 있지?&#39;라고 누군가 묻는다면 대답할 자신이 없없다.</p>
<p>아예 처음이었고, 특히 내 아이디어로 진행된 프로젝트였기 때문에 분명 나에게는 의미가 큰 프로젝트고, 그만큼 많은 걸 배우게 해준 프로젝트였다. 그렇지만 취업 시장에서는 이야기가 다르지 않나.
그맘 때쯤 포트폴리오를 정리해봤는데.. 개발적인 부분에서는 도무지 무슨 내용을 써야할지 감이 잡히지 않았다.</p>
<p>그간 아무리 학교 공부를 잘 안한다 하더라도 학교에 왔다갔다하고, 과제와 팀플을 하다보면 이래저래 신경쓸 게 많았다. 학교에 다니면서 프로젝트를 같이 하기는 조금 힘들었었다.</p>
</br>

<p>생각 후 내린 결론은 </p>
<blockquote>
<p><strong>학교를 쉬고, 기존에 진행하던 프로젝트를 리팩토링하면서 개발 실력을 향상시키자!</strong></p>
</blockquote>
<p>였다.</p>
<p>이를 위해서는 휴학이 꼭 필요하다고 판단했다. (그래서 휴학함)
</br></br></p>
<h2 id="✍🏻-스터디">✍🏻 스터디</h2>
<p>지식을 제대로 학습하고 싶다는 생각에 스터디를 몇 개 진행했는데, 스터디 활동을 간단히 정리해 보겠다.</p>
<h3 id="android">Android</h3>
<p>2024년이 되고 가장 먼저 시작한 유의미한 일은 Android 스터디에 들어간 걸 꼽겠다.
안드로이드에 대해 더 심도 있게 공부하고 싶다는 생각을 하고 있던 차에, 내가 들어가 있던 동아리에서 안드로이드 스터디 모집글이 올라왔고, &#39;이건 해야겠다!&#39; 싶은 생각에 바로 신청했다.</p>
<image width=600 align="center" src="https://velog.velcdn.com/images/nahy-512/post/a5e71ad8-50ac-47df-a5a6-bceee585fc4e/image.png">

<p>모집 공고에서 본 스터디의 주된 내용은</p>
<blockquote>
<ol>
<li>코틀린 코드 리뷰<ol start="2">
<li>안드로이드 공부</li>
</ol>
</li>
</ol>
</blockquote>
<p>  였다.</p>
<h4 id="스터디-참여-이유">&lt;스터디 참여 이유&gt;</h4>
<p>나는 앱 개발자, 그 중에서도 Android 개발자로 진로를 정한 상태였는데.. 안드로이드를 처음 배우고 나서 2년째 안드로이드 개발을 하고 있지만 개발 실력은 형편없다고 느꼈다. 분명 코드를 쓰긴 쓰는데.. 어떻게 동작하는지는 제대로 모르고 예전에 썼던 코드만 계속해서 복붙해서 쓰는 느낌이었다.</p>
<p>안드로이드 공부를 더 심도있게 진행하고 싶다고는 생각만 했을 뿐, 혼자서 공부를 하기엔 어디부터 시작해야할지 막막했던 차에 좋은 기회라는 생각이 들었다. 어쨌든 여럿이서 하는 스터디는 강제성을 느끼게 되니까! 그만큼 더 배워가는 게 많겠다고 생각했다.</p>
<p>스터디에서 기대헀던 건 &#39;내가 쓰는 코드에 대해서 명확히 이해하기&#39;, &#39;안드로이드 최신 기술 공부해보기&#39; 였었다.
항상 디자인 패턴이 어떻고, 클린 아키텍쳐, MVVM, DI, 컴포즈 등등.. 들어보기만 했지 프로젝트에 사용하거나 공부를 제대로 해 본 적은 없었기에 항상 배워보고 싶었다. 그래서 스터디 공고에서 안드로이드 공부 안에 이런 내용이 포함된다고 적혀있었을 때 꼭 해야겠다 싶어서 바로 지원했다.</p>
<h4 id="활동-내용">&lt;활동 내용&gt;</h4>
<p>스터디는 매주 별 일이 없는 이상 대면으로, 스터디룸을 잡아서 진행했다. 인원은 4명이었다.
스터디 첫 시간에는 스터디 때 어떤 내용을 다루면 좋을지 각자 의견을 냈고, 아래와 같은 내용으로 진행됐다.</p>
<ol>
<li><p>코틀린 코드리뷰 (우테코 프리코스 과제)</p>
<blockquote>
<p><a href="https://github.com/2024-android-study">🔗 스터디 Repository</a></p>
</blockquote>
</li>
<li><p><a href="https://github.com/skydoves/android-developer-roadmap">Android 로드맵</a>에 맞춰 공식 문서 공부</p>
<ul>
<li><a href="https://developer.android.com/topic/architecture">Architecture</a><ul>
<li>Domain, UI, Data layer</li>
<li>Paging Source</li>
<li>Clean Architecture</li>
</ul>
</li>
<li>Design Pattern<ul>
<li>Builder Pattern, Factory Pattern</li>
</ul>
</li>
<li>Dependency Injection</li>
<li>Observer Pattern</li>
</ul>
</li>
<li><p>Compose 공부</p>
<blockquote>
</blockquote>
<p>a. Codelab - <a href="https://github.com/nahy-512/Compose">🔗 Github</a>
b. Toss 클론 코딩 - <a href="https://github.com/nahy-512/Compose_Toss">🔗 Github</a> </p>
</li>
</ol>
<h4 id="스터디-후기">&lt;스터디 후기&gt;</h4>
<p>우선, 우테코 프리코스 과제를 각자 풀어보며 서로 코드 리뷰를 해 준 부분이 좋았다. 다른 사람의 코드를 면밀히 살펴본 게 처음이기도 했고, 동일한 문제를 풀다 보니 내 코드와 비교해서 생각해볼 수 있었다. 좋은 코드를 짜기 위해서는 객체지향, 코틀린 기초부터 다시 공부할 필요가 있다고 느꼈다.</p>
<p>Android 로드맵을 따라 각자 조사하면서 공부하고, 스터디 시간에 공유했을 때는 공부한 주제를 가지고 프로젝트에 적용시켜보기도 했다. 나는 처음 사용해보는 기술이라 적용에 조금 헤맸었는데 이미 해당 기술을 사용해 보신 분들이 프로젝트에 적용하신 모습을 볼 수 있어서 많은 도움이 됐었다.</p>
<p>기존 동아리에서는 안드로이드를 공부하는 사람이 적었고, 모두가 동일한 내용을 배우고 프로젝트를 진행하다 보니 사용하는 기술이 비슷비슷했었다. 그래서 스터에서 동아리 외의, 안드로이드로 진로를 생각하는 사람들과 다양한 인사이트를 나눌 수 있다는 점도 좋았다.</p>
</br>

<h3 id="flutter">Flutter</h3>
<p>Android, iOS를 네이티브로 배워보고 나니 이 두 가지를 동시에 개발할 수 있는 <code>크로스 플랫폼</code>에 대한 관심이 높아졌다.</p>
<p>그렇지만 플러터도 혼자서 공부를 시작하려니 엄두가 안 났었는데,,
방학에 일이 어느정도 정리된 이후 동아리 디스코드에서 구미가 당기는 모집글을 하나 발견하게 됐다!
<img src="https://velog.velcdn.com/images/nahy-512/post/c102df20-3769-4e63-a439-d85930a9aedc/image.png" alt=""></p>
<h4 id="스터디-참여-이유-1">&lt;스터디 참여 이유&gt;</h4>
<p>다같이 스터디를 진행한 후 프로젝트까지 한다니, 완전 이상적인 학습 방법이 아니겠는가?!? 하는 마음에 바로 신청했다.
네이티브도 했는데 플러터 한 번은 해봐야지, 하는 생각도 컸다.</p>
<p>막상 사람들이 모이니 다 아는 얼굴들(이전 기수 운영진)이어서 조금 웃겼다.</p>
<h4 id="활동-내용-1">&lt;활동 내용&gt;</h4>
<ol>
<li><a href="https://nomadcoders.co/dart-for-beginners/lobby">Dart 문법 강의</a> 수강</li>
<li><a href="https://nomadcoders.co/flutter-for-beginners/lobby">Flutter로 웹툰 앱 만들기</a> 수강</li>
<li><a href="https://github.com/nahy-512/Instagram_Flutter">Instagram 클론 코딩</a></li>
</ol>
<p>시작은 노마드코더의 강의 2개를 보면서 매주 각자 학습한 내용을 공유하는 식으로 진행됐다.</p>
<blockquote>
<p><strong>[📝 강의 내용 정리]</strong></p>
</blockquote>
<ul>
<li>문법: <a href="https://cocoa-log.notion.site/Dart-5d0f2300433e4dc79213c4d159ed6242?pvs=4">https://cocoa-log.notion.site/Dart-5d0f2300433e4dc79213c4d159ed6242?pvs=4</a></li>
<li>웹툰 앱: <a href="https://cocoa-log.notion.site/Flutter-12790afc1865800597fecff096a00887?pvs=4">https://cocoa-log.notion.site/Flutter-12790afc1865800597fecff096a00887?pvs=4</a></li>
</ul>
<p>그리고 강의 수강이 끝난 뒤에는 Instagram 클론 코딩을 하며 플러터 개발에 대한 감을 익혔다.
구현한 기능은 아래와 같다.</p>
<ul>
<li>구현 화면<ul>
<li>로그인 화면<ul>
<li>회원가입 화면</li>
</ul>
</li>
<li>홈 화면 구현<ul>
<li>BottomNavigation</li>
</ul>
</li>
</ul>
</li>
<li>파이어베이스로 회원가입, 로그인 및 게시물 조회 기능 구현</li>
</ul>
<h4 id="스터디-후기-1">&lt;스터디 후기&gt;</h4>
<p>스터디 커리큘럼은 스터디를 모집해주신 멘토님이 모두 짜주셨다.
강의를 수강하고, 인스타그램 클론 코딩을 하면서 멘토님이 다른 팀원들 피드백을 많이 해주셔서 좋았다.
일단, 플러터를 사용해 앱을 만든다는 거 자체부터 되게 새로웠는데, 컴포즈를 해보고 플러터를 하니 UI 구현 방식을 비교해보는 재미도 있었다.</p>
<p>그리고 크롬 개발자모드로 들어가서 로그인 과정과 성적 조회 과정의 API를 살펴본 것도 무척 신기했다. 네트워크 과목에서 배운 내용들이 괜히 생각났다. 로그인을 거쳐 세션 정보를 가져오고, 세션을 쿠키에 넣고 포스트맨에서 직접 성적 조회를 해본 일이 굉장히 기억에 남는다.</p>
<h2 id="💻-프로젝트">💻 프로젝트</h2>
<h3 id="나모---ios-출시-및-리팩토링">나모 - iOS 출시 및 리팩토링</h3>
<blockquote>
<ol>
<li>iOS 출시 ‼️</li>
<li>PM을 넘기고, Android 개발만 진행하자!</li>
</ol>
</blockquote>
<p>기존에 진행하던 <strong>&#39;나모&#39;</strong>라는 프로젝트에서 변화가 유독 컸다.</p>
<h4 id="ios-출시">&lt;iOS 출시&gt;</h4>
<p>나의 작년의 가장 큰 목표가 나모 Android 출시였을 정도로 23년도에는 나모 안드 출시가 간절했는데, 2024년에는 iOS도 출시하고자 했다. 이유는 내가 아이폰 유저기 때문에 내가 실제로도 자주 사용할 목적 + 제대로 홍보할 목적이었다.</p>
<p>1월달에 iOS 개발자를 새로 모집하고, 새로운 개발자를 위해 문서 정리를 싹 하고, 이왕 하는 김에 서버 API 수정 필요한 부분도 싹 다 바로잡고자 했다. UMC 2기 프로젝트이고, 나도 프로젝트 자체가 처음인데 PM까지 맡았다 보니 미흡한 부분이 많았기 때문이다.</p>
<p>팀 블로그도 만들어 우리의 개발 과정을 기록하고자 했다.</p>
<blockquote>
<p>📝 나모 팀 블로그: <a href="https://namo-log.vercel.app/">https://namo-log.vercel.app/</a></p>
</blockquote>
<h4 id="android-리팩토링">&lt;Android 리팩토링&gt;</h4>
<p>올해 초반에는 PM으로서 iOS 런칭에 힘을 쏟았지만, 한편으로는 &#39;개발자로서 나모를 제대로 개발하고 싶다.&#39;는 욕구가 컸다. 기존 프로젝트는 항상 아무것도 모르고 마구잡이로 개발했기에, 제대로 된 개발 경험을 가지고 싶었다. 새로 프로젝트를 시작해봤자 크게 달라지지 않을 걸 알았기에, 새로운 안드로이드 개발자를 뽑아 기존 코드와 프로젝트 구조를 리팩토링 하기로 했다. 리팩토링 경험을 챙겨보고 싶었다.</p>
<p>새 개발자와 가장 첫 번째로 논의한 내용은 <strong>&#39;디자인 패턴 변경&#39;</strong>이었다.
기존에는 디자인 패턴이라고 부를만한 게 없었고, 그냥 View 코드에 모든 걸 다 때려놓은 구조였다. 코드 리뷰도 제대로 이루어지지 않았기에 서로의 코드에 대한 이해가 없었고, 앞으로 새로운 기능을 넣고 지속적으로 유지보수 하려면 먼저 기존 코드들을 제대로 이해하고, 컨벤션에 맞게 리팩토링 해야겠다고 판단했다. 그리고 추후 확장성을 고려하고, 앱 전반적으로 동일한 로직을 지니게 하고자 MVVM 디자인 패턴을 채택하면서 MVVM과 궁합이 좋은 DataBinding도 함께 쓰기로 했다.</p>
<p> 레거시 코드를 정리하고, 디자인 패턴을 변경하며 리팩토링하는 과정은 장장 2개월간 이루어졌다. 기존 다른 개발자들이 작성했던 코드를 이해하는 것도 힘들었고, 이에 새로운 디자인 패턴을 적용해 수정해나가는 것도 쉽지 않았다. 책임 분리를 명확히 하기 위해 아키텍처도 변경하고자 했다. presentation, domain, data 3개 레이어를 두었는데, 의존성을 어떻게 가져갈지 설계하는 과정도 기억에 남았다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/29a676e7-6576-446d-83b6-20f384e60158/image.png" alt=""> 리팩토링 기간 (24.03~24.05)</th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/7223691c-b6fa-4354-bcb5-1a01b52d7f4f/image.png" alt=""> 2024년 전체 기간 (24.03~24.12)</th>
</tr>
</thead>
<tbody><tr>
<td><del>정말 많은 수정이 있었다ㅋ.ㅋ</del></td>
<td></td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/ae9d7532-50e7-401f-b81f-901acf2a2786/image.png" alt=""></p>
<p>리팩토링 후에도 자잘한 오류를 확인하며 수정하거나, 추가 기능을 넣는 식으로 버전 업데이트들이 있었다.</p>
<p>리팩토링 경험 자체도 굉장히 의미있었지만, 사소한 부분까지도 다른 안드로이드 개발자와 의논하고, 새롭게 이슈 및 PR  템플릿을 도입하고 PR 리뷰를 남기면서 서로의 개발 상황을 파악 + 컨벤션을 맞추는 과정이 인상깊었다. 개발에 있어서 컨벤션이 얼마나 중요한지도 많이 알게됐던 것 같다. 프로젝트를 처음 시작할 때 컨벤션 맞추는 게 중요하다는 것도.</p>
<h3 id="j력---포지션-변경-android---flutter-개발자">J력 - 포지션 변경 (Android -&gt; Flutter 개발자)</h3>
<p>기존 J력 내가 2022년 12월부터 합류했던 프로젝트로, iOS가 이미 있던 앱을 Android로 개발해서 출시했었다. 하지만 iOS, Android 간 개발 속도가 다른 문제로 싱크가 잘 맞지 않고 개발자 리소스가 많이 들었기에(당시 iOS 개발자 분이 일이 바쁘셨음), PM 분께서 Flutter로 스팩을 변경하자는 결정을 내리셨다. 막 결정이 내려진 당시에는 기존 iOS 개발자 한 분과 새로 모셔온 개발자까지, 2명이서 Flutter로 프로젝트 개발을 처음부터 시작했고, 나와 기존 Android 개발자 분은 기존 안드로이드 프로젝트에 추가 기능(캘린더 디자인&amp;로직 변경)을 개발했었다. 즉, iOS만 Flutter로 변경되어 처음부터 시작하고, Android는 안드대로 신기능을 이어서 개발하는, 어쨌든 두 종류의 개발이 이루어졌다는 말이다.</p>
<p>  사실 안드로이드로 신기능을 개발하면서도 &#39;플러터로 아예 바꾸게 되면 안드로이드 개발은 이제 어쩌지?&#39;하는 걱정이 들었었는데, 같이 안드로이드 개발을 하던 분이 개발자를 아예 접고 다른 진로를 찾는다며 팀을 나갔고, 기존에 iOS 개발을 하던 분도 바빠지셨기에 PM과의 면담 후 내가 Flutter 개발자로 들어가게 됐다. 올해 4월달의 일이었다.</p>
<p>  기존에 어쨌든 한 번 개발해 본 기능을 플러터로 새로 만드는 거니까, 어떤 차이가 있을지가 궁금했었다. 그리고 그때는 플러터 스터디도 했을 때라서 내가 프로젝트 구조를 이해하는 거나, 신기능을 개발하는 게 문제 없지 않을까 싶었다.</p>
<p>  플러터 개발이 안드로이드와 달랐던 점은, 컴포넌트에 대한 고민이 체게적으로 이루어졌다는 점이다. 선언형 UI다 보니까 동일한 컴포넌트를 여기저기에 활용해 쓸 일이 많았다. 플러터에서 Android, iOS 앱을 배포하는 경험도 얻을 수 있었다. 그리고 무엇보다 개발을 오래 하셨던, 잘하시는 분과 같이 개발했던 점이 좋았다. &#39;개발 시에 이런 점들을 미리 고려해야 하는구나.&#39;, &#39;이렇게 컴포넌트를 미리 만들고, 프로젝트를 세팅하면 편하구나.&#39;하는 점들을 알 수 있었다.</p>
</br>


<h2 id="🏫-복학">🏫 복학</h2>
<blockquote>
<p>졸업작품, 학과 해외연수 프로그램 탈락</p>
</blockquote>
<p>  상반기를 휴학하고, 하반기에 바로 복학을 했다. 한 학기만 휴학하고 빠르게 복학한 이유는 휴학하고 생각보다 한 게 없었고.. 졸업작품을 시작하는 학기라(우리 학교의 졸업작품은 1년인다) 학과 팀플도 날이 갈수록 하기 싫어지는데, 차라리 빨리 하고 끝내자! 라는 생각이 강했다.</p>
<p>  복학하고 돌아온 학교에서, 나는 &#39;3학년은 실력이 확 다르구나.&#39;를 느꼈던 것 같다. 내가 한 학기를 휴학하고 학교에 돌아온 거라 더 느껴졌을지도 모르겠다. 이것저것 하다가 정신을 차려보니 나는 이미 3학년이고, 졸작도 하고 있었다. &#39;나 더이상 어리지 않네? 근데 지금 왜 아직까지도 이러고 있지?&#39;라는 생각이 컸다.</p>
<p>   1, 2학년에 걸쳐 다양한 분야를 배운 것을 후회하지는 않는다. 다만, 정신을 차려보니 3학년이었고, 3학년이 되고 고민이 조금 많아졌을 뿐이다. 이래서 3학년을 사망년이라고 부르나? 싶기도 했다. 나는 앱 개발 쪽으로 진로를 빨리 정했다고 생각했기에 미래 걱정으로 이렇게 힘들 줄은 몰랐었다.</p>
<p>  기폭제는 학과 해외연수 프로그램에서 2번 떨어졌던 일이었다. 호주와 미국으로 2번의 기회가 있었는데, 둘 다 서류는 붙었지만 면접에서 떨어졌었다. 심지어 미국 면접은 30명 중 18명이 붙는, 과반 이상이 합격하는 면접이었는데 그 과반에조차 내가 없다는 사실에 자존감이 많이 떨어졌다.(지인들은 나 빼고 다들 붙었다는 점에서도)
  그간 나쁘지 않은 학점을 받아왔고, 교내/교외 활동도 나름 열심히 해왔다고 생각했기에 결과를 받아들이기가 더 힘들었던 것 같다. 어쩔 수 없이 다른 사람들과 비교를 하게 됐다.</p>
<p>  자꾸만 땅굴을 파며 우울해지는 기분이 너무 싫었고, 혼자 왜 떨어졌는지 계속 생각하며 끙끙 앓을 바에야 이유라도 직접 들어보자! 하고 해외연수 면접관이셨던 교수님 한 분께 메일을 보냈다. 말을 잘 하고 싶어서 스피치 학원까지 다녔던 적이 있는데, 여전히 면접에는 자신이 없는 것 같다. 면접관 입장에서는 어떻게 느껴졌는지 궁금하기에 면접 피드백을 요청드리고 싶다는 내용으로 말이다. 면담 후 찾은 원인은 생각보다 허무했다. 면접에서 그렇게 망친 게 아니라, 애초에 영어 점수가 전혀 없어서 서류 점수가 낮았던 거라고. 그 말을 들으니까 그냥.. 다 납득이 됐다. 지인 중에서는 호주 서류를 떨어지고 미국 모집 공고가 나오기 전, 영어 성적을 준비해 미국 최종 선발까지 간 사례가 있었다. 충분히 할 수 있는데 그렇게 하지 않았던 건 내 탓이었다. 내가 부족하고 안일해서 떨어진 거라는 걸 받아들이게 되니 마음이 조금은 편해졌다.</p>
<p>  그렇지만? 시련은 여기서 끝나지 않았다. (여기서 잘 끝맺었더라면 참 좋았을 것이다..)
  휴학하고 진행하던 프로젝트 정리 없이, 학교를 다니면서 계속 프로젝트도 진행하다 보니까 안그래도 정신적으로 많이 부담이 됐었다. 어느 순간 또!!!! 일을 감당할 수 없게 너무 벌려버린, 내 모습을 볼 수 있었다. 심지어 해외연수 프로그램에서 떨어지고 &#39;이제 방학에 해외에 나가지 않으니까, 돈을 받고 개발하는 일을 해볼까?&#39;하고 외주 프로젝트를 시작한 게 최악의 선택이었다. 그 때는 다들 나한테 &quot;요즘 바쁘지 않아? 하는 일 몇 개나 있어?&quot;라는 말로 인사를 건네는 게 일상이었다. (ㅋㅋ.. 정말 반성한다) <em><del>대체 왜 투두메 상메로 &#39;감당할 수 있는 일만 벌이자&#39;라고 떡하니 써놓고도 자꾸만 일을 만드는 거임?!</del></em></p>
<p>하는 양이 많아지다 보니까 당연하게도 뭐 하나 제대로 하지 못했다. 점점 일이 감당이 안 되니까 자꾸 의문을 던져보려고 했던 것 같다. 내가 왜 일을 이렇게 많이 벌이게 됐지? 하면서 말이다. 나는 내가 꼼꼼함이 장점인, 완벽주의자 성향이지 않았나 싶었는데 점점 스스로와 타협하는 내 모습을 발견하니 내가 너무 싫어졌다. 자꾸만 단점밖에 안 보이고, 내 장점이 뭐였는지 까먹게 됐다. 이전에 생각해오던 내 장점은 더이상 장점이 아니게 됐다. 그간 쉬지 않고 무언가를 지속적으로 해왔건만, 실력은 제자리걸음인 느낌이었고, 부족한 만큼 열심히 배워야지! 하던 열정 넘치는 마음도 어느새 사라진 것 같았다. 언제부터인지도 모르겠다. 그냥.. 어느 순간부터 점점 &#39;이만하면 됐지.&#39;라는 생각으로 개발을 했다. 너무 갑갑하고 막막했으나, 어떻게 이 상황을 타계할 수 있을지, 이런 무력감에서 벗어날 수 있을지 감이 오지 않았다.</p>
<p>  그 때 많이 도움됐던 건 예전 동아리 선배들과 돌아가며 커피챗을 했던 일이었다.</p>
<h2 id="☕️-선배들과의-커피챗">☕️ 선배들과의 커피챗</h2>
<blockquote>
<p>내가 있는 곳은 우물 안이었다.</p>
</blockquote>
<p>첫 커피챗은 프로젝트에서 PO로 있던 선배와 진행했다. 내가 휴학을 왜 했었는지, 그리고 개발을 왜 하고 싶었는지를 말했다. 어느 순간 생각 없이 자꾸만 일을 늘리는데, 제대로 감당을 못할 수준이 되자 너무 힘들다는 고민도 털어놓았다. 이에 들었던 말은 &quot;일을 더 늘리는 건 지금 필요한 게 아니다. 책임감을 줄 수 있는 일이 필요하다. 일정을 맞추지 않으면 큰일나는 일. 그치만 이건 사이드 프로젝트에선 겪을 수 없는 일이다&quot;. 그리고 프로젝트에 계시던 안드 현업자 분께서 날 보며 예전보다 많이 성장했다고 말씀해주셨다는 얘기를 전해들었다. 이 말을 듣고 어쩐지 눈물이 났었는데, 스스로도 그 이유를 잘 모르겠었다. 그땐 내가 이렇게 다른 사람과 얘기하다가 눈물이 나올 정도로 심적으로 힘든 상황이었나? 싶은 생각이 가장 컸다.</p>
<p>  그리고 내 상황과 고민을 계속해서 듣던 선배가 헤어지기 전 과제를 하나 내주었다. 바로 &#39;무언가 결정내린 상황이 있으면, 잠깐 멈춰서서 &#39;내가 왜 이런 결정을 내렸지?&#39; 하고 생각해봐라.&#39;는 것이었다. &#39;오늘 점심 뭐 먹지?&#39;와 같은 간단한 의사결정이더라도 말이다. 이렇게 한다고 무언가가 크게 달라질 수 있을까 잠시 의문이 들었지만, 어쨌든 그동안 충분한 고민 없이 &#39;하고 싶어? 그럼 해야지!&#39;하고 내린 결정이 모여 큰 파장을 일으키는 걸 보고 많이 반성했기에, 의사 결정 과정에서 의식적으로 이유를 되짚어보는 게 도움이 되겠다 싶었다. 실제로도 그랬다.</p>
<p>  그 뒤로는 자주 만나던 모임의 직장인 선배들과 한 번씩 커피챗 시간을 가졌다. J력에서 나랑 같이 플러터 개발을 하시던 리드 개발자 분과 커피챗을 나눴을 때는 좀 더 개발적인 부분에서 조언을 구할 수 있었다. 동아리에서 제일 친하고 편한 언니와는 좀 더 사담을 나누었는데, 근황 토크를 하며 내가 너무 일을 많이 한다는, 어딘가 뼈가 들어있는 말을 들었다.</p>
<p>  누군가에게 내 상황을 말하고, 그들이 해주는 이야기를 들으며 정리되지 않던 생각이 조금 정리되는 느낌이었다. 말하는 대상이 나를 오래 봐왔던 사람들이기에. 그리고 나랑 프로젝트를 같이 해봤기에 일적인 내 모습도 알고 있는 사람들이었기에 말할 때 더 편했고, 그들이 하는 조언도 나를 위해 해주는 말이라는 게 많이 느껴졌다. 이래서 개발자에게 네트워킹이 중요하다는 건가? 싶기도 했다. 그들 중에 안드로이드 개발을 하는 사람들은 없었음에도 말이다.</p>
<p>  나는 내 고민을 누군가에게 털어놓는 편이 아니다. 고민이 있더라도 어느정도 해결된 후에 &quot;나 이런 일이 있었는데, 지금은 이래.&quot;라고 회고하듯이 말하곤 했다.(그리고 항상 그닥 심각한 고민은 아니었다.) 친구들을 만나면 하는 얘기는 대개 일상 속에 있었던 개인적인 에피소드였고, 비개발자 친구들에게 이런 개발적인 고민을 털어놓을 수는 없었으므로 이런 고민 자체를 밖으로 꺼낼 일이 전혀 없었다. 더욱이 나는 올해는 동아리를 전혀 하지 않았고(그동안 내 학교 인맥은 동아리 사람들이 전부였다.), 2학기에 복학하고서 학교에 다니면서도 이야기를 할 사람이 없었다. 원래 안면이 있던 사람들과 팀플을 같이 했어도 그냥 팀플만 하고 끝이었다. 어디에도 내 얘기를 털어놓을 데가 없었다. 커피챗 전에는 생각하지 못했지만, 그 점이 내 우울함에 크게 작용했던 것 같다.</p>
<p>  커피챗 시간은 사실 그렇게 길지 않았다. 선배들은 나에게 해결책을 제시해주지 않았다. 다만 내 얘기를 듣고, 그간 나를 보며, 그리고 내 얘기를 들으며 느꼈던 점들을 말해줄 뿐이었다. 어떻게 보면 고민 상담의 형태였다. 그렇지만 그들과 한 이야기를 곱씹어볼수록, 나는 나를 다시 되돌아볼 수 있었다.
  스스로 질문을 던지고 내가 왜 그런 결정을 내렸을까, 하는 이유를 자꾸만 붙어보려고 했다. 처음 한 커피챗에서 선배가 내준 과제처럼 말이다. 혼자 생각해볼 때는 생각이 계속 맴돌고 우울해지기만 했었는데, 선배들이 내 이야기를 들으면서 간간히 묻는 질문들에 어떻게든 대답하려 정리되지 않던 내 생각을 밖으로 이야기하다 보니 &#39;아, 내가 이런 생각을 가지고 있었구나.&#39;를 새롭게 알게되기도 했다.</p>
<p>  혼자 생각하는 것과, 그걸 누구한테 얘기하는 건 차원이 달랐다. 덕분에 생각을 정리하고, 다시 발전을 꿈꾸며 기운차릴 수 있었다.</p>
<!-- 내가 그때 가장 싫었던 건, 내가 잘 못하고 있는 걸 누구보다 잘 알고 있는데 고민을 잠깐 털어놓으면 "아냐, 너 잘하고 있어."라는 껍데기 뿐인 위로만 돌아왔다는 점이었다. -->

</br>

<h2 id="🌈-2025년의-목표">🌈 2025년의 목표</h2>
<p>2024년에도 나는 정말 많은 일을 했다!
그러나 일이 많이 벌이는 것은 오히려 도움이 안 된다는 걸 깨닫게 된 해였다.</p>
<p>  많이 힘든 시간을 겪었지만, 주변인들과 이야기하고, 스스로 고민을 거듭하며 깨달은 점이 많았다.</p>
<p>  앞으로 나는 어떤 일을 마친 후에 나를 되돌아보는 시간을 항상 가지려고 한다. 내가 향하고 있는 방향이 맞는지, 그 일이 내게 어떤 변화를 주었는지를 매번 점검할 생각이다. </p>
<p> 우물 안 개구리라는 걸 뼈저리게 느꼈으니, 더 넓은 세상을 향해 나아가보려고 한다.
  앞으로 얼마나 더 많은 벽에 가로막힐지 모른다. 그렇지만 그럴 때마다 나의 좁디좁은 새장을 인지한, 올해의 무력감을 떠올릴 생각이다. 나는 분명히 올해 내가 했던 수많은 고민들이 내 앞으로의 인생에 있어서 변곡점이 되리라 믿는다.</p>
<p>가장 중요한 다짐은 기존에 진행하던 프로젝트를 다 정리하고, 새로운 한 가지에 몰입하는 경험을 해보고자 하는 것이다. 써봤던 기술로만 프로젝트를 진행하는 자가복제성 프로젝트에 지쳐버렸다. 앞으로는 내가 쓰는 기술에 대해 깊게 고민해보고, 필요성을 느껴 선택하는, 그런 경험을 너무나 하고 싶다.
  안드로이드 개발로 나아갈 마음을 먹었으니, 앞으로는 안드로이드 개발에 뜻이 있는 사람들을 적극적으로 만나볼 생각이다. 그간은 주변에 안드로이드 개발자가 많이 없었을 뿐더러 다들 비슷한 환경에만 있던 사람들이라, 안드로이드 개발 이야기를 나누는 데 한계가 있었다. 새로운 기술을 도입하고 싶더라도 다들 처음 접하는 기술이었고, 우리가 진행하는 프로젝트에 어떤 문제점이 있는지 파악하더라도 어떻게 고쳐야 할지 잘 모르겠어 힘들었다. UMC에서 2년간 다양한 분야를 접했다면, 이제는 다른 곳에서 Android 개발을 새로운 마음가짐으로 공부하고 싶다. 그간 내가 가졌던 지식은 아무것도 아니었고, 나는 아직 아무것도 모르는 감자라는, 그런 마음가짐으로 새롭게 나를 쌓아올리고 싶다.</p>
<p>올해 고민이 깊었던 자기 점검 후에 내린 나라는 사람에 대한 결론은, 한 가지만큼은 분명했다.
나는 계속해서 발전하고 싶은 사람이라는 것에는 변함이 없다.</p>
<p>변화한 투두메이트 상태메시지를 소개하며 길었던 2024년 회고록을 마무리하려 한다.</p>
<h3 id="현실에-안주하지-말자"><strong><em>현실에 안주하지 말자.</em></strong></h3>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 온보딩 구현하기 (feat. indicator)]]></title>
            <link>https://velog.io/@nahy-512/Flutter-%EC%98%A8%EB%B3%B4%EB%94%A9-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@nahy-512/Flutter-%EC%98%A8%EB%B3%B4%EB%94%A9-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 01 Dec 2024 14:13:59 GMT</pubDate>
            <description><![CDATA[<p>앱을 처음 설치하는 유저에게 보여주는 온보딩은 앱에 대한 기본 사용법 또는 기능에 대해 설명해준다.
오늘은 내가 진행한 프로젝트에서 온보딩 화면을 구현한 방법에 대해 다뤄보겠다.</p>
<h2 id="✍🏻-요구사항-분석">✍🏻 요구사항 분석</h2>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/7502e763-1e72-42d6-81a3-234b59bbb557/image.png" alt=""></p>
<p>위의 화면으로 볼 때, 구현해야하는 기능은 아래와 같을 것이다.</p>
<blockquote>
<ol>
<li>페이지는 총 3개이고, 스크롤을 통해 넘길 수 있다.</li>
<li>페이지 번호는 인디케이터로 확인할 수 있다.</li>
<li>건너뛰기를 하면 로그인 화면으로 이동한다.</li>
<li>마지막 온보딩 페이지에서는 하단에 시작하기 버튼을 띄워준다.</li>
</ol>
</blockquote>
<p>이외에도 &#39;온보딩&#39;이라는 특성 상 최초 앱을 설치한 유저에게만 보여줘야 한다. 즉, 온보딩 과정을 끝낸 이후에는 다시 표시해주지 않아야 한다.</p>
<p>온보딩을 구현하는 방법은 다양할 수 있겠지만, 위의 디자인 상으로는 페이지마다 표시해 줄 정보가 모두 동일하다.
세 페이지 모두 동일한 위치에 아래 3가지 정보를 포함한다.</p>
<ul>
<li>제목 (텍스트)</li>
<li>설명 (텍스트)</li>
<li>이미지</li>
</ul>
<p>그러면 화면 3개를 따로 만들지 않고 재활용하기가 용이하다. 스크린 파일 하나에서 모든 온보딩 과정을 한번 구현해 보자!</p>
<h2 id="💻-코드-작성">💻 코드 작성</h2>
<h3 id="1️⃣-model-작성">1️⃣ Model 작성</h3>
<p>먼저 페이지 별로 표시해 줄 정보를 클래스로 만들어 준다.</p>
<pre><code class="language-dart">class OnboardingInfo {
  OnboardingInfo({
    required this.title,
    required this.content,
    required this.image});

  final String title;
  final String content;
  final String image;
}</code></pre>
<h3 id="2️⃣-화면-별로-들어갈-데이터-정의">2️⃣ 화면 별로 들어갈 데이터 정의</h3>
<p>위에서 만든 모델로 3개 화면에 들어갈 데이터를 각각 정의해준다.
이를 <code>items</code>라 명칭.</p>
<pre><code class="language-dart">class OnboardingItems {
  static String basePath = &quot;assets/images/onboarding&quot;;

  List&lt;OnboardingInfo&gt; items = [
    OnboardingInfo(
        title: &quot;onboarding_title1&quot;.tr(),
        content: &quot;onboarding_content1&quot;.tr(),
        image: &quot;$basePath/onboarding1.png&quot;),

    OnboardingInfo(
        title: &quot;onboarding_title2&quot;.tr(),
        content: &quot;onboarding_content2&quot;.tr(),
        image: &quot;$basePath/onboarding2.png&quot;),

    OnboardingInfo(
        title: &quot;onboarding_title3&quot;.tr(),
        content: &quot;onboarding_content3&quot;.tr(),
        image: &quot;$basePath/onboarding3.png&quot;),
  ];
}</code></pre>
<h3 id="3️⃣-화면-구현">3️⃣ 화면 구현</h3>
<h4 id="인디케이터-라이브러리-추가">인디케이터 라이브러리 추가</h4>
<p>페이지 번호를 표시해주기 위한 인디케이터 라이브러리를 추가해 준다. 구글링해보다가 가장 괜찮아 보이는 것으로 선택했다. <a href="https://pub.dev/packages/smooth_page_indicator">smooth_page_indicator</a> 이다.</p>
<pre><code class="language-yaml">smooth_page_indicator: ^1.1.0</code></pre>
<h4 id="기본-화면--페이지-이동">기본 화면 &amp; 페이지 이동</h4>
<p>건너뛰기 버튼은 상단에 고정되어 있으니까, Scaffold의 앱바로 추가해 준다.
body에는 페이지 아이템의 제목, 내용, 이미지가 들어갈 수 있게끔 구현한다.
<code>PageView.builder</code>를 통해 페이지 이동을 설정해 준다. 이때, 아이템은 OnboardingItems의 개수만큼 만들어 주고, 컨트롤러로는 pageController를 가진다.</p>
<pre><code class="language-dart">class OnboardingScreen extends StatefulWidget {
  const OnboardingScreen({super.key});

  static String routeName = &quot;/onboarding&quot;;

  @override
  State&lt;OnboardingScreen&gt; createState() =&gt; _OnboardingScreenState();
}

class _OnboardingScreenState extends State&lt;OnboardingScreen&gt; {
  final controller = OnboardingItems();
  final pageController = PageController();

  bool isLastPage = false;

  Future&lt;void&gt; moveToLoginScreen() async {
    final pres = SharedPreferencesService.get();
    pres.setBool(&quot;isOnboardingFinished&quot;, true);
    if (!mounted) return;
    Navigator.pushNamedAndRemoveUntil(
        context, LoginScreen.routeName, (route) =&gt; false);
  }

  @override
  Widget build(BuildContext context) {
    return PopScope(
        canPop: false,
        child: Scaffold(
          backgroundColor: ColorStyles.onboardingBackground,
          appBar: AppBar(
            automaticallyImplyLeading: false,
            backgroundColor: ColorStyles.onboardingBackground,
            actions: [
              TextButton( // 건너뛰기 버튼
                  onPressed: moveToLoginScreen, // 로그인 화면으로 이동
                  child: Text(
                    &quot;skip&quot;.tr(),
                    style: const TextStyle(
                        color: ColorStyles.categoryEtc, fontSize: 12),
                  )),
            ],
          ),
          body: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: [
              Flexible(
                flex: 5,
                child: PageView.builder(
                    onPageChanged: (index) =&gt; setState(() =&gt;
                        isLastPage = controller.items.length - 1 == index),
                    itemCount: controller.items.length,
                    controller: pageController,
                    itemBuilder: (context, index) {
                      return SizedBox(
                        height: MediaQuery.of(context).size.height * 0.5,
                        width: double.infinity,
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.start,
                          children: [
                            SizedBox(
                                height: MediaQuery.of(context).size.height *
                                    0.06),
                            Text( // title
                              controller.items[index].title,
                              style: const TextStyle(
                                color: Colors.white,
                                fontSize: 24,
                                fontWeight: FontWeight.bold,
                              ),
                              textAlign: TextAlign.center,
                            ),
                            const SizedBox(height: 6),
                            Text( // content
                              controller.items[index].content,
                              style: const TextStyle(
                                color: ColorStyles.onboardingContentText,
                                fontSize: 14,
                              ),
                              textAlign: TextAlign.center,
                            ),
                            const SizedBox(height: 60),
                            Image.asset( // image
                              controller.items[index].image,
                              // width: 375
                            ),
                          ],
                        ),
                      );
                    }),
              ),
            ],
          ),
        )
    );
  }</code></pre>
<h4 id="인디케이터-추가">인디케이터 추가</h4>
<pre><code class="language-dart">SmoothPageIndicator(
                      controller: pageController,
                      count: controller.items.length,
                      effect: const ExpandingDotsEffect(
                          dotHeight: 8,
                          dotWidth: 8,
                          dotColor: ColorStyles.gray_500,
                          activeDotColor: ColorStyles.accent),
                    ),</code></pre>
<p>화면의 하단 부분에는 인디케이터와 (마지막 페이지라면) 시작하기 버튼이 들어가야 한다. 인디케이터는 페이지 이동과 동기화되어야 하기 때문에 <code>PageView.builder</code>과 아이템 개수와 컨트롤러를 동일하게 가져간다. <code>ExpandingDotsEffect</code>로 인디케이터의 스타일을 설정해 준다.</p>
<h4 id="시작하기-버튼-추가">시작하기 버튼 추가</h4>
<p>버튼 위젯은 코드에서 따로 분리했는데, 마지막 페이지일 때만 버튼을 표시해주어야 한다.</p>
<pre><code class="language-dart">Widget getStartButton() {
    return Column(
      mainAxisAlignment: MainAxisAlignment.end,
      children: [
        const Divider(height: 1, color: ColorStyles.divider),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
          child: SizedBox(
            width: double.infinity,
            child: ElevatedButton(
              style: ElevatedButton.styleFrom(
                padding: const EdgeInsets.all(15),
                backgroundColor: ColorStyles.jBlue_500,
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(5.0),
                ),
              ),
              onPressed: moveToLoginScreen,
              child: Text(&quot;do_start&quot;.tr(),
                  style: const TextStyle(
                    fontWeight: FontWeight.bold,
                    color: Colors.white,
                  )),
            ),
          ),
        ),
      ],
    );
  }</code></pre>
<p>찾아보니 플러터에서는 Visibility 속성도 위젯이라고 해서 <code>getStartButton()</code>을 Visibility 위젯으로 감싸, isLastPage를 visible로 주었다.</p>
<pre><code class="language-dart">Visibility(
    visible: isLastPage,
    child: getStartButton()
),</code></pre>
<h4 id="화면-이동-온보딩-과정-끝">화면 이동 (온보딩 과정 끝)</h4>
<p>마지막 온보딩 화면에서 &#39;시작하기&#39; 버튼을 누르거나 온보딩 과정 중에 &#39;건너뛰기&#39; 버튼을 누를 경우 로그인 화면으로 이동한다. 이때, 온보딩 완료 처리를 위해 SharedPreferences의 &quot;isOnboardingFinished&quot;를 true로 넣어준다.</p>
<pre><code class="language-dart">Future&lt;void&gt; moveToLoginScreen() async {
    final pres = SharedPreferencesService.get();
    pres.setBool(&quot;isOnboardingFinished&quot;, true);
    if (!mounted) return;
    Navigator.pushNamedAndRemoveUntil(
        context, LoginScreen.routeName, (route) =&gt; false);
  }</code></pre>
<p>이 &#39;isOnboardingFinished&#39; 값은 초기 스플래시 화면에서 다음으로 이동시킬 화면을 판단할 때 사용하면 된다.</p>
<blockquote>
<ul>
<li>온보딩 완료 이전이라면 (ex. 앱을 처음 설치한 유저) -&gt; 온보딩 화면으로 이동,</li>
</ul>
</blockquote>
<ul>
<li>온보딩을 이미 끝냈으면 -&gt; 로그인 여부에 따라 로그인 화면이나 홈 화면으로 이동</li>
</ul>
<p>식으로 <code>main.dart</code> 코드에서 다음에 실행할 화면을 지정할 수 있다.</p>
<h3 id="전체-코드">전체 코드</h3>
<pre><code class="language-dart">class OnboardingScreen extends StatefulWidget {
  const OnboardingScreen({super.key});

  static String routeName = &quot;/onboarding&quot;;

  @override
  State&lt;OnboardingScreen&gt; createState() =&gt; _OnboardingScreenState();
}

class _OnboardingScreenState extends State&lt;OnboardingScreen&gt; {
  final controller = OnboardingItems();
  final pageController = PageController();

  bool isLastPage = false;

  Future&lt;void&gt; moveToLoginScreen() async {
    final pres = SharedPreferencesService.get();
    pres.setBool(&quot;isOnboardingFinished&quot;, true);
    if (!mounted) return;
    Navigator.pushNamedAndRemoveUntil(
        context, LoginScreen.routeName, (route) =&gt; false);
  }

  @override
  Widget build(BuildContext context) {
    return PopScope(
        canPop: false,
        child: Scaffold(
          backgroundColor: ColorStyles.onboardingBackground,
          appBar: AppBar(
            automaticallyImplyLeading: false,
            backgroundColor: ColorStyles.onboardingBackground,
            actions: [
              TextButton( // 건너뛰기 버튼
                  onPressed: moveToLoginScreen, // 로그인 화면으로 이동
                  child: Text(
                    &quot;skip&quot;.tr(),
                    style: const TextStyle(
                        color: ColorStyles.categoryEtc, fontSize: 12),
                  )),
            ],
          ),
          body: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: [
              Flexible(
                flex: 5,
                child: PageView.builder(
                    onPageChanged: (index) =&gt; setState(() =&gt;
                        isLastPage = controller.items.length - 1 == index),
                    itemCount: controller.items.length,
                    controller: pageController,
                    itemBuilder: (context, index) {
                      return SizedBox(
                        height: MediaQuery.of(context).size.height * 0.5,
                        width: double.infinity,
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.start,
                          children: [
                            SizedBox(
                                height: MediaQuery.of(context).size.height *
                                    0.06),
                            Text( // title
                              controller.items[index].title,
                              style: const TextStyle(
                                color: Colors.white,
                                fontSize: 24,
                                fontWeight: FontWeight.bold,
                              ),
                              textAlign: TextAlign.center,
                            ),
                            const SizedBox(height: 6),
                            Text( // content
                              controller.items[index].content,
                              style: const TextStyle(
                                color: ColorStyles.onboardingContentText,
                                fontSize: 14,
                              ),
                              textAlign: TextAlign.center,
                            ),
                            const SizedBox(height: 60),
                            Image.asset( // image
                              controller.items[index].image,
                              // width: 375
                            ),
                          ],
                        ),
                      );
                    }),
              ),
              Flexible(
                flex: 1,
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    SmoothPageIndicator(
                      controller: pageController,
                      count: controller.items.length,
                      effect: const ExpandingDotsEffect(
                          dotHeight: 8,
                          dotWidth: 8,
                          dotColor: ColorStyles.gray_500,
                          activeDotColor: ColorStyles.accent),
                    ),
                    Visibility(
                        visible: isLastPage,
                        child: getStartButton()
                    ),
                  ],
                ),
              ),
            ],
          ),
        ));
  }

  Widget getStartButton() {
    return Column(
      mainAxisAlignment: MainAxisAlignment.end,
      children: [
        const Divider(height: 1, color: ColorStyles.divider),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
          child: SizedBox(
            width: double.infinity,
            child: ElevatedButton(
              style: ElevatedButton.styleFrom(
                padding: const EdgeInsets.all(15),
                backgroundColor: ColorStyles.jBlue_500,
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(5.0),
                ),
              ),
              onPressed: moveToLoginScreen,
              child: Text(&quot;do_start&quot;.tr(),
                  style: const TextStyle(
                    fontWeight: FontWeight.bold,
                    color: Colors.white,
                  )),
            ),
          ),
        ),
      ],
    );
  }
}</code></pre>
<h2 id="📱-완성-화면">📱 완성 화면</h2>
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/077668e6-4ef2-4d1c-bc1a-5967cdfe90a5/image.gif">
</br>

<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li><a href="https://www.youtube.com/watch?v=MDGcD1kv6ZE">https://www.youtube.com/watch?v=MDGcD1kv6ZE</a></li>
<li><a href="https://dudgus907.tistory.com/8">https://dudgus907.tistory.com/8</a></li>
<li><a href="https://sh0seo.tistory.com/5">https://sh0seo.tistory.com/5</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] Bottom Navigation 구현하기]]></title>
            <link>https://velog.io/@nahy-512/Flutter-BottomNavigation-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@nahy-512/Flutter-BottomNavigation-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 20 Nov 2024 05:14:30 GMT</pubDate>
            <description><![CDATA[<p>제가 iOS(찍먹), Android 개발만 계속 하다가 Flutter의 세계에 발을 들였던 게 올해 1월로, 어느덧 10여 개월이 흘렀는데 말이죠.
그동안 플러터로 진행한 프로젝트도 2개나<del>(그 중 하나는 무려 Andriod로 출시까지 했다가 Flutter로 넘어갔습니다)</del> 되다보니 작성할 콘텐츠 거리가 꽤나 쌓였습니다. 앞으로는 플러터 포스트도 하나씩 부지런히 풀어가도록 하겠습니다😊</p>
<p>그동안 진행한 프로젝트 마다 바텀네비게이션으로 화면을 이동했기에 바텀네비게이션을 구현할 일이 많았는데요, 생각해보면 진행한 프로젝트에서 90%는 제가 바텀네비를 도맡아 만들었던 것 같습니다. <del>이 정도면 바텀네비 전문 개발자가 아닌지?</del>
그치만 전 개인적으로 바텀네비를 구현하는 일을 좋아합니다. 한 번 만들어놓으면 바뀔 일이 잘 없긴 하지만, 어찌보면 프로젝트의 시작이 되는 일이니까요. 제가 바텀네비를 작업하고 나면 각 탭 별 화면 담당자를 나눠 작업이 착착 진행되는, 그 지점이 참 좋습니다.
</br></p>
<h2 id="✍🏻-요구사항-분석">✍🏻 요구사항 분석</h2>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/153fd4cc-4ba1-4c82-a34e-34dd942a4dc0/image.png" alt=""></p>
<p>앞서 사담이 좀 길었지만.., 이번에는 조금 특이했던 게 아이콘 밑에 label도 함께 표시해 주어야 한다는 점이었습니다. 학교 성적 조회 어플을 만들다보니 공시, 학기, 전체 성적이라는 3가지 각기 다른 성적을 아이콘만으로 구별하기는 좀 애매하더라구요 ^~^</p>
<p>바텀네비게이션이 늘상 그렇듯, 구현해주어야 하는 요구사항은 크게 2가지가 될 것입니다.</p>
<blockquote>
<ol>
<li>선택한 아이템은 바텀네비에서 흰색으로 표시해주어야 한다.</li>
<li>아이템이 선택되면, 위에 표시되는 페이지를 변경해주어야 한다.</li>
</ol>
</blockquote>
</br>


<h2 id="💻-코드-작성">💻 코드 작성</h2>
<h3 id="1️⃣-아이템-이미지-추가하기">1️⃣ 아이템 이미지 추가하기</h3>
<p><code>assets &gt; images &gt; menu</code>를 두어 바텀네비 아이콘이 활성화/비활성화 되었을 때의 에셋을 추가해 준다.
Flutter에서는 assets 안에 패키지를 자유롭게 만들 수 있다는 게 큰 장점인 것 같다. <del>(menu 폴더 안에 이미지를 추가할 수 있다는 것에 감명받은 Android 개발자🥹)</del>
<img src="https://velog.velcdn.com/images/nahy-512/post/1826c414-8c66-4516-a2d8-227929c98d60/image.png" alt=""></p>
<p>그렇지만 이미지 사용을 위해서는 선행되어야 하는 작업이 있다.
<code>pubspec.yaml</code>에 <code>flutter_svg</code>(svg 이미지를 추가했기 때문에)와 <code>assets의 경로</code>를 추가해 준다. </p>
<pre><code class="language-yaml">dependencies:
  flutter:
    sdk: flutter
  flutter_svg: ^2.0.9

# The following section is specific to Flutter packages.
flutter:
  assets:
    - assets/images/
    - assets/images/menu/</code></pre>
<p>바텀네비 아이콘은 menu로 한 번 더 들어가서 저장했기 때문에 <code>assets/images/menu/</code>까지 꼭 추가해 주어야 한다.</p>
<p>바텀네비에 사용될 이미지들은 관리를 손쉽게 하기 위해 <code>svg_icons</code>에 경로를 저장해 주었다.</p>
<pre><code class="language-dart">class SvgIcons {
  /// Menu
  static SvgPicture menuPublic =
      SvgPicture.asset(&quot;assets/images/menu/public_grade.svg&quot;);
  static SvgPicture menuPublicActive =
      SvgPicture.asset(&quot;assets/images/menu/public_grade_active.svg&quot;);
  static SvgPicture menuSemester =
      SvgPicture.asset(&quot;assets/images/menu/semester_grade.svg&quot;);
  static SvgPicture menuSemesterActive =
      SvgPicture.asset(&quot;assets/images/menu/semester_grade_active.svg&quot;);
  static SvgPicture menuTotal =
      SvgPicture.asset(&quot;assets/images/menu/total_grade.svg&quot;);
  static SvgPicture menuTotalActive =
      SvgPicture.asset(&quot;assets/images/menu/total_grade_active.svg&quot;);
  static SvgPicture menuSetting =
      SvgPicture.asset(&quot;assets/images/menu/setting.svg&quot;);
  static SvgPicture menuSettingActive =
      SvgPicture.asset(&quot;assets/images/menu/setting_active.svg&quot;);
}
</code></pre>
<h3 id="2️⃣-bottomnavigation-코드-작성">2️⃣ BottomNavigation 코드 작성</h3>
<p>BottomNavigationBar 안에서 아이템의 label를 표시해줄지, 배경색은 뭘로 할지, 선택되었을 때와 선택되지 않았을 떄의 label 폰트 크기는 뭘로 할지 등 다양한 옵션을 부여할 수 있다.</p>
<pre><code class="language-dart"> BottomNavigationBar(
              type: BottomNavigationBarType.fixed,
              currentIndex: _tabController.index,
              showSelectedLabels: true,
              showUnselectedLabels: true,
              backgroundColor: ColorStyles.bottomNavBackground,
              selectedItemColor: Colors.white,
              unselectedItemColor: ColorStyles.itemBackground,
              selectedFontSize: 12,
              unselectedFontSize: 12,
              items: &lt;BottomNavigationBarItem&gt;[
                BottomNavigationBarItem(
                    icon: SvgIcons.menuPublic,
                    activeIcon: SvgIcons.menuPublicActive,
                    label: &quot;공시 성적&quot;),
                BottomNavigationBarItem(
                    icon: SvgIcons.menuSemester,
                    activeIcon: SvgIcons.menuSemesterActive,
                    label: &quot;학기 성적&quot;),
                BottomNavigationBarItem(
                    icon: SvgIcons.menuTotal,
                    activeIcon: SvgIcons.menuTotalActive,
                    label: &quot;전체 성적&quot;),
                BottomNavigationBarItem(
                    icon: SvgIcons.menuSetting,
                    activeIcon: SvgIcons.menuSettingActive,
                    label: &quot;마이페이지&quot;),
              ])</code></pre>
<p>위의 BottomNavigation 코드를 화면에 표시해주기 위해 Scaffold 안에 넣게 되면</p>
<pre><code class="language-dart">class MainScreen extends StatefulWidget {
  const MainScreen({super.key});

  static String routeName = &quot;/&quot;;

  @override
  State&lt;MainScreen&gt; createState() =&gt; _MainScreenState();
}

class _MainScreenState extends State&lt;MainScreen&gt; {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        bottomNavigationBar: BottomNavigationBar(
              // 위의 BottomNavigationBar 코드
        );
  }
}</code></pre>
<p>위와 같은 형태가 된다.
<code>main.dart</code> 코드만 수정하고 바로 돌려보자.</p>
<pre><code class="language-dart">void main() {
  runApp(const GachonGradeApp());
}

/// 라우팅 설정 (pushNamed를 통해 쉽게 화면 라우팅을 할 수 있다)
final route = {
  MainScreen.routeName: (context) =&gt; const MainScreen(),
};


class GachonGradeApp extends StatelessWidget {
  const GachonGradeApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: &#39;GachonGrade&#39;,
      theme: ThemeData(
      ),
      initialRoute: MainScreen.routeName,
      routes: route,
    );
  }
}</code></pre>
<p>첫 화면으로 MainScreen이 나오도록 한다.
<image width=375 src="https://velog.velcdn.com/images/nahy-512/post/dc101ea1-46fb-4b6c-9018-fb90e64099e2/image.png"></p>
<p>실행시켜보면 위와 같은 화면이 나온다. 아직 아이템 클릭 이벤트는 없다.
클릭했을 때 선택한 아이템을 변경하고, 화면을 전환하는 코드를 추가해야 한다.</p>
<h3 id="3️⃣-bottom-navigation-클릭-동작-정의-선택-아이템-및-화면-변경">3️⃣ Bottom Navigation 클릭 동작 정의 (선택 아이템 및 화면 변경)</h3>
<h4 id="a-탭에-들어갈-화면-정의">a. 탭에 들어갈 화면 정의</h4>
<pre><code class="language-dart">class PublicGradeTab extends StatelessWidget {
  const PublicGradeTab({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      backgroundColor: ColorStyles.defaultScreenBackground,
      body: Center(
          child: Text(
            &quot;공시 성적 화면&quot;,
            style: TextStyle(color: Colors.white),
      )),
    );
  }
}</code></pre>
<p>현재 바텀네비 아이템 개수는 총 4개이다. 바텀네비 하나당 화면 하나를 매칭해 총 4개의 화면이 필요하다.
위의 예시는 공시 성적의 <code>PublicGradeTab</code>이지만, 이런 식으로 화면마다 구분이 될 수 있게끔 <code>SemesterGradeTab</code>, <code>TotalGradeTab</code>, <code>SettingTab</code>를 각각 추가해 준다.</p>
<h4 id="b-tabcontroller-tabitem-정의">b. TabController, TabItem 정의</h4>
<p>화면 전환을 관리해 줄 TabController와, 화면 안에 들어갈 TabItem을 정의한다.
TabItem에는 바로 직전 만들어준 화면들을 List&lt;Widget&gt; 형태로 넣어준다.</p>
<p>TabController에서는 애니메이션을 지원하는데, TabController 초기화 및 필수 조건인 <code>vsync</code> 사용을 위해서는 class 뒤에 <code>with SingleTickerProviderStateMixin</code>를 붙여줘야 한다. <code>vsync</code>는 애니메이션 최적화 및 언제 재생할지 시간을 세어주는 등의 기능을 위해 필요하다. <a href="https://api.flutter.dev/flutter/widgets/SingleTickerProviderStateMixin-mixin.html">SingleTickerProviderStateMixin</a>는 현재 위젯 트리가 활성화된 동안만 Tick하는 단일 Ticker를 제공하는 것이다.</p>
<pre><code class="language-dart">  class _MainScreenState extends State&lt;MainScreen&gt; with SingleTickerProviderStateMixin {
  late TabController _tabController;

  final List&lt;Widget&gt; _tabItem = [
    const PublicGradeTab(),
    const SemesterGradeTab(),
    const TotalGradeTab(),
    const SettingTab(),
  ];

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: _tabItem.length, vsync: this);
  }

  // 네비게이션바 클릭 이벤트
  void _onNavigationBarTab(int index) {
    setState(() {
      _tabController.animateTo(index);
    });
  }

  @override
  Widget build(BuildContext context) {
      // ...
  }
}</code></pre>
<h4 id="tabbarview-코드-작성">TabBarView 코드 작성</h4>
<image width=500 src="https://velog.velcdn.com/images/nahy-512/post/1fc22ea4-35a1-4c59-8fa2-7a105e1406c0/image.png">

<pre><code class="language-dart">class MainView extends StatefulWidget {
  const MainView({super.key});

  @override
  State&lt;MainView&gt; createState() =&gt; _MainViewState();
}

class _MainViewState extends State&lt;MainView&gt; with SingleTickerProviderStateMixin {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: TabBarView(
          physics: const NeverScrollableScrollPhysics(),
          controller: _tabController,
          children: _tabItem,
        ),
        bottomNavigationBar: Theme(
          data: ThemeData(
            // 아이템 클릭 효과 제거
            splashColor: Colors.transparent,
            highlightColor: Colors.transparent,
          ),
          child: BottomNavigationBar(
              // ...
              onTap: _onNavigationBarTab),
        )
    );
  }
}</code></pre>
<p>Scaffold의 body에 TabBarView를 두고, 앞서 만든 <code>_tabController</code>과 <code>_tabItem</code>를 넣어준다.
BottomNavigatoin 코드는 위에서 작성했던 것과 동일한데, onTap으로 마찬가지로 <code>_onNavigationBarTab</code>을 넣어준다. 클릭 시 다른 화면으로 이동시켜주는 역할이다.
바텀네비 아이템 클릭 효과 제거를 위해서 BottomNavigationBar를 Theme로 감싸는 코드도 추가했다.</p>
<h3 id="전체-코드">전체 코드</h3>
<pre><code class="language-dart">  class MainScreen extends StatefulWidget {
  const MainScreen({super.key});

  static String routeName = &quot;/&quot;;

  @override
  State&lt;MainScreen&gt; createState() =&gt; _MainScreenState();
}

class _MainScreenState extends State&lt;MainScreen&gt; with SingleTickerProviderStateMixin {
  late TabController _tabController;

  final List&lt;Widget&gt; _tabItem = [
    const PublicGradeTab(),
    const SemesterGradeTab(),
    const TotalGradeTab(),
    const SettingTab(),
  ];

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: _tabItem.length, vsync: this);
  }

  void _onNavigationBarTab(int index) {
    setState(() {
      _tabController.animateTo(index);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: TabBarView(
          physics: const NeverScrollableScrollPhysics(),
          controller: _tabController,
          children: _tabItem,
        ),
        bottomNavigationBar: Theme(
          data: ThemeData(
            // 아이템 클릭 효과 제거
            splashColor: Colors.transparent,
            highlightColor: Colors.transparent,
          ),
          child: BottomNavigationBar(
              type: BottomNavigationBarType.fixed,
              currentIndex: _tabController.index,
              showSelectedLabels: true,
              showUnselectedLabels: true,
              backgroundColor: ColorStyles.bottomNavBackground,
              selectedItemColor: Colors.white,
              unselectedItemColor: ColorStyles.itemBackground,
              selectedFontSize: 12,
              unselectedFontSize: 12,
              items: &lt;BottomNavigationBarItem&gt;[
                BottomNavigationBarItem(
                    icon: SvgIcons.menuPublic,
                    activeIcon: SvgIcons.menuPublicActive,
                    label: &quot;공시 성적&quot;),
                BottomNavigationBarItem(
                    icon: SvgIcons.menuSemester,
                    activeIcon: SvgIcons.menuSemesterActive,
                    label: &quot;학기 성적&quot;),
                BottomNavigationBarItem(
                    icon: SvgIcons.menuTotal,
                    activeIcon: SvgIcons.menuTotalActive,
                    label: &quot;전체 성적&quot;),
                BottomNavigationBarItem(
                    icon: SvgIcons.menuSetting,
                    activeIcon: SvgIcons.menuSettingActive,
                    label: &quot;설정&quot;),
              ],
              onTap: _onNavigationBarTab),
        )
    );
  }
}</code></pre>
<h2 id="📱-완성-화면">📱 완성 화면</h2>
<image width=375 src="https://velog.velcdn.com/images/nahy-512/post/e38e9703-8e57-40bb-9824-2b52cb59fad3/image.gif">
탭을 클릭했을 떄 화면 이동이 잘 이루어지는 걸 확인할 수 있다😊
  </br>

<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li><a href="https://velog.io/@keemeesuu/Flutter-Animation-Controller-%EA%B7%BC%EB%B3%B8%EC%A0%81-%EA%B8%B0%EC%88%A0">https://velog.io/@keemeesuu/Flutter-Animation-Controller-%EA%B7%BC%EB%B3%B8%EC%A0%81-%EA%B8%B0%EC%88%A0</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android/Kotlin] 커스텀 달력 구현하기 (feat.  Date Picker)]]></title>
            <link>https://velog.io/@nahy-512/AndroidKotlin-custom-calendar</link>
            <guid>https://velog.io/@nahy-512/AndroidKotlin-custom-calendar</guid>
            <pubDate>Sat, 09 Nov 2024 16:10:41 GMT</pubDate>
            <description><![CDATA[<h2 id="✍🏻-요구사항-분석">✍🏻 요구사항 분석</h2>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/4af83405-1eb7-4a82-82f4-f72daec92f1e/image.png" alt=""></p>
<p>디자인으로 위와 같은 달력 구현을 요구받았다.
<img src="https://velog.velcdn.com/images/nahy-512/post/418fc717-896f-4b7a-bcad-8993e541e1c6/image.png" width=375>
화면에서는 바텀시트처럼 띄워줘야 했다.
커스텀을 위해 <code>MaterialCalendar</code> 활용 대신 직접 구현하는 방식을 택했다.</p>
<p>PM과 논의한 결과 월, 년도 선택은 나중에 구현하기로 했고, 일 선택 화면만 우선 구현하기로 했다.
그럼, 피그마 화면에서의 요구사항을 한 번 정리해 보자.</p>
<blockquote>
<p><strong>[요구사항]</strong></p>
</blockquote>
<ol>
<li>맨 처음에는 오늘 날짜가 세팅되어 있음 (날짜에 초록색 배경 원 표시)</li>
<li>오늘 이전 날짜는 비활성화 처리</li>
<li>상단 화살표를 통해 달을 이동할 수 있음</li>
<li>날짜를 클릭한 후에는 텍스트뷰에 선택 날짜 반영</li>
</ol>
<p>아이디어는 여느때와 같이 리사이클러뷰를 활용하는 것이다. 한 주는 7일로 고정이니, GridLayoutManager를 사용할 계획이다.</p>
</br>

<h2 id="💻-코드-작성">💻 코드 작성</h2>
<h3 id="1️⃣-날짜-아이템">1️⃣ 날짜 아이템</h3>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;layout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;&gt;

    &lt;androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;wrap_content&quot;
        android:layout_marginBottom=&quot;6dp&quot;&gt;

        &lt;LinearLayout
            android:id=&quot;@+id/item_calendar_date_bg&quot;
            android:layout_width=&quot;0dp&quot;
            android:layout_height=&quot;wrap_content&quot;
            app:layout_constraintDimensionRatio=&quot;1:1&quot;
            android:padding=&quot;11dp&quot;
            android:backgroundTint=&quot;@color/transparent&quot;
            android:background=&quot;@drawable/bg_circle_fill&quot;
            app:layout_constraintStart_toStartOf=&quot;parent&quot;
            app:layout_constraintEnd_toEndOf=&quot;parent&quot;
            app:layout_constraintTop_toTopOf=&quot;parent&quot;
            app:layout_constraintBottom_toBottomOf=&quot;parent&quot;&gt;

            &lt;TextView
                android:id=&quot;@+id/item_calendar_date_tv&quot;
                android:layout_width=&quot;match_parent&quot;
                android:layout_height=&quot;wrap_content&quot;
                android:text=&quot;1&quot;
                android:textColor=&quot;@color/title_black&quot;
                style=&quot;@style/calendar_date_tv&quot;/&gt;

        &lt;/LinearLayout&gt;

    &lt;/androidx.constraintlayout.widget.ConstraintLayout&gt;

&lt;/layout&gt;</code></pre>
<p>달력에 들어갈 날짜의 디자인을 잡아준다. 선택된 아이템의 경우는 배경이 초록색 원으로 되어야 하므로, LinearLayout의 background를 동그라미로 해주고, 레이아웃 안에 텍스트뷰를 넣어준다. 
<img src="https://velog.velcdn.com/images/nahy-512/post/a9735fc5-a684-438b-b368-4fdda48fb06f/image.png" alt=""></p>
<p>배경색을 지정하면 오른쪽 같은 느낌이다.</p>
<h3 id="2️⃣-바텀시트-레이아웃-구현">2️⃣ 바텀시트 레이아웃 구현</h3>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;layout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;&gt;

    &lt;androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;match_parent&quot;&gt;

        &lt;ImageView
            android:id=&quot;@+id/calendar_close_iv&quot;
            android:layout_width=&quot;wrap_content&quot;
            android:layout_height=&quot;wrap_content&quot;
            android:layout_marginTop=&quot;24dp&quot;
            android:layout_marginEnd=&quot;22dp&quot;
            android:padding=&quot;5dp&quot;
            android:src=&quot;@drawable/ic_close&quot;
            app:layout_constraintEnd_toEndOf=&quot;parent&quot;
            app:layout_constraintTop_toTopOf=&quot;parent&quot;/&gt;

        &lt;!-- 상단 날짜 --&gt;
        &lt;LinearLayout
            android:layout_width=&quot;match_parent&quot;
            android:layout_height=&quot;wrap_content&quot;
            android:orientation=&quot;vertical&quot;
            android:paddingHorizontal=&quot;22dp&quot;
            app:layout_constraintTop_toBottomOf=&quot;@id/calendar_close_iv&quot;
            app:layout_constraintStart_toStartOf=&quot;parent&quot;&gt;

            &lt;LinearLayout
                android:id=&quot;@+id/calendar_top_ll&quot;
                android:layout_width=&quot;wrap_content&quot;
                android:layout_height=&quot;wrap_content&quot;
                android:orientation=&quot;horizontal&quot;
                android:layout_gravity=&quot;center_vertical&quot;&gt;

                &lt;ImageView
                    android:id=&quot;@+id/calendar_previous_month_iv&quot;
                    android:layout_width=&quot;26dp&quot;
                    android:layout_height=&quot;26dp&quot;
                    android:padding=&quot;3dp&quot;
                    android:layout_gravity=&quot;center_vertical&quot;
                    android:src=&quot;@drawable/ic_arrow_left&quot; /&gt;

                &lt;TextView
                    android:id=&quot;@+id/calendar_year_month_tv&quot;
                    android:layout_width=&quot;wrap_content&quot;
                    android:layout_height=&quot;wrap_content&quot;
                    android:minWidth=&quot;110dp&quot;
                    android:paddingVertical=&quot;3dp&quot;
                    android:layout_marginHorizontal=&quot;10dp&quot;
                    tools:text=&quot;2023년 1월&quot;
                    style=&quot;@style/title_l&quot;/&gt;

                &lt;ImageView
                    android:id=&quot;@+id/calendar_next_month_iv&quot;
                    android:layout_width=&quot;26dp&quot;
                    android:layout_height=&quot;26dp&quot;
                    android:padding=&quot;3dp&quot;
                    android:layout_gravity=&quot;center_vertical&quot;
                    android:rotation=&quot;180&quot;
                    android:src=&quot;@drawable/ic_arrow_left&quot;/&gt;

            &lt;/LinearLayout&gt;

            &lt;!-- 달력 --&gt;
            &lt;LinearLayout
                android:layout_width=&quot;match_parent&quot;
                android:layout_height=&quot;42dp&quot;
                android:layout_marginTop=&quot;18dp&quot;
                android:orientation=&quot;horizontal&quot;
                android:gravity=&quot;center_vertical&quot;
                app:layout_constraintStart_toStartOf=&quot;parent&quot;
                app:layout_constraintTop_toBottomOf=&quot;@+id/full_cal_change_month_arrows_ll&quot;&gt;

                &lt;TextView
                    android:text=&quot;일&quot;
                    style=&quot;@style/calendar_date_tv&quot; /&gt;
                &lt;TextView
                    android:text=&quot;월&quot;
                    style=&quot;@style/calendar_date_tv&quot; /&gt;
                &lt;TextView
                    android:text=&quot;화&quot;
                    style=&quot;@style/calendar_date_tv&quot; /&gt;
                &lt;TextView
                    android:text=&quot;수&quot;
                    style=&quot;@style/calendar_date_tv&quot; /&gt;
                &lt;TextView
                    android:text=&quot;목&quot;
                    style=&quot;@style/calendar_date_tv&quot; /&gt;
                &lt;TextView
                    android:text=&quot;금&quot;
                    style=&quot;@style/calendar_date_tv&quot; /&gt;
                &lt;TextView
                    android:text=&quot;토&quot;
                    style=&quot;@style/calendar_date_tv&quot; /&gt;
            &lt;/LinearLayout&gt;

            &lt;androidx.recyclerview.widget.RecyclerView
                android:id=&quot;@+id/calendar_date_rv&quot;
                android:layout_width=&quot;match_parent&quot;
                android:layout_height=&quot;wrap_content&quot;
                android:orientation=&quot;vertical&quot;
                app:layoutManager=&quot;androidx.recyclerview.widget.GridLayoutManager&quot;
                app:layout_constraintVertical_weight=&quot;1&quot;
                app:layout_constraintHorizontal_weight=&quot;1&quot;
                app:layout_constraintTop_toBottomOf=&quot;@+id/full_cal_day_of_month_ll&quot;
                app:layout_constraintStart_toStartOf=&quot;parent&quot;
                app:spanCount=&quot;7&quot;
                tools:listitem=&quot;@layout/item_calendar_date&quot;
                tools:itemCount=&quot;31&quot;/&gt;

        &lt;/LinearLayout&gt;

    &lt;/androidx.constraintlayout.widget.ConstraintLayout&gt;
&lt;/layout&gt;</code></pre>
<p>날짜 표시는 리사이클러뷰 GridLayout를 사용해서 해줄 수 있다.
<img src="https://velog.velcdn.com/images/nahy-512/post/c34b0df6-b2cb-4f0a-b940-6b233a96c5cc/image.png" alt=""></p>
<p>디자인 탭에서 보면 이런 모습이다.</p>
<h3 id="3️⃣-리사이클러뷰-어댑터">3️⃣ 리사이클러뷰 어댑터</h3>
<pre><code class="language-kotlin">class CalendarRVAdapter(private val selectedDatePosition: Int, private val selectedMonth: Int) : RecyclerView.Adapter&lt;CalendarRVAdapter.ViewHolder&gt;() {

    private var dateList = listOf&lt;LocalDate?&gt;() // 달력에 표시될 날짜 목록
    private var selectedItemPosition = -1 // 달이 넘어가더라도 선택한 날짜는 유일하게 표시해주기 위함
    private lateinit var mItemClickListener: MyDateClickListener

    private lateinit var context: Context

    interface MyDateClickListener {
        fun onDateClick(selectedDate: LocalDate)
    }

    fun setMyDateClickListener(itemClickListener: MyDateClickListener) {
        mItemClickListener = itemClickListener
    }

    @SuppressLint(&quot;NotifyDataSetChanged&quot;)
    fun addDateList(dateList: List&lt;LocalDate?&gt;) {
        this.dateList = dateList
        this.selectedItemPosition = if (dateList[10]!!.monthValue == selectedMonth) selectedDatePosition else -1
        notifyDataSetChanged()
    }

    // 보여지는 화면 설정
    override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
        val binding: ItemCalendarDateBinding = ItemCalendarDateBinding.inflate(
            LayoutInflater.from(viewGroup.context), viewGroup, false
        )
        context = viewGroup.context
        return ViewHolder(binding)
    }

    // 내부 데이터 설정
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        if (dateList[position] == null) { // 날짜 데이터가 없을 경우 캘린더에 표시하지 않음
            holder.dateText.text = null
            return
        }

        // 날짜의 date만 표시
        holder.dateText.text = dateList[position]!!.dayOfMonth.toString()

        if (dateList[position]!! &lt; TODAY) { // 오늘 이전 날짜 회색 처리
            holder.dateText.setTextColor(ContextCompat.getColor(context, R.color.gray5))
            holder.dateText.setOnClickListener { null } // 클릭 불가 처리
            return
        }
        if (selectedItemPosition == position) { // 선택 날짜 표시
            holder.bg.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.main))
            holder.dateText.setTextColor(ContextCompat.getColor(context, R.color.white))
            holder.dateText.setTypeface(null, Typeface.BOLD) // 볼드 처리
        } else { // 선택하지 않은 날짜 표시
            holder.bg.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.transparent))
            holder.dateText.setTextColor(ContextCompat.getColor(context, R.color.title_black))
            holder.dateText.setTypeface(null, Typeface.NORMAL)
        }

        // 날짜 클릭 이벤트
        holder.bg.setOnClickListener {
            notifyItemChanged(selectedItemPosition) // 이전에 선택한 아이템 notify
            selectedItemPosition = position // 선택한 날짜 position 업데이트
            notifyItemChanged(selectedItemPosition) // 새로 선택한 아이템 notify
            mItemClickListener.onDateClick(dateList[selectedItemPosition]!!) // 클릭 이벤트 처리
        }
    }

    override fun getItemCount(): Int = dateList.size

    inner class ViewHolder(val binding: ItemCalendarDateBinding): RecyclerView.ViewHolder(binding.root){
        val bg: LinearLayout = binding.itemCalendarDateBg
        var dateText: TextView = binding.itemCalendarDateTv
    }
}</code></pre>
<p>달력에 표시할 날짜 목록은 LocalDate 리스트로 받아온다. 이전에 선택한 날짜는 그대로 선택된 채 나타내기 위해 <code>selectedItemPosition</code>를 사용한다. 
<code>onBindViewHolder</code>에서 리사이클러뷰 날짜를 위한 코드를 작성해 준다. 오늘 이전 날짜는 회색으로 표시하고, 선택 날짜는 초록색 동그라미 배경 + 글자색 흰색 + 볼드 처리를 해준다.</p>
<h3 id="4️⃣-calendarbottomsheet">4️⃣ CalendarBottomSheet</h3>
<pre><code class="language-kotlin">interface DateClickListener {
    fun onDateReceived(isStartDate: Boolean, date: LocalDate)
}

@RequiresApi(Build.VERSION_CODES.O)
class CalendarBottomSheet(private var listner: DateClickListener, var isStartDate: Boolean, private var initialDate: LocalDate) : BottomSheetDialogFragment() {
    private lateinit var binding: BottomSheetCalendarBinding
    private var criteriaDate = this.initialDate // 캘린더 날짜를 가져오는 기준 일자

    private lateinit var calendarAdapter: CalendarRVAdapter

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = BottomSheetCalendarBinding.inflate(inflater, container, false)

        initClickListeners()
        setAdapter()

        return binding.root
    }


    private fun initClickListeners() {
        /* 화살표 눌러서 월 이동 */
        binding.calendarPreviousMonthIv.setOnClickListener { // 이전 달
            setCalendarDate(-1)
        }
        binding.calendarNextMonthIv.setOnClickListener { // 다음 달
            setCalendarDate(+1)
        }

        // 닫기 버튼 클릭
        binding.calendarCloseIv.setOnClickListener {
            dismiss() // 창 닫기
        }
    }

    // 날짜 적용 함수
    private fun setAdapter() {
        // 어댑터 초기화
        calendarAdapter = CalendarRVAdapter(getSelectedDatePosition(), initialDate.monthValue)
        binding.calendarDateRv.apply {
            layoutManager = GridLayoutManager(requireContext(), DAY_OF_WEEK)
            adapter = calendarAdapter
        }
        setCalendarDate(0)
        // 날짜 클릭 이벤트
        calendarAdapter.setMyDateClickListener(object: CalendarRVAdapter.MyDateClickListener{
            override fun onDateClick(selectedDate: LocalDate) {
                listner.onDateReceived(isStartDate, selectedDate) // 날짜 전달
                dismiss() // 뒤로가기
            }
        })
    }

    private fun setCalendarDate(direct: Long) {
        criteriaDate = criteriaDate.plusMonths(direct)
        // 상단 날짜 세팅
        binding.calendarYearMonthTv.text = DateConverter.getFormattedYearMonth(criteriaDate)
        calendarAdapter.addDateList(dayInMonthArr(criteriaDate))
    }

    // 날짜 생성
    private fun dayInMonthArr(date: LocalDate): ArrayList&lt;LocalDate?&gt; {
        val dateList = ArrayList&lt;LocalDate?&gt;()
        val yearMonth = YearMonth.from(date)

        // 월의 시작일
        val monthFirstDate = criteriaDate.withDayOfMonth(1)
        // 월 첫 날의 요일 (일요일=0, ... ,월요일=6)
        val dayOfMonthFirstDate = monthFirstDate.dayOfWeek.value % DAY_OF_WEEK
        // 월의 종료일
        val monthLastDate = yearMonth.lengthOfMonth()

        for (i in 1..DAY_OF_WEEK * 6) { // 6줄짜리 달력
            if (dayOfMonthFirstDate == SUNDAY) { // 일~토 달력에서 1일이 일요일일 때, 첫째주가 비는 현상 제거
                if (i &lt;= monthLastDate){
                    dateList.add(LocalDate.of(date.year, date.monthValue, i))
                }
                else {
                    dateList.add(null)
                }
            } else {
                if (i &gt; dayOfMonthFirstDate &amp;&amp; i &lt; (monthLastDate + dayOfMonthFirstDate)) {
                    dateList.add(LocalDate.of(date.year, date.monthValue, i - dayOfMonthFirstDate))
                } else {
                    dateList.add(null)
                }
            }
        }

        return dateList
    }

    private fun getSelectedDatePosition(): Int {
        // 월 첫 날의 요일 구하기
        val dayOfWeek = initialDate.withDayOfMonth(1).dayOfWeek.value % DAY_OF_WEEK
        // 초기 날짜의 포지션 계산
        return initialDate.dayOfMonth + dayOfWeek - 1
    }

    companion object {
        const val DAY_OF_WEEK = 7 // 일주일
        const val SUNDAY = 0
    }
}</code></pre>
<p>일주일은 7일이니까 <code>DAY_OF_WEEK = 7</code>로 상수 처리를 해주고, 특별한 상황인 <code>SUNDAY = 0</code>도 미리 추가했다.</p>
<ul>
<li><code>dayInMonthArr</code>
달력에는 기본적으로 일요일부터 표시를 시작해서, 7일 * 6주 = 42개의 날짜를 dateList에 넣어줄 것이다. 하지만 디자인을 봤을 때 이번 달에 벗어나는 날짜는 표시해 줄 필요가 없으므로 이전/다음 달의 날짜인 경우에는 null를 추가해 준다. 이 null인 date는 어댑터에서 null일 경우는 표시해주지 않게끔 미리 구현했다.</li>
<li><code>DateClickListener</code>
날짜를 클릭하면 선택한 날짜를 이전 화면에 넘겨줘야 하기에 인터페이스를 구현했다.</li>
</ul>
<h3 id="5️⃣-달력-바텀시트-띄우기">5️⃣ 달력 바텀시트 띄우기</h3>
<pre><code class="language-kotlin">class RouteCreateActivity : AppCompatActivity(), DateClickListener {
    private lateinit var binding: ActivityRouteCreateBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_route_create)

        // ,..

        initClickListeners()
    }

    private fun initClickListeners() {
        // ...
        // 시작 날짜
        binding.routeCreateStartDateTv.setOnClickListener {
            showCalendarBottomSheet(true, viewModel.startDate.value!!)
        }

        // 종료 날짜
        binding.routeCreateEndDateTv.setOnClickListener {
            showCalendarBottomSheet(false, viewModel.endDate.value!!)
        }
    }

    private fun showCalendarBottomSheet(isStartDate: Boolean, date: LocalDate) {
        val calendarBottomSheet = CalendarBottomSheet(this, isStartDate, date)
        calendarBottomSheet.run {
            setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialogStyle)
        }
        calendarBottomSheet.show(this.supportFragmentManager, calendarBottomSheet.tag)
    }

    override fun onDateReceived(isStartDate: Boolean, date: LocalDate) {
        viewModel.updateDate(isStartDate, date)
    }

    companion object {
        @RequiresApi(Build.VERSION_CODES.O)
        val TODAY: LocalDate = LocalDate.now()
    }
}</code></pre>
<p><code>showCalendarBottomSheet</code>에서 앞서 구현한 캘린더 바텀시트를 띄우는 코드를 작성해 준다.
바텀시트에서 날짜를 클릭하면 뷰모델로 선택한 날짜가 업데이트되었다고 알려준다.</p>
<h3 id="번외-이전-날짜-회색-처리-여부를-선택하는-옵션-추가하기">(번외) 이전 날짜 회색 처리 여부를 선택하는 옵션 추가하기</h3>
<p>맨 처음 나온 화면에서는 달력에서 오늘 이후 날짜만 선택할 수 있게끔 이전 날짜들은 아예 회색으로 처리하고, 클릭도 불가능하게 했었다. 이건 기획상으로 과거 날짜는 선택하지 못했기 때문인데, 수정 시에는 지나간 날짜를 아예 수정하지 못하면 날짜를 선택하는 의미가 없어진다. 때문에 이전 날짜도 선택할 수 있게끔 옵션을 제공해야 했다.
바로 어댑터의 코드를 수정해주면 된다!</p>
<pre><code class="language-kotlin">class CalendarRVAdapter(private val setPrevDateDisable: Boolean, ...) : RecyclerView.Adapter&lt;CalendarRVAdapter.ViewHolder&gt;() {
    // 내부 데이터 설정
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        if (dateList[position] == null) { // 날짜 데이터가 없을 경우 캘린더에 표시하지 않음
            holder.dateText.text = null
            return
        }

        // 날짜의 date만 표시
        holder.dateText.text = dateList[position]!!.dayOfMonth.toString()

        if (setPrevDateDisable &amp;&amp; dateList[position]!! &lt; TODAY) { // 오늘 이전 날짜 회색 처리
            holder.dateText.setTextColor(ContextCompat.getColor(context, R.color.gray5))
            holder.dateText.setOnClickListener { null } // 클릭 불가 처리
            return
        }

        // 선택 날짜, 선택하지 않은 날짜 처리

        // 날짜 클릭 이벤트
        holder.bg.setOnClickListener {
            notifyItemChanged(selectedItemPosition) // 이전에 선택한 아이템 notify
            selectedItemPosition = position // 선택한 날짜 position 업데이트
            notifyItemChanged(selectedItemPosition) // 새로 선택한 아이템 notify
            mItemClickListener.onDateClick(dateList[selectedItemPosition]!!) // 클릭 이벤트 처리
        }
    }
}</code></pre>
<p><code>CalendarRVAdapter</code>의 생성자로 이전 날짜를 비활성화 할지를 관리하는 <code>setPrevDateDisaable</code>를 추가하고, 기존에 오늘 이전 날짜 회색 처리를 하던 코드에 <code>setPrevDateDisable</code>를 달아준다. 이 값이 false면 이전 날짜도 그대로 표시할 수 있도록!
</br></p>
<h2 id="📱-완성-화면">📱 완성 화면</h2>
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/5a7d57ea-cf8c-48b6-9d4e-9b8cf298f184/image.gif">

<p>맨 처음에는 오늘 날짜로 달력이 설정되고, 화살표를 눌러 월을 이동하는 것, 과거 날짜는 회색 처리가 되어있는 것, 클릭했을 때 텍스트뷰에 반영되는 것까지! 요구사항대로 구현이 모두 끝난 것을 확인할 수 있다.</p>
<blockquote>
<p>👉🏻 달력/피커 관련 다른 글 보러가기</p>
</blockquote>
<ul>
<li><a href="https://velog.io/@nahy-512/AndroidKotlin-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EC%97%86%EC%9D%B4-%EC%A3%BC%EA%B0%84%EB%8B%AC%EB%A0%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1">라이브러리 없이 주간달력 구현하기 (1/2) - xml 편</a></li>
<li><a href="https://velog.io/@nahy-512/AndroidKotlin-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EC%97%86%EC%9D%B4-%EC%A3%BC%EA%B0%84%EB%8B%AC%EB%A0%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-2">라이브러리 없이 주간달력 구현하기 (2/2) - 코드 편</a></li>
<li><a href="https://velog.io/@nahy-512/AndroidKotlin-timepicker-interval#3%EF%B8%8F%E2%83%A3-timepickerbottomsheetkt">TimePicker 시간 간격(interval) 설정하기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android/Kotlin] 카카오맵에 선형 경로 표시하기]]></title>
            <link>https://velog.io/@nahy-512/AndroidKotlin-%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5%EC%97%90-%EC%84%A0%ED%98%95-%EA%B2%BD%EB%A1%9C-%ED%91%9C%EC%8B%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@nahy-512/AndroidKotlin-%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5%EC%97%90-%EC%84%A0%ED%98%95-%EA%B2%BD%EB%A1%9C-%ED%91%9C%EC%8B%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 30 Oct 2024 03:14:04 GMT</pubDate>
            <description><![CDATA[<h2 id="✍🏻-요구사항-분석">✍🏻 요구사항 분석</h2>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/abe0c247-5b9d-474c-bfba-140885503469/image.png" alt="">
지난 주에 포스팅 한 <a href="https://velog.io/@nahy-512/AndroidKotlin-%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5-%EB%A7%88%EC%BB%A4-%EC%9C%84%EC%97%90-%ED%85%8D%EC%8A%A4%ED%8A%B8-%ED%91%9C%EC%8B%9C%ED%95%98%EA%B8%B0">카카오맵 마커(라벨) 위에 텍스트 표시하기</a>에 이어서, 사용자가 이동한 경로를 지도에 선으로 표시해 주어야 한다는 요구사항이 있었다.
오늘은 카카오맵의 RouteLine을 이용해 지도에 선을 표시하는 코드를 작성해 보겠다.</p>
<blockquote>
<ul>
<li>RouteLine 공식 문서: <a href="https://apis.map.kakao.com/android_v2/docs/api-guide/routeline/">https://apis.map.kakao.com/android_v2/docs/api-guide/routeline/</a></li>
</ul>
</blockquote>
<p>아래의 RouteLine에 적용할 수 있는 다양한 스타일 중 가장 왼쪽의, 하나의 세그컨트와 스타일로 이루어진 형태를 만들 것이기 때문에 간단하게 구현할 수 있었다.
<img src="https://velog.velcdn.com/images/nahy-512/post/d8344493-742f-4815-956e-436cab43fe2b/image.png" alt="">
</br></br></p>
<h2 id="💻-코드-작성">💻 코드 작성</h2>
<h3 id="1️⃣-스타일-지정">1️⃣ 스타일 지정</h3>
<p>지난 번에 작성한 MapUtil에 RouteLine 관련 코드도 추가해 준다.</p>
<pre><code class="language-kotlin">object MapUtil {
    const val DEFAULT_ZOOM_LEVEL = 10 // 루트를 표시하는 기본 줌 레벨

    // 루트 경로의 평균 좌표로 지도 중심에 위치할 지점을 반환
    fun getRoutePathCenterPoint(activities: List&lt;ActivityResult&gt;): LatLng {
        val routeActivityList = getLatLngRoutePath(activities)
        return LatLng.from(
            routeActivityList.map { it.latitude }.average(),
            routeActivityList.map { it.longitude }.average()
        )
    }

    // 루트 경로를 그릴 LatLng 리스트 반환
    fun getLatLngRoutePath(activities: List&lt;ActivityResult&gt;): List&lt;LatLng&gt; {
        //TODO: 활동 경로 외에도 점들로 기록한 routePath 추가
        return activities.map {
            LatLng.from(it.latitude.toDouble(), it.longitude.toDouble())
        }
    }

    // RouteLine
    fun setRoutePathStyle(context: Context): RouteLineStyles {
        return RouteLineStyles.from(
            RouteLineStyle.from(6f, ContextCompat.getColor(context, R.color.main))
        )
    }
}</code></pre>
<p><code>setRoutePathStyle</code>로 RouteLine의 굵기와 색상을 정해준다.
<code>getLatLngRoutePath</code>에서는 현재 활동끼리만 선으로 이어주고 있는데, 활동 외에도 사용자가 지나온 경로를 함께 이어주어야 한다.</p>
<h3 id="2️⃣-activityfragment에서의-사용">2️⃣ Activity/Fragment에서의 사용</h3>
<pre><code class="language-kotlin">private fun drawRoutePath() {
        if (!viewModel.hasActivity()) return
        val segment: RouteLineSegment = RouteLineSegment.from(
            getLatLngRoutePath(viewModel.getActivityList())
        ).setStyles(setRoutePathStyle(this))
        val options = RouteLineOptions.from(segment)
        // 지도에 선 표시
        kakaoMap?.routeLineManager?.layer?.addRouteLine(options)?.show()
    }</code></pre>
<p>RouteLineSegment에 이어줘야 할 점(point)을 넣어주고, 위에서 미리 만들어 준 스타일을 지정할 수 있다.
RouteLineOptions에는 여러 segment를 넣어줄 수도 있지만, 우리 서비스에서 표시해주어야 할 건 단색의 일관된 선이기 때문에 segment 하나만 넣어주었다.
</br></br></p>
<h2 id="📱-완성-모습">📱 완성 모습</h2>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/845ef017-9045-4846-b72f-6fc4a4202056/image.png" alt="">
지도에 선으로 잘 연결된 모습도 같이 확인할 수 있다!
</br></br></p>
<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li><a href="https://apis.map.kakao.com/android_v2/docs/api-guide/routeline/">https://apis.map.kakao.com/android_v2/docs/api-guide/routeline/</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android/Kotlin] 카카오맵 마커(라벨) 위에 텍스트 표시하기]]></title>
            <link>https://velog.io/@nahy-512/AndroidKotlin-%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5-%EB%A7%88%EC%BB%A4-%EC%9C%84%EC%97%90-%ED%85%8D%EC%8A%A4%ED%8A%B8-%ED%91%9C%EC%8B%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@nahy-512/AndroidKotlin-%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5-%EB%A7%88%EC%BB%A4-%EC%9C%84%EC%97%90-%ED%85%8D%EC%8A%A4%ED%8A%B8-%ED%91%9C%EC%8B%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 20 Oct 2024 18:07:54 GMT</pubDate>
            <description><![CDATA[<p>매우매우 삽질했던 이야기...... 카카오맵 자체에서는 기본적으로 지원하지 않는 기능인 거 같아 많이 헤맸었다.</p>
<blockquote>
<p>🔗 <strong>[관련 포스트]</strong></p>
</blockquote>
<ul>
<li>이전 글: <a href="https://velog.io/@nahy-512/%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5-sdk-%EB%B2%84%EC%A0%84-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8">카카오맵 신규 SDK 사용하기</a></li>
<li>다음 글: <a href="https://velog.io/@nahy-512/AndroidKotlin-%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5%EC%97%90-%EC%84%A0%ED%98%95-%EA%B2%BD%EB%A1%9C-%ED%91%9C%EC%8B%9C%ED%95%98%EA%B8%B0">카카오맵에 선형 경로 표시하기</a></li>
</ul>
<h2 id="✍🏻-요구사항-분석">✍🏻 요구사항 분석</h2>
<table>
<thead>
<tr>
<th><img width=375 src="https://velog.velcdn.com/images/nahy-512/post/381f7418-9b2f-4557-87c7-5ba17ddd54ac/image.jpg"> 🌟 루트 표시 🌟</th>
<th><img width=375 src="https://velog.velcdn.com/images/nahy-512/post/7eceef43-1a9e-4f3c-8cd6-4c3561fe802c/image.png"> (참고) 활동 목록</th>
</tr>
</thead>
<tbody><tr>
<td>디자인을 보면서 지도에 나타낼 것을 생각해보자.</td>
<td></td>
</tr>
<tr>
<td>지도에는 아래 정보를 표시해야 한다.</td>
<td></td>
</tr>
<tr>
<td>&gt; 1. 지나온 루트를 선으로 연결해주어야 한다.</td>
<td></td>
</tr>
<tr>
<td>2. 활동을 순서대로 마커로 표시해주어야 한다. (우측 활동 목록과 비교)</td>
<td></td>
</tr>
</tbody></table>
<p>1번은 쉽게 구현할 수 있었지만, 2번은 매우 많은 고민을 했었다.
카카오맵이 24년 6월 30일부터 v2로 바뀌어서.. 관련 자료도 많이 없다는 게 참 마힘이었다.
<img src="https://velog.velcdn.com/images/nahy-512/post/e5eae9de-f809-40a2-817e-f8f5843d9c3f/image.png" alt=""></p>
<blockquote>
<ul>
<li><a href="https://apis.map.kakao.com/android_v2/docs/api-guide/label/label/">KakaoMaps SDK v2 for Android - Label</a></li>
</ul>
</blockquote>
<ul>
<li><a href="https://apis.map.kakao.com/android_v2/reference/com/kakao/vectormap/label/LabelStyle.html">https://apis.map.kakao.com/android_v2/reference/com/kakao/vectormap/label/LabelStyle.html</a></li>
</ul>
<p>위의 두 개 문서를 많이 참고했다. 카카오에서 제공해 주는 공식 문서이다.
<img src="https://velog.velcdn.com/images/nahy-512/post/407ecb01-a2b2-4dd9-883a-8bc49966982a/image.png" alt=""></p>
<h2 id="🔥-시행-착오">🔥 시행 착오</h2>
<p>위의 공식 문서의 LabelStyle를 보면 나에게 필요한 건 Icon과 Text라는 것을 알 수 있었다.
그래서 첫 번째로 떠올렸던 방법은 LabelStyles에서 IconStyle과 LabelTextStyle를 지정한 뒤, <strong>&quot;이 Icon과 Text를 겹치는 방법&quot;</strong>이었다.</p>
<ul>
<li>기본 코드 (잘 보이게 하기 위해 텍스트 색상을 검정으로 해놨다)<pre><code class="language-kotlin">kakaoMap?.labelManager?.layer?.addLabel(LabelOptions.from(latLng)
          .setStyles(setPinStyle(this, category))
          .setTexts(
              LabelTextBuilder().setTexts(activityNumber)
          )
      )
</code></pre>
</li>
</ul>
<p>companion object {
    fun setPinStyle(context: Context, category: Category): LabelStyles {
        return LabelStyles.from(
            LabelStyle.from(category.categoryMarkerIcon)
                    .setTextStyles(LabelTextStyle.from(35, ContextCompat.getColor(context, R.color.black))
            )
        )
    }
}</p>
<pre><code>- 기본 화면
&lt;img width=375 src=&quot;https://velog.velcdn.com/images/nahy-512/post/1c9fca05-f6d0-429c-bd35-3872cc3f6658/image.png&quot;&gt;


그러나.. LabelStyle에서 `setPadding`, `setTextGravity`, `setAnchorPoint` 등을 모두 써보고 숫자도 엄청 조절해 봤지만 내가 원하는 &#39;아이콘과 텍스트가 완전히 겹쳐지는 결과&#39;는 나타나지 않았다.
![](https://velog.velcdn.com/images/nahy-512/post/3d01e0b4-8437-42b0-9adf-80cb1a9d703f/image.png)


![](https://velog.velcdn.com/images/nahy-512/post/142d8574-8728-42c4-bf14-e78172bc3c0a/image.png) .setPadding(0.5f) | ![](https://velog.velcdn.com/images/nahy-512/post/eb1fbf14-944e-475b-9b8b-9c3096ff13ad/image.png) .setAnchorPoint(0f, -1f) | ![](https://velog.velcdn.com/images/nahy-512/post/788f6fb0-04b2-49dd-b486-bab57bb8a2c8/image.png) .setTextGravity(1)
---|---|---|
아무리 숫자를 바꿔봐도...... 원하는 결과는 나오지 않았다.

그래서 생각한 방법은 두 번째!
IconLabel과 TextLabel을 나눠서 텍스트가 아이콘 안에 위치하게끔 하는 것이었다.
이 방법이 정답이었다... 
&lt;/br&gt;&lt;/br&gt;


## 💻 코드 작성
### 1️⃣ LabelStyle 지정
``` kotlin
companion object {
        const val DEFAULT_ZOOM_LEVEL = 10 // 루트를 표시하는 기본 줌 레벨

        // IconLabel
        private fun setMapIconLabelStyles(category: Category): LabelStyles {
            return LabelStyles.from(
                LabelStyle.from(category.categoryMarkerIcon)
            )
        }

        fun getMapActivityIconLabelOptions(latLng: LatLng, category: Category, activityNumber: Int): LabelOptions {
            return LabelOptions.from(latLng)
                .setStyles(setMapIconLabelStyles(category))
        }

        // TextLabel
        private fun setMapTextLabelStyle(): LabelStyles {
            return LabelStyles.from(
                LabelStyle.from(LabelTextStyle.from(28, Color.BLACK))
            )
        }

        fun getMapActivityNumberLabelOptions(latLng: LatLng, activityNumber: Int): LabelOptions {
            return LabelOptions.from(latLng)
                .setStyles(setMapTextLabelStyle())
                .setTexts(LabelTextBuilder().setTexts(activityNumber.toString()))
        }
    }</code></pre><p>TextLabel의 사이즈는 마커 아이콘에 맞춰 28로 해주었다. 이건 디자인에 따라 달라질 수 있다.</p>
<h3 id="2️⃣-지도-위에-마커-표시">2️⃣ 지도 위에 마커 표시</h3>
<pre><code class="language-kotlin">private fun setActivityMarker() {
        if (!viewModel.hasActivity()) return // 활동이 하나도 없다면 return
        // 활동 마커 추가하기
        viewModel.route.value?.routeActivities!!.forEachIndexed { index, activity -&gt;
            // 지도에 마커 표시
            addMarker(
                LatLng.from(activity.latitude.toDouble(), activity.longitude.toDouble()),
                Category.getCategoryByName(activity.category),
                index.plus(1) // 장소 번호는 0번부터 시작
            )
        }
    }

// 마커 띄우기
private fun addMarker(latLng: LatLng, category: Category, activityNumber: Int) {
        val layer = kakaoMap?.labelManager?.layer

        // IconLabel 추가
        val iconLabel = layer?.addLabel(
            getMapActivityIconLabelOptions(latLng, category, activityNumber)
        )

        // TextLabel 추가
        val textLabel = layer?.addLabel(
            getMapActivityNumberLabelOptions(latLng, activityNumber)
        )

        // TextLabel의 위치를 IconLabel 내부로 조정
        if (iconLabel != null &amp;&amp; textLabel != null) {
            // IconLabel의 크기를 가정 (예: 60x60 픽셀)
            val iconSize = 60f
            // 텍스트를 아이콘 중심에서 약간 위로 이동
            val offsetY = - iconSize / (2.3)

            // changePixelOffset 메서드를 사용하여 텍스트 라벨의 위치 조정
            textLabel.changePixelOffset(0f, offsetY.toFloat())
        }
    }</code></pre>
<p>iconLabel, textLabel를 만든 뒤 <code>changePixelOffset()</code>를 통해 textLabel의 위치를 iconLabel 안으로 옮겨준다. iconSize와 offsetY 또한 여러 번 시도해서 조정한 값이다.</p>
<h4 id="📱-중간-점검---1">📱 중간 점검 - 1</h4>
<p>1️⃣, 2️⃣ 과정 이후 빌드를 시켜보자.
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/b4c556e5-a612-45cc-8fd3-f57423b2950a/image.png">
일단 텍스트를 이미지 안에 위치하는 건 성공했다!</p>
<p>하지만 문제가 하나 생겼다.
위 사진을 보면 텍스트가 아이콘 위에 위치하는 경우도, 아래에 위치해서 안 보이는 경우도 생긴다. 기대했던 것과는 조금 다른 결과다.</p>
<blockquote>
<p>해결을 위해서는 <strong>아이콘/텍스트 라벨 간의 우선 순위</strong>를 정해주어야 한다.</p>
</blockquote>
<h3 id="3️⃣-setrank로-아이콘텍스트-간-우선-순위-정해주기">3️⃣ setRank로 아이콘/텍스트 간 우선 순위 정해주기</h3>
<p>텍스트는 무조건 아이콘 위에 위치시켜주어야 한다.
이를 위해서 <code>LabelOptions</code>에 <code>setRank()</code>를 붙여줄 수 있다.</p>
<pre><code class="language-kotlin"> private const val RANK_OFFSET = 1 // 아이콘-텍스트 간 rank 차이 (기본적으로 텍스트는 아이콘 위에 표시)

// IconLabel
fun getMapActivityIconLabelOptions(latLng: LatLng, category: Category, activityNumber: Int): LabelOptions {
            return LabelOptions.from(latLng)
                .setStyles(setMapIconLabelStyles(category))
                .setRank(100) // activityNumber가 클수록 높은 rank를 가짐
        }

// TextLabel
fun getMapActivityNumberLabelOptions(latLng: LatLng, activityNumber: Int): LabelOptions {
            return LabelOptions.from(latLng)
                .setStyles(setMapTextLabelStyle())
                .setTexts(LabelTextBuilder().setTexts(activityNumber.toString()))
                .setRank(200) // 텍스트는 아이콘보다 높은 rank를 가짐
        }
</code></pre>
<p>텍스트의 setRank에 아이콘의 setRank보다 더 큰 값을 넣어주면 텍스트는 아이콘 위에 표시되게 된다.
그렇지만~ 여기서 끝날 순 없죠. 빌드를 한 번 더 시켜 봅시다.</p>
<h4 id="📱-중간-점검---2">📱 중간 점검 - 2</h4>
<img width=500 src="https://velog.velcdn.com/images/nahy-512/post/7efd2919-b392-4933-9981-5dbab6e40aa0/image.jpg">

<p>6, 7, 8번 핀을 보면 굉장히 가까운 좌표에 위치해서 마커가 서로 겹치는 걸 확인할 수 있다.</p>
<p>여기서 6번 마커의 텍스트가 7, 8번 마커의 위로 표시되는 걸 볼 수 있는데,
이는 앞서 TextLabel의 rank를 무조건 200으로, IconLabel은 100으로 설정해 놓아서 &#39;activityNumber에 따른 rank 차이는 무시했기 때문에&#39; 발생한 일이다.</p>
<p>그렇다면 해결을 위해서는 어떻게 수정해야 할까?</p>
<blockquote>
<p>해결 방법은 <strong>activityNumber가 같은 아이콘/텍스트 끼리 그룹화</strong>를 해주는 것이다.</p>
</blockquote>
<h3 id="4️⃣-같은-활동-순서의-아이콘텍스트-그룹화">4️⃣ 같은 활동 순서의 아이콘/텍스트 그룹화</h3>
<pre><code class="language-kotlin">private const val RANK_INTERVAL = 10 // activity 번호에 따른 rank 차이 (더 높은 activityNumber를 가졌다면 핀을 더 위에 표시)
private const val RANK_OFFSET = 1 // 아이콘-텍스트 간 rank 차이 (기본적으로 텍스트는 아이콘 위에 표시)

// IconLabel
fun getMapActivityIconLabelOptions(latLng: LatLng, category: Category, activityNumber: Int): LabelOptions {
    return LabelOptions.from(latLng)
        .setStyles(setMapIconLabelStyles(category))
        .setRank((activityNumber * RANK_INTERVAL).toLong()) // activityNumber가 클수록 높은 rank를 가짐
}

// TextLabel
fun getMapActivityNumberLabelOptions(latLng: LatLng, activityNumber: Int): LabelOptions {
    return LabelOptions.from(latLng)
        .setStyles(setMapTextLabelStyle())
         .setTexts(LabelTextBuilder().setTexts(activityNumber.toString()))
        .setRank((activityNumber * RANK_INTERVAL + RANK_OFFSET).toLong()) // 텍스트는 아이콘보다 높은 rank를 가짐
}</code></pre>
<p><code>RANK_INTERVAL</code>은 activityNumber간 차이를 나타내기 위해 <strong>곱할 값</strong>이고,
<code>RANK_OFFSET</code>은 같은 activityNumber일 때 Text를 Label 위에 표시해 주기 위해 <strong>더할 값</strong>이다.</p>
<img width=500 src="https://velog.velcdn.com/images/nahy-512/post/f3e45421-0e8f-41a1-975d-9f0d8f28ecf4/image.jpg">

<p>이렇게까지 하면 <strong>더 높은 activityNumber를 가진 마커는 지도에서 더 위에!</strong> 잘 표시되는 걸 확인할 수 있다.
</br></br></p>
<h2 id="🌟-최종-코드">🌟 최종 코드</h2>
<h4 id="maputilkt">MapUtil.kt</h4>
<p>코드를 조금 더 깔끔하게 관리하기 위해 MapUtil 클래스를 만들어 지도 관련 코드를 정리해 주었다.
(지도에 마커를 표시하는 게 한, 두 화면에서 쓰이는 게 아니다.)</p>
<pre><code class="language-kotlin">object MapUtil {
    const val DEFAULT_ZOOM_LEVEL = 10 // 루트를 표시하는 기본 줌 레벨

    private const val RANK_INTERVAL = 10 // activity 번호에 따른 rank 차이 (더 높은 activityNumber를 가졌다면 핀을 더 위에 표시)
    private const val RANK_OFFSET = 1 // 아이콘-텍스트 간 rank 차이 (기본적으로 텍스트는 아이콘 위에 표시)

    private const val ICON_SIZE = 60f // IconLabel의 크기를 가정
    const val TEXT_OFFSET_Y = - (ICON_SIZE / (2.3)).toFloat() // 텍스트를 이동할 offset (아이콘 중심에서 약간 위로 이동)

    /** 스타일 관련 */
    // IconLabel
    private fun setMapIconLabelStyles(category: Category): LabelStyles {
        return LabelStyles.from(
            LabelStyle.from(category.categoryMarkerIcon)
        )
    }

    fun getMapActivityIconLabelOptions(latLng: LatLng, category: Category, activityNumber: Int): LabelOptions {
        return LabelOptions.from(latLng)
            .setStyles(setMapIconLabelStyles(category))
            .setRank((activityNumber * RANK_INTERVAL).toLong()) // activityNumber가 클수록 높은 rank를 가짐
    }

    // TextLabel
    private fun setMapTextLabelStyle(): LabelStyles {
        return LabelStyles.from(
            LabelStyle.from(LabelTextStyle.from(28, Color.WHITE))
        )
    }

    fun getMapActivityNumberLabelOptions(latLng: LatLng, activityNumber: Int): LabelOptions {
        return LabelOptions.from(latLng)
            .setStyles(setMapTextLabelStyle())
            .setTexts(LabelTextBuilder().setTexts(activityNumber.toString()))
            .setRank((activityNumber * RANK_INTERVAL + RANK_OFFSET).toLong()) // 텍스트는 아이콘보다 높은 rank를 가짐
    }
}</code></pre>
<p>텍스트의 색상은 디자인대로 흰색으로 바꾸어주었다.</p>
<h4 id="activityfragment">Activity/Fragment</h4>
<p>지도에 마커를 표시하려면, 지도를 사용하는 액티비티/프래그먼트에 아래 코드만 추가해 주면 된다.</p>
<pre><code class="language-kotlin">private fun setActivityMarker() {
        if (!viewModel.hasActivity()) return
        // 활동 마커 추가하기
        viewModel.route.value?.routeActivities!!.forEachIndexed { index, activity -&gt;
            // 지도에 마커 표시
            addMarker(
                LatLng.from(activity.latitude.toDouble(), activity.longitude.toDouble()),
                Category.getCategoryByName(activity.category),
                index.plus(1) // 장소 번호는 0번부터 시작
            )
        }
}

// 마커 띄우기
private fun addMarker(latLng: LatLng, category: Category, activityNumber: Int) {
        val layer = kakaoMap?.labelManager?.layer

        // IconLabel 추가
        val iconLabel = layer?.addLabel(
            getMapActivityIconLabelOptions(latLng, category, activityNumber)
        )

        // TextLabel 추가
        val textLabel = layer?.addLabel(
            getMapActivityNumberLabelOptions(latLng, activityNumber)
        )

        // TextLabel의 위치를 IconLabel 내부로 조정
        if (iconLabel != null &amp;&amp; textLabel != null) {
            // changePixelOffset 메서드를 사용하여 텍스트 라벨의 위치 조정
            textLabel.changePixelOffset(0f, MapUtil.TEXT_OFFSET_Y)
        }
}</code></pre>
<p></br></br></p>
<h2 id="📱-완성-화면">📱 완성 화면</h2>
<p>다양한 화면에서 쓰이는 모습! MapUtil로 코드를 분리한 보람이 있다.
<img src="https://velog.velcdn.com/images/nahy-512/post/a94f3e73-0a8a-4d68-8583-49435f551a8b/image.png" alt=""></p>
<p>영상으로도 확인해 보자.
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/f3271ad5-9f24-488b-86f5-7b19a6f21e72/image.gif">
활동과 지도의 마커가 잘 매치되는 걸 확인할 수 있다.
</br></p>
<h2 id="💬-마치며">💬 마치며</h2>
<p>오늘은 꽤나 구현에 어려움을 겪었던 마커 안에 텍스트를 표시하는 방법에 대해 알아보았다!
이것저것 실험해보고, 가능한 아이디어를 찾아내는 과정이 나름은 재미있었다.
디자인을 보면 활동들이 선으로 연결되어 있는 걸 알 수 있는데, 다음 편에는 카카오맵에 선형의 선을 표시하는 방법을 다뤄보겠다.</p>
<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li><a href="https://apis.map.kakao.com/android_v2/docs/api-guide/label/label/">KakaoMaps SDK v2 for Android - Label</a></li>
<li><a href="https://apis.map.kakao.com/android_v2/reference/com/kakao/vectormap/label/LabelStyle.html">https://apis.map.kakao.com/android_v2/reference/com/kakao/vectormap/label/LabelStyle.html</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android/Kotlin] strings.xml 문자열 리소스 제대로 활용하기 (feat. 데이터바인딩)]]></title>
            <link>https://velog.io/@nahy-512/AndroidKotlin-String-%EB%A6%AC%EC%86%8C%EC%8A%A4-%ED%8C%8C%EC%9D%BC-%EC%82%AC%EC%9A%A9%EB%B2%95</link>
            <guid>https://velog.io/@nahy-512/AndroidKotlin-String-%EB%A6%AC%EC%86%8C%EC%8A%A4-%ED%8C%8C%EC%9D%BC-%EC%82%AC%EC%9A%A9%EB%B2%95</guid>
            <pubDate>Wed, 09 Oct 2024 07:41:19 GMT</pubDate>
            <description><![CDATA[<p>오늘은 Android Studio의 strings.xml에 저장해놓은 문자열을 어떻게 사용할 수 있을지 정리해보겠다!</p>
<p>앱 내에서 사용되는 문자열을 strings.xml에 미리 저장해놓으면 중복되는 텍스트를 쉽게 관리할 수 있고, 하드코딩도 줄일 수 있다는 장점이 있다. 그렇지만 어정쩡하게 활용하면 안하느니만 못할 수 있다.</p>
<p>문자열 안에 변수를 어떻게 넣고, 데이터바인딩을 어떻게 적용할 수 있을지까지 다뤄보겠다.</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/608cd204-b7f1-48bf-807f-87563dca83d7/image.png" alt=""></p>
<p>프로젝트에서의 문자열 관리 예시</p>
<h2 id="1️⃣-xml-레이아웃">1️⃣ xml 레이아웃</h2>
<h3 id="1-basic">1) Basic</h3>
<ul>
<li><p>strings.xml</p>
<pre><code class="language-xml">&lt;string name=&quot;next_btn&quot;&gt;다음&lt;/string&gt;</code></pre>
</li>
<li><p>layout.xml</p>
<pre><code class="language-xml">&lt;androidx.appcompat.widget.AppCompatButton
          android:id=&quot;@+id/route_create_next_btn&quot;
          android:layout_width=&quot;match_parent&quot;
          android:layout_height=&quot;60dp&quot;
          android:text=&quot;@string/next_btn&quot;
          android:layout_marginHorizontal=&quot;20dp&quot;
          android:layout_marginBottom=&quot;28dp&quot;
          app:layout_constraintBottom_toBottomOf=&quot;parent&quot;
          app:layout_constraintStart_toStartOf=&quot;parent&quot;
          style=&quot;@style/default_large_button&quot;/&gt;</code></pre>
<p>가장 기본적으로는 TextView, Button 등 텍스트가 들어갈 수 있는 위젯에
<code>android:text=&quot;@string/next_btn&quot;</code>
식으로 미리 저장된 리소스를 넣을 수 있다!</p>
</li>
</ul>
<p>이 이후에 작성되는 레이아웃 코드에서는, 편의상 텍스트를 지정하는 부분을 <code>android:text=&quot;@string/next_btn&quot;</code> 식으로만 표시하도록 하겠다. 택스트가 들어갈 수 있는 위젯에 모두 공통적으로 쓰일 수 있음을 의미한다고 생각해주면 좋겠다.</p>
</br>


<h3 id="2-변수를-넣을-때">2) 변수를 넣을 때</h3>
<p>타 프로그래밍 언어에서 문자열 내에 변수를 넣어 출력하고 싶을 때 Int 형식이면 %d, 문자열 형식이면 %s 등으로 표시하는 걸 본 적 있을 것이다. 이와 유사한 개념으로 보면 된다.
<code>strings.xml</code>에 문자열을 미리 넣어둘 때도 %d, %s 등으로 변수가 들어갈 자리를 지정할 수 있다.</p>
<ul>
<li>strings.xml<pre><code class="language-xml">&lt;string name=&quot;search_route_result_title&quot;&gt;\&#39;%s\&#39; 루트&lt;/string&gt;
&lt;string name=&quot;filter_look_route&quot;&gt;%d개 루트 보기&lt;/string&gt;</code></pre>
여기에서 <code>%s</code>, <code>%d</code> 자리에 문자열을 넣어줄 수 있다.</li>
</ul>
<ul>
<li>layout.xml<pre><code class="language-xml">android:text=&quot;@{@string/search_route_result_title(`hi`)}&quot;
android:text=&quot;@{@string/filter_look_route(111)}&quot;</code></pre>
</li>
</ul>
<blockquote>
<p>** 문자열(%s)의 경우: xml 안에서는 큰 따옴표(&quot;)나 작은 따옴표(&#39;)가 안 먹기 때문에, 문자열을 꼭`로 감싸서 사용해야 함.</p>
</blockquote>
<p>그치만! 이것 또한 하드코딩과 별다를 바 없기 때문에 보통은 데이터바인딩과 함께 사용한다. (&lt;- 사용자가 입력한 데이터나, 서버에서 받아온 정보를 받아서 텍스트에 보여주기가 용이함)</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/60948e98-d3b9-4495-bd62-ee0d5531fcaf/image.jpg" alt=""> %s 자리에 문자열 &#39;hi&#39;가 들어감</th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/a746d73d-5dea-4cc7-a872-22c6a291bee9/image.jpg" alt=""> %d 자리에 정수 111이 들어감</th>
</tr>
</thead>
</table>
<p></br></br></p>
<h3 id="3-데이터바인딩에서의-사용-예시">3) 데이터바인딩에서의 사용 예시</h3>
<pre><code class="language-xml">android:text=&quot;@{@string/search_route_result_title(viewModel.routeSearchKeyWord)}&quot;
android:text=&quot;@{@string/filter_look_route(viewModel.searchResultNum)}&quot;</code></pre>
<p>레이아웃의 xml 파일에서 미리 variable를 정의하고, 이를 활용해 텍스트에 변수를 전달할 수 있다.</p>
<pre><code>&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;layout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;&gt;

    &lt;data&gt;
        &lt;variable
            name=&quot;viewModel&quot;
            type=&quot;com.daval.routebox.presentation.ui.seek.search.FilterViewModel&quot; /&gt;
    &lt;/data&gt;
&lt;/layout&gt;</code></pre><p>참고로 뷰모델에서는 변수가 아래와 같이 정의되어 있다.</p>
<ul>
<li><p><code>routeSearchKeyWord</code></p>
<pre><code class="language-kotlin">@HiltViewModel
class SearchViewModel @Inject constructor(
  val repository: SeekRepository
): ViewModel() {
  private val _routeSearchKeyWord = MutableLiveData&lt;String&gt;(&quot;&quot;) // 검색 결과 타이틀 입력용
  val routeSearchKeyWord: LiveData&lt;String&gt; = _routeSearchKeyWord

  val searchWord = MutableLiveData&lt;String&gt;(&quot;&quot;) // 사용자가 입력한 검색어

  // 루트 검색
  fun inputRouteSearchWord() {
      Log.d(&quot;SearchViewModel&quot;, &quot;입력한 검색어: ${searchWord.value}&quot;)
      // 검색 결과 수정
      _routeSearchKeyWord.value = searchWord.value
      // 루트 검색 결과 조회
      viewModelScope.launch {
          _searchResultRoutes.value = repository.searchRoute(
              searchWord = searchWord.value!!,
              sortBy = _selectedOrderOption.value!!.serverEnum,
              // ...
          )
      }
  }
}</code></pre>
<p>searchWord는 EditText와 양방향 바인딩이 걸려있는 변수이고, 이 EditText에 입력한 검색어를 &#39;검색 버튼을 누르면&#39; 조회를 진행하기 때문에 버튼 클릭 후의 텍스트를 저장하기 위해 routeSearchKeyWord를 사용했다.</p>
</br>
</li>
<li><p><code>searchResultNum</code></p>
<pre><code class="language-kotlin">@HiltViewModel
class FilterViewModel @Inject constructor(
  val repository: SeekRepository
): ViewModel() {

  // 필터링을 적용한 검색 결과 개수
  private val _searchResultNum = MutableLiveData&lt;Int&gt;(0)
  val searchResultNum: LiveData&lt;Int&gt; = _searchResultNum

  // 루트를 조회해서 결과 개수 확인
  fun inquirySearchResultNum() {
      Log.d(&quot;FilterViewModel&quot;, &quot;받아온 검색어: $searchWord&quot;)
      viewModelScope.launch {
          _searchResultNum.value = repository.searchRoute(
              searchWord = searchWord,
              sortBy = OrderOptionType.ORDER_RECENT.serverEnum,
              // ...
          ).size
      }
  }
}</code></pre>
</li>
</ul>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/e29f4f97-d57f-47af-90f9-8f6ee6acdd4e/image.gif" alt=""> %s 자리에 뷰모델의 routeSearchKeyWord가 들어감</th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/d374b2f1-2650-4516-9b1b-81f3479cbbb6/image.gif" alt=""> %d 자리에 뷰모델의 searchResultNum이 들어감</th>
</tr>
</thead>
</table>
<p></br></br></p>
<h3 id="4-변수가-여러개인-경우">4) 변수가 여러개인 경우</h3>
<p>변수가 여러개인 경우에는 그냥 콤마(,)로 구분해준다.</p>
<ul>
<li>strings.xml<pre><code class="language-xml">&lt;string name=&quot;weather_update_date&quot;&gt;%s %s 업데이트\n제공: 기상청&lt;/string&gt;</code></pre>
</li>
<li>layout.xml<pre><code class="language-xml">android:text=&quot;@{@string/weather_update_date(date, time)}&quot;</code></pre>
괄호 안에 들어갈 변수를 순서대로 넣어주면 된다.
</br></br></br></li>
</ul>
<h2 id="2️⃣-activityfragment-kotlin-코드">2️⃣ Activity/Fragment Kotlin 코드</h2>
<p>Activity나 Fragment 코드에서 사용할 수 있는 방법이다.</p>
<blockquote>
<p>&lt;다양한 예시 참고&gt;
🔗 <a href="https://velog.io/@nahy-512/AndroidKotlinMySQLwithJDBC3">[Android/Kotlin] 안드로이드 스튜디오에서 JDBC를 이용해 MySQL 연동하기 (3/3) - 통신편</a></p>
</blockquote>
<h3 id="1-basic-1">1) Basic</h3>
<p>기본적으로 strings.xml 코드를 불러오는 방식은</p>
<pre><code class="language-kotlin">resources.getString(R.string.db_url)</code></pre>
<p>형태이다.
텍스트로 넣어주고 싶다면</p>
<pre><code class="language-kotlin">binding.nextBtn.text = ContextCompat.getString(this, R.string.next_btn)</code></pre>
<p>식으로 넣어줄 수 있다.
</br></p>
<h3 id="2-변수를-넣을-때-1">2) 변수를 넣을 때</h3>
<p>strings.xml 파일에서 문자열 작성 방법은 xml에서 한 것과 동일하다.</p>
<ul>
<li>strings.xml<pre><code class="language-kotlin">&lt;string name=&quot;signup_complete&quot;&gt;%s,\n루트박스에 온 걸 환영해요!&lt;/string&gt;</code></pre>
%s 자리에 회원가입 시에 작성한 유저의 닉네임을 넣어주면 된다. 하드코딩할 경우, 아래와 같이 작성할 수 있다.</li>
<li>Fragent/Activity.kt<pre><code class="language-kotlin">binding.termTitle.text = String.format(resources.getString(R.string.signup_complete), &quot;코코아&quot;)</code></pre>
변수를 넣어줘야 하는 경우
<code>String.format({resources.getString({리소스_id}), {넘겨줄 값})</code> 식으로 작성해줄 수 있다.</br>

</li>
</ul>
<h3 id="3-데이터바인딩에서의-사용-예시-1">3) 데이터바인딩에서의 사용 예시</h3>
<p>위와 같은 동일한 string 리소스에서 하드 코딩 대신 뷰모델에서 사용한 변수를 전달할 수 있다.</p>
<ul>
<li>Fragent/Activity.kt<pre><code class="language-kotlin">binding.termTitle.text = String.format(resources.getString(R.string.signup_complete), viewModel.nickname.value)</code></pre>
</br>

</li>
</ul>
<h3 id="4-변수가-여러개인-경우-1">4) 변수가 여러개인 경우</h3>
<p>strings.xml 파일 작성은 마찬가지로 앞선 xml에서와 동일하다. 이번엔 3개를 넣어 본다고 하자.</p>
<ul>
<li><p>strings.xml</p>
<pre><code class="language-xml">// 팔로워, 팔로잉 목록 조회
&lt;string name=&quot;query_select_follow_list&quot;&gt;SELECT user_id, user_name, name, profileImage_url FROM user WHERE user_id IN (SELECT %1$s FROM %2$s WHERE user_id = %3$d)&lt;/string&gt;</code></pre>
<blockquote>
<p><code>%1$1</code>, <code>%2$s</code>, <code>%3%d</code> 식으로 변수가 들어갈 순서도 지정해 줄 수 있다.</p>
</blockquote>
</li>
<li><p>Fragment/Activity.kt</p>
<pre><code class="language-kt">String.format(resources.getString(R.string.query_select_follow_list), targetId, table, userId)</code></pre>
</br>



</li>
</ul>
<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li><a href="https://bumjae.tistory.com/42">string.xml 에 %d, %s 사용 / Databinding 에 StringFormat 적용</a></li>
<li><a href="https://holika.tistory.com/entry/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%ED%8C%81-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B0%94%EC%9D%B8%EB%94%A9-%EB%82%B4%EB%B6%80%EC%97%90%EC%84%9C-%EB%A6%AC%EC%86%8C%EC%8A%A4-%EB%98%90%EB%8A%94-Stringplain-text%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95">[안드로이드 팁]  데이터바인딩 내부에서 리소스 또는 String(plain text)를 사용하는 방법 - Uing? Uing!!:티스토리</a></li>
<li><a href="https://velog.io/@ymj10/Android-Strings.xml-%EB%B3%80%EC%88%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0">[Android] Strings.xml 변수 사용하기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android/Kotlin] 카카오맵 신규 SDK 사용하기]]></title>
            <link>https://velog.io/@nahy-512/%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5-sdk-%EB%B2%84%EC%A0%84-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8</link>
            <guid>https://velog.io/@nahy-512/%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5-sdk-%EB%B2%84%EC%A0%84-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8</guid>
            <pubDate>Sun, 22 Sep 2024 10:12:38 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>프로젝트(나모)의 팀 블로그에서 작성한 내용을 가지고 왔습니다.
🔗 원본 링크: <a href="https://namo-log.vercel.app/android-map">https://namo-log.vercel.app/android-map</a>
<em>* 작성일: 24.06.09</em></p>
</blockquote>
<p>안녕하세요, 나모 안드로이드 개발자 코코아입니다!
오늘은 <strong>카카오맵 SDK 버전을 업데이트 했던 경험</strong>에 대해 이야기해보려 합니다.</p>
<p>기존 코드에서 코드가 어떻게 변경되었는지에 대해 다뤄볼게요!
지도를 표시하고, 지도에 마커를 나태내는 것까지 모두 다룰 예정입니다.
</br></p>
<h2 id="1️⃣-sdk-버전-업데이트">1️⃣ SDK 버전 업데이트</h2>
<p><del>~ 사건의 발단 ~</del></p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/025cd073-9dfb-4ed9-aca5-8f62c1035185/image.png" alt=""></p>
<p><a href="https://devtalk.kakao.com/t/android-ios-sdk-2024-6-30/134180">https://devtalk.kakao.com/t/android-ios-sdk-2024-6-30/134180</a></p>
<p>kakao developers에서 위와 같은 글을 보게 되었습니다.</p>
<p>구 버전의 SDK를 지원 종료할 예정이라고 하니, 이 지도 때문에 앱 사용에 지장이 가면 안 되잖아요?
그래서 <strong>지원 종료 전에 v1 → v2로 버전 업데이트</strong>를 진행하기로 했습니다.
</br></p>
<h3 id="구현의-어려움">구현의 어려움</h3>
<p>기존에 지도 SDK는 많이 사용해봐서 어려움이 크게 없으리라고 생각했는데,
예상치 못하게 구현에 어려움을 맞닥뜨리게 되었습니다.</p>
<p>비로 <strong>신규 SDK라서 그런지 관련 자료가 몹시 적었고,
카카오 측의 가이드는 모두 자바로 작성</strong>되어 있었다는 점이었는데요,</p>
<blockquote>
<p>참고한 공식 가이드라인 링크입니다.
<a href="https://apis.map.kakao.com/android_v2/docs/">KakaoMaps SDK for Android</a></p>
</blockquote>
<p>안드로이드 개발을 코틀린으로 시작해 코틀린밖에 모르는 저로서는 청천벽력 같은 소식이었답니다.</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/74bc0ec1-e3f5-48c5-ac76-4d2d55fdd8b3/image.png" alt=""></p>
<p>공식 문서에서 발견한 ‘<a href="https://apis.map.kakao.com/android_v2/KakaoMapsSDKSample.zip"><code>KakaoMaps Api Demo</code></a>’라는 이름의 샘플 프로젝트를 실제로 돌려봐도 뭐가 문제인 건지 권한이 없다는 오류가 나오면서 지도가 제대로 표시되지 않는 탓에 ‘이 메서드는 대체 어디에 쓰이는 걸까’, ‘기존 메서드는 이번에 어떻게 변경이 되었나’, 하며 샘플 프로젝트 및 라이브러리의 코드를 조금씩 뜯어보기 시작했습니다.
항상 코틀린만 보아왔던 저에게 자바로 작성된 코드는 이해하기가 조금 어려웠답니다.</p>
<blockquote>
<p>카카오맵 SDK 설명
<a href="https://apis.map.kakao.com/android_v2/reference/overview-summary.html">https://apis.map.kakao.com/android_v2/reference/overview-summary.html</a></p>
</blockquote>
<p>기존 기능들을 어떻게 다시 구현할 수 있을지에 대해서는 위의 자료에서 많이 힌트를 얻었습니다.
위 링크에서 카카오맵 API에 대한 구체적인 설명을 확인할 수 있습니다.</p>
<p>다행히 안드로이드 스튜디오에 자동 마이그레이션 기능이 있어 조금 수월했던 거 같아요. 세상이 참 좋아졌습니다.</p>
<p>그렇지만 기존에 사용하던 SDK의 의존성을 지웠을 때….
코드들에서 나타났던 빨간 줄들은 다시 생각해도 참 아찔했습니다.</p>
<p>기존 카카오맵 코드를 제가 작성했던 게 아니었기에 코드를 이해하는 데 조금 어렵긴 했지만,
사실 SDK가 바뀌면서 변경된 부분이 너무 많아서 코드를 아예 새로 작성해야 했다는 게
다행이라면 다행인 일일까요..
<del>요즘 원영적 사고를 배우려고 하는 중이기에, 다행이라고 생각하겠습니다.</del></p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/6ae56a58-c5f7-475a-b19c-effed5e4bbc6/image.png" alt=""></p>
<h3 id="지도-띄우기">지도 띄우기</h3>
<p>지도를 띄우는 것까지는 순조로웠습니다.
이전에도 새로운 SDK로 지도를 나타내본 적 있었기 때문인데요..</p>
<pre><code class="language-kotlin">// 카카오맵 초기화
private fun initMapView() {
        mapView = binding.dialogSchedulePlaceContainer
        mapView.start(object : MapLifeCycleCallback() {
            override fun onMapDestroy() {
                // 지도 API 가 정상적으로 종료될 때 호출됨
            }

            override fun onMapError(error: Exception) {
                // 인증 실패 및 지도 사용 중 에러가 발생할 때 호출됨
            }
        }, object : KakaoMapReadyCallback() {
            override fun onMapReady(map: KakaoMap) {
                // 인증 후 API 가 정상적으로 실행될 때 호출됨
                kakaoMap = map
                setMapContent()
            }

            override fun getPosition(): LatLng {
                return LatLng.from(place.y, place.x)
            }

            override fun getZoomLevel(): Int {
                // 지도 시작 시 확대/축소 줌 레벨 설정
                return MapActivity.ZOOM_LEVEL
            }
        })
    }

// 지도 표시
private fun setMapContent() {
        binding.dialogSchedulePlaceNameTv.text = place.place_name
        binding.dialogSchedulePlaceContainer.visibility = View.VISIBLE

        // 지도 위치 조정
        val latLng = LatLng.from(place.y, place.x)
        // 카메라를 마커의 위치로 이동
        kakaoMap?.moveCamera(CameraUpdateFactory.newCenterPosition(latLng, MapActivity.ZOOM_LEVEL))
    }</code></pre>
<p><del>그러나 문제는 언제나 예상치 못하게 찾아오는 법이죠.</del>
</br></p>
<h3 id="장소-표시하기-마커">장소 표시하기 (마커)</h3>
<p>나모에서는 유저가 선택한 장소를 표시할 때 마커를 찍어서 나타내는데,
이 마커의 명칭이 원래 <code>MapPoint</code> 였었거든요. 그런데 이번에 보니까 명칭이 아예 <code>Label</code>로 바뀌었더라구요.</p>
<p>그리고 기존에는 별도의 스타일 지정 없이 <code>MapPOIItem.MarkerType</code> 을 통해 미리 만들어져 있는 스타일을 사용할 수 있었는데
신규 SDK에서는 <strong>라벨 스타일을 직접 지정</strong>해줘야 했습니다.</p>
<p>선택된 핀과 선택되지 않은 핀은 아래와 같이 표시해 주어야 했습니다.</p>
<img align=right src="https://velog.velcdn.com/images/nahy-512/post/b8dcaa06-2a0d-4732-9985-8d274ae7a8cb/image.png">

<table>
<thead>
<tr>
<th>핀 선택 여부</th>
<th>선택 O</th>
<th>선택 X</th>
</tr>
</thead>
<tbody><tr>
<td>색상</td>
<td>빨간색</td>
<td>파란색</td>
</tr>
<tr>
<td>장소 이름 표시 여부</td>
<td>O</td>
<td>X</td>
</tr>
</tbody></table>
<p>오른쪽 이미지 같은 느낌입니다
</br>
다음은 <strong>선택된 장소에 마커를 표시하는 코드</strong>가 카카오맵 v1, v2에서 각각 어떤 식으로 작성되었는지를 설명하겠습니다.
</br></p>
<h4 id="1-핀-스타일-지정">1. 핀 스타일 지정</h4>
<blockquote>
<p>선택된 핀과 선택되지 않은 핀의 스타일을 미리 지정해준 코드입니다.</p>
</blockquote>
<pre><code class="language-kotlin">companion object {
        fun setPinStyle(isSelected: Boolean): LabelStyle {
            if (isSelected) { // 선택된 핀
                return LabelStyle.from(
                    R.drawable.ic_pin_selected
                ).setTextStyles(20, R.color.black)
            }
            return LabelStyle.from( // 기본 핀
                R.drawable.ic_pin_default
            )
        }</code></pre>
<img align=center width=700 src="https://velog.velcdn.com/images/nahy-512/post/1cbb1627-044d-418f-af50-340c8aa2bb81/image.png">


<p>선택된 핀이라면 빨간 색상으로 표시하고, 장소 이름을 나타냅니다.</p>
<p>기존 코드는 MapPOIItem.MarkerType.BluePin, MapPOIItem.MarkerType.RedPin으로 핀 스타일을 바로 설정할 수 있었기에 자세한 설명은 넘어가겠습니다.
</br></p>
<h4 id="2-마커-표시">2. 마커 표시</h4>
<blockquote>
<p>선택된 장소에 핀을 표시해주는 코드입니다.</p>
</blockquote>
<ul>
<li><p>기존 v1 코드</p>
<pre><code class="language-kotlin">  var mapPoint = MapPoint.mapPointWithGeoCoord(place_y, place_x)
  mapView.setMapCenterPointAndZoomLevel(mapPoint, 1, true)

  var marker = MapPOIItem()
  marker.itemName = place_name
  marker.tag = 0
  marker.mapPoint = mapPoint
  marker.markerType = MapPOIItem.MarkerType.BluePin // 기본 핀
  marker.selectedMarkerType = MapPOIItem.MarkerType.RedPin // 선택된 핀

  mapView.addPOIItem(marker)</code></pre>
<p>  markerType, selectedMarkerType을 통해 선택 시의 마커 스타일을 지정할 수 있습니다.</p>
</li>
<li><p>v2 코드</p>
<pre><code class="language-kotlin">  kakaoMap?.labelManager?.layer?.addLabel(LabelOptions.from(latLng).setStyles(MapActivity.setPinStyle(false)))</code></pre>
<p>  위의 1번에서 지정한 코드 스타일을 바탕으로 setPinStyle(false)라면 파란 핀을, setPinStyle(true)라면 빨간 핀을 나타냅니다.</p>
</li>
</ul>
<p><strong>3. 핀 스타일 변경</strong></p>
<blockquote>
<p>장소 검색 결과에서 선택한 장소의 핀 색상을 변경하는 코드입니다.</p>
</blockquote>
<ul>
<li><p>기존 v1 코드</p>
<pre><code class="language-kotlin">  mapView.selectPOIItem(markerList[position], true)</code></pre>
<p>  <code>selectPOIItem</code>를 통해 핀 선택 여부를 쉽게 나타낼 수 있습니다.</p>
</li>
<li><p>v2 코드</p>
<pre><code class="language-kotlin">  markerList[position].changeStyles(LabelStyles.from(setPinStyle(true)))</code></pre>
<p>  <code>changeStyles</code>를 통해 핀의 스타일을 바꿔줄 수 있습니다.</p>
</li>
</ul>
<p></br></br></p>
<h2 id="2️⃣-장소-선택-로직-수정">2️⃣ 장소 선택 로직 수정</h2>
<p>3기를 진행하고 iOS 출시를 준비하면서 장소 선택과 관련된 내부 기획이 조금 변경되었습니다.
따라서 지도 SDK를 마이그레이션하는 이번 기회에 이 수정된 기획을 반영하고자 했습니다.</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/35a3053e-b87d-46eb-a47d-740d43529dfd/image.png" alt=""></p>
<p>기존에 구현된 로직과 비교해 수정할 부분들은 크게 아래의 세 부분이었습니다.</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/a8cb9d67-7535-41e6-9f90-4d2c6fd0a505/image.png" alt=""></p>
<p>자, 그럼 기획에 맞게 내부 로직을 바로잡아 봅시다.
</br></p>
<h3 id="21-장소-리사이클러뷰-아이템-선택-체크-표시">2.1. 장소 리사이클러뷰 아이템 선택 (체크 표시)</h3>
<p>장소는 하나만 선택하도록 해야하고, 선택된 장소의 경우 아이템 우측에 체크 표시를 달아줍니다.</p>
<pre><code class="language-kotlin">// 검색 리스트에서 선택한 장소
mapRVAdapter.setItemClickListener( object : MapRVAdapter.OnItemClickListener {
    override fun onClick(position: Int) {
        val place : Place = placeList[position]
        selectedPlace = place // 선택 장소 설정
        val latLng = LatLng.from(place.y, place.x)
        //...
        // 장소 취소 &amp; 확인 버튼 표시
        binding.mapBtnLayout.visibility = View.VISIBLE
        // 이전에 선택한 장소 핀 색상은 파란색으로 돌려놓기
        if (prevLabel != markerList[position]) {
            prevLabel.changeStyles(LabelStyles.from(setPinStyle(false)))
        }
        prevLabel = markerList[position] // 마커 업데이트
      }
})</code></pre>
<p>다른 아이템 클릭 시 이전에 선택된 Label 색상은 돌려놓아야 하기에, prevLabel이라는 변수를 사용합니다.</p>
<h3 id="22-장소-선택-후-취소-버튼-클릭-이벤트">2.2. <strong>장소 선택 후 취소 버튼 클릭 이벤트</strong></h3>
<pre><code class="language-kotlin">// 취소 버튼
binding.cancelBtn.setOnClickListener {
            // 선택된 핀 다시 파란색으로 표시
            prevLabel.changeStyles(LabelStyles.from(setPinStyle(false)))
            // 줌 레벨 살짝 낮추기
            moveCamera(LatLng.from(uLatitude, uLongitude), ZOOM_LEVEL - 3)
            // 아이템 체크 표시 삭제
            mapRVAdapter.setSelectedPosition(-1)
            // 취소 &amp; 확인 버튼 없애기
            binding.mapBtnLayout.visibility = View.GONE
}</code></pre>
<p>취소 버튼 클릭 시 선택된 장소를 해제해 줍니다.</p>
<p>기존에 선택되었던 핀은 다시 파란색으로 돌려놓고, 장소 선택을 취소했으니 줌 레벨을 살짝 낮춰 지도를 축소해 줍니다. 아이템 체크 표시까지 삭제한 뒤에 버튼도 보이지 않게 해주면 변경된 기획 반영 완료입니다.</p>
<h3 id="23-기존에-입력한-장소-표시">2.3. 기존에 입력한 장소 표시</h3>
<p>나모에서 장소를 저장할 때 사용하는 값은 장소명과 x, y 좌표였기 때문에,</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/368f9b1b-3b1a-4955-af5c-8bbf4d40ef70/image.png" alt=""></p>
<p>위의 정보로 지번 주소와 도로명 주소와 받아와야 했습니다.
찾아보니 좌표로 주소를 알아낼 수가 있더라구요.</p>
<p><a href="https://developers.kakao.com/docs/latest/ko/local/dev-guide#coord-to-address">Kakao Developers</a></p>
<p>위의 자료를 참고해 <strong>좌표를 통해 주소 정보를 표시</strong>해 주었습니다. 코드는 아래와 같습니다.</p>
<pre><code class="language-kotlin">/** 좌표로 주소 변환 */
data class ResultCoord2Address(
    val documents: List&lt;Document&gt;
)

data class Document(
    val address: Address,
    val road_address: RoadAddress?
)

data class Address(
    val address_name: String, // 전체 지번 주소
)
data class RoadAddress(
    val address_name: String, // 전체 도로명 주소
)

private fun setPreLocationItem(placeX: Double, placeY: Double) {
    // 카카오 API를 이용해 좌표로 주소 정보 가져오기
    val call = kakaoService.getPlaceInfo(&quot;KakaoAK $API_KEY&quot;, placeX.toString(), placeY.toString())

    call.enqueue(object : Callback&lt;ResultCoord2Address&gt; {
        override fun onResponse(
            call: Call&lt;ResultCoord2Address&gt;,
      response: Response&lt;ResultCoord2Address&gt;
        ) {
            if (response.isSuccessful) {
          val placeInfo = response.body()?.documents?.firstOrNull()
        Log.d(&quot;PlaceInfo&quot;, placeInfo.toString())
        if (placeInfo != null) {
            selectedPlace.address_name = placeInfo.address.address_name
          selectedPlace.road_address_name = placeInfo.road_address?.address_name.toString()
            // 선택된 장소를 리사이클러뷰 아이템에 표시
          placeList.clear()
          placeList.add(selectedPlace)
          setPlaceData()
          mapRVAdapter.setSelectedPosition(0) // 첫 번째 아이템에 체크 표시
         }
       }
      }

      override fun onFailure(call: Call&lt;ResultCoord2Address&gt;, t: Throwable) {
          Log.d(&quot;MapActivity&quot;, &quot;좌표로 주소 정보 불러오기 실패\n${t.message}&quot;)
      }
    })
}</code></pre>
<h2 id="📱-완성-화면">📱 완성 화면</h2>
<img width=400 src="https://velog.velcdn.com/images/nahy-512/post/048b6141-980f-461b-af7d-ab9d07aeb307/image.webp">

<p>이렇게 우여곡절 끝에 장소 선택 기능을 잘 구현할 수 있었습니다!</p>
<p>거창한 기능을 구현한 건 아니지만, 기존 SDK가 지원 종료됨에 따라 신규 SDK로 마이그레이션을 진행한 경험이 저에게는 무척 새로웠기에 이렇게 글을 작성해 보았습니다😃</p>
<p>이외에도 새로운 요구사항에 맞게 내부 로직을 변경해가는 과정이 제법 재밌었습니다. 기능을 이것저것 사용해 보면서 신규 카카오맵 SDK와 조금 더 친해진 기분이었달까요..</p>
<p>그렇지만 핀 디자인이 기존과 조금 달라진 부분이 있어 이 점도 수정할 방법을 더 찾아보고자 합니다. (핀 하단에 표시되는 장소 이름이 잘 눈에 띄지 않더라구요)</p>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/8a4a547f-264b-4774-943c-77f032a30cea/image.png" alt=""></p>
<p>카카오맵과 벌인 사투의 흔적..</p>
<p><a href="https://github.com/Namo-log/Android/pull/244">https://github.com/Namo-log/Android/pull/244</a></p>
<p>변경 전/후 코드에 대한 자세한 정보는 위 PR에서 확인할 수 있습니다.</p>
<p>혹시라도 코틀린으로 카카오맵 v2 지도를 구현하시는 분들께 저의 삽질 이야기가 조금 도움이 되었으면 좋겠네요.
댓글로 남겨주시는 피드백은 언제든지 환영입니다:)</p>
<p>긴 글 읽어주셔서 감사합니다!
</br></p>
<h2 id="📚-참고-자료">📚 참고 자료</h2>
<p><del>공식 문서를 많이 활용했습니다.. 근데 이제 자바로 적힌</del></p>
<ul>
<li><a href="https://apis.map.kakao.com/android_v2/docs/getting-started/maptype_overlay/">지도 구성하기</a></li>
<li><a href="https://apis.map.kakao.com/android_v2/reference/overview-summary.html">Overview</a></li>
<li><a href="https://developers.kakao.com/docs/latest/ko/local/dev-guide#coord-to-address">Kakao Developers</a></li>
<li><a href="https://mechacat.tistory.com/15">[Android/KakaoAPI] 카카오 장소 검색 (Retrofit)</a></li>
<li><a href="https://jangstory.tistory.com/44">[android / Kotlin] 카카오맵 Api 장소 검색 결과 리사이클러 뷰에 추가하기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android/Kotlin] TimePicker 시간 간격(interval) 설정하기]]></title>
            <link>https://velog.io/@nahy-512/AndroidKotlin-timepicker-interval</link>
            <guid>https://velog.io/@nahy-512/AndroidKotlin-timepicker-interval</guid>
            <pubDate>Tue, 10 Sep 2024 17:21:22 GMT</pubDate>
            <description><![CDATA[<h2 id="✍🏻-요구-사항-분석">✍🏻 요구 사항 분석</h2>
<p>아래처럼 5분 간격의 타임 피커를 만들 것을 요구받았다.
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/3698c68c-0402-450f-bb1f-83f20c1e45bf/image.png">
다만, 구현 시간 상 디자인은 네이티브 기본 디자인으로 구현해도 된다고 했다.
그래서 TimePicker를 커스텀해서 5분 간격을 가지게끔 만들고자 했다.
(예전에는 뭣도 모르고 NumberPicker를 일일이 수정해서 사용했지만, TimePicker를 바로 활용하면 편하다.
</br></br></p>
<h2 id="💻-코드-작성">💻 코드 작성</h2>
<h3 id="1️⃣-바텀시트피커-스타일-설정-stylesxml">1️⃣ 바텀시트/피커 스타일 설정 (styles.xml)</h3>
<ul>
<li>바텀시트 스타일<pre><code class="language-xml">&lt;style name=&quot;BottomSheetDialogStyle&quot;
      parent=&quot;Theme.Design.Light.BottomSheetDialog&quot;&gt;
      &lt;item name=&quot;bottomSheetStyle&quot;&gt;@style/BottomSheetModel&lt;/item&gt;
&lt;/style&gt;
</code></pre>
</li>
</ul>
<style name="BottomSheetModel"
        parent="Widget.Design.BottomSheet.Modal">
        <item name="android:background">@drawable/bottom_dialog_radius</item>
</style>
<pre><code>바텀시트 상단 모서리를 둥글게 만들어줘야 해서 미리 스타일 지정을 한다.


- 피커 스타일
``` xml
&lt;style name=&quot;MyBase.TimePicker&quot; parent=&quot;Theme.AppCompat.Light.Dialog&quot;&gt;
        &lt;item name=&quot;android:background&quot;&gt;@color/white&lt;/item&gt;
        &lt;item name=&quot;android:headerBackground&quot;&gt;@color/white&lt;/item&gt;
        &lt;item name=&quot;android:textColor&quot;&gt;@color/title_black&lt;/item&gt;
        &lt;item name=&quot;android:colorAccent&quot;&gt;@color/title_black&lt;/item&gt;
        &lt;item name=&quot;android:textColorPrimary&quot;&gt;@color/title_black&lt;/item&gt;
        &lt;!-- NumberPicker divider color --&gt;
        &lt;item name=&quot;colorControlNormal&quot;&gt;@color/main&lt;/item&gt;
&lt;/style&gt;</code></pre><p>피커도 Material 피커를 사용하는 거긴 하지만, 우리 앱과 최대한 어우러질 수 있게끔 메인 색상을 지정하고, 조금 꾸며주자!</p>
<img width=500 src="https://velog.velcdn.com/images/nahy-512/post/406eab78-436f-4380-ab89-9885bfccabee/image.png">

<p>Android Studio에서 확인해 보면 이런 모습니다.</p>
<h3 id="2️⃣-레이아웃-코드-작성-bottom_sheet_time_pickerxml">2️⃣ 레이아웃 코드 작성 (bottom_sheet_time_picker.xml)</h3>
<p>타임 피커를 띄울 때 바텀 시트로 띄우기 때문에 바텀 시트의 레이아웃을 먼저 만들어 준다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;layout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;&gt;

    &lt;androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;match_parent&quot;&gt;

        &lt;ImageView
            android:id=&quot;@+id/picker_close_iv&quot;
            android:layout_width=&quot;wrap_content&quot;
            android:layout_height=&quot;wrap_content&quot;
            android:layout_marginTop=&quot;24dp&quot;
            android:layout_marginEnd=&quot;22dp&quot;
            android:padding=&quot;5dp&quot;
            android:src=&quot;@drawable/ic_close&quot;
            app:layout_constraintEnd_toEndOf=&quot;parent&quot;
            app:layout_constraintTop_toTopOf=&quot;parent&quot;/&gt;

        &lt;TimePicker
            android:id=&quot;@+id/picker_tp&quot;
            android:layout_width=&quot;match_parent&quot;
            android:layout_height=&quot;wrap_content&quot;
            android:timePickerMode=&quot;spinner&quot;
            android:theme=&quot;@style/MyBase.TimePicker&quot;
            app:layout_constraintTop_toBottomOf=&quot;@id/picker_close_iv&quot;/&gt;

        &lt;androidx.appcompat.widget.AppCompatButton
            android:id=&quot;@+id/picker_save_btn&quot;
            android:layout_width=&quot;match_parent&quot;
            android:layout_height=&quot;60dp&quot;
            android:text=&quot;@string/save&quot;
            android:layout_marginHorizontal=&quot;20dp&quot;
            android:layout_marginBottom=&quot;15dp&quot;
            app:layout_constraintTop_toBottomOf=&quot;@id/picker_tp&quot;
            app:layout_constraintBottom_toBottomOf=&quot;parent&quot;
            app:layout_constraintVertical_bias=&quot;0&quot;
            style=&quot;@style/large_fill_default&quot;/&gt;

    &lt;/androidx.constraintlayout.widget.ConstraintLayout&gt;
&lt;/layout&gt;</code></pre>
<p>TimePicker에 <code>android:timePickerMode=&quot;spinner&quot;</code>를 설정하면 돌릴 수 있는 피커 모양이 나온다.
<img src="https://velog.velcdn.com/images/nahy-512/post/0d0443d0-f323-4d1a-927f-725e759278ee/image.png" alt=""></p>
<p>디자인 탭에서 확인해 보면 오른쪽 모습과 같다.
xml에서는 따로 시간 간격을 설정하는 옵션이 없어서, 미리보기에는 분이 1분 간격으로 표시된다.
이걸 이제 5분 간격을 가지게끔 바꿔보자.
</br></p>
<h3 id="3️⃣-timepickerbottomsheetkt">3️⃣ TimePickerBottomSheet.kt</h3>
<pre><code class="language-kotlin">class TimePickerBottomSheet(private val initHour: Int, private val initMinute: Int) : BottomSheetDialogFragment() {
    private lateinit var binding: BottomSheetTimePickerBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = BottomSheetTimePickerBinding.inflate(inflater, container, false)

        return binding.root
    }

    private fun initPicker() {
        // 시간 간격을 5분 단위로 설정
        binding.pickerTp.setTimeInterval(MINUTES_INTERVAL)

        // TimePicker에 초기 시간을 설정
        val adjustedMinute = initMinute / MINUTES_INTERVAL
        binding.pickerTp.apply {
            hour = initHour
            minute = adjustedMinute
        }
    }

    private fun TimePicker.setTimeInterval(
        @IntRange(from = 1, to = 30)
        timeInterval: Int = MINUTES_INTERVAL
    ) {
        try {
            // 분 단위 스피너 찾기
            val minutePicker = findViewById&lt;NumberPicker&gt;(
                resources.getIdentifier(&quot;minute&quot;, &quot;id&quot;, &quot;android&quot;)
            )

            // 5분 간격의 배열을 생성해 분 단위 스피너에 적용하기
            val minuteValues = Array(MINUTES_MAX / timeInterval) { String.format(MINUTE_FORMAT, (it * timeInterval)) }
            minutePicker.minValue = MINUTES_MIN
            minutePicker.maxValue = MINUTES_MAX / timeInterval - 1
            minutePicker.displayedValues = minuteValues
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    companion object {
        const val MINUTES_INTERVAL = 5 // 5분 간격 설정
        const val MINUTES_MIN = 0
        const val MINUTES_MAX = 60
        const val MINUTE_FORMAT = &quot;%02d&quot;
    }
}</code></pre>
<p><code>MINUTES_INTERVAL</code>의 숫자를 조정하면 원하는 간격을 설정할 수 있다.
피커 안에 들어가는, 분을 나타내는 배열인 <code>minuteValues</code>를 만들 때는 (디자인에 맞춰) 한 자리 숫자이더라도 &#39;00&#39;, &#39;05&#39; 식으로 표시해 주어야 한다. 따라서 정수가 문자열에 <code>%02d</code> 포맷으로 들어가도록 해 주었다.</p>
<blockquote>
<p>* 아래 코드로도 동일한 작동이 가능하다. </p>
</blockquote>
<pre><code>val minuteValues = Array(MINUTES_MAX / timeInterval) { (it * timeInterval).toString().padStart(2, &#39;0&#39;) }</code></pre></br>


<h3 id="4️⃣-피커-띄우기">4️⃣ 피커 띄우기</h3>
<p>Activity/Fragment에서 클릭 이벤트로 피커를 show 해주면 된다.</p>
<pre><code class="language-kotlin">private fun initClickListeners() {
        // 시작 시간
        binding.routeCreateStartTimeTv.setOnClickListener {
            showTimePickerBottomSheet(true, viewModel.startTimePair.value)
        }

        // 종료 시간
        binding.routeCreateEndTimeTv.setOnClickListener {
            showTimePickerBottomSheet(false, viewModel.endTimePair.value)
        }
    }

private fun showTimePickerBottomSheet(isStartTime: Boolean, initTime: Pair&lt;Int, Int&gt;?) {
        val pickerBottomSheet = TimePickerBottomSheet(
            initTime?.first ?: Calendar.getInstance().get(Calendar.HOUR_OF_DAY), // 선택한 시간 정보가 없다면 현재 hour로 피커 초기화
            initTime?.second ?: Calendar.getInstance().get(Calendar.MINUTE) // 선택한 시간 정보가 없다면 현재 minute로 피커 초기화
        )
        pickerBottomSheet.run {
            setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialogStyle)
        }
        pickerBottomSheet.show(this.supportFragmentManager, pickerBottomSheet.tag)
    }</code></pre>
<p><code>setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialogStyle)</code>는 1️⃣에서 만든 바텀시트 다이얼로그 스타일을 넣어주는 코드이다. 이 코드를 통해 상단이 둥근 바텀 시트를 만들어줄 수 있다.</p>
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/7142ff74-7ad2-4636-82cf-18104194a534/image.png">

<p>실행 시켜보면 이처럼 5분 간격의 타임 피커가 잘 나타나는 걸 확인할 수 있다!
이제 바텀 시트에서 시간을 선택하고 <code>저장</code> 버튼을 눌렀을 때 텍스트뷰에 해당 시간을 표시해 주기만 하면 된다.</p>
</br>


<h3 id="5️⃣-선택한-시간-텍스트뷰에-나타내기">5️⃣ 선택한 시간 텍스트뷰에 나타내기</h3>
<h4 id="1-interface-구현">1) Interface 구현</h4>
<p>시간을 선택했을 때 선택된 시간을 넘길 인터페이스를 만들어 준다.</p>
<pre><code class="language-kotlin">interface TimeChangedListener {
    fun onTimeSelected(isStartTime: Boolean, hour: Int, minute: Int)
}</code></pre>
<p>레이아웃을 보면 아래와 같이 시작 시간과 종료 시간 두 가지 옵션이 필요하다. 옵션 지정을 안 해주면 시작/종료 시간 중 무엇이 수정되었는지 알지 못한다. 때문에 <code>isStartTime</code>을 통해 시작 시간인지, 종료 시간인지를 함께 받을 수 있도록 했다.
<img src="https://velog.velcdn.com/images/nahy-512/post/a5569dd8-482d-4472-b34a-1bb3571946e1/image.png" alt=""></p>
<h4 id="2-timepickerbottomsheet에-listener-달기">2) TimePickerBottomSheet에 listener 달기</h4>
<pre><code class="language-kotlin">class TimePickerBottomSheet(private var listener: TimeChangedListener, private val isStartTime: Boolean, private val initHour: Int, private val initMinute: Int) : BottomSheetDialogFragment() {
    private lateinit var binding: BottomSheetTimePickerBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = BottomSheetTimePickerBinding.inflate(inflater, container, false)

        initPicker()
        initClickListeners()

        return binding.root
    }

    private fun initClickListeners() {
        // x 버튼
        binding.pickerCloseIv.setOnClickListener {
            dismiss() // 종료
        }

        // 저장 버튼
        binding.pickerSaveBtn.setOnClickListener {
            val selectedHour = binding.pickerTp.hour
            val selectedMinute = binding.pickerTp.minute * MINUTES_INTERVAL
            listener.onTimeSelected(isStartTime, selectedHour, selectedMinute) // 선택한 시간 넘기기
            dismiss()
        }
    }
}</code></pre>
<p>저장 버튼을 클릭했을 때 현재 선택된 타임피커의 hour, minute를 가져와서 넘겨준다.
이때 minute는 5분 간격으로 구현한다고 개수를 줄여놨기 때문에 <code>MINUTES_INTERVAL</code>를 곱해주어야 한다.</p>
<h4 id="3-선택한-시간-받기">3) 선택한 시간 받기</h4>
<pre><code class="language-kotlin">class RouteCreateActivity : AppCompatActivity(), TimeChangedListener {
    private lateinit var binding: ActivityRouteCreateBinding

    private val viewModel: RouteCreateViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_route_create)

        binding.apply {
            viewModel = this@RouteCreateActivity.viewModel
            lifecycleOwner = this@RouteCreateActivity
        }

        initClickListeners()
    }

    private fun initClickListeners() {
        // ...

        // 시작 시간
        binding.routeCreateStartTimeTv.setOnClickListener {
            showTimePickerBottomSheet(true, viewModel.startTimePair.value)
        }

        // 종료 시간
        binding.routeCreateEndTimeTv.setOnClickListener {
            showTimePickerBottomSheet(false, viewModel.endTimePair.value)
        }
    }

    private fun showTimePickerBottomSheet(isStartTime: Boolean, initTime: Pair&lt;Int, Int&gt;?) {
        val pickerBottomSheet = TimePickerBottomSheet(
            this,
            isStartTime,
            initTime?.first ?: Calendar.getInstance().get(Calendar.HOUR_OF_DAY), // 선택한 시간 정보가 없다면 현재 hour로 피커 초기화
            initTime?.second ?: Calendar.getInstance().get(Calendar.MINUTE) // 선택한 시간 정보가 없다면 현재 minute로 피커 초기화
        )
        pickerBottomSheet.run {
            setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialogStyle)
        }
        pickerBottomSheet.show(this.supportFragmentManager, pickerBottomSheet.tag)
    }

    override fun onTimeSelected(isStartTime: Boolean, hour: Int, minute: Int) {
        viewModel.updateTime(isStartTime, Pair(hour, minute))
    }
}</code></pre>
<p><strong>시간이 선택되었을 때의 코드 처리는 뷰모델에게 넘겨준다.</strong></p>
<ul>
<li><p>뷰모델</p>
<pre><code class="language-kotlin">@HiltViewModel
@RequiresApi(Build.VERSION_CODES.O)
class RouteCreateViewModel @Inject constructor(
  private val repository: RouteRepository
) : ViewModel() {
  private val _startTimePair = MutableLiveData&lt;Pair&lt;Int, Int&gt;&gt;()
  val startTimePair: LiveData&lt;Pair&lt;Int, Int&gt;&gt; = _startTimePair

  private val _endTimePair = MutableLiveData&lt;Pair&lt;Int, Int&gt;&gt;()
  val endTimePair: LiveData&lt;Pair&lt;Int, Int&gt;&gt; = _endTimePair

  fun updateTime(isStartTime: Boolean, timePair: Pair&lt;Int, Int&gt;) {
      if (isStartTime) _startTimePair.value = timePair
      else _endTimePair.value = timePair
      updateButtonActivation()
  }
}</code></pre>
</li>
</ul>
<h4 id="4-dateconverter-작성">4) DateConverter 작성</h4>
<p>마지막으로 시간을 텍스트뷰에 실제로 표시하기 위한 작업이다.
TextView에 시간을 나타낼 형식을 지정해 준다.</p>
<pre><code class="language-kotlin">object DateConverter {
    private const val TIME_PLACEHOLDER = &quot;시간 선택&quot;
    private const val TIME_DELIMINATOR = &quot;:&quot;

    @SuppressLint(&quot;DefaultLocale&quot;)
    @JvmStatic
    fun getFormattedTime(timePair: Pair&lt;Int, Int&gt;?): String {
        if (timePair == null) return TIME_PLACEHOLDER
        return &quot;${timePair.first}${TIME_DELIMINATOR}${format(MINUTE_FORMAT, timePair.second)}&quot;
    }
}</code></pre>
<p>시간 선택 전에는 &#39;시간 선택&#39;이라는 placeHolder가 표시될 수 있게 한다.</p>
<p>데이터바인딩을 사용하기 때문에 시간을 표시해 줄 레이아웃에서 아래 코드를 작성해주면 된다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;layout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;&gt;

    &lt;data&gt;
        &lt;import type=&quot;com.daval.routebox.presentation.utils.DateConverter&quot;/&gt;
        &lt;variable
            name=&quot;viewModel&quot;
            type=&quot;com.daval.routebox.presentation.ui.route.write.RouteCreateViewModel&quot; /&gt;
    &lt;/data&gt;
&lt;/layout&gt;</code></pre>
<p><code>DateConverter</code>를 import 해주고</p>
<pre><code class="language-xml">&lt;androidx.appcompat.widget.AppCompatTextView
                    android:id=&quot;@+id/route_create_end_time_tv&quot;
                    android:text=&quot;@{DateConverter.getFormattedTime(viewModel.startTimePair)}&quot;
                    tools:text=&quot;19:05&quot;
                    style=&quot;@style/common_time_selected_tv&quot; /&gt;</code></pre>
<p>사용할 TextView의 text에 앞서 작성한 <code>@{DateConverter.getFormattedTime(viewModel.startTimePair)}</code>를 사용해주면!
드디어 모든 작업이 끝났다.</p>
<p></br></br></p>
<h2 id="📱-완성-화면">📱 완성 화면</h2>
<img width=375 src="https://velog.velcdn.com/images/nahy-512/post/262f2f8b-9ebf-4bba-aef9-7b0d8e276bb5/image.gif">


<p>피커가 5분 간격으로 나오고, 저장 버튼을 눌렀을 때 TextView에 잘 표시되는 것을 확인할 수 있다.
</br></p>
<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li><a href="https://zion830.tistory.com/74">[Android/Kotlin] TimePicker에 시간 간격(Interval) 설정하기</a></li>
<li><a href="https://goatlab.tistory.com/entry/Android-Studio-TimePicker-%EC%83%89%EC%83%81-%EC%BB%A4%EC%8A%A4%ED%85%80">[Android Studio] TimePicker 색상 커스텀 - GOATLAB:티스토리</a></li>
<li><a href="https://medium.com/%EB%B0%95%EC%83%81%EA%B6%8C%EC%9D%98-%EC%82%BD%EC%A7%88%EB%B8%94%EB%A1%9C%EA%B7%B8/timepicker%EB%A5%BC-5%EB%B6%84%EB%8B%A8%EC%9C%84-10%EB%B6%84%EB%8B%A8%EC%9C%84%EB%A1%9C-%EC%8B%9C%EA%B0%84-%EA%B0%84%EA%B2%A9-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0-android-9849f1df986a">TimePicker를 5분단위, 10분단위로 시간 간격 설정하기 | Android</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android/Kotlin] 리사이클러뷰 FlexboxLayoutManager 사용하기]]></title>
            <link>https://velog.io/@nahy-512/AndroidKotlin-%EB%A6%AC%EC%82%AC%EC%9D%B4%ED%81%B4%EB%9F%AC%EB%B7%B0-FlexboxLayoutManager</link>
            <guid>https://velog.io/@nahy-512/AndroidKotlin-%EB%A6%AC%EC%82%AC%EC%9D%B4%ED%81%B4%EB%9F%AC%EB%B7%B0-FlexboxLayoutManager</guid>
            <pubDate>Tue, 13 Aug 2024 04:15:02 GMT</pubDate>
            <description><![CDATA[<h2 id="✍🏻-요구사항-분석">✍🏻 요구사항 분석</h2>
<center><img width=375 src=https://velog.velcdn.com/images/nahy-512/post/ec445e41-d595-4590-97c7-df556ba1857d/image.png></center>

<p>위 사진처럼 <strong>화면의 남은 공간에 따라 아이템을 배치해주어야 하는</strong> 필터 화면을 요구받았다.</p>
<p>위젯 하나하나를 만들어 넣자니 옵션의 변동 가능성을 너무 고려하지 못하는 느낌이라
어찌되었건 <u>리사이클러뷰로 만들어야겠다는 생각</u>은 하게 되었다.</p>
<p>어떻게 구현해야할지 고민하던 중, 같은 팀원분께서 <a href="https://github.com/google/flexbox-layout">flexbox-layout</a>를 추천해 주셨다.</p>
<p>그래서 오늘은 <strong>리사이클러뷰에 FlexboxLayoutManager를 적용한 방법</strong>에 대해 작성해 보겠다!
</br></p>
<h2 id="💻-코드-작성">💻 코드 작성</h2>
<h3 id="1️⃣-flexbox-의존성-추가">1️⃣ flexbox 의존성 추가</h3>
<pre><code>dependencies {
    implementation &#39;com.google.android.flexbox:flexbox:3.0.0&#39;
}</code></pre><p>모듈 단위의 Gradle에 flexbox 의존성을 추가해 준다.</p>
<h3 id="2️⃣-필터-옵션-정의하기">2️⃣ 필터 옵션 정의하기</h3>
<pre><code class="language-kotlin">/** 필터 유형 */
enum class FilterType(val order: Int) {
    WITH_WHOM(0), // 누구와
    HOW_MANY(1), // 몇 명과
    HOW_LONG(2), // 며칠 동안
    ROUTE_STYLE(3), // 원하는 스타일
    MEANS_OF_TRANSPORTATION(4), // 이동 수단
}

/** 필터 옵션 */
enum class FilterOption(val filterType: FilterType, val optionName: String) {
    // 누구와
    WITH_ALONE(FilterType.WITH_WHOM, &quot;혼자&quot;),
    WITH_FRIEND(FilterType.WITH_WHOM, &quot;친구와&quot;),
    WITH_LOVER(FilterType.WITH_WHOM, &quot;연인과&quot;),
    WITH_PARTNER(FilterType.WITH_WHOM, &quot;배우자와&quot;),
    WITH_CHILD(FilterType.WITH_WHOM, &quot;아이와&quot;),
    WITH_PARENT(FilterType.WITH_WHOM, &quot;부모님과&quot;),
    WITH_ETC(FilterType.WITH_WHOM, &quot;기타&quot;),
    // 몇 명과
    MANY_ALONE(FilterType.HOW_MANY, &quot;혼자&quot;),
    MANY_TWO(FilterType.HOW_MANY, &quot;2명&quot;),
    MANY_THREE(FilterType.HOW_MANY, &quot;3명&quot;),
    MANY_FOUR(FilterType.HOW_MANY, &quot;4명&quot;),
    MANY_OVER_FIVE(FilterType.HOW_MANY, &quot;5명 이상&quot;),
    // 며칠 동안
    LONG_THE_DAY(FilterType.HOW_LONG, &quot;당일&quot;),
    ...

    companion object {
        // 필터 유형에 해당하는 선택지 리스트 반환
        fun findOptionsByFilterType(type: FilterType): List&lt;FilterOption&gt; {
            return entries.filter { it.filterType == type }
        }

        // 필터 유형 순서에 따라 정렬된 필터 옵션 리스트 반환
        fun getOptionsSortedByFilterType(): List&lt;List&lt;FilterOption&gt;&gt; {
            return FilterType.entries.sortedBy { it.order }.map { type -&gt;
                findOptionsByFilterType(type)
            }
        }
    }
}</code></pre>
<p>변동 가능성을 최대한 고려해 enum class를 만들어 필터 유형과, 해당 필터 유형에 속하는 옵션을 정의해준다.</p>
<h3 id="3️⃣-옵션-아이템-만들기">3️⃣ 옵션 아이템 만들기</h3>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/df01fadf-2e18-4ae1-b927-f137a897ea15/image.png" alt=""></p>
<p>아이템 디자인이 굉장히 간단해서 TextView의 배경으로 테두리가 회색인, 원형 형태의 drawable을 넣어주고 옵션 이름을 텍스트에 넣어준다. (데이터바인딩 활용)</p>
<h3 id="4️⃣-xml에-리사이클러뷰-추가하기">4️⃣ xml에 리사이클러뷰 추가하기</h3>
<p>생각보다 간단하다. 그냥 레이아웃 매니저로 FlexboxLayoutManager를 설정할 뿐이다.</p>
<pre><code class="language-xml">&lt;androidx.recyclerview.widget.RecyclerView
    android:id=&quot;@+id/filter_question1_with_whom_rv&quot;
    android:layout_width=&quot;wrap_content&quot;
    android:layout_height=&quot;wrap_content&quot;
    android:layout_marginTop=&quot;16dp&quot;
    android:layout_marginEnd=&quot;30dp&quot;
    android:overScrollMode=&quot;never&quot;
    app:layoutManager=&quot;com.google.android.flexbox.FlexboxLayoutManager&quot;
    tools:listitem=&quot;@layout/item_filter_option&quot;
    tools:itemCount=&quot;3&quot;/&gt;</code></pre>
<p>이런 식으로 리사이클러뷰를 정의해주면
<img src="https://velog.velcdn.com/images/nahy-512/post/b17af18e-2425-4abc-bcbb-4f4c92775386/image.png" alt=""></p>
<p>위와 같은 화면이 나온다.
<code>tools:listitem</code>을 통해 대충 적용한 모습을 미리 확인해볼 수 있다. (실제 화면에는 적용X, xml의 Design 탭에서 확인해 보는 용도)</p>
<h3 id="5️⃣-어댑터-코드-작성">5️⃣ 어댑터 코드 작성</h3>
<pre><code class="language-kotlin">class FilterOptionsRVAdapter: RecyclerView.Adapter&lt;FilterOptionsRVAdapter.ViewHolder&gt;(){

    private var optionList = listOf&lt;FilterOption&gt;()

    @SuppressLint(&quot;NotifyDataSetChanged&quot;)
    fun addOption(optionList: List&lt;FilterOption&gt;) {
        this.optionList = optionList
        notifyDataSetChanged()
    }

    override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
        val binding: ItemFilterOptionBinding = ItemFilterOptionBinding.inflate(
            LayoutInflater.from(viewGroup.context), viewGroup, false
        )
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.bind(optionList[position])
        holder.itemView.setOnClickListener {
            holder.updateSelection(selectedOption)
        }
    }

    override fun getItemCount(): Int = optionList.size

    inner class ViewHolder(val binding: ItemFilterOptionBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(option: FilterOption) {
            binding.option = option
            updateSelection(option)
        }

        fun updateSelection(option: FilterOption) {
            // 옵션 아이템 클릭 여부에 따른 UI 업데이트
        }
    }
}</code></pre>
<p>필터 옵션들을 <code>optionList</code>로 받아 bind에서 추가해주는 코드이다.
일반적인 LinearLayoutManager 등을 사용할 때와 크게 다른 점은 없다.</p>
<h3 id="6️⃣-리사이클러뷰와-어댑터-연결">6️⃣ 리사이클러뷰와 어댑터 연결</h3>
<pre><code class="language-kotlin">class FilterActivity : AppCompatActivity() {
    private lateinit var binding: ActivityFilterBinding

    private val viewModel: FilterViewModel by viewModels()
    private lateinit var adapterList: List&lt;FilterOptionsRVAdapter&gt;

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_filter)

        binding.apply {
            viewModel = this@FilterActivity.viewModel
            lifecycleOwner = this@FilterActivity
        }

        setFilterOptions()
    }

    private fun setFilterOptions() {
        val filterOptionList = FilterOption.getOptionsSortedByFilterType() // 필터 유형마다의 옵션 목록을 리스트에 저장
        adapterList = List(filterOptionList.size) { FilterOptionsRVAdapter() } // 리사이클러뷰와 연결할 어댑터 리스트 정의
        binding.apply {
            val recyclerViewList = listOf&lt;RecyclerView&gt;(
                filterQuestion1WithWhomRv,
                filterQuestion2HowManyRv,
                filterQuestion3HowLongRv,
                filterQuestion4RouteStyleRv,
                filterQuestion5MeansOfTransportationRv
            )
            // 리사이클러뷰에 어댑터 연결
            recyclerViewList.forEachIndexed { index, rv -&gt;
                rv.apply {
                    adapter = adapterList[index]
                    layoutManager = FlexboxLayoutManager(context).apply {
                        flexWrap = FlexWrap.WRAP
                        flexDirection = FlexDirection.ROW
                    }
                }
            }
        }
        // FilterType 순서대로 필터 옵션을 어댑터에 추가
        adapterList.forEachIndexed { index, adapter -&gt;
            adapter.addOption(filterOptionList[index])
        }
    }
}</code></pre>
<p>전체 코드는 위와 같다.
여기서 주목해야할 부분은</p>
<pre><code class="language-kotlin">rv.apply {
    adapter = adapterList[index]
    layoutManager = FlexboxLayoutManager(context).apply {
    flexWrap = FlexWrap.WRAP
    flexDirection = FlexDirection.ROW
    }
}</code></pre>
<p>이 부분이다.
리사이클러뷰의 레이아웃 매니저를 <code>FlexboxLayoutManager</code>로 설정하고, [flexWrap], [flexDirection] 속성을 정의해 준다.</p>
<blockquote>
<p><strong>[FlexWrap]</strong></p>
</blockquote>
<ul>
<li><code>WRAP</code> : 현재 라인에 충분한 공간이 없는 경우 다음 라인에 뷰를 배치함</li>
<li><code>NOWRAP</code> : 한 라인 안에서 공간을 나눠가지는 듯함</li>
</ul>
<blockquote>
<p><strong>[FlexDirection]</strong></p>
</blockquote>
<ul>
<li><code>ROW</code> : 아이템이 가로(행) 방향으로 &#39;좌-&gt;우&#39; 순으로 배치됨. 행을 다 채웠다면 그 아래 행에 배치함.</li>
<li><code>ROW_REVERSE</code> : 아이템이 오른쪽부터 행 방향으로 배치됨</li>
<li><code>COLUMN</code> : 아이템이 세로(열) 방향으로 &#39;위-&gt;아래&#39; 순으로 배치됨</li>
<li><code>COLUMN_REVERSE</code> : 아이템이 아래부터 열 방향으로 배치됨</li>
</ul>
<h3 id="번외-flexlayout-속성-테스트">번외) FlexLayout 속성 테스트</h3>
<h4 id="flexwrap--wrap인-경우-flexdirection에-따른-차이">flexWrap = <code>wrap</code>인 경우, flexDirection에 따른 차이</h4>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/e6a9459e-1da4-4604-9dd9-d0e5ea9a89da/image.jpg" alt="wrap_row"> row</th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/a28cf774-3cb9-43cb-93ee-38f1942a4067/image.jpg" alt="wrap_rerow"> row_reverse</th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/52f2bf4f-d39a-4c5d-bc29-4b8ccf9a7fc7/image.jpg" alt="wrap_column"> column</th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/8498d97e-c18f-4425-a958-9b7af370938d/image.jpg" alt="wrap_recolumn"> column_reverse</th>
</tr>
</thead>
</table>
<h4 id="flexwrap--nowrap인-경우-flexdirection에-따른-차이">flexWrap = <code>nowrap</code>인 경우, flexDirection에 따른 차이</h4>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/a626886f-a276-4ade-b3bf-d7c8ee1b3016/image.jpg" alt="nowrap_row"> row</th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/de3f3d44-a67c-41ae-a685-e830ae226ac3/image.jpg" alt="nowrap_rerow"> row_reverse</th>
<th><img src="https://velog.velcdn.com/images/nahy-512/post/cb9bfade-7b2c-434b-8bd7-815482232492/image.jpg" alt="nowrap_column"> column</th>
</tr>
</thead>
<tbody><tr>
<td>nowrap, column_reverse인 경우에는 앱이 종료되었다.</td>
<td></td>
<td></td>
</tr>
<tr>
<td>FlexboxLayout이 필요한 상황을 생각해보면, 아무래도 flexWrap = wrap, flexDirection = row인 경우가 가장 많을 것 같았다. (내가 구현하려던 것도 이 경우에 속함)</td>
<td></td>
<td></td>
</tr>
</tbody></table>
<p>같은 데이터를 넣었음에도 속성을 뭘로 설정하냐에 따라 매우 다른 결과가 나와서 신기했다.
</br></p>
<h2 id="📱-완성-화면">📱 완성 화면</h2>
<p><img src="https://velog.velcdn.com/images/nahy-512/post/4894de01-fed5-4b7e-ab5c-f872c9d4b788/image.gif" alt="">
설정한 옵션들이 깔끔하게 잘 표시되는 걸 확인할 수 있다!</p>
</br>

<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li><a href="https://github.com/google/flexbox-layout">https://github.com/google/flexbox-layout</a></li>
<li><a href="https://woovictory.github.io/2020/06/13/Android-FlexBoxLayout/">[Android] FlexBoxLayout</a></li>
<li><a href="https://www.howtodoandroid.com/flexboxlayout-with-recyclerview-android/">A Step-by-Step Guide to Using FlexboxLayout with RecyclerView in Android</a></li>
<li><a href="https://salix97.tistory.com/268">[Android] 안드로이드 리사이클러뷰에서 Flexbox 사용하기 (FlexboxLayoutManager) feat. 스크롤 없는 태그 입력 리사이클러뷰</a></li>
<li><a href="https://www.dev2qa.com/android-flexbox-layout-example/">Android Flexbox Layout Example</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android/Kotlin] 코틀린 파일에서 색상 다루는 법 - String, Int]]></title>
            <link>https://velog.io/@nahy-512/AndroidKotlin-%EC%BD%94%ED%8B%80%EB%A6%B0-%ED%8C%8C%EC%9D%BC%EC%97%90%EC%84%9C-%EC%83%89%EC%83%81-%EB%8B%A4%EB%A3%A8%EB%8A%94-%EB%B2%95</link>
            <guid>https://velog.io/@nahy-512/AndroidKotlin-%EC%BD%94%ED%8B%80%EB%A6%B0-%ED%8C%8C%EC%9D%BC%EC%97%90%EC%84%9C-%EC%83%89%EC%83%81-%EB%8B%A4%EB%A3%A8%EB%8A%94-%EB%B2%95</guid>
            <pubDate>Mon, 12 Aug 2024 18:48:23 GMT</pubDate>
            <description><![CDATA[<h2 id="🤔-시작하기에-앞서">🤔 시작하기에 앞서</h2>
<p>캘린더 앱을 만드는 프로젝트를 하면서 색을 정말 많이 다뤘다. 특히 리사이클러뷰를 이용해 색을 넣어줘야했기 때문에 xml이 아닌 코틀린 파일에서 색을 다뤘어야 했다. 따라서! 오늘은 색상 및 리소스 변경 🌟완벽 정리🌟 해보려고 한다.
</br></p>
<h2 id="😱-내가-부딪힌-어려움">😱 내가 부딪힌 어려움??</h2>
<p>생각보다.. 관련되어 잘 정리되어 있는 자료를 찾기 힘들었던 것 같다. 처음에 문제에 직면했을 때는 어떻게 해결할지 감도 오지 않았었다.
나는 이것저것 테스트해보며 여러 방법으로 진행했다.
<img src="https://velog.velcdn.com/images/nahy-512/post/773bad4c-c48d-4627-a5ea-351ebe36ae40/image.png" alt="">
<img src="https://velog.velcdn.com/images/nahy-512/post/938243d9-fc3d-4bfd-9105-a866199406ad/image.png" alt=""></p>
<p>관련해서 정리해두었던 사진이다. 저 때가 Android 개발이 아예 처음이라 막막하기만 했던 시절이다. 추억 삼아 첨부한다.
</br></br></p>
<h2 id="💻-코드-정리">💻 코드 정리</h2>
<h3 id="1️⃣-cardview">1️⃣ CardView</h3>
<h4 id="1-색이-string-형태일-때">1. 색이 String 형태일 때</h4>
<pre><code class="language-kotlin">binding.itemPaletteColorCv.setCardBackgroundColor(Color.parseColor(&quot;#FFFFFF&quot;)</code></pre>
<p>Hex 코드를 바로 쓸 수 있다는 점은 편하지만, 일일이 색을 지정해줘야한다는 번거로움이 있다.</br></p>
<h4 id="2-색이-int-형태일-때">2. 색이 Int 형태일 때</h4>
<pre><code class="language-kotlin">binding.itemPaletteColorCv.background.setTint(context.resources.getColor(R.color.white))
//혹은
binding.itemPaletteColorCv.setCardBackgroundColor(ContextCompat.getColor(context, R.color.white))</code></pre>
<p>리소스 파일에 저장한 색상을 바로 활용할 수 있어 좋았다.</p>
<blockquote>
</blockquote>
<ul>
<li><em>해당 화면이 Activity이냐, Fragment이냐, Adapter이냐에 따라 context 부분을 다르게 써야할 수 있음</em></li>
</ul>
</br>


<h3 id="2️⃣-textview">2️⃣ TextView</h3>
<h4 id="1-색이-string-형태일-때-1">1. 색이 String 형태일 때</h4>
<pre><code class="language-kotlin">textView.setTextColor(Color.parseColor(&quot;#FFFFFF&quot;))</code></pre>
<h4 id="2-색이-int-형태일-때-1">2. 색이 Int 형태일 때</h4>
<pre><code class="language-kotlin">textView.setTextColor(ContextCompat.getColor(context, R.color.white))
//혹은 deprecated 되긴 했지만
textView.setTextColor(resources.getColor(R.color.white))</code></pre>
</br>

<h3 id="3️⃣-배경-background">3️⃣ 배경 Background</h3>
<h4 id="1-drawable-리소스-자체를-변경">1. drawable 리소스 자체를 변경</h4>
<pre><code class="language-kotlin">itemBG.setBackgroundResource(R.drawable.style_weekly_calendar_circle_selected)</code></pre>
<h4 id="2-색만-변경">2. 색만 변경</h4>
<pre><code class="language-kotlin">itemBG.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.white)) // Int
itemBG.backgroundTintList = ColorStateList.valueOf(Color.parseColor(&quot;#FFFFFF&quot;)); // String
itemBG.background.setTint(context.resources.getColor(R.color.white))</code></pre>
<p>배경은 그대로인데 색만 바꿔줘야하는 경우 유용한 방법이다. (ex. 버튼 활성화)
</br></p>
<h2 id="📚-참고">📚 참고</h2>
<h4 id="-cardview의-xml-사용-예시">* CardView의 xml 사용 예시</h4>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;androidx.constraintlayout.widget.ConstraintLayout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    android:layout_width=&quot;wrap_content&quot;
    android:layout_height=&quot;wrap_content&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;&gt;

    &lt;androidx.cardview.widget.CardView
        android:id=&quot;@+id/item_palette_color_cv&quot;
        android:layout_marginStart=&quot;15dp&quot;
        android:layout_marginTop=&quot;15dp&quot;
        android:layout_marginBottom=&quot;1dp&quot;
        app:cardBackgroundColor=&quot;@color/categoryGray&quot;
        app:layout_constraintTop_toTopOf=&quot;parent&quot;
        app:layout_constraintStart_toStartOf=&quot;parent&quot;
        style=&quot;@style/category_cv&quot;&gt;
        &lt;ImageView
            android:id=&quot;@+id/item_palette_select_iv&quot;
            style=&quot;@style/img_category_check&quot;/&gt;
    &lt;/androidx.cardview.widget.CardView&gt;


&lt;/androidx.constraintlayout.widget.ConstraintLayout&gt;</code></pre>
]]></description>
        </item>
    </channel>
</rss>