Skip to content

Instantly share code, notes, and snippets.

@Achoo-kr
Last active November 14, 2024 13:20
Show Gist options
  • Save Achoo-kr/5d8936d12e71028fcc4a7c5e078ca038 to your computer and use it in GitHub Desktop.
Save Achoo-kr/5d8936d12e71028fcc4a7c5e078ca038 to your computer and use it in GitHub Desktop.
swift-composable-architecture readme Korean translation

The Composable Architecture

CI Slack

The Composable Architecture (이하 TCA) 는 합성(Composition), 테스트, 인체공학(Ergonomics)을 고려하여 일관성 있고 이해하기 쉬운 방식으로 애플리케이션을 구축하기 위한 라이브러리입니다. SwiftUI, UIKit 등에서 사용할 수 있으며 Apple의 어느 플랫폼(iOS, macOS, tvOS, watchOS)에서도 사용 가능합니다.

Composable Architecture란?

이 라이브러리는 다양한 목적과 복잡성을 가진 애플리케이션을 빌드하는 데 사용할 수 있는 몇 가지 핵심 도구를 제공합니다. 그리고 그 도구들은 다음과 같이 애플리케이션을 빌드할 때 일상적으로 직면하는 많은 문제를 해결하기 위해 따를 수 있는 매력적인 스토리를 제공합니다.

  • 상태 관리
    간단한 값 타입을 사용하여 애플리케이션의 상태를 관리하고, 여러 화면에서 상태를 공유하여 한 화면의 변화를 다른 화면에서 즉시 관찰(Observe)할 수 있도록 하는 방법.

  • 합성(Composition)
    큰 기능을 작은 구성 요소(Component)로 분해하여 각각의 독립된 모듈로 추출한 후, 쉽게 다시 붙여 기능을 구성하는 방법.

  • 사이드 이펙트
    애플리케이션의 특정 부분이 최대한 테스트 가능하고 이해하기 쉬운 방식으로 외부 세계와 대화하도록 하는 방법.

  • 테스트
    아키텍처에 구축된 기능을 테스트할 뿐만 아니라 여러 부분으로 구성된 기능에 대한 통합 테스트를 작성하고 E2E 테스트를 작성하여 사이드 이펙트가 애플리케이션에 어떤 영향을 미치는지 이해하는 방법. 이를 통해 비즈니스 로직이 예상한 대로 작동하고 있는지 확실하게 보장할 수 있습니다.

  • 인체공학(Ergonomics)
    상기(上記)의 모든 것을, 가능한 한 적은 개념과 구성요소로 이루어진 간단한 API로 실현하는 방법.

더 알아보기

The Composable Architecture 는 Brandon WilliamsStephen Celis 가 진행하는 함수형 프로그래밍과 Swift 언어를 탐구하는 시리즈인 Point-Free 의 여러 에피소드에 걸쳐 설계되었습니다.

모든 에피소드를 이 곳 에서 시청할 수 있을 뿐만 아니라, 아키텍처를 처음부터 다시 살펴보는 멀티 파트 투어 또한 시청할 수 있습니다.

video poster image

예제

Screen shots of example applications

이 저장소에는 일반적인 문제부터 복잡한 문제까지 TCA로 해결하는 방법을 보여주기 위한 수많은 예제가 있습니다. 이 디렉토리 에서 모든 예제를 확인할 수 있으며 다음의 예제를 포함하고 있습니다.

좀 더 실용적인 것을 찾고 계시다면, SwiftUI와 컴포저블 아키텍처로 제작된 iOS 단어 검색 게임인 isowords의 소스 코드를 확인해 보세요.

기본적인 사용 방법

참고 단계별로 대화형 튜토리얼을 진행하려면, Meet the Composable Architecture 을 확인하세요.

