본문 바로가기
👨‍🏫Study/JAVA

[JAVA] 18 - 2. 네트워킹

by 코푸는 개발자 2022. 3. 29.
728x90

18.6. 네트워크 기초

네트워크(network) 는 여러 대의 컴퓨터를 통신 회선으로 연결한 것을 말한다. 

 

18.6.1. 서버와 클라이언트

컴퓨터가 인터넷에 연결되어 있다면 실제로 데이터를 주고받는 행위는 프로그램들이 한다. 서비스를 제공하는 프로그램을 일반적으로 서버(server)라고 부르고, 서비스를 받는 프로그램을 클라이언트(client)라고 부른다. 인터넷에서 두 프로그램이 통신하기 위해서는 연결을 요청하는 역할과 연결을 수락하는 역할이 필요하다. 클라이언트는 서비스를 받기 위해 연결을 요청하고, 서버는 연결을 수락하여 서버스를 제공해준다. 서버는 클라이언트가 요청(request) 하는 내용을 처리해주고, 응답(response) 을 클라이언트로 보낸다.

 

18.6.2. IP 주소와 포트(Port)

컴퓨터의 고유한 주소를 바로  IP(Internet Protocol) 주소라고 한다. IP 주소는 네트워크 어댑터 (랜(Lan) 카드) 마다 할당되는데, 한 개의 컴퓨터에 두 개의 네트워크 어댑터가 장착되어 있다면, 두 개의 IP 주소를 할당할 수 있다. 네트워크 어댑터에 어떤 IP 주소가 부여되어 있는지 확인하려면 터미널에 다음과 같이 실행하면 된다.

*Windows OS에서는 명령 프롬프트에서 ipconfig/all 명령어를 실행하면 된다.

IP 주소는 xxx.xxx.xxx.xxx와 같은 형식으로 표현된다.(xxx는 0~255사이)

프로그램은 DNS(Domain Name System)를 이용해서 연결할 컴퓨터의 IP 주소를 찾는다. 대중에게 서비스를 제공하는 대부분의 서버는 도메인 이름을 가지고 있는데, 다음과 같은 DNS에 도메인 이름으로 IP를 등록해 놓는다. 

[DNS]

도메인 이름 등록된 IP 주소
www.naver.com 222.122.195.5

*인터넷 익스플로러와 같은 웹 브라우저는 사용자가 입력한 도메인 이름을 DNS에서 검색하여 IP를 얻은 다음, 해당 IP를 가진 서버로 연결한다.

IP는 컴퓨터의 네트워크 어뎁터까지만 갈 수 있는 정보이기 때문에 컴퓨터 내에서 실행하는 서버를 선택하기 위해서는 추가적인 정보가 필요하다. 이 정보가 포트(port) 번호이다. 서버는 시작할 때 고정적인 포트 번호를 가지고 실행하는데, 이것을 포트 바인딩(binding)이라고 한다. 

클라이언트도 서버에서 보낸 정보를 받기 위해 포트 번호가 필요한데, 서버와 같이 고정적인 포트번호가 아니라 운영체제가 자동으로 부여하는 동적 포트 번호를 사용한다. 이 동적 포트 번호는 클라이언트가 서버가 연결 요청을 할 때 전송되어 서버가 킄라이언트로 데이터를 보낸 때 사용한다.(포트 번호의 전체 범위는 0-65,535이다.)

포트 번호 범위 구분

 

구분명 범위 설명
Well Know Port Numbers 0-1023 국제인터넷주소관리기구(ICANN)가 특정 애플리케이션용으로 미리 예약한 포트
Registered Port Numbers 1024-49151 회사에서 등록해서 사용할 수 있는 포트
Dynamic Or Private Port Numbers 49152-65535 운영체제가 부여하는 동적 포트 또는 개인적인 목적으로 사용할 수 있는 포트

 

18.6.3. InetAddress로 IP 주소 얻기

자바는 IP 주소를 java.net.InetAddress 객체로 표현한다. InetAddress는 로컬 컴퓨터의 IP 주소뿐만 아니라 도메인 이름을 DNS에서 검색한 후 IP 주소를 가져오는 기능을 제공한다. 로컬 컴퓨터의 InetAddress를 얻고 싶다면 InetAddress.getLocalHost() 메소드를 다음과 같이 호출하면 된다.

