! This post is also available in the following languages. 영어, 일어

따로 놀던 슬랙봇, gRPC 통해 하나 되기

안녕하세요? LINE IT 전략실에서 사내 시스템의 개발 및 운영을 담당하고 있는 suzuki-shunsuke입니다. 오늘은 얼마 전 grpc를 이용해 여러 개의 슬랙봇(Slack Bot)을 하나로 통합했던 이야기를 해 볼까 합니다.

배경

저는 사내 Slack을 관리하는 팀에서 슬랙봇을 만들고 운영하고 있습니다. 어느 날 ‘봇을 하나로 합쳐줬으면 좋겠다’는 요청이 있었습니다. 다음과 같은 이유 때문이었습니다.

  • 대화창에 여러 봇을 초대하기 번거롭다.
  • 어떤 봇들이 있는지 다 알 수 없다
  • 여러 봇을 자꾸 추가하는 것보다는 하나의 봇에 기능을 추가하는 편이 사용자 입장에서 봇에 더 애착이 간다.

봇을 하나로 합친다는 것은 정확히 어떤 것일까요? 이해를 돕고자 두 개의 봇이 있다고 가정해 보겠습니다. 하나는 채널 이름을 바꿀 수 있는 @renamechan이라는 봇이고 다른 하나는 사내 메일링 리스트에 소속된 사용자를 초대할 수 있는 @spinvite라는 봇이라고 하겠습니다. 자, 채널 이름을 바꾼다거나 사용자를 초대하려면 다음의 명령어를 사용하겠지요?

# 채널명을 foo로 변경하기
@renamechan foo
# foo@example.com에 소속된 사용자 초대하기
@spinvite foo@example.com

우리의 두 봇을 하나의 봇, 예를 들어 @satosan이라는 이름을 가진 단일 봇으로 통합하면, 다음과 같이 두 개의 작업을 단 하나의 봇만 이용하여 수행할 수 있게 됩니다.

# 채널명을 foo로 변경하기
@satosan rename foo
# foo@example.com에 소속된 사용자 초대하기
@satosan invite foo@example.com

사실 봇을 하나로 통합해야겠다는 생각은 봇 개발 초기부터 있었고, 심지어 통합 작업을 위한 저장소도 만들어놨었습니다. 다만 통합 시 발생할 다음 문제들 때문에 당장 실행에 옮길 수는 없었습니다.

A. 단일형(monolithic) 아키텍처가 되어 개발하기 어렵다.
B. 여러 명이 개발할 경우, 언어나 프레임워크를 통일하기 힘들다.
C. 토큰에 많은 권한을 부여하는 것이 신경쓰인다.

이번 기회에 문제 A와 B를 gRPC를 이용해서 해결해 보려고 했습니다. 안타깝게도 문제 C까지 해결할 수는 없지만요.

왜 gRPC인가?

REST API 등이 아닌 gRPC를 선택한 이유는 다음과 같습니다. 참고로 이번 글에서 gRPC에 관한 설명은 생략하겠습니다.

  • Protocol Buffers라는 통일된 형식으로 API를 작성할 수 있다 → 기능별로 API 문서의 품질이나 형식이 일정하다
  • Protocol Buffers를 바탕으로 클라이언트와 서버 양쪽의 코드를 자동으로 생성할 수 있다 → API 사양이 바뀌어도 대처하기 쉽다

통합 봇 설계

봇의 각 기능을 gRPC 서버로 구현한 다음, 슬랙의 RTM API(Real Time Messaging API)로 슬랙봇 하나를 실행시켜 두고, 사용자 요청이 발생하면 gRPC 서버에 요청을 보냅니다. 즉 슬랙봇이 gRPC 클라이언트가 되는 것입니다. 각 gRPC 서버에 슬랙봇의 토큰을 배포하면, gRPC 서버는 이 토큰을 사용해서 내부에서 Slack API를 호출합니다. 이렇게 기능별로 시스템을 따로 개발하면 단일형 아키텍처가 되는 것을 피하면서 언어나 프레임워크도 자유롭게 선택할 수 있습니다.

