이 글에서는 Netty를 이용해 채팅 서버 구축을 위한 기본 셋팅과 구조에 집중해 이야기를 전개합니다. 실제 운영환경에서 서비스되는 시스템을 만드는 과정은 많은 고민과 트러블슈팅이 필요하기 때문에 자칫 주제와 조금만 벗어나도 이야기가 우왕좌왕해질 수 있기 때문입니다. 따라서 비지니스 로직이 담긴 서비스레벨과 데이터 설계 부분은 제외됐음을 알려드립니다.

소개

저는 신사업부문에서 영상기반 소셜서비스인 Thiiing(띠잉)서비스를 만들고 있어요.
띠잉은 재미난 영상을 바탕으로 소셜로의 확장도 하며 다양한 시도를 하고 있는 팀이고요.
그 중 소셜서비스에서 빠지면 섭섭한, 관계/추천/커뮤니케이션 도구도 꾸준히 만들어 가고 있어요.
이 글에서는 그 중 하나인 채팅 서버를 구축한 경험에 대해 이야기해볼게요.


배경

채팅 기술을 어떤 방식으로 도입할지에 대해 치열한(!) 논의를 우선 거쳤어요.
아주 근본적이고 원초적인 ‘누가 만들건데?’부터 시작됐고, 의견은 크게 두 가지로 갈렸어요.

  1. 외부 완성형 솔루션을 구입하여 서비스에 적용한다.
  2. 직접 구현한다.

저는 직접 만들자는 2안을 주장했고 이유는 아래와 같았어요.

  1. 데이터
    • 팩트 : 외부 솔루션 도입시 유저의 대화 내역이 third-party에 보관된다.
    • 의견 : 가장 개인적이어야 할 대화내역에 대한 보안 의존성을 외부에 맡겨도 좋은걸까? 운영관점에서 심리적으로 불안함이 느껴진다.
  2. 일정
    • 팩트 : 채팅 적용 시점인 데드라인이 이미 정해져 있다.
    • 의견 : 확신하기는 어렵지만 그정도 시간이면 직접 만들 수 있을 것 같다.
  3. 기술 의존성
    • 팩트 : 외부 솔루션 도입시 기술 디펜던시가 추가된다.
    • 의견 : 우리 요구사항에 대해 기민하게 대응할 수 없을 것 같다.
      이는 내부적으로 한 차례 외부 기술에 의존성을 가지면서 골치아픈 상황을 겪은 바 있었다.
      불가피하게 기술 의존성을 가져야 하는 경우도 있지만, 가능하다면 내재화하여 소화하는 것이 최선이라고 본다.
  4. 클라이언트 리소스
    • 팩트 : 외부 솔루션을 도입하더라도 앱 개발 리소스가 없기 때문에 결국 서버파트내에서 웹뷰 구현이 필요하다.
    • 의견 : 새로 만드나 돈주고 사나 그 쪽 리소스는 변함이 없다.
  5. 경험
    • 팩트 : 서버 구성원중 채팅 서비스를 운영해 본 경험자가 부족하다.
    • 의견 : 나는 해봤다.
  6. 자존감
    • 이 팀에 maker로 합류한 거라 adaptor로 만족할 순 없다.

저는 이전에 채팅 서비스를 운영해본 경험이 있었기 때문에 고려할 것과 이슈들에 대해서는 대략적으로 인지하고 있었어요.
다만 처음부터 셋업할 기회가 그동안 없었고, 당시 기술셋을 그대로 차용할 수도 없었죠. 반대로 생각하면 이보다 좋은 기회는 없었던거죠.

기술셋을 고려하기 이전부터 이런 논의가 있을 수 밖에 없던 이유가 있었는데요.
신사업부문의 특성상 갑자기 치고 들어오는 업무를 일정에 맞춰 개발해야 하는 일들이 많았고, 그게 아니더라도 일은 넘쳤거든요.
그렇기 때문에 개발 리소스를 어디에 집중할 것인지가 의사결정에 꽤 중요한 판단 기준이었고요.
다소 열악한 상황이었고, 분명 리스크한 부분도 있었기에 의사결정이 쉽지 않았다는데 공감할 수 있었어요.
어떻게 보면 편하게 갈 수 있는 길을 조금 어렵지만 도전해보고 싶다는 제 주장을 증명하기 위해서 더 절박한 상황이 됐다고 봐야겠죠.
그럼에도 최소 리소스로 일정내에 마치되, 경험을 살려 그 이상의 스펙을 구현해 보겠다는 욕심도 있었고요.
이 또한 신사업부문이었기에 겪어볼 법한 의미있는 경험이 아니었나 싶네요.


