React와 Kotlin/JS로 웹 애플리케이션 만들기 — 튜토리얼

React와 Kotlin/JS로 웹 애플리케이션 만들기 — 튜토리얼

이 튜토리얼에서는 Kotlin/JS와 React 프레임워크로 브라우저 애플리케이션을 만드는 방법을 배워요. 다음을 하게 될 거예요.

  • 전형적인 React 애플리케이션을 만드는 데 관련된 일반적인 작업을 완료해요.
  • Kotlin의 DSL이 가독성을 희생하지 않으면서 개념을 간결하고 일관되게 표현하는 데 어떻게 도움이 되는지 살펴봐요. 이로써 완전한 애플리케이션을 전부 Kotlin으로 작성할 수 있어요.
  • 기성 npm 컴포넌트를 사용하는 방법, 외부 라이브러리를 사용하는 방법, 최종 애플리케이션을 게시하는 방법을 배워요.

결과물은 KotlinConf 이벤트에 바쳐진 KotlinConf Explorer 웹 앱으로, 컨퍼런스 강연 링크가 포함돼요. 사용자는 한 페이지에서 모든 강연을 시청하고 본 강연/안 본 강연으로 표시할 수 있어요.

이 튜토리얼은 Kotlin에 대한 사전 지식과 HTML, CSS에 대한 기초 지식을 가정해요. React 뒤의 기본 개념을 이해하면 일부 샘플 코드를 이해하는 데 도움이 될 수 있지만, 반드시 필요한 건 아니에요.

최종 애플리케이션은 여기에서 얻을 수 있어요.

시작하기 전에

최신 버전의 IntelliJ IDEA를 다운로드하고 설치해요.

프로젝트 템플릿을 클론하고 IntelliJ IDEA에서 열어요. 템플릿에는 필요한 모든 구성과 의존성을 갖춘 기본 Kotlin Multiplatform Gradle 프로젝트가 포함돼 있어요.

build.gradle.kts 파일의 의존성과 태스크:

dependencies {
    // React, React DOM + Wrappers
    implementation(enforcedPlatform("org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom:1.0.0-pre.430"))
    implementation("org.jetbrains.kotlin-wrappers:kotlin-react")
    implementation("org.jetbrains.kotlin-wrappers:kotlin-react-dom")

    // Kotlin React Emotion (CSS)
    implementation("org.jetbrains.kotlin-wrappers:kotlin-emotion")

    // Video Player
    implementation(npm("react-player", "2.12.0"))

    // Share Buttons
    implementation(npm("react-share", "4.4.1"))

    // Coroutines & serialization
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
}

이 튜토리얼에서 사용할 JavaScript 코드를 삽입하기 위한 src/jsMain/resources/index.html의 HTML 템플릿 페이지:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Hello, Kotlin/JS!</title>
</head>
<body>
    <div id="root"></div>
    <script src="confexplorer.js"></script>
</body>
</html>

Kotlin/JS 프로젝트를 빌드하면 모든 코드와 그 의존성이 프로젝트와 같은 이름인 단일 JavaScript 파일 confexplorer.js로 자동 번들돼요. 일반적인 JavaScript 관례에 따라 body의 내용(root div 포함)이 먼저 로드되어 브라우저가 스크립트보다 먼저 모든 페이지 요소를 로드하도록 해요.

src/jsMain/kotlin/Main.kt의 코드 스니펫:

import kotlinx.browser.document

fun main() {
    document.bgColor = "red"
}

개발 서버 실행하기

기본적으로 Kotlin Multiplatform Gradle 플러그인은 임베디드 webpack-dev-server 지원과 함께 제공되어, 서버를 수동으로 설정하지 않고도 IDE에서 애플리케이션을 실행할 수 있어요.

프로그램이 브라우저에서 성공적으로 실행되는지 테스트하려면 IntelliJ IDEA 안의 Gradle 도구 창에서 run 또는 browserDevelopmentRun 태스크(other 또는 kotlin browser 디렉터리에 있음)를 호출해 개발 서버를 시작해요.

Terminal에서 프로그램을 실행하려면 대신 ./gradlew run을 사용해요.

프로젝트가 컴파일되고 번들되면 브라우저 창에 빨간색 빈 페이지가 나타나요.

핫 리로드 / 연속 모드 활성화하기

매번 변경할 때마다 프로젝트를 수동으로 컴파일하고 실행할 필요가 없도록 연속 컴파일 모드를 구성해요. 계속하기 전에 실행 중인 모든 개발 서버 인스턴스를 중지해야 해요.

Gradle run 태스크를 처음 실행한 후 IntelliJ IDEA가 자동으로 생성하는 실행 구성을 편집해요.

Run/Debug Configurations 대화상자에서 실행 구성의 arguments에 --continuous 옵션을 추가해요.

변경 사항을 적용한 뒤에는 IntelliJ IDEA 안의 Run 버튼을 사용해서 개발 서버를 다시 시작할 수 있어요. Terminal에서 연속 Gradle 빌드를 실행하려면 대신 ./gradlew run --continuous를 사용해요.

이 기능을 테스트하려면 Gradle 태스크가 실행되는 동안 Main.kt 파일에서 페이지 색상을 파란색으로 바꿔요.

document.bgColor = "blue"

그러면 프로젝트가 다시 컴파일되고, 리로드 후 브라우저 페이지가 새 색상이 돼요.

개발 과정 동안 개발 서버를 연속 모드로 계속 실행해 둘 수 있어요. 변경할 때마다 자동으로 재빌드하고 페이지를 리로드해요.

프로젝트의 이 상태는 master 브랜치 여기에서 찾을 수 있어요.

웹 앱 초안 만들기

React로 첫 정적 페이지 추가하기

앱이 간단한 메시지를 표시하도록 Main.kt 파일의 코드를 다음으로 바꿔요.

import kotlinx.browser.document
import react.*
import emotion.react.css
import csstype.Position
import csstype.px
import react.dom.html.ReactHTML.h1
import react.dom.html.ReactHTML.h3
import react.dom.html.ReactHTML.div
import react.dom.html.ReactHTML.p
import react.dom.html.ReactHTML.img
import react.dom.client.createRoot
import kotlinx.serialization.Serializable