InetAddress ia = InetAddress.getLocalHost();

외부 컴퓨터의 도메인 이름을 알고 있다면 다음 두 개의 메소드를 사용하여 InetAddress 객체를 얻으면 된다.

InetAddress ia = InetAddress.getByName(String host);
InetAddress[] iaArr = InetAddress.getAllByName(String host);

getByName() 메소드는 매개값으로 준 도메인 이름으로 DNS에서 단 하나의 IP 주소를 얻어와 InetAddress를 생성하고 리턴한다. 연결 클라이언트가 많은 회사의 경우 서버의 과부화를 막기 위해 하나의 도메인 이름에 여러 개의 컴퓨터 IP를 등록하여 운영하기도 한다. 이 경우 DNS에 등록된 모든 IP 주소를 얻고 싶다면 getAllByName() 메소드를 호출하면 된다. InetAddress 객체에서 IP 주고를 얻기 위해서는 getHostAddress() 메소드를 다음과 같이 호출하면 된다. 리턴값은 문자열로된 IP 주소이다.

String ip = InetAddress.getHostAddress();

 

 

18.7. TCP 네트워킹

TCP(Transmission Control Protocol)는 연결 지향적 프로토콜이다. 연결 지향 프로토콜이란 클라이언트와 서버가 연결된 상태에서 데이터를 주고받는 프로토콜을 말한다. 클라이언트가 연결 요청을 하고, 서버가 연결을 수락하면 통신 선로가 고정되고, 모든 데이터는 고정된 통신 선로를 통해서 순차적으로 전달된다. 그렇기 대문에 TCP는 데이터를 정확하고 안정적으로 전달한다. TCP의 단점은 데이터를 보내기 전에 반드시 연결이 형성되어야 하고(가장 시간이 많이 걸리는 작업), 고정된 통신 선로가 최단선(네트워크 길이 측면)이 아닐 경우 상대적으로 UDP(User Datagram Protocol)보다 데이터 전송 속도가 느릴 수 있다. 자바는 TCP 네트워킹을 위해 java.net.ServerSocket과 java.net.Socket 클래스를 제공하고 있다.

 

18.7.1. ServerSocket과 Socket의 용도

TCP 서버의 역할은 두 가지로 볼 수 있다. 하나는 클라이언트가 연결 요청을 해오면 연결을 수락하는 것이고, 다른 하나는 연결된 클라이언트와 통신하는 것이다. 자바에서는 이 두 역할별로 별도의 클래스를 제공하고 있다. 클라이언트의 연결 요청을 기다리면서 연락 수락을 담당하는 것이 java.net.ServerSocket 클래스이고, 연결된 클라이언트와 통신을 담당하는 것이 java.net.Socket 클래스이다. 클라이언트가 연결 요청을 해오면 ServerSocket은 연결을 수락하고 통신용 Socket을 만든다.

서버는 클라이언트가 접속할 포트를 가지고 있어야 하는데, 이 포트를 바인딩(binding) 포트라고 한다. 서버는 고정된 포트 번호에 바인딩해서 실행하므로, ServerSocket을 생성할 때 포트 번호 하나를 지정해야 한다. 위 그림에서는 5001번이 서버 바인딩 포트이다. 서버가 실행되면 클라이언트는 서버의 IP 주소와 바인딩 포트 번호로 Socket을 생성해서 연결 요청을 할 수 있다. ServerSocket은 클라이언트가 연결 요청을 해오면 accept() 메소드로 연결 수락을 하고 통신용 Socket을 생성한다. 그리고 나서 클라이언트와 서버는 각각의 Socket을 이용해서 데이터를 주고받게 된다.

 

18.7.2. ServerSocket 생성과 연결 수락

서버를 개발하려면 우선 ServerSocket 객체를 얻어야 한다. ServerSocket을 얻는 가장 간단한 방법은 생성자에 바인딩 포트를 대입하고 객체를 생성하는 것이다. 다음은 5001번 포트에 바인딩하는 ServerSocket을 생성한다.

ServerSocket serverSocket = new ServerSocket(5001);

ServerSocket을 얻는 다른 방법은 디폴트 생성자로 객체를 생성하고 포트 바인딩을 위해 bind() 메소드를 호출하는 것이다. bind() 메소드의 매개값은 포트 정보를 가진 InetSocketAddress이다. 

ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(5001));

