안드로이드 자바 클라이언트 소켓 프로그래밍. (Android Java Client Socket Programming)

2019. 5. 15. 15:41


1. 안드로이드와 자바 소켓(Socket).

안드로이드 앱을 개발할 때, 사용자 인터페이스(User Interface) 외의 여러 요소들이 그렇듯이, 안드로이드 SDK가 아닌 자바 SDK에서 제공되는 API들을 사용해야 하는 경우가 많습니다. "android.*"에 포함되지 않고, "java.*" 패키지에 포함된 API들이 바로 그런 것들이며, 앞서 [파일 입출력]에서 사용했던 "java.io.*" 패키지, [스레드]에서 사용한 "java.lang.*" 등이 대표적인 것들이죠.


안드로이드에서 소켓(Socket)을 사용하는 경우도 마찬가지입니다. 소켓(Socket)을 포함한 기본적인 네트워크 관련 기능은 자바 SDK의 "java.net.*" 패키지에 포함되어 있는 API를 사용해서 구현할 수 있습니다.

2. 클라이언트 소켓(Client Socket)

앞서 [소켓 프로그래밍. (Socket Programming)]에서 소켓(Socket)의 기본 개념에 대해 설명하고, 소켓(Socket)을 사용해 네트워크 통신 연결을 수립하기 위한 절차에 대해서도 알아보았습니다. 이 때, 소켓 연결 과정에서 맡은 역할에 따라, 연결을 요청하는 클라이언트 소켓(Client Socket)과 연결을 기다리는 서버 소켓(Server Socket)으로 나뉜다는 것도 확인하였습니다. 그리고 그 역할에 따라 다른 API가 사용된다는 것도 살펴보았죠.


그러면 이제, [소켓 프로그래밍. (Socket Programming)]에서 설명한 내용들이 실제 구현 과정에서 어떤 코드로 작성되고, API 함수들이 어떻게 사용되는지 알아보도록 하겠습니다.


먼저, 여기서는 클라이언트 소켓(Client Socket)과 관련된 코드에 대해 설명할텐데요. [소켓 프로그래밍. (Socket Programming)]에서 설명한 내용이 어떤 API 함수 또는 코드로 매핑되는지 정도만 보도록 하겠습니다. 실제 동작이 가능한 예제와 서버 소켓(Server Socket)에 대한 내용은 다른 글들을 통해 소개하도록 하겠습니다.

3. 소켓 API 실행 에러(Error)에 대한 대처.

한 가지 더, 클라이언트 소켓(Client Socket) 프로그래밍 코드를 확인하기 전에, API 실행 중 에러(Error)가 발생하는 상황에 대해 언급이 필요할 듯 합니다.


소켓(Socket) 통신은 "네트워크 상에서 동작"하기 때문에 언제든지 개발자가 예상치 못한 문제가 발생할 수 있습니다. 올바르지 않은 네트워크 주소 사용, 네트워크 단절로 인한 데이터 전송 실패 또는 응답 없음, 보안 이슈로 인한 연결 거부, 네트워크 부하로 인한 전송 지연과 전송 시간 초과 등 다양한 문제들이 발생할 수 있죠.


그런데 발생 가능한 모든 문제들에 대해 케이스 별로 개별적인 대응을 하는 것은 쉽지 않은 작업입니다. 그렇다고 문제가 발생할 때마다 모든 리소스를 정리하고 재연결을 시도하는 것과 같은 방법 또한 그리 좋은 해결책은 아닙니다. 클라이언트 입장에서야 단순히 "연결 과정을 한번 더 수행"한다는 것 뿐이지만, 수많은 클라이언트의 연결을 관리해야 하는 서버 입장에서는 클라이언트들의 동시 다발적인 연결 요청이 시스템 과부하로 이어지고, 이에 성능 저하를 초래할 수도 있습니다.


결국, 소켓 프로그래밍에서 에러에 대한 대처는 경험에 의해 축적된 노하우와 다양한 모니터링 장치, 그리고 개발 과정에서 다양한 상황을 시험할 수 있게 해주는 시뮬레이터(Simulator) 등에 의존할 수 밖에 없는데요. 네트워크 관련 개발 경험이 상대적으로 적은 개발자가 이해하고 다루기는 쉽지 앟은 내용들이죠.


하지만 처음부터 너무 어렵고 복잡하게 생각할 필요는 없습니다. 명확한 것들부터 하나씩 대처해 나가다보면 경험은 자연적으로 축적되는 것이니까요.


음.. 그럼 여기선, 소켓 프로그래밍 과정에서 발생하는 문제 상황들을 처리하는 필수 작업인, 소켓 예외 사항 처리(Exception Handling)에 대해 알아볼까요?

3.1 예외 사항 처리(Exception Handling)

자바에서 코드 실행 시 발생하는 다양한 예외들은 try - catch 문을 통해 처리됩니다. 소켓 API를 호출하는 과정에서도 다양한 예외 사항이 발생할 수 있는데, 해당 예외들은 각 API 메서드에 대한 레퍼런스에서 확인할 수 있습니다. 아래는 Socket 클래스의 connect 메서드("https://developer.android.com/reference/java/net/Socket.html#connect(java.net.SocketAddress,%20int)") 호출 시 발생할 수 있는 예외(Exception) 리스트입니다.

Socket connect() 메서드 예외


아래 표는 소켓 프로그래밍 과정에서 발생할 수 있는 예외(Exception)에 대해 정리한 것입니다.


예외(Exception) 설명
IllegalArgumentException 메서드에 잘못된 파라미터가 전달되는 경우 발생.
(0~65535 범위 밖의 포트 번호 사용, null 프록시(proxy) 전달)
SecurityException 보안(Security) 위반에 대해 보안 관리자(Security Manager)에 의해 발생.
(프록시(proxy) 접속 거부, 허용되지 않은 함수 호출)
UnknownHostException 호스트의 IP 주소를 식별할 수 없는 경우 발생.
(잘못된 주소 값 또는 호스트 이름 사용)
IOException 입출력(Input/Ouput) 과정에서 실패 또는 중단과 같은 문제가 생긴 경우 발생.
(소켓 생성 실패, bind() 실패, connect() 실패, 스트림 생성 실패, close() 실패)
NullPointerException null인 객체(Object)를 사용할 때 발생.
(소켓 주소 객체가 null)
SocketException 소켓 생성 및 접근 에러.
(프로토콜 에러)
SocketTimeoutException 소켓 처리 과정에서 시간 초과(Timeout) 발생.
(connect() 시간 초과)
IllegalBlockingModeException 잘못된 Blocking 모드 오퍼레이션 호출 시 발생.
(non-blocking 모드에서 blocking 함수 호출)


아래 코드는 소켓(Socket) 생성 과정 중 발생할 수 있는 예외(Exception)를 처리하는 예제 코드입니다.


    try {
        Socket s = new Socket("www.unknown-host.com", 65536) ;
        ...
        ...
    } catch (IOException ioe) {
        // 소켓 생성 과정에서 I/O 에러 발생.
    } catch (UnknownHostException uhe) {
        // 소켓 생성 시 전달되는 호스트(www.unknown-host.com)의 IP를 식별할 수 없음.
    } catch (SecurityException se) {
        // security manager에서 허용되지 않은 기능 수행.
    } catch (IllegalArgumentException) {
        // 소켓 생성 시 전달되는 포트 번호(65536)이 허용 범위(0~65535)를 벗어남.
    }

4. 자바 클라이언트 소켓 프로그래밍(Client Socket Programming)

자바에서 네트워크 관련 기능은 java.net 패키지에 구현되어 있으며, 소켓(Socket) 프로그래밍은 Socket 클래스를 사용합니다.

자바 Socket 클래스


Socket 클래스를 사용하는 방법을 알아보기 전에, 먼저 클라이언트 소켓 프로그래밍 절차를 다시 떠올려 볼까요?

클라이언트 소켓 프로그래밍 절차


4.1 클라이언트 소켓 생성. create.

자바에서 소켓(Socket)을 생성하는 절차는 간단합니다. Socket 클래스가 하나의 소켓(Socket)을 의미하므로, Socket 클래스의 인스턴스를 new 키워드로 생성하기만 하면 됩니다.


[STEP-1] 클라이언트 소켓 생성. create.
    Socket socket = new Socket() ;


그런데 Socket 클래스에는 파라미터가 다른 생성자가 다수 정의되어 있습니다. 주로 서버의 IP 주소와 포트 번호를 지정하여, 인스턴스 생성과 함께 연결 요청(connect)까지 바로 수행하도록 만들기 위한 생성자들입니다. 몇 가지 나열해보자면 아래와 같습니다.


생성자 설명
Socket() 연결되지 않은(unconnected) 소켓 생성.
Socket(String host, int port) 소켓을 생성하고, 지정된 서버 문자열 호스트(host)와 포트 번호(port)로 연결 요청.
Socket(InetAddress address, int port) 소켓을 생성하고, 지정된 서버 IP 주소(address)와 포트 번호(port)로 연결 요청.
Socket(String host, int port, InetAddress localAddr, int localPort) 소켓을 생성하고, 지정된 서버 문자열 호스트(host)와 포트 번호(port)로 연결 요청. 단, 로컬 IP 주소(localAddr)와 로컬 포트 번호(localPort)에 바인딩.
Socket(InetAddress address, int port, InetAddress localAddr, int localPort) 소켓을 생성하고, 지정된 서버 IP 주소(address)와 포트 번호(port)로 연결 요청. 단, 로컬 IP 주소(localAdd)와 로컬 포트 번호(localPort)에 바인딩.

4.2 연결 요청. connect.

연결되지 않은(unconnected) 소켓(Socket)을 생성했다면, 이제 할 일은 서버에 연결을 요청하는 것입니다. 이를 위해 사용하는 메서드는 connect() 메서드입니다.


[STEP-2] 연결 요청. connect.
    SocketAddress addr = new InetSocketAddress("192.168.1.1", 3333/*port*/) ;
    socket.connect(addr) ;


그런데 connect() 메서드에 전달되는 파라미터를 보면, 연결을 요청할 서버 IP 주소와 포트 번호가 단순 문자열과 숫자로 전달되지 않고 SocketAddress 클래스의 인스턴스로 만들어져 전달된 것을 확인할 수 있습니다. 그것도 SocketAddress의 서브클래스(subclass)인 InetSocketAddress 클래스로 생성한 인스턴스를 말이죠.


우리가 흔히 네트워크에서 "주소(Address)"라고 지칭하는 단어는, 자신을 포함한, 네트워크에 연결된 대상을 구분할 수 있도록 각자에게 부여된 식별자를 말합니다. OSI 7-Layer의 2계층(Data Link)에 해당하는 이더넷(Ethernet) 프로토콜에서는 MAC 주소(MAC Address)가 있고, 3계층(Network)에 해당하는 IP 프로토콜에서는 IP 주소(IP Address)가 있죠.


그리고 소켓(Socket)은 4계층(Transport)인 TCP 또는 UDP 프로토콜 상에서 동작합니다. 이는 곧 통신할 대상이 "IP 주소"와 "포트 번호"로 식별된다는 것을 의미하죠. 즉, 소켓(Socket)의 주소는 IP 주소와 포트 번호를 가지며, 자바에서는 SocketAddress 클래스와 InetSocketAddress 클래스를 사용합니다.


하지만 SocketAddress 클래스는 추상 클래스(Abstract Class)입니다. 그 자체로는 new 키워드를 사용해 인스턴스를 만들 수 없습니다. SocketAddress 클래스는 프로토콜(protocol)이 결합되지 않은 단순 소켓 주소를 나타내기 때문에, 추상 클래스로 선언되어 있습니다. 그래서 특정 프로토콜(protocol)에 맞게 구현된 서브 클래스(Sub Class)가 필요하며, 그것이 바로 InetSocketAddress인 것이죠.

SocketAddress and InetSocketAddress


음, 위의 내용이 너무 복잡하게 느껴지시나요? 그렇다면 아래 코드처럼, 소켓 생성과 연결 요청을 한번에 수행하는 Socket 클래스의 생성자를 사용해 보세요. 코드가 간단해집니다.


    Socket socket = new Socket("192.168.1.1", 3333/*port*/) ;

4.3 데이터 송수신. send/recv.

클라이언트 소켓 처리 과정에서 Socket 인스턴스가 생성되고 서버 소켓과의 연결이 완료되었다면, 이제 생성된 소켓 인스턴스를 통해 데이터를 송수신 할 수 있습니다.


그런데 [소켓 프로그래밍. (Socket Programming)]에서 설명한 대로라면 데이터 송수신을 위해 사용하는 API가 Socket 클래스의 send() 또는 recv()여야 할 것 같은데, Socket 클래스에는 send()recv() 메서드가 존재하지 않습니다.


대신 자바에서는 소켓(Socket)을 통해 데이터를 주고 받을 때, 자바의 다른 입출력 방법과 마찬가지로, 스트림(Stream)을 사용합니다.

java socket and stream


자바의 입출력(I/O)에 사용되는 스트림(Stream)은, 알다시피, 입력(Input) 또는 출력(Output)에 대한 단일 방향으로 동작합니다. 즉, 입력된 데이터를 처리하기 위한 입력 스트림(Input Stream)과 출력할 데이터를 전달할 출력 스트림(Output Stream)이 각각 따로 존재한다는 말이죠.


소켓(Socket)을 통해 주고 받는 데이터는 소켓(Socket)에 할당된 시스템 버퍼를 통해 순서대로 처리되는데, 수신된 데이터를 위한 버퍼와 송신을 위한 버퍼가 각각 따로 존재합니다. 네트워크를 통해 수신된 데이터는 수신 버퍼(RCV_BUF)에 쌓이고, 이는 입력 스트림(Input Stream)을 사용해 프로그램에서 가져올 수 있습니다. 반대로 출력 스트림(Output Stream)을 통해 쓰여진 데이터는 송신 버퍼(SND_BUF)를 통해 순서대로 네트워크를 통해 전송됩니다.

java InputStream and OutputStream


입력 스트림(Input Stream)과 출력 스트림(Output Stream)을 사용하려면, 소켓 인스턴스로부터 스트림에 대한 참조를 가져오면 되는데요, getInputStream() 메서드와 getOutputStream() 메서드를 통해 각 스트림의 참조를 획득할 수 있습니다.

getInputStream() and getOutputStream()


먼저, getInputStream() 메서드를 통해 획득한 입력 스트림(Input Stream)을 사용해 데이터를 읽어들이는 코드는 아래와 같습니다.


[STEP-3.1] 데이터 수신 처리. recv.
    byte[] bufRcv = new byte[1024] ;
    int size ;

    InputStream is = socket.getInputStream() ;
    size = is.read(bufRcv) ;

    // TODO : process bufRcv.

그리고 소켓(Socket)의 출력 스트림(Output Stream)에 대한 참조는 getOutputStream() 메서드를 사용하여 획득합니다. write() 메서드를 사용하여 데이터를 보낼 수 있습니다.


[STEP-3.2] 데이터 송신 처리. send.
    byte[] bufSnd = new byte[64] ;

    // TODO : fill bufSnd with data.
    bufSnd[0] = 0x00 ;
    bufSnd[1] = 0x11 ;
    ...
    bufSnd[15] = 0xFF ;

    OutputStream os = socket.getOutputStream() ;
    os.write(bufSnd, 0/*off*/, 16/*len*/) ;

여기서는 InputStream 클래스와 OutputStream 클래스를 그대로 사용했지만, 버퍼 사용 유무, 처리할 데이터의 유형, 문자 인코딩 타입 등 여러 가지 조건에 따라 다양한 서브 클래스를 사용할 수 있습니다. 다양한 스트림(Stream) 클래스를 사용하는 방법은 다른 예제를 통해 소개하도록 하겠습니다.

Subclasses of InputStream and OutputStream


4.4 연결 종료. close.

모든 데이터 송수신이 완료되고 소켓(Socket)을 사용할 필요가 없어지게 되면, 마지막으로 해야 할 일은 사용했던 소켓(Socket)을 닫는 것입니다. Socket 클래스의 close() 함수를 호출하면 연결을 종료하고 소켓(Socket)을 닫습니다. 물론 입출력을 위해 사용했던 스트림(Stream)도 close() 함수를 사용하여 닫아줘야 합니다.


[STEP-4] 연결 종료. close.
    is.close() ;
    os.close() ;
    socket.close() ;

5. 전체 코드 요약.

앞에서 설명한 내용을 정리하면, 아래 코드와 같습니다. 하지만 아쉽게도, 아래 코드를 각 단계 별 코드 작성을 위해 참고하는 것은 괜찮지만, 실제 클라이언트 통신 로직으로 사용하기엔 많이 부족합니다.


여기서는 대략적인 코드 형태만 소개하도록 하구요, 다른 예제를 통해 좀 더 유용한 코드를 소개하도록 하겠습니다.


    Socket socket = null ;
    InputStream is = null ;
    OutputStream os = null ;

    try {
    // 소켓 생성 및 연결.
        socket = new Socket() ;
        SocketAddress addr = new InetSocketAddress("192.168.1.1", 3333/*port*/) ;
        socket.connect(addr) ;
        
    // 데이터 수신.
        byte[] bufRcv = new byte[1024] ;
        int size ;

        is = socket.getInputStream() ;
        size = is.read(bufRcv) ;

        // TODO : process bufRcv.

    // 데이터 송신.
        byte[] bufSnd = new byte[64] ;

        // TODO : fill bufSnd with data.
        bufSnd[0] = 0x00 ;
        bufSnd[1] = 0x11 ;
        ...
        bufSnd[15] = 0xFF ;

        os = socket.getOutputStream() ;
        os.write(bufSnd, 0/*off*/, 16/*len*/) ;
    } catch (Exception e) {
        // TODO : process exceptions.
    }

    try {
    // 소켓 종료.
        if (is != null)
            is.close() ;    
        
        if (os != null)
            os.close() ;

        if (socket != null)
            socket.close() ;
    } catch (Exception e) {
        // TODO : process exceptions.
    }

6. 참고.

.END.


'ANDROID 프로그래밍 > NETWORK' 카테고리의 다른 글

소켓 프로그래밍. (Socket Programming)  (111) 2019.03.05

ANDROID 프로그래밍/NETWORK