fun main() {
    val container = document.getElementById("root") ?: error("Couldn't find root container!")
    createRoot(container).render(Fragment.create {
        h1 {
            +"Hello, React+Kotlin/JS!"
        }
    })
}

render() 함수는 kotlin-react-domfragment 안의 첫 HTML 요소를 root 요소에 렌더링하도록 지시해요. 이 요소는 템플릿에 포함된 src/jsMain/resources/index.html에 정의된 컨테이너예요.

내용은 <h1> 헤더이며 HTML을 렌더링하는 데 타입세이프한 DSL을 사용해요.

h1은 람다 파라미터를 받는 함수예요. 문자열 리터럴 앞에 + 기호를 추가하면 연산자 오버로딩을 통해 실제로 unaryPlus() 함수가 호출돼요. 그것은 감싸인 HTML 요소에 문자열을 추가해요.

프로젝트가 다시 컴파일되면 브라우저가 이 HTML 페이지를 표시해요.

HTML을 Kotlin의 타입세이프한 HTML DSL로 변환하기

React용 Kotlin 래퍼는 순수 Kotlin 코드로 HTML을 작성할 수 있게 해 주는 도메인 특화 언어(DSL)와 함께 제공돼요. 이 점에서 JavaScript의 JSX와 유사해요. 하지만 이 마크업이 Kotlin이므로 자동 완성이나 타입 검사 같은 정적으로 타입이 지정된 언어의 모든 이점을 얻을 수 있어요.

미래의 웹 앱을 위한 전통적인 HTML 코드와 Kotlin에서의 타입세이프 변형을 비교해 보세요.

<h1>KotlinConf Explorer</h1>
<div>
    <h3>Videos to watch</h3>
    <p>John Doe: Building and breaking things</p>
    <p>Jane Smith: The development process</p>
    <p>Matt Miller: The Web 7.0</p>
    <h3>Videos watched</h3>
    <p>Tom Jerry: Mouseless development</p>
</div>
<div>
    <h3>John Doe: Building and breaking things</h3>
    <img src="https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder">
</div>
h1 {
    +"KotlinConf Explorer"
}
div {
    h3 {
        +"Videos to watch"
    }
    p {
        + "John Doe: Building and breaking things"
    }
    p {
        +"Jane Smith: The development process"
    }
    p {
        +"Matt Miller: The Web 7.0"
    }
    h3 {
        +"Videos watched"
    }
    p {
        +"Tom Jerry: Mouseless development"
    }
}
div {
    h3 {
        +"John Doe: Building and breaking things"
    }
    img {
       src = "https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder"
    }
}

Kotlin 코드를 복사하고 main() 함수 안의 Fragment.create() 함수 호출을 업데이트해서 이전 h1 태그를 교체해요.

브라우저가 리로드될 때까지 기다려요. 이제 페이지가 이렇게 보일 거예요.

마크업에서 Kotlin 구성으로 비디오 추가하기

이 DSL로 Kotlin에서 HTML을 작성하는 데는 몇 가지 장점이 있어요. 루프, 조건, 컬렉션, 문자열 보간 같은 일반적인 Kotlin 구성을 사용해서 앱을 조작할 수 있어요.

이제 하드코딩된 비디오 목록을 Kotlin 객체 목록으로 바꿀 수 있어요.

Main.kt에서 모든 비디오 속성을 한 곳에 담기 위해 Video data class를 만들어요.

data class Video(
    val id: Int,
    val title: String,
    val speaker: String,
    val videoUrl: String
)

안 본 비디오용과 본 비디오용 목록 두 개를 각각 채워요. 이 선언들을 Main.kt의 파일 수준에서 추가해요.

val unwatchedVideos = listOf(
    Video(1, "Opening Keynote", "Andrey Breslav", "https://youtu.be/PsaFVLr8t4E"),
    Video(2, "Dissecting the stdlib", "Huyen Tue Dao", "https://youtu.be/Fzt_9I733Yg"),
    Video(3, "Kotlin and Spring Boot", "Nicolas Frankel", "https://youtu.be/pSiZVAeReeg")
)

val watchedVideos = listOf(
    Video(4, "Creating Internal DSLs in Kotlin", "Venkat Subramaniam", "https://youtu.be/JzTeAM8N1-o")
)

페이지에서 이 비디오들을 사용하려면 안 본 Video 객체 컬렉션을 순회하는 Kotlin for 루프를 작성해요. "Videos to watch" 아래의 p 태그 세 개를 다음 스니펫으로 바꿔요.

for (video in unwatchedVideos) {
    p {
        +"${video.speaker}: ${video.title}"
    }
}

"Videos watched" 뒤의 단일 태그 코드를 수정할 때도 같은 과정을 적용해요.

for (video in watchedVideos) {
    p {
        +"${video.speaker}: ${video.title}"
    }
}

브라우저가 리로드될 때까지 기다려요. 레이아웃은 이전과 같아야 해요. 루프가 동작하는지 확인하려면 목록에 비디오를 몇 개 더 추가할 수도 있어요.

타입세이프한 CSS로 스타일 추가하기

Emotion 라이브러리용 kotlin-emotion 래퍼를 사용하면 HTML 옆에서 CSS 속성 — 동적인 속성까지도 — JavaScript로 지정할 수 있어요. 개념적으로는 CSS-in-JS와 유사하지만 — Kotlin용이라는 점이 달라요. DSL을 사용하는 이점은 Kotlin 코드 구성을 사용해서 포맷 규칙을 표현할 수 있다는 거예요.

이 튜토리얼의 템플릿 프로젝트에는 kotlin-emotion을 사용하는 데 필요한 의존성이 이미 포함돼 있어요.

dependencies {
    // ...
    // Kotlin React Emotion (CSS) (chapter 3)
    implementation("org.jetbrains.kotlin-wrappers:kotlin-emotion")
    // ...
}

kotlin-emotion을 사용하면 HTML 요소 divh3 안에서 css 블록을 지정할 수 있는데, 여기에서 스타일을 정의할 수 있어요.

비디오 플레이어를 페이지의 오른쪽 위 모서리로 옮기려면 CSS를 사용하고 비디오 플레이어(스니펫의 마지막 div) 코드를 조정해요.

