Dart로 채팅 앱 만들기

Dart로 채팅 앱 만들기

Dart 언어의 기본을 다뤘으니 이제 그 개념을 실제로 활용해 볼 차례예요. 이 글에서는 dart-lang 그룹에서 만든 built_valuesource_gen 패키지를 실제 프로젝트에 적용해 채팅 앱을 만들어 보면서, 순수 Dart SDK만으로도 얼마나 많은 일을 할 수 있는지 함께 살펴볼게요.

출처: Building a Chat App in Dart

본문

이 코드는 Dart 서밋에서 발표할 때 데모로 쓰려고 하루 반 만에 작성했어요. built_valuesource_gen을 선보이는 게 목적이었는데, 동시에 Dart가 SDK와 dart-lang의 핵심 패키지들만으로도 꽤 많은 일을 해낼 수 있다는 것도 보여줬죠.

코드 받아보기

여기서 코드를 그대로 읽어도 되지만, 직접 다운로드해서 실행해 보는 편이 훨씬 재미있을 거예요. 실행하려면 dartgit이 설치되어 있어야 해요. 그다음 이렇게 진행하면 됩니다.

git clone https://github.com/google/built_value.dart.git
cd built_value.dart
git checkout tags/v0.4.3-article
cd chat_example
pub get
# 이 세 개는 각각 별도의 터미널 창에서 실행하세요.
pub serve
dart bin/main.dart
dart tool/watch.dart

이제 Dartium이나 Chrome, 또는 아무 최신 브라우저에서 localhost:26199로 접속해 보세요. 대략 이런 화면이 보일 거예요.

Welcome to the built_value chat example. For help, type /help. You are connected as anon9531.

화려하지는 않지만, 탭을 몇 개 더 열어 연결해 보면 완전히 동작하는 채팅 애플리케이션이라는 걸 알 수 있어요. 로그인 기능과 자리 비움/상태 메시지, 공개 메시지와 비공개 메시지까지 모두 지원하거든요.

코드를 바꿔보면 클라이언트의 변경 사항은 새로고침만 해도 바로 반영돼요. 서버 쪽 코드를 바꿨다면 dart bin/main.dart를 종료했다가 다시 실행해 주세요.

채팅 앱에 들어가는 것

우리 서버 코드가 처리해야 할 일은 크게 셋이에요.

  • HTML과 CSS를 서빙하기
  • 사용자 상태를 추적하기
  • 메시지를 주고받기

클라이언트도 마찬가지로 이 정도를 준비해야 하죠.

  • 텍스트를 보여주고 입력을 받는 UI 제공하기
  • 메시지를 주고받기

하나씩 차근차근 살펴볼게요.

HTML과 CSS 서빙하기

여기서는 dart-lang의 package:shelf를 사용해요. 실제 운영 서비스를 만든다면 서빙할 리소스를 미리 빌드해 두는 편이 좋지만, 지금은 개발에 집중할 테니 HTML과 CSS를 곧바로 pub serve에 넘겨주는 방식으로 갈게요. 그 역할을 하는 Cascade는 이렇게 생겼어요.

var cascade = new Cascade()
  // 요청을 pub serve로 보내 봅니다.
  .add(proxyHandler(Uri.parse('http://localhost:8080')))
  // 그게 실패했다면 pub serve에 문제가 있는 겁니다.
  .add((_) {
    print('Request failed. Check pub serve output for errors.');
    return new Response.notFound('');
  });

그리고 서버는 이렇게 띄워요.

await io.serve(cascade.handler, 'localhost', 26199);

이 부분은 메시지 처리를 추가하면서 조금 더 다듬을 거예요.

클라이언트 UI

채팅 클라이언트는 많은 UI가 필요하지 않아서, 책임을 분리하는 게 깔끔하죠. SDK로 충분히 처리할 수 있고요. 먼저 텍스트 영역부터 볼게요. 여기 필요한 것은 querySelector, HtmlEscape, Window예요.

void addLocal(String text) {
  querySelector('#text').innerHtml +=
    '<div class="local">${_htmlEscape.convert(text)}</div>';
  window.scrollTo(0, document.body.scrollHeight);
}

다음은 입력 필드예요. 아래 클래스는 텍스트 입력을 아무나 구독할 수 있는 Stream<String>으로 바꿔주는 역할을 해요.

class Input {
  final StreamController<String> _streamController =
      new StreamController<String>();

  Input() {
    final input = querySelector('#input') as InputElement;

    input.onKeyPress.listen((keyEvent) {
      if (keyEvent.keyCode == KeyCode.ENTER) {
        _streamController.add(input.value);
        input.value = '';
       }
    });
  }

  Stream<String> get keyboardInput => _streamController.stream;
}

이 코드들은 최소한의 HTML과 CSS 위에서 동작해요. 그리고 입력 요소가 처음부터 포커스를 갖고, 그 포커스를 유지하도록 만드는 코드가 조금 더 필요해요.

var screen = querySelector('#screen');
var text = querySelector('#text');
var input = querySelector('#input');

input.focus();

for (var element in [screen, text]) {
  element.onClick.listen((e) {
    input.focus();
  });
}

이게 전부예요. 어떤 프레임워크도 필요 없죠. 물론 UI가 아주 단순한 수준이긴 해요. 더 많은 걸 하고 싶다면 그때는 Angular로 넘어가면 될 것 같아요.

클라이언트-서버 통신

채팅 애플리케이션은 서버에서 클라이언트로 메시지를 보낼 수 있어야 해서, 단순한 POST 방식만으로는 부족해요. 그래서 영구적인 양방향 통신 채널을 제공하는 WebSocket을 선택했어요.