기술셋

  • Netty
  • Spring boot
  • Maria DB
  • Redis
  • Kafka
  • Hibernate

Stack Layer

properties
Thiiing chat-app simple stack layer



커넥션

채팅에서 가장 중요하다고 해도 과언이 아닌 것이 커넥션 관리인데요.

  1. 현재 띠잉 채팅의 클라이언트는 웹뷰이기 때문에 서버와의 통신은 웹소켓을 이용합니다.
    • 향후 앱기반 클라이언트가 붙게 되면 웹소켓 연결부만 소켓방식으로 갈아끼면 됩니다.
  2. 스케일 아웃 가능한 서버 구조
    • 서버간 메시지 포워딩이 가능해야 합니다. 이를 위해 서버간 mesh구조를 채택했습니다.
    • 어떤 유저가 어떤 서버에 붙어 있는지를 관리해야 합니다.
    • 현재 운영중인 서버 목록을 관리해야 합니다.
  3. 서버 커넥션
    • 클라이언트-서버간 연결은 웹소켓입니다.
    • 서버간 연결은 소켓입니다. 이를 위해 각 서버는 소켓서버와 소켓클라이언트도 갖습니다.
  4. 커넥션 체크/갱신 스케줄링
    • 서버에 붙은 클라이언트의 소켓 상태를 감시하고 관리합니다.
    • 서버간 소켓의 상태를 감시하고 관리합니다.

다행스럽게도(!) Netty는 이 부분을 아주 잘 지원해주고 있어서 네트워크 low-level에서 작업해야 할 비용이 크게 줄어들었어요.
물론 스케일 아웃 가능한 구조라든지 커넥션 갱신 등에 관한 세부적인 것들은 신경써서 개발이 필요하고요.

구글 네티 그룹을 꼭 참고하세요. 책에서 설명해주지 않는 실무적인 어려움이 있을 때 큰 도움이 됐어요. 직접 질문을 하지 않더라도 이미 많은 분들이 고민하셨던 부분들에 대해 친절한 답변이 많이 있고, 메일링도 추가해서 틈틈이 트러블슈팅을 모니터링할 수도 있어요.
무엇보다 Netty 창시자인 이희승님이 직접 달아주신 답변들도 있으니 도움이 안될래야 안될 수가 없겠죠!

코드에서 설정값들은 참고로만 봐주세요.
각 서비스 환경에 맞게 운영을 하며 적절한 값을 찾는게 중요하기 때문이에요.
물론 저희 서비스의 현재 설정들도 운영 과정에서 조정될 여지가 있어요.

Bootstrap

웹소켓용 서버, 소켓용 서버/클라이언트를 구동하기 위한 Bootstrap을 먼저 살펴볼게요.

WebServer

웹 서버를 구동해요.

@Component
public class WebServer { // [1]
    ...

    public void start() {
        try {
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(bossGroup, workerGroup) // [2]
                     .channel(NioServerSocketChannel.class)
                     .handler(bootstrapHandler) // [3]
                     .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
                     .option(ChannelOption.SO_BACKLOG, 500) // [4]
                     .childOption(ChannelOption.TCP_NODELAY, true) // [5]
                     .childOption(ChannelOption.SO_LINGER, 0) // [6]
                     .childOption(ChannelOption.SO_KEEPALIVE, true) // [7]
                     .childOption(ChannelOption.SO_REUSEADDR, true) // [8]
                     .childHandler(webChannelInitializer); // [9]

            ChannelFuture channelFuture = bootstrap.bind(port).sync();
            channelFuture.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            ...
        } finally {
            stop();
        }
    }

