

# IVS 실시간 스트리밍 시작하기
<a name="getting-started"></a>

이 문서에서는 Amazon IVS Real-Time Streaming을 앱에 통합하는 단계를 안내합니다.

**Topics**
+ [IVS Real-Time Streaming 소개](getting-started-introduction.md)
+ [1단계: IAM 권한 설정](getting-started-iam-permissions.md)
+ [2단계: 선택적 참가자 레코딩으로 스테이지 생성](getting-started-create-stage.md)
+ [3단계: 참가자 토큰 배포](getting-started-distribute-tokens.md)
+ [4단계: IVS Broadcast SDK 통합](getting-started-broadcast-sdk.md)
+ [5단계: 비디오 게시 및 구독](getting-started-pub-sub.md)

# IVS Real-Time Streaming 소개
<a name="getting-started-introduction"></a>

이 섹션에서는 실시간 스트리밍을 사용하기 위한 사전 조건을 나열하고 주요 용어를 소개합니다.

## 사전 조건
<a name="getting-started-introduction-prereq"></a>

실시간 스트리밍을 처음 사용하는 경우, 먼저 다음 작업을 완료해야 합니다. 지침은 [IVS 지연 시간이 짧은 스트리밍 시작하기](https://docs.aws.amazon.com/ivs/latest/LowLatencyUserGuide/getting-started.html)를 참조하세요.
+ AWS 계정 생성
+ 루트 및 관리 사용자 설정

## 기타 참조
<a name="getting-started-introduction-extref"></a>
+ [IVS Web Broadcast SDK Reference](https://aws.github.io/amazon-ivs-web-broadcast/docs/sdk-reference)
+ [IVS Android Broadcast SDK Reference](https://aws.github.io/amazon-ivs-broadcast-docs/latest/android/)
+ [IVS iOS Broadcast SDK Reference](https://aws.github.io/amazon-ivs-broadcast-docs/latest/ios/)
+ [IVS Real-Time Streaming API Reference](https://docs.aws.amazon.com/ivs/latest/RealTimeAPIReference/Welcome.html)

## 실시간 스트리밍 용어
<a name="getting-started-introduction-terminology"></a>


| Term | 설명 | 
| --- | --- | 
| 단계 | 참가자들이 실시간으로 비디오를 교환할 수 있는 가상 공간입니다. | 
| Host | 스테이지로 로컬 비디오를 전송하는 참가자. | 
| 뷰어 | 호스트의 비디오를 수신하는 참가자. | 
| Participant | 스테이지에 호스트 또는 시청자로 연결된 사용자입니다. | 
| 참가자 토큰 | 스테이지에 참가할 때 참가자를 인증하는 토큰입니다. | 
| Broadcast SDK | 참가자가 비디오를 전송하고 수신할 수 있도록 하는 클라이언트 라이브러리입니다. | 

## 단계 개요
<a name="getting-started-introduction-steps"></a>

1. [IAM 권한 설정](getting-started-iam-permissions.md) - 사용자에게 기본 권한 세트를 제공하는 AWS Identity and Access Management(IAM) 정책을 생성하고 해당 정책을 사용자에게 할당합니다.

1. [스테이지 생성](getting-started-create-stage.md) - 참가자들이 실시간으로 비디오를 교환할 수 있는 가상 공간을 생성합니다.

1. [참가자 토큰 배포](getting-started-distribute-tokens.md) - 참가자가 스테이지에 참가할 수 있도록 토큰을 전송합니다.

1. [IVS Broadcast SDK 통합](getting-started-broadcast-sdk.md) - 참가자가 비디오를 전송하고 수신할 수 있도록 앱에 Broadcast SDK를 추가합니다([웹](getting-started-broadcast-sdk.md#getting-started-broadcast-sdk-web), [Android](getting-started-broadcast-sdk.md#getting-started-broadcast-sdk-android) 및 [iOS](getting-started-broadcast-sdk.md#getting-started-broadcast-sdk-ios)).

1. [비디오 게시 및 구독](getting-started-pub-sub.md) - 비디오를 스테이지에 보내고 다른 호스트의 비디오를 받습니다([IVS 콘솔](getting-started-pub-sub.md#getting-started-pub-sub-console), [IVS Web Broadcast SDK를 사용하여 게시 및 구독](getting-started-pub-sub-web.md), [IVS Android Broadcast SDK를 사용하여 게시 및 구독](getting-started-pub-sub-android.md) 및 [IVS iOS Broadcast SDK를 사용하여 게시 및 구독](getting-started-pub-sub-ios.md)).

# 1단계: IAM 권한 설정
<a name="getting-started-iam-permissions"></a>

다음으로 사용자에게 기본 권한 집합(예: Amazon IVS 스테이지 생성 및 참가자 토큰 생성)을 제공하는 AWS Identity and Access Management(IAM) 정책을 생성하고 해당 정책을 사용자에게 할당해야 합니다. [새 사용자](#iam-permissions-new-user)를 생성할 때 권한을 할당하거나 [기존 사용자](#iam-permissions-existing-user)에 권한을 추가할 수 있습니다. 두 절차 모두 아래에 나와 있습니다.

IAM 사용자 및 정책에 대한 자세한 내용, 사용자에게 정책을 연결하는 방법, Amazon IVS로 수행할 수 있는 작업 등 자세한 내용은 다음을 참조하세요.
+ *IAM 사용 설명서*의 [IAM 사용자 생성](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html#Using_CreateUser_console)
+ IAM의 [Amazon IVS 보안](https://docs.aws.amazon.com/ivs/latest/LowLatencyUserGuide/security.html) 및 ‘IVS에 대한 관리형 정책’의 정보입니다.
+ [Amazon IVS 보안](https://docs.aws.amazon.com/ivs/latest/LowLatencyUserGuide/security.html)의 IAM 정보

Amazon IVS에 대한 기존 AWS 관리형 정책을 사용하거나 사용자, 그룹 또는 역할 집합에 부여할 권한을 사용자 지정하는 새 정책을 생성할 수 있습니다. 아래에 두 가지 접근 방식이 모두 설명되어 있습니다.

## IVS 권한에 대한 기존 정책 사용
<a name="iam-permissions-existing-policy"></a>

대부분의 경우 Amazon IVS에 대한 AWS 관리형 정책을 사용하는 것이 좋습니다. **IVS 보안의 [IVS에 대한 관리형 정책](https://docs.aws.amazon.com/ivs/latest/LowLatencyUserGuide/security-iam-awsmanpol.html) 섹션에 자세히 설명되어 있습니다.
+ `IVSReadOnlyAccess` AWS 관리형 정책을 사용하여 애플리케이션 개발자에게 모든 IVS Get 및 List API 정책에 대한 액세스 권한을 부여합니다(저지연 스트리밍과 실시간 스트리밍에 모두 해당).
+ `IVSFullAccess` AWS 관리형 정책을 사용하여 애플리케이션 개발자에게 모든 IVS API 작업에 대한 액세스 권한을 부여합니다(저지연 스트리밍과 실시간 스트리밍에 모두 해당).

## 선택 사항: Amazon IVS 권한에 대한 사용자 지정 정책 생성
<a name="iam-permissions-new-policy"></a>

다음 단계를 수행합니다.

1. AWS Management Console에 로그인하여 [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/)에서 IAM 콘솔을 엽니다.

1. 탐색 창에서 **정책**을 선택한 다음 **정책 생성**을 선택합니다. **권한 지정** 창이 열립니다.

1. **권한 지정** 창에서 **JSON** 탭을 선택하고 다음 IVS 정책을 복사하여 **권한 편집기** 텍스트 영역에 붙여넣습니다. (이 정책에 모든 Amazon IVS 작업이 포함되어 있는 것은 아닙니다. 필요에 따라 작업 액세스 권한을 추가/삭제(허용/거부)할 수 있습니다. IVS 작업에 대한 자세한 내용은 [IVS 실시간 스트리밍 API Reference](https://docs.aws.amazon.com//ivs/latest/RealTimeAPIReference/Welcome.html)를 참조하세요.)

------
#### [ JSON ]

****  

   ```
   {
      "Version":"2012-10-17",		 	 	 
      "Statement": [
         {
            "Effect": "Allow",
            "Action": [
               "ivs:CreateStage",
               "ivs:CreateParticipantToken",
               "ivs:GetStage",
               "ivs:GetStageSession",
               "ivs:ListStages",
               "ivs:ListStageSessions",
               "ivs:CreateEncoderConfiguration",
               "ivs:GetEncoderConfiguration",
               "ivs:ListEncoderConfigurations",
               "ivs:GetComposition",
               "ivs:ListCompositions",
               "ivs:StartComposition",
               "ivs:StopComposition"
             ],
             "Resource": "*"
         },
         {
            "Effect": "Allow",
            "Action": [
               "cloudwatch:DescribeAlarms",
               "cloudwatch:GetMetricData",
               "s3:DeleteBucketPolicy",
               "s3:GetBucketLocation",
               "s3:GetBucketPolicy",
               "s3:PutBucketPolicy",
               "servicequotas:ListAWSDefaultServiceQuotas",
               "servicequotas:ListRequestedServiceQuotaChangeHistoryByQuota",
               "servicequotas:ListServiceQuotas",
               "servicequotas:ListServices",
               "servicequotas:ListTagsForResource"
            ],
            "Resource": "*"
         }
      ]
   }
   ```

------

1. 여전히 **권한 지정** 창에서 **다음**(창 아래쪽으로 스크롤하여 확인)을 선택합니다. **검토 및 생성** 창이 열립니다.

1. **검토 및 생성** 창에서 **정책 이름**을 입력하고 선택적으로 **설명**을 추가합니다. 사용자를 생성할 때(아래) 필요한 정책 이름을 기록해 둡니다. **정책 생성**(창 하단)을 선택합니다.

1. IAM 콘솔 창으로 돌아가면 새 정책이 생성되었음을 확인하는 배너가 표시됩니다.

## 새 사용자 생성 및 권한 추가
<a name="iam-permissions-new-user"></a>

### IAM 사용자 액세스 키
<a name="iam-permissions-new-user-access-keys"></a>

IAM 액세스 키는 액세스 키 ID와 비밀 액세스 키로 구성됩니다. 이 키들은 AWS에 보내는 프로그래밍 방식의 요청에 서명하는 데 사용됩니다. 액세스 키가 없는 경우 AWS Management Console에서 액세스 키를 생성할 수 있습니다. 루트 사용자 액세스 키는 사용하지 않는 것이 좋습니다.

*비밀 액세스 키는 액세스 키를 생성할 때만 보고 다운로드할 수 있습니다. 나중에 복구할 수 없습니다.* 언제든지 새 액세스 키를 생성할 수 있지만 필요한 IAM 작업을 수행할 수 있는 권한이 있어야 합니다.

항상 액세스 키를 안전하게 보관하세요. 절대로 (Amazon에서 문의가 온 것처럼 보여도) 제3자와 공유하지 마세요. 자세한 내용은 * IAM 사용 설명서*의 [IAM 사용자의 액세스 키 관리](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html)를 참조하세요.

### 절차
<a name="iam-permissions-new-user-procedure"></a>

다음 단계를 따릅니다.

1. 탐색 창에서 **사용자**를 선택한 다음, **사용자 생성**을 선택합니다. **사용자 세부 정보 지정** 창이 열립니다.

1. **사용자 세부 정보 지정** 창에서,

   1. **사용자 세부 정보**에서 생성하려고 하는 신규 **사용자 이름**을 입력합니다.

   1. **AWS Management Console에 대한 사용자 액세스 제공**을 선택합니다.

   1. **콘솔 암호**에서 **자동 생성된 암호(권장)**을 선택합니다.

   1. **다음 로그인 시 사용자가 새 암호를 생성해야 함**을 선택합니다.

   1. **다음**을 선택합니다. **권한 설정** 창이 열립니다.

1. **권한 설정**에서 **정책 직접 연결**을 선택합니다. **권한 정책** 창이 열립니다.

1. 검색 상자에 IVS 정책 이름을 입력합니다(AWS 관리형 정책 또는 이전에 생성한 사용자 지정 정책). 검색되면 확인란을 선택하여 정책을 선택합니다.

1. **다음**(창 하단)을 선택합니다. **검토 및 생성** 창이 열립니다.

1. **검토 및 생성** 창에서 모든 사용자 세부 정보가 올바른지 확인한 다음 **사용자 생성**(창 하단)을 선택합니다.

1. **콘솔 로그인 세부 정보**가 포함되어 있는 **암호 검색** 창이 열립니다. *해당 정보를 안전하게 저장하여 향후에 참조하세요*. 완료되면 **사용자 목록으로 돌아가기**를 선택하세요.

## 기존 사용자에 권한 추가
<a name="iam-permissions-existing-user"></a>

다음 단계를 수행합니다.

1. AWS Management Console에 로그인하여 [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/)에서 IAM 콘솔을 엽니다.

1. 탐색 창에서 **사용자**를 선택한 다음 업데이트할 기존 사용자 이름을 선택합니다. (이름을 클릭하여 선택합니다. 선택 상자는 선택하지 마세요.)

1. **요약** 페이지의 **권한** 탭에서 **권한 추가**를 선택합니다. **권한 추가** 창이 열립니다.

1. **기존 정책 직접 연결**을 선택합니다. **권한 정책** 창이 열립니다.

1. 검색 상자에 IVS 정책 이름을 입력합니다(AWS 관리형 정책 또는 이전에 생성한 사용자 지정 정책). 정책을 찾으면 확인란을 선택하여 정책을 선택합니다.

1. **다음**(창 하단)을 선택합니다. **검토** 창이 열립니다.

1. **검토** 창에서 **권한 추가**(창 하단)를 선택합니다.

1. **요약** 페이지에서 IVS 정책이 추가되었는지 확인합니다.

# 2단계: 선택적 참가자 레코딩으로 스테이지 생성
<a name="getting-started-create-stage"></a>

스테이지는 참가자들이 실시간으로 비디오를 교환할 수 있는 가상 공간입니다. 실시간 스트리밍 API의 기본 리소스입니다. 콘솔이나 CreateStage 작업을 사용하여 스테이지를 생성할 수 있습니다.

가능한 경우 재사용을 위해 이전 스테이지를 유지하는 대신 각 논리적 세션에 대해 새 스테이지를 생성하고 완료되면 삭제하는 것이 좋습니다. 기한 경과 리소스(재사용하지 않는 오래된 스테이지)를 정리하지 않으면 최대 스테이지 수 제한에 더 빨리 도달할 수 있습니다.

Amazon IVS 콘솔 또는 AWS CLI를 통해 개별 참가자 레코딩 유무에 관계없이 스테이지를 생성할 수 있습니다. 스테이지 생성 및 레코딩은 아래에서 설명합니다.

## 개별 참가자 레코딩
<a name="getting-started-create-stage-ipr-overview"></a>

스테이지에 대한 개별 참가자 레코딩을 활성화하는 옵션이 있습니다. S3에 대한 개별 참가자 레코딩 특성이 활성화된 경우 스테이지에 브로드캐스팅되는 모든 개별 참가자가 레코딩되고 사용자가 소유한 Amazon S3 스토리지 버킷에 저장됩니다. 이후 레코딩을 온디맨드 재생에 사용할 수 있습니다.

*이것을 설정하는 것은 고급 옵션입니다.* 기본적으로 스테이지가 생성될 때는 레코딩이 비활성화됩니다.

레코딩할 스테이지를 설정하려면 *스토리지 구성*을 생성해야 합니다. 이는 스테이지의 레코딩된 스트림이 저장되는 Amazon S3 위치를 지정하는 리소스입니다. 콘솔 또는 CLI를 사용하여 스토리지 구성을 생성하고 관리할 수 있습니다. 두 절차 모두 아래에 나와 있습니다. 스토리지 구성을 생성한 후 아래에 설명된 대로 스테이지를 생성할 때나 나중에 기존 스테이지를 업데이트하여 스테이지와 연결합니다. (API의 [CreateStage](https://docs.aws.amazon.com//ivs/latest/RealTimeAPIReference/API_CreateStage.html) 및 [CreateStage](https://docs.aws.amazon.com//ivs/latest/RealTimeAPIReference/API_UpdateStage.html)를 참조하세요.) 여러 스테이지를 동일한 스토리지 구성과 연결할 수 있습니다. 어느 스테이지와도 더는 연결되지 않는 스토리지 구성은 삭제할 수 있습니다.

다음의 제약 조건에 유의하세요.
+ S3 버킷을 소유해야 합니다. 즉, 레코딩할 스테이지를 설정하는 계정은 레코딩이 저장되는 S3 버킷을 소유해야 합니다.
+ 스테이지, 스토리지 구성 및 S3 위치는 동일한 AWS 리전에 있어야 합니다. 다른 리전에서 스테이지를 생성하고 레코딩하려면, 해당 리전에서도 스토리지 구성 및 S3 버킷을 설정해야 합니다.

S3 버킷에 레코딩하려면 AWS 보안 인증 정보로 승인을 받아야 합니다. IVS에 필요한 액세스 권한을 부여하기 위해 레코딩 구성을 생성할 때 AWS IAM [서비스 연결 역할](https://docs.aws.amazon.com/IAM/latest/UserGuide/using-service-linked-roles.html)(SLR)이 자동으로 생성됩니다. 해당 SLR은 IVS에 특정 버킷에 대한 쓰기 권한만 부여하도록 제한됩니다.

스트리밍 위치와 AWS 간 또는 AWS 클라우드 내에서 네트워크 문제로 인해 스트림을 레코딩하는 동안 일부 데이터가 유실될 수 있습니다. 이러한 경우 Amazon IVS는 레코딩보다 라이브 스트림의 우선순위를 높게 지정합니다. 이중화를 위해 스트리밍 도구를 통해 로컬에서 레코딩하세요.

레코딩된 파일의 사후 처리 또는 VOD 재생을 설정하는 방법 등을 비롯한 자세한 내용은 [개별 참가자 레코딩](rt-individual-participant-recording.md)을 참조하세요.

### 레코딩을 비활성화하는 방법
<a name="getting-started-disable-recording"></a>

기존 스토리지에서 Amazon S3 레코딩을 비활성화하는 방법
+ 콘솔 - 관련 스테이지에 대한 세부 정보 페이지의 **개별 참가자 스트림 레코딩** 섹션에서 **S3에 자동 레코딩** 아래 **자동 레코딩 활성화**를 끈 다음에 **변경 사항 저장**을 선택합니다. 그러면 스토리지 구성과 스테이지의 연결이 제거되고 해당 스테이지의 스트림이 더는 레코딩되지 않습니다.
+ CLI-`update-stage` 명령을 실행하여 레코딩 구성 ARN을 빈 문자열로 전달합니다.

  ```
  aws ivs-realtime update-stage --arn arn:aws:ivs:us-west-2:123456789012:stage/abcdABCDefgh --auto-participant-recording-configuration storageConfigurationArn=""
  ```

  그러면 레코딩이 비활성화되었다는 것을 나타내는 `storageConfigurationArn`에 대한 빈 문자열이 있는 스테이지 객체가 반환됩니다.

## IVS 스테이지 생성에 대한 콘솔 지침
<a name="getting-started-create-stage-console"></a>

1. [Amazon IVS 콘솔](https://console.aws.amazon.com/ivs)을 엽니다.

   ([AWS Management Console](https://console.aws.amazon.com/)을 통해 Amazon IVS 콘솔에 액세스할 수도 있습니다.)

1. 왼쪽 탐색 창에서 **스테이지**를 선택한 다음 **스테이지 생성**을 선택합니다. **스테이지 생성** 창이 나타납니다.  
![\[스테이지 생성 창을 사용하여 새 스테이지와 그에 대한 참가자 토큰을 생성합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Create_Stage_Console_IPR.png)

1. 필요에 따라 **스테이지 이름**을 입력합니다.

1. 개별 참가자 레코딩을 활성화하려면 아래 [Amazon S3에 자동 개별 참가자 레코딩 설정(선택 사항)](#getting-started-create-stage-ipr)의 단계를 완료하세요.

1. **스테이지 생성**을 선택하여 스테이지를 생성합니다. 새 스테이지의 스테이지 세부 정보 페이지가 나타납니다.

### Amazon S3에 자동 개별 참가자 레코딩 설정(선택 사항)
<a name="getting-started-create-stage-ipr"></a>

스테이지를 생성하는 동안 개별 참가자 레코딩을 활성화하려면 다음과 같은 단계를 따르세요.

1. **스테이지 생성** 페이지의 **개별 참가자 레코딩**에서 **자동 레코딩 활성화**를 켭니다. **레코딩된 미디어 유형**을 선택하고, 기존 **스토리지 구성** 선택하거나 새로 하나를 생성하고, 일정한 간격으로 썸네일을 레코딩할지 여부를 선택하는 추가 필드가 표시됩니다.  
![\[개별 참가자 레코딩 대화 상자를 사용하여 스테이지에 대한 개별 참가자 레코딩을 구성합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Create_Stage_Console_enable_IPR.png)

1. 레코딩할 미디어 유형을 선택합니다.

1. **스토리지 구성 생성**을 선택합니다. Amazon S3 버킷을 생성하고 새 레코딩 구성에 연결하는 옵션을 포함하는 새 창이 열립니다.  
![\[스토리지 구성 생성 창을 사용하여 스테이지에 대한 새로운 스토리지 구성을 생성합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Create_Storage_Configuration_IPR.png)

1. 입력란을 작성합니다.

   1. **스토리지 구성 이름**을 입력합니다(선택 사항).

   1. **버킷 이름**을 입력합니다.

1. **스토리지 구성 생성**을 선택하여 고유한 ARN으로 새로운 스토리지 구성 리소스를 생성합니다. 일반적으로 레코딩 구성을 생성하는 데 몇 초 정도지만, 최대 20초가 걸릴 수 있습니다. 스토리지 구성이 생성되면 **스토리지 생성** 창으로 돌아갑니다. 여기에서는 생성한 새로운 **스토리지 구성**과 S3 버킷(**스토리지**)이 **개별 참가자 레코딩** 영역에 표시됩니다.  
![\[IVS 콘솔을 사용하여 스테이지 생성: 새로운 스토리지 구성이 생성되었습니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Create_Stage_Console_Storage_Configuration.png)

1. 참가자 복제본 레코딩, 개별 참가자 레코딩 병합, 썸네일 레코딩 등 비기본 옵션을 선택적으로 활성화할 수 있습니다.  
![\[IVS 콘솔을 사용하여 스테이지 생성: 썸네일 레코딩 및 IPR 스티칭과 같은 고급 옵션을 활성화합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Create_Stage_Console_IPR_Stitching.png)

## IVS 스테이지 생성에 대한 CLI 지침
<a name="getting-started-create-stage-cli"></a>

AWS CLI를 설치하려면 [AWS CLI의 최신 버전 설치 또는 업데이트](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html)를 참조하세요.

이제는 활성화된 개별 참자가 레코딩이 있거나 없는 스테이지를 생성할지 여부에 따라 아래의 두 가지 절차 중 하나에 따라 CLI를 사용하여 리소스를 생성하고 관리할 수 있습니다.

### 개별 참가자 레코딩이 없는 스테이지 생성
<a name="getting-started-create-stage-cli-without-ipr"></a>

스테이지 API는 ivs-realtime 네임스페이스 아래에 있습니다. 예를 들어, 스테이지를 생성하려면:

```
aws ivs-realtime create-stage --name "test-stage"
```

다음과 같이 응답합니다.

```
{
   "stage": {
      "arn": "arn:aws:ivs:us-west-2:376666121854:stage/VSWjvX5XOkU3",
      "name": "test-stage"
   }
}
```

### 개별 참가자 레코딩이 있는 스테이지 생성
<a name="getting-started-create-stage-cli-with-ipr"></a>

활성화된 개별 참가자 레코딩이 있는 스테이지를 생성하는 방법

```
aws ivs-realtime create-stage --name "test-stage-participant-recording" --auto-participant-recording-configuration storageConfigurationArn=arn:aws:ivs:us-west-2:123456789012:storage-configuration/LKZ6QR7r55c2,mediaTypes=AUDIO_VIDEO
```

`thumbnailConfiguration` 파라미터를 전달하여 썸네일 스토리지 및 레코딩 모드와 썸네일 간격(초)을 수동으로 설정합니다(선택 사항).

```
aws ivs-realtime create-stage --name "test-stage-participant-recording" --auto-participant-recording-configuration storageConfigurationArn=arn:aws:ivs:us-west-2:123456789012:storage-configuration/LKZ6QR7r55c2,mediaTypes=AUDIO_VIDEO,thumbnailConfiguration="{targetIntervalSeconds=10,storage=[SEQUENTIAL,LATEST],recordingMode=INTERVAL}"
```

`recordingReconnectWindowSeconds` 파라미터를 전달하여 조각화된 개별 참가자 레코딩 병합을 활성화합니다(선택 사항).

```
aws ivs-realtime create-stage --name "test-stage-participant-recording" --auto-participant-recording-configuration "storageConfigurationArn=arn:aws:ivs:us-west-2:123456789012:storage-configuration/LKZ6QR7r55c2,mediaTypes=AUDIO_VIDEO,thumbnailConfiguration="{targetIntervalSeconds=10,storage=[SEQUENTIAL,LATEST],recordingMode=INTERVAL}",recordingReconnectWindowSeconds=60"
```

다음과 같이 응답합니다.

```
{
   "stage": {
      "arn": "arn:aws:ivs:us-west-2:123456789012:stage/VSWjvX5XOkU3",
      "autoParticipantRecordingConfiguration": {
         "hlsConfiguration": {
             "targetSegmentDurationSeconds": 6
         },
         "mediaTypes": [
            "AUDIO_VIDEO"
         ],
         "recordingReconnectWindowSeconds": 60,
         "recordParticipantReplicas": true,
         "storageConfigurationArn": "arn:aws:ivs:us-west-2:123456789012:storage-configuration/LKZ6QR7r55c2",
         "thumbnailConfiguration": {
            "recordingMode": "INTERVAL",
            "storage": [
               "SEQUENTIAL",
               "LATEST"
            ],
            "targetIntervalSeconds": 10
         }
      },
      "endpoints": {
         "events": "<events-endpoint>",
         "rtmp": "<rtmp-endpoint>",
         "rtmps": "<rtmps-endpoint>",
         "whip": "<whip-endpoint>"
      },
      "name": "test-stage-participant-recording"
   }
}
```

# 3단계: 참가자 토큰 배포
<a name="getting-started-distribute-tokens"></a>

이제 스테이지가 있으므로 참가자가 스테이지에 조인하고 비디오 송수신을 시작할 수 있도록 토큰을 생성하고 참가자에게 배포해야 합니다. 토큰을 생성하는 접근 방식은 두 가지입니다.
+ 키 페어로 토큰을 [생성](#getting-started-distribute-tokens-self-signed)합니다.
+ [IVS 실시간 스트리밍 API로 토큰을 생성합니다](#getting-started-distribute-tokens-api).

아래에 이러한 두 가지 접근 방식이 모두 설명되어 있습니다.

## 키 페어로 토큰 생성
<a name="getting-started-distribute-tokens-self-signed"></a>

서버 애플리케이션에서 토큰을 생성하여 스테이지에 조인하려는 참가자에게 배포할 수 있습니다. ECDSA 퍼블릭/프라이빗 키 페어를 생성하여 JWT에 서명하고 퍼블릭 키를 IVS로 가져옵니다. 그러면 스테이지 조인 시 IVS에서 토큰을 확인할 수 있습니다.

IVS에서는 키 만료를 제공하지 않습니다. 프라이빗 키가 손상된 경우 이전 퍼블릭 키를 삭제해야 합니다.

### 새 키 페어 생성
<a name="getting-started-distribute-tokens-self-signed-create-key-pair"></a>

키 페어를 생성하는 방법에는 여러 가지가 있습니다. 아래에 두 가지 예가 나와 있습니다.

콘솔에서 새 키 페어를 생성하려면 다음과 같은 단계를 따릅니다.

1. [Amazon IVS 콘솔](https://console.aws.amazon.com/ivs)을 엽니다. 아직 스테이지의 리전을 선택하지 않은 경우 리전을 선택합니다.

1. 왼쪽 탐색 메뉴에서 **실시간 스트리밍 > 퍼블릭 키**를 선택합니다.

1. **퍼블릭 키 생성**을 선택합니다. **퍼블릭 키 생성**이라는 대화 상자가 나타납니다.

1. [**생성(Create)**]을 선택하고 프롬프트의 메시지를 따릅니다.

1. Amazon IVS가 새 키 페어를 생성합니다. 퍼블릭 키는 퍼블릭 키 리소스로 가져오고 프라이빗 키는 즉시 다운로드할 수 있습니다. 필요하면 퍼블릭 키도 나중에 다운로드할 수 있습니다.

   Amazon IVS는 클라이언트 측에서 키를 생성하고 프라이빗 키를 저장하지 않습니다. ***나중에 검색할 수 없으므로 키를 저장해 두어야 합니다.***

OpenSSL로 새 P384 EC 키 페어를 생성하려면(먼저 [OpenSSL](https://www.openssl.org/source/)을 설치해야 할 수 있음) 다음 단계를 따릅니다. 이 과정을 통해 프라이빗 키와 퍼블릭 키 모두에 액세스할 수 있습니다. 퍼블릭 키는 토큰 확인을 테스트하려는 경우에만 필요합니다.

```
openssl ecparam -name secp384r1 -genkey -noout -out priv.pem
openssl ec -in priv.pem -pubout -out public.pem
```

이제 아래 지침을 사용하여 새 퍼블릭 키를 가져옵니다.

### 퍼블릭 키 가져오기
<a name="getting-started-distribute-tokens-import-public-key"></a>

키 페어가 있다면 퍼블릭 키를 IVS로 가져올 수 있습니다. 프라이빗 키는 시스템에 필요하지 않지만 사용자가 토큰에 서명하는 데 사용됩니다.

콘솔에서 기존 퍼블릭 키를 가져오는 방법

1. [Amazon IVS 콘솔](https://console.aws.amazon.com/ivs)을 엽니다. 아직 스테이지의 리전을 선택하지 않은 경우 리전을 선택합니다.

1. 왼쪽 탐색 메뉴에서 **실시간 스트리밍 > 퍼블릭 키**를 선택합니다.

1. **가져오기**를 선택합니다. **퍼블릭 키 가져오기**라는 대화 상자가 나타납니다.

1. 프롬프트를 따르고[**가져오기(Import)**]를 선택합니다.

1. Amazon IVS에서는 퍼블릭 키를 가져오고 퍼블릭 키 리소스를 생성합니다.

CLI로 기존 퍼블릭 키를 가져오는 방법

```
aws ivs-realtime import-public-key --public-key-material "`cat public.pem`" --region <aws-region>
```

리전이 로컬 AWS 구성 파일에 있는 경우 `--region <aws-region>`을 삭제할 수 있습니다.

다음은 예제 응답입니다.

```
{
    "publicKey": {
        "arn": "arn:aws:ivs:us-west-2:123456789012:public-key/f99cde61-c2b0-4df3-8941-ca7d38acca1a",
        "fingerprint": "98:0d:1a:a0:19:96:1e:ea:0a:0a:2c:9a:42:19:2b:e7",
        "publicKeyMaterial": "-----BEGIN PUBLIC KEY-----\nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVjYMV+P4ML6xemanCrtse/FDwsNnpYmS\nS6vRV9Wx37mjwi02hObKuCJqpj7x0lpz0bHm5v1JBvdZYAd/r2LR5aChK+/GM2Wj\nl8MG9NJIVFaw1u3bvjEjzTASSfS1BDX1\n-----END PUBLIC KEY-----\n",
        "tags": {}
    }
}
```

### API 요청
<a name="getting-started-distribute-tokens-create-api"></a>

```
POST /ImportPublicKey HTTP/1.1
{
  "publicKeyMaterial": "<pem file contents>"
}
```

### 토큰 생성 및 서명
<a name="getting-started-distribute-tokens-self-signed-generate-sign"></a>

JWT 및 지원되는 토큰 서명 라이브러리 작업에 대한 자세한 내용은 [jwt.io](https://jwt.io/)를 참조하세요. jwt.io 인터페이스에서 토큰에 서명하려면 프라이빗 키를 입력해야 합니다. 퍼블릭 키는 토큰을 확인하려는 경우에만 필요합니다.

모든 JWT에는 헤더, 페이로드 및 서명과 같은 세 개의 필드가 있습니다.

JWT의 헤더 및 페이로드에 대한 JSON 스키마는 아래에 설명되어 있습니다. 그 대신에 IVS 콘솔에서 샘플 JSON을 복사할 수 있습니다. IVS 콘솔에서 헤더 및 페이로드 JSON을 가져오는 방법:

1. [Amazon IVS 콘솔](https://console.aws.amazon.com/ivs)을 엽니다. 아직 스테이지의 리전을 선택하지 않은 경우 리전을 선택합니다.

1. 왼쪽 탐색 메뉴에서 **실시간 스트리밍 > 스테이지**를 선택합니다.

1. 사용할 스테이지를 선택합니다. **세부 정보 보기**를 선택합니다.

1. **참가자 토큰** 섹션에서 **토큰 생성** 옆의 드롭다운을 선택합니다.

1. **토큰 헤더 및 페이로드 빌드**를 선택합니다.

1. 양식을 작성하고 팝업 하단에 표시된 JWT 헤더와 페이로드를 복사합니다.

#### 토큰 스키마: 헤더
<a name="getting-started-distribute-tokens-self-signed-generate-sign-header"></a>

헤더는 다음을 지정합니다.
+ `alg`는 서명 알고리즘입니다. ES384는 SHA-384 해시 알고리즘을 사용하는 ECDSA 서명 알고리즘입니다.
+ `typ`는 토큰 유형, JWT입니다.
+ `kid`는 토큰에 서명하는 데 사용되는 퍼블릭 키의 ARN입니다. [ GetPublicKey](https://docs.aws.amazon.com//ivs/latest/RealTimeAPIReference/API_GetPublicKey.html) API 요청에서 반환된 ARN과 동일해야 합니다.

```
{
  "alg": "ES384",
  "typ": "JWT"
  “kid”: “arn:aws:ivs:123456789012:us-east-1:public-key/abcdefg12345”
}
```

#### 토큰 스키마: 페이로드
<a name="getting-started-distribute-tokens-self-signed-generate-sign-payload"></a>

페이로드에는 IVS 관련 데이터가 포함되어 있습니다. `user_id`를 제외한 모든 필드가 필수적입니다.
+ JWT 사양의 `RegisteredClaims`는 스테이지 토큰이 유효하려면 제공되어야 하는 예약된 클레임입니다.
  + `exp`(만료 시간)는 토큰이 만료될 시점의 Unix UTC 타임스탬프입니다. (Unix 타임스탬프는 윤초를 무시하고 1970-01-01T00:00:00Z UTC부터 지정된 UTC 날짜/시간까지의 초 수를 나타내는 숫자 값입니다.) 토큰은 참가자가 스테이지에 조인할 때 검증됩니다. IVS에서는 기본 12시간 TTL(권장 사항)의 토큰을 제공합니다. 발급 시간(iat)부터 최대 14일까지 연장할 수 있습니다. 이 값은 정수 유형이어야 합니다.
  + `iat`(발급 시간)는 JWT가 발급된 시점의 Unix UTC 타임스탬프입니다. (Unix 타임스탬프에 대한 `exp`은 참고를 참조하세요.) 값이 정수 유형이어야 합니다.
  + `jti`(JWT ID)는 토큰이 부여된 참가자를 추적하고 참조하는 데 사용되는 참가자 ID입니다. 토큰마다 고유한 참가자 ID가 있어야 합니다. 영숫자, 하이픈(-) 및 밑줄(\$1) 문자만 포함하여 최대 64자 길이의 대소문자를 구분하는 문자열이어야 합니다. 기타 특수 문자는 허용되지 않습니다.
+ `user_id`는 선택 사항이며, 토큰을 식별하는 데 도움이 되는 고객이 할당한 이름입니다. 참가자를 고객 자체 시스템의 사용자와 연결하는 데 사용할 수 있습니다. [CreateParticipantToken](https://docs.aws.amazon.com/ivs/latest/RealTimeAPIReference/API_CreateParticipantToken.html) API 요청의 `userId` 필드와 일치해야 합니다. UTF-8로 인코딩된 모든 텍스트가 가능하며, 최대 128자의 문자열입니다. **이 필드는 모든 스테이지 참가자에게 노출되며 개인 식별 정보, 기밀 정보 또는 민감한 정보에 사용해서는 안 됩니다.
+ `resource`는 스테이지의 ARN입니다(예: `arn:aws:ivs:us-east-1:123456789012:stage/oRmLNwuCeMlQ`).
+ `topic`은 스테이지 ARN에서 추출할 수 있는 스테이지의 ID입니다. 예를 들어, 스테이지 ARN이 `arn:aws:ivs:us-east-1:123456789012:stage/oRmLNwuCeMlQ`인 경우 스테이지 ID는 `oRmLNwuCeMlQ`입니다.
+ `events_url`은 CreateStage 또는 GetStage 작업에서 반환된 이벤트 엔드포인트여야 합니다. 스테이지 생성 시 이 값을 캐싱하는 것이 좋습니다. 최대 14일 동안 값을 캐싱할 수 있습니다. 예시 값은 `wss://global.events.live-video.net`입니다.
+ `whip_url`은 CreateStage 또는 GetStage 작업에서 반환된 WHIP 엔드포인트여야 합니다. 스테이지 생성 시 이 값을 캐싱하는 것이 좋습니다. 최대 14일 동안 값을 캐싱할 수 있습니다. 예시 값은 `https://453fdfd2ad24df.global-bm.whip.live-video.net`입니다.
+ `capabilities`는 토큰의 기능을 지정합니다. 유효한 값은 `allow_publish` 및 `allow_subscribe`입니다. 구독 전용 토큰의 경우 `allow_subscribe`만 `true`로 설정합니다.
+ `attributes`는 애플리케이션에서 제공되는 속성을 지정하여 토큰에 인코딩하고 스테이지에 연결할 수 있는 선택 사항 필드입니다. 맵 키와 값에는 UTF-8 인코딩 텍스트가 포함될 수 있습니다. 이 필드의 최대 길이는 총 1KB입니다. **이 필드는 모든 스테이지 참가자에게 노출되며 개인 식별 정보, 기밀 정보 또는 민감한 정보에 사용해서는 안 됩니다.
+ `version`은 `1.0`여야 합니다.

  ```
  {
    "exp": 1697322063,
    "iat": 1697149263,
    "jti": "Mx6clRRHODPy",
    "user_id": "<optional_customer_assigned_name>",
    "resource": "<stage_arn>",
    "topic": "<stage_id>",
    "events_url": "wss://global.events.live-video.net",
    "whip_url": "https://114ddfabadaf.global-bm.whip.live-video.net",
    "capabilities": {
      "allow_publish": true,
      "allow_subscribe": true
    },
    "attributes": {
      "optional_field_1": "abcd1234",
      "optional_field_2": "false"
    },
    "version": "1.0"
  }
  ```

#### 토큰 스키마: 서명
<a name="getting-started-distribute-tokens-self-signed-generate-sign-signature"></a>

서명을 생성하려면 헤더(ES384)에 지정된 알고리즘과 함께 프라이빗 키를 사용하여 인코딩된 헤더 및 인코딩된 페이로드에 서명합니다.

```
ECDSASHA384(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  <private-key>
)
```

#### 지침
<a name="getting-started-distribute-tokens-self-signed-generate-sign-instructions"></a>

1. ES384 서명 알고리즘과 IVS에 제공된 퍼블릭 키와 연결된 프라이빗 키로 토큰의 서명을 생성합니다.

1. 토큰을 수집합니다.

   ```
   base64UrlEncode(header) + "." +
   base64UrlEncode(payload) + "." +
   base64UrlEncode(signature)
   ```

## IVS 실시간 스트리밍 API로 토큰 생성
<a name="getting-started-distribute-tokens-api"></a>

![\[참가자 토큰 배포: 스테이지 토큰 워크플로\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Distribute_Participant_Tokens.png)


위에 표시된 것처럼 클라이언트 애플리케이션은 서버 애플리케이션에 토큰을 요청하고 서버 애플리케이션은 AWS SDK 또는 SigV4 서명 요청을 사용하여 CreateParticipantToken을 직접적으로 호출합니다. AWS 자격 증명은 API를 직접적으로 호출하는 데 사용되므로 토큰은 클라이언트 측 애플리케이션이 아닌 안전한 서버 측 애플리케이션에서 생성되어야 합니다.

참가자 토큰을 생성 시 선택 사항으로 속성 및/또는 기능을 지정할 수 있습니다.
+ 애플리케이션에서 제공되는 속성을 지정하여 토큰에 인코딩하고 스테이지에 연결할 수 있습니다. 맵 키와 값에는 UTF-8 인코딩 텍스트가 포함될 수 있습니다. 이 필드의 최대 길이는 총 1KB입니다. **이 필드는 모든 스테이지 참가자에게 노출되며 개인 식별 정보, 기밀 정보 또는 민감한 정보에 사용해서는 안 됩니다.
+ 토큰을 통해 활성화되는 기능을 지정할 수 있습니다. 기본값은 참가자가 오디오와 비디오를 전송하고 수신을 수 있도록 하는 `PUBLISH` 및 `SUBSCRIBE`이지만 기능의 하위 세트로 토큰을 발행할 수 있습니다. 예를 들어 진행자를 위한 `SUBSCRIBE` 기능만 있는 토큰을 발급할 수 있습니다. 이 경우 진행자는 비디오를 전송하는 참가자를 볼 수 있지만 직접 비디오를 전송할 수는 없습니다.

자세한 내용은 [CreateParticipantToken](https://docs.aws.amazon.com//ivs/latest/RealTimeAPIReference/API_CreateParticipantToken.html)을 참조하세요.

테스트 및 개발을 위해 콘솔이나 CLI를 통해 참가자 토큰을 생성할 수 있지만, 프로덕션 환경에서 AWS SDK를 사용하여 생성하는 것이 가장 좋습니다.

서버에서 각 클라이언트로 토큰을 배포하는 방법(예: API 요청을 통해)이 필요합니다. 이 기능은 제공하지 않습니다. 이 가이드에서는 다음 단계에 따라 토큰을 복사하여 클라이언트 코드에 붙여넣기만 하면 됩니다.

**중요**: 토큰을 불투명한 것으로 취급하세요. 즉, 토큰 콘텐츠를 기반으로 기능을 빌드하지 마세요. 토큰 형식은 향후 변경될 수 있습니다.

### 콘솔 지침
<a name="getting-started-distribute-tokens-console"></a>

1. 이전 단계에서 생성한 스테이지로 이동합니다.

1. **토큰 생성**을 선택합니다. **토큰 생성** 창이 나타납니다.

1. 토큰과 연결할 사용자 ID를 입력합니다. UTF-8로 인코딩된 텍스트일 수 있습니다.

1. **생성**을 선택합니다.

1. 토큰을 복사합니다. *중요: 토큰을 저장해야 합니다. IVS는 토큰을 저장하지 않으며 나중에 검색할 수 없습니다.*.

### CLI 지침
<a name="getting-started-distribute-tokens-cli"></a>

AWS CLI를 사용하여 채팅 토큰을 생성하려면 먼저 시스템에 CLI를 다운로드하고 구성해야 합니다. 자세한 내용은 [AWS 명령줄 인터페이스 사용 설명서](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html)를 참조하세요. AWS CLI를 사용하여 토큰을 생성하는 것은 테스트 목적에 적합하지만, 프로덕션 용도의 경우 AWS SDK를 사용하여 서버 측에서 토큰을 생성하는 것이 좋습니다(아래 지침 참조).

1. 스테이지 ARN과 함께 `create-participant-token` 명령을 실행합니다. `"PUBLISH"`, `"SUBSCRIBE"` 기능 중 일부 또는 전부를 포함합니다.

   ```
   aws ivs-realtime create-participant-token --stage-arn arn:aws:ivs:us-west-2:376666121854:stage/VSWjvX5XOkU3 --capabilities '["PUBLISH", "SUBSCRIBE"]'
   ```

1. 참가자 토큰이 반환됩니다.

   ```
   {
       "participantToken": {
           "capabilities": [
               "PUBLISH",
               "SUBSCRIBE"
           ],
           "expirationTime": "2023-06-03T07:04:31+00:00",
           "participantId": "tU06DT5jCJeb",
           "token": "eyJhbGciOiJLTVMiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjE2NjE1NDE0MjAsImp0aSI6ImpGcFdtdmVFTm9sUyIsInJlc291cmNlIjoiYXJuOmF3czppdnM6dXMtd2VzdC0yOjM3NjY2NjEyMTg1NDpzdGFnZS9NbzhPUWJ0RGpSIiwiZXZlbnRzX3VybCI6IndzczovL3VzLXdlc3QtMi5ldmVudHMubGl2ZS12aWRlby5uZXQiLCJ3aGlwX3VybCI6Imh0dHBzOi8vNjZmNzY1YWM4Mzc3Lmdsb2JhbC53aGlwLmxpdmUtdmlkZW8ubmV0IiwiY2FwYWJpbGl0aWVzIjp7ImFsbG93X3B1Ymxpc2giOnRydWUsImFsbG93X3N1YnNjcmliZSI6dHJ1ZX19.MGQCMGm9affqE3B2MAb_DSpEm0XEv25hfNNhYn5Um4U37FTpmdc3QzQKTKGF90swHqVrDgIwcHHHIDY3c9eanHyQmcKskR1hobD0Q9QK_GQETMQS54S-TaKjllW9Qac6c5xBrdAk"
       }
   }
   ```

1. 이 토큰을 저장합니다. 스테이지에 참가하고 비디오를 전송하고 수신하려면 이 토큰이 필요합니다.

### AWS SDK 지침
<a name="getting-started-distribute-tokens-sdk"></a>

AWS SDK를 사용하여 토큰을 생성할 수 있습니다. 다음은 JavaScript를 사용하는 AWS SDK에 대한 지침입니다.

**중요:** 이 코드는 서버 측에서 실행되어야 하며 해당 출력은 클라이언트에 전달되어야 합니다.

**사전 조건:** 아래 코드 샘플을 사용하려면 aws-sdk/client-ivs-realtime 패키지를 설치해야 합니다. 자세한 내용은 [JavaScript용 AWS SDK 시작하기](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-started.html)를 참조하세요.

```
import { IVSRealTimeClient, CreateParticipantTokenCommand } from "@aws-sdk/client-ivs-realtime";

const ivsRealtimeClient = new IVSRealTimeClient({ region: 'us-west-2' });
const stageArn = 'arn:aws:ivs:us-west-2:123456789012:stage/L210UYabcdef';
const createStageTokenRequest = new CreateParticipantTokenCommand({
  stageArn,
});
const response = await ivsRealtimeClient.send(createStageTokenRequest);
console.log('token', response.participantToken.token);
```

# 4단계: IVS Broadcast SDK 통합
<a name="getting-started-broadcast-sdk"></a>

IVS는 애플리케이션에 통합할 수 있는 웹, Android 및 iOS용 Broadcast SDK를 제공합니다. Broadcast SDK는 비디오 전송 및 수신에 모두 사용됩니다. [스테이지에 대한 RTMP 수집을 구성](https://docs.aws.amazon.com/ivs/latest/RealTimeUserGuide/rt-stream-ingest.html)한 경우 RTMP 엔드포인트로 브로드캐스팅할 수 있는 인코더를 사용할 수 있습니다(예: OBS 또는 ffmpeg).

이 섹션에서는 두 명 이상의 참가자가 실시간으로 상호 작용할 수 있는 간단한 애플리케이션을 작성하는 방법에 대해 설명하고 있습니다. 아래 단계에서는 BasicRealTime이라는 앱을 생성하는 과정을 안내합니다. 전체 앱 코드는 CodePen과 GitHub에 있습니다.
+  웹: [https://codepen.io/amazon-ivs/pen/ZEqgrpo](https://codepen.io/amazon-ivs/pen/ZEqgrpo) 
+  Android: [https://github.com/aws-samples/amazon-ivs-real-time-streaming-android-samples](https://github.com/aws-samples/amazon-ivs-real-time-streaming-android-samples) 
+  iOS: [https://github.com/aws-samples/amazon-ivs-real-time-streaming-ios-samples](https://github.com/aws-samples/amazon-ivs-real-time-streaming-ios-samples) 

## 웹
<a name="getting-started-broadcast-sdk-web"></a>

### 파일 설정
<a name="getting-started-broadcast-sdk-web-setup"></a>

시작하려면 폴더와 초기 HTML 및 JS 파일을 생성하여 파일을 설정합니다.

```
mkdir realtime-web-example
cd realtime-web-example
touch index.html
touch app.js
```

스크립트 태그 또는 npm을 사용하여 Broadcast SDK를 설치할 수 있습니다. 이 예제에서는 간단히 하기 위해 스크립트 태그를 사용하지만 나중에 npm을 사용하도록 선택하려는 경우 쉽게 수정할 수 있습니다.

### 스크립트 태그 사용
<a name="getting-started-broadcast-sdk-web-script"></a>

Web Broadcast SDK는 JavaScript 라이브러리로 배포되며 [https://web-broadcast.live-video.net/1.33.0/amazon-ivs-web-broadcast.js](https://web-broadcast.live-video.net/1.33.0/amazon-ivs-web-broadcast.js)에서 찾을 수 있습니다.

`<script>` 태그를 통해 로드되면 라이브러리는 `IVSBroadcastClient`라는 창 범위에 글로벌 변수를 노출합니다.

### npm 사용
<a name="getting-started-broadcast-sdk-web-npm"></a>

npm 패키지 설치

```
npm install amazon-ivs-web-broadcast
```

이제 IVSBroadcastClient 객체에 액세스할 수 있습니다.

```
const { Stage } = IVSBroadcastClient;
```

## Android
<a name="getting-started-broadcast-sdk-android"></a>

### Android 프로젝트 생성
<a name="getting-started-broadcast-sdk-android-project"></a>

1. Android Studio에서 **새 프로젝트**를 생성합니다.

1. **빈 보기 활동**을 선택합니다.

   참고: 일부 이전 버전의 Android Studio에서는 보기 기반 활동을 **빈 활동**이라고 합니다. Android Studio 창에 **빈 활동**이 표시되고 **빈 보기** 활동이 표시되지 **않으면 **빈 활동**을 선택합니다. 그렇지 않으면 Jetpack Composite가 아닌 View API를 사용하므로 **빈 활동**을 선택하지 마세요.

1. 프로젝트 **이름**을 지정한 다음 **완료**를 선택합니다.

### Broadcast SDK 설치
<a name="getting-started-broadcast-sdk-android-install"></a>

Android 개발 환경에 Amazon IVS Android 브로드캐스트 라이브러리를 추가하려면 여기에 표시된 대로 모듈의 `build.gradle` 파일에 라이브러리를 추가합니다(최신 버전의 Amazon IVS broadcast SDK의 경우). 최신 프로젝트에서는 `mavenCentral` 리포지토리가 이미 `settings.gradle` 파일에 포함되어 있을 수 있습니다. 이 경우 `repositories` 블록을 생략할 수 있습니다. 샘플의 경우 `android` 블록에서 데이터 바인딩도 활성화해야 합니다.

```
android {
    dataBinding.enabled true
}

repositories {
    mavenCentral()
}
 
dependencies {
     implementation 'com.amazonaws:ivs-broadcast:1.40.0:stages@aar'
}
```

또는 SDK를 수동으로 설치하려면 다음 위치에서 최신 버전을 다운로드하세요.

[https://search.maven.org/artifact/com.amazonaws/ivs-broadcast](https://search.maven.org/artifact/com.amazonaws/ivs-broadcast)

## iOS
<a name="getting-started-broadcast-sdk-ios"></a>

### iOS 프로젝트 생성
<a name="getting-started-broadcast-sdk-ios-project"></a>

1. 새 Xcode 프로젝트를 생성합니다

1. **플랫폼**에서 **iOS**를 선택합니다.

1. **애플리케이션**에서 **앱**을 선택합니다.

1. 앱의 **제품 이름**을 입력하고 **다음**을 선택합니다.

1. 프로젝트를 저장할 디렉터리를 선택(이동)하고 **생성**을 선택합니다.

다음으로 SDK를 가져와야 합니다. 지침은 *iOS Broadcast SDK 안내서*의 [라이브러리 설치](broadcast-ios-getting-started.md#broadcast-ios-install)를 참조하세요.

### 권한 구성
<a name="getting-started-broadcast-sdk-ios-config"></a>

프로젝트의 `Info.plist`를 업데이트하여 `NSCameraUsageDescription`과 `NSMicrophoneUsageDescription`에 대한 2개의 새 항목을 추가해야 합니다. 값으로 앱에서 카메라 및 마이크 액세스를 요청하는 이유에 대한 사용자 대면 설명을 제공합니다.

![\[iOS 권한 구성\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/iOS_Configure.png)


# 5단계: 비디오 게시 및 구독
<a name="getting-started-pub-sub"></a>

다음을 통해 IVS 게시/구독(실시간)이 가능합니다.
+ WebRTC와 RTMPS를 지원하는 네이티브 [IVS Broadcast SDK](https://docs.aws.amazon.com//ivs/latest/LowLatencyUserGuide/getting-started-set-up-streaming.html#broadcast-sdk). 특히 프로덕션 시나리오의 경우 이 방법을 사용하는 것이 좋습니다. [웹](getting-started-pub-sub-web.md), [Android](getting-started-pub-sub-android.md) 및 [iOS](getting-started-pub-sub-ios.md)에 대한 자세한 내용은 아래를 참조하세요.
+ Amazon IVS 콘솔. 스트리밍을 테스트하는 데 적합합니다. 자세한 내용은 아래 섹션을 참조하세요.
+ 기타 스트리밍 소프트웨어 및 하드웨어 인코더 - RTMP, RTMPS 또는 WHIP 프로토콜을 지원하는 모든 스트리밍 인코더를 사용할 수 있습니다. 자세한 내용은 [스트림 수집](rt-stream-ingest.md)을 참조하세요.

## IVS 콘솔
<a name="getting-started-pub-sub-console"></a>

1. [Amazon IVS 콘솔](https://console.aws.amazon.com/ivs)을 엽니다.

   ([AWS Management Console](https://console.aws.amazon.com/)을 통해 Amazon IVS 콘솔에 액세스할 수도 있습니다.)

1. 탐색 창에서 **스테이지**를 선택합니다. (탐색 창이 축소되어 있는 경우 햄버거 아이콘을 선택하여 펼치세요.)

1. 구독 또는 게시하려는 스테이지를 선택하여 해당 세부 정보 페이지로 이동합니다.

1. 구독하는 방법: 스테이지에 게시자가 한 명 이상 있으면 **구독** 탭 아래의 **구독** 버튼을 눌러 구독할 수 있습니다. (탭은 **일반 구성** 섹션 아래에 있습니다.)

1. 게시하는 방법:

   1. **게시** 탭을 선택합니다.

   1. IVS 콘솔에 카메라 및 마이크에 대한 액세스 권한을 부여하라는 메시지가 표시됩니다. 해당 권한을 **허용**합니다.

   1. **게시** 탭 하단의 드롭다운 상자를 사용하여 마이크 및 카메라용 입력 디바이스를 선택합니다.

   1. 게시를 시작하려면 **게시 시작**을 선택합니다.

   1. 게시된 콘텐츠를 보려면 **구독** 탭으로 돌아갑니다.

   1. 게시를 중지하려면 **게시** 탭으로 이동하여 **게시 중지** 버튼을 아래쪽으로 누릅니다.

**참고:** 구독 및 게시에는 리소스가 소비되며, 스테이지에 연결된 시간에 대한 시간당 요금이 발생합니다. 자세한 내용은 IVS 요금 페이지의 [실시간 스트리밍](https://aws.amazon.com/ivs/pricing/#Real-Time_Streaming)을 참조하세요.

# IVS Web Broadcast SDK를 사용하여 게시 및 구독
<a name="getting-started-pub-sub-web"></a>

이 섹션에서는 웹 앱을 사용하여 스테이지에 게시하고 구독하는 데 관련된 단계를 안내합니다.

## HTML 표준 문안 생성
<a name="getting-started-pub-sub-web-html"></a>

먼저 HTML 표준 문안을 생성하고 라이브러리를 스크립트 태그로 가져오겠습니다.

```
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />

  <!-- Import the SDK -->
  <script src="https://web-broadcast.live-video.net/1.33.0/amazon-ivs-web-broadcast.js"></script>
</head>

<body>

<!-- TODO - fill in with next sections -->
<script src="./app.js"></script>

</body>
</html>
```

## 토큰 입력 수락 및 Join/Leave 버튼 추가
<a name="getting-started-pub-sub-web-join"></a>

여기서는 입력 통제로 본문을 채웁니다. 입력 통제는 토큰을 입력으로 사용하고 **Join** 및 **Leave** 버튼을 설정합니다. 일반적으로 애플리케이션은 애플리케이션의 API에서 토큰을 요청하지만 이 예제에서는 토큰을 복사하여 토큰 입력에 붙여넣습니다.

```
<h1>IVS Real-Time Streaming</h1>
<hr />

<label for="token">Token</label>
<input type="text" id="token" name="token" />
<button class="button" id="join-button">Join</button>
<button class="button" id="leave-button" style="display: none;">Leave</button>
<hr />
```

## 미디어 컨테이너 요소 추가
<a name="getting-started-pub-sub-web-media"></a>

이러한 요소에는 로컬 및 원격 참가자를 위한 미디어가 포함됩니다. `app.js`에 정의된 애플리케이션 로직을 로드하는 스크립트 태그를 추가합니다.

```
<!-- Local Participant -->
<div id="local-media"></div>

<!-- Remote Participants -->
<div id="remote-media"></div>

<!-- Load Script -->
<script src="./app.js"></script>
```

이렇게 하면 HTML 페이지가 완성되고 브라우저에서 `index.html`을 로드할 때 다음이 표시됩니다.

![\[브라우저에서 실시간 스트리밍 보기: HTML 설정 완료.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/RT_Browser_View.png)


## app.js 생성
<a name="getting-started-pub-sub-web-appjs"></a>

이제 `app.js` 파일의 내용을 정의하도록 하겠습니다. 먼저 SDK의 글로벌에서 필요한 모든 속성을 가져옵니다.

```
const {
  Stage,
  LocalStageStream,
  SubscribeType,
  StageEvents,
  ConnectionState,
  StreamType
} = IVSBroadcastClient;
```

## 애플리케이션 변수 생성
<a name="getting-started-pub-sub-web-vars"></a>

**Join** 및 **Leave** 버튼 HTML 요소에 대한 참조를 포함하고 애플리케이션의 상태를 저장하는 변수를 설정합니다.

```
let joinButton = document.getElementById("join-button");
let leaveButton = document.getElementById("leave-button");

// Stage management
let stage;
let joining = false;
let connected = false;
let localCamera;
let localMic;
let cameraStageStream;
let micStageStream;
```

## joinStage 1 생성: 함수 정의 및 입력 검증
<a name="getting-started-pub-sub-web-joinstage1"></a>

`joinStage` 함수는 입력 토큰을 가져와서 스테이지에 대한 연결을 생성하고 `getUserMedia`에서 검색된 비디오와 오디오를 게시하기 시작합니다.

먼저 함수를 정의하고 상태 및 토큰 입력을 검증합니다. 다음 몇몇 섹션에서 이 함수를 구체화하겠습니다.

```
const joinStage = async () => {
  if (connected || joining) {
    return;
  }
  joining = true;

  const token = document.getElementById("token").value;

  if (!token) {
    window.alert("Please enter a participant token");
    joining = false;
    return;
  }

  // Fill in with the next sections
};
```

## joinStage 2 생성: 게시할 미디어 가져오기
<a name="getting-started-pub-sub-web-joinstage2"></a>

다음은 스테이지에 게시될 미디어입니다.

```
async function getCamera() {
  // Use Max Width and Height
  return navigator.mediaDevices.getUserMedia({
    video: true,
    audio: false
  });
}

async function getMic() {
  return navigator.mediaDevices.getUserMedia({
    video: false,
    audio: true
  });
}

// Retrieve the User Media currently set on the page
localCamera = await getCamera();
localMic = await getMic();

// Create StageStreams for Audio and Video
cameraStageStream = new LocalStageStream(localCamera.getVideoTracks()[0]);
micStageStream = new LocalStageStream(localMic.getAudioTracks()[0]);
```

## joinStage 3 생성: 스테이지 전략 정의 및 스테이지 생성
<a name="getting-started-pub-sub-web-joinstage3"></a>

이 단계 전략은 SDK가 게시할 항목과 구독할 참가자를 결정하는 데 사용하는 결정 로직의 핵심입니다. 함수의 용도에 대한 자세한 내용은 [전략](web-publish-subscribe.md#web-publish-subscribe-concepts-strategy)을 참조하세요.

이 전략은 간단합니다. 스테이지에 참가한 후 방금 검색한 스트림을 게시하고 모든 원격 참가자의 오디오와 비디오를 구독합니다.

```
const strategy = {
  stageStreamsToPublish() {
    return [cameraStageStream, micStageStream];
  },
  shouldPublishParticipant() {
    return true;
  },
  shouldSubscribeToParticipant() {
    return SubscribeType.AUDIO_VIDEO;
  }
};

stage = new Stage(token, strategy);
```

## joinStage 4 생성: 스테이지 이벤트 처리 및 미디어 렌더링
<a name="getting-started-pub-sub-web-joinstage4"></a>

스테이지는 많은 이벤트를 내보냅니다. 페이지에서 미디어를 렌더링하고 제거하려면 `STAGE_PARTICIPANT_STREAMS_ADDED`와 `STAGE_PARTICIPANT_LEFT`를 수신해야 합니다. [이벤트](web-publish-subscribe.md#web-publish-subscribe-concepts-events)에는 보다 포괄적인 이벤트 세트가 나열됩니다.

여기서는 필요한 DOM 요소를 관리하는 데 도움이 되는 4가지 도우미 함수인 `setupParticipant`, `teardownParticipant`, `createVideoEl` 및 `createContainer`를 생성합니다.

```
stage.on(StageEvents.STAGE_CONNECTION_STATE_CHANGED, (state) => {
  connected = state === ConnectionState.CONNECTED;

  if (connected) {
    joining = false;
    joinButton.style = "display: none";
    leaveButton.style = "display: inline-block";
  }
});

stage.on(
  StageEvents.STAGE_PARTICIPANT_STREAMS_ADDED,
  (participant, streams) => {
    console.log("Participant Media Added: ", participant, streams);

    let streamsToDisplay = streams;

    if (participant.isLocal) {
      // Ensure to exclude local audio streams, otherwise echo will occur
      streamsToDisplay = streams.filter(
        (stream) => stream.streamType === StreamType.VIDEO
      );
    }

    const videoEl = setupParticipant(participant);
    streamsToDisplay.forEach((stream) =>
      videoEl.srcObject.addTrack(stream.mediaStreamTrack)
    );
  }
);

stage.on(StageEvents.STAGE_PARTICIPANT_LEFT, (participant) => {
  console.log("Participant Left: ", participant);
  teardownParticipant(participant);
});


// Helper functions for managing DOM

function setupParticipant({ isLocal, id }) {
  const groupId = isLocal ? "local-media" : "remote-media";
  const groupContainer = document.getElementById(groupId);

  const participantContainerId = isLocal ? "local" : id;
  const participantContainer = createContainer(participantContainerId);
  const videoEl = createVideoEl(participantContainerId);

  participantContainer.appendChild(videoEl);
  groupContainer.appendChild(participantContainer);

  return videoEl;
}

function teardownParticipant({ isLocal, id }) {
  const groupId = isLocal ? "local-media" : "remote-media";
  const groupContainer = document.getElementById(groupId);
  const participantContainerId = isLocal ? "local" : id;

  const participantDiv = document.getElementById(
    participantContainerId + "-container"
  );
  if (!participantDiv) {
    return;
  }
  groupContainer.removeChild(participantDiv);
}

function createVideoEl(id) {
  const videoEl = document.createElement("video");
  videoEl.id = id;
  videoEl.autoplay = true;
  videoEl.playsInline = true;
  videoEl.srcObject = new MediaStream();
  return videoEl;
}

function createContainer(id) {
  const participantContainer = document.createElement("div");
  participantContainer.classList = "participant-container";
  participantContainer.id = id + "-container";

  return participantContainer;
}
```

## joinStage 5 생성: 스테이지에 참가
<a name="getting-started-pub-sub-web-joinstage5"></a>

드디어 스테이지에 참가하여 `joinStage` 함수를 완성해 보겠습니다.

```
try {
  await stage.join();
} catch (err) {
  joining = false;
  connected = false;
  console.error(err.message);
}
```

## leaveStage 생성
<a name="getting-started-pub-sub-web-leavestage"></a>

leave 버튼이 간접적으로 호출할 `leaveStage` 함수를 정의합니다.

```
const leaveStage = async () => {
  stage.leave();

  joining = false;
  connected = false;
};
```

## 입력 이벤트 핸들러 초기화
<a name="getting-started-pub-sub-web-handlers"></a>

`app.js` 파일에 마지막 함수를 하나 추가하겠습니다. 이 함수는 페이지가 로드될 때 즉시 간접적으로 호출되고 스테이지 참가 및 탈퇴를 위한 이벤트 핸들러를 설정합니다.

```
const init = async () => {
  try {
    // Prevents issues on Safari/FF so devices are not blank
    await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
  } catch (e) {
    alert(
      "Problem retrieving media! Enable camera and microphone permissions."
    );
  }

  joinButton.addEventListener("click", () => {
    joinStage();
  });

  leaveButton.addEventListener("click", () => {
    leaveStage();
    joinButton.style = "display: inline-block";
    leaveButton.style = "display: none";
  });
};

init(); // call the function
```

## 애플리케이션 실행 및 토큰 제공
<a name="getting-started-pub-sub-run-app"></a>

이 부분에서는 로컬에서 또는 다른 사용자와 웹 페이지를 공유하고, [페이지를 열고](#getting-started-pub-sub-web-media), 참가자 토큰을 입력하여 스테이지에 참가할 수 있습니다.

## 다음 단계
<a name="getting-started-pub-sub-next"></a>

npm, React 등과 관련된 자세한 예제를 확인하려면 [IVS Broadcast SDK: 웹 안내서(실시간 스트리밍 가이드)](broadcast-web.md)를 참조하세요.

# IVS Android Broadcast SDK를 사용하여 게시 및 구독
<a name="getting-started-pub-sub-android"></a>

이 섹션에서는 Android 앱을 사용하여 스테이지에 게시하고 구독하는 단계를 안내합니다.

## 보기 생성
<a name="getting-started-pub-sub-android-views"></a>

먼저 자동 생성된 `activity_main.xml` 파일을 사용하여 앱의 간단한 레이아웃을 생성합니다. 레이아웃에는 토큰 추가를 위한 `EditText`, Join `Button`, 스테이지 상태 표시를 위한 `TextView`, 게시 전환을 위한 `CheckBox`가 포함되어 있습니다.

![\[Android 앱의 게시 레이아웃을 설정합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_Android_1.png)


다음은 보기 뒤의 XML입니다.

```
<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:keepScreenOn="true"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".BasicActivity">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/main_controls_container"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/cardview_dark_background"
            android:padding="12dp"
            app:layout_constraintTop_toTopOf="parent">

            <EditText
                android:id="@+id/main_token"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:autofillHints="@null"
                android:backgroundTint="@color/white"
                android:hint="@string/token"
                android:imeOptions="actionDone"
                android:inputType="text"
                android:textColor="@color/white"
                app:layout_constraintEnd_toStartOf="@id/main_join"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <Button
                android:id="@+id/main_join"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:backgroundTint="@color/black"
                android:text="@string/join"
                android:textAllCaps="true"
                android:textColor="@color/white"
                android:textSize="16sp"
                app:layout_constraintBottom_toBottomOf="@+id/main_token"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toEndOf="@id/main_token" />

            <TextView
                android:id="@+id/main_state"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@string/state"
                android:textColor="@color/white"
                android:textSize="18sp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/main_token" />

            <TextView
                android:id="@+id/main_publish_text"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@string/publish"
                android:textColor="@color/white"
                android:textSize="18sp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toStartOf="@id/main_publish_checkbox"
                app:layout_constraintTop_toBottomOf="@id/main_token" />

            <CheckBox
                android:id="@+id/main_publish_checkbox"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:buttonTint="@color/white"
                android:checked="true"
                app:layout_constraintBottom_toBottomOf="@id/main_publish_text"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintTop_toTopOf="@id/main_publish_text" />

        </androidx.constraintlayout.widget.ConstraintLayout>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/main_recycler_view"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintTop_toBottomOf="@+id/main_controls_container"
            app:layout_constraintBottom_toBottomOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>
<layout>
```

여기에서 몇 가지 문자열 ID를 참조했으므로 이제 전체 `strings.xml` 파일을 생성하겠습니다.

```
<resources>
    <string name="app_name">BasicRealTime</string>
    <string name="join">Join</string>
    <string name="leave">Leave</string>
    <string name="token">Participant Token</string>
    <string name="publish">Publish</string>
    <string name="state">State: %1$s</string>
</resources>
```

XML의 이러한 보기를 `MainActivity.kt`에 연결해 보겠습니다.

```
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

private lateinit var checkboxPublish: CheckBox
private lateinit var recyclerView: RecyclerView
private lateinit var buttonJoin: Button
private lateinit var textViewState: TextView
private lateinit var editTextToken: EditText

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

    checkboxPublish = findViewById(R.id.main_publish_checkbox)
    recyclerView = findViewById(R.id.main_recycler_view)
    buttonJoin = findViewById(R.id.main_join)
    textViewState = findViewById(R.id.main_state)
    editTextToken = findViewById(R.id.main_token)
}
```

이제 `RecyclerView`에 대한 항목 보기를 생성합니다. 이렇게 하려면 `res/layout` 디렉터리를 마우스 오른쪽 버튼으로 클릭하고 **신규 > 레이아웃 리소스 파일**을 선택합니다. 이 파일의 이름을 `item_stage_participant.xml`로 바꿉니다.

![\[Android 앱 RecyclerView의 항목 보기를 생성합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_Android_2.png)


이 항목의 레이아웃은 간단합니다. 여기에는 참가자의 비디오 스트림을 렌더링하기 위한 보기와 참가자에 대한 정보를 표시하기 위한 레이블 목록이 포함되어 있습니다.

![\[Android 앱 RecyclerView - 레이블의 항목 보기를 생성합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_Android_3.png)


다음은 XML입니다.

```
<?xml version="1.0" encoding="utf-8"?>
<com.amazonaws.ivs.realtime.basicrealtime.ParticipantItem 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">

    <FrameLayout
        android:id="@+id/participant_preview_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:background="@android:color/darker_gray" />

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:background="#50000000"
        android:orientation="vertical"
        android:paddingLeft="4dp"
        android:paddingTop="2dp"
        android:paddingRight="4dp"
        android:paddingBottom="2dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <TextView
            android:id="@+id/participant_participant_id"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="@android:color/white"
            android:textSize="16sp"
            tools:text="You (Disconnected)" />

        <TextView
            android:id="@+id/participant_publishing"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="@android:color/white"
            android:textSize="16sp"
            tools:text="NOT_PUBLISHED" />

        <TextView
            android:id="@+id/participant_subscribed"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="@android:color/white"
            android:textSize="16sp"
            tools:text="NOT_SUBSCRIBED" />

        <TextView
            android:id="@+id/participant_video_muted"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="@android:color/white"
            android:textSize="16sp"
            tools:text="Video Muted: false" />

        <TextView
            android:id="@+id/participant_audio_muted"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="@android:color/white"
            android:textSize="16sp"
            tools:text="Audio Muted: false" />

        <TextView
            android:id="@+id/participant_audio_level"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="@android:color/white"
            android:textSize="16sp"
            tools:text="Audio Level: -100 dB" />

    </LinearLayout>

</com.amazonaws.ivs.realtime.basicrealtime.ParticipantItem>
```

이 XML 파일은 아직 생성하지 않은 클래스인 `ParticipantItem`을 확장합니다. XML에는 전체 네임스페이스가 포함되어 있으므로 이 XML 파일을 네임스페이스로 업데이트해야 합니다. 이 클래스를 만들고 보기를 설정하되 지금은 비워 두겠습니다.

새 Kotlin 클래스 `ParticipantItem`을 생성합니다.

```
package com.amazonaws.ivs.realtime.basicrealtime

import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.TextView
import kotlin.math.roundToInt

class ParticipantItem @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
    defStyleRes: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {

    private lateinit var previewContainer: FrameLayout
    private lateinit var textViewParticipantId: TextView
    private lateinit var textViewPublish: TextView
    private lateinit var textViewSubscribe: TextView
    private lateinit var textViewVideoMuted: TextView
    private lateinit var textViewAudioMuted: TextView
    private lateinit var textViewAudioLevel: TextView

    override fun onFinishInflate() {
        super.onFinishInflate()
        previewContainer = findViewById(R.id.participant_preview_container)
        textViewParticipantId = findViewById(R.id.participant_participant_id)
        textViewPublish = findViewById(R.id.participant_publishing)
        textViewSubscribe = findViewById(R.id.participant_subscribed)
        textViewVideoMuted = findViewById(R.id.participant_video_muted)
        textViewAudioMuted = findViewById(R.id.participant_audio_muted)
        textViewAudioLevel = findViewById(R.id.participant_audio_level)
    }
}
```

## 권한
<a name="getting-started-pub-sub-android-perms"></a>

카메라와 마이크를 사용하려면 사용자에게 권한을 요청해야 합니다. 이에 대한 표준 권한 흐름을 따릅니다.

```
override fun onStart() {
    super.onStart()
    requestPermission()
}

private val requestPermissionLauncher =
    registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
        if (permissions[Manifest.permission.CAMERA] == true && permissions[Manifest.permission.RECORD_AUDIO] == true) {
            viewModel.permissionGranted() // we will add this later
        }
    }

private val permissions = listOf(
    Manifest.permission.CAMERA,
    Manifest.permission.RECORD_AUDIO,
)

private fun requestPermission() {
    when {
        this.hasPermissions(permissions) -> viewModel.permissionGranted() // we will add this later
        else -> requestPermissionLauncher.launch(permissions.toTypedArray())
    }
}

private fun Context.hasPermissions(permissions: List<String>): Boolean {
    return permissions.all {
        ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
    }
}
```

## 앱 상태
<a name="getting-started-pub-sub-android-app-state"></a>

애플리케이션은 `MainViewModel.kt`에서 로컬로 참가자를 추적하며, 상태는 Kotlin의 [StateFlow](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/)를 사용하여 `MainActivity`에 다시 전달됩니다.

새 Kotlin 클래스 `MainViewModel`을 생성합니다.

```
package com.amazonaws.ivs.realtime.basicrealtime

import android.app.Application
import androidx.lifecycle.AndroidViewModel

class MainViewModel(application: Application) : AndroidViewModel(application), Stage.Strategy, StageRenderer {

}
```

`MainActivity.kt`에서 보기 모델을 관리합니다.

```
import androidx.activity.viewModels

private val viewModel: MainViewModel by viewModels()
```

`AndroidViewModel`과 이러한 Kotlin `ViewModel` 확장을 사용하려면 모듈의 `build.gradle` 파일에 다음을 추가해야 합니다.

```
implementation 'androidx.core:core-ktx:1.10.1'
implementation "androidx.activity:activity-ktx:1.7.2"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.10.0'
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"

def lifecycle_version = "2.6.1"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
```

### RecyclerView 어댑터
<a name="getting-started-pub-sub-android-app-state-recycler"></a>

간단한 `RecyclerView.Adapter` 하위 클래스를 생성하여 참가자를 추적하고 스테이지 이벤트에서 `RecyclerView`를 업데이트합니다. 그러나 먼저 참가자를 나타내는 클래스가 필요합니다. 새 Kotlin 클래스 `StageParticipant`을 생성합니다.

```
package com.amazonaws.ivs.realtime.basicrealtime

import com.amazonaws.ivs.broadcast.Stage
import com.amazonaws.ivs.broadcast.StageStream

class StageParticipant(val isLocal: Boolean, var participantId: String?) {
    var publishState = Stage.PublishState.NOT_PUBLISHED
    var subscribeState = Stage.SubscribeState.NOT_SUBSCRIBED
    var streams = mutableListOf<StageStream>()

    val stableID: String
        get() {
            return if (isLocal) {
                "LocalUser"
            } else {
                requireNotNull(participantId)
            }
        }
}
```

다음에 생성할 `ParticipantAdapter` 클래스에서 이 클래스를 사용하겠습니다. 먼저 클래스를 정의하고 참가자를 추적할 변수를 생성합니다.

```
package com.amazonaws.ivs.realtime.basicrealtime

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView

class ParticipantAdapter : RecyclerView.Adapter<ParticipantAdapter.ViewHolder>() {

    private val participants = mutableListOf<StageParticipant>()
```

또한 나머지 재정의를 구현하기 전에 `RecyclerView.ViewHolder`을 정의해야 합니다.

```
class ViewHolder(val participantItem: ParticipantItem) : RecyclerView.ViewHolder(participantItem)
```

이를 사용하여 표준 `RecyclerView.Adapter` 재정의를 구현할 수 있습니다.

```
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val item = LayoutInflater.from(parent.context)
        .inflate(R.layout.item_stage_participant, parent, false) as ParticipantItem
    return ViewHolder(item)
}

override fun getItemCount(): Int {
    return participants.size
}

override fun getItemId(position: Int): Long =
    participants[position]
        .stableID
        .hashCode()
        .toLong()

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    return holder.participantItem.bind(participants[position])
}

override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
    val updates = payloads.filterIsInstance<StageParticipant>()
    if (updates.isNotEmpty()) {
        updates.forEach { holder.participantItem.bind(it) // implemented later }
    } else {
        super.onBindViewHolder(holder, position, payloads)
    }
}
```

마지막으로, 참가자가 변경될 때 `MainViewModel`에서 직접적으로 호출할 새 메서드를 추가합니다. 이러한 메서드는 어댑터에 대한 표준 CRUD 작업입니다.

```
fun participantJoined(participant: StageParticipant) {
    participants.add(participant)
    notifyItemInserted(participants.size - 1)
}

fun participantLeft(participantId: String) {
    val index = participants.indexOfFirst { it.participantId == participantId }
    if (index != -1) {
        participants.removeAt(index)
        notifyItemRemoved(index)
    }
}

fun participantUpdated(participantId: String?, update: (participant: StageParticipant) -> Unit) {
    val index = participants.indexOfFirst { it.participantId == participantId }
    if (index != -1) {
        update(participants[index])
        notifyItemChanged(index, participants[index])
    }
}
```

`MainViewModel`로 돌아가서 이 어댑터에 대한 참조를 생성하고 포함해야 합니다.

```
internal val participantAdapter = ParticipantAdapter()
```

## 단계 상태
<a name="getting-started-pub-sub-android-views-stage-state"></a>

또한 `MainViewModel` 내에서 일부 스테이지 상태를 추적해야 합니다. 이제 이러한 속성을 정의해 보겠습니다.

```
private val _connectionState = MutableStateFlow(Stage.ConnectionState.DISCONNECTED)
val connectionState = _connectionState.asStateFlow()

private var publishEnabled: Boolean = false
    set(value) {
        field = value
        // Because the strategy returns the value of `checkboxPublish.isChecked`, just call `refreshStrategy`.
        stage?.refreshStrategy()
    }

private var deviceDiscovery: DeviceDiscovery? = null
private var stage: Stage? = null
private var streams = mutableListOf<LocalStageStream>()
```

스테이지에 참가하기 전에 미리 보기를 보려면 로컬 참가자를 즉시 생성합니다.

```
init {
    deviceDiscovery = DeviceDiscovery(application)

    // Create a local participant immediately to render our camera preview and microphone stats
    val localParticipant = StageParticipant(true, null)
    participantAdapter.participantJoined(localParticipant)
}
```

`ViewModel`이 정리될 때 이러한 리소스를 정리해야 합니다. `onCleared()`를 즉시 재정의하므로 이러한 리소스를 정리하는 것을 잊지 않습니다.

```
override fun onCleared() {
    stage?.release()
    deviceDiscovery?.release()
    deviceDiscovery = null
    super.onCleared()
}
```

이제 권한이 부여되는 즉시 로컬 `streams` 속성을 채우고 이전에 직접적으로 호출한 `permissionsGranted` 메서드를 구현합니다.

```
internal fun permissionGranted() {
    val deviceDiscovery = deviceDiscovery ?: return
    streams.clear()
    val devices = deviceDiscovery.listLocalDevices()
    // Camera
    devices
        .filter { it.descriptor.type == Device.Descriptor.DeviceType.CAMERA }
        .maxByOrNull { it.descriptor.position == Device.Descriptor.Position.FRONT }
        ?.let { streams.add(ImageLocalStageStream(it)) }
    // Microphone
    devices
        .filter { it.descriptor.type == Device.Descriptor.DeviceType.MICROPHONE }
        .maxByOrNull { it.descriptor.isDefault }
        ?.let { streams.add(AudioLocalStageStream(it)) }

    stage?.refreshStrategy()

    // Update our local participant with these new streams
    participantAdapter.participantUpdated(null) {
        it.streams.clear()
        it.streams.addAll(streams)
    }
}
```

## 스테이지 SDK 구현
<a name="getting-started-pub-sub-android-stage-sdk"></a>

실시간 기능의 3가지 [핵심 개념](android-publish-subscribe.md#android-publish-subscribe-concepts)은 스테이지, 전략 및 렌더러입니다. 설계 목표는 작동하는 제품을 구축하는 데 필요한 클라이언트 측 로직의 수를 최소화하는 것입니다.

### 스테이지. 전략
<a name="getting-started-pub-sub-android-stage-sdk-strategy"></a>

우리의 `Stage.Strategy` 구현은 간단합니다.

```
override fun stageStreamsToPublishForParticipant(
    stage: Stage,
    participantInfo: ParticipantInfo
): MutableList<LocalStageStream> {
    // Return the camera and microphone to be published.
    // This is only called if `shouldPublishFromParticipant` returns true.
    return streams
}

override fun shouldPublishFromParticipant(stage: Stage, participantInfo: ParticipantInfo): Boolean {
    return publishEnabled
}

override fun shouldSubscribeToParticipant(stage: Stage, participantInfo: ParticipantInfo): Stage.SubscribeType {
    // Subscribe to both audio and video for all publishing participants.
    return Stage.SubscribeType.AUDIO_VIDEO
}
```

요약하면, 내부 `publishEnabled` 상태를 기반으로 게시합니다. 게시하는 경우 이전에 수집한 스트림을 게시합니다. 마지막으로 이 샘플에서는 항상 다른 참가자를 구독하여 오디오와 비디오를 모두 수신합니다.

### StageRenderer
<a name="getting-started-pub-sub-android-stage-sdk-renderer"></a>

`StageRenderer` 구현도 매우 간단하지만 함수 수를 감안할 때 훨씬 더 많은 코드가 포함되어 있습니다. 이 렌더러의 일반적인 접근 방식은 SDK가 참가자에 대한 변경 사항을 알릴 때 참가자 `ParticipantAdapter`를 업데이트하는 것입니다. 로컬 참가자가 참가하기 전에 카메라 미리 보기를 볼 수 있도록 직접 관리하기로 결정했기 때문에 로컬 참가자를 다르게 처리하는 특정 시나리오가 있습니다.

```
override fun onError(exception: BroadcastException) {
    Toast.makeText(getApplication(), "onError ${exception.localizedMessage}", Toast.LENGTH_LONG).show()
    Log.e("BasicRealTime", "onError $exception")
}

override fun onConnectionStateChanged(
    stage: Stage,
    connectionState: Stage.ConnectionState,
    exception: BroadcastException?
) {
    _connectionState.value = connectionState
}

override fun onParticipantJoined(stage: Stage, participantInfo: ParticipantInfo) {
    if (participantInfo.isLocal) {
        // If this is the local participant joining the stage, update the participant with a null ID because we
        // manually added that participant when setting up our preview
        participantAdapter.participantUpdated(null) {
            it.participantId = participantInfo.participantId
        }
    } else {
        // If they are not local, add them normally
        participantAdapter.participantJoined(
            StageParticipant(
                participantInfo.isLocal,
                participantInfo.participantId
            )
        )
    }
}

override fun onParticipantLeft(stage: Stage, participantInfo: ParticipantInfo) {
    if (participantInfo.isLocal) {
        // If this is the local participant leaving the stage, update the ID but keep it around because
        // we want to keep the camera preview active
        participantAdapter.participantUpdated(participantInfo.participantId) {
            it.participantId = null
        }
    } else {
        // If they are not local, have them leave normally
        participantAdapter.participantLeft(participantInfo.participantId)
    }
}

override fun onParticipantPublishStateChanged(
    stage: Stage,
    participantInfo: ParticipantInfo,
    publishState: Stage.PublishState
) {
    // Update the publishing state of this participant
    participantAdapter.participantUpdated(participantInfo.participantId) {
        it.publishState = publishState
    }
}

override fun onParticipantSubscribeStateChanged(
    stage: Stage,
    participantInfo: ParticipantInfo,
    subscribeState: Stage.SubscribeState
) {
    // Update the subscribe state of this participant
    participantAdapter.participantUpdated(participantInfo.participantId) {
        it.subscribeState = subscribeState
    }
}

override fun onStreamsAdded(stage: Stage, participantInfo: ParticipantInfo, streams: MutableList<StageStream>) {
    // We don't want to take any action for the local participant because we track those streams locally
    if (participantInfo.isLocal) {
        return
    }
    // For remote participants, add these new streams to that participant's streams array.
    participantAdapter.participantUpdated(participantInfo.participantId) {
        it.streams.addAll(streams)
    }
}

override fun onStreamsRemoved(stage: Stage, participantInfo: ParticipantInfo, streams: MutableList<StageStream>) {
    // We don't want to take any action for the local participant because we track those streams locally
    if (participantInfo.isLocal) {
        return
    }
    // For remote participants, remove these streams from that participant's streams array.
    participantAdapter.participantUpdated(participantInfo.participantId) {
        it.streams.removeAll(streams)
    }
}

override fun onStreamsMutedChanged(
    stage: Stage,
    participantInfo: ParticipantInfo,
    streams: MutableList<StageStream>
) {
    // We don't want to take any action for the local participant because we track those streams locally
    if (participantInfo.isLocal) {
        return
    }
    // For remote participants, notify the adapter that the participant has been updated. There is no need to modify
    // the `streams` property on the `StageParticipant` because it is the same `StageStream` instance. Just
    // query the `isMuted` property again.
    participantAdapter.participantUpdated(participantInfo.participantId) {}
}
```

## 사용자 지정 RecyclerView LayoutManager 구현
<a name="getting-started-pub-sub-android-layout"></a>

다른 수의 참가자를 배치하는 것은 복잡할 수 있습니다. 참가자가 전체 상위 보기의 프레임을 차지하도록 하되 각 참가자 구성을 독립적으로 처리하지 않으려고 합니다. 이를 쉽게 수행할 수 있도록 `RecyclerView.LayoutManager`을 구현하는 과정을 살펴보겠습니다.

`GridLayoutManager`를 확장해야 하는 또 다른 새 클래스인 `StageLayoutManager`를 생성합니다. 이 클래스는 흐름 기반 행/열 레이아웃의 참가자 수를 기준으로 각 참가자의 레이아웃을 계산하도록 설계되었습니다. 각 행은 다른 행과 높이가 같지만 열은 행마다 너비가 다를 수 있습니다. 이 동작을 사용자 정의하는 방법에 대한 설명은 `layouts` 변수 위의 코드 주석을 참조하세요.

```
package com.amazonaws.ivs.realtime.basicrealtime

import android.content.Context
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView

class StageLayoutManager(context: Context?) : GridLayoutManager(context, 6) {

    companion object {
        /**
         * This 2D array contains the description of how the grid of participants should be rendered
         * The index of the 1st dimension is the number of participants needed to active that configuration
         * Meaning if there is 1 participant, index 0 will be used. If there are 5 participants, index 4 will be used.
         *
         * The 2nd dimension is a description of the layout. The length of the array is the number of rows that
         * will exist, and then each number within that array is the number of columns in each row.
         *
         * See the code comments next to each index for concrete examples.
         *
         * This can be customized to fit any layout configuration needed.
         */
        val layouts: List<List<Int>> = listOf(
            // 1 participant
            listOf(1), // 1 row, full width
            // 2 participants
            listOf(1, 1), // 2 rows, all columns are full width
            // 3 participants
            listOf(1, 2), // 2 rows, first row's column is full width then 2nd row's columns are 1/2 width
            // 4 participants
            listOf(2, 2), // 2 rows, all columns are 1/2 width
            // 5 participants
            listOf(1, 2, 2), // 3 rows, first row's column is full width, 2nd and 3rd row's columns are 1/2 width
            // 6 participants
            listOf(2, 2, 2), // 3 rows, all column are 1/2 width
            // 7 participants
            listOf(2, 2, 3), // 3 rows, 1st and 2nd row's columns are 1/2 width, 3rd row's columns are 1/3rd width
            // 8 participants
            listOf(2, 3, 3),
            // 9 participants
            listOf(3, 3, 3),
            // 10 participants
            listOf(2, 3, 2, 3),
            // 11 participants
            listOf(2, 3, 3, 3),
            // 12 participants
            listOf(3, 3, 3, 3),
        )
    }

    init {
        spanSizeLookup = object : SpanSizeLookup() {
            override fun getSpanSize(position: Int): Int {
                if (itemCount <= 0) {
                    return 1
                }
                // Calculate the row we're in
                val config = layouts[itemCount - 1]
                var row = 0
                var curPosition = position
                while (curPosition - config[row] >= 0) {
                    curPosition -= config[row]
                    row++
                }
                // spanCount == max spans, config[row] = number of columns we want
                // So spanCount / config[row] would be something like 6 / 3 if we want 3 columns.
                // So this will take up 2 spans, with a max of 6 is 1/3rd of the view.
                return spanCount / config[row]
            }
        }
    }

    override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
        if (itemCount <= 0 || state?.isPreLayout == true) return

        val parentHeight = height
        val itemHeight = parentHeight / layouts[itemCount - 1].size // height divided by number of rows.

        // Set the height of each view based on how many rows exist for the current participant count.
        for (i in 0 until childCount) {
            val child = getChildAt(i) ?: continue
            val layoutParams = child.layoutParams as RecyclerView.LayoutParams
            if (layoutParams.height != itemHeight) {
                layoutParams.height = itemHeight
                child.layoutParams = layoutParams
            }
        }
        // After we set the height for all our views, call super.
        // This works because our RecyclerView can not scroll and all views are always visible with stable IDs.
        super.onLayoutChildren(recycler, state)
    }

    override fun canScrollVertically(): Boolean = false
    override fun canScrollHorizontally(): Boolean = false
}
```

`MainActivity.kt`로 돌아가서 `RecyclerView`에 대한 어댑터 및 레이아웃 관리자를 설정해야 합니다.

```
// In onCreate after setting recyclerView.
recyclerView.layoutManager = StageLayoutManager(this)
recyclerView.adapter = viewModel.participantAdapter
```

## UI 작업 연결
<a name="getting-started-pub-sub-android-actions"></a>

거의 다 되었습니다. 몇 가지 UI 작업만 연결하면 됩니다.

먼저 `MainActivity`가 `MainViewModel`의 `StateFlow` 변경 사항을 관찰하도록 합니다.

```
// At the end of your onCreate method
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.CREATED) {
        viewModel.connectionState.collect { state ->
            buttonJoin.setText(if (state == ConnectionState.DISCONNECTED) R.string.join else R.string.leave)
            textViewState.text = getString(R.string.state, state.name)
        }
    }
}
```

다음으로 Join 버튼과 Publish 확인란에 리스너를 추가합니다.

```
buttonJoin.setOnClickListener {
    viewModel.joinStage(editTextToken.text.toString())
}
checkboxPublish.setOnCheckedChangeListener { _, isChecked ->
    viewModel.setPublishEnabled(isChecked)
}
```

지금 구현하는 `MainViewModel`의 위 직접 호출 기능은 모두 다음과 같습니다.

```
internal fun joinStage(token: String) {
    if (_connectionState.value != Stage.ConnectionState.DISCONNECTED) {
        // If we're already connected to a stage, leave it.
        stage?.leave()
    } else {
        if (token.isEmpty()) {
            Toast.makeText(getApplication(), "Empty Token", Toast.LENGTH_SHORT).show()
            return
        }
        try {
            // Destroy the old stage first before creating a new one.
            stage?.release()
            val stage = Stage(getApplication(), token, this)
            stage.addRenderer(this)
            stage.join()
            this.stage = stage
        } catch (e: BroadcastException) {
            Toast.makeText(getApplication(), "Failed to join stage ${e.localizedMessage}", Toast.LENGTH_LONG).show()
            e.printStackTrace()
        }
    }
}

internal fun setPublishEnabled(enabled: Boolean) {
    publishEnabled = enabled
}
```

## 참가자 렌더링
<a name="getting-started-pub-sub-android-participants"></a>

마지막으로 SDK에서 수신하는 데이터를 이전에 생성한 참가자 항목에 렌더링해야 합니다. `RecyclerView` 로직이 이미 완성되었으므로 `ParticipantItem`에서 `bind` API를 구현하기만 하면 됩니다.

먼저 empty 함수를 추가한 다음 단계별로 살펴보겠습니다.

```
fun bind(participant: StageParticipant) {

}
```

먼저 쉬움 상태, 참가자 ID, 게시 상태 및 구독 상태를 처리하겠습니다. 이를 위해 `TextViews`를 직접 업데이트합니다.

```
val participantId = if (participant.isLocal) {
    "You (${participant.participantId ?: "Disconnected"})"
} else {
    participant.participantId
}
textViewParticipantId.text = participantId
textViewPublish.text = participant.publishState.name
textViewSubscribe.text = participant.subscribeState.name
```

다음으로 오디오 및 비디오 음소거 상태를 업데이트하겠습니다. 음소거 상태를 얻으려면 streams 배열에서 `ImageDevice`와 `AudioDevice`를 찾아야 합니다. 성능을 최적화하기 위해 마지막으로 연결된 디바이스 ID를 기억합니다.

```
// This belongs outside the `bind` API.
private var imageDeviceUrn: String? = null
private var audioDeviceUrn: String? = null

// This belongs inside the `bind` API.
val newImageStream = participant
    .streams
    .firstOrNull { it.device is ImageDevice }
textViewVideoMuted.text = if (newImageStream != null) {
    if (newImageStream.muted) "Video muted" else "Video not muted"
} else {
    "No video stream"
}

val newAudioStream = participant
    .streams
    .firstOrNull { it.device is AudioDevice }
textViewAudioMuted.text = if (newAudioStream != null) {
    if (newAudioStream.muted) "Audio muted" else "Audio not muted"
} else {
    "No audio stream"
}
```

마지막으로 `imageDevice`에 대한 미리 보기를 렌더링하려고 합니다.

```
if (newImageStream?.device?.descriptor?.urn != imageDeviceUrn) {
    // If the device has changed, remove all subviews from the preview container
    previewContainer.removeAllViews()
    (newImageStream?.device as? ImageDevice)?.let {
        val preview = it.getPreviewView(BroadcastConfiguration.AspectMode.FIT)
        previewContainer.addView(preview)
        preview.layoutParams = FrameLayout.LayoutParams(
            FrameLayout.LayoutParams.MATCH_PARENT,
            FrameLayout.LayoutParams.MATCH_PARENT
        )
    }
}
imageDeviceUrn = newImageStream?.device?.descriptor?.urn
```

그리고 `audioDevice`의 오디오 통계를 표시합니다.

```
if (newAudioStream?.device?.descriptor?.urn != audioDeviceUrn) {
    (newAudioStream?.device as? AudioDevice)?.let {
        it.setStatsCallback { _, rms ->
            textViewAudioLevel.text = "Audio Level: ${rms.roundToInt()} dB"
        }
    }
}
audioDeviceUrn = newAudioStream?.device?.descriptor?.urn
```

# IVS iOS Broadcast SDK를 사용하여 게시 및 구독
<a name="getting-started-pub-sub-ios"></a>

이 섹션에서는 iOS 앱을 사용하여 스테이지에 게시하고 구독하는 데 관련된 단계를 안내합니다.

## 보기 생성
<a name="getting-started-pub-sub-ios-views"></a>

먼저 자동 생성된 `ViewController.swift` 파일을 사용하여 `AmazonIVSBroadcast`를 가져온 다음 링크에 `@IBOutlets`를 몇 개 추가합니다.

```
import AmazonIVSBroadcast

class ViewController: UIViewController {

    @IBOutlet private var textFieldToken: UITextField!
    @IBOutlet private var buttonJoin: UIButton!
    @IBOutlet private var labelState: UILabel!
    @IBOutlet private var switchPublish: UISwitch!
    @IBOutlet private var collectionViewParticipants: UICollectionView!
```

이제 이러한 보기를 생성하고 `Main.storyboard`에서 연결합니다. 사용할 보기 구조는 다음과 같습니다.

![\[Main.storyboard를 사용하여 iOS 보기를 생성합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_iOS_1.png)


AutoLayout 구성을 위해 3가지 보기를 사용자 지정해야 합니다. 첫 번째 보기는 **컬렉션 보기 참가자**(`UICollectionView`)입니다. **선행**, **후행** 및 **하단**을 **안전한 영역**에 바인딩합니다. 또한 **상단**을 **컨트롤 컨테이너**에 바인딩합니다.

![\[iOS 컬렉션 보기 참가자 보기를 사용자 지정합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_iOS_2.png)


두 번째 보기는 **컨트롤 컨테이너**입니다. **선행**, **후행** 및 **상단**을 **안전한 영역**에 바인딩합니다.

![\[iOS 컨트롤 컨테이너 보기를 사용자 지정합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_iOS_3.png)


세 번째이자 마지막 보기는 **수직 스택 보기**입니다. **상단**, **선행**, **후행** 및 **하단**을 **슈퍼뷰**에 바인딩합니다. 스타일을 지정하려면 간격을 0 대신 8로 설정합니다.

![\[iOS 수직 스택 보기를 사용자 지정합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_iOS_4.png)


**UIStackViews**는 나머지 보기의 레이아웃을 처리합니다. 3가지 **UIStackViews** 모두에 대해 **채우기**를 **정렬**과 **배포**로 사용합니다.

![\[UIStackView로 나머지 iOS 보기를 사용자 지정합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_iOS_5.png)


마지막으로 이들 보기를 `ViewController`에 연결하겠습니다. 위에서 다음 보기를 매핑합니다.
+ **텍스트 필드 조인**은 `textFieldToken`에 바인딩됩니다.
+ **버튼 조인**은 `buttonJoin`에 바인딩됩니다.
+ **레이블 상태**는 `labelState`에 바인딩됩니다.
+ **스위치 게시**는 `switchPublish`에 바인딩됩니다.
+ **컬렉션 보기 참가자**는 `collectionViewParticipants`에 바인딩됩니다.

또한 이 시간을 사용하여 **컬렉션 보기 참가자** 항목의 `dataSource`를 소유하는 `ViewController`로 설정합니다.

![\[iOS 앱용 컬렉션 보기 참가자의 dataSource를 설정합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_iOS_6.png)


이제 참가자를 렌더링할 `UICollectionViewCell` 하위 클래스를 생성합니다. 먼저 새 **Cocoa Touch Class** 파일을 생성합니다.

![\[UICollectionViewCell을 생성하여 iOS 실시간 참가자를 렌더링합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_iOS_7.png)


이름을 `ParticipantUICollectionViewCell`로 지정하고 Swift에서 `UICollectionViewCell`의 하위 클래스로 만듭니다. Swift 파일에서 다시 시작하여 연결할 `@IBOutlets`를 생성합니다.

```
import AmazonIVSBroadcast

class ParticipantCollectionViewCell: UICollectionViewCell {

    @IBOutlet private var viewPreviewContainer: UIView!
    @IBOutlet private var labelParticipantId: UILabel!
    @IBOutlet private var labelSubscribeState: UILabel!
    @IBOutlet private var labelPublishState: UILabel!
    @IBOutlet private var labelVideoMuted: UILabel!
    @IBOutlet private var labelAudioMuted: UILabel!
    @IBOutlet private var labelAudioVolume: UILabel!
```

연결된 XIB 파일에서 다음과 같은 보기 계층을 생성합니다.

![\[연결된 XIB 파일에서 iOS 보기 계층을 생성합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_iOS_8.png)


AutoLayout에 대해 3가지 보기를 다시 수정합니다. 첫 번째 보기는 **보기 미리 보기 컨테이너**입니다. **후행**, **선행**, **상단** 및 **하단**을 **참가자 컬렉션 보기 셀**로 설정합니다.

![\[iOS View 미리 보기 컨테이너 보기를 사용자 지정합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_iOS_9.png)


두 번째 보기는 **보기**입니다. **선행**과 **상단**을 **참가자 컬렉션 보기 셀**로 설정하고 값을 4로 변경합니다.

![\[iOS View 보기를 사용자 지정합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_iOS_10.png)


세 번째 보기는 **Stack View**입니다. **후행**, **선행**, **상단** 및 **하단**을 **슈퍼뷰**로 설정하고 값을 4로 변경합니다.

![\[iOS Stack View 보기를 사용자 지정합니다.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_iOS_11.png)


## 권한 및 유휴 타이머
<a name="getting-started-pub-sub-ios-perms"></a>

`ViewController`로 돌아가서 애플리케이션이 사용되는 동안 디바이스가 절전 모드로 전환되지 않도록 시스템 유휴 타이머를 비활성화하겠습니다.

```
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    // Prevent the screen from turning off during a call.
    UIApplication.shared.isIdleTimerDisabled = true
}

override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
    UIApplication.shared.isIdleTimerDisabled = false
}
```

다음으로 시스템에서 카메라 및 마이크 권한을 요청합니다.

```
private func checkPermissions() {
    checkOrGetPermission(for: .video) { [weak self] granted in
        guard granted else {
            print("Video permission denied")
            return
        }
        self?.checkOrGetPermission(for: .audio) { [weak self] granted in
            guard granted else {
                print("Audio permission denied")
                return
            }
            self?.setupLocalUser() // we will cover this later
        }
    }
}

private func checkOrGetPermission(for mediaType: AVMediaType, _ result: @escaping (Bool) -> Void) {
    func mainThreadResult(_ success: Bool) {
        DispatchQueue.main.async {
            result(success)
        }
    }
    switch AVCaptureDevice.authorizationStatus(for: mediaType) {
    case .authorized: mainThreadResult(true)
    case .notDetermined:
        AVCaptureDevice.requestAccess(for: mediaType) { granted in
            mainThreadResult(granted)
        }
    case .denied, .restricted: mainThreadResult(false)
    @unknown default: mainThreadResult(false)
    }
}
```

## 앱 상태
<a name="getting-started-pub-sub-ios-app-state"></a>

이전에 생성한 레이아웃 파일을 사용하여 `collectionViewParticipants`를 구성해야 합니다.

```
override func viewDidLoad() {
    super.viewDidLoad()
    // We render everything to exactly the frame, so don't allow scrolling.
    collectionViewParticipants.isScrollEnabled = false
    collectionViewParticipants.register(UINib(nibName: "ParticipantCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: "ParticipantCollectionViewCell")
}
```

각 참가자를 나타내기 위해 `StageParticipant`라는 간단한 구조체를 생성합니다. 이를 `ViewController.swift` 파일에 포함하거나 새 파일을 생성할 수 있습니다.

```
import Foundation
import AmazonIVSBroadcast

struct StageParticipant {
    let isLocal: Bool
    var participantId: String?
    var publishState: IVSParticipantPublishState = .notPublished
    var subscribeState: IVSParticipantSubscribeState = .notSubscribed
    var streams: [IVSStageStream] = []

    init(isLocal: Bool, participantId: String?) {
        self.isLocal = isLocal
        self.participantId = participantId
    }
}
```

이러한 참가자를 추적하기 위해 `ViewController`에 참가자 배열을 프라이빗 속성으로 유지합니다.

```
private var participants = [StageParticipant]()
```

이 속성은 이전에 스토리보드에서 연결된 `UICollectionViewDataSource`를 구동하는 데 사용됩니다.

```
extension ViewController: UICollectionViewDataSource {

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return participants.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ParticipantCollectionViewCell", for: indexPath) as? ParticipantCollectionViewCell {
            cell.set(participant: participants[indexPath.row])
            return cell
        } else {
            fatalError("Couldn't load custom cell type 'ParticipantCollectionViewCell'")
        }
    }

}
```

스테이지에 참가하기 전에 미리 보기를 보려면 로컬 참가자를 즉시 생성합니다.

```
override func viewDidLoad() {
    /* existing UICollectionView code */
    participants.append(StageParticipant(isLocal: true, participantId: nil))
}
```

그 결과, 앱이 실행되는 즉시 참가자 셀이 렌더링되어 로컬 참가자를 나타냅니다.

사용자는 스테이지에 참가하기 전에 자신을 볼 수 있기를 원하므로 이전에 권한 처리 코드에서 직접적으로 호출되는 `setupLocalUser()` 메서드를 구현합니다. 카메라 및 마이크 참조를 `IVSLocalStageStream` 객체로 저장합니다.

```
private var streams = [IVSLocalStageStream]()
private let deviceDiscovery = IVSDeviceDiscovery()

private func setupLocalUser() {
    // Gather our camera and microphone once permissions have been granted
    let devices = deviceDiscovery.listLocalDevices()
    streams.removeAll()
    if let camera = devices.compactMap({ $0 as? IVSCamera }).first {
        streams.append(IVSLocalStageStream(device: camera))
        // Use a front camera if available.
        if let frontSource = camera.listAvailableInputSources().first(where: { $0.position == .front }) {
            camera.setPreferredInputSource(frontSource)
        }
    }
    if let mic = devices.compactMap({ $0 as? IVSMicrophone }).first {
        streams.append(IVSLocalStageStream(device: mic))
    }
    participants[0].streams = streams
    participantsChanged(index: 0, changeType: .updated)
}
```

여기서는 SDK를 통해 디바이스의 카메라와 마이크를 찾아 로컬 `streams` 객체에 저장한 다음, 첫 번째 참가자(이전에 만든 로컬 참가자)의 `streams` 배열을 `streams`에 할당했습니다. 마지막으로 `index`가 0이고 `changeType`이 `updated`인 `participantsChanged`를 직접적으로 호출합니다. 이 함수는 멋진 애니메이션으로 `UICollectionView`를 업데이트하기 위한 도우미 함수입니다. 다음과 같습니다.

```
private func participantsChanged(index: Int, changeType: ChangeType) {
    switch changeType {
    case .joined:
        collectionViewParticipants?.insertItems(at: [IndexPath(item: index, section: 0)])
    case .updated:
        // Instead of doing reloadItems, just grab the cell and update it ourselves. It saves a create/destroy of a cell
        // and more importantly fixes some UI flicker. We disable scrolling so the index path per cell
        // never changes.
        if let cell = collectionViewParticipants?.cellForItem(at: IndexPath(item: index, section: 0)) as? ParticipantCollectionViewCell {
            cell.set(participant: participants[index])
        }
    case .left:
        collectionViewParticipants?.deleteItems(at: [IndexPath(item: index, section: 0)])
    }
}
```

아직 `cell.set`에 대해 걱정하지 마세요. 나중에 다루겠지만 여기서 참가자를 기반으로 셀의 내용을 렌더링할 것입니다.

`ChangeType`은 간단한 열거형입니다.

```
enum ChangeType {
    case joined, updated, left
}
```

마지막으로 스테이지가 연결되어 있는지 여부를 추적하려고 합니다. 간단한 `bool`을 사용하여 자체적으로 업데이트될 때 무엇이 UI를 자동으로 업데이트하는지 추적합니다.

```
private var connectingOrConnected = false {
    didSet {
        buttonJoin.setTitle(connectingOrConnected ? "Leave" : "Join", for: .normal)
        buttonJoin.tintColor = connectingOrConnected ? .systemRed : .systemBlue
    }
}
```

## 스테이지 SDK 구현
<a name="getting-started-pub-sub-ios-stage-sdk"></a>

실시간 기능의 3가지 [핵심 개념](ios-publish-subscribe.md#ios-publish-subscribe-concepts)은 스테이지, 전략 및 렌더러입니다. 설계 목표는 작동하는 제품을 구축하는 데 필요한 클라이언트 측 로직의 수를 최소화하는 것입니다.

### IVSStageStrategy
<a name="getting-started-pub-sub-ios-stage-sdk-strategy"></a>

우리의 `IVSStageStrategy` 구현은 간단합니다.

```
extension ViewController: IVSStageStrategy {
    func stage(_ stage: IVSStage, streamsToPublishForParticipant participant: IVSParticipantInfo) -> [IVSLocalStageStream] {
        // Return the camera and microphone to be published.
        // This is only called if `shouldPublishParticipant` returns true.
        return streams
    }

    func stage(_ stage: IVSStage, shouldPublishParticipant participant: IVSParticipantInfo) -> Bool {
        // Our publish status is based directly on the UISwitch view
        return switchPublish.isOn
    }

    func stage(_ stage: IVSStage, shouldSubscribeToParticipant participant: IVSParticipantInfo) -> IVSStageSubscribeType {
        // Subscribe to both audio and video for all publishing participants.
        return .audioVideo
    }
}
```

요약하자면, 게시 스위치가 '켜기' 위치에 있는 경우에만 게시하고, 게시하는 경우 이전에 수집한 스트림을 게시합니다. 마지막으로 이 샘플에서는 항상 다른 참가자를 구독하여 오디오와 비디오를 모두 수신합니다.

### IVSStageRenderer
<a name="getting-started-pub-sub-ios-stage-sdk-renderer"></a>

`IVSStageRenderer` 구현도 매우 간단하지만 함수 수를 감안할 때 훨씬 더 많은 코드가 포함되어 있습니다. 이 렌더러의 일반적인 접근 방식은 SDK가 참가자에 대한 변경 사항을 알릴 때 참가자 `participants` 배열을 업데이트하는 것입니다. 로컬 참가자가 참가하기 전에 카메라 미리 보기를 볼 수 있도록 직접 관리하기로 결정했기 때문에 로컬 참가자를 다르게 처리하는 특정 시나리오가 있습니다.

```
extension ViewController: IVSStageRenderer {

    func stage(_ stage: IVSStage, didChange connectionState: IVSStageConnectionState, withError error: Error?) {
        labelState.text = connectionState.text
        connectingOrConnected = connectionState != .disconnected
    }

    func stage(_ stage: IVSStage, participantDidJoin participant: IVSParticipantInfo) {
        if participant.isLocal {
            // If this is the local participant joining the Stage, update the first participant in our array because we
            // manually added that participant when setting up our preview
            participants[0].participantId = participant.participantId
            participantsChanged(index: 0, changeType: .updated)
        } else {
            // If they are not local, add them to the array as a newly joined participant.
            participants.append(StageParticipant(isLocal: false, participantId: participant.participantId))
            participantsChanged(index: (participants.count - 1), changeType: .joined)
        }
    }

    func stage(_ stage: IVSStage, participantDidLeave participant: IVSParticipantInfo) {
        if participant.isLocal {
            // If this is the local participant leaving the Stage, update the first participant in our array because
            // we want to keep the camera preview active
            participants[0].participantId = nil
            participantsChanged(index: 0, changeType: .updated)
        } else {
            // If they are not local, find their index and remove them from the array.
            if let index = participants.firstIndex(where: { $0.participantId == participant.participantId }) {
                participants.remove(at: index)
                participantsChanged(index: index, changeType: .left)
            }
        }
    }

    func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didChange publishState: IVSParticipantPublishState) {
        // Update the publishing state of this participant
        mutatingParticipant(participant.participantId) { data in
            data.publishState = publishState
        }
    }

    func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didChange subscribeState: IVSParticipantSubscribeState) {
        // Update the subscribe state of this participant
        mutatingParticipant(participant.participantId) { data in
            data.subscribeState = subscribeState
        }
    }

    func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didChangeMutedStreams streams: [IVSStageStream]) {
        // We don't want to take any action for the local participant because we track those streams locally
        if participant.isLocal { return }
        // For remote participants, notify the UICollectionView that they have updated. There is no need to modify
        // the `streams` property on the `StageParticipant` because it is the same `IVSStageStream` instance. Just
        // query the `isMuted` property again.
        if let index = participants.firstIndex(where: { $0.participantId == participant.participantId }) {
            participantsChanged(index: index, changeType: .updated)
        }
    }

    func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didAdd streams: [IVSStageStream]) {
        // We don't want to take any action for the local participant because we track those streams locally
        if participant.isLocal { return }
        // For remote participants, add these new streams to that participant's streams array.
        mutatingParticipant(participant.participantId) { data in
            data.streams.append(contentsOf: streams)
        }
    }

    func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didRemove streams: [IVSStageStream]) {
        // We don't want to take any action for the local participant because we track those streams locally
        if participant.isLocal { return }
        // For remote participants, remove these streams from that participant's streams array.
        mutatingParticipant(participant.participantId) { data in
            let oldUrns = streams.map { $0.device.descriptor().urn }
            data.streams.removeAll(where: { stream in
                return oldUrns.contains(stream.device.descriptor().urn)
            })
        }
    }

    // A helper function to find a participant by its ID, mutate that participant, and then update the UICollectionView accordingly.
    private func mutatingParticipant(_ participantId: String?, modifier: (inout StageParticipant) -> Void) {
        guard let index = participants.firstIndex(where: { $0.participantId == participantId }) else {
            fatalError("Something is out of sync, investigate if this was a sample app or SDK issue.")
        }

        var participant = participants[index]
        modifier(&participant)
        participants[index] = participant
        participantsChanged(index: index, changeType: .updated)
    }
}
```

이 코드는 확장을 사용하여 연결 상태를 사용자에게 친숙한 텍스트로 변환합니다.

```
extension IVSStageConnectionState {
    var text: String {
        switch self {
        case .disconnected: return "Disconnected"
        case .connecting: return "Connecting"
        case .connected: return "Connected"
        @unknown default: fatalError()
        }
    }
}
```

## 사용자 지정 UICollectionViewLayout 구현
<a name="getting-started-pub-sub-ios-layout"></a>

다른 수의 참가자를 배치하는 것은 복잡할 수 있습니다. 참가자가 전체 상위 보기의 프레임을 차지하도록 하되 각 참가자 구성을 독립적으로 처리하지 않으려고 합니다. 이를 쉽게 수행할 수 있도록 `UICollectionViewLayout`을 구현하는 과정을 살펴보겠습니다.

`UICollectionViewLayout`을 확장해야 하는 또 다른 새 파일인 `ParticipantCollectionViewLayout.swift`를 생성합니다. 이 클래스는 곧 다룰 `StageLayoutCalculator`라는 다른 클래스를 사용합니다. 이 클래스는 각 참여자에 대해 계산된 프레임 값을 받은 다음 필요한 `UICollectionViewLayoutAttributes` 객체를 생성합니다.

```
import Foundation
import UIKit

/**
 Code modified from https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/layouts/customizing_collection_view_layouts?language=objc
 */
class ParticipantCollectionViewLayout: UICollectionViewLayout {

    private let layoutCalculator = StageLayoutCalculator()

    private var contentBounds = CGRect.zero
    private var cachedAttributes = [UICollectionViewLayoutAttributes]()

    override func prepare() {
        super.prepare()

        guard let collectionView = collectionView else { return }

        cachedAttributes.removeAll()
        contentBounds = CGRect(origin: .zero, size: collectionView.bounds.size)

        layoutCalculator.calculateFrames(participantCount: collectionView.numberOfItems(inSection: 0),
                                         width: collectionView.bounds.size.width,
                                         height: collectionView.bounds.size.height,
                                         padding: 4)
        .enumerated()
        .forEach { (index, frame) in
            let attributes = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: index, section: 0))
            attributes.frame = frame
            cachedAttributes.append(attributes)
            contentBounds = contentBounds.union(frame)
        }
    }

    override var collectionViewContentSize: CGSize {
        return contentBounds.size
    }

    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        guard let collectionView = collectionView else { return false }
        return !newBounds.size.equalTo(collectionView.bounds.size)
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return cachedAttributes[indexPath.item]
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var attributesArray = [UICollectionViewLayoutAttributes]()

        // Find any cell that sits within the query rect.
        guard let lastIndex = cachedAttributes.indices.last, let firstMatchIndex = binSearch(rect, start: 0, end: lastIndex) else {
            return attributesArray
        }

        // Starting from the match, loop up and down through the array until all the attributes
        // have been added within the query rect.
        for attributes in cachedAttributes[..<firstMatchIndex].reversed() {
            guard attributes.frame.maxY >= rect.minY else { break }
            attributesArray.append(attributes)
        }

        for attributes in cachedAttributes[firstMatchIndex...] {
            guard attributes.frame.minY <= rect.maxY else { break }
            attributesArray.append(attributes)
        }

        return attributesArray
    }

    // Perform a binary search on the cached attributes array.
    func binSearch(_ rect: CGRect, start: Int, end: Int) -> Int? {
        if end < start { return nil }

        let mid = (start + end) / 2
        let attr = cachedAttributes[mid]

        if attr.frame.intersects(rect) {
            return mid
        } else {
            if attr.frame.maxY < rect.minY {
                return binSearch(rect, start: (mid + 1), end: end)
            } else {
                return binSearch(rect, start: start, end: (mid - 1))
            }
        }
    }
}
```

더 중요한 것은 `StageLayoutCalculator.swift` 클래스입니다. 이 클래스는 흐름 기반 행/열 레이아웃의 참가자 수를 기준으로 각 참가자의 프레임을 계산하도록 설계되었습니다. 각 행은 다른 행과 높이가 같지만 열은 행마다 너비가 다를 수 있습니다. 이 동작을 사용자 정의하는 방법에 대한 설명은 `layouts` 변수 위의 코드 주석을 참조하세요.

```
import Foundation
import UIKit

class StageLayoutCalculator {

    /// This 2D array contains the description of how the grid of participants should be rendered
    /// The index of the 1st dimension is the number of participants needed to active that configuration
    /// Meaning if there is 1 participant, index 0 will be used. If there are 5 participants, index 4 will be used.
    ///
    /// The 2nd dimension is a description of the layout. The length of the array is the number of rows that
    /// will exist, and then each number within that array is the number of columns in each row.
    ///
    /// See the code comments next to each index for concrete examples.
    ///
    /// This can be customized to fit any layout configuration needed.
    private let layouts: [[Int]] = [
        // 1 participant
        [ 1 ], // 1 row, full width
        // 2 participants
        [ 1, 1 ], // 2 rows, all columns are full width
        // 3 participants
        [ 1, 2 ], // 2 rows, first row's column is full width then 2nd row's columns are 1/2 width
        // 4 participants
        [ 2, 2 ], // 2 rows, all columns are 1/2 width
        // 5 participants
        [ 1, 2, 2 ], // 3 rows, first row's column is full width, 2nd and 3rd row's columns are 1/2 width
        // 6 participants
        [ 2, 2, 2 ], // 3 rows, all column are 1/2 width
        // 7 participants
        [ 2, 2, 3 ], // 3 rows, 1st and 2nd row's columns are 1/2 width, 3rd row's columns are 1/3rd width
        // 8 participants
        [ 2, 3, 3 ],
        // 9 participants
        [ 3, 3, 3 ],
        // 10 participants
        [ 2, 3, 2, 3 ],
        // 11 participants
        [ 2, 3, 3, 3 ],
        // 12 participants
        [ 3, 3, 3, 3 ],
    ]

    // Given a frame (this could be for a UICollectionView, or a Broadcast Mixer's canvas), calculate the frames for each
    // participant, with optional padding.
    func calculateFrames(participantCount: Int, width: CGFloat, height: CGFloat, padding: CGFloat) -> [CGRect] {
        if participantCount > layouts.count {
            fatalError("Only \(layouts.count) participants are supported at this time")
        }
        if participantCount == 0 {
            return []
        }
        var currentIndex = 0
        var lastFrame: CGRect = .zero

        // If the height is less than the width, the rows and columns will be flipped.
        // Meaning for 6 participants, there will be 2 rows of 3 columns each.
        let isVertical = height > width

        let halfPadding = padding / 2.0

        let layout = layouts[participantCount - 1] // 1 participant is in index 0, so `-1`.
        let rowHeight = (isVertical ? height : width) / CGFloat(layout.count)

        var frames = [CGRect]()
        for row in 0 ..< layout.count {
            // layout[row] is the number of columns in a layout
            let itemWidth = (isVertical ? width : height) / CGFloat(layout[row])
            let segmentFrame = CGRect(x: (isVertical ? 0 : lastFrame.maxX) + halfPadding,
                                      y: (isVertical ? lastFrame.maxY : 0) + halfPadding,
                                      width: (isVertical ? itemWidth : rowHeight) - padding,
                                      height: (isVertical ? rowHeight : itemWidth) - padding)

            for column in 0 ..< layout[row] {
                var frame = segmentFrame
                if isVertical {
                    frame.origin.x = (itemWidth * CGFloat(column)) + halfPadding
                } else {
                    frame.origin.y = (itemWidth * CGFloat(column)) + halfPadding
                }
                frames.append(frame)
                currentIndex += 1
            }

            lastFrame = segmentFrame
            lastFrame.origin.x += halfPadding
            lastFrame.origin.y += halfPadding
        }
        return frames
    }

}
```

`Main.storyboard`로 돌아가서 `UICollectionView`의 레이아웃 클래스를 방금 생성한 클래스로 설정해야 합니다.

![\[Xcode interface showing storyboard with UICollectionView and its layout settings.\]](http://docs.aws.amazon.com/ko_kr/ivs/latest/RealTimeUserGuide/images/Publish_iOS_12.png)


## UI 작업 연결
<a name="getting-started-pub-sub-ios-actions"></a>

거의 다 되었습니다. 몇 가지 `IBActions`만 생성하면 됩니다.

먼저 Join 버튼을 처리하겠습니다. 이 버튼은 `connectingOrConnected` 값에 따라 다르게 응답합니다. 이미 연결되어 있으면 그냥 스테이지에서 나갑니다. 연결이 끊어지면 토큰 `UITextField`에서 텍스트를 읽고 해당 텍스트로 새 `IVSStage`를 생성합니다. 그런 다음 `ViewController`를 `IVSStage`에 대한 `strategy`, `errorDelegate` 및 renderer로 추가하고 마지막으로 스테이지에 비동기적으로 조인합니다.

```
@IBAction private func joinTapped(_ sender: UIButton) {
    if connectingOrConnected {
        // If we're already connected to a Stage, leave it.
        stage?.leave()
    } else {
        guard let token = textFieldToken.text else {
            print("No token")
            return
        }
        // Hide the keyboard after tapping Join
        textFieldToken.resignFirstResponder()
        do {
            // Destroy the old Stage first before creating a new one.
            self.stage = nil
            let stage = try IVSStage(token: token, strategy: self)
            stage.errorDelegate = self
            stage.addRenderer(self)
            try stage.join()
            self.stage = stage
        } catch {
            print("Failed to join stage - \(error)")
        }
    }
}
```

연결해야 하는 또 다른 UI 작업은 게시 스위치입니다.

```
@IBAction private func publishToggled(_ sender: UISwitch) {
    // Because the strategy returns the value of `switchPublish.isOn`, just call `refreshStrategy`.
    stage?.refreshStrategy()
}
```

## 참가자 렌더링
<a name="getting-started-pub-sub-ios-participants"></a>

마지막으로 SDK에서 수신하는 데이터를 이전에 생성한 참가자 셀에 렌더링해야 합니다. `UICollectionView` 로직이 이미 완성되었으므로 `ParticipantCollectionViewCell.swift`에서 `set` API를 구현하기만 하면 됩니다.

먼저 `empty` 함수를 추가한 다음 단계별로 살펴보겠습니다.

```
func set(participant: StageParticipant) {
   
}
```

먼저 쉬움 상태, 참가자 ID, 게시 상태 및 구독 상태를 처리하겠습니다. 이를 위해 `UILabels`를 직접 업데이트합니다.

```
labelParticipantId.text = participant.isLocal ? "You (\(participant.participantId ?? "Disconnected"))" : participant.participantId
labelPublishState.text = participant.publishState.text
labelSubscribeState.text = participant.subscribeState.text
```

게시 및 구독 열거형의 텍스트 속성은 로컬 확장에서 가져온 것입니다.

```
extension IVSParticipantPublishState {
    var text: String {
        switch self {
        case .notPublished: return "Not Published"
        case .attemptingPublish: return "Attempting to Publish"
        case .published: return "Published"
        @unknown default: fatalError()
        }
    }
}

extension IVSParticipantSubscribeState {
    var text: String {
        switch self {
        case .notSubscribed: return "Not Subscribed"
        case .attemptingSubscribe: return "Attempting to Subscribe"
        case .subscribed: return "Subscribed"
        @unknown default: fatalError()
        }
    }
}
```

다음으로 오디오 및 비디오 음소거 상태를 업데이트하겠습니다. 음소거 상태를 얻으려면 `streams` 배열에서 `IVSImageDevice`와 `IVSAudioDevice`를 찾아야 합니다. 성능을 최적화하기 위해 마지막으로 연결된 디바이스를 기억합니다.

```
// This belongs outside `set(participant:)`
private var registeredStreams: Set<IVSStageStream> = []
private var imageDevice: IVSImageDevice? {
    return registeredStreams.lazy.compactMap { $0.device as? IVSImageDevice }.first
}
private var audioDevice: IVSAudioDevice? {
    return registeredStreams.lazy.compactMap { $0.device as? IVSAudioDevice }.first
}

// This belongs inside `set(participant:)`
let existingAudioStream = registeredStreams.first { $0.device is IVSAudioDevice }
let existingImageStream = registeredStreams.first { $0.device is IVSImageDevice }

registeredStreams = Set(participant.streams)

let newAudioStream = participant.streams.first { $0.device is IVSAudioDevice }
let newImageStream = participant.streams.first { $0.device is IVSImageDevice }

// `isMuted != false` covers the stream not existing, as well as being muted.
labelVideoMuted.text = "Video Muted: \(newImageStream?.isMuted != false)"
labelAudioMuted.text = "Audio Muted: \(newAudioStream?.isMuted != false)"
```

마지막으로 `imageDevice`에 대한 미리 보기를 렌더링하고 `audioDevice`의 오디오 통계를 표시하려고 합니다.

```
if existingImageStream !== newImageStream {
    // The image stream has changed
    updatePreview() // We’ll cover this next
}

if existingAudioStream !== newAudioStream {
    (existingAudioStream?.device as? IVSAudioDevice)?.setStatsCallback(nil)
    audioDevice?.setStatsCallback( { [weak self] stats in
        self?.labelAudioVolume.text = String(format: "Audio Level: %.0f dB", stats.rms)
    })
    // When the audio stream changes, it will take some time to receive new stats. Reset the value temporarily.
    self.labelAudioVolume.text = "Audio Level: -100 dB"
}
```

마지막으로 생성해야 하는 함수는 보기에 참가자의 미리 보기를 추가하는 `updatePreview()`입니다.

```
private func updatePreview() {
    // Remove any old previews from the preview container
    viewPreviewContainer.subviews.forEach { $0.removeFromSuperview() }
    if let imageDevice = self.imageDevice {
        if let preview = try? imageDevice.previewView(with: .fit) {
            viewPreviewContainer.addSubviewMatchFrame(preview)
        }
    }
}
```

위에서는 서브뷰를 더 쉽게 포함할 수 있도록 `UIView`에서 도우미 함수를 사용합니다.

```
extension UIView {
    func addSubviewMatchFrame(_ view: UIView) {
        view.translatesAutoresizingMaskIntoConstraints = false
        self.addSubview(view)
        NSLayoutConstraint.activate([
            view.topAnchor.constraint(equalTo: self.topAnchor, constant: 0),
            view.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: 0),
            view.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 0),
            view.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: 0),
        ])
    }
}
```