TCA를 사용하여 기능을 만드려면 다음과 같이 도메인을 모델링하는 몇 가지 타입과 값을 정의합니다.

  • 상태: 한 기능의 비즈니스 로직을 수행하고 UI를 렌더링하는 데에 필요한 데이터를 설명하는 타입.

  • Action: 사용자 액션(행동), 알림(Notification), 이벤트 등 기능에서 발생할 수 있는 모든 액션을 나타내는 타입.

  • Reducer: 액션이 주어졌을 때 앱의 현재 상태를 다음 상태로 변형시키는 방법을 기술하는 함수입니다. 또한 리듀서는 API와 같은 실행(run)되어야하는 하는 이펙트를 반환해야하는데, 이는 Effect 라는 값을 반환함으로써 수행할 수 있습니다.

  • Store: 실제로 기능이 작동하는 곳, 런타임입니다. 모든 사용자 액션을 스토어로 전송하면 스토어는 리듀서와 Effect를 실행할 수 있고, 상태 변화를 관찰하여 UI를 업데이트할 수 있습니다.

이렇게 하면 기능의 테스트 가능성을 즉시 확보할 수 있고 크고 복잡한 기능을 서로 연결될 수 있는 작은 도메인으로 나눌 수 있다는 이점이 있습니다.

기초적인 예시로, 숫자가 나오고 "+" 및 "-" 으로 그 숫자를 증감시킬 수 있는 UI를 예로 들어보겠습니다. 그리고 더 재미있게 만들기 위해, 사실 버튼이라는 하나의 버튼을 더 만들어, 해당 숫자에 대한 랜덤으로 흥미로운 사실을 불러오도록 API 요청을 하고 이를 Alert로 보여주도록 하겠습니다.

이 기능을 구현하기 위해 도메인과 기능의 동작(behavior)을 담는 새로운 타입을 'Reducer'를 준수하여 생성합니다:

import ComposableArchitecture

struct Feature: Reducer {
}

여기에서는 현재 카운트를 나타내는 정수와 표시하려는 Alert의 타이틀을 나타내는 옵셔널 스트링으로 구성된 기능의 상태(State) 타입을 정의해야 합니다. (nil은 알림을 표시하지 않음을 의미하므로 옵셔널):

struct Feature: Reducer {
  struct State: Equatable {
    var count = 0
    var numberFactAlert: String?
  }
}

또한 기능의 Action 타입을 정의해야 합니다. 감소 버튼, 증가 버튼 또는 사실 버튼 탭과 같은 명확고 바로 떠오르는 동작이 있습니다. 하지만 사용자가 알림을 해제하는 동작이나 fact API 요청에서 응답을 받을 때 발생하는 동작과 같이 다소 명확하지 않는, 떠올리기 힘든 동작도 있습니다:

struct Feature: Reducer {
  struct State: Equatable { /* ... */ }
  enum Action: Equatable {
    case factAlertDismissed
    case decrementButtonTapped
    case incrementButtonTapped
    case numberFactButtonTapped
    case numberFactResponse(String)
  }
}

다음으로 기능에 대한 실제 로직과 동작을 처리하는 reduce 메서드를 구현합니다. 이 메서드는 현재 상태를 다음 상태로 변경하는 방법과 어떤 이펙트를 실행해야 하는지를 설명합니다. 일부 액션은 이펙트를 실행할 필요가 없으며, 이를 나타내기 위해 '.none'을 반환할 수 있습니다:

struct Feature: Reducer {
  struct State: Equatable { /* ... */ }
  enum Action: Equatable { /* ... */ }
  
  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .factAlertDismissed:
      state.numberFactAlert = nil
      return .none

    case .decrementButtonTapped:
      state.count -= 1
      return .none

    case .incrementButtonTapped:
      state.count += 1
      return .none

    case .numberFactButtonTapped:
      return .run { [count = state.count] send in
        let (data, _) = try await URLSession.shared.data(
          from: URL(string: "http://numbersapi.com/\(count)/trivia")!
        )
        await send(
          .numberFactResponse(String(decoding: data, as: UTF8.self))
        )
      }

    case let .numberFactResponse(fact):
      state.numberFactAlert = fact
      return .none
    }
  }
}

마지막으로 기능을 표시하는 뷰를 정의합니다. 이 뷰는 상태의 모든 변경 사항을 관찰하고 다시 렌더링할 수 있도록 StoreOf<Feature>를 보유하며, 상태가 변경되도록 모든 사용자 액션을 스토어로 전송할 수 있습니다. 또한 '.alert' 뷰 모디파이어가 요구하는 identifiable을 충족시킬 수 있도록 fact Alert를 struct로 감싸는 형태로 만들어야합니다.

