java, NIO를 이용한 소켓 클라이언트 by 오리대마왕

*이 글은 개선이 많이 필요한 내용이라 조심하고 보세요*

안드로이드로 socket client를 만들 일이 필요해졌다. 처음엔 백그라운드 처리를 하려고 안드로이드 서비스까지 뒤적거렸으나 다행히 요구사항이 좀 단순하게 정리가 되어 서비스까진 아니고 액티비티의 thread 형태로만 구현하기로 했다.

되돌아보니 소켓 잠깐 열어서 전문 보내고, 응답오면 닫는 간단한 작업은 몇 번 했던 것 같은데 요번엔 영속적인 session을 유지해야 해서 신경써서 잘 만들어야 한다. 암, 잘 만들어야 하지.

소켓 클라이언트 만드는 방식은 전통적인 동기 api인 Socket을 이용한 방식 ( http://docs.oracle.com/javase/7/docs/api/java/net/Socket.html )  과, 1.4에 추가된 nio 패키지의 비동기 api인 SocketChannel을 이용한 방식( http://docs.oracle.com/javase/7/docs/api/java/nio/channels/SocketChannel.html ) 이 있다. 쓰고보니 nio 패키지가 추가된 게 1.4 니, 전통적인 방식이니 아니니 하는 구분은 이상하군. 둘 다 역사와 전통을 자랑합니다, 하하하.

제품 수준으로 만들려고 하니 바로 난관에 봉착했다. 이건 내 네트웍 프로그램 지식이 일천한 문제가 제일 크지만 하여간.
  • 대부분의 socket 예제들이 영속적인 connection이 아닌 echo server/client 수준이다. 이렇게 주고 받고 땡 하는 것과 주구장창 주고 받는 건 많이 다른 문제다.
  • 그나마 nio 관련 예제는 찾아보기도 힘들다. Socket 쓰는 예제는 워낙에 많으니 이런 저런 케이스의 예제들이 좀 보이는데, SocketChannel은 더 찾기 어렵다.

이 와중에 안드로이드를 지배하는 통신 프로그래밍이란 책이 많은 도움이 되었다. 참고로 yes24의 서평엔 별로란 글이 많으니 구매 할 생각이라면 한번 주변 서점에서 훓어보고 판단하시길. 내가 봤을 땐 돈 값어치는 분명히 하는 책이다. 적어도 내 문제에선 상당히 많은 실마리를 제공했다.

이 책에선 nio의 예제로 wifi keyboard를 사용한다. 나도 잘 쓰고 있는 안드로이드 앱이다. 앱 자체가 html server로 동작하고, 사용자가 이 서버에 접속해서 request를 날리면 server가 받아서 키보드 입력 효과를 내는 앱이다. 비록 여기선 client socket 이 아니라  server socket이지만, 영속적인 커넥션을 참조하려면 차라리 client socket 예제보다 server socket 예제를 찾아보는 게 나았다.

구글 코드 프로젝트이므로 브라우저 상에서 주요 코드를 바로 둘러볼 수 있다.(http://code.google.com/p/wifikeyboard/source/browse/#hg%2Fsrc%2Fcom%2Fvolosyukivan) 이 중 핵심은 HttpConnection, HttpServer 이다. HttpServer 는 Thread를 extends한 서버 클래스로, server socket 하나를 연 다음 while 문 돌면서 client socket accept 하고 HttpConnection을 이용해 읽고 쓰기 작업을 한다. HttpConnection 클래스는 버퍼를 이용해 읽고 쓰는 작업을 한다.

nio 방식은 Socket 클래스를 이용한 동기 방식보다 복잡하다. SelectionKey, Selector, SocketChannel 등이 필요하고, 버퍼도 ByteBuffer 라는 nio 전용 버퍼를 사용해야 한다. 하지만 하나의 쓰레드에서 작업을 처리할 수 있고 (selector 때문에 내부에서 쓰레드가 더 생기나? 거기까진 확인 안해서 모르겠다.), 읽고 쓰는 모드를 선택할 수 있으며 read 중에 블럭된 쓰레드를 중단하려면 interrupt 해서 예외를 발생시켜 끊어버려야 하는 방식보다 훨씬 자연스런 종료 구현이 가능하다. 제대로 된 동기 소켓 클라이언트를 만든 경험이 없어서 잘 모르겠는데, Socket을 쓸 땐 socket 유지를 위한 쓰레드, write 쓰레드, read 쓰레드 3개가 필요하지 않나? 또 현재 쓰기를 마친 후에 읽기를 한다 등의 쓰레드 간 교통정리가 필요하다면 초기 러닝커브는 좀 있지만 nio 방식이 더 깔끔한 코드를 만들 수 있으리라 생각한다.

실제 코드는 wifi keyboard의 소스코드를 보면 된다. server와 client socket의 차이는 그다지 크지 않다. 서버에선 server socket 열고, client가 들어왔을 때 accept 하는 부분이 있는데 이것만 빠진다고 봐도 무방할 듯.

초기에 nio 구동방식을 잘 몰라서 엄청 삽질을 했는데 진작 이 코드를 봤으면 삽질을 엄청나게 줄일 수 있었을 것이다. SocketChannel 구동 방식을 아주 대강 설명하면 다음과 같다.

  • SocketChannel을 사용할 경우 일종의 event 주도 방식으로 구현한다. 
  • 내가 관심있는 key를 channel에 등록한다. // ch.register(selector, SelectionKey.OP_ACCEPT);
  • selector.select() 를 호출해서 이벤트가 발생할 때 까지 기다린다. select() 메서드는  이벤트가 발생할 때 까지 blocking된다. 따라서 당연히 SocketChannel 로 통신하는 코드는 별도의 thread 안에서 실행되어야 한다.
  • 유일하게 block되는 selector.select() 메서드를 도중에 깨울 땐 selector.wakeup() 메서드를 쓰면 된다. 이 부분이 read() 에서 blocking된 상태를 중단하려면 thread를 interrupt 해야 하는 Socket 구현방식보다 자연스럽다. select() 메서드는 해당 이벤트를 처리할 수 있는 채널의 개수를 반환한다. 이벤트를 기다리는 중간에 깨웠거나, 지정한 timeout 을 지났을 땐 0 이 반환될 수 있으니 적절히 처리해 줘야 한다.
  • selector.selectedKey() 를 이용해 SelectionKey 를 가져온다. 이 클래스를 이용해 현재 가능한 작업이 뭐고 어떤 채널을 써야 하는 지 알아낼 수 있다. 어려개의 channel을 사용하는 경우라면 좀 복잡해 질 수 있는데, 내 경우엔 채널이 하나만 있기 때문에 간단했다. 클라이언트라면 채널을 여러 개 쓸 일은 없을 듯 하다. 여러 서버에 각기 붙을 게 아니라면.
  • SelectionKey 가 읽기 모드라 함은 읽어들일 게 있다는 말이다. 즉, 서버가 뭘 보냈으니 어서 읽으라는 것이다. 따라서 읽기 모드에선 그냥 들어온 정보를 읽어들이면 된다. 
  • 쓰기모드라면 어떨까? 쓰기는 서버로 보낼 준비가 된 상태에선 항상 반환된다. 따라서 selector에 쓰기 모드를 지정하면 selector.select() 는 바로 key를 반환한다. 따라서 클라이언트 입장에선 서버에 보낼 게 생길 때 까진 계속 read 모드를 유지하고 있다가, 쓸 게 생기면 read & write 모드로 변경한 후 selector 를 wakeup 해야 한다.
  • 내 경우엔 읽고 쓰는 작업이 뒤섞이면 좀 골치가 아파서 간단히  다음과 같이 정리했다. 1. 읽을 게 생겨서 읽기 시작했다면 계속 읽자 / 2. 쓸 게 생겼다면 다 쓸 때 까지 읽지 말고  쓰기부터 마치자. 이 부분이 nio 의 효력을 발휘하는 부분인데, Socket으로 구현했다면 저쪽 thread를 wait 시켜놨다가 notify 하는 등의 교통정리가 필요한데 이렇게 한 쓰레드로 구현했다면 그냥 모드만 살짝 바꿔주기만 하면 된다.
  • 읽기 시작했다면 버퍼가 빌 때 까지 계속 읽기 모드로 고정한다. 이 경우엔 서버로 보낼 새로운 메시지가 생겨도 계속 OP_READ만 가지고 간다. 읽기가 다 끝났다면 쓸 게 생겼을 경우 OP_WRITE, 아니라면 계속 OP_READ 상태를 유지한다.
  • 쓰기 시작했다면 OP_WRITE만 유지한다. 쓰기가 끝나면 또 쓸 게 있다면 OP_WRITE, 아니라면 OP_READ 상태로 유지한다. 이렇게 읽기 중이냐, 쓰기 중이냐는 별도의 enum을 이용해 저장해뒀다.
  • 쓰기와 읽기 작업은 한 턴에 끝나지 않을 수 있다. 다 읽고/썼는지 알고자 한다면 ByteBuffer.hasRemaining() 메서드를 사용한다. 물론 미리 buffer의 사이즈를 알고 있어야 한다. 쓰기가 문제가 없는데, 읽기를 할 땐 미리 서버로 부터 읽어와야 할 데이터의 크기를 알아야 한다. 이 정보는 packet의 헤더 등에 미리 정의를 해 둬야 한다.
이 내용을 잘 모르고 구현을 시작하니 괴이한 코드를 만들어냈는데, 진작 알았으면 삽질을 많이 줄일 수 있었을 것이다. 이 내용을 보면 왜 wifi keyboard 의 HttpConnection 에서 SELECTOR_WAIT_FOR_NEW_INPUT 등의 flag를 쓰고, HttpServer 는 newState 와 prevState 등으로 다음 번 key의 interestOps 를 설정했는지 힌트를 얻을 수 있을 것이다. 다행히 난 wifi keyboard 보다 단순한 클라이언트 구현이라 코드는 좀 더 단순했다. 대신 socket clinet 가 ui thread 가 아닌 별도의 thread에서 돌아가기 때문에 ui thread와 함께 돌아가기 위해 handler 등을 써야 했기에 딴 코드가 많이 붙었네.

nio로 소켓 작업을 하는 분에게 도움이 되길 바라며. + 틀린 부분이 있다면 지적 부탁드립니다. m(_ _)m

덧글

  • 김기한 2013/04/04 09:40 # 삭제

    저도 자바 서버 - 안드로이드 클라이언트를 개발하는데 NIO 이해에 큰 도움이 되었습니다^^
    감사합니다^^
※ 로그인 사용자만 덧글을 남길 수 있습니다.