2016년 중반 마이크로서비스로의 전환을 결정했습니다. 마이크로서비스는 이론상 다른 서비스에 영향을 주지 않고 내부 기술을 바꿀 수 있습니다. 하지만 마이크로서비스 간의 통신 방법은 한번 결정하면 쉽게 바뀌기 어려울 것 같아서 가장 많이 고민했습니다. 그리고 Thrift를 선택했습니다. 이번 글에서는 그 이유와 이후의 상황에 관해 설명하겠습니다.

마이크로서비스에 대한 글들을 찾아보면 대부분은 통신 방법도 REST API를 많이 얘기하고 있습니다. (예. Introduction to Microservices | NGINX) 그러나 이전 글에서 썼듯이 REST API에 불편함을 느끼고 있었습니다. 특히 마이크로서비스 간에는 REST로 표현하기 어려운 더 다양한 API가 필요해질 것 같았습니다. 그래서 대안을 찾아봤습니다.

그 당시 고려했던 대안은 Thrift, Avro, Protocol Buffers였던 것으로 기억합니다. 지금 시점에 괜찮아 보이는 gRPC는 이상하게 당시 고려대상에서 벗어나 있었던 것 같습니다. 아마 RPC 보다는 데이터 직렬화 쪽을 더 중점적으로 생각했던 것 같습니다. 데이터- 특히 배열 -를 JSON으로 만들면 크기가 매우 크기 때문에 데이터 크기가 줄어드는 게 매력적으로 다가왔습니다.

그중에서 Thrift를 선택한 건 다음과 같은 이유가 있었던 것 같습니다.

  1. Avro 방식보다는 Thrift / Protocol Buffers 방식이 더 작은 사이즈를 만들 것 같았습니다.
  2. Protocol Buffers는 JavaScript를 잘 지원하는 듯이 보이지 않았습니다. (공식 문서의 튜토리얼에 없음)
  3. VCNC에서 Thrift를 쓰고 있다는 글을 봐서 어느 정도 검증이 됐으리라 봤습니다.

