> For the complete documentation index, see [llms.txt](/llms.txt)

# GS2-Inbox

프레젠트 박스 기능




시스템이나 운영진이 플레이어에게 메시지나 선물을 전달하는 구조를 구현합니다.

게임에서는 "보상 배포(사과)", "로그인 보너스", "과금 특전", "이벤트 보상" 등 플레이어에게 비동기적으로 보상을 전달해야 하는 상황이 자주 발생합니다.
GS2-Inbox는 메시지의 큐잉, 읽음/안읽음 관리, 보상 첨부, 유효기간에 따른 자동 삭제 기능을 제공하며, 게임의 라이프사이클 전반에 걸쳐 프레젠트 박스 기능을 담당합니다.

```mermaid
sequenceDiagram
  participant Ops as 운영 / 서버
  participant Inbox as GS2-Inbox
  participant Player as 플레이어
  Ops->>Inbox: 메시지 송신 (보상 첨부)
  Inbox-->>Player: receiveNotification 알림
  Player->>Inbox: 메시지 목록 조회
  Player->>Inbox: 메시지 개봉 (Read)
  Inbox->>Player: 첨부 보상 지급 (트랜잭션)
  Player->>Inbox: 읽은 메시지 삭제
```

## 메시지

### 읽음 관리

메시지는 읽음 상태를 가집니다.
읽음 상태의 메시지를 목록에 남길지, 목록에서 삭제할지 설정할 수 있습니다.

네임스페이스 설정의 `isAutomaticDeletingEnabled`를 활성화하면, 읽음 처리되는 시점에 메시지가 자동으로 삭제됩니다.
프레젠트 박스 목록에 아직 수령하지 않은 메시지만 남기는 사양을, 서버 측의 추가 구현 없이 구현할 수 있습니다.

### 보상 첨부

메시지에는 보상을 첨부할 수 있습니다.
읽음 플래그를 세우는 대신, 메시지에 첨부된 보상을 수령할 수 있습니다.

첨부 가능한 보상은 GS2의 트랜잭션 메커니즘을 통한 획득 액션(`readAcquireActions`)으로 표현되므로, `GS2-Inventory`, `GS2-Money`, `GS2-Experience` 등 임의의 마이크로서비스 리소스를 보상으로 배포할 수 있습니다.

### 유효기간

메시지에는 유효기간(`expiresAt`)을 설정할 수 있습니다.
유효기간이 지난 메시지는 읽지 않은 상태, 개봉 후 읽음 상태와 관계없이 자동으로 삭제됩니다.

첨부된 보상을 수령하지 않았더라도 삭제됩니다.

### 메시지의 라이프사이클

```mermaid
stateDiagram-v2
  [*] --> Unread: 메시지 수신
  Unread --> Read: 개봉 (Read)
  Unread --> Expired: 유효기간 만료
  Read --> Deleted: 삭제 (Delete)
  Read --> AutoDeleted: isAutomaticDeletingEnabled
  Read --> Expired: 유효기간 만료
  Deleted --> [*]
  AutoDeleted --> [*]
  Expired --> [*]
```

## 글로벌 메시지

특정 플레이어가 아니라 모든 플레이어에게 동일한 메시지를 배포하고 싶을 때는 "글로벌 메시지"를 사용합니다.
글로벌 메시지는 마스터 데이터로 정의하며, 각 플레이어가 `ReceiveGlobalMessageAsync`를 호출했을 때 아직 수신하지 않은 메시지를 해당 플레이어의 수신함에 복사합니다.

| 기능 | 설명 |
| --- | --- |
| `expiresAt` | 절대 시각으로 메시지 유효기간을 지정 |
| `expiresTimeSpan` | 수신 시점부터의 상대 기간으로 유효기간을 결정 (예: 수신 후 3일간) |
| `messageReceptionPeriodEventId` | GS2-Schedule의 이벤트ID를 지정하여, 특정 기간 동안에만 글로벌 메시지를 수신할 수 있도록 함 |

