java-school logo

소켓

소켓이란 TCP/IP를 이용하는 API를 말한다. 1982년 BSD 유닉스에서 처음 소개되었다. 소켓은 소프트웨어로 작성된 통신 접속점이라 이해하면 된다.

자바에도 소켓이 있다. 클라이언트와 서버에 소켓이 서로 연결되면 스트림을 이용하여 통신을 할 수 있게 된다. 클라이언트 측에서 소켓을 목적지로 하는 출력 스트림을 통해 쓰면 서버 측에서는 자신의 소켓을 근원지로 하는 입력 스트림을 통해 클라이언트가 보낸 정보를 받을 수 있게 된다. 클라이언트 측과 서버 측의 소켓은 둘 다 java.net.Socket 클래스로 같다.

소켓이 연결되려면 서버 측의 서버 소켓(ServerSocket)을 통해야 한다. 서버 소켓은 서버에서 직접 통신에 간여하지 않고 창구 역할만 한다. 서버 소켓은 외부에서 소켓 연결 요청이 오면 클라이언트 소켓과 통신할 서버 측 소켓을 만들고 서로 연결한다.

Server.java
package net.java_school.socket;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class Server {
	public static void main(String[] args) throws IOException {
		ServerSocket serverSocket = new ServerSocket(3000);
		Socket socket = serverSocket.accept();
		//TODO
	}
}

Socket socket = serverSocket.accept();에서 프로그램은 멈추고 외부의 소켓 접속 요청을 기다린다. 소켓 접속 요청이 오면 클라이언트와 통신을 할 서버 측 소켓을 만들고 외부 소켓과 연결한 후 레퍼런스가 반환된다. 실제로 접속이 이뤄지는 서버 측 소켓의 포트는 남아있는 포트 번호 중 임의로 정해진다.

첫 번째로 준비한 예제는, 소켓이 연결되면 서버가 클라이언트에게 특정 메시지를 보내고 종료된다. 여기서 특정 메시지는 서버 측 소켓의 포트 번호다.

Server.java
package net.java_school.socket;

import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;

public class Server {
	public static void main(String[] args) throws IOException {
		ServerSocket serverSocket = new ServerSocket(3000);
		Socket socket = serverSocket.accept();
		OutputStream os = socket.getOutputStream();
		OutputStreamWriter osw = new OutputStreamWriter(os);
		BufferedWriter bw = new BufferedWriter(osw);
		PrintWriter pw = new PrintWriter(bw);
		pw.println("Socket Connected[" + socket.getPort() + "]");
	}
}

클라이언트 측에서 소켓을 연결하기 위해서는 서버 IP뿐 아니라 서버 소켓ServerSocket의 포트 번호가 필요하다. 컴퓨터 하나라면 IP는 localhost다. 두 대 이상이면 아래 소스에서 localhost 부분을 서버가 동작하는 IP 주소로 대체한다. 서버 소켓이 3000번 포트를 이용하고 있으므로 두 번째 인자로 3000을 입력한다.

Client.java
package net.java_school.socket;

import java.io.IOException;
import java.net.Socket;
import java.net.UnknownHostException;

public class Client {

	public static void main(String[] args) throws UnknownHostException, IOException {
		Socket socket = new Socket("localhost", 3000);
		//TODO
	}

}

소켓이 연결되었다면 Server는 문자열을 클라이언트에 전달한다. 서버가 보낸 문자열을 읽기 위해서 아래 강조한 부분을 추가한다.

Client.java
package net.java_school.socket;

import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;

public class Client {

	public static void main(String[] args) throws UnknownHostException, IOException {
		Socket socket = new Socket("localhost", 3000);
		OutputStream os = socket.getOutputStream();
		OutputStreamWriter osw = new OutputStreamWriter(os);
		BufferedWriter bw = new BufferedWriter(osw);
		PrintWriter pw = new PrintWriter(bw);
		pw.println("Socket Connected[Port:" + socket.getPort() + "]");
		pw.flush();
	}

}

