현재 크로키는 API를 GraphQL로 만들고 있습니다. 아직 많은 부분에 대해서 연구 중이어서 현재 상황만 간단하게 정리해 보겠습니다.

Thrift를 1년 정도 쓴 시점부터 여러 가지 불편함을 느끼고 대안을 찾다가 GraphQL을 선택했습니다. GraphQL 생태계가 Node.js를 중심으로 발전하고 있어서 크게 망설이지 않고 정할 수 있었던 것 같습니다.

외부에 영향을 주지 않는 기능 중 N+1 문제를 가지고 있는 기능을 하나 선택해 변환을 해봤습니다. 조금 생각은 해야 하지만 충분히 기존 API에 성능이 떨어지지 않고 사용은 더 편하게 할 수 있는 것을 확인하고 조금씩 확대해나갔습니다.

1년 이상 진행하면서 정리된 API 스펙은 스타일 가이드 저장소에서 공개하고 있습니다.

스키마 정의

처음에는 GraphQLObjectType을 써서 정의했습니다.

import { GraphQLInt, GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';

const type = new GraphQLObjectType({
  name: 'Shop',
  fields: {
    id: {
      type: new GraphQLNonNull(GraphQLInt),
    },
    name: {
      type: new GraphQLNonNull(GraphQLString),
    },
    url: {
      type: new GraphQLNonNull(GraphQLString),
    },
  },
});

그러다가 타입이 많아지면 보기가 힘들어서 스키마 문자열을 통해 정의하는 것으로 변경해 봤습니다.

import { makeExecutableSchema } from 'graphql-tools';

const typeDefs = `
type Shop {
  id: ID!
  name: String!
  url: String!
}
`;

const resolvers = {
  Query: {
    shop: ShopService.shop,
  },
};

const schema = makeExecutableSchema({
  typeDefs: [typeDefs],
  resolvers: [resolvers],
});

현재는 resolver 구현에도 타입 체킹이 되고, DB 모델과 통일시키기 위해 type-graphql을 적용했습니다. 다만 현재 퍼포먼스 이슈 때문에 수정한 버전을 사용하고 있습니다.

import { Field, ID, ObjectType } from 'type-graphql';

@ObjectType()
export class Shop {
  @Field((type) => ID, { nullable: false })
  id: string;

  @Field((type) => String, { nullable: false })
  name: string;

  @Field((type) => String, { nullable: false })
  url: string;
}

클라이언트 호출 코드

클라어인트에서는 apollo 툴을 사용해 코드를 생성해 호출하고 있습니다.

안드로이드

LoginInput input = LoginInput.builder().email(email).password(password).build();
apolloClient.mutate(
    LoginMutation.builder().input(input).build()
).enqueue(new ApolloCall.Callback<LoginMutation.Data>() {
    ...
});

iOS

let input = LoginInput(email: email, password: password)
apolloClient.perform(mutation: LoginMutation(input: input)) { (result, error) in
    ...
}

// api.ts (apollo-tooling 위에 자체 스크립트로 생성한 파일)
import * as types from './types';

export async function login(variable: types.LoginVariables, options?: GqlRequestOptions) {
  const query = 'mutation Login($input: LoginInput!) { login(input: $input) }';
  return await request<types.Login, types.LoginVariables>(query, variable, options);
}

// LoginView.ts
async login() {
  await api.login({
    input: { email, password },
  });
}

마무리하며

전환을 시작한 이후로 많은 GraphQL API를 만들었지만, 기존 API는 손대지 못하고 거의 그대로 사용하고 있습니다. 그걸 보면서 진작에 GraphQL로 전환했으면 두 번 작업하는 일이 줄어들었을 터라는 생각이 듭니다. 하지만 초반에 적용했으면 아직 안정화되지 않은 생태계로 인해 고생했을 것 같습니다.

웹 기술을 TypeScript로, GraphQL로, React로 전환할 때마다, 이 방향이 맞는지, 이 시기가 적당한지 고민이 됩니다. 서비스를 이어가는 와중에 기술 부채를 털어낼 적절한 시기를 놓치지 않도록 계속 노력하고 있습니다.

차후에도 GraphQL을 사용하면서 의미가 있는 부분이 있으면 공개하도록 하겠습니다.

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는 아직 시작하지 못했지만, 회사 행정에 도움 되는 기능부터 하나씩 스킬을 늘려가고 있습니다. 다음번에는 인터랙티브 메시지를 만드는 방법을 소개하도록 하겠습니다.