    private void stop() {
        bossGroup.shutdownGracefully().awaitUninterruptibly();
        workerGroup.shutdownGracefully().awaitUninterruptibly();
        ...
    }

[1] WebServer는 Application main()에서 실행합니다.
[2] bossGroup과 workerGroup은 NioEventLoopGroup의 인스턴스입니다.
이 때 스레드 개수를 설정할 수 있는데, 저의 경우 각각 1, Runtime.getRuntime().availableProcessors() * 2로 설정했습니다.
[3] 채널이 활성화되면 소켓 서버와 소켓 클라이언트를 구동하게 되는 핸들러를 등록합니다.
[4] 동시에 수용할 수 있는 소켓 연결 요청 수입니다.
[5] 반응속도를 높이기 위해 Nagle 알고리즘을 비활성화 합니다.
[6] 소켓이 close될 때 신뢰성있는 종료를 위해 4way-handshake가 발생하고 이때 TIME_WAIT로 리소스가 낭비됩니다. 이를 방지하기 위해 0으로 설정합니다.
[7] Keep-alive를 켭니다.
[8] SO_LINGER설정이 있으면 안해도 되나 혹시나병(!)으로 TIME_WAIT걸린 포트를 재사용할 수 있도록 설정합니다.
[9] 아래 Initializer > WebChannelInitializer에서 설명합니다.

SocketServer

소켓 서버를 구동해요.

@Component
public class SocketServer {
    ...

    public void start() {
        try {
            ServerBootstrap bootstrap = new ServerBootstrap(); // [1]
            bootstrap.group(bossGroup, workerGroup)
                     .channel(NioServerSocketChannel.class)
                     .handler(bootstrapHandler)
                     .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
                     .option(ChannelOption.SO_BACKLOG, 50)
                     .childOption(ChannelOption.TCP_NODELAY, true)
                     .childOption(ChannelOption.SO_LINGER, 0)
                     .childOption(ChannelOption.SO_KEEPALIVE, true)
                     .childOption(ChannelOption.SO_REUSEADDR, true)
                     .childHandler(channelInitializer);

            bootstrap.bind(port).sync();
            ...
        } catch (InterruptedException e) {
            ...
        }
    }

    public void stop() {
        ...
    }
}

[1] 위 WebServer설정과 유사하며 설정값들만 미세하게 조정했습니다.

SocketClient

소켓 클라이언트를 구동해요.
여기서는 다른 곳에 있는 소켓 서버와 연결하게 돼요.

@Component
public class SocketClient {
    ...

    public void start() throws Exception {
        Set<String> hosts = connectionInfoService.getHosts(); // [1]
        for (String host : hosts) {
            ...
            if (!hasConnect(host)) { // [2]
                connect(host);
            }
        }
    }

    public boolean connect(String host) throws Exception {
        Bootstrap bootstrap = new Bootstrap();
        NioEventLoopGroup eventLoopGroup = new NioEventLoopGroup();

        return retryTemplate.execute((RetryCallback<Boolean, Exception>) context -> { // [3]
            ...
            ChannelFuture future = configureClientBootstrap(bootstrap, eventLoopGroup, host).connect().sync();
            boolean isAddClientChannel = channelService.addHostChannel(host, future.channel());
            if (isAddClientChannel) {
                eventLoopGroups.put(host, eventLoopGroup);

                return true;
            } else {
                throw ...
            }
        }, context -> {
            return false;
        });
    }

    ...

    private Bootstrap configureClientBootstrap(Bootstrap bootstrap, EventLoopGroup eventLoopGroup, String host) { // [4]
        bootstrap.group(eventLoopGroup)
                 .channel(NioSocketChannel.class) // [5]
                 .remoteAddress(host, port)
                 .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
                 .option(ChannelOption.SO_LINGER, 0)
                 .option(ChannelOption.SO_KEEPALIVE, true)
                 .option(ChannelOption.SO_REUSEADDR, true)
                 .option(ChannelOption.TCP_NODELAY, true)
                 .handler(clientChannelInitializer);  // [6]

        return bootstrap;
    }