struct FeatureView: View {
  let store: StoreOf<Feature>

  var body: some View {
    WithViewStore(self.store, observe: { $0 }) { viewStore in
      VStack {
        HStack {
          Button("") { viewStore.send(.decrementButtonTapped) }
          Text("\(viewStore.count)")
          Button("+") { viewStore.send(.incrementButtonTapped) }
        }

        Button("Number fact") { viewStore.send(.numberFactButtonTapped) }
      }
      .alert(
        item: viewStore.binding(
          get: { $0.numberFactAlert.map(FactAlert.init(title:)) },
          send: .factAlertDismissed
        ),
        content: { Alert(title: Text($0.title)) }
      )
    }
  }
}

struct FactAlert: Identifiable {
  var title: String
  var id: String { self.title }
}

이 스토어에서 UIKit 컨트롤러를 구동시키는 것도 간단합니다. UI를 업데이트하고 Alert를 표시하기 위해 viewDidLoad에서 스토어를 구독합니다. 코드는 SwiftUI 버전보다는 약간 깁니다. 궁금하시다면 다음을 펼쳐 확인할 수 있습니다:

펼쳐보기
class FeatureViewController: UIViewController {
  let viewStore: ViewStoreOf<Feature>
  var cancellables: Set<AnyCancellable> = []

  init(store: StoreOf<Feature>) {
    self.viewStore = ViewStore(store, observe: { $0 })
    super.init(nibName: nil, bundle: nil)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func viewDidLoad() {
    super.viewDidLoad()

    let countLabel = UILabel()
    let incrementButton = UIButton()
    let decrementButton = UIButton()
    let factButton = UIButton()

    // Omitted: Add subviews and set up constraints...

    self.viewStore.publisher
      .map { "\($0.count)" }
      .assign(to: \.text, on: countLabel)
      .store(in: &self.cancellables)

    self.viewStore.publisher.numberFactAlert
      .sink { [weak self] numberFactAlert in
        let alertController = UIAlertController(
          title: numberFactAlert, message: nil, preferredStyle: .alert
        )
        alertController.addAction(
          UIAlertAction(
            title: "Ok",
            style: .default,
            handler: { _ in self?.viewStore.send(.factAlertDismissed) }
          )
        )
        self?.present(alertController, animated: true, completion: nil)
      }
      .store(in: &self.cancellables)
  }

  @objc private func incrementButtonTapped() {
    self.viewStore.send(.incrementButtonTapped)
  }
  @objc private func decrementButtonTapped() {
    self.viewStore.send(.decrementButtonTapped)
  }
  @objc private func factButtonTapped() {
    self.viewStore.send(.numberFactButtonTapped)
  }
}

예를 들어 앱의 엔트리 포인트에 이 뷰를 표시할 준비가 되면 스토어를 구성할 수 있습니다. 이는 애플리케이션을 시작할 초기 상태와 애플리케이션을 가동시킬 리듀서를 지정하여 수행할 수 있습니다:

import ComposableArchitecture

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      FeatureView(
        store: Store(initialState: Feature.State()) {
          Feature()
        }
      )
    }
  }
}

이 정도면 화면에 무언가를 띄워놓고 놀기에 충분합니다. 물론 바닐라 SwiftUI(순수한 SwiftUI) 방식으로 이 작업을 수행하는 것보다 몇 단계가 더 필요하지만 이에는 몇 가지 이점이 있습니다. 몇몇 observable objects와 UI 컴포넌트의 다양한 액션 클로저에 로직을 분산시키는 대신 상태의 변이를 적용하기 위한 일관된 방법을 제공합니다. 또한 사이드 이펙트를 간결하게 표현할 수 있는 방법도 제공합니다. 그리고 추가 작업을 많이 하지 않고도 effect를 포함한 로직을 즉시 테스트할 수 있습니다.

테스트

참고 테스트에 대한 더 깊은 정보들은, 테스트 만을 집중해서 다룬 아티클을 참고해주세요.

테스트를 위해서는 Store와 같은 형태로 작성되는 TestStore를 사용하는데, action이 전송되었을 때 기능이 어떻게 변화하는 지를 확인할 수 있도록 추가 작업을 수행합니다.

@MainActor
func testFeature() async {
  let store = TestStore(initialState: Feature.State()) {
    Feature()
  }
}

