<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Moondev</title>
    <link>https://moondev03.tistory.com/</link>
    <description>공부 기록장</description>
    <language>ko</language>
    <pubDate>Mon, 6 Jul 2026 20:41:59 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Moondev</managingEditor>
    <image>
      <title>Moondev</title>
      <url>https://tistory1.daumcdn.net/tistory/6214488/attach/813bc5ff0c844e1aa62835dcabefee50</url>
      <link>https://moondev03.tistory.com</link>
    </image>
    <item>
      <title>[Android] Server-Sent Events(SSE)를 활용한 실시간 화면 동기화</title>
      <link>https://moondev03.tistory.com/entry/Android-Server-Sent-EventsSSE%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%99%94%EB%A9%B4-%EB%8F%99%EA%B8%B0%ED%99%94</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;보따리 프로젝트에서는 팀원 입장/퇴장, 물건 추가/삭제/편집, 체크 상태 등 팀에 대한 여러 변경 사항이 발생했을 때 그 화면을 보고 있는 다른 사람의 화면에도 변경 사항이 즉시 반영되어야 했다. 즉, 앱은 서버에서 발생한 변경 사항을 지연 없이 실시간으로 반영해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1722&quot; data-origin-height=&quot;1158&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Yl3bh/dJMcacO00up/yVwVlkgtUoc3KKpFHKuQkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Yl3bh/dJMcacO00up/yVwVlkgtUoc3KKpFHKuQkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Yl3bh/dJMcacO00up/yVwVlkgtUoc3KKpFHKuQkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYl3bh%2FdJMcacO00up%2FyVwVlkgtUoc3KKpFHKuQkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1722&quot; height=&quot;1158&quot; data-origin-width=&quot;1722&quot; data-origin-height=&quot;1158&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 우선 안드로이드에서 사용 가능한 실시간 처리 방식들을 비교한 뒤,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 그중 Server-Sent Events를 선택했는지, 그리고 안드로이드에서 어떻게 구현했는지까지 정리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실시간 처리 방법 비교&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 서버와 클라이언트 사이에서 실시간으로 데이터를 주고받는 대표적인 방식들이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1) Short Polling&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 단순한 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 &lt;b&gt;일정 주기마다 서버에 요청을 보내며 변화가 있는지 직접 확인&lt;/b&gt;한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;변경 사항이 있나요?&quot;를 5초마다 반복해서 묻는 방식&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;구현 난이도가 매우 낮다&lt;/li&gt;