    public void stop() {
        eventLoopGroups.values().forEach(group -> group.shutdownGracefully().awaitUninterruptibly()); // [7]
        ...
    }
}

[1] 스케일아웃된 채팅 서버들의 IP목록을 레디스에서 가져옵니다.
[2] 해당 서버와 연결되지 않은 상태면 연결을 시도합니다.
[3] org.springframework.retry.support패키지에 있는 RetryTemplate을 이용합니다. 로직 실패(여기서는 연결 실패)시 일정 횟수를 일정 시간 간격으로 재시도하는데 유용합니다.
[4] WebServer에서의 설정과 유사합니다.
[5] 소켓 클라이언트를 만들기 위해 NioSocketChannel.class를 지정합니다.
[6] 핸들러를 등록하며 아래 ClientChannelInitializer에서 설명합니다.
[7] 종료시 등록했던 이벤트 루프 그룹들을 정리합니다.

Initializer

비지니스 로직을 담당할 핸들러와 코덱을 채널 파이프라인에 등록해주는 단계예요.

WebChannelInitializer

웹소켓으로 전달된 데이터는 주고 받는 과정에서 코덱으로 파싱되고 인증 및 비지니스 로직을 처리하는 핸들러를 거쳐요.

@Component
public class WebChannelInitializer extends ChannelInitializer<SocketChannel> { // [1]
    ... 

    @Override
    public void initChannel(SocketChannel ch) {
        ChannelPipeline pipeline = ch.pipeline();
        ...
        pipeline.addLast(new HttpServerCodec()) // [2]
                .addLast(new HttpObjectAggregator(65536)) // [3]
                .addLast(new CorsHandler(corsConfig)) // [4]
                .addLast(new WebSocketServerCompressionHandler())
                .addLast(new WebSocketServerProtocolHandler("/ws", null, true))
                .addLast(new IdleStateHandler(0, 0, 180)) // [5]
                .addLast(idleHandler) // [6]
                .addLast(healthEndpointHandler) // [7]
                .addLast(new WebPayloadDecoder()) // [8]
                .addLast(authHandler) // [9]
                .addLast(commandHandler); // [10]
    }
}

[1] 비지니스 로직을 처리할 여러 채널 핸들러를 등록하기 위해서 ChannelInitializer사용하여 파이프라인에 추가합니다.
[2] Netty에서 제공하는 여러 codec이 있습니다. 웹소켓으로 통신하므로 HttpServerCodec을 지정합니다.
[3] HTTP로 넘어온 content를 64KB까지 aggregation합니다.
[4] CorsConfigBuilder를 통해 몇 가지 설정을 추가한 빈입니다.
[5] 6번 항목에서 이벤트를 트리거받기 위해 IdleStateHandler를 추가했습니다. 3분동안 read/write에 대해 idle인 경우 트리거됩니다.
[6] 일정시간동안 트래픽이 없어서 커넥션이 끊기는 것을 방지하기 위해 Keep-alive를 갱신할 목적으로 추가한 핸들러입니다.
구현내용은 ChannelDuplexHandler를 상속받고 userEventTriggered이벤트에서 IdleState를 체크한 뒤 핑을 날려주게 됩니다.
[7] 헬스체크용 핸들러는 아래 링크를 참조했습니다.
https://github.com/netty/netty/blob/netty-4.1.44.Final/example/src/main/java/io/netty/example/http/websocketx/server/WebSocketIndexPageHandler.java
[8] Codec항목에서 설명합니다.
[9] 클라이언트의 모든 요청에 대해 인증체크하기 위한 핸들러를 등록했습니다.
[10] 서비스 비지니스 로직에 있어 가장 중요하고 집중할 커맨드 기반 핸들러를 등록했습니다. 이 부분은 Command항목에서 설명합니다.

SocketChannelInitializer

소켓 통신은 소켓 서버와 소켓 클라이언트간에 메시지를 포워딩하게 돼요.
따라서 소켓간 통신을 위해 메시지 포맷을 정의하고 그에 맞게 변환이 필요하고요.
일관된 비지니스 로직을 처리하도록 하기 위해 웹소켓 통신때와 마찬가지로 파싱된 Payload는 CommandHandler로 흘러가게 돼요.

@Component
public class SocketChannelInitializer extends ChannelInitializer<SocketChannel> {
    ...