div {
    css {
        position = Position.absolute
        top = 10.px
        right = 10.px
    }
    h3 {
        +"John Doe: Building and breaking things"
    }
    img {
        src = "https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder"
    }
}

다른 스타일도 자유롭게 실험해 보세요. 예를 들어 fontFamily를 바꾸거나 UI에 color를 추가할 수 있어요.

앱 컴포넌트 설계하기

React의 기본 구성 요소는 컴포넌트라고 불러요. 컴포넌트 자체도 더 작은 다른 컴포넌트들로 구성될 수 있어요. 컴포넌트를 결합해서 애플리케이션을 만들죠. 컴포넌트를 일반적이고 재사용 가능하게 구조화하면 코드나 로직을 중복하지 않고도 앱의 여러 부분에서 사용할 수 있어요.

render() 함수의 내용은 일반적으로 기본 컴포넌트를 설명해요. 현재 애플리케이션의 레이아웃은 이렇게 생겼어요.

애플리케이션을 개별 컴포넌트로 분해하면 각 컴포넌트가 자기 책임을 처리하는 더 구조화된 레이아웃이 돼요.

컴포넌트는 특정 기능을 캡슐화해요. 컴포넌트를 사용하면 소스 코드가 짧아지고 읽고 이해하기 쉬워져요.

메인 컴포넌트 추가하기

애플리케이션 구조 만들기를 시작하려면 먼저 root 요소에 렌더링할 메인 컴포넌트 App을 명시적으로 지정해요.

src/jsMain/kotlin 폴더에 새 App.kt 파일을 만들어요.

이 파일 안에 다음 스니펫을 추가하고 Main.kt의 타입세이프 HTML을 그 안으로 옮겨요.

import kotlinx.coroutines.async
import react.*
import react.dom.*
import kotlinx.browser.window
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import emotion.react.css
import csstype.Position
import csstype.px
import react.dom.html.ReactHTML.h1
import react.dom.html.ReactHTML.h3
import react.dom.html.ReactHTML.div
import react.dom.html.ReactHTML.p
import react.dom.html.ReactHTML.img

val App = FC<Props> {
    // typesafe HTML goes here, starting with the first h1 tag!
}

FC 함수는 함수 컴포넌트를 만들어요.

Main.kt 파일에서 main() 함수를 다음과 같이 업데이트해요.

fun main() {
    val container = document.getElementById("root") ?: error("Couldn't find root container!")
    createRoot(container).render(App.create())
}

이제 프로그램은 App 컴포넌트의 인스턴스를 만들고 그것을 지정된 컨테이너에 렌더링해요.

React 개념에 대한 자세한 내용은 문서와 가이드를 참고하세요.

목록 컴포넌트 추출하기

watchedVideosunwatchedVideos 목록이 각각 비디오 목록을 담고 있으므로, 단일 재사용 가능 컴포넌트를 만들어서 목록에 표시되는 내용만 조정하는 게 합리적이에요.

VideoList 컴포넌트는 App 컴포넌트와 같은 패턴을 따라요. FC 빌더 함수를 사용하고 unwatchedVideos 목록의 코드를 포함해요.

src/jsMain/kotlin 폴더에 새 VideoList.kt 파일을 만들고 다음 코드를 추가해요.

import kotlinx.browser.window
import react.*
import react.dom.*
import react.dom.html.ReactHTML.p

val VideoList = FC<Props> {
    for (video in unwatchedVideos) {
        p {
            +"${video.speaker}: ${video.title}"
        }
    }
}

App.kt에서 매개변수 없이 호출해서 VideoList 컴포넌트를 사용해요.

// . . .

div {
    h3 {
        +"Videos to watch"
    }
    VideoList()

    h3 {
        +"Videos watched"
    }
    VideoList()
}

// . . .

지금은 App 컴포넌트가 VideoList 컴포넌트가 보여 주는 내용을 제어할 수 없어요. 하드코딩되어 있으므로 같은 목록이 두 번 보여요.

컴포넌트 사이에 데이터를 전달하기 위한 props 추가하기

VideoList 컴포넌트를 재사용하려면 그것을 서로 다른 내용으로 채울 수 있어야 해요. 컴포넌트에 항목 목록을 속성으로 전달하는 기능을 추가할 수 있어요. React에서 이 속성을 props라고 불러요. React에서 컴포넌트의 props가 바뀌면 프레임워크가 컴포넌트를 자동으로 다시 렌더링해요.

VideoList에는 표시할 비디오 목록을 담는 prop이 필요해요. VideoList 컴포넌트에 전달할 수 있는 모든 props를 담는 인터페이스를 정의해요.

다음 정의를 VideoList.kt 파일에 추가해요.

external interface VideoListProps : Props {
    var videos: List<Video>
}

external 수정자는 컴파일러에 인터페이스의 구현이 외부에서 제공된다고 알려주므로, 선언에서 JavaScript 코드를 생성하려 하지 않아요.

VideoList의 클래스 정의를 조정해서 FC 블록에 매개변수로 전달되는 props를 활용해요.

val VideoList = FC<VideoListProps> { props ->
    for (video in props.videos) {
        p {
            key = video.id.toString()
            +"${video.speaker}: ${video.title}"
        }
    }
}

key 속성은 props.videos의 값이 바뀔 때 React 렌더러가 무엇을 해야 할지 알아내도록 도와줘요. 목록의 어떤 부분을 새로고침해야 하고 어떤 부분이 그대로 유지되는지 key로 판단해요. 목록과 key에 대한 자세한 정보는 React 가이드에서 찾을 수 있어요.

App 컴포넌트에서 자식 컴포넌트들이 적절한 속성으로 인스턴스화되도록 해요. App.kt에서 h3 요소 아래의 두 루프를 unwatchedVideoswatchedVideos 속성과 함께 VideoList 호출로 교체해요. Kotlin DSL에서는 이를 VideoList 컴포넌트에 속한 블록 안에 할당해요.

h3 {
    +"Videos to watch"
}
VideoList {
    videos = unwatchedVideos
}
h3 {
    +"Videos watched"
}
VideoList {
    videos = watchedVideos
}

