Nakama 게임 서버로 인게임 친구 시스템 구현하기: 상태 머신부터 실시간 동기화까지

오픈소스 게임 서버 Nakama를 활용해 인게임 친구 요청, 수락, 차단 상태 머신을 구축하고 클라이언트 연동 및 실시간 접속 상태(Presence) 동기화 파이프라인을 최적화하는 실무 가이드입니다.

✓ 복사 완료!
챕터넷코드 & 상태 동기화 (Client Prediction/Lag)
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Nakama 게임 서버로 인게임 친구 시스템 구현하기: 상태 머신부터 실시간 동기화까지

핵심 요약

Nakama(v3.x+)는 PostgreSQL 기반의 관계형 데이터 모델을 통해 친구 요청, 수락, 차단 상태를 4단계 정수형 State(0: Friend, 1: Invite Sent, 2: Invite Received, 3: Blocked)로 관리하는 기본 프렌드 시스템을 제공합니다. 클라이언트는 gRPC/WebSocket API를 호출하여 친구 목록과 페이징 커서를 조회하고 서버 런타임(Go/TypeScript) 후크(Hook)와 Notification/Status 스트림을 결합하여 최대 친구 수 제한 검증과 실시간 온라인 상태 동기화를 안전하게 구현할 수 있습니다.


Nakama 친구 시스템의 데이터 구조와 상태 머신

멀티플레이 게임에서 친구 시스템은 단순한 일대일 매핑이 아닙니다. 요청 대기, 상호 수락, 거절, 차단 등 다양한 관계 상태를 일관성 있게 관리해야 합니다. Heroic Labs의 오픈소스 게임 서버인 Nakama는 이러한 소셜 그래프 처리를 내장 프로토콜 버퍼(Protobuf)와 관계형 테이블(user_edge)을 통해 지원합니다.

Nakama 내부에서 두 유저 (UA,UB)(U_A, U_B) 간의 관계는 다음과 같은 4가지 상태 코드(State)로 표현됩니다.

상태 코드 (State)상태 명칭설명
0Friend (상호 친구)양방향 요청 및 수락이 완료되어 상호 친구 목록에 노출되는 상태
1Invite Sent (보낸 요청)UAU_A가 UBU_B에게 친구 요청을 보낸 상태 (UAU_A 기준)
2Invite Received (받은 요청)UBU_B가 UAU_A로부터 친구 요청을 받은 상태 (UBU_B 기준)
3Blocked (차단됨)특정 유저와의 상호작용 및 친구 요청을 차단한 상태
flowchart TD
    A[None / 비관계] -->|AddFriend 요청| B[Invite Sent / Received]
    B -->|상대방 AddFriend 수락| C[Friend 상호 친구]
    B -->|DeleteFriends 거절/취소| A
    C -->|DeleteFriends 삭제| A
    A -->|BlockFriends 차단| D[Blocked 차단]
    B -->|BlockFriends 차단| D
    C -->|BlockFriends 차단| D
    D -->|DeleteFriends 차단 해제| A

유저 UAU_A가 UBU_B에게 AddFriends API를 호출하면 Nakama 데이터베이스의 user_edge 테이블에 두 레코드가 원자적(Atomic)으로 기록됩니다. UAU_A에게는 position=1(Invite Sent), UBU_B에게는 position=2(Invite Received) 레코드가 생성됩니다. 이후 UBU_B가 동일하게 UAU_A를 대상으로 AddFriends를 호출하면 두 레코드의 상태가 모두 0(Friend)으로 갱신됩니다.

Nakama 인게임 소셜 아키텍처 다이어그램


인게임 친구 시스템을 어떻게 구현할까? 단계별 연동 가이드

Unity C# 클라이언트와 Nakama Go 서버 런타임을 기준으로 친구 시스템을 구축하는 표준 4단계 절차를 살펴봅니다.

1단계: 클라이언트 세션 연결 및 친구 목록 페이징 조회