    @Override
    public void initChannel(SocketChannel ch) {
        ChannelPipeline pipeline = ch.pipeline();
        ...
        // [1]
        pipeline.addLast(new JsonObjectDecoder(65536))
                .addLast(new StringDecoder(CharsetUtil.UTF_8))
                .addLast(new PayloadDecoder())
                .addLast(new WebPayloadDecoder())
                .addLast(new SocketResponseEncoder(CharsetUtil.UTF_8))
                .addLast(authHandler)
                .addLast(commandHandler);
    }
}

[1] 소켓간 통신시 데이터 포맷은 json을 사용하고 있고, 이 raw데이터를 서비스 내에서 약속한 Payload포맷으로 변환하여 CommandHandler로 흘려줍니다.

ClientChannelInitializer

소켓 클라이언트도 핸들러를 등록해요.

@Component
public class ClientChannelInitializer extends ChannelInitializer<SocketChannel> {
    ...

    @Override
    public void initChannel(SocketChannel ch) {
        ch.pipeline()
          .addLast(new PayloadEncoder(CharsetUtil.UTF_8))
          .addLast(socketClientHandler);
    }
}

Codec

네트워크 통신으로 전달된 바이너리 데이터를 특정 포맷으로 변환하기 위해서 코덱을 정의해 줘야 해요.
들어오는 데이터는 디코더로 변환하고, 나가는 데이터는 인코더로 변환이 필요하거든요.
그 과정에서 생성한 커스텀 코덱 중 몇 개를 추려봤어요.
저희는 Payload라는 공통 데이터 포맷을 정의했고, 상황에 따라 디코딩, 인코딩하고 있는데요.
Netty에서 다양한 포맷에 대한 코덱 클래스(io.netty.handler.codec 패키지)를 정의해주고 있어서 이를 활용해 입맛에 맞는 구현체를 개발할 수 있어요.

@Sharable
public class PayloadDecoder extends MessageToMessageDecoder<String> {

    @Override
    protected void decode(ChannelHandlerContext ctx, String msg, List<Object> out) {
        ...
        Payload payload = MapperUtil.readValueOrThrow(msg, Payload.class); // [1]
        out.add(payload);
        ...
    }
}
@Sharable
public class PayloadEncoder extends MessageToMessageEncoder<Payload> {
    ...

    @Override
    protected void encode(ChannelHandlerContext ctx, Payload payload, List<Object> out) throws IOException {
        Payload convertedPayload = MapperUtil.readValueOrThrow(payload, Payload.class);
        ...
        out.add(ByteBufUtil.encodeString(ctx.alloc(), CharBuffer.wrap(new JSONObject(convertedPayload).toString()), charset)); // [2]
    }
}

[1] MapperUtil은 저희가 자주 사용하는 ObjectMapper를 더 쉽게 사용할 수 있게 감싼 유틸리티성 클래스입니다.
[2] 인코딩은 Netty의 ByteBufUtil로 쉽게 인코딩할 수 있고, 인코딩할 데이터인 json포맷은 CharBuffer로 래핑해서 넘겨줍니다.


커맨드

지금까지 Netty를 이용해 채팅 서버 구조를 만들어줬다면 이제부터는 비지니스 로직에 집중하는 부분이에요. Restful API가 URI로 리소스에 대한 요청/응답을 처리하는 것처럼, TCP환경인 채팅에서는 커맨드를 정의해서 로직을 처리할 엔드포인트를 찾아가도록 했어요.

포맷

데이터를 공통된 포맷으로 맞추면 코덱을 여러개 만들 필요없어서 간편한데요.
띠잉에서 정의한 Payload는 아래와 같아요. 심플하죠?

...
public class Payload implements Serializable {
    private String token; // [1]
    private Command command; // [2]
    private Object body; // [3]
}

[1] 인증 정보를 확인하기 위한 토큰입니다.
[2] 어떤 요청인지 구분하기 위한 커맨드입니다.
[3] 실제 데이터이며, 커맨드에 따라 요구되는 포맷이 달라집니다.

커맨드 종류

현재 커맨드는 20여개 넘게 정의돼 있는데요.
커맨드 네이밍에 대해 잠깐 다른 얘기도 해보면, DIRECT_ prefix가 붙은 것들은 1:1채팅에서 사용하는 커맨드를 뜻하는 네이밍이고요. 그렇지 않은 것들은 향후 그룹채팅으로 확장돼도 그대로 사용할 수 있는 커맨드임을 의미해요.
애초에 설계할때부터 그룹채팅을 염두하고 개발했기 때문에 이런 네이밍을 짓는데도 도움이 됐던 것 같아요.

public enum Command implements IdentityComparable<Command> {
    ...
    CONNECT(false),
    DIRECT_CHAT_ROOM_CREATE(false),
    CHAT_LOG_SEND(false),
    CHAT_LOG_RECEIVE(true),
    ...
    ;