Server는 소켓을 연결하고 메시지를 클라이언트에 전달한 후 종료된다. Client는 서버로부터 메시지를 받고 메시지를 콘솔에 출력한 후 종료된다.

Server를 먼저 실행하고 Client를 실행한다. 같은 PC에서 실행시킬 때 명령 프롬프트를 각각 띄어서 테스트한다.

현재 Server는 클라이언트의 접속 요청이 오면 소켓을 연결하고 클라이언트로 메시지를 보낸 후 종료되므로 다음 클라이언트의 접속 요청은 서비스할 수 없다. 서버가 종료되지 않도록 예제를 수정하자.

Server.java
public static void main(String[] args) throws IOException {
	ServerSocket serverSocket = new ServerSocket(3000);
	while (true) {
		Socket socket = serverSocket.accept();
		OutputStream os = socket.getOutputStream();
		OutputStreamWriter osw = new OutputStreamWriter(os);
		BufferedWriter bw = new BufferedWriter(osw);
		PrintWriter pw = new PrintWriter(bw);
		pw.println("Socket Connected[Port:" + socket.getPort() + "]");
		pw.flush();
		pw.close();
		socket.close();
	}
}

Server를 종료하려면 Ctrl + C를 동시에 눌러 강제 종료한다.

다음 예제는 Client에서 보낸 메시지를 Server가 그대로 다시 Client에게 전송한다.

Server.java
package net.java_school.socket;

public class Server {
	//TODO
}

Server는 두 가지 일을 동시에 해야 한다. 외부에서 소켓 연결 요청에 서비스하고, 이미 접속한 Client의 메시지를 받아서 다시 Client로 보내는 일을 동시에 해야 한다. 멀티 스레드 프로그램으로 만들어야 한다는 얘기다.

Client의 메시지를 받아서 다시 Client로 그대로 보내는 코드가 새로운 스레드를 타도록 하고 싶다. '클라이언트의 메시지를 받아서 다시 클라어언트로 그대로 보내는 코드'가 필요하다면 서버 측의 소켓을 근원지로 하는 입력 스트림과 서버 측의 소켓을 목적지로 하는 출력 스트림이 필요하다.

소켓, 입력 스트림, 출력 스트림, 스레드의 묶음을 하나의 클래스로 만들고, 클래스의 이름을 Echo라 하자. Echo 클래스는 Server의 자원을 쉽게 접근할 수 있도록 서버의 내부 클래스를 만들자.

Server.java
package net.java_school.socket;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;

public class Server {
	
	private class Echo extends Thread {
		private Socket socket;
		private BufferedReader br;
		private PrintWriter pw;
			
		public Echo(Socket socket) throws IOException {
			this.socket = socket;
			InputStream is = socket.getInputStream();
			br = new BufferedReader(new InputStreamReader(is));
			OutputStream os = socket.getOutputStream();
			BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(os));
			pw = new PrintWriter(bw);
		}

		@Override
		public void run() {
			try {
				while (true) {
					String str = br.readLine();
					pw.println("From Server: " + str);
					pw.flush();
				}
			} catch (Exception e) {
				e.printStackTrace();
				close();
			}
		}
		