```json
{
  "version": "2018-04-20",
  "globalMessages": [
    {
      "name": "welcome",
      "metadata": "신규 사용자용 선물",
      "readAcquireActions": [
        {
          "action": "Gs2Money:DepositByUserId",
          "request": "{\"namespaceName\":\"money-0001\",\"slot\":1,\"userId\":\"#{userId}\",\"price\":0,\"count\":100}"
        }
      ],
      "expiresTimeSpan": { "days": 7 }
    }
  ]
}
```

마스터 데이터의 종류에는 다음이 있습니다.

- `GlobalMessage`: 모든 플레이어에게 배포하는 메시지 정의

마스터 데이터의 등록은 관리 콘솔에서 등록하는 것 외에, GitHub에서 데이터를 반영하거나, GS2-Deploy를 사용해 CI에서 등록하는 워크플로우를 구성할 수 있습니다.

## 스크립트 트리거

네임스페이스에 `receiveMessageScript`·`readMessageScript`·`deleteMessageScript`를 설정하면, 메시지 수신, 개봉, 삭제 시 전후로 커스텀 스크립트를 실행할 수 있습니다. 스크립트는 동기·비동기 실행 방식을 선택할 수 있으며, 비동기 방식에서는 GS2-Script나 Amazon EventBridge를 이용한 외부 연동도 가능합니다.

설정 가능한 주요 이벤트 트리거와 스크립트 설정명은 다음과 같습니다.

- `receiveMessageScript`(완료 알림: `receiveMessageDone`): 메시지 수신 전후
- `readMessageScript`(완료 알림: `readMessageDone`): 메시지 개봉 전후
- `deleteMessageScript`(완료 알림: `deleteMessageDone`): 메시지 삭제 전후

## 푸시 알림
설정 가능한 주요 푸시 알림과 설정명은 다음과 같습니다.

- `receiveNotification`: 메시지 수신 시 알림

알림 대상 단말이 오프라인인 경우 모바일 푸시 알림으로 전달하는 설정도 지정할 수 있어, 확실한 전송을 실현할 수 있습니다.

GS2-Gateway의 WebSocket 연결을 가진 플레이어에게는 즉시 알림을, 연결되어 있지 않은 플레이어에게는 iOS / Android의 모바일 푸시 알림을 자동으로 전송하는 배포 전략을, 추가 구현 없이 실현할 수 있습니다.

## 트랜잭션 액션

GS2-Inbox에서는 다음과 같은 트랜잭션 액션을 제공합니다.

- 소비 액션: 메시지 개봉, 메시지 삭제
- 획득 액션: 메시지 송신

"메시지 송신"을 획득 액션으로 사용함으로써, 상점에서 상품 구매 시나 퀘스트 클리어 시의 보상으로 플레이어의 프레젠트 박스에 직접 메시지(아이템 포함)를 전달하는 처리를 트랜잭션 내에서 완결시킬 수 있습니다. 이를 통해 보상 지급 타이밍을 유연하게 제어할 수 있으며, 플레이어의 인벤토리가 가득 찬 경우에도 일단 프레젠트 박스에 보상을 대피시키는 운영이 용이해집니다.

## 구현 예제

### 메시지 송신

메시지 송신은 게임 엔진용 SDK에서는 직접 호출할 수 없습니다. 일반적으로 서버 사이드 스크립트나, 위의 "트랜잭션 액션"을 통해 실행됩니다.

운영진의 일괄 배포인 경우 관리 콘솔이나 GS2-Deploy를 통해 송신할 수 있습니다.
게임 내에서 "메시지 송신"을 보상이나 연출의 트리거로 사용하고 싶은 경우, GS2-Exchange나 GS2-Quest의 `acquireActions`에 "`Gs2Inbox:SendMessageByUserId`"를 포함시킵니다.

