국내 유일(???) Sendbird API 적용기

봄도둑·2024년 2월 14일
0

Spring 개인 노트

목록 보기
17/17

데이팅앱의 백엔드 서비스를 구현하면서 채팅 기능을 위해 도입한 sendbird API 사용하는 방향을 정리해보았습니다.

1. Sendbird 도입 검토

데이팅앱 개발을 진행하면서 큰 난관에 부딪혔던 부분은 바로 채팅 기능이었습니다.

채팅 기능을 직접 구현한다면 DB의 사용 구조를 바꾸고 Websocket을 이용한 별도의 채팅 서비스를 구현해야 합니다. 개발까지 시간이 충분하다면 도전해보고 싶었지만 아쉽게도 저에게 주어진 시간은 많지 않았습니다.

그에 따라 채팅 기능을 구현하기 위해 여러 솔루션을 후보로 찾았습니다.

  • Sendbird
  • TalkPlus
  • vChatCloud

그 중에서 Sendbird를 선택하게 되었습니다.

그 이유는 다음과 같습니다.

  • 고객사가 미국 회사로 개발 완료 후 고객사가 앱을 관리하게 되었을 때 채팅 솔루션 회사와 커뮤니케이션이 원활해야 한다.
  • 그에 따라 최대한 채팅 솔루션 자체가 한국의 서비스들에 종속적이어선 안된다.
  • 채팅 화면은 커스텀으로 구현해야 하기 때문에 message 목록 response 조회 시 가공이 편리해야 한다.
  • cross platform에 대한 푸시 지원을 필요로 한다.

TalkPlus는 한국 서비스여서 개발하기에는 편하지만 개발 문서와 그에 따른 레퍼런스가 빈약했습니다. 또한 메세지 발송을 감지해서 푸시를 보내는 것이 아니라 메세지를 보내는 API를 호출한 후, FCM 토큰을 가지고 다시 한 번 push notification을 호출하는 API를 한 번 더 호출해야 했습니다.

vChatCloud는 자바에 대한 지원을 하지 않기 때문에 선택지에서 일찍 제외되었습니다.

이제 남은 건 Sendbird 입니다.

미국쪽에서 기술 지원 및 영업, 후속 처리 등 지원이 가능하고, 솔루션 자체가 한국의 카카오톡, 네이버 등 여타 서비스와의 연동을 필요로 하지 않았습니다.

더불어 레퍼런스들이 부족했지만 TalkPlus보다 풍부하고 공식 문서의 작성이 잘 되어 있었습니다. 개발 시 막히는 부분에 대해서 대처가 어느 정도 가능할 것이라고 보았습니다.

또한, 푸시 기능을 별도의 API가 아닌 메세지 전송을 트리거로 삼아 알림이 전송되기 때문에 개발하는 입장에서 push notification을 신경쓰지 않아도 된다는 점이었습니다.


2. Sendbird API 시나리오 확인

이제 Sendbird를 도입하기로 했으니 Sendbird를 통해 구현할 기능들을 정의해야 합니다. 기본적으로 백엔드에서 채팅 관련된 시나리오는 다음과 같습니다.

  1. 사용자 A가 사용자 B에게 대화 요청을 하기 위한 메세지를 보낸다.
  2. 사용자 B는 받은 대화 요청에 대해 답변 메세지를 보낸다.
  3. 이후 채팅방에서 이뤄지는 대화는 프론트엔드가 Sendbird SDK를 통해 직접 주고 받는다.

백엔드는 1, 2번 시나리오인 최초 메세지 전송과 그에 따른 답신 메세지 전송 2가지를 다룹니다. 언뜻 보면 굉장히 단순해 보이는 2단계이지만 그 안에는 보이지 않는 단계가 들어가 있습니다.

  1. 사용자 A, B는 Sendbird에 등록된 사용자이다. → Sendbird의 채팅 기능을 적용하기 위해선 사용자를 등록해야 한다.
  2. 사용자 A와 B가 같이 들어가 있는 channel(카카오톡 개념으로는 채팅방)을 생성한다.
  3. 사용자 A가 사용자 B에게 2번 단계에서 생성한 channel에 메세지를 전송한다. → 사용자 A가 사용자 B에게 대화 요청을 하기 위한 메세지를 보낸다.
  4. 사용자 B가 사용자 A에게 2번 단계에서 생성한 channel에 메세지를 전송한다. → 사용자 B는 받은 대화 요청에 대해 답변 메세지를 보낸다.
  5. 백엔드는 프론트엔드에 사용자 A, B가 들어 있는 channel url을 전달한다.