		private void close() {
			try {
				br.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
			pw.close();
			try {
				socket.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}//Echo inner class end

}

소켓 접속을 유지하기 위해서는 Echo 객체를 유지해야 한다. Echo 객체를 담을 ArrayList를 추가한다. Echo의 close() 메서드 마지막에 Echo 객체 참조 값을 ArrayList에서 제거하는 코드를 추가한다.

Server.java
package net.java_school.socket;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.ArrayList;

public class Server {
	private ArrayList<Echo> echos = new ArrayList<Echo>();

	private class Echo extends Thread {
		private Socket socket;
		private BufferedReader br;
		private PrintWriter pw;
			
		public Echo(Socket socket) throws IOException {
			this.socket = socket;
			InputStream is = socket.getInputStream();
			br = new BufferedReader(new InputStreamReader(is));
			OutputStream os = socket.getOutputStream();
			BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(os));
			pw = new PrintWriter(bw);
		}

		@Override
		public void run() {
			try {
				while (true) {
					String str = br.readLine();
					pw.println("From Server: " + str);
					pw.flush();
				}
			} catch (Exception e) {
				e.printStackTrace();
				close();
			}
		}
		
		private void close() {
		
			try {
				br.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
			pw.close();
			try {
				socket.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
			echos.remove(this);
		}
	}//Echo inner class end
     
}

외부 소켓 연결 요청에 서비스하는 코드를 작성한다. 메서드 내용은 무한 루프 안에 서버 소켓의 accept() 메서드를 둔다. 메인 메서드에서 서버 객체 생성 후 이 메서드를 호출하도록 구현한다.

Server.java
package net.java_school.socket;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;

public class Server {
	private ArrayList<Echo> echos = new ArrayList<Echo>();
	
	public void startServer() {
		ServerSocket serverSocket = null;
		
		try {
			serverSocket = new ServerSocket(3000);
			while (true) {
				Socket socket = serverSocket.accept();
				Echo echo = new Echo(socket);
				echo.start();
				echos.add(echo);
			}
		} catch (IOException e) {
			e.printStackTrace();
		}
		
	}
	
	private class Echo extends Thread {
		private Socket socket;
		private BufferedReader br;
		private PrintWriter pw;
			
		public Echo(Socket socket) throws IOException {
			this.socket = socket;
			InputStream is = socket.getInputStream();
			br = new BufferedReader(new InputStreamReader(is));
			OutputStream os = socket.getOutputStream();
			BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(os));
			pw = new PrintWriter(bw);
		}

		@Override
		public void run() {
			try {
				while (true) {
					String str = br.readLine();
					pw.println("From Server: " + str);
					pw.flush();
				}
			} catch (Exception e) {
				e.printStackTrace();
				close();
			}
		}
		
		private void close() {
			try {
				br.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
			pw.close();
			try {
				socket.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
			echos.remove(this);
		}
	}//Echo inner class end
	
	public static void main(String[] args) {
		new Server().startServer();
	}
		
}

다음으로 Client를 작성한다.

Client.java
package net.java_school.socket;

public class Client {
	//TODO
}

Client 역시 스레드가 필요할까? 사용자가 글을 쓰고 있는 동안에도 서버에서 메시지가 오는 경우에는 그렇다. 같은 이유로 채팅 프로그램의 클라이언트는 반드시 스레드가 필요하다. 하지만 에코는 클라이언트에서 서버로 메시지를 보내야 서버에서 메시지가 온다. 이 경우 서버의 응답시간이 길지 않는다면 굳이 스레드를 사용할 필요가 없다. 소켓 연결을 하는 코드를 추가한다.

Client.java
package net.java_school.socket;

import java.io.IOException;
import java.net.Socket;
import java.net.UnknownHostException;

public class Client {
	
	public static void main(String[] args) throws UnknownHostException, IOException {
		Socket socket = new Socket("localhost", 3000);
		//TODO
		
	}

}

사용자가 키보드에 입력한 문자열을 읽어 오는 입력 스트림이 필요하다.

Client.java
package net.java_school.socket;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
import java.net.UnknownHostException;

public class Client {
	
	public static void main(String[] args) throws UnknownHostException, IOException {
		Socket socket = new Socket("localhost", 3000);

		BufferedReader keyboard = new BufferedReader(new InputStreamReader(System.in));
		//TODO
	}

}

Server로 문자열을 보내기 위해서는 소켓을 목적지로 하는 출력 스트림이 필요하다.

Client.java
package net.java_school.socket;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;

public class Client {
	
	public static void main(String[] args) throws UnknownHostException, IOException {
		Socket socket = new Socket("localhost", 3000);

		BufferedReader keyboard = new BufferedReader(new InputStreamReader(System.in));
		
		OutputStream os = socket.getOutputStream();
		OutputStreamWriter osw = new OutputStreamWriter(os);
		PrintWriter pw = new PrintWriter(osw);
		
		//TODO		
		
	}

}

Server로부터 온 메시지를 읽기 위해서는 소켓을 근원지로 하는 입력 스트림이 필요하다.

Client.java
package net.java_school.socket;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;

public class Client {
	
	public static void main(String[] args) throws UnknownHostException, IOException {
		Socket socket = new Socket("localhost", 3000);

		BufferedReader keyboard = new BufferedReader(new InputStreamReader(System.in));

		OutputStream os = socket.getOutputStream();
		OutputStreamWriter osw = new OutputStreamWriter(os);
		PrintWriter pw = new PrintWriter(osw);
		
		InputStream is = socket.getInputStream();
		InputStreamReader isr = new InputStreamReader(is);
		BufferedReader br = new BufferedReader(isr);
		
		//TODO		
	}

}

스레드가 메인 스레드 하나이므로 사용자가 키보드에 입력하기를 기다리는 상태인지, 아니면 서버로부터 문자열을 기다리는 상태인지를 저장할 플래그를 둔다.

Client.java
package net.java_school.socket;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;

public class Client {
	
	public static void main(String[] args) throws UnknownHostException, IOException {
		Socket socket = new Socket("localhost", 3000);

		BufferedReader keyboard = new BufferedReader(new InputStreamReader(System.in));

		OutputStream os = socket.getOutputStream();
		OutputStreamWriter osw = new OutputStreamWriter(os);
		PrintWriter pw = new PrintWriter(osw);

		InputStream is = socket.getInputStream();
		InputStreamReader isr = new InputStreamReader(is);
		BufferedReader br = new BufferedReader(isr);
		
		boolean isCommandLineInputWaiting = true;
		String str = null;
		
		while (true) {
			if (isCommandLineInputWaiting) {
				str = keyboard.readLine();
				pw.println(str);
				pw.flush();
				isCommandLineInputWaiting = false;
				continue;
			}
			
			if (isCommandLineInputWaiting == false) {
				str = br.readLine();
				System.out.println(str);
				isCommandLineInputWaiting = true;
				continue;
			}
			
		}
		
	}
}

2대 이상의 컴퓨터에서 테스트하기

이클립스에서 Server를 먼저 실행한다. 명령 프롬프트를 열고 Client를 실행한다. 서버와 클라이언트를 서로 다른 컴퓨터로 테스트한다면, Sever를 실행하는 서버 컴퓨터는 3000번 포트를 개방해야 한다. 시스템이 윈도라면 Windows 방화벽에서 포트를 개방하는 조치가 필요하다. 클라이언트에서는 Client의 메인 메서드에서 "localhost"를 서버의 정확한 IP로 수정한다.

채팅

에코 예제를 확장해서 간단한 채팅 프로그램을 만들어 보자. 채팅은 에코와 달리 한 사용자가 서버로 전송한 메시지가 모든 사용자에게 전달되어야 한다. 먼저 클라이언트를 구현한다.

Client.java
package net.java_school.socket;

public class Client {
	//TODO
}

사용자가 키보드에 입력하는 동안에도 서버로부터 메시지가 전달될 수 있으니 스레드를 사용해야 한다.

Client.java
package net.java_school.socket;

public class Client extends Thread {
	
	@Override
	public void run() {
		//TODO
	}
	
}

run() 메서드는 서버로부터 오는 메시지를 콘솔에 출력하는 코드를 둔다. 소켓을 근원지로 하는 입력 스트림을 필요하다.

Client.java
package net.java_school.socket;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.Socket;

public class Client extends Thread {
	private Socket socket;
	private BufferedReader br;
	
	public Client() throws IOException {
		this.socket = new Socket("localhost", 3000);
		InputStream is = socket.getInputStream();
		br = new BufferedReader(new InputStreamReader(is));
		//TODO
	}
	
	@Override
	public void run() {
		//TODO
	}
	
	public static void main(String[] args) throws IOException {
		new Client();
	}
	
}

run() 메서드를 구현하자.

Client.java
package net.java_school.socket;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.Socket;

public class Client extends Thread {
	private Socket socket;
	private BufferedReader br;
	
	public Client() throws IOException {
		this.socket = new Socket("localhost", 3000);
		InputStream is = socket.getInputStream();
		br = new BufferedReader(new InputStreamReader(is));
		//TODO
	}
	
	@Override
	public void run() {
		String str = null;
		while(true) {
			try {
				str = br.readLine();
				System.out.println(str);
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
		
	}
	
	public static void main(String[] args) throws IOException {
		new Client();
	}
	
}

메시지를 보내려면 키보드를 근원지로 하는 입력 스트림과 소켓을 목적지로 하는 출력 스트림이 필요하다.

Client.java
package net.java_school.socket;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;

public class Client extends Thread {
	private Socket socket;
	private BufferedReader br;
	private BufferedReader keyboard;
	private PrintWriter pw;
	
	public Client() throws IOException {
		this.socket = new Socket("localhost", 3000);
		InputStream is = socket.getInputStream();
		br = new BufferedReader(new InputStreamReader(is));
		keyboard = new BufferedReader(new InputStreamReader(System.in));
		OutputStream os = socket.getOutputStream();
		BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(os));
		pw = new PrintWriter(bw);
	}
	
	@Override
	public void run() {
		String str = null;
		while(true) {
			try {
				str = br.readLine();
				System.out.println(str);
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}
	
	public static void main(String[] args) throws IOException {
		new Client();
	}
	
}

사용자가 키보드애 입력하는 내용을 서버로 전송하는 코드를 구현한다. 이 코드를 새로운 메서드 chatStart()에 구현한다.

Client.java
package net.java_school.socket;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;

public class Client extends Thread {
	private Socket socket;
	private BufferedReader br;
	private BufferedReader keyboard;
	private PrintWriter pw;
	
	public Client() throws IOException {
		this.socket = new Socket("localhost", 3000);
		InputStream is = socket.getInputStream();
		br = new BufferedReader(new InputStreamReader(is));
		keyboard = new BufferedReader(new InputStreamReader(System.in));
		OutputStream os = socket.getOutputStream();
		BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(os));
		pw = new PrintWriter(bw);
	}
	
	public void chatStart() {
		start();
		String str = null;
		while (true) {
			try {
				str = keyboard.readLine();
				pw.println(str);
				pw.flush();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}
	
	@Override
	public void run() {
		String str = null;
		while(true) {
			try {
				str = br.readLine();
				System.out.println(str);
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}
	
	public static void main(String[] args) throws IOException {
		new Client().chatStart();
	}
	
}

에코 예제에서 Server 내부 클래스 Echo의 run() 메서드만 아래와 같이 수정한다. 클라이언트가 강제 종료할 경우 null이 서버로 전송되므로, 서버로 전달된 문자열이 null이면 해당 Echo 객체의 자원을 반납하도록 한다. 채팅 프로그램이므로 클래스 이름을 Echo에서 Chatter로 변경한다.

@Override
public void run() {
	try {
		String str = null;
		while (true) {
			str= br.readLine();
			if (str != null) {
				for (Echo echo : echos) {
					echo.pw.println(str);
					echo.pw.flush();
				}
			} else {
				throw new Exception("null!");
			}
		}
	} catch (Exception e) {
		e.printStackTrace();
		close();
	}
}

버그

서버를 강제 종료하면 모든 사용자는 무한 루프로 null이 찍히는 걸 보게 된다. 클라이언트의 run() 메서드를 다음과 같이 수정한다.

Client.java
@Override
public void run() {
	String str = null;
	try {
		while((str = br.readLine()) != null) {
			System.out.println(str);
		}
	} catch (IOException e) {
		e.printStackTrace();
	}
}
참고