### 수신한 메시지 목록 가져오기



**Unity**
```csharp

    var items = await gs2.Inbox.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).MessagesAsync(
    ).ToListAsync();
```
**Unreal Engine**
```cpp

    const auto It = Gs2->Inbox->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Messages(
    );
    TArray<Gs2::UE5::Inbox::Model::FEzMessagePtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }
```
**Godot**
```gdscript

var iterator = ez.inbox.namespace_(
        "namespace-0001"
    ).me(
        game_session
    ).messages(
    )

var async_result = await iterator.load()
if async_result.error != null:
    # 오류를 처리
    push_error(str(async_result.error))
    return

var items = async_result.result

```


#### 읽지 않은 메시지만 목록 가져오기



**Unity**
```csharp

    var items = await gs2.Inbox.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).MessagesAsync(
        isRead: false
    ).ToListAsync();
```
**Unreal Engine**
```cpp

    const auto It = Gs2->Inbox->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Messages(
        false // isRead
    );
    TArray<Gs2::UE5::Inbox::Model::FEzMessagePtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }
```
**Godot**
```gdscript

var iterator = ez.inbox.namespace_(
        "namespace-0001"
    ).me(
        game_session
    ).messages(
        false
    )

var async_result = await iterator.load()
if async_result.error != null:
    # 오류를 처리
    push_error(str(async_result.error))
    return

var items = async_result.result

```


#### 읽은 메시지만 목록 가져오기



**Unity**
```csharp

    var items = await gs2.Inbox.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).MessagesAsync(
        isRead: true
    ).ToListAsync();
```
**Unreal Engine**
```cpp

    const auto It = Gs2->Inbox->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Messages(
        true // isRead
    );
    TArray<Gs2::UE5::Inbox::Model::FEzMessagePtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }
```
**Godot**
```gdscript

var iterator = ez.inbox.namespace_(
        "namespace-0001"
    ).me(
        game_session
    ).messages(
        true
    )

var async_result = await iterator.load()
if async_result.error != null:
    # 오류를 처리
    push_error(str(async_result.error))
    return

var items = async_result.result

```


### 메시지 개봉

개봉하면 메시지가 `Read` 상태로 전환됨과 동시에 `readAcquireActions`에 정의된 보상 지급 처리가 트랜잭션으로 실행됩니다.
네임스페이스 설정에서 `isAutomaticDeletingEnabled`를 활성화한 경우, 개봉과 동시에 메시지가 삭제됩니다.



**Unity**
```csharp

    var result = await gs2.Inbox.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Message(
        messageName: "message-0001"
    ).ReadAsync(
    );
```
**Unreal Engine**
```cpp

    const auto Future = Gs2->Inbox->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Message(
        "message-0001" // messageName
    )->Read(
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
```
**Godot**
```gdscript

var domain = ez.inbox.namespace_(
        "namespace-0001"
    ).me(game_session).message(
        "message-0001"
    )

var async_result = await domain.read(
)
if async_result.error != null:
    if async_result.error.type == "MessageExpiredException":
        # 메시지의 유효기간이 만료되었습니다
        pass
    push_error(str(async_result.error))
    return

var result = async_result.result

```


### 메시지 일괄 개봉

여러 메시지를 한꺼번에 개봉하여, 각각의 첨부 보상을 하나의 트랜잭션으로 수령합니다.
보상 지급 처리는 내부적으로 순차 실행되므로, 동일한 아이템이 대량으로 첨부된 메시지를 병렬로 개봉했을 때 발생할 수 있는 갱신 레이트 제한 문제를 회피할 수 있습니다.



