개인참고자료/자바(네트워크)

TCP 프로그래밍 - 간단한 채팅 클라이언트 / 서버 프로그래밍

경진 2008. 7. 19. 13:49
간단한 채팅 클라이언트 / 서버 프로그래밍

채팅 프로그래밍을 하기 전에 클라이언트의 동작 방법부터 정의하자. 채팅 클라이언트는 다음과 같은 방식으로 동작한다.

1. 채팅 클라이언트를 실행할 때 사용자의 아이디와 접속할 서버의 IP 주소를 전달한다.
2. 다른 클라이언트가 접속하면, "XXX님이 접속했습니다."란 메세지를 출력한다.
3. 다른사람의 대화 내용이 클라이언트에서 키보드로 입력하는 중에도 전달되어 화면에 출력된다.
4. 클라이언트에서 키보드로 문장을 입력한 후 엔터 키를 입력하면, 접속된 모든 클라이언트에 입력된 문자열이 전송된다.
5. 클라이언트를 종료하면 "XXX님이 접속 종료했습니다."란 메시지를 출력한다.

위의 클라이언트의 동작에 대한 정의를 보면 클라이언트는 다음과 같은 내용을 서버에 전송할 수 있어야 한다.

클라이언트의 ID 정보를 서버에 전송한다.
클라이언트에서 키보드로 입력된 문자열을 서버에 전송한다.
클라이언트의 접속이 종료될 경우, 접속이 종료되었음을 서버에 알린다.

클라이언트 동작 방법의 정의를 통해서 서버가 해야 할 일도 다음과 같이 정의할 수 있다.

1. 클라이언트 여러 개가 서버에 접속할 수 있어야 한다.
2. 클라이언트가 접속할 경우, 서버는 이미 접속되어 있는 클라이언트에게 "XXX님이 접속했습니다."라는 문자열을 전송해야 한다.
3. 클라이언트가 문자열을 전송할 경우, 서버는 접속되어 있는 모든 클라이언트에게 전달받은 문자열을 전송해야 한다.
4. 클라이언트가 접속을 종료했을 경우, 서버는 접속되어 있는 클라이언트에게 "XXX님이 접속 종료했습니다"라는 문자열을 전송해야 한다.

이런 특징을 살펴보면, 클라이언트, 서버 모두 동시에 처리해야 하는 일이 있다는 것을 알 수 있다. 클라이언트의 경우에는 입력과 출력을 동시에 할 수 있어야 하며, 서버는 클라이언트 여러 개로부터 입출력을 동시에 해야 한다. 그리고 참고적으로 자바에서 '동시에'라는 말이 나오면 '스레드'라는 말이 자연스럽게 생각나야 한다.

따라서 클라이언트는 입력과 출력을 동시에 하기 위해서 스레드를 사용할 것이며,, 서버는 클라이언트 여러개로부터 입출력을 하기 위해서 스레드를 사용한다.

또한 채팅 서버가 앞서 배웠던 EchoThreadServer보다 어려운 점은 EchoThreadServer는 클라이언트를 처리하는 스레드 간에 아무런 연관이 없었지만, 채팅 서버는 스레드 간에 연관을 맺고 있다. 그래야지만 하나의 스레드가 클라이언트로부터 문자열을 전송ㅇ 받으면, 다른 스레드에 있는 OutputStream을 통해서 전송 받은 문자열으르 재전송할 수 잇기 때문이다.

채팅 서버 프로그래밍 : ChatServer

채팅 서버는 클라이언트 여러 개의 요청을 처리하기 위해서 접속한 클라이언트마다 스레드 하나를 생성해서 동작하게 한다. 하지만 앞서 배웠던 EchoThreadServer와 다른 점은 채팅 서버의 경우 클라이언트가 보낸 문자열을 접속한 모든 클라이언트에게 전송하기 위해서 스레드 간에 접속한 클라이언트와 OutputStram을 공유하는 방법이 필요하다.

이번 예제에서는 스레드 간에 정보를 공유하기 위해서 해시맵(HashMap) 자료구조를 이용했다. 해시맵 자료구조를 스레드에게 어떻게 전달하는지, 해시맵에 저장되어 있는 OutputStream을 이용해서 모든 클라이언트에게 문자열을 어떻게 전송할 수 있는지 잘 살펴보기 바란다. 참고로 OutputStream과 InputStream을 직접 이용하지 않고 각각 PrintWriter를 이용하는 것과 같다는 것을 알아두기 바란다.