만약 서버 PC에 멀티 IP가 할당되어 있을 경우, 특정 IP로 접속할 때만 연결 수락을 하고 싶다면 다음과 같이 작성하되, "localhost" 대신 정확한 IP를 주면 된다.

ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress("localhost", 5001));

*ServerSocket을 생성할 때 해당 포트가 이미 다른 프로그램에서 사용 중이라면 bindException이 발생한다. 이 경우에는 다른 포트로 바인딩하거나, 다른 프로그램을 종료하고 다시 실행하면 된다.

연결 수락이란 클라이언트가 연결 요청을 하면 accept()는 클라이언트와 통신할 Socket을 만들고 리턴한다.

 

만약 accept()에서 블로킹되어 있을 때 ServerSocket을 닫기 위해 close() 메소드를 호출하면 SocketException이 발생한다. 그렇기 때문에 예외 처리가 필요한다.

try {
	Socket socket = serverSocket.accept();
} catch(Exception e) { }

 

연결된 클라이언트의 IP와 포트 정보를 알고 싶다면 Socket의 getRemoteSocketAddress() 메소드를 호출해서 SocketAddress를 얻으면 된다. 실제 리턴되는 것을 InetSocketAddress 객체이므로 다음과 같이 타입 변환할 수 있다.

InetSocketAddress socketAddress = (InetSocketAddress) socket.getRemoteSocketAddress();
리턴 타입 메소드명(매개 변수) 설명
String getHostName() 클라이언트 IP 리턴
int getPort() 클라이언트 포트 번호 리턴
String toString() "IP:포트번호" 형태의 문자열 리턴

더 이상 클라이언트 연결 수락이 필요 없으면 ServerSocket의 close() 메소드를 호출해서 포트를 언바인딩시켜야 한다. 그래야 다른 프로그램에서 해당 포트를 재사용할 수 있다.

serverSocket.close();

 

연결 수락 예제

public class ServerExample {
	public static void main(String[ ] args) {
    	ServerSocket serverSocket = null;
        try {
        	//ServerSocket 생성
        	serverSocket = new ServerSocket();
            serverSocket.bind(new InetSocketAddress("localhost", 5001);
            while(true) {
            	System.out.println("[연결 기다림]");
                //클라이언트 연결 수락
                Socket socket = serverSocket.accept();
                InetSocketAddress isa = (InetSocketAddress) socket.getRemoteSocketAddress();
                System.out.println("[연결 수락함] " + isa.getHostName());
            }
        } catch(Exception e) {}
        
        //ServerSocket이 닫혀있지 않을 경우
        if(!serverSocket.isClosed()) {
        	try {
            	//ServerSocket 닫기
            	serverSocket.close());
           	} catch (IOException e1) {}
        }
	}
}

 

18.7.3. Socket 생성과 연결 요청

클라이언트가 서버에 연결 요철을 하려면 java.net.Socket을 이용해야 한다.

다은은 로컬 PC의 5001 포트에 연결 요청하는 코드이다.