그렇게 Thrift를 마이크로서비스 간의 통신에 적용하는 작업을 시작했는데 생각만큼 잘 동작하지는 않았습니다.

  1. JavaScript를 지원하지만, 막상 해보니 클리어언트 JavaScript 용이었고, Node.js에 어울리는 코드를 만들어주지 않았습니다. TypeScript 지원을 포함해 몇 가지 수정을 가해서 사용하고 있습니다. (https://github.com/croquiscom/thrift/tree/croquis-171130)
  2. TCP 소켓 통신을 통해 요청마다 연결을 만들어야 하는 낭비를 없애기를 기대했는데, 오토 스케일링과 어울리지 않아서 결국 HTTP 통신을 했습니다.

몇 가지 문제가 있긴 하지만, 오랜 시간에 걸쳐 안정화되어 현재 수백개의 Thrift API가 존재하고 있습니다.

크로키닷컴만의 특이한 규칙이 하나 있다면 Read API에서 모든 필드 요청을 피하기 위해서 받기를 원하는 필드를 지정할 수 있도록 구성되어 있다는 것입니다. Update API도 하나로 여러 요구 사항에 대응하기 위해 업데이트를 할 필드를 같이 주도록 했습니다.

/// 지그재그 공지사항
struct ZigzagNotice {
  /// 레코드 ID
  1: optional i32 id
  /// 공지사항 내용
  2: optional string contents
  /// 공지 날짜
  3: optional i32 date
  /// 배포 대상 OS 타입 (0:common,1:none,2:iOS,3:Android)
  4: optional i32 os
  /// 공지사항에 필요한 링크 URL
  5: optional string link
}

service ZigzagNoticeService {
  /**
   * 해당 레코드 ID의 공지사항을 반환한다.
   *
   * fields는 ZigzagNotice의 구조체 필드 ID로 빈 경우 레코드 ID만 반환한다.
   */
  ZigzagNotice getZigzagNotice(1: i32 notice_id, 2: list<i32> fields)

  /**
   * 해당 레코드 ID의 공지사항을 업데이트 한다.
   *
   * fields는 ZigzagNotice의 구조체 필드 ID로 정의된 필드의 값만 업데이트 된다.
   */
  void updateZigzagNotice(1: i32 notice_id, 2: ZigzagNotice notice, 3: list<i32> fields)
}

이렇게 1년 이상 Thrift를 사용해왔지만 여러 가지 불편함이 발생했습니다.

  1. 라이브러리가 활발하게 개발되지 않고 있습니다. 특히 JavaScript/Node.js 쪽은 큰 변화가 없습니다.
  2. TypeScript와 잘 어울리지 않았습니다.
  3. API 추가/변경 시마다 코드를 생성해야 하는 것이 생각보다 불편했습니다.

그래서 2017년 말에 GraphQL을 살펴보기 시작해서 현재는 새로 작성하는 모든 API를 GraphQL로 만들고 있습니다. 다음번에는 크로키닷컴에서 GraphQL을 적용하는 과정에 관해서 설명하겠습니다.

크로키닷컴을 시작하고 비교적 초기부터 ChatOps를 해보고 싶었습니다. GitHub의 글을 보고 도입하고 싶다는 생각이 들었던 거로 기억합니다. 당연하게 Hubot을 이용해 채팅봇을 설정했습니다.

초기에는 HipChat에 Hubot을 붙였고, 2014년 중반 Slack으로 전환했습니다. 봇을 활용하려는 시도는 여러 번 했지만 대부분 장난 수준을 벗어나지 못했고(예. 점심 메뉴 보여주고 임의로 고르기), 그나마 조금 복잡했던 것이 Box, Dropbox, Evernote에서 변경된 내용을 인식해 특정 채널에 알려주는 기능이었습니다.

그렇게 방치하다가 2019년에 들어와 개발팀 인원도 늘어나서 다시 한번 제대로 채팅봇을 만들자는 얘기가 나왔습니다. 이전에 작업해서 익숙한 Hubot을 다시 사용할까 했는데 아무래도 소스 기반이 CoffeeScript인게 마음에 걸렸습니다. 여러 가지를 찾아보다가 Botkit을 사용하기로 결정했습니다.

이번 글에서는 Botkit을 이용해 슬랙 봇을 만드는 방법을 설명합니다.

슬랙 앱 생성

Hubot은 슬랙 앱이 있어서 설정이 편했는데, Botkit은 슬랙 앱을 만들어야 합니다.

Botkit은 AI 봇을 만들어 여러 업체에 제공하기 위한 솔루션인 듯 여러 팀을 다룰 수 있도록 구성되어 있고, Botkit 슬랙 앱 설정 문서도 내용이 많습니다. 하지만 내부적으로 사용하는 용도로는 그렇게까지 필요하지 않습니다.

우선 Your Apps에 가서 새 앱 생성을 합니다.

Create a Slack App

그리고 Bot Users 메뉴에서 Bot User를 추가합니다.

Add Bot User

마지막으로 Install App에서 'Install App to Workspace'를 누르고 Authorize를 선택하면 Bot User Access Token을 얻을 수 있습니다.

Installed App Settings

Bot 만들기

Botkit Starter Kit for Slack Bots가 있지만, 이 역시 저희 용도에는 너무 복잡해서 처음부터 만들기로 했습니다.

필요한 패키지는 botkit, dotenv 뿐입니다. 그리고 TypeScript로 만들기 위해 typescript와 ts-node를 추가합니다.

package.json

{
  "name": "bot",
  "scripts": {
    "start": "ts-node app.ts"
  },
  "dependencies": {
    "botkit": "^0.7.4",
    "dotenv": "^7.0.0",
    "ts-node": "^8.0.3",
    "typescript": "^3.4.3"
  },
  "devDependencies": {
    "@types/dotenv": "^6.1.1"
  }
}

Access Token은 소스 관리가 되지 않는 .env 파일에 기록합니다.

.env

SLACK_BOT_TOKEN=xoxb-276777......

TypeScript 컴파일 설정도 만들어줍니다.

tsconfig.json

{
  "compilerOptions": {
    "noImplicitAny": true,
    "noImplicitThis": true,
    "strictNullChecks": true,
    "target": "es2017",
    "module": "CommonJS",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "lib": [
      "es2017",
      "dom",
      "esnext.asynciterable"
    ]
  }
}

마지막으로 다음과 같이 봇 코드를 작성합니다.

app.ts

import Botkit from 'botkit';
import dotenv from 'dotenv';

dotenv.config();

const controller = Botkit.slackbot({
});

controller.startTicking();

const bot = controller.spawn({ token: process.env.SLACK_BOT_TOKEN || '' });

bot.startRTM((error) => {
  if (error) {
    console.log('구동에 실패했습니다.');
  } else {
    bot.say({ text: '봇이 배포되었습니다! 😄', channel: 'Cxxxxx' });
  }
});

npm start를 하면 봇이 구동되고 지정한 채널에 메시지가 표시됩니다.

봇 스킬 추가

봇 동작을 기술할 스크립트는 Botkit Starter Kit에 맞춰 스킬이라고 부르기로 했습니다.

스킬 추가는 파일을 추가하기만 하면 되는 구조로 작성했습니다.

skill/index.ts

import { SlackController } from 'botkit';
import fs from 'fs';

export const loadSkills = (controller: SlackController) => {
  fs.readdirSync(__dirname).forEach((filename) => {
    if (filename !== 'index.ts' && !filename.includes('.disabled.')) {
      require('./' + filename).default(controller);
    }
  });
};

app.ts

...

import { loadSkills } from './skill';

...

loadSkills(controller);

다음은 Botkit Starter Kit for Slack Bots에서 가져온 스킬 샘플입니다. 봇에게 color나 question이라는 단어를 포함해 1:1 메시지(direct_message)를 보내거나 언급하면 동작합니다.

skill/sample-conversation.ts

// copied from https://github.com/howdyai/botkit-starter-slack/blob/master/skills/sample_conversations.js
import { SlackController } from 'botkit';

export default (controller: SlackController) => {
  controller.hears(['color'], ['direct_message', 'direct_mention'], (bot, message) => {
    bot.startConversation(message, (error, convo) => {
      convo.say('This is an example of using convo.ask with a single callback.');
      convo.ask('What is your favorite color?', (response) => {
        convo.say('Cool, I like ' + response.text + ' too!');
        convo.next();
      });
    });
  });

  controller.hears(['question'], ['direct_message', 'direct_mention'], (bot, message) => {
    bot.createConversation(message, (error, convo) => {
      convo.addMessage({ text: 'How wonderful.' }, 'yes_thread');
      convo.addMessage({ text: 'Cheese! It is not for everyone.', action: 'stop' }, 'no_thread');
      convo.addMessage({ text: 'Sorry I did not understand. Say `yes` or `no`', action: 'default' }, 'bad_response');

      convo.ask('Do you like cheese?', [{
        pattern: bot.utterances.yes,
        callback: (response) => {
          convo.gotoThread('yes_thread');
        },
      }, {
        pattern: bot.utterances.no,
        callback: (response) => {
          convo.gotoThread('no_thread');
        },
      }, {
        default: true,
        callback: (response) => {
          convo.gotoThread('bad_response');
        },
      }]);

      convo.activate();

      convo.on('end', () => {
        if (convo.successful()) {
          bot.reply(message, 'Let us eat some!');
        }
      });
    });
  });
};

마무리

원래 봇을 통해 의도했던 ChatOps는 아직 시작하지 못했지만, 회사 행정에 도움 되는 기능부터 하나씩 스킬을 늘려가고 있습니다. 다음번에는 인터랙티브 메시지를 만드는 방법을 소개하도록 하겠습니다.

크로키가 클라이언트-서버 아키텍처를 가진 첫 번째 서비스 개발을 시작한 것은 2012년이었습니다. 클라이언트에서 서버와 통신할 방법이 필요했는데 당시의 대세는 REST API였습니다. 저도 거기에 공감했기 때문에 REST API를 만들어 클라이언트를 구현했습니다. 그 후로 모든 서비스는 기본적으로 REST API로 클라이언트와 서버가 통신하고 있습니다.

다만 여기서 말하는 REST API는 REST API라는 것에 대한 일반적인 인식 - 리소스 URI 명명법 및 HTTP 상태 코드 - 을 따른 API를 말하는 것으로, Roy Fielding이 얘기하는 REST 아키텍처와는 거리가 있습니다. Stateless 조건을 만족하는 대신 쿠키를 사용하고 있고, HATEOAS는 지금도 어떻게 사용하면 좋을지 감을 잡지 못하고 있습니다. Accept 헤더도 인식하지 않고 무조건 JSON을 가정하고 있습니다.

지금도 REST 스타일로 리소스 URI를 명명하는 것은 옳다고 생각하고, 웹 서비스의 각 페이지 주소는 최대한 REST 스타일로 이름 짓고 있습니다. 다만 API를 REST API로 하는 것이 맞는지는 계속 의문을 품고 있습니다. 다음은 수년간 서비스를 개발하면서 REST API 스타일이 적합하지 않는다고 생각한 예를 적어보겠습니다.

HTTP 동사로 처리하기 어려운 API

가장 처음에 맞닥뜨린 고민은 로그인, 로그아웃 API였습니다. HTTP 동사에 딱 맞지 않는 경우 적당한 리소스 뒤에 동사를 붙이는 것으로 처리하는 경우를 많이 봤습니다. 예를 들어 로그인은 /users/me/login과 같이 처리하는 것이죠. 그런데 저는 동사를 피해야 한다고 하면서 마지막에 동사가 있는 것이 맘에 들지 않았습니다.

로그인/로그아웃의 경우 세션이라는 리소스를 다루는 것으로 간주하는 API 디자인도 봤습니다. 로그인 = POST /sessions, 로그아웃 = DELETE /sessions와 같이 대응시키는 것입니다. 하지만 너무 어색하게 느껴졌고, 이런 식으로 처리하기 어려운 예도 많았습니다.

그래서 이런 경우는 REST API가 아닌 API를 만들고 문서도 다르게 기술했습니다.

  • REST API: GET /users, POST /events
  • non-REST API: /login (POST)

다음은 non-REST API로 만들었던 API들의 예입니다.

  • 로그인: /login
  • 사용자 A가 B에게 팀에 가입 요청(join_request)을 한 후 B가 해당 요청을 승인/거절: /acceptJoinRequest, /ignoreJoinRequest
  • 특정 경기에 참여: /joinMatch
  • 서비스 계정과 페이스북 계정의 연결/연결 끊기: /linkWithFacebook, /unlinkWithFacebook

여러 리소스를 한 번에 가져오기

몇 개의 리소스에서 데이터를 한 번에 가져오고 싶은 경우에 API를 정의하기 어려웠습니다.

예를 들어 지그재그에서 사용자가 담아둔 상품의 가격을 갱신하기 위해서 서버에 데이터를 요청하는데 REST API로는 기술하기가 어려웠습니다. 굳이 하자면 GET /products/1,6,29,35/price 같은 형태를 생각해볼 수 있지만, 상품이 많은 경우 URL 길이의 제한에 걸릴 수 있습니다.

결국, 이런 경우 RPC 스타일로 API를 정의했습니다. /getProductPrices

요청자마다 원하는 데이터가 다른 경우

같은 리소스지만 모든 데이터가 필요하지 않은 경우 네트워크 사용량을 줄이기 위해 특정 필드만 반환하도록 fields 인자를 지정하는 REST API를 종종 보셨을 것입니다.

저의 경우 네트워크 사용량은 무시해서 fields 인자를 사용하지는 않았지만, 일부 필드는 추가적인 DB 접근이 필요한 경우가 있어서 필요한 경우만 요청하도록 하는 규칙을 정했었습니다. 예를 들어 이벤트 정보를 받을 때 이벤트가 열리는 장소와 참석자는 별도 테이블(Place, EventAttendance)에 있다 보니 다음과 같이 요청했습니다.

GET /events/123?add=attendances,place

같은 리소스지만 인자에 따라 처리 코드가 다른 경우

비슷한 상품 목록이지만 검색어 검색과 카테고리 검색의 코드가 다릅니다. 그런데 라우트는 GET /products 하나로 구성해야 합니다. 이런 경우가 있을 때마다 제대로 URL을 정한 것인지 고민이 됩니다.

특히 인자에 따라 결괏값이 달라야 하는 경우 같은 리소스가 맞나 싶을 때가 있습니다.

결정하기 어려운 이름

보통 리소스 목록은 /resources, 개별 리소스는 /resources/:id로 구성을 하지만, 가끔 리소스 목록에 후자를 쓰고 싶을 때가 있습니다. 상태(/resources/valid)나 검색(/resources/my-query) 같은 경우인데요, 페이지 URL이 REST 형태로 잘 구성되어 있다고 생각하는 GitHub에서도 https://github.com/nodejs/node/pulls/10000 같은 주소를 보면 쉽지 않다고 느낍니다.

결론

현재 크로키닷컴의 API는 REST API가 기본이지만 위에 적힌 이유로 마이크로서비스 간의 통신에는 Thrift를 사용해서 RPC 스타일의 API를 사용해 보고 있습니다. 또한, 최근에는 클라이언트-서버 간의 API도 통일하기 위해서 GrpahQL을 시도해보고 있습니다. 이에 대해서는 추후 다른 포스팅으로 설명하겠습니다.