테스트 스토어를 작성하면, 이를 사용하여 전체 유저 플로우의 각 단계들에 대한 Assertion을 만들 수 있습니다. 각 단계에서 상태가 예상한 대로 변경되었음을 증명해야 합니다. 예를 들어 증가 및 감소 버튼을 탭하는 사용자 흐름을 시뮬레이션할 수 있습니다:

// Test that tapping on the increment/decrement buttons changes the count
await store.send(.incrementButtonTapped) {
  $0.count = 1
}
await store.send(.decrementButtonTapped) {
  $0.count = 0
}

또한 단계에 따라서 effect가 실행되고 데이터가 store로 피드백되는 경우에는 이에 대해 assert 해야 합니다. 예를 들어, 사용자가 사실 버튼을 탭하는 것을 시뮬레이션할 경우 사실을 포함한 응답이 다시 반환되고 그에 따라 알림이 표시될 것을 예상합니다:

Further, if a step causes an effect to be executed, which feeds data back into the store, we must assert on that. For example, if we simulate the user tapping on the fact button we expect to receive a fact response back with the fact, which then causes the alert to show:

await store.send(.numberFactButtonTapped)

await store.receive(.numberFactResponse(???)) {
  $0.numberFactAlert = ???
}

하지만 어떤 fact가 우리에게 돌아오는지 어떻게 알 수 있을까요?

현재 우리 리듀서는 실제값을 받기 위해 effect를 사용하여 API 서버에 접근하고 있습니다. 이는 즉, 우리가 그 동작을 제어할 방법이 없다는 것을 의미합니다. 그리고 이에 대한 테스트를 쓰기 위해서 우리는 인터넷 연결과 API 서버가 사용가능한 지 여부에 따라 변하는 불안정한 상황에 노출되어 있습니다.

디바이스에서 애플리케이션을 실행할 때는 실제 종속성(dependency)을 사용하지만 테스트에는 모의 종속성을 사용할 수 있도록 이 종속성을 리듀서에 전달하는 것이 더 좋습니다. 이를 위해 'Feature' 리듀서에 프로퍼티를 추가하면 됩니다:

struct Feature: Reducer {
  let numberFact: (Int) async throws -> String
  // ...
}

그럼 이제 이것을 reduce 구현에 사용할 수 있습니다.

case .numberFactButtonTapped:
  return .run { [count = state.count] send in 
    let fact = try await self.numberFact(count)
    await send(.numberFactResponse(fact))
  }

그리고 애플리케이션의 엔트리 포인트에서 실제 API 서버와 상호 작용하는 종속성을 제공할 수 있습니다:

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      FeatureView(
        store: Store(initialState: Feature.State()) {
          Feature(
            numberFact: { number in
              let (data, _) = try await URLSession.shared.data(
                from: URL(string: "http://numbersapi.com/\(number)")!
              )
              return String(decoding: data, as: UTF8.self)
            }
          )
        }
      )
    }
  }
}

하지만 테스트에선 예측 가능하고 정해져있는(결정되어있는) 사실을 즉시 반환하는 mock dependency(모의 의존성)을 사용할 수 있습니다:

@MainActor
func testFeature() async {
  let store = TestStore(initialState: Feature.State()) {
    Feature(numberFact: { "\($0) is a good number Brent" })
  }
}

이러한 약간의 초기 작업을 통해 사용자가 사실 버튼을 누른 후 종속성으로부터 응답을 받아 Alert를 트리거한 후에 dismiss하는 시뮬레이션을 통해 테스트를 완료할 수 있습니다:

await store.send(.numberFactButtonTapped)

await store.receive(.numberFactResponse("0 is a good number Brent")) {
  $0.numberFactAlert = "0 is a good number Brent"
}

await store.send(.factAlertDismissed) {
  $0.numberFactAlert = nil
}

또한, 애플리케이션에서 numberFact 의존성을 사용하는 인체공학적 측면도 개선할 수 있습니다. 시간이 지남에 따라 응용 프로그램은 많은 기능을 갖게 되며, 기능 중 일부는 numberFact에 접근해야할 수도 있습니다. 이때 모든 레이어를 통해 명시적으로 numberFact를 전달하는 것은 귀찮아질 수 있습니다. dependency를 라이브러리에 '등록'함으로써 애플리케이션의 어느 레이어에서나 즉시 dependency를 이용할 수 있도록 하는 프로세스가 있습니다.

