ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • TIL #27 - 채팅 프로그램
    프로그래밍/TIL(국비과정) 2020. 5. 6. 19:34

     

    지난 번엔 내가 서버이자 클라이언트가 되는 나홀로 채팅 프로그램이었다면 

    이번엔 정말로 하나의 서버에 여러 클라이언트들이 들어와 대화를 나눌 수 있는 프로그램을 만들어본다. 

     

    먼저 클라이언트가 될 ChatClient.java 이다. 

    import javax.swing.JButton;
    import javax.swing.JFrame;
    import javax.swing.JTextArea;
    import javax.swing.JPanel;
    import javax.swing.JTextField;
    import javax.swing.JScrollPane;
    import javax.swing.ScrollPaneConstants;
    import javax.swing.JOptionPane;
    
    import java.awt.Container;
    import java.awt.Font;
    import java.awt.BorderLayout;
    import java.awt.event.ActionListener;
    import java.awt.event.ActionEvent;
    import java.awt.event.WindowAdapter;
    import java.awt.event.WindowEvent;
    
    
    import java.io.BufferedReader;
    import java.io.InputStreamReader;
    import java.io.IOException;
    import java.net.UnknownHostException;
    import java.io.OutputStreamWriter;
    import java.io.PrintWriter;
    import java.net.Socket;
    
    class ChatClient extends JFrame implements ActionListener, Runnable{
    	private JTextArea output; //output 용
    	private JTextField input; 
    	private JButton sendBtn;
    	private Socket socket; // 소켓생성 
    	private BufferedReader reader; 
    	private PrintWriter writer; 

     

    채팅창 JFrame이다. 

    public ChatClient(){
    	
    		output = new JTextArea(11, 23);
    		output.setFont(new Font("Serif",Font.BOLD ,16));
    		output.setEditable(false); // textarea를 수정 못하게 막는다. 
    
    		JScrollPane scroll = new JScrollPane(output);
    		scroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS); // 스크롤 항상 보이기 
    		add(scroll);
    
    		JPanel sendP = new JPanel();
    		sendP.setLayout(new BorderLayout()); 
    		input = new JTextField();
    		sendBtn = new JButton("보내기");
    		sendP.add("Center", input); 
    		sendP.add("East", sendBtn);
    		
    		Container c = getContentPane();
    		
    		c.add("Center", scroll);
    		c.add("South", sendP);
    
    		setTitle("채팅");
    		setBounds(700, 200, 400, 500);
    		setVisible(true);

    여기서 주의할 것은 setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); 을 사용해 

    곧장 프로그램을 종료시키면 안된다. 

    대화를 그만하고 채팅창을 나가고자 한다면 먼저 서버에게 끝낼 것이라고 알려주어야한다. 

    WindowListener를 익명이너클래스로 불러온다. 

    addWindowListener(new WindowAdapter(){
    			@Override
    			public void windowClosing(WindowEvent e){
    				// 이제 서버에게 끊겠다고 알리는 시점  
    				writer.println("exit");
    				writer.flush();
    			}
    		});
    	}

    종료 의사를 서버에게 꼭 알려준다. 

     

    이제 서버와 본격적으로 연결을 해본다. 

    public void service(){
    	String serverIP = JOptionPane.showInputDialog(this, "서버 IP를 입력하세요",
    								"IP주소");
        if(serverIP == null || serverIP.length() == 0){
        	System.out.println("서버IP가 입력되지 않았습니다");
    			System.exit(0);
    
    		}

    만약 서버IP 가 null이거나 길이가 이면 서버 IP 가 입력되지 않았다는 메세지를 띄운다.

    null인 경우는 잘 없지만 가끔 있기 때문에 따로 지정해준다. 

    서버 IP는  아예 잘못되면 채팅을 할 수 없으므로 종료시키다. 

     

    제대로된 서버 IP를 입력해서 채팅 프로그램에 들어왔다면 닉네임을 묻는다. 

    닉네임이 없다면 guest 라는 이름을 준다. 

    	String nickName = JOptionPane.showInputDialog(this, "닉네임을 입력하세요.",
    						"닉네임", JOptionPane.INFORMATION_MESSAGE);
    	
    		if(nickName == null || nickName.length() == 0){
    			nickName = "guest"; // guest 라는 별명 준다. 
    		}
    

     

    이제 클라이언트와 대화를 위해 소켓을 생성한다.

    reader와 writer로 인한 IOException이 발생하기 때문에 미리 try-catch를 잡아준다 

    try{
    	// 소켓생성 
    	socket = new Socket(serverIP, 9500); // 서버IP가 고정적이면 안되기 때문 
    			
    	// SOCKET이 있지마자 IO가 들어온다 
    	reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
    	writer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()));
    

      소켓이 가지고 있을 서버IP는 고정적이면 안되므로 변수를 따로 설정해 변수를 넣어준다. 

    SOCKET이 생기자 마자 IO(Input/Output)이 들어오므로 reader와 writer를 작성해준다. 

    여기서 생기는 Exception은 위에서 말한 IOException과 함께 UnknowHostException 도 발생하므로 함께 try-catch로 잡아준다. 

    }catch(UnknownHostException e){
    	System.out.println("서버를 찾을 수 없습니다");
    	e.printStackTrace();
    	System.exit(0);
    }catch(IOException e){
    	System.out.println("서버와 연결이 안되었습니다");
    	e.printStackTrace();
    	System.exit(0);
    }

     

    이로써 서버도 잘 찾고 연결도 잘 되었다면 들어왔다는 표시를 해준다. 

    누가 들어왔다는 표시를 해주기 위해 서버로 닉네임을 보내준다. 

    writer.println(nickName);
    writer.flush();

     

    대화는 각각 순차적으로 진행되는 것이 아니다.

    대화는 정해진 순서 없이 입력하면 입력한 대로 출력되기 때문에 스레드를 생성해준다. 

    여기서 스레드로 만들어 줄 것은 나 이므로 this를 넣어준다. 

    Thread t = new Thread(this);
    t.start();
    }

    스레드가 start되면 자동으로 오버라이드된 run() 메서드로 가게 된다. 

    run() 메서드를 작성해준다. 

    @Override
    	public void run(){
        
       	 String line;
    		while(true){

    여기서 서버로부터 온 응답을 받는다. 

    대화가 끝날 때까지 코드가 살아있어야하므로 while문을 돌려준다. 

    먼저 사용자가 채팅을 마치고 나가고자 했을 경우이다. 

    try{
    	line = reader.readLine();
    	if(line == null || line.equals("exit")){
        
    	reader.close();
    	writer.close();
    	socket.close();
    
    	System.exit(0);
    }

    먼저 서버로부터 읽어온 데이터를 line으로 받는다. 

    line이 null이거나 exit 인 경우에 reader, writer, socket을 순서대로 close 해 준 후 시스템을 종료시킨다. 

    간혹 종료되어도 백그라운드에서 스레드가 도는 경우가 있다. 

    그렇게 돌고 돌다가 null이 들어올 수 있기 때문에 null 인 경우를 염두해 둔 것이다. 

     

    여기서 exit는 위의 window event로 exit를 받았을 경우이다. 

     

    만약 종료가 아닌 일반적인 메시지일 경우엔 채팅을 이어갈 수 있게 한다. 

    서버로 부터 받은 내용 line을 output에게 보낸다. 

        output.append(line + "\n"); // 받은 내용line을 output에 보낸다. 
        int pos = output.getText().length();
        output.setCaretPosition(pos);
    	
     	   }catch(IOException e){
    			e.printStackTrace();
    		}
    	}// while()
    } 

    그런 후 output의 text를 가져와 길이를 잰 후 output의 길이에 따라 스크롤바가 함께 따라 내려갈 수 있게 setCaretPosition을 지정해 준다. 

     

    이번엔 버튼 액션이다. 

    채팅창에는 보내기 버튼이 있다. 

    하지만 우리가 채팅을 할 때엔 보내기 버튼을 직접 클릭하는 경우도 있지만 엔터를 사용해 보내기를 사용할 떄도 있다. 

    엔터 이벤트도 생성해준다. 

    // 버튼 액션 
    @Override
    public void actionPerformed(ActionEvent e){
    	String data = input.getText();	
       	writer.println(data);
    	writer.flush();
       	input.setText("");
    }
    public static void main(String[] args) {
    		new ChatClient().service();	
    	}
    }
    
        

     

    위에서 input과 sendBtn이 동시에 넘어오지만 굳이 if문을 이용해 갈라놓을 필요는 없다. 

    둘다 처리할 일이 같기 때문이다. 

    보내기를 눌러 보내나, 엔터를 눌러보내나 서버로 가는 것은 똑같기 때문에 굳이 구분하지 않는다. 

     

    그런 후 input.getText() 를 통해 JTextField 값을 꺼내와 data 변수에 넣는다. 

    writer.println(data)를 통해 꺼내온 데이터를 서버로 보낸다. 

    서버로 보낸 후엔 버퍼를 반드시 비운다. 

    마지막으론 textfield를 깨끗하게 만들어 초기화 해준다. 

     

     

    다음은 서버의 역할을 할 ChatServer.java 이다. 

    앞서 말했듯이 채팅창이 돌아가는 형태를 보면 클라이언트의 대화들이 순차적으로 오가는 것이 아니다. 

    즉, 스레드를 이용해야한다. 

    그렇게 되면 그 대화 데이터들을 받아줄 서버의 소켓 역시 스레드가 되어야한다. 

    하지만 서버 자체가 스레드가 되는 것은 아니다.

    클라이언트들의 대화를 직접 받아줄 서버의 소켓이 스레드가 되어야한다 

    서버는 여러개가 될 수 없고 필요하다면 소켓을 여러개로 만드는 것이다. 

    예를 들어 KT의 서버는 하나이고 KT의 기지국은 여러개인 꼴인 건데 

    여기서 KT의 기지국이 소켓과 같은 모습인 것이다. 

    이제 서버에서 직접 처리하는 것이 아닌 chatHandler 라는 기지국을 지정해 스레드로 만들어 준다. 

    일단 여기서 ChatServer의 코드를 먼저 본 후 chatHandler를 본다. 

     

    import java.net.ServerSocket;
    import java.net.Socket;
    import java.util.ArrayList;
    import java.util.List;
    
    import java.io.IOException;
    
    class ChatServer {
    
    	private ServerSocket serverSocket;
    	private List<ChatHandler> list; 
    	public ChatServer(){

    list는 소켓들을 모아 관리해줄 ArrayList이다. 

    이제 클라이언트는 여러개이다. 

    그에 따라 서버의 소켓도 여러개여야할 것이고 그 소켓들을 한데 모아 관리해준다. 

    list는 우선 부모인 List로 잡고 자식인 ArrayList를 참조한다. 

     

    try{
    	serverSocket = new ServerSocket(9500); // 9500번 포트를 잡고 기다린다. 
    	System.out.println("서버 준비 완료");
       	list = new ArrayList<ChatHandler>();

     list는 new를 해줄 수 없는 인터페이스이므로 ArrayList로 new 해준다. 

    이제 클라이언트가 들어오면 낚아채야한다 (accept)

    그런데 이번엔 들어올 클라이언트가 여러개이기 때문에 여러번 accpet를 해주어야한다. 

    while문을 돌려준다. 

    while(true){
    	Socket socket = serverSocket.accept();	
        ChatHandler handler = new ChatHandler(socket, list); 
        handler.start();
    	}// while
    }catch(IOException e){
    	e.printStackTrace();
        }
    }
    	public static void main(String[] args) {
    		new ChatServer();
    	}
    }
    

    클라이언트를 낚아챈 후 

    ChatHandler에게 scoket과 list를 보내준다. 

    현재 ChatHandler가 스레드를 상속받을 것이고 스레드를 상속받은 ChatHandler가 스레드 자체가 될 것이기 때문에 

    스레드를 시작한다. 

     

    handler 역시 클라이언트 갯수만큼 생길 것이기 때문에 

    list에 handler를 넣어준다. 

     

    마지막으로 기지국의 역할을 하는 ChatHandler.java 이다. 

    Handler는 서버에 의해서 넘겨주고, 스레드가 직접 되기도 하고 

    서버의 소켓을 가지고 있다. 

    Handler가 전과 다르게 생김으로 인해서 클라이언트 소켓은 CahtHandler의 소켓과 대화를 하게 된다. 

    import java.net.Socket;
    import java.util.ArrayList;
    import java.util.List;
    
    import java.io.BufferedReader;
    import java.io.InputStreamReader;
    import java.io.OutputStreamWriter;
    import java.io.PrintWriter;
    import java.io.IOException;
    
    class ChatHandler extends Thread{
    	private BufferedReader reader; 
    	private PrintWriter writer;
      	private Socket socket;
    	private List<ChatHandler> list;
       	public ChatHandler(Socket socket, List<ChatHandler> list) throws IOException{
       	this.socket = socket;
       	this.list = list; 
    	

     현재 ChatHandler가 데이터를 처리하기 때문에 socket을 가져야하지만 socket은 server가 가지고 있다. 

    그렇기 때문에 server가 socket을 넘겨준다. 

    Server에서 생성자를 통해서 socket과 list를 보내줬기 때문에 ChatHandler 역시 생성자를 통해서 받아낸다. 

    그런데 생성자 안에서만 사는 것은 생명력이 너무 짧기 때문에 필드로 보내주었다. 

     

    list는 구체적으로 무엇을 담아줘야할까? 

    ChatHandler가 현재 reader, writer를 가지고 있기 때문에 ( 클라이언트와 대화를 하기 때문에 ) 

    ChatHandler를 담아준다. 

    reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
    writer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()));
    }

     

    Server에서 스레드를 시작했기 때문에 스레드 자체가 된 ChatHandler에서 run() 메서드를 작성해준다. 

    @Override
    	public void run(){
    		try{
            String nickName = reader.readLine(); 
            broadcast(nickName + "님 입장하였습니다." ); 

      클라이언트로 부터 메시지를 받는다. 

    클라이언트는 우선 닉네임을 가장 먼저 보내게 되어 있다. 

    메시지를 읽어드린 것을 nickName으로 저장한다. 

    그리고 모든 클라이언트에게 입장 메시지를 나 자신을 포함해서 보낸다. 

    그때 사용하는 메서드가 broadcast이다. 

    broadcast 메서드는 밑에서 보도록 한다. 

     

    클라이언트로부터 받았다면 이제 클라이언트에게 보낸다. 

    클라이언트가 종료할 때까지 진행되어야하기 때문에 while문을 돌린다. 

    String line;
    	while(true){
        	line = reader.readLine(); // 메시지 받기 
    		if(line == null || line.equals("exit")){
            
           	writer.println("exit");
    		writer.flush();
    		reader.close();
    		writer.close();
    		socket.close();

    클라이언트에게 받은 메시지를 line에 담는다. 

    메세지가 null 이거나 exit 이면 채팅창을 나가겠다는 신호이므로 

    writer, reader, socket을 차례차례 닫아준다. 

    현재 클라이언트들은 나 자신을 합쳐 여러개이기 때문에 

    나 자신에게 보내는 메시지와 다른 클라이언트들에게 보내는 메시지가 다르다. 

     

    먼저 나 자신에게는 나가려고 exit를 보낸 클라이언트에게 답변을 보낸다. 

    그렇지 않으면 컴퓨터가 아무 일 없이 기다리기만 한다. 

    서버와 클라이언트의 관계는 동기식이므로 누구 한 명이 요청을 하면 상대방이 반드시 응답을 해주어야한다. 

    writer.println("exit");

     

    단체 채팅방을 생각했을 때 누구 한 명이 나갔다고 채팅창이 없어지지 않는다. 

    그래서 여기서는 나가고자 하는 클라이언트 하나의 writer, reader, socket 만 close 해주고 

    시스템은 종료시켜주지 않는다. 

    대신에 퇴장했다는 메시지를 다른 클라이언트들에게 띄워준다. 

     

     클라이언트가 나갔다면 나간 클라이언트를 list에서 제거해주어야한다. 

        list.remove(this); 

     

        broadcast(nickName + "님이 퇴장하였습니다.");
        break;
        }
      broadcast("["+nickName+"]" + line);
      }
      }catch(IOException e){
      e.printStackTrace();
      }
    }

     나가지 않으면 채팅을 계속해서 진행하는 것이므로 채팅 내용을 모든 클라이언트들에게 보낸다. 

     

    아래는 broadcast()이다. 

    broadcast는 모든 클라이언트들에게 한 번에 메시지를 보내는 역할을 한다.  

    	public void broadcast(String msg){
    		for(ChatHandler handler : list){ // list 안에 handler
    			handler.writer.println(msg); // 현재 메시지를 보낸다. 
    			handler.writer.flush(); // 버퍼 비워주기 
    
    		}// for
    	}
    }
    

     

    <실행 화면>

    항상 먼저 서버를 실행한다.

    서버실행

     

    그런 다음 클라이언트를 실행하면 아래와 같이 IP를 묻는다. 

    채팅창 실행
    diddkanro

     

    여러개의 클라이언트들이 IP주소만 알면 접속할 수 있다. 

     

    '프로그래밍 > TIL(국비과정)' 카테고리의 다른 글

    TIL #29 - 데이터베이스 Update  (0) 2020.05.13
    TIL #28 - 데이터베이스 Insert  (0) 2020.05.11
    TIL #26 - 서버와 클라이언트  (0) 2020.05.04
    TIL #25 - 네트워크, I/O  (0) 2020.05.01
    TIL #24 - I/O  (0) 2020.04.29

    댓글

Designed by Tistory.