import java.net.*;               
import java.io.*;               
import java.util.*;                
               
public class ChatServer {                
               
    public static void main(String[] args) {           
        try{       
            ServerSocket server = new ServerSocket(10001);   
            System.out.println("접속을 기다립니다.");   
            HashMap hm = new HashMap();   
            while(true){   
                Socket sock = server.accept();
                ChatThread chatthread = new ChatThread(sock, hm);
                chatthread.start();
            } // while   
        }catch(Exception e){   
            System.out.println(e);
        }   
    } // main       
}            
           
class ChatThread extends Thread{           
    private Socket sock;       
    private String id;       
    private BufferedReader br;       
    private HashMap hm;       
    private boolean initFlag = false;       
    public ChatThread(Socket sock, HashMap hm){       
        this.sock = sock;   
        this.hm = hm;   
        try{   
            PrintWriter pw = new PrintWriter(new OutputStreamWriter(sock.getOutputStream()));   
            br = new BufferedReader(new InputStreamReader(sock.getInputStream()));   
            id = br.readLine();   
            broadcast(id + "님이 접속하였습니다.");   
            System.out.println("접속한 사용자의 아이디는 " + id + "입니다.");   
            synchronized(hm){   
                hm.put(this.id, pw);
            }   
            initFlag = true;   
        }catch(Exception ex){       
            System.out.println(ex);   
        }       
    } // 생성자           
    public void run(){           
        try{       
            String line = null;   
            while((line = br.readLine()) != null){       
                if(line.equals("/quit"))   
                    break;
                if(line.indexOf("/to ") == 0){   
                    sendmsg(line);
                }else   
                    broadcast(id + " : " + line);
            }       
        }catch(Exception ex){           
            System.out.println(ex);       
        }finally{           
            synchronized(hm){       
                hm.remove(id);   
            }       
            broadcast(id + " 님이 접속 종료하였습니다.");       
            try{       
                if(sock != null)   
                    sock.close();
            }catch(Exception ex){}       
        }           
    } // run               
    public void sendmsg(String msg){               
        int start = msg.indexOf(" ") +1;           
        int end = msg.indexOf(" ", start);           
        if(end != -1){           
            String to = msg.substring(start, end);       
            String msg2 = msg.substring(end+1);       
            Object obj = hm.get(to);       
            if(obj != null){       
                PrintWriter pw = (PrintWriter)obj;   
                pw.println(id + " 님이 다음의 귓속말을 보내셨습니다. :" + msg2);   
                pw.flush();   
            } // if   
        }       
    } // sendmsg           
    public void broadcast(String msg){           
        synchronized(hm){       
            Collection collection = hm.values();   
            Iterator iter = collection.iterator();   
            while(iter.hasNext()){   
                PrintWriter pw = (PrintWriter)iter.next();
                pw.println(msg);
                pw.flush();
            }   
        }       
    } // broadcast           
}               

먼저, 클라이언트가 접속할 수 있도록 10001번 포트에서 동작하는 ServerSocket을 생성했다. 그런후 스레드 간에 공유하게 할 HashMap 객체를 만들었다.

while 무한 반복문에서 accept() 메소드를 이용해서 클라이언트의 접속을 기다린다. 클라이언트가 접속할 경우, 클라이언트와 통신할 수 있게 도와주는 소켓 객체와 해시맵 객체를 ChatThread 생성자에 전달한 후 ChatThread의 start()메소드를 호출해서 스레드를 실행시킨다.

            ServerSocket server = new ServerSocket(10001);   
            System.out.println("접속을 기다립니다.");   
            HashMap hm = new HashMap();   
            while(true){   
                Socket sock = server.accept();
                ChatThread chatthread = new ChatThread(sock, hm);
                chatthread.start();
            }
※ Hashmap을 생성하는 위치와 ChatThread 생성자에 HashMap을 인자로 전달했다는 점이 중요하다. while문 안에서 생성되는 ChatThread는 하나의 HashMap 객체를 공유하는 것을 의미한다.  