참고 의존성에 대한 더 깊은 정보들은, 의존성 만을 집중해서 다룬 아티클을 참고해주세요.

number fact 함수를 새로운 타입으로 감싸는 것에서부터 시작합니다:

struct NumberFactClient {
  var fetch: (Int) async throws -> String
}

그런 다음 클라이언트를 시뮬레이터나 디바이스에서 애플리케이션을 실행할 때 사용할 라이브 값을 지정해야 하는 'DependencyKey' 프로토콜에 준수하도록 함으로써 종속성 관리 시스템에 등록합니다:

extension NumberFactClient: DependencyKey {
  static let liveValue = Self(
    fetch: { number in
      let (data, _) = try await URLSession.shared
        .data(from: URL(string: "http://numbersapi.com/\(number)")!
      )
      return String(decoding: data, as: UTF8.self)
    }
  )
}

extension DependencyValues {
  var numberFact: NumberFactClient {
    get { self[NumberFactClient.self] }
    set { self[NumberFactClient.self] = newValue }
  }
}

이 약간의 초기 작업을 수행하면 @Dependency 프로퍼티 래퍼를 사용하여 어떠한 기능에서도 의존성을 즉시 활용할 수 있습니다:

 struct Feature: Reducer {
-  let numberFact: (Int) async throws -> String
+  @Dependency(\.numberFact) var numberFact-  try await self.numberFact(count)
+  try await self.numberFact.fetch(count)
 }

이 코드는 이전과 동일하게 작동하지만 더 이상 기능의 리듀서를 구성할 때 종속성을 명시적으로 전달할 필요가 없습니다. 프리뷰, 시뮬레이터 또는 장치에서 앱을 실행할 때 라이브 종속성이 리듀서에 제공되고 테스트에서는 테스트 종속성이 제공됩니다.

즉, 애플리케이션의 엔트리 포인트는 더 이상 종속성을 구성할 필요가 없습니다:

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      FeatureView(
        store: Store(initialState: Feature.State()) {
          Feature()
        }
      )
    }
  }
}

또한 테스트 스토어는 종속성을 지정하지 않고도 구성할 수 있지만 테스트를 위해 필요한 종속성은 override할 수 있습니다:

let store = TestStore(initialState: Feature.State()) {
  Feature()
} withDependencies: {
  $0.numberFact.fetch = { "\($0) is a good number Brent" }
}

// ...

여기까지가 TCA에서 기능을 만들고 테스트하기 위한 기본적인 절차였습니다. 합성(Composition), 모듈화, 적응성(Adaptability), 복잡한 effect와 같이 탐구해야할 것은 훨씬 더 많습니다. 예제 디렉토리에는 심화된 사용법들을 탐구할 수 있는 많은 프로젝트들이 있습니다.

문서

releases 와 main 도큐먼트들은 다음 링크에서 확인할 수 있습니다.

다른 버전

문서에는 TCA를 능숙하게 다루는 데에 도움이 될만한 아티클들이 많이 게재되어 있습니다:

커뮤니티

TCA에 대해 논의하고 싶거나 특정 문제를 해결하는 데 사용하는 방법에 대해 궁금한 점이 있다면 Point-Free 애호가와 함께 논의할 수 있는 다양한 장소가 있습니다:

설치

ComposableArchitecture 를 package dependency에 추가하여 엑스코드 프로젝트에 추가할 수 있습니다.

  1. File 메뉴에서, Add Packages... 에 들어갑니다.
  2. package repository의 URL을 입력하는 텍스트필드에 "https://github.com/pointfreeco/swift-composable-architecture" 를 입력합니다.
  3. 다음은 프로젝트 구성에 따라 달라집니다:
    • 만약 라이브러리에 접근해야하는 단일 애플리케이션이 있다면 ComposableArchitecture 을 직접 애플리케이션에 추가합니다.

    • 만약 여러 Xcode 타겟에서 이 라이브러리를 사용하고 싶다면, 혹은 Xcode 타겟과 SPM 타겟을 섞고싶다면, ComposableArchitecture에 의존하는 공유 프레임 워크를 만들고 모든 타겟에서 해당 프레임워크에 의존해야합니다. 이 예로 Tic-Tac-Toe 데모 애플리케이션을 체크하세요. 이는 많은 기능을 모듈로 분할하고 tic-tac-toe Swift package를 사용하여 정적 라이브러리를 사용하고 있습니다.