리로드 후 브라우저는 목록이 이제 올바르게 렌더링되는 것을 보여 줄 거예요.

목록을 인터랙티브하게 만들기

먼저 사용자가 목록 항목을 클릭할 때 나타나는 알림 메시지를 추가해요. VideoList.kt에서 현재 비디오로 알림을 트리거하는 onClick 핸들러 함수를 추가해요.

// . . .

p {
    key = video.id.toString()
    onClick = {
        window.alert("Clicked $video!")
    }
    +"${video.speaker}: ${video.title}"
}

// . . .

브라우저 창에서 목록 항목 중 하나를 클릭하면 이렇게 알림 창에서 비디오에 대한 정보를 얻게 돼요.

onClick 함수를 람다로 직접 정의하는 건 간결하고 프로토타이핑에 매우 유용해요. 하지만 Kotlin/JS에서 동등성이 현재 동작하는 방식 때문에, 성능 면에서 클릭 핸들러를 전달하는 최적의 방법은 아니에요. 렌더링 성능을 최적화하려면 함수를 변수에 저장해서 전달하는 것을 고려해요.

값을 유지하기 위한 상태 추가하기

사용자에게 알리기만 하는 대신, 선택된 비디오를 ▶ 삼각형으로 강조하는 기능을 추가할 수 있어요. 그러려면 이 컴포넌트 고유의 상태를 도입해요.

상태는 React의 핵심 개념 중 하나예요. 현대 React(소위 Hooks API를 사용하는)에서는 상태가 useState hook으로 표현돼요.

다음 코드를 VideoList 선언의 맨 위에 추가해요.