ChatTread는 클라이언트와 실질적으로 통신하는 객체다. ChatThread의 생성자는 Socket과 HashMap을 인자로 전달받으며, Socket으로부터 InputStream과 OutputStream을 얻어서 각각 BufferedReader와 PrintWriter로 변환시킨다.

BufferedReader를 구했다면, readLine() 메소드를 호출하는데, 클라이언트가 가장먼저 전송하는 문자열이 클라이언트의 아이디기 때문이다. 이렇게 전달 받은 아이디는 필드 id에 지정한다.

그 후에 가장 중요한 작업이라고 할 수 있는 Hashmap에 id를 key로, PrintWriter를 value로 해서 저장하게 된다. HashMap에 클라이언트의 PrintWriter 객체를 저장해놓은 이유는 HashMap을 공유함으로써 BufferedReader로 전달 받은 문자열을 HashMap에 저장되어 잇는 모든 PrintWriter를 이용해서 쓰게 만들기 위해서다.

synchronized 블록 안에서 HashMap의 put() 메소드를 사용한 이유는 여러 스레드가 HashMap을 공유하기 때문인데, HashMap에 있는 자료를 삭제하거나, 수정하거나, 읽어오는 부분이 동시에 일어날 수 있기 때문이다.

    private Socket sock;       
    private String id;       
    private BufferedReader br;       
    private HashMap hm;       
    private boolean initFlag = false;       
    public ChatThread(Socket sock, HashMap hm){       
        this.sock = sock;   
        this.hm = hm;   
        try{   
            PrintWriter pw = new PrintWriter(new OutputStreamWriter(sock.getOutputStream()));   
            br = new BufferedReader(new InputStreamReader(sock.getInputStream()));   
            id = br.readLine();   
            broadcast(id + "님이 접속하였습니다.");   
            System.out.println("접속한 사용자의 아이디는 " + id + "입니다.");   
            synchronized(hm){   
                hm.put(this.id, pw);
            }   
            initFlag = true;   
        }catch(Exception ex){       
            System.out.println(ex);   
        }       
    }

ChatThread의 run 메소드를 설명하기 전에 ChatThread의 broadcast() 메소드 부터 설명하는 이유는 broadcast() 메소드가 접속한 모든 클라이언트에게 문자열을 전송하는 중요 메소드기 때문이다.

broadcast() 메소드는 문자열을 인자로 전달 받은 후, HashMap에 저장된 PrintWriter를 하나씩 얻어 사용한다. HashMap에는 접속된 모든 클라이언트의 PrintWriter가 있기 때문에 HashMap으로부터 PrintWriter 객체를 얻어와서 println() 메소드로 문자열을 출력한다는 것은 접속된 모든 클라이언트에게 문자열을 전송하는 효과를 발생시킨다.

    public void broadcast(String msg){           
        synchronized(hm){       
            Collection collection = hm.values();   
            Iterator iter = collection.iterator();   
            while(iter.hasNext()){   
                PrintWriter pw = (PrintWriter)iter.next();
                pw.println(msg);
                pw.flush();
            }   
        }       
    }

run() 메소드는 실제로 스레드의 동작을 정의하는 가장 중요한 메소드다. 소켓을 통해서 한 줄씩 읽어 들인 후 읽어 들인 문자열이 "/quit"일 경우에는 클라이언트가 종료 메시지를 보낸 것으로 판단해서 while문에서 빠져나가게 한다. 만약, "/to"로 시작하는 문자열을 전송했다면, 전체에게 보내는 메시지가 아니라 특정 아이디의 클라이언트에게 보내는 문자열로 판단하게 한다.