부가 라이브러리(Companion Libraries)

TCA는 확장성을 염두에 두고 만들어졌으며, 애플리케이션을 향상시키기 위해 사용할 수 있는 커뮤니티 지원 라이브러리는 다음과 같습니다:

라이브러리에 컨트리뷰트하는 것에 관심이 있다면, PR을 날려주세요!

FAQ

  • TCA를 Elm, Redux 등과 비교했을 때 어떤 점이 다른가요?

    펼쳐서 답변 보기 TCA는 Elm 아키텍처(TEA)와 Redux에 의해 대중화된 아이디어를 기반으로 구축되었지만, Swift와 Apple 플랫폼에서 친숙하게 느껴지도록 만들어졌습니다.

    어떤 면에서 TCA는 다른 라이브러리보다 조금 더 의견이 강하다고 할 수 있습니다. 예를 들어, Redux는 Side Effect를 실행하는 방법에 대해 규정이 없지만, TCA는 모든 Side Effect를 Effect 타입으로 모델링하고 에서 반환해야 합니다.

    다른 점에서는 TCA는 다른 라이브러리보다 조금 느슨하다고 할 수 있습니다. 예를 들어 Elm 는 Cmd 타입을 통해 어떤 종류의 Effect를 작성할 수 있는지를 제어하지만, TCA에서는 Effect가 Combine Publisher Protocol을 준수하고 있기 때문에 어떤 종류의 Effect에도 탈출구를 제공(escape hatch)합니다.

    그리고 Redux, Elm 또는 대부분의 다른 라이브러리에서는 중시하지 않지만 TCA에서는 우선순위가 높은 특정 사항도 있습니다. 예를 들어, 합성(Composition)은 큰 기능을 작은 단위로 분해하고 서로 연결이 가능하도록 하는 프로세스는 TCA에 있어서 매우 중요한 부분입니다. 이는 Scope와 같은 리듀서 빌더와 연산자를 통해 수행되며, 복잡한 기능을 처리하는 데 도움이 될 뿐만 아니라 모듈화를 통해 코드 기반을 더 잘 분리하고 컴파일 시간을 개선할 수 있습니다.

크레딧과 감사인사

다음 분들은 초기 단계에서 라이브러리에 대한 피드백을 주셨고 지금의 라이브러리를 만드는 데 도움을 주셨습니다:

Paul Colton, Kaan Dedeoglu, Matt Diephouse, Josef Doležal, Eimantas, Matthew Johnson, George Kaimakas, Nikita Leonov, Christopher Liscio, Jeffrey Macko, Alejandro Martinez, Shai Mishali, Willis Plummer, Simon-Pierre Roy, Justin Price, Sven A. Schmidt, Kyle Sherman, Petr Šíma, Jasdev Singh, Maxim Smirnov, Ryan Stone, Daniel Hollis Tavares, 그리고 모든 Point-Free 구독자 여러분들 😁.

많은 이상한 SwiftUI 문제를 해결하고 최종 API를 개선하는 데 도움을 준 Chris Liscio에게 특별히 감사드립니다.

그리고 Shai MishaliCombineCommunity 프로젝트 덕분에, 'Effect"에서 델리게이트와 콜백 기반 API를 연결하는 데에 사용하는 Publishers.Create 를 구현하여 서드 파티 프레임워크와 훨씬 쉽게 인터페이스할 수 있게 되었습니다.

다른 라이브러리

TCA는 ElmRedux 와 같은 다른 라이브러리에서 시작한 아이디어를 기반으로 고안되었습니다.

Swift 및 iOS 커뮤니티에는 많은 아키텍처 라이브러리가 있습니다. 이들 각각은 TCA와는 다른 자체적인 우선순위와 절충안을 가지고 있습니다.

라이센스

본 라이브러리는 MIT 라이센스로 공개되어있습니다. 자세한 사항은 LICENSE 를 체크해주세요

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment