-
[세미나][Netty] 네트워크 프로그래밍 3.세미나 2023. 4. 23. 00:25
자바 네트워킹
소켓 라이브러리
최초의 자바 API의 소켓 라이브러리는블로킹 함수만 지원한다.
한 번에 한 연결만 처리하기 때문에 새로운 클라이언트 소켓마다 새로운 스레드를 할당해야 한다.
- 여러 스레드가 입력이나 출력 데이터가 들어오기를 기다리며 무한정 대기상태로 유지될 수 있어 자원낭비로 이어질 가능성이 높다.
- 각 스레드가 스택 메모리를 할당해야 하는데, 운영체제에 따라 다르지만 스택의 기본 크기는 64KB ~ 1MB까지 차지할 수 있다.
- JVM이 물리적으로 아주 많은 수의 스레드를 지원할 수 있지만, 동시 접속이 한계에 이르기 훨씬 전부터 컨텍스트 전환에 따른 오버헤드가 심각한 문제가 될 수 있다.
- 이러한 동시성 처리 방식도 클라이언트 수가 적다면 고려해볼 만하지만 10만 이상의 동시 연결을 지원해야 할 때는 처음부터 배제하는 것이 바람직하다.
자바 NIO
2002년 소켓 라이브러리에 자바 NIO 도입으로 논블로킹 함수 지원한다.
- Selector는 논블로킹 입출력 구현의 핵심으로서, 논블로킹 Socket의 집합에서 입출력이 가능한 항목을 지정하기 위해 이벤트 통지 API를 이용한다.
- Selector가 있음으로써 언제든지 읽거나 쓰기 작업의 완료 상태를 확인할 수 있으므로 한 스레드로 여러 동시 연결을 처리할 수 있다.
- 블로킹 입출력 모델에 비해 전체적으로 훨씬 개선된 리소스 관리 효율을 보여준다.
- 적은 수의 스레드로 더 많은 연결을 처리할 수 있으므로 메모리 관리와 컨텍스트 전환에 따르는 오버헤드가 감소한다.
- 입출력을 처리하지 않을 때는 스레드를 다른 작업에 활용할 수 있다.
Netty Network Framwork
자바 NIO를 올바르고 안전하게 하기는 아주 어렵다.
그래서 Netty Framwork가 나옴으로써 간단한 코드 작성만으로 안정적이고 빠른 네트워크 어플리케이션을 개발할 수 있게 되었다.
Netty Network Framwork는 유지 관리가 용이한 고성능 프로토콜 서버와 클라이언트를 신속하게 개발할 수 있도록 하는 비동기 이벤트 기반 네트워크이다.
비동기 + 이벤트 기반 특징
- 바로 발생하는 이벤트에 대해 언제든지, 그리고 순서에 관계없이 응답할 수 있다는 것이다.
네티를 이용한다면?
- 논블로킹 네트워크 연결 -> 작업 완료를 기다릴 필요가 없게 해준다.
- 비동기 입출력 -> 즉시 반환하며 작업이 완료되면 직접 또는 나중에 이를 통지한다.
- 셀렉터 -> 적은 수의 스레드로 여러 연결에서 이벤트를 모니터링할 수 있게 해준다.
=> 논블로킹을 이용하면 블로킹보다 더 많은 이벤트를 훨씬 빠르고 경제적으로 처리할 수 있으며, 네티 설계의 핵심이다.
네티의 핵심 컴포넌트
- Channel
- 콜백
- Future
- 이벤트와 핸들러
=> 이 컴포넌트가 협력해 네트워크에서 발생하는 이벤트에 대한 알림을 제공하고 이벤트를 처리할 수 있게 도와준다.
Channel
- 자바 NIO의 기본 구조
- 하나 이상의 입출력 작업을 수행할 수 있는 하드웨어 장치, 파일, 네트워크 소켓, 프로그램 컴포넌트와 같은 엔티티에 대한 열린 연결
- 즉, 인바운드 데이터와 아웃바운드 데이터를 위한 운송수단
- 열거나 닫고 연결하거나 연결을 끊을 수 있다.
콜백
- 다른 메서드로 자신에 대한 참조를 제공할 수 있는 메서드
- 관심 대상에게 작업 완료를 알리는 가장 일반적인 방법 중 하나이다.
public class Handler extends ChannelInboundHandlerAdapter { @Override public void channelActive(ChannelHandlerContext ctx) { System.out.println("Client " + ctx.channel().remoteAddress() + " connected"); } }
네티는 이벤트를 처리할 때 내부적으로 콜백을 이용한다.
위 예제 코드는 클라이언드와 서버의 새로운 연결이 이뤄지면 채널 핸들러 콜백인 채널 액티브가 호출되며 여기에서 메시지를 출력한다.
이 코드처럼 콜백이 트리거되면 채널핸들러 인터페이스의 구현을 통해 이벤트를 처리할 수 있다.
Future
- 작업이 완료되면 이를 애플리케이션에 알리는 한 방법
- 미래의 어떤 시점에 작업이 완료되면 그 결과에 접근할 수 있게 해줌.
- 네티에는 이 Future 컴포넌트가 있기 때문에 비동기식이며, 이벤트 기반이라고 할 수 있음.
Channel channel = ...; ChannelFuture future = channel.connect(new InetSocketAddress("192.168.0.1", 25); future.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) { if(future.isSuccess()) { ByteBuf buffer = Unpooled.copiedBuffer("Hello", Charset.defaultCharset()); ChannelFuture wf = future.channel().writeAndFlush(buffer); ..... } else { Throwable cause = future.cause(); cause.printStackTrace(); } } });
위 예제 코드를 살펴보자.
먼저 서버로 비동기 연결한 다음, connect() 호출로 반환된 ChannelFuture를 이용해 채널 퓨처 리스너로 등록한다.
등록된 리스너는 서버와의 연결이 이루어지면 콜백 메서드인 operationComplete가 호출되어, 작업이 정상적으로 완료됐는지, 오류가 발생했는지 확인한다.
여기서 ChannelFutureListener는 더 정교한 버전의 콜백이라고 생각할 수 있다.
실제로 콜백과 퓨처는 상호 보완적 메커니즘이고 둘의 조합을 통해 네티의 핵심 구성요소 중 하나를 형성한다.
이벤트와 핸들러
- 네티는 작업의 상태 변화를 알리기 위해 고유한 이벤트를 이용하며, 발생한 이벤트를 기준으로 적절한 동작을 트리거할 수 있음.
- 로깅
- 데이터 변환
- 흐름 제어
- 애플리케이션 논리
- 모든 이벤트는 핸들러 클래스의 사용자 구현 메서드로 전달할 수 있음.
실습 - Echo 서버/클라이언트 구현
Echo 서버
- 하나 이상의 ChannelHandler : 클라이언트로부터 받은 데이터를 서버측에서 처리하는 비즈니스 논리를 구현
- 부트스트랩 : 서버를 구성하는 시동 코드를 의미, 최소한 서버가 연결 요청을 수신하는 포트를 서버와 바인딩하는 코드가 있어야 함.
Echo 서버는 들어오는 메시지에 반응해야 하므로 인바운드 이벤트에 반응하는 메서드가 정의된 ChannelInboundHandler 인터페이스를 구현해야 한다.
- channelRead() : 메시지가 들어올 때마다 호출됨.
- channelReadComplete() : channelRead()의 마지막 호출에서 현재 일괄 처리의 마지막 메시지를 처리했음을 핸들러에 통보함.
- exceptionCaught() : 읽기 작업 중 예외가 발생하면 호출됨.
서버 핸들러
@Component @ChannelHandler.Sharable // 여러 채널에서 핸들러를 공유할 수 있음 @RequiredArgsConstructor public class ServerHandler extends ChannelInboundHandlerAdapter { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { ByteBuf mBuf = (ByteBuf) msg; System.out.println("Server received: " + mBuf.toString(CharsetUtil.UTF_8)); ctx.write(mBuf); } @Override public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { ctx.writeAndFlush(Unpooled.EMPTY_BUFFER) .addListener(ChannelFutureListener.CLOSE); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { cause.printStackTrace(); ctx.close(); // 채널 닫음. } }
클라이언트에서 보낸 메시지를 수신하면 수신된 메시지를 처리하기 위해 channelRead가 호출되어 콘솔에 로그를 출력하고, 메시지 수신을 다하면 아래 channelReadComplete가 호출되면서 받은 메시지를 클라이언트로 다시 보내면서 채널을 닫는다. 그리고 메시지 수신 중에 예외가 발생하게 되면 exceptionCaught가 호출되면서 예외 스택 추적을 출력하고, 채널을 닫는다.
서버 부트스트랩
- 서버가 수신할 포트를 바인딩하고 들어오는 연결 요청을 수락.
- ChannelHandler 인스턴스에 인바운드 메시지에 대해 알리도록 Channel을 구성.
@Component @RequiredArgsConstructor public class NettyServerSocket { private final int port = 9001; public void start() throws InterruptedException { final ServerHandler serverHandler = new ServerHandler(); EventLoopGroup group = new NioEventLoopGroup(); try { ServerBootstrap b = new ServerBootstrap(); b.group(group) // 이벤트 루프 설정 .channel(NioServerSocketChannel.class) // 소켓 입출력 모드 (논블로킹) .localAddress(new InetSocketAddress(port)) // 소켓 주소 설정 .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(serverHandler); } }); ChannelFuture f = b.bind().sync(); f.channel().closeFuture().sync(); } catch (InterruptedException e) { group.shutdownGracefully().sync(); } } }
먼저 서버 부트스트랩 인스턴스를 생성한다.
Group 함수를 통해 NioEventLoopGroup이 새로운 연결을 수락 및 처리하도록 지정하고, 채널 유형을 논블로킹 모드인 NioServerSocketChannel로 지정한다.
포트는 9001로 지정하면 서버는 이 주소로 바인딩하고 새로운 연결 요청을 수신한다.
앞에서 구현한 핸들러를 channelpipeline에 추가한다.
서버를 바인딩하고 서버의 채널이 닫힐 때까지 기다린다.
마지막으로 eventLoopGroup을 종료하고 생성된 스레드를 포함해 모든 리소스를 해제한다.
2023.03.22 - 2주차 세미나에서 위 코드를 보시고 비동기인데 왜 동기인 sync()를 사용하냐는 질문이 나왔었다.
ChannelFuture f = b.bind().sync();
위 코드에서 sync()를 사용한 이유는 서버 소켓 채널이 바인딩되고 연결을 수락할 준비가 될 때까지 대기해야하기 때문에 서버 소켓이 바인딩되기 전에 다른 코드가 진행되는 것을 막기 위해 사용된다.
f.channel().closeFuture().sync();위 코드는 서버 소켓이 클라이언트 연결을 수락하고, 클라이언트와의 통신이 완료되면 서버 소켓이 닫힐 때까지 기다리는 역할을 한다. 이렇게 서버 소켓이 닫힐 때까지 대기하는 것은 서버가 정상적으로 종료되거나 예외가 발생했을 때 적절한 종료 처리를 할 수 있도록 하기 위함이다.
sync()를 사용함으로써 서버 소켓이 정상적으로 종료되기 전에 프로그램이 종료되는 것을 방지하고, 서버 소켓의 종료 처리를 확실하게 할 수 있도록 도와준다.
Echo 클라이언트
- 서버로 연결
- 메시지를 하나 이상 전송
- 메시지마다 대기하고 서버로부터 동일한 메시지를 수신
- 연결을 닫음.
Echo 클라이언트 역시 서버와 비슷하게 데이터를 처리할 SimpleChannelInboundHandler 인터페이스를 구현해야 한다.
- channelActive() : 서버에 대한 연결이 만들어지면 호출됨.
- channelRead0() : 서버로부터 메시지를 수신하면 호출됨.
- exceptionCaught() : 처리 중 예외가 발생하면 호출됨.
클라이언트 핸들러
@Component @ChannelHandler.Sharable // 여러 채널에서 핸들러를 공유할 수 있음 @RequiredArgsConstructor public class ClientHandler extends SimpleChannelInboundHandler<ByteBuf> { @Override public void channelActive(ChannelHandlerContext ctx) { String sendMessage = "Hello,Netty!"; ctx.writeAndFlush(Unpooled.copiedBuffer(sendMessage, CharsetUtil.UTF_8)); } @Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception { System.out.println("Client received: " + msg.toString(CharsetUtil.UTF_8)); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { cause.printStackTrace(); ctx.close(); } }
서버와 클라이언트의 연결이 이루어져 알림을 받으면 channelActive가 호출되어 서버로 메시지를 전송한다.
서버에서 메시지를 보내면 channelRead0가 호출되면서 콘솔에 로그를 출력한다.
그리고 메시지 수신 중에 예외가 발생하게 되면 exceptionCaught가 호출되면서 예외 스택 추적을 출력하고, 채널을 닫는다.
클라이언트 부트스트랩
@Component @RequiredArgsConstructor public class NettyClientSocket { private final String host = "localhost"; private final int port = 9001; public void start() { EventLoopGroup group = new NioEventLoopGroup(); try { Bootstrap b = new Bootstrap(); b.group(group) .channel(NioSocketChannel.class) .remoteAddress(new InetSocketAddress(host, port)) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new Handler()); } }); ChannelFuture f = b.connect().sync(); f.channel().closeFuture().sync(); } catch(InterruptedException e) { e.printStackTrace(); } } }
먼저 부트스트랩 인스턴스를 생성한다.
클라이언트 이벤트를 처리할 EventLoopGroup을 지정하고, 채널 유형을 논블로킹 모드인 NioServerSocketChannel로 지정한다.
서버 주소로 연결하기 위해 호스트와 포트 번호를 매개변수로 전달한다.
그리고 채널이 생성될 때 앞에서 구현한 핸들러를 channelpipeline에 추가한다.
그다음 서버로 연결을 하고 채널이 닫힐 때까지 기다린다.
마지막으로 eventLoopGroup을 종료하고 생성된 스레드를 포함해 모든 리소스를 해제한다.
네티 컴포넌트
Channel 인터페이스
- 자바 기반 네트워크에서 Socket 클래스와 비슷
- Socket으로 직접 작업할 때의 복잡성을 크게 완화하는 API를 제공.
- 다수의 미리 정의된 특수한 구현을 포함하는 광범위한 클래스 계층의 루트
EventLoop 인터페이스
- 연결의 수명주기 중 발생하는 이벤트를 처리하는 네티의 핵심 추상화를 정의
위 그림은 채널과 eventLoop, 스레드, eventLoopGroup 간의 관계를 대략적으로 보여준다.
- 하나의 eventLoopGroup은 하나 이상의 eventLoop를 포함합니다.
- 한 eventLoop는 수명주기 동안 한 Thread로 바인딩됩니다.
- 한 eventLoop에서 처리되는 모든 입출력 이벤트는 해당 전용 Thread에서 처리됩니다.
- 한 channel은 수명주기 동안 한 EventLoop에 등록할 수 있습니다.
- 한 EventLoop를 하나 이상의 채널로 할당할 수 있습니다.
(솔직히 위 내용은 이해가 안된다. 찾아봐도 위 내용과 똑같은 내용만 적혀있을 뿐 더 이상의 설명은 찾아볼 수 없었다.)
ChannelFuture 인터페이스
- 네티의 모든 입출력 작업은 비동기적이기 때문에 작업이 즉시 반환되지 않을 수 있으므로 나중에 결과를 확인하는 방법이 필요한 데 ChannelFuture 클래스가 이 역할을 함.
ChannelHandler 인터페이스
- 인바운드와 아웃바운드 데이터의 처리에 적용되는 모든 애플리케이션 논리의 컨테이너 역할
ChannelPipeline 인터페이스
- ChannelHandler 체인을 위한 컨테이너를 제공하며, 체인 상에서 인바운드와 아웃바운드 이벤트를 전파하는 API 정의
=> 즉, 채널에서 발생한 이벤트가 이동하는 통로 역할
채널이 생성되면 여기에 자체적인 채널 파이프라인이 할당되고, 채널 핸들러는 채널 파이프라인 안에 설치된다.
위 그림은 네티 애플리케이션에서 인바운드와 아웃바운드 데이터 흐름의 차이를 보여준다.
클라이언트 관점에서 클라이언트에서 서버로 움직이는 이벤트는 아웃바운드이며, 그 반대는 인바운드이다.
'세미나' 카테고리의 다른 글
[세미나][Netty] 네트워크 프로그래밍 2. (0) 2023.02.09 [세미나][Netty] 네트워크 프로그래밍 1 (0) 2023.01.25