val VideoList = FC<VideoListProps> { props ->
    var selectedVideo: Video? by useState(null)

// . . .

VideoList 함수형 컴포넌트는 상태(현재 함수 호출과 독립적인 값)를 유지해요. 상태는 nullable이고 Video? 타입을 가져요. 기본값은 null이에요.

React의 useState() 함수는 프레임워크가 함수의 여러 호출에 걸쳐 상태를 추적하도록 지시해요. 예를 들어 기본값을 지정하더라도 React는 기본값이 처음에만 할당되도록 보장해요. 상태가 바뀌면 컴포넌트가 새 상태에 기반해 다시 렌더링돼요.

by 키워드는 useState()위임 프로퍼티(delegated property)로 동작함을 나타내요. 다른 변수처럼 값을 읽고 씁니다. useState() 뒤의 구현이 상태가 동작하는 데 필요한 메커니즘을 처리해요.

State Hook에 대해 더 알아보려면 React 문서를 확인하세요.

VideoList 컴포넌트의 onClick 핸들러와 텍스트를 다음과 같이 바꿔요.

val VideoList = FC<VideoListProps> { props ->
    var selectedVideo: Video? by useState(null)
    for (video in props.videos) {
        p {
            key = video.id.toString()
            onClick = {
                selectedVideo = video
            }
            if (video == selectedVideo) {
                +"▶ "
            }
            +"${video.speaker}: ${video.title}"
        }
    }
}

사용자가 비디오를 클릭하면 그 값이 selectedVideo 변수에 할당돼요.

선택된 목록 항목을 렌더링할 때 삼각형이 앞에 붙어요.

상태 관리에 대한 더 자세한 내용은 React FAQ에서 확인할 수 있어요.

브라우저를 확인하고 목록의 항목을 클릭해서 모든 것이 올바르게 동작하는지 확인해 보세요.

컴포넌트 합성하기

현재 두 비디오 목록은 각각 독립적으로 동작해서, 각 목록이 선택된 비디오를 따로 추적해요. 사용자는 플레이어가 하나뿐인데도 안 본 목록과 본 목록에서 각각 하나씩 두 개의 비디오를 선택할 수 있어요.

목록은 자신의 내부와 형제 목록의 내부 둘 다에서 어떤 비디오가 선택되었는지 추적할 수 없어요. 그 이유는 선택된 비디오가 목록 상태의 일부가 아니라 애플리케이션 상태의 일부이기 때문이에요. 즉, 개별 컴포넌트에서 상태를 끌어올려야(lift state) 해요.

상태 끌어올리기

React는 props가 부모 컴포넌트에서 자식으로만 전달될 수 있도록 보장해요. 이는 컴포넌트들이 서로 하드와이어되지 않도록 해줘요.

컴포넌트가 형제 컴포넌트의 상태를 바꾸고 싶다면 부모를 통해서 해야 해요. 그 시점에 상태는 더 이상 어떤 자식 컴포넌트에도 속하지 않고 상위 부모 컴포넌트에 속해요.

상태를 컴포넌트에서 부모로 옮기는 과정을 상태 끌어올리기(lifting state)라고 불러요. 앱에서는 currentVideo를 상태로 App 컴포넌트에 추가해요.

App.kt에서 App 컴포넌트 정의의 맨 위에 다음을 추가해요.

val App = FC<Props> {
    var currentVideo: Video? by useState(null)

    // . . .
}

VideoList 컴포넌트는 더 이상 상태를 추적할 필요가 없어요. 대신 현재 비디오를 prop으로 받게 돼요.

VideoList.kt에서 useState() 호출을 제거해요.

VideoList 컴포넌트가 선택된 비디오를 prop으로 받도록 준비해요. 그러려면 VideoListProps 인터페이스를 확장해서 selectedVideo를 포함해요.

external interface VideoListProps : Props {
    var videos: List<Video>
    var selectedVideo: Video?
}

삼각형의 조건을 state 대신 props를 사용하도록 바꿔요.

if (video == props.selectedVideo) {
    +"▶ "
}

핸들러 전달하기

지금은 prop에 값을 할당할 방법이 없으므로 onClick 함수가 현재 설정된 방식으로는 동작하지 않아요. 부모 컴포넌트의 상태를 바꾸려면 상태를 다시 끌어올려야 해요.

React에서 상태는 항상 부모에서 자식으로 흐릅니다. 그렇기에 자식 컴포넌트 중 하나에서 애플리케이션 상태를 바꾸려면 사용자 상호작용을 처리하는 로직을 부모 컴포넌트로 옮기고 그 로직을 prop으로 전달해야 해요. Kotlin에서 변수는 함수 타입을 가질 수 있다는 것을 기억하세요.

VideoListProps 인터페이스를 다시 확장해서 Video를 받고 Unit을 반환하는 함수인 onSelectVideo 변수를 포함해요.

external interface VideoListProps : Props {
    // ...
    var onSelectVideo: (Video) -> Unit
}

VideoList 컴포넌트에서 onClick 핸들러에 새 prop을 사용해요.

onClick = {
    props.onSelectVideo(video)
}

이제 VideoList 컴포넌트에서 selectedVideo 변수를 삭제할 수 있어요.

App 컴포넌트로 돌아가서 두 비디오 목록 각각에 selectedVideoonSelectVideo 핸들러를 전달해요.

VideoList {
    videos = unwatchedVideos // and watchedVideos respectively
    selectedVideo = currentVideo
    onSelectVideo = { video ->
        currentVideo = video
    }
}

본 비디오 목록에 대해 이전 단계를 반복해요.

브라우저로 돌아가서, 비디오를 선택할 때 선택이 두 목록 사이에서 중복 없이 이동하는지 확인해요.

컴포넌트 더 추가하기

비디오 플레이어 컴포넌트 추출하기

이제 자립형 컴포넌트인 비디오 플레이어(현재는 플레이스홀더 이미지)를 만들 수 있어요. 비디오 플레이어는 강연 제목, 강연 저자, 비디오 링크를 알아야 해요. 이 정보는 이미 각 Video 객체에 담겨 있으므로, 그것을 prop으로 전달하고 속성에 접근할 수 있어요.

VideoPlayer.kt 파일을 만들고 VideoPlayer 컴포넌트에 다음 구현을 추가해요.

import csstype.*
import react.*
import emotion.react.css
import react.dom.html.ReactHTML.button
import react.dom.html.ReactHTML.div
import react.dom.html.ReactHTML.h3
import react.dom.html.ReactHTML.img

external interface VideoPlayerProps : Props {
    var video: Video
}

val VideoPlayer = FC<VideoPlayerProps> { props ->
    div {
        css {
            position = Position.absolute
            top = 10.px
            right = 10.px
        }
        h3 {
            +"${props.video.speaker}: ${props.video.title}"
        }
        img {
            src = "https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder"
        }
    }
}

VideoPlayerProps 인터페이스가 VideoPlayer 컴포넌트가 non-nullable Video를 받도록 지정하므로, App 컴포넌트에서 그에 맞게 처리해야 해요.

App.kt에서 비디오 플레이어용 이전 div 스니펫을 다음으로 교체해요.

currentVideo?.let { curr ->
    VideoPlayer {
        video = curr
    }
}

let 스코프 함수state.currentVideo가 null이 아닐 때만 VideoPlayer 컴포넌트가 추가되도록 보장해요.

이제 목록의 항목을 클릭하면 비디오 플레이어가 나타나고 클릭한 항목의 정보로 채워질 거예요.

버튼 추가하고 연결하기

사용자가 비디오를 본/안 본으로 표시하고 두 목록 사이에서 옮길 수 있게 하려면 VideoPlayer 컴포넌트에 버튼을 추가해요.

이 버튼이 비디오를 두 개의 다른 목록 사이에서 옮기게 되므로, 상태 변경을 처리하는 로직을 VideoPlayer 밖으로 끌어올려 부모에서 prop으로 전달해야 해요. 버튼은 비디오를 봤는지 안 봤는지에 따라 다르게 보여야 해요. 이것도 prop으로 전달해야 하는 정보예요.

VideoPlayer.kt에서 VideoPlayerProps 인터페이스를 확장해서 이 두 경우에 대한 프로퍼티를 포함해요.

external interface VideoPlayerProps : Props {
    var video: Video
    var onWatchedButtonPressed: (Video) -> Unit
    var unwatchedVideo: Boolean
}

이제 실제 컴포넌트에 버튼을 추가할 수 있어요. 다음 스니펫을 VideoPlayer 컴포넌트 본문의 h3img 태그 사이에 복사해요.

button {
    css {
        display = Display.block
        backgroundColor = if (props.unwatchedVideo) NamedColor.lightgreen else NamedColor.red
    }
    onClick = {
        props.onWatchedButtonPressed(props.video)
    }
    if (props.unwatchedVideo) {
        +"Mark as watched"
    } else {
        +"Mark as unwatched"
    }
}

스타일을 동적으로 바꿀 수 있게 해 주는 Kotlin CSS DSL 덕분에 기본 Kotlin if 표현식으로 버튼 색상을 바꿀 수 있어요.

비디오 목록을 애플리케이션 상태로 옮기기

이제 App 컴포넌트의 VideoPlayer 사용 위치를 조정할 차례예요. 버튼을 클릭하면 비디오가 안 본 목록에서 본 목록으로 또는 그 반대로 이동해야 해요. 이 목록들이 이제 실제로 바뀔 수 있으므로, 애플리케이션 상태로 옮겨요.

App.kt에서 App 컴포넌트 위에 useState() 호출이 있는 다음 프로퍼티를 추가해요.

val App = FC<Props> {
    var currentVideo: Video? by useState(null)
    var unwatchedVideos: List<Video> by useState(listOf(
        Video(1, "Opening Keynote", "Andrey Breslav", "https://youtu.be/PsaFVLr8t4E"),
        Video(2, "Dissecting the stdlib", "Huyen Tue Dao", "https://youtu.be/Fzt_9I733Yg"),
        Video(3, "Kotlin and Spring Boot", "Nicolas Frankel", "https://youtu.be/pSiZVAeReeg")
    ))
    var watchedVideos: List<Video> by useState(listOf(
        Video(4, "Creating Internal DSLs in Kotlin", "Venkat Subramaniam", "https://youtu.be/JzTeAM8N1-o")
    ))

    // . . .
}

모든 데모 데이터가 watchedVideosunwatchedVideos의 기본값에 직접 포함되므로 이제 파일 수준 선언이 필요 없어요. Main.kt에서 watchedVideosunwatchedVideos 선언을 삭제해요.

App 컴포넌트에서 비디오 플레이어에 속한 VideoPlayer의 호출 위치를 이렇게 바꿔요.

VideoPlayer {
    video = curr
    unwatchedVideo = curr in unwatchedVideos
    onWatchedButtonPressed = {
        if (video in unwatchedVideos) {
            unwatchedVideos = unwatchedVideos - video
            watchedVideos = watchedVideos + video
        } else {
            watchedVideos = watchedVideos - video
            unwatchedVideos = unwatchedVideos + video
        }
    }
}

브라우저로 돌아가서 비디오를 선택하고 버튼을 몇 번 눌러보세요. 비디오가 두 목록 사이를 오갈 거예요.

npm 패키지 사용하기

앱을 유용하게 만들려면 실제로 비디오를 재생하는 비디오 플레이어와 콘텐츠 공유를 돕는 버튼이 여전히 필요해요.

React에는 이 기능을 직접 만드는 대신 사용할 수 있는 미리 만들어진 컴포넌트가 풍부한 생태계가 있어요.

비디오 플레이어 컴포넌트 추가하기

플레이스홀더 비디오 컴포넌트를 실제 YouTube 플레이어로 교체하려면 npm의 react-player 패키지를 사용해요. 비디오를 재생할 수 있고 플레이어의 모양을 제어할 수 있게 해 줘요.

컴포넌트 문서와 API 설명은 GitHub의 README를 참고하세요.

build.gradle.kts 파일을 확인해요. react-player 패키지가 이미 포함되어 있어야 해요.

dependencies {
    // ...
    // Video Player
    implementation(npm("react-player", "2.12.0"))
    // ...
}

보시다시피 npm 의존성은 빌드 파일의 dependencies 블록에서 npm() 함수를 사용해서 Kotlin/JS 프로젝트에 추가할 수 있어요. 그러면 Gradle 플러그인이 이 의존성들을 다운로드하고 설치하는 일을 처리해요. 그러기 위해 자체 번들된 Yarn 패키지 매니저 설치본을 사용해요.

React 애플리케이션 안에서 JavaScript 패키지를 사용하려면 external 선언을 제공해서 Kotlin 컴파일러에 무엇을 기대해야 하는지 알려줘야 해요.

ReactYouTube.kt 파일을 만들고 다음 내용을 추가해요.

@file:JsModule("react-player")
@file:JsNonModule

import react.*

@JsName("default")
external val ReactPlayer: ComponentClass<dynamic>

컴파일러가 ReactPlayer 같은 external 선언을 보면 해당 클래스의 구현이 의존성에 의해 제공된다고 가정하고 그것에 대한 코드를 생성하지 않아요.

마지막 두 줄은 require("react-player").default; 같은 JavaScript 임포트와 동등해요. 컴파일러에게 런타임에 컴포넌트가 ComponentClass<dynamic>을 따를 것이 확실하다고 알려줘요.

하지만 이 구성에서 ReactPlayer가 받는 props의 제네릭 타입은 dynamic으로 설정돼요. 그 뜻은 컴파일러가 어떤 코드든 받아들이고, 런타임에 문제가 생길 위험은 사용자가 감당한다는 거예요.

더 나은 대안은 이 외부 컴포넌트의 props에 어떤 종류의 프로퍼티가 속하는지 지정하는 external interface를 만드는 거예요. 컴포넌트의 props 인터페이스는 README에서 배울 수 있어요. 이 경우 urlcontrols props를 사용해요.

dynamic을 external interface로 교체해서 ReactYouTube.kt의 내용을 조정해요.

@file:JsModule("react-player")
@file:JsNonModule

import react.*

@JsName("default")
external val ReactPlayer: ComponentClass<ReactPlayerProps>

external interface ReactPlayerProps : Props {
    var url: String
    var controls: Boolean
}

이제 새 ReactPlayer를 사용해서 VideoPlayer 컴포넌트의 회색 플레이스홀더 사각형을 교체할 수 있어요. VideoPlayer.kt에서 img 태그를 다음 스니펫으로 교체해요.

ReactPlayer {
    url = props.video.videoUrl
    controls = true
}

소셜 공유 버튼 추가하기

애플리케이션의 내용을 공유하는 쉬운 방법은 메신저와 이메일용 소셜 공유 버튼을 갖는 거예요. 이를 위해 기성 React 컴포넌트를 사용할 수도 있어요. 예를 들어 react-share를요.

build.gradle.kts 파일을 확인해요. 이 npm 라이브러리가 이미 포함되어 있어야 해요.

dependencies {
    // ...
    // Share Buttons
    implementation(npm("react-share", "4.4.1"))
    // ...
}

Kotlin에서 react-share를 사용하려면 더 기본적인 external 선언을 작성해야 해요. GitHub의 예제는 공유 버튼이 EmailShareButtonEmailIcon 같은 두 개의 React 컴포넌트로 구성됨을 보여 줘요. 다른 유형의 공유 버튼과 아이콘은 모두 같은 종류의 인터페이스를 가져요. 각 컴포넌트에 대해 비디오 플레이어에서 했던 것과 같은 방식으로 external 선언을 만들 거예요.

ReactShare.kt 파일에 다음 코드를 추가해요.

@file:JsModule("react-share")
@file:JsNonModule

import react.ComponentClass
import react.Props

@JsName("EmailIcon")
external val EmailIcon: ComponentClass<IconProps>

@JsName("EmailShareButton")
external val EmailShareButton: ComponentClass<ShareButtonProps>

@JsName("TelegramIcon")
external val TelegramIcon: ComponentClass<IconProps>

@JsName("TelegramShareButton")
external val TelegramShareButton: ComponentClass<ShareButtonProps>

external interface ShareButtonProps : Props {
    var url: String
}

external interface IconProps : Props {
    var size: Int
    var round: Boolean
}

애플리케이션의 사용자 인터페이스에 새 컴포넌트를 추가해요. VideoPlayer.kt에서 ReactPlayer 사용 바로 위의 div에 공유 버튼 두 개를 추가해요.

// . . .

div {
    css {
         position = Position.absolute
         top = 10.px
         right = 10.px
     }
    EmailShareButton {
        url = props.video.videoUrl
        EmailIcon {
            size = 32
            round = true
        }
    }
    TelegramShareButton {
        url = props.video.videoUrl
        TelegramIcon {
            size = 32
            round = true
        }
    }
}

// . . .

이제 브라우저를 확인해서 버튼이 실제로 동작하는지 볼 수 있어요. 버튼을 클릭하면 비디오의 URL이 있는 공유 창이 나타나야 해요. 버튼이 나타나지 않거나 동작하지 않는다면 광고·소셜 미디어 차단기를 비활성화해야 할 수 있어요.

react-share에서 사용할 수 있는 다른 소셜 네트워크용 공유 버튼으로 이 단계를 자유롭게 반복해 보세요.

외부 REST API 사용하기

이제 하드코딩된 데모 데이터를 앱의 REST API의 실제 데이터로 바꿀 수 있어요.

이 튜토리얼에는 작은 API가 있어요. 단일 엔드포인트 videos만 제공하고, 목록의 요소에 접근하기 위해 숫자 매개변수를 받아요. 브라우저로 API를 방문하면 API가 반환하는 객체들이 Video 객체와 같은 구조를 가진다는 것을 볼 수 있어요.

Kotlin에서 JS 기능 사용하기

브라우저에는 이미 다양한 Web API가 함께 제공돼요. Kotlin/JS에는 이러한 API용 래퍼가 기본으로 포함되어 있으므로 Kotlin/JS에서도 사용할 수 있어요. 한 예로 HTTP 요청을 만드는 데 사용되는 fetch API가 있어요.

첫 번째 잠재적 문제는 fetch() 같은 브라우저 API가 콜백을 사용해서 비차단(non-blocking) 연산을 수행한다는 거예요. 여러 콜백이 차례로 실행되어야 할 때는 중첩되어야 해요. 당연히 코드가 심하게 들여쓰여지고 기능 조각들이 서로 안에 점점 더 쌓여 읽기 어려워져요.

이를 극복하려면 Kotlin의 코루틴을 사용할 수 있는데, 이런 기능에 더 나은 접근 방식이에요.

두 번째 문제는 JavaScript의 동적 타입 특성에서 발생해요. 외부 API가 반환하는 데이터의 타입에 대한 보장이 없어요. 이를 해결하려면 kotlinx.serialization 라이브러리를 사용할 수 있어요.

build.gradle.kts 파일을 확인해요. 관련 스니펫이 이미 존재해야 해요.

dependencies {
    // . . .

    // Coroutines & serialization
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
}

직렬화 추가하기

외부 API를 호출하면 JSON 형식의 텍스트가 반환되는데, 이것을 작업할 수 있는 Kotlin 객체로 바꿔야 해요.

kotlinx.serialization은 JSON 문자열에서 Kotlin 객체로의 이런 종류의 변환을 작성할 수 있게 해 주는 라이브러리예요.

build.gradle.kts 파일을 확인해요. 해당 스니펫이 이미 존재해야 해요.

plugins {
    // . . .
    kotlin("plugin.serialization") version "2.4.20"
}

dependencies {
    // . . .

    // Serialization
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
}

첫 비디오를 가져오기 위한 준비로, 직렬화 라이브러리에 Video 클래스에 대해 알려주는 것이 필요해요. Main.kt에서 그 정의에 @Serializable 어노테이션을 추가해요.

@Serializable
data class Video(
    val id: Int,
    val title: String,
    val speaker: String,
    val videoUrl: String
)

비디오 가져오기

API에서 비디오를 가져오려면 App.kt(또는 새 파일)에 다음 함수를 추가해요.

suspend fun fetchVideo(id: Int): Video {
    val response = window
        .fetch("https://my-json-server.typicode.com/kotlin-hands-on/kotlinconf-json/videos/$id")
        .await()
        .text()
        .await()
    return Json.decodeFromString(response)
}

suspend 함수 fetch()는 주어진 id를 가진 비디오를 API에서 가져와요. 이 응답은 시간이 걸릴 수 있으므로 결과를 await()해요. 다음으로 콜백을 사용하는 text()가 응답에서 본문을 읽어요. 그런 다음 그 완료를 await()해요.

함수의 값을 반환하기 전에 kotlinx.coroutines의 함수인 Json.decodeFromString에 전달해요. 요청에서 받은 JSON 텍스트를 적절한 필드를 가진 Kotlin 객체로 변환해요.

window.fetch 함수 호출은 Promise 객체를 반환해요. 보통은 Promise가 해결되고 결과를 사용할 수 있을 때 호출되는 콜백 핸들러를 정의해야 해요. 하지만 코루틴에서는 그런 promise들을 await()할 수 있어요. await() 같은 함수가 호출될 때마다 메서드는 실행을 멈추고(일시 중단하고)요. Promise가 해결될 수 있게 되면 실행이 계속돼요.

사용자에게 비디오 선택지를 주려면 위와 같은 API에서 25개의 비디오를 가져올 fetchVideos() 함수를 정의해요. 모든 요청을 동시에 실행하려면 Kotlin 코루틴이 제공하는 async 기능을 사용해요.

App.kt에 다음 구현을 추가해요.

suspend fun fetchVideos(): List<Video> = coroutineScope {
    (1..25).map { id ->
        async {
            fetchVideo(id)
        }
    }.awaitAll()
}

구조적 동시성(structured concurrency)의 원칙에 따라 구현은 coroutineScope로 감싸여요. 그런 다음 25개의 비동기 태스크(요청당 하나)를 시작하고 모두 완료될 때까지 기다릴 수 있어요.

이제 애플리케이션에 데이터를 추가할 수 있어요. mainScope의 정의를 추가하고 App 컴포넌트가 다음 스니펫으로 시작하도록 바꿔요. 데모 값을 emptyLists 인스턴스로 교체하는 것도 잊지 말아요.

val mainScope = MainScope()

val App = FC<Props> {
    var currentVideo: Video? by useState(null)
    var unwatchedVideos: List<Video> by useState(emptyList())
    var watchedVideos: List<Video> by useState(emptyList())

    useEffectOnce {
        mainScope.launch {
            unwatchedVideos = fetchVideos()
        }
    }

// . . .

MainScope()는 Kotlin의 구조적 동시성 모델의 일부이며 비동기 태스크가 실행될 수 있는 스코프를 만들어요.

useEffectOnce는 또 다른 React hook(구체적으로는 useEffect hook의 단순화된 버전)이에요. 컴포넌트가 부수 효과(side effect)를 수행함을 나타내요. 단순히 렌더링만 하는 게 아니라 네트워크를 통해서도 통신해요.

브라우저를 확인해 보세요. 애플리케이션이 실제 데이터를 보여 줘야 해요.

페이지를 로드하면:

  • App 컴포넌트의 코드가 호출돼요. 이것이 useEffectOnce 블록의 코드를 시작해요.
  • App 컴포넌트는 본/안 본 비디오 목록이 빈 채로 렌더링돼요.
  • API 요청이 끝나면 useEffectOnce 블록이 그것을 App 컴포넌트의 상태에 할당해요. 이는 재렌더링을 트리거해요.
  • App 컴포넌트의 코드가 다시 호출되지만, useEffectOnce 블록은 두 번 다시 실행되지 않아요.

코루틴이 어떻게 동작하는지 깊이 이해하고 싶다면 코루틴 튜토리얼을 확인해 보세요.

프로덕션과 클라우드에 배포하기

애플리케이션을 클라우드에 게시하고 다른 사람들이 접근할 수 있게 만들 때가 됐어요.

프로덕션 빌드 패키징하기

프로덕션 모드로 모든 자산을 패키징하려면 IntelliJ IDEA의 도구 창을 통해 또는 ./gradlew build를 실행해서 Gradle에서 build 태스크를 실행해요. 이는 DCE(데드 코드 제거) 같은 다양한 개선을 적용한 최적화된 프로젝트 빌드를 생성해요.

빌드가 끝나면 배포에 필요한 모든 파일을 /build/dist에서 찾을 수 있어요. 여기에는 애플리케이션을 실행하는 데 필요한 JavaScript 파일, HTML 파일, 기타 리소스가 포함돼요. 정적 HTTP 서버에 두거나, GitHub Pages로 서빙하거나, 선택한 클라우드 제공자에 호스팅할 수 있어요.

Heroku에 배포하기

Heroku는 자체 도메인으로 접근할 수 있는 애플리케이션을 띄우는 것을 상당히 간단하게 만들어 줘요. 무료 티어로도 개발 목적에는 충분할 거예요.

프로젝트 루트에 있는 상태에서 Terminal에서 다음 명령을 실행해서 Git 저장소를 만들고 Heroku 앱을 연결해요.

git init
heroku create
git add .
git commit -m "initial commit"

Heroku에서 실행되는 일반적인 JVM 애플리케이션(예: Ktor나 Spring Boot로 작성된 것)과 달리, 당신의 앱은 정적 HTML 페이지와 JavaScript 파일을 생성하므로 그에 맞게 서빙되어야 해요. 프로그램을 제대로 서빙하려면 필요한 buildpack을 조정할 수 있어요.

heroku buildpacks:set heroku/gradle
heroku buildpacks:add https://github.com/heroku/heroku-buildpack-static.git

heroku/gradle buildpack이 제대로 실행되게 하려면 build.gradle.kts 파일에 stage 태스크가 있어야 해요. 이 태스크는 build 태스크와 동등하며, 해당 별칭이 이미 파일 맨 아래에 포함돼 있어요.

// Heroku Deployment
tasks.register("stage") {
    dependsOn("build")
}

buildpack-static을 구성하려면 프로젝트 루트에 새 static.json 파일을 추가해요.

파일 안에 root 프로퍼티를 추가해요.

{
    "root": "build/distributions"
}

이제 예를 들어 다음 명령을 실행해서 배포를 트리거할 수 있어요.

git add -A
git commit -m "add stage task and static content root configuration"
git push heroku master

non-main 브랜치에서 push한다면 main 원격으로 push하도록 명령을 조정하세요, 예: git push heroku feature-branch:main.

배포가 성공하면 사람들이 인터넷에서 애플리케이션에 접근할 수 있는 URL을 보게 될 거예요.

프로젝트의 이 상태는 finished 브랜치 여기에서 찾을 수 있어요.

다음 단계

더 많은 기능 추가하기

결과 앱을 출발점으로 사용해서 React, Kotlin/JS 등 영역의 더 고급 주제를 탐구할 수 있어요.

  • 검색. 강연 목록을 필터링하는 검색 필드를 추가할 수 있어요 — 예를 들어 제목이나 저자별로요. HTML 폼 요소가 React에서 어떻게 동작하는지 알아보세요.
  • 영속성. 현재 애플리케이션은 페이지가 리로드될 때마다 시청자의 시청 목록을 잃어버려요. Kotlin에서 사용할 수 있는 웹 프레임워크(예: Ktor) 중 하나를 사용해서 자체 백엔드를 구축하는 것을 고려해 보세요. 또는 클라이언트에 정보를 저장하는 방법을 찾아보세요.
  • 복잡한 API. 사용 가능한 데이터셋과 API가 많아요. 다양한 종류의 데이터를 애플리케이션에 가져올 수 있어요. 예를 들어 고양이 사진 시각화 도구나 로열티 프리 스톡 사진 API를 만들 수 있어요.

스타일 개선: 반응형과 그리드

애플리케이션 디자인은 여전히 매우 단순해서 모바일 기기나 좁은 창에서는 보기 좋지 않아요. 앱을 더 접근성 있게 만들기 위해 CSS DSL을 더 탐구해 보세요.

커뮤니티에 참여하고 도움 받기

문제를 보고하고 도움을 받는 가장 좋은 방법은 kotlin-wrappers 이슈 트래커예요. 문제에 대한 티켓을 찾을 수 없다면 새로 등록해도 좋아요. 공식 Kotlin Slack에 참여할 수도 있어요. #javascript#react 채널이 있어요.

코루틴에 대해 더 알아보기

동시성 코드를 작성하는 방법을 더 알고 싶다면 코루틴 튜토리얼을 확인해 보세요.

React에 대해 더 알아보기

이제 기본 React 개념과 그것이 Kotlin에 어떻게 옮겨지는지 알게 됐으니, React의 문서에 설명된 다른 개념들을 Kotlin으로 변환해 볼 수 있어요.