봇은 기본적으로 gRPC 서버에 요청을 보내기만 하도록 최대한 가볍게 구현합니다. 단, 메시지 전송 작업을 gRPC 서버에 전적으로 넘겨 버리면 각 기능마다 메시지 형식이 달라져 봇 사용자 입장에서는 불편할 수도 있습니다. 그리고 메시지를 전송할 때 gRPC 서버는 chat.postMessage API를 호출하는데, 봇은 웹 소켓을 통해 전송할 수 있습니다. 봇이 웹 소켓으로 메시지를 반환하면 속도 제한이나 권한 문제로 인해 전송이 안 되는 일은 없기 때문에 봇이 메시지를 전송하는 것이 나을 수도 있습니다. 이 부분은 앞으로 검토할 여지가 있어 보입니다.

토큰을 나누면 어떻게 될까?

각 gRPC 서버별로 슬랙 앱을 만들어 토큰을 생성하면 각 토큰의 권한을 최소화할 수 있고 토큰을 배포할 필요도 없습니다. 하지만 이렇게 하면 메시지를 전송하는 주체가, 사용자가 대화창에 초대한 봇이 아닌 다른 봇이 됩니다. 또한, 프라이빗 채널에서 무엇인가 전송하고 싶을 때 봇을 채널에 초대해야 하므로 기껏 봇을 하나로 통합한 의미가 사라지게 되겠죠?

통합 봇 구현하기

이제 구현 방법에 대해 조금 더 자세히 살펴보겠습니다. 구현은 Go 언어로 합니다. 먼저 채널명만 변경하는 단순한 기능을 gRPC 서비스로 구현해 보겠습니다. 과정은 다음과 같습니다. (참고로 예제 코드에서 오류 처리는 생략했습니다.)

우리가 구현할 파일과 디렉터리 구조는 다음과 같습니다.

slack-bot-satosan/  # Bot. 즉 gRPC 클라이언트
slack-bot-grpc-renamechan/  # 채널 이름을 변경하는 gRPC 서버
  renamechan.proto  # Proto definition 파일
  protobuf/
    renamechan.pb.go  # 자동 생성된 코드

Protocol Buffers로 서비스 코드 생성하기

Protocol Buffers 저장소에서 최신 protoc 컴파일러가 압축된 파일을 다운로드한 다음, 압축 해제한 protoc 바이너리를 PATH에 설정된 위치 중 하나에 옮겨 설치하세요. 설치 방법과 Protocol Buffers에 대한 자세한 안내는 다음 링크를 참고하시기 바랍니다.

  • 설치 방법: https://grpc.io/docs/quickstart/go.html
  • Protocol Buffers 튜토리얼: https://developers.google.com/protocol-buffers/docs/gotutorial

채널 변경 서비스 API를 다음과 같이 Protocol Buffers 언어로 정의합니다. 아래 코드를 renamechan.proto라는 이름의 파일로 저장합니다.

syntax = "proto3";

option go_package = "protobuf";

service Renamechan {
  rpc Rename (RenameMessage) returns (RenameResponse) {}
  rpc Help (HelpMessage) returns (HelpResponse) {}
}

message RenameMessage {
  string channel_id = 1;
  string bot_name = 2;
  repeated string args = 3;
}

message RenameResponse {
  string message = 1;
}

message HelpMessage {
  string channel_id = 1;
  string bot_name = 2;
}

message HelpResponse {
  string message = 1;
}

우리가 준비한 renamechan.proto 파일을 컴파일하기 위해 다음과 같이 Makefile을 준비합니다.

protobuf/renamechan.pb.go: renamechan.proto
    protoc renamechan.proto --go_out=plugins=grpc:protobuf

Makefile을 실행하면 자동으로 서비스 코드가 protobuf/renamechan.pb.go 파일로 생성됩니다.

$ make protobuf/renamechan.pb.go

gRPC 서버 구현하기

gRPC 서버에서는 handler라는 별도 패키지에 RenamechanServer라는 인터페이스를 구현합니다.

// RenamechanServer is the server API for Renamechan service.
type RenamechanServer interface {
    Rename(context.Context, *RenameMessage) (*RenameResponse, error)
    Help(context.Context, *HelpMessage) (*HelpResponse, error)
}

그리고 다음과 같이 main() 함수를 작성해서 gRPC 서버를 실행합니다. 서버 실행 방법 대한 자세한 내용은 여기를 확인하시기 바랍니다.