    @Getter
    private final boolean isReceive; // [1]
    ...

[1] isReceive필드가 true인 것들은 웹 클라이언트에서 요청온 게 아닌, 서버to서버로 메시지가 포워딩돼서 들어오는 커맨드를 구분하기 위함입니다.

Validation

아래 CommandHandler를 보기 전에 유효성 체크부분부터 보도록 할게요.
커맨드마다 요구되는 데이터 포맷의 유효성 체크를 해줄 필요가 있어요. 이를 일반화하면 비지니스 로직을 담은 모든 메소드내에서 매번 유효성 체크해야 하는 지저분한 코드가 상당부분 제거되어 실수를 방지할 수 있고 보기도 좋아져요.
validation은 크게 두 가지 항목으로 진행이 되는데요.
하나는 데이터 클래스의 각 필드들에 명시한 제약조건에 대한 validation이고요.
다른 하나는 채팅 메시지 포맷에 대한 validation이에요.

ValidationService

@Service
public class ValidationService {
    ...

    public <T> T validateAndGet(Object obj, Class<T> clazz) throws IOException { // [1]
        T value = MapperUtil.readValueOrThrow(obj, clazz);

        validate(value);

        return value;
    }

    private <T> void validate(T value) {
        Set<ConstraintViolation<T>> violations = validator.validate(value); // [2]
        if (!violations.isEmpty()) {
            ConstraintViolation<T> violation = violations.stream().findFirst().orElse(null);

            throw ...
        }
    }

    public void validateChatLog(ChatLogSendReq req) { // [3]
        ...

        Class<?> contentValidationClass = req.getType().getContentValidationClass(req.getVersion());
        if (contentValidationClass != null) {
            Object result = MapperUtil.readValue(req.getContent(), contentValidationClass);
            try {
                validate(result);
            } catch (Exception e) {
                throw ...
            }
        }
    }

    ...
}

[1] 데이터를 정의한 클래스의 각 필드에 대한 제약조건(javax.validation.constraints 패키지)을 체크합니다.
[2] validator는 아래와 같이 javax.validation에 있는 Validation에 HibernateValidator.class를 프로바이더로 지정해서 썼습니다.

@Configuration
public class ValidatorConfig {

    @Bean
    public Validator validator() {
        ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
                                                      .configure()
                                                      ...
                                                      .buildValidatorFactory();

        return validatorFactory.getValidator();
    }
}

[3] 채팅 메시지는 종류를 매우 다양하게 가질 수 있습니다. 단순하게는 텍스트가 되겠고, 나아가면 이미지, 영상 또는 서비스 도메인에 따라 다양한 디자인 요소가 적용된 메시지로 확장될 수 있습니다. 따라서 채팅 메시지 포맷은 요구사항에 따라 계속해서 늘어나게 됩니다. 서로 다른 메시지 형식을 가질 수 있기 때문에 결국 각각의 메시지 형태에 따른 유효성 체크가 필요하게 됩니다. 그래서 ChatLogSendReq클래스 정의가 중요합니다. (여기서는 채팅 메시지를 챗로그로 네이밍합니다.)
또한 각 채팅 메시지마다 타입을 가지게 되고, 같은 타입도 버전에 따라 다르게 유효성 체크가 돼야 합니다.

public class ChatLogSendReq {
    ...