로그인된 세션을 기반으로 친구 목록을 가져옵니다. 친구 수가 많은 유저를 고려하여 반드시 limit과 cursor를 활용한 페이징 조회를 구현해야 합니다.

using Nakama;
using System.Threading.Tasks;
using UnityEngine;

public class FriendManager : MonoBehaviour
{
    private IClient _client;
    private ISession _session;

    public async Task LoadFriendListAsync(int limit = 50, string cursor = null)
    {
        // limit: 한 페이지에 가져올 친구 수, state: null(전체) 또는 특정 상태 필터
        IApiFriendList result = await _client.ListFriendsAsync(_session, null, limit, cursor);
        
        foreach (var friend in result.Friends)
        {
            string userId = friend.User.Id;
            string username = friend.User.Username;
            
            switch (friend.State)
            {
                case 0: // 상호 친구
                    Debug.Log($"[Friend] {username} ({userId})");
                    break;
                case 1: // 보낸 요청
                    Debug.Log($"[Outgoing Request] {username}");
                    break;
                case 2: // 받은 요청
                    Debug.Log($"[Incoming Request] {username}");
                    break;
                case 3: // 차단
                    Debug.Log($"[Blocked] {username}");
                    break;
            }
        }

        // 다음 페이지 커서 보관
        if (!string.IsNullOrEmpty(result.Cursor))
        {
            string nextCursor = result.Cursor;
        }
    }
}

2단계: 친구 요청 전송 및 수락 처리

유저 ID(UUID) 또는 유저네임(Username)을 기반으로 친구 요청을 보냅니다. 상대방이 수락할 때도 동일한 AddFriendsAsync API를 호출합니다.

public async Task SendOrAcceptFriendRequestAsync(string targetUserId)
{
    var ids = new string[] { targetUserId };
    
    // 대상 유저 ID로 요청/수락 실행
    await _client.AddFriendsAsync(_session, ids);
    Debug.Log($"친구 요청 또는 수락 완료: {targetUserId}");
}

public async Task RemoveOrDeclineFriendAsync(string targetUserId)
{
    var ids = new string[] { targetUserId };
    
    // 친구 삭제, 받은 요청 거절, 보낸 요청 취소에 공통 사용
    await _client.DeleteFriendsAsync(_session, ids);
    Debug.Log($"친구 삭제 또는 요청 거절 완료: {targetUserId}");
}

3단계: 서버 런타임에서 최대 친구 수 검증 (Go Runtime Hook)

Nakama Go 런타임의 BeforeAddFriends 후크로 추가 요청을 검증할 수 있습니다. 아래 예시는 현재 상호 친구 수를 확인하는 사전 검사일 뿐, 한 요청의 여러 대상이나 동시에 도착한 요청까지 포함한 엄격한 최대치 보장은 아닙니다. 하드 리밋이 필요하면 모든 추가·수락 경로를 직렬화하는 서버 권위 처리와 원자적 저장소 검증을 설계해야 합니다.

package main

import (
	"context"
	"database/sql"
	"errors"
	"github.com/heroiclabs/nakama-common/api"
	"github.com/heroiclabs/nakama-common/runtime"
)

const MaxFriendLimit = 100

func BeforeAddFriendsHook(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, in *api.AddFriendsRequest) (*api.AddFriendsRequest, error) {
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		return nil, errors.New("invalid user context")
	}

	// 예시용 사전 검사: 동시 요청이나 이번 요청의 여러 대상을 포함하지 않음
	friends, _, err := nk.FriendsList(ctx, userId, 1000, nil, "")
	if err != nil {
		logger.Error("친구 목록 조회 실패: %v", err)
		return nil, err
	}

	friendCount := 0
	for _, f := range friends {
		if f.GetState().GetValue() == 0 {
			friendCount++
		}
	}

	if friendCount >= MaxFriendLimit {
		return nil, runtime.NewError("친구 최대 보유 한도를 초과했습니다.", 3) // 3: INVALID_ARGUMENT
	}

	return in, nil
}

