# 자산 관리

**URL:** https://heroiclabs.com/docs/kr/nakama/guides/deployment/asset-management/
**Summary:** 이 가이드에서는 Nakama를 자산 관리 시스템으로 사용하여 클라이언트에 임시 액세스를 제공하여 클라우드 호스트(Amazon S3) 자산을 확보하는 방법을 설명하고 Unity Addressable에 대한 완벽한 예시를 제공합니다.

---


# 자산 관리

다음 예시는 자산 관리 시스템으로 Nakama를 사용하는 방법에 대한 설명을 통해 클라이언트가 클라우드 호스트(Amazon S3) 자산에 임시로 액세스할 수 있도록 합니다.
이 방법은 여러 상황에서 매우 유용합니다. 예를 들어, 개인적인 Amazon S3 버킷에서 게임 자산을 호스트하도록 선택하고 클라이언트가 런타임 시 안전하게 자산을 다운로드하도록 할 수 있습니다.

자산 관리 시스템을 사용하는 다양한 이점은 다음과 같습니다:

* 자산은 개인적으로 유지되며 수요 기반의 시간 제한적 방식으로 제공됩니다.
* 클라이언트에 다양한 자산(예: 베타 액세스 자산) 제공
* 자산 목록 보기 등 클라우드 저장소 제공자에서 왕복 작업을 절감함
* 사용자가 컨텐츠를 생성하고 자산에 대해 유효성 검사를 진행할 수 있음

이 시스템에는 크게 3개의 부분이 있습니다:

* Nakama 저장소 엔진을 통해 분류되고 저장되는 자산 매니페스트
* 바이너리 데이터를 얻어서 클라우드에 업로드하고 해당 자산 매니페스트를 저장하는 자산 업로드 RPC
* 요청된 자산에 사전 서명된 임시 다운로드 URL을 생성하는 자산 다운로드 RPC

이 시스템은 클라이언트 중심적으로 설계되었지만, 이 가이드의 마지막 부분에서는 안전한 자산 전달 시스템을 실행하여 개인적인 S3 버킷에서 Unity Addressable 자산으로 직접 런타임 액세스를 제공하는 방법에 대해서 살펴봅니다.

## 자산 매니페스트
Nakama 저장소 엔진을 사용하여 업로드된 자산에 정보를 보관할 수 있는 자산 매니페스트를 저장할 수 있습니다.

* 이름
* 카테고리
* 소유자
* md5 체크섬
* 생성된 타임스탬프

Nakama에서 자산 매니페스트를 저장하면 클라이언트가 클라우드 저장소 제공자에 왕복 작업 없이도 개별 레코드 또는 레코드 목록(예: 카테고리별)을 회수할 수 있습니다.

