이번 글에서는 채팅 프로그램을 만들면서 소켓 프로그래밍 및 멀티 쓰레드 프로그래밍을 사용하는 방법에 대해 알아보도록 하겠습니다.
네트워크( TCP/IP(링크) , HTTP(링크) )와 쓰레드의 개념은 자세히 다루지 않을 것이며, 채팅 프로그램 구현 자체에 초점을 맞출 것입니다.
전체 소스는 깃헙에서 확인할 수 있습니다.
1. API
먼저 소켓 프로그래밍에서 사용되는 API들을 정리해보겠습니다.
1) ServerSocket
서버 역할을 하는 소켓 객체입니다.
클라이언트의 연결 요청을 기다리면서 연결 요청에 대한 수락을 담당합니다.
함수명 | 설명 |
public void bind(SocketAddress endpoint) | 어떤 소켓으로 연결을 기다릴 것인지 바인딩 |
public Socket accept() | 연결을 기다리며, 연결이 될 때까지 block상태가 됨 |
2) Socket
클라이언트와 서버 간의 통신을 직접 담당합니다.
함수명 | 설명 |
public SocketAddress getRemoteSocketAddress() | 소켓에 연결된 종단의 주소를 반환 |
public InputStream getInputStream() | 소켓을 위한 input stream을 반환 |
public OutputStream getOutputStream() | 소켓을 위한 output stream을 반환 |
public void connect(SocketAddress endpoint) | 서버에 연결 |
3) InetAddress
IP 주소를 표현 할 때 사용하는 클래스입니다.
함수명 | 설명 |
public static InetAddress getLocalHost() | 로컬호스트의 “호스트이름/IP주소”를 반환 |
public String getHostAddress() | IP주소를 반환 |
public String getHostName() | 호스트 이름을 문자열로 반환 |
4) InetSocketAddress
SocketAddress를 상속받은 클래스로서, 소켓의 IP 주소와 port번호를 알 수 있도록 구현한 클래스입니다.
도메인 이름만 알아도 객체를 생성할 수 있습니다.
( ServerSocket 객체의 bind() , Socket객체의 connect() 메서드를 호출할 때 인자로 사용됩니다. )
함수명 | 설명 |
InetSocketAddress ( String hostname, int port ) InetSocketAddress( InetAddress addr, int port ) |
첫 번째 인자로 호스트 이름 또는 IP 주소를, 두 번째 인자로 포트번호를 넘겨서 socket address를 생성 |
public final int getPort() | 포트번호를 반환 |
public final InetAddress getAddress() | InetAddress를 반환 |
5) InputStream과 OutputStream
byte 데이터를 입출력 하기 위한 IO 클래스
InputStream
함수명 | 설명 |
public int read(byte[] b) | 스트림을 통해 buffer array b를 읽으며, 데이터가 input될 때까지 block 상태 더 이상 읽을 데이터가 없으면 -1을 반환 |
OutputStream
함수명 | 설명 |
public void write(byte[] b) | buffer b를 쓴다. |
2. TCP 서버와 클라이언트 구현 테스트
1) TCPServer Class
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class TCPServer {
public static void main(String[] args){
final int SERVER_PORT = 5000;
ServerSocket serverSocket = null;
try{
// 1. 서버 소켓 객체 생성
serverSocket = new ServerSocket();
// 2. 소켓을 호스트의 포트와 binding
String localHostAddress = InetAddress.getLocalHost().getHostAddress();
serverSocket.bind(new InetSocketAddress(localHostAddress, SERVER_PORT));
System.out.println("[server] binding! \naddress:" + localHostAddress + ", port:" + SERVER_PORT);
// 3. 클라이언트로부터 연결 요청이 올 때까지 대기
// 연결 요청이 오기 전까지 서버는 block 상태이며,
// TCP 연결 과정인 3-way handshake로 연결이 되면 통신을 위한 Socket 객체가 반환됨
// TCP 연결은 java에서 처리해주며, 더 내부적으로는 OS가 처리한다.
Socket socket = serverSocket.accept();
// 4. 연결 요청이 오면 연결이 되었다는 메시지 출력
InetSocketAddress remoteSocketAddress =(InetSocketAddress)socket.getRemoteSocketAddress();
String remoteHostName = remoteSocketAddress.getAddress().getHostAddress();
int remoteHostPort = remoteSocketAddress.getPort();
System.out.println("[server] connected! \nconnected socket address:" + remoteHostName
+ ", port:" + remoteHostPort);
}
catch(IOException e){
e.printStackTrace();
}
finally{
try{
if( serverSocket != null && !serverSocket.isClosed() ){
serverSocket.close();
}
}
catch(IOException e){
e.printStackTrace();
}
}
}
}
- ServerSocket 객체로 연결을 받아들이는 소켓을 생성한 후, 클라이언트로부터 요청을 기다립니다.
- 포트 번호는 임의로 5000으로 설정하였고, 프로그램을 실행하면 아래와 같은 메시지가 출력 될 것입니다.
- connected 메시지는 아직 출력이 되지 않았습니다. 아직 클라이언트로부터 요청이 오지 않았기 때문이죠.
- 즉, accept() 메서드가 호출 되면서 클라이언트로부터 요청이 오기 전까지는 block 상태임을 알 수 있습니다.
이어서 TCP Client를 작성할 것인데, binding 메시지에서 출력된 address( 192.168.219.107 )를 사용할 것입니다. ( 물론 저하고 address가 다릅니다. )
2) TCPClient Class
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
public class TCPClient {
// 1. TCP 서버의 IP와 PORT를 상수로 할당
// 실제로는 서버의 IP보다는 도메인을 작성하는 것이 좋다.
private static final String SERVER_IP = "192.168.219.107";
private static final int SERVER_PORT = 5000;
public static void main(String[] args) {
Socket socket = null;
try{
// 2. 서버와 연결을 위한 소켓을 생성
socket = new Socket();
// 3. 생성한 소켓을 서버의 소켓과 연결(connect)
socket.connect(new InetSocketAddress(SERVER_IP, SERVER_PORT));
}
catch (IOException e) {
e.printStackTrace();
}
finally{
try{
if( socket != null && !socket.isClosed()){
socket.close();
}
}
catch(IOException e){
e.printStackTrace();
}
}
}
}
- 상수로 TCP Server의 IP를 작성한 후, 통신을 담당하는 Socket 객체를 생성하여 connect() 메서드를 호출합니다.
이제 TCP 서버와 클라이언트 클래스를 모두 구현했습니다.
실행 방법은 Putty 같은 터미널을 사용해도 되지만, 간단하게 cmd 창에서 확인하도록 하겠습니다.
우선 workspace 폴더가 어디에 있는지 확인을 해야 합니다.
( 이클립스에서 프로젝트 폴더를 우클릭 한 후 가장 아래에 있는 Properties를 클릭하시면 경로를 확인할 수 있습니다. )
cmd 창을 실행하여 위의 경로까지 이동을 한 후, bin 폴더로 이동합니다.
저 같은 경우는 다음과 같습니다.
# cd C:\Users\samsung\workspace\TCPTest\bin
그리고 TCPServer를 실행 하도록 하겠습니다.
class 파일을 실행하는 방법은 아래와 같이 패키지명( TCPServer )과 함께 파일명을 작성합니다.
# java TCPTest.TCPServer
이어서 클라이언트도 실행해보겠습니다.
# java TCPTest.TCPClient
4. 테스트 코드의 문제점
이상으로 간단하게 TCP의 서버와 클라이언트가 어떤 식으로 동작하는지를 살펴보았습니다.
그런데 서버가 하나의 연결이 요청된 후, 바로 종료가 되었습니다.
이렇게 되면, 또 다른 클라이언트는 서버에 접속을 할 수 없겠네요.
그래서 서버는 요청을 받아들이는 부분, 즉 accept() 메서드를 호출하는 부분을 무한 루프로 처리하여 계속해서 연결을 받아주도록 하려고 합니다.
무한루프로 계속 요청을 받아들이는 것은 좋은데, 그러면 서버는 요청을 받아들이는 역할만 하고 끝나겠네요.
요청을 받아들인 이후의 동작, 예를 들어 클라이언트와 채팅을 주고 받는다든지, 파일을 전송한다든지 등의 과정은 할 수 없게 됩니다.
정리하면 TCP 서버는 여러 클라이언트의 요청을 처리 할 수 있어야 하고, 동시에 각 클라이언트들과 통신을 할 수 있어야 합니다.
그래서 필요한 것이 쓰레드입니다.
쓰레드를 통해 TCPServer의 메인 메서드는 무한루프를 돌면서 요청을 계속 받아들이고, 요청이 오면 새로운 쓰레드를 생성하여 각 클라이언트들과 통신을 할 수 있도록 하는 개선할 것입니다.
이 과정을 코드로 표현하면 다음과 같습니다.
try{
// 1. 서버 소켓 객체 생성
serverSocket = new ServerSocket();
// 2. 소켓을 호스트의 포트와 binding
String localHostAddress = InetAddress.getLocalHost().getHostAddress();
serverSocket.bind(new InetSocketAddress(localHostAddress, SERVER_PORT));
System.out.println("[server] binding! \naddress:" + localHostAddress + ", port:" + SERVER_PORT);
while(true) {
// 3. 클라이언트로부터 연결 요청이 올 때까지 대기
Socket socket = serverSocket.accept();
// 4. 연결 요청이 오면 연결이 되었다는 메시지 출력
new ProcessThread(socket).start();
}
}
TCPServer 클래스의 try 부분을 수정한 것인데, 3, 4번을 수정했습니다.
ProcessThread는 뒤에서 작성할 클래스(ChatServerProcessThread)로서, Thread 클래스를 상속 받습니다.
ProcessThread 클래스에는 클라이언트로부터 요청을 받은 후에 수행될 로직을 작성할 것입니다.
5. 채팅 프로그램
지금까지 TCPServer와 Client를 구현하였고, 많은 클라이언트의 요청을 처리하는 방법에 대해 알아보았습니다.
이제 채팅프로그램을 구현할 준비가 끝났습니다.
UI는 java.awt 패키지를 사용할 것인데, awt 관련 코드는 전혀 몰라도 상관이 없습니다.
1) 개요
채팅 프로그램의 구성 클래스는 다음과 같습니다.
- ChatServer
- 클라이언트의 요청을 받아들이는 TCP
- ChatServerProcessThread
- 클라이언트로부터 요청이 오면 클라이언트와 통신을 하기 위해 서버에서 생성되는 쓰레드
- ChatClientApp
- TCP 클라이언트 클래스
- ChatWindow
- java.awt로 작성된 UI 화면 클래스
채팅 프로그램에서 서버와 클라이언트의 구조는 다음과 같습니다.
- 서버와 클라이언트가 연결이 되면, 서버는 클라이언트가 메시지를 보낼 때까지 대기 상태입니다.
- 클라이언트가 메시지를 보내면( write ) 서버는 이를 읽고 ( read ), broadcast 방식으로 자신과 연결된 모든 클라이언트에게 메시지를 보냅니다
서버는 여러 클라이언트를 계속해서 받아야 하기 때문에 무한 루프를 돌 것이며, 클라이언트도 메시지를 계속해서 작성할 수 있어야 하므로 무한 루프를 돌아야 합니다.
또한 코드 상에서 통신을 위한 프로토콜을 작성했습니다.
클라이언트는 요청을 할 때, 아래와 같은 프로토콜을 서버에 보냅니다.
- join
- 채팅 서버에 참여
- message
- 메시지를 보냄
- quit
- 방을 나감
어떤 행위를 하겠다는 의미를 서버에 전송한다고 보시면 됩니다.
이와 같은 개략적인 내용을 참고하여 코드를 살펴보도록 하겠습니다.
2) ChatServer Class
package chat;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;
public class ChatServer {
public static final int PORT = 5000;
public static void main(String[] args) {
ServerSocket serverSocket = null;
List<printwriter> listWriters = new ArrayList<printwriter>();
try {
// 1. 서버 소켓 생성
serverSocket = new ServerSocket();
// 2. 바인딩
String hostAddress = InetAddress.getLocalHost().getHostAddress();
serverSocket.bind( new InetSocketAddress(hostAddress, PORT) );
consoleLog("연결 기다림 - " + hostAddress + ":" + PORT);
// 3. 요청 대기
while(true) {
Socket socket = serverSocket.accept();
new ChatServerProcessThread(socket, listWriters).start();
}
}
catch (IOException e) {
e.printStackTrace();
}
finally {
try {
if( serverSocket != null && !serverSocket.isClosed() ) {
serverSocket.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
private static void consoleLog(String log) {
System.out.println("[server " + Thread.currentThread().getId() + "] " + log);
}
}
3) ChatServerProcessThread Class
package chat;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.List;
public class ChatServerProcessThread extends Thread{
private String nickname = null;
private Socket socket = null;
List<printwriter> listWriters = null;
public ChatServerProcessThread(Socket socket, List<printwriter> listWriters) {
this.socket = socket;
this.listWriters = listWriters;
}
@Override
public void run() {
try {
BufferedReader buffereedReader =
new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
PrintWriter printWriter = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));
while(true) {
String request = buffereedReader.readLine();
if( request == null) {
consoleLog("클라이언트로부터 연결 끊김");
doQuit(printWriter);
break;
}
String[] tokens = request.split(":");
if("join".equals(tokens[0])) {
doJoin(tokens[1], printWriter);
}
else if("message".equals(tokens[0])) {
doMessage(tokens[1]);
}
else if("quit".equals(tokens[0])) {
doQuit(printWriter);
}
}
}
catch(IOException e) {
consoleLog(this.nickname + "님이 채팅방을 나갔습니다.");
}
}
private void doQuit(PrintWriter writer) {
removeWriter(writer);
String data = this.nickname + "님이 퇴장했습니다.";
broadcast(data);
}
private void removeWriter(PrintWriter writer) {
synchronized (listWriters) {
listWriters.remove(writer);
}
}
private void doMessage(String data) {
broadcast(this.nickname + ":" + data);
}
private void doJoin(String nickname, PrintWriter writer) {
this.nickname = nickname;
String data = nickname + "님이 입장하였습니다.";
broadcast(data);
// writer pool에 저장
addWriter(writer);
}
private void addWriter(PrintWriter writer) {
synchronized (listWriters) {
listWriters.add(writer);
}
}
private void broadcast(String data) {
synchronized (listWriters) {
for(PrintWriter writer : listWriters) {
writer.println(data);
writer.flush();
}
}
}
private void consoleLog(String log) {
System.out.println(log);
}
}
- listWriters 변수는 채팅 서버에 연결된 모든 클라이언트들을 저장하고 있는 List입니다. ( join시 추가됨 )
- boardcast() 메서드는 서버에 연결된 모든 클라이언트들에게 메시지를 전달하기 위한 메서드입니다.
4) ChatClientApp Class
package chat;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
public class ChatClientApp {
private static final String SERVER_IP = "192.168.219.107";
private static final int SERVER_PORT = 5000;
public static void main(String[] args) {
String name = null;
Scanner scanner = new Scanner(System.in);
while( true ) {
System.out.println("대화명을 입력하세요.");
System.out.print(">>> ");
name = scanner.nextLine();
if (name.isEmpty() == false ) {
break;
}
System.out.println("대화명은 한글자 이상 입력해야 합니다.\n");
}
scanner.close();
Socket socket = new Socket();
try {
socket.connect( new InetSocketAddress(SERVER_IP, SERVER_PORT) );
consoleLog("채팅방에 입장하였습니다.");
new ChatWindow(name, socket).show();
PrintWriter pw = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);
String request = "join:" + name + "\r\n";
pw.println(request);
}
catch (IOException e) {
e.printStackTrace();
}
}
private static void consoleLog(String log) {
System.out.println(log);
}
}
- 클라이언트는 채팅 서버에 연결을 하면, new ChatWinodw() 메서드를 호출하여 UI 화면이 나타나게 됩니다.
5) ChatWindow Class
package chat;
import java.awt.BorderLayout;
import java.awt.Button;
import java.awt.Color;
import java.awt.Frame;
import java.awt.Panel;
import java.awt.TextArea;
import java.awt.TextField;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class ChatWindow {
private String name;
private Frame frame;
private Panel pannel;
private Button buttonSend;
private TextField textField;
private TextArea textArea;
private Socket socket;
public ChatWindow(String name, Socket socket) {
this.name = name;
frame = new Frame(name);
pannel = new Panel();
buttonSend = new Button("Send");
textField = new TextField();
textArea = new TextArea(30, 80);
this.socket = socket;
new ChatClientReceiveThread(socket).start();
}
public void show() {
// Button
buttonSend.setBackground(Color.GRAY);
buttonSend.setForeground(Color.WHITE);
buttonSend.addActionListener( new ActionListener() {
@Override
public void actionPerformed( ActionEvent actionEvent ) {
sendMessage();
}
});
// Textfield
textField.setColumns(80);
textField.addKeyListener( new KeyAdapter() {
public void keyReleased(KeyEvent e) {
char keyCode = e.getKeyChar();
if (keyCode == KeyEvent.VK_ENTER) {
sendMessage();
}
}
});
// Pannel
pannel.setBackground(Color.LIGHT_GRAY);
pannel.add(textField);
pannel.add(buttonSend);
frame.add(BorderLayout.SOUTH, pannel);
// TextArea
textArea.setEditable(false);
frame.add(BorderLayout.CENTER, textArea);
// Frame
frame.addWindowListener(new WindowAdapter() {
public void windowClosing(WindowEvent e) {
PrintWriter pw;
try {
pw = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);
String request = "quit\r\n";
pw.println(request);
System.exit(0);
}
catch (IOException e1) {
e1.printStackTrace();
}
}
});
frame.setVisible(true);
frame.pack();
}
// 쓰레드를 만들어서 대화를 보내기
private void sendMessage() {
PrintWriter pw;
try {
pw = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);
String message = textField.getText();
String request = "message:" + message + "\r\n";
pw.println(request);
textField.setText("");
textField.requestFocus();
}
catch (IOException e) {
e.printStackTrace();
}
}
private class ChatClientReceiveThread extends Thread{
Socket socket = null;
ChatClientReceiveThread(Socket socket){
this.socket = socket;
}
public void run() {
try {
BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
while(true) {
String msg = br.readLine();
textArea.append(msg);
textArea.append("\n");
}
}
catch (IOException e) {
e.printStackTrace();
}
}
}
}
- sendMessage()는 메시지를 보내는 메서드입니다.
- inner class인 ChatClientReceiveThread는 서버가 전달하는 메시지를 수신하기 위한 쓰레드입니다.
- 즉, 자신 말고 다른 클라이언트가 메시지를 입력하게 되면, 서버는 broadcast를 통해 모든 클라이언트에게 메시지를 보내게 되는데, 이 메시지를 수신하기 위한 쓰레드라고 생각하시면 됩니다.
이상으로 TCP 소켓 프로그래밍 및 채팅 프로그램을 구현해보았습니다.
글이 상당히 길어졌는데 내용이 잘 전달되었으면 좋겠습니다...