shelf를 쓰면 우리 Cascade의 맨 앞에 이 한 줄만 추가하면 돼요.

var cascade = new Cascade()
 .add(webSocketHandler(socketReceiver))

여기서 socketReceiver는 새로 만들어진 WebSocketChannel을 인자로 받는 함수예요. webSocketHandler는 일반 HTTP 요청을 거절하니까, 그런 요청들은 계속해서 캐스케이드의 다음 단계로 흘러가게 되죠.

클라이언트 쪽에서의 연결도 똑같이 간단해요.

var socket = new WebSocket('ws://${window.location.host}/ws');
socket.onMessage.listen((message) => <handle message>);
socket.send('Hello server!');

물론 통신할 때 쓸 데이터 형식도 필요해요. 이 코드를 쓴 목적이 built_value를 보여주는 것이었으니, 당연히 그 직렬화를 사용했어요. 순수 JSON에 손으로 쓴 변환 코드를 쓰는 것도 간단하긴 한데, built_value의 핵심은 그걸 공짜로 얻는다는 데 있으니까요.

WebSocket과 SSL

참고로 한 가지 짚고 넘어갈게요. 지금 이 예제는 암호화되지 않은 연결을 사용해요. 그런데 암호화되지 않은 WebSocket 연결은 동작이 불량한 프록시에 의해 가로막히는 경우가 있어요. 이 문제를 해결하려면 사이트 전체를 http 대신 https로 바꿔 암호화하면 돼요. 설정 과정이 번거롭지만, 다른 이유로도 해두면 좋은 선택이죠.

객체 모델

채팅 예제의 핵심에는 서버와 클라이언트가 함께 쓰는 객체 모델이 있어요. built_value를 사용하기 때문에 이 클래스들을 그대로 통신으로 주고받을 수 있죠. 예를 들어 아래 클래스는 채팅 메시지를 보낼 때 사용해요.

abstract class Chat implements Command {
  String get text;
  BuiltSet<String> get targets;
}

서버의 로그인 처리, 클라이언트 로직, 클라이언트 렌더링이 모두 이 객체 모델 클래스들을 기반으로 만들어져 있어요.

클라이언트 로직

무언가를 입력하면 클라이언트 코드는 그 내용이 명령에 해당하는지 확인하고 적절히 처리해야 해요. 이건 단순히 문자열을 비교한 다음, 알맞은 객체 모델 클래스를 만들어 보내는 일이에요. 전체 코드는 여기에서 볼 수 있어요.

예를 들어 /away로 시작하는 내용을 입력하면, 그건 상태 갱신으로 변환돼요.

_send(new Status((b) => b
  ..message = command.substring('/away '.length)
  ..type = StatusType.away));

클라이언트 렌더링

서버가 보낼 수 있는 모든 메시지는 다음 인터페이스를 구현해요.

abstract class Response {
  String render();
}

그래서 메시지를 받으면 클라이언트는 그냥 render를 호출해서 결과를 화면에 보여주기만 하면 돼요. 예를 들어 공개 메시지와 비공개 메시지를 모두 담을 수 있는 ShowChat 클래스를 볼게요.

class ShowChat implements Response {
  String get username;
  bool get private;
  String get text;

  @override
  String render() =>
    '$username${private ? "(private)" : ""}: $text';
}

참고로, 단순한 렌더링 로직을 데이터 클래스 안에 캡슐화하는 이 패턴은 국제화(i18n)에도 잘 들어맞아요.

서버 로직

서버 쪽에는 상태가 담겨 있어요. 채팅 로그, 사용자 이름과 비밀번호, 사용자 상태가 그것이죠. 메시지를 받으면 상태를 갱신하고 필요에 따라 메시지를 내보내요. 전체 코드는 여기에서 볼 수 있어요.

예를 들어 여기서 command는 클라이언트로부터 받은 메시지예요. 만약 그게 chat 메시지라면 — 즉 대상을 지정하는지 — 비공개로 보낼지 판단하고, 대상이 있으면 그 대상에게만, 없으면 모두에게 보내는 거죠.

if (command is Chat) {
  _chat(connection, command);
}

...

void _chat(ServerConnection connection, Chat chat) {
  if (chat.targets.isEmpty) {
    _sendToAll(new ShowChat((b) => b
      ..username = connection.username
      ..private = false
      ..text = chat.text));
  } else {
    _sendTo(
      chat.targets,
      new ShowChat((b) => b
        ..username = connection.username
        ..private = true
        ..text = chat.text));
  }
}

이게 전부예요

이렇게 해서 채팅 서버와 클라이언트가 단 820줄의 코드로 완성됐어요. 세어 본 분들을 위해 말씀드리면, 추가로 1120줄은 built_value가 생성해 주고, 엔드 투 엔드 테스트는 155줄이 필요했어요.

잠깐, 엔드 투 엔드 테스트라고요?

분명 하루 반 만에 썼다고 했는데요. 중간쯤에서 기능 하나를 추가할 때마다 다른 기능을 망가뜨리지 않았는지 일일이 수동으로 테스트해야 하는 게 점점 불편해지더라고요. 그래서 한발 물러나 테스트하기 좋게 리팩터링하고, 주요 기능들에 테스트 커버리지를 추가했어요.

다음 주에는 이 채팅 예제가 정확히 어떻게 테스트되는지, 그리고 서버와 클라이언트 양쪽에서 Dart로 테스트하는 것이 얼마나 좋은지 자세히 살펴볼게요.

편집: 다음 글에서 이어집니다.

더 알아보기