func main() {
    lis, _ := net.Listen("tcp", ":5000")
    s := grpc.NewServer()
    server := handler.Server{
        Bot: slack.New(config.GetSlackAppUserToken()),
    }
    pb.RegisterRenamechanServer(s, &server)
    reflection.Register(s)
    s.Serve(lis)
}

gRPC 상태 코드

HTTP 요청에 대한 결과로 404, 500 등의 상태 코드가 있는 것처럼, gRPC에도 결과에 대한 상태 코드가 정의되어 있습니다. 여러분이 구현하는 gRPC 서버는 상황에 맞는 상태 코드를 요청자에게 반환해야 합니다. 자세한 내용을 보려면 아래 링크를 확인해 보시기 바랍니다.

예를 들어 시스템 오류가 발생하면 Internal을, 사용자가 입력한 파라미터가 잘못되었다면 InvalidArgument를 반환하면 되겠지요?

return nil, status.Error(codes.InvalidArgument, "channel name is required")

gRPC 클라이언트 구현하기

다음은 gRPC 클라이언트를 구현하는 것에 대해 살펴보겠습니다. 자세한 내용은 gRPC가 제공하는 안내 문서를 참고해 주시기 바랍니다. 다음은 클라이언트를 생성하는 간단한 코드 예제입니다.

conn, _ := infra.GetGRPCConnOfRenamechan()
c := renamePB.NewRenamechanClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err = c.Rename(
    ctx, &renamePB.RenameMessage{
        ChannelId: ev.Channel,
        BotName:   botName,
        Args:      args})

위 코드에도 이미 나와있지만, gRPC 서버가 반환하는 상태 코드를 받는 방법을 살펴보겠습니다. gRPC 서버가 반환한 error 객체를 gRPC 클라이언트가 status.Code에 전달하면 상태 코드를 수신할 수 있습니다. 가령 gRPC 서버가 다운되면 codes.Unavailable가 반환되니, 클라이언트는 사용자에게 적절한 오류 메시지를 표시해야 합니다.

_, err = c.Rename(
    ctx, &renamePB.RenameMessage{
        ChannelId: ev.Channel,
        BotName:   botName,
        Args:      args})
code := status.Code(err)

연결 관리는 grpc.ClientConn을 싱글톤 패턴으로 재사용하여 관리합니다(아래 샘플 코드는 실제 코드와는 많이 다릅니다). 이것이 정말 맞는 방법인지는 잘 모르겠습니다. Best practices for reusing connections, concurrency를 한번 읽어 보시기 바랍니다.。

var (
    conn *grpc.ClientConn
)

func getGRPCConn(addr string) (*grpc.ClientConn, error) {
    if conn != nil {
        return conn, nil
    }
    c, _ := grpc.Dial(
        config.GetRenamechanAddress(), grpc.WithInsecure())
    conn = c
    return c, nil
}

연결을 매번 끊지 않고 반복해서 쓰더라도 애플리케이션을 종료할 때는 반드시 끊어야(close) 합니다. 이때는 main() 함수에서 defer로 모든 연결을 끊습니다.

// 모든 연결 close
defer infra.CloseGRPCConn()

SSL/TLS화하기

SSL 인증서를 준비해서 gRPC 클라이언트와 서버에 설치합니다. 그리고 google.golang.org/grpc/credentials를 임포트해서 다음과 같이 수정합니다. (기본 코드)

클라이언트

// https://godoc.org/google.golang.org/grpc/credentials#NewClientTLSFromFile
creds, _ := credentials.NewClientTLSFromFile(certFile, serverNameOverride)
c, _ := grpc.Dial(
    config.GetRenamechanAddress(), grpc.WithTransportCredentials(creds))

서버

// https://godoc.org/google.golang.org/grpc/credentials#NewServerTLSFromFile
creds, _ := credentials.NewServerTLSFromFile(certFile, keyFile)
s := grpc.NewServer(grpc.Creds(creds))

글을 맺으며

이번 글에선 ‘다목적 슬랙봇을 효율적으로 개발하기’라는 주제하에, gRPC를 이용해 봇 기능별로 마이크로서비스화하는 접근법을 살펴보았습니다. 앞으로도 계속 기존 봇의 기능을 gRPC 서버로 구현하여 많은 봇을 통합해 가고자 합니다.