즉, sendbird를 사용하기 위해 백엔드가 sendbird API와 통신해야 하는 기능은 크게 3가지입니다. 사용자를 생성하고, 사용자들이 포함된 channel을 생성하고, 사용자가 메세지를 보내도록 처리하는 API를 호출하는 시나리오를 가집니다.

그럼 이제 코드 레벨에서 적용해봅시다.


3. sendbird 환경 구축

1. sendbird 의존성 추가

sendbird API를 작업하면서 가장 힘들었던 부분입니다. 먼저 sendbird API는 SDK 대신 feign client를 이용해 sendbird API를 호출할 예정입니다. sendbird 패키지와 feign client를 추가해봅시다.

<dependencies>
	<!--  sendbird   -->
  <dependency>
    <groupId>org.sendbird</groupId>
    <artifactId>sendbird-platform-sdk</artifactId>
    <version>0.0.16</version>
  </dependency>
	
	<!--  feign client  -->
  <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
    <version>4.0.3</version>
  </dependency>
</dependencies>

그런데, 문제가 하나 있었습니다. 우리는 sendbird의 Platform API를 사용하기 위해 platform-sdk라는 의존성을 추가했는데 maven 레포지토리에서는 이를 인식할 수 없는 문제가 생겼습니다.


(maven repository에서 sendbird를 검색한 결과)

maven 레포지토리에는 sendbird의 안드로이드, WebRTC에 관한 SDK만 있었기 때문에 우리가 추가한 org.sendbird , sendbird-platform-sdk 를 찾을 수 없는 문제가 있었습니다. 이 문제는 정말 해결하기 힘들었는데 sendbird API를 사용하기 위한 레퍼런스가 많이 없었을 뿐만 아니라 저 문제를 겪은 사람이 없었습니다.

한참을 찾은 후에 sendbird-platform-sdk 는 maven repository에서 의존성을 추가하는 것이 아니라 sendbird의 repository를 통해 추가해야 했습니다.

<repositories>
    <repository>
        <id>sendbird</id>
        <url>https://repo.sendbird.com/public/maven</url>
    </repository>
</repositories>

<dependencies>
	<!--  sendbird   -->
  <dependency>
    <groupId>org.sendbird</groupId>
    <artifactId>sendbird-platform-sdk</artifactId>
    <version>0.0.16</version>
  </dependency>
</dependencies>

이렇게 sendbird의 repository를 추가해야 해당 레포지토리에서 sendbird-platform-sdk 를 찾을 수 있게 되었습니다.

2. sendbird config 설정

일반적인 sendbird 예제에서는 API token을 매 호출마다 직접 설정해주는 부분이 있습니다.

public class Example {
    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();
        defaultClient.setBasePath("https://api-APP_ID.sendbird.com");

        UserApi apiInstance = new UserApi(defaultClient);
        String userId = "userId_example"; // String | 
        String apiToken = "{{API_TOKEN}}"; // String | 
        CreateUserTokenData createUserTokenData = new CreateUserTokenData(); // CreateUserTokenData | 
        //...
    }
}

저는 이러한 반복적인 작업을 config로 빼내 rest API 호출 시 interceptor로 빼내서 헤더에 API Token을 넣어주기로 했습니다. API token은 각 메소드별 다른 것이 아니라 sendbird라는 서버를 호출하기 위해 사용하는 하나의 키값이기 때문에 공통 config로 빼냈습니다.

여기서 한 가지 주의할 사항이 있습니다. sendbird는 API Token을 Bearer 방식도, API Key 방식으로도 검증하지 않습니다. 헤더에 Api-Token 이라는 키가 가리키는 value만을 체크합니다.

public class SendbirdConfig {

    @Value("${sendbird.token}")
    private String sendbirdToken;

    @Bean
    public RequestInterceptor requestInterceptor() {
        return requestTemplate -> {
            requestTemplate.header("Api-Token", sendbirdToken);
        };
    }
}

※참고 - feign client 설정

@Configuration
@EnableFeignClients(basePackageClasses = {Main.class})
public class FeignClientConfig {

