이 책을 공부하는 이유
대규모 트래픽을 좀 더 효율적으로 처리할 수 있는 서버를 개발하기 위해, 많은 사람들이 기존 Tomcat + Spring MVC 환경에서 Netty + Spring Webflux 전환을 고려하고 있다.
각각의 환경의 장단점이 있고 상호보완적으로 쓰일 수 있다.
Netty 란 무엇일까? 근본적으로 비동기+논블로킹+이벤트기반 프로그래밍이란 뭘까? 라는 물음에 답하기 위해 책을 찾아봤고 회사 선배의 추천을 받아 '자바 네트워크 소녀 Netty' 를 공부하게 되었다.
이 책의 내용을 기반으로 Netty의 동작원리, 개념에 대해 정리하고자 한다.
1장에서는 본격적으로 네티의 개념에 대해 알아보기 전, 간단한 echo 서버, 클라이언트를 구현해본다.
네티는 자바 네트워크 프레임워크로서 자바환경에서 네트워크 프로그래밍을 손쉽게 할 수 있게 해주며 고성능, 고안정성의 프로그램을 만들 수 있게 해준다.
네트워크 어플리케이션을 그냥 자바의 소켓 프로그래밍을 통해 구현하는 것에 비해 네티의 추상화로 인해 얼마나 편하고 간결하게 자바 네트워크 프로그래밍을 할 수 있는지를 보여준다.
네티는 이벤트를 Inbound 이벤트, Outbound 이벤트로 구분한다.
클라이언트로 부터 데이터 수신할 때 발생하는 이벤트에 관심이 있다면, Inbound 이벤트 중 하나인 '데이터 수신 이벤트'를 담당하는 메서드에 원하는 로직을 넣으면 된다.
네티는 기본적으로, 적절한 추상화 모델을 제공하여 개발자가 간단한 코드작성만으로 안정적이고 빠른 네트워크 어플리케이션을 개발할 수 있게 도와준다.
네티는 '네트워크 송수신' 을 추상화하기 위해 이벤트 모델을 정의했다.
Client ---------------------------------------------------------Server
Outbound -----> Send (몇시에요?) ----------> Inbound
Inbound <–--- (오후 8시) Response <----------- Outbound
네티의 주요 키워드
함수 또는 서비스의 호출방식
개념 : https://deveric.tistory.com/99
비동기호출을 지원하는 디자인 패턴
자바에서 블로킹 소켓 : ServerSocket, Socket
블로킹 소켓을 사용하면, 클라이언트가 서버로 연결요청을 할 때 서버가 연결을 수락하고 클라이언트와 연결된 소켓을 새로 생성하는데 이때 처리가 완료될 때 까지 스레드의 블로킹이 발생한다.
다중 클라이언트 처리를 위해 클라이언트 마다 소켓 생성후 그 소켓을 새로 생성된 스레드에 할당해주는 개선안이 사용된다. (클라이언트 마다 스레드 생성)
하지만 이 경우에도 결국은 ServerSocket 의 accept 메서드가 병목지점이 된다.
동시에 오는 여러 클라이언트의 요청을 처리해야하기 때문이다.
또한 다중 클라이언트 처리를 위해 쓰레드풀의 크기를 무한정 늘릴 수도 없다.
JDK 1.4 부터 NIO 라는 논블로킹 I/O API 가 추가되었음
소켓도 입출력 채널의 하나로서 NIO API 를 이용한다
블로킹 소켓과 논블로킹 소켓은 데이터 송수신을 위한 함수의 동작방식에 따른 분류.
자바에서 논블로킹 소켓 : ServerSocketChannel, SocketChannel
Java NIO 컴포넌트 중 하나인 Selector 는 자신에게 등록된 채널에 변경사항이 생겼는지 검사하고 발생한 채널에 대한 접근을 가능하게 해준다.
클라이언트가 아직 전송하지 않았거나 데이터가 수신 버퍼까지 도달하지 않았다면, read 메서드는 0을 리턴한다.
비동기 호출이나 논블로킹 소켓을 사용하면 필연적으로 프로그램 복잡도가 증가한다. 하지만 네티는 개발자가 기능구현에 집중하도록 프레임워크 레벨에서 복잡한 로직을 모아 API 로 제공한다.
소켓의 동작방식이 다르므로 입출력을 위한 메서드 및 프로그램 호출 구조가 다르다.
하지만 네티는 소켓의 모드와 상관없이 개발할 수 있도록 추상화된 전송 API를 제공한다. 따라서 소켓 모드를 바꾸기 위해서 데이터 송수신 부분의 로직을 고치지 않아도 된다.
각 이벤트를 정의해두고 이벤트가 발생했을 때 실행될 코드를 준비해둔다.
논블로킹 소켓의 Selector 를 사용한 I/O 이벤트 감지 및 처리도 이벤트 기반 프로그램의 한 종류이다.
얼마나 작은 단위로 이벤트를 나눌 것인지
이벤트 추상화가 너무 고수준이면 세부적 제어가 힘듦
너무 저수준이면 한 동작에 대해 너무 많은 이벤트가 발생하여 성능에 악영향
서버에 연결될 클라이언트 수는 매우 가변적이며 예측 불가하다.
이런 관점에서 서버에서 사용하는 이벤트 기반 프레임워크의 적절한 추상화 단위는 매우 중요하다.
이벤트를 발생시키는 객체와 발생될 이벤트 종류를 정의해야 한다.
네트워크 프로그램에서 이벤트 발생 주체는 '소켓' 이며, 이벤트 종류는 소켓연결, 데이터 송수신 이다.
소켓에 데이터를 직접 기록하고 읽으려면 소켓에 연결된 소켓채널(NIO) 또는 스트림을 사용해야 한다.
이벤트 핸들러 == 데이터 핸들러
네티는 데이터를 소켓으로 접근하기 위해 채널에 직접 쓰기/읽기 하지 않고 데이터 핸들러를 통한다.
'부트스트랩'은 네티로 작성한 네트워크 프로그램이 시작할 때 가장 먼저 수행됨.
하는일
1. 어플리케이션이 수행할 동작을 지정
2. 프로그램에 대한 각종 설정을 지정한다.
부트스트랩에서 설정할 수 있는 것들에는 무엇무엇이 있을까?
이벤트 루프에 대한 설정
채널 전송 모드에 대한 설정
채널 파이프라인에 대한 설정
예시) 네트워크에서 수신한 데이터를 단일 스레드로 DB 에 저장하는 프로그램 (8088 포트 사용, NIO 소켓모드 사용)
설정가능한 내용들
프로토콜은 채널 파이프라인에 등록되는 인코더/디코더 에서 처리한다
서버 어플리케이션을 위한 ServerBootstrap
클라이언트 어플리케이션을 위한 Bootstrap
부트스트랩을 사용하면 네트워크 어플리케이션 작성시 유연성을 얻을 수 있다.
이전 2장에서 나온 BlockingServer 와 NonBlockingServer 예시에서, 만약 소켓 채널 입출력 방식이 변경되어야 한다면 소스코드 수정할 양이 무지막지하게 많다.
반면 네티를 사용한다면, 데이터 처리하는 코드를 변경하지 않고 부트스트랩의 설정만 변경해주면 된다. 이는 소켓 채널에 대한 입출력을 우아하게 추상화했기 때문에 가능하다. 사용할 입출력 모드에 해당하는 소켓 채널 클래스를 설정하기만 하면 변경이 완료된다.
클라이언트는 연결 요청 완료 후 데이터 송수신 처리를 위해 하나의 이벤트 루프로 모든 처리를 한다.
서버는 연결 요청 수락을 위한 루프 그룹, 데이터 송수신 처리를 위한 루프 그룹 2가지가 필요하다.
부트스트랩 클래스를 통해 생성된 채널의 입출력 모드를 설정할 수 있다.
channel 메서드에 등록된 소켓 채널 생성 클래스가 소켓 채널을 생성한다.
설정가능한 클래스 목록
channel 메서드와 하는일은 동일하다.
네티가 제공하는 ChannelFactory 인터페이스의 구현체 : NioUdtProvider
서버 소켓 채널의 이벤트를 처리할 핸들러 설정
여기서 등록된 핸들러는 서버 소켓 채널에서 발생한 이벤트 만을 처리한다.
(데이터 송수신에 대한 이벤트는 여기서 등록된 핸들러에서 처리되지 않음 -> 클라이언트 소켓 채널에 대한 이벤트는 childHandler 에서 처리한다)
만약 여기에 LoggingHandler 를 추가한다면, 아래 로그들이 찍힌다.
클라이언트 소켓 채널로 송수신되는 데이터를 가공하는 데이터 핸들러 설정 API
서버에 연결된 클라이언트 소켓 채널에서 발생하는 이벤트를 수신하여 처리한다.
서버 소켓 채널로 연결된 클라이언트 채널에 파이프라인을 설정하는 역할을 수행할 수 있다.
소켓 옵션 == 소켓의 동작방식 지정
네티의 부트스트랩에서 설정할 수 있는 소켓 옵션 == 자바에서 설정할 수 있는 소켓 옵션
자바에서 socket.send 메서드가 호출되면 커널의 시스템 함수를 호출한다. 시스템 함수는 어플리케이션에서 수신한 데이터를 송신용 커널 버퍼에 쌓아두었다가 인터넷을 통해 전송한다.
기본적으로 부트스트랩의 소켓옵션은 커널에서 사용하는 소켓관련 설정값들을 변경하는 것이다. 즉 Netty 를 통해 생성된 소켓들은 이런 소켓 설정값들을 사용하겠다는 의미이다.
주요 소켓 옵션들
서버에 접속한 클라이언트 소켓 채널에 대한 옵션을 설정
클라이언트 어플리케이션을 설정하는 Bootstrap 의 주요 API에 대한 설명
기본적으로 ServerBootstrap 과 같고 몇가지 측면에서 미세한 차이들을 같는다.
이는 필요할 때 살펴보면 되는 수준이다.
몇가지를 살펴보면, 클라이언트 에서 사용하는 단일 소켓 채널에 대한 설정이므로 부모 자식이라는 관계에 해당하는 API 들은 없다.
서버에 있는 채널은 2가지 종류
채널 파이프라인
이벤트 핸들러
코덱
네티는 네트워크 소켓에서 일어나는 여러가지 이벤트들을 '채널 파이프라인'과 '이벤트 핸들러' 로 추상화한다.
네티를 사용하면 데이터가 수신되었을 때의 메소드 호출이나 소켓의 연결이 끊겼을 때와 같은 예외처리 메소드 호출에 관여할 필요가 없다.
네티의 이벤트 루프가 소켓 채널에서 발생한 이벤트를 처리하는 메서드를 자동으로 실행해준다.
위와 같이 네티의 이벤트모델을 따르면 우리가 구현해야할 코드의 구분과 위치가 명확해지고 더 적은 코드로 튼튼한 네트워크 어플리케이션을 만들 수 있다.
채널과 이벤트 핸들러 사이에서 연결 통로 역할을 수행한다.
이벤트 핸들러는 채널 파이프라인에 등록된다.
채널 파이프라인은 자신에게 등록된 이벤트 핸들러들의 (순서가 있는) 묶음이다.
채널에서 발생한 이벤트는 채널 파이프라인을 따라 흐른다.
흐르는 이벤트들을 수신하고 처리하는 기능은 이벤트 핸들러가 수행한다.
하나의 채널 파이프라인에 여러 이벤트 핸들러를 등록할 수 있다.
네티는 이벤트 처리를 위한 추상화 모델로서 채널 파이프라인을 사용한다.
소켓 채널에서 발생한 이벤트가 이를 통로로 하여 이동한다.
public class EchoServer {
public static void main(String[] args) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) { // 클라이언트 소켓 채널이 생성될 때 실행됨
// 채널 파이프라인 설정
ChannelPipeline p = ch.pipeline();
p.addLast(new EchoServerHandler());
}
});
ChannelFuture f = b.bind(8888).sync();
f.channel().closeFuture().sync();
}
finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}
}
채널 생성과 채널 파이프라인 구성
소켓 채널에서 발생한 이벤트를 처리하는 인터페이스
채널 파이프라인에 등록되어 파이프라인으로 들어오는 이벤트를 이벤트루프가 가로채어 알맞은 메서드를 수행한다.
네티가 제공하는 이벤트의 종류, 발생시기(조건)를 잘 알아야한다.
네티는 비동기 호출을 지원하는 2가지 패턴을 제공함
이벤트 핸들러는 리액터 패턴의 구현체이다.
TODO : 리액터 패턴이란?
네티는 소켓 채널에서 발생하는 이벤트를 '인바운드 이벤트'와 '아웃바운드 이벤트'로 추상화한다.
인바운드 이벤트
네티는 인바운드 이벤트 핸들러를 ChannelInboundHandler 인터페이스로 제공한다.
채널이 이벤트루프에 등록되었을 때 발생
새로운 채널이 생성되는 시점에 발생
channelRegistered 이후에 발생
채널이 생성되고 이벤트 루프에 등록된 후, 네티 API 를 이용하여 채널 입출력을 수행할 상태가 되었음을 알려주는 이벤트
서버 또는 클라이언트가 상대방에 연결한 직후에 한번 수행할 작업을 처리하기에 적합
데이터가 수신되었음을 알려주는 이벤트
수신된 데이터는 ByteBuf 객체에 저장되어 있음
데이터 수신이 완료되었음을 알려주는 이벤트
채널의 데이터를 다 읽어서 더 이상 데이터가 없을 때 발생
채널이 비활성화 되었을 때 발생
이 이후에는 채널에 대한 입출력 작업을 수행할 수 없음
채널이 이벤트 루프에서 제거되었을 때 발생
이 이후에는 채널에서 발생한 이벤트를 처리할 수 없음
소켓 채널에서 발생한 이벤트 중 프로그래머가 요청한 동작에 해당하는 이벤트
ChannelHandlerContext 의 역할
- 채널에 대한 입출력 처리
- writeAndFlush 메서드로 채널에 데이터 기록
- close 메서드로 채널의 연결을 종료시킴
- 채널 파이프라인에 대한 상호작용
- 프로그래머에 의한 이벤트 발생
- 채널 파이프라인에 등록된 이벤트 핸들러의 조회 및 동적 변경
ex) 데이터 수신 이벤트 처리 메서드에서 오류가 발생했고 오류처리 공통로직이 exceptionCaught 이벤트 메서드에 작성되어 있다면, fireExceptionCaught 메서드를 호출할 수 있음. 채널 파이프라인으로 exceptionCaught 이벤트 전달
서버 소켓채널이 클라이언트의 연결을 받아들이는 ip,port 정보 설정시 발생
클라이언트 소켓채널이 서버에 연결되었을 때 발생
클라이언트 소켓채널의 연결이 끊어졌을 때 발생
클라이언트 소켓채널의 연결이 닫혔을 때
소켓채널에 데이터가 기록되었을 때 발생
소켓채널에 대한 flush 메서드 호출시 발생
채널 파이프라인에 여러 개의 이벤트 핸들러가 등록되어 있을 때의 동작에 대해 알아보자
네티에서 인코더는 전송할 데이터를 전송프로토콜에 맞춰 변환작업 수행하는 것
디코더는 반대작업 수행
수신 : 인바운드, ChannelInboundHandler == 디코더
송신 : 아웃바운드, ChannelOutboundHandler == 인코더
인코딩/디코딩은 어플리케이션 내부의 데이터를 각 프로토콜에 맞는 데이터로 변환하는 작업
ex) 네티가 제공하는 Base64Encoder를 채널 파이프라인에 등록되어 있다면, 채널에 데이터를 기록하면 ChannelOutboundHandlerAdapter의 write 이벤트 메서드가 수행된다.
네티는 자주 사용되는 프로토콜의 인코더, 디코더를 기본 제공한다.
기본 제공 코덱들은 인바운드와 아웃바운드 핸들러를 모두 구현했다.
-> 네트워크 입출력 프로토콜을 구현한 것
ex) HttpServerCodec 생성자에서 HttpRequestDecoder, HttpResponseEncoder 를 모두 생성
HttpRequestDecoder : 수신된 ByteBuf 를 HttpRequest + HttpContent 로 변환
HttpResponseEncoder : HttpResponse 객체를 ByteBuf 로 인코딩하여 송신
사용자가 직접 필요한 프로토콜을 구현하는 것 -> 필요에 따라 인바운드와 아웃바운드 핸들러를 구현한다.
네티 기본 제공 코덱과 사용자정의 코덱을 함께 채널 파이프라인에 등록해서 쓸 수 있다.
채널 파이프라인에 등록된 이벤트 핸들러의 순서가 중요하다.
대부분의 어플리케이션 로직이 이벤트 핸들러에 구현된다.
네티가 제공하는 '코덱'은 이벤트 핸들러의 일종임을 잊지 말자.
이벤트 루프 : 이벤트를 실행하기 위한 무한루프 스레드
이벤트 큐에 이벤트를 등록하고 이벤트 루프가 큐에 접근하여 처리한다.
이벤트 루프가 다중 스레드이면 한 큐를 여러 스레드에서 공유해서 쓴다.
스레드 종류에 따른 구분
처리한 이벤트의 결과를 돌려주는 방식
장점
단점
이벤트 발생순서에 따른 처리 순서를 보장한다.
어떻게?
네티는 이벤트처리를 위해 SingleThreadEventExecutor 를 사용한다
네티는 비동기 호출을 위한 두 가지 패턴을 제공
1. 리액터 패턴의 구현체인 이벤트 핸들러
2. 퓨처 패턴
ChannelFuture는 채널 I/O의 비동기 호출을 지원하고자 제공된다.
비동기 I/O 메서드 호출 결과로 ChannelFuture를 돌려 받고 이걸 통해 작업 완료 유무를 확인한다.
네티에서는 ChannelFuture 객체에 작업이 왼료되었을 때 수행할 채널 리스너를 설정할 수 있다.
네티는 몇 가지 기본 채널 리스너를 제공하며 ChannelFutureListener 인터페이스를 통해 사용한다.
자바가 버퍼 패키지를 제공함에도 네티는 내부에서의 데이터 이동, 입출력에 자체 버퍼 API 를 사용한다.
바이트 데이터를 저장하고 읽는 저장소
배열을 멤버 변수를 갖고 있고 배열에 대한 읽고 쓰기를 추상화한 메서드 제공
저장되는 데이터형에 따른 바이트 버퍼 종류
바이트 버퍼 클래스는 내부의 배열 상태를 관리하는 3가지 속성을가진다
allocate (힙 버퍼 생성)
allocateDirect (다이렉트 버퍼 생성)
wrap
*다이렉트 버퍼는 힙 버퍼에 비해 생성시간 길지만 더 빠른 읽기 쓰기 성능 제공
바이트 버퍼를 사용할 때는 항상 버퍼 크기 (capacity, limit) 에 유의하며 사용해야 한다.
쓰기 작업 완료 이후에 데이터의 처음 부터 읽을 수 있도록 현재 포인터의 위치를 변경
읽기->쓰기 또는 쓰기->읽기 작업의 전환을 할 수 있게 해준다.
하나의 바이트 버퍼에 대해 읽기 또는 쓰기 작업의 완료를 의미하는 flip 메서드를 호출하지 않으면 반대 작업을 수행할 수 없다.
그림) 자바 바이트 버퍼에 데이터 기록 후 flip 메서드 호출한 상태
자바 바이트 버퍼 사용시 읽기/쓰기를 분리하여 생각해야 함.
다중 쓰레드 환경에서 바이트 버퍼를 공유하지 않아야 함.
네티는 이런 자바 바이트 버퍼의 문제점을 해결하기 위해 읽기 인덱스와 쓰기 인덱스를 별도로 제공한다.
자바 바이트 버퍼에 비해 빠른 성능 제공
빈번한 바이트 버퍼 할당과 해제 부담 줄여 GC 부담 줄인다
네티 바이트 버퍼 특징
저장되는 데이터형에 따른 별도의 바이트 버퍼를 제공하지 않음
각 데이터형에 따른 읽기/쓰기 메서드 제공
각 읽기/쓰기 메서드 실행시 각자의 인덱스 증가시킴
flip 같은 메서드 호출없이 읽기/쓰기 작업 번갈아 수행할 수 있음 (작업전환 용이)
하나의 바이트 버퍼에 대해 읽기/쓰기 작업 병행 가능
프레임워크 레벨의 바이트 버퍼 풀 제공
네티의 바이트 버퍼를 바이트 버퍼 풀에 할당 하기 위해
네티 바이트 버퍼 생성시 고려할 것
네티 바이트 버퍼 생성 메서드의 인수에 버퍼 크기를 지정하지 않으면 기본값인 256 바이트가 설정됨
네티 바이트 버퍼는 자바 바이트 버퍼와 다르게 읽기, 쓰기 인덱스를 따로 관리하기 때문에 서로간 작업전환이 자유롭다.
네티는 생성된 버퍼의 크기를 동적으로 변경할 수 있다
ex) buf.capacity(N)
프레임워크에서 바이트 버퍼 풀을 제공
장점
풀링은 ByteBufAllocator 사용하여 바이트 버퍼 생성시 자동으로 수행됨
네티는 바이트 버퍼 풀링을 위해 바이트 버퍼에 참조 수를 기록함
참조수 관리를 위해 ReferenceCountUtil 클래스의 retain, release 메서드 사용
네티 바이트 버퍼의 기본 엔디안은 자바와 동일하게 빅 엔디안 이다.
nioBuffer 메서드를 사용하여 자바 NIO 버퍼로 변환 가능. 반대도 가능.
-> 변환된 NIO 바이트 버퍼는 네티 바이트 버퍼의 내부 바이트 배열을 공유
네티 내부에서 데이터 처리시 네티의 바이트 버퍼를 사용함
네티 바이트 버퍼 풀은 네티 어플리케이션의 서버 소켓 채널이 초기화될 때 같이 초기화되며 ChannelHandlerContext 인터페이스의 alloc 메서드로 생성된 바이트 버퍼 풀을 참조할 수 있다.
// 바이트 버퍼 풀을 관리하는 인터페이스
ByteBufAllocator bytebufAllocator = channelHandlerContext.alloc();
// ByteBufAllocator 의 풀에서 관리되는 바이트 버퍼. 사용 다되면 다시 풀로 돌아감.
ByteBuf newBuffer = bytebufAllocator.buffer();
// ### newBuffer 사용
// write 메서드 인수로 사용된 바이트 버퍼는 데이터를 채널에 기록하고 난 뒤 버퍼 풀로 돌아감.
channelHandlerContext.write(msg);
네티 바이트 버퍼 장점 다시 한번 요약
네티가 제공하는 바이트 버퍼 풀이 언제 생성되고 어떻게 유지되는지 이해하는 것이 메모리의 효율적 사용 측면에서 매우 중요하다.
글 너무 잘읽었습니다. 감사합니다.