    @NotBlank(message = REQUIRED_CODE)
    @Size(max = CHAT_LOG_MAX_LENGTH, message = CHAT_LOG_MAX_LENGTH_CODE)
    private String content;

    @NotNull(message = REQUIRED_CODE)
    private ChatLogType type;

    @Min(value = MIN_CHAT_LOG_VERSION, message = CHAT_MIN_VERSION_CODE)
    private int version;
    
    ...
}

같은 채팅 메시지 타입도 버전에 따라 다른 포맷을 가질 수 있다는 점은, 유효성 체크를 다르게 챙겨야 한다는 것과도 같습니다.
그래서 메시지마다 아래와 같이 enum을 정의해줬습니다.
예를들어 THIIING_CONTENTS(띠잉앱에서 촬영한 콘텐츠를 친구에게 전달하는 타입)란 메시지 타입이 현재는 하나인데, 꽤 큰 확률로 요구사항이 바뀌어서 새로운 타입을 가질 수 있게 되면 클라이언트의 하위호환성을 지키기 어려워지게 됩니다.
이럴 때 아래 THIIING_CONTENTS의 Map 데이터에 버전을 올려주고 새로운 메시지 포맷을 가진 클래스를 추가해주면 하위호환성을 지킬 수 있는 확장 가능한 구조를 갖게 됩니다. 더불어 해당 버전의 메시지 타입에 따라 유효성 체크도 일반화할 수 있습니다.

public enum ChatLogType implements Findable<Integer>, IdentityComparable<ChatLogType> {
    TEXT(1, Map::of),
    ...
    THIIING_CONTENTS(100, () -> {
        return Map.of(0, ThiiingContentsChatLogContent.class);
    }),
    ;

    private final int value;
    private final Map<Integer, Class<?>> contentValidationClass;

    ChatLogType(int value, Supplier<Map<Integer, Class<?>>> versionSupplier) {
        this.value = value;
        this.contentValidationClass = versionSupplier.get();
    }

