<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>sanghwi_back.log</title>
        <link>https://velog.io/</link>
        <description>plug-compatible programming unit</description>
        <lastBuildDate>Wed, 14 Jan 2026 09:29:13 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>sanghwi_back.log</title>
            <url>https://images.velog.io/images/sanghwi_back/profile/aff0e0be-ebc7-4fc6-985d-b9f097f5fd35/스크린샷 2022-03-08 오후 10.17.33.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. sanghwi_back.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/sanghwi_back" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Android 공부 (9)]]></title>
            <link>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-9</link>
            <guid>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-9</guid>
            <pubDate>Wed, 14 Jan 2026 09:29:13 GMT</pubDate>
            <description><![CDATA[<p>안드로이드 개발에서 UI 를 만드는 새로운 방법이다.</p>
<ul>
<li>젯팩 컴포즈란?</li>
<li>사용자 액션 처리</li>
<li>컴포즈 테마 설정</li>
<li>기존 프로젝트에 컴포즈 적용</li>
</ul>
<h2 id="젯팩-컴포즈란">젯팩 컴포즈란?</h2>
<p>XML 등으로 만드는 전통적인 안드로이드 개발은 명령형 접근법(Inperative approach) 이라고 한다.</p>
<p>젯팩 컴포즈는 선언형 접근법(Declarative approach)이다. 구현하고자 하는 UI의 최종 상태를 명시하면 내부에서 필요한 작업이 수행되는 식이다.</p>
<pre><code class="language-kotlin">@Composable
fun MyTextDisplay(myState: MyState) {
    Text(text = myState.text, color = myState.color)
}
data class MyState(
    val text: String,
    val color: Color
)</code></pre>
<p>@Composable 이 붙은 함수를 정의했다. 상태는 별도의 데이터 클래스로 정의했다. MyState 에서 발생하는 모든 변경 사항에 대응해 UI 를 다시 그린다. 이를 리컴포지션(recomposition) 이라고 한다. @Composable 함수를 컴포즈가 호출하는 방식이다.</p>
<p>새로운 화면을 만들 때는 Row, Column 함수를 선택한다. 세로 배치에는 Column, 가로 배치에는 Row 를 사용한다.</p>
<pre><code class="language-kotlin">@Composable
fun MyScreen() {
    Column {
        Text(text = &quot;My Static Text&quot;)
        TextField(value = &quot;My Text Field&quot;, onValueChange = {})
        Button(onClick = { }) { }
        Icon(painter = painterResource(R.drawable.icon),
             contentDescription = stringResource(id = R.string.icon_content_description))
    }
}</code></pre>
<p>forEach 문을 통해 여러 개 만들 수도 있다.</p>
<pre><code class="language-kotlin">@Composable
fun MyList(itemList: List&lt;String&gt;) {
    Column {
        itemList.forEach { str -&gt; Text(text = str) }
    }
}</code></pre>
<p>스크롤이 필요한 경우엔 LazyColumn 을 사용하는 것이 유리하다.</p>
<pre><code class="language-kotlin">@Composable
fun MyList(itemList: List&lt;String&gt;) {
    LazyColumn {
        item { Text(text = &quot;Header&quot;) }
        items(itemList) { str -&gt; Text(text = str) }
        item { Text(text = &quot;Footer&quot;) }
    }
}</code></pre>
<p>요소에 Attribute 는 Modifier 를 통해 부여한다.</p>
<pre><code class="language-kotlin">@Composable // 모든 방향 16dp 패딩
fun MyScreen() {
    Column(
        modifier = Modifier.padding(all = 16.dp)
    ) {

    }
}</code></pre>
<pre><code class="language-kotlin">@Composable // 각 방향 다른 패딩값
fun MyScreen() {
    Column(
        modifier = Modifier.padding(
        top = 5.dp, bottom = 5.dp,
        start = 10.dp, end = 10.dp)
    ) {
    }
}</code></pre>
<pre><code class="language-kotlin">@Composable // 수직 수평 패딩
fun MyScreen() {
    Column(
        modifier = Modifier.padding(
            vertical = 5.dp,
            horizontal = 10.dp)
    ) {
    }
}</code></pre>
<pre><code class="language-kotlin">@Composable // 클릭 가능하게 만든다.
fun MyScreen() {
    Column(
        modifier = Modifier.padding(
            vertical = 5.dp,
            horizontal = 10.dp
        ).clickable { }
    ) {
    }
}</code></pre>
<p>이제는 액티비티 클래스에 ActivityCompat 대신 ComponentActivity 를 사용한다. 뷰를 불러오는 방식도 약간 다르다.</p>
<pre><code class="language-kotlin">class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstance: Bundle?) {
        super.onCreate(savedInstance)
        setContent { MyScreen() }
    }
}</code></pre>
<h2 id="사용자-액션-처리">사용자 액션 처리</h2>
<p>위의 예시에서 TextField 가 잠시 스쳐갔는데 입력이 되지 않을 것이다. 이는 리컴포지션과 사용자 액션 처리 쪽 작업이 필요하기 때문이다.</p>
<pre><code class="language-kotlin">@Composable // 입력해도 값 안나옴. value 가 &quot;&quot; 인 TextField 만 리컴포지션 하기 때문
fun MyScreen() {
    Column { TextField(value = &quot;&quot;, onValueChange = {}) }
}

@Composable
fun MyScreen() {
    var text by remember { mutableStateOf(&quot;&quot;) }
    Column { TextField(value = text, onValueChange = { text = it }) }
}</code></pre>
<p>젯팩 컴포즈에서는 @Composable 함수인 remember 를 사용해 텍스트를 저장하는 MutableState 가 있다. 이 변수는 리컴포지션 이후에도 값을 유지한다.</p>
<p>이 변수에 값을 반영하는 액션을 정의하고 실제 TextField 의 값으로 반영하도록 해야 한다.</p>
<blockquote>
<p>액티비티가 새로 만들어질 때에도 변수의 값을 유지하려면 rememberSaveable 함수를 사용한다.</p>
</blockquote>
<p>상태관리에 대해 구글은 stateful(하나 이상의 상태를 관리) 한 방식보단 stateless(상태를 관리하지 않음) 한 방식으로 구현할 것을 권고한다. 이를 위해 상태 호이스팅이란 방식을 사용하는데 @Composable 함수 호출자에게 자신의 상태 관리 책임을 이동 혹은 부여하는 방식이다.</p>
<pre><code class="language-kotlin">@Composable
fun MyScreen() {
    var text by rememberSaveable { mutableStateOf(&quot;&quot;) }
    MyScreenContent(text = text, onTextChange = { text = it })
}

@Composable
fun MyScreenContent(text: String, onTextChange: (String) -&gt; Unit) {
    Column {
        TextField(value = text, onValueChange = onTextChange)
    }
}</code></pre>
<p>MyScreen 함수의 상태를 MyScreenContent 가 가져다 쓴다. MyScreenContent 는 상태관리 하지 않는다. 이런 것이 stateless 하다는 것이다. 이는 함수 재사용성, 상태 관리 로직 분리, 상태에 대한 SSOT(Single source of truth. 단일 진실 공급원) 등 여러가지 이점을 제공한다.</p>
<blockquote>
<p>State, MutableState 객체는 androidx.compose.runtime.getValue 와 androidx.compose.runtime.setValue 메서드를 갖는다. 이를 통해 값을 지정해줄 수도 있다.</p>
</blockquote>
<p>상태가 변하면 리컴포지션이 일어난다. 여기서 주의할 것은 Snackbar, Toast 다. 일회성 이벤트가 리컴포지션에 의해 실행되지 않을 수도 있다. 이를 해결하기 위해 LaunchedEffect 를 사용한다.</p>
<pre><code class="language-kotlin">@Composable
fun MyScreenContent() {
    val context = LocalContext.current
    LaunchedEffect(anObjectToChange) { // anObjectToChange 가 바뀌면 토스트 호출.
        Toast.makeText(context, &quot;Toast text&quot;, Toast.LENGTH_SHORT).show()
    }
}</code></pre>
<p>anObjectToChange 를 Unit 으로 대체하면 LaucnhedEffect 블록이 한 번만 실행된다.</p>
<h2 id="컴포즈-테마-설정">컴포즈 테마 설정</h2>
<p>컴포즈 탬플릿으로 (Empty Activity) 앱을 만들면 ui.theme 패키지가 자동으로 만들어지고 안에는 Color.kt, Shape.kt, Type.kt, Theme.kt 파일이 존재한다. 이 안에는 전역적으로 사용할 디자인 요소들이 정의되어 있다.</p>
<p>이를 Activity 에 적용하기 위해서는 아래와 같이 진행한다.</p>
<pre><code class="language-kotlin">setContent {
    MyApplicationTheme {
        Surface(color = MaterialTheme.colorScheme.background) { }
    }
}</code></pre>
<p>Surface 함수 내에 들어가는 모든 뷰는 테마에 정의된 요소를 공통적으로 사용할 수 있다.</p>
<pre><code class="language-kotlin">@Composable
fun ItemScreenContent(
    itemScreenState: ItemScreenState
) {
    LazyColumn {
        items(itemScreenState.items) { item -&gt;
            Column(modifier = Modifier.padding(vertical = 4.dp)) {
                OnBackgroundItemText(text = item)
            }
        }
    }
}

@Composable
fun ItemScreen(itemCount: String) {
    ItemScreenContent(itemScreenState =
        ItemScreenState((1..itemCount.toInt()).toList()
            .map {
                stringResource(id = R.string.item_format,
                    formatArgs = arrayOf(&quot;$it&quot;))
            })
    )
}

@Composable
fun ItemCountScreenContent(
    itemCountScreenState: ItemCountScreenState,
    onItemCountChange: (String) -&gt; Unit,
    onButtonClick: () -&gt; Unit,
) {
    Column {
        OnBackgroundTitleText(text = stringResource(id = R.string.enter_number))
        TextField(
            value = itemCountScreenState.itemCount,
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Number),
            onValueChange = onItemCountChange
        )
        PrimaryTextButton(text = stringResource(id = R.string.click_me), onClick = onButtonClick)
    }
}

@Composable
fun ItemCountScreen(onButtonClick: (String) -&gt; Unit) {
    var state by remember {
        mutableStateOf(ItemCountScreenState())
    }
    ItemCountScreenContent(state, {
        state = state.copy(itemCount = it)
    }, {
        onButtonClick(state.itemCount)
    })
}</code></pre>
<p>컴포즈에서 화면 내비게이션 라이브러리를 사용하면 화면 이동을 할 수 있다. URL 기반이다.</p>
<pre><code class="language-kotlin">implementation(libs.androidx.navigation.compose)</code></pre>
<pre><code class="language-kotlin">class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            FirstComposeTheme {
                Surface(color = MaterialTheme.colorScheme.background) {
                    val navController = rememberNavController()
                    Column(modifier = Modifier.padding(16.dp)) {
                        MyApp(navController)
                    }
                }
            }
        }
    }
}

@Composable
fun MyApp(navController: NavHostController) {
    NavHost(navController = navController,
        startDestination = &quot;itemCountScreen&quot;) {
        composable(&quot;itemCountScreen&quot;) {
            ItemCountScreen {
                navController.navigate(&quot;itemScreen/?itemCount=$it&quot;)
            }
        }
        composable(
            &quot;itemScreen/?itemCount={itemCount}&quot;,
            arguments = listOf(navArgument(&quot;itemCount&quot;) {
                type = NavType.StringType
            })
        ) {
            ItemScreen(
                it.arguments?.getString(&quot;itemCount&quot;).orEmpty()
            )
        }
    }
}</code></pre>
<h2 id="기존-프로젝트에-컴포즈-적용">기존 프로젝트에 컴포즈 적용</h2>
<p>이상적인 상황은 액티비티가 적거나 하나인 상태로 모든 화면을 컴포즈로 개발하는 것이다. 기존 프로젝트라면 기존 뷰가 컴포즈 환경에서 빌드될 수 있도록 뷰 계층 구조의 맨 아래에서부터 시작해야 한다.</p>
<p>용이한 마이그레이션을 위해 XML 레이아웃에서 ComposeView 를 사용할 수 있는 기능을 제공한다.</p>
<pre><code class="language-xml">&lt;androidx.compose.ui.platform.ComposeView
  android:id=&quot;@+id/compose_view&quot;
  android:layout_width=&quot;match_parent&quot;
  android:layout_height=&quot;match_parent&quot; /&gt;</code></pre>
<pre><code class="language-kotlin">class MyFragment: Fragment() {
  override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
  ): View? {
    return inflater.inflate(
      R.layout.my_fragment_layout, container).apply {
        findViewById&lt;ComposeView&gt;(R.id.compose_view).apply {
          setViewCompositionStrategy(
            ViewCompositionStragy.DisposeOnViewTreeLifecycleDestroyed)
          setContent {
            MaterialTheme {
              Text(&quot;My Text&quot;)
            }
          }
        }
      }
  }
}</code></pre>
<p>Fragment 가 XML 레이아웃을 인플레이트 하고 있다. ComposeView 가 프래그먼트 소멸 시점에 맞게 컴포즈 콘텐츠도 소멸되도록 명시하고 있다. 이로 인해 메모리 누수를 방지한다. 콘텐츠는 Text 이다.</p>
<p>반대도 가능하다. AndroidView 를 사용하면 된다.</p>
<pre><code class="language-kotlin">@Compose
fun MyCustomisedElement(text: String) {
  AndroidView(factory = { context -&gt;
    TextView(context).apply {
      this.text = text
    }
  })
}</code></pre>
<p>context 로 서비스 시작, 토스트 표시 등이 가능하다.</p>
<p>컴포즈도 기존 명령형 접근법에서 사용하던 여러 라이브러리를 사용할 수 있다.</p>
<ul>
<li>ViewModel 라이브러리는 viewModel() 이라는 @Compose 함수를 통해 ViewModel 에 대한 참조를 얻을 수 있다.<pre><code class="language-kotlin">@Compose
fun MyScreen(viewModel: MyViewModel = viewModel()) {
Text(text = viewModel.myText)
}</code></pre>
</li>
<li>데이터 스트림 라이브러리는 인터넷 혹은 로컬 파일 시스템에서 비동기로 데이터를 로드하려는 경우 완료시점을 UI 에 전달한다. LiveData, RxJava, Coroutine Flow 등이 있다. 위에서 사용한 State 를 바로 앞에서 언급한 세 라이브러리 모두 확장 기능을 통해 만들 수 있다.<pre><code class="language-kotlin">@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
viewModel.myLiveData.observeAsState()?.let {
  myLiveDataText -&gt; Text(text = myLiveDataText)
}
viewModel.myObservable.subscribeAsState()?.let {
  myObservableText -&gt; Text(text = myObservableText)
}
viewModel.myFlow.collectAsState()?.let {
  myFlowText -&gt; Text(text = myFlowText)
}
}</code></pre>
</li>
<li>hilt 는 안드로이드 의존성 주입 라이브러리다. navigation 라이브러리를 사용하지 않는다면 viewModel() 함수를 사용해 ViewModel 참조를 얻어 사용하고, navigation 라이브러리를 사용한다면 hilt 로 navigation 시 viewModel 를 주입해야 할 수 있다. viewModel 대신 hiltViewModel 함수를 써야할 것이다.<pre><code class="language-kotlin">implementation(libs.androidx.hilt.navigation.compose)</code></pre>
<pre><code class="language-xml">[versions]
...
hiltNavigationCompose = &quot;1.0.0&quot;
</code></pre>
</li>
</ul>
<p>[libraries]
...
androidx-hilt-navigation-compose = { group = &quot;androidx.hilt&quot;, name = &quot;hilt-navigation-compose&quot;, version.ref = &quot;hiltNavigationCompose&quot; }</p>
<pre><code></code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[2025 회고]]></title>
            <link>https://velog.io/@sanghwi_back/2025-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@sanghwi_back/2025-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Thu, 25 Dec 2025 03:23:27 GMT</pubDate>
            <description><![CDATA[<p>2025년 크리스마스 당일 솔크에다 헬스장도 쉬는 날, 슬랙 코드스쿼드 워크스페이스에 홍보가 가능하다면 적어달라는 게시글을 보게 되었다. (게시글 작성자의 정보 유출을 최소화하기 위해 가져갈 것을 없게 만들어 보았다)</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/a467cbd9-492f-4999-9363-7ab1a115b45c/image.png" alt=""></p>
<p>우선 게시글에서 부탁한 것부터 하면서 올해를 회고해 보자. 이번 글은 회고이기도 하지만 사실 지금까지 어떻게 개발자로 살아왔고, 앞으로 어떻게 성장할지를 적어나갈 것 같다. 나는 프리랜서이지만 정규직 개발자분들과 딱히 다를 것 같지는 않다.</p>
<ul>
<li>&quot;내가 코드스쿼드에서 얻은 한 가지&quot;라는 주제의 자유로운 한 문장으로 아래 빈칸을 채운다.<pre><code>제가 수료했던 코드스쿼드의 마스터즈 코스에서 2026 멤버를 추가모집하고 있습니다. 
저는 코드스쿼드를 다니면서 
[[개발자로 일만 해서는 절대 알 수 없었을 것들을 깨달았습니다]]. 
관심 있으신 분들은 링크를 통해 자세한 내용을 참고해주세요.</code></pre></li>
</ul>
<p><a href="https://lucas.codesquad.kr/main/course/u/2026-%EB%A7%88%EC%8A%A4%ED%84%B0%EC%A6%88-%EC%B6%94%EA%B0%80-%EB%AA%A8%EC%A7%91-%EC%A0%84%ED%98%95">위 글의 링크</a></p>
<p>누군가에겐 글의 신뢰도가 의심될 수도 있겠다. 하지만 저렇게 글을 적은데엔 이유가 있다.</p>
<p>코드스쿼드 등록을 결심한 그 시절을 생각하기 위해 잠시 2019년 처음 개발자 시작부터 2022년 부트캠프를 하던 그때로 찬찬히 기억을 돌이켜 보는 것이 좋겠다.</p>
<h2 id="개발자가-됨-20192022">개발자가 됨 (2019~2022)</h2>
<p>대학 졸업 후 딱히 개발자를 해야겠다는 생각은 없었다. 그치만 딱히 꿈이나 하고싶은 것이 없는 나에게 개발자가 될 수 있는 방법이 있었다.</p>
<ul>
<li>(무조건) 좋아하는 일을 해야된다는 사회분위기</li>
<li>내일배움카드로 개발자 교육 및 취업알선을 무료로 받을 수 있음</li>
<li>컴퓨터 앞에서 뭔가 하는 것을 좋아함</li>
</ul>
<p>당시엔 절박했다. 교육 듣고 근처 카페에서 3-4시간 더 공부하고 집에 가곤 했다.</p>
<p>열심히 수업도 듣고 취업알선도 받아서 첫번째 SI 회사에 입사했지만 3-4일 후 서류를 돌려받았다. 고용이 취소된 것이다.</p>
<p>다행히도 이 교육엔 나같은 사람을 위한 A/S 제도가 있어서 두 번째 취업알선을 받아 두 번째 회사 면접을 볼 수 있었다.</p>
<p>그 회사는 &quot;컴퓨터를 줄테니 웹 페이지에 CRUD 가능한 게시판을 만드세요. 필요한 거 있으면 얘기하고요.&quot; 라면서 랩톱 하나를 던져주었다. 여러 인프라와 사회경험을 할 수 있었던 좋은 회사였다. 특히 좋은 사람들을 많이 만날 수 있었던 것이 행운이다.</p>
<p>그리고 퇴사를 결심했다. 똑같은 기술에 똑같은 업무가 반복되는 것이 이유였다. &quot;개발자는 맥북&quot; 이라는 말을 듣고 산 리퍼 맥북으로 iOS 개발을 공부해 보았는데 이쪽으로 커리어를 바꾸기로 했다. 정말 부끄러운 얘기지만 난 웹 개발이 더 이상 발전이 없을 것이라고 생각했다.</p>
<p>나의 iOS 개발자 이력서에 관심을 가져준 회사 면접을 본 날 아는 형과 밥을 먹게 되었다. 근데 그 형은 &quot;상한이 정해진 정규직보다 프리랜서를 해봐라&quot; 라고 말을 했다. 난 1년이라도 젊을 때 이걸 해봐야겠다고 생각했다.</p>
<p>포트폴리오를 강화해서 프리랜서 일자리를 알아보기 시작했다. 그리고 놀랍게도 한 대기업 프로젝트 iOS 팀 멤버로 들어가게 되었다.</p>
<p>냉정하게 말하자면 이건 실력이 아니었다.</p>
<ul>
<li>iOS 리더분이 면접 본 사람들의 면접 태도가 아주 불성실했다</li>
<li>포트폴리오 상태가 좋진 못해도 이정도까지 해보려는 사람이 없었다</li>
<li>그래도 초급 개발자 살려는 줘야 한다는 착한 분들이었다</li>
</ul>
<pre><code>여기까지 적으니 나의 가장 큰 장점은 사람복인 것 같다.</code></pre><p>문제는 여기서 시작된다. 공부 열심히 함 -&gt; 삐끗 -&gt; 입사 후 3년 순탄 -&gt; iOS 면접 합격 후 거절 -&gt; 프리랜서 도전 -&gt; 대기업 프로젝트 참여 라는 나름의 탄탄대로가 나를 오만의 늪으로 끌고 간 것 같다.</p>
<p>이후 프로젝트가 끝나며 만났던 iOS 리더분께 전화로 &quot;대기업에 입사 하려면 어떻게 공부하면 좋을까요?&quot; 라고 물어보았을 때 그 분은 이렇게 답을 했다.</p>
<ul>
<li>HTTP 프로토콜 왜 써요?</li>
<li>HTTP 프로토콜의 특징은 뭐가 있어요?</li>
<li>우물대는 거 보니 잘 모르네요? 모바일이든 서버든 이건 기본이에요. 상휘씨는 기본이 안되어 있으니 다시 공부하세요. <code>부트캠프도 좋은 선택이에요</code></li>
</ul>
<p>그때가 2022년 이었다.</p>
<h2 id="코드스쿼드-2022">코드스쿼드 (2022)</h2>
<p>한마디로 참교육 당한 이후로 나는 코드스쿼드에 발을 들이게 되었다. 시험을 보고 들어간다는 점이 왠지 신뢰가 갔다.</p>
<p>코드스쿼드에서 처음 들어갔을 때 다르다는 느낌이 몇 개 있었다.</p>
<ul>
<li>프로그래밍 스킬을 키우기 위해 특정 기술 스택을 중점적으로 가르치지 않는다. 왜 그걸 사용해야 하는지가 더 중요하다.</li>
<li>실제 개발자가 되기 위해 지금 코스의 기간(6개월)은 너무 짧다. 하지만 여기서 길을 닦아 놓는다면 계속 발전해나갈 수 있을 것이다.</li>
<li>협업하는 법을 배워야 한다. 좋은 동료는 시너지를 일으킨다.</li>
</ul>
<p>다른 부트캠프 과정이 이런 점을 강조하는지는 잘 모르겠다. 하지만 확실한 것은 내가 받아온 교육과는 달랐다는 것이다.</p>
<ul>
<li>여러가지 개발방법론을 연습하며 실제로 적용해보고 피드백을 받는 일상이 반복되었다</li>
<li>구현하는 데 사용하는 기술스택을 제한하는 경우는 정말 적다. 내가 정한 기술스택에 대해서는 내가 설명해야 한다.</li>
<li>iOS 마스터분께서는 교육자료를 공유해주며 수업을 진행했는데 (당시는 코로나 시국이라 비대면) 수준이 상당히 높았다. 당시에도 지금도 다 이해가 가진 않는다는 것이다. 그래서 지금도 가끔 본다</li>
<li>처음 몇주간 진행한 CS 수업은 내가 혼자 &quot;Mano의 컴퓨터 시스템 구조&quot;, &quot;Operating Systems: Three Easy Pieces&quot;, &quot;(현재 읽는 중)Computer Systems: A Programmer&#39;s Perspective&quot; 를 공부해야 된다는 당위를 부여해 주었다.<ul>
<li>CS 지식은 단순히 힙한 개발자의 멋진 악세서리가 아니다. 모든 개발자들이 공유하는 지식이고 기술은 이 지식을 기반으로 발전한다. </li>
<li>달리는 말에 올라타기 위해선 말 안장에 발이 올라가야 하는 것이다.</li>
</ul>
</li>
<li>객체를 나누고 테스트 가능한, 수정이 편한 코드란 무엇인지 혼자가 아닌 함께 만들어 나가는 경험을 할 수 있었다.</li>
</ul>
<p>당시 iOS 마스터께서는 정말 코드를 꼼꼼이 보시는 분이었다. 그 때 내가 적은 모든 코드가 다 기억나지 않지만 나의 코드에는 &quot;왜?&quot; 가 없었다. 그 &quot;왜?&quot; 라는 질문에는 지금에서야 조금씩 답을 할 수 있게 되었다.</p>
<p>지금 생각해보면 그것의 반복과 더불어 가끔 외부 강연등이 주를 이뤘던 것 같다. 개인적으로 기억에 남는 것은 &quot;오브젝트&quot;, &quot;객체지향의 사실과 오해&quot; 의 저자인 조영호 님의 강의였다. 지금도 그 실강에서 질문할 것이 있었는데 소심해서 못했던 자신을 자책하는 중이다.</p>
<p>코드스쿼드 수료 후 나빠진 채용시장에서 프리랜서와 정규직을 동시에 도전해 보았을 때 결국 프리랜서 쪽으로 일이 풀리게 되었다. 개발자로 일을 하며 벌이를 다시 할 수 있게 되었다.</p>
<h2 id="수료-후-현재와-미래">수료 후 현재와 미래</h2>
<p>주변 코드스쿼드를 수료한, 혹은 정말 괜찮다고 생각하는 개발자들은 <code>취업이 안되면 이 참에 좀 쉬면서 새로운 기술이나 좀 파봐야 겠다</code> 라고 생각한다.</p>
<p>코드스쿼드에서 배우고 경험한 시야는 깊고 넓기 때문에 지금 일을 하는 개발자라 하더라도 부족함을 항상 느끼기 때문이다.</p>
<p>개인적으로 내가 보는 현재 개발자 인력시장은 다음과 같다.</p>
<ul>
<li>&quot;개발자 연봉 1억&quot; 이라는 이상한 말이 돌던 그 때 IT 개발자 인력시장은 거품이 끼어 있었다. 이건 사실이라고 생각한다.</li>
<li>고용주는 AI 서비스를 활용해 인건비를 줄이려 한다. 하지만 이는 AI 라는 것을 근거로 인건비를 줄이는 시도라고도 생각한다.<ul>
<li>고용주가 AI 와 관련된 전문지식을 말하는 경우를 지금까지 본 적은 없다.</li>
</ul>
</li>
<li>현업 개발자들은 AI 에 대해 불신/맹신 두 가지로 나뉘는 느낌이다. AI 로 무엇이든 할 수 있다거나, 기존 코드베이스를 망칠 것이라 생각한다. 둘 다 위험하다.<ul>
<li>AI 로 무엇이든 할 수는 없다고 생각한다. AI 는 결국 <code>서비스</code> 이며 구독한 고객의 만족도를 신경쓸 수 밖에 없다. 우선순위가 지금 개발하는 프로그램이 아닌 본인이라는 사실을 잊으면 안된다.</li>
<li>AI 가 코드베이스를 망가지게 놔두면 안되지만 AI 의 생산성을 무시하면 안된다. 그 생산성을 최대한 활용할 수 있는 방안은 본인이 만들어야 한다. </li>
</ul>
</li>
<li>본질은 바뀌지 않았다. 개발자는 <code>특정 문제에 IT 와 관련한 솔루션을 자신있게 제시할 수 있어야 한다</code>.<ul>
<li>예전에는 신입들에게 그런 걸 묻지 않았지만 지금은 그런 걸 따지지 않는다.</li>
</ul>
</li>
<li>시니어 혹은 미들급이 주니어 개발자를 이끌고 간다는 개념이 희박해졌다.</li>
</ul>
<p>코드스쿼드는 이런 상황에서 개발자로 살아남는 방법을 배울 수 있을 것이다. 위에서 서술했듯 초보자 혼자 살아남기 굉장히 힘든 상황이기 때문이다.</p>
<p>이런 식의 공포감을 조성하는 것을 좋아하지는 않지만, 앞에 주의 표지판이 붙어있지 않으면 주의하라고 알려주는 편이다.</p>
<hr>
<p>수료부터 올해까지 개발자로서 어떻게 성장하는 것이 좋을지 일과 병행하며 계속 찾아다녔다.</p>
<ul>
<li>CS부터 시작할까? - 전념해봄 - 너무 오래 걸려서 포기</li>
<li>기술스택을 쌓아볼까? - 전념해봄 - 앞에 놓고 온 CS 가 맘이 쓰임</li>
<li>개인 프로젝트를 열심히 해볼까? - 여유가 있으면 하지만 일이 바쁘면 못함</li>
<li>오픈소스 코드를 뜯어보며 기여를 할까? - 시도도 못해봄</li>
<li>AI 를 들입다 파야 하나? - 사무실에서 하고 있음</li>
<li>책을 수십권 읽어야 하나? - 가끔 읽음</li>
</ul>
<p>그리고 2026년 목표를 세웠다.</p>
<ul>
<li>프로그램을 개발환경 관점에서 아키텍처 관점으로 돌린다.<ul>
<li>비즈니스 로직을 KMP 를 통해 모듈화하고 UI 부분을 네이티브로 모듈화하여 생산성을 높인다.</li>
<li>iOS 개발자가 아닌 모바일 혹은 플랫폼 개발자로 커리어 성장을 한다.</li>
<li>언어도 Swift 에서 Swift/Kotlin/Javascript(TypeScript) 로 확장한다.</li>
</ul>
</li>
<li>테스트를 적극 도입한다. 이제 물러설 수 없다.</li>
<li>CS 공부는 일주일에 몇시간 안해도 좋으니 꾸준히 한다.</li>
<li>일은 할 수 있을 때 무조건 잡아서 한다.</li>
<li>AI 의 생산성을 활용하는 연습을 한다.</li>
<li>휴식과 운동에도 진심이어야 한다. 주말 중 하루는 쉬고 일주일에 최소 3일은 헬스를 간다.</li>
</ul>
<p>쓰고 보니 다시 정신이 아득해진다.</p>
<h2 id="마치며">마치며</h2>
<p>위의 글 중 일부를 다시 가져와 보겠다.</p>
<ul>
<li>&quot;내가 코드스쿼드에서 얻은 한 가지&quot;라는 주제의 자유로운 한 문장으로 아래 빈칸을 채운다.<pre><code>제가 수료했던 코드스쿼드의 마스터즈 코스에서 2026 멤버를 추가모집하고 있습니다. 
저는 코드스쿼드를 다니면서 
[[개발자로 일만 해서는 절대 알 수 없었을 것들을 깨달았습니다]]. 
관심 있으신 분들은 링크를 통해 자세한 내용을 참고해주세요.</code></pre></li>
</ul>
<p><a href="https://lucas.codesquad.kr/main/course/u/2026-%EB%A7%88%EC%8A%A4%ED%84%B0%EC%A6%88-%EC%B6%94%EA%B0%80-%EB%AA%A8%EC%A7%91-%EC%A0%84%ED%98%95">위 글의 링크</a></p>
<p>내가 지금까지 한 것은 여러가지 시도를 해 보았다는 것이다. 만약 회사생활만 열심히 했다면 이런 시도를 해볼 수나 있었을까? 했다면 방향은 잘 잡을 수 있었을까?</p>
<p>이런 질문에 나 혼자만의 힘으로는 그럴 수 없다라고 할 수 있겠다. 이런 질문을 가진 개발자가 있다면 여러가지 답이 있겠지만 부트캠프도 하나의 답이 되겠고 그 중 <strong>코드스쿼드</strong>가 좋은 답이 될 수 있다고 확신한다.</p>
<p>올해도 솔크를 맞이하며 코틀린 책을 보다가 영화 한편 보는 것으로 하루를 마무리해야겠다.</p>
<p>Merry Christmas! 🎅</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android 공부(8)]]></title>
            <link>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%808</link>
            <guid>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%808</guid>
            <pubDate>Wed, 05 Nov 2025 07:37:29 GMT</pubDate>
            <description><![CDATA[<p>앱 백그라운드 작업을 관리하고 추가 작업을 하는 방법에 대해 알아본다.</p>
<p>모바일 환경에서는 온고잉 백그라운드 작업이 흔하다. 온고잉 백그라운드 작업이란 앱이 활성화되지 않은 경우에도 실행하는 작업으로 파일 다운로드, 리소스 정리, 음악 재생, 사용자 위치 추적 등이 이런 작업의 예이다.</p>
<p>이런 작업을 수행하기 위해선 서비스, JobScheduler, 파이어베이스의 JobDispatcher, AlarmManager 등을 사용할 수 있다. WorkManager 는 API 버전에 따라 백그라운드 실행 메커니즘을 선택하는 방식을 추상화하였다. 그러나 음악 재생, 실행중인 앱의 위치 추적에는 Foreground service 를 사용한다.</p>
<p>서비스는 앱이 실행 중이지 않을 때도 백그라운드에서 실행되도록 설계된 앱 구성 요소다. 서비스는 사용자 인터페이스를 갖지 않는다. 포어 그라운드 서비스만 예외적으로 사용자 인터페이스를 갖는다. 알람을 제외하고는 유저 인터페이스에 직접적으로 영향을 주지 않는다.</p>
<p>이를 토대로 아래의 내용을 다루고자 한다.</p>
<ul>
<li>WorkManager 를 사용한 백그라운드 작업 시작</li>
<li>사용자에게 알림을 주는 백그라운드 작업: 포어그라운드 서비스 사용</li>
</ul>
<h2 id="workmanager-를-사용한-백그라운드-작업-시작">WorkManager 를 사용한 백그라운드 작업 시작</h2>
<p>WorkManager, Foreground Service 어떤 것을 선택해야 할까? 이를 선택하기 위해서는 실시간으로 상태를 추적하는지 물어봐야 한다.</p>
<ul>
<li>추적 필요 : Foreground Service</li>
<li>추적 불필요(오래걸림) : WorkManager</li>
</ul>
<blockquote>
<p>WorkManager 2.3.0-alpha02 버전부터는 <code>setForegroundAsync(ForegroundInfo)</code> 를 이용해 WorkManager 싱글톤을 사용해서 Foreground Service 를 시작할 수 있다. 기능은 제한적이지만 작업과 동시에 알림을 지정할 수 있어 알아두면 편하다.</p>
</blockquote>
<p>WorkManager 를 사용하려면 4가지 클래스를 알아야 한다.</p>
<ul>
<li>WorkManager : 제공된 인자와 제약조건(인터넷 연결, 충전 등)을 기반으로 작업을 수신하고 대기열에 추가한다.</li>
<li>Worker : 수행해야 할 작업을 래핑한다. doWork() 함수 하나를 가지며, 이 함수를 재정의해 백그라운드 작업 코드를 구현한다. 이 함수는 백그라운드 스레드에서 실행된다.</li>
<li>WorkRequest : Worker 클래스를 인자와 제약 조건에 바인딩한다. 두 종류의 WorkRequest 가 있으며, 작업을 한 번 실행하는 OneTimeWorkRequest, 일정한 간격으로 작업을 예약하는 PeriodicWorkRequest 가 있다.</li>
<li>ListenableWorker.Result : 실행된 작업의 결과를 가진다. 결과는 Success, Failure, Retry 중 하나다.</li>
</ul>
<p>WorkManager 를 사용하기 전에 먼저 앱의 종속성을 추가한다.</p>
<pre><code class="language-xml">// build.gradle.kts
implementation(libs.androidx.work.runtime)

// libs.versions.toml
[versions]
workRuntime = &quot;2.8.0&quot;
[libraries]
androidx-work-runtime = { group = &quot;androidx.work&quot;, name = &quot;work-runtime&quot;, version.ref = &quot;workRuntime&quot; }</code></pre>
<p>Worker 를 생성해서 작업을 정의해보자.</p>
<pre><code class="language-kotlin">class CatStretchingWorker(
  context: Context, workerParameters: WorkerParameters
) : Worker(context, workerParameters) {
  override fun doWork(): Result {
    val catAgentId = inputData.getString(INPUT_DATA_CAT_AGENT_ID)
    Thread.sleep(3000L)
    val outputData = Data.Builder()
      .putString(OUTPUT_DATA_CAT_AGENT_ID, catAgentId).build()
    return Result.success(outputData)
  }
  companion object {
    const val INPUT_DATA_CAT_AGENT_ID = &quot;id&quot;
    const val OUTPUT_DATA_CAT_AGENT_ID = &quot;id&quot;
  }
}</code></pre>
<p>doWork 함수를 오버라이드 하고 입력 데이터를 불러온 다음 3초 대기 후 success 를 데이터와 함께 반환한다.</p>
<p>Worker 를 WorkManager 를 이용해 연결하는 것은 아래와 같이 수행한다.</p>
<pre><code class="language-kotlin">val networkConstraints = Constraints.Builder()
  .setRequiredNetworkType(NetworkType.CONNECTED)
  .build()
val catStretchingInputData = Data.Builder()
  .putString(CatStretchingWorker.INPUT_DATA_CAT_AGENT_ID, &quot;catAgentId&quot;)
  .build()
val catStretchingRequest = OneTimeWorkRequest
  .Builder(CatStretchingWorker::class.java)
  .setConstraints(networkConstraints)
  .setInputData(catStretchingInputData)
  .build()

// ...

WorkManager.getInstance(this)
  .beginWith(catStretchingRequest)
  .then(catFurGroomingRequest)
  .then(catLitterBoxSittingRequest)
  .then(catSuitUpRequest)
  .enqueue()</code></pre>
<p>Constraint 는 작업 실행 전 인터넷 연결이 필요하다는 제약 조건이다. 입력 데이터 정의 후 OneTimeWorkRequest 로 제약조건과 입력 데이터를 Worker 클래스에 바인딩한다.</p>
<p>각 Request 인스턴스는 고유 식별자가 있고, WorkManager 는 Request 에 대한 LiveData 속성을 통해 진행 상황 추적이 가능하도록 해준다.</p>
<pre><code class="language-kotlin">workManager.getWorkInfoByIdLiveData(
  catStretchingRequest.id
).observe(this) { info -&gt;
  if (info.state.isFinished) { doSomething() }
}</code></pre>
<p>작업 상태는 다음과 같이 나뉘어져 있다.</p>
<ul>
<li>BLOCKED : 요청 체인이 있고 이 작업이 체인에서 다음 차례가 아닌 경우</li>
<li>ENQUEUED : 요청 체인이 있고 이 작업이 다음 차례인 경우</li>
<li>RUNNING : doWork 에서 작업 실행 중</li>
<li>SUCCEEDED : 작업 완료</li>
<li>CANCELED : 작업 취소</li>
<li>FAILED : 작업 실패</li>
</ul>
<p>Worker 의 결과 값으로 Result.retry 가 있는데 이 경우 대기열에 작업을 다시 넣도록 WorkManager 클래스에 지시할 수 있다. 작업 재시작 정책은 WorkRequest Builder 에서 설정한 backoff 기준으로 정의한다.</p>
<h2 id="사용자가-인지할-수-있는-백그라운드-작업-포어그라운드-서비스">사용자가 인지할 수 있는 백그라운드 작업: 포어그라운드 서비스</h2>
<p>현재 위치를 지속적으로 폴링하여 스티키 알림을 새 위치로 업데이트한다.</p>
<p>포어그라운드 서비스는 알림과 연결돼 있고, 기본 안드로이드 서비스는 사용자가 볼 수 있는 표시가 없이 백그라운드에서 실행된다. 포어그라운드 서비스에서 ANR(Application Not Responding) 메시지를 표시하는 시간 내에 포어그라운드 서비스가 알림과 연결돼 있지 않으면 서비스가 중지되고 앱이 응답하지 않는 것으로 표시한다.</p>
<p>서비스를 빨리 알림과 연결시키려면 서비스의 onCreate 를 사용한다.</p>
<pre><code class="language-kotlin">private fun onCreate() {
  val channelId = if (Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.0) {
    val newCahnnelId = &quot;ChannelId&quot;
    val channelName = &quot;My Background Service&quot;
    val channel = NotificationChannel(
      newChannelId,
      channelName,
      NotificationManager.IMPORTANT_DEFAULT)
    val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    service.createNotificationChannel(channel)
    newChannelId
  } else { &quot;&quot; }

  val flag = if (Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.S) FLAG_IMMUTABLE else 0
  val pendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent -&gt; 
    PendingIntent.getActivity(this, 0, notificationIntent, flag)
  }

  val notification = NotificationCompat.Builder(this, channelId)
    .setContentTitle(&quot;Content Title&quot;)
    .setContentText(&quot;Content text&quot;)
    .setSmallIcon(R.drawable.notification_icon)
    .setContentIntent(pendingIntent)
    .setTicker(&quot;Ticker message&quot;).build()

  startForeground(NOTIFICATION_ID, notificationBuilder.build())
}</code></pre>
<p>채널 ID 정의는 오레오 이상에서만 필요하다. pendingIntent 는 액티비티를 실행하는 Intent 를 래핑해 만들 수 있다. 채널 ID, pendingIntent 를 통해 알림을 만들 수 있다.</p>
<p>포어그라운드에서 서비스를 시작하고 알림을 표시하기 위해 startForeground 함수를 호출한다.</p>
<p>현재 위 서비스는 알림 표시 외에 아무것도 수행하지 않지만 onStartCommand(Intent?, Int, Int) 를 오버라이드하면 된다. 이 함수는 UI 스레드에서 호출된다. 그러므로 긴 시간 호출되는 함수를 수행하면 사용자가 불편을 겪을 수 있다. 대신 HandlerThread 를 사용해 새 핸들러를 생성하고 작업을 해당 핸들러에 전달한다. 이러면 핸들러는 작업을 받을 때까지 계속 돈다.</p>
<p>완료된 후 다른 작업들을 수행해야 하면 완료를 기다리는 대상(예: 액티비티)에게 작업이 완료되었음을 알린 후 포어그라운드에서 실행을 중지시키고 서비스가 다시 필요하지 않다면 서비스를 중지시킨다.</p>
<p>앱이 서비스와 통신하기 위한 방법에는 바인딩, 브로드캐스트 리시버, 버스 아키텍처, 리절트 리시버 등 다양한 방법이 있다.</p>
<p>LiveData 는 companion object 내에서 정의한다.</p>
<pre><code class="language-kotlin">companion object {
  private val mutableWorkCompletion = MutableLiveData&lt;String&gt;()
  val workCompletion: LiveData&lt;String&gt; = mutableWorkCompletion
}</code></pre>
<p>MutableLiveData 인스턴스를 LiveData 인터페이스 뒤에 숨겼다. 이렇게 하면 옵저버는 LiveData 를 읽기 전용으로만 사용한다. mutableWorkCompletion 을 통해 완료 상태를 알릴 수 있고, LiveData 인스턴스에서만 값을 할당할 수 있다.</p>
<p>작업 완료 후 메인스레드로 전환할 메인 Looper 핸들러를 추가한다.</p>
<p>서비스 작업을 수행하기 전 AndroidManifest.xml 에 service 태그를 추가했는지 확인한다.</p>
<pre><code class="language-xml">&lt;application ...&gt;
  &lt;service android:name=&quot;ForegroundService&quot; /&gt;
&lt;/application&gt;</code></pre>
<p>서비스 수행을 위한 Intent 도 생성한다.</p>
<pre><code class="language-kotlin">val serviceIntent = Intent(this, ForegroundService::class.java).apply {
  putExtra(&quot;ExtraData&quot;, &quot;Extra value&quot;)
}

// 서비스 시작
ContextCompat.startForegroundService(this, serviceIntent)</code></pre>
<hr>
<p>브로드캐스트 리시버만 간단히 살펴보자. 브로드캐스트 리시버를 사용하면 앱이 발행-구독 디자인 패턴과 유사한 패턴을 사용해 메시지를 보내고 받을 수 있다.</p>
<p>시스템은 디바이스 부팅, 충전 시작과 같은 이벤트를 브로드캐스트한다. 앱도 이벤트를 브로드캐스트할 수 있다. 브로드캐스팅은 서비스와 통신하기 위한 일반적인 방법이었지만 LocalBroadcastManager 클래스가 앱 전체 이벤트 버스로 사용돼 안티패턴을 유도해서 더 이상 사용하지 않는다. 하지만 전역 이벤트 핸들링엔 유용하다.</p>
<pre><code class="language-kotlin">class ToastBoradcastReceiver: BoradcastReceiver() {
  override fun onReceive(context: Context, intent: Intent) {
    StringBuilder().apply {
      append(&quot;Action: ${intent.action}\n&quot;)
      append(&quot;URI: ${intent.toUri(Intent.URI_INTENT_SCHEME)}\n&quot;)
      toString().let { eventText -&gt;
        Toast.makeText(context, eventText, Toast.LENGTH_LONG).show()
      }  
    }
  }
}</code></pre>
<p>Manifest.xml 파일을 통해 리시버를 등록할 수 있다.</p>
<pre><code class="language-xml">&lt;receiver android:name=&quot;.ToastBroadcastReceiver&quot; android:exported=&quot;true&quot;&gt;
    &lt;intetn-filter&gt;
      &lt;action android:name=&quot;android.intent.action.ACTION_POWER_CONNECTED&quot; /&gt;
    &lt;/intent-filter&gt;
&lt;/receiver&gt;</code></pre>
<p>android:exported 가 true 면 이 리시버는 앱 외부에서 메시지를 수신할 수 있다고 표시하는 것이다.</p>
<p>코드로도 가능하다.</p>
<pre><code class="language-kotlin">val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
  .apply { addAction(Intent.ACTION_POWER_CONNECTED) }
registerReceiver(ToastBroadcastReceiver(), filter)</code></pre>
<p>리시버는 컨텍스트가 유효한 한 계속 사용 가능하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android 공부 (7)]]></title>
            <link>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-7</link>
            <guid>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-7</guid>
            <pubDate>Mon, 03 Nov 2025 05:36:55 GMT</pubDate>
            <description><![CDATA[<p>안드로이드는 특이하게 앱이 앱 권환을 OS 에 요청하는 방식을 취한다. 예제로는 구글맵스 API 를 사용해 지도를 추가하고 상호작용하는 법을 통해 아래의 내용을 학습한다.</p>
<ul>
<li>사용자 권한 요청</li>
<li>사용자 위치 지도 표시</li>
<li>지도 클릭과 커스텀 마커</li>
</ul>
<p>앱이 요청할 수 있는 권한에는 기기 위치 획득, 연락처 접근, 카메라 실행, 블루투스 연결 등이 있다.</p>
<p>앱이 가진 권한에 따라 작업을 정의하는 법을 배워보자.</p>
<h2 id="사용자-권한-요청">사용자 권한 요청</h2>
<p>안드로이드 6 마시멜로 이상에서 실행 중이고 타겟 API 가 23 이상일 경우 일부 기능에 대해서는 런타임에 권한에 대한 경고를 표시한 뒤 사용하도록 할 수 있다.</p>
<p>이런 권한 요청에는 앱을 개발할 때 AndroidManifest.xml 수정이 필수이다. 예를 들어 SEND_SMS 권한을 포함하려면 아래와 같이 한다.</p>
<pre><code class="language-xml">&lt;manifest xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;&gt;
  &lt;uses-feature
        android:name=&quot;android.hardware.telephony&quot;
        android:required=&quot;false&quot; /&gt;
  &lt;uses-permission android:name=&quot;android.permission.SEND_SMS&quot;/&gt;
&lt;/manifest&gt;</code></pre>
<p>일반적으로 안전한 권한(구글에서 정한)은 자동으로 허용된다. 그러나 위험한 권한은 사용자로부터 승인을 받도록 되어 있다. 사용자 승인 없이 기능을 수행하려고 하면 앱이 크래시 될 수도 있다.</p>
<p>그러므로 권한 요청 전 이미 승인했는지 여부를 확인하고 Dialog 창을 호출한다. <code>shouldShowRequestPermissionRationale(Activity, String)</code> 이라는 함수로 이런 작업이 가능하다.</p>
<p>권한 요청을 위해서는 build.gradle.kts , libs.versions.xml 에 의존성 주입을 해야 한다.</p>
<pre><code class="language-xml">// build.gradle.kts
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.fragment.ktx)

// libs.versions.toml
android-acitivy-ktx = { group = &quot;androidx.activity&quot;, name = &quot;activity-ktx&quot;, version.ref = &quot;activityKtx&quot; }
android-fragment-ktx = { group = &quot;androidx.fragment&quot;, name = &quot;fragment-ktx&quot;, version.ref = &quot;fragmentKtx&quot; }</code></pre>
<p>다음은 권한 요청을 하는 코드이다.</p>
<pre><code class="language-kotlin">class MainActivity: AppCompatActivity() {
  private lateinit var requestPermissionLauncher: ActivityResultLauncheer&lt;String&gt;
  override fun onCreate() {
      // ...
    requestPermissionLauncher = registerForActivityResult(RequestPermission()) { isGranted -&gt;
      if (isGranted) {
        // ...
      } else {
        // ...
      }
    }
  }
}</code></pre>
<p>권한 요청을 위한 런처를 등록하고 있다. 참조를 유지하는 이유는 결과값을 사용하기 위해서이다. 액티비티가 다시 시작되면 권한 상태값을 이용해 다른 작업을 수행할 수 있다.</p>
<pre><code class="language-kotlin">override fun onResume() {
  when {
    hasLocationPermission() -&gt; getLastLocation()
    shouldShowRequestPermissionRationale(this, ACESS_FINE_LOCATION) -&gt; {
      showPermissionRationale {
        requestPermissionLauncher.launch(ACCESS_FINE_LOCATION)
      } // 권한 설명 필요한 경우
    }
    else -&gt; requestPermissionLauncher.launch(ACCESS_FINE_LOCATION) // 권한 설명 필요없는 경우
  }
}

private fun hasLocationPermission() = checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PERMISSION_GRANTED

private fun showPermissionRationale(
  positiveAction: () -&gt; Unit) {
    AlertDialog.Builder(this)
      .setTitle(&quot;Location Permission&quot;)
      .setMessage(&quot;We need your permission to find your current position&quot;)
      .setPositiveButton(android.R.string.ok) { _, _ -&gt; positiveAction() }
      .setNegativeButton(android.R.string.cancel) { dialog, _ -&gt; dialog.dismiss() }
      .create().show()
  }
}</code></pre>
<p><code>hasLocationPermission()</code> 을 이용해 위치 권한을 확인하고 있다. <code>checkSelfPermission(Context, String)</code> 을 사용하는 로컬 함수이다. <code>showPermissionRationale</code> 함수는 사용자에게 권한이 필요한 이유를 설명하는 대화상자를 표시한다.</p>
<p><code>requestPermissionLauncher</code> 의 결과값인 <code>isGranted</code> 에 따라 위의 코드에서 결과값을 처리할 수 있다.</p>
<pre><code class="language-kotlin">registerForActivityResult(RequestPermission()) { isGranted -&gt;
  if (isGranted) { getLastLocation() }
  else {
    showPermissionRationale {
      requestPermissionLauncher.launch(ACCESS_FINE_LOCATION)
    }
  }
}</code></pre>
<h2 id="사용자-위치-지도-표시">사용자 위치 지도 표시</h2>
<p>사용자가 위치 접근 권한을 허용하면 사용자의 기기에 가장 마지막으로 기록된 위치를 얻을 수 있다.</p>
<p>구글 플레이 로케이션 서비스(Google Play Location service) 를 통해 <code>FusedLocationProviderClient</code> 클래스를 제공한다. Fused Location Provider API 를 제공하는 이 클래스는 구글 플레이 로케이션 서비스 라이브러리 추가가 필요하다.</p>
<pre><code class="language-xml">// build.gradle.kts
implementation(libs.gms.play.services.location)

// libs.versions.toml
playServicesLocation = &quot;21.0.1&quot;
gms-play-services-location = { group = &quot;com.google.android.gms&quot;, name = &quot;play-services-location&quot;, version.ref = &quot;playServicesLocation&quot; }</code></pre>
<p><code>FusedLocationProviderClient</code> 인스턴스는 <code>LocationServices.getFusedLocationProviderClient(this@MainActivity)</code> 를 호출하면 된다.</p>
<p>이 때의 작업들은 이미 사용자로부터 위치 권한을 획득했다고 가정한다.</p>
<p>마지막 위치는 인스턴스의 <code>lastLocation</code> 이라는 <code>Task&lt;Location&gt;</code> 이다. 즉, 비동기로 마지막 위치를 가져오며 실패 상황의 리스너도 추가 가능하다. Location 인스턴스는 위/경도 데이터를 담고 있다.</p>
<pre><code class="language-kotlin">.addOnSuccessListener { location: Location? -&gt; }</code></pre>
<p>가끔 null 인 경우가 있는데 사용자가 위치 호출 중 앱의 위치 서비스를 비활성화하는 경우가 이에 해당한다.</p>
<p>지도 자체는 프래그먼트이며 <code>SupportMapFragment</code> 클래스를 사용한다.</p>
<p>지도 배치는 <code>GoogleMap</code> 인스턴스의 <code>moveCamera</code> 에다가 <code>CameraUpdateFactory.newLatLng(LatLng)</code> 를 반영하고, 지도에서 줌을 수행하려면 <code>newLatLngZoom(LatLng, Float)</code> 을 반영하면 된다. 마커는 <code>MarkerOptions</code> 를 사용한다.</p>
<h2 id="지도-클릭과-커스텀-마커">지도 클릭과 커스텀 마커</h2>
<p>마커를 더 다양한 방법으로 다루어보자.</p>
<p>지도 클릭을 감지하기 위한 리스너를 추가하면 된다.</p>
<pre><code class="language-kotlin">override fun onMapReady(googleMap: GoogleMap) {
  mMap = googleMap.apply {
    setOnMapClickListener { latlng -&gt;
      addMarkerAtLocation(latlng, &quot;Deploy here&quot;)
    }
  }
}</code></pre>
<p>기존에 추가된 마커를 다루기 위해서는 우선 <code>GoogleMap.addMarker(MarkerOptions)</code> 의 반환값을 참조 유지하도록 로컬 변수를 만들어야 할 것이다. 마커의 위치를 변환하는 데엔 Marker 의 position 세터가 필요하다.</p>
<p>MarkerOptions() 인스턴스의 BitmapDescriptor 를 이용해 마커를 커스텀 아이콘으로 대체 가능하다. BitmapDescriptor 는 BitmapDescriptorFactory 를 사용한다.</p>
<p>마커를 만들기 위해 벡터 드로어블을 만든다. New -&gt; Vector Asset 을 선택하면 만들 수 있다. 만들어진 애셋은 <code>ContextCompat.getDrawable(Context, Int)</code> 를 호출하여 참조를 받아올 수 있다. Drawable 인스턴스는 그려져야 할 영역도 정의해야 하는데 <code>Drawable.setBound(0,0,drawable.intrinsicWidth,drawable.intrinsicHeight)</code> 를 호출한다. 이외에 틴트 설정, 비트맵 생성, 캔버스 생성은 아래와 같이 정리할 수 있다.</p>
<pre><code class="language-kotlin">private fun getBitmapDescriptorFromVector(@DrawableRes vectorDrawableResourceId: Int): BitmapDescriptor? {
  var bitmap = ContextCompat.getDrawable(this, vectorDrawableResourceId)?.let { vectorDrawable -&gt;
    vectorDrawable.setBounds(0, 0, vectorDrawable.intrinsicWidth, vectorDrawable.intrinsicHeight)
    // DrawableCompat 으로 감싸기
    val drawableWithTint = DrawableCompat.wrap(vectorDrawable)
    // 틴트 색상 설정
    DrawableCompat.setTint(drawableWithTint, Color.RED)
    // 비트맵 생성
    val bitmap = Bitmap.createBitmap(
      vectorDrawable.intrinsicWidth,
      vectorDrawable.intrinsicHeight,
      Bitmap.Config.ARGB_8888
    )
    // Canvas 생성
    val canvas = Canvas(bitmap)
    // 드로어블 그리기
    drawableWithTint.draw(canvas)
  }

  return BitmapDescriptorFactory.fromBitmap(bitmap)
    .also { bitmap?.recycle() }
}</code></pre>
<p>이렇게 BitMapDescriptorFactory 를 생성하여 인스턴스를 얻을 수 있다. 비트맵을 리사이클하는 것을 잊으면 안되는데 메모리 누수를 방지하기 위함이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android 공부 (5)]]></title>
            <link>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-5</link>
            <guid>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-5</guid>
            <pubDate>Sat, 18 Oct 2025 11:53:00 GMT</pubDate>
            <description><![CDATA[<p>현실 개발업무 시 중요한 원격 서버의 응답을 다루는 방법을 배운다. <strong>Retrofit</strong> 을 사용해 네트워크 엔드포인트에서 데이터를 가져오고, <strong>Moshi</strong> 를 이용해 JSON 페이로드를 코틀린 데이터 객체로 파싱하고 <strong>Glide</strong> 를 사용해 ImageView 에 이미지를 로드하는 방법을 알 수 있게 된다.</p>
<p>여기서 다룰 내용은 아래와 같다.</p>
<ul>
<li>REST, API, JSON, XML 소개</li>
<li>네트워크 엔드포인트에서 데이터 가져오기</li>
<li>JSON 응답 파싱</li>
<li>원격 URL에서 이미지 로딩</li>
</ul>
<h2 id="rest-api-json-xml-소개">REST, API, JSON, XML 소개</h2>
<p>REST 는 서버에서 데이터를 가져오는 아키텍처 중 하나이다. 6가지 제약조건을 갖는데 클라이언트-서버 구조, 무상태, 캐싱, 계층 구조, 코드 온 디맨드, 인터페이스 일관성이다.</p>
<p>이를 웹 서비스의 API(애플리케이션 프로그래밍 인터페이스, Application Programming Interface) 에 적용하면 HTTP 기반의 RESTful-API 가 된다. HTTP 인터넷 기반 데이터 통신의 기초 프로토콜이다.</p>
<p>RESTful-API 는 표준 HTTP 메서드인 GET, POST, PUT, DELETE, PATCH 를 이용해 데이터를 가져오고 반환한다.</p>
<p>HTTP 메서드를 실행하기 위해서는 자바에서 제공하는 HttpURLConnection 클래스를 사용할 수 있다. gzipping, Redirection, Retry, Async call 등은 OkHttp 같은 라이브러리를 통해 구현할 수 있다. 현재는 산업표준인 Retrofit 이 추천된다. 타입 안정성이 높다.</p>
<p>대부분의 경우 데이터는 JSON 으로 표현한다. XML 도 자주 사용되는데 데이터 크기가 더 크다. JSON 페이로드는 대부분 문자열이고 내장된 org.json 패키지와 GSON, Jackson, Moshi 를 조합해 사용할 수 있다.</p>
<p>마지막으로 웹에서 이미지를 효율적으로 로드하는 방법인 Moshi 를 살펴보자.</p>
<h2 id="네트워크-엔드포인트에서-데이터-가져오기">네트워크 엔드포인트에서 데이터 가져오기</h2>
<p>우선 AndroidManifest.xml 파일의 Application 태그 바로 앞에 다음 코드를 추가한다.</p>
<pre><code class="language-xml">&lt;uses-permission android:name=&quot;android.permission.INTERNET&quot; /&gt;</code></pre>
<p>그 다음 Retrofit 의존성 추가를 한다.</p>
<pre><code class="language-kotlin">// https://mvnrepository.com/artifact/com.squareup.retrofit2/retrofit
implementation(&quot;com.squareup.retrofit2:retrofit:3.0.0&quot;)</code></pre>
<p>Retrofit 은 엔드포인트에 대한 엑세스 스펙을 interface 로 정의한다.</p>
<pre><code class="language-kotlin">interface TheCatApiService {
    @GET(&quot;images/search&quot;)
    fun searchImages(
        @Query(&quot;limit&quot;) limit: Int,
        @Query(&quot;size&quot;) format: String
    ) : Call&lt;String&gt;
}</code></pre>
<ul>
<li>GET(@GET) 메소드를 사용해 HTTP 통신을 수행한다.</li>
<li>컨텍스트 패스는 images/search, 함수 이름은 searchImages 이다. 둘을 연관지어 적절한 이름을 짓는다.</li>
<li>HTTP Query (@Query) 로 파라미터를 지정한다.<ul>
<li>이외에도 @Path 를 이용해 url path 를 사용할 수도 있다.</li>
<li>@Header, @Headers, @HeaderMap 으로 헤더를 사용할 수 있다.</li>
<li>@Body 는 POST, PUT 에서 본문 내용을 전달한다.</li>
</ul>
</li>
<li>반환 타입인 Call 인터페이스는 네트워크 요청을 동기 혹은 비동기적으로 실행하는데 사용한다.<ul>
<li>코루틴에서는 suspend 로 대체 가능</li>
</ul>
</li>
</ul>
<p>이제 Retrofit 으로 실제 코드를 작성한다. 반환된 값을 String 으로 변환하는 방식을 넣어야 한다. 이를 위해 ScalarsConverterFactory 를 사용한다.</p>
<pre><code class="language-kotlin">var retrofit = Retrofit.Builder()
    .baseUrl(&quot;https://api.thecatapi.com/v1/&quot;).build()
var theCatApiService = retrofit
    .create(TheCatApiService::class.java)</code></pre>
<pre><code class="language-kotlin">var retrofit = Retrofit.Builder()
    .baseUrl(&quot;https://api.thecatapi.com/v1/&quot;)
    .addConverterFactory(ScalarsConverterFactory.create())
    .build()

val theCatApiService by lazy { retrofit.create(TheCatApiService::class.java) }</code></pre>
<p>이런 Retrofit 코드는 클린 아키텍처에 따르면 레포지토리에 위치해야 한다. 이를 통해 테스트 용이성을 추가해야 한다. 레포지토리는 데이터 소스를 갖는데 네트워크용 데이터 소스가 따로 있다. 여기에 네트워크 호출을 구현해야 한다. 그리고 이 코드를 ViewModel 이 호출하는 것이다.</p>
<h2 id="json-응답-파싱">JSON 응답 파싱</h2>
<p>API 로부터 가져온 JSON 을 사용하려면 JSON 페이로드를 파싱해주는 구글의 GSON, Square 의 Moshi 가 주로 쓰인다. 이런 JSON 라이브러리는 data class를 JSON 문자열로 변환하거나(직렬화) JSON 문자열을 data class로 변환한다(역직렬화).</p>
<p>MvnRepository 사이트에서 retrofit converter moshi 로 검색하여 라이브러리를 찾아 추가한다. 그리고 JSON 문자열을 data class 로 변환하기 위해 data class 를 생성한다. 주로 이름은 접미사 Data 혹은 Entity 를 붙이는 것이 관례이다.</p>
<pre><code class="language-kotlin">import com.squareup.moshi.Json

data class ImageResultData(
    @Json(name = &quot;url&quot;) val imageUrl: String,
    val id: String,
    val width: Int,
    val height: Int
)</code></pre>
<p>프로퍼티에 정의하지 않는 데이터는 무시된다. 위의 코드에 @Json 은 실제 JSON 이름을 다르게 쓰고 싶을 때 사용할 수 있는 어노테이션이다.</p>
<p>이제 새로운 Retrofit 인스턴스와 인터페이스 그리고 컨버터가 필요하다.</p>
<pre><code class="language-kotlin">interface TheCatApiService {
    @GET(&quot;images/search&quot;)
    fun searchImages(
        @Query(&quot;limit&quot;) limit: Int,
        @Query(&quot;size&quot;) format: String
    ) : Call&lt;List&lt;ImageResultData&gt;&gt;
}
// 최근 추가된 기능인 듯. 아래 오류가 발생할 수 있음.
//
// E  Caused by: java.lang.IllegalArgumentException: Cannot serialize Kotlin type com.example.catagentproject.ImageResultData. Reflective serialization of Kotlin classes without using kotlin-reflect has undefined and unexpected behavior. Please use KotlinJsonAdapterFactory from the moshi-kotlin artifact or use code gen from the moshi-kotlin-codegen artifact. (Ask Gemini)

private val moshi by lazy {
    Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .build()
}
private val retrofit by lazy {
    Retrofit.Builder()
        .baseUrl(&quot;https....&quot;)
        .addConverterFactory(MoshiConverterFactory.create(moshi))
        .build()
}
private val theCatApiService by lazy {
    retrofit.create(TheCatApiService::class.java) }

// ... 실제 호출 및 사용
fun getImageResponse() {
    val call = theCatApiService.searchImage(1, &quot;full&quot;) // limit, size
    call.enqueue(object: Callback&lt;List&lt;ImageResultData&gt;&gt; { // TheCatApiService 반환 타입 참고.
        override fun onFailure(call: Call&lt;List&lt;ImageResultData&gt;&gt;, t: Throwable) {
            Log.e(
                &quot;MainActivity&quot;, 
                &quot;Failed to get search results&quot;, 
                t)
        }

        override fun onResponse(
            call: Call&lt;List&lt;ImageResultData&gt;&gt;,
            response: Response&lt;List&lt;ImageResultData&gt;&gt;
        ) {
            if (response.isSuccessful) {
                val imageResults = response.body()
                val firstImageUrl = imageResults?.firstOrNull()?.imageUrl ?: &quot;No URL&quot;
                serverResponseView.text = &quot;Image URL: $firstImageUrl&quot;
            } else {
                Log.e(
                    &quot;MainActivity&quot;,
                    &quot;Failed to get search results&quot;)
            }
        }
    })
}</code></pre>
<h2 id="원격-url에서-이미지-로딩">원격 URL에서 이미지 로딩</h2>
<p>이제 이미지 URL 을 이용해 이미지를 표시해야 한다. 먼저 URL에서 바이너리 스트림으로 이미지를 가져온 뒤 이 스트림을 이미지로 변환한다. 그 다음엔 비트맵 인스턴스로 변환하고 크기를 조정해서 메모리 효율성을 충족시킨다.</p>
<p>이 작업을 대신해주는 라이브러리는 Square 의 Picaso, Bump Technologies 의 Glide, Facebook 의 Fresco, Coil 이 있다. 여기서는 Glide 를 사용한다.</p>
<p>MvnRepository 에서 Glide 를 검색해서 추가한 뒤 ImageLoader 인터페이스를 추가한 뒤 ImageView 에 사용해보자.</p>
<pre><code class="language-kotlin">interface ImageLoader {
    fun loadImage(imageUrl: String, imageView: ImageView)
}

// context 는 Activity 또는 Fragment
class GlideImageLoader(private val context: Context): ImageLoader { 
    override fun loadImage(imageUrl: String, imageView: ImageView) {
        Glide.with(context)
             .load(imageUrl).centerCrop().into(imageView)
    }
}

private val imageView: ImageView by lazy {
    findViewById(R.id.image_view) }
private val imageLoader by lazy {
    GlideImageLoader(this) }

// ...Retrofit, Moshi 를 이용해 데이터 클래스로 JSON 을 변환함(역직렬화)
if (response.isSuccessful) {
    val body = response.body()
    val result = body?.firstOrNull()

    if (result != null) {
        val imageUrl = result.imageUrl
        imageLoader.loadImage(imageUrl, imageView)
    } else {
        Log.d(&quot;MainActivity&quot;, &quot;Failed to get search results&quot;)
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android 공부 (4)]]></title>
            <link>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-4</link>
            <guid>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-4</guid>
            <pubDate>Fri, 10 Oct 2025 13:49:03 GMT</pubDate>
            <description><![CDATA[<p>안드로이드 앱 내비게이션은 3개로 구분된다. 아래 순서대로 내비게이션 방식을 차례로 살펴본다.</p>
<ol>
<li>Drawer</li>
<li>Bottom</li>
<li>Tabbed</li>
</ol>
<p>추가로 primary(주요) 목적지와 secondary(보조) 목적지 간 차이점을 설명하고, 앱의 유즈케이스 별로 어떤 내비게이션을 선택해야 하는지 설명한다.</p>
<p>여기서 다룰 내용은 다음과 같다.</p>
<ul>
<li>내비게이션 개요</li>
<li>내비게이션 드로어</li>
<li>바텀 내비게이션</li>
<li>탭 내비게이션</li>
</ul>
<h2 id="내비게이션-개요">내비게이션 개요</h2>
<p>주요 목적지란 앱 내에서 항상 표시되는 목적지를 얘기한다. 보조 목적지는 주요 목적지 아래에 위치하며, 필요 시 접근 가능하다.</p>
<p>사용자는 자신이 어디에 있는지 앱 바를 통해 알 수 있다.</p>
<h2 id="내비게이션-드로어">내비게이션 드로어</h2>
<p>내비게이션 드로어는 안드로이드에서 가장 오래된 내비게이션 패턴이며, 닫혀있을 땐 앱 바에 햄버거 메뉴를 보여준다. 햄버거 메뉴를 탭 하면 왼쪽으로 메뉴 리스트가 보이게 된다. 네비게이션 드로어는 여러 목적지에 빠르게 접근할 때 유용하다.</p>
<p>단점은 햄버거 메뉴를 꼭 선택해야 한다는 것이다. 이에 반해 바텀, 탭 내비게이션 패턴은 화면 내 목적지들이 보인다. 하지만 반대로 내비게이션 드로어는 화면을 더 여유롭게 사용할 수 있다는 말이기도 하니 장점도 있다..</p>
<blockquote>
<p>내가 보기엔 진짜 단점은 설정하기가 무지하게 복잡하고 내용이 많다는 것이다. 다 의미가 있는 행동이겠지만 더 쉽게 만들 수는 없었을까?</p>
</blockquote>
<ol>
<li><p><strong>라이브러리 추가</strong>가 필요하다.</p>
<ul>
<li>implementation(libs.androidx.navigation.fragment.ktx) : Fragment 를 내비게이션 목적지로 사용할 수 있게 함.</li>
<li>implementation(libs.androidx.navigation.ui.ktx) : App Bar, Bottom Navigation, Navigation Drawer UI Component</li>
</ul>
</li>
<li><p>AndroidManifest.xml 에 <strong>액션바 사용하지 않도록</strong> <code>android:theme</code> 를 아래와 같이 설정한다.</p>
<pre><code class="language-xml">&lt;activity
 android:name=&quot;.MainActivity&quot;
 android:exported=&quot;true&quot;
 android:theme=&quot;@style/Theme.NavigationDrawer.NoActionBar&quot;&gt;</code></pre>
</li>
<li><p><strong>내비게이션 그래프를 추가</strong>해야 한다. res 폴더에서 Command+N 으로 Resource Type 은 Navigation 으로 설정한 xml 파일 만들고 아래와 같이 만든다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;navigation xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
 xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
 xmlns:tools=&quot;http://schemas.android.com/tools&quot;
 android:id=&quot;@+id/mobile_navigation.xml&quot;
 app:startDestination=&quot;@id/nav_home&quot;&gt;

 &lt;fragment
     android:id=&quot;@+id/nav_home&quot;
     android:name=&quot;com.example.navigationdrawer.HomeFragment&quot;
     android:label=&quot;@string/home&quot;
     tools:layout=&quot;@layout/fragment_home&quot;&gt;

     &lt;action
         android:id=&quot;@+id/nav_home_to_content&quot;
         app:destination=&quot;@id/nav_content&quot;
         app:popUpTo=&quot;@id/nav_home&quot; /&gt;
 &lt;/fragment&gt;

 &lt;fragment
     android:id=&quot;@+id/nav_content&quot;
     android:name=&quot;com.example.navigationdrawer.ContentFragment&quot;
     android:label=&quot;@string/content&quot;
     tools:layout=&quot;@layout/fragment_content&quot; /&gt;
 &lt;!-- 아래 더 많음 --&gt;
&lt;/navigation&gt;</code></pre>
<ul>
<li>fragment 태그의 android:id 와 android:name 으로 목적지를 설정하고 있다.</li>
<li>home 의 action 태그로 상세 화면으로 가는 액션을 생성했다.</li>
<li>실제 그래프는 훨씬 길다. 일부분만 추가한 것이다.</li>
</ul>
</li>
<li><p>위의 액션과 관련된 프로그래밍을 위해 HomeFragment 의 onCreateView 를 아래와 같이 수정한다.</p>
<pre><code class="language-kotlin">override fun onCreateView(
 inflater: LayoutInflater, container: ViewGroup?,
 savedInstanceState: Bundle?
): View? {
 val view = inflater.inflate(R.layout.fragment_home, container, false)
 view.findViewById&lt;Button&gt;(R.id.button_home)?.setOnClickListener(
     Navigation.createNavigateOnClickListener(R.id.nav_home_to_content, null))
 return view
}</code></pre>
</li>
<li><p>내비게이션 그래프로 목적지도 설정했고 상세 화면 액션도 설정했지만 <strong>내비게이션 호스트</strong>가 필요하다. layout 폴더에 content_main.xml 을 생성한다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;androidx.fragment.app.FragmentContainerView xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
 xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
 android:id=&quot;@+id/nav_host_fragment&quot;
 android:name=&quot;androidx.navigation.fragment.NavHostFragment&quot;
 android:layout_width=&quot;match_parent&quot;
 android:layout_height=&quot;match_parent&quot;
 app:defaultNavHost=&quot;true&quot;
 app:navGraph=&quot;@navigation/mobile_navigation&quot;/&gt;</code></pre>
<ul>
<li>FragmentContainerView 를 생성한다. 목적지는 Fragment 다.</li>
<li>android:name 은 NavHostFragment 로 설정한다. 내비게이션 호스트가 될 것이다.</li>
<li>app:defaultNavHost 는 true. 기본 내비게이션 호스트다.</li>
<li>app:navGraph 는 3번에서 만든 @navigation/mobile_navigation 으로 설정해준다.</li>
</ul>
</li>
<li><p>이제부터 drawer UI 설정이다. Drawer 의 헤더부터 설정한다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;LinearLayout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
 xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
 android:layout_width=&quot;match_parent&quot;
 android:layout_height=&quot;176dp&quot;
 android:background=&quot;@color/teal_700&quot;
 android:gravity=&quot;bottom&quot;
 android:orientation=&quot;vertical&quot;
 android:paddingStart=&quot;16dp&quot;
 android:paddingTop=&quot;16dp&quot;
 android:paddingEnd=&quot;16dp&quot;
 android:paddingBottom=&quot;16dp&quot;
 android:theme=&quot;@style/ThemeOverlay.AppCompat.Dark&quot;&gt;

 &lt;ImageView
     android:id=&quot;@+id/imageView&quot;
     android:layout_width=&quot;wrap_content&quot;
     android:layout_height=&quot;wrap_content&quot;
     android:contentDescription=&quot;@string/nav_header_desc&quot;
     android:paddingTop=&quot;8dp&quot;
     app:srcCompat=&quot;@mipmap/ic_launcher_round&quot; /&gt;

 &lt;TextView
     android:layout_width=&quot;match_parent&quot;
     android:layout_height=&quot;wrap_content&quot;
     android:paddingTop=&quot;8dp&quot;
     android:text=&quot;@string/app_name&quot;
     android:textAppearance=&quot;@style/TextAppearance.AppCompat.Body1&quot; /&gt;
</code></pre>
</li>
</ol>
</LinearLayout>
```
7. Drawer 를 위해 제외한 앱 바를 대체하기 위해 새로운 **앱 바 및 컨텐츠 레이아웃**도 만든다.
```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

<pre><code>&lt;!-- MARK - App bar --&gt;
&lt;com.google.android.material.appbar.AppBarLayout
    android:layout_width=&quot;match_parent&quot;
    android:layout_height=&quot;wrap_content&quot;
    android:theme=&quot;@style/Theme.NavigationDrawer.AppBarOverlay&quot;&gt;

    &lt;androidx.appcompat.widget.Toolbar
        android:id=&quot;@+id/toolbar&quot;
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;?attr/actionBarSize&quot;
        android:background=&quot;?attr/colorPrimary&quot;
        app:popupTheme=&quot;@style/Theme.NavigationDrawer.PopupOverlay&quot;/&gt;
&lt;/com.google.android.material.appbar.AppBarLayout&gt;

&lt;!-- MARK - Contents --&gt;
&lt;include layout=&quot;@layout/content_main&quot; /&gt;</code></pre><p>&lt;/androidx.coordinatorlayout.widget.CoordinatorLayout&gt;</p>
<pre><code>8. 내비게이션 드로어 목록을 채운다. res 폴더에서 Command+N 으로 Resource Type 을 Menu 로 선택하고 activity_main_drawer.xml 을 생성한다.
```xml
&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;menu xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;
    tools:showIn=&quot;navigation_view&quot;&gt;

    &lt;group
        android:id=&quot;@+id/menu_top&quot;
        android:checkableBehavior=&quot;single&quot;&gt;

        &lt;item
            android:id=&quot;@+id/nav_home&quot;
            android:icon=&quot;@drawable/home&quot;
            android:title=&quot;@string/home&quot; /&gt;
        &lt;item
            android:id=&quot;@+id/nav_recent&quot;
            android:icon=&quot;@drawable/recent&quot;
            android:title=&quot;@string/recent&quot; /&gt;
        &lt;item
            android:id=&quot;@+id/nav_favorites&quot;
            android:icon=&quot;@drawable/favorites&quot;
            android:title=&quot;@string/favorites&quot; /&gt;
    &lt;/group&gt;

    &lt;group
        android:id=&quot;@+id/menu_bottom&quot;
        android:checkableBehavior=&quot;single&quot; &gt;
        &lt;item
            android:id=&quot;@+id/nav_archive&quot;
            android:icon=&quot;@drawable/archive&quot;
            android:title=&quot;@string/archive&quot; /&gt;
        &lt;item
            android:id=&quot;@+id/nav_bin&quot;
            android:icon=&quot;@drawable/bin&quot;
            android:title=&quot;@string/bin&quot; /&gt;
    &lt;/group&gt;

&lt;/menu&gt;</code></pre><ul>
<li><p>group 은 Drawer 내에 목록들 중 divider 로 나눠지는 기준이 될 것이다.</p>
</li>
<li><p>item 의 android:id 는 mobile_navigation.xml (navGraph, 내비게이션 그래프) 의 fragment 태그 android:id 와 일치한다.</p>
<ol start="8">
<li><strong>Overflow 메뉴</strong> (맨 우측 세로로 ... 표시) 를 추가한다. res 폴더에서 Command+N 으로 <strong>Resource Type 을 Menu 로 설정한 뒤 main.xml 을 생성</strong>한다.<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;menu xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;&gt;
</code></pre>
</li>
</ol>
<p><item
   android:id="@+id/nav_settings"
   android:title="@string/settings"
   app:showAsAction="never" /></p>
</li>
</ul>
</menu>
```





<ol start="9">
<li><p>내비게이션 드로어, 오버플로 메뉴, 앱 바(헤더 및 컨텐츠) 전부 연결하기 위해 <strong>이들을 activity_main.xml 에 추가</strong>한다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;androidx.drawerlayout.widget.DrawerLayout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
 xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
 xmlns:tools=&quot;http://schemas.android.com/tools&quot;
 android:id=&quot;@+id/drawer_layout&quot;
 android:layout_width=&quot;match_parent&quot;
 android:layout_height=&quot;match_parent&quot;
 android:fitsSystemWindows=&quot;true&quot;
 tools:openDrawer=&quot;start&quot;&gt;

 &lt;include
     layout=&quot;@layout/app_bar_main&quot;
     android:layout_width=&quot;match_parent&quot;
     android:layout_height=&quot;match_parent&quot; /&gt;

 &lt;!-- app:headerLayout 에는 내가 만든 헤더 --&gt;
 &lt;!-- app:menu 에는 내가 만든 메뉴 목록 --&gt;
 &lt;com.google.android.material.navigation.NavigationView
     android:id=&quot;@+id/nav_view&quot;
     android:layout_width=&quot;wrap_content&quot;
     android:layout_height=&quot;match_parent&quot;
     android:layout_gravity=&quot;start&quot;
     android:fitsSystemWindows=&quot;true&quot;
     app:headerLayout=&quot;@layout/nav_header_main&quot;
     app:menu=&quot;@menu/activity_main_drawer&quot; /&gt;
</code></pre>
</li>
</ol>
<p>&lt;/androidx.drawerlayout.widget.DrawerLayout&gt;</p>
<pre><code>10. 아직 오버플로 메뉴 설정과 드로어 상호작용이 남았다. **MainActivity 를 세팅한다**.
```kotlin
class MainActivity : AppCompatActivity() {

    private lateinit var appBarConfiguration: AppBarConfiguration

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        // Toolbar 세팅
        setSupportActionBar(findViewById(R.id.toolbar))

        // NavHostFragment 가져옴
        val navHostFragment = supportFragmentManager
            .findFragmentById(R.id.nav_host_fragment) as NavHostFragment
        // NavHostFragment 를 통해 NavController 를 가져옴
        val navController = navHostFragment.navController

        // Navigation Drawer 세팅 (여기서 설정하는 메뉴들은 primary destination)
        // 즉, setOf 안의 Destination ID 들은 다른 Destination ID 들 중 앱 바에 햄버거 메뉴를 노출해야 할 ID 인 것이다.
        // setOf 다음 parameter 인 drawer_layout 은 햄버거 메뉴를 선택 했을 때 보여줘야 할 레이아웃이다.
        appBarConfiguration = AppBarConfiguration(
            setOf(
                R.id.nav_home, R.id.nav_recent, R.id.nav_favorites,
                R.id.nav_archive, R.id.nav_bin
            ), findViewById(R.id.drawer_layout)
        )

        // 앱 바와 내비게이션 그래프 연결
        setupActionBarWithNavController(navController, appBarConfiguration)
        // 내비게이션 드로어의 한 항목을 클릭했을 떄 강조 표시해야 하는 항목을 지정
        findViewById&lt;NavigationView&gt;(R.id.nav_view)
            ?.setupWithNavController(navController)
    }

    // 뒤로가기 버튼 클릭 시 부모 목적지 돌아가는 작업 처리
    override fun onSupportNavigateUp(): Boolean {
        val navController = findNavController(R.id.nav_host_fragment)
        return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
    }

    // 앱 바에 추가할 메뉴를 결정
    override fun onCreateOptionsMenu(menu: Menu?): Boolean {
        menuInflater.inflate(R.menu.main, menu)
        return true
    }

    // 드로어 메뉴 선택 시
    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return item.onNavDestinationSelected(
            findNavController(R.id.nav_host_fragment))
    }
}</code></pre><ul>
<li>햄버거 메뉴를 선택해서 나오는 메뉴들은 화면 이동은 되지만 햄버거 버튼이 뒤로가기 버튼으로 대체되지 않는다.</li>
<li>하지만 아까 HomeFragment 에 추가한 버튼을 누르면 컨텐츠로 이동하게 되는데 이 땐 뒤로가기 버튼이 나온다.</li>
<li>오버플로 버튼으로 이동한 화면도 뒤로가기 버튼이 나온다.</li>
</ul>
<h2 id="바텀-내비게이션">바텀 내비게이션</h2>
<p>최상위 목적지 (primary destination) 이 적은 경우 유용하다. 앱의 보조 목적지 내에서 빠르게 항상 이용 가능한 내비게이션을 목적으로 사용된다.</p>
<ol>
<li><p>의존성 주입, 내비게이션 그래프, Menu 리소스 파일 생성은 같다. 하지만 activity_main.xml 에서 추가해야 할 컴포넌트가 다르다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;androidx.constraintlayout.widget.ConstraintLayout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
 xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
 android:id=&quot;@+id/main&quot;
 android:layout_width=&quot;match_parent&quot;
 android:layout_height=&quot;match_parent&quot;
 android:paddingTop=&quot;?attr/actionBarSize&quot;&gt;
&lt;!-- 새로운 바텀 내비게이션 뷰 --&gt; &lt;com.google.android.material.bottomnavigation.BottomNavigationView
     android:id=&quot;@+id/nav_view&quot;
     android:layout_width=&quot;0dp&quot;
     android:layout_height=&quot;wrap_content&quot;
     android:layout_marginStart=&quot;0dp&quot;
     android:layout_marginEnd=&quot;0dp&quot;
     android:background=&quot;?android:attr/windowBackground&quot;
     app:layout_constraintBottom_toBottomOf=&quot;parent&quot;
     app:layout_constraintStart_toStartOf=&quot;parent&quot;
     app:layout_constraintEnd_toEndOf=&quot;parent&quot;
     app:menu=&quot;@menu/bottom_nav_menu&quot;
     app:labelVisibilityMode=&quot;labeled&quot; /&gt;
 &lt;androidx.fragment.app.FragmentContainerView
     android:id=&quot;@+id/nav_host_fragment&quot;
     app:layout_constraintStart_toStartOf=&quot;parent&quot;
     app:layout_constraintEnd_toEndOf=&quot;parent&quot;
     app:layout_constraintTop_toTopOf=&quot;parent&quot;
     android:name=&quot;androidx.navigation.fragment.NavHostFragment&quot;
     android:layout_width=&quot;match_parent&quot;
     android:layout_height=&quot;wrap_content&quot;
     app:defaultNavHost=&quot;true&quot;
     app:navGraph=&quot;@navigation/mobile_navigation&quot; /&gt;
&lt;/androidx.constraintlayout.widget.ConstraintLayout&gt;</code></pre>
</li>
<li><p>이를 MainActivity 에서 활성화한다.</p>
<pre><code class="language-kotlin">class MainActivity : AppCompatActivity() {
 private lateinit var appBarConfiguration: AppBarConfiguration

 override fun onCreate(savedInstanceState: Bundle?) {
     super.onCreate(savedInstanceState)
     setContentView(R.layout.activity_main)

     val navHostFragment = supportFragmentManager
         .findFragmentById(R.id.nav_host_fragment) as NavHostFragment
     val navController = navHostFragment.navController

     appBarConfiguration = AppBarConfiguration(
         setOf(R.id.nav_home, R.id.nav_tickets, R.id.nav_offers, R.id.nav_rewards))
     setupActionBarWithNavController(navController,
         appBarConfiguration)
     findViewById&lt;BottomNavigationView&gt;(R.id.nav_view)
         ?.setupWithNavController(navController)
 }

 override fun onSupportNavigateUp(): Boolean {
     val navController = findNavController(R.id.nav_host_fragment)
     return navController.
         navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
 }

 override fun onCreateOptionsMenu(menu: Menu?): Boolean {
     menuInflater.inflate(R.menu.main, menu)
     return true
 }

 override fun onOptionsItemSelected(item: MenuItem): Boolean {
     super.onOptionsItemSelected(item)
     return item.onNavDestinationSelected(findNavController(
         R.id.nav_host_fragment
     ))
 }
}</code></pre>
</li>
</ol>
<ul>
<li>내비게이션 drawer 와의 차이점은 AppBarConfiguration 에 네비게이션 drawer 관련 뷰를 넣지 않는다는 것이다.</li>
<li>onCreateOptionsMenu, onOptionsItemSelected 은 이전과 마찬가지로 main 메뉴를 오버플로 메뉴에 넣고, 프래그먼트 컨테이너 뷰를 통해 내비게이션 상태값을 관찰한다.</li>
</ul>
<h2 id="탭-내비게이션">탭 내비게이션</h2>
<p>관련 항목을 표시할 때 유용하다. 2~5개 사이 탭을 표시하고 그 이상의 탭은 스크롤로 처리한다.</p>
<p>주로 바텀 내비게이션과 함께 사용한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android 공부 (3)]]></title>
            <link>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-3</link>
            <guid>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-3</guid>
            <pubDate>Sun, 05 Oct 2025 09:21:59 GMT</pubDate>
            <description><![CDATA[<p>프래그먼트는 액티비티의 일부 영역을 얘기한다. 프래그먼트 사용법을 알아보고 액티비티 안에서 어떻게 관리하는지 알아본다. 프래그먼트 추가 및 정적/동적 프로그래먼트의 차이도 알아본다.</p>
<p>액티비티와 다르게 프래그먼트는 dual-pane layout 을 사용해 태블릿 레이아웃을 생성할 수도 있다. dual 이기 때문에 최소 2개의 프래그먼트가 필요한데 이는 폰에서 사용하는 프래그먼트를 재활용할 수 있다.</p>
<p>여기서 다루는 내용은 아래와 같다.</p>
<ul>
<li>프래그먼트 생명주기</li>
<li>정적 프래그먼트와 듀얼 패인 레이아웃</li>
<li>동적 프래그먼트</li>
<li>젯팩 Navigation</li>
</ul>
<h2 id="프래그먼트-생명주기">프래그먼트 생명주기</h2>
<p>프래그먼트도 자체 생명주기가 있다(액티비티 생명주기와 매우 비슷). 그리고 액티비티 생명주기에 종속된다.</p>
<p>프래그먼트 추가 -&gt; onAttach -&gt; onCreate -&gt; onCreateView -&gt; onViewCreated -&gt; onActivityCreated -&gt; onStart -&gt; onResume -&gt; 프래그먼트 실행 -&gt; onPause (앱 백그라운드 전환 시 onResume 으로 돌아감) -&gt; onStop -&gt; onDestroyView -&gt; (프래그먼트 제거/대체 또는 앱 종료) -&gt; onDestroy -&gt; onDetachView -&gt; 프래그먼트 제거</p>
<ul>
<li>onAttach : 액티비티와 연결된 직후</li>
<li>onCreate : 프래그먼트 초기화 작업. UI 는 아님. setContentView 사용 못함.</li>
<li>onCreateView : 레이아웃 생성 가능. 반환 타입이 View 이므로 실제 View 를 생성해서 반환해야 함. 레이아웃 내 뷰를 참조하려면 우선 생성해야 한다.</li>
<li>onViewCreated : 프래그먼트가 사용자에게 표시되기 전에 호출된다. 뷰의 기능과 상호작용 추가.</li>
<li>onActivityCreated : 액티비티 onCreate 이후 실행된다. 2번째 초기화 설정하는 함수</li>
<li>onStart : onViewCreated 처럼 사용자에게 뷰가 보이기 전 호출. 아직은 상호작용 불가함.</li>
<li>onResume : 여기서부턴 상호작용 가능. 앱이 백그라운드 -&gt; 포어그라운드 전환 시 호출된다.</li>
<li>onPause : 앱이 백그라운드로 전환될 때, 다른 요소에 가려질 때 실행된다.</li>
<li>onStop : 사용자에게 보이지 않고 백그라운드로 전환될 때</li>
<li>onDestroyView : 프래그먼트 소멸 전 최종 정리. 프래그먼트가 백스택에 저장되면 프래그먼트 소멸은 안되지만 이 콜백이 호출될 수 있다. 이 콜백의 반환이 프래그먼트의 끝이다.</li>
<li>onDestroy : 프래그먼트 소멸 중. 앱 종료 혹은 프래그먼트 교체 시 호출</li>
<li>onDetach : 프래그먼트가 액티비티와 분리됨</li>
</ul>
<p>프래그먼트와 액티비티 관계를 알아보기 위해 아래처럼 코딩한다. 차례대로 레이아웃 xml, Activity 및 Fragment 클래스이다.</p>
<pre><code class="language-xml">&lt;!-- fragment_main.xml --&gt;
&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;FrameLayout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;
    android:layout_width=&quot;match_parent&quot;
    android:layout_height=&quot;match_parent&quot;
    tools:context=&quot;.MainFragment&quot;&gt;

    &lt;!-- TODO: Update blank fragment layout --&gt;
    &lt;TextView
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;match_parent&quot;
        android:gravity=&quot;center&quot;
        android:text=&quot;@string/hello_blank_fragment&quot; /&gt;

&lt;/FrameLayout&gt;

&lt;!-- activity_main.xml --&gt;
&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;androidx.constraintlayout.widget.ConstraintLayout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;
    android:id=&quot;@+id/main&quot;
    android:layout_width=&quot;match_parent&quot;
    android:layout_height=&quot;match_parent&quot;
    tools:context=&quot;.MainActivity&quot;&gt;

    &lt;fragment
        android:id=&quot;@+id/main_fragment&quot;
        android:name=&quot;com.example.fragmentlifecycle.MainFragment&quot;
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;match_parent&quot; /&gt;

&lt;/androidx.constraintlayout.widget.ConstraintLayout&gt;</code></pre>
<pre><code class="language-kotlin">// MainActivity.kt
package com.example.fragmentlifecycle

import android.os.Bundle
import android.util.Log
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat

private const val TAG = &quot;MainActivity&quot;

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        Log.d(TAG, &quot;onCreate: &quot;)
    }
}

// MainFragment.kt
package com.example.fragmentlifecycle

import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup

private const val ARG_PARAM1 = &quot;param1&quot;
private const val ARG_PARAM2 = &quot;param2&quot;
private const val TAG = &quot;MainFragment&quot;
class MainFragment : Fragment() {
    private var param1: String? = null
    private var param2: String? = null

    override fun onAttach(context: Context) {
        super.onAttach(context)
        Log.d(TAG, &quot;onAttach: &quot;)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d(TAG, &quot;onCreate: &quot;)
        arguments?.let {
            param1 = it.getString(ARG_PARAM1)
            param2 = it.getString(ARG_PARAM2)
        }
}

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        Log.d(TAG, &quot;onCreateView: &quot;)
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_main, container, false)
    }

    companion object {
        @JvmStatic
        fun newInstance(param1: String, param2: String) =
            MainFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_PARAM1, param1)
                    putString(ARG_PARAM2, param2)
                }
            }
    }
}</code></pre>
<p>중요한 점 몇가지를 짚어보자.</p>
<ul>
<li>activity_main.xml 에 fragment 태그를 써서 미리 프래그먼트를 넣었다. 이처럼 액티비티에 이미 정의된 프래그먼트를 <code>정적 프래그먼트</code>라고 한다.</li>
<li>Log 를 확인해보면 프래그먼트 관련 생명주기 함수 실행 후 액티비티의 onCreate 함수가 실행됨을 알 수 있다. 액티비티는 자신이 포함한 뷰 기반으로 UI 를 생성하기 때문에 프래그먼트가 먼저 생성된 것이다.</li>
</ul>
<p>프래그먼트, 액티비티 생명주기 함수에 로그를 찍는 함수를 추가하고 앱을 빌드한다. 그리고 화면을 회전시키면 액티비티가 새로 만들어질 것이다. 아래는 회전시킬 경우 찍히는 로그를 정리한 것이다.</p>
<ol>
<li>(F) onPause</li>
<li>(A) onPause</li>
<li>(F) onStop</li>
<li>(A) onStop</li>
<li>(F) onDestroyView</li>
<li>(F) onDestroy</li>
<li>(F) onDetach</li>
<li>(A) onDestroy</li>
<li>(F) onAttach</li>
<li>(F) onCreate</li>
<li>(F) onCreateView</li>
<li>(A) onCreate</li>
</ol>
<p>정리하자면 액티비티가 소멸될 때 수행해야 할 함수들을 프래그먼트-액티비티 순서로 차례를 지켜가며 실행하는 모습을 보여준다. 액티비티의 onDestroy 를 끝으로 액티비티가 소멸되기 전 onDetach 를 통해 프래그먼트도 소멸한다. 그리고 프래그먼트가 onAttach 를 시작으로 생성되기 시작하며 액티비티도 onCreate 됨을 알 수 있다.</p>
<p>액티비티 내에는 아래와 같이 프래그먼트를 추가할 수 있다. layout_weight 로 프래그먼트 높이 비율을 설정했다는 것을 알 수 있다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;LinearLayout
    xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;
    android:layout_width=&quot;match_parent&quot;
    android:layout_height=&quot;match_parent&quot;
    android:orientation=&quot;vertical&quot;
    tools:context=&quot;.MainActivity&quot;&gt;

    &lt;fragment
        android:id=&quot;@+id/counter_fragment&quot;
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;0dp&quot;
        android:layout_weight=&quot;2&quot;
        android:name=&quot;com.example.fragmentintro.CounterFragment&quot; /&gt;

    &lt;fragment
        android:id=&quot;@+id/color_fragment&quot;
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;0dp&quot;
        android:layout_weight=&quot;1&quot;
        android:name=&quot;com.example.fragmentintro.ColorFragment&quot; /&gt;

&lt;/LinearLayout&gt;</code></pre>
<h2 id="정적-프래그먼트와-듀얼-패인-레이아웃">정적 프래그먼트와 듀얼 패인 레이아웃</h2>
<blockquote>
<p>Fragment 는 2011년 API 11 에서 소개됐으며, FragmentManager 클래스를 사용해 액티비티와의 상호작용을 관리한다. SupportFragmentManager 는 안드로이드 11 이전 버전의 안드로이드 서포트 라이브러리에서 프래그먼트를 사용할 수 있게 도입됐다. SupportFragmentManager 는 프래그먼트를 관리하기 위한 개선 사항이 추가돼 젯팩 fragment 라이브러리의 기반이 됐다.</p>
</blockquote>
<p>정적 프래그먼트를 이용해 듀얼 패인 레이아웃을 설정하는 법을 배워보자. 순서는 다음과 같다.</p>
<ol>
<li><code>Android Resource</code> 를 통해 레이아웃을 만들어준다. 여기서 <code>Smallest Width</code> 를 옵션으로 추가하는데 값을 600 으로 설정한다.<ul>
<li>태블릿인지 아닌지를 나누는 기준의 폭 600dp 이다.</li>
<li>res/layout 말고 res/sw600dp 폴더가 하나 더 생긴다.</li>
</ul>
</li>
<li>프래그먼트를 두 개 만든다. 목록 용, 상세 용</li>
<li>interface 를 선언하고 onSelected 함수를 선언한 뒤 MainActivity 가 이를 구현하도록 한다.</li>
<li>각 프래그먼트를 구현하고 목록 프래그먼트에는 interface 구현을 하도록 한다.</li>
<li>MainActivity 에서 600dp 기준 듀얼 패인인지 아닌지 확인하고 아래 작업을 수행한다.<ul>
<li>600dp 이상 : 상세 프래그먼트를 <code>supportFragmentManager</code> 의 <code>findFragmentById</code> 혹은 <code>findFragmentByTag</code> 를 이용해 불러온다.</li>
<li>600dp 미만 : startActivity 를 수행할 Intent 를 생성해서 상세 액티비티를 생성한다.</li>
</ul>
</li>
</ol>
<pre><code class="language-kotlin">const val STAR_SIGN_ID = &quot;STAR_SIGN_ID&quot;
interface StarSignListener {
    fun onSelected(id: Int)
}

class MainActivity : AppCompatActivity(), StarSignListener {
    var isDualPane: Boolean = false
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        isDualPane = findViewById&lt;View&gt;(R.id.star_sign_detail) != null
    }

    override fun onSelected(id: Int) {
        if (isDualPane) { // DetailFragment 이용
            val detailFragment = supportFragmentManager
                .findFragmentById(R.id.star_sign_detail) as DetailFragment
            detailFragment.setStarSignData(id)
        } else { // DetailActivity 이용
            val detailIntent = Intent(this, DetailActivity::class.java).apply {
                putExtra(STAR_SIGN_ID, id)
            }
            startActivity(detailIntent)
        }
    }
}

class ListFragment : Fragment(), View.OnClickListener {
    override fun onAttach(context: Context) {
        super.onAttach(context)
        if (context is StarSignListener) {
            starSignListener = context
        } else {
            throw RuntimeException(&quot;Must implement StarSignListener&quot;)
        }
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val starSigns: List&lt;View&gt; = listOf(
            R.id.aquarius, R.id.pisces, R.id.aries,
            R.id.taurus, R.id.gemini, R.id.cancer,
            R.id.leo, R.id.virgo, R.id.libra,
            R.id.scorpio, R.id.sagittarius, R.id.capricorn,
        ).map {
            id -&gt; view.findViewById(id)
        }

        starSigns.forEach {
            it.setOnClickListener(this)
        }
    }

    override fun onClick(p0: View?) {
        p0?.let { starSign -&gt;
            starSignListener.onSelected(starSign.id)
        }
    }
}

class DetailFragment : Fragment() {
    private val starSign: TextView?
        get() = view?.findViewById(R.id.star_sign)
    private val symbol: TextView?
        get() = view?.findViewById(R.id.symbol)
    private val dateRange: TextView?
        get() = view?.findViewById(R.id.date_range)

    fun setStarSignData(starSignId: Int) {
        when (starSignId) {
            R.id.aquarius -&gt; {
                starSign?.text = getString(R.string.aquarius)
                symbol?.text = getString(R.string.symbol, &quot;Water Carrier&quot;)
                dateRange?.text = getString(R.string.date_range, &quot;January 20 - February 18&quot;)
            }
        // ...
        }
    }
}</code></pre>
<h2 id="동적-프래그먼트">동적 프래그먼트</h2>
<p>사용자의 동작에 대응하기 위해 동적 프래그먼트를 생성할 수도 있다. 프래그먼트 컨테이너 역할을 하는 ViewGroup 을 추가한 뒤 추가/교체/제거하는 방법을 배워본다.</p>
<p>우선 FragmentContainerView 를 사용하기 위해 의존성 추가를 수행한다. toml 파일 수정도 해야 함을 잊지 말아야 한다.</p>
<p>&lt;의존성 추가&gt;</p>
<pre><code class="language-xml">implementation(libs.androidx.fragment.ktx)</code></pre>
<p>&lt;toml 수정&gt;</p>
<pre><code class="language-xml">[versions]
...
fragmentKtx = &quot;1.5.6&quot;

[libraries]
androidx-fragment-ktx = { group = &quot;androidx.fragment&quot;, name = &quot;fragment-ktx&quot;, version.ref = &quot;fragmentKtx&quot; }</code></pre>
<p>activity_main 을 FragmentContainerView 로 만든다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;androidx.fragment.app.FragmentContainerView xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    android:id=&quot;@+id/fragment_container&quot;
    android:layout_width=&quot;match_parent&quot;
    android:layout_height=&quot;match_parent&quot;/&gt;</code></pre>
<p>이제 MainActivity 에서 프래그먼트를 교체하는 코드를 추가한다.</p>
<pre><code class="language-kotlin">interface StarSignListener {
    fun onSelected(id: Int)
}

class MainActivity : AppCompatActivity(), StarSignListener {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (savedInstanceState == null) {
            findViewById&lt;FragmentContainerView&gt;(R.id.fragment_container)?.let { frameLayout -&gt;
                val listFragment = ListFragment()
                supportFragmentManager.beginTransaction()
                    .add(frameLayout.id, listFragment)
                    .commit()
            }
        }
    }

    override fun onSelected(id: Int) {
        findViewById&lt;FragmentContainerView&gt;(R.id.fragment_container)?.let { frameLayout -&gt;
            val detailFragment = DetailFragment.newInstance(id)
            // 백스택에서 꺼내진 트랜잭션은 순서와 작업 자체를 완전 반대로 수행한다.
            // 아래 작업은 &quot;ListFragment 를 제거하고 DetailFragment 로 교체 후 이 작업을 백 스택에 push 하라&quot; 이다.
            // 이 반대작업은 &quot;DetailFragment 를 제거하고 ListFragment 로 교체하라&quot; 이다.
            // 이에 대한 내용은 더 익숙해질 필요가 있을 듯... 약간 자의적인 해석이라 느껴짐.
            supportFragmentManager.beginTransaction()
                .replace(frameLayout.id, detailFragment)
                .addToBackStack(null)
                .commit()
        }
    }
}</code></pre>
<h2 id="jetpack-navigation">Jetpack Navigation</h2>
<p>그냥 예제 코드만 추가</p>
<p>&lt;nav_graph.xml&gt;</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;navigation xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;
    android:id=&quot;@+id/nav_graph&quot;
    app:startDestination=&quot;@id/starSignList&quot;&gt;

    &lt;fragment
        android:id=&quot;@+id/starSignList&quot;
        android:name=&quot;com.example.jetpackfragments.ListFragment&quot;
        android:label=&quot;List&quot;
        tools:layout=&quot;@layout/fragment_list&quot;&gt;

        &lt;action
            android:id=&quot;@+id/star_sign_id_action&quot;
            app:destination=&quot;@id/starSign&quot; /&gt;
    &lt;/fragment&gt;
    &lt;fragment
        android:id=&quot;@+id/starSign&quot;
        android:name=&quot;com.example.jetpackfragments.DetailFragment&quot;
        android:label=&quot;Detail&quot;
        tools:layout=&quot;@layout/fragment_detail&quot; /&gt;

&lt;/navigation&gt;</code></pre>
<p>&lt;activity_main.xml&gt;</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;androidx.fragment.app.FragmentContainerView xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
    android:id=&quot;@+id/nav_host_fragment&quot;
    android:name=&quot;androidx.navigation.fragment.NavHostFragment&quot;
    android:layout_width=&quot;match_parent&quot;
    android:layout_height=&quot;match_parent&quot;
    app:defaultNavHost=&quot;true&quot;
    app:navGraph=&quot;@navigation/nav_graph&quot; /&gt;</code></pre>
<p>&lt;ListFragment.kt&gt;</p>
<pre><code class="language-kotlin">class ListFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_list, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val starSigns: List&lt;View&gt; = listOf(
            R.id.aquarius, R.id.pisces, R.id.aries,
            R.id.taurus, R.id.gemini, R.id.cancer,
            R.id.leo, R.id.virgo, R.id.libra,
            R.id.scorpio, R.id.sagittarius, R.id.capricorn,
        ).map {
                id -&gt; view.findViewById(id)
        }

        starSigns.forEach { starSign -&gt;
            val fragmentBundle = Bundle()
            fragmentBundle.putInt(STAR_SIGN_ID, starSign.id)
            starSign.setOnClickListener(
                Navigation.createNavigateOnClickListener(
                    R.id.star_sign_id_action, fragmentBundle
                )
            )
        }
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android 공부 (2)]]></title>
            <link>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-2</link>
            <guid>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-2</guid>
            <pubDate>Fri, 03 Oct 2025 03:43:43 GMT</pubDate>
            <description><![CDATA[<h2 id="액티비티-생명주기">액티비티 생명주기</h2>
<p>안드로이드 SDK 의 생명주기 메소드들을 이용해 이에 적합한 작업을 수행할 수 있도록 코드를 작성할 수 있다. 각각 생명주기의 콜백함수를 잘 이용해야 한다.</p>
<ul>
<li><p>onCreate(savedInstantceState: Bundle?) : 가장 많이 사용. </p>
<ul>
<li>일반적으로 한 번만 호출되지만 액티비티 재생성 시 다시 호출된다.</li>
<li>레이아웃 표시를 준비하는 코드를 작성한다. 메소드가 반환되고 나서 바로 UI 가 표시되는 건 아니다. </li>
<li>보통 setContentView(R.layout.activity_main) 호출을 통해 초기화 작업을 시작한다.</li>
<li>savedInstanceState 매개변수는 액티비티 재생성 간 데이터 유지를 위해 사용한다. 키-값 쌍.</li>
</ul>
</li>
<li><p>onRestart() : 액티비티가 다시 시작될 때 onStart() 직전 호출된다.</p>
<ul>
<li>액티비티를 다시 시작한다는 것은 홈 버튼을 눌러 액티비티가 백그라운드로 이동한 후 다시 포어그라운드로 돌아올 때 이다.</li>
<li>기기 회전 등 구성 변경이 일어나면 액티비티가 재생성 된다.<ul>
<li>화면 회전 시 기기 구성 변경으로 인한 액티비티 재생성이 일어나지 않게 하려면 AndroidManifest.xml 파일의 MainActivity 에 android:configCHanges=&quot;orientation|screenSize|screenLayout&quot; 을 추가한다.</li>
<li>액티비티를 다시 시작하지 않는 방식은 권장되지 않는다. 시스템이 자동으로 대체 리소스를 적용하지 않기 때문이다.</li>
</ul>
</li>
<li>액티비티 다시 시작, 재생성은 다른 얘기다.</li>
</ul>
</li>
<li><p>onStart() : 액티비티가 백그라운드에서 포어그라운드로 이동할 때 수행되는 첫 번째 콜백이다.</p>
</li>
<li><p>onRestoreInstanceState(savedInstanceState: Bundle?) : savedInstanceState 로 상태를 저장한 경우 onStart 이후에 호출되는 메서드. onCreate(savedInstanceState:Bundle?) 말고도 여기서 상태를 복원할 수도 있다.</p>
</li>
<li><p>onResume() : 액티비티 생성 마지막 과정에서 호출되는 콜백. 백그라운드에서 포어그라운드로 돌아올 때 실행.</p>
<ul>
<li>이 메소드를 끝으로 UI 가 표시되고 이벤트를 받을 준비가 완료된다.</li>
</ul>
</li>
<li><p>onSaveInstanceState(outState: Bundle?) : 액티비티 상태를 저장하기에 좋은 함수다. (액티비티 deinit 혹은 백그라운드 상태변경 시 호출되는 함수인 듯)</p>
</li>
<li><p>onPause() : 액티비티 백그라운드 전환 또는 대화상자나 다른 액티비티가 나타날 때 호출.</p>
</li>
<li><p>onStop() : 액티비티가 가려질 때 호출. 액티비티는 백그라운드.</p>
</li>
<li><p>onDestroy() : 시스템 자원이 부족할 시 안드로이드가 자동으로 액티비티를 deinit(finish 함수 호출).</p>
</li>
<li><p>액티비티 시작 -&gt; onCreate() -&gt; onStart() -&gt; (액티비티 화면 표시) -&gt; onResume() -&gt; (액티비티 동작 및 실행) -&gt; (홈버튼 눌러서 액티비티 보이지 않음) -&gt; onStop() -&gt; (다시 앱 실행) -&gt; onRestart() -&gt; onStart, onResume, 액티비티 실행, onPause, onStop -&gt; (앱 종료) -&gt; onDestroy() -&gt; (액티비티 종료)</p>
</li>
</ul>
<pre><code class="language-kotlin">package com.example.activitycallback

import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import android.util.Log

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        Log.d(TAG, &quot;onCreate&quot;)
    }

    override fun onRestart() {
        super.onRestart()
        Log.d(TAG, &quot;onRestart&quot;)
    }

    override fun onStart() {
        super.onStart()
        Log.d(TAG, &quot;OnStart&quot;)
    }

    override fun onRestoreInstanceState(savedInstanceState: Bundle) {
        super.onRestoreInstanceState(savedInstanceState)
        Log.d(TAG, &quot;OnRestoreInstanceState&quot;)
    }

    override fun onResume() {
        super.onResume()
        Log.d(TAG, &quot;onResume&quot;)
    }

    override fun onPause() {
        super.onPause()
        Log.d(TAG, &quot;onPause&quot;)
    }

    override fun onStop() {
        super.onStop()
        Log.d(TAG, &quot;onStop&quot;)
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        Log.d(TAG, &quot;onSaveInstanceState&quot;)
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d(TAG, &quot;onDestroy&quot;)
    }

    companion object {
        private const val TAG = &quot;MainActivity&quot;
    }
}</code></pre>
<h2 id="액티비티-상태-저장-및-복원">액티비티 상태 저장 및 복원</h2>
<p>기기 회전이 구성 변경을 일으켜 액티비티를 재생성한다고 배웠다. 메모리 확보를 위해 액티비티 종료가 발생하기도 한다.</p>
<p>그 때문에 액티비티 상태를 보존하고 복원하는 것이 중요하다.</p>
<p>android:textSize 의 단위를 sp 로 지정하는데, 이는 밀도 독립적인 픽셀을 나타낸다. 앱이 실행되는 기기의 밀도에 따라 크기를 정의하되 사용자 설정에 따라 텍스트 크기도 변경 가능하다.</p>
<p>layout xml 파일에서 안드로이드 프레임워크는 id 가 지정된 경우에만 상태를 저장한다.</p>
<p>onSaveInstanceState 에서 Bundle 값을 키-값 형태로 저장하고 onRestoreInstanceState 나 onCreate 에서 다시 가져와 사용한다. 간단한 로직이라면 onCreate 에서, 아니면 onRestoreInstanceState 에서 복구한다.</p>
<p>Bundle 을 이용하는 방법도 있지만 안드로이드 프레임워크에서 제공하는 안드로이드 아키텍처 컴포넌트(AAC, Android Architecture Component) 중 하나인 ViewModel 을 이용해 상태를 저장하고 복원할 수도 있다.</p>
<h2 id="인텐트와의-액티비티-상호작용">인텐트와의 액티비티 상호작용</h2>
<p>안드로이드에서 인텐트는 컴포넌트 간의 통신 메커니즘이다. 앱을 제작할 때 대부분의 경우 현재 액티비티에서 어떤 동작이 발생하면 특정한 다른 액티비티가 시작되기를 원할 수 있다.</p>
<p>정확히 어떤 액티비티가 시작될지 지정하는 걸 명시적 인텐트라고 한다.</p>
<p>묵시적 인텐트의 예시는 카메라이다. 카메라 시작 인텐트를 보내고 시스템이 그걸 처리하는 방식이다.</p>
<p>인텐트 관련 이벤트에 응답하려면 인텐트 필터를 등록해야 한다. 인텐트 필터는 AndroidManifest.xml 의 intent-filter 태그를 사용한다.</p>
<pre><code class="language-xml">&lt;intent-filter&gt;
  &lt;action android:name=&quot;android.intent.action.MAIN&quot; /&gt;
  &lt;category android:name=&quot;android.intent.category.LAUNCHER&quot; /&gt;
&lt;/intent-filter&gt;</code></pre>
<p>android.intent.action.MAIN 는 앱의 주 진입점을 말한다. android.intent.category.LAUNCHER 는 앱이 런처에 표시되게 해준다. 둘을 합치면 런처에서 앱 아이콘을 클릭할 때 앱이 실행된다는 의미다.</p>
<p>실제 앱을 만들어서 인텐트를 이용해 두 액티비티가 상호작용하는 과정을 설명한다.</p>
<pre><code class="language-kotlin">const val RAINBOW_COLOR_NAME = &quot;RAINBOW_COLOR_NAME&quot;
const val RAINBOW_COLOR = &quot;RAINBOW_COLOR&quot;
const val DEFAULT_COLOR = &quot;#FFFFFF&quot;
class MainActivity : AppCompatActivity() {
    private val startForResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
        activityResult -&gt; val data = activityResult.data
        val backgroundColor = data?.getIntExtra(
            RAINBOW_COLOR,
            Color.parseColor(DEFAULT_COLOR))
            ?: Color.parseColor(DEFAULT_COLOR)

        val colorName = data?.getStringExtra(RAINBOW_COLOR_NAME) ?: &quot;&quot;
        val colorMessage = getString(R.string.color_chosen_message, colorName)

        val rainbowColor = findViewById&lt;TextView&gt;(R.id.rainbow_color)
        rainbowColor.setBackgroundColor(ContextCompat.getColor(this, backgroundColor))
        rainbowColor.text = colorMessage
        rainbowColor.isVisible = true
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById&lt;Button&gt;(R.id.submit_button)
            .setOnClickListener {
                startForResult.launch(
                    Intent(
                        this,
                        RainbowColorPickerActivity::class.java))
            }
    }
}

class RainbowColorPickerActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_rainbow_color_picker)

        val colorPickerClickListener = View.OnClickListener { view -&gt;
            when (view.id) {
                R.id.red_button -&gt; setRainbowColor(
                    getString(R.string.red),
                    R.color.red)
                R.id.orange_button -&gt; setRainbowColor(
                    getString(R.string.orange),
                    R.color.orange)
                R.id.yellow_button -&gt; setRainbowColor(
                    getString(R.string.yellow),
                    R.color.yellow)
                R.id.green_button -&gt; setRainbowColor(
                    getString(R.string.green),
                    R.color.green)
                R.id.blue_button -&gt; setRainbowColor(
                    getString(R.string.blue),
                    R.color.blue)
                R.id.indigo_button -&gt; setRainbowColor(
                    getString(R.string.indigo),
                    R.color.indigo)
                R.id.violet_button -&gt; setRainbowColor(
                    getString(R.string.violet),
                    R.color.violet)
                else -&gt; {
                    Toast.makeText(this,
                        getString(R.string.unexpected_color), Toast.LENGTH_LONG
                    ).show()
                }
            }
        }

        findViewById&lt;View&gt;(R.id.red_button).setOnClickListener(colorPickerClickListener)
        findViewById&lt;View&gt;(R.id.blue_button).setOnClickListener(colorPickerClickListener)
        findViewById&lt;View&gt;(R.id.yellow_button).setOnClickListener(colorPickerClickListener)
        findViewById&lt;View&gt;(R.id.green_button).setOnClickListener(colorPickerClickListener)
        findViewById&lt;View&gt;(R.id.blue_button).setOnClickListener(colorPickerClickListener)
        findViewById&lt;View&gt;(R.id.indigo_button).setOnClickListener(colorPickerClickListener)
        findViewById&lt;View&gt;(R.id.violet_button).setOnClickListener(colorPickerClickListener)
    }

    private fun setRainbowColor(colorName: String, color: Int) {
        Intent().let { pickedColorIntent -&gt;
            pickedColorIntent.putExtra(RAINBOW_COLOR_NAME, colorName)
            pickedColorIntent.putExtra(RAINBOW_COLOR, color)
            setResult(Activity.RESULT_OK, pickedColorIntent)
            finish()
        }
    }
}</code></pre>
<p><code>registerForActivityResult</code> 를 이용해서 액티비티의 상호작용을 미리 정의해 놓는다. registerForActivityResult 내에는 activityResult.data 를 통해 원시타입 데이터들을 사용할 수 있다. 키는 미리 상수로 정의해 두었다. 이를 통해 넘어온 데이터로 뷰의 속성을 새로 정의하고 있다.</p>
<p>registerForActivityResult 로 만들어지는 <code>ActivityResultLauncher</code> 의 launch 함수를 이용해 인텐트를 정의한다. <code>Intent(this, RainbowColorPickerActivity::class.java)</code> 는 새로운 액티비티를 생성한다는 의미이다. MainActivity.kt 는 이쯤 보면 될 것 같다.</p>
<p>RainbowColorPickerActivity.kt 는 <code>setRainbowColor</code> 가 중요하겠다. Intent() 를 이용해 MainActivity 에서 생성한 인텐트를 불러오고 전달된 String, Int 를 putExtra 함수로 추가하고 있다. 세팅이 끝났다면 setResult 로 결과 반환 및 finish 로 액티비티를 종료시키고 이전 액티비티를 보여주게 한다.</p>
<h2 id="인텐트-태스크-및-런치-모드">인텐트, 태스크 및 런치 모드</h2>
<p>앱을 런처에서 열면 앱은 자체적인 Task 를 생성하고 생성한 각 액티비티는 Back Stack 에 추가된다. 여기서 동작방식이 액티비티 런치 모드에 따라 달라진다.</p>
<p>아래 3개는 일반적으로 사용하지는 않는다.</p>
<ul>
<li>standard : 사용자가 3 개의 동일한 액티비티를 차례로 열도록 작업을 했다면 뒤로가기 3번 눌렀을 때 이전 화면/액티비티로 이동한 후 기기의 홈 화면으로 돌아가지만 여전히 앱은 실행돼 있는 상태를 유지한다.</li>
<li>singleTop : 백스택 최상단에 위치한 액티비티가 또 실행되면 onNewIntent 콜백을 실행하며 기존의 액티비티 인스턴스를 재활용한다.</li>
<li>singleTask</li>
<li>singleInstance</li>
<li>singleInstancePerTask</li>
</ul>
<p>Activity 를 추가하게 되면 AndroidManifest.xml 에 activity 태그가 추가되는데 여기에 속성으로 android:launchMode=&quot;singleTop&quot; 을 주게 되면 singleTop 모드가 된다. 기본은 당연히 standard.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android 공부 (1)]]></title>
            <link>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-1</link>
            <guid>https://velog.io/@sanghwi_back/Android-%EA%B3%B5%EB%B6%80-1</guid>
            <pubDate>Sun, 31 Aug 2025 05:09:55 GMT</pubDate>
            <description><![CDATA[<h2 id="androidmanifestxml">AndroidManifest.xml</h2>
<ul>
<li>핵심 구성요소 정의</li>
<li>activity 와 그 하위 intent 사용</li>
<li>activity 는 화면, intent 중 name 이 android.intent.action.MAIN 이 들어있으면 앱의 주 진입점 의미. android.intent.category.LAUNCHER 는 런처에 표시된다는 의미.<ul>
<li>특정 기능들은 권한까지 정의해야 하는데 Normal(네트워크, 블루투스 등 권한) / Signature(동일 인증서 서명 앱들인지 구분) / Dangerous(개인 정보 접근)</li>
</ul>
</li>
</ul>
<h2 id="웹뷰-표시해보기">웹뷰 표시해보기</h2>
<ul>
<li><p>MainActivity 의 onCreate 에서 시작한다. setContentView 에 webView 를 추가한다.</p>
</li>
<li><p>오류난다. 인터넷 권한 허용해야 한다.</p>
</li>
<li><p>AndroidManifest.xml 열어 아래 코드 추가한다. <application> 태그 위에 추가한다.</p>
  <uses-permission android:name="android.permission.INTERNET" />

</li>
</ul>
<h2 id="gradle">Gradle</h2>
<ul>
<li>빌드 도구. 라이브러리 간 의존성 관리. 그루비라는 자바 가상 머신에서 사용하는 언어를 사용.</li>
<li>빌드 및 구성 정보가 저장되는 파일은 build.gradle.kts<ul>
<li>최상위 하나(프로젝트 수준), 앱에 별도로 하나(앱 수준) 총 2개 만들어진다.</li>
</ul>
</li>
<li>프로젝트 수준의 build.gradle.kts 는 몇 줄 안된다. 각각의 의미는 다음과 같다.</li>
</ul>
<pre><code class="language-xml">plugins { // Gradle 은 플러그인 시스템을 기반으로 동작한다.
    alias(libs.plugins.android.application) apply false // 앱 생성을 지원한다. apply false 는 플러그인을 하위 프로젝트 및 모듈에만 적용하고 프로젝트 루트 수준엔 적용하지 않는다는 의미.
    alias(libs.plugins.kotlin.android) apply false // 프로젝트에서 코틀린 지원 제공
}</code></pre>
<ul>
<li>앱 수준의 build.gradle.kts 는 좀 길다. 각각의 의미는 다음과 같다.</li>
</ul>
<pre><code class="language-xml">plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
}

android {
    namespace = &quot;com.example.myapplication&quot; // 프로젝트 패키지 명. 빌드 및 리소스 식별자 생성에 사용
    compileSdk = 36 // 앱 컴파일 API 레벨. 하위호환 제공

    defaultConfig {
        applicationId = &quot;com.example.myapplication&quot; // 구글플레이에서의 앱 아이디
        minSdk = 24 // 앱이 지원하는 최소 API 레벨. 버전 낮은 기기에서는 이 값에 의해 구글플레이에 노출되지 않을 수 있음.
        targetSdk = 36 // 앱의 타겟 API 레벨. 가장 최적화된 버전 표시
        versionCode = 1 // 앱 업데이트 시 하나 업데이트 해야 함.
        versionName = &quot;1.0&quot; // 버전 이름. X.Y.Z 로 표기

        testInstrumentationRunner = &quot;androidx.test.runner.AndroidJUnitRunner&quot; // UI 테스트에 활용할 실행기
    }

    buildTypes { // 릴리즈 빌드 관련
        release {
            isMinifyEnabled = false // true 로 하면 사용 안하는 코드 제거 후 앱 난독화하여 앱 크기 줄임
            proguardFiles(
                getDefaultProguardFile(&quot;proguard-android-optimize.txt&quot;),
                &quot;proguard-rules.pro&quot;
            )
        }
    }
    compileOptions { // 자바, 바이트코드 언어 수준 명시
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    kotlinOptions { // kotlin gradle 플러그인에서 사용할 jvm 라이브러리를 나타낸다.
        jvmTarget = &quot;11&quot;
    }
}

dependencies { // SDK 내 앱이 사용할 라이브러리 지정. 버전 표기는 groupId, artifactId, versionId 를 : 로 구분

    implementation(libs.androidx.core.ktx) // 젯팩컴포즈
    implementation(libs.androidx.appcompat) // 하위호환 + 젯팩컴포즈
    implementation(libs.material) // Material 디자인 구성요소
    implementation(libs.androidx.activity) // 액티비티
    implementation(libs.androidx.constraintlayout) // ConstraintLayout ViewGroup
    testImplementation(libs.junit) // Unit-Test 라이브러리
    androidTestImplementation(libs.androidx.junit) // UI-Test 라이브러리
    androidTestImplementation(libs.androidx.espresso.core) // 테스트 관련 에스프레소 라이브러리
}</code></pre>
<h2 id="setting">Setting</h2>
<ul>
<li>모듈 추가할 때 쓴다. 아래가 처음 생성되는 파일 내용인데 맨 밑에 app 만 추가되어 있다.</li>
</ul>
<pre><code class="language-xml">pluginManagement {
    repositories {
        google {
            content {
                includeGroupByRegex(&quot;com\\.android.*&quot;)
                includeGroupByRegex(&quot;com\\.google.*&quot;)
                includeGroupByRegex(&quot;androidx.*&quot;)
            }
        }
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = &quot;My Application&quot;
include(&quot;:app&quot;)</code></pre>
<h2 id="material-design---theme">Material Design - Theme</h2>
<ul>
<li>새 프로젝트를 생성하고 build.gradle.kts 파일을 찾는다. libs.materal 이 dependencies 에 정의되어 있는지 확인한다.</li>
<li>themes.xml 파일을 연다. (app/src/main/res/values, app/src/main/res/values/night) 다음과 같이 수정한다.</li>
</ul>
<pre><code class="language-xml">&lt;resources xmlns:tools=&quot;http://schemas.android.com/tools&quot;&gt;
    &lt;!-- Base application theme. --&gt;
    &lt;style name=&quot;Base.Theme.MyApplication&quot; parent=&quot;Theme.Material3.DayNight.NoActionBar&quot;&gt;
        &lt;!-- Customize your light theme here. --&gt;
        &lt;item name=&quot;colorPrimary&quot;&gt;@color/purple_500&lt;/item&gt;
        &lt;item name=&quot;colorPrimaryVariant&quot;&gt;@color/purple_700&lt;/item&gt;
        &lt;item name=&quot;colorOnPrimary&quot;&gt;@color/white&lt;/item&gt;
        &lt;item name=&quot;colorSecondary&quot;&gt;@color/teal_200&lt;/item&gt;
        &lt;item name=&quot;colorSecondaryVariant&quot;&gt;@color/teal_700&lt;/item&gt;
        &lt;item name=&quot;colorOnSecondary&quot;&gt;@color/black&lt;/item&gt;
        &lt;item name=&quot;android:statusBarColor&quot;&gt;?attr/colorPrimaryVariant&lt;/item&gt;
    &lt;/style&gt;

    &lt;style name=&quot;Theme.MyApplication&quot; parent=&quot;Base.Theme.MyApplication&quot; /&gt;
&lt;/resources&gt;</code></pre>
<p>AppCompatActivity 는 안드로이드 액티비티 클래스로 액티비티 Life-Cycle 을 관리(콜백 함수 실행)한다. 처음 프로젝트 생성 시 만들어지는 아래와 비슷하며, 각각의 의미는 주석으로 달아놓았다.</p>
<pre><code class="language-kotlin">class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) { // 이전에 저장한 상태를 Bundle 매개변수에 저장해서 전달해준다.
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main) // 액티비티에 표시하려는 레이아웃을 로드한다.
    }
}</code></pre>
<p>최초 생성되는 파일들을 더 살펴보자.</p>
<ul>
<li>ExampleInstrumentedTest : UI-Test example</li>
<li>ExampleUnitTest : Unit-Test example</li>
<li>ic_launcer_background.xml / ic_launcher_foreground.xml : Launcher Icon as vector</li>
<li>activity_main.xml : MainActivity Layout file. Starts header -&gt; ViewGroup -&gt; Views..</li>
</ul>
<p>ConstraintsLayout 은 제약조건으로 뷰를 그리는데 상당히 유용하다. XML Namespace 3 개가 나오니 살펴보자.</p>
<ul>
<li>xmlns:android : Android namespace. SDK 내 모든 속성과 값에 사용. (거의 필수적으로 들어가는 듯)</li>
<li>xmlns:app : Library namespace. SDK 에 포함되어 있지는 않고 라이브러리로서 추가 됨.</li>
<li>xmlns:tools : Metadata namespace. tools:context 를 통해 레이아웃이 사용되는 코드 위치를 나타내고, 미리보기 텍스트를 정의하는 데도 사용하는 등 메타데이터를 추가하는데 이용된다.</li>
</ul>
<p>webp 파일은 각기 다른 픽셀 밀도를 가진 기기에 이미지를 대응하기 위한 파일이다. 구글에 의해 만들어 졌으며, PNG 에 비해 압축률이 크다. 위의 ic_launcher_background.xml 처럼 벡터 형식 이전에는 webp 파일을 사용했다.</p>
<p>픽셀 밀도는 다음과 같다.</p>
<ul>
<li>nodpi: dpi 독립적</li>
<li>ldpi : 120dpi</li>
<li>mdpi : 160dpi. 여기가 기준 값</li>
<li>hdpi : 240dpi</li>
<li>xhdpi : 320dpi</li>
<li>xxhdpi : 480dpi</li>
<li>xxxhdpi : 640dpi</li>
<li>tvdpi : 약 213dpi</li>
</ul>
<p>밑으로 갈수록 (tvdpi 빼고) 픽셀의 수가 높고, 더 큰 화면에 대응한다. 픽셀6 에뮬의 픽셀 밀도가 411 dpi 인데 이는 xxhdpi(480dpi) 를 사용한다.</p>
<p>픽셀 밀도를 대체하는 법은 Bitmap-Drawable 이라는 것도 있다. 스케일 비율 3:4:6:8:12:16 을 따라야 한다.</p>
<ul>
<li>mdpi : 48x48</li>
<li>hdpi : 72x72</li>
<li>xhdpi : 96x96</li>
<li>xxhdpi : 144x144</li>
<li>xxxhdpi : 192x192</li>
</ul>
<p>Tip : 런쳐 아이콘은 일반 이미지보다 약간 크게 생성한다. 일부 런처는 이미지를 확대해 사용할 수 있기 때문에 흐려지는 경우가 있다. 이를 방지하자.</p>
<p>앱에 사용되는 리소스는 아래와 같다.</p>
<ul>
<li>colors.xml</li>
<li>strings.xml</li>
<li>themes.xml : AndroidManifest.xml 파일에 액티비티, 최상위 테마를 지정할 수 있음.</li>
<li>styles.xml : View 요소에서 직접 접근할 수 있는 스타일을 지정한다. xml 형태이니 각 요소에 대해 item 태그로 추가하면 된다.</li>
</ul>
<pre><code class="language-xml">&lt;style name=&quot;screen_layout_margin&quot;&gt;
    &lt;item name=&quot;android:layout_margin&quot;&gt;12dp&lt;/item&gt;
&lt;/style&gt;</code></pre>
<p>Text 를 입력하는 데엔 TextView, EditText 등을 사용할 수 있다. 디자인 적용을 더 하고 싶으면 TextInputLayout, TextInputEditText 를 사용하자. Button 말고 MaterialButton 도 좋은 선택이다.</p>
<p>TextView, EditTextView 유효성 검사는 중요하다. 아래 속성들은 미리 입력을 제한하는 속성들이다.</p>
<ul>
<li>android:digits=&quot;0123456789.&quot; : 허용할 개별 문자 정의</li>
<li>android:maxLength=&quot;15&quot; : 최대 문자 수 정의</li>
<li>android:inputType : &quot;textPassword&quot; 는 비밀번호, &quot;Phone&quot; 은 전화번호</li>
</ul>
<h2 id="예제-소스코드-작성">예제 소스코드 작성</h2>
<p>주요 파일만 작성함.</p>
<pre><code class="language-kotlin">class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_main)

        findViewById&lt;Button&gt;(R.id.calculateButton)?.setOnClickListener {
            fun getText(textView: TextView): String {
                return textView.text.toString().trim()
            }

            val tRed = findViewById&lt;TextView&gt;(R.id.redText)
            val tGreen = findViewById&lt;TextView&gt;(R.id.greenText)
            val tBlue = findViewById&lt;TextView&gt;(R.id.blueText)

            val colorHex = &quot;#${getText(tRed)}${getText(tGreen)}${getText(tBlue)}&quot;

            val color = colorHex.toColorInt()

            findViewById&lt;Button&gt;(R.id.calculateButton)?.setBackgroundColor(color)
        }
    }
}</code></pre>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;androidx.constraintlayout.widget.ConstraintLayout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;
    android:id=&quot;@+id/main&quot;
    android:layout_width=&quot;match_parent&quot;
    android:layout_height=&quot;match_parent&quot;
    style=&quot;@style/screen_layout&quot;
    tools:context=&quot;.MainActivity&quot;&gt;

    &lt;TextView
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;wrap_content&quot;
        android:text=&quot;@string/channel_red&quot;
        android:id=&quot;@+id/redTitle&quot;
        style=&quot;@style/text_view_margin&quot;
        app:layout_constraintStart_toStartOf=&quot;parent&quot;
        app:layout_constraintEnd_toEndOf=&quot;parent&quot;
        app:layout_constraintTop_toTopOf=&quot;parent&quot;/&gt;

    &lt;com.google.android.material.textfield.TextInputLayout
        android:id=&quot;@+id/redChannel&quot;
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;wrap_content&quot;
        app:layout_constraintTop_toBottomOf=&quot;@id/redTitle&quot;
        app:layout_constraintStart_toStartOf=&quot;parent&quot;
        app:layout_constraintEnd_toEndOf=&quot;parent&quot; &gt;

        &lt;com.google.android.material.textfield.TextInputEditText
            android:id=&quot;@+id/redText&quot;
            android:layout_width=&quot;match_parent&quot;
            android:layout_height=&quot;wrap_content&quot;
            android:hint=&quot;@string/hint_red&quot;
            android:maxLength=&quot;2&quot;
            android:digits=&quot;0123456789ABCDEF&quot; /&gt;
    &lt;/com.google.android.material.textfield.TextInputLayout&gt;

    &lt;TextView
        android:id=&quot;@+id/greenTitle&quot;
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;wrap_content&quot;
        android:text=&quot;@string/channel_green&quot;
        style=&quot;@style/text_view_margin&quot;
        app:layout_constraintStart_toStartOf=&quot;parent&quot;
        app:layout_constraintEnd_toEndOf=&quot;parent&quot;
        app:layout_constraintTop_toBottomOf=&quot;@id/redChannel&quot; /&gt;

    &lt;com.google.android.material.textfield.TextInputLayout
        android:id=&quot;@+id/greenChannel&quot;
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;wrap_content&quot;
        app:layout_constraintTop_toBottomOf=&quot;@id/greenTitle&quot;
        app:layout_constraintStart_toStartOf=&quot;parent&quot;
        app:layout_constraintEnd_toEndOf=&quot;parent&quot;&gt;

        &lt;com.google.android.material.textfield.TextInputEditText
            android:id=&quot;@+id/greenText&quot;
            android:layout_width=&quot;match_parent&quot;
            android:layout_height=&quot;wrap_content&quot;
            android:hint=&quot;@string/hint_green&quot;
            android:maxLength=&quot;2&quot;
            android:digits=&quot;0123456789ABCDEF&quot; /&gt;
    &lt;/com.google.android.material.textfield.TextInputLayout&gt;

    &lt;TextView
        android:id=&quot;@+id/blueTitle&quot;
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;wrap_content&quot;
        android:text=&quot;@string/channel_blue&quot;
        style=&quot;@style/text_view_margin&quot;
        app:layout_constraintStart_toStartOf=&quot;parent&quot;
        app:layout_constraintEnd_toEndOf=&quot;parent&quot;
        app:layout_constraintTop_toBottomOf=&quot;@id/greenChannel&quot; /&gt;

    &lt;com.google.android.material.textfield.TextInputLayout
        android:id=&quot;@+id/blueChannel&quot;
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;wrap_content&quot;
        app:layout_constraintTop_toBottomOf=&quot;@id/blueTitle&quot;
        app:layout_constraintStart_toStartOf=&quot;parent&quot;
        app:layout_constraintEnd_toEndOf=&quot;parent&quot;&gt;

        &lt;com.google.android.material.textfield.TextInputEditText
            android:id=&quot;@+id/blueText&quot;
            android:layout_width=&quot;match_parent&quot;
            android:layout_height=&quot;wrap_content&quot;
            android:hint=&quot;@string/hint_blue&quot;
            android:maxLength=&quot;2&quot;
            android:digits=&quot;0123456789ABCDEF&quot; /&gt;
    &lt;/com.google.android.material.textfield.TextInputLayout&gt;

    &lt;com.google.android.material.button.MaterialButton
        android:id=&quot;@+id/calculateButton&quot;
        android:layout_width=&quot;match_parent&quot;
        android:layout_height=&quot;wrap_content&quot;
        android:text=&quot;@string/button_title_calculate&quot;
        style=&quot;@style/button_margin&quot;
        app:layout_constraintStart_toStartOf=&quot;parent&quot;
        app:layout_constraintEnd_toEndOf=&quot;parent&quot;
        app:layout_constraintTop_toBottomOf=&quot;@id/blueChannel&quot;/&gt;
&lt;/androidx.constraintlayout.widget.ConstraintLayout&gt;</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[어려워도 이해해보자 - Next.js Parallel Routes & Intercepting Routes]]></title>
            <link>https://velog.io/@sanghwi_back/%EC%96%B4%EB%A0%A4%EC%9B%8C%EB%8F%84-%EC%9D%B4%ED%95%B4%ED%95%B4%EB%B3%B4%EC%9E%90-Next.js-Parallel-Routes-Intercepting-Routes</link>
            <guid>https://velog.io/@sanghwi_back/%EC%96%B4%EB%A0%A4%EC%9B%8C%EB%8F%84-%EC%9D%B4%ED%95%B4%ED%95%B4%EB%B3%B4%EC%9E%90-Next.js-Parallel-Routes-Intercepting-Routes</guid>
            <pubDate>Sat, 24 May 2025 14:24:55 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/1d54b0aa-cdbe-4918-93c4-c10cbd697a07/image.png" alt="">
[출처] - <a href="https://www.krds.go.kr/html/site/index.html">KRDS</a> (한국 디자인 시스템)</p>
<blockquote>
<p>웹 개발을 결심했다면 결국 SSR(Server Side Rendering) 에 관심을 가지지 않을 수는 없다. 그나마 쉽게 만든 것이 Next.js 라고 하는데 개인적으론 이것도 어렵다. 그래도 하고 싶은 것만 할 수는 없으니 특히나 어려운 것을 직관적으로 설명하고자 이 게시글을 작성해본다.</p>
</blockquote>
<h2 id="이-지긋지긋한-프레임워크-같으니라고">이 지긋지긋한 프레임워크 같으니라고..</h2>
<p>한 주제로 이렇게 오랫동안 앱을 만들기를 망설여본 프레임워크는 처음이다.</p>
<p>개인적으론 공부 -&gt; 아무거나 하나 만들기 -&gt; 다시 공부 (깊게) -&gt; 아무거나 또 하나 만들기 (깊게) 를 반복하면서 계속 깊이를 더해가는(+ 이력서에 기술 스택을 추가하며) 편이다.</p>
<p>솔직히 서버, 클라이언트 컴포넌트 도 제대로 사용 못했다. 서버 컴포넌트에 useState 를 비롯한 Hook 을 못쓰는데 어떻게 앱을 만들라는 건가?</p>
<p>최대한 서버 컴포넌트 안에 클라이언트 컴포넌트를 작게 만들어서 어떻게든 해봤는데 내부 작동방식도 모르고 그냥 하니 찝찝하다. 컴포넌트의 렌더링과 동작방식은 다음 포스팅에 다뤄봐야겠다.</p>
<p>신세한탄은 여기까지 하고 이것만큼 어려웠던 Intercepting &amp; Parallel Routes 를 이해해보는 시간을 가져보고자 한다. (아 useState 쓰게 해줬으면 모달 만들려고 이 난리 안피워도 되잖슴)</p>
<h2 id="간단한-예제-만들기">간단한 예제 만들기</h2>
<p>대충 이런 앱을 만들어볼 것이다.</p>
<blockquote>
<p>대시보드를 보여주는 페이지이다.</p>
<ul>
<li>왼쪽 상단에는 데이터에 대한 Chart 를 보여준다.</li>
<li>오른쪽 상단에는 데이터를 표로 보여준다.</li>
<li>아래는 데이터를 격자 형태로 보여준다.</li>
<li>위의 세 개를 클릭하면 모달을 보여준다.</li>
</ul>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/bb15f7ee-0bbb-4d16-8391-6794d9c316dd/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/c6a9060d-97af-4d4c-a4d2-230c4a16a411/image.png" alt=""></p>
<p>하루동안 AI 와 함께 열심히 만든 화면이다. (<a href="https://github.com/SangHwi-Back/ReactDojo/tree/main/parallel-intercepting-route-test">GitHub링크</a>)</p>
<p>sticky 된 부분이 강조된 것, &quot;School Dashboard&quot; 라는 컴포넌트가 사라졌다 말았다 하는 부분을 해결하지 못하고 넘어가는 것이 아쉽지만 굳이 이런 부분을 고치기보단 다루려는 주제를 빠르게 다루는게 우선이라고 생각했다.</p>
<p>복잡한 UI 는 라이브러리를 통해 해결하였다. 참고할 내용부터 간단히 짚고 넘어가자.</p>
<h3 id="parallel-routes">Parallel Routes</h3>
<p>Next.js 는 어떤 화면을 Layout 이라는 컴포넌트에 담는다. Layout 은 Page 를 포함하는 컴포넌트로 한번 로드된 후에는 다시 로드되지 않는다. Page 내 하위 페이지가 Layout 을 정의하지 않는다면 하위 Page 는 상위 Page 의 Layout 에 그대로 속한다.</p>
<p>물론 Layout 안에는 Page 를 넣지 않을수도, Page 를 여러 개 넣을 수도 있다. 물론 이렇게 하나의 Layout 안에 여러 개의 Page 를 넣으려면 추가 작업이 필요하다.</p>
<blockquote>
<p>src 디렉토리를 선호하나 간략히 표현하고자 제외. Parallel Routes 와 직접적인 관련이 없는 경우 또한 제외.</p>
</blockquote>
<pre><code class="language-shell">app
|-@chart
  |-page.tsx
|-@grid
  |-page.tsx
|-@table
  |-page.tsx
-layout.tsx
-page.tsx</code></pre>
<p>@chart 내에는 URL 화면 <strong>/</strong> 내의 파이 <strong>차트</strong> 부분을 담당한다. @grid, @table 은 각각 <strong>전체</strong>, <strong>테이블</strong> 을 담당한다.</p>
<p>Next.js 는 파일시스템 기반의 라우팅 방식을 적용한다. app 폴더 내의 다른 폴더는 URL 의 Context-Path 를 담당한다는 뜻이다. 예를 들어 app 폴더 내 detail 폴더가 있다면 <strong>/detail</strong> 을 의미한다.</p>
<pre><code class="language-shell"># 실제 앱에는 detail 말고는 다른 폴더는 존재하지 않을 것이다.
app
|-detail # /detail
  |-page.tsx
|-setting # /setting
  |-page.tsx
  |-detail # /setting/detail
    |-page.tsx
|-my # /my
  |-page.tsx
-layout.tsx
-page.tsx</code></pre>
<p>그런데 이것만으론 부족한데</p>
<ul>
<li>어떤 화면에서 실제 다시 렌더링해야 할 컴포넌트는 일부라면 그 외 다른 컴포넌트가 영향받지 않도록 해야 한다.</li>
<li>하나의 컴포넌트 내에 여러 컴포넌트를 추가하면 복잡성이 올라가고 재사용성이 떨어진다. 즉, 유지보수가 어렵다.</li>
</ul>
<p>그렇기 때문에 <strong>Slot</strong> 이라는 개념을 도입한다.</p>
<h4 id="slot">Slot</h4>
<p>예제의 화면은 대시보드 화면이다. 상당히 많은 정보와 반짝반짝한 컴포넌트가 들어갈 것이다. 만들기 전부터 여러가지 작업들이 예상된다. 그렇다면 이를 분리해서 개발할 수 있다면 좋겠다. (레이아웃만 짜고 다른 개발자에게 부탁을 드릴 수도 있겠다.</p>
<p>Slot 은 최종 Page 컴포넌트를 만들기 위한 컴포넌트이다. 그리고 Routing 에 영향을 주지 않는다. Slot 은 폴더이름에 @ 를 붙이는데, 예를 들어 @chart 폴더 내에 viewChart 폴더가 있을 경우 /@chart/viewChart 가 아닌 /viewChart 로 접근해야 한다.</p>
<p>화면은 layout.tsx 에서 시작한다. layout.tsx 파일이 없다면 부모경로의 layout.tsx, 타고 올라가서 없다면 루트 경로의 layout.tsx 를 참고한다.</p>
<pre><code class="language-shell">app
|-@chart
  |-default.tsx
  |-layout.tsx
  |-page.tsx
|-@grid
  |-default.tsx
  |-layout.tsx
  |-page.tsx
|-@table
  |-default.tsx
  |-layout.tsx
  |-page.tsx
-layout.tsx
-page.tsx</code></pre>
<p>그럼 루트 경로의 layout.tsx 를 간단히 소개해보자.</p>
<pre><code class="language-javascript">/**
 * 아래와 같은 레이아웃
 * [-][-]
 * [----]
 */
type Props = Readonly&lt;{ children: ReactNode, chart: ReactNode, grid: ReactNode, table: ReactNode }&gt;

export default function RootLayout({ children, chart, grid, table }: Props) {
  return (
    &lt;html&gt;
      &lt;body&gt;
        {children} // 루트 경로의 page.tsx
        &lt;div className=&quot;grid grid-rows-[auto_1fr] gap-1 p-4&quot;&gt;
          &lt;div className=&quot;grid grid-cols-[auto_1fr] gap-1&quot;&gt;
            {chart} // @chart 내 layout.tsx 혹은 page.tsx
            {table} // @table 내 layout.tsx 혹은 page.tsx
          &lt;/div&gt;
          &lt;div className=&quot;grid grid-cols-[1fr] gap-4&quot;&gt;
            {grid} // @grid 내 layout.tsx 혹은 page.tsx
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
<p>RootLayout 은 하나의 컴포넌트이며, 다른 컴포넌트를 같이 렌더링하고 있다. 바로 <strong>slot</strong> 이다.</p>
<ul>
<li>children : 같은 경로에 있는 page.tsx 를 뜻한다.</li>
<li>chart : @chart 경로에 있는 layout.tsx 또는 page.tsx 이다.</li>
<li>table : @table 경로에 있는 layout.tsx 또는 page.tsx 이다.</li>
<li>grid : @grid 경로에 있는 layout.tsx 또는 page.tsx 이다.</li>
</ul>
<p>원래 존재하는 children 을 제외한, 같은 경로에 @ 를 앞에 붙인 디렉토리는 모두 slot 인 것이다. 만약 chart 만 업데이트 된다면 chart 만 업데이트 될 것이다.</p>
<p>한가지 주의사항이 있는데 내가 소개한 것은 정적 slot 이다. @[foldername] 은 동적 slot 인데 한 레벨의 slot 에는 모든 slot 을 정적 혹은 동적 하나로 통일해야 한다.</p>
<p>자세한 건 지금 같이 작성하는 공식문서 읽어보기 시리즈가 있으니 거기서 다뤄보겠다.</p>
<h3 id="intercepting-routes">Intercepting Routes</h3>
<p>현재 레이아웃에 다른 Routing 컴포넌트를 불러오는 것이다. 사용자가 현재 컨텍스트에서 벗어나지 않으면서도 다른 화면을 보여주는 데 유용하다(ex. 모달창, 확인창)</p>
<p>공식문서의 예시는 인스타그램같은 피드가 /feed 이고 어떤 사진을 보여주는 모달창은 /photo/123 일 경우를 들고 있다. /feed 컨텍스트 내에서 /photo/123 에서 보여줄 모달창을 보여주고 싶은 상황인 것이다.</p>
<p>파일 시스템 내에선 괄호 안에 넣는 . 의 갯수에 따라 어떤 라우팅을 Intercept 할지 정하는데 그 규칙은 다음과 같다.</p>
<ul>
<li>(.) 은 같은 레벨에 같은 이름 Route 를 Intercept 한다.</li>
<li>(..) 은 한 레벨 위에 같은 이름 Route 를 Intercept 한다.</li>
<li>(..)(..) 은 두 레벨 위에 같은 이름 Route 를 Intercept 한다.</li>
<li>(...) 은 루트 Route 를 Intercept 한다.</li>
<li>여기서의 레벨은 Route 를 말하는 것이지 파일시스템을 말하는 것이 아니다.</li>
</ul>
<pre><code class="language-shell">app
|-feed
  -layout.tsx
  -page.tsx
  |-(..)photo
    |-[id]
      -page.tsx
|-photo
  |-[id]
    -page.tsx
-layout.tsx
-page.tsx</code></pre>
<blockquote>
<p>필자는 처음 보고 정신이 좀 멍해졌다. 제대로 설명할 수 있을지 좀 난감하긴 하지만 해보겠다.</p>
</blockquote>
<p>우선 (..) 를 빼고 보면 photo 폴더가 루트에 하나, feed 폴더에 하나다. 아까 예시에 사용자가 feed 내 위치해 있다고 가정하고 사진을 보는 모달을 띄우고 싶다고 하자. 사진을 띄우는 모달은 루트 내 photo 폴더에 만들어 뒀다.</p>
<p>만약 사용자에게 모달을 보여주려면 feed 폴더 내에 새로 모달을 만들어도 되지만 위처럼 (..)photo 폴더를 만들고 page.tsx 에서 photo 폴더의 page.tsx 를 임포트하는 것만으로 재사용이 가능할 것이다.</p>
<p>즉 위의 예시에서 /feed 에 위치한 상태로 /photo/123 으로 이동하는 코드가 아래와 같이 준비되어 있다면 /feed 화면 내에 다른 화면 이동 없이 /photo/123 을 보여주는 것이다.</p>
<pre><code class="language-javascript">// ---- 클라이언트 컴포넌트 ----
import { useRouter } from &#39;next/navigation&#39;;

router.replace(&#39;/photo/123&#39;);

// ------ 서버 컴포넌트 -------
import Link from &#39;next/link&#39;;

&lt;Link href={&#39;/photo/123&#39;}&gt;
  &lt;Image src=&quot;/COOLIMAGE.svg&quot; width={200} height={200}/&gt;
&lt;/Link&gt;</code></pre>
<p>위의 Parallel Routes 처럼 자세히 알아볼 포스트를 작성할 것이다.</p>
<h3 id="모달-창-만들기">모달 창 만들기</h3>
<p>개인적으로는 일반적인 모달창을 만들어놓고 싶었다. 그래야 나중에 모달을 추가할 일이 있더라도 작업을 최소화하고 싶었기 때문이다.</p>
<p>우선 모달창을 만들어 놓은 /modal 경로의 구조는 다음과 같다.</p>
<pre><code class="language-shell">app
|-modal
  |-[type] # 보여주고 싶은 모달을 동적으로 처리하기 위해 동적 슬롯 적용
    -ChartModalPage.tsx # chart 의 모달
    -GridModalPage.tsx # grid 의 모달
    -page.tsx # 모든 모달은 page.tsx 내 분기하는 코드에 의해 렌더링 됨.
    -TableModalPage.tsx # table 의 모달
  -CommonModal.tsx # 모달 배경과 루트 컴포넌트 담당
  -default.tsx # 렌더링 되지 않았을 경우 보여줄 컴포넌트
  -layout.tsx # URL modal 경로에서 보여줄 컴포넌트의 레이아웃
  -OverviewModal.tsx # 이번 예제에서 보여줄 공통 버튼과 컨텐츠 위치 잡아줌
  |-(..)photo
    |-[id]
      -page.tsx
|-photo
  |-[id]
    -page.tsx
-layout.tsx
-page.tsx</code></pre>
<p>그럼 시작해보자. 우선 CommonModal 을 만들어 공통적인 부분을 미리 만들자.</p>
<pre><code class="language-javascript">&#39;use client&#39; // import excluded

export default function CommonModal({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const dialogRef = useRef&lt;HTMLDialogElement&gt;(null);

  const onDismiss = useCallback(() =&gt; {
    router.back()
  }, [router]);

  useEffect(() =&gt; {
    const handleEscape = (e: KeyboardEvent) =&gt; {
      if (e.key === &#39;Escape&#39;) onDismiss();
    }

    document.addEventListener(&#39;keydown&#39;, handleEscape);
    return () =&gt; {
      document.removeEventListener(&#39;keydown&#39;, handleEscape);
    }
  }, [onDismiss]);

  const Dialog = () =&gt; {
    return &lt;dialog
      ref={dialogRef}
      className=&quot;inset-0 bg-gray-500 bg-opacity-75 transition-opacity flex flex-col items-center justify-center&quot;
      aria-labelledby=&quot;modal-title&quot;
      onClick={() =&gt; router.back()}
    &gt;
      &lt;div
        role=&quot;dialog&quot;
        aria-modal=&quot;true&quot;
        aria-labelledby=&quot;modal-title&quot;
        className=&quot;fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-xl&quot;
        onClick={(e) =&gt; e.stopPropagation()}
      &gt;
        {children}
      &lt;/div&gt;
    &lt;/dialog&gt;
  }

  return createPortal(
    &lt;Dialog/&gt;,
    document.body
  )
}</code></pre>
<p>createPortal 을 이용해 해당 컴포넌트가 다른 리액트 컴포넌트 트리에 라우팅 되도록 하였다. 정적인 zIndex 값을 관리하는 것보다 유용하다는 의견이 많아서 나도 해봤다.</p>
<p>CommonModal 은 잘 보면 알겠지만 우리가 예상할 수 있는 &quot;확인&quot; 혹은 &quot;닫기&quot; 버튼 등이 없다. 모달 창은 여러 요소를 가질 수 있다. 이런 버튼은 동적으로 처리해야 한다.</p>
<p>이번에 구현할 모달창은 공통적으로 닫기 버튼이 우측상단에 존재하고 아래에 컨텐츠가 들어간다. 그러므로 OverviewModal.tsx 가 필요한 것이다.</p>
<pre><code class="language-javascript">&#39;use client&#39; // import excluded

export default function OverviewModal({ children }: { children: ReactNode }) {
  const CommonModal = dynamic(() =&gt; import(&#39;./CommonModal&#39;), {
    ssr: false,
  });
  const router = useRouter();
  const [isPending, startTransition] = useTransition();

  const handleClose = () =&gt; {
    startTransition(() =&gt; {
      router.back();
      router.refresh();
    });
  }

  const ModalButton = () =&gt; {
    return &lt;button
      className={`px-4 py-2 ${isPending ? &#39;bg-gray-300&#39; : &#39;bg-blue-500&#39;} text-white rounded mt-2 me-2`}
      onClick={handleClose}
      disabled={isPending}
    &gt;
      {&#39;닫기&#39;}
    &lt;/button&gt;
  }

  return &lt;CommonModal&gt;
    &lt;div className=&quot;flex flex-col items-end justify-end&quot;&gt;
      &lt;ModalButton/&gt;
      {children}
    &lt;/div&gt;
  &lt;/CommonModal&gt;
}</code></pre>
<p>이 모든 요소를 포함한 layout.tsx 이다. 회색의 투명한 배경가 모달창 위치를 잡아준다.</p>
<pre><code class="language-javascript">import {ReactNode} from &quot;react&quot;;

export default async function ModalLayout({children}: { children: ReactNode }) {
  return &lt;div style={{
    height: &#39;100vh&#39;,
    width: &#39;100vw&#39;,
    backgroundColor: &#39;rgba(105, 105, 105, 0.5)&#39;,
    position: &#39;fixed&#39;,
    display: &#39;flex&#39;,
    alignItems: &#39;center&#39;,
    justifyContent: &#39;center&#39;
  }}&gt;
    &lt;div className={`fixed inset-0 flex flex-col items-end justify-end`}&gt;
      {children}
    &lt;/div&gt;
  &lt;/div&gt;
}</code></pre>
<p>default.tsx 는 아무것도 반환하지 않는다. 기본적으로 보여줘야 할 컴포넌트는 없다.</p>
<pre><code class="language-javascript">export default function Default() {
  return null;
}</code></pre>
<h3 id="좀-더-구체적으로-모달-만들기">좀 더 구체적으로 모달 만들기</h3>
<p>내가 하고 싶은 건 이것이다. 만약 사용자가 &#39;/&#39; 에 있을 경우</p>
<ul>
<li>&#39;/modal/chart&#39; 에 접근하면 ChartModalPage.tsx 를 포함한 모달창을 보여준다.</li>
<li>&#39;/modal/grid&#39; 에 접근하면 GridModalPage.tsx 를 포함한 모달창을 보여준다.</li>
<li>&#39;/modal/table&#39; 에 접근하면 TableModalPage.tsx 를 포함한 모달창을 보여준다.</li>
</ul>
<p>그러기 위해 @chart, @grid, @table 에는 아래와 같은 layout.tsx 가 존재한다. 예시는 @chart/layout.tsx 다.</p>
<pre><code class="language-javascript">// 서버 컴포넌트
export default function Layout({ children }: { children: ReactNode }) {
  return &lt;div className={`bg-gray-50 rounded-2xl w-[400px] h-[400px] flex justify-items-start content-start flex-col m-2`}&gt;
    &lt;div className={`flex ml-2 mr-2 mt-2 justify-between items-center`}&gt;
      &lt;div className=&quot;flex-1&quot;/&gt;
      &lt;h2 className={`text-xl m-1 text-center font-bold flex-1`}&gt;차트&lt;/h2&gt;
      &lt;div className=&quot;flex-1 flex justify-end&quot;&gt;
        &lt;Link href={&#39;/modal/chart&#39;}&gt; // 모달 창 띄움.
          &lt;Image
            src=&quot;/ButtonModal.svg&quot;
            alt=&quot;Modal button&quot;
            width={24}
            height={24}
            className=&quot;w-6 h-6&quot;
          /&gt;
        &lt;/Link&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;div className={`p-2 h-full`}&gt;
      &lt;Suspense fallback={&lt;div&gt;Loading...&lt;/div&gt;}&gt;
        {children}
      &lt;/Suspense&gt;
    &lt;/div&gt;
  &lt;/div&gt;
}</code></pre>
<p>위와 같은 /modal/chart 를 포함한 /modal/grid, /modal/table 를 대응하려면 어떻게 해야 할까? 내 아이디어는 dynamic segment 였다.</p>
<pre><code class="language-shell">app
|-modal
  |-[type] # 요거다
    -ChartModalPage.tsx
    -GridModalPage.tsx
    -page.tsx # 모든 모달은 page.tsx 내 분기하는 코드에 의해 렌더링 됨.
    -TableModalPage.tsx
  -CommonModal.tsx
  -default.tsx
  -layout.tsx
  -OverviewModal.tsx
  |-(..)photo
    |-[id]
      -page.tsx
|-photo
  |-[id]
    -page.tsx
-layout.tsx
-page.tsx</code></pre>
<p>[type] 폴더는 구체적으로 정해진 라우팅 외의 라우팅을 전체적으로 다룬다. 즉, type 내의 page.tsx 가 /modal/?? 에서 chart 인지, grid 인지, table 인지만 알 수 있으면 된다. 그러므로 layout.tsx 필요없이 page.tsx 를 다음과 같이 만든다.</p>
<pre><code class="language-javascript">// 서버 컴포넌트. import excluded
export default async function ModalPage({params}: { params: Promise&lt;{ type: string }&gt; }) {
  const {type} = await params;

  if (type === &#39;table&#39;) {
    return &lt;TableModalPage/&gt;
  } else if (type === &#39;chart&#39;) {
    return &lt;ChartModalPage/&gt;
  } else if (type === &#39;grid&#39;) {
    return &lt;GridModalPage/&gt;
  } else {
    return &lt;div&gt;asdf&lt;/div&gt;
  }
}</code></pre>
<p>동적 라우팅의 값은 위와같이 Promise 객체에 담겨져서 오므로 잘 꺼내 쓰면 된다.</p>
<p>그러므로 이제 각 모달을 만들어주면 된다. 우리는 모달창의 배경, 모달 자체 화면을 공통으로 분리했다. 그러므로 모달 자체 화면 안에 들어갈 내용만 넣어주면 된다.</p>
<pre><code class="language-javascript">// 서버 컴포넌트. import excluded
export default function ChartModalPage() {
  return &lt;OverviewModal&gt;
    &lt;div className=&quot;bg-white rounded-lg p-6 w-[400px] text-center&quot;&gt;
      차트 모달입니당
    &lt;/div&gt;
  &lt;/OverviewModal&gt;
}</code></pre>
<p>다른 모달 페이지들은 단지 문구만 바뀌어 있다.</p>
<h2 id="느낀-점">느낀 점</h2>
<p>사실 Next.js 를 틈날때마다 짬짬이 본 일은 많지만 실제 뭘 만들어 볼 엄두가 나지 않아서 실력을 많이 못 올린 것 같은데 이런 도전을 자주 하면 실력 올리기에 참 좋은 것 같다.</p>
<p>앞으로도 이런식으로 헷갈릴만한 내용을 정리하며 괜찮은 FE 개발자가 되면 아주 조켓다!</p>
<h2 id="출처">출처</h2>
<ul>
<li><a href="https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes">https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes</a></li>
<li><a href="https://nextjs.org/docs/app/building-your-application/routing/parallel-routes">https://nextjs.org/docs/app/building-your-application/routing/parallel-routes</a></li>
<li><a href="https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes">https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes</a></li>
<li><a href="https://velog.io/@rachel28/Next.js-parellel-intercepting-routes%EB%A1%9C-%EB%AA%A8%EB%8B%AC-%EB%A7%8C%EB%93%A4%EA%B8%B0">https://velog.io/@rachel28/Next.js-parellel-intercepting-routes%EB%A1%9C-%EB%AA%A8%EB%8B%AC-%EB%A7%8C%EB%93%A4%EA%B8%B0</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Docs] Next.js from Analytics to JSON-LD]]></title>
            <link>https://velog.io/@sanghwi_back/Docs-Next.js-from-Analytics-to-JSON-LD</link>
            <guid>https://velog.io/@sanghwi_back/Docs-Next.js-from-Analytics-to-JSON-LD</guid>
            <pubDate>Mon, 19 May 2025 14:50:26 GMT</pubDate>
            <description><![CDATA[<h2 id="analytics">Analytics</h2>
<h3 id="how-to-add-analytics-to-your-nextjs-application">How to add analytics to your Next.js application</h3>
<p>성능 측정 기능은 useReportWebVitals 훅을 사용하거나 <a href="https://vercel.com/products/observability">Observability</a> 를 사용하면 자동으로 수집된다.</p>
<h4 id="client-instrumentation">Client Instrumentation</h4>
<p>만약 더 고도화 된 분석이 필요하다면 <code>intrumentation-client.ts</code> 파일을 루트 디렉토리에 생성한다. 이 파일은 FE 코드 중 가장 먼저 실행된다.</p>
<blockquote>
<p>실제 사용했다는 사람이나 사례가 나오지 않는다. 잘 모르겠는데...</p>
</blockquote>
<pre><code class="language-javascript">// Initialize analytics before the app starts
console.log(&#39;Analytics initialized&#39;)

// Set up global error tracking
window.addEventListener(&#39;error&#39;, (event) =&gt; {
  // Send to your error tracking service
  reportError(event.error)
})</code></pre>
<h4 id="build-your-own">Build Your Own</h4>
<p><code>useReportWebVitals</code> 를 사용하려면 <code>&#39;use client&#39;</code> 디렉티브가 필요하므로 Root layout 이 컴포넌트화 된 <code>useReportWebVitals</code> 를 임포트 하면 된다.</p>
<pre><code class="language-javascript">&#39;use client&#39;

import { useReportWebVitals } from &#39;next/web-vitals&#39;

export function WebVitals() {
  useReportWebVitals((metric) =&gt; {
    console.log(metric)
  })
}</code></pre>
<pre><code class="language-javascript">import { WebVitals } from &#39;./_components/web-vitals&#39;

export default function Layout({ children }) {
  return (
    &lt;html&gt;
      &lt;body&gt;
        &lt;WebVitals /&gt;
        {children}
      &lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<h4 id="web-vitals">Web Vitals</h4>
<p>사용자의 UX 경험을 수집한다.</p>
<ul>
<li>TTFB (<a href="https://developer.mozilla.org/ko/docs/Glossary/Time_to_first_byte">Time to First Byte</a>)</li>
<li>FCB (<a href="https://developer.mozilla.org/ko/docs/Glossary/First_contentful_paint">First Contentful Paint</a>)</li>
<li>LCP (<a href="https://web.dev/articles/lcp?hl=ko">Largest Contentful Paint</a>)</li>
<li>FID (<a href="https://web.dev/articles/fid?hl=ko">First Input Delay</a>)</li>
<li>CLS (<a href="https://web.dev/articles/cls?hl=ko">Cumulative Layout Shift</a>)</li>
<li>INP (<a href="https://web.dev/articles/inp?hl=ko">Interaction to Next Paint</a>)</li>
</ul>
<p>위의 약자를 name 프로퍼티에 넣도록 하자.</p>
<pre><code class="language-javascript">&#39;use client&#39;

import { useReportWebVitals } from &#39;next/web-vitals&#39;

export function WebVitals() {
  useReportWebVitals((metric) =&gt; {
    switch (metric.name) {
      case &#39;FCP&#39;: {
        // handle FCP results
      }
      case &#39;LCP&#39;: {
        // handle LCP results
      }
      // ...
    }
  })
}</code></pre>
<h4 id="sending-results-to-external-systems">Sending results to external systems</h4>
<p>외부 시스템에 수집한 데이터를 보내는 것은 해당 시스템이 요구하는 사양을 따르도록 하자.</p>
<h2 id="ci-build-caching">CI Build Caching</h2>
<h3 id="how-to-configure-continuous-integration-ci-build-caching">How to configure Continuous Integration (CI) build caching</h3>
<p><code>.next/cache</code> 는 빌드 시간을 줄이기 위해 Next.js 가 관리하는 디렉토리이다. 아래는 그 예시의 일부이며, <code>.next/cache</code> 관련 설정이 없다면 <strong>No Cache Detected</strong> 에러가 발생한다.</p>
<h4 id="vercel">Vercel</h4>
<p>기본 구성됨. Turborepo 를 사용한다면 <a href="https://vercel.com/docs/monorepos/turborepo">링크</a>를 참고하세요.</p>
<h4 id="circleci">CircleCI</h4>
<p><code>.circleci/config.yml</code> 을 아래와 같이 설정하여 <code>.next/cache</code> 를 생성하도록 한다. 아래 보이는 save_cache 가 없으면 <a href="https://circleci.com/docs/caching/">링크</a> 참조.</p>
<pre><code class="language-yaml">steps:
  - save_cache:
      key: dependency-cache-{{ checksum &quot;yarn.lock&quot; }}
      paths:
        - ./node_modules
        - ./.next/cache</code></pre>
<blockquote>
<p>나머지는 각자 확인하면 되므로 목록만 나열함.</p>
<ul>
<li>Travis CI</li>
<li>GitLab CI</li>
<li>Netlify CI</li>
<li>AWS CodeBuild</li>
<li>GitHub Actions</li>
<li>Bitbucket Pipelines</li>
<li>Heroku</li>
<li>Azure Pipelines</li>
<li>Jenkins (Pipeline)</li>
</ul>
</blockquote>
<h2 id="content-security-policy">Content Security Policy</h2>
<h3 id="how-to-set-a-content-security-policy-csp-for-your-nextjs-application">How to set a Content Security Policy (CSP) for your Next.js application</h3>
<p>cross-site scripting (XSS), clickjacking 등의 보안위협을 막기 위해 Content Security Policy (CSP) 를 사용한다. content sources, scripts, stylesheets, images, fonts, objects, media (audio/video), iframes 등이 실행되는 시작점을 필터링한다.</p>
<h3 id="nonces">Nonces</h3>
<p>nonce 는 한 번 사용되는 유니크한 문자열로, CSP 와 같이 사용되어 특정 스크립트나 스타일들을 실행하거나 넘긴다. Next.js 버전 13.4.2 이상을 권고한다.</p>
<h4 id="why-use-a-nonce">Why use a nonce?</h4>
<p>CSP 가 악성 스크립트를 방지하더라도, 그 스크립트를 실행해야 하는 경우가 있습니다. 만약 적절한 nonce 를 사용한다면 이런 상황을 허용시킨다.</p>
<h4 id="adding-a-nonce-with-middleware">Adding a nonce with Middleware</h4>
<p>Middleware 를 이용해서 헤더를 추가하고 nonce 를 페이지 렌더링 전에 생성한다. 페이지 렌더링마다 새로운 nonce 생성이 필요하므로 dynamic rendering 이 필요하다.</p>
<pre><code class="language-javascript">import { NextRequest, NextResponse } from &#39;next/server&#39;;

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString(&#39;base64&#39;);
  const cspHeader = `
    default-src &#39;self&#39;;
    script-src &#39;self&#39; &#39;nonce-${nonce}&#39; &#39;strict-dynamic&#39;;
    style-src &#39;self&#39; &#39;nonce-${nonce}&#39;;
    img-src &#39;self&#39; blob: data:;
    font-src &#39;self&#39;;
    object-src &#39;none&#39;;
    base-uri &#39;self&#39;;
    form-action &#39;self&#39;;
    frame-ancestors &#39;none&#39;;
    upgrade-insecure-requests;
`;
  // Replace newline characters and spaces
  const contentSecurityPolicyHeaderValue = cspHeader
    .replace(/\s{2,}/g, &#39; &#39;)
    .trim();

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set(&#39;x-nonce&#39;, nonce);

  requestHeaders.set(
    &#39;Content-Security-Policy&#39;,
    contentSecurityPolicyHeaderValue
  );

  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
  response.headers.set(
    &#39;Content-Security-Policy&#39;,
    contentSecurityPolicyHeaderValue
  );

  return response;
}</code></pre>
<p>기본적으로 Middleware 는 모든 리퀘스트에 관여한다. matcher 를 사용하면 특정 상황에서만 Middleware 를 사용하도록 할 수 있다. CSP 가 필요없는 경우에 대한 대응도 필요하다.</p>
<pre><code class="language-javascript">export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    {
      source: &#39;/((?!api|_next/static|_next/image|favicon.ico).*)&#39;,
      missing: [
        { type: &#39;header&#39;, key: &#39;next-router-prefetch&#39; },
        { type: &#39;header&#39;, key: &#39;purpose&#39;, value: &#39;prefetch&#39; },
      ],
    },
  ],
};</code></pre>
<h4 id="reading-the-nonce">Reading the nonce</h4>
<p>Server Components 를 headers 를 이용해서 읽을 수 있다.</p>
<pre><code class="language-javascript">import { headers } from &#39;next/headers&#39;;
import Script from &#39;next/script&#39;;

export default async function Page() {
  const nonce = (await headers()).get(&#39;x-nonce&#39;);

  return (
    &lt;Script
      src=&quot;https://www.googletagmanager.com/gtag/js&quot;
      strategy=&quot;afterInteractive&quot;
      nonce={nonce}
    /&gt;
  )
}</code></pre>
<h3 id="without-nonces">Without Nonces</h3>
<p>nonce 가 필요없는 앱이라면 CSP 헤더를 직접 <code>next.config.ts</code> 파일에 넣으면 된다.</p>
<pre><code class="language-javascript">const cspHeader = `
    default-src &#39;self&#39;;
    script-src &#39;self&#39; &#39;unsafe-eval&#39; &#39;unsafe-inline&#39;;
    style-src &#39;self&#39; &#39;unsafe-inline&#39;;
    img-src &#39;self&#39; blob: data:;
    font-src &#39;self&#39;;
    object-src &#39;none&#39;;
    base-uri &#39;self&#39;;
    form-action &#39;self&#39;;
    frame-ancestors &#39;none&#39;;
    upgrade-insecure-requests;
`;

module.exports = {
  async headers() {
    return [
      {
        source: &#39;/(.*)&#39;,
        headers: [
          {
            key: &#39;Content-Security-Policy&#39;,
            value: cspHeader.replace(/\n/g, &#39;&#39;),
          },
        ],
      },
    ]
  },
}</code></pre>
<h2 id="css-in-js">CSS-in-JS</h2>
<h3 id="how-to-use-css-in-js-libraries">How to use CSS-in-JS libraries</h3>
<p>app 디렉토리에 Client Component 에 적용할 수 있는 CSS 라이브러리들은 다음과 같다.</p>
<ul>
<li>ant-design</li>
<li>chakra-ui</li>
<li>@fluentui/react-components</li>
<li>kuma-ui</li>
<li>@mui/material</li>
<li>@mui/joy</li>
<li>pandaces</li>
<li>styled-jsx</li>
<li>styled-components</li>
<li>stylex</li>
<li>tamagui</li>
<li>tss-react</li>
<li>vanilla-extract</li>
</ul>
<h3 id="configuring-css-in-js-in-app">Configuring CSS-in-JS in app</h3>
<p>CSS-in-JS 를 효율적으로 적용하기 위해서는</p>
<ol>
<li>CSS 규칙을 렌더링할 때 수집할 수 있는 <code>style-registry</code></li>
<li><code>useServerInsertedHTML</code> 훅을 이용해서 컨텐츠 사용 전에 규칙을 삽입한다.</li>
<li>초기 서버 측 렌더링 중에 <code>style-registry</code> 로 앱을 래핑하는 클라이언트 구성 요소</li>
</ol>
<h4 id="styled-jsx">styled-jsx</h4>
<p>styled-jsx 5.1.0 이상의 버전이 필요하다. 우선 registry 를 등록한다.</p>
<pre><code class="language-javascript">&#39;use client&#39;

import React, { useState } from &#39;react&#39;
import { useServerInsertedHTML } from &#39;next/navigation&#39;
import { StyleRegistry, createStyleRegistry } from &#39;styled-jsx&#39;

export default function StyledJsxRegistry({
  children,
}: {
  children: React.ReactNode
}) {
  // Only create stylesheet once with lazy initial state
  // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
  const [jsxStyleRegistry] = useState(() =&gt; createStyleRegistry())

  useServerInsertedHTML(() =&gt; {
    const styles = jsxStyleRegistry.styles()
    jsxStyleRegistry.flush()
    return &lt;&gt;{styles}&lt;/&gt;
  })

  return &lt;StyleRegistry registry={jsxStyleRegistry}&gt;{children}&lt;/StyleRegistry&gt;
}</code></pre>
<p>그리고 Root Layout 을 감싼다.</p>
<pre><code class="language-javascript">import StyledJsxRegistry from &#39;./registry&#39;

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    &lt;html&gt;
      &lt;body&gt;
        &lt;StyledJsxRegistry&gt;{children}&lt;/StyledJsxRegistry&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<h4 id="styled-components">Styled Components</h4>
<p>styled-component 6버전 이상을 사용한다. 우선 <code>next.config.js</code> 를 수정한다.</p>
<pre><code class="language-javascript">module.exports = {
  compiler: {
    styledComponents: true,
  },
}</code></pre>
<p>그리고 styled-components API 를 이용해 global registry 컴포넌트를 만든다. 이 컴포넌트는 CSS 를 렌더링할 때 생성한다. useServerInsertedHTML 훅을 이용해서 registry 에 의해 수집된 스타일을 Root Layout 내 head 태그에 추가한다.</p>
<pre><code class="language-javascript">&#39;use client&#39;

import React, { useState } from &#39;react&#39;
import { useServerInsertedHTML } from &#39;next/navigation&#39;
import { ServerStyleSheet, StyleSheetManager } from &#39;styled-components&#39;

export default function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode
}) {
  // Only create stylesheet once with lazy initial state
  // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
  const [styledComponentsStyleSheet] = useState(() =&gt; new ServerStyleSheet())

  useServerInsertedHTML(() =&gt; {
    const styles = styledComponentsStyleSheet.getStyleElement()
    styledComponentsStyleSheet.instance.clearTag()
    return &lt;&gt;{styles}&lt;/&gt;
  })

  if (typeof window !== &#39;undefined&#39;) return &lt;&gt;{children}&lt;/&gt;

  return (
    &lt;StyleSheetManager sheet={styledComponentsStyleSheet.instance}&gt;
      {children}
    &lt;/StyleSheetManager&gt;
  )
}</code></pre>
<pre><code class="language-javascript">import StyledComponentsRegistry from &#39;./lib/registry&#39;

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    &lt;html&gt;
      &lt;body&gt;
        &lt;StyledComponentsRegistry&gt;{children}&lt;/StyledComponentsRegistry&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<h2 id="custom-server">Custom Server</h2>
<h3 id="how-to-set-up-a-custom-server-in-nextjs">How to set up a custom server in Next.js</h3>
<p>Next.js 는 기본적으로 <code>next start</code> 를 통해 자체 서버를 가질 수 있다. 만약 자체 백엔드 서버가 있다면 이를 이용해 새로운 실행방식을 쓸 수 있다. 대체적으로 필요하진 않지만 필요하다면 쓸 수도 있다.</p>
<p>아래는 custom server 의 예시다. (Express 와 비슷?)</p>
<pre><code class="language-javascript">import { createServer } from &#39;http&#39;
import { parse } from &#39;url&#39;
import next from &#39;next&#39;

const port = parseInt(process.env.PORT || &#39;3000&#39;, 10)
const dev = process.env.NODE_ENV !== &#39;production&#39;
const app = next({ dev })
const handle = app.getRequestHandler()

app.prepare().then(() =&gt; {
  createServer((req, res) =&gt; {
    const parsedUrl = parse(req.url!, true)
    handle(req, res, parsedUrl)
  }).listen(port)

  console.log(
    `&gt; Server listening at http://localhost:${port} as ${
      dev ? &#39;development&#39; : process.env.NODE_ENV
    }`
  )
})</code></pre>
<p>그리고 <code>package.json</code> 을 다음과 같이 수정한다.</p>
<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;node server.js&quot;,
    &quot;build&quot;: &quot;next build&quot;,
    &quot;start&quot;: &quot;NODE_ENV=production node server.js&quot;
  }
}</code></pre>
<h2 id="debugging">Debugging</h2>
<blockquote>
<p>Skip</p>
</blockquote>
<h2 id="draft-mode">Draft Mode</h2>
<blockquote>
<p>Skip</p>
</blockquote>
<h2 id="environment-variables">Environment Variables</h2>
<h3 id="how-to-use-environment-variables-in-nextjs">How to use environment variables in Next.js</h3>
<p>다른 프레임워크와 다르게 Next.js 는 Build-in 된 .env 활용방식이 있다.</p>
<h4 id="loading-environment-variables">Loading Environment Variables</h4>
<p>Node.js 내장객체를 사용하는 방법이다. 먼저 아래와 같은 .env 를 가정한다.</p>
<pre><code class="language-shell">DB_HOST=localhost
DB_USER=myuser
DB_PASS=mypassword

# you can write with line breaks
PRIVATE_KEY=&quot;-----BEGIN RSA PRIVATE KEY-----
...
Kh9NV...
...
-----END DSA PRIVATE KEY-----&quot;

# or with `\n` inside double quotes
PRIVATE_KEY=&quot;-----BEGIN RSA PRIVATE KEY-----\nKh9NV...\n-----END DSA PRIVATE KEY-----\n&quot;</code></pre>
<blockquote>
<p>src 폴더를 사용한다면, .env 파일이 꼭 src 폴더를 나타내지 않는다는 사실에 주의하라. src 폴더가 있더라도 .env 파일은 root directory 에 존재해야 한다.</p>
</blockquote>
<p>그럼 아래와 같이 사용할 수 있다.</p>
<pre><code class="language-javascript">export async function GET() {
  const db = await myDB.connect({
    host: process.env.DB_HOST,
    username: process.env.DB_USER,
    password: process.env.DB_PASS,
  })
}</code></pre>
<h4 id="loading-environment-variables-with-nextenv">Loading Environment Variables with @next/env</h4>
<p>Next.js 런타임 외에 ORM 혹은 root config 파일 혹은 test runner 를 위해 환경변수를 불러오려면 <code>@next/env</code> 를 사용하는 것을 고려해보자.</p>
<pre><code class="language-shell">npm install @next/env</code></pre>
<pre><code class="language-javascript">// envConfig.ts
import { loadEnvConfig } from &#39;@next/env&#39;

const projectDir = process.cwd()
loadEnvConfig(projectDir)

// orm.config.ts
import &#39;./envConfig.ts&#39;

export default defineConfig({
  dbCredentials: {
    connectionString: process.env.DATABASE_URL!,
  },
})</code></pre>
<h4 id="referencing-other-variables">Referencing Other Variables</h4>
<p>$ 기호를 이용해서 환경변수 파일 내에서 한 변수가 다른 변수를 참조할 수 있다.</p>
<pre><code class="language-shell">TWITTER_USER=nextjs
TWITTER_URL=https://x.com/$TWITTER_USER</code></pre>
<h3 id="bundling-environment-variables-for-the-browser">Bundling Environment Variables for the Browser</h3>
<p>Node 환경이 아닌 경우는 빌드할 때 터미널에 직접 입력할 수 있다.</p>
<pre><code class="language-shell">NEXT_PUBLIC_ANALYTICS_ID=abcdefghijk</code></pre>
<p>이건 코드 내에 <code>process.env.NEXT_PUBLIC_ANALYTICS_ID</code> 가 참조할 값을 설정한다. 여기서 말한 코드는 <code>next build</code> 로 생성된 코드를 말한다. 빌드가 된 코드는 더 이상의 변경이 되지 않으므로 위의 명령어는 빌드 전에 실행해야 한다.</p>
<pre><code class="language-javascript">import setupAnalyticsService from &#39;../lib/my-analytics-service&#39;

// setupAnalyticsService(&#39;abcdefghijk&#39;) 와 같다.
setupAnalyticsService(process.env.NEXT_PUBLIC_ANALYTICS_ID)

export default function HomePage() {
  return &lt;h1&gt;Hello World&lt;/h1&gt;
}</code></pre>
<p>아래와 같이 동적으로 참조하는 해당 안되니 참고해야 한다.</p>
<pre><code class="language-javascript">// 변수 형태로 쓰면 안된다 1.
const varName = &#39;NEXT_PUBLIC_ANALYTICS_ID&#39;
setupAnalyticsService(process.env[varName])

// 변수 형태로 쓰면 안된다 2.
const env = process.env
setupAnalyticsService(env.NEXT_PUBLIC_ANALYTICS_ID)</code></pre>
<h4 id="runtime-environment-variables">Runtime Environment Variables</h4>
<p>Next.js 는 빌드 혹은 런타임 시 환경변수 모두 지원한다. 기본적으로 환경변수는 서버(빌드 타임을 말하는 듯)에서만 사용 가능하다. 서버에서 사용할 때는 <code>NEXT_PUBLIC_</code> 접두사가 필요하다. 이 접두사가 들어간 환경변수는 빌드 시 Javascript 번들링이 되어야 한다.</p>
<p>서버를 통해 환경변수를 읽으려면 이 방법을 쓰면 된다.</p>
<pre><code class="language-javascript">import { connection } from &#39;next/server&#39;

export default async function Component() {
  await connection()
  // cookies, headers, and other Dynamic APIs
  // will also opt into dynamic rendering, meaning
  // this env variable is evaluated at runtime
  const value = process.env.MY_VALUE
  // ...
}</code></pre>
<p>만약 환경변수가 도커 이미지로 배포되고 있다면 이 방법이 좋다.</p>
<h3 id="test-environment-variables">Test Environment Variables</h3>
<p><code>.env.test</code> 파일을 이용하면 testing 환경의 환경변수도 대응 가능하다. 테스트용 값은 <code>NODE_ENV</code> 가 test 에 설정되어 있어야 한다. test 환경에서는 <code>.env.local</code> 을 불러오지 않음을 주의하자.</p>
<p>유닛 테스트에서 환경변수를 불러오는 방법이다.</p>
<pre><code class="language-javascript">// The below can be used in a Jest global setup file or similar for your testing set-up
import { loadEnvConfig } from &#39;@next/env&#39;

export default async () =&gt; {
  const projectDir = process.cwd()
  loadEnvConfig(projectDir)
}</code></pre>
<h3 id="environment-variable-load-order">Environment Variable Load Order</h3>
<p>환경변수는 아래 순서대로 값을 검색한다.</p>
<ol>
<li>process.env</li>
<li>.env.$(NODE_ENV).local</li>
<li>.env.local (test 에선 안함)</li>
<li>.env.$(NODE_ENV)</li>
<li>.env</li>
</ol>
<p>예를 들어 NODE_ENV 는 development 라면 환경변수는 .env.development.local 과 .env 그리고 .env.development.local 가 쓰일 것이다.</p>
<h2 id="instrumentation">Instrumentation</h2>
<p>instrumentation 은 성능 및 이슈 검사를 위한 기능이다.</p>
<h3 id="convention">Convention</h3>
<p><code>instrumentation.ts</code> 파일을 루트 디렉토리에 만든다(src 폴더를 쓴다면 src 폴더 안에 넣는다). 그리고 register 함수를 export 한다. 이 함수는 앱에서 한 번만 실행될 것이다. 아래는 OpenTelemetry 를 사용한 것이다.</p>
<pre><code class="language-javascript">import { registerOTel } from &#39;@vercel/otel&#39;

export function register() {
  registerOTel(&#39;next-app&#39;)
}</code></pre>
<h3 id="examples">Examples</h3>
<blockquote>
<p>읽어보긴 했는데 무슨 말인지 잘 모르겠음</p>
</blockquote>
<h2 id="json-ld">JSON-LD</h2>
<h3 id="how-to-implement-json-ld-in-your-nextjs-application">How to implement JSON-LD in your Next.js application</h3>
<p>JSON-LD 는 검색 엔진이나 AI 에서 사용되어 순수한 형태의 데이터를 구조화 하는 것이다. 추천하는 JSON-LD 는 layout.ts 혹은 page.ts 컴포넌트에 script 태그 형태로 렌더링 되는 것이다.</p>
<pre><code class="language-javascript">export default async function Page({ params }) {
  const { id } = await params
  const product = await getProduct(id)

  const jsonLd = {
    &#39;@context&#39;: &#39;https://schema.org&#39;,
    &#39;@type&#39;: &#39;Product&#39;,
    name: product.name,
    image: product.image,
    description: product.description,
  }

  return (
    &lt;section&gt;
      {/* Add JSON-LD to your page */}
      &lt;script
        type=&quot;application/ld+json&quot;
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      /&gt;
      {/* ... */}
    &lt;/section&gt;
  )
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Docs] Next.js Guides 'Authentication']]></title>
            <link>https://velog.io/@sanghwi_back/Docs-Next.js-Guides-from-Authentication</link>
            <guid>https://velog.io/@sanghwi_back/Docs-Next.js-Guides-from-Authentication</guid>
            <pubDate>Mon, 28 Apr 2025 13:52:42 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>산은 산이요. 물은 물이렷다.</p>
</blockquote>
<h2 id="authentication">Authentication</h2>
<h3 id="how-to-implement-authentication-in-nextjs">How to implement authentication in Next.js</h3>
<p>인증 프로세스는 3개의 컨셉으로 나뉜다.</p>
<ol>
<li>Authentication : 인증. 유저가 누구인지 검증한다. (예: 로그인)</li>
<li>Session Management : 요청 간 사용자의 인증상태를 추적한다.</li>
<li>Authorization : 사용자가 어떤 데이터에 접근할 수 있는지 인가한다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/48e0de7a-2a7d-434e-b660-7d5f5d98f331/image.webp" alt=""></p>
<p>위 다이어그램은 이름과 비밀번호로 인증하는 일반적인 방법이다. 이것 말고도 <a href="https://nextjs.org/docs/app/guides/authentication#auth-libraries">Auth Libraries</a> 들은 위의 기능과 더불어 소셜 로그인, MFA(다중 인증), 역할 기반 접근 제어도 가능하다.</p>
<h3 id="authentication-1">Authentication</h3>
<h4 id="sign-up-and-login-functionality">Sign-up and login functionality</h4>
<p>&lt;form&gt; 과 리액트 Server Actions, useActionState 를 이용하여 사용자 인증, form 값 검증, 인증 API 혹은 DB 호출이 가능하다. signup/login 기능 구현은 다음과 같다.</p>
<ol>
<li>Capture user credentials</li>
</ol>
<p>form 태그를 이용해 사용자 정보를 얻어야 한다.</p>
<ol start="2">
<li>Validate form fields on the server</li>
</ol>
<p>Server Action 을 이용해서 form 필드들을 검증한다. zod 나 yup 등을 이용해서 클라이언트 레벨에서 해도 좋다.</p>
<p>zod 를 이용한 검증은 넣어두면 좋다. 불필요한 Server Action 을 줄일 수 있기 때문이다.</p>
<p>이미 signup 하였는지 등의 나머지 작업은 Server Action 을 통해 진행한다.</p>
<ol start="3">
<li>Create a user or check user credentials</li>
</ol>
<p>Server Action 에서 2차적으로 체크하도록 함수를 만든다.</p>
<pre><code class="language-javascript">export async function signup(state: FormState, formData: FormData) {
  // 1. Validate form fields
  // ...

  // 2. Prepare data for insertion into database
  const { name, email, password } = validatedFields.data
  // e.g. Hash the user&#39;s password before storing it
  const hashedPassword = await bcrypt.hash(password, 10)

  // 3. Insert the user into the database or call an Auth Library&#39;s API
  const data = await db
    .insert(users)
    .values({
      name,
      email,
      password: hashedPassword,
    })
    .returning({ id: users.id })

  const user = data[0]

  if (!user) {
    return {
      message: &#39;An error occurred while creating your account.&#39;,
    }
  }

  // TODO:
  // 4. Create user session
  // 5. Redirect user
}</code></pre>
<p>이제 사용자 세션을 만들 차례이다. 위의 그림에 따르면 세션은 쿠키 / DB 혹은 두 곳 모두에 저장된다.</p>
<blockquote>
<p>Tips:</p>
</blockquote>
<ul>
<li>웬만하면 직접 구현하는 것보다 라이브러리를 사용하는 것을 추천한다.</li>
<li>UX 를 위해 <a href="https://www.npmjs.com/package/use-debounce">useDebounce</a> 를 이용해서 사용자 입력과 동시에 중복된 정보를 체크할 수 있다.</li>
</ul>
<h3 id="session-management">Session Management</h3>
<p>Session Management 란 사용자 인증 상태가 리퀘스트마다 유지되는 것을 말한다. 세션 혹은 토큰을 Creating, Storing, Refreshing, Deleting 할 수 있다.</p>
<p>여기에는 2가지 타입의 세션들이 존재한다.</p>
<ul>
<li>Stateless : 세션 데이터 혹은 쿠키가 브라우저의 쿠키에 저장되어 있는 경우이다. 각 요청마다 쿠키가 전송되며, 서버에서 세션이 인증된다. 간단하지만 자칫 잘못하면 보안 상 문제가 발생한다.</li>
<li>Database : 세션 데이터가 DB 에 저장되어 있는 경우이다. 사용자의 브라우저는 암호화된 세션 아이디만을 수신한다. 보안 상 더 우수하지만 구현이 복잡하고 서버 자원을 더 많이 쓴다.</li>
</ul>
<h4 id="stateless-sessions">Stateless Sessions</h4>
<ol>
<li>비밀키를 생성하여 세션을 인증한다. 그리고 환경변수로 저장한다.</li>
<li>세션 관리 라이브러리로 암호화/복호화 로직을 작성한다.</li>
<li>Next.js 의 cookies API 를 이용해서 쿠키를 관리한다.</li>
</ol>
<p>사용자가 앱을 다시 사용할 때를 대비해 세션을 update / refresh 하는 기능 또한 고려해 보자. 사용자가 로그아웃하면 delete 도 해야 한다.</p>
<ol>
<li>비밀키 생성</li>
</ol>
<p>openssl 을 사용할 수 있다. 아래는 32자의 랜덤 문자열을 생성한다.</p>
<pre><code class="language-shell">openssl rand -base64 32</code></pre>
<p>이를 환경변수 파일에 저장한다.</p>
<pre><code class="language-shell"># .env 파일
SESSION_SECRET=your_secret_key</code></pre>
<p>그리고 이 키를 참조할 수 있다.</p>
<pre><code class="language-shell">const secretKey = process.env.SESSION_SECRET</code></pre>
<ol start="2">
<li>세션 암호화 / 복호화</li>
</ol>
<p><a href="https://nextjs.org/docs/app/guides/authentication#session-management-libraries">session management library</a> 를 이용할 수 있다. 이 중 아래는 <strong>Jose</strong> 를 사용하고 server-only 패키지를 임포트하기 때문에 서버에서 실행됨을 보장한다.</p>
<pre><code class="language-javascript">import &#39;server-only&#39;
import { SignJWT, jwtVerify } from &#39;jose&#39;
import { SessionPayload } from &#39;@/app/lib/definitions&#39;

const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)

export async function encrypt(payload: SessionPayload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: &#39;HS256&#39; })
    .setIssuedAt()
    .setExpirationTime(&#39;7d&#39;)
    .sign(encodedKey)
}

export async function decrypt(session: string | undefined = &#39;&#39;) {
  try {
    const { payload } = await jwtVerify(session, encodedKey, {
      algorithms: [&#39;HS256&#39;],
    })
    return payload
  } catch (error) {
    console.log(&#39;Failed to verify session&#39;)
  }
}</code></pre>
<blockquote>
<p>payload 는 항상 최소한으로 유지하라. 개인을 특정할 수 있는 정보는 넣지 마라.</p>
</blockquote>
<h4 id="setting-cookies-recommended-options">Setting cookies (recommended options)</h4>
<p>세션을 쿠키에 저장할 때 Next.js 의 cookie API 를 사용해보자. cookie API 는 아래 간편한 옵션이 있다.</p>
<ul>
<li>HttpOnly : client-side javascript 가 쿠키에 접근하는 것을 방지한다.</li>
<li>Secure : 쿠키를 보낼 때 https 만 사용한다.</li>
<li>SameSite : cross-site 정책을 설정한다.</li>
<li>Max-Age or Expired : 쿠키의 유효기간을 설정한다.</li>
<li>Path : cookie 의 URL path 를 설정한다.</li>
</ul>
<pre><code class="language-javascript">import &#39;server-only&#39;
import { cookies } from &#39;next/headers&#39;

export async function createSession(userId: string) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const session = await encrypt({ userId, expiresAt })
  const cookieStore = await cookies()

  cookieStore.set(&#39;session&#39;, session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: &#39;lax&#39;,
    path: &#39;/&#39;,
  })
}

import { createSession } from &#39;@/app/lib/session&#39;

// 위의 signup 함수.
export async function signup(state: FormState, formData: FormData) {
  // Previous steps:
  // 1. Validate form fields
  // 2. Prepare data for insertion into database
  // 3. Insert the user into the database or call an Library API

  // Current steps:
  // 4. Create user session
  await createSession(user.id)
  // 5. Redirect user
  redirect(&#39;/profile&#39;)
}</code></pre>
<blockquote>
<p>쿠키를 서버에서만 세팅하여 client-side tempering 을 방지하자. 자세한 사항은 <a href="https://www.youtube.com/watch?v=DJvM2lSPn6w">Youtube</a></p>
</blockquote>
<h4 id="updating-or-refreshing-sessions">Updating (or refreshing) sessions</h4>
<p>session 의 만료기간을 늘릴 수 있다. 자동 로그인에 필요.</p>
<pre><code class="language-javascript">import &#39;server-only&#39;
import { cookies } from &#39;next/headers&#39;
import { decrypt } from &#39;@/app/lib/session&#39;

export async function updateSession() {
  const session = (await cookies()).get(&#39;session&#39;)?.value
  const payload = await decrypt(session)

  if (!session || !payload) {
    return null
  }

  const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)

  const cookieStore = await cookies()
  cookieStore.set(&#39;session&#39;, session, {
    httpOnly: true,
    secure: true,
    expires: expires,
    sameSite: &#39;lax&#39;,
    path: &#39;/&#39;,
  })
}</code></pre>
<h4 id="deleting-the-session">Deleting the session</h4>
<pre><code class="language-javascript">import &#39;server-only&#39;
import { cookies } from &#39;next/headers&#39;

export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete(&#39;session&#39;)
}

import { cookies } from &#39;next/headers&#39;
import { deleteSession } from &#39;@/app/lib/session&#39;

export async function logout() {
  deleteSession()
  redirect(&#39;/login&#39;)
}</code></pre>
<h3 id="database-sessions">Database Sessions</h3>
<ol>
<li>DB 에 세션을 저장할 테이블을 과 데이터를 생성한다. 아니면 Auth Library 사용.</li>
<li>세션 삽입, 수정, 삭제를 구현한다.</li>
<li>세션ID 를 브라우저에서 미리 암호화한 뒤 저장하고 쿠키와 DB 의 싱크를 확인한다. (필수는 아니지만 Middleware 안정성을 위해 필요)</li>
</ol>
<pre><code class="language-javascript">import cookies from &#39;next/headers&#39;
import { db } from &#39;@/app/lib/db&#39;
import { encrypt } from &#39;@/app/lib/session&#39;

export async function createSession(id: number) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)

  // 1. Create a session in the database
  const data = await db
    .insert(sessions)
    .values({
      userId: id,
      expiresAt,
    })
    // Return the session ID
    .returning({ id: sessions.id })

  const sessionId = data[0].id

  // 2. Encrypt the session ID
  const session = await encrypt({ sessionId, expiresAt })

  // 3. Store the session in cookies for optimistic auth checks
  const cookieStore = await cookies()
  cookieStore.set(&#39;session&#39;, session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: &#39;lax&#39;,
    path: &#39;/&#39;,
  })
}</code></pre>
<h2 id="authorization">Authorization</h2>
<p>사용자가 인증되었고 세션이 생성되었다면 권한을 부여하여 사용자가 접근할 수 있는 권한을 설정해야 한다.</p>
<p>2 개의 권한 검증 타입이 존재한다.</p>
<ol>
<li>Optimistic : 쿠키에 저장된 세션 데이터를 통해 특정 라우트나 액션이 가능한지 검증한다. 이런 타입은 UI 숨기기 혹은 강제 리다이렉트 등에 유용하다.</li>
<li>Secure : DB에 저장된 세션 데이터를 통해 특정 라우트나 액션이 가능한지 검증한다. 민감 정보나 액션 관련 작업에 유용하다.</li>
</ol>
<p>이와 관련하여 추천되는 유즈 케이스는 아래와 같다.</p>
<ul>
<li>Data Access Layer 를 생성하여 권한 로직을 통합한다.</li>
<li>Data Transfer Objects (DTO) 를 사용해서 데이터를 반환한다.</li>
<li>Optimistic 권한 체크에는 Middleware 를 사용한다.</li>
</ul>
<h3 id="optimistic-checks-with-middelware-optional">Optimistic checks with Middelware (Optional)</h3>
<ul>
<li>Middleware 는 모든 라우트에서 동작한다. 리다이렉트와 비인가 유저 필터링을 통합하기에 좋은 방법이다.</li>
<li>사용자 간 데이터를 교환하는 정적 라우트를 방지한다.</li>
</ul>
<p>Middleware 는 모든 라우트에서 동작하므로 세션은 쿠키에서 읽어와야 한다.(optimistic checks, DB 는 성능 이슈로 제외해야 함)</p>
<pre><code class="language-javascript">import { NextRequest, NextResponse } from &#39;next/server&#39;
import { decrypt } from &#39;@/app/lib/session&#39;
import { cookies } from &#39;next/headers&#39;

// 1. Specify protected and public routes
const protectedRoutes = [&#39;/dashboard&#39;]
const publicRoutes = [&#39;/login&#39;, &#39;/signup&#39;, &#39;/&#39;]

export default async function middleware(req: NextRequest) {
  // 2. Check if the current route is protected or public
  const path = req.nextUrl.pathname
  const isProtectedRoute = protectedRoutes.includes(path)
  const isPublicRoute = publicRoutes.includes(path)

  // 3. Decrypt the session from the cookie
  const cookie = (await cookies()).get(&#39;session&#39;)?.value
  const session = await decrypt(cookie)

  // 4. Redirect to /login if the user is not authenticated
  if (isProtectedRoute &amp;&amp; !session?.userId) {
    return NextResponse.redirect(new URL(&#39;/login&#39;, req.nextUrl))
  }

  // 5. Redirect to /dashboard if the user is authenticated
  if (
    isPublicRoute &amp;&amp;
    session?.userId &amp;&amp;
    !req.nextUrl.pathname.startsWith(&#39;/dashboard&#39;)
  ) {
    return NextResponse.redirect(new URL(&#39;/dashboard&#39;, req.nextUrl))
  }

  return NextResponse.next()
}

// Routes Middleware should not run on
export const config = {
  matcher: [&#39;/((?!api|_next/static|_next/image|.*\\.png$).*)&#39;],
}</code></pre>
<p>Middleware 가 마지막 데이터 방어책이 되면 안된다. 보안의 총책은 data source 에서 담당해야 한다.</p>
<h3 id="creating-a-data-access-layerdal">Creating a Data Access Layer(DAL)</h3>
<p>DAL 로 데이터 요청과 권한 로직을 통합하는 것이 좋다. DAL 에는 사용자의 세션을 검증하는 로직이 들어가 있어야 한다. 최소한 세션을 리다이렉트 하거나 사용자 정보를 반환할 수 있어야 한다.</p>
<p>예를 들어 verifySession 을 포함한 DAL 파일을 만든다. 리액트의 cache API 로 반환값을 memoize 한다.</p>
<pre><code class="language-javascript">import &#39;server-only&#39;

import { cookies } from &#39;next/headers&#39;
import { decrypt } from &#39;@/app/lib/session&#39;

export const verifySession = cache(async () =&gt; {
  const cookie = (await cookies()).get(&#39;session&#39;)?.value
  const session = await decrypt(cookie)

  if (!session?.userId) {
    redirect(&#39;/login&#39;)
  }

  return { isAuth: true, userId: session.userId }
})

export const getUser = cache(async () =&gt; {
  const session = await verifySession()
  if (!session) return null

  try {
    const data = await db.query.users.findMany({
      where: eq(users.id, session.userId),
      // Explicitly return the columns you need rather than the whole user object
      columns: {
        id: true,
        name: true,
        email: true,
      },
    })

    const user = data[0]

    return user
  } catch (error) {
    console.log(&#39;Failed to fetch user&#39;)
    return null
  }
})</code></pre>
<blockquote>
<ul>
<li>DAL 은 런타임의 요청 시 사용된다. 하지만 정적 라우팅으로 사용자 간 데이터를 교환하면, 데이터는 빌드 시 fetch 된다. Middleware 를 사용하면 정적 라우팅 시 사용자 간 데이터 교환을 방지한다.</li>
</ul>
</blockquote>
<ul>
<li>보안 체크를 위해 DB 의 세션 ID 를 검증할 수 있다. 리액트의 cache 함수는 불필요한 DB 확인을 방지한다.</li>
<li>verifySession 은 모든 요청과 관련된 Javascript 클래스에서 사용할 수 있다.</li>
</ul>
<h3 id="using-data-transfer-objectsdto">Using Data Transfer Objects(DTO)</h3>
<p>데이터를 반환 받았을 때 전체 객체가 아닌 DTO 를 사용하자.</p>
<pre><code class="language-javascript">import &#39;server-only&#39;
import { getUser } from &#39;@/app/lib/dal&#39;

function canSeeUsername(viewer: User) {
  return true
}

function canSeePhoneNumber(viewer: User, team: string) {
  return viewer.isAdmin || team === viewer.team
}

export async function getProfileDTO(slug: string) {
  const data = await db.query.users.findMany({
    where: eq(users.slug, slug),
    // Return specific columns here
  })
  const user = data[0]

  const currentUser = await getUser(user.id)

  // Or return only what&#39;s specific to the query here
  return {
    username: canSeeUsername(currentUser) ? user.username : null,
    phonenumber: canSeePhoneNumber(currentUser, user.team)
      ? user.phonenumber
      : null,
  }
}</code></pre>
<h3 id="server-components">Server Components</h3>
<p>Server Compoentns 에서의 인증은 권한 접근에 유용하다. 부분 렌더링 예시이다.</p>
<pre><code class="language-javascript">import { verifySession } from &#39;@/app/lib/dal&#39;

export default function Dashboard() {
  const session = await verifySession()
  const userRole = session?.user?.role // Assuming &#39;role&#39; is part of the session object

  if (userRole === &#39;admin&#39;) {
    return &lt;AdminDashboard /&gt;
  } else if (userRole === &#39;user&#39;) {
    return &lt;UserDashboard /&gt;
  } else {
    redirect(&#39;/login&#39;)
  }
}</code></pre>
<h3 id="layouts-and-auth-checks">Layouts and auth checks</h3>
<p>Partial Rendering 에서 Layout 이 네비게이션에서 다시 렌더링되지 않는다는 사실은 중요하다. 사용자 세션이 모든 라우팅에서 같지 않기 때문이다.</p>
<p>이를 위해 사용자 세션 검증 data source 나 컴포넌트를 레이아웃과 가까이 둔다. 아래 예시는 레이아웃에서 getUser 함수를 통해 DAL 에서 사용자 세션 체크를 하고 있다.</p>
<pre><code class="language-javascript">export const getUser = cache(async () =&gt; {
  const session = await verifySession()
  if (!session) return null

  // Get user ID from session and fetch data
})

export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getUser();

  return (
    // ...
  )
}</code></pre>
<blockquote>
<p>SPA 에서 인증되지 않은 사용자는 return null 하는 것이 일반적이다. Next.js 에선 이런 방식이 좋지 않다. Next.js 는 많은 엔트리 포인트가 있는데 nested route segments 나 Server Action 에의 접근을 막지 않는다.</p>
</blockquote>
<h3 id="server-actions">Server Actions</h3>
<p>Server Actions 자체는 public API 로 두고 사용자가 mutation 을 수행할 수 있는지 내부에서 검증한다.</p>
<pre><code class="language-javascript">&#39;use server&#39;
import { verifySession } from &#39;@/app/lib/dal&#39;

export async function serverAction(formData: FormData) {
  const session = await verifySession()
  const userRole = session?.user?.role

  // Return early if user is not authorized to perform the action
  if (userRole !== &#39;admin&#39;) {
    return null
  }

  // Proceed with the action for authorized users
}</code></pre>
<h3 id="route-handlers">Route Handlers</h3>
<p>위와 같이 라우팅도 public API 로 두고 내부에서 검증한다.</p>
<pre><code class="language-javascript">import { verifySession } from &#39;@/app/lib/dal&#39;

export async function GET() {
  // User authentication and role verification
  const session = await verifySession()

  // Check if the user is authenticated
  if (!session) {
    // User is not authenticated
    return new Response(null, { status: 401 })
  }

  // Check if the user has the &#39;admin&#39; role
  if (session.user.role !== &#39;admin&#39;) {
    // User is authenticated but does not have the right permissions
    return new Response(null, { status: 403 })
  }

  // Continue for authorized users
}</code></pre>
<h2 id="context-providers">Context Providers</h2>
<p>Context Providers 는 인증 작업에 interleaving 이 필요할 때 사용한다. Context Provider 는 서버에서 우선 렌더링되고 Client Component 는 Context Provider 의 세션 데이터(저장소)에 접근한다.</p>
<pre><code class="language-javascript">import { ContextProvider } from &#39;auth-lib&#39;

export default function RootLayout({ children }) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;body&gt;
        // Server 에서 렌더링 됨.
        &lt;ContextProvider&gt;{children}&lt;/ContextProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<pre><code class="language-javascript">&quot;use client&quot;;

import { useSession } from &quot;auth-lib&quot;;

export default function Profile() {
  const { userId } = useSession();
  const { data } = useSWR(`/api/user/${userId}`, fetcher)

  return (
    // ...
  );
}</code></pre>
<p>Client Component 에서 세션 정보가 필요하면 리액트의 taintUniqueValue API 를 사용해서 민감 세션 정보가 클라이언트에 노출되지 않도록 하자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Docs] Next.js Getting Started from 'Fetching Data' to last]]></title>
            <link>https://velog.io/@sanghwi_back/Docs-Next.js-Getting-Started-from-Fetching-Data-to-last</link>
            <guid>https://velog.io/@sanghwi_back/Docs-Next.js-Getting-Started-from-Fetching-Data-to-last</guid>
            <pubDate>Wed, 23 Apr 2025 14:36:58 GMT</pubDate>
            <description><![CDATA[<p>오늘은 Getting Started 의 마지막까지 다룹니다. 생각보다 Getting Started 쓸 내용들이 많네요.</p>
<h2 id="fetching-data">Fetching Data</h2>
<p>Sever / Client Components 에서 데이터를 fetch 하는 방법을 알아본다. 그리고 데이터에 따라 컨텐츠를 stream 하는 법을 알아보자.</p>
<h3 id="fetching-data---server-components">Fetching data - Server Components</h3>
<p>방법은 2개다.</p>
<ol>
<li>fetch API</li>
<li>ORM or database</li>
</ol>
<p>fetch API 는 async 함수에서 사용해야 한다.</p>
<pre><code class="language-javascript">export default async function Page() { // async 주목
  const data = await fetch(&#39;https://api.vercel.app/blog&#39;)
  const posts = await data.json()
  return (
    &lt;ul&gt;
      {posts.map((post) =&gt; (
        &lt;li key={post.id}&gt;{post.title}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  )
}</code></pre>
<p>서버 컴포넌트를 서버에서 만들 때 ORM 혹은 데이터베이스에서 쿼리를 실행해서 가져올 수 있다.</p>
<pre><code class="language-javascript">import { db, posts } from &#39;@/lib/db&#39;

export default async function Page() {
  const allPosts = await db.select().from(posts)
  return (
    &lt;ul&gt;
      {allPosts.map((post) =&gt; (
        &lt;li key={post.id}&gt;{post.title}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  )
}</code></pre>
<h3 id="fetching-data---client-components">Fetching data - Client Components</h3>
<p>방법은 2개다.</p>
<ol>
<li>React 의 use 훅</li>
<li>SWR, React Query(Tanstack Query) 사용</li>
</ol>
<p>use 훅을 이용해 서버에서 데이터를 가져오는 과정을 stream 해야 한다. Promise 를 서버 컴포넌트에서 prop 으로 전달한다.</p>
<pre><code class="language-javascript">// &#39;use server&#39;
import Posts from &#39;@/app/ui/posts
import { Suspense } from &#39;react&#39;

export default function Page() {
  // Don&#39;t await the data fetching function
  const posts = getPosts()

  return (
    &lt;Suspense fallback={&lt;div&gt;Loading...&lt;/div&gt;}&gt;
      &lt;Posts posts={posts} /&gt;
    &lt;/Suspense&gt;
  )
}</code></pre>
<p>그리고 Client Component 에서 use 훅에 promise 를 이용한다.</p>
<pre><code class="language-javascript">&#39;use client&#39;
import { use } from &#39;react&#39;

type Prop = { posts: Promise&lt;{ id: string; title: string }[]&gt; }

export default function Posts({ posts }: Props) {
  const allPosts = use(posts)
  return (
    &lt;ul&gt;
      {allPosts.map((post) =&gt; (
        &lt;li key={post.id}&gt;{post.title}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  )
}</code></pre>
<p>여기서 중요한 점은 Posts 컴포넌트가 Suspense 에 싸여져 있어야 한다는 것이다. Suspense 에 의해 fallback 이 렌더링 되었다가 Posts 가 렌더링 된다.</p>
<p>SWR, React-Query(TanStack Query) 을 사용하는 방법은 아래와 같다.</p>
<pre><code class="language-javascript">&#39;use client&#39;
import useSWR from &#39;swr&#39;

const fetcher = (url) =&gt; fetch(url).then((r) =&gt; r.json())

export default function BlogPage() {
  const { data, error, isLoading } = useSWR(
    &#39;https://api.vercel.app/blog&#39;,
    fetcher
  )

  if (isLoading) return &lt;div&gt;Loading...&lt;/div&gt;
  if (error) return &lt;div&gt;Error: {error.message}&lt;/div&gt;

  return (
    &lt;ul&gt;
      {data.map((post: { id: string; title: string }) =&gt; (
        &lt;li key={post.id}&gt;{post.title}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  )
}</code></pre>
<p>이런 라이브러리들은 자신만의 caching, streaming 등의 유용한 기능이 있다.</p>
<h3 id="streaming">Streaming</h3>
<blockquote>
<p><a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/dynamicIO">dynamicIO</a> 설정 옵션이 적용되어 있어야 한다(Next.js 15 이상).</p>
</blockquote>
<p>Next.js 의 서버 컴포넌트 내 async/await 는 프레임워크 레벨에서 dynamic rendering 에 최적화 되어 있다. 최적화 방법은 서버가 데이터를 fetch 하고 서버에서 컴포넌트를 렌더링하는 것이다. 만약 다수 fetch 중 하나만 느리더라도 전체 route 가 영향을 받을 수 있다.</p>
<p>초기 load 시간과 UX 를 최적화하기 위해서는 streaming 을 이용해서 HTML 을 작은 조각으로 나눌 수 있다.</p>
<p>streaming 을 구현하는 방법은 2개다.</p>
<ol>
<li>loading.js 파일</li>
<li>Suspense 컴포넌트</li>
</ol>
<p>loading.tsx 파일은 page.tsx 의 전체 페이지 stream 을 담당한다. <em>app/blog/page.tsx</em> 를 stream 하기 위해서는 <em>app/blog/loading.tsx</em> 를 이용하는 것이다.</p>
<pre><code class="language-javascript">export default function Loading() {
  // Define the Loading UI here
  return &lt;div&gt;Loading...&lt;/div&gt;
}</code></pre>
<p>처음 라우트가 되면 layout 에 loading 컴포넌트가 보이고 page 는 렌더링 시작한다. page 렌더링이 끝나면 자동으로 스왑된다.</p>
<p>Layout 의 시점에서 볼 때 loading.tsx 는 layout.tsx 내에 page.tsx 를 자동으로 감싼다.</p>
<p>그러므로 자세한 Suspense 는 직접 page.tsx 내에서 사용해야 한다.</p>
<pre><code class="language-javascript">import { Suspense } from &#39;react&#39;
import BlogList from &#39;@/components/BlogList&#39;
import BlogListSkeleton from &#39;@/components/BlogListSkeleton&#39;

export default function BlogPage() {
  return (
    &lt;div&gt;
      {/* This content will be sent to the client immediately */}
      &lt;header&gt;
        &lt;h1&gt;Welcome to the Blog&lt;/h1&gt;
        &lt;p&gt;Read the latest posts below.&lt;/p&gt;
      &lt;/header&gt;
      &lt;main&gt;
        {/* Any content wrapped in a &lt;Suspense&gt; boundary will be streamed */}
        &lt;Suspense fallback={&lt;BlogListSkeleton /&gt;}&gt;
          &lt;BlogList /&gt;
        &lt;/Suspense&gt;
      &lt;/main&gt;
    &lt;/div&gt;
  )
}</code></pre>
<p>React Devtools 를 이용하면 로딩 상태값을 테스트해볼 수 있다.</p>
<h2 id="updating-data">Updating Data</h2>
<p>Server Functions 를 이용하면 데이터를 업데이트할 수 있다.</p>
<h3 id="creating-server-functions">Creating Server Functions</h3>
<p>&#39;use server&#39; 디렉티브를 사용하면 Server Function 을 만들 수 있다. asynchronous 함수 맨 위에 &#39;use server&#39; 를 넣어서 Server Function 을 만들거나 파일 맨 위에 넣으면 된다.</p>
<pre><code class="language-javascript">export async function createPost(formData: FormData) {
  &#39;use server&#39;
  const title = formData.get(&#39;title&#39;)
  const content = formData.get(&#39;content&#39;)

  // Update data
  // Revalidate cache
}

export async function deletePost(formData: FormData) {
  &#39;use server&#39;
  const id = formData.get(&#39;id&#39;)

  // Update data
  // Revalidate cache
}

export default function Page() {
  // Server Action
  async function createPost(formData: FormData) {
    &#39;use server&#39;
    // ...
  }

  return &lt;&gt;&lt;/&gt;
}</code></pre>
<p>하지만 Client Component 에서는 Server Function 을 정의할 수 없다. 그러나, Server Function 은 import 할 수 있다.</p>
<pre><code class="language-javascript">// app/actions.ts
&#39;use server&#39;

export async function createPost() {}

// app/ui/button.tsx
&#39;use client&#39;

import { createPost } from &#39;@/app/actions&#39;

export function Button() {
  return &lt;button formAction={createPost}&gt;Create&lt;/button&gt;
}</code></pre>
<h3 id="invoking-server-functions">Invoking Server Functions</h3>
<p>Server Function 을 선언하는 방법은 2가지다.</p>
<ol>
<li>Client Component 내에 Forms</li>
<li>Client Component 내에 Event Handlers</li>
</ol>
<p>HTML form 태그를 확장한 Next.js 의 Form 태그에서 action 프롭을 이용할 수 있다. form 의 action 프롭은 Server Function 이며 FormData 객체를 받아 서버 기능을 사용할 수 있다.</p>
<pre><code class="language-javascript">// app/ui/form.tsx
import { createPost } from &#39;@/app/actions&#39;

export function Form() {
  return (
    &lt;form action={createPost}&gt;
      &lt;input type=&quot;text&quot; name=&quot;title&quot; /&gt;
      &lt;input type=&quot;text&quot; name=&quot;content&quot; /&gt;
      &lt;button type=&quot;submit&quot;&gt;Create&lt;/button&gt;
    &lt;/form&gt;
  )
}

// app/actions.ts
&#39;use server&#39;

export async function createPost(formData: FormData) {
  const title = formData.get(&#39;title&#39;)
  const content = formData.get(&#39;content&#39;)

  // Update data
  // Revalidate cache
}</code></pre>
<p>Event handlers 는 아래와 같이 onClick 을 사용할 수 있다.</p>
<pre><code class="language-javascript">&#39;use client&#39;

import { incrementLike } from &#39;./actions&#39;
import { useState } from &#39;react&#39;
type Props = { initialLikes: number };

export function LikeButton({ initialLikes }: Props) {
  const [likes, setLikes] = useState(initialLikes)

  return (
    &lt;&gt;
      &lt;p&gt;Total Likes: {likes}&lt;/p&gt;
      &lt;button
        onClick={async () =&gt; { // 여기가 Server Function
          const updatedLikes = await incrementLike()
          setLikes(updatedLikes)
        }}
      &gt;
        Like
      &lt;/button&gt;
    &lt;/&gt;
  )
}</code></pre>
<h3 id="examples">Examples</h3>
<p>Server Function 을 실행할 때 pending 상태를 보여주기 위해 loading indicator 를 사용하는 예시다. React 의 <a href="https://react.dev/reference/react/useActionState">useActionState</a> 를 사용한다.</p>
<pre><code class="language-javascript">&#39;use client&#39;

import { useActionState } from &#39;react&#39;
import { createPost } from &#39;@/app/actions&#39; // Server Function
import { LoadingSpinner } from &#39;@/app/ui/loading-spinner&#39;

export function Button() {
  const [state, action, pending] = useActionState(createPost, false)

  return (
    &lt;button onClick={async () =&gt; action()}&gt;
      {pending ? &lt;LoadingSpinner /&gt; : &#39;Create Post&#39;}
    &lt;/button&gt;
  )
}</code></pre>
<p>Update 수행 후 Next.js 캐시를 초기화하고 업데이트 된 데이터를 보여주기 위해 <a href="https://nextjs.org/docs/app/api-reference/functions/revalidatePath">revalidatePath</a> 혹은 <a href="https://nextjs.org/docs/app/api-reference/functions/revalidateTag">revalidateTag</a> 를 Server Function 내에서 사용한다.</p>
<pre><code class="language-javascript">import { revalidatePath } from &#39;next/cache&#39;

export async function createPost(formData: FormData) {
  &#39;use server&#39;
  // Update data
  // ...

  revalidatePath(&#39;/posts&#39;)
}</code></pre>
<p>Server Function 에서 update 뒤에 redirect 로 다른 페이지로 간다.</p>
<pre><code class="language-javascript">&#39;use server&#39;

import { redirect } from &#39;next/navigation&#39;

export async function createPost(formData: FormData) {
  // Update data
  // ...

  redirect(&#39;/posts&#39;)
}</code></pre>
<h2 id="error-handling">Error Handling</h2>
<p>에러는 예상 하였는지 여부에 따라 나뉘어진다.</p>
<h3 id="handling-expected-errors">Handling expected errors</h3>
<p>가장 대표적인 예시는 server-side form validation 이나 failed requests 이다. 이런 에러는 핸들링 한 뒤 클라이언트에 리턴하면 된다.</p>
<p>Server Function 에서는 useActionState 훅이 효과적이다. 이 경우에는 try/catch 블록 혹은 throw error 를 사용하지 말고 에러를 값으로 리턴해야 한다.</p>
<pre><code class="language-javascript">&#39;use server&#39;

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get(&#39;title&#39;)
  const content = formData.get(&#39;content&#39;)

  const res = await fetch(&#39;https://api.vercel.app/posts&#39;, {
    method: &#39;POST&#39;,
    body: { title, content },
  })
  const json = await res.json()

  if (!res.ok) { // throw 대신 에러를 반환
    return { message: &#39;Failed to create post&#39; }
  }
}</code></pre>
<p>서버 컴포넌트 안에서 데이터 fetch 할 땐 response 에 따라 다른 컴포넌트를 렌더링 한다.</p>
<pre><code class="language-javascript">export default async function Page() {
  const res = await fetch(`https://...`)
  const data = await res.json()

  if (!res.ok) { // response 를 이용
    return &#39;There was an error.&#39;
  }

  return &#39;...&#39;
}</code></pre>
<p>notFound 함수를 이용하면 404 에러를 핸들링할 수 있다.</p>
<pre><code class="language-javascript">// app/blog/[slug]/page.tsx
import { getPostBySlug } from &#39;@/lib/posts&#39;

export default async function Page({ params }: { params: { slug: string } }) {
  const { slug } = await params
  const post = getPostBySlug(slug)

  if (!post) {
    notFound()
  }

  return &lt;div&gt;{post.title}&lt;/div&gt;
}

// app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return &lt;div&gt;404 - Page Not Found&lt;/div&gt;
}</code></pre>
<h3 id="handling-uncaught-exceptions">Handling uncaught exceptions</h3>
<p>예상하지 못한 이슈는 throw error 로 인한 것이기 때문에 error.tsx 를 사용해야 한다.</p>
<pre><code class="language-javascript">&#39;use client&#39; // Error boundaries must be Client Components

import { useEffect } from &#39;react&#39;
type Props = {
  error: Error &amp; { digest?: string }
  reset: () =&gt; void
}

export default function Error({ error, reset }: Props) {
  useEffect(() =&gt; {
    // Log the error to an error reporting service
    console.error(error)
  }, [error])

  return (
    &lt;div&gt;
      &lt;h2&gt;Something went wrong!&lt;/h2&gt;
      &lt;button
        onClick={
          // Attempt to recover by trying to re-render the segment
          () =&gt; reset()
        }
      &gt;
        Try again
      &lt;/button&gt;
    &lt;/div&gt;
  )
}</code></pre>
<p>Error boundary 는 Layout 바로 아래 컴포넌트로 렌더링 한다.</p>
<p>Root Layout 은 루트 앱 디렉토리의 global-error.tsx 파일을 사용한다. global-error.tsx 에는 html, body 태그가 포함되어 있어야 한다.</p>
<pre><code class="language-javascript">&#39;use client&#39; // Error boundaries must be Client Components

type Props = {
  error: Error &amp; { digest?: string }
  reset: () =&gt; void
}

export default function GlobalError({ error, reset }: Props) {
  return (
    // global-error must include html and body tags
    &lt;html&gt;
      &lt;body&gt;
        &lt;h2&gt;Something went wrong!&lt;/h2&gt;
        &lt;button onClick={() =&gt; reset()}&gt;Try again&lt;/button&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<h2 id="metadata-and-og-images">Metadata and OG images</h2>
<p>Metadata API 는 SEO 향상과 웹 공유성과 다음을 제공한다.</p>
<ol>
<li>static metadata 객체</li>
<li>dynamic generateMetadata 함수</li>
<li>static / dynamic favicon 과 OG image 들에 대한 파일 컨벤션</li>
</ol>
<p>위에 옵션에 대해 Next.js 는 자동으로 head 태그에 이를 반영한다.</p>
<h3 id="default-fields">Default fields</h3>
<p>Next.js 에서는 라우팅에 관계없이 아래 2개의 메타 태그가 기본으로 들어간다.</p>
<pre><code class="language-html">&lt;meta charset=&quot;utf-8&quot; /&gt; // character encoding
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot; /&gt; // viewport width, scale</code></pre>
<p>나머지 요소들은 Metadata 객체 (static metadata) 혹은 generatedMetadata 함수 (generated metadata) 를 사용한다.</p>
<h3 id="static-metadata">Static metadata</h3>
<p>Metadata 객체를 layout.tsx 혹은 page.tsx 에서 사용한다.</p>
<pre><code class="language-javascript">import type { Metadata } from &#39;next&#39;

export const metadata: Metadata = {
  title: &#39;My Blog&#39;,
  description: &#39;...&#39;,
}

export default function Page() {}</code></pre>
<p><a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadata-fields">generate metadata documentation</a> 참고</p>
<h3 id="generated-metadata">Generated metadata</h3>
<p>generatedMetadata 함수로 data 에 의존성이 있는 메타데이터를 fetch 할 수 있다. 아래 예시는 특정 블로그 포스트에 대해 제목과 설명을 fetch 한다.</p>
<pre><code class="language-javascript">import type { Metadata, ResolvingMetadata } from &#39;next&#39;

type Props = {
  params: Promise&lt;{ id: string }&gt;
  searchParams: Promise&lt;{ [key: string]: string | string[] | undefined }&gt;
}

export async function generateMetadata(
  { params, searchParams }: Props,
  parent: ResolvingMetadata
): Promise&lt;Metadata&gt; {
  const slug = (await params).slug

  // fetch post information
  const post = await fetch(`https://api.vercel.app/blog/${slug}`).then((res) =&gt;
    res.json()
  )

  return {
    title: post.title,
    description: post.description,
  }
}

export default function Page({ params, searchParams }: Props) {}</code></pre>
<p>내부적으로 Next.js 는 메타 데이터를 fetch 하여 UI 와는 비동기적으로 메타 데이터를 html 에 넣는다.</p>
<p>만약 Metadata 가 같은 데이터를 갖고 있는 경우를 대비한다면 React 의 catch 함수를 사용하여 리턴 데이터를 memoize 한다.</p>
<pre><code class="language-javascript">// app/lib/data.ts
import { cache } from &#39;react&#39;
import { db } from &#39;@/app/lib/db&#39;

// getPost will be used twice, but execute only once
export const getPost = cache(async (slug: string) =&gt; {
  const res = await db.query.posts.findFirst({ where: eq(posts.slug, slug) })
  return res
})

// app/blog/[slug]/page.tsx
import { getPost } from &#39;@/app/lib/data&#39;

export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}) {
  // fetch 를 대체
  const post = await getPost(params.slug)
  return {
    title: post.title,
    description: post.description,
  }
}

export default async function Page({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)
  return &lt;div&gt;{post.title}&lt;/div&gt;
}</code></pre>
<h3 id="favicons">Favicons</h3>
<p>app 디렉토리에 favicon.ico 를 넣는다.</p>
<h3 id="static-open-graph-images">Static Open Graph images</h3>
<p>OG image 는 SNS 에 노출되는 이미지이다. app 디렉토리 혹은 특정 라우트 디렉터리에 opengraph-image.jpg 파일을 넣는다.</p>
<h3 id="generated-open-graph-images">Generated Open Graph images</h3>
<p>dynamic OG image 를 사용하고 싶을 경우 ImageResponse 생성자를 이용한다.</p>
<pre><code class="language-javascript">import { ImageResponse } from &#39;next/og&#39;
import { getPost } from &#39;@/app/lib/data&#39;

// Image metadata
export const size = {
  width: 1200,
  height: 630,
}

export const contentType = &#39;image/png&#39;

// Image generation
export default async function Image({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)

  return new ImageResponse(
    (
      // ImageResponse JSX element
      &lt;div
        style={{
          fontSize: 128,
          background: &#39;white&#39;,
          width: &#39;100%&#39;,
          height: &#39;100%&#39;,
          display: &#39;flex&#39;,
          alignItems: &#39;center&#39;,
          justifyContent: &#39;center&#39;,
        }}
      &gt;
        {post.title}
      &lt;/div&gt;
    )
  )
}</code></pre>
<h2 id="deploying">Deploying</h2>
<p>Next.js 앱이 준비되었다? managed infrastructure provider 혹은 self-host 로 웹 애플리케이션을 배포한다.</p>
<h3 id="managed-infrastructure-providers">Managed infrastructure providers</h3>
<p>Vercel 에서 했으면 좋겠다고 한다.</p>
<h3 id="self-hosting">Self-Hosting</h3>
<p>실제 서버를 구축하는 것을 말한다.</p>
<h2 id="upgrading">Upgrading</h2>
<h3 id="latest-version">Latest version</h3>
<pre><code class="language-shell">npx @next/codemod@canary upgrade latest</code></pre>
<p>이렇게도 활용할 수 있다.</p>
<pre><code class="language-shell">npm i next@latest react@latest react-dom@latest eslint-config-next@latest</code></pre>
<p>2025-04-23 기준 현재 최신 버전은 Next.js 15 canary 이다.</p>
<pre><code class="language-shell">npm i next@canary</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Docs] Next.js Getting Started from 'Installation' to 'CSS']]></title>
            <link>https://velog.io/@sanghwi_back/Docs-Next.js-Getting-Started-from-Installation-to-CSS</link>
            <guid>https://velog.io/@sanghwi_back/Docs-Next.js-Getting-Started-from-Installation-to-CSS</guid>
            <pubDate>Wed, 16 Apr 2025 11:33:24 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Next.js 공부를 위해 공식문서를 읽기 쉽게 제가 생각하는 핵심내용만 짚어서 정리합니다.</p>
</blockquote>
<h2 id="installation">Installation</h2>
<h3 id="how-to-set-up-a-new-nextjs-project">How to set up a new Next.js project</h3>
<ul>
<li>Node 18.18 이상</li>
<li>macOS, Windows, Linux OS</li>
</ul>
<h3 id="automatic-installation-자동이라고-할-것-까지야">Automatic installation (자동이라고 할 것 까지야...)</h3>
<p><code>create-next-app</code> 라는 명령어를 사용한다.</p>
<pre><code class="language-shell">npx create-next-app@latest</code></pre>
<p>명령어 입력하면 프로젝트의 여러 옵션이 표시되고 결정할 수 있는데 TailWind CSS, import alias 만 개인 취향으로 하고 나머지는 그냥 Yes 하면 될 것 같다.</p>
<h3 id="manual-installation">Manual installation</h3>
<p>아래 명령어를 입력해 직접 패키지를 설치한다.</p>
<pre><code class="language-script">npm install next@latest react@latest react-dom@latest</code></pre>
<p>package.json 파일은 아래와 같이 수정한다.</p>
<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;next dev&quot;, // 개발서버를 실행
    &quot;build&quot;: &quot;next build&quot;, // 앱을 프로덕션 버전으로 빌드
    &quot;start&quot;: &quot;next start&quot;, // 프로덕션 서버 실행
    &quot;lint&quot;: &quot;next lint&quot; // ESLint 실행
  }
}</code></pre>
<h4 id="create-the-app-directory">Create the &quot;app&quot; directory</h4>
<p>Next.js 는 파일 시스템 기반의 라우팅을 제공하기 때문에 필요한 작업이다. 루트 경로에 <code>app</code> 디렉토리를 만들고 <code>layout.tsx</code> 파일을 만든다. 이 파일은 <strong>Root Layout</strong> 이다. (참고로 이 파일 생성을 잊어먹으면 프레임워크가 자동으로 생성한다)</p>
<p>Root Layout 은 반드시 <em>html</em>, <em>body</em> 를 포함해야 한다.</p>
<pre><code class="language-typescript">type Children = { children: React.ReactNode };
export default function RootLayout({ children }: Children) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;body&gt;{children}&lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<p><code>app</code> 디렉토리에 <code>page.tsx</code> 를 만들어준다. 이건 레이아웃 내에 포함될 페이지 컴포넌트이다.</p>
<pre><code class="language-typescript">export default function Page() {
  return &lt;h1&gt;Hello, Next.js!&lt;/h1&gt;
}</code></pre>
<p>이 컴포넌트는 URL 의 <strong>[schema]://[domain]/</strong> 으로 접속 가능하다 (예시: <a href="https://localhost:3000/">https://localhost:3000/</a>)</p>
<p>여기까지 진행했다면 아래와 같은 파일 구조일 것이다.</p>
<pre><code>[app]
|-layout.tsx
|-page.tsx</code></pre><p>src 디렉토리를 넣고 싶다면 app 폴더를 포함해서 넣도록 한다.</p>
<h4 id="create-the-public-folder-optional">Create the &quot;public&quot; folder (optional)</h4>
<p><code>public</code> 폴더를 루트 경로에 추가하여 정적 애셋을 추가한다. 이 애셋들은 base URL 으로 접근 가능하다.</p>
<pre><code class="language-typescript">import Image from &#39;next/image&#39;

export default function Page() {
  return &lt;Image
      src=&quot;/profile.png&quot;
      alt=&quot;Profile&quot;
      width={100}
      height={100}
  /&gt;
}</code></pre>
<h3 id="run-the-development-server">Run the development server</h3>
<ol>
<li><code>npm run dev</code></li>
<li><a href="https://localhost:3000">https://localhost:3000</a> 접속</li>
<li><strong>app/page.tsx</strong> 파일을 수정하고 반영되는지 확인</li>
</ol>
<h3 id="set-up-typescript">Set up Typescript</h3>
<p>최소 타입스크립트 버전은 4.5.2</p>
<p>Next.js 는 타입스크립트를 내장한다. 그러므로 js / jsx 확장자를 ts / tsx 로만 바꾸고 개발서버를 실행하면 된다. tsconfig.json 을 넣으면 원하는 설정을 추가할 수도 있다.</p>
<h3 id="set-up-eslint">Set up ESLint</h3>
<p>실행은 <code>npm run lint</code> 로 한다. (package.json 설정은 위에 참조)</p>
<p>콘솔창에 이 명령어를 실행하면 Lint 실행 옵션이 보인다.</p>
<ul>
<li>Strict : Next.js 기반 ESLint 설정에 Core Web Vitals rule-set 을 적용한다(뭔지는 잘 모르겠다).</li>
<li>Base : Next.js 기반 ESLint 설정을 적용한다.</li>
<li>Cance : 자신이 설정한 ESLint 설정을 적용한다.</li>
</ul>
<p>Strict, Base 를 실행하면 ESLint 가 자동 설치. 이후엔 <code>next lint</code> 로 실행 가능.</p>
<h3 id="set-up-absolute-imports-and-module-path-aliases">Set up Absolute Imports and Module Path Aliases</h3>
<p><code>tsconfig.json</code> 혹은 <code>jsconfig.json</code> 에서 paths, baseUrl 설정이 가능하다. 절대경로를 @ 를 이용해서 가독성이 좋은 상대경로로 바꿀 수 있다. 이외에도 다른 경로도 alias 로 만들 수 있다.</p>
<pre><code class="language-json">{
  &quot;compilerOptions&quot;: {
    &quot;baseUrl&quot;: &quot;src/&quot;,
    &quot;paths&quot;: {
      &quot;@/styles/*&quot;: [&quot;styles/*&quot;],
      &quot;@/components/*&quot;: [&quot;components/*&quot;]
    }
  }
}</code></pre>
<h2 id="project-structure-and-organization">Project structure and organization</h2>
<p>Next.js 의 파일 컨벤션과 추천사항들을 다룬다.</p>
<h3 id="folder-and-file-conventions">Folder and file conventions</h3>
<p>public
src
|--app</p>
<h4 id="top-level-folders">Top-level folders</h4>
<ul>
<li>app : App Router</li>
<li>pages : Pages Router</li>
<li>public : Static assets</li>
<li>src : &quot;Optional&quot;</li>
</ul>
<h4 id="top-level-files">Top-level files</h4>
<p>의존성, 미들웨어, 모니터링 툴, 환경변수 설정</p>
<ul>
<li>next.config.js : Next.js 설정</li>
<li>package.json : Node 패키지 설정</li>
<li>middleware.ts : Next.js request middleware</li>
<li>.env : 환경변수<ul>
<li>.env.local : 로컬 환경변수</li>
<li>.env.production : 배포 환경변수</li>
<li>.env.development : 개발 환경변수</li>
<li>.eslintrc.json : ESLint 설정</li>
</ul>
</li>
<li>next-env.d.ts : Next.js Typescript 선언 파일</li>
<li>tsconfig.json / jsconfig.js : TS / JS 설정 파일</li>
</ul>
<h4 id="routing-files">Routing Files</h4>
<ul>
<li>layout</li>
<li>page</li>
<li>loading</li>
<li>not-found</li>
<li>error</li>
<li>global-error</li>
<li>route</li>
<li>template</li>
<li>default : Parallel route 시 fallback page</li>
</ul>
<h4 id="nested-routes">Nested routes</h4>
<ul>
<li>folder : Route 세그먼트</li>
<li>folder/folder : Nested Route 세그먼트</li>
</ul>
<h4 id="dynamic-routes">Dynamic routes</h4>
<ul>
<li>[folder] : Dynamic route 세그먼트</li>
<li>[...folder] : Catch-all route 세그먼트</li>
<li>[[...folder]] : Optional catch-all route 세그먼트</li>
</ul>
<h4 id="route-groups-and-private-folders">Route Groups and private folders</h4>
<ul>
<li>(folder) : Route 에 영향을 주지 않는 그룹</li>
<li>_folder : Route 에 영향을 주지 않는 그룹의 하위 폴더</li>
</ul>
<h4 id="parallel-and-intercepted-routes">Parallel and Intercepted Routes</h4>
<ul>
<li>@folder : Slot</li>
<li>(.)folder : 같은 레벨 Intercept</li>
<li>(..)folder : 한 레벨 위 Intercept</li>
<li>(..)(..)folder : 두 레벨 위 Intercept</li>
<li>(...)folder : 루트 Intercept</li>
</ul>
<h4 id="metadata-file-convention">Metadata file convention</h4>
<ul>
<li>App icons<ul>
<li>favicon : &quot;.ico&quot; / 앱 Favicon</li>
<li>icon : &quot;.ico&quot; 비롯한 이미지 확장자 / 앱 아이콘</li>
<li>icon : &quot;.js&quot;, &quot;.ts&quot;, &quot;.tsx&quot; / 생성된 앱 아이콘</li>
<li>apple-icon : 이미지 확장자 / 애플 앱 아이콘</li>
<li>apple-icon : &quot;.js&quot;, &quot;.ts&quot;, &quot;.tsx&quot; / 생성된 애플 앱 아이콘</li>
</ul>
</li>
<li>Open Graph and Twitter images<ul>
<li>opengraph-image : 이미지 확장자 / Open graph 이미지 파일</li>
<li>opengraph-image : &quot;.js&quot;, &quot;.ts&quot;, &quot;.tsx&quot; / 생성된 Open graph 이미지 파일</li>
<li>twitter-image : 이미지 확장자 / Open graph 이미지 파일</li>
<li>twitter-image : &quot;.js&quot;, &quot;.ts&quot;, &quot;.tsx&quot; / 생성된 Open graph 이미지 파일</li>
</ul>
</li>
<li>SEO<ul>
<li>sitemap : &quot;.xml&quot; / Sitemap 파일</li>
<li>sitemap : &quot;.js&quot;, &quot;.ts&quot; / 생성된 Sitemap 파일</li>
<li>robots : &quot;.txt&quot; / Robots 파일</li>
<li>robots : &quot;.js&quot;, &quot;.ts&quot; / 생성된 Robots 파일</li>
</ul>
</li>
</ul>
<h3 id="organizing-your-project">Organizing your project</h3>
<p>Next.js 는 파일 구성에 대해 이미지 정해진 룰이 있으나 아래 사항을 참고하면 좋다.</p>
<h4 id="component-hierarchy">Component hierarchy</h4>
<p>아래 파일에 정의된 컴포넌트는 위계구조를 갖는다.</p>
<ul>
<li>layout.js</li>
<li>template.js</li>
<li>error.js (React error boundary)</li>
<li>loading.js (React suspense boundary)</li>
<li>not-found.js (React error boundary)</li>
<li>page.js / nested layout.js</li>
</ul>
<pre><code class="language-typescript">&lt;Layout&gt;
  &lt;Template&gt;
      &lt;ErrorBoundary fallback={&lt;Error /&gt;}&gt;
      &lt;Suspense fallback={&lt;Loading /&gt;}&gt;
        &lt;ErrorBoundary fallback={&lt;NotFound /&gt;}&gt;
          &lt;Page /&gt;
        &lt;/ErrorBoundary&gt;
      &lt;/Suspense&gt;
    &lt;/ErrorBoundary&gt;
  &lt;/Template&gt;
&lt;/Layout&gt;</code></pre>
<p>컴포넌트들은 연속적으로 렌더링된다. nested 세그먼트는 부모 세그먼트 안에 렌더링된다.</p>
<p>dashboard
|--layout.ts
|--error.js
|--loading.js
|---settings
&nbsp;&nbsp;&nbsp;&nbsp;|---layout.ts
&nbsp;&nbsp;&nbsp;&nbsp;|---error.js
&nbsp;&nbsp;&nbsp;&nbsp;|---loading.js
&nbsp;&nbsp;&nbsp;&nbsp;|---page.js</p>
<pre><code class="language-typescript">&lt;Layout&gt;
  &lt;ErrorBoundary fallback={&lt;Error /&gt;}&gt;
    &lt;Suspense fallback={&lt;Loading /&gt;}&gt;
      // Start of settings
      &lt;Layout&gt;
        &lt;ErrorBoundary fallback={&lt;Error /&gt;}&gt;
          &lt;Suspense fallback={&lt;Loading /&gt;}&gt;
            &lt;Page /&gt;      
          &lt;/Suspense&gt;
        &lt;/ErrorBoundary&gt; 
      &lt;/Layout&gt;
      // End of settings
    &lt;/Suspense&gt;
  &lt;/ErrorBoundary&gt;
&lt;/Layout&gt;</code></pre>
<h4 id="colocation">Colocation</h4>
<p><code>app</code> 디렉토리에는 아래 폴더들이 Route 세그먼트를 정의한다(URL 의 세그먼트). 하지만 <code>page.js</code> 혹은 <code>route.js</code> 가 없으면 Route 세그먼트로 작동하지 않는다.</p>
<p>app
|---dashboard&nbsp;&nbsp;&nbsp;&nbsp;<em>Not Routable</em>
&nbsp;&nbsp;&nbsp;&nbsp;|---settings&nbsp;&nbsp;&nbsp;&nbsp;<em>Not Routable</em>
|---api&nbsp;&nbsp;&nbsp;&nbsp;<em>Not Routable</em></p>
<p>반대로 <code>page.js</code> 혹은 <code>route.js</code> 만 있는 폴더라면 Route 세그먼트로 작동한다.</p>
<p>app
|---dashboard&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;|---page.js&nbsp;&nbsp;&nbsp;&nbsp;<em>Routablable, /dashboard</em>
&nbsp;&nbsp;&nbsp;&nbsp;|----settings
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|----page.js&nbsp;&nbsp;&nbsp;&nbsp;<em>Routable, /dashboard/settings</em>
|---api
&nbsp;&nbsp;&nbsp;&nbsp;|---route.js&nbsp;&nbsp;&nbsp;&nbsp;<em>Routable, /dashboard/api/</em></p>
<p>이는 라우팅할 파일이 정해져 있다는 뜻이기 때문에 실수를 줄일 수 있다.</p>
<h4 id="private-folders">Private folders</h4>
<p>폴더 앞에 _ 를 붙인다(%5F 도 가능). Route 세그먼트에 포함되지 않는다.</p>
<p>app
|---dashboard&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;|---page.js&nbsp;&nbsp;&nbsp;&nbsp;<em>Routablable, /dashboard</em>
&nbsp;&nbsp;&nbsp;&nbsp;|----_components
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|----buttons.js
&nbsp;&nbsp;&nbsp;&nbsp;|----_lib
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|----format-date.js
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|----page.jsroute.js&nbsp;&nbsp;&nbsp;&nbsp;<em>Not Routable, /dashboard/_lib/</em></p>
<p>Private folder 는 아래의 상황에서 사용한다.</p>
<ul>
<li>UI logic 을 routing logic 과 분리하고 싶음</li>
<li>사용할 내부 파일을 만들고 싶음</li>
<li>코드와 파일 정리</li>
<li>Next.js 파일 컨벤션과 충돌을 방지</li>
</ul>
<h4 id="route-groups">Route groups</h4>
<p>(폴더이름) 으로 만들 수 있다. Route 세그먼트에 포함되지 않는다.</p>
<p>app
|---(admin)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|---dashboard
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|---page.js&nbsp;&nbsp;&nbsp;&nbsp;<em>Routablable, /dashboard</em>
|---(marketing)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|---about
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|---page.js&nbsp;&nbsp;&nbsp;&nbsp;<em>Routablable, /about</em>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|---blog
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|---page.js&nbsp;&nbsp;&nbsp;&nbsp;<em>Routablable, /blog</em></p>
<p>Route groups 는 아래의 상황에서 사용한다.</p>
<ul>
<li>용도에 따라 Route 세그먼트 파일들을 묶고 싶음.</li>
<li>특정 Route 세그먼트들에만 적용되는 레이아웃을 만들고 싶음.<ul>
<li>loading.tsx 등도 같은 그룹 내에서 공유한다.</li>
<li>하나의 Route 세그먼트에서 여러 개의 loading, layout 가질 수 있다.</li>
</ul>
</li>
</ul>
<h4 id="src-directory">src directory</h4>
<p>Optional. 루트의 루트. 앱의 코드와 프로젝트 설정파일 (예: package.json) 을 나누기 좋음.</p>
<h2 id="layouts-and-pages">Layouts and Pages</h2>
<h3 id="how-to-create-layouts-and-pages">How to create layouts and pages</h3>
<h4 id="creating-a-page">Creating a page</h4>
<p>page 는 렌더링 되는 컴포넌트이다.</p>
<p>app
|--page.js&nbsp;&nbsp;&nbsp;&nbsp;<em>Routable, /</em></p>
<pre><code class="language-typescript">export default function Page() {
  return &lt;h1&gt;Hello Next.js!&lt;/h1&gt;
}</code></pre>
<h4 id="creating-a-layout">Creating a layout</h4>
<p>다수의 page 에서 공유하며, navigation 에도 그 상태와 상호작용이 유지된다. 렌더링 사이클은 page 와 같이 가져가지 않는다.</p>
<p>layout 파일을 만들면 된다. 아래 예시는 Root Layout 이다. Root Layout 은 특별히 html, body xormfmf 가져야 한다.</p>
<p>app
|--layout.js
|--page.js&nbsp;&nbsp;&nbsp;&nbsp;<em>Routable, /</em></p>
<pre><code class="language-typescript">type Children = { children: React.ReactNode }
export default function DashboardLayout({ children }: Children) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;body&gt;
        &lt;main&gt;{children}&lt;/main&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<h4 id="creating-a-nested-route">Creating a nested route</h4>
<p>앱 내 여러 URL 세그먼트를 정의할 수 있다.</p>
<p>/blog/[slug]</p>
<ul>
<li>/ = 루트 세그먼트</li>
<li>blog = 세그먼트</li>
<li>[slug] = 자식 세그먼트 (다이나믹 라우팅 세그먼트)</li>
</ul>
<h4 id="nesting-layouts">Nesting layouts</h4>
<p>Layout 도 nested 된다. nested 된 layout 은 부모 layout 내에 렌더링 된다.</p>
<h4 id="linking-between-pages">Linking between pages</h4>
<p>&lt;Link&gt; 컴포넌트를 이용하면 라우팅이 가능하다. Next.js 빌트인 컴포넌트이고 a 태그를 확장하였다.</p>
<p>아래 코드는 &#39;/blog/[Dynamic_Routing_Segment]&#39; 로 라우팅 하는 예제이다.</p>
<pre><code class="language-typescript">import Link from &#39;next/link&#39;;

export default async function Post({ post }) {
  const posts = await getPosts();

  return (
    &lt;ul&gt;
      {posts.map((post) =&gt; (
        &lt;li key={post.slug}&gt;
          &lt;Link href={`/blog/${post.slug}`}&gt;{post.title}&lt;/Link&gt;
        &lt;/li&gt;
      ))}
    &lt;/ul&gt;
  )
}</code></pre>
<p>Link 컴포넌트는 대부분의 라우팅 상황에서 사용할 수 있지만 useRouter 훅을 이용해야 할 경우도 있다.</p>
<h2 id="images-and-fonts">Images and Fonts</h2>
<p>Next.js 는 이미지와 폰트에 대한 최적화가 이미 되어 있다.</p>
<h3 id="optimizing-images">Optimizing images</h3>
<p>Image 컴포넌트는 html &lt;img&gt; 태그를 확장한다. 이 컴포넌트는 다음의 기능을 제공한다.</p>
<ul>
<li>크기(파일 크기, 이미지 크기) 최적화, 모던 이미지 형식인 WebP 을 사용한다.</li>
<li>이미지 로딩 최적화</li>
</ul>
<pre><code class="language-typescript">import Image from &#39;next/image&#39;;

export default function Page() {
  return &lt;Image src=&quot;&quot; alt=&quot;&quot; /&gt;
}</code></pre>
<p>src 속성은 로컬, 원격 주소 모두 가능.</p>
<h4 id="local-images">Local images</h4>
<p>public 폴더 내에 있다면 가능.</p>
<pre><code class="language-typescript">import Image from &#39;next/image&#39;;
import profilePic from &#39;./me.png&#39;;

export default function Page() {
  return &lt;Image src={profilePic} alt=&quot;Picture of the author&quot; /&gt;
}</code></pre>
<p>Next.js 는 import 한 이미지 크기를 이용해 내부 폭, 높이를 설정한다. blurDataURL, placeholder 속성도 있는데 설정 안하면 기본값이 제공된다.</p>
<h4 id="remote-images">Remote images</h4>
<p>URL string 을 제공한다.</p>
<pre><code class="language-typescript">import Image from &#39;next/image&#39;

export default function Page() {
  return (
    &lt;Image
      src=&quot;https://s3.amazonaws.com/my-bucket/profile.png&quot;
      alt=&quot;Picture of the author&quot;
      width={500} height={500}
    /&gt;
  )
}</code></pre>
<p>이 경우는 빌드 타임에 이미지 크기를 Next.js 는 모르니 width, height 를 제공해주는 것이 좋다.</p>
<p>next.config.js 파일에 제공될 URL 패턴을 제공하면 자칫 잘못된 URL 을 사용하는 것을 막을 수 있다.</p>
<pre><code class="language-typescript">import { NextConfig } from &#39;next&#39;;

const config: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: &#39;https&#39;,
        hostname: &#39;s3.amazonaws.com&#39;,
        port: &#39;&#39;,
        pathname: &#39;/my-bucket/**&#39;,
        search: &#39;&#39;,
      },
    ],
  },
}

export default config;</code></pre>
<h3 id="optimizing-fonts">Optimizing fonts</h3>
<p>next/font 모듈은 폰트를 최적화하고 네트워크 요청을 최소화 한다. 거기다 Next.js 는 내장된 폰트가 따로 있다.</p>
<p>만약 로컬 파일로 존재하는 폰트를 가져오기 위해서는 &#39;next/font/local&#39; 혹은 &#39;next/font/google&#39; 을 사용한다.</p>
<pre><code class="language-typescript">import { Geist } from &#39;next/font/google&#39;;

const geist = Geist({
  weight: &#39;400&#39;, // 속성 적용 가능
  subsets: [&#39;latin&#39;],
});

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang=&quot;en&quot; className={geist.className}&gt;
      &lt;body&gt;{children}&lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<pre><code class="language-typescript">import localFont from &#39;next/font/local&#39;;

const myFont = localFont({
  src: &#39;./my-font.woff2&#39;,
})

// 아래도 가능.
// const roboto = localFont({
//   src: [
//     {
//       path: &#39;./Roboto-Regular.woff2&#39;,
//       weight: &#39;400&#39;,
//       style: &#39;normal&#39;,
//     },
//     {
//       path: &#39;./Roboto-Italic.woff2&#39;,
//       weight: &#39;400&#39;,
//       style: &#39;italic&#39;,
//     },
//     {
//       path: &#39;./Roboto-Bold.woff2&#39;,
//       weight: &#39;700&#39;,
//       style: &#39;normal&#39;,
//     },
//     {
//       path: &#39;./Roboto-BoldItalic.woff2&#39;,
//       weight: &#39;700&#39;,
//       style: &#39;italic&#39;,
//     },
//   ],
// })

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang=&quot;en&quot; className={geist.className}&gt;
      &lt;body&gt;{children}&lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<h2 id="css">CSS</h2>
<p>CSS 에 관해서 Next.js 는 여러 개의 방식을 제공한다.</p>
<ul>
<li>CSS Modules</li>
<li>Global CSS</li>
<li>Tailwind CSS</li>
<li>Sass</li>
<li>CSS-in-JS</li>
<li>External Stylesheets</li>
</ul>
<h3 id="css-modules">CSS Modules</h3>
<p>CSS classname 을 만들고 적용한다.</p>
<pre><code class="language-typescript">// app/blog/styles.module.css
.blog {
  padding: 24px;
}

// app/blog/page.tsx
import styles from &#39;./styles.module.css&#39;

export default function Page({ children }: { children: React.ReactNode }) {
  return &lt;main className={styles.blog}&gt;{children}&lt;/main&gt;
}</code></pre>
<h3 id="global-css">Global CSS</h3>
<p>앱 전체에 적용하는 CSS 를 정의한다.</p>
<pre><code class="language-typescript">// app/global.css
body {
  padding: 20px 20px 60px;
  max-width: 680px;
  margin: 0 auto;
}

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;body&gt;{children}&lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<h3 id="tailwind-css">Tailwind CSS</h3>
<p>Next.js 와 호환이 잘 된다.</p>
<h4 id="installing-tailwind">Installing Tailwind</h4>
<pre><code class="language-shell">npm install tailwindcss @tailwindcss/postcss postcss</code></pre>
<h4 id="configuring-tailwind">Configuring Tailwind</h4>
<p>root 프로젝트에 postcss.config.mjs 파일을 넣는다.</p>
<pre><code class="language-javascript">/** @type {import(&#39;tailwindcss&#39;).Config} */
export default {
  plugins: {
    &#39;@tailwindcss/postcss&#39;: {},
  },
}</code></pre>
<h4 id="using-tailwind">Using Tailwind</h4>
<p>Global Stylesheet (app/globals.css) 에 Tailwind directives 를 넣는다.</p>
<pre><code class="language-css">@import &#39;tailwindcss&#39;;</code></pre>
<p>그리고 root layout 에 Global Stylesheet 를 추가한다.</p>
<pre><code class="language-typescript">import type { Metadata } from &#39;next&#39;
// These styles apply to every route in the application
import &#39;./globals.css&#39;

export const metadata: Metadata = {
  title: &#39;Create Next App&#39;,
  description: &#39;Generated by create next app&#39;,
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;body&gt;{children}&lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<p>이제 사용 가능</p>
<pre><code class="language-typescript">export default function Page() {
  return &lt;h1 className=&quot;text-3xl font-bold underline&quot;&gt;Hello, Next.js!&lt;/h1&gt;
}</code></pre>
<h3 id="sass">Sass</h3>
<h4 id="installing-sass">Installing Sass</h4>
<pre><code class="language-shell">npm install --save-dev sass</code></pre>
<h4 id="customizing-sass-options">Customizing Sass options</h4>
<p>next.config.js 에서 아래와 같이 작성한다.</p>
<pre><code class="language-typescript">import type { NextConfig } from &#39;next&#39;;

const nextConfig: NextConfig = {
  sassOptions: {
    additionalData: `$var: red;`,
  },
}

export default nextConfig;</code></pre>
<p>나머지는 나중에 필요하면 따로 공부.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[WWDC24 Swift 부문 정리]]></title>
            <link>https://velog.io/@sanghwi_back/WWDC24-Swift-%EB%B6%80%EB%AC%B8-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@sanghwi_back/WWDC24-Swift-%EB%B6%80%EB%AC%B8-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Sat, 15 Jun 2024 07:41:41 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>작년에 왔던 각설이가 죽지도 않고 또 오듯 올해도 WWDC 가 왔다. 서비스를 위한 앱 개발을 하는 입장에서 Swift, UIKit, SwiftUI 의 중요한 부분은 반드시 봐야겠다고 생각해서 세션을 보고 난 후 정리 겸 포스트를 작성한다. 나머지는 필요할 때 따로 공부할 생각이다.</p>
</blockquote>
<h2 id="swift6-가-나올겁니다">Swift6 가 나올겁니다!</h2>
<p>Swift6 의 업데이트가 가시화 되었다고 본다. Apple 에서 공식적으로 출시일을 언급한 부분은 찾지 못했지만 9월로 많이 얘기하는 것 같다. 9월은 애플이 뭔 이벤트 하나 여는 때이기도 하니 합리적인 의심이라 생각된다.</p>
<p>이번 Swift 세션을 전체적으로 보면서 든 생각은 <strong>새로운 기능보다는 내부적인 개선</strong> 에 중점을 맞추었다고 본다.</p>
<ul>
<li>Swift 세션에서 계속 강조하는 건 <em>Data-race</em> 라는 동시성(비동기 프로그래밍)의 고질적 문제이다. 예전부터 값 타입이 이 문제를 극복할 수 있다고 강조했지만 이번 WWDC 에서는 특히 더 많이 언급된다.</li>
<li><em>Embeded Programming</em> (Preview), <em>Swift-on-Server</em>, <em>Swift Community</em> 등 외연을 넓히려는 시도가 돋보인다.</li>
<li>새로운 테스트 프레임워크가 나왔다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/67a66ca9-1ffd-4870-833b-c43a4c94f0b8/image.png" alt=""></p>
<h3 id="개인적인-흥미">개인적인 흥미</h3>
<p>현재 프리랜서인 필자는 Swift 를 통해 최대한 많은 프로젝트를 수행하는 것이 이득이다. 그런 입장에서 두 가지 키워드가 눈에 띄었다.</p>
<ul>
<li>Embeded Swift</li>
<li>Swift on Server</li>
</ul>
<blockquote>
<p>임베디드 프로젝트랑 간단한 서버 개발도 Swift 개발자가 할 수 있는 건가?</p>
</blockquote>
<p>실제로 임베디드 관련 프로젝트는 잘잘하게 끊이지 않는 것 같다.</p>
<img src='https://velog.velcdn.com/images/sanghwi_back/post/a90b3450-8c06-4ef7-97a8-a40a89046359/image.png' width='250px'/>

<p>하지만 Swift 를 바로 시도할 만한 기업은 많지 않을 것 같다. 아직 이 자체가 Preview 이기도 하고, 업계 표준은 쉽게 바뀌지 않는다. 수많은 C 의 향연을 위의 이미지에서 볼 수 있다.</p>
<p>그리고 가장 중요한 문제는 Swift 로 만든 임베디드 프로그램이 디바이스 공급자가 만든 API 를 호출하기만 할 뿐이라는 것이다. 더 깊은 수준의 커스텀이 필요하다면 Swift 가 과연 좋은 선택일지는 의문이다.</p>
<h2 id="swift-둘러보기-swift의-기능-및-디자인-살펴보기">Swift 둘러보기: Swift의 기능 및 디자인 살펴보기</h2>
<p>Swift 기본, 설계 철학에 대한 세션이었다. 유튜브에서 &quot;개발 공부 어떻게 하나요&quot; 에 항상 빠지지 않는 얘기가 사용하는 언어의 철학에 대해 공부하라고 하는데 과연 그 질문에 대답할만한 내용을 담았는지 한번 살펴보고자 한다.</p>
<blockquote>
<p>이 세션은 각 주제에 대해 깊이 다루기보단 모든 주제의 중심이 될만한 내용을 다루는 것이다.</p>
</blockquote>
<p>이 세션에서 다룬 주제는 다음과 같다.</p>
<ul>
<li>Value types</li>
<li>Errors and optionals</li>
<li>Code organization</li>
<li>Classes</li>
<li>Protocols</li>
<li>Concurrency</li>
<li>Extensibility</li>
</ul>
<h3 id="what-are-value-types">What are value types?</h3>
<p>Value type 에 대한 간단한 코드를 보여주면서 알아둬야 할 내용을 정리해준다.</p>
<pre><code class="language-swift">var x: Int = 1
var y: Int = x // 1
x = 42 // 42
y // 1</code></pre>
<ul>
<li>값 타입은 공유되지 않는다.</li>
<li>값 타입에는 아이덴티티가 없다. 같은 값은 구분되지 않는다.</li>
<li>데이터를 나타내는 기본적인 타입인 정수, 논리, 소수값 등은 모두 값 타입이다.</li>
</ul>
<p>여기서 더 복잡한 데이터 타입은 struct 이다.</p>
<pre><code class="language-swift">struct User {
    let username: String
    var isVisible: Bool = true
    var friends: [String] = []
}

var alice = User(username: &quot;alice&quot;)
alice.friends = [&quot;charlie&quot;]

var bruno = User(username: &quot;bruno&quot;)
bruno.friends = alice.friends // 복사!!

alice.friends.append(&quot;dash&quot;)
bruno.friends</code></pre>
<p>타입 자체는 더 복잡해져도 위의 3가지 사항을 어기지는 않는다. 여기서 또 다른 중요한 사항이 나온다.</p>
<ul>
<li>값 타입이 다른 변수에 할당되면 복사 된다는 것이다.</li>
</ul>
<p>위의 코드에 나온 변수들은 User 타입이고, 이는 값 타입이므로 안의 프로퍼티도 값 타입이다.</p>
<p>여기서 대놓고 Swift 는 struct 를 class 보다, let 을 var 보다 더 중요하게 생각한다고 하였는데 이는 전문 자체를 남기는 것이 좋을 것 같다.</p>
<blockquote>
<p>Swift emphasizes value types and immutability because controlling when a value can change makes it much easier to reason about code, especially in tricky domains like concurrent programming.</p>
</blockquote>
<blockquote>
<p>스위프트는 값 타입과 불변성을 더 집중합니다. 그 이유는 변하는 시점을 컨트롤하는 것은 비동기 프로그래밍 같은 까다로운 영역에서 특히 코드를 더욱 읽기 쉽게 만듭니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/83539950-2f7f-422f-86da-cce64222a59a/image.png" alt=""></p>
<h3 id="errors-and-optionals">Errors and optionals</h3>
<p>Swift 의 에러 핸들링 철학은 3개 라고 한다.</p>
<ol>
<li>에러의 발생원인을 인지하기 쉬워야 한다.</li>
<li>후속작업을 할 수 있는 정보를 포함해야 한다.</li>
<li>복구 가능한 에러와 프로그래머의 실수는 다르다. (추가: 네트워크 연결 실패는 프로그램을 죽이지 않지만, 배열에서 접근할 수 없는 인덱스에 접근하는 것은 앱을 죽인다.)</li>
</ol>
<p>이 세션에서는 Index-Out-Of-Bounds 보다는 데이터 자체의 Threshold 즉, 데이터가 가지지 말아야 할 상태 등에 대한 에러에 대해 상세히 얘기하려 한다(고 생각한다). 즉 Validation 에 대해 설명한다.</p>
<pre><code class="language-swift">struct User {
    let username: String
    var isVisible: Bool = true
    var friends: [String] = []

    mutating func addFriend(username: String) throws {
        guard username != self.username else {
            throw SocialError.befriendingSelf
        }
        guard !friends.contains(username) else {
            throw SocialError.duplicateFriend(username: username)
        }
        friends.append(username)
    }
}

enum SocialError: Error {
    case befriendingSelf
    case duplicateFriend(username: String)
}

var alice = User(username: &quot;alice&quot;)
do {
    try alice.addFriend(username: &quot;charlie&quot;)
    try alice.addFriend(username: &quot;charlie&quot;)
} catch {
    error
}

var allUsers = [
    &quot;alice&quot;: alice
]

func findUser(_ username: String) -&gt; User? {
    allUsers[username]
}

if let charlie = findUser(&quot;charlie&quot;) {
    print(&quot;Found \(charlie)&quot;)
} else {
    print(&quot;charlie not found&quot;)
}

let dash = findUser(&quot;dash&quot;)!</code></pre>
<p>코드에서 프로그래머는 중복된 데이터를 배열에 넣을 경우 정의된 에러를 Throw 하도록 하려 한다. enum 은 에러를 정의하는데 굉장히 유용하기 때문에 enum 을 사용한다.</p>
<p>이제 에러를 throw 할 수 있는 함수를 그냥 호출하려 하면 컴파일러는 에러를 handling 하지 않았다면서 경고를 표시한다. <em>do-catch</em> 블록으로 이를 해결한다.</p>
<p>에러와 동시에 원치 않는 상황을 핸들링하는 데엔 Optional 도 좋은 선택이다.</p>
<p>Optional은 값이 nil 혹은 특정 타입의 데이터인 값이다. 값을 사용하기 위해서는 이 Optional 을 걷어내기 위해 <em>Optional-Binding</em> 을 사용한다. 값이 nil 인 상황에서 발생할 크래시를 막는 데 Optional 이 유용하다.</p>
<h3 id="code-organization">Code organization</h3>
<p>Swift 의 코드 구조는 Modules, Package 라는 2개의 세부구조로 나뉜다. 패키지와 패키지는 서로 의존성을 가질 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/a8d10431-5d08-46ac-9c2b-c8770ceb1428/image.png" alt=""></p>
<p>패키지는 Swift-Package-Manager 라는 툴을 통해 생성하고 관리할 수 있는데, 커맨드 라인을 통해 build/test/run 할 수 있다. IDE(Xcode, VSCode) 에서도 수정하고 실행할 수 있다. 패키지는 Swift-Package-Index 에 배포할 수 있다.</p>
<p>패키지를 만들 때 코드 베이스에서 중요한 것은 접근 제한자를 잘 선택하는 것이다. (대부분 public 으로 가는 것 같지만...)</p>
<ul>
<li>private : 파일 내 같은 프로퍼티 레벨에서만 접근 가능.</li>
<li>internal : 같은 모듈 내에서만 접근 가능. (default acces-control-level)</li>
<li>package : 같은 패키지 내에서 접근 가능.</li>
<li>public : 전부 접근 가능. (패키지 외부에서 접근 가능)</li>
</ul>
<h3 id="classes">Classes</h3>
<p>공유 가능하며 수정 가능한 타입이 필요할 때 Swift 의 참조타입을 쓰면 되는데, Classes 가 대표적이다.</p>
<pre><code class="language-swift">class Pet {
    func speak() {}
}

class Cat: Pet {
    override func speak() { print(&quot;meow&quot;) }

    func purr() { print(&quot;purr&quot;) }
}

let pet: Pet = Cat()
pet.speak()

if let cat = pet as? Cat {
    cat.purr()
}</code></pre>
<p>Swift 는 단일 상속만을 지원한다. 다중 상속을 하려면 상속의 상속을 해야 한다 (A 가 B / C 를 상속하고 싶다면, B 가 C 를 상속하도록 한 다음 A 가 B 를 상속하는 등의 방법이 있지만 프로토콜 썼으면 좋겠다)</p>
<hr>
<p>참조 타입을 다룰 때 중요하게 다뤄져야 하는 것이 메모리 관리이다. 얘기하자면 말이 길어지니 짧게 정리해본다.</p>
<ul>
<li>Swift 는 ARC(Automatic-Reference-Counting) 기법으로 메모리를 관리한다.</li>
<li>컴파일러는 참조 타입이 참조되어 있다면 객체를 메모리에 남겨 놓는다.</li>
<li>메모리에 남겨 놓기 위해 Reference-Count 를 참조할 때마다 더하고, 참조가 해제되면 뺀다. 참조 카운트가 0이 되는 순간 deallocate 된다.</li>
<li>Reference-Cycle 에 주의해야 한다. A 참조 타입이 B 참조 타입을 참조하는데, B 참조 타입의 특정 프로퍼티가 A 참조 타입을 참조할 경우 두 참조 타입은 Reference-Cycle 을 발생시킬 우려가 있다. 이럴 경우 B 에서 A 타입을 참조하는 프로퍼티에 weak 키워드를 써서 참조 카운트를 증가시키지 않도록 처리한다.</li>
</ul>
<h3 id="protocols">Protocols</h3>
<p>다형성, 추상화는 객체지향 프로그래밍의 기본 특성이고 Swift 또한 그런 특성을 갖는다. 프로토콜은 타입이 추상화를 위해 구현해야 할 요구사항을 정의해놓은 선언문이다.</p>
<p>Swift 의 프로토콜의 예시로서 가장 많이 사용되는 것은 Collection 프로토콜이다. Collection 프로토콜을 implement 할 경우, 연속된 값을 다루는 Array/Dictionary/Set/String(Character 의 배열) 가 가진 map, filter, reduce, append, for-loop 에서 순환하기, 인덱스로 특정 데이터 접근하기 등을 사용할 수 있는 새로운 타입을 만들 수 있다.</p>
<pre><code class="language-swift">/// An in-memory store for users of the service.
public class UserStore {
    var allUsers: [String: User] = [:]
}

extension UserStore {
    /// If the username maps to a User and that user is visible,
    /// returns the User. Returns nil otherwise.
    public func lookUpUser(_ username: String) -&gt; User? {
        guard let user = allUsers[username],
              user.isVisible else {
            return nil
        }
        return user
    }

    /// If the username maps to a User and that user is visible,
    /// returns the User. Otherwise, throws an error.
    public func user(for username: String) throws -&gt; User {
        guard let user = lookUpUser(username) else {
            throw SocialError.userNotFound(username: username)
        }
        return user
    }

    public func friendsOfFriends(_ username: String) throws -&gt; [String] {
        let user = try user(for: username)
        let excluded = Set(user.friends + [username])
        return user.friends
            .compactMap { lookUpUser($0) }      // [String] -&gt; [User]
            .flatMap { $0.friends }             // [User] -&gt; [String]
            .filter { !excluded.contains($0) }  // drop excluded
            .uniqued()
    }
}

extension Collection where Element: Hashable {
    func uniqued() -&gt; [Element] {
        let unique = Set(self)
        return Array(unique)
    }
}</code></pre>
<p>Collection 프로토콜을 직접 구현하는 경우도 흔하지만, 위와 같이 uniqued() 메소드를 따로 사용하기 위해 타입을 extension 하는 경우는 흔한 것 같다.</p>
<p>저 코드에서 개인적으로 아쉬운 것은 위와 같은 extension 에는 private extension 을 우선적으로 고려했으면 좋겠다는 것이다. 다른 소스코드나 타입 등에 영향을 끼쳐서 전체 코드베이스에 안좋은 결과를 안길 수 있다.</p>
<p>프로토콜은 Generic 과 더하여 타입을 확장하는 데 아주 훌륭한 장점을 갖는다. 클래스의 상속을 통한 추상화보다 더 유연하기 때문에 자주 쓰이고 잘 알아둬야 한다.</p>
<h3 id="concurrency-data-race">Concurrency(+ Data-race)</h3>
<p>Swift 의 동시성을 대표하는 것은 Task 이다.</p>
<p>Task 는 독립적이고 동시적으로 실행 가능한 컨텍스트를 뜻한다. Task 는 가벼워서 오버헤드가 적고, 취소하거나 작동을 멈추도록 할 수도 있다.</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/0c8a8370-6f0f-4049-80d9-3e4be52f02e6/image.png" alt=""></p>
<p>Task 에서 고려해봐야 할 점은 <em>멈췄다가 실행하고 멈췄다가 실행한다</em> 는 것이다. Task 가 멈춰있을 때는 CPU 를 yield 하기 때문에 CPU 자원도 효과적으로 사용할 수 있다.</p>
<p>Data-race(경쟁상태) 는 여기서 발생한다. 만약 수정가능한 상태값이 있을 때 서로 다른 스레드에서 접근하여 수정하거나 읽으려고 할 경우 원치 않는 결과를 가져올 수 있다.</p>
<p>Swift 6 는 경쟁상태를 해결하기 위해 컴파일 시점에 동시성에 사용할 값 타입에 대해 Sendable 프로토콜을 구현할 것을 요구한다. 이를 구현하였다는 것은 컴파일러에게 동시적으로 해당 값에 접근할 수 있음을 뜻하는 것이며, 값을 읽거나 쓸 때 많은 lock 이 실행되게 된다.</p>
<p>이를 안전하게 실행하기 위해 Actor 가 제공된다. Actor 는 참조 타입인데, 공유 가능하고 수정 가능한 상태값을 동기화 된 접근으로부터 캡슐화 한다. 하나의 Task 만이 Actor 의 실행 중 접근이 가능하다. Actor 의 메소드를 캡슐화 된 영역 바깥에서부터 호출하는 것이 비동기 프로그래밍이다.</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/90c0991b-7624-4a44-9f11-dcf0441412bb/image.png" alt=""></p>
<pre><code class="language-swift">extension UserStore {
    static let shared = UserStore.makeSampleStore()
}

router.get(&quot;friendsOfFriends&quot;) { request, context -&gt; [String] in
    let username = try request.queryArgument(for: &quot;username&quot;)
    return try await UserStore.shared.friendsOfFriends(username) // actor 로 인한 비동기 접근
}

public actor UserStor {
    package var allUsers: [String: User] = [:]
    static func makeSampleStore() {
        ...
    }
}</code></pre>
<p>class 였던 UserStore 를 actor 로 바꿈으로써 UserStore 에 대한 비동기적 접근이 가능해졌다.</p>
<h3 id="extensibility">Extensibility</h3>
<p>타입 확장은 프로토콜에서도 한번 다뤘지만, boiler-plate code, duplication 을 줄이는데 효과적이다.</p>
<p>대표적인 예시는 property wrapper 이다.</p>
<pre><code class="language-swift">struct FriendsOfFriends: AsyncParsableCommand {
    @Argument var username: String

    mutating func run() async throws {
        // ...
    }
}</code></pre>
<p>애플의 예시는 서버 애플리케이션의 Argument 로서 동작할 username 에 대한 코드라 좀 어려운 것 같다. 좀 더 간단한 예시는 역시 RGB 컬러를 저장하는 타입이지 않을까 싶다.</p>
<pre><code class="language-swift">@propertyWrapper struct RGBElement {
    private var value: Int = 0

    var wrappedValue: Int {
        get { self.value }
        set { // RGB 색상에서 red/green/blue 는 0 부터 255 의 값만 가질 수 있다.
            if 0 ... 255 ~= newValue {
                self.value = newValue
            } else {
                self.value = newValue &lt; 0 ? 0 : 255
            }
        }
    }
}

struct SafeRGBColor {
    @RGBElement var red: Int
    @RGBElement var green: Int
    @RGBElement var blue: Int

    var color: UIColor {
        UIColor(red: red, green: green, blue: blue, alpha: 1.0)
    }
}</code></pre>
<h2 id="swift의-새로운-기능">Swift의 새로운 기능</h2>
<p>이 세션은 먼저 Swift 10주년 기념인 만큼 Swift 가 걸어온 길을 간단히 소개한다. (그러므로 나는 소개하지 않으려 한다. 본인도 흥미로운 부분들이 많았으니 궁금하신 분은 <a href="https://developer.apple.com/wwdc24/10136">세션</a>을 통해 직접 확인 바란다.)</p>
<p>개인적으로 눈에 띄는 부분은 ABI 가 공식적으로 지원되지 않았다는 것이었다. 머리털나고 처음 듣는 내용이라 <a href="https://ko.wikipedia.org/wiki/%EC%9D%91%EC%9A%A9_%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8_%EC%9D%B4%EC%A7%84_%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4">위키</a>를 찾아보니 아래와 같이 설명하고 있다.</p>
<blockquote>
<p>응용 프로그램 이진 인터페이스(Application Binary Interface, ABI)는 응용 프로그램과 운영 체제 또는 응용 프로그램과 해당 라이브러리, 마지막으로 응용 프로그램의 구성요소 간에서 사용되는 낮은 수준의 인터페이스이다.</p>
</blockquote>
<p>그리고 해당 세션의 전문에서는 이렇게 설명한다.</p>
<blockquote>
<p>In Swift 5 we introduced the stable ABI on Apple Platforms. For app developers, this meant a smaller download size, because you no longer bundled a complete copy of the Swift standard library in your app. 
Swift 5 에서 애플 플랫폼의 안정적인 ABI 를 공개했습니다. 앱 개발자에게 있어 더 이상은 Swift standard library 를 앱에 완전히 복사하여 번들링 할 필요가 없게 되었습니다.</p>
</blockquote>
<p>가끔 클라이언트 측으로부터 앱 사이즈 측정을 요청받는 경우가 있는데 이거랑 연관이 있는건가 싶었다. Swift 4 가 2017년이니 ABI 로 앱 사이즈 개선이 많이 이뤄졌다는 것이 현업에 전달되기는 아직 이르다.</p>
<p>그 뒤에는 Swift 의 새로운 기능을 설명하는데 목록은 아래와 같다.</p>
<ul>
<li>Cross compilation to Linux</li>
<li>Foundation</li>
<li>Swift Testing</li>
<li>Improvements to builds</li>
<li>Swift&#39;s new space</li>
<li>Launguage updates<ul>
<li>Noncopyable types</li>
<li>Embedded Swift</li>
<li>C++ Interperability</li>
<li>Typed throws</li>
<li>data-race safety</li>
</ul>
</li>
</ul>
<h3 id="cross-compilation-to-linux">Cross compilation to Linux</h3>
<p>Cross compilation 이 뭘까? 영상에서는 이렇게 설명하고 있다.</p>
<p>MacOS 실행가능한 앱을 iPad 에서 실행하는 것과 같이 MacOS 실행가능한 앱을 Linux 에서 실행할 수 있도록 지원한다는 것이다. 앱은 MacOS 에서 개발하고 배포 및 실행은 Linux 에서 하는 것이다.</p>
<p>이를 위해 Linux SDK for Swift 를 제공한다. 앱 실행 및 개발을 위해 Linux SDK for Swift 외에 추가적인 Swift Package 설치가 필요하지 않다.</p>
<p>세션에서는 리눅스 서버에서 랜덤 이모지를 반환하는 간단한 API 를 제공하는 프로그램을 Swift Package 로 개발 및 배포하는 것을 보여준다.</p>
<ol>
<li>Xcode Command Line Tools 등을 통해 package 를 만들어서 localhost 에 특정 URL을 통해 API를 제공되는 간단한 프로그램을 만든다.</li>
<li>swift build 명령어를 통해 빌드 하고 실행한다. 터미널 창을 하나 열고 curl 명령어로 실제 API 를 호출해본다.</li>
<li>성공했다면 <code>swift sdk install ~/preview-static-swift-linux-0.0.1.tar.gz</code> 명령어를 통해 Linux SDK for Swift 를 설치한다.</li>
<li>cross compile 을 위해 아래의 플래그를 붙여서 컴파일한다.<ul>
<li>--swift-sdk : 빌드를 진행할 SDK 를 정의한다.</li>
<li>aarch64-swift-linux-musl : musl 이라는 리눅스 커널 라이브러리를 사용하는 ARM64 Linux 환경에서 실행할 바이너리를 생성하라고 지시한다.</li>
</ul>
</li>
<li>Linux SDK for Swift 설치가 완료되었다면 <code>swift build --swift-sdk aarch64-swift-linux-musl</code> 로 다시 빌드하고 실행가능한 앱을 리눅스 서버로 복사(배포)한다.</li>
<li>리눅스 서버에서 앱을 실행하고 터미널에서 localhost 부분을 리눅스 서버의 IP 및 Port 로 바꾼다.</li>
</ol>
<p>Swift Package 를 만드는 것에 익숙하지 않은 나로선 리눅스에서 실행 가능한 앱을 만든것보다 Swift Package 로 앱을 만드는 과정이 더 흥미로웠다.</p>
<h3 id="foundation">Foundation</h3>
<p>Foundation 은 모든 애플 플랫폼에서 사용할 프레임워크로 만들어졌다. 이는 Swift 에서도 마찬가지였기 때문에 swift-corelib-foundation 이 만들어졌고, 이 둘을 하나의 프레임워크처럼 모든 애플 플랫폼에서 사용하기 위해 swift-foundation 이 만들어졌다.</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/a6b8c059-f32b-4579-9f67-41ca285ede02/image.png" alt=""></p>
<p>swift-foundation 은 오픈소스이다. 누구든 contribute 할 수 있다.</p>
<h3 id="swift-testing">Swift Testing</h3>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/ef0e3689-c8db-4f0d-aeb0-310ac20cdc61/image.png" alt=""></p>
<p>새로운 프레임워크이고 오픈소스이다. 최신 Swift 기능을 탑재하였으며 코드베이스와 seamlessly integrate 한다고 한다.</p>
<pre><code class="language-swift">// XCTest framework
import XCTest

func testRating(videoId: Int, videoName: String, expectedRating: String) {
    let video = Video(id: videoId, name: videoName)
    #expect(video.rating == expectedRating)
}

// Swift Testing 

import Testing

@Test(&quot;Recognized rating&quot;, // display name
       .tags(.critical), // tag
       arguments: [ // add argument as test function
           (1, &quot;A Beach&quot;,       &quot;⭐️⭐️⭐️⭐️⭐️&quot;),
           (2, &quot;Mystery Creek&quot;, &quot;⭐️⭐️⭐️⭐️&quot;),
       ])
func rating(videoId: Int, videoName: String, expectedRating: String) {
    let video = Video(id: videoId, name: videoName)
    #expect(video.rating == expectedRating)
}</code></pre>
<p>위처럼 기존에는 <code>test</code> 라는 이름을 넣었어야 하는 것과 다르게 새로 추가된 매크로인 <code>@Test</code> 를 통해 여러 방식으로 커스텀이 가능해졌다.</p>
<p>자세한 사항은 따로 세션이 준비되어 있으므로 따로 참고하기 바란다.</p>
<h3 id="improvements-to-builds">Improvements to builds</h3>
<p>빌드 과정에 향상된 부분이 있다고 한다.</p>
<p>하나의 모듈(소스코드 및 파일의 집합)은 다른 모듈에 의존성을 갖고 이는 SDK 까지 이어질 수도 있다. 빌드 과정에서 이 의존성을 파악하는 과정은 <strong>내부적</strong>으로 이루어진다.</p>
<p>이 과정은 굉장히 많은 작업량을 갖는데, 모듈의 의존성을 파악하는 과정이 병렬적이 아닌 순차적이기 때문이다. 여기다가 디버거가 위의 과정을 똑같이 반복하면 디버거가 처음 무언가를 출력하기 전까지는 또 한번의 긴 멈춤을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/a5098f27-4ef9-4359-b67a-cabc778b779a/image.png" alt=""></p>
<p>이번에 소개된 Explicitly built modules 를 통해 내부적인 빌드과정을 외부로 이동시키게 되었다. 이를 통해 아래의 개선사항이 있다고 한다.</p>
<ul>
<li>More parallelism in builds : 병렬적으로 진행되는 빌드. 빌드 성능 향상.</li>
<li>Better visibility into build steps : 빌드 로그가 더욱 직관적.</li>
<li>Improved reliability of builds : 빌드가 좋아졌다는 뜻.</li>
<li>Faster debugger start-up : 디버거가 빨라졌다는 뜻.</li>
</ul>
<p>물론 이를 강제하지는 않는 것 같다. Xcode 에 아래의 빌드 세팅이 추가되는 것을 기다려보자.</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/de315bd4-00cf-496d-8c5b-ba2fba7d481d/image.png" alt=""></p>
<h3 id="swifts-new-space">Swift&#39;s new space</h3>
<p>Swift 관련 프로젝트를 관리하는 레포지토리를 관리하기 위해 깃허브 프로젝트로 <a href="github.com/swiftlang">swiftlang</a> 을 만들었다고 한다.</p>
<p>현재시각 2024-6-16 12:15PM. 레포지토리 수는 7개이다. 관련 프로젝트 몇가지만 확인되고 세션에서 언급한 Swift Compiler, Foundation 등은 확인되지 않는다. 앞으로 쭉 진행한다고 했으니 순차적으로 진행할 것인가보다.</p>
<p>개인적으로 깃허브 내 Swift 코드를 직접 참조 링크로 건 일이 있는데 주소가 바뀌면 한번 확인해봐야 겠다.</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/cd58627c-d179-4bba-8c60-c1abb098ee36/image.png" alt=""></p>
<h3 id="launguage-updates">Launguage updates</h3>
<p>Swift 는 6버전이 되면서 data-race safety, 제한된 embedded 환경 제공을 위한 업데이트가 이뤄질 것이다. 이를 위해 아래 5가지 업데이트 사항이 제공될 것이다.</p>
<h4 id="noncopyable-types">Noncopyable types</h4>
<p>값 타입, 참조 타입 모두 Copyable 타입이었다. 하지만 이를 차단하는 Noncopyable 타입이 생겼다.</p>
<pre><code class="language-swift">struct File: ~Copyable {
  private let fd: CInt

  init(descriptor: CInt) {
    self.fd = descriptor
  }

  func write(buffer: [UInt8]) {
    // ...
  }

  deinit {
    close(fd)
  }
}</code></pre>
<p>Noncopyable 타입은 위와 같이 <code>~Copyable</code> 로 선언할 수 있다. deinit 의 close 는 자동으로 실행된다고 하는데 직접 써보지 않으면 어떨지 모르겠다. Noncopyable 타입으로 가장 적절한 것은 앱이 사용하는 시스템의 자원이다. 위의 예시처럼 파일이 대표적일 것이다.</p>
<p>이를 통해 동시성에 의한 런타임 이슈인 스레드 간 경쟁상태를 제거하고, 자동 cleanup 함수를 사용하지 않아 파일이 닫히지 않은 resource leak 을 방지할 수 있다.</p>
<p>Noncopyable 타입이 resource leak 을 방지할 순 있지만 한 가지 주의사항이 있다. 하지만 위의 예시에서 문제가 하나 있는데 initializer 이다. </p>
<pre><code class="language-swift">guard let fd = open(name) else { // open function
  return
}
let file = File(descriptor: fd) // initialization
file.write(buffer: data)</code></pre>
<p>위 코드에서 File descriptor 를 사용하는데 이는 직관적이지 않고 안전하지도 않다. open 함수에 의해 initialization 가 실행되지 않고 종료되면 deinit 이 실행되지 않고 resource leak 이 발생한다. 아래와 같이 코드를 변경해보자.</p>
<pre><code class="language-swift">struct File: ~Copyable {
  private let fd: CInt

  init?(name: String) {
    guard let fd = open(name) else {
      return nil
    }
    self.fd = fd
  }

  func write(buffer: [UInt8]) {
    // ...
  }

  deinit {
    close(fd)
  }
}</code></pre>
<p>우선 init 함수 자체가 직관적이다. 그리고 Optional init에 의해 Optional Noncopyable 타입이 생성되었다. Optional 은 대표적인 제네릭 타입인데, Swift 5.10 의 Noncopyable 타입은 제네릭 타입이 아닌 구체타입만 제공된다. 그 대신 Swift 6 은 제네릭으로 제공된다.</p>
<p>Noncopyable 의 목적은 무엇일까? 그것은 <code>Unique ownership 을 통한 퍼포먼스 향상</code> 이다. Noncopyable 타입으로 선언된 데이터는 특정 스레드나 Task 등만 소유하고 write/update 할 수 있다. 이를 통해 data-race 를 방지하고 성능도 향상한다는 얘기이다. 사람에 따라서는 다르게 들릴 수도 있을 것 같다.</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/78a2eb97-ae13-4e9b-9320-d2b8c126fbb0/image.png" alt=""></p>
<p>Swift 개발자로 처음 입문하는 개발자들이 점점 진입하기 어려워지는 것 같다는 느낌도 든다...</p>
<h4 id="embedded-swift">Embedded Swift</h4>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/ad2992df-f064-487d-b2e2-de448e14f957/image.png" alt=""></p>
<p>Embedded Swift 는 Javascript 와 Typescript 의 관계처럼 Swift 의 서브셋이다. Swift 라는 말로 들린다. Embedded Swift 는 우선 작다. 작게 만들기 위해 새로운 컴파일 모델을 생성했다는 것이다.</p>
<p>이를 위해 제한된 사항 중 대표적인 것은 <code>Mirror(swift reflection)</code>, <code>any</code> 이다. 컴파일 과정에서는 full generics specialization(무슨 소리인지 잘 모르겠다), static linking 을 통해 작은 바이너리를 생성한다.</p>
<p>ARM / RISC-V 등 여러 칩에서 작동 가능할 것이다.</p>
<h4 id="c-interoperability">C++ interoperability</h4>
<p>작년의 C++ 상호작용과 관련하여 올해는 아래와 같은 개선사항이 있다고 한다. C++ 은 잘 몰라서 아래의 스크린샷으로 대체한다...</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/e98fa62d-76b9-4308-925a-cfe814cbd184/image.png" alt=""></p>
<h4 id="typed-throws">Typed throws</h4>
<p>특정 타입의 에러를 핸들링 하기 위해서는 catch 구문에서 에러에 대해 <code>as</code> 로 타입 변환을 해줘야 했다. 이를 줄이기 위해 throws 키워드 뒤에 실제 에러의 concrete 타입을 정의할 수 있도록 하였다.</p>
<pre><code class="language-swift">enum IntegerParseError: Error {
  case nonDigitCharacter(String, index: String.Index)
}

func parse(string: String) throws(IntegerParseError) -&gt; Int {
  for index in string.indices {
    // ...
    throw IntegerParseError.nonDigitCharacter(string, index: index)
  }
}

do {
  let value = try parse(string: &quot;1+234&quot;)
}
catch {
   // error is &#39;IntegerParseError&#39;
}</code></pre>
<p>이제 위와 같이 에러 타입을 선언하지 않는 것은 any Error 를 선언한 것과 같이 된다. 만약 throws 자체를 하지 않는 경우는 <code>throws(Never)</code> 와 같아진다.</p>
<p>이는 아래와 같이 응용할 수 있다. Collection 프로토콜의 map 을 확장하는 예시인데, map 에 전달할 클로저가 throws 를 할 경우 throws 할 에러의 타입을 명시할 수 있다.</p>
<pre><code class="language-swift">extension Collection {
    func map&lt;T, Failure&gt;(body: (Element) throws(Failure) -&gt; T) throws(Failure) -&gt; [T] {
        // ....
    }
}</code></pre>
<p>물론 에러 타입을 제한하는 것이 제한사항은 아니다.</p>
<h4 id="data-race-safety">Data-race safety</h4>
<p>Swift 6 compiler 의 Swift 6 language mode 는 Data-race safety 를 기본 제공한다.(!!!)</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/d8ba10bb-0793-4bec-aaa6-ed1c8cab4320/image.png" alt=""></p>
<p>Swift concurrency 가 나오고 나서 부터 Data-race safety 는 Swift concurrency 의 목표 중 하나였다. Swift concurrency 가 data isolation 메커니즘을 갖도록 디자인된 것도 이러한 이유였다.</p>
<p>Data-race safety default 를 제공하는 Swift 6 language mode 는 제한사항이 아니다(우선 지금은 그렇다). 모듈별로 준비가 되면 하나하나 스위치 켜듯 Swift 6 compiler 로 업데이트 하여 flag 값이 수정되면 진행된다.</p>
<p>구체적으로 어떤 일이 일어날까? </p>
<pre><code class="language-swift">class Client {
  init(name: String, balance: Double) {}
}

actor ClientStore {
  static let shared = ClientStore()
  private var clients: [Client] = []
  func addClient(_ client: Client) {
    clients.append(client)
  }
}

@MainActor
func openAccount(name: String, balance: Double) async {
  let client = Client(name: name, balance: balance)
  await ClientStore.shared.addClient(client)
  // 에러 발생시키기!
  logger.log(&quot;Opened account for \(client.name)&quot;)
}</code></pre>
<p>Swift 5.10 에서는 addClient 쪽에 컴파일러 warning 이 뜬다. MainActor 에서 실행되는 actor 가 다루는, 새로 만들어진 client 변수의 Client 는 Sendable 을 구현하지 않기 때문이다.</p>
<p>하지만 client 는 MainActor 에서 addClient 더 이상 사용되지 않는다. 이를 통해 client 가 더 이상 공유되지 않는다는 것을 컴파일러가 인지하고 warning 이 발생하지 않아 컴파일이 정상적으로 작동한다.</p>
<p>하지만 MainActor 에서 client 를 통해 무언가를 하려고 하면 warning 이 아닌 error 를 볼 것이다.</p>
<p>추가로 저레벨의 Synchronization module 을 소개한다. </p>
<ul>
<li>Atomic 타입은 어떤 타입이든 제네릭하게 가지고 있고 효율적으로 lock-free 하게 동작한다. 안전한 접근을 위해 let 을 사용하도록 권장하고 있다.</li>
</ul>
<pre><code class="language-swift">import Dispatch
import Synchronization 

let counter = Atomic&lt;Int&gt;(0)

DispatchQueue.concurrentPerform(iterations: 10) { _ in
  for _ in 0 ..&lt; 1_000_000 {
    counter.wrappingAdd(1, ordering: .relaxed)
  }
}

print(counter.load(ordering: .relaxed))</code></pre>
<ul>
<li>Mutex 를 제공한다. 완벽한 Lock 상태에서 값을 쓰고, 다른 쓰기 동작은 앞의 동작이 끝날 때까지 기다리는데, 이는 Mutext 의 withLock 클로저에 의해 수행된다.</li>
</ul>
<pre><code class="language-swift">import Synchronization

final class LockingResourceManager: Sendable {
  let cache = Mutex&lt;[String: Resource]&gt;([:])

  func save(_ resource: Resource, as key: String) {
    cache.withLock {
      $0[key] = resource
    }
  }
}</code></pre>
<h2 id="swift-6-는-swift-5-의-확장팩인가">Swift 6 는 Swift 5 의 확장팩인가?</h2>
<p>Swift 5 의 변천사를 가볍게 살펴보자. (<a href="https://blog.swiftify.com/whats-new-in-swift-6-e875ca675a28">출처</a>)</p>
<ul>
<li>5.1 : Opaque return type, return 생략 가능, module format stability</li>
<li>5.2 : key-path 함수에도 적용, 진단기능 향상</li>
<li>5.3 : 여러 타입의 에러 catch, 연속적인 trailing closure 표현, SPM 기능 향상</li>
<li>5.4 : Result builder, Variadic 파라미터 지원</li>
<li>5.5 : Swift concurrency 에 대한 방대한 업데이트.</li>
<li>5.6 : Concurrency, SPM 에 대한 추가 업데이트.</li>
<li>5.7 : optional unwrap 표현 방식, Swift Regex 추가.</li>
<li>5.8 : Result builder 제한사항 일부 제거, self 표현 생략 가능 범위 증가, @backDeployed 어노테이션 추가.</li>
<li>5.9 : Macros 추가, if/switch 개선, task group discard 추가</li>
<li>5.10 : global variable 을 동시성에서 사용 못하도록 제한.</li>
</ul>
<p>이해가 가지 않는 몇가지 요소는 뺏다. 지금까지 살펴본 Swift 6 에 비해 굉장히 넓은 범위로 업데이트가 이뤄졌다.</p>
<p>Swift 6 는 이러한 업데이트를 더욱 향상된 방식으로 사용할 수 있도록 추가 업데이트를 계속 해나갈 생각인 것 같다. 그 첫번째는 Noncopyable 타입과 Swift 6 language mode 등을 통한 data-race safety, 그리고 Typed throws 를 통한 에러 핸들링 방식 개선이다.</p>
<p>Swift 5 에서 6 로 넘어가는 것은 생각보다 수월하지 않을까 생각한다.</p>
<h2 id="reference">Reference</h2>
<ul>
<li><a href="https://forums.swift.org/t/swift-6-0-release-process/70220">https://forums.swift.org/t/swift-6-0-release-process/70220</a></li>
<li><a href="https://www.wishket.com/">https://www.wishket.com/</a></li>
<li><a href="https://developer.apple.com/wwdc24/10184">https://developer.apple.com/wwdc24/10184</a></li>
<li><a href="https://developer.apple.com/wwdc24/10136">https://developer.apple.com/wwdc24/10136</a></li>
<li><a href="https://ko.wikipedia.org/wiki/%EC%9D%91%EC%9A%A9_%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8_%EC%9D%B4%EC%A7%84_%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4">https://ko.wikipedia.org/wiki/응용_프로그램_이진_인터페이스</a></li>
<li><a href="https://blog.swiftify.com/whats-new-in-swift-6-e875ca675a28">https://blog.swiftify.com/whats-new-in-swift-6-e875ca675a28</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[React 공부 노트 맹글어보기]]></title>
            <link>https://velog.io/@sanghwi_back/React-%EA%B3%B5%EB%B6%80-%EB%85%B8%ED%8A%B8-%EB%A7%B9%EA%B8%80%EC%96%B4%EB%B3%B4%EA%B8%B0-React-%EA%B3%B5%EC%8B%9D%EB%AC%B8%EC%84%9C-%ED%8C%8C%EB%A8%B9%EA%B8%B0</link>
            <guid>https://velog.io/@sanghwi_back/React-%EA%B3%B5%EB%B6%80-%EB%85%B8%ED%8A%B8-%EB%A7%B9%EA%B8%80%EC%96%B4%EB%B3%B4%EA%B8%B0-React-%EA%B3%B5%EC%8B%9D%EB%AC%B8%EC%84%9C-%ED%8C%8C%EB%A8%B9%EA%B8%B0</guid>
            <pubDate>Tue, 02 Apr 2024 13:47:24 GMT</pubDate>
            <description><![CDATA[<p>사실 이게 다른 분들께는 별 의미가 없다는 건 잘 안다. 하지만 정리를 이런식으로 해두지 않으면 손에서 빠져나가는 모래처럼 배운 것이 없어질 것 같아 이 글을 게시하기로 하였다.</p>
<hr>
<p>사실 집에 이런 책이 있다.</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/dfc575f0-20d3-4a39-84fd-a708b016e592/image.png" alt=""></p>
<p>굉장히 깊은 내용들을 다루고 있어서 한번 쯤 읽지 않으면 리액트로 프론트엔드 개발을 못할 것만 같지만 이 책 너무 어렵다.</p>
<p>리액트는 수많은 개발 프로젝트를 자양분 삼아 엄청난 발전이 있었다. 그렇지만 천리길도 한걸음 부터라고 중요한 것만 간단히 다루기로 했다.</p>
<hr>
<h2 id="component">Component</h2>
<p>React 의 기본 UI 단위이다. 단순히 html div 태그 특별히 하나 더 만든 것은 아니다.</p>
<h3 id="specification">Specification</h3>
<ul>
<li>마치 객체지향의 클래스처럼 재사용이 가능하고 prop 이라는 정의된 값을 전달할 수도 있다.</li>
<li>자신만의 상태값과 렌더링 사이클을 가질 수 있다.</li>
<li>import &amp; export 가 가능하다.</li>
<li>Built-in 컴포넌트로는 아래의 종류가 있다.<ul>
<li>Fragment : 여러 개의 JSX 노드들을 하나로 합치는 일종의 바구니이다. Fragment 자체로는 아무런 스타일도 기능도 없다. <code>&lt;&gt;&lt;/&gt;</code> 로도 나타낼 수 있다.</li>
<li>Profiler : React tree 내의 렌더링 퍼포먼스를 측정할 때 사용할 수 있다.</li>
<li>Suspense : Child component 들이 로딩 중일 때 보여줄 컴포넌트를 정의한다.</li>
<li>StrictMode : React 렌더링을 한번 더 반복한다. 개발 중 발생할 수 있는 오류를 점검할 때 유용하다. (개인적으로는 항상 사용하는 편이다)</li>
</ul>
</li>
</ul>
<h3 id="types">Types</h3>
<p>Component 를 생성 방법에 따라 종류가 나뉜다.</p>
<ul>
<li>함수형 컴포넌트 : 함수를 통해 정의하는 컴포넌트이다. 리액트에서는 아래의 클래스보다는 함수형을 더 선호한다. 트리 구조를 갖는 리액트 앱의 특성 상 컴포넌트가 순수(Pure)할수록 명확한 구조를 구성할 수 있기 때문인 것으로 생각된다.<pre><code class="language-jsx">import React from &quot;react&quot;;
// Element.jsx 파일
export default function Element(props) {
return &lt;div&gt;{props.inx}번째 Hello world!&lt;/div&gt;
}
// Root.jsx 파일
export default function Root() {
return &lt;&gt;
  &lt;Element inx=&quot;0&quot;/&gt;
  &lt;Element inx=&quot;1&quot;/&gt;
  &lt;Element inx=&quot;2&quot;/&gt;
&lt;/&gt;
}
// index.js 파일
const root = ReactDOM.createRoot(document.getElementById(&quot;root&quot;));
</code></pre>
</li>
</ul>
<p>root.render(
  &lt;React.StrictMode&gt;
      <Root/>
  &lt;/React.StrictMode&gt;
)</p>
<pre><code>
* 클래스형 컴포넌트 : 위에서 설명했다시피 최신 리액트 공식문서에서는 해당 내용에 대한 언급이 없다. 예전 문서를 봐야 한다. 클래스형 컴포넌트의 장점은 생명주기를 명확히 정의할 수 있다는 것이다. 생명주기 함수는 어떤 친절한 분이 만들어 놓은 아래 그림 (출처는 https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/)으로 대체할 수 있을 것이다.

![](https://velog.velcdn.com/images/sanghwi_back/post/fb5d28db-82bd-4fe8-84cb-e58d7aa01b30/image.png)

```jsx
import React, { Component } from &quot;react&quot;;

class ProgressBar() extends Component {
  constructor(props) {
    super(props); // this.props 에 값 반영
    this.state = { progress: this.props.progress || 0 };
  }

  componentDidUpdate(prevProps) { // 갱신 직후 호출
    if (prevProps.progress != this.props.progress) {
      this.setState({ progress: this.props.progress });
    }
  }

  render() {
    const { progress } = this.state;

    return (
      &lt;div class=&quot;w-full h-4 bg-gray-200 rounded&quot;&gt;
          &lt;div
          className=&quot;h-full bg-blue-500 rounded&quot;
          style={{ width: `${progress}%` }}
        &gt;
          `${progress}%`
        &lt;/div&gt;
      &lt;/div&gt;
    )
  }
}</code></pre><p>리액트가 클래스 컴포넌트에 대한 언급을 최신 문서에서 제외한 이유는 다음과 같다고 생각된다.</p>
<ul>
<li>클래스 컴포넌트는 Javascript 의 this 와 깊은 관련이 있지만 this 는 코드베이스를 무겁게 만든다.</li>
<li>컴포넌트를 미리 컴파일해놓고 사용하는 방식의 높은 잠재력이 있다고 판단하였지만, 클래스 컴포넌트는 이런 최적화에 방해가 된다.</li>
</ul>
<h3 id="named--default-export">named / default export</h3>
<p>자바스크립트의 export 를 간단히 짚고 넘어가자</p>
<ul>
<li>named export<ul>
<li>여러 식별자를 한번에 내보낼 수 있다.</li>
<li>import 할 땐 export 에서 사용한 식별자를 사용해야 한다.</li>
</ul>
</li>
<li>default export<ul>
<li>한 파일에 하나의 식별자만 내보낼 수 있다.</li>
<li>import 할 때 원하는 식별자를 사용할 수 있다.</li>
</ul>
</li>
</ul>
<table>
<thead>
<tr>
<th>Syntax</th>
<th>Export statement</th>
<th>Import statement</th>
</tr>
</thead>
<tbody><tr>
<td>Default</td>
<td><code>export default function Button() {}</code></td>
<td><code>import Button from &#39;./Button.js&#39;;</code></td>
</tr>
<tr>
<td>Named</td>
<td><code>export function Button() {}</code></td>
<td><code>import { Button } from &#39;./Button.js&#39;;</code></td>
</tr>
</tbody></table>
<p>예제를 끝으로 간단히 마무리 한다. 예제는 MDN 을 그대로 가져왔다.</p>
<pre><code class="language-javascript">// named export
// 호이스팅에 의해 미리 export 가능
// import 시 이름은 고정!
export { cube, foo, graph };

function cube(x) {
  return x*x*x;
}
const foo = Math.PI + Math.SQRT2;
var graph = {
  options: {
    color: &quot;white&quot;,
    thickness: &quot;2px&quot;,
  },
  draw: function() {
    console.log(&quot;From graph draw function&quot;);
  },
};</code></pre>
<pre><code class="language-javascript">// default export
// File A
export default function cube(x) {
  return x*x*x;
}

// File B
import { createContext } from &quot;react&quot;; // 컨텍스트 생성 hook
const AppContext = createContext({ }); // 객체 형태의 컨텍스트 생성

// AppContext 라는 이름으로 export. 다른 파일에선 여러 이름으로 사용 가능.
export default AppContext;</code></pre>
<h2 id="jsx">JSX</h2>
<p>JSX 는 자바스크립트 파일 내에서 자바스크립트를 이용하여 HTML 를 작성할 수 있도록 도와주는 마크업을 작성할 수 있게 도와주는 자바스크립트 확장 기능이다. 대부분의 리액트 개발에서 UI 를 작성할 때 활용된다.</p>
<p>리액트 안에 JSX 가 포함된 것도 아니고, 반드시 써야되는 건 아니다.</p>
<h3 id="usability">Usability</h3>
<p>예를 들어 조건에 따라 3 개의 HTML 을 화면에 출력해야 한다고 가정하자. 그럴 경우에는 약간 복잡한 필요하였다.</p>
<pre><code class="language-javascript">&lt;script&gt;
  document.addEventListener(&quot;DOMContentLoaded&quot;, function() {
      const isLoggedIn = await isLoggedIn();
      let id = &quot;&quot;;

    if (isLoggedIn === true) {
      id = &quot;A&quot;;
    } else if (isLoggedIn === false) {
      id = &quot;B&quot;;
    } else {
      id = &quot;C&quot;;
    }

      document.getElementById(id).hidden = false;
  });
&lt;/script&gt;

&lt;div id=&quot;A&quot; hidden&gt;
  &lt;p&gt;Hello A world&lt;/p&gt;
&lt;/div&gt;

&lt;div id=&quot;B&quot; hidden&gt;
  &lt;p&gt;Hello B world&lt;/p&gt;
&lt;/div&gt;

&lt;div id=&quot;C&quot; hidden&gt;
  &lt;p&gt;Hello C world&lt;/p&gt;
&lt;/div&gt;</code></pre>
<p>위의 경우 불필요하게 코드가 길어지게 되었다. 원하는 것은 단지 isLoggedIn() 의 결과에 따라 다른 HTML 을 화면에 보여주는 것 뿐인데 말이다. 코드를 읽는 입장에서도 복잡하다.</p>
<p>JSX 는 자바스크립트로 HTML 을 생성할 수 있게 도와준다. 참고로 useState 의 setter 역할의 함수가 set 을 하면 React 의 컴포넌트는 ReRendering 된다.</p>
<pre><code class="language-jsx">import React, { useState } from &quot;react&quot;;

export default function HelloWorld() {
  const [id, setID] = useState(&quot;&quot;);

  () =&gt; {
    const isLoggedIn = await isLoggedIn();
    if (isLoggedIn === true) {
      setID(&quot;A&quot;);
    } else if (isLoggedIn === false) {
      setID(&quot;B&quot;);
    } else {
      setID(&quot;C&quot;);
    }
  }();

  return (
    &lt;div&gt;
      &lt;p&gt;Hello {id} world&lt;/P&gt;
      &lt;/div&gt;
  );
}</code></pre>
<h3 id="limitation">Limitation</h3>
<p>JSX 는 확장 기능이자 일종의 문법이기 때문에 아래의 제한사항을 갖는다.</p>
<ol>
<li>JSX 로 반환되는 HTML Element 는 반드시 하나여야 한다. 여러 개의 Element 를 반환하고 싶다면 Fragment 혹은 다른 컨테이너 역할의 Element 를 사용하자.</li>
</ol>
<pre><code class="language-jsx">// X. It won&#39;t work.
export default function ShoppingList() {
  return (
    &lt;h1&gt;Shopping List&lt;/h1&gt;
    &lt;ul&gt;
      &lt;li&gt;Tomatoes&lt;/li&gt;
      &lt;li&gt;Beef&lt;/li&gt;
      &lt;li&gt;Milk&lt;/li&gt;
    &lt;/ul&gt;
  );
}

// Correct. Used Fragment
export default function ShoppingList() {
  return (&lt;&gt;
    &lt;h1&gt;Shopping List&lt;/h1&gt;
    &lt;ul&gt;
      &lt;li&gt;Tomatoes&lt;/li&gt;
      &lt;li&gt;Beef&lt;/li&gt;
      &lt;li&gt;Milk&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/&gt;);
}</code></pre>
<p>여러 개의 Element 를 바로 반환하는 것을 허용하지 않는 이유는 JSX 의 반환값이 자바스크립트 객체이기 때문이다. 하나의 함수에서 두 개의 객체를 동시에 반환하지는 못한다.</p>
<ol start="2">
<li>태그는 전부 닫혀있어야 한다.</li>
<li>JSX 속성은 대부분 camelCase 를 사용한다. 자바스크립트의 변수명은 &#39;-&#39;와 키워드로 선언되는 것을 금지한다. JSX 의 리턴 값은 객체이기 때문에 속성 또한 변수명의 규칙을 따른다.</li>
</ol>
<pre><code class="language-jsx">&lt;img 
  src=&quot;https://i.imgur.com/yXOvdOSs.jpg&quot; 
  alt=&quot;Hedy Lamarr&quot; 
  className=&quot;photo&quot; // CamelCase
/&gt;</code></pre>
<h3 id="extension-using-curly-braces">Extension using Curly Braces</h3>
<p>JSX 는 HTML 을 쉽게 작성하기 위한 마크업 확장이기 때문에 동적으로 리턴값인 HTML(자바스크립트 객체) 을 정의할 수 있다.</p>
<pre><code class="language-jsx">export default function toDollar(number) {
  function formatNumber(number) {
    return number.toLocaleString();
  }
  return &lt;p&gt;Current price is `${formatNumber(number)}`&lt;/p&gt;
}</code></pre>
<p>특이하게도 style 처럼 다수의 속성을 정의하는 경우에는 다음과 같이 사용한다. 하지만 이것도 자세히 보면 한 개의 객체를 전달하는 것 뿐이라는 것을 알 수 있다.</p>
<pre><code class="language-jsx">export default function TodoList() {
  return (
    &lt;ul style={{
        backgroundColor: &#39;black&#39;,
        color: &#39;pink&#39;
    }}&gt;
      &lt;li&gt;Study&lt;/li&gt;
      &lt;li&gt;Workout&lt;/li&gt;
      &lt;li&gt;Sleep well&lt;/li&gt;
    &lt;/ul&gt;
  );
}</code></pre>
<h2 id="props">Props</h2>
<p>리액트의 컴포넌트들은 props 라는 변수를 이용해 서로 소통한다. 소통방향은 부모 컴포넌트에서 자식 컴포넌트가 일반적이며, props 는 값, 객체, 함수/메소드 심지어 컴포넌트도 가능하다.</p>
<h3 id="pass-data-object-functionmethod">pass data, object, function/method</h3>
<p>컴포넌트는 미리 지정된 다음의 프로퍼티 혹은 파라미터를 사용할 수 있다. 클래스 컴포넌트인 경우는 this.props (이는 constructur 에서 super(props) 를 호출하였기 때문) 으로 사용 가능하며, 함수형 컴포넌트인 경우는 그냥 파라미터로 받아서 사용하면 된다. </p>
<pre><code class="language-jsx">function Welcome(props) {
  return &lt;h1&gt;Hello, {props.name}&lt;/h1&gt;;
}</code></pre>
<pre><code class="language-jsx">class Welcome extends React.Component {
  render() {
    return &lt;h1&gt;Hello, {this.props.name}&lt;/h1&gt;
  }
}</code></pre>
<p>주로 함수형 컴포넌트를 사용하는 현재는 자바스크립트의 객체 destructuring 문법을 혼합하여 사용하고 있다.</p>
<pre><code class="language-jsx">export default function MyProfile({name, job, age}) {
  return (&lt;&gt;
    &lt;ul&gt;
      &lt;li&gt;이름은 {name}&lt;/li&gt;
      &lt;li&gt;직업은 {job}&lt;/li&gt;
      &lt;li&gt;나이는 {age}&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/&gt;);
}</code></pre>
<h3 id="set-default-value">set default value</h3>
<p>간단히 예제로 언급하도록 한다.</p>
<pre><code class="language-jsx">export default function MyProfile({ name, job = &#39;searching&#39;, age }) {
  // ...
}</code></pre>
<h3 id="children">children</h3>
<p>props 내에는 children 이라는 키워드가 있다. 이는 사용중인 컴포넌트를 리턴값으로 선언할 때 태그와 태그 사이의 위치한 컴포넌트이다.</p>
<pre><code class="language-jsx">function Card({ children }) {
  return (
    &lt;div className=&quot;card&quot;&gt;
      {children}
    &lt;/div&gt;
  );
}

export default function Profile() {
  return (
    &lt;Card&gt;
      &lt;Avatar
        size={100}
        person={{ 
          name: &#39;Katsuko Saruhashi&#39;,
          imageId: &#39;YfeOqp2&#39;
        }}
      /&gt;
    &lt;/Card&gt;
  );
}</code></pre>
<h3 id="props-that-changing">props that changing</h3>
<p>부모 컴포넌트에 의해 전달된 props 는 부모 컴포넌트의 라이프사이클에 따라 바뀔 수 있다.</p>
<pre><code class="language-jsx">export default function Clock({ color, time }) {
  return (
    &lt;h1 style={{color: color}}&gt;
      {time}
    &lt;/h1&gt;
  );
}</code></pre>
<p>부모에 의해 전달된 color 값, time 값은 state 일 것이다. useState 훅에 의해 정의된 위의 두 값은 바뀔 때마다 부모 컴포넌트를 rendering 할 것이며, 그로 인해 자식 컴포넌트도 redering 될 것이다.</p>
<h2 id="react-ui-as-tree--and-pure-function">React UI as Tree &amp; and Pure function</h2>
<h3 id="pure-function">Pure function</h3>
<p>순수 함수란 외부의 영향 없이 정해진 값에 정해진 결과만을 반환하는 함수를 말한다.</p>
<pre><code class="language-javascript">function pureSum(a, b) {
  return a+b;
}

let externalNumber = 0;
function notPureSum(a) {
  return externalNumber + a;
}</code></pre>
<p>순수 함수의 장점은 개발자가 의도한 결과를 반환하는데 더 유리하다는 것이다. 해당 함수에 대해서는 <strong>정확한 값만 전달 되었다면</strong> 예상한 값만 반환할 것이기 때문에, 전달된 값만 검증하면 된다.</p>
<h3 id="ui-as-tree">UI as Tree</h3>
<p>React 뿐만 아닌 최근 프론트엔드의 트렌드는 트리 구조의 UI 인가보다.</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/f443432d-f06e-43d0-b813-13c82e5f6c8c/image.png" alt=""></p>
<p>여기서 중요한 것은 부모 컴포넌트가 렌더링되면 자식 컴포넌트도 같이 렌더링 된다는 것이다. 위의 그림에서 A가 렌더링되면 B,C 도 렌더링되지만, B가 렌더링 된다고 A가 렌더링 되지는 않는다.</p>
<blockquote>
<p>그렇기 때문에 자식 컴포넌트를 정의함에 있어 순수 함수를 사용하는 것이 중요하다
만약 자식 컴포넌트를 렌더링하는 함수가 다른 함수 혹은 컴포넌트의 영향을 받게 되면 코드 베이스는 혼잡해지고, 유지보수하기 어려워진다.</p>
</blockquote>
<blockquote>
<p>리액트 공식문서에서는 렌더링과 함께 순수함수에 대해 아래와 같이 명시하고 있다.</p>
<pre><code>렌더링은 반드시 순수 연산으로 이뤄져야 한다.</code></pre></blockquote>
<ul>
<li>같은 입력은 같은 출력을 동반해야 한다.
컴포넌트는 같은 입력에 대해 언제나 같은 JSX 를 반환해야 한다.</li>
<li>독립성을 고려해야 한다.
특정 컴포넌트 변경이 다른 컴포넌트에 영향을 끼쳐서는 안된다.
코드베이스가 복잡 해질수록 혼란스러운 버그와 예상치 못한 상황을 여러 번 맞이할 것이다.
&quot;Strict Mode&quot; 로 개발을 진행하면 각 컴포넌트 함수가 두 번 호출 되는데
표면 상 잘 드러나지 않는 실수를 검증할 수 있다.<blockquote>
<pre><code></code></pre></blockquote>
</li>
</ul>
<h2 id="react-triggering--rendering--committing">React Triggering &amp; Rendering &amp; Committing</h2>
<p>렌더링은 다음의 과정에 따라 진행된다. 내 머릿 속에 박아놓고 튀어 나와야 할 지식들을 적어 놓는다.</p>
<h3 id="triggering">Triggering</h3>
<p>Triggering은 렌더링을 유도하는 과정이고 다음의 두 이유로 발생한다.</p>
<ul>
<li>컴포넌트로서 최초 렌더링</li>
<li>컴포넌트의 상태값이 변경될 경우</li>
</ul>
<p>여기서 말하는 최초 렌더링은 createRoot 에 의해 발생하는 최초 렌더링을 말한다. 주로 리액트 프로젝트를 생성하면 index.js 가 생성되는데 이 안에 createRoot 가 들어있는 것을 확인할 수 있다.</p>
<p>(화면 이동은 react-router-dom 같은 외부 라이브러리를 이용하거나, a 태그를 이용하는 것으로 리액트와는 거리가 먼 이야기다)</p>
<p>상태값이 변경되는 것은 useState 훅을 이용하는 경우를 말한다. useState 훅의 리턴값은 배열인데, 0번째 인덱스는 상태값 자체, 1번째 인덱스는 상태값을 변경할 수 있는 set 함수이다. 즉, 화면 상의 변화가 발생하였다면 컴포넌트의 set 함수가 작동했을 가능성이 가장 높다.</p>
<h3 id="redering">Redering</h3>
<p>트리거가 되면 리액트는 렌더링해야 할 컴포넌트를 호출한다.</p>
<ul>
<li>최초 렌더링(createRoot) 시에는 리액트가 루트 컴포넌트를 호출한다.</li>
<li>이후의 렌더링은 리액트가 컴포넌트 상태값 업데이트에 컴포넌트 함수가 호출된다.</li>
</ul>
<p>이 과정은 연속적인데, 한 부모 컴포넌트를 런더링하면 자식 컴포넌트도 렌더링되는 방식이다.</p>
<p>렌더링의 작동방식을 이해한다면, UI 트리 구조상 높은 위치의 컴포넌트 렌더링에는 신중을 기해야 한다는 것도 이해할 수 있을 것이다. 성능 이슈가 발생한다면 <a href="https://reactjs.org/docs/optimizing-performance.html">Performance</a> 를 참고하자.</p>
<h3 id="committing">Committing</h3>
<p>렌더링과 동시에 리액트는 DOM 을 변경한다.</p>
<ul>
<li>최초 렌더링 시에 리액트는 appendChild() DOM API 를 이용하여 루트 DOM 노드와 그 아래의 노드를 새로 생성한다.</li>
<li>이후의 렌더링은 최소한의 작업만을 이용해 DOM 이 렌더링 되도록 한다.</li>
</ul>
<h2 id="hooks">Hooks</h2>
<p>React 16.8 부터 지원되어오던 리액트의 기본 기능 중 하나이다. 버전만 보면 상당히 늦게 지원이 시작된 것으로 보인다.</p>
<p><a href="https://youtu.be/dpw9EHDh2bM">https://youtu.be/dpw9EHDh2bM</a></p>
<p>훅의 사용은 선택적이면서 클래스로 관리되었던 컴포넌트를 함수형으로 관리할 때의 장점을 극대화 한다.</p>
<p>훅의 특이한 점은 반드시 컴포넌트의 최상위 부분에 선언해야 한다는 것이다. 이는 훅이 컴포넌트의 상태값을 관리하는데 깊이 관여하기 때문인데, 이에 관하여서는 바로 아래 useState 와 함께 정리한다.</p>
<h3 id="usestate">useState</h3>
<p>컴포넌트는 자신의 상태를 따로 저장하고 있다. 이를 useState 를 통해 정의하고 가져올 수 있다.</p>
<pre><code class="language-jsx">import { useState } from &#39;react&#39;;
import { images } from &#39;./images.js&#39;;

export default function Gallery() {
  const [index, setIndex] = useState(0);

  function handleClick() {
    setIndex((prev) =&gt; prev % images.length);
  }

  return (
    &lt;&gt;
      &lt;img src={images[index].url} alt={images[index].alt}/&gt;
      &lt;button onClick={handleClick}&gt;Next&lt;/button&gt;  
    &lt;/&gt;
  );
}</code></pre>
<p>다음은 Hook 없이 useState 를 구현한 코드이다.</p>
<hr>
<p>사실 굉장히 간단하지만 약간 마법같은 일이다. 값을 바꾸니 컴포넌트가 렌더링 된다는 것은 어떻게 발생하는가? 그건 다음 링크에 자세히 나와있지만 따로 아래에 정리하도록 한다. <a href="https://react.dev/learn/state-a-components-memory#how-does-react-know-which-state-to-return">How does React know which state to return</a></p>
<p>리액트는 같은 함수로 생성된 상태값임에도 불구하고 특정 setter 를 통해 정확한 상태값 업데이트가 가능하다. 그것이 가능한 이유는 <strong>컴포넌트 당 최상위에 각자의 상태값을 갖기 때문</strong>이다.</p>
<p>리액트 내부에는 각 컴포넌트마다 상태값 쌍을 위한 배열과 상태값을 가져올 현재 인덱스를 갖도록 한다.</p>
<p>아래 useState 를 직접 구현한 것을 보면 더 자세히 이해할 수 있다.</p>
<pre><code class="language-jsx">let componentHooks = [];
let currentHookIndex = 0;

function useState(initialState) {
  let pair = componentHook[currentHookIndex];
  if (pair) { // 현재 인덱스로 저장된 상태값이 있다면 그 상태값 반환

    currentHookIndex++;
    return pair;
  }

  pair = [initialState, setState]; // 없다면 새로 만들어줌

  function setState(nextState) {

    pair[0] = nextState;
    updateDOM(); // setter 에서 DOM 을 업데이트 하도록 함
  }

  componentHooks[currentHookIndex] = pair;
  currentHookIndex++;
  return pair;
}</code></pre>
<hr>
<p>useState 는 특히 다른 Hook 도 마찬가지지만, 컴포넌트 최상위에서만 선언이 가능함을 다시 강조한다. 특정 컴포넌트만 렌더링 하는 것으로 퍼포먼스를 상승시키고, 상태값을 분리하고 순수함수 형태로 컴포넌트를 관리할 수 있도록 하려면 훅은 컴포넌트 내 최상위에서 선언되어야 알맞다.</p>
<h3 id="usereducer">useReducer</h3>
<p>요즘 대세는 단방향 아키텍처인 것 같다. 예를 들어 나는 본업이 iOS 개발자이니 단방향 아키텍처이면서 redux 의 영향을 받은 2 개의 구조를 나타내는 그림만 가져와 보았다.</p>
<div style="display: flex">
  <img src="https://velog.velcdn.com/images/sanghwi_back/post/dbac1b49-f8fa-4cc0-91e0-7c649a0a52a5/image.png" style="width: 40%;  height: 40%; padding-right: 2px;"/>
  <img src="https://velog.velcdn.com/images/sanghwi_back/post/cfbb162d-1da8-4369-bcd7-54de20abc55a/image.png" style="width: 60%;  height: 60%; padding-left: 2px;"/>
</div>

<p>(왼쪽은 <a href="">ReactorKit Github</a>, 오른쪽은 이 블로그의 <a href="https://velog.io/@sanghwi_back/TCA-Deep-Dive-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EC%9D%98-%EC%9D%B4%ED%95%B4">TCA Deep-Dive[1]</a> 에서 가져왔다.</p>
<p>2 개의 구조에 Reactor, Reducer 라는 요소를 발견할 수 있다. 이 2 요소는 공통적으로 Action 을 받는데 (Store 내부에 Action 이 정의되어 있음) Action 은 여러 개 정의될 수 있으며, 다수의 액션에 대한 동작을 Reactor, Reducer 가 정의하고 있다.</p>
<p>Reducer 연산자를 컴퓨터 과학에서는 동시 프로그래밍에 사용한다고 한다. 여러 개의 element 들을 하나로 만들어주는 연산자를 뜻한다. 위키에서 행렬로 이론을 설명하는 걸 보고 여기까지만 알고 싶어졌다.</p>
<hr>
<p>리액트에서도 마찬가지이다. 미리 정의한 액션들을 토대로 똑같은 작업을 수행하는 Pure 한 reducer 함수와 reducer 의 초기 상태값을 정의한 뒤 reducer 에 명령을 보낼 수 있는 dispatch 함수를 반환한다.</p>
<p>내가 쓰고도 무슨 말인지 잘 모르겠으니 코드로 표현하고자 한다.</p>
<pre><code class="language-jsx">import { useReducer } from &#39;react&#39;;
import { data } from &#39;./cards.js&#39;;

export default function Root() {
  // dispatch 로 실행되는 reducer.
  // 현재 상태값인 state, 전달된 action 을 파라미터로 받는다. 둘 다 객체.
  function reducer(state, action) {
    switch(action.type) {
      case &quot;setCard&quot;:
        let newCards = state.cards;
        newCards.push(action.card);
        return { ...state, newCards };
      case &quot;removeCard:
        let newCards = state.cards;
        newCards.splice(action.index, 1);
        return { ...state, newCards };
      case &quot;printCard&quot;:
        console.log(state.status());
        return state;
      default:
        return state;
    }
  }

  // dispatch 에는 액션 객체를 전달해서 작업을 수행하게 할 수 있다.
  // dispatch({ type: &#39;printCard&#39; });
  // dispatch({ type: &#39;removeCard&#39;, index: 0 });
  const [state, dispatch] = useReducer(reducer, {
    cards: data.cards,
    status: () =&gt; {
      return `Card count is ${state.cards.length}`
    }
  });

  return &lt;CardGame state={state} dispatch={dispatch}/&gt;
}</code></pre>
<p>개인적으로 이 훅은 상당히 즐겨쓴다. 위에서 정한 reducer 만 잘 테스트 된다면 이를 통해 정의된 컴포넌트도 testable 해지게 되는 것 같다고 생각한다.</p>
<h3 id="useeffect">useEffect</h3>
<p>이 훅은 굉장히 의존성과 관련이 많다. 만약 이 훅이 선언된 컴포넌트가 추가되면 이 훅의 셋업 함수가 실행되는데, 이외에도 훅의 동작방식이 굉장히 특이하다.</p>
<pre><code class="language-jsx">useEffect(setup, dependencies?);</code></pre>
<ol>
<li>최초 컴포넌트가 추가되면 setup 함수가 실행된다.</li>
<li>dependencies 는 옵셔널 형태의 의존성으로 전달된 상태값이 바뀌게 되면(useState 의 setter) setup 함수부터 다시 실행하게 된다.</li>
<li>setup 함수는 cleanup 함수를 반환할 수 있다. cleanup 함수는 의존성 변경으로 인해 다시 실행되는 setup 함수 실행 전에 정리 작업을 할 수 있도록 해준다.</li>
</ol>
<p>리액트 공식문서의 예제 코드가 아주 좋다. 사실 나도 네트워크 연결보다는 컴포넌트 트리를 완전 리프레쉬할 때 말고는 써본 적이 없다.</p>
<pre><code class="language-jsx">import { useEffect, useEffect } from &#39;react&#39;;
import { createConnection } from &#39;./chat.js&#39;;

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState(&#39;https://localhost:1234&#39;);

  useEffect(() =&gt; {
    const connection = createConnection(serverUrl, roomId);
    conntion.connect();
    return () =&gt; {
      connection.disconnect();
    };
  }, [serverUrl, roomId]);
  // ...
}</code></pre>
<p>만약 setServerUrl 이 호출되어 URL 이 바뀐다면 connection.disconnect() 를 호출 후에 다시 connection.connect() 하게 된다.</p>
<h3 id="createcontext-usecontext">createContext, useContext</h3>
<p>리액트에서 컨텍스트란 컴포넌트와 완전히 분리된 위치에서 선언되는 객체이며, 컴포넌트는 이 컨텍스트의 Provider (Consumer 도 있지만 잘 사용하지 않음) 내에 있다면 이를 읽고 수정할 수 있다.</p>
<p>컴포넌트들을 분리한 뒤 특정 컴포넌트들끼리 공유하는 값을 관리하고 싶을 때 유용하다.</p>
<pre><code class="language-jsx">import ThemeContext from &#39;./context.js&#39;;

function App() {
  const [theme, setTheme] = useState(&#39;light&#39;);
  // ...
  return (
    &lt;ThemeContext.Provier value={ theme, setTheme }&gt;
      &lt;Page/&gt;
    &lt;/ThemeContext.Provider&gt;
  );
}

function Page() {
  let { theme, setTheme } = useContext(ThemeContext);
  function toggleTheme() {
    setTheme((prev) =&gt; prev === &#39;light&#39; ? &#39;dark&#39; : &#39;light&#39;);
  }
  return (
    &lt;div&gt;
      &lt;Contents theme={theme}/&gt;
      &lt;button onClick={toggleTheme}&gt;toggle theme&lt;/button&gt;
    &lt;/div&gt;
  )
}</code></pre>
<p>Page 는 ThemeContext 의 Provider 의 트리 내에 속해있기 때문에 useContext 를 통해서 상태를 읽을 수도 있고, 수정할수도 있다.</p>
<h2 id="출처">출처</h2>
<ul>
<li><a href="https://react.dev/">https://react.dev/</a></li>
<li><a href="https://ko.legacy.reactjs.org">https://ko.legacy.reactjs.org</a></li>
<li><a href="https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/export">https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/export</a></li>
<li><a href="https://en.wikipedia.org/wiki/Reduction_operator">https://en.wikipedia.org/wiki/Reduction_operator</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[독후감] 아론 힐리가스의 오브젝티브-C 프로그래밍]]></title>
            <link>https://velog.io/@sanghwi_back/%EB%8F%85%ED%9B%84%EA%B0%90-%EC%95%84%EB%A1%A0-%ED%9E%90%EB%A6%AC%EA%B0%80%EC%8A%A4%EC%9D%98-%EC%98%A4%EB%B8%8C%EC%A0%9D%ED%8B%B0%EB%B8%8C-C-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D</link>
            <guid>https://velog.io/@sanghwi_back/%EB%8F%85%ED%9B%84%EA%B0%90-%EC%95%84%EB%A1%A0-%ED%9E%90%EB%A6%AC%EA%B0%80%EC%8A%A4%EC%9D%98-%EC%98%A4%EB%B8%8C%EC%A0%9D%ED%8B%B0%EB%B8%8C-C-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D</guid>
            <pubDate>Sun, 31 Dec 2023 08:31:13 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/bf76cc49-cb53-460a-bb64-528d2d39b53d/image.jpg" alt=""></p>
<blockquote>
<p>프로그래밍을 업으로 삼았다면 직업인으로서, 비즈니스적으로서 프로그래밍을 접근하는 시각도 필요한 것 같다. 프리랜서로 활동하는 지금 Objective-C(줄여서 objc) 가 필요하다 느낀 이유는 &quot;레거시 프로젝트의 기능 수정이나 오류 수정이 필요한데....&quot; 에 가장 적합한 것은 잠깐 왔다 갈 프리랜서가 가장 적합하다고 느꼈기 때문이다.</p>
</blockquote>
<p>위의 사유로 objc 공부에 돌입했다.</p>
<h2 id="책을-선정한-이유">책을 선정한 이유</h2>
<p>그 전에 왜 책인가부터 얘기하고 싶다.</p>
<p>사실 objc 책은 약 2015 년을 기점으로 더 이상 출판되지 않는 것 같다. 인터넷 강의와 같은 컨텐츠들도 더 이상 생산되지 않고 있다. 그리고 블로그 포스팅도 간단한 문법만 다룰 뿐 objc 를 &quot;배운다&quot;는 것에 실질적인 도움을 주는 것은 레거시라고 말할만한 책 말고는 없었다.</p>
<p>즉, 옛날 자료라서 현재와는 맞지 않는 부분이 있더라도 양질의 자료를 발굴하는 수 밖에 없었던 것이다.</p>
<h3 id="왜-이-책인가">왜 이 책인가?</h3>
<p>사실 이 책 말고도 다른 책들을 여럿 보았다. 실패를 경험하면서 이 책에 가장 끌린 이유는 다음과 같다.</p>
<ul>
<li>명확하고 직관적이다. 주제를 조금도 벗어나지 않고 일직선으로 얘기한다는 느낌을 받았다.</li>
<li>저자인 아론 힐리가스는 NeXT 를 거쳐 애플에서 근무한 사람이다. objc 에 대한 이해도가 굉장한 사람일거란 생각이 있었다.</li>
</ul>
<p>그리고 난 이 책에 상당히 만족스럽다.</p>
<h3 id="내용에-대해">내용에 대해</h3>
<p>이 책은 상당히 독특한데, C언어로 시작해서 C언어로 끝난다. 그 이유는 책의 초반 &quot;서막&quot;이라는 장에서 설명한다.</p>
<blockquote>
<p>왜 먼저 C를 설명하겠다고 할까? 오브젝티브-C 프로그래머라면 예외 없이 C에 대한 이해가 매우 깊다. 또한 오브젝티브-C로는 복잡해 보이는 수많은 개념들도 그 뿌리에 해당하는 C의 시각에서는 매우 단순해진다. 이런 이유로 개념을 설명할 때는 C를 사용하고, 관련 지식을 여러분이 섭렵할 수 있도록 안내할 때는 오브젝티브-C를 사용하는 일이 잦을 것이다.</p>
</blockquote>
<p>즉, 개념이 많이 겹친다는 것이다. C에 이해가 있는 사람들이 내심 부러워지는 순간이었다.</p>
<h3 id="번역에-대해">번역에 대해</h3>
<p>특히 이 책의 번역은 놀라우면서 &quot;이런 책이 더 많으면 재밋겠다&quot; 라는 생각이 들었다. 부정적인 어투로 하는 말이 아니다.</p>
<p>예를 들어 본문 중 이런 얘기가 있다.</p>
<blockquote>
<p>(지금 이런 충고는 프로그래밍 입문자에게만 국한되는 것이 아니다. 어느 집단에나 꼭 이런 사람 은 한 명씩 있다. &quot;멀티스레드로 동작하는 앱을 만들 때 세터 메소드를 atomic으로 설정해야 보호 를 받을 수 있어.&quot;라고 말하는, 아는 것이 힘이 아닌 병인 사람이다. 그런 사람에게 한마디하겠다.<br/>*멀티스레드 코드를 작성할 일은 별로 없을 텐데. 설령 작성하게 되더라도 세터 메소드를 atomic 으로 설정해봐야 아무런 도움이 안 되지 하지만 정작 하고 싶은 말은 따로 있다.&quot;&#39;OK 그렇다면 세터들을 그냥 atomic으로 놔둬.&quot; 소 귀에 경 읽기이기 때문이다.)<br/><p><i>atomic이나 nonatomic을 선택할 기준 중</i></p></p>
</blockquote>
<p>어투가 기존 개발 관련 책들과는 다르다. 이런 내용들도 있는 걸 보면 대충 꽤 솔직한 매력이 있는 사람인 것 같다.</p>
<blockquote>
<p>미키 워드(Mikey Ward)는 &#39;첫 iOS 애플리케이션&#39;, &#39;첫 코코아 애플리케이션&#39;, &#39;블록&#39; 등의 장을 써주었다. 내가 만약 좀 더 친절한 상사였다면 그의 이름이 표지에 실렸을지도 모른다.</p>
</blockquote>
<p><i>감사의 글 중</i></p>

<h3 id="누구에게-추천하는가">누구에게 추천하는가?</h3>
<p>이 책은 Swift 를 이용해 iOS 앱 개발이 가능한 개발자 중 objc 에 관심이 있는 분들에게 추천한다.</p>
<p>추가로, 나는 아직 구매하지 않았다. 주변 도서관에 이 책을 검색해보자. 있을 확률이 높다. 필자는 의정부에 거주중인데, 의정부 과학도서관에서 찾았다.</p>
<h2 id="책의-내용">책의 내용</h2>
<p>앞에서 말한 대로 이 책은 C언어로 시작해 C언어로 끝났다.</p>
<hr>
<p>참고로 나는 2 개의 내용을 빼고 4일 동안 매일 4시간 정도 읽었다.</p>
<ul>
<li>C 언어 고급</li>
<li>첫 코코아 애플리케이션</li>
</ul>
<p>이번주에는 C 언어보다는 objc 문법에 충실하고 싶었기도 했고, MacOS 개발은 아직 나랑은 너무 먼 얘기였다.</p>
<hr>
<h3 id="c-언어-시작하기">C 언어 시작하기</h3>
<p>이 앞의 내용은 뭐... 그냥 그런 내용이다. C 가 중요하다.. 프로그래머는 이래야 한다.. Xcode 는 이렇게 깐다..</p>
<p>대부분 이 책을 현재 읽는 사람은 Swift 강의나 책을 한번 쯤 본 사람들이라고 생각한다. 이 부분에 대해 할 말은 거의 없다.</p>
<p>이후에 Xcode 로 C 언어 프로그램을 작성하기 시작한다.</p>
<ul>
<li>변수/타입 선언</li>
<li>if, while, for...</li>
<li>함수</li>
<li>주소와 포인터에 대한 이해, 참조</li>
<li>구조체</li>
<li>힙</li>
</ul>
<p>나도 처음에는 몰랐는데 변수가 값을 저장한 경우 직접 저장하지만, 객체를 저장할 경우엔 힙 내의 객체가 저장된 주소를 저장한다는 사실을 이 책을 읽으면 자연스레 이해하게 된다.</p>
<h3 id="objc-공부하기">objc 공부하기</h3>
<p>Swift, Java 만 경험한 나로서는 객체의 포인터, id 변수는 좀 서툴렀다. 하지만 금방 사용법은 알 수 있었다.</p>
<p>더블 포인터 얘기같은 것들이 종종 나오던데 &quot;안다&quot;고 하기에는 한참 먼 얘기인 것 같다.</p>
<p>이후에는 함수 호출을 &quot;메시지&quot; 를 보내는 형태로 이해해야 한다는 사실을 알려준다. 실제로 저자는 &quot;.&quot;으로 함수를 호출하기 보다 &quot;메시지&quot; 를 이용해 함수를 호출하는 것이 좀 더 명확해서 즐겨 사용한다고 한다.</p>
<p>그 이후에는 주로 사용되는 객체들인 NSString, NSArray 로 위에서 배운 개념들을 복습하기 시작한다.</p>
<p>그러면서 자연스레 클래스로 넘어간다. 클래스를 작성하고 인스턴스화하여 사용하고 상속을 하면서 자연스레 Swift 의 Scope 와 비슷한 Frame 이라는 개념을 배우게 된다.</p>
<p>클래스, 객체, 인스턴스 등이 나오면 면접 질문 단골인 ARC, 메모리 누수가 나오게 된다. 언제 나오나 궁금했다. MRC 가 나오나 싶어 두근두근 했는데 ARC 로 설명해서 편안한 마음으로 읽었다. 읽다보니 예전에 정리한 내용도 생각이 나서 추가로 공유하도록 한다.</p>
<p><a href="https://github.com/SangHwi-Back/iOS-Interviews/blob/main/LowLevel/ARC/AutoReferenceCounting.md">https://github.com/SangHwi-Back/iOS-Interviews/blob/main/LowLevel/ARC/AutoReferenceCounting.md</a></p>
<p>이제 다 나왔나 싶었는데 컬렉션을 집중적으로 다루기 시작한다. NSArray/NSMutableArray, NSSet/NSMutableSet, NSDictionary/NSMutableDictionary 를 다루기 시작한다.</p>
<p>이제는 상수를 다룬다. 전역변수는 예전에도 잘 쓰곤 했나보다.</p>
<p>생각해보니 아직 다루지 않은게 여럿 있다. NSData, Callback, Protocol, plist 를 차례대로 다룬다.</p>
<h3 id="앱-만들기">앱 만들기</h3>
<p>iOS, MacOS 앱을 차례로 만들어본다. 이 부분은 각자의 판단에 따라 자유롭게 읽어도 될 것 같다.</p>
<h3 id="오브젝티브-c-고급">오브젝티브-C 고급</h3>
<p>자신만의 init 메소드를 작성하는 방법을 알려준다. 그러면서 모든 init 이 결국 designate init 을 호출하도록 해야 한다는 꿀팁도 전달해준다. 이 부분은 Swift 개발할 때도 참고할만한 것 같다.</p>
<p>이후에는 objc 프로퍼티들에 대해 자세히 다룬다. weak 같은 반가운 얼굴들도 나오고 let 을 대체하는 readonly 도 나온다. atomic, nonatomic 도 굉장히 흥미로운 내용이다.</p>
<p>카테고리는 Swift 에서 경험한 extension 과 같은 개념이다.</p>
<p>대망의 블록이 나오는데 closure 와 같은 개념이다. Swift 보다 어려울 순 있지만 Swift 에서 활용할 수 있는 기능은 objc 에도 구현되어 있는 것 같다.</p>
<h2 id="책을-읽은-방법">책을 읽은 방법</h2>
<p>2번 이상 읽을 생각은 했다. 그렇기 때문에 처음 빠르게 훑기 위해서 4일 간 읽는 목표를 삼았다.</p>
<p>그래도 후루룩 읽어버리면 후루룩 없어져 버릴 것만 같아서 필사를 하며 읽었다. 타자를 치며 읽었다는 것이다. 내용을 private 레포에 푸시 하면서 깃헙 잔디도 좀 심었다.</p>
<p>이제 두 번쨰 읽을 타이밍이다. 이제는 좀 심오하게 읽으며 놓친 부분을 다시 상기시키는 시간을 5일동안 가져볼 생각이다.</p>
<p>이 다음은? objc 만 사용해서 앱 하나 만드는 것이 당연한 수순일 것이다. 그것이 저자의 생각이기도 하다.</p>
<blockquote>
<p>이 책은 Mac 컴퓨터 앞에서 읽히도록 짜여 있다. 개념 설명을 읽고 나면 그와 관련된 내용을 실제로 체험할 텐데, 이 체험은 선택이 아니다. 체험하지 않는다면 책을 정말로 이해했다고 할 수 없다. 프로그래밍을 배우는 가장 적합한 방법은 코드를 일일이 입력하고, 그러다 오타가 생기면 직접 고치고 하는 과정을 겪어 프로그래밍 언어의 패턴에 익숙해지는 것이다. 단순히 코드를 읽고 개념을 이론적으로 이해한다고 해서 여러분에게 도움이 된다거나 여러분의 능력이 향상되지 않는다.</p>
</blockquote>
<h2 id="reference">Reference</h2>
<ul>
<li><a href="https://www.yes24.com/Product/Goods/6761546">https://www.yes24.com/Product/Goods/6761546</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[TCA Deep-Dive [1]]]></title>
            <link>https://velog.io/@sanghwi_back/TCA-Deep-Dive-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EC%9D%98-%EC%9D%B4%ED%95%B4</link>
            <guid>https://velog.io/@sanghwi_back/TCA-Deep-Dive-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EC%9D%98-%EC%9D%B4%ED%95%B4</guid>
            <pubDate>Sat, 16 Dec 2023 08:24:42 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이번 프로젝트에서 SwiftUI + TCA 를 도입하여 개발 시작부터 앱 배포까지 진행을 해 보았다. 이를 통해 얻은 노하우와 팁 등을 정리하고자 TCA 시리즈를 준비해 보았다.</p>
</blockquote>
<blockquote>
<p>이번 게시글에는 TCA 의 구성요소, 의미, 그리고 간단한 구현 예제를 작성하고자 한다.</p>
</blockquote>
<h2 id="tca-의-cacomposable-architecture-란">TCA 의 CA(Composable Architecture) 란</h2>
<p>TCA, The Swift Composable Architecture 의 The 는 관사이니 제외하면 CA 만 남는다. 즉, Swift 를 이용한 Composable Architecture 라는 뜻이 된다.</p>
<p>Composable Architecture 에 대한 아이디어는 많은 정보를 찾을 수는 없었다. 아마도 아래의 내용에 대한 것을 포함한다면 Composable Architecture 라고 부르는 것으로 판단된다.</p>
<ol>
<li>객체를 <strong><em>작게 분류</em></strong>한다.</li>
<li>분류한 객체를 각각의 목적에 따라 <strong><em>재사용</em></strong> 할 수 있다.</li>
<li><strong><em>재사용한 객체를 하나의 시스템으로</em></strong> 구성 가능하다.</li>
</ol>
<img src="https://velog.velcdn.com/images/sanghwi_back/post/f1e3c615-932f-467f-890e-9c228f58aea4/image.png" style="width: 380px"/>

<p>이를 통해 저는 TCA 의 가장 큰 장점으로 다음의 요소를 꼽고 싶다.</p>
<blockquote>
<p>TCA 는 비즈니스 로직을 작게 분류하여 여러 개의 비즈니스 로직으로 만드는 데 특화된 아키텍처이다</p>
</blockquote>
<h2 id="tca-의-기본-흐름">TCA 의 기본 흐름</h2>
<img src="https://velog.velcdn.com/images/sanghwi_back/post/cfbb162d-1da8-4369-bcd7-54de20abc55a/image.png" style="width: 450px"/>

<p>TCA 의 기본 흐름도이다. 우선 구성요소들에 대한 설명은 아래와 같다.</p>
<ol>
<li>View : 말 그대로 View 이다. 뷰는 구체 Store 타입을 갖는다.</li>
<li>ViewStore : State 의 값 변화를 관찰하며 바뀔 경우 이벤트를 전달한다. Action 을 Reducer 에 전송하는 역할도 한다. (아마 가장 많이 사용되는 객체 중 하나일 것이다)</li>
<li>Store : State, Action, Reducer 를 Thread Safe 하게 관리하는 컨테이너이다. 일종의 ViewModel 이다.<ul>
<li>State : 뷰의 상태를 정의한다. State 의 각 프로퍼티는 SwiftUI 의 @Binding 처럼 작동한다.</li>
<li>Action : Store 에 미리 정의된 User Action 에 대한 추상화 타입이다.</li>
<li>Reducer : 아래 3 개의 역할을 수행한다.<ul>
<li>Mutable 한 State 를 바꿀 수 있는 객체이다.</li>
<li>Store 에 주입된 Dependency (immutable) 를 사용할 수 있다.</li>
<li>Effect 를 이용해 Store 에 새로운 Action 을 보내거나, 비동기 작업을 수행하는 스레드를 생성할 수 있다.</li>
<li>속해있는 Store 의 State 가 다른 Store 의 State 를 포함한 경우, 포함하고 있는 Store 의 Action 을 관찰하여 자신을 변경하고 재사용할 수 있도록 한다. (나중에 후술)</li>
</ul>
</li>
</ul>
</li>
<li>Dependency : Store 에 주입되는 객체로, 외부 Entity 나 Repository 등을 뜻한다.</li>
</ol>
<h2 id="clean-architecture-와의-비교">Clean Architecture 와의 비교</h2>
<p>Clean Architecture 의 상세한 설명은 (잘 모르기도 하고...) 아래의 그림으로 대체한다. (<a href="https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html">https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html</a>)</p>
<img src="https://velog.velcdn.com/images/sanghwi_back/post/70da8b59-3695-4280-a33a-1fb4f975aab1/image.jpg" style="width: 450px"/>

<p>가장 겉 껍질을 통해 계속 의존성이 전달되어야 함을 강조하는 그림이다. </p>
<ul>
<li>UI 등 인터페이스는 자신을 컨트롤할 객체에 의존, 즉 자신의 책임 일부를 전달해주어야 한다.</li>
<li>Controller, Presenter 등은 최근엔 ViewModel 로 많이 해석되는 것 같다.</li>
<li>ViewModel 은 특정 도메인의 작업을 처리하는 UseCase, 그리고 UseCase 가 이용하는 Entity 를 의존성 주입받아 마찬가지로 자신의 책임을 전달한다.</li>
<li>GateWay 역할을 하는 객체는 ViewStore 이다. View 는 ViewStore 를 통해 원하는 작업을 전달함으로써 Store 에 의존한다.</li>
</ul>
<p>클린 아키텍처 관점에서 TCA 는 어떻게 해석할 수 있을까?</p>
<p><em><em>TCA 의 View 와 Store 는 UI, Presenter, Controller 를 뜻한다. Dependency 는 UseCase 를 뜻한다.</em></em></p>
<p>다른 의미로는 Use Case, Entity 에 의한 여러 도메인들은 직접 정의해야 한다는 뜻이다. TCA 는 거기까진 책임지지 않는다.</p>
<h3 id="view-와-store">View 와 Store</h3>
<p>View 는 명실상부 View 이다. 한 가지 책임이라 하면 (사실 iOS 의 핵심이지 않나 싶다) 유저 이벤트를 받는다는 것이다.</p>
<p>그럼 Store 는 어째서 ViewModel 일까? Store 가 State, Action 을 저장하고 있다는 것을 생각하면 ViewModel 의 다음 책임을 똑같이 수행하고 있다고 할 수 있다.</p>
<ul>
<li>뷰의 상태를 정의하고 상태가 바뀌면 뷰 또한 바뀌게(다시 그리게) 된다.</li>
<li>뷰가 전달받은 액션을 통해 자신의 값을 바꾼다.</li>
<li>위의 바꿈은 자신의 책임 일부를 전달받은 UseCase, Entity 의 도움을 받을 수 있다.</li>
<li>UseCase, Entity 는 Dependency 를 이용한다.</li>
</ul>
<p>이렇게 본다면 View 로 사용자에게 보여줄 뷰를 그리고, Store 로 내부 로직을 정의하면 된다는 사실을 쉽게 알 수 있을 것이다.</p>
<h3 id="dependency">Dependency</h3>
<p>Dependency 는 미리 정의된 DependencyKey, DependencyValue 를 이용해 어느 Store 에든 주입할 수 있는 객체이다. (<a href="https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/dependencymanagement/">https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/dependencymanagement/</a>)</p>
<p>UseCase, Entity 는 외부 환경과 소통하기 위해 정의하는 객체이다. 이를 통해 뷰에서 사용할 모델의 Raw Data 를 받아올 수도 있으며, ViewModel 에서 만든 데이터를 외부에 전달해야 할 수도 있다.</p>
<p>즉, mutable 할 필요가 없다. 실제 Dependency 도 Read-Only 이다. 아래는 DependencyKey 소스코드에 포함된 예제 코드이다. 유저를 불러오고 저장하는 클라이언트 객체이며, 수정될 이유가 없는 코드이다.</p>
<pre><code class="language-swift">struct UserClient {
  var fetchUser: (User.ID) async throws -&gt; User
  var saveUser: (User) async throws -&gt; Void
}

extension UserClient: DependencyKey {
  static let liveValue = Self(
    fetchUser: { /* Make request to fetch user */ },
    saveUser: { /* Make request to save user */ }
  )
}

extension DependencyValues {
  var userClient: UserClient {
    get { self[UserClient.self] }
    set { self[UserClient.self] = newValue }
  }
}</code></pre>
<p>자세한 사용법은 나중에 후술하도록 하겠다.</p>
<h2 id="tca-와-test">TCA 와 Test</h2>
<p>사실 이 부분은 개인적인 의견이 너무 많이 (사실 앞에도 그랬다) 포함되어 있다. TCA 는 자체적인 테스트 케이스 클래스를 포함하고 있는데 Unit/Integration-Test 이 모두 가능하다. 그렇게 때문에 TCA 에서 사용하는 테스트 클래스만으로 테스트를 수행해도 괜찮다.</p>
<p>그럼 Unit/Integration-Test 를 얼마나, 어디까지 수행해야 할 것인가? SUT(System under test) 를 어디까지 정의할 것인지가 중요한 것 같다. <strong><em>그래서 필자는 SUT 를 Store 로 정의하고 테스트할 것을 추천한다. 그리고 Integration-Test 는 지양할 것을 추천한다.</em></strong></p>
<p>Store 에는 뷰의 State 가 저장되어 있으며, 뷰가 수행해야 할 Action 도 정의되어 있다. <em>사실 애플리케이션에서 버그가 발생한다면 Store 부터 보게 될 것이다. 그렇기 때문에 Store 가 유일한 SUT 이다.</em></p>
<p>그리고 Integration-Test 를 추천하지 않는 이유는 <em>Integration-Test 가 타겟팅하는 테스트 타겟은 Store 가 아니라고 생각하기 때문이다. Integration-Test 는 Dependency 에서 테스트해야 한다.</em></p>
<p>아마 TCA 개발팀도 같은 생각이었는지 <strong><em>TestStore</em></strong> 객체를 통해 테스트를 수행하는 것을 권장하고 있다. (<a href="https://github.com/pointfreeco/swift-composable-architecture?tab=readme-ov-file#testing">https://github.com/pointfreeco/swift-composable-architecture?tab=readme-ov-file#testing</a>)</p>
<pre><code class="language-swift">@MainActor
func testFeature() async {
  // 기존 Store 초기화 방법
  // Store(initialState: Feature.State(), reducer: { Feature() })

  let store = TestStore(initialState: Feature.State()) {
    Feature()
  }

  // 동기화 된 객체 변경 - Unit-Test 가능.
  // send 를 통해 Action 전달. trailing closure 에는 Action 수행 후 변경될 State 와 같도록 mutable 한 State 를 조작.
  await store.send(.incrementButtonTapped) { $0.count = 1 }
  await store.send(.decrementButtonTapped) { $0.count = 0 }

  // 비동기 Action 수행 - Integration-Test 가능
  // send 를 통해 Action 전달. receive 에서 전달되는 KeyPath 는 State 의 KeyPath 이다.
  // 위와 같이 예상되는 State 의 상태로 직접 변경해주는 것으로 테스트를 수행한다.
  await store.send(.numberFactButtonTapped)
  await store.receive(\.numberFactResponse) { $0.numberFactAlert = false }
}</code></pre>
<p>참고로 여기서 Unit-Test, Integration-Test 는 각각 다음의 차이점을 갖고 정의했다.</p>
<ul>
<li>Unit-Test : 빠르게 수행되는 테스트. 주로 동기화 된 작업을 테스트한다. 단일 Mock 혹은 SUT 객체를 타겟으로 한다.</li>
<li>Integration-Test : 느리게 수행되는 테스트. 주로 비동기화 된 작업을 테스트한다. 여러 Mock 혹은 SUT 객체를 타겟으로 한다.</li>
</ul>
<h2 id="example">Example</h2>
<p>아래는 간단한 계산기 앱을 구축해 볼 것이다. SwiftUI + TCA 를 사용할 것이며, 소스코드는 <a href="https://github.com/SangHwi-Back/TCACalculator">Repository_URL</a> 에서 확인 가능하다.</p>
<h3 id="계산기의-기능">계산기의 기능</h3>
<ul>
<li>사칙연산만 수행한다.</li>
<li>Input Field 2 개에 연산해야 할 수를 입력한다.</li>
<li>수행할 연산자는 Picker 를 이용해서 정의한다.</li>
<li>결과 값을 Fetch 한 뒤 결과를 받아온다<ul>
<li>서버가 구축되어 있지는 않으므로 간단한 Timer 를 이용해서 비동기 작업을 수행한다.</li>
</ul>
</li>
</ul>
<h3 id="앱의-구조">앱의 구조</h3>
<img src="https://velog.velcdn.com/images/sanghwi_back/post/9defa027-ee7e-46ab-b7e1-4439334f9990/image.png" style="width: 550px"/>

<ul>
<li>CalculatorView = View. 사용자와 상호작용한다.<ul>
<li>CommonTextField = 서브 뷰로 재사용될 TextField.</li>
</ul>
</li>
<li>ViewStore = WithViewStore 객체에 의해 참조할 수 있는 ViewStore 객체. Store 와 View 간 상호작용을 담당.</li>
<li>CalculatorFeature = Store. 뷰의 상태 및 역할을 모두 정의한다. State, Action, Reducer 소유. Point-Free 예제 코드에 따라 Feature 로 네이밍 한다.<ul>
<li>CommonTextFieldFeature = 서브 뷰 TextField 의 Store</li>
</ul>
</li>
<li>ResultUseCase = Dependency. Store 에 주입되는 외부 환경과 상호작용 하는 객체.</li>
</ul>
<p>개인적으로 아키텍처를 통해 정확히 책임을 분리하는 앱을 구현하고 싶었다.</p>
<h3 id="ui-view-계층">UI, View 계층</h3>
<pre><code class="language-swift">struct CalculatorView: View {
    typealias Feat = CalculatorFeature
    let store: StoreOf&lt;Feat&gt;

    var body: some View {
        // 1
        WithViewStore(store, observe: {$0}) { vs in
            VStack {
                HStack {
                    // 2
                    ForEachStore(
                        store.scope(state: \.textFields, action: Feat.Action.fromTextField)
                    ) { textFieldStore in
                        CommonTextField(store: textFieldStore)
                    }
                    // 3
                    Picker(
                        Feat.Operator.addition.rawValue,
                        selection: vs.binding(get: \.operator, send: { .setOperator($0) })
                    ) {
                        ForEach(Feat.Operator.allCases, id: \.self) { op in
                            Text(op.rawValue)
                                .lineLimit(1)
                        }
                    }
                    .foregroundStyle(.secondary)
                    .pickerStyle(.wheel)
                    .buttonStyle(BorderedButtonStyle())
                    .minimumScaleFactor(0.2)
                }
                .padding(.horizontal, 30)
                .padding(.bottom, 50)
                // 4
                Button(&quot;Calculate!&quot;, systemImage: &quot;rectangle.portrait.and.arrow.forward.fill&quot;) {
                    vs.send(.calculateButtonClicked)
                }
                .foregroundStyle(.primary)
                .buttonStyle(BorderedButtonStyle())
            }
            // 5
            .onAppear(perform: { vs.send(.refresh) })
            .navigationDestination(isPresented: .constant(vs.result != nil)) {
                ResultMessageView(result: vs.result ?? 0)
            }
        }
        .navigationTitle(&quot;Calculator&quot;)
    }
}

struct CommonTextField: View {
    typealias Feat = CommonTextFieldFeature
    let store: StoreOf&lt;Feat&gt;
    var body: some View {
        WithViewStore(store, observe: {$0}) { vs in
            TextField(
                text: vs.binding(get: \.text, send: { .setString($0) }),
                prompt: Text(vs.prompt ?? &quot;&quot;)
            ) {
                EmptyView()
            }
            .keyboardType(.numberPad)
            .multilineTextAlignment(.center)
            .border(.secondary)
        }
    }
}</code></pre>
<ol>
<li>Store 는 위에 선언되어 있다. WithViewStore 를 이용해 store 를 관찰하고 Action 을 보낼 수 있는 ViewStore 객체를 참조할 수 있다.</li>
<li>관찰 가능한 Store 를 가져오는 ForEachStore 를 사용하여 2 개의 TextField 를 생성하였다.</li>
<li>Picker 에 넣을 raw data 를 ViewStore 에서 가져와 바인딩하였다.</li>
<li>Button 의 action 클로저로 ViewStore 에 Action 을 보낸다.</li>
<li>View LifeCycle 에서 처음에 refresh 를 시키기 위한 함수를 호출하고 있다.</li>
</ol>
<h3 id="controller-viewmodel-store-계층">Controller, ViewModel, Store 계층</h3>
<pre><code class="language-swift">struct CalculatorFeature: Reducer {
    typealias UseCase = ResultUseCase
    typealias Operator = ResultUseCase.Operator
    @Dependency(\.resultUseCase) var useCase: UseCase

    struct State: Equatable {
        var textFields = IdentifiedArrayOf&lt;CommonTextFieldFeature.State&gt;(uniqueElements: [.init(), .init()])
        var `operator`: Operator = .addition
        var result: Int?

        var localError: UseCase.UseCaseError?
    }

    enum Action {
        case refresh
        case setOperator(Operator)
        case setLocalError(UseCase.UseCaseError)
        case setResult(Int)
        case calculateButtonClicked

        case fromTextField(CommonTextFieldFeature.State.ID, CommonTextFieldFeature.Action)
    }

    var body: some ReducerOf&lt;Self&gt; {
        Reduce { state, action in
            switch action {
            case .refresh:
                state.result = nil
                state.localError = nil
                return .none
            case .setOperator(let `operator`):
                state.operator = `operator`
                return .none
            case .setLocalError(let error):
                state.localError = error
                return .none
            case .setResult(let result):
                state.result = result
                return .none
            case .calculateButtonClicked:
                return .run { [lh = state.textFields[0].text, rh = state.textFields[1].text, op = state.operator] send in
                    let fetch = await useCase.getResult(lh, rh, op: op)

                    switch fetch {
                    case .success(let result):
                        await send(.setResult(result))
                    case .failure(let error):
                        await send(.setLocalError(error))
                    }
                } catch: { error, send in
                    if let error = error as? UseCase.UseCaseError {
                        await send(.setLocalError(error))
                    }
                }
            default:
                return .none
            }
        }
        .forEach(\.textFields, action: /Action.fromTextField) {
            CommonTextFieldFeature()
        }
    }
}</code></pre>
<ol>
<li>Store 에 의존성을 주입한 모습이다. @Dependency 를 이용해 DependencyValues 에 정의된 객체를 쉽게 가져올 수 있다.</li>
<li>View 의 상태를 정의하는 State 이고 Equatable 을 구현하였다. Equatable 을 직접 구현할 경우 원하는 값이 바뀌었을 때만 뷰가 그려지도록 할 수도 있다. <strong><em>Equatable 을 구현하지 않으면 뷰가 다시 그려지지 않으니 주의해야 한다.</em></strong></li>
<li>View 의 상태를 변경하거나 특정 로직을 처리하는 Action 을 미리 정의하는 모습이다. fromTextField 는 후술하도록 하겠다.</li>
<li>Reduce 에서는 refresh 로 뷰가 처음 그려질 경우 처리해야 할 작업을 만들어 놓는 것을 추천한다. 아래는 사용되는 State 중 local scope 에 있는 것들을 변경하는 모습이다. Effect 를 이용할 필요가 없으므로 Effect.none 을 반환한다.</li>
<li>비동기 작업을 위해 Effect.run 을 사용하고 있다.</li>
<li>하위 Store 를 관찰하기 위해 forEach 를 사용하고 있다. IdentifiedArray 를 사용할 경우 Reduce 자체에 forEach 를 사용하고 그렇지 않다면 Store 객체를 body 안에 선언해야 한다.</li>
<li>TextField 에 맨 앞/뒤 0이 존재할 경우 빼주는 역할을 하고 있다.</li>
</ol>
<h3 id="usecase-dependency-계층">UseCase, Dependency 계층</h3>
<pre><code class="language-swift">class ResultUseCase {
    private let isTestable: Bool
    private var cache: (lh: Int?, rh: Int?)

    init(isTestable: Bool = false) {
        self.isTestable = isTestable
    }

    enum Operator: String, CaseIterable {
        case addition // +
        case subtraction // -
        case multiplication // *
        case division // ÷
    }

    enum UseCaseError: Error {
        case divideWithZero
        case twoNumberZero
        case sameInput
        case undefinedNumbers
    }

    func getResult(_ lh: Int, _ rh: Int, op: Operator) async -&gt; Result&lt;Int, UseCaseError&gt; {
        await withCheckedContinuation { continuation in
            self.calculate(lh: lh, rh: rh, op: op) {
                continuation.resume(returning: $0)
            }
        }
    }

    func getResult(_ lh: String, _ rh: String, op: Operator) async -&gt; Result&lt;Int, UseCaseError&gt; {
        await withCheckedContinuation { continuation in
            guard let lh = Int(lh), let rh = Int(rh) else {
                continuation.resume(returning: .failure(UseCaseError.undefinedNumbers))
                return
            }

            self.calculate(lh: lh, rh: rh, op: op) {
                continuation.resume(returning: $0)
            }
        }
    }

    private func calculate(lh: Int, rh: Int, op: Operator, completionHandler: @escaping (Result&lt;Int, UseCaseError&gt;) -&gt; Void) {
        if cache.lh == lh &amp;&amp; cache.rh == rh {
            completionHandler(Result.failure(UseCaseError.sameInput))
            return
        }
        if lh == 0, rh == 0 {
            completionHandler(Result.failure(UseCaseError.twoNumberZero))
            return
        }

        let interval = DispatchTimeInterval.seconds(isTestable ? 0 : Int.random(in: 0...3))

        DispatchQueue.global().asyncAfter(deadline: .now() + interval) {
            guard (op == .division &amp;&amp; rh == 0) == false else {
                completionHandler(Result.failure(UseCaseError.divideWithZero))
                return
            }

            self.cache = (lh, rh)
            completionHandler(Result.success(lh.calculate(op, operand: rh)))
        }
    }
}


extension ResultUseCase: DependencyKey {
    static var liveValue: ResultUseCase = .init()
    static var testValue: ResultUseCase = .init(isTestable: true)
}

extension DependencyValues {
    var resultUseCase: ResultUseCase {
        get { self[ResultUseCase.self] }
        set { self[ResultUseCase.self] = newValue }
    }
}

private extension Int {
    func calculate(
        _ `operator`: ResultUseCase.Operator,
        operand: Int
    ) -&gt; Int {
        switch `operator` {
        case .addition:
            return self + operand
        case .subtraction:
            return self - operand
        case .multiplication:
            return self * operand
        case .division:
            return self / operand
        }
    }
}</code></pre>
<p>위의 로직은 ViewModel 역할을 하는 Store 에서 계산과 관련된 역할을 모두 가져왔다고 보면 된다. TCA 와 관련하여 주목할 사항은 위의 1,2 라고 생각한다.</p>
<ol>
<li>Dependency 를 주입할 때 Key 로 어떤 값을 가져올지 정한다.</li>
<li>Dependency 로 주입할 값을 정의한다. 일종의 Dependency Container 로 보면 된다.</li>
</ol>
<h3 id="test">Test</h3>
<pre><code class="language-swift">final class TCACalculatorTests: XCTestCase {
    @MainActor
    func testExample() async throws {
        let store = TestStore(initialState: CalculatorFeature.State()) {
            CalculatorFeature()
        }

        await store.send(.refresh)

        await store.send(.setOperator(.multiplication)) { state in
            state.operator = .multiplication
        }

        await store.send(.setResult(5)) { state in
            state.result = 5
        }
    }

    @MainActor
    func testDivisionError() async throws {
        let store = TestStore(initialState: CalculatorFeature.State(textFields: .init(uniqueElements: [
            .init(),
            .init(text: &quot;0&quot;)
        ]))) {
            CalculatorFeature()
        }

        await store.send(.calculateButtonClicked)
        await store.receive(/CalculatorFeature.Action.setLocalError) { state in
            state.localError = .undefinedNumbers
        }
    }

    @MainActor
    func testFailed() async throws {
        let store = TestStore(initialState: CalculatorFeature.State(textFields: .init(uniqueElements: [
            .init(text: &quot;2&quot;),
            .init(text: &quot;0&quot;)
        ]))) {
            CalculatorFeature()
        }

        await store.send(.setOperator(.division)) { state in
            state.operator = .division
        }

        await store.send(.calculateButtonClicked).finish()
        await store.receive(/CalculatorFeature.Action.setLocalError) { state in
            state.localError = .divideWithZero
        }
    }
}</code></pre>
<p>Unit Test 를 진행중이다. 원래는 비동기 테스트를 진행하지 않도록 하는 것이 원칙이나, 할 수만 있다면 하는 방법을 고민해봐야 할 것이다. 하나의 테스트로 여러 개 테스트할 수 있다면 좋은 거 아닐까?</p>
<p>하지만 UseCase 테스트를 빼먹으면 안된다고 생각한다. 내부에 정확한 테스트 자체는 Store 만 진행한다면 테스트 코드에 드는 노력도 너무 낭비될 것이다.</p>
<pre><code class="language-swift">extension ResultUseCase: DependencyKey {
    static var liveValue: ResultUseCase = .init()
    static var testValue: ResultUseCase = .init(isTestable: true)
}</code></pre>
<p>Dependency 로 추가될 값 중 test 에서 쓰일 의존성은 <strong><em>testValue</em></strong> 이다. 여기서 사용된 isTestable 프로퍼티는 아래와 같이 실행 타이밍을 지워준다.</p>
<pre><code class="language-swift">let interval = DispatchTimeInterval.seconds(isTestable ? 0 : Int.random(in: 0...3))

DispatchQueue.global().asyncAfter(deadline: .now() + interval) {
    // [execute logic]
}</code></pre>
<p>Integration-Test 는 UseCase 만 따로 진행해주면 대부분의 코드를 테스트할 수 있을 것이다.</p>
<pre><code class="language-swift">func useCaseTests() async throws {
    typealias E = ResultUseCase.UseCaseError
    let useCase = ResultUseCase()

    if case let .success(result) = await useCase.getResult(2, 0, op: .addition) {
        XCTAssert(result &gt; 0)
    }

    if case let .failure(error) = await useCase.getResult(&quot;2&quot;, &quot;0&quot;, op: .multiplication) {
        XCTAssertEqual(error, E.sameInput)
    }

    if case let .failure(error) = await useCase.getResult(0, 0, op: .addition) {
        XCTAssertEqual(error, E.twoNumberZero)
    }

    if case let .failure(error) = await useCase.getResult(&quot;NotNum&quot;, &quot;0&quot;, op: .addition) {
        XCTAssertEqual(error, E.undefinedNumbers)
    }

    if case let .success(result) = await useCase.getResult(&quot;2&quot;, &quot;4&quot;, op: .multiplication) {
        XCTAssert(result == 8)
    }

    if case let .failure(error) = await useCase.getResult(&quot;2&quot;, &quot;0&quot;, op: .division) {
        XCTAssertEqual(error, E.divideWithZero)
    }
}</code></pre>
<h2 id="reference">Reference</h2>
<ul>
<li><a href="https://github.com/pointfreeco/swift-composable-architecture">[GitHub] swift-composable-architecture</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[2023년 08월 2주차 회고]]></title>
            <link>https://velog.io/@sanghwi_back/2023%EB%85%84-08%EC%9B%94-2%EC%A3%BC%EC%B0%A8-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@sanghwi_back/2023%EB%85%84-08%EC%9B%94-2%EC%A3%BC%EC%B0%A8-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Sun, 13 Aug 2023 13:24:04 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/6b56acb0-9c56-418a-a2af-8f968875e7e8/image.png" alt=""></p>
<p>이번주는 간단히 회고를 적어보고자 한다. 어떤 일을 했고 어떤 일을 해야할지만 적어도 될 것 같다.</p>
<h2 id="프로젝트-진행-상황">프로젝트 진행 상황</h2>
<p>개인적으로는 잘 진행되고 있는건지 아닌건지 모르겠다.</p>
<p>프리랜서로서 잘 진행된 프로젝트를 가지고 있다는 건 좋다. 왠지 프리랜서가 일이 많다는 것은 개인적으로 PR 도 많이 해야겠지만, 남들이 많이 찾아줘야 많이 하는 것 같다.</p>
<p>프로젝트를 찾아다니지 말아야 한다는 것이 아니다. 얼굴이 명함이라는 말을 정말 절실히 깨닫고 있는 중인 것이다.</p>
<p>나는 몇년동안 밤을 새본 적이 없는 것 같다. 음향업계에 계신 분 유튜브에서 사실 밤 새지 못하면 이 업계에서 프리랜서는 못한다고 보면 된다고 하시던데 날 돌아보았다기 보다는 개발자로서는 어떤 맷집이 필요할까 생각을 해보았다.</p>
<p>밤샘하는 개발자는 나쁜가? 이번 프로젝트는 시간을 많이 쓰면 좋은 결과가 나올 것 같다. 그럼 밤샘해서 좋은 결과를 가져올 수 있으니 이번에는 시간을 좀 많이 써볼까?</p>
<p>그래서 다음주는 시간을 많이 써볼 생각이다. 이번주 내로 모든 UX 관련 작업을 끝낼 것이다.</p>
<p>그래도 공부와 운동은 쉬면 안된다. 조금 하더라도 멈추는 건 안된다. 멈추니 안하게 되더라.</p>
<h2 id="지식-공유">지식 공유</h2>
<p>이번주 내가 잘 쓰고 있는 Coordinator Pattern 에 대해 한번 정리하는 시간을 가졌다. 블로그에도 기재를 했다.</p>
<p>원래는 이번 프로젝트를 성공적으로 마무리 하고, 맥북도 바꾸고, 회고도 멋들어지게 써서 회사를 가든 다른 프로젝트를 구하든 이 회사의 다른 프로젝트를 추가로 진행하든 여러가지를 해볼 생각이었다.</p>
<p>그래도 내가 원하는대로 살고 있다. 원하는 일 하고, 원하는 공부 하고, 지식공유도 하고 있다. 물론 사람들이 많이 보지는 않지만 그래도 공유는 하니 나중에라도 이걸 토대로 계속 발전할 수 있을 것이다.</p>
<p>생각보다 잘 살고 있다고 위로하면서 오늘은 그냥 일찍 자야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SwiftUI View transition strategy]]></title>
            <link>https://velog.io/@sanghwi_back/SwiftUI-View-transition-strategy</link>
            <guid>https://velog.io/@sanghwi_back/SwiftUI-View-transition-strategy</guid>
            <pubDate>Sun, 13 Aug 2023 07:18:01 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>(주의) 이 글은 최소 배포 버전 <strong>iOS 14</strong> 를 기준으로 구성하였다. 만약 iOS 16 이상으로 잡았다면 <a href="https://developer.apple.com/documentation/swiftui/navigationstack">NavigationStack</a> 이라는 좋은 녀석이 있다.</p>
</blockquote>
<p>요즘 한동안 팔자에는 없겠다 싶었던 SwiftUI 로 프로젝트를 진행하게 되었다. (사람 일이라는게 정말 한치 앞도 모른다더니 정말이었다)</p>
<p>SwiftUI 는 선언형 문법을 이용해 뷰를 만든다는 장점 아닌 단점이 있다. 그리고 뷰 클래스들이 모두 뷰 구조체로 바뀌었으며, 뷰 구조체 내부의 상태값 프로퍼티 (Swift Combine 을 응용한 어노테이션 사용) 들을 계속 Observe 하며 자신의 상태/레이아웃/라이프 사이클을 바꾼다.</p>
<p>여기서 앞으로 계속 언급할 얘기가 있다.</p>
<blockquote>
<p>SwiftUI 는 모든 뷰 상태값을 뷰 내부에서 정의해야 한다</p>
</blockquote>
<p>여기까지 언급하면 iOS 개발을 많이 해본 분들에겐 아래의 궁금증이 떠오를 것이다.</p>
<h2 id="어떻게-뷰-네비게이션-중간에-루트-뷰로-돌아가지">어떻게 뷰 네비게이션 중간에 루트 뷰로 돌아가지?</h2>
<p>아래의 그림을 보자. 예시이므로 약간 현실의 프로젝트와는 다르게 flow 를 꼬아놓은 면도 있다.</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/c7d8a6b2-c9d4-4e4f-9145-1e991d3b4eb8/image.png" alt=""></p>
<ul>
<li>Start = 시작점이다. 네비게이션 뷰 안에 맨 처음 보여줄 뷰가 정의되어 있다.</li>
<li>A = 루트 뷰이다. 비즈니스 로직 혹은 특정 도메인이 시작되는 화면이다.</li>
<li>B, C, H, A = 우리가 구현하는 앱에서 추가 작업을 진행하지 않아도 될 경우 흘러갈 뷰 플로우이다.</li>
<li>F, G, A = 기본 플로우에 예외가 발생할 경우 거쳐야 하는 1번 플로우이다.</li>
<li>D, E, A = 기본 플로우에 예외가 발생할 경우 거쳐야 하는 2번 플로우이다.</li>
</ul>
<hr>
<p>잠시 구현하는 방법을 생각해보는 것도 좋을 것 같다. 여기서 다시 한번 상기할 필요가 있는데, <strong><em>SwiftUI 는 모든 뷰 상태값을 뷰 내부에서 정의해야 한다</em></strong>. 어떤 뷰의 화면 전환은 반드시 그 뷰 내부의 로직에 의해 실행되어야 한다.</p>
<p>만약 내가 G 에 있고 A 로 가야한다고 해보자.</p>
<ul>
<li>A, F, G 까지는 <code>init(destination:Destination, isActive:Binding&lt;Bool&gt;, @ViewBuilder label:() -&gt; Label)</code> 를 이용해서 이동하였다.</li>
<li>G 에서 A 를 이동해야 하므로 G 에서 dismiss 한다. dismiss 하면서 F 에서 전달받은 @Binding 값을 변경해준다. 이 @Binding 값은 F 의 NavigationLink 와 연결되어 있다.</li>
<li>F 뷰를 읽어들이다 @Binding 에 연결된 값이 변경된 것을 확인하였다. 사실 이 값은 A 뷰에서 전달받은 것이다. A 뷰의 NavigationLink 에 연결된 @State 인 것이다.</li>
<li>A 뷰에 잘 도착한다.</li>
</ul>
<p>완전 나쁘지는 않다. 실제로 이렇게 사용한다고 주장하는 여러 게시글을 보았고 이번에 만든 예제 프로젝트에서도 이상 없이 동작하고 있었다.</p>
<p>그런데 개인적으로는 A 의 상태값을 어떻게 네이밍 해야 하는지도 모르겠고, 유지보수 비용이 무지하게 커질 것 같다는 생각이 든다. 특정 뷰에서 그것도 뷰 전환에만 관여하는 상태값을 선언해놓고 인수인계 하는 상황을 상상해보면 나는 좀 아찔하다.</p>
<hr>
<p>만약 iOS 16 부터 사용 가능한 NavigationStack 을 사용한다면 이런 문제는 고민할 필요 없다. 현재까지 계속 NavigationView 에 쌓인 뷰들을 NavigationPath 라는 구조체로 추적해볼 수 있다.</p>
<p>하지만 이는 쉽지 않은 부분이다. SwiftUI 는 사실 아직까지도 현업에서 사용하기에는 논의가 필요한 기술이라는게 나의 생각이다.</p>
<ol>
<li>낮은 iOS 버전을 사용하는 유저들은 아직까지도 의미있는 숫자로 집계된다.</li>
<li>SwiftUI 앱은 iOS 13 버전 이하 버전으로는 빌드할 수 없다.</li>
<li>iOS 13 버전의 SwiftUI 는 그 흔한 List 뷰도 없어 굉장히 사용하기 불편하다.</li>
</ol>
<p>그래서 방법을 고심하던 중 Coordinator Pattern 을 찾게 되었다.</p>
<h2 id="문제-정의">문제 정의</h2>
<p>우리는 2 개의 문제를 풀어볼 것이다. 그리고 현재 필자가 사용하는 답안도 같이 표시한다.</p>
<ol>
<li>뷰 네비게이션을 중간에 예외 로직을 실행하고 예외 로직을 시작한 뷰로 돌아가야 한다.<ul>
<li><strong>Coordinator Pattern 이용</strong></li>
</ul>
</li>
<li>모든 뷰 네비게이션을 취소하고 초기화하는 방법이 필요하다.<ul>
<li><strong>id 뷰 수정자와 EnvironmentObject 이용</strong></li>
</ul>
</li>
</ol>
<h2 id="1번-문제-해결-coordinator-pattern">(1번 문제 해결) Coordinator Pattern</h2>
<p>Coordinator Pattern(코디네이터 패턴) 은 화면전환 관련 로직을 별도의 모델 객체로 분리 및 관리하는 디자인 패턴이다.</p>
<p>처음 이 패턴에 대한 얘기를 찾았을 때는 머릿속에서 이런 생각이 들었다.</p>
<blockquote>
<p>그럼 이걸 하나 만들고 모든 뷰들이 이 객체 하나만 보게 하면 되겠다.</p>
</blockquote>
<p>하지만 다시 설명한다. <strong><em>SwiftUI 는 모든 뷰 상태값을 뷰 내부에서 정의해야 한다</em></strong>. 만약 뷰 내부에서 관찰하는 값이 아닌 주입된 객체라면 원치 않는 결과가 나오기가 굉장히 쉽다.</p>
<p>여기서 Coordinator 객체들을 뷰마다 배치하고 NavigationLink 의 isActive 바인딩 값을 이용한다. 전략은 생각보다 간단하다.</p>
<ul>
<li>초기에 NavigationLink 의 바인딩 Bool 은 false 로 세팅해 놓는다.</li>
<li>뷰를 이동하고 싶다면 바인딩 값만 true 로 바꿔서 네비게이션을 실행시킨다<ul>
<li>false 였던 바인딩 값이 true 로 변하면 뷰가 다시 그려진다.</li>
<li>NavigationLink 가 activate 된 상태로 그려지기 때문에 NavigationLink 의 destination 으로 뷰 이동이 진행된다.</li>
</ul>
</li>
<li>특정 뷰로 돌아가고 싶다면 NotificationCenter 를 이용해 강제로 바인딩 값을 다시 true 로 변경한다.</li>
</ul>
<p>이제 직접 구현으로 들어가보도록 하자. 자세한 구현은 아래 깃허브 주소를 남겨놓도록 하겠다. Coordinator 패턴을 쓴 경우, 일반적(필자의 기준에서 일반적)인 경우로 나누었다.</p>
<h3 id="구현">구현</h3>
<p>우선 Coordinator 객체부터 정의해야 한다. Coordinator 의 명세는 다음과 같다.</p>
<ul>
<li>어떤 뷰로 이동할지 저장해 놓아야 한다.</li>
<li>NavigationLink 를 따로 정의해야 한다.<ul>
<li>그렇지 않으면 Coordinator 가 아닌 NavigationLink 를 사용하게 되어 Coordinator 패턴으로 정의되는 뷰 전환의 의미가 흐려진다.</li>
</ul>
</li>
<li>현재 자신이 포함된 뷰가 root 뷰인지 아닌지 알아야 한다.</li>
</ul>
<p>결과적으로 아래와 같은 Coordinator 를 만들 수 있다.</p>
<pre><code class="language-swift">import SwiftUI
import Combine

final class Coordinator: ObservableObject {
  @Published private var trigger = false
  @Published private var rootTrigger = false

  private let isRoot: Bool
  private var destination: CoordinatorDestination
  private var subscriptions = Set&lt;AnyCancellable&gt;()

  init(isRoot: Bool = false, destination: CoordinatorDestination) {
    self.isRoot = isRoot // 1
    self.destination = destination

    if isRoot { // 1
      NotificationCenter.default
        .publisher(for: .popToRoot)
        .sink { _ in
          self.rootTrigger = false
        }
        .store(in: &amp;subscriptions)
    }
  }

  @ViewBuilder
  func navigationContext() -&gt; some View { // 2
    NavigationLink(isActive: Binding(
      get: getTrigger,
      set: setTrigger(newValue:))
    ) {
      destination.view
    } label: {
      EmptyView()
    }
  }

  private func getTrigger() -&gt; Bool {
    isRoot ? rootTrigger : trigger
  }

  private func setTrigger(newValue: Bool) {
    if isRoot {
      rootTrigger = newValue
    } else {
      trigger = newValue
    }
  }

  func push(destination: CoordinatorDestination) { // 3
    self.destination = destination
    setTrigger(newValue: true)
  }

  func popToRoot() {
    NotificationCenter.default
      .post(name: .popToRoot, object: destination)
  }
}

enum CoordinatorDestination { // 4
  case aView, bView, cView, dView, eView, fView, gView, hView

  @ViewBuilder
  var view: some View {
    switch self {
    case .aView:
      A()
    case .bView:
      B()
    case .cView:
      C()
    case .dView:
      D()
    case .eView:
      E()
    case .fView:
      F()
    case .gView:
      G()
    case .hView:
      H()
    }
  }
}

extension Notification.Name {
  static var popToRoot = Notification.Name(&quot;popToRoot&quot;)
}</code></pre>
<ol>
<li>isRoot 는 현재 자신이 속한 뷰가 root 인지 알아낸다.<ul>
<li>만약 isRoot 가 true 일 경우 특정 Notification 에 반응하는 Publisher 를 등록해 놓게 된다.</li>
</ul>
</li>
<li>@ViewBuilder 를 이용해 DSL 로 뷰를 정의한다. 하나의 NavigationLink 이지만 destination 이 여러개의 뷰이므로 @ViewBuilder 를 사용한다.</li>
<li>push 는 새로운 destination 을 정의하고 binding 값을 true 로 변경한다.</li>
<li>Coordinator 가 다룰 view 들을 enum 으로 미리 정의해 놓는다. 여기서도 @ViewBuilder 를 이용해 여러 개의 뷰를 정의해 놓는다.</li>
</ol>
<p>참고로 destination 을 꼭 정의해야 하는 이유는 push 할 때 써야하기 때문이다. 초기값은 그렇게 중요하지 않다. 가독성을 위해 Coordinator 를 소유한 뷰의 이름을 그대로 따르곤 한다.</p>
<hr>
<p>A 뷰가 root 이므로 A 뷰 부터는 Coordinator 를 추가한다.</p>
<pre><code class="language-swift">struct A: View {
  @StateObject var coordinator = Coordinator(isRoot: true, destination: .aView)

  var body: some View {
    VStack(spacing: 20) {
      coordinator.navigationContext()
      /**
      NavigationLink(isActive: Binding(
        get: rootTrigger,
        set: setTrigger(newValue:))
      ) {
        destination.view
      } label: {
        EmptyView()
      }
      */

      Button {
        coordinator.push(destination: .fView)
      } label: {
        Text(&quot;Go to F&quot;)
      }

      Button {
        coordinator.push(destination: .bView)
      } label: {
        Text(&quot;Go to B&quot;)
      }

      Button {
        coordinator.push(destination: .dView)
      } label: {
        Text(&quot;Go to D&quot;)
      }
      .padding(.bottom, 20)

      Image(&quot;ViewStructure&quot;)
        .resizable()
        .aspectRatio(contentMode: .fit)
    }
  }
}</code></pre>
<p>A 에서는 어떤 일이 일어날까? <code>coordinator.navigatonContext()</code> 밑에 작은 주석을 넣어보았다.</p>
<p>개인적으론 내장되어 있다는 표현을 쓰고 싶다. @StateObject 로 선언된 ObservableObject 타입의 Coordinator 에서 @Published 인 rootTrigger 를 바인딩으로 관찰하는 NavigationLink 이다.</p>
<p>만약 A 의 rootTrigger 가 true 로 바뀌는, 즉 push 가 일어난 후 다른 뷰에서 A 로 돌아가고 싶다면 rootTrigger 만 false 로 바꿔주면 된다.</p>
<hr>
<p>아래는 보통 뷰들의 구조를 표현해 보았다. F 뷰를 예로 들어보자.</p>
<pre><code class="language-swift">struct F: View {
  @StateObject var coordinator = Coordinator(destination: .fView)
  var body: some View {
    VStack(spacing: 20) {
      Button {
        coordinator.push(destination: .gView)
      } label: {
        Text(&quot;Go to G&quot;)
      }

      coordinator.navigationContext()
    }
  }
}</code></pre>
<p>F 에도 Coordinator 를 새로 정의하였다. DI 등을 이용하는 것이 아니다. <strong><em>SwiftUI 는 모든 뷰 상태값을 뷰 내부에서 정의해야 한다</em></strong>. 해당 뷰에서 사용할 뷰 전환에 사용될 객체는 내부에서 선언되어야 하는 것이다.</p>
<hr>
<p>이제 popToRoot 를 해보자. A 뷰로 돌아가는 것이다. 즉 A -&gt; F -&gt; G -&gt; A 이다. 사실은 A -&gt; F -&gt; G 가 다시 A 로 바뀌는 것이지만 말이다.</p>
<pre><code class="language-swift">struct G: View {
  @StateObject var coordinator = Coordinator(destination: .gView)
  var body: some View {
    VStack(spacing: 20) {
      Button {
        coordinator.popToRoot()
        /**
        func popToRoot() {
          NotificationCenter.default
            .post(name: .popToRoot, object: destination)
        }
        */
      } label: {
        Text(&quot;Go to A(Root)&quot;)
      }

      coordinator.navigationContext()
    }
  }
}</code></pre>
<p>popToRoot 를 실행하는 Button 을 생성하며 아래에 popToRoot 소스코드를 주석으로 넣어보았다. 단순히 Notificaton 을 post 할 뿐이다.</p>
<hr>
<p>Coordinator 에서 해당 Notification 을 어떻게 핸들링 했는지 기억해 볼 시간이 왔다.</p>
<pre><code class="language-swift">NotificationCenter.default
  .publisher(for: .popToRoot)
  .sink { _ in
    self.rootTrigger = false
  }
  .store(in: &amp;subscriptions)</code></pre>
<p>어떤 뷰든지 간에 rootTrigger 에 영향을 받는 뷰라면 그 뷰는 다시 그려질 것이다. rootTrigger 는 Published 로 선언되어 있기 때문이다.</p>
<p>그런 뷰가 기억 나는가? 바로 A 다. 이로 인해 A 에 내장된 NavigationLink 의 뷰 전환은 취소된다.</p>
<h2 id="2번-문제-해결-navigationview-시작-뷰-id-로-재정의">(2번 문제 해결) NavigationView 시작 뷰 id 로 재정의</h2>
<p>SwiftUI 의 View 타입에는 다음의 뷰 수정자가 존재한다. 해당 수정자의 이름과 설명을 공식문서에서 가져와 보았다.</p>
<blockquote>
<p>.id&lt;ID&gt;(_ id: ID) -&gt; some View where ID: Hashable</p>
<ul>
<li>Binds a view’s identity to the given proxy value.</li>
</ul>
</blockquote>
<p>즉 해당 값이 바뀌게 되면 SwiftUI 는 다른 뷰로 인식하는 것이다. 우리는 이 특성을 응용할 것이다.</p>
<pre><code class="language-swift">class SessionManager: ObservableObject {
  @Published private(set) var coordinatorID = UUID() // 1
  @Published private(set) var normalID = UUID() // 2

  func popToCoordinatorRootView() {
    self.coordinatorID = .init()
  }

  func popToNormalRootView() {
    self.normalID = .init()
  }
}</code></pre>
<ol>
<li>coordinatorID 는 coordinator 패턴을 사용하는 뷰들의 시작 뷰 ID 로 사용된다.</li>
<li>normalID 는 coordinator 패턴을 사용하지 않는 뷰들의 시작 뷰 ID 로 사용된다.</li>
<li>아래는 ID 프로퍼티들을 초기화하는 인터페이스이다.</li>
</ol>
<p>여기서부터는 해당 기능을 개발하는 개발자의 결정에 따른다. 어느 뷰에 <code>.id()</code> 뷰 수정자를 사용할 것인가? 필자는 이번 예제 프로젝트를 생성하며 시작점 뷰를 따로 정의하고 해당 뷰에 id 를 부여하였다.</p>
<pre><code class="language-swift">struct StartView: View {
  @EnvironmentObject var sessionManager: SessionManager
  var body: some View {
    VStack(spacing: 50) {
      Text(&quot;Start View&quot;)
        .font(.largeTitle)
      Text(&quot;Coordinator&quot;)
        .font(.subheadline)

      NavigationLink {
        A()
      } label: {
        Text(&quot;Start&quot;)
          .font(.title)
          .foregroundColor(.red)
      }
    }
    .id(sessionManager.coordinatorID)
  }
}</code></pre>
<p>그리고 특정 뷰에서 다시 시작점 뷰로 이동하고 싶다면 아래와 같이 하면 된다.</p>
<pre><code class="language-swift">struct H: View {
  @EnvironmentObject var sessionManager: SessionManager
  var body: some View {
    VStack {
      Button {
        sessionManager.popToCoordinatorRootView()
      } label: {
        Text(&quot;Go to Start&quot;)
      }
    }
    .commonNavigationBar(&quot;I&#39;m H&quot;)
  }
}</code></pre>
<hr>
<p>지금까지 2 개의 문제를 해결하며 우리는 아래의 flow 를 모두 구현할 수 있게 된 것이다. 물론 이는 Coordinator 패턴 없이도, id() 뷰 수정자 없이도 구현 가능하다. 하지만 복잡도, 유지보수성 측면에서 유리한 면이 있다고 생각 된다.</p>
<p><img src="https://velog.velcdn.com/images/sanghwi_back/post/c829d60b-6136-464a-b8eb-f39a9716060c/image.png" alt=""></p>
<h3 id="단점-주의할-점">단점 (주의할 점)</h3>
<ul>
<li>나는 이 패턴이 결국 눈속임이라는 생각이 든다.</li>
</ul>
<p>내가 생각한 가장 좋은 방법은 현재의 뷰가 계속 바꿔치기 되는 것이었다. 하지만, 현재의 구현방법은 사실 뷰를 계속 네비게이션 뷰에 쌓다가 한번에 버리는 형식이 된다.</p>
<p>그럴 일은 없겠지만, 이런 식의 뷰가 계속 쌓이다 보면 메모리는 갈수록 늘어나게 된다. 이 중간에 이미지를 관리하는 뷰가 들어가게 되면 메모리 문제는 갈수록 심각해진다.</p>
<p>이래뵈도 필자는 메모리 문제에 대해 귀여운 정도로 심각하다.</p>
<ul>
<li>DoubleColumnNavigationViewStyle 을 사용할 수 없다.</li>
</ul>
<p>Coordinator 패턴을 사용하려면 반드시 NavigationView 들의 style 이 StackNavigationViewStyle 로 정의되어 있어야 한다.</p>
<pre><code class="language-swift">TabView(selection: $selection) {
  NavigationView {
    StartView()
  }
  .navigationViewStyle(.stack)
  .tabItem { Text(Tabs.coordinator.rawValue.uppercased()) }

  NavigationView {
    StartNormalView()
  }
  .navigationViewStyle(.stack)
  .tabItem { Text(Tabs.normal.rawValue.uppercased()) }
}</code></pre>
<p>원인은 현재 확인중이나 개인적으로는 NavigationLink 타입의 생성자 중 isActive 생성자가 deprecated 된 이유와 깊이 관련이 있어 보인다.</p>
<p>즉, isActive 바인딩 값이 영향을 주는 프로퍼티들은 NavigationView 의 StackNavigationViewStyle 에 의존적이라고 생각된다.</p>
<ul>
<li>pop 은 뷰 내부에서 직접 정의해야 한다.</li>
</ul>
<p><strong><em>SwiftUI 는 모든 뷰 상태값을 뷰 내부에서 정의해야 한다</em></strong>. (약간 치트키처럼 쓰고 있는 것 같아 가슴이 아프다)</p>
<p>위의 Coordinator 객체에 있는 바인딩 값을 다른 뷰에서 변경하려면 결국 Notification 을 뷰마다 정의하거나, 바인딩 값을 전달해야 한다. Coordinator 로 얻을 수 있는 장점이 줄어들게 되고 유지보수 비용은 다시 늘어난다.</p>
<p>필자는 그냥 아래처럼 뷰 내부에서 pop 을 정의한다.</p>
<pre><code class="language-swift">import SwiftUI

struct PopView: View {
    @Environment(\.presentationMode) var presentationMode
    var body: some View {
        VStack {
            Button {
                presentationMode.wrappedValue.dismiss()
            } label: {
                Text(&quot;분하군...&quot;)
            }
        }
    }
}           </code></pre>
<h2 id="example-project">example project</h2>
<p><a href="https://github.com/SangHwi-Back/CoordinatorTest">https://github.com/SangHwi-Back/CoordinatorTest</a></p>
<h2 id="reference">Reference</h2>
<ul>
<li><a href="https://labs.brandi.co.kr//2022/12/12/leehs81.html">https://labs.brandi.co.kr//2022/12/12/leehs81.html</a></li>
<li><a href="https://jh-make.tistory.com/entry/SwiftUI-NavigationView%EC%97%90%EC%84%9C-Root%EB%B7%B0%EB%A1%9C-%EB%8F%8C%EC%95%84%EC%98%A4%EA%B8%B0">https://jh-make.tistory.com/entry/SwiftUI-NavigationView%EC%97%90%EC%84%9C-Root%EB%B7%B0%EB%A1%9C-%EB%8F%8C%EC%95%84%EC%98%A4%EA%B8%B0</a></li>
</ul>
]]></description>
        </item>
    </channel>
</rss>