데보션앱 소개페이지 바로가기
로그인 선택

신고하기

CLOSE
신고사유 (대표 사유 1개)
상세내용 (선택)
0/200
  • 신고한 게시글은 더 이상 보이지 않습니다.
  • 이용약관과 운영정책에 따라 신고사유에 해당하는지 검토 후 조치됩니다.
  • 허위 신고인 경우, 신고자의 서비스 이용이 제한될 수 있으니 유의하시어 신중하게 신고해 주세요.
(이 회원이 작성한 모든 댓글과 커뮤니티 게시물이 보이지 않고, 알림도 오지 않습니다.)

미리보기

커뮤니티

      1,234

      badge 23.06.15

      글 등록

      카테고리를 선택해주세요.

      DEVOTEE를 활성화 시키면
      지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.

      버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.

      임시저장함에 저장되었습니다. 저장일시 : 2022.5.17 14:29:08

      임시저장함

      제목을 선택하시면 이어서 작성이 가능하며,
      최대 20건까지 저장합니다.
      컨텐츠 유형, 제목, 저장일시, 삭제로 이뤄진 임시저장 목록
      컨텐츠 유형 제목 저장일 삭제

      데보션 블로그 게재 요청

      CLOSE
      • *
      • *

      본인인증

      효율적인 데보션 서비스 이용 및
      고객님의 소중한 개인정보보호를 위해
      본인인증을 진행해주세요. 본인인증 미 진행 시 로그인이 제한됩니다.
      본인인증 실패

      본인인증 로그인에 실패하였습니다.
      회원이 아니시거나 본인인증 등록이
      완료되지 않은 사용자입니다.

      회원정보 연결

      Kotlin으로 iOS까지? 이게 되네… 되긴 해요!

      kimdocs 25.04.16
      6,913 9 3
      DEVOTEE 요약
      Kotlin Multiplatform은 Android와 iOS에서의 비즈니스 로직을 공통 코드로 공유하여 중복 구현을 줄이고 유지보수 부담을 덜기 위한 개발 도구입니다. 이를 이용하면 각 플랫폼의 고유 UI를 유지하면서 공통 로직을 공유할 수 있으며, 특히 Clean Architecture를 통해 프로젝트를 효율적으로 관리할 수 있습니다. Kotlin Multiplatform은 Android와 iOS를 함께 개발함으로써 플랫폼 간 일관성을 유지할 수 있지만, JVM 기반 라이브러리를 사용할 수 없는 제약이 있으며, iOS에서의 디버깅이 복잡하다는 단점이 있습니다.
      DEVOTEE 추천 블로그

      안녕하세요. 에이닷사업부 AI서비스개발본부 iOS개발팀 kimdocs입니다.🍀


      모바일 앱을 개발하다 보면,Android와 iOS에서 동일한 비즈니스 로직을 각각 따로 구현해야 하는 상황이 꽤 자주 생깁니다.

      서비스가 커지고 수정/변경이 반복될수록 플랫폼 간 로직 중복 및 불일치, 유지보수 부담이 점점 쌓여가죠.

      그러다 보니 자연스럽게 이런 고민이 생겼습니다...

      🤔 공통 로직을 하나의 코드로 공유할 수 있는 구조는 없을까?!

      그 해답을 찾기 위한 도구 중 하나로 Kotlin Multiplatform을 직접 검토해보게 되었습니다.


      Kotlin Multiplatform이 뭐지요?

      Kotlin Multiplatform은 하나의 공통 Kotlin 코드베이스여러 플랫폼 (Android, iOS, Web, Desktop 등)에 앱을 개발할 수 있도록 도와주는 크로스 플랫폼 개발도구에요.

      👉 공식 문서

      JetBrains에서 제공하는 SDK로, Flutter나 React Native처럼 크로스 플랫폼 개발을 지원한다는 점에서는 유사하지만,

      가장 큰 차이점은 기존 플랫폼 언어(Android는 Kotlin, iOS는 Swift)를 그대로 사용할 수 있다는 점이에요.


      🧐 UI는 각 플랫폼별로, 혹은 Compose로?!

      일반적으로 Kotlin Multiplatform에서는 UI는 각 플랫폼 고유 방식으로 개발하고,

      공통 비즈니스 로직만 공유하는 구조를 많이 사용해요.

      하지만 최근에는 Jetpack Compose를 확장한 Compose Multiplatform도 함께 주목받고 있습니다.

      이걸 활용하면 UI까지도 Kotlin으로 공유할 수 있기 때문에, 진정한 의미의 “한 번 작성해 두 플랫폼에서 동작하는 UI”가 가능해집니다.

      그럼에도 불구하고, UI를 각 플랫폼 언어로 구현하는 전략에도 분명한 장점이 있어요

      • 플랫폼 고유의 UX 가이드에 맞춘 구현이 용이 (예: iOS의 네이티브 제스처, 스타일)

      • 최신 플랫폼 기능을 제약 없이 활용 가능

      • 기존 네이티브 코드와의 통합이 자연스러움

      👉 이번 포스팅에서는 UI는 Android는 Kotlin, iOS는 Swift로 구현하고,

      비즈니스 로직만 Kotlin Multiplatform으로 공유하는 구조를 기준으로 설명할 예정이에요.


      Kotlin Multiplatform,  어떤 구조로 동작하나요?

      Kotlin Multiplatform에서는 공통 비즈니스 로직(예: API 통신, 데이터 가공, 도메인 처리 등)을

      shared 모듈에 Kotlin으로 작성하고, 이를 Android에서는 Gradle 의존성으로, iOS에서는 Framework로 가져다 씁니다.

      덕분에 중복 구현 없이 동일한 로직을 두 플랫폼에서 그대로 사용할 수 있게 됩니다.


      👀 자세히 살펴보기

      Kotlin Multiplatform 프로젝트는 구조상 Shared, Android App, iOS App 크게 세 가지 영역으로 나눌 수 있어요.

      KMM Architecture (1).png

      (1) Android App / iOS App 모듈

      이 영역은 실제로 Android와 iOS 앱으로 빌드되는 코드들이 위치한 부분이에요.

      구성 방식은 기존 네이티브 개발과 동일하기 때문에, Android 개발자나 iOS 개발자 모두 익숙하게 접근할 수 있습니다.

      • Android: XML, Jetpack Compose 등으로 UI를 구성하고, Activity, Fragment, Service 등 Android 컴포넌트를 그대로 사용합니다.

      • iOS: SwiftUI 또는 UIKit 기반의 뷰를 구성하며, UIViewController, NavigationStack 등 기존 iOS 개발 방식 그대로 진행됩니다.

      이 모듈들은 주로 UI 구성, 플랫폼 전용 기능 처리, 앱 진입점(MainActivity, AppDelegate 등)을 담당하게 됩니다.

      Kotlin Multiplatform에서는 이처럼 UI와 플랫폼 고유 코드는 그대로 유지하면서,

      공통 비즈니스 로직만 shared 모듈에서 가져다 사용하는 구조를 취하는 것이죠.

      기존 네이티브 개발의 장점을 그대로 유지하면서도, 공통 로직은 재사용할 수 있다는 점에서 두 플랫폼의 장점을 모두 취할 수 있는 형태라고 볼 수 있어요!

      (2) Shared 모듈

      플랫폼에 종속되지 않는 공통 로직을 담는 핵심 영역이에요.

      아래와 같은 공통 로직을 작성하게 됩니다:

      • API 통신 로직 (ex. Ktor)

      • 데이터 클래스 (DTO, Domain Model)

      • 비즈니스 로직 (UseCase, Repository 등)

      이 Shared 모듈이 바로 Kotlin Multiplatform의 중심이라고 할 수 있어요.KMM Architecture - 2.png

      🧱 Shared 모듈은 내부적으로도 세가지로 나뉘어요

      shared 모듈은 내부적으로도 세 가지 소스셋으로 나뉘어 있어요:

      • shared:commonMain

      • shared:androidMain

      • shared:iosMain

      우리가 주로 다루는 비즈니스 로직 대부분은 commonMain에 위치하게 됩니다.

      이 영역은 어느 플랫폼에도 종속되지 않는 순수 Kotlin 코드로 구성되며,

      Kotlin Multiplatform 프로젝트에서 가장 핵심적인 역할을 담당해요.

      • 네트워크 요청

      • 데이터 가공

      • 비즈니스 처리 로직

      이처럼 플랫폼에 구애받지 않는 공통 로직들은 전부 이곳에서 구현되며,

      Android와 iOS 등 모든 타겟에서 공유되어 실행됩니다.

      따라서 commonMain에는 플랫폼 의존적인 코드가 포함되지 않도록 주의해야 해요.


      🔧 공통 모듈로 모든 걸 처리할 수 있을까?

      Kotlin Multiplatform에서는 commonMain을 통해 플랫폼에 독립적인 공통 로직을 구현할 수 있지만,

      모든 로직을 공통화하는 건 현실적으로 어렵습니다.

      예를 들어:

      • 디바이스의 UUID 가져오기

      • 플랫폼별 저장소에 접근하기

      • OS 버전에 따른 조건 분기 등

      위 처럼 플랫폼 고유 기능이 필요한 경우에는 commonMain만으로는 구현에 한계가 생기죠.

      이럴 땐 플랫폼 전용 소스셋인 androidMain, iosMain을 활용하게 됩니다.

      • androidMain에서는 Kotlin으로 Android Framework API (SharedPreferences, LocationManager 등)에 접근할 수 있고,

      • iosMain에서는 Kotlin에서 iOS의 Foundation, UIKit, SwiftUI 등의 네이티브 기능을 사용할 수 있어요.


      🧩  expect / actual 패턴 !

      앞서 말했듯, shared 모듈에서는 기본적으로 플랫폼에 의존하지 않는 코드만 작성해야 해요.

      하지만 플랫폼 기능을 공통 코드 안에서 호출하고 싶은 상황도 분명 존재하죠.

      이런 경우에는 Kotlin의 expect / actual 키워드를 활용하면 됩니다.


      어떻게 동작하나요?
      1. commonMain(공통 코드)에서 expect 키워드로 함수나 클래스를 선언하고,

      2. 각 플랫폼 전용 소스셋(androidMain, iosMain)에서 actual 키워드로 해당 기능을 구현합니다.

      예를 들어, UUID를 생성해야 하는 상황을 볼까요?

      // shared:commonMain
      expect fun generateUUID(): String
      // shared:androidMain
      actual fun generateUUID(): String = UUID.randomUUID().toString()
       
      // shared:iosMain
      actual fun generateUUID(): String = NSUUID().UUIDString()

      이렇게 하면 공통 코드는 generateUUID()가 어떤 플랫폼에서 실행되든 동일한 방식으로 호출할 수 있고,

      실제 동작은 플랫폼별로 나뉘어 실행되는 구조가 됩니다.

      이렇게 분리된 구조를 통해 코드는 최대한 공유하고, UI나 플랫폼 특화 로직은 각 모듈에 맞게 유연하게 관리할 수 있다는 점이 Kotlin Multiplatform의 큰 장점이에요.


      어떻게 컴파일 될까?

      Kotlin Multiplatform은 하나의 공통 모듈을 다양한 플랫폼에서 사용할 수 있게 해주는데요,

      실제로 플랫폼별로 컴파일 방식은 조금씩 다르게 동작합니다.

      🤖 Android에서의 컴파일 방식

      Android에서는 공통 모듈이 JVM 바이트코드로 컴파일되고,

      이후 다른 Android 모듈들과 함께 APK에 패키징됩니다.

      • shared 모듈은 일반 Kotlin 모듈처럼 Gradle을 통해 Android 프로젝트에 통합되며,

      • Android 앱 내 다른 Kotlin 코드들과 동일한 방식으로 호출할 수 있어요.

      🍎 iOS에서의 컴파일 방식

      공통 모듈은 Kotlin/Native를 통해 네이티브 바이너리 형태로 컴파일되고,

      이 과정에서 LLVM 컴파일러를 활용해 iOS에서 실행 가능한 네이티브 코드를 생성해요.

      • 최종적으로 .framework 또는 XCFramework 형태로 빌드되며,

      • Xcode 프로젝트에 통합되어 Swift나 Objective-C 코드에서 직접 사용할 수 있게 됩니다.

      덕분에 iOS에서도 Kotlin으로 작성한 로직을 Swift 코드에서 함수 호출하듯 자연스럽게 사용할 수 있어요.


      Toy Project : 간단한 리스트앱을 만들면서 Kotlin Multiplatform이 어떻게 사용되는 지 살펴보기 🤔

      이번 예제에서는 실제로 에이닷에서 사용 중인 Deeplink 데이터를 사용해서 이를 화면에 보여주는 간단한 리스트 앱을 Kotlin Multiplatform으로 구성해봤어요.

      전체적인 토이프로젝트 구성은 다음과 같아요.

      Architecture

      아키텍쳐 (2).png

      Clean Architecture 기반으로 프로젝트를 구성했어요.

      공통 로직은 모두 shared 모듈에 구현하고, Android와 iOS App 모듈에는 각각 플랫폼에 맞는 View 로직만 분리해서 작성했습니다.


      🔗 Dependencies 설정

      iOS 의존성 관리는 SPM (Swift Package Manager) 혹은 Cocoapods, Android 의존성관리는 Gradle로 진행했어요.

      • DI: Koin (Kotlin/Swift 모두 지원)

      • Networking: Ktor + kotlinx.serialization

      • Database: SQLDelight

      • Logging: expect/actual 로직으로 분기

      • 비동기 처리: Coroutine + Flow, Swift에서는 SKIE로 연동

      😊 네트워크를 통해 받아온 리스트 데이터를 UI에 어떻게 연결하고 보여주는지에 초점을 맞춰 작성해볼게요 !!


      1️⃣ Networking 구성

      Kotlin Multiplatform에서 네트워크 통신을 구현할 때, 기존 Android에서 자주 사용하던 Retrofit은 사용할 수 없습니다.

      그 이유는 Retrofit이 JVM 기반 라이브러리이기 때문이에요.

      즉, Android에서는 문제 없이 작동하지만, iOS에서는 컴파일 자체가 불가능하죠.

      그래서 Kotlin Multiplatform에서는 멀티플랫폼을 지원하는 Ktor를 네트워크 클라이언트로 사용하는 것이 일반적입니다.

      shared 모듈 내의 appModule에서 HttpClient를 구성하여 DI로 주입할 수 있도록 설정했어요.


      appModule.kt

      fun init(appDeclaration: KoinAppDeclaration = {}) = startKoin {
          appDeclaration()
          modules(appModule())
      }
       
      fun initKoin() = init {}
       
      fun appModule() = module {
          single { createJson() }
          single { createHttpClient(get()) }
          single { DeeplinkApiService(get()) }
          single<DeeplinkListRepository> { DeeplinkListRepositoryImpl(get()) }
          single<ExecuteFetchDeeplinkListUseCase> {ExecuteFetchDeeplinkListUseCase(get())}
      }
       
       
      fun createJson(): Json = Json {
          prettyPrint = true
          isLenient = true
          ignoreUnknownKeys = true
      }
       
      fun createHttpClient(json: Json): HttpClient = HttpClient {
          install(ContentNegotiation) {
              json(json)
          }
          install(Logging) {
              logger = object : Logger {
                  override fun log(message: String) {
                      println("Ktor Log: $message")
                  }
              }
              level = LogLevel.ALL
          }
      }


      💡 멀티플랫폼 대응을 위한 엔진 설정

      HttpClient는 내부적으로 플랫폼에 맞는 네트워크 엔진(Ktor Engine)을 사용해야 합니다.

      이를 위해 expect/actual 패턴을 이용해 플랫폼 별 엔진을 분리해서 설정합니다.


      commonMain > KtorEngine.kt

      expect fun ktorEngineModule(): Module

      androidMain > KtorEngine.android.kt

      actual fun ktorEngineModule() = module {
          single { Android.create()  }
      }

      iosMain > KtorEngine.ios.kt

      actual fun ktorEngineModule() = module {
          single { Darwin.create() }
      }

      Darwin이란?

      Ktor의 iOS 플랫폼용 네트워크 엔진으로 Kotlin/Native에서 iOS의 네이티브 네트워크 API를 사용할 수 있도록 구현된 Ktor의 내부 엔진 중 하나

      • iOS의 URLSession을 기반으로 네트워크 요청을 수행함

      • Kotlin/Native 환경에서도 네이티브와 동일한 수준의 성능과 안정성을 보장

      앞서 네트워크 통신을 위한 Ktor 설정과 DI 설정을 마쳤다면

      이제 실제로 API 통신을 통해 리스트를 가져와볼게요.


      큰 흐름은 다음과 같아요!

      (화살표는 데이터의 흐름을 나타냅니다.)

      KMM Architecture (2).png

      2️⃣ DeeplinkApiService – API 호출

      shared:commonMain

      class DeeplinkApiService(private val httpClient: HttpClient) {
          suspend fun fetchData(): DeeplinkResponse? {
              val url = "https://url주소"
              return try {
                  val response: HttpResponse = httpClient.get(url)
                  response.body()
              } catch (e: Exception) {
                  null
              }
          }
      }

      Ktor + kotlinx.serialization을 사용해 JSON을 바로 모델로 디코딩합니다.


      3️⃣ Repository – API를 캡슐화하여 도메인 계층에 전달

      shared:commonMain 

      domain > repository

      interface DeeplinkListRepository {
          suspend fun fetchDeepLinkList() : DeeplinkResponse?
      }

      data > impl

      class DeeplinkListRepositoryImpl(
          private val apiService: DeeplinkApiService
      ): DeeplinkListRepository {
          override suspend fun fetchDeepLinkList(): DeeplinkResponse? {
              return apiService.fetchData()
          }
      }


      4️⃣ UseCase – ViewModel이 직접 호출할 유즈케이스 정의

      shared:commonMain

      class ExecuteFetchDeeplinkListUseCase(
          private val deeplinkListRepository: DeeplinkListRepository
      ) {
          private val _deeplinkListFlow = MutableStateFlow(emptyList<DeeplinkUIModel>())
          val deeplinkListFlow: StateFlow<List<DeeplinkUIModel>> = _deeplinkListFlow.asStateFlow()
       
          suspend operator fun invoke() {
              val result = deeplinkListRepository.getDeeplinkList()
              val mapped = result.map { it.toUIModel() }
              _deeplinkListFlow.value = mapped
          }
      }


      5️⃣ ViewModel – Flow를 구독하여 UI 상태 업데이트

      🟢 Android – ComposeApp에서의 ViewModel

      Android에서는 공통 모듈에서 정의한 UseCase가 제공하는 Flow<List<DeeplinkUIModel>>를 ViewModel에서 직접 구독하여 상태를 관리합니다.

      class ListViewModel(
          private val executeFetchDeeplinkListUseCase: ExecuteFetchDeeplinkListUseCase
      ) : ViewModel() {
          private val tag = "[ListViewModel]"
          private val safeScope = viewModelScope + CoroutineExceptionHandler { _, throwable ->
              Log.d(tag, "CoroutineException - $throwable")
          }
       
          private val _deeplinkList = MutableStateFlow<List<DeeplinkUIModel>>(emptyList())
          val deeplinkList = _deeplinkList.asStateFlow()
       
          init {
              Log.d("ListViewModel", "init")
              fetchDeeplinkList()
          }
       
           fun fetchDeeplinkList() {
              safeScope.launch {
                  executeFetchDeeplinkListUseCase().collect{
                      _deeplinkList.value = it
                  }
              }
          }
      }

      viewModelScope + CoroutineExceptionHandler 조합을 사용해 예외 상황도 안전하게 처리하고 있어요.

      UI에서는 StateFlow를 collectAsState()로 구독해 상태를 렌더링합니다.


      🍎 iOS – Swift ViewModel (with SKIE)

      iOS에서는 Kotlin의 Flow나 StateFlow를 직접 사용할 수 없는데요. 🥲

      이를 해결하기 위해  SKIE (Swift-Kotlin Interop Enhancer) 라는 도구를 사용합니다.

      SKIE는 Kotlin의 Flow를 Swift 코드에서 async/await + Collector 방식으로 구독할 수 있게 도와주는 브릿지 역할을 합니다.

      덕분에 Swift의 @Published와 연동하여 UI 상태를 자연스럽게 갱신할 수 있어요.

      class ListViewModel: ObservableObject {
          @LazyKoin private var executeFetchDeeplinkListUseCase: ExecuteFetchDeeplinkListUseCase
           
          @Published private(set) var deepLinkList: [DeeplinkUIModel] = []
           
          init() {
              fetchDeepLinkList()
          }
       
          func fetchDeepLinkList() {
              Task {
                  do {
                      try await executeFetchDeeplinkListUseCase.invoke()
                          .collect(collector: Collector<[DeeplinkUIModel]>{ deepLinkList in
                              DispatchQueue.main.async { [weak self] in
                                  self?.deepLinkList = deepLinkList
                              }
                          })
                  } catch {
                      // error handling
                  }
              }
          }
      }

      Collector<T>는 SKIE에서 제공하는 구조로, Kotlin의 Flow를 Swift에서 비동기적으로 구독할 수 있도록 도와주는 역할을 해요.

      다만 이 Collector의 콜백이 어떤 스레드에서 실행될지 보장되지 않는다는 점은 주의해야 해요.

      그래서 UI 상태를 업데이트할 땐, DispatchQueue.main.async로 명시적으로 메인 스레드에서 처리해주는 게 안전합니다.


      6️⃣ View

      🟢 Jetpack Compose – Android에서 보여주기

      @Composable
      fun DeeplinkListScreen(viewModel: ListViewModel = koinViewModel()) {
          val items by viewModel.items.collectAsState()
       
          LazyColumn {
              items(items) { item ->
                  Column(Modifier.padding(16.dp)) {
                      Text(text = item.title, style = MaterialTheme.typography.titleMedium)
                      Text(text = item.url, style = MaterialTheme.typography.bodySmall)
                  }
              }
          }
      }

      🍎 SwiftUI View - iOS에서 보여주기

      struct ContentView: View {
          @ObservedObject var viewModel: ListViewModel
       
          var body: some View {
              List(viewModel.items, id: \.id) { item in
                  VStack(alignment: .leading) {
                      Text(item.title)
                          .font(.headline)
                      Text(item.url)
                          .font(.subheadline)
                          .foregroundColor(.gray)
                  }
              }
              .navigationTitle("Deeplink 리스트")
          }
      }


      Kotlin Multiplatform을 직접 적용해보며

      ✅ 장점

      • 중복되는 비즈니스 로직을 공통 모듈로 묶어, 개발 리소스를 효율적으로 활용할 수 있어요.

      • 아키텍처 측면에서도 레이어 분리를 구조적으로 잘 유지할 수 있어 유지보수에 유리합니다.

      • UI는 각 플랫폼의 네이티브 방식으로 구현할 수 있어, 사용자 친화적인 UX 설계가 가능합니다.

      ⚠️ 단점

      • 공통 모듈에서는 JVM 기반 라이브러리를 쓸 수 없기 때문에, Kotlin Multiplatform을 지원하는 라이브러리로만 구성해야 하는 제한이 있어요.

      • iOS에서 디버깅이 쉽지 않아요. 크래시 원인을 추적하는 데 시간이 꽤 걸렸습니다.

      • 아직 iOS에서는 Coroutines나 Flow를 완벽하게 대응하지 못하기 때문에, 별도 래핑이 필요합니다. (SKIE 같은 라이브러리를 사용할 수 있지만, 제약이 분명 존재합니다.)

      • 그리고 아직은 Kotlin Multiplatform이 상대적으로 새로운 기술이라 그런지… 자료가 부족한 점도 아쉬움으로 남네요. 😢

      🙋🏻 느낀점

      아직 상용 서비스에 바로 적용하기엔 다소 아쉬운 부분이 있었지만,

      간단한 구조의 앱이나 프로토타입 수준에서는 충분히 시도해볼 만한 가치가 있다고 느꼈습니다.


      특히 기존 Android 프로젝트에서 아키텍처가 잘 분리된 구조라면,

      비즈니스 로직을 공통화하고 iOS는 UI만 따로 개발하는 방식으로 마이그레이션이 가능하기 때문에,

      개발 비용과 시간을 효율적으로 절감할 수 있는 여지가 충분히 있어요.


      물론, Flutter처럼 처음부터 모든 것을 크로스 플랫폼으로 개발하는 방법도 있지만,

      Kotlin Multiplatform은 기존 코드를 기반으로 점진적으로 확장해나갈 수 있다는 점에서 훨씬 실용적인 접근이라는 생각이 들었습니다.

      (다만, 공통 모듈에서 JVM 기반 라이브러리를 사용할 수 없다는 점은 실무에서 꽤나 큰 제약으로 느껴지네요 😢)


      앞으로 Kotlin Multiplatform 도입을 고려하시는 분들께 실제 적용 시 고려할 점과 장단점, 느낌점을 공유하고 싶어 작성한 글인데요,

      조금이라도 도움이 되셨으면 좋겠습니다 🙌 💛 읽어주셔서 감사합니다!

      댓글 0

      DEVOTEE를 활성화 시키면
      지금 작성한 댓글에 AI가 댓글을 달아줍니다.

      kimdocs 님의 최신 블로그

      더보기

      DEVOTEE 추천 블로그

      동영상 기고하기