이를 제외한 나머지 문자열은 앞서 설명한 broadcast() 메소드를 이용해서, 접속한 모든 클라이언트에게 문자열을 전송한다.

    public void run(){           
        try{       
            String line = null;   
            while((line = br.readLine()) != null){       
                if(line.equals("/quit"))   
                    break;
                if(line.indexOf("/to ") == 0){   
                    sendmsg(line);
                }else   
                    broadcast(id + " : " + line);
            }        
……
    }

클라이언트가 "/quit" 문자열을 보내거나, 클라이언트가 접속을 강제로 종료했다면 finally 블록의 문장이 실행된다. 클라이언트가 접속을 종료했을 때의 처리에 대한 부분이므로 HashMap에 현재 스레드의 id에 해당하는 정보를 삭제한 후, 나머지 클라이언트에게 broadcast() 메소드를 이용해서 접속 종료 메시지를 전송하게 된다.

    public void run(){            
……
        }catch(Exception ex){           
            System.out.println(ex);       
        }finally{           
            synchronized(hm){       
                hm.remove(id);   
            }       
            broadcast(id + " 님이 접속 종료하였습니다.");       
            try{       
                if(sock != null)   
                    sock.close();
            }catch(Exception ex){}       
        }           
    }

특정 아이디에게 문자열을 전송하기 위해서 클라이언트는 다음과 같은 형식의 문자열을 서버 쪽에게 전달한다.

/to [전송받을 ID] [문자열]

sendmsg()는 위 형식의 문자열을 문자열 관련 메소드를 이용해서 분석한 뒤 HashMap에서 key 값이 전송 받을 ID를 찾아 PrintWriter를 이용해서 문자열을 전송한다.

    public void sendmsg(String msg){               
        int start = msg.indexOf(" ") +1;           
        int end = msg.indexOf(" ", start);           
        if(end != -1){           
            String to = msg.substring(start, end);       
            String msg2 = msg.substring(end+1);       
            Object obj = hm.get(to);       
            if(obj != null){       
                PrintWriter pw = (PrintWriter)obj;   
                pw.println(id + " 님이 다음의 귓속말을 보내셨습니다. :" + msg2);   
                pw.flush();   
            } // if   
        }       
    }

채팅 클라이언트 프로그래밍 : ChatClient

채팅 클라이언트를 작성한다. (윈도우용 프로그램이 아닌 명령 창에서 동작하는 프로그램이다)

채팅 클라이언트는 키보드로 부터 입력 받은 문자열을 소켓을 통해서 구한 PrintWriter를 이용해서 출력한다. 그 결과 서버에 문자열이 전송된다. 그런데 문제는 키보드로 사용자가 글을 입력하고 있는 중간에도 서버에서 다른 클라이언트에서 전송한 문자열을 소켓을 통해서 전달 받을 수 있다는 것이다.

채팅 클라이언트의 작동 모습

채팅 클라이언트의 작동 모습

결국, 메인스레드가 키보드로부터 입력을 받을 때에는 다른 일을 할 수 없기 때문에 서버로 부터 전달 받은 문자열을 화면에 출력할 수 없다는 문제가 생긴다. 이러한 문제를 해결하려면 입력 따로, 출력 따로 동작하도록 채팅 클라이언트를 작성해야한다. 키보드로부터 문자열을 입력 받아 서버로 전달하는 것은 메인 스레드로 처리하고, 서버로부터 전송받은 문자열은 스레드를 따로 작동 시켜서 처리하게 한다.

서버가 전달하는 문자열을 처리하기 위한 스레드가 있는 채팅 클라이언트

서버가 전달하는 문자열을 처리하기 위한 스레드가 있는 채팅 클라이언트

ChatClient 구현

import java.net.*;           
import java.io.*;            
           
public class ChatClient {            
           
    public static void main(String[] args) {       
        if(args.length != 2){   
            System.out.println("사용법 : java ChatClient id 접속할서버ip");
            System.exit(1);
        }   
        Socket sock = null;   
        BufferedReader br = null;   
        PrintWriter pw = null;   
        boolean endflag = false;   
        try{   
            sock = new Socket(args[1], 10001);
            pw = new PrintWriter(new OutputStreamWriter(sock.getOutputStream()));       
            br = new BufferedReader(new InputStreamReader(sock.getInputStream()));       
            BufferedReader keyboard = new BufferedReader(new InputStreamReader(System.in));       
            // 사용자의 id를 전송한다.       
            pw.println(args[0]);       
            pw.flush();       
            InputThread it = new InputThread(sock, br);       
            it.start();       
            String line = null;       
            while((line = keyboard.readLine()) != null){       
                pw.println(line);   
                pw.flush();   
                if(line.equals("/quit")){   
                    endflag = true;
                    break;
                }   
            }       
            System.out.println("클라이언트의 접속을 종료합니다.");       
        }catch(Exception ex){           
            if(!endflag)       
                System.out.println(ex);   
        }finally{           
            try{       
                if(pw != null)   
                    pw.close();
            }catch(Exception ex){}       
            try{       
                if(br != null)   
                    br.close();
            }catch(Exception ex){}       
            try{       
                if(sock != null)   
                    sock.close();
            }catch(Exception ex){}       
        } // finally           
    } // main               
} // class                    
                   