표준 구조를 사용하거나 아래의 [프로토콜 버퍼](https://developers.google.com/protocol-buffers)와 특정 메시지 형식을 사용하여 자산 매니페스트와 관련 메시지를 정의할 수 있습니다:

```proto
syntax = "proto3";

package api;

option go_package = "heroiclabs.com/nakama-amazon-s3/api";

message RpcAssetUploadRequest {
  string category = 1;
  string name = 2;
  bytes data = 3;
}

message RpcAssetUploadResponse {
  AssetManifest manifest = 1;
}

message RpcAssetDownloadRequest {
  string category = 1;
  string name = 2;
}

message RpcAssetDownloadResponse {
  string name = 1;
  string download_uri = 2;
}

message RpcAllAssetsDownloadResponse {
  repeated RpcAssetDownloadResponse assets = 1;
}

message AssetManifest {
  string category = 1;
  string name = 2;
  string owner = 3;
  string md5 = 4;
  int64 created_at = 5;
}
```

{{< note "important" >}}
프로토콜 버퍼를 사용하고 있는 경우, `protoc` 도구를 사용하여 `.proto` 파일에서 프로그래밍 언어에 맞는 파일을 생성해야 합니다. 자세한 내용은 [프로토콜 버퍼에 대한 기본: Go](https://developers.google.com/protocol-buffers/docs/gotutorial) 문서를 참조하십시오.
{{< / note >}}

## 서버 런타임에 대한 부트스트랩

자산 업로드/다운로드 RPC를 실행하기 전에 프로토콜 버퍼 마샬과 S3 서비스 개체 및 업로더를 초기화해야 합니다.

`runtime.env` 섹션에 있는 `local.yml` 구성 파일을 통해 환경 변수를 Nakama로 전달할 수 있습니다. 자세한 내용은 [구성](../../../getting-started/configuration/#runtime) 문서를 참조하십시오.

```go
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
  initStart := time.Now()
  env := ctx.Value(runtime.RUNTIME_CTX_ENV).(map[string]string)

  // Ensure AWS environment vars have been configured
  awsAccessKeyID, ok := env["AWS_ACCESS_KEY_ID"]
  if !ok {
    return errors.New("missing AWS_ACCESS_KEY_ID in runtime env")
  }
  awsSecretAccessKey, ok := env["AWS_SECRET_ACCESS_KEY"]
  if !ok {
    return errors.New("missing AWS_SECRET_ACCESS_KEY in runtime env")
  }
  awsRegion, ok := env["AWS_REGION"]
  if !ok {
    return errors.New("missing AWS_REGION in runtime env")
  }
  awsBucket, ok := env["AWS_BUCKET"]
  if !ok {
    return errors.New("missing AWS_BUCKET in runtime env")
  }

  // Create protobuf marshalers
  marshaler := &jsonpb.Marshaler{
    EnumsAsInts: true,
  }

  unmarshaler := &jsonpb.Unmarshaler{
    AllowUnknownFields: false,
  }

  // Create an S3 service object and uploader
  sess := session.Must(session.NewSession(&aws.Config{
    Region:      aws.String(awsRegion),
    Credentials: credentials.NewStaticCredentials(awsAccessKeyID, awsSecretAccessKey, ""),
  }))

  svc := s3.New(sess)
  uploader := s3manager.NewUploader(sess)

  // Register RPCs here...

  logger.Info("Plugin loaded in '%d' msec.", time.Since(initStart).Milliseconds())
  return nil
}
```

## 자산 업로드 RPC
자산 매니페스트가 정의된 상태에서 RPC 기능을 실행하여 바이너리 데이터를 base64 문자열로 인코딩하고, 자산 매니페스트를 생성하고 저장하며, 바이너리 파일을 개인 Amazon S3 버킷에 업로드합니다.

```go
func UploadAssetToS3(marshaler *jsonpb.Marshaler, unmarshaler *jsonpb.Unmarshaler, uploader *s3manager.Uploader, bucketName string) func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
  return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
    // Check if s3 storage client is configured or return error
    if uploader == nil {
      return "", errors.New("s3 uploader not configured")
    }

    if len(bucketName) == 0 {
      return "", errors.New("no s3 bucket name provided")
    }

    // Check user that's making the request
    userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
    if !ok {
      return "", errors.New("user ID not found")
    }

    // Unmarshal the upload request input
    request := &api.RpcAssetUploadRequest{}

    logger.Info("got payload: %q", payload)

    if err := unmarshaler.Unmarshal(bytes.NewReader([]byte(payload)), request); err != nil {
      logger.WithField("error", err).Error("error unmarshalling RpcAssetUploadRequest")
      return "", errors.New("error unmarshalling RpcAssetUploadRequest")
    }

    // Check that the input fields are valid
    if request.Category == "" {
      return "", errors.New("asset Category required")
    }

    if request.Name == "" {
      return "", errors.New("asset Name required")
    }

    if len(request.Data) == 0 {
      return "", errors.New("asset Data required")
    }

    // Upload the asset to s3 bucket
    result, err := uploader.Upload(&s3manager.UploadInput{
      Bucket: aws.String(bucketName),
      Key:    aws.String(request.Name),
      Body:   bytes.NewReader(request.Data),
    })
    if err != nil {
      logger.WithField("error", err).Error("failed to upload file")
      return "", errors.New("failed to upload file")
    }

    // Prepare the asset manifest
    md5 := strings.ReplaceAll(*result.ETag, "\"", "")
    manifest := &api.AssetManifest{
      Category:  request.Category,
      Name:      request.Name,
      Owner:     userID,
      Md5:       md5,
      CreatedAt: time.Now().Unix(),
    }

    buf := &bytes.Buffer{}
    if err := marshaler.Marshal(buf, manifest); err != nil {
      logger.WithField("error", err).Error("error encoding asset manifest")
      return "", errors.New("error encoding asset manifest")
    }

    // Write the asset manifest to nakama storage
    if _, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{{
      Collection:      fmt.Sprintf("asset_%s", request.Category),
      Key:             request.Name,
      UserID:          "", // Will be owned by the system user
      Value:           buf.String(),
      PermissionRead:  2, // Clients can read directly
      PermissionWrite: 0, // Only server can write
    }}); err != nil {
      logger.WithField("error", err).Error("error writing asset manifest")
      return "", errors.New("error writing asset manifest")
    }

    // Prepare response to the client
    response := &api.RpcAssetUploadResponse{
      Manifest: manifest,
    }

    buf = &bytes.Buffer{}
    if err := marshaler.Marshal(buf, response); err != nil {
      logger.WithField("error", err).Error("error encoding response")
      return "", errors.New("error encoding response")
    }

    return buf.String(), nil
  }
}
```

그런 다음 `InitModule` 기능에 RPC를 등록해야 합니다.

```go
if err := initializer.RegisterRpc("upload_asset", UploadAssetToS3(marshaler, unmarshaler, uploader, awsBucket)); err != nil {
  return err
}
```

## 자산 다운로드 RPC
자산 다운로드 RPC는 요청된 파일을 기존의 자산 매니페스트에서 확인합니다. 파일을 찾은 후에 Amazon S3에 전달되고 클라이언트로 전달되는 자산에 대해서 사전 서명된 임시 다운로드 URL을 생성합니다.

```go
func GetAssetDownloadUri(marshaler *jsonpb.Marshaler, unmarshaler *jsonpb.Unmarshaler, svc *s3.S3, bucketName string) func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
  return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
    // Check user that's making the request
    _, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
    if !ok {
      // Would happen if calling this RPC server-to-server. See: https://heroiclabs.com/docs/nakama/server-framework/introduction/#server-to-server
      // We're choosing not to allow this here, downloads are expected to be done by a user.
      return "", errors.New("user ID not found")
    }

    // Unmarshal the input
    request := &api.RpcAssetDownloadRequest{}
    if err := unmarshaler.Unmarshal(bytes.NewReader([]byte(payload)), request); err != nil {
      logger.WithField("error", err).Error("error unmarshalling request")
      return "", errors.New("error unmarshalling request")
    }

    // Check input fields are valid
    if request.Category == "" {
      return "", errors.New("asset Category required")
    }

    if request.Name == "" {
      return "", errors.New("asset Name required")
    }

    // Look up the asset in the storage manifest
    objects, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
      Collection: fmt.Sprintf("asset_%s", request.Category),
      Key:        request.Name,
      UserID:     "", // Owned by the system user
    }})

    if err != nil {
      logger.WithField("error", err).Error("error reading asset manifest")
      return "", errors.New("error reading asset manifest")
    }

    // Check if the object exists
    if len(objects) == 0 {
      return "", errors.New("asset not found")
    }

    // Get the asset manifest
    manifest := &api.AssetManifest{}
    if err := unmarshaler.Unmarshal(bytes.NewReader([]byte(objects[0].Value)), manifest); err != nil {
      logger.WithField("error", err).Error("error unmarshaling manifest")
      return "", errors.New("error unmarshaling manifest")
    }

    // Prepare a short-lived asset download link
    assetDownload, err := getAssetDownloadUri(request.Name, svc, bucketName)
    if err != nil {
      logger.WithField("error", err).Error(err.Error())
      return "", err
    }

    // Prepare response to client
    buf := &bytes.Buffer{}
    if err := marshaler.Marshal(buf, assetDownload); err != nil {
      logger.WithField("error", err).Error("error encoding response")
      return "", errors.New("error encoding response")
    }

    logger.Info("Responding with:\n%q", buf.String())

    return buf.String(), nil
  }
}

func getAssetDownloadUri(key string, svc *s3.S3, bucketName string) (*api.RpcAssetDownloadResponse, error) {
  // Prepare a short-lived asset download link
  req, _ := svc.GetObjectRequest(&s3.GetObjectInput{
    Bucket: aws.String(bucketName),
    Key:    aws.String(key),
  })

  uri, err := req.Presign(24 * time.Hour)
  if err != nil {
    return nil, errors.New("error signing request")
  }

  response := &api.RpcAssetDownloadResponse{
    Name:        key,
    DownloadUri: uri,
  }

  return response, nil
}
```

그런 다음 `InitModule` 기능에 RPC를 등록해야 합니다.

```go
if err := initializer.RegisterRpc("get_asset_download_uri", GetAssetDownloadUri(marshaler, unmarshaler, svc, awsBucket)); err != nil {
  return err
}
```

## 카테고리 RPC별 자산 다운로드
다운로드하려고 하는 자산 카테고리를 제공하여 자산 다운로드 URL 목록을 받는 것이 유용할 수 있습니다. 아래에서는 새로운 RPC를 통해 카테고리가 지정된 자산에 대해서 사전 서명된 다운로드 URL에 대한 목록을 생성합니다.

```go
func GetAllAssetDownloadUris(marshaler *jsonpb.Marshaler, unmarshaler *jsonpb.Unmarshaler, svc *s3.S3, bucketName string) func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
  return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
    // Check user that's making the request
    _, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
    if !ok {
      // Would happen if calling this RPC server-to-server. See: https://heroiclabs.com/docs/nakama/server-framework/introduction/#server-to-server
      // We're choosing not to allow this here, downloads are expected to be done by a user.
      return "", errors.New("user ID not found")
    }

    // Unmarshal the input
    request := &api.RpcAssetDownloadRequest{}
    if err := unmarshaler.Unmarshal(bytes.NewReader([]byte(payload)), request); err != nil {
      logger.WithField("error", err).Error("error unmarshalling request")
      return "", errors.New("error unmarshalling request")
    }

    // Check input fields are valid
    if request.Category == "" {
      return "", errors.New("asset Category required")
    }

    // Look up the asset in the storage manifest
    objects, _, err := nk.StorageList(ctx, "", fmt.Sprintf("asset_%s", request.Category), 100, "")
    if err != nil {
      logger.WithField("error", err).Error("error reading asset manifest")
      return "", errors.New("error reading asset manifest")
    }

    // Check if any objects exist
    if len(objects) == 0 {
      return "", errors.New("no assets found")
    }

    // Create a response object
    response := &api.RpcAllAssetsDownloadResponse{}

    // Loop through all assets and get a download uri
    for _, object := range objects {
      assetDownload, err := getAssetDownloadUri(object.Key, svc, bucketName)
      if err != nil {
        logger.WithField("error", err).Error(err.Error())
        return "", err
      }

      response.Assets = append(response.Assets, assetDownload)
    }

    // Marshal the response
    buf := &bytes.Buffer{}
    if err := marshaler.Marshal(buf, response); err != nil {
      logger.WithField("error", err).Error("error encoding response")
      return "", errors.New("error encoding response")
    }

    return buf.String(), nil
  }
}
```

그런 다음 `InitModule` 기능에 RPC를 등록해야 합니다.

```go
if err := initializer.RegisterRpc("get_all_asset_download_uris_by_category", GetAllAssetDownloadUris(marshaler, unmarshaler, svc, awsBucket)); err != nil {
  return err
}
```

## 예: 자산 관리 시스템을 Unity Addressable과 통합하기
위의 자산 관리 시스템은 Unity의 Addressable 기능과 같은 원격 자산 전달 파이프라인과 통합될 수 있습니다.
Unity Addressable를 사용하는 경우, 자산을 패키지로 구성하고, 원격으로 호스트하여 런타임 시 해당 자산을 로드할 수 있습니다.

Unity Addressable은 Amazon S3, Google 클라우드 파일 저장소 또는 디지털 오션 객체 저장소 등 공용 클라우드 저장소를 사용합니다. 아쉽게도, 기본으로 제공되는 개인 클라우드 저장소를 지원하지 않습니다. 하지만, 몇 단계 과정을 통해 Nakama 자산 관리 시스템을 통합할 수 있습니다.

먼저, 아래에 보이는 것처럼 Addressable 자산 구성을 원격 경로에서 Build and Loan으로 설정합니다. **원격 카탈로그 빌드** 옵션을 선택합니다.

![Addressable을 Build and Loan으로 원격 구성하기]({{< fingerprint_image "/images/pages/nakama/guides/deployment/asset-management/configuration.png" >}})

Addressable 프로필 창에서 Addressable 자산의 정확한 원격 로드 경로를 구성할 수 있습니다. 런타임 시 나중에 추가되는 코드로 대체할 것이기 때문에 `http(s)`으로 시작되는 모든 주소를 입력할 수 있습니다.

![Addressables 원격 로드 경로 구성하기]({{< fingerprint_image "/images/pages/nakama/guides/deployment/asset-management/remote-load-path.png" >}})

Addressable 시스템이 구성된 상태에서 Addressable 그룹 창에서 **Build** 버튼을 클릭하여 Addressable 자산 번들을 빌드합니다. 자산을 처음으로 빌드하는 경우, **새 빌드**를 선택합니다. 그렇지 않을 경우, **이전 빌드 업데이트**를 선택합니다. (이렇게 하면 같은 카탈로그 파일을 사용하여 클라이언트를 업데이트하지 않아도 최신 자산을 다운로드할 수 있습니다.) 이 가이드에서 앞서 설명한 `asset_upload` RPC를 사용하여 Nakama 서버에 자산을 업로드할 수 있습니다. 업로드하는 방법은 사용자가 선택할 사항이지만, 자산 업로드를 실행하기 위해서 편집기 스크립트를 작성하는 것이 좋습니다. 예시는 아래에서 설명합니다.

```csharp
var directoryInfo = new DirectoryInfo("<PathToAssetBundle>/StandaloneWindows64");
foreach (var fileInfo in directoryInfo.GetFiles())
{
  var fileBytes = UnityEngine.Windows.File.ReadAllBytes(fileInfo.FullName);
  var request = new RpcAssetUploadRequest
  {
    category = "StandaloneWindows64",
    name = fileInfo.Name,
    data = Convert.ToBase64String(fileBytes)
  };
  await Client.RpcAsync(Session, "upload_asset", JsonUtility.ToJson(request));
}
```

Unity를 사용하여 Nakama 자산 관리 시스템을 실행하려면 프로젝트에 다음의 클래스 정의를 추가합니다.

```csharp
[Serializable]
public class RpcAssetUploadRequest
{
  public string category;
  public string name;
  public string data;
}

[Serializable]
public class RpcAssetDownloadRequest
{
  public string category;
  public string name;
}

[Serializable]
public class RpcAssetDownloadResponse
{
  public string name;
  public string downloadUri;
}

[Serializable]
public class RpcGetAllAssetsDownloadResponse
{
  public RpcAssetDownloadResponse[] assets;
}
```

그런 다음, `AddressablesManager`이라고 하는 스크립트를 생성하고 Nakama에서 자산 번들뿐만 아니라 (현재 플랫폼과) 연관된 Unity 자산 카탈로그(해시/JSON)를 회수합니다.

```csharp
private IDictionary<string, string> assetPresignedUris = new Dictionary<string, string>();

private async void Start()
{
  await FetchPresignedAssets("StandaloneWindows64");
}

private async Task FetchPresignedAssets(string buildTarget)
{
  var payload = new RpcAssetDownloadRequest { category = buildTarget };
  var response = await Client.RpcAsync(Session, "get_all_asset_download_uris_by_category", JsonUtility.ToJson(payload));
  var result = JsonUtility.FromJson<RpcGetAllAssetsDownloadResponse>(response.Payload);

  foreach (var asset in result.assets)
  {
    assetPresignedUris.Add(asset.name, asset.downloadUri);
  }
}
```

다음으로, 내부 자산 ID로 사전 서명된 자산 다운로드 URL을 전환하는 `Start` 메서드에서 Addressable 시스템의 `ResourceManager.InternalIdTransformFunc`에 새로운 변환 함수를 지정합니다.


```csharp
private async void Start()
{
  // ...
  Addressables.ResourceManager.InternalIdTransformFunc += TransformFunc;
}

private string TransformFunc(IResourceLocation location)
{
  // If we're trying to load a remote asset over http(s):
  // - grab the file name from the url path
  // - look up the pre-signed url and return that if available; otherwise just return the same location.InternalId
  var regex = new Regex("https?.+/([^?]+)");
  var match = regex.Match(location.InternalId);
  if (match.Success)
  {
    var key = match.Groups[1].Value;
    if (assetPresignedUris.ContainsKey(key))
    {
      return assetPresignedUris[key];
    }
  }

  return location.InternalId;
}
```

마지막으로, 정상 작업과 마찬가지로 주소 지정 가능한 자산을 인스턴스화합니다.

```csharp
private async void Start()
{
  // ...
  Addressables.InitializeAsync().Completed += OnInitializeAsyncCompleted;
}

private void OnInitializeAsyncCompleted(AsyncOperationHandle<IResourceLocator> obj)
{
  // e.g.
  // addressablePrefabReference.InstantiateAsync().Completed += go =>
  // {
  //  Instantiate(go.Result);
  // };
}
```

이제 애플리케이션을 빌드하고 실행할 수 있으며, 개인적으로 호스트된 주소 지정 가능한 자산이 로드되었습니다.

![런타임 시 개인적으로 호스트된 주소 지정 가능한 자산]({{< fingerprint_image "/images/pages/nakama/guides/deployment/asset-management/hosted.png" >}})