// InitModule에서 후크 등록
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
	if err := initializer.RegisterBeforeAddFriends(BeforeAddFriendsHook); err != nil {
		return err
	}
	return nil
}

Nakama 서버 런타임 이벤트 파이프라인

4단계: 실시간 접속 상태(Presence) 및 인앱 알림 구독

소켓(Socket) 세션을 활성화하여 친구가 로그인/로그아웃하거나 상태 메시지를 변경할 때 실시간 알림을 수신합니다.

public async Task InitializeStatusTrackingAsync(ISocket socket, string[] friendUserIds)
{
    // 실시간 상태 변경 이벤트 리스너 등록
    socket.ReceivedStatusPresence += presenceEvent =>
    {
        foreach (var join in presenceEvent.Joins)
        {
            Debug.Log($"[Online] {join.Username} ({join.Status})");
        }
        foreach (var leave in presenceEvent.Leaves)
        {
            Debug.Log($"[Offline] {leave.Username}");
        }
    };

    // 친구 목록의 유저 ID 배열을 전달하여 실시간 상태 구독
    var result = await socket.FollowUsersAsync(friendUserIds);
    foreach (var presence in result.Presences)
    {
        Debug.Log($"초기 접속 상태: {presence.Username} -> {presence.Status}");
    }
}

대규모 트래픽 환경에서의 성능 및 데이터베이스 최적화

동시 접속자가 수만 명 이상인 라이브 서비스에서는 친구 시스템이 데이터베이스와 네트워크 부하의 주요 원인이 될 수 있습니다.

  1. PostgreSQL 복합 인덱스 활용: Nakama의 user_edge 테이블은 (source_id, state, position) 복합 인덱스를 사용하여 친구 조회를 고속 처리합니다. 커스텀 쿼리나 통계 조회가 필요할 경우 테이블 풀 스캔(Full Scan)이 발생하지 않도록 실행 계획(EXPLAIN ANALYZE)을 점검해야 합니다.
  2. 상태 구독(FollowUsers) 개수 최적화: 클라이언트가 수백 명의 친구 상태를 동시에 구독하면 소켓 브로드캐스트 패킷 양이 급증합니다. UI상 현재 화면에 표시되는 상위 20~30명 단위로만 FollowUsersAsync를 부분 갱신하고 미표시 유저는 구독을 해제(UnfollowUsersAsync)하는 가상화(Virtualization) 기법이 필수적입니다.
  3. 알림(Notification) 푸시 지연 완화: 친구 요청 전송 시 즉각적인 푸시 알림은 Nakama 내장 Notification 시스템(nk.NotificationSend)을 사용하여 비동기 큐 방식으로 전송함으로써 API 응답 지연을 방지합니다.

자주 묻는 질문 (FAQ)

Q1. 차단된 유저(State = 3)가 나에게 다시 친구 요청을 보낼 수 있나요?

Nakama 내부 로직상 한쪽이 상대를 차단하면 user_edge에 차단 상태가 기록됩니다. 차단당한 유저가 AddFriends를 호출하더라도 Nakama 서버는 에러를 반환하지 않고 요청을 조용히 무시(Silent Drop)하거나 거부 처리하여 차단자의 프라이버시를 보호합니다.

Q2. 스팀(Steam)이나 페이스북(Facebook) 친구 목록을 Nakama로 가져올 수 있나요?

네, 가능합니다. Nakama는 ImportSteamFriendsAsync 및 ImportFacebookFriendsAsync 클라이언트 API를 기본 지원합니다. 유저가 스팀 토큰이나 페이스북 토큰으로 소셜 로그인을 완료한 후 해당 API를 호출하면 동일 게임을 플레이하는 외부 소셜 플랫폼 친구를 Nakama 친구 관계로 자동 동기화합니다.

Q3. 친구 삭제 시 양방향 데이터가 모두 삭제되나요?

DeleteFriends를 실행하면 요청을 보낸 유저와 대상 유저 사이의 양방향 user_edge 레코드가 단일 트랜잭션 내에서 모두 삭제되어 비관계 상태로 복구됩니다.