    public Class<?> getContentValidationClass(int version) {
        return contentValidationClass.get(version);
    }

CommandHandler

Netty는 이벤트 콜백 방식이라 각각의 구현 핸들러들은 Netty에서 이미 만들어 놓은 핸들러를 상속받는 것으로 비지니스로직에 집중할 수 있도록 돼 있어요.
Payload를 정의했기 때문에 실제 커맨드를 소비하는 쪽에서는 코드의 일관성을 가질 수 있게 돼요.
커맨드 별 요청포맷을 클래스로 정의하고 이에 대한 유효성 체크를 한 뒤 실제 비지니스 로직은 서비스레벨로 넘기는 구조예요.

@Component
@Sharable // [1]
public class CommandHandler extends SimpleChannelInboundHandler<Payload> { // [2]
    ...

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Payload payload) throws IOException {
        ...

        switch (payload.getCommand()) {
            case CONNECT:
                response = connectionService.connect(ctx.channel(), payload.getToken(), validationService.validateAndGet(payload.getBody(), ConnectReq.class));
                break;
            ...
            case CHAT_LOG_SEND:
                response = chatService.sendChatLog(validationService.validateAndGet(payload.getBody(), ChatLogSendReq.class)); // [3]
                break;
            ...
        }

        ...
        channelService.sendResponse(response, ctx.channel().id()); // [4]
        ...
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        ...
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable throwable) {
        ...
    }

[1] 이 빈을 채널 파이프라인에 추가하기 위해서 @Sharable을 지정합니다.
[2] InboundHandler로 데이터를 전달받습니다.
[3] Payload의 실제 데이터에 해당하는 body를 해당 요청 포맷을 정의한 클래스와 유효한지 체크한 뒤 변환된 값을 파라미터로 넘깁니다.
[4] 연결된 유저의 채널정보를 이용해 메시지를 전달합니다.


보안

띠잉 채팅 서비스는 보안에 있어서는 개발자가 불편하더라도 최대한 보수적으로 접근하자는 공감대를 가지고 있어요.

임시 토큰

저희 서비스는 OAuth2를 이용한 토큰 방식의 인증을 취하고 있고요. 채팅에서도 인증처리는 필수이죠.
이 때 기존에 사용하고 있던 인증토큰을 그대로 활용하는 것이 가장 손쉬운 방법인데요. 저희는 그렇게 하고 있지 않아요.
앱 내 인증 토큰과 채팅용 인증 토큰을 분리해서 사용중이고, 채팅에서의 토큰은 임시로 발급한 토큰을 사용하는 것으로 정했어요.
최악의 경우 어느 한 쪽의 토큰이 노출되더라도 그 영향이 다른 곳으로 전파되는 걸 막을 수도 있거든요.
또한 채팅은 매우 개인적인 도메인이기 때문에 가능하다면 토큰을 분리 관리하는 편이 좀 더 나은 방식이라고 봤고요.

데이터

접근 제한

채팅 운영DB에 대한 접근 권한은 최소한의 인원으로 제한돼 있어요. DB스키마는 운영중에 수정이 필요하기 때문에 피치 못하게 접근하게 되더라도, 그마저도 항상 페어로 작업할 수 있도록 문화를 가져가고 있고요.
또한 보통 개발자들이 편하게 작업하기 위해서 로컬 개발환경에서 운영DB에 붙는 걸 허용하는 경우들이 있는데요. 저희는 이를 원천적으로 차단하고 있어요. 아주 당연하고 단순한 원칙이지만 지키지 못하는 곳들이 꽤 있거든요.

대화내용 마스킹

운영DB에 대한 접근을 극도로 제한하고 있지만 불가피하게 스키마 변경을 한다든가 할 때 접속하더라도, 유저의 대화가 담긴 필드는 *로 마스킹되어 기본적으로 볼 수 없도록 하고 있어요.

로깅

저희가 운영DB에 접근할 일이 거의 없는 가장 큰 이유는 로그를 충분히 잘 정리했기 때문이에요. QA나 CS로 이슈업이 되면 로그만으로도 충분히 트래킹이 가능하거든요. 물론 로그에는 유저의 대화내역이 “FILTERED”로 대체되기 때문에 노출되지 않고 있고요.


문서화

클라이언트 입장에서 세분화된 여러 커맨드들을 적재 적소에 호출하기 위해서는 가이드가 반드시 필요해요.
API설계 때보다 더 자세한 의도를 문서로 남겨서 공유돼야만 의도된 설계가 빛을 볼 수 있기 때문이에요.
덕분에 웹뷰를 개발하는 동료 서버개발자(ㅠㅠ)와 각 커맨드별 설계 의도를 논의하고 수정하는 작업이 밀도있게 진행됐어요.
문서는 기본적으로 커맨드 중심으로 작성하되, 아래 내용을 필수로 넣었어요. 특히 ‘요청 타이밍’과 ‘참고, 자세한 설명’ 부분은 커맨드의 컨텍스트를 이해하기 위한 부분이므로 꽤 신경써서 작성했고요.

  • 간략한 설명
  • 요청 타이밍
  • 요청 필드
  • 응답 필드
  • 응답 예
  • 참고, 자세한 설명

마무리

Netty를 이용한 채팅 서버 구축 경험의 바탕을 공유하는 것이 핵심이다보니, 아무래도 채팅 본연의 비지니스 로직이 담긴 서비스레벨까지 설명하기에는 내용이 너무 길어지고 정신없어지는 느낌이 들어 제외했어요. 비지니스 로직을 설명하기 위해서는 데이터가 밀접하게 연결되다 보니 이 글에서 다 담기에도 부담스러운 점이 있었고요. 뿐만 아니라 코드도 설명에 필요한 부분이 아니면 지우는 편이 낫다보니 그 과정에서의 고민의 흔적을 온전히 담지 못한 아쉬움도 드네요. 최대한 주제에 맞는 핵심을 선별하느라 애쓴 부분임을 이해해주세요. 부족한 내용이었지만 채팅 시스템을 구축하시는 분들께 조금이라도 도움이 되길 바랄게요.


광고

실제 동작이 궁금하신 분들은 띠잉앱을 설치해서 사용해보세요 :)
IOS
Android

띠잉 공식 SNS도 운영하고 있어요.
Instagram
Facebook