&lt;li&gt;서버나 클라이언트 모두 특별한 프로토콜이나 인프라 없이 도입 가능하다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;불필요한 요청이 과도하게 발생한다&lt;/li&gt;
&lt;li&gt;주기 사이에 늦게 전달되는 &quot;최소 지연&quot; 문제가 있다&lt;/li&gt;
&lt;li&gt;네트워크, 배터리 리소스 낭비가 크다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  즉, &quot;쉽지만 비효율적인 방식&quot;이라는 한계가 명확하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2) Long Polling&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Short Polling의 비효율을 개선한 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 요청을 보낸 뒤 &lt;b&gt;서버가 새로운 데이터가 생길 때까지 응답을 지연&lt;/b&gt;한다.&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;&quot;변경되면 알려줘&quot;라고 말하고 기다리는 방식&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Short Polling 대비 지연 시간이 크게 감소한다&lt;/li&gt;
&lt;li&gt;서버가 이벤트가 생겼을 때만 응답하므로 효율성이 증가한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청-응답 사이클을 여전히 반복된다&lt;/li&gt;
&lt;li&gt;서버사 연결을 오래 유지해야 하므로 커넥션 부담이 증가한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실시간에 준하는 성능을 얻을 수 있지만 구조적 한계는 여전히 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3) WebSocket&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트와 서버 간의 상시 &lt;b&gt;양방향 통신 채널을 유지하는 방식&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 커넥션이 열리면 양쪽 모두 자유롭게 메시지를 주고받을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;세 가지 방식 중 지연이 가장 짧다&lt;/li&gt;
&lt;li&gt;실제 실시간 서비스 경험에 가장 적합하다&lt;/li&gt;
&lt;li&gt;양방향 통신이 자연스럽게 이뤄진다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;구현 난이도가 높다 (프로토콜, 핑-퐁 관리, 재연결 등)&lt;/li&gt;
&lt;li&gt;서버 및 인프라 부담 증가&lt;/li&gt;
&lt;li&gt;모바일 환경에서는 백그라운드 제약, 네트워크 전환 등 모바일 특성을 고려가 필요하다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  가장 실시간답지만 개발, 운영 비용이 크고 모바일에서는 신중한 선택이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;4) Server-Sent Events&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSE(Server-Sent Events)는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;HTTP 기반의 단방향 실시간 스트리밍 기술&lt;/b&gt;로&amp;nbsp;WebSocket 처럼 별도의 복잡한 프로토콜을 사용하지 않아도 서버에서 클라이언트로 이벤트를 지속적으로 전송할 수 있는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히, &quot;서버 -&amp;gt; 클라이언트&quot;의 단방향 흐름만 필요할 때 매우 효율적이며 구조가 단순해 적용 난이도가 낮다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTTP 기반&lt;/li&gt;
&lt;li&gt;Polling 방식 대비 효율적&lt;/li&gt;
&lt;li&gt;WebSocket 대비 단순&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Server-Sent Events를 선택한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 요구사항을 다시 살펴보자.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자 A가 물건을 추가하면&lt;/li&gt;
&lt;li&gt;서버가 변경 사항을 저장하고&lt;/li&gt;
&lt;li&gt;사용자 B, C, D의 화면에 즉시 반영되어야 한다&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;양방향 통신은 필요하지 않다&quot;&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;폴링 방식의 불필요한 요청&amp;middot;리소스 낭비를 최소화하고 싶다&quot;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  서버 -&amp;gt; 클라이언트 단방향으로 변경된 데이터를 스트리밍 해주기만 하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 위해 `Server-Sent Events`를 선택하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안드로이드에서 SSE (Server-Sent Events) 처리 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 구현에 앞서 어떤 흐름으로 SSE가 처리되는지를 살펴보자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2736&quot; data-origin-height=&quot;1540&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dmp22Z/dJMcagYbyB5/cTBvcLe42MK0G5jAz0FuMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dmp22Z/dJMcagYbyB5/cTBvcLe42MK0G5jAz0FuMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dmp22Z/dJMcagYbyB5/cTBvcLe42MK0G5jAz0FuMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdmp22Z%2FdJMcagYbyB5%2FcTBvcLe42MK0G5jAz0FuMk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2736&quot; height=&quot;1540&quot; data-origin-width=&quot;2736&quot; data-origin-height=&quot;1540&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최초 HTTP 요청으로 서버와 스트림이 연결되었다면 OkHttp에서는 `source.exhausted()`라는 메서드로 스트림 종료 여부를 판단하게 된다. 이 값을 통해 &lt;b&gt;스트림이 유지되는 동안 새로운 텍스트 라인이 들어왔을 때 이를&lt;/b&gt; &lt;b&gt;SSE 규약에 맞게 파싱하여 원하는 처리&lt;/b&gt;를 할 수 있도록 구현하면 된다. SSE는 &lt;b&gt;id, event, data&lt;/b&gt; 등의 규약이 존재하며 &lt;b&gt;빈 줄(두 개의 개행)로 이벤트를 구분&lt;/b&gt;하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 &lt;b&gt;OkHttp를 사용해 SSE에 대한 모든 처리를 구현하는 방식&lt;/b&gt;과 &lt;b&gt;OkHttp-SSE를 사용하여 어느 정도 SSE에 대한 처리가 되어있는 라이브러리를 사용하는 방식&lt;/b&gt;을 택할 수 있다. 여기서는 서버 측과 논의 하에 &lt;b&gt;SSE 규약의 Prefix 등을 변경할 필요가 있다면 직접 구현&lt;/b&gt;하고 아니라면 OkHttp-SSE를 사용해도 무방하다고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, OkHttp-SSE는 아직 실험적 단계이기 때문에 스펙 변경에 유의할 필요가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 글에서는 OkHttp-SSE를 사용하는 방식을 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안드로이드에서 SSE (Server-Sent Events) 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OkHttp-SSE는 SSE(Server-Sent Events) 통신에서 가장 번거로운 부분인 &lt;b&gt;스트림 연결 유지, 라인 파싱, 이벤트 조립 등을 내부적으로 처리해 준다.&lt;/b&gt; 이를 위해 라이브러리 내부에는 &lt;b&gt;몇 가지 핵심 타입들이 역할을 분담&lt;/b&gt;하고 있으며, 그 구조를 이해하면 SSE 구현 방식이 훨씬 명확해질 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 3개의 코드가 바로 oKhttp-sse의 핵심 엔트리 포인트이다.&lt;/p&gt;
&lt;pre id=&quot;code_1763306441671&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;object EventSources {
  @JvmStatic
  fun createFactory(client: OkHttpClient): EventSource.Factory {
    return EventSource.Factory { request, listener -&amp;gt;
      val actualRequest =
        if (request.header(&quot;Accept&quot;) == null) {
          request.newBuilder().addHeader(&quot;Accept&quot;, &quot;text/event-stream&quot;).build()
        } else {
          request
        }

      RealEventSource(actualRequest, listener).apply {
        connect(client)
      }
    }
  }

  @JvmStatic
  fun processResponse(response: Response, listener: EventSourceListener) {
    val eventSource = RealEventSource(response.request, listener)
    eventSource.processResponse(response)
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1763306514498&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface EventSource {
  fun request(): Request

  fun cancel()

  fun interface Factory {
    fun newEventSource(request: Request, listener: EventSourceListener): EventSource
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1763306553707&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;abstract class EventSourceListener {
  open fun onOpen(eventSource: EventSource, response: Response) {}

  open fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {}

  open fun onClosed(eventSource: EventSource) {}

  open fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 `EventSources.createFactory()`를 호출하여 `EventSource.Factory` 객체를 생성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 팩토리는 SSE 규약에 맞는 `Accept: text/event-stream` 헤더를 자동으로 추가한 뒤, `RealEventSource(request, listener)`를 만들어 OkHttp의 `Call.enqueue(callback)`을 사용해 비동기 요청을 수행하도록 구성된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 callback 내부에서는&lt;b&gt; SSE 스트림을 라인 단위로 파싱하고 완성된 이벤트를 `ServerSentEventListener`로 전달&lt;/b&gt;하도록 처리된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, `createFactory()` 호출 시점에서는 아직 SSE 연결이 시작되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 스트림 연결은 `EventSource.Factory.newEventSource(request, listener)`를 호출할 때야 비로소 수행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 &lt;b&gt;ServerSentEventListener&lt;/b&gt;로 전달된 이벤트들은 그 이벤트(open, close, failure, event)에 맞는 처리를 한 후 Flow를 통해 UI까지 전달되도록 하면 실시간성을 확보할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글의 내용은 우아한테크코스에서 진행한 테코톡 세션에서도 발표한 바 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혹시 글만으로 이해가 어렵다면 발표 자료나 영상을 함께 참고해 보면 전체 흐름을 더 쉽게 파악할 수 있을 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=pIVOs3bf3Gw&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[10분&amp;nbsp;테코톡]&amp;nbsp;다이스의&amp;nbsp;Server-Sent&amp;nbsp;Events&amp;nbsp;for&amp;nbsp;Android&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/woowacourse-teams/2025-bottari&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;보따리 레포지토리&lt;/a&gt;&lt;/p&gt;</description>
      <category>Android</category>
      <author>Moondev</author>
      <guid isPermaLink="true">https://moondev03.tistory.com/67</guid>
      <comments>https://moondev03.tistory.com/entry/Android-Server-Sent-EventsSSE%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%99%94%EB%A9%B4-%EB%8F%99%EA%B8%B0%ED%99%94#entry67comment</comments>
      <pubDate>Mon, 17 Nov 2025 00:47:54 +0900</pubDate>
    </item>
    <item>
      <title>[Android] AppLink 인앱 브라우저 호환 문제 처리</title>
      <link>https://moondev03.tistory.com/entry/Android-AppLink-%EC%9D%B8%EC%95%B1-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%ED%98%B8%ED%99%98-%EB%AC%B8%EC%A0%9C-%EC%B2%98%EB%A6%AC</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;들어가며&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱을 개발하다 보면 &quot;URL을 눌렀을 때 앱이 바로 열리게 하고 싶다&quot;는 요구를 자주 듣습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 가능하게 하는 기술이 바로 `DeepLink`입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 진행하고 있는 `보따리`라는 프로젝트에서도 초대 링크를 눌렀을 때 앱이 열리도록 `AppLink`를 적용했지만&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오톡이나 인스타그램 등 인앱 브라우저에서는 정상적으로 동작하지 않는 문제가 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 DeepLink의 개념부터 AppLink가 등장한 이유,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 보따리 앱에서 이 문제를 어떻게 해결했는지를 차례로 정리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;DeepLink란?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`DeepLink`는 말 그대로 &quot;사용자를 특정 앱으로 이동시켜서 원하는 화면을 보여주거나, 사용자 액션을 유도하는 링크&quot;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹의 URL처럼 보이지만 브라우저 대신 앱을 직접 실행시켜 특정 화면으로 진입하게 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래 링크를 클릭하면 앱의 상세 화면이 열리도록 구성할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1761739526310&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;myapp://detail/123&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 링크는 앱 외부(ex. 브라우저, 메시지, 이메일 등)에서 클릭해도 해당 앱이 설치되어 있다면 바로 열리고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치되어 있지 않다면 브라우저가 대신 처리하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;DeepLink의 세 가지 형태&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시간이 지나면서 DeepLink는 보안성과 사용자 경험을 보완하기 위해 발전했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 Android에서 사용할 수 있는 DeepLink는 크게 세 가지 형태로 나뉩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Custom Scheme DeepLink&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;310&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNj1Zv/dJMcacBnPtv/RmBYxN6eONS3RAbKJHTtc1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNj1Zv/dJMcacBnPtv/RmBYxN6eONS3RAbKJHTtc1/img.png&quot; data-alt=&quot;출처: https://docs.tosspayments.com/blog/android-ios-webview-deeplink&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNj1Zv/dJMcacBnPtv/RmBYxN6eONS3RAbKJHTtc1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNj1Zv%2FdJMcacBnPtv%2FRmBYxN6eONS3RAbKJHTtc1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;310&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;310&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처: https://docs.tosspayments.com/blog/android-ios-webview-deeplink&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Scheme을 통해 실행시킬 앱을 특정하고 Path를 통해 들어가고 싶은 페이지를 특정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 Custom Scheme 방식의 DeepLink가 가진 문제점은 서로 다른 앱에서 같은 Scheme을 가질 수 있다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히 Android에서는 동일한 Scheme을 가진 앱이 여러 개 존재한다면 어떤 앱을 실행할 지에 대한 UI가 노출됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;AppLink&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1761739765393&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;https://myapp.com/detail/123&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Custom Scheme 방식과 달리 표준 웹링크 형태를 가졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AppLink는 PlayConsole를 통해 발급받은 `assetlinks.json` 파일을 통해 도메인을 인증하는 식으로 링크에 대한 검증을 수행합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Intent Scheme DeepLink&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1761743107739&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;intent://bottari.app/team
   #Intent;
      package=com.bottari.bottari; -&amp;gt; 앱의 패키지명
      scheme=https;
      S.browser_fallback_url=${encodeURIComponent(PLAY_STORE_LINK)}; -&amp;gt; 앱이 설치되어 있지 않을 때 이동할 URL
   end;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Intent URI 형식을 사용해 앱을 직접 실행하는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AppLink처럼 OS 수준의 검증은 없지만 In-App 브라우저 환경에서도 작동한다는 점이 가장 큰 장점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 링크를 클릭하면 Android System이 다음 단계를 수행합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. `scheme=bottari`를 가진 앱이 설치되어 있다면 -&amp;gt; 앱을 실행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 앱이 설치되어 있지 않다면 -&amp;gt; Play Store를 열도록 처리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;보따리 앱에서의 AppLink 문제&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`보따리` 앱에서는 `AppLink`를 통해 팀 초대 링크를 클릭하면 앱을 바로 열고 팀에 참가하는 기능을 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 사용자가 친구에게 다음과 같은 링크를 보냈다고 가정해 보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1761740168478&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;https://bottari.app/team?code=ABCDEFGH&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`AndroidManifest.xml`에서는 다음과 같은 `AppLink` 설정을 해두었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1761740262971&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;intent-filter android:autoVerify=&quot;true&quot;&amp;gt;
    &amp;lt;action android:name=&quot;android.intent.action.VIEW&quot; /&amp;gt;
    &amp;lt;category android:name=&quot;android.intent.category.DEFAULT&quot; /&amp;gt;
    &amp;lt;category android:name=&quot;android.intent.category.BROWSABLE&quot; /&amp;gt;
    
    &amp;lt;data
        android:scheme=&quot;https&quot;
        android:host=&quot;bottari.app&quot;
        android:pathPrefix=&quot;/team&quot; /&amp;gt;
&amp;lt;/intent-filter&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ Chrome과 Slack에서는 정상 작동했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`https://bottari.app/team?code=ABCDEFGH`를 클릭하면 앱 설치 여부에 따라 앱 혹은 스토어로 이동했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;❌ 하지만 카카오톡, 인스타그램 등 내장 브라우저에서는 앱이 실행되지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;왜 내장 브라우저에서는 AppLink가 동작하지 않을까?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 핵심은 &lt;b&gt;AppLink는 OS 수준에서 앱과 도메인 소유권을 검증한 뒤 브라우저가 이를 대신 실행해 주는 구조&lt;/b&gt;이기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Chrome, Samsung Internet 등 OS 브라우저에서는 앱 링크를 인식하고 Intent를 전달합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 하지만 카카오톡, 인스타그램 등의 In-App 브라우저는 독립된 WebView 기반으로 동작하기 때문에 &lt;b&gt;OS 레벨의 앱 소유 검증&lt;/b&gt;을 수행하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &quot;OS가 아닌 앱 내부 브라우저에서는 AppLink를 신뢰하지 않기 때문에 앱을 직접 실행하지 않는다.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; &lt;span&gt; &quot;결과적으로 사용자는 앱이 설치되어 있어도 웹 페이지만 열리는 현상이 발생한다.&quot;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;In-App 브라우저 환경에서는 앱링크의 동작이 제한적이고 불확실하다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결 전략&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`보따리`에서는 이를 해결하기 위해 &lt;b&gt;중간 랜딩 페이지&lt;/b&gt;를 추가했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 구조는 `[카카오톡에서 링크 클릭] -&amp;gt; [브라우저 열림]`&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개선된 구조는 `[카카오톡에서 링크 클릭] -&amp;gt; [랜딩 페이지 (Web)] -&amp;gt; [앱 설치 여부 확인] -&amp;gt; 앱 실행 or PlayStore로 이동`&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFPd7h/dJMcacg44Lh/heOaKUkIOjwS58gcRcHae1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFPd7h/dJMcacg44Lh/heOaKUkIOjwS58gcRcHae1/img.jpg&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1151&quot; data-filename=&quot;KakaoTalk_Photo_2025-10-29-21-37-51 002.jpeg&quot; data-widthpercent=&quot;64.83&quot; style=&quot;width: 64.079608%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFPd7h/dJMcacg44Lh/heOaKUkIOjwS58gcRcHae1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbFPd7h%2FdJMcacg44Lh%2FheOaKUkIOjwS58gcRcHae1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1080&quot; height=&quot;1151&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cz5ZU2/dJMcaiawT5q/TI2RvvaZMqj5H6PH5vzYM1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cz5ZU2/dJMcaiawT5q/TI2RvvaZMqj5H6PH5vzYM1/img.jpg&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2122&quot; data-filename=&quot;KakaoTalk_Photo_2025-10-29-21-37-50 001.jpeg&quot; data-widthpercent=&quot;35.17&quot; style=&quot;width: 34.757601%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cz5ZU2/dJMcaiawT5q/TI2RvvaZMqj5H6PH5vzYM1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcz5ZU2%2FdJMcaiawT5q%2FTI2RvvaZMqj5H6PH5vzYM1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1080&quot; height=&quot;2122&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;구현 방식&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`https://bottari.app/team` 경로로 요청이 오는 경우 사용자에게 아래 코드들이 포함된 Html 파일을 응답하도록 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 함수로 &lt;b&gt;안드로이드 기기&lt;/b&gt;인지를 판단하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1761809449321&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function isAndroid() {
    return /Android/i.test(navigator.userAgent || '');
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`intentUrl`은 위에서 언급한 &lt;b&gt;DeepLink의 세 가지 유형&lt;/b&gt; 중 `Intent Scheme DeepLink`에 해당합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1761809291559&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const intentUrl = `${intentBaseUrl}#Intent;scheme=https;package=${CONFIG.APP_PACKAGE_NAME};S.browser_fallback_url=${encodeURIComponent(PLAY_STORE_LINK)};end`;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`intentUrl`으로 앱 실행을 시도했을 때 앱이 실행되지 않는다면 설치되지 않았다고 판단하고 플레이스토어를 열도록 하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1761741304426&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function openApp(id) {
    const { httpsUrl, intentUrl } = buildLinks(id || '');

    if (isAndroid()) {
        // Android: Intent로 앱 실행 시도 &amp;rarr; 미설치 시 Play 스토어
        const fallback = setTimeout(() =&amp;gt; {
            window.location.href = PLAY_STORE_LINK;
        }, CONFIG.FALLBACK_DELAY);

        // 사용자가 앱으로 전환하면 폴백 취소
        document.addEventListener('visibilitychange', () =&amp;gt; {
            if (document.hidden) {
                clearTimeout(fallback);
            }
        });

        window.location.href = intentUrl;
    } else {
        // 다른 환경: HTTPS URL로 이동
        window.location.href = httpsUrl;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리하며&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AppLink는 분명 강력한 기능이지만 &lt;b&gt;모든 환경에서 동일하게 동작하지 않는다&lt;/b&gt;는 점을 직접 경험하며 크게 배웠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보따리 앱의 초대 기능은 대부분 &lt;b&gt;카카오톡을 통해&lt;/b&gt; 공유되는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AppLink가 인앱 브라우저에서 동작하지 않아서 사용자들은 초대 코드를 직접 복사해 입력해야 하는 불편함을 겪었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 &lt;b&gt;중간 랜딩 페이지&lt;/b&gt;를 추가하면서,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 어떤 환경에서 링크를 열든 앱으로 자연스럽게 이동할 수 있도록 개선했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순한 기술적 조치였지만 결과적으로는 초대 기능의 접근성과 완성도를 한 단계 끌어올릴 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료&lt;/h3&gt;
&lt;figure id=&quot;og_1761744225935&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Android, iOS 웹뷰에서 딥링크 열기 | 토스페이먼츠 개발자센터&quot; data-og-description=&quot;각 딥링크 유형의 특징과 차이점을 자세히 알아보고, Android와 iOS 웹뷰에서 딥링크로 국내 카드앱&amp;middot;은행앱으로 이동하는 예시를 살펴볼게요.&quot; data-og-host=&quot;docs.tosspayments.com&quot; data-og-source-url=&quot;https://docs.tosspayments.com/blog/android-ios-webview-deeplink&quot; data-og-url=&quot;https://docs.tosspayments.com/blog/android-ios-webview-deeplink&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bm2FLm/hyZMPDr27Y/bJ4BFE7wf4eKFoOjTKMxTK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bmukjF/hyZMlYhhel/OQklpkPjnffF3U65zrYTl0/img.png?width=3004&amp;amp;height=1752&amp;amp;face=0_0_3004_1752,https://scrap.kakaocdn.net/dn/cQlicx/hyZLoAGXPt/hevLzHeX4picxoHdqwSLU1/img.png?width=2816&amp;amp;height=682&amp;amp;face=0_0_2816_682&quot;&gt;&lt;a href=&quot;https://docs.tosspayments.com/blog/android-ios-webview-deeplink&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.tosspayments.com/blog/android-ios-webview-deeplink&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bm2FLm/hyZMPDr27Y/bJ4BFE7wf4eKFoOjTKMxTK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bmukjF/hyZMlYhhel/OQklpkPjnffF3U65zrYTl0/img.png?width=3004&amp;amp;height=1752&amp;amp;face=0_0_3004_1752,https://scrap.kakaocdn.net/dn/cQlicx/hyZLoAGXPt/hevLzHeX4picxoHdqwSLU1/img.png?width=2816&amp;amp;height=682&amp;amp;face=0_0_2816_682');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Android, iOS 웹뷰에서 딥링크 열기 | 토스페이먼츠 개발자센터&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;각 딥링크 유형의 특징과 차이점을 자세히 알아보고, Android와 iOS 웹뷰에서 딥링크로 국내 카드앱&amp;middot;은행앱으로 이동하는 예시를 살펴볼게요.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.tosspayments.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1761744278055&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;앱 링크 정보 &amp;nbsp;|&amp;nbsp; App architecture &amp;nbsp;|&amp;nbsp; Android Developers&quot; data-og-description=&quot;이 페이지는 Cloud Translation API를 통해 번역되었습니다. 앱 링크 정보 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Android 앱 링크는 Android 6 이상에서 제공되&quot; data-og-host=&quot;developer.android.com&quot; data-og-source-url=&quot;https://developer.android.com/training/app-links/about?hl=ko&quot; data-og-url=&quot;https://developer.android.com/training/app-links/about?hl=ko&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bjFRmY/hyZMEolG7j/dYIEI3ZmrbxvwTCxW6IbI1/img.png?width=600&amp;amp;height=338&amp;amp;face=0_0_600_338&quot;&gt;&lt;a href=&quot;https://developer.android.com/training/app-links/about?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.android.com/training/app-links/about?hl=ko&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bjFRmY/hyZMEolG7j/dYIEI3ZmrbxvwTCxW6IbI1/img.png?width=600&amp;amp;height=338&amp;amp;face=0_0_600_338');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;앱 링크 정보 &amp;nbsp;|&amp;nbsp; App architecture &amp;nbsp;|&amp;nbsp; Android Developers&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이 페이지는 Cloud Translation API를 통해 번역되었습니다. 앱 링크 정보 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Android 앱 링크는 Android 6 이상에서 제공되&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.android.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1761744301428&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - woowacourse-teams/2025-bottari:   당신의 여행, 행사, 일상 속 든든한 짐 친구 &amp;#96;보따리&amp;#96;&quot; data-og-description=&quot;  당신의 여행, 행사, 일상 속 든든한 짐 친구 &amp;#96;보따리&amp;#96;. Contribute to woowacourse-teams/2025-bottari development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/woowacourse-teams/2025-bottari&quot; data-og-url=&quot;https://github.com/woowacourse-teams/2025-bottari&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b42eRx/hyZMkE12TF/qUSxHPKyAI7tp7lu2C1Rf1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/ImkKE/hyZMfDJPhl/gURTkeSp4kFqeAI4bEeEAk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/woowacourse-teams/2025-bottari&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/woowacourse-teams/2025-bottari&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b42eRx/hyZMkE12TF/qUSxHPKyAI7tp7lu2C1Rf1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/ImkKE/hyZMfDJPhl/gURTkeSp4kFqeAI4bEeEAk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - woowacourse-teams/2025-bottari:   당신의 여행, 행사, 일상 속 든든한 짐 친구 `보따리`&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  당신의 여행, 행사, 일상 속 든든한 짐 친구 `보따리`. Contribute to woowacourse-teams/2025-bottari development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Android</category>
      <category>Android</category>
      <category>AppLink</category>
      <category>Bottari</category>
      <category>deeplink</category>
      <author>Moondev</author>
      <guid isPermaLink="true">https://moondev03.tistory.com/66</guid>
      <comments>https://moondev03.tistory.com/entry/Android-AppLink-%EC%9D%B8%EC%95%B1-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%ED%98%B8%ED%99%98-%EB%AC%B8%EC%A0%9C-%EC%B2%98%EB%A6%AC#entry66comment</comments>
      <pubDate>Wed, 29 Oct 2025 22:45:18 +0900</pubDate>
    </item>
    <item>
      <title>Build Logic으로 멀티모듈 공통 빌드 설정 효율적으로 관리하기</title>
      <link>https://moondev03.tistory.com/entry/Build-Logic%EC%9C%BC%EB%A1%9C-%EB%A9%80%ED%8B%B0%EB%AA%A8%EB%93%88-%EA%B3%B5%ED%86%B5-%EB%B9%8C%EB%93%9C-%EC%84%A4%EC%A0%95-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9C%BC%EB%A1%9C-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트의 기능이 확장되면서 모듈 구조의 복잡도가 급격히 높아졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 단일 모듈(:app)로 시작했지만 기능별 책임을 분리하기 위해 멀티모듈 구조로 리팩터링을 진행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 시간이 지날수록 공통 빌드 설정이 여러 모듈에 중복되면서 관리 포이트가 늘어나는 문제가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;멀티 모듈 프로젝트 구조&lt;/h3&gt;
&lt;pre id=&quot;code_1760427814810&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Stacko/
 ├── app/                        # Entry point (Application)
 ├── feature/
 │   ├── home/                   # 홈 화면
 │   ├── search/                 # 검색 기능
 │   ├── detail/                 # 상세 화면
 │   └── settings/               # 설정 화면
 ├── core/
 │   ├── designsystem/           # Compose 기반 디자인 시스템
 │   ├── data/                   # Repository 및 DataSource 계층
 │   ├── domain/                 # UseCase 계층
 │   ├── network/                # Retrofit 기반 네트워크 모듈
 │   ├── database/               # Room 기반 로컬 DB
 │   └── testing/                # 테스트 유틸리티 및 Fixture
 ├── build-logic/                # 공통 빌드 로직 관리 (BuildLogic)
 ├── build.gradle.kts
 ├── settings.gradle.kts
 └── gradle/
     └── libs.versions.toml      # 버전 카탈로그&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 모듈은 역할에 따라 잘 분리되어 있었지만 `build.gradle.kts`의 코드 중복이 심했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 모듈이 동일한 설정을 반복 선언하고 있었고 새로운 모듈을 추가할 때마다 기존 설정을 복사해 붙여 넣는 비효율적인 과정이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;build.gradle.kts (:feature:home)&lt;/h4&gt;
&lt;pre id=&quot;code_1760427925406&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.jetbrains.kotlin.android)
    ...
}

android {
    namespace = &quot;com.devbuds.feature.home&quot;
    compileSdk = libs.versions.projectCompileSdkVersion.get().toInt()

    defaultConfig {
        minSdk = libs.versions.projectMinSdkVersion.get().toInt()
        ...
    }
    ...
}

dependencies {
    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;build.gradle.kts (:feature:detail)&lt;/h4&gt;
&lt;pre id=&quot;code_1760427954093&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.jetbrains.kotlin.android)
    ...
}

android {
    namespace = &quot;com.devbuds.feature.detail&quot;
    compileSdk = libs.versions.projectCompileSdkVersion.get().toInt()

    defaultConfig {
        minSdk = libs.versions.projectMinSdkVersion.get().toInt()
        ...
    }
    ...
}

dependencies {
    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 이런 반복되는 &lt;b&gt;Gradle 설정, 버전 불일치, 새로운 모듈 추가의 부담&lt;/b&gt;을 해결하기 위해 도입된 `build-logic 모듈`의 설계와 적용 과정을 다룬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 원인 분석&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트가 모듈화되면서 코드 레벨에서는 책임이 명확해지고 유지보수가 쉬워졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 빌드 설정 수준에서는 각 모듈이 `build.gradle.kts`가 독립적으로 존재하게 되면서 오히려 복잡도가 증가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 대부분의 모듈이 다음과 같은 공통 항목을 반복 선언하고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Android SDK 버전 (compileSdk, minSdk, targetSdk, ...)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Compose 관련 설정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 코드 스타일 및 정적 분석 도구 설정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 테스트 관련 의존성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- ...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 중복은 단순히 보기 불편한 수준을 넘어서 실제 문제를 유발했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 동일한 의존성을 여러 곳에서 관리하면서 버전 불일치와 충돌 위험 증가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 새로운 모듈로 추가할 때마다 기존 설정을 복사, 붙여 넣는 비효율적인 작업 반복&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- ...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 문제의 본질은 &lt;b&gt;모듈 구조는 정리됐지만 빌드 로직은 여전히 분산&lt;/b&gt;되어 있다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 과정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1️⃣ `build-logic` 모듈 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, 프로젝트 루트에 `build-logic` 모듈을 생성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모듈은 다은 모듈과 달리 애플리케이션 코드가 아닌 &lt;b&gt;Gradle 설정을 관리하는 전용 모듈&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서는 &lt;b&gt;Convention Plugin&lt;/b&gt;을 정의하여 공통 빌드 설정을 한 곳에서 관리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;settings.gradle.kts(프로젝트 루트)&lt;/h4&gt;
&lt;pre id=&quot;code_1760428334821&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;enableFeaturePreview(&quot;TYPESAFE_PROJECT_ACCESSORS&quot;)

pluginManagement {
    includeBuild(&quot;build-logic&quot;)
    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gradle이 일반 &lt;b&gt;Kotlin Module&lt;/b&gt;과 &lt;b&gt;Build Module&lt;/b&gt;을 구별할 수 있도록 `includeBuild(&quot;build-logic&quot;)`을 작성해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2️⃣&amp;nbsp;`build-logic` 모듈 설정&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;build.gradle.kts(Included build: :build-logic)&lt;/h4&gt;
&lt;pre id=&quot;code_1760428451817&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;plugins {
    `kotlin-dsl`
}

group = &quot;com.devbuds.buildlogic&quot;

dependencies {
    compileOnly(libs.android.gradlePlugin)
    compileOnly(libs.android.tools.common)
    compileOnly(libs.kotlin.gradlePlugin)
    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Convention Plugin&lt;/b&gt;으로 정의할 관련 의존성을 추가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`build-logic`에서 생성되는 플러그인은 컴파일 중에만 사용되기 때문에 `implementation` 대신 `compileOnly`를 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3️⃣&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;Version Catalog &amp;nbsp;참조&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;`build-logic` 내부에서도 `libs.version.toml`을 그대로 참조하도록 설정했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 모든 모듈이 동일한 버전 카탈로그(libs)를 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;settings.gradle(:build-logic)&lt;/h4&gt;
&lt;pre id=&quot;code_1760428837498&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;versionCatalogs {
    create(&quot;libs&quot;) {
        from(files(&quot;../gradle/libs.versions.toml&quot;))
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Extensions.kt&lt;/h4&gt;
&lt;pre id=&quot;code_1760428856802&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;internal val Project.libs: VersionCatalog
    get() = extensions.getByType&amp;lt;VersionCatalogsExtension&amp;gt;().named(&quot;libs&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4️⃣ Convention Plugin 구조 설계&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;새로운 모듈을 추가할 때, build.gradle.kts 파일에 플러그인 한 줄만 추가하면 끝나도록 하자.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 모듈이 어떤 성격을 가지는지에 따라 공통 설정을 자동으로 해주는 Convention Plugin을 설계했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, Applicaiton, Feature, Testing 등으로 나누면 다음과 같은 구조가 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1760428629149&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;build-logic/
 ├── build.gradle.kts
 ├── settings.gradle.kts
 └── src/main/kotlin/
     ├── AndroidApplicationConventionPlugin.kt // Entry point (Application ID, Signing, Compose 등)
     ├── AndroidFeatureConventionPlugin.kt // 공통 Android UI 설정 (Compose, ViewModel, Navigation 등)
     ├── AndroidTestConventionPlugin.kt // 테스트 환경 설정 (JUnit, Kotest, Mockk 등)
     └── Extensions.kt&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 플러그인은 Gradle의 Plugin Interface를 구현하며,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플러그인 적용 시 자동으로 필요한 설정(ex. android, plugins, dependencies)을 주입한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5️⃣ Convention Plugin 구현 (Application)&lt;/h3&gt;
&lt;pre id=&quot;code_1760428888952&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;internal fun Project.configureKotlin() {
    extensions.configure&amp;lt;JavaPluginExtension&amp;gt; {
        sourceCompatibility = ApplicationConfig.JavaVersion
        targetCompatibility = ApplicationConfig.JavaVersion
    }

    extensions.configure&amp;lt;KotlinProjectExtension&amp;gt; {
        jvmToolchain(ApplicationConfig.JavaVersionAsInt)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1760428898331&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;internal fun Project.configureAndroid(commonExtension: CommonExtension&amp;lt;*, *, *, *, *, *&amp;gt;) {
    commonExtension.apply {
        compileSdk = libs.findVersion(&quot;projectCompileSdkVersion&quot;).get().toString().toInt()

        defaultConfig.minSdk = libs.findVersion(&quot;projectMinSdkVersion&quot;).get().toString().toInt()

        configureKotlin()
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1760428907903&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class AndroidApplicationConventionPlugin : Plugin&amp;lt;Project&amp;gt; {
    override fun apply(target: Project) {
        target.run {
            pluginManager.run {
                apply(plugin = &quot;com.android.application&quot;)
                apply(plugin = &quot;org.jetbrains.kotlin.android&quot;)
            }

            extensions.configure&amp;lt;ApplicationExtension&amp;gt; {
                defaultConfig {
                    applicationId = libs.findVersion(&quot;projectApplicationId&quot;).get().toString()
                    targetSdk =libs.findVersion(&quot;projectTargetSdkVersion&quot;).get().toString().toInt()
                    versionCode = libs.findVersion(&quot;projectVersionCode&quot;).get().toString().toInt()
                    versionName = libs.findVersion(&quot;projectVersionName&quot;).get().toString()
                }

                configureAndroid(this)

                // BuildTypes, BuildFeatures ...
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6️⃣ Convention Plugin 적용 (Application)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정의한 Convention Plugin을 다른 모듈에서 사용하기 위해서는 `build-logic` 내부의 `resources/main/META_INF/gradle-plugins/` 경로에 각 플러그인 ID와 연결될 properties 파일이 존재해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1202&quot; data-origin-height=&quot;1008&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dh2xBT/btsQ79ZVPOF/sUi8ZEGKXgsrKXbBM97lkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dh2xBT/btsQ79ZVPOF/sUi8ZEGKXgsrKXbBM97lkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dh2xBT/btsQ79ZVPOF/sUi8ZEGKXgsrKXbBM97lkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdh2xBT%2FbtsQ79ZVPOF%2FsUi8ZEGKXgsrKXbBM97lkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;538&quot; height=&quot;451&quot; data-origin-width=&quot;1202&quot; data-origin-height=&quot;1008&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 파일은 아래 설정으로 생성할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;build.gradle.kts (:build-logic)&lt;/h4&gt;
&lt;pre id=&quot;code_1760429070118&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;gradlePlugin {
    plugins {
        register(&quot;android-application&quot;) {
            id = libs.plugins.devbuds.android.application.asProvider().get().pluginId
            implementationClass = &quot;com.devbuds.buildlogic.plugin.AndroidApplicationConventionPlugin&quot;
        }

        ...
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;build.gradle.kts (:app)&lt;/p&gt;
&lt;pre id=&quot;code_1760429104580&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;plugins {
    id(&quot;com.devbuds.android.application&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드 로직을 도입하면서 멀티 모듈 환경의 복잡한 Gradle 설정을 단 한 줄로 단순화할 수 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2308&quot; data-origin-height=&quot;1392&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PdgyW/btsRahoIRI9/kSLXGtFjHfYPUk74m0uOjk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PdgyW/btsRahoIRI9/kSLXGtFjHfYPUk74m0uOjk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PdgyW/btsRahoIRI9/kSLXGtFjHfYPUk74m0uOjk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPdgyW%2FbtsRahoIRI9%2FkSLXGtFjHfYPUk74m0uOjk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;714&quot; height=&quot;431&quot; data-origin-width=&quot;2308&quot; data-origin-height=&quot;1392&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 &lt;b&gt;공통 설정은 중앙에서 일관되게 관리&lt;/b&gt;되고 &lt;b&gt;각 모듈은 오직 자신의 역할에만 집중&lt;/b&gt;하면 되는 구조가 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중복된 빌드 스크립트를 제거함으로써 유지보수성이 높아지고 새로운 요구사항에 대응하기 위한 확장성도 확보되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 가장 큰 변화는 &lt;b&gt;새로운 모듈을 생성하는 과정이 훨씬 단순&lt;/b&gt;해졌다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡한 설정 대신 명확한 규칙만 따르면 누구나 일관된 환경에서 개발을 시작할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 단순한 설정 정리가 아니라 &lt;b&gt;프로젝트 구조 전반의 개발 경험을 개선한 변화&lt;/b&gt;라고 생각한다.&lt;/p&gt;</description>
      <category>Android</category>
      <category>Android</category>
      <category>BuildLogic</category>
      <category>Gradle</category>
      <category>kotlin</category>
      <category>MulitModule</category>
      <category>Nia</category>
      <category>plugin</category>
      <author>Moondev</author>
      <guid isPermaLink="true">https://moondev03.tistory.com/65</guid>
      <comments>https://moondev03.tistory.com/entry/Build-Logic%EC%9C%BC%EB%A1%9C-%EB%A9%80%ED%8B%B0%EB%AA%A8%EB%93%88-%EA%B3%B5%ED%86%B5-%EB%B9%8C%EB%93%9C-%EC%84%A4%EC%A0%95-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9C%BC%EB%A1%9C-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0#entry65comment</comments>
      <pubDate>Tue, 14 Oct 2025 17:26:11 +0900</pubDate>
    </item>
    <item>
      <title>[Comopse] Android Compose Theme 살펴보기</title>
      <link>https://moondev03.tistory.com/entry/Comopse-Android-Compose-Theme-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;왜 Theme가 필요한가?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안드로이드 개발에서 UI는 앱의 첫인상을 결정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 화면이 많아질수록 색상, 글꼴, 모서리 둥글기 같은 스타일을 각 화면마다 직접 지정하는 방식은 유지보수가 매우 어렵습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 Compose는 &lt;span&gt;&lt;b&gt;Theme&lt;/b&gt;&lt;/span&gt; 개념을 도입하여 앱 전반에 일관된 디자인을 주입합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;일관성&lt;/b&gt;: 버튼, 텍스트, 카드 등 공통 UI 컴포넌트가 동일한 스타일로 표현&lt;/li&gt;
&lt;li&gt;&lt;b&gt;재사용성&lt;/b&gt;: 여러 화면에서 동일한 색상/글꼴/모양을 공유&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유지보수성&lt;/b&gt;: 브랜드 리뉴얼, 다크 모드 대응 시 코드 수정 범위 최소화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 Compose는 &lt;b&gt;Material Design 3(M3)&lt;/b&gt;를 기본 권장하며, &lt;span&gt;MaterialTheme&lt;/span&gt;를 통해 색상, 글꼴, 모양을 한 번에 관리할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;MaterialTheme 살펴보기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MaterialTheme는 세 가지 주요 토큰으로 구성됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;ColorScheme&lt;/b&gt;: 앱의 주요 색상(primary, secondary, surface, background 등)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Typography&lt;/b&gt;: 글꼴 스타일 (body, title, display 등)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Shapes&lt;/b&gt;: 컴포넌트 모서리 곡률 (RoundedCorner 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose는 이 값들을 &lt;b&gt;CompositionLocal&lt;/b&gt;을 통해 트리 전체에 전달합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 하위 컴포넌트는 어디서든 &lt;u&gt;MaterialTheme.colorScheme.primary&lt;/u&gt;처럼 호출할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  세부 속성은 &lt;a href=&quot;https://m3.material.io/&quot;&gt;Material 3 공식 문서&lt;/a&gt;에서 확인 가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;기본 Theme 구조&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 Compose 프로젝트를 생성하면 `&amp;lt;프로젝트명&amp;gt;Theme`라는 기본 테마 래퍼가 자동으로 만들어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 아래와 같이 앱 전체 UI에 적용할 색상, 타이포그래피 등을 묶어서 제공하는 &quot;테마 래퍼&quot;가 생성됩니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun BottariTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -&amp;gt; Unit
) {
    val colorScheme = when {
        dynamicColor &amp;amp;&amp;amp; Build.VERSION.SDK_INT &amp;gt;= Build.VERSION_CODES.S -&amp;gt; {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }

        darkTheme -&amp;gt; DarkColorScheme
        else -&amp;gt; LightColorScheme
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun BottariTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -&amp;gt; Unit
)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;u&gt;darkTheme&lt;/u&gt;&lt;span&gt;: 다크 모드 적용 여부 (기본값은 &lt;/span&gt;isSystemInDarkTheme()&lt;span&gt;)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;u&gt;&lt;span&gt;dynamicColor&lt;/span&gt;&lt;/u&gt;: Android 12 이상에서 제공하는 &lt;span&gt;&lt;b&gt;Dynamic Color&lt;/b&gt;&lt;/span&gt; 적용 여부&lt;/li&gt;
&lt;li&gt;&lt;u&gt;&lt;span&gt;content&lt;/span&gt;&lt;/u&gt;: 실제 UI를 그리는 Composable&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;  즉, 이 래퍼는 &lt;/span&gt;&lt;b&gt;앱의 색상 팔레트 결정 &amp;rarr; MaterialTheme 적용 &amp;rarr; 하위 UI에 주입&lt;/b&gt;&lt;span&gt;의 역할을 담당합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;MaterialTheme 내부 동작&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MaterialTheme&lt;span&gt;는 내부적으로 &lt;/span&gt;CompositionLocalProvider&lt;span&gt;를 이용해 Local 값들을 설정합니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;object MaterialTheme {
    val colorScheme: ColorScheme
        @Composable 
        @ReadOnlyComposable 
        get() = LocalColorScheme.current

    val typography: Typography
        @Composable 
        @ReadOnlyComposable 
        get() = LocalTypography.current

    val shapes: Shapes
        @Composable 
        @ReadOnlyComposable 
        get() = LocalShapes.current
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun MaterialTheme(
    colorScheme: ColorScheme = MaterialTheme.colorScheme,
    shapes: Shapes = MaterialTheme.shapes,
    typography: Typography = MaterialTheme.typography,
    content: @Composable () -&amp;gt; Unit
) {
    val rippleIndication = rippleOrFallbackImplementation()
    val selectionColors = rememberTextSelectionColors(colorScheme)

    CompositionLocalProvider(
        LocalColorScheme provides colorScheme,
        LocalIndication provides rippleIndication,
        LocalRippleTheme provides CompatRippleTheme,
        LocalShapes provides shapes,
        LocalTextSelectionColors provides selectionColors,
        LocalTypography provides typography,
    ) {
        ProvideTextStyle(value = typography.bodyLarge, content = content)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;MaterialTheme.colorScheme.primary&lt;/u&gt;&lt;span&gt;를 호출하면 &lt;/span&gt;&lt;span&gt;내부적으로 &lt;/span&gt;&lt;u&gt;LocalColorScheme.current.primary&lt;/u&gt;&lt;span&gt;를 가져오는 구조입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;  결과적으로 &lt;/span&gt;&lt;b&gt;모든 하위 UI는 자동으로 Theme 값을 상속&lt;/b&gt;&lt;span&gt;받게 되며 &lt;/span&gt;이를 통해 전체 앱의 디자인 일관성을 보장할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Bottari 서비스의 Custom Theme 적용&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 진행하고 있는 &quot;&lt;b&gt;보따리&quot;&lt;/b&gt;&amp;nbsp;앱에서는 MaterialTheme를 기반으로 &lt;span&gt;&lt;b&gt;자체 Color, Typography, Extra Tokens&lt;/b&gt;&lt;/span&gt;를 추가해 사용합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;ColorScheme&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;private val BottariLightColors = lightColorScheme(
    primary = Color(0xFF0064FF),
    secondary = Color(0xFF6EBA76),
    tertiary = Color(0xFF00AEFF),

    background = Color(0xFFFFFFFF),
    surface = Color(0xFFF8F8F8),
    onPrimary = Color.White,
    onSecondary = Color.Black,
    onTertiary = Color.White,
    onBackground = Color.Black,
    onSurface = Color.Black,

    error = Color(0xFFFF3B30),
    onError = Color.White
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다크 모드 지원 계획이 없기 때문에 LightColors만 정의해 줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 간단히 각 색상이 무엇은 의미하는지 살펴보겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;primary&lt;/b&gt;: 앱 대표 파랑 (#0064FF) &amp;rarr; 주요 버튼, 강조 색상&lt;/li&gt;
&lt;li&gt;&lt;b&gt;secondary&lt;/b&gt;: 개인 외의 상태(예: 공유 보따리, 협업 기능) &amp;rarr; 초록 계열&lt;/li&gt;
&lt;li&gt;&lt;b&gt;tertiary&lt;/b&gt;: 특정 구분(예: 할당된 보따리) &amp;rarr; 하늘색 계열&lt;/li&gt;
&lt;li&gt;&lt;b&gt;error&lt;/b&gt;: 에러 메시지, 삭제 버튼 &amp;rarr; soft red (#FF3B30)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;background / surface&lt;/b&gt;: 기본 흰색과 회색 계열 &amp;rarr; 앱 바탕/카드 배경&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; 자세한 설명은 &lt;a href=&quot;https://m3.material.io/styles/color/roles&quot;&gt;M3 Color Rules&lt;/a&gt;에서 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가적으로, 세부 상태 값은 &lt;u&gt;&lt;span&gt;BottariExtraColors&lt;/span&gt;&lt;/u&gt;라는 별도 &lt;b&gt;CompositionLocal&lt;/b&gt;로 관리합니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Immutable
data class BottariExtraColors(
    val gray50: Color = Color(0xFFF8F8F8),
    val gray100: Color = Color(0xFFF2F2F5),
    val gray200: Color = Color(0xFFEEEEEE),
    val gray300: Color = Color(0xFFD1DEE8),
    val gray400: Color = Color(0xFFC4C4C4),
    val gray500: Color = Color(0xFFA6A6A6),
    val gray600: Color = Color(0xFF999999),
    val gray700: Color = Color(0xFF787878),

    val productTypePersonal: Color = Color(0xFF0064FF),
    val productTypeShared: Color = Color(0xFF6EBA76),
    val productTypeAssigned: Color = Color(0xFF00AEFF),
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Typography&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;val Pretendard =
    FontFamily(
        Font(R.font.pretendard_regular, FontWeight.W400),
        Font(R.font.pretendard_medium, FontWeight.W500),
        Font(R.font.pretendard_semibold, FontWeight.W600),
        Font(R.font.pretendard_bold, FontWeight.W700),
    )&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  보따리 앱에서는 &lt;b&gt;Pretendard&lt;/b&gt; 폰트를 모든 스타일의 기본 폰트로 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FontWeight에 따라 Regular, Medium, SemiBold, Bold 리소스를 지정했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Immutable
data class BottariTextStyle(
    val fontFamily: FontFamily = Pretendard,
    val fontWeight: FontWeight = FontWeight.Medium,
    val fontSize: Dp = Dp.Unspecified,
    val lineHeight: Dp = Dp.Unspecified,
    val letterSpacing: TextUnit = 0.em,
    val color: Color = Color.Unspecified,
    val textAlign: TextAlign = TextAlign.Start,
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  시스템 글자 크기 변경에 영향을 받지 않고 항상 동일한 크기로 보이고 싶었기에 Material3의 Typography를 그대로 쓰는 대신, 보따리 앱에 맞는 Typography를 새로 만들었습니다. (권장은 sp 사용)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MaterialTheme.typography 대신, &lt;b&gt;Dp 기반으로 커스텀 폰트 크기/줄 높이를 지정&lt;/b&gt;할 수 있는 구조&lt;/li&gt;
&lt;li&gt;toTextStyle() 메서드를 통해 TextStyle로 변환할 때 LocalDensity를 이용해 Dp &amp;rarr; Sp 변환&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Material3의 Typography는 15개의 스타일로 구성되어 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 스타일은 &lt;b&gt;역할 기반 네이밍&lt;/b&gt;(Display, Headline, Title, Body, Label) + &lt;b&gt;크기 구분(Large/Medium/Small)으로&lt;/b&gt; 정의됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1720&quot; data-origin-height=&quot;684&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FS1kz/btsQY7GtEaA/YoBe7tfvE4m49KOZT9rpj0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FS1kz/btsQY7GtEaA/YoBe7tfvE4m49KOZT9rpj0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FS1kz/btsQY7GtEaA/YoBe7tfvE4m49KOZT9rpj0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFS1kz%2FbtsQY7GtEaA%2FYoBe7tfvE4m49KOZT9rpj0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1720&quot; height=&quot;684&quot; data-origin-width=&quot;1720&quot; data-origin-height=&quot;684&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;val BottariTypography = Typography(
    displayLarge = TextStyle(
        fontFamily = Pretendard,
        fontWeight = FontWeight.W700,
        fontSize = 32.sp,
        lineHeight = 40.sp
    ),
    displayMedium = TextStyle(
        fontFamily = Pretendard,
        fontWeight = FontWeight.W700,
        fontSize = 28.sp,
        lineHeight = 36.sp
    ),
    displaySmall = TextStyle(
        fontFamily = Pretendard,
        fontWeight = FontWeight.W700,
        fontSize = 24.sp,
        lineHeight = 32.sp
    ),
    ...
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Spacing&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1759250880522&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Immutable
data class BottariSpacing(
    val extraSmall: Dp = 8.dp,     
    val small: Dp = 12.dp,     
    val medium: Dp = 16.dp,      
    val large: Dp = 20.dp,     
    val extraLarge: Dp = 24.dp, 
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일관된 간격을 위해 별도로 정의하여 사용하도록 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;BottariTheme&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 위에서 정의한 내용들을 바탕으로 테마 래핑을 변경하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;val LocalBottariExtraColors = staticCompositionLocalOf { BottariExtraColors() }
val LocalBottariSpacing = staticCompostionLocalOf { BottariSpacing() }

@Composable
fun BottariTheme(
    content: @Composable () -&amp;gt; Unit
) {
    CompositionLocalProvider(
        LocalBottariExtraColors provides BottariExtarColors()
        LocalBottariSpacing provides BottariSpacing()
    ) {
        MaterialTheme(
            colorScheme = BottariLightColors,
            typography = BottariTypography,
            content = content
        )
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;적용 예시&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun BottariCard() {
    Card(
        shape = MaterialTheme.shapes.medium,
        colors = CardDefaults.cardColors(
            containerColor = LocalBottariExtraColors.current.gray100
        ),
        modifier = Modifier.padding(
            horizontal = LocalBottariSpacing.current.medium,
            vertical = LocalBottariSpacing.current.small,
        ),
    ) {
        Text(
            text = &quot;내 보따리&quot;,
            style = LocalBottariTypographySystem.current.bold20.toTextStyle(),
            color = MaterialTheme.colorScheme.primary
        )
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 &lt;b&gt;MaterialTheme + LocalBottari*&lt;/b&gt;를 함께 조합하여 UI 컴포넌트를 구성할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;사례: Now in Android&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구글의 &lt;a href=&quot;https://github.com/android/nowinandroid/blob/main/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Theme.kt&quot;&gt;Now in Android 프로젝트&lt;/a&gt;는 GradientColors, BackgroundTheme 같은 추가 토큰을 Theme에 통합해 복잡한 UI에서도 일관성을 유지합니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun NiaTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    androidTheme: Boolean = false,
    disableDynamicTheming: Boolean = true,
    content: @Composable () -&amp;gt; Unit,
) {
    // Color scheme
    val colorScheme = when {
        androidTheme -&amp;gt; if (darkTheme) DarkAndroidColorScheme else LightAndroidColorScheme
        !disableDynamicTheming &amp;amp;&amp;amp; supportsDynamicTheming() -&amp;gt; {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }

        else -&amp;gt; if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme
    }
    // Gradient colors
    val emptyGradientColors = GradientColors(container = colorScheme.surfaceColorAtElevation(2.dp))
    val defaultGradientColors = GradientColors(
        top = colorScheme.inverseOnSurface,
        bottom = colorScheme.primaryContainer,
        container = colorScheme.surface,
    )
    val gradientColors = when {
        androidTheme -&amp;gt; if (darkTheme) DarkAndroidGradientColors else LightAndroidGradientColors
        !disableDynamicTheming &amp;amp;&amp;amp; supportsDynamicTheming() -&amp;gt; emptyGradientColors
        else -&amp;gt; defaultGradientColors
    }
    // Background theme
    val defaultBackgroundTheme = BackgroundTheme(
        color = colorScheme.surface,
        tonalElevation = 2.dp,
    )
    val backgroundTheme = when {
        androidTheme -&amp;gt; if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme
        else -&amp;gt; defaultBackgroundTheme
    }
    val tintTheme = when {
        androidTheme -&amp;gt; TintTheme()
        !disableDynamicTheming &amp;amp;&amp;amp; supportsDynamicTheming() -&amp;gt; TintTheme(colorScheme.primary)
        else -&amp;gt; TintTheme()
    }
    // Composition locals
    CompositionLocalProvider(
        LocalGradientColors provides gradientColors,
        LocalBackgroundTheme provides backgroundTheme,
        LocalTintTheme provides tintTheme,
    ) {
        MaterialTheme(
            colorScheme = colorScheme,
            typography = NiaTypography,
            content = content,
        )
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;정리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면이 많아질수록 개별 스타일을 직접 지정하는 방식에는 분명 한계가 있고, 테마를 통해 공통 규칙을 주입해야만 확장 가능한 구조를 만들 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  결국 &lt;b&gt;Theme의 존재 이유는 앱의 첫인상을 지배하는 UI를 &amp;ldquo;관리 가능한 코드&amp;rdquo;로 만드는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 단순한 디자인 요소의 모음이 아니라, &lt;b&gt;앱 전체&lt;/b&gt;&lt;b&gt;&amp;nbsp;&lt;b&gt;UI의 일관성과 유지보수성을 책임지는 핵심 장치&lt;/b&gt;&lt;/b&gt;라고 할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  더 배우고 싶다면 &lt;a href=&quot;https://developer.android.com/codelabs/jetpack-compose-theming?hl=ko#0&quot;&gt;Compose Theming CodeLab&lt;/a&gt;을 추천합니다.&lt;/p&gt;</description>
      <category>Android/Compose</category>
      <category>Android</category>
      <category>compose</category>
      <category>designsystem</category>
      <category>MaterialTheme</category>
      <author>Moondev</author>
      <guid isPermaLink="true">https://moondev03.tistory.com/64</guid>
      <comments>https://moondev03.tistory.com/entry/Comopse-Android-Compose-Theme-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0#entry64comment</comments>
      <pubDate>Wed, 1 Oct 2025 01:43:00 +0900</pubDate>
    </item>
    <item>
      <title>[Git] Github Labels 한 번에 적용하는 방법</title>
      <link>https://moondev03.tistory.com/entry/Git-Github-Labels-%ED%95%9C-%EB%B2%88%EC%97%90-%EC%A0%81%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Github Repo를 생성할 때마다 수동으로 추가하던 Label을 한 번에 적용하는 방법을 알게 되어 소개하려고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  &lt;b&gt;준비물&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;git-labels.json&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1732719444079&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;name&quot;: &quot;라벨 이름&quot;,
&quot;color&quot;: &quot;라벨 색상&quot;,
&quot;description&quot;: &quot;라벨 설명&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1732719788759&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;[
  {
    &quot;name&quot;: &quot;⚙ Setting&quot;,
    &quot;color&quot;: &quot;e3dede&quot;,
    &quot;description&quot;: &quot;개발 환경 세팅&quot;
  },
  {
    &quot;name&quot;: &quot;✨ Feature&quot;,
    &quot;color&quot;: &quot;a2eeef&quot;,
    &quot;description&quot;: &quot;기능 개발&quot;
  },
  {
    &quot;name&quot;: &quot;  Deploy&quot;,
    &quot;color&quot;: &quot;C2E0C6&quot;,
    &quot;description&quot;: &quot;배포 관련&quot;
  },
  {
    &quot;name&quot;: &quot;  UI Design&quot;,
    &quot;color&quot;: &quot;FEF2C0&quot;,
    &quot;description&quot;: &quot;UI 및 스타일링 관련&quot;
  },
  {
    &quot;name&quot;: &quot;  BugFix&quot;,
    &quot;color&quot;: &quot;d73a4a&quot;,
    &quot;description&quot;: &quot;버그 수정&quot;
  },
  {
    &quot;name&quot;: &quot;  Docs&quot;,
    &quot;color&quot;: &quot;1D76DB&quot;,
    &quot;description&quot;: &quot;문서 작성 및 수정 (README.md 등)&quot;
  },
  {
    &quot;name&quot;: &quot;  API&quot;,
    &quot;color&quot;: &quot;D4C5F9&quot;,
    &quot;description&quot;: &quot;서버 API 통신&quot;
  },
  {
    &quot;name&quot;: &quot;  Refactor&quot;,
    &quot;color&quot;: &quot;f29a4e&quot;,
    &quot;description&quot;: &quot;코드 리팩토링&quot;
  },
  {
    &quot;name&quot;: &quot; &amp;zwj;♂️ Question&quot;,
    &quot;color&quot;: &quot;9ED447&quot;,
    &quot;description&quot;: &quot;추가 정보 요청&quot;
  },
  {
    &quot;name&quot;: &quot;✅ Test&quot;,
    &quot;color&quot;: &quot;ccffc4&quot;,
    &quot;description&quot;: &quot;테스트 관련(JUnit, Espresso 등)&quot;
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Github Personal Access Token&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Github -&amp;gt; Settings -&amp;gt; Developer Settings -&amp;gt; Personal Access Tokens -&amp;gt; Classic&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Repository 관련 Scope만 체크하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  &lt;b&gt;실행 방법&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1️⃣ 위에서 생성한 git-labels.json이 위치한 폴더로 이동합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2️⃣ 아래 명령어를 통해 라벨을 한 번에 적용합니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1732720098560&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npx github-label-sync --access-token {AccessToken} --labels ./git-labels.json {사용자 이름}/{레포지터리 이름}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조직에 속한 Repository의 Label를 업데이트하고 싶은 경우는 아래와 같이 입력하면 됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1732720261195&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npx github-label-sync --access-token {AccessToken} --labels ./git-labels.json {조직 이름}/{레포지터리 이름}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  &lt;b&gt;Financial-Times/github-label-sync&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;자세한 설명은 해당 링크를 참조해 주세요!&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1732720586433&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Financial-Times/github-label-sync: Synchronise your GitHub labels with as few destructive operations as possible&quot; data-og-description=&quot;Synchronise your GitHub labels with as few destructive operations as possible - Financial-Times/github-label-sync&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Financial-Times/github-label-sync#configuration&quot; data-og-url=&quot;https://github.com/Financial-Times/github-label-sync&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bF13si/hyXGNN9Zqe/XBrMcKrOB6WQXf3cEsriAk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/sKHWX/hyXGC6WepN/wMWbHF2C1SbaliLnZ1hZ9K/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Financial-Times/github-label-sync#configuration&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Financial-Times/github-label-sync#configuration&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bF13si/hyXGNN9Zqe/XBrMcKrOB6WQXf3cEsriAk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/sKHWX/hyXGC6WepN/wMWbHF2C1SbaliLnZ1hZ9K/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Financial-Times/github-label-sync: Synchronise your GitHub labels with as few destructive operations as possible&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Synchronise your GitHub labels with as few destructive operations as possible - Financial-Times/github-label-sync&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Etc/Git</category>
      <category>git</category>
      <category>Labels</category>
      <category>NPM</category>
      <category>오블완</category>
      <category>티스토리챌린지</category>
      <author>Moondev</author>
      <guid isPermaLink="true">https://moondev03.tistory.com/62</guid>
      <comments>https://moondev03.tistory.com/entry/Git-Github-Labels-%ED%95%9C-%EB%B2%88%EC%97%90-%EC%A0%81%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95#entry62comment</comments>
      <pubDate>Wed, 27 Nov 2024 23:53:35 +0900</pubDate>
    </item>
    <item>
      <title>[Compose] 한국 이름 입력을 위한 TextField 구현 (+천지인)</title>
      <link>https://moondev03.tistory.com/entry/Compose-%ED%95%9C%EA%B5%AD-%EC%9D%B4%EB%A6%84-%EC%9E%85%EB%A0%A5%EC%9D%84-%EC%9C%84%ED%95%9C-TextField-%EA%B5%AC%ED%98%84-%EC%B2%9C%EC%A7%80%EC%9D%B8</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;한국에서&amp;nbsp;이름&amp;nbsp;입력&amp;nbsp;시,&amp;nbsp;천지인&amp;nbsp;키보드와&amp;nbsp;삼성&amp;nbsp;한손모아키&amp;nbsp;키보드의&amp;nbsp;호환성을&amp;nbsp;고려해야&amp;nbsp;합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히,&amp;nbsp;두&amp;nbsp;키보드에서&amp;nbsp;미들닷(ㆍᆢ)의&amp;nbsp;표현이&amp;nbsp;다르기&amp;nbsp;때문에&amp;nbsp;이를&amp;nbsp;적절히&amp;nbsp;처리하는&amp;nbsp;정규표현식을&amp;nbsp;사용해야&amp;nbsp;합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;정규표현식&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력&amp;nbsp;필터링을&amp;nbsp;위한&amp;nbsp;정규표현식은&amp;nbsp;다음과&amp;nbsp;같습니다:&lt;/p&gt;
&lt;pre id=&quot;code_1732029147009&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val regex = Regex(&quot;^[가-힣ㆍᆞᆢㄱ-ㅎㅏ-ㅣ]*$&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이&amp;nbsp;정규표현식은&amp;nbsp;한글&amp;nbsp;문자(가-힣),&amp;nbsp;미들닷(ㆍ,&amp;nbsp;ᆢ),&amp;nbsp;그리고&amp;nbsp;자음(ㄱ-ㅎ)과&amp;nbsp;모음(ㅏ-ㅣ)을&amp;nbsp;포함하여&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오직&amp;nbsp;한국어만&amp;nbsp;입력받도록&amp;nbsp;설정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미들닷(ㆍ)이 두 개인 이유는 삼성의 한손모아키에서 사용되는 미들닷(ㆍ)이 천지인과 다르기 때문에&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 모두 대응하기 위해 추가하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;예시 코드&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는&amp;nbsp;Compose를&amp;nbsp;사용하여&amp;nbsp;한국어&amp;nbsp;입력을&amp;nbsp;처리하는&amp;nbsp;KoreanTextField&amp;nbsp;컴포저블&amp;nbsp;함수의&amp;nbsp;예시입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1732029079426&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Composable
fun KoreanTextField(
    value: String,
    onValueChange: (String) -&amp;gt; Unit,
    modifier: Modifier = Modifier,
    textStyle: TextStyle = TextStyle.Default
) {
    val regex = Regex(&quot;^[가-힣ㆍᆞᆢㄱ-ㅎㅏ-ㅣ]*$&quot;)
    
    BasicTextField(
        value = value,
        onValueChange = { newValue -&amp;gt;
            if (newValue.isEmpty() || regex.matches(newValue)) {
                onValueChange(newValue)
            }
        },
        modifier = modifier
            .fillMaxWidth()
            .border(1.dp, Color.Gray)
            .padding(8.dp),
        textStyle = textStyle,
        singleLine = true,
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;사용 예시&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는&amp;nbsp;KoreanTextField를&amp;nbsp;사용하는&amp;nbsp;화면의&amp;nbsp;예시입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1732029667048&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Composable
fun Screen() {
    var text by remember { mutableStateOf(&quot;&quot;) }
    
    KoreanTextField(
        value = text,
        onValueChange = { text = it }
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;정리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이&amp;nbsp;코드는&amp;nbsp;천지인&amp;nbsp;키보드와&amp;nbsp;삼성&amp;nbsp;한손모아키&amp;nbsp;키보드에서&amp;nbsp;한글&amp;nbsp;이름을&amp;nbsp;올바르게&amp;nbsp;입력받을&amp;nbsp;수&amp;nbsp;있도록&amp;nbsp;도와줍니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정규표현식을&amp;nbsp;통해&amp;nbsp;입력값을&amp;nbsp;필터링하고,&amp;nbsp;사용자가&amp;nbsp;입력한&amp;nbsp;내용이&amp;nbsp;유효할&amp;nbsp;경우에만&amp;nbsp;상태를&amp;nbsp;업데이트합니다.&amp;nbsp;&lt;/p&gt;</description>
      <category>Android/Compose</category>
      <category>compose</category>
      <category>regex</category>
      <category>천지인</category>
      <author>Moondev</author>
      <guid isPermaLink="true">https://moondev03.tistory.com/61</guid>
      <comments>https://moondev03.tistory.com/entry/Compose-%ED%95%9C%EA%B5%AD-%EC%9D%B4%EB%A6%84-%EC%9E%85%EB%A0%A5%EC%9D%84-%EC%9C%84%ED%95%9C-TextField-%EA%B5%AC%ED%98%84-%EC%B2%9C%EC%A7%80%EC%9D%B8#entry61comment</comments>
      <pubDate>Wed, 20 Nov 2024 00:24:14 +0900</pubDate>
    </item>
    <item>
      <title>[Git] CHANGELOG.md Generate</title>
      <link>https://moondev03.tistory.com/entry/Git-CHANGELOGmd-Generate</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지난 포스트에서 AngularJS Commit Convention에 대해 정리했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 정리한 내용 중에서 CHANGELOG.md를 직접 생성해보려 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;nbsp;  CHANGELOG.md&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성하기에 앞서 CHANGELOG.md가 뭔지 설명해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CHANGELOG.md는 Release 시점에 Commit Message를 &amp;lt;type&amp;gt; 및 &amp;lt;scope&amp;gt; 별로 정리해 놓은&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Markdown 형식의 파일입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 프로젝트의 feature, bug fix, refactor 등으로부터 변경된 변화를 한눈에 파악할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;nbsp;  간단한 생성 방법&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글에서도 소개해드렸는데 Git 명령어를 통해 생성하는 방법입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1729889310278&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;git log --pretty=&quot;- %s&quot; &amp;gt; CHANGELOG.md&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 명령어를 입력 후 실행하면 --pretty 설정에 맞춰 CHANGELOG.md가 생성됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;%s 는 요약이라는 의미입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 이미지는 위 명령어로 생성한 CHANGELOG입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;502&quot; data-origin-height=&quot;296&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cOFQFZ/btsKjOvaf7p/iJD93CY0dNxFeWZh4FvjWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cOFQFZ/btsKjOvaf7p/iJD93CY0dNxFeWZh4FvjWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cOFQFZ/btsKjOvaf7p/iJD93CY0dNxFeWZh4FvjWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcOFQFZ%2FbtsKjOvaf7p%2FiJD93CY0dNxFeWZh4FvjWK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;502&quot; height=&quot;296&quot; data-origin-width=&quot;502&quot; data-origin-height=&quot;296&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;pretty 확장 옵션&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 pretty의 확장 옵션 종류 및 설명입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;%H: 커밋 해시&lt;/li&gt;
&lt;li&gt;%h:&amp;nbsp;축약된 커밋 해시&lt;/li&gt;
&lt;li&gt;%T: 트리 해쉬&lt;/li&gt;
&lt;li&gt;%t: 축약된 트리 해시&lt;/li&gt;
&lt;li&gt;%P: 부모 해시들&lt;/li&gt;
&lt;li&gt;%p: 축약된 부모 해시들&lt;/li&gt;
&lt;li&gt;%an: 저자 이름&lt;/li&gt;
&lt;li&gt;%aN: 저자 이름 (존칭)&lt;/li&gt;
&lt;li&gt;%ae: 저자 이메일&lt;/li&gt;
&lt;li&gt;%aE: 저자 이메일&amp;nbsp;(존칭)&lt;/li&gt;
&lt;li&gt;%al: 저자 이메일 local-part (the part before the&amp;nbsp;@&amp;nbsp;sign)&lt;/li&gt;
&lt;li&gt;%aL: 저자 local-part (see&amp;nbsp;%al) respecting .mailmap, see&amp;nbsp;git-shortlog[1]&amp;nbsp;or&amp;nbsp;git-blame[1])&lt;/li&gt;
&lt;li&gt;%ad: 저자&amp;nbsp;날짜 (format respects --date= option)&lt;/li&gt;
&lt;li&gt;%aD: 저자&amp;nbsp;날짜, RFC2822 style&lt;/li&gt;
&lt;li&gt;%ar: 저자&amp;nbsp;날짜, 상대적인&lt;/li&gt;
&lt;li&gt;%at: 저자&amp;nbsp;날짜, UNIX timestamp&lt;/li&gt;
&lt;li&gt;%ai: 저자&amp;nbsp;날짜, ISO 8601-like format&lt;/li&gt;
&lt;li&gt;%aI: 저자&amp;nbsp;날짜, strict ISO 8601 format&lt;/li&gt;
&lt;li&gt;%as: 저자&amp;nbsp;날짜, short format (YYYY-MM-DD)&lt;/li&gt;
&lt;li&gt;%ah: 저자&amp;nbsp;날짜, human style (like the&amp;nbsp;--date=human&amp;nbsp;option of&amp;nbsp;git-rev-list[1])&lt;/li&gt;
&lt;li&gt;%cn: 커미터 이름&lt;/li&gt;
&lt;li&gt;%cN: 커미터 이름&amp;nbsp;(존칭)&lt;/li&gt;
&lt;li&gt;%ce: 커미터 이메일&lt;/li&gt;
&lt;li&gt;%cE: 커미터 이메일 (존칭)&lt;/li&gt;
&lt;li&gt;%cl: 커미터 이메일&amp;nbsp;local-part (the part before the&amp;nbsp;@&amp;nbsp;sign)&lt;/li&gt;
&lt;li&gt;%cL: 커미터 local-part (see&amp;nbsp;%cl) 존칭&lt;/li&gt;
&lt;li&gt;%cd: 커미터 날짜 (format respects --date= option)&lt;/li&gt;
&lt;li&gt;%cD: 커미터 날짜, RFC2822 style&lt;/li&gt;
&lt;li&gt;%cr: 커미터 날짜, relative&lt;/li&gt;
&lt;li&gt;%ct: 커미터 날짜, UNIX timestamp&lt;/li&gt;
&lt;li&gt;%ci: 커미터 날짜, ISO 8601-like format&lt;/li&gt;
&lt;li&gt;%cI: 커미터 날짜, strict ISO 8601 format&lt;/li&gt;
&lt;li&gt;%cs: 커미터 날짜, short format (YYYY-MM-DD)&lt;/li&gt;
&lt;li&gt;%ch: 커미터 날짜, human style&lt;/li&gt;
&lt;li&gt;%d: 참조 이름&lt;/li&gt;
&lt;li&gt;%D: &quot; (&quot;, &quot;)&quot;처럼 래핑 없는 참조 이름&lt;/li&gt;
&lt;li&gt;%S: 커맨드 라인상에서 제공하는 참조이름 (=&amp;nbsp;git log --source), git log에서만 동작&lt;/li&gt;
&lt;li&gt;%e: 인코딩&lt;/li&gt;
&lt;li&gt;%s: 주제&lt;/li&gt;
&lt;li&gt;%f:&amp;nbsp;파일 이름에 적합한 정리된 제목 줄&lt;/li&gt;
&lt;li&gt;%b: 바디&lt;/li&gt;
&lt;li&gt;%B: raw body (unwrapped subject and body)&lt;/li&gt;
&lt;li&gt;%N: commit notes&lt;/li&gt;
&lt;li&gt;%GG:&amp;nbsp;서명된 커밋에 대한 GPG의&amp;nbsp;raw verification message&lt;/li&gt;
&lt;li&gt;%G?:&amp;nbsp;좋은 (유효한) 서명을 위해 &quot;G&quot;표시&lt;/li&gt;
&lt;li&gt;%GS: 서명된&amp;nbsp;커밋의&amp;nbsp;서명자&amp;nbsp;이름&amp;nbsp;표시&lt;/li&gt;
&lt;li&gt;%GK:&amp;nbsp;서명된 커밋에 서명하는 데 사용되는 키 표시&lt;/li&gt;
&lt;li&gt;%GF: show the fingerprint of the key used to sign a signed commit&lt;/li&gt;
&lt;li&gt;%GP:&amp;nbsp;서명된 커밋에 서명하는 데 사용된 키의 지문 표시&lt;/li&gt;
&lt;li&gt;%GT:&amp;nbsp;서명된 커밋에 서명하는 데 사용되는 키의 신뢰 수준을 표시&lt;/li&gt;
&lt;li&gt;%gD: reflog selector&lt;/li&gt;
&lt;li&gt;%gd: shortened reflog selector; %gD와 동일, 하지만 참조 이름 부분은 사람의 가독성을 위해 단축되었습니다. (그래서 refs/heads/master는&amp;nbsp;master가 됨)&lt;/li&gt;
&lt;li&gt;%gn: 신분&amp;nbsp;이름을&amp;nbsp;reflog&lt;/li&gt;
&lt;li&gt;%gN: 신분&amp;nbsp;이름을&amp;nbsp;reflog (존칭)&lt;/li&gt;
&lt;li&gt;%ge: 신원&amp;nbsp;이메일&amp;nbsp;거부&lt;/li&gt;
&lt;li&gt;%gE: 신원 이메일 거부 (존칭)&lt;/li&gt;
&lt;li&gt;%gs: 주제를&amp;nbsp;reflog&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;nbsp;  라이브러리를 활용한 생성 방법&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 &lt;b&gt;generate-changelog&lt;/b&gt;라는 라이브러리를 통해 CHANGELOG.md를 생성해보려고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. Node.js 설치&lt;/h4&gt;
&lt;figure id=&quot;og_1729889647218&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Node.js &amp;mdash; Download Node.js&amp;reg;&quot; data-og-description=&quot;Node.js&amp;reg; is a JavaScript runtime built on Chrome's V8 JavaScript engine.&quot; data-og-host=&quot;nodejs.org&quot; data-og-source-url=&quot;https://nodejs.org/en/download/&quot; data-og-url=&quot;https://nodejs.org/en/download/package-manager&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/kbag6/hyXlRSHMJM/GYwxxGqgQX7YGq7Ybw9750/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/xOfSj/hyXpwlN5Gb/kxEfBO9nrkBURZNXkKzhKK/img.png?width=224&amp;amp;height=256&amp;amp;face=0_0_224_256&quot;&gt;&lt;a href=&quot;https://nodejs.org/en/download/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://nodejs.org/en/download/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/kbag6/hyXlRSHMJM/GYwxxGqgQX7YGq7Ybw9750/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/xOfSj/hyXpwlN5Gb/kxEfBO9nrkBURZNXkKzhKK/img.png?width=224&amp;amp;height=256&amp;amp;face=0_0_224_256');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Node.js &amp;mdash; Download Node.js&amp;reg;&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Node.js&amp;reg; is a JavaScript runtime built on Chrome's V8 JavaScript engine.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;nodejs.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. generate-changelog 설치&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1729889692300&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 글로벌
npm i generate-changelog -g

# 특정 프로젝트에서만
npm i generate-changelog -D&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 다른 프로젝트에서도 사용할 수 있도록 Global로 설치했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. package.json 생성&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 라이브러리는 Node.js 기반이기 때문에 package.json이 없으면 정상적으로 실행되지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;때문에 Project Root에 package.json을 생성해 주세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Intellij IEDA의 경우 New &amp;gt; package.json을 선택하면 생성할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;573&quot; data-origin-height=&quot;560&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHwaxh/btsKj0CkG4r/UXe1zL5qGx4UaK7xZ8P9R0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHwaxh/btsKj0CkG4r/UXe1zL5qGx4UaK7xZ8P9R0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHwaxh/btsKj0CkG4r/UXe1zL5qGx4UaK7xZ8P9R0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHwaxh%2FbtsKj0CkG4r%2FUXe1zL5qGx4UaK7xZ8P9R0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;573&quot; height=&quot;560&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;573&quot; data-origin-height=&quot;560&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4. 생성 명령어 실행&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1729890012223&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;changelog generate&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 명령어를 실행하면 CHANGELOG.md가 생성된 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;503&quot; data-origin-height=&quot;755&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lCWi5/btsKlmKP3o1/64QPXKNpPbKIHZHsKwU1k1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lCWi5/btsKlmKP3o1/64QPXKNpPbKIHZHsKwU1k1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lCWi5/btsKlmKP3o1/64QPXKNpPbKIHZHsKwU1k1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlCWi5%2FbtsKlmKP3o1%2F64QPXKNpPbKIHZHsKwU1k1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;503&quot; height=&quot;755&quot; data-origin-width=&quot;503&quot; data-origin-height=&quot;755&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Etc/Git</category>
      <category>changelog</category>
      <category>git</category>
      <category>node</category>
      <author>Moondev</author>
      <guid isPermaLink="true">https://moondev03.tistory.com/59</guid>
      <comments>https://moondev03.tistory.com/entry/Git-CHANGELOGmd-Generate#entry59comment</comments>
      <pubDate>Sat, 26 Oct 2024 06:03:34 +0900</pubDate>
    </item>
    <item>
      <title>[Git] AngularJS Commit Conventions</title>
      <link>https://moondev03.tistory.com/entry/Git-AngularJS-Commit-Conventions</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;우아한테크코스 프리코스 진행을 하면서 요구사항 중 AngularJS Commit Convention을 참고하여&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커밋 메시지를 작성하라는 요구사항이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 기회에 정리해보며 사용 방법을 익혀보려고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자료는 해당 &lt;a href=&quot;https://gist.github.com/stephenparish/9941e89d80e2bc58a153&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;링크&lt;/a&gt;를 참고했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  CHANGELOG.md 생성&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 기록에는 다음 세 가지의 내용이 포함되어 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. New Feature&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Bug Fixes&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. Breaking Changes&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 정의한 여러 스크립트에 따라 다양한 형식의 CHANGELOG를 생성할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 명령어로 간단한 형태의 CHANGELOG.md를 생성할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1729886536333&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;git log --pretty=&quot;- %s&quot; &amp;gt; CHANGELOG.md&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  중요하지 않은 Commit 식별&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요하지 않은 커밋은 주로 기존에 작성된 로직에 변화가 없는 Commit을 뜻합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주로 공백이나 빈 줄의 추가, 제거, 들여쓰기 같은 포맷의 변경이나 세미콜론 누락, 주석 등&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요하지 않은 커밋은 이진 탐색을 진행할 때 제외하도록 할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 명령어는 &quot;irrelevant&quot;라는 단어가 포함된 커밋 메시지를 찾아 제외하도록 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1729886735253&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;git bisect skip $(git rev-list --grep irrelevant &amp;lt;good place&amp;gt; HEAD)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  히스토리 조회 시 더 많은 정보 제공&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Fix small typo in docs widget (tutorial instructions)&lt;/li&gt;
&lt;li&gt;Fix test for scenario.Application - should remove old iframe&lt;/li&gt;
&lt;li&gt;docs - various doc fixes&lt;/li&gt;
&lt;li&gt;docs - stripping extra new lines&lt;/li&gt;
&lt;li&gt;Replaced double line break with single when text is fetched from Google&lt;/li&gt;
&lt;li&gt;Added support for properties in documentation&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 커밋 메시지들은 어떤 변경이 발생했는지 명시하려 하지만, 공통적인 규약이 있지는 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;fix comment stripping&lt;/li&gt;
&lt;li&gt;fixing broken links&lt;/li&gt;
&lt;li&gt;Bit of refactoring&lt;/li&gt;
&lt;li&gt;Check whether links do exist and throw exception&lt;/li&gt;
&lt;li&gt;Fix sitemap include (to work on case sensitive linux)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 커밋 메시지만 보고는 변경된 점을 알 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, docs, docs-parser, compiler, senario-runner 같이 어디가 변경되었는지 알려주는 게 좋다고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  커밋 메시지의 형식&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 집중해서 본 내용입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평소에는 Commit 시에 &amp;lt;type&amp;gt;과 &amp;lt;subject&amp;gt;만 작성하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기회에 Commit 메시지가 어디까지 활용될 수 있는지 알게되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1729887105159&quot; class=&quot;xml&quot; style=&quot;text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;&amp;lt;type&amp;gt;(&amp;lt;scope&amp;gt;): &amp;lt;subject&amp;gt;
&amp;lt;BLANK LINE&amp;gt;
&amp;lt;body&amp;gt;
&amp;lt;BLANK LINE&amp;gt;
&amp;lt;footer&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&amp;lt;type&amp;gt;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;feat: 새로운 기능 추가&lt;/li&gt;
&lt;li&gt;fix: 버그 추가&lt;/li&gt;
&lt;li&gt;docs: 문서 관련 작업&lt;/li&gt;
&lt;li&gt;style: 스타일 변경 (들여쓰기, 공백 제거 등)&lt;/li&gt;
&lt;li&gt;refactor: 리팩토링&lt;/li&gt;
&lt;li&gt;test: 테스트 관련 코드 작업&lt;/li&gt;
&lt;li&gt;chore: 자잘한 수정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데, Augular 9 Convention에서는 chore type이 제거되는 대신 build, ci, perf가 추가되었다고 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;build: 빌드 관련 작업&lt;/li&gt;
&lt;li&gt;ci: CI 설정 관련 작업&lt;/li&gt;
&lt;li&gt;perf: 성능 개선&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&amp;lt;scope&amp;gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적으로 가장 유용했던 부분이었던 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 변경한 위치를 header에 작성 했었는데 &amp;lt;scope&amp;gt;를 통해 변경된 위치를 알려줌으로써&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커밋 내역에 대한 가독성이 크게 향상되는 것을 경험할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;scope에는 어디가 변경되었는지가 작성되는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경된 부분은 모두 들어가는 게 가능합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;location&lt;/li&gt;
&lt;li&gt;browser&lt;/li&gt;
&lt;li&gt;compile&lt;/li&gt;
&lt;li&gt;rootScope&lt;/li&gt;
&lt;li&gt;ngHref&lt;/li&gt;
&lt;li&gt;ngClick&lt;/li&gt;
&lt;li&gt;ngView&lt;/li&gt;
&lt;li&gt;등등&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생략도 가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경된 클래스명 혹은 함수명을 작성하면 좋을 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&amp;lt;subject&amp;gt; or &amp;lt;short summary&amp;gt; or &amp;lt;header&amp;gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령문, 현재 시제로 작성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영문으로 작성 시 &quot;Add&quot;나 &quot;Change&quot;로 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주의할 점은 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;첫 글자를 대문자로 작성하지 마세요.&lt;/li&gt;
&lt;li&gt;마지막에 마침표를 붙이지 마세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&amp;lt;body&amp;gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령문, 현재 시제로 작성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경한 이유와 변경 전과의 차이점을 설명합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&amp;lt;footer&amp;gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 주요 변경 내역(Breaking Change)들은 다음과 함께 footer에 언급되어야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;변경점&lt;/li&gt;
&lt;li&gt;변경 사유&lt;/li&gt;
&lt;li&gt;마이그레이션 지시&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결된 이슈(Referencing Issues)는 Commit Message 최하단에 &lt;b&gt;Closes #이슈번호&amp;nbsp;&lt;/b&gt;와 같이 작성되어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결된 이슈가 여러 개인 경우에는 &lt;b&gt;Closes #123, #23, #44&lt;/b&gt;와 같이 작성할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  커밋 메시지 예시&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1729888339574&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;style($location): add couple of missing semi colons&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1729888324196&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;feat(directive): ng:disabled, ng:checked, ng:multiple, ng:readonly, ng:selected

New directives for proper binding these attributes in older browsers (IE).
Added coresponding description, live examples and e2e tests.

Closes #351&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1729888306163&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docs(guide): updated fixed docs from Google Docs

Couple of typos fixed:
- indentation
- batchLogbatchLog -&amp;gt; batchLog
- start periodic checking
- missing brace&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1729888293029&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;feat($compile): simplify isolate scope bindings

Changed the isolate scope binding options to:
  - @attr - attribute binding (including interpolation)
  - =model - by-directional model binding
  - &amp;amp;expr - expression execution binding

This change simplifies the terminology as well as
number of choices available to the developer. It
also supports local name aliasing from the parent.

BREAKING CHANGE: isolate scope bindings definition has changed and
the inject option for the directive controller injection was removed.

To migrate the code follow the example below:

Before:

scope: {
  myAttr: 'attribute',
  myBind: 'bind',
  myExpression: 'expression',
  myEval: 'evaluate',
  myAccessor: 'accessor'
}

After:

scope: {
  myAttr: '@',
  myBind: '@',
  myExpression: '&amp;amp;',
  // myEval - usually not useful, but in cases where the expression is assignable, you can use '='
  myAccessor: '=' // in directive's template change myAccessor() to myAccessor
}

The removed `inject` wasn't generaly useful for directives so there should be no code using it.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Etc/Git</category>
      <category>AngularJS</category>
      <category>commit convention</category>
      <category>git</category>
      <author>Moondev</author>
      <guid isPermaLink="true">https://moondev03.tistory.com/58</guid>
      <comments>https://moondev03.tistory.com/entry/Git-AngularJS-Commit-Conventions#entry58comment</comments>
      <pubDate>Sat, 26 Oct 2024 05:43:20 +0900</pubDate>
    </item>
    <item>
      <title>[Compose] SMS Retriever API - SMS 인증번호 자동입력</title>
      <link>https://moondev03.tistory.com/entry/Compose-SMS-Retriever-API-SMS-%EC%9D%B8%EC%A6%9D%EB%B2%88%ED%98%B8-%EC%9E%90%EB%8F%99%EC%9E%85%EB%A0%A5</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Google Play Service에는 SMS 기반 확인 프로세스를 간소화하는 데 사용할 수 있는 두 가지 API가 있습니다. &lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SMS Retriever API&lt;/li&gt;
&lt;li&gt;SMS User Consent API&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SMS Retriever API는 정말 자동화된 사용자 경험을 제공하므로 가능한 경우 사용하는 것이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 메시지 본문에 사용자 정의 해시 코드를 입력해야 하며, 해당 메시지를 보낸 사람이 아니라면 이 작업을 하는 것이 어려울 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지의 내용을 제어할 수 없는 경우(예: 앱이 앱 내에서 결제 거래를 승인하기 전에 사용자 전화번호를 인증할 수 있는 금융 기관과 협력하는 경우) 커스텀 해시 코드가 필요하지 않은 SMS User Consent API를 사용할 수 있습니다.&lt;span&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 사용자에게 앱의 인증 코드가 포함된 메시지에 대한 액세스 요청은 승인해야 합니다.&lt;span&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자에게 잘못된 메시지가 표시될 가능성을 최소화하기 위해 SMS 사용자 동의 기능은 메시지에 적어도 하나의 숫자를 포함한 4~10자리의 영숫자 코드가 포함되어 있는지 확인합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;869&quot; data-origin-height=&quot;159&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baImDU/btsJRAcwSmS/O4phTx9E6EaHCWIkYNMXi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baImDU/btsJRAcwSmS/O4phTx9E6EaHCWIkYNMXi1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baImDU/btsJRAcwSmS/O4phTx9E6EaHCWIkYNMXi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaImDU%2FbtsJRAcwSmS%2FO4phTx9E6EaHCWIkYNMXi1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;869&quot; height=&quot;159&quot; data-origin-width=&quot;869&quot; data-origin-height=&quot;159&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;자동 SMS 인증 흐름&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SMS Retriever API를 사용하면 사용자가 직접 인증 코드를 입력하지 않아도 되며, 추가 앱 권한이 없어도 Android 앱에서 SMS 기반 사용자 인증을 자동으로 실행할 수 있습니다. &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱에서 자동 SMS 인증을 구현하면 인증 흐름은 다음과 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;482&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1GbKU/btsJRzY1Asw/oppaweQUbphkM2VVdfBdkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1GbKU/btsJRzY1Asw/oppaweQUbphkM2VVdfBdkK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1GbKU/btsJRzY1Asw/oppaweQUbphkM2VVdfBdkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1GbKU%2FbtsJRzY1Asw%2FoppaweQUbphkM2VVdfBdkK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;482&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;482&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단히 흐름을 정리하자면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;1. 전화번호 인증 요청&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 인증할 전화번호를 서버에 전달하며 인증번호 발송을 요청합니다. &lt;br /&gt;&lt;br /&gt;&lt;b&gt;2. SMS Retriever API 사용&lt;/b&gt;&lt;br /&gt;동시에 앱은 SMS Retriever API를 사용하여 SMS를 리슨 합니다. &lt;br /&gt;&lt;br /&gt;&lt;b&gt;3. 서버에서 SMS 발송&lt;/b&gt;&lt;br /&gt;서버는 사용자에게 일회용 코드와 앱을 식별하는 해시가 포함된 SMS 메시지를 발송합니다. &lt;br /&gt;&lt;br /&gt;&lt;b&gt;4. SMS 수신 및 확인&lt;/b&gt;&lt;br /&gt;사용자의 기기에서 SMS 메시지를 수신하면 Google Play 서비스는 앱 해시를 사용하여 메시지가 앱을 대상으로 하는 것인지 확인하고, SMS Retriever API를 통해 메시지 텍스트를 제공합니다. &lt;br /&gt;&lt;br /&gt;&lt;b&gt;5. 인증 코드 파싱&lt;/b&gt;&lt;br /&gt;앱은 메시지 텍스트에서 일회용 코드(인증 코드)를 파싱하여 서버로 인증 요청을 합니다. &lt;br /&gt;&lt;br /&gt;&lt;b&gt;6. 인증 확인&lt;/b&gt;&amp;nbsp;&amp;nbsp;&lt;br /&gt;서버는 앱에서 전달받은 일회용 코드(인증 코드)를 확인한 후 사용자가 계정을 성공적으로 인증했음을 기록합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;구현 방법&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;필요 버전&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1727713805011&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;minSdkVersion 19 이상
compileSdkVersion 28 또는 이후 버전&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Dependency&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1727713851995&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation(&quot;com.google.android.gms:play-services-auth:21.2.0&quot;)
implementation(&quot;com.google.android.gms:play-services-auth-api-phone:18.1.0&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;SMS 규칙&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1727714011403&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;1. SMS 내용은 140byte 이하여야 합니다.
2. SMS 맨 앞은 &amp;lt;#&amp;gt;라는 Text로 시작해야 합니다.
3. SMS 맨 마지막은 앱을 식별하는 11글자 해시 문자열로 끝나야 합니다.

ex) &amp;lt;#&amp;gt; [앱 이름] 인증번호는 [123456] 입니다. bzXF9SEFPHs&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙 1, 2, 3을 제외한 나머지 부분은 변경해도 상관없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ex) &amp;lt;#&amp;gt; 인증번호: 123456 bzXF9SEFPHs&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;해시키 구하는 코드&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1727720398432&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class AppSignatureHelper(context: Context?) : ContextWrapper(context) {

    val appSignatures: List&amp;lt;String&amp;gt;
        get() {
            val appCodes = mutableListOf&amp;lt;String&amp;gt;()

            try {
                val packageName = packageName
                val signatures = getPackageSignatures(packageName)

                signatures.forEach { signature -&amp;gt;
                    hashSignature(packageName, signature)?.let { appCodes.add(it) }
                }
            } catch (e: PackageManager.NameNotFoundException) {
                Log.e(TAG, &quot;패키지를 찾을 수 없어 해시를 가져올 수 없습니다.&quot;, e)
            }

            return appCodes
        }


    private fun getPackageSignatures(packageName: String): Array&amp;lt;Signature&amp;gt; {
        val packageManager = packageManager
        return packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES).signatures
    }

    private fun hashSignature(packageName: String, signature: Signature): String? {
        return hash(packageName, signature.toCharsString())
    }

    private fun hash(packageName: String, signature: String): String? {
        val appInfo = &quot;$packageName $signature&quot;
        return try {
            val messageDigest = MessageDigest.getInstance(HASH_TYPE)
            messageDigest.update(appInfo.toByteArray(StandardCharsets.UTF_8))
            val hashSignature = messageDigest.digest().take(NUM_HASHED_BYTES).toByteArray()
            encodeToBase64(hashSignature)
        } catch (e: NoSuchAlgorithmException) {
            Log.e(TAG, &quot;해시 알고리즘을 찾을 수 없습니다.&quot;, e)
            null
        }
    }

    private fun encodeToBase64(hashSignature: ByteArray): String {
        val base64Hash = Base64.encodeToString(hashSignature, Base64.NO_PADDING or Base64.NO_WRAP)
        return base64Hash.take(NUM_BASE64_CHAR)
    }

    companion object {
        private val TAG: String = AppSignatureHelper::class.java.simpleName
        private const val HASH_TYPE = &quot;SHA-256&quot;
        private const val NUM_HASHED_BYTES = 9
        private const val NUM_BASE64_CHAR = 11
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;BroadcastReceiver&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째로 SMS 수신을 확인하기 위해 BroadcastReceiver 코드를 작성.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본인의 SMS 형식에 맞춰 파싱 함수를 수정해야 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727714213539&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class AuthSmsReceiver : BroadcastReceiver() {

    private var otpReceiver: OtpReceiveListener? = null

    interface OtpReceiveListener {
        fun onSmsReceived(otp: String)
    }

    private fun extractVerificationCode(message: String): String? {
        if (message.contains(&quot;app_name&quot;)) {
            val regex = &quot;&quot;&quot;\d{6}&quot;&quot;&quot;.toRegex()
            return regex.find(message)?.value
        }
        return null
    }

    override fun onReceive(context: Context, intent: Intent) {
        if (SmsRetriever.SMS_RETRIEVED_ACTION == intent.action) {
            intent.extras?.let { bundle -&amp;gt;
                val status = bundle.get(SmsRetriever.EXTRA_STATUS) as Status

                when (status.statusCode) {
                    CommonStatusCodes.SUCCESS -&amp;gt; {
                        val otpSms = bundle.getString(SmsRetriever.EXTRA_SMS_MESSAGE, &quot;&quot;)

                        if (otpReceiver != null &amp;amp;&amp;amp; otpSms.isNotEmpty()) {
                            val otp = extractVerificationCode(otpSms)
                            if (!otp.isNullOrEmpty()) {
                                otpReceiver!!.onSmsReceived(otp)
                            }
                        }
                    }
                }
            }
        }
    }

    fun setSmsListener(receiver: OtpReceiveListener) {
        this.otpReceiver = receiver
    }

    fun doFilter() = IntentFilter().apply {
        addAction(SmsRetriever.SMS_RETRIEVED_ACTION)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;View &amp;amp; ViewModel&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;많은 예제들이 Activity에서 구현하고 있지만, 저는 프로젝트에서 특정 뷰에서만 사용하기를 원했고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Multi Module + Single Activity 구조의 프로젝트였기에 app 모듈에 있는 MainActivity를 가져오기 힘들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;때문에 LocalContext.current를 통해 Context를 가져와서 activity로 캐스팅하고 ViewModel에 매개변수로 넘겨 사용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DisposableEffect를 사용해 activity의 smsReceiver를 생성 및 제거했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727714615334&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;DisposableEffect(LocalLifecycleOwner.current) {
    (context as? Activity)?.let { viewModel.startSmsRetriever(it) }
    onDispose { (context as? Activity)?.let { viewModel.stopSmsRetriever() } }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ViewModel에서는 AuthSmsReceiver.OtpReceiveListener를 상속받아 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 apply에서 this.setSmsListener(object: AuthSmsReceiver.OtpReceiveListener { /* override fun onSmsReceived... */})로 작성해도 상관없습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727714745442&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    private var smsReceiver: AuthSmsReceiver? = null
    private var weakActivity: WeakReference&amp;lt;Activity&amp;gt;? = null

    @SuppressLint(&quot;UnspecifiedRegisterReceiverFlag&quot;)
    fun startSmsRetriever(activity: Activity) {
        weakActivity = WeakReference(activity)
        SmsRetriever.getClient(activity).startSmsRetriever().also { task -&amp;gt;
            task.addOnSuccessListener {
                if (smsReceiver == null) {
                    smsReceiver = AuthSmsReceiver().apply {
                        setSmsListener(this@MobileSendAuthViewModel)
                    }
                }

                if(Build.VERSION.SDK_INT &amp;gt;= Build.VERSION_CODES.UPSIDE_DOWN_CAKE){
                    weakActivity?.get()?.registerReceiver(smsReceiver, smsReceiver!!.doFilter(), RECEIVER_EXPORTED)
                } else {
                    weakActivity?.get()?.registerReceiver(smsReceiver, smsReceiver!!.doFilter())
                }
            }

            task.addOnFailureListener {
                setEffect { AuthEffect.ShowToast(
                    message = it.message.toString(),
                    type = SnackBarType.ERROR
                ) }
            }
        }
    }

    override fun onSmsReceived(otp: String) {
        setAction(AuthAction.AutoFillCode(otp))
        stopSmsRetriever()
    }

    fun stopSmsRetriever() {
        weakActivity?.get()?.let { activity -&amp;gt;
            if (smsReceiver != null) {
                activity.unregisterReceiver(smsReceiver)
                smsReceiver = null
            }
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 주의할 점은 API 34부터 registerReceiver를 사용할 때&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RECEIVER_EXPORTED 또는 RECEIVER_NOT_EXPORTED를 넣어줘야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 값을 넣어주지 않으면 아래와 같은 Exception이 발생한다고 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727715047998&quot; class=&quot;routeros&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;java.lang.SecurityException: package: One of RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED should be specified when a receiver isn&amp;rsquo;t being registered exclusively for system broadcasts&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 내용에 대한 자세한 설명은 &lt;a href=&quot;https://heegs.tistory.com/159&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;여기&lt;/a&gt;를 참고해 주세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;예시 화면&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;sms_preview.gif&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;3120&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfyWkE/btsJSe00nHP/1fAKCinJHbNCMHknlA9zC0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfyWkE/btsJSe00nHP/1fAKCinJHbNCMHknlA9zC0/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfyWkE/btsJSe00nHP/1fAKCinJHbNCMHknlA9zC0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bfyWkE/btsJSe00nHP/1fAKCinJHbNCMHknlA9zC0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;287&quot; height=&quot;622&quot; data-filename=&quot;sms_preview.gif&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;3120&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증 코드 재전송 시 다시 Receive를 받기 위해서는 기존 receiver를 제거하고 새로운 receiver를 등록해야지만 정상적으로 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 다른 방법으로는 AndroidManifest.xml에서 아래처럼 Receiver를 정적으로 등록해도 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만, 특정 뷰에서만 해당 기능이 필요하기 때문에 동적으로 Receiver를 등록하는 게 좋을 것 같습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727719369904&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;receiver
    android:name=&quot;com.weave.intro.utils.AuthSmsReceiver&quot;
    android:exported=&quot;true&quot;
    android:permission=&quot;com.google.android.gms.auth.api.phone.permission.SEND&quot;&amp;gt;
    &amp;lt;intent-filter&amp;gt;
        &amp;lt;action android:name=&quot;com.google.android.gms.auth.api.phone.SMS_RETRIEVED&quot;/&amp;gt;
    &amp;lt;/intent-filter&amp;gt;
&amp;lt;/receiver&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Android/Compose</category>
      <category>Android</category>
      <category>compose</category>
      <category>smsretriever</category>
      <author>Moondev</author>
      <guid isPermaLink="true">https://moondev03.tistory.com/56</guid>
      <comments>https://moondev03.tistory.com/entry/Compose-SMS-Retriever-API-SMS-%EC%9D%B8%EC%A6%9D%EB%B2%88%ED%98%B8-%EC%9E%90%EB%8F%99%EC%9E%85%EB%A0%A5#entry56comment</comments>
      <pubDate>Tue, 1 Oct 2024 03:05:04 +0900</pubDate>
    </item>
    <item>
      <title>[Compose] SnackBar Duration Custom</title>
      <link>https://moondev03.tistory.com/entry/Compose-SnackBar-Duration-Custom</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1025&quot; data-origin-height=&quot;466&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9O06Q/btsJTeeELoc/tmNDatRQgOzCcdFPBXlim0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9O06Q/btsJTeeELoc/tmNDatRQgOzCcdFPBXlim0/img.png&quot; data-alt=&quot;https://m3.material.io/components/snackbar/guidelines#74ae61e1-a27a-4543-b3ba-b2391cfe0308&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9O06Q/btsJTeeELoc/tmNDatRQgOzCcdFPBXlim0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9O06Q%2FbtsJTeeELoc%2FtmNDatRQgOzCcdFPBXlim0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1025&quot; height=&quot;466&quot; data-origin-width=&quot;1025&quot; data-origin-height=&quot;466&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://m3.material.io/components/snackbar/guidelines#74ae61e1-a27a-4543-b3ba-b2391cfe0308&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;SnackBar란? &lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SnackBar는 간단한 메시지를 사용자에게 전달하는 UI 구성 요소로, 주로 아래와 같은 용도로 사용됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;정보 전달&lt;/b&gt;: 작업의 결과나 상태에 대한 피드백을 제공합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용자 액션 유도&lt;/b&gt;: 사용자가 특정 작업을 수행하도록 유도할 수 있습니다(예: &quot;취소&quot; 또는 &quot;다시 시도&quot; 버튼 포함).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;SnackBar 구현하기 &lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Android Compose에서 SnackBar를 사용하기 위해서는 &lt;span&gt;`Scaffold`&lt;/span&gt;와 &lt;span&gt;`SnackbarHost`&lt;/span&gt;를 이용해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 기본적인 SnackBar 구현 예제입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727710288501&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }

Scaffold(
    snackbarHost = {
        SnackbarHost(hostState = snackbarHostState)
    },
    floatingActionButton = {
        ExtendedFloatingActionButton(
            text = { Text(&quot;Show snackbar&quot;) },
            icon = { Icon(Icons.Filled.Image, contentDescription = &quot;&quot;) },
            onClick = {
                scope.launch {
                    snackbarHostState.showSnackbar(&quot;Snackbar&quot;)
                }
            }
        )
    }
) { contentPadding -&amp;gt;
    // content
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;SnackbarHostState를 생성하고&lt;span&gt;&amp;nbsp;&lt;/span&gt;Scaffold&lt;span&gt;&amp;nbsp;&lt;/span&gt;컴포넌트를 사용하여 SnackBar를 표시하는 구조입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼 클릭 시&lt;span&gt;&amp;nbsp;&lt;/span&gt;CoroutineScope를 이용하여 SnackBar를 나타내게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Snackbar Duration Custom&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Snackbar 자체의 커스텀은 넘어가도록 하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Snackbar를 사용하면서 가장 불편했던 점인 노출 시간을 조절해보려고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 SnackBar의 지속 시간은 다음과 같은 세 가지 옵션이 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1727710791787&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;enum class SnackbarDuration {
    /** Show the Snackbar for a short period of time */
    Short, // 4000L

    /** Show the Snackbar for a long period of time */
    Long, // 10000L

    /** Show the Snackbar indefinitely until explicitly dismissed or action is clicked */
    Indefinite // Long.MAX_VALUE
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 짧은&lt;b&gt;Short&lt;/b&gt; 옵션도 여전히 4초나 지속되어 다소 긴 것으로 느껴질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자에게 무의미한 대기 시간을 최소화하기 위해 맞춤형 지속 시간이 필요할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선, showSnackbar 함수가 suspend 함수이기 때문에 Snackbar는 CoroutineScope안에서 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CoroutineScope안이라서 Job를 통해 새로운 코루틴을 생성하고 cancel()을 통해 중단할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 다음과 같이 코드를 작성할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727711206364&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;LaunchedEffect(viewModel.uiEffect) {
    viewModel.uiEffect.collect { effect -&amp;gt;
        when (effect) {
            ...
            is AuthEffect.ShowMessage -&amp;gt; coroutineScope.launch {
                val job = launch {
                    snackState.showSnackbar(effect.message)
                }
                delay(1000L) // 원하는 시간 설정
                job.cancel()
            }
            ...
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;delay(1000L)을 사용하여 1초 후에 SnackBar가 자동으로 사라지도록 설정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필요한 대기 시간이 달라질 수 있으므로 적절한 시간을 설정하여 사용자 경험을 개선할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 추가 예시 코드입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727711781181&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val scope = rememberCoroutineScope()
val snackBarMsg = stringResource(id = R.string.error_msg)

scope.launch {
    val job = scope.launch {
        snackbarHostState.showSnackbar(
            message = snackBarMsg,
            duration = SnackbarDuration.Indefinite,
        )
    }
    delay(1000)
    job.cancel()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Reference&lt;/h4&gt;
&lt;figure id=&quot;og_1727711835222&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;jetpack compose Scaffold Possible to override the standard durations of SnackBar?&quot; data-og-description=&quot;How can override the standard durations of the Scaffold SnackBar to apply my own durations in MS. I can't see a way to do it is EventsToAddAlbumScreen.ShowSnackbarEventToAddAlbumScreen -&amp;gt;&quot; data-og-host=&quot;stackoverflow.com&quot; data-og-source-url=&quot;https://stackoverflow.com/questions/71471679/jetpack-compose-scaffold-possible-to-override-the-standard-durations-of-snackbar&quot; data-og-url=&quot;https://stackoverflow.com/questions/71471679/jetpack-compose-scaffold-possible-to-override-the-standard-durations-of-snackbar&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/vfjT0/hyXaBBDhtP/Fxuwvh2yftp1UXSTjfHOHk/img.png?width=316&amp;amp;height=316&amp;amp;face=0_0_316_316&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/71471679/jetpack-compose-scaffold-possible-to-override-the-standard-durations-of-snackbar&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://stackoverflow.com/questions/71471679/jetpack-compose-scaffold-possible-to-override-the-standard-durations-of-snackbar&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/vfjT0/hyXaBBDhtP/Fxuwvh2yftp1UXSTjfHOHk/img.png?width=316&amp;amp;height=316&amp;amp;face=0_0_316_316');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;jetpack compose Scaffold Possible to override the standard durations of SnackBar?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;How can override the standard durations of the Scaffold SnackBar to apply my own durations in MS. I can't see a way to do it is EventsToAddAlbumScreen.ShowSnackbarEventToAddAlbumScreen -&amp;gt;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;stackoverflow.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1727711879588&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;스낵바(snackbar) &amp;nbsp;|&amp;nbsp; Jetpack Compose &amp;nbsp;|&amp;nbsp; Android Developers&quot; data-og-description=&quot;이 페이지는 Cloud Translation API를 통해 번역되었습니다. 스낵바(snackbar) 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 스낵바 구성요소는 화면 하단에 위치합&quot; data-og-host=&quot;developer.android.com&quot; data-og-source-url=&quot;https://developer.android.com/develop/ui/compose/components/snackbar?hl=ko&quot; data-og-url=&quot;https://developer.android.com/develop/ui/compose/components/snackbar?hl=ko&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/PLzXv/hyXaHu4sG9/Otlg0RpyEObXMpJuZhnFkK/img.png?width=1201&amp;amp;height=676&amp;amp;face=0_0_1201_676,https://scrap.kakaocdn.net/dn/brdL7d/hyXaJGpCqF/YUk6buhvb2yWfeAq9CFCWk/img.png?width=2048&amp;amp;height=600&amp;amp;face=0_0_2048_600&quot;&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/components/snackbar?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.android.com/develop/ui/compose/components/snackbar?hl=ko&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/PLzXv/hyXaHu4sG9/Otlg0RpyEObXMpJuZhnFkK/img.png?width=1201&amp;amp;height=676&amp;amp;face=0_0_1201_676,https://scrap.kakaocdn.net/dn/brdL7d/hyXaJGpCqF/YUk6buhvb2yWfeAq9CFCWk/img.png?width=2048&amp;amp;height=600&amp;amp;face=0_0_2048_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;스낵바(snackbar) &amp;nbsp;|&amp;nbsp; Jetpack Compose &amp;nbsp;|&amp;nbsp; Android Developers&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이 페이지는 Cloud Translation API를 통해 번역되었습니다. 스낵바(snackbar) 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 스낵바 구성요소는 화면 하단에 위치합&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.android.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Android/Compose</category>
      <category>Android</category>
      <category>compose</category>
      <category>snackbar</category>
      <author>Moondev</author>
      <guid isPermaLink="true">https://moondev03.tistory.com/55</guid>
      <comments>https://moondev03.tistory.com/entry/Compose-SnackBar-Duration-Custom#entry55comment</comments>
      <pubDate>Tue, 1 Oct 2024 00:58:44 +0900</pubDate>
    </item>
  </channel>
</rss>