try {
	Socket socket = new Socket("localhost", 5001);//방법1
    Socket socket = new Socket(new InetSocketAddress("localhost", 5001);//방법2
} catch (UnknownHostException e) {
	//IP 표기 방법이 잘못되었을 경우
} catch (IOException e) {
	//해당 포트의 서버에 연결할 수 없는 경우
}

 

Socket 생성과 동시에 연결 요청을 하지 않고, 다음과 같이 기본 생성자로 Socket을 생성한 후, connect() 메소드로 연결 요청을 할 수도 있다.

socket = new Socket();
socket.connect(new InetSocketAddress("localhost", 5001));

연결 요청을 할 때 발생할 수 있는 2가지 예외

  • UnknownHostException : 잘못 표기된 IP주소를 입력했을 경우 발생
  • IOException : 주어진 포트로 접속할 수 없을 때 발생

close() 메소드에서 IOException이 발생할 수 있으므로 예외 처리가 필요하다.

try {
	socket.close();
} catch (IOException e) { }

 

localhost 5001 포트로 연결을 요청하는 코드 예제

public class ServerExample {
	public static void main(String[ ] args) {
    	Socket socket = null;
        try {
        	//Socket 생성
        	socket = new Socket();
            System.out.println("[연결 요청]");
            //연결 요청
            socket.connect(new InetSocketAddress("localhost", 5001);
            System.out.println("[연결 성공]");
        } catch(Exception e) {}
        
        //연결이 되어 있을 경우
        if(!socket.isClosed()) {
        	try {
            	//연결 끊기
            	socket.close();
           	} catch (IOException e1) {}
        }
	}
}

 

18.7.4. Socket 데이터 통신

클라이언트가 연결 요청(connect())하고 서버가 연결 수락(accept())했다면, 양쪽의 Socket 객체로부터 각각 입력 스트림(InputStream) 과 출력 스트림(OutputStream)을 얻을 수 있다.

 

다음은 Socket으로부터 InputStream과 OutputStream을 얻는 코드이다.

//입력 스트림 얻기
InputStream is = socket.getInputStream();

//출력 스트림 얻기
OutputStream os = socket.getOutputStream();

 

상대방에게 데이터를 보내기 위해서는 보낼 데이터를 byte[] 배열로 생성하고, 이것을 매개값으로 해서 OutputStream의 write() 메소드를 호출하면 된다. 다음은 문자열을 UTF-8로 인코딩한 바이트 배열을 얻어내고, write() 메소드를 전송한다.

String data = "보낼 데이터";
byte[ ] byteArr = data.getBytes("UTF-8");
OutputStream outputStream = socket.getOutputStream();
outputStream.write(byteArr);
outputStream.flush();

 

상대방이 보낸 데이터를 받기 위해서는 받은 데이터를 저장할 byte[ ] 배열을 하나 생성하고, 이것을 매개값으로 해서 InputStream의 read() 메소드를 호출하면 된다. read() 메소드는 읽은 데이터를 byte[ ] 배열에 저장하고 읽은 바이트 수를 리턴한다. 다음은 데이터를 읽고 UTF-8로 디코딩한 문자열을 얻는 코드이다.

byte[ ] byteArr = new byte[100];
InputStream inputStream = socket.getInputStream();
int readByteCount = inputStream.read(byteArr);
String data = new String(byteArr, 0, readByteCount, "UTF-8");

 

예제

1. 클라이언트가 먼저 "Hello Server"를 서버로 보낸다. 

2. 서버가 이 데이터를 받고 3. "Hello Client"를 클라이언트로 보내면 4. 클라이언트가 이 데이터를 받는다.

데이터 보내고 받기(클라이언트)

public class ServerExample {
	public static void main(String[ ] args) {
    	Socket socket = null;
        try {
        	//Socket 생성
        	socket = new Socket();
            System.out.println("[연결 요청]");
            //연결 요청
            socket.connect(new InetSocketAddress("localhost", 5001);
            System.out.println("[연결 성공]");
            
            byte[ ] bytes = null;
            String message = null;
            
            //1
            OutputStream os = socket.getOutputStream();
            message = "Hello Server";
            bytes = message.getBytes("UTF-8");
            os.write(bytes);
            os.flush();
            System.out.println("[데이터 보내기 성공]");
            
            //4
            InputStream is = socket,getInputStream();
            bytes = new byte[100];
            int readByteCount = is.read(bytes);
            message = new String(bytes, 0, readByteCount, "UTF-8");
            System.out.println("[데이터 받기 성공]: " + message);
            
            os.close();
            is.close();
        } catch(Exception e) {}
        
        if(!socket.isClose()) {
        	try {
            	socket.close();
            } catch (IOException e1) {}
        }
    }
}

 

데이터 받고 보내기(서버)

public class ServerExample {
	public static void main(String[ ] args) {
    	ServerSocket serverSocket = null;
        try {
        	//ServerSocket 생성
        	serverSocket = new ServerSocket();
            serverSocket.bind(new InetSocketAddress("localhost", 5001);
            while(true) {
            	System.out.println("[연결 기다림]");
                //클라이언트 연결 수락
                Socket socket = serverSocket.accept();
                InetSocketAddress isa = (InetSocketAddress) socket.getRemoteSocketAddress();
                System.out.println("[연결 수락함] " + isa.getHostName());

				byte[ ] bytes = null;
                String message = null;
                
                //2
                InputStream is = socket.getInputStream();
                bytes = new byte[100];
                int readByteCount = is.read(bytes);
                message = new String(bytes, 0, readByteCount, "UTF-8");
                System.out.printls("[데이터 받기 성공]: " + message);
                
                //3
                OutputStream os = socket.getOutputStream();
                message = "Hello Client";
                bytes = message.getBytes("UTF-8");
                os.write(bytes);
                os.flush();
                System.out.printls("[데이터 보내기 성공]");
                
                is.close();
                os.close();
                socket.close();
            }
        } catch(Exception e) {}
        
        if(!serverSocket,isClose()) {
        	try {
            	serverSocket.close();
            } catch (IOException e1) {}
        }
    }
}

*서버를 먼저 실행하고 클라이언트를 실행한다.

 

데이터를 받기 위해 InputStream의 read() 메소드를 호출하면 상대방이 데이터를 보내기 전까지는 블로키(blocking)되는데, read() 메소드가 블로킹 해제되고 리턴되는 경우는 다음 세 가지이다.

블로킹이 해제되는 경우 리턴값
상대방이 데이터를 보냄 읽은 바이트 수
상대방이 정상적으로 Socket의 close()를 호출 -1
상대방이 비정상적으로 종료 IOException 발생

*상대방이 정상적으로 Socket의 close()를 호출하고 연결을 끊었을 경우와 비정상적으로 종료했을 경우, 모두 예외 처리를 해서 이쪽도 Socket을 닫기 위해 close() 메소드를 호출해야 한다.

try {
	...
    //상대방이 비정상적으로 종료했을 경우 IOException 발생
    int readByteCount = inputStream.read(byteArr);
    
    //상대방이 정상적으로 Socket의 close()를 호출했을 경우
    if(readByteCount == -1) {
    	throw new IOException();//강제로 IOException 발생시킴
    }
    ...
} catch (Exception e) {
	try { socket.close(); } catch(Exception e2) {}
}

 

18.7.5. 스레드 병렬 처리

연결 수락을 위해 ServerSocket의 accept()를 실행하거나, 서버 연결 요청을 위해 Socket 생성자 또는 connect()를 실행할 경우에는 해당 작업이 완료되기 전까지 블로킹(blocking)된다. 데이터 통신을 할 때에도 마찬가지인데 InputStream의 read() 메소드는 상대방이 데이터를 보내기 전까지 블로킹되고, Outpustream의 write() 메소드는 데이터를 완전하게 보내기 전까지 블로킹된다. 결론적으로 말해서 ServerSocket과 Socket은 동기(블로킹) 방식으로 구동된다.

만약 서버를 실행시키는 main 스레드가 직접 입출력 작업을 담당하게 되면 입출력이 완료될 때까지 다른 작업을 할 수 없는 상태가 된다. 서버 애플리케이션은 지속적으로 클라이언트의 연결 수락 기능을 수행해야 하는데, 입출력에서 블로킹되면 이 작업을 할 수 없게 된다. 또한 클라이언트 1과 입출력하는 동안에는 클라이언트2와 입출력을 할 수 없게 된다. 그렇기 때문에 accept(), connect(), read(), write()는 별도의 작업 스레드를 생성해서 병렬적으로 처리하는 것이 좋다. 다음 그림은 서버가 별도의 작업 스레드를 생성하고, 다중 클라이언트와 병렬적으로 통신하는 모습을 보여준다.

위 그림과 같이 스레드로 병렬 처리를 할 경우, 수천 개의 클라이언트가 동시에 연결되면 서버에서 수천 개의 스레드가 생성되기 때문에 서버 성능이 급격히 저하되고, 다운되는 현상이 발생할 수 있다. 클라이언트의 폭증으로 인해 서버의 과도한 스레드 생성을 방지하려면 스레드풀을 사용하는 것이 바람직하다. 다음은 스레드풀을 이요한 서버 구현 방식을 보여준다.

클라이언트가 연결 요청을 하면 서버의 스레드풀에서 연결 수락을 하고 Socket을 생성한다. 클라이언트가 작업 처리 요청을 하면 서버의 스레드풀에서 요청을 처리하고 응답을 클라이언트로 보낸다. 

*스레드풀은 스레드 수를 제한해서 사용하기 때문에 갑작스런 클라이언트의 폭증은 작업 큐의 작업량만 증가시킬 뿐 스레드 수는 변함이 없기 때문에 서버 성능은 완만히 저하된다. 다만 대기하는 작업량이 많을 경우 개별 클라이언트에서 응답을 늦게 받을 수 있다.

728x90

댓글