**Unity**
```csharp

    var result = await gs2.Inbox.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).BatchReadAsync(
        messageNames: new [] {
            "message-0001",
            "message-0002",
            "message-0003",
        }
    );
```
**Unreal Engine**
```cpp

    const auto Future = Gs2->Inbox->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->BatchRead(
        []
        {
            const auto v = MakeShared<TArray<FString>>();
            v->Add("message-0001");
            v->Add("message-0002");
            v->Add("message-0003");
            return v;
        }()
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
```
**Godot**
```gdscript

var domain = ez.inbox.namespace_(
        "namespace-0001"
    ).me(game_session)

var async_result = await domain.batch_read(
    [
        "message-0001",
        "message-0002",
    ] # message_names
)
if async_result.error != null:
    if async_result.error.type == "MessageExpiredException":
        # 메시지의 유효기간이 만료되었습니다
        pass
    push_error(str(async_result.error))
    return

var result = async_result.result

```


### 메시지 삭제



**Unity**
```csharp

    var result = await gs2.Inbox.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Message(
        messageName: "message-0001"
    ).DeleteAsync(
    );
```
**Unreal Engine**
```cpp

    const auto Future = Gs2->Inbox->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Message(
        "message-0001" // messageName
    )->Delete(
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
```
**Godot**
```gdscript

var domain = ez.inbox.namespace_(
        "namespace-0001"
    ).me(game_session).message(
        "message-0001"
    )

var async_result = await domain.delete(
)
if async_result.error != null:
    push_error(str(async_result.error))
    return

var result = async_result.result

```


### 글로벌 메시지 수신

글로벌 메시지 중 아직 수신하지 않은 메시지가 있는 경우,
자신의 수신함에 메시지를 복사합니다.
로그인 시점이나 타이틀 화면 로드 완료 시점 등, 타이밍을 게임 측에서 제어할 수 있습니다.



**Unity**
```csharp

    var result = await gs2.Inbox.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).ReceiveGlobalMessageAsync(
    );
```
**Unreal Engine**
```cpp

    const auto Future = Gs2->Inbox->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->ReceiveGlobalMessage(
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
```
**Godot**
```gdscript

var domain = ez.inbox.namespace_(
        "namespace-0001"
    ).me(game_session)

var async_result = await domain.receive_global_message(
)
if async_result.error != null:
    push_error(str(async_result.error))
    return

var result = async_result.result

```


## 자주 묻는 질문

### 메시지 일괄 개봉이 가능한가요?

네, `BatchReadAsync`를 사용하면 여러 메시지를 하나의 트랜잭션으로 한꺼번에 개봉할 수 있습니다. 자세한 내용은 [메시지 일괄 개봉](#메시지-일괄-개봉)을 참조하십시오.

또한 개별 `ReadAsync`를 병렬로 호출하는 것도 가능하지만, 보상 지급 처리가 각 마이크로서비스에서 정의한 제한에 도달하지 않는지 충분히 고려해야 합니다.

예를 들어, GS2-Inventory에서는 동일한 아이템의 소지 수량 갱신이 1초에 3회까지로 정의되어 있습니다.
동일한 아이템이 첨부된 10개의 메시지를 병렬로 개봉하면 이 제한을 초과할 수 있습니다.

이 제한을 초과하더라도, 원칙적으로 결과가 유실되는 일은 없습니다.
다만, 개봉 API를 호출한 후 비동기로 동작하는 보상 지급 처리가 완료되기까지 시간이 걸릴 수 있습니다.
이런 경우에도 `BatchReadAsync`라면 내부에서 순차적으로 실행되므로, 레이트 제한에 걸리지 않고 확실하게 보상을 받을 수 있습니다.

### 메시지의 읽지 않은 개수 배지를 표시하고 싶습니다

`MessagesAsync(isRead: false)`로 가져온 목록의 건수를 읽지 않은 개수로 표시할 수 있습니다. 수신 알림(`receiveNotification`)을 구독해 두면, 새 메시지가 도착한 시점에 클라이언트 측에서 다시 가져올 수 있습니다.

## 상세 레퍼런스

[GS2-Inbox API 레퍼런스](../../api_reference/inbox)



