Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Kotlin, Gradle and AWS SAM

Kotlin, Gradle and AWS SAM

* Kotlin, Gradle 그리고 Serverless Application Model을 이용해서 AWS Lambda, Gateway API를 배포해 봅니다.
* 배포한 API를 사용하는 CLI 앱을 만들어 봅니다.
* 두 프로젝트에서 함께 쓰는 코드를 하나로 합쳐봅니다.

Jaewe Heo

April 08, 2019
Tweet

More Decks by Jaewe Heo

Other Decks in Programming

Transcript

  1. 이야기 할 내용 ˖ ,PUMJO (SBEMF믆읺몮4FSWFSMFTT"QQMJDBUJPO.PEFM4".픒핂푷 컪"84-BNCEB (BUFXBZ"1*읊짾쫓삖삲 ˖ 짾"1*읊칺푷쁢$-*팿픒잚슲펂쫓삖삲

    ˖ 숞옪헫펞컪벦튾쁢슪읊빦옪쫓삖삲 펂썲뮪졶펞컪슮핞3&45짝8FC4PDLFU"1*읊캫컿 멚킪 퓮힎뫎읺 졶삖잏짝쫂쿦핖멚훊쁢"84컪찒큲 컪쩒읊뫎읺힎팘몮슪읊킲쿦핖멚훊쁢컪찒큲 푢킪펞잚킲  4"."84캏펞컪컪쩒읺큲팿픒잚슲믾퓒칺푷쿦핖쁢폲콚큲엖핒풚
  2. 개발환경 ˖ +%, ˖ (SBEMF9 ˖ %PDLFS ˖ "84 ˖

    $-*BQQT BXTDMJ BXTTBNDMJ ˖ $POHVSBUJPO$SFEFOUJBM'JMFT
  3. 진행 순서  옪헫캫컿  "1*(BUFXBZ)BOEMFS잚슲믾  4".tempalte.yml핟컿  옪큲

     "84펞짾  묺"1*읊핂푷쁢$-*팿잚슲믾 뫃슪뫃퓮
  4. 프로젝트 생성 › # ೐۽ં౟ ٣۩షܻ ࢤࢿ ߂ ੉ز ›

    mkdir kotlin-night-seoul && cd $_ › # Gradle ೐۽ં౟ ୡӝച › gradle init --type basic --dsl kotlin Project name (default: kotlin-night-seoul): › # ࢲ࠳ ݽٕ `function` ࢤࢿ › mkdir function && touch "$_/build.gradle.kts"
  5. 프로젝트 생성 › # ೐۽ં౟ ٣۩షܻ ࢤࢿ ߂ ੉ز ›

    mkdir kotlin-night-seoul && cd $_ › # Gradle ೐۽ં౟ ୡӝച › gradle init --type basic --dsl kotlin Project name (default: kotlin-night-seoul): › # ࢲ࠳ ݽٕ `function` ࢤࢿ › mkdir function && touch "$_/build.gradle.kts"
  6. 프로젝트 생성 › # ೐۽ં౟ ٣۩షܻ ࢤࢿ ߂ ੉ز ›

    mkdir kotlin-night-seoul && cd $_ › # Gradle ೐۽ં౟ ୡӝച › gradle init --type basic --dsl kotlin Project name (default: kotlin-night-seoul): › # ࢲ࠳ ݽٕ `function` ࢤࢿ › mkdir function && touch "$_/build.gradle.kts"
  7. 프로젝트 생성 › # ೐۽ં౟ ٣۩షܻ ࢤࢿ ߂ ੉ز ›

    mkdir kotlin-night-seoul && cd $_ › # Gradle ೐۽ં౟ ୡӝച › gradle init --type basic --dsl kotlin Project name (default: kotlin-night-seoul): › # ࢲ࠳ ݽٕ `function` ࢤࢿ › mkdir function && touch "$_/build.gradle.kts"
  8. 프로젝트 생성 › # ೐۽ં౟ ٣۩షܻ ࢤࢿ ߂ ੉ز ›

    mkdir kotlin-night-seoul && cd $_ › # Gradle ೐۽ં౟ ୡӝച › gradle init --type basic --dsl kotlin Project name (default: kotlin-night-seoul): › # ࢲ࠳ ݽٕ `function` ࢤࢿ › mkdir function && touch "$_/build.gradle.kts"
  9. 프로젝트 구조 . ├── build.gradle.kts ├── function │ └── build.gradle.kts

    ├── gradle │ └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts
  10. 프로젝트 구조 . ├── build.gradle.kts ├── function │ └── build.gradle.kts

    ├── gradle │ └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts
  11. 프로젝트 구조 . ├── build.gradle.kts ├── function │ └── build.gradle.kts

    ├── gradle │ └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts
  12. 프로젝트 구조 . ├── build.gradle.kts ├── function │ └── build.gradle.kts

    ├── gradle │ └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts
  13. 프로젝트 구조 . ├── build.gradle.kts ├── function │ └── build.gradle.kts

    ├── gradle │ └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts
  14. 핸들러 만들기 멾뫊쭎쫂졂 › http get http: //localhost:3000/greeting HTTP/1.0 200

    OK Content-Length: 47 Content-Type: application/json { "message": "Kotlin Night Seoul, Hello!" }
  15. 핸들러 만들기 functions/build.gradle.kts plugins { kotlin("jvm") version "1.3.21" } repositories

    { jcenter() } dependencies { implementation(kotlin("stdlib-jdk8")) implementation("com.amazonaws:aws-lambda-java-core:1.2.0") implementation("com.amazonaws:aws-lambda-java-events:2.2.5") implementation("com.amazonaws:aws-lambda-java-log4j2:1.0.0") testImplementation(kotlin("test-junit")) }
  16. 핸들러 만들기 functions/build.gradle.kts plugins { kotlin("jvm") version "1.3.21" } repositories

    { jcenter() } dependencies { implementation(kotlin("stdlib-jdk8")) implementation("com.amazonaws:aws-lambda-java-core:1.2.0") implementation("com.amazonaws:aws-lambda-java-events:2.2.5") implementation("com.amazonaws:aws-lambda-java-log4j2:1.0.0") testImplementation(kotlin("test-junit")) }
  17. 핸들러 테스트 functions/ .../GreetingHandlerTest.kt class GreetingHandlerTest { private lateinit var

    app: GreetingHandler @BeforeTest fun setup() { app = GreetingHandler() } @Test fun testSuccessfulResponse() { // ... } }
  18. 핸들러 테스트 functions/ .../GreetingHandlerTest.kt class GreetingHandlerTest { private lateinit var

    app: GreetingHandler @BeforeTest fun setup() { app = GreetingHandler() } @Test fun testSuccessfulResponse() { // ... } }
  19. 핸들러 테스트 functions/ .../GreetingHandlerTest.kt class GreetingHandlerTest { private lateinit var

    app: GreetingHandler @BeforeTest fun setup() { app = GreetingHandler() } @Test fun testSuccessfulResponse() { // ... } }
  20. 핸들러 테스트 functions/ .../GreetingHandlerTest.kt class GreetingHandlerTest { private lateinit var

    app: GreetingHandler @BeforeTest fun setup() { app = GreetingHandler() } @Test fun testSuccessfulResponse() { // ... } }
  21. 핸들러 테스트 functions/ .../GreetingHandlerTest.kt @Test fun testSuccessfulResponse() { val result

    = app.handleRequest(null, null) assertEquals(result.statusCode, 200) assertEquals(result.headers["Content-Type"], "application/json") result.body ?.let { content -> //language=JSON assertEquals( expected = """ { "message": "Kotlin Night Seoul, Hello!" } """.trimIndent(), actual = content ) } }
  22. 핸들러 테스트 functions/ .../GreetingHandlerTest.kt @Test fun testSuccessfulResponse() { val result

    = app.handleRequest(null, null) assertEquals(result.statusCode, 200) assertEquals(result.headers["Content-Type"], "application/json") result.body ?.let { content -> //language=JSON assertEquals( expected = """ { "message": "Kotlin Night Seoul, Hello!" } """.trimIndent(), actual = content ) } }
  23. 핸들러 테스트 functions/ .../GreetingHandlerTest.kt @Test fun testSuccessfulResponse() { val result

    = app.handleRequest(null, null) assertEquals(result.statusCode, 200) assertEquals(result.headers["Content-Type"], "application/json") result.body ?.let { content -> //language=JSON assertEquals( expected = """ { "message": "Kotlin Night Seoul, Hello!" } """.trimIndent(), actual = content ) } }
  24. 핸들러 테스트 functions/ .../GreetingHandlerTest.kt @Test fun testSuccessfulResponse() { val result

    = app.handleRequest(null, null) assertEquals(result.statusCode, 200) assertEquals(result.headers["Content-Type"], "application/json") result.body ?.let { content -> //language=JSON assertEquals( expected = """ { "message": "Kotlin Night Seoul, Hello!" } """.trimIndent(), actual = content ) } }
  25. 핸들러 테스트 functions/ .../GreetingHandlerTest.kt @Test fun testSuccessfulResponse() { val result

    = app.handleRequest(null, null) assertEquals(result.statusCode, 200) assertEquals(result.headers["Content-Type"], "application/json") result.body ?.let { content -> //language=JSON assertEquals( expected = """ { "message": "Kotlin Night Seoul, Hello!" } """.trimIndent(), actual = content ) } }
  26. 핸들러 만들기 functions/ .../GreetingHandler.kt class GreetingHandler : RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

    override fun handleRequest( input: APIGatewayProxyRequestEvent?, context: Context? ): APIGatewayProxyResponseEvent = APIGatewayProxyResponseEvent() .withStatusCode(200) .withHeaders(headers()) .withBody(body(input ?.queryStringParameters ?: emptyMap())) private fun headers(): Map<String, String> = mapOf() private fun body(params: Map<String, String>): String = "" }
  27. 핸들러 만들기 functions/ .../GreetingHandler.kt class GreetingHandler : RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

    override fun handleRequest( input: APIGatewayProxyRequestEvent?, context: Context? ): APIGatewayProxyResponseEvent = APIGatewayProxyResponseEvent() .withStatusCode(200) .withHeaders(headers()) .withBody(body(input ?.queryStringParameters ?: emptyMap())) private fun headers(): Map<String, String> = mapOf() private fun body(params: Map<String, String>): String = "" }
  28. 핸들러 만들기 functions/ .../GreetingHandler.kt class GreetingHandler : RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

    override fun handleRequest( input: APIGatewayProxyRequestEvent?, context: Context? ): APIGatewayProxyResponseEvent = APIGatewayProxyResponseEvent() .withStatusCode(200) .withHeaders(headers()) .withBody(body(input ?.queryStringParameters ?: emptyMap())) private fun headers(): Map<String, String> = mapOf() private fun body(params: Map<String, String>): String = "" }
  29. 핸들러 만들기 functions/ .../GreetingHandler.kt class GreetingHandler : RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

    override fun handleRequest( input: APIGatewayProxyRequestEvent?, context: Context? ): APIGatewayProxyResponseEvent = APIGatewayProxyResponseEvent() .withStatusCode(200) .withHeaders(headers()) .withBody(body(input ?.queryStringParameters ?: emptyMap())) private fun headers(): Map<String, String> = mapOf() private fun body(params: Map<String, String>): String = "" }
  30. 핸들러 만들기 functions/ .../GreetingHandler.kt class GreetingHandler : RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

    override fun handleRequest( input: APIGatewayProxyRequestEvent?, context: Context? ): APIGatewayProxyResponseEvent = APIGatewayProxyResponseEvent() .withStatusCode(200) .withHeaders(headers()) .withBody(body(input ?.queryStringParameters ?: emptyMap())) private fun headers(): Map<String, String> = mapOf() private fun body(params: Map<String, String>): String = "" }
  31. 핸들러 만들기 functions/ .../GreetingHandler.kt private fun headers() = mapOf( "Content-Type"

    to "application/json" ) private fun body(params: Map<String, String>): String = params .getOrDefault("content", "Hello") .let { content -> //language=JSON """ { "message": "Kotlin Night Seoul, ${content.capitalize()}!" } """.trimIndent() }
  32. SAM 템플릿 작성 function/template.yml AWSTemplateFormatVersion: "2010-09-09" Transform: "AWS ::Serverless-2016-10-31" Resources:

    GreetingFunction: Type: "AWS ::Serverless ::Function" Properties: Handler: "GreetingHandler ::handleRequest" CodeUri: "./build/libs/function-all.jar" Events: IndexApi: Type: "Api" Properties: Path: "/greeting" Method: "get" Runtime: "java8" Outputs: ProdEndpoint: Value: !Sub "https: //${ServerlessRestApi}.execute-api.${AWS ::Region}.amazonaws.com/Prod/"
  33. SAM 템플릿 작성 function/template.yml AWSTemplateFormatVersion: "2010-09-09" Transform: "AWS ::Serverless-2016-10-31" Resources:

    GreetingFunction: Type: "AWS ::Serverless ::Function" Properties: Handler: "GreetingHandler ::handleRequest" CodeUri: "./build/libs/function-all.jar" Events: IndexApi: Type: "Api" Properties: Path: "/greeting" Method: "get" Runtime: "java8" Outputs: ProdEndpoint: Value: !Sub "https: //${ServerlessRestApi}.execute-api.${AWS ::Region}.amazonaws.com/Prod/"
  34. SAM 템플릿 작성 function/template.yml AWSTemplateFormatVersion: "2010-09-09" Transform: "AWS ::Serverless-2016-10-31" Resources:

    GreetingFunction: Type: "AWS ::Serverless ::Function" Properties: Handler: "GreetingHandler ::handleRequest" CodeUri: "./build/libs/function-all.jar" Events: IndexApi: Type: "Api" Properties: Path: "/greeting" Method: "get" Runtime: "java8" Outputs: ProdEndpoint: Value: !Sub "https: //${ServerlessRestApi}.execute-api.${AWS ::Region}.amazonaws.com/Prod/"
  35. SAM 템플릿 작성 function/template.yml AWSTemplateFormatVersion: "2010-09-09" Transform: "AWS ::Serverless-2016-10-31" Resources:

    GreetingFunction: Type: "AWS ::Serverless ::Function" Properties: Handler: "GreetingHandler ::handleRequest" CodeUri: "./build/libs/function-all.jar" Events: IndexApi: Type: "Api" Properties: Path: "/greeting" Method: "get" Runtime: "java8" Outputs: ProdEndpoint: Value: !Sub "https: //${ServerlessRestApi}.execute-api.${AWS ::Region}.amazonaws.com/Prod/"
  36. SAM 템플릿 작성 function/template.yml AWSTemplateFormatVersion: "2010-09-09" Transform: "AWS ::Serverless-2016-10-31" Resources:

    GreetingFunction: Type: "AWS ::Serverless ::Function" Properties: Handler: "GreetingHandler ::handleRequest" CodeUri: "./build/libs/function-all.jar" Events: IndexApi: Type: "Api" Properties: Path: "/greeting" Method: "get" Runtime: "java8" Outputs: ProdEndpoint: Value: !Sub "https: //${ServerlessRestApi}.execute-api.${AWS ::Region}.amazonaws.com/Prod/"
  37. 로컬 테스트 function/build.gradle.kts plugins { kotlin("jvm") version "1.3.21" id("aws.sam") version

    "0.1.0" } repositories { jcenter() } dependencies { implementation(kotlin("stdlib-jdk8")) implementation("com.amazonaws:aws-lambda-java-core:1.2.0") implementation("com.amazonaws:aws-lambda-java-events:2.2.5") implementation("com.amazonaws:aws-lambda-java-log4j2:1.0.0") testImplementation(kotlin("test-junit")) } sam { template = file("template.yml") }
  38. 로컬 테스트 function/build.gradle.kts plugins { kotlin("jvm") version "1.3.21" id("aws.sam") version

    "0.1.0" } repositories { jcenter() } dependencies { implementation(kotlin("stdlib-jdk8")) implementation("com.amazonaws:aws-lambda-java-core:1.2.0") implementation("com.amazonaws:aws-lambda-java-events:2.2.5") implementation("com.amazonaws:aws-lambda-java-log4j2:1.0.0") testImplementation(kotlin("test-junit")) } sam { template = file("template.yml") }
  39. id("aws.sam") aws-sam-gradle-plugin ˖ 5BTLT ˖ JOTUBMM"XT4BN › pip install awscli

    aws-sam-cli ˖ NBLF#VDLFU › aws s3 mb s3: // ... ˖ QBDLBHF4BN"QQ › sam package ... ˖ SVO-PDBM4UBSU"QJ › sam local start-api ... ˖ EFQMPZ4BN"QQ › sam deploy ... BXTTBNHSBEMFQMVHJOJNQPSUSFBXTTBNHSBEMFQMVHJO
  40. 로컬 테스트 httpie › http get http: //localhost:3000/greeting HTTP/1.0 200

    OK Content-Length: 47 Content-Type: application/json { "message": "Kotlin Night Seoul, Hello!" } IUUQJFbrew install httpie
  41. 로컬 테스트 httpie › http get http: //localhost:3000/greeting\?content\=2019 HTTP/1.0 200

    OK Content-Length: 46 Content-Type: application/json { "message": "Kotlin Night Seoul, 2019!" } IUUQJFbrew install httpie
  42. AWS에 배포 function/build.gradle.kts sam { template = file("template.yml") bucket =

    "YOUR_S3_BUCKET_NAME" stack = "YOUR_CLOUDFORMATION_STACK_NAME" } ⚠ 4#VFDLFU $MPVE'PSNBUJPO4UBDL핂읒뮪훊픦
  43. AWS에 배포 function/build.gradle.kts sam { template = file("template.yml") bucket =

    "YOUR_S3_BUCKET_NAME" stack = "YOUR_CLOUDFORMATION_STACK_NAME" } ⚠ 4#VFDLFU $MPVE'PSNBUJPO4UBDL핂읒뮪훊픦
  44. AWS에 배포 $POHVSBUJPO$SFEFOUJBM'JMFT핆 › ./gradlew :function:deploySamApp ... ... ... [

    { "OutputKey": "ProdEndpoint", "OutputValue": "https: //???.execute-api.ap-northeast-2.amazonaws.com/Prod/" } ]
  45. AWS에 배포 $POHVSBUJPO$SFEFOUJBM'JMFT핆 › ./gradlew :function:deploySamApp ... ... ... [

    { "OutputKey": "ProdEndpoint", "OutputValue": "https: //???.execute-api.ap-northeast-2.amazonaws.com/Prod/" } ]
  46. CLI 앱 만들기 › # ࢲ࠳ ݽٕ `cli` ࢤࢿ ›

    mkdir cli && touch "$_/build.gradle.kts"
  47. CLI 앱 만들기 cli/build.gradle.kts plugins { kotlin("jvm") version "1.3.21" application

    } repositories { jcenter() } dependencies { implementation(kotlin("stdlib-jdk8")) implementation("com.squareup.retrofit2:retrofit:2.5.0") implementation("com.squareup.retrofit2:converter-gson:2.5.0") implementation("com.squareup.retrofit2:adapter-rxjava2:2.5.0") testImplementation(kotlin("test-junit")) } application { mainClassName = "MainKt" }
  48. CLI 앱 만들기 cli/build.gradle.kts plugins { kotlin("jvm") version "1.3.21" application

    } repositories { jcenter() } dependencies { implementation(kotlin("stdlib-jdk8")) implementation("com.squareup.retrofit2:retrofit:2.5.0") implementation("com.squareup.retrofit2:converter-gson:2.5.0") implementation("com.squareup.retrofit2:adapter-rxjava2:2.5.0") testImplementation(kotlin("test-junit")) } application { mainClassName = "MainKt" }
  49. CLI 앱 만들기 cli/ .../Main.kt data class GreetingData( val message:

    String ) interface GreetingService { @GET("/greeting") fun greeting( @Query("content") content: String? = null ): Single<Response<GreetingData >> }
  50. CLI 앱 만들기 cli/ .../Main.kt data class GreetingData( val message:

    String ) interface GreetingService { @GET("/greeting") fun greeting( @Query("content") content: String? = null ): Single<Response<GreetingData >> }
  51. CLI 앱 만들기 cli/ .../Main.kt data class GreetingData( val message:

    String ) interface GreetingService { @GET("/greeting") fun greeting( @Query("content") content: String? = null ): Single<Response<GreetingData >> }
  52. CLI 앱 만들기 cli/ .../Main.kt private val service = Builder()

    .client(OkHttpClient()) .baseUrl("http: //127.0.0.1:3000") .addConverterFactory(GsonConverterFactory.create()) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .build() .create(GreetingService ::class.java) fun main() { service.greeting() .map { it.body() ?.message ?: it.errorBody() ?: "???" } .subscribe( ::println, Throwable ::printStackTrace) }
  53. CLI 앱 만들기 cli/ .../Main.kt private val service = Builder()

    .client(OkHttpClient()) .baseUrl("http: //127.0.0.1:3000") .addConverterFactory(GsonConverterFactory.create()) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .build() .create(GreetingService ::class.java) fun main() { service.greeting() .map { it.body() ?.message ?: it.errorBody() ?: "???" } .subscribe( ::println, Throwable ::printStackTrace) }
  54. Shared 모듈 만들기 shared/build.gradle.kts plugins { kotlin("jvm") version "1.3.21" }

    repositories { jcenter() } dependencies { implementation(kotlin("stdlib-jdk8")) testImplementation(kotlin("test-junit")) }
  55. JVM Kotlin Plugin 젎옪헫 // build.gradle.kts plugins { kotlin("jvm") version

    "1.3.21" apply false } // shared/build.gradle.kts // function/build.gradle.kts // cli/build.gradle.kts plugins { kotlin("jvm") }
  56. Response 변경 function/ .../GreetingHandler.kt private fun headers() = mapOf( "Content-Type"

    to "application/json" ) private fun body(params: Map<String, String>): String = params .getOrDefault("content", "Hello") .let { content -> "Kotlin Night Seoul, ${content.capitalize()}!" } .let { message -> GreetingData(message = message) } .let { data -> Gson().toJson(data) }
  57. Response 변경 function/ .../GreetingHandler.kt private fun headers() = mapOf( "Content-Type"

    to "application/json" ) private fun body(params: Map<String, String>): String = params .getOrDefault("content", "Hello") .let { content -> "Kotlin Night Seoul, ${content.capitalize()}!" } .let { message -> GreetingData(message = message) } .let { data -> Gson().toJson(data) }
  58. Response 변경 function/ .../GreetingHandler.kt private fun headers() = mapOf( "Content-Type"

    to "application/json" ) private fun body(params: Map<String, String>): String = params .getOrDefault("content", "Hello") .let { content -> "Kotlin Night Seoul, ${content.capitalize()}!" } .let { message -> GreetingData(message = message) } .let { data -> Gson().toJson(data) }
  59. Response 변경 function/ .../GreetingHandler.kt private fun headers() = mapOf( "Content-Type"

    to "application/json" ) private fun body(params: Map<String, String>): String = params .getOrDefault("content", "Hello") .let { content -> "Kotlin Night Seoul, ${content.capitalize()}!" } .let { message -> GreetingData(message = message) } .let { data -> Gson().toJson(data) }
  60. 요약 ˖ "84-BNCEB "1*(BUFXBZ핟컿픒읾픊옪 ˖ BXTBXTMBNCEBKBWBMJCT ˖ JNQPSUSFBXTTBNHSBEMFQMVHJO ˖ BXTDMJ

    BXTTBNDMJ ˖ 찚슪큲잋솒읾픊옪 ˖ ,PUMJO%4- ˖ 컪쩒퐎않핂펆슪읊뫃퓮믾 ˖ "VUIPSJOH.VMUJ1SPKFDU#VJMET ˖ "QQMJDBUJPO1MVHJO
  61. Positions ˖ "OESPJE ❤ ,PUMJO ˖ #BDLFOE ❤ ,PUMJO ˖

    "OE"* J04 8FC 핞퓮퍟킫픊옪핂엳컪읊쫂뺂훊켆푢 ˋSFDSVJU!SJJJEDP IUUQTSJJJEDPDBSFFS
  62. Q&A