    @Bean
    public Logger.Level feignLoggerLevel() {
        return Logger.Level.FULL;
    }

    @Bean
    public Logger feignLogger() {
        return new Log4j2Logger();
    }

    @Bean
    public ErrorDecoder feignErrorDecoder() { return new CustomErrorDecoder(); }
}

위의 코드는 feignClientConfig로 CustomErrorDecoder 는 sendbird가 응답하는 에러를 개발하고자 하는 서비스에 맞춰 사용하기 위해 만든 사용자 정의 error decoder입니다. 각자 프로젝트의 상황에 맞춰 custom error decoder를 사용하면 각 서비스의 호출에 대해 명확하게 처리할 수 있습니다.

3. feign client

앞서 우리는 feign client를 통해 sendbird API를 호출할 예정이기 때문에 feign client의 의존성을 추가했습니다. 이제 API를 호출할 feign client를 정의만 하면 됩니다.

@FeignClient(
    name = "sendbird-service-client",
    url = "${sendbird.app}",
    configuration = {SendbirdConfig.class}
)
public interface SendbirdServiceClient {
    
}

url은 우리가 호출할 sendbird의 API 주소를, configuration에는 위에서 만들었던 sendbirdConfig를 넣어 매 호출마다 헤더에 Api-Token 값을 설정하도록 처리했습니다.

이제, sendbird API를 시나리오에 맞춰 호출해봅시다.


3. Sendbird API 호출

1. 사용자 추가하기

우리는 sendbird의 channel을 생성하기 위해 두 사용자가 sendbird의 user로 등록되어 있어야 합니다.

호출해야할 API spec은 다음과 같습니다.

  • Method : POST
  • url : https://api-{application_id}.sendbird.com/v3/users
  • request :