class InputThread extends Thread{                   
    private Socket sock = null;               
    private BufferedReader br = null;               
    public InputThread(Socket sock, BufferedReader br){               
        this.sock = sock;           
        this.br = br;           
    }               
    public void run(){               
        try{           
            String line = null;       
            while((line = br.readLine()) != null){       
                System.out.println(line);   
            }       
        }catch(Exception ex){           
        }finally{           
            try{       
                if(br != null)   
                    br.close();
            }catch(Exception ex){}       
            try{       
                if(sock != null)   
                    sock.close();
            }catch(Exception ex){}       
        }           
    } // InputThread               
}                   

서버에 접속하기 위해서 Socket 객체를 생성했으며, Socket으로부터 InputStream과 OutputStream을 얻어와서 각각 BufferedReader와 PrintWriter 형태로 변환시킨다.

키보드로부터 한 줄씩 입력 받기 위한 BufferedReader를 생성한 후, 서버로부터 전달된 문자열을 표준 출력 장치인 모니터에 출력하는 InputThread 객체를 생성한 후 start() 메소드를 실행한다.

            sock = new Socket(args[1], 10001);
            pw = new PrintWriter(new OutputStreamWriter(sock.getOutputStream()));       
            br = new BufferedReader(new InputStreamReader(sock.getInputStream()));       
            BufferedReader keyboard = new BufferedReader(new InputStreamReader(System.in));       
            // 사용자의 id를 전송한다.       
            pw.println(args[0]);       
            pw.flush();       
            InputThread it = new InputThread(sock, br);       
            it.start();       

키보드로부터 한 줄씩 입력 받아 서버에 전송한다. "/quit" 를 입력했을 경우 클라이언트를 종료 시킨다.

            while((line = keyboard.readLine()) != null){       
                pw.println(line);   
                pw.flush();   
                if(line.equals("/quit")){   
                    endflag = true;
                    break;
                }   
            }  

다음은 서버로부터 전달 받은 문자열을 표준입력 장치인 모니터에 출력하는 InputThread 객체다. 생성자에 서버로부터 읽어 들이는 BufferedReader와 Socket 객체를 인자로 전달받는다. BufferedReader는 서버로부터 읽어 들이기 위해서 사용되며, Socket 객체는 Server와 접속이 끊어질 경우 close() 하기 위해서 사용된다.

class InputThread extends Thread{                   
    private Socket sock = null;               
    private BufferedReader br = null;               
    public InputThread(Socket sock, BufferedReader br){               
        this.sock = sock;           
        this.br = br;           
    }
……
}

InputThread의 run() 메소드는 서버로부터 문자열을 읽어 들여 표준 입력 장치인 모니터에 출력하는 역할을 하게 된다.

class InputThread extends Thread{                    
……
    public void run(){               
        try{           
            String line = null;       
            while((line = br.readLine()) != null){       
                System.out.println(line);   
            }        
……
        } // InputThread 메소드 종료
}

채팅 클라이언트와 서버의 실행

채팅 서버를 실행한 후, 새로운 명령 창을  두 개이상 연 후 다음과 같이 채팅 클라이언트를 실행해서 채팅이 잘되는지 확인한다.

채팅 서버 실행

채팅 서버 실행 화면

채팅 클라이언트 실행

채팅 클라이언트 실행 화면

채팅 클라이언트 실행

채팅 클라이언트 실행 화면

채팅 클라이언트 실행

채팅 클라이언트 실행 화면

채팅 클라이언트 / 서버 에서 아쉬운 부분

채팅 클라이언트와 서버는 방(room)이 하나만 있다. 그렇기 때문에 많은 클라이언트가 접속하게 되면 하나의 방 안에서 아주 복잡하게 채팅을 해야 한다. 이러한 점을 해결하려면 방 여러 개를 운영해야 한다.