(이미지 출처 : https://sendbird.com/docs/chat/platform-api/v3/user/creating-users/create-a-user)

  • response : sendbird user info json

여기서 우리는 사용자의 프로필 이미지는 제가 개발한 서비스에서 관리하고 있기 때문에 굳이 sendbird를 사용하지 않아도 된다고 판단했습니다. 그래서 사용자를 생성할 때 user_id, nickname 만 보내 사용자를 만들었습니다. 또한, user_id는 현재 서비스가 사용하고 있는 사용자 구분자와 동일한 값으로 사용했습니다.

이를 구현한 코드는 아래와 같습니다.

//feign client 
@FeignClient(
    name = "sendbird-service-client",
    url = "${sendbird.app}",
    configuration = {SendbirdConfig.class}
)
public interface SendbirdServiceClient {
    @PostMapping(value = "users", consumes = MediaType.APPLICATION_JSON_VALUE)
    SendBirdUser createUser(@RequestBody CreateUserRequest body);
}

//CreateUserRequest
@Getter
public class CreateUserRequest {
    @JsonProperty("user_id")
    private String userId;
    @JsonProperty("nickname")
    private String nickname;
    @JsonProperty("profile_url")
    private String profileUrl;

	public static CreateUserRequest buildSendbirdUser(User user) {
    	return new CreateUserRequest(user.getId(), user.getFirstName());
    }

	private CreateUserRequest(long userId, String nickName) {
    	this.userId = String.valueOf(userId);
        this.nickName = nickName;
   }
}

feign client는 controller처럼 함수를 선언하고 해당 함수를 호출하면 http 통신이 가능하도록 도와줍니다. 위의 코드에서 SendbirdServiceClient의 createUser() 함수를 호출하면 sendbird의 사용자 추가 API가 호출된다고 보시면 됩니다.

해당 API의 응답 결과인 SendBirdUser 는 sendbird SDK에서 제공하는 User 정보의 엔티티로 Sendbird가 내려주는 사용자 정보 대부분은 SendbirdUser 클래스를 통해 받아올 수 있습니다.

해당 API 호출 후 정상적으로 처리가 되었다면 아래의 예시와 같은 응답을 받을 수 있습니다.

{
    "user_id": "1",
    "nickname": "User_A",
    "profile_url": null,
    "access_token": null,
    "is_online": false,
    "is_active": true,
    "is_created": true,
    "phone_number": "",
    "require_auth_for_profile_image": false,
    "session_tokens": [],
    "last_seen_at": -1,
    "discovery_keys": ["123-456-7890", "654-321-0987"],
    "preferred_languages": [],
    "has_ever_logged_in": false,
    "metadata": {}
}

2. Channel 생성하기

본격적으로 채널을 생성하기에 앞서 sendbird는 channel을 크게 2가지로 나눠서 구분하고 있습니다. 바로 open channel과 group channel입니다.

open channel은 누구든지 참여가 가능한 channel입니다. sendbird의 사용자로 등록되어 있지 않더라도 언제든지 사용자를 추가해 채팅을 할 수 있도록 합니다.

group channel은 sendbird에 등록된 사용자들만 참여 가능한 channel입니다.

channel의 종류에 대해 살펴보았으니 API spec을 보겠습니다.

  • method : POST
  • url : https://api-{application_id}.sendbird.com/v3/group_channels
  • request :

(이미지 출처 : https://sendbird.com/docs/chat/platform-api/v3/channel/creating-a-channel/create-a-group-channel)

  • response : sendbird channel info json

우리는 channel을 생성할 때, 해당 channel에 들어갈 사용자들을 지정합니다. 이 때 지정한 사용자가 sendbird에 들어 있지 않은 사용자라면 해당 channel은 open channel로 생성됩니다. 그렇게 되면 DM 관리가 힘들어지고 sendbird sdk가 제공하는 response로 사용할 SendBirdChannelResponse 를 응답으로 받을 때 제대로 파싱하지 못하는 문제가 발생합니다.

비록 url은 group channel을 생성하는 API를 호출하지만 추가하려는 사용자가 sendbird의 user가 아니라면 에러를 응답하는 것이 아니라 open channel로 생성하고 200 ok를 응답합니다. 이러한 문제에서 명확하게 처리하고자 우리는 sendbird에 사용자를 등록하는 과정을 거친 것입니다.

이제 channel을 생성하는 코드를 살펴보겠습니다.

//feign client 
@FeignClient(
    name = "sendbird-service-client",
    url = "${sendbird.app}",
    configuration = {SendbirdConfig.class}
)
public interface SendbirdServiceClient {
    @PostMapping(value = "group_channels", consumes = MediaType.APPLICATION_JSON_VALUE)
    SendBirdChannelResponse createChannel(@RequestBody CreateChannelRequest body);
}

//CreateChannelRequest 
@Getter
public class CreateChannelRequest {
    @JsonProperty("user_ids")
    private List<String> userIds;

	public static CreateChannelRequest buildChannelRequest(User requester, User responder) {
		return new CreateChannelRequest (requester.getId(), responder.getId());
	}

    private CreateChannelRequest (long requesterId, long respondentId) {
        this.userIds = List.of(String.valueOf(requesterId), String.valueOf(respondentId));
    }
}

SendBirdChannelResponse 는 앞서 말씀 드린 것 처럼 group channel에 대한 정보를 파싱하는데 용이하지만 open channel를 파싱 시도 시 에러를 일으킵니다.

우리는 두 사용자에 대해 channel을 생성하는데, sendbird의 user_id는 서비스가 관리하는 사용자 구분자인 user의 id와 동일하기 때문에 userIds 배열은 각 사용자의 id를 string으로 변환한 값을 사용했습니다.

해당 API 호출을 통해 channel이 생성되면 아래와 같은 json을 받을 수 있습니다.

{
    "name": "test_id_1",
    "channel_url": "test_id_1",
    "cover_url": null,
    "custom_type": null,
    "unread_message_count": 0,
    "data": "",
    "is_distinct": true,
    "is_public": false,
    "is_super": false,
    "is_ephemeral": false,
    "is_access_code_required": false,
    "member_count": 4,
    "joined_member_count": 1,
    "unread_mention_count": 0,
    "created_by": {},
    "members": [], //추가한 두 사용자의 정보
    "operators": [], //channel 생성 시 지정한 operator, 지정하지 않았다면 빈 배열
    "last_message": null,
    "message_survival_seconds": -1,
    "max_length_message": 5000,
    "created_at": 1543468122,
    "freeze": false
}

이 응답에서 우리가 사용할 부분은 channel_url 입니다. 이는 channel의 구분자인 id와도 동일하게 사용합니다.

3. 특정 channel에 message 보내기

우리는 2번과 같은 과정을 통해 생성한 group channel에 message를 보내야 합니다. 이 때 호출할 API의 spec은 다음과 같습니다.

  • method : POST
  • url : https://api-{application_id}.sendbird.com/v3/{channel_type}/{channel_url}/messages
  • request :

(이미지 출처 : https://sendbird.com/docs/chat/platform-api/v3/message/messaging-basics/send-a-message)

  • response : sendbird message info json

message_type은 보낼 종류의 메세지를 의미합니다. 우리는 단순한 텍스트 메세지만 주고 받을 것이기 때문에 MESG 만 사용합니다.

user_id는 메세지를 보낸 user의 user_id값을 의미합니다. 우리 비즈니스 로직상 사용자 구분자인 id값을 의미합니다.

message는 보낼 메세지의 내용을 의미합니다.

이제 이를 적용한 코드를 살펴 보겠습니다.

//feign client 
@FeignClient(
    name = "sendbird-service-client",
    url = "${sendbird.app}",
    configuration = {SendbirdConfig.class}
)
public interface SendbirdServiceClient {
    @PostMapping(value = "group_channels/{channelUrl}/messages", consumes = MediaType.APPLICATION_JSON_VALUE)
    SendBirdMessageResponse sendMessage(@PathVariable String channelUrl, @RequestBody MessageRequest body);
}

//MessageRequest 
@Getter
public class MessageRequest {
    @JsonProperty("user_id")
    private String userId;
    @JsonProperty("message_type")
    private String messageType;
    @JsonProperty("message")
    private String message;
    @JsonProperty("send_push")
    private Boolean sendPush;

	public static MessageRequest buildMessage(User user, String message) {
		return new CreateChannelRequest (requester.getId(), responder.getId());
	}

    public MessageRequest (long userId, String message) {
        this.userId = Long.toString(userId);
        this.messageType = "MESG";
        this.message = message;
        this.sendPush = false;
    }
}

SendBirdMessageResponse sendbird sdk에서 제공하는 message 엔티티입니다. 단일 메세지에 대한 정보를 받는데 사용합니다.

MessageRequest에 sendPush라는 항목이 있습니다. 이 항목은 메세지가 전달되면 상대방에게 push를 전송할 것인지에 대한 여부를 나타냅니다. 우리는 메세지를 보낼 때 자체 푸시(FCM)을 보낼 것이기 때문에 sendbird가 발송하는 푸시는 필요하지 않아 false로 처리했습니다.

메세지 전송이 성공하고 나면 Message 개별 정보가 response로 내려옵니다. 200 ok를 받았다면 정상 처리한 것으로 봅니다.


4. 마치며

위의 시나리오는 단순하게 sendbird에 user를 등록하고 channel를 생성하고, channel에 message를 보내는 단순한 구조입니다.

다만, 이 부분이 sendbird를 적용하기 위한 가장 기초적인 부분으로 이외에 대부분의 Platform API들은 이런 기능을 조금씩 세분화해서 관리하는 것에 가깝습니다. channel을 삭제하거나 message 목록을 불러온다거나 하는 응용에 가까운 부분이고 실제로 채팅이란 기능에 부합하는 핵심 API 세 가지를 잘 활용해보는 부분을 살펴보았습니다.

sendbird는 확실히 편합니다. 백엔드 개발자 입장에서는 웹소켓을 활용한 chatting 서버 자체를 구현하지 않아도 됩니다. 이 부분이 엄청난 메리트였습니다. chat이라는 부가 기능이 실제로 구현해야할 비즈니스 로직보다 더 커지게 되는 부분을 방지할 수 있었습니다.

반면, 찾아볼 수 있는 레퍼런스가 지극히 한정적이었습니다. 처음 환경 설정을 하다가 막히는 부분에 대해서는 거의 도움을 받을 수 없었습니다. sendbird 자체의 커뮤니티도 있었지만 원하는 정보를 얻기에는 레퍼런스 자체가 너무 적었습니다.

특히나 한국어로 된 레퍼런스 찾기가 하늘의 별 따기였는데, 이 글이 sendbird의 한국어 레퍼런스에 작게나마 도움이 되었으면 합니다. 이번에 senbird를 사용해보면서 maven의 custom repo 추가부터 feign client까지, 알차게 배울 수 있는 시간이었습니다!


*Reference

profile
Java Spring 백엔드 개발자입니다. java 외에도 다양하고 흥미로운 언어와 프레임워크를 학습하는 것을 좋아합니다.

0개의 댓글