AWS BACK DAY 40. "자바 소켓 통신을 활용한 멀티챗 프로그램 구현 - 실시간 채팅 애플리케이션 개발 가이드"

이강용·2023년 2월 27일

Java 기초

목록 보기
26/26

📑 Socket 통신이란?

  • 소켓(Socket)은 TCP/IP 기반 네트워크 통신에서 데이터 송수신의 마지막 접점을 의미
  • 소켓통신은 이러한 소켓을 통해 Server - Client 간 데이터를 주고받는 양방향 연결 지향성 통신
  • 보통 지속적으로 연결을 유지하면서 실시간으로 데이터를 주고받아야 하는 경우 사용

📑 project 생성



maven dependency 추가

<dependency>
	    <groupId>org.projectlombok</groupId>
	    <artifactId>lombok</artifactId>
	    <version>1.18.24</version>
	    <scope>provided</scope>
	</dependency>
	
	<dependency>
	    <groupId>com.google.code.gson</groupId>
	    <artifactId>gson</artifactId>
	    <version>2.10.1</version>
	</dependency>

  </dependencies>
  <build>
    <sourceDirectory>src</sourceDirectory>
    <plugins>
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.1</version>
        <configuration>
          <release>11</release>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

📑 깃허브 연동

📑 Server , Client Package 생성


📑 프로그램 구성

📑 Server

  • 📑Dto
    • Request
    • Response
  • 📑Entity
    • Room
  • 📑Main
    • ConnectedSocket
    • ServerApplication

ServerApplication

package main;

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

import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.SwingConstants;


public class ServerApplication {

    public static void main(String[] args) {
        JFrame serverFrame = new JFrame("서버");
        serverFrame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        serverFrame.setSize(150, 80);

        JLabel statusLabel = new JLabel("서버 구동 중...", SwingConstants.CENTER);
        serverFrame.add(statusLabel, BorderLayout.CENTER);

        serverFrame.setVisible(true);

        ServerSocket serverSocket = null;
        try {
            serverSocket = new ServerSocket(9090);

            while (true) {
                Socket socket = serverSocket.accept();
                ConnectedSocket connectedSocket = new ConnectedSocket(socket);
                connectedSocket.start();
            }

        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (serverSocket != null) {
                try {
                    serverSocket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

}

서버 실행 시, 구동 창 팝업

- Server Socket 생성
    - ServerSocket serverSocket = new ServerSocket(9090);
- Client 접속 대기 
    - Socket socket = serverSocket.accpet();

이 method는 9090 포트를 listen하는 ServerSocket을 생성하고, Client의 연결 요청을 기다리면서 무한 루프를 실행, Client의 연결 요청이 들어오면, 해당 소켓과 통신할 ConnectedSocket 객체를 생성하고 start() method를 호출하여 Thread를 실행, 마지막으로, ServerSocket이 null이 아니면 close() method를 호출하여 소켓을 닫음


ConnectedSocket

package main;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.SocketException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import com.google.gson.Gson;

import dto.request.RequestDto;
import dto.response.ResponseDto;
import entity.Room;
import lombok.Getter;

@Getter
public class ConnectedSocket extends Thread {

	private static List<ConnectedSocket> connectedSocketList = new ArrayList<>();
	private static List<Room> roomList = new ArrayList<>();
	private static int index = 0;
	private Socket socket;
	private String username;
	
	private Gson gson;
	
	public ConnectedSocket(Socket socket) {
		this.socket = socket;
		gson = new Gson();
	}
	
	@Override
	public void run() {
			BufferedReader bufferedReader;
			try {
				while(true) {
				bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
				String requestJson = bufferedReader.readLine();
				
				System.out.println("요청: " + requestJson);
				requestMapping(requestJson);
				}
			} catch(SocketException e){
				connectedSocketList.remove(this);
				System.out.println(username + ": 클라이언트 종료");
			}catch (IOException e) {
				e.printStackTrace();
			}
	}
	
	private void requestMapping(String requestJson) {
		RequestDto<?> requestDto = gson.fromJson(requestJson, RequestDto.class);
		Room room = null;
		
		switch(requestDto.getResource()) {
			case "usernameCheck":
				checkUsername((String) requestDto.getBody());
				break;
			case "createRoom":
				room = new Room((String) requestDto.getBody(), username);
				room.getUsers().add(this);
				roomList.add(room);
				sendToMe(new ResponseDto<String>("createRoomSuccessfully", null));
				refreshUsernameList(room);
				sendToAll(refreshRoomList(), connectedSocketList);
				break;
			case "enterRoom":
				// switch 문에서 겹치는 객체는 switch문 밖으로 빼서 생성
				room = findRoom((Map<String, String>)requestDto.getBody()); 
				room.getUsers().add(this); //connectedSocket
				sendToMe(new ResponseDto<String>("enterRoomSuccessfully",null));
				refreshUsernameList(room);
				break;
			case "sendMessage" :
				room = findConnectedRoom(username);
				sendToAll(new ResponseDto<String>("reciveMessage", username + " >>> " + (String)requestDto.getBody()), room.getUsers());
				break;
				
			case "exitRoom":
				room = findConnectedRoom(username);
				try {
					if(room.getOwner().equals(username)) {
						exitRoomAll(room);
					}else {
						exitRoom(room);
					}	
				}catch(NullPointerException e){
					
					System.out.println("클라이언트 강제 종료");
				}
				break;
		}
	}
	
	private void checkUsername(String username) {
		if(username.isBlank()) {
			sendToMe(new ResponseDto<String>("usernameCheckIsBlank", "사용자 이름은 공백일 수 없습니다"));
			return;
		}
		
		for(ConnectedSocket connectedSocket : connectedSocketList) {
			if(connectedSocket.getUsername().equals(username)) {
				sendToMe(new ResponseDto<String>("usernameCheckIsDuplicate", "이미 사용중인 이름입니다."));
				return;
			}
		}
		
		this.username = username;
		connectedSocketList.add(this);
		sendToMe(new ResponseDto<String>("usernameCheckSuccessfully", null));
		sendToMe(refreshRoomList());
	}
	
	private ResponseDto<List<Map<String, String>>> refreshRoomList() {
		List<Map<String, String>> roomNameList = new ArrayList<>();
		
		for(Room room : roomList) {
			Map<String, String> roomInfo = new HashMap<>();
			roomInfo.put("roomName", room.getRoomName());
			roomInfo.put("owner", room.getOwner());
			roomNameList.add(roomInfo);
		}
		
		ResponseDto<List<Map<String, String>>> responseDto = new ResponseDto<List<Map<String, String>>>("refreshRoomList", roomNameList);
		return responseDto;
	}
	
	private Room findConnectedRoom(String username) {
		for(Room r : roomList) {
			for(ConnectedSocket cs : r.getUsers()) {
				if(cs.getUsername().equals(username)) {
					return r;
				}
			}
		}
		return null;
	}
	
	private Room findRoom(Map<String, String> roomInfo) {
		for(Room room : roomList) {
			if(room.getRoomName().equals(roomInfo.get("roomName"))
					&& room.getOwner().equals(roomInfo.get("owner"))) {
				return room;
			}
		}
		return null;
	}
	
	private void refreshUsernameList(Room room) {
		List<String> usernameList = new ArrayList<>();
		usernameList.add("방제목: " + room.getRoomName());
		for(ConnectedSocket connectedSocket : room.getUsers()) {
			if(connectedSocket.getUsername().equals(room.getOwner())) {
				usernameList.add(connectedSocket.getUsername() + "(방장)");
				continue;
			}
			usernameList.add(connectedSocket.getUsername());
		}
		ResponseDto<List<String>> responseDto = new ResponseDto<List<String>>("refreshUsernameList", usernameList);
		sendToAll(responseDto, room.getUsers());
	}
	
	private void exitRoomAll(Room room) {
		sendToAll(new ResponseDto<String>("exitRoom", null), room.getUsers());
		roomList.remove(room);
		sendToAll(refreshRoomList(), connectedSocketList);
	}
	
	
	private void exitRoom(Room room) {
		room.getUsers().remove(this);
		sendToMe(new ResponseDto<String>("exitRoom", null));
		refreshUsernameList(room);
		
	}
	
	
	private void sendToMe(ResponseDto<?> responseDto) {
		try {
			OutputStream outputStream = socket.getOutputStream();
			PrintWriter printWriter = new PrintWriter(outputStream, true);
			
			String responseJson = gson.toJson(responseDto);
			printWriter.println(responseJson);
			
		} catch (IOException e) {
			e.printStackTrace();
		}
		
	}
	
	private void sendToAll(ResponseDto<?> responseDto, List<ConnectedSocket> connectedSockets) {
		for(ConnectedSocket connectedSocket : connectedSockets) {
			try {
				OutputStream outputStream = connectedSocket.getSocket().getOutputStream();
				PrintWriter printWriter = new PrintWriter(outputStream, true);
				
				String responseJson = gson.toJson(responseDto);
				printWriter.println(responseJson);
				
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}
	
}

Variable Declaration (변수 선언부)

private static List<ConnectedSocket> connectedSocketList = new ArrayList<>();
	private static List<Room> roomList = new ArrayList<>();
	private static int index = 0;
	private Socket socket;
	private String username;
	
	private Gson gson;

static 변수 : "connectedSocketList", "roomList", "int index"
클래스의 모든 인스턴스들이 공유하는 변수, 클래스 안에서 한번 생성되고 프로그램이 종료될 때까지 유지, 이 변수들은 모든 인스턴스에서 동일한 값을 가지게 되지만, index 변수는 변수의 모든 ConnectedSocket 인스턴스에서 공유되지만, 값이 하나씩 증가하도록 되어 있으므로, 각 인스턴스가 다른 값을 가짐
instance 변수 : socket, username
인스턴스 변수는 클래스의 인스턴스마다 생성되는 변수, ConnectedSocket클래스의 인스턴스마다 생성되므로, 각 인스턴스에서 서로 다른 값을 가질 수 있음
이 변수들은 각 인스턴스가 생성될 때마다 새로 생성되며, 인스턴스가 소멸할 때 함께 소멸

📑 Thread

  • Static 함수로 생명 주기가 method 안에서 시작하고 끝난다.
  • 트립(Trip)시 문맥을 알아야한다.
  • Runnable 타입이여야하고 반드시 run method를 가지고 있어야한다.
  • Main Thread는 Sub Thread를 실행만 시키고 자기 일을 한다.
  • Java는 Thread가 하나라도 돌아가고 있으면 종료되지 않는다.

기본적으로 Socket은 Thread를 상속받기때문에 반드시 run method를 오버라이드 해줘야한다.

public ConnectedSocket(Socket socket) {
		this.socket = socket;
		gson = new Gson();
	}

"ConnectedSocket" 클래스의 생성자를 정의하며 Socket 객체를 매개변수(인자)로 받음
생성자에서는 "socket" 멤버 변수에 전달된 "socket" 객체를 할당
"Gson" 라이브러리를 사용하기 위해 "gson" 멤버 변수를 생성, 초기화

💡Gson 이란?

Java에서 JSON 데이터를 직렬화 및 역직렬화하기 위한 라이브러리, 여기서는 Gson을 사용하여 소켓을 통해 전송되는 JSON 데이터를 처리할 수 있도록 준비하는 것

@Override
	public void run() {
			BufferedReader bufferedReader;
			try {
				while(true) {
				bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
				String requestJson = bufferedReader.readLine();
				
				System.out.println("요청: " + requestJson);
				requestMapping(requestJson);
				}
			} catch(SocketException e){
				connectedSocketList.remove(this);
				System.out.println(username + ": 클라이언트 종료");
			}catch (IOException e) {
				e.printStackTrace();
			}
	}

"Runnable" 인터페이스를 구현하는 "run" method를 정의, "run" method는 무한 루프를 실행하면서 소켓으로부터 입력을 읽고, 그 입력을 처리하는 "requestMapping" method를 호출

"BufferedReader" 객체를 생성하여 "socket"에서 입력 스트림을 가져와 "InputStreamReader"로 래핑한 후 "BufferedReader"로 다시 래핑함
그 후, "readLine" method를 사용하여 Client로부터 전송된 요청을 읽고, "requestJson" 변수에 할당 → method를 호출하여 요청을 처리

Client와의 연결이 끊어졌을 때 "Connectedsocket" 객체를 리스트에서 제거

private void requestMapping(String requestJson) {
		RequestDto<?> requestDto = gson.fromJson(requestJson, RequestDto.class);
		Room room = null;
		
		switch(requestDto.getResource()) {
			case "usernameCheck":
				checkUsername((String) requestDto.getBody());
				break;
			case "createRoom":
				room = new Room((String) requestDto.getBody(), username);
				room.getUsers().add(this);
				roomList.add(room);
				sendToMe(new ResponseDto<String>("createRoomSuccessfully", null));
				refreshUsernameList(room);
				sendToAll(refreshRoomList(), connectedSocketList);
				break;
			case "enterRoom":
				// switch 문에서 겹치는 객체는 switch문 밖으로 빼서 생성
				room = findRoom((Map<String, String>)requestDto.getBody()); 
				room.getUsers().add(this); //connectedSocket
				sendToMe(new ResponseDto<String>("enterRoomSuccessfully",null));
				refreshUsernameList(room);
				break;
			case "sendMessage" :
				room = findConnectedRoom(username);
				sendToAll(new ResponseDto<String>("reciveMessage", username + " >>> " + (String)requestDto.getBody()), room.getUsers());
				break;
				
			case "exitRoom":
				room = findConnectedRoom(username);
				try {
					if(room.getOwner().equals(username)) {
						exitRoomAll(room);
					}else {
						exitRoom(room);
					}	
				}catch(NullPointerException e){
					
					System.out.println("클라이언트 강제 종료");
				}
				break;
		}
	}

"requestMapping" method를 정의하는 코드로, Client로부터 수신한 JSON 요청 메시지를 파싱하고, 요청의 리소스를 분석하여 적절한 method를 호출
"requestJson" 문자열을 "RequestDto"객체로 변환, 이를 위해 "gson"의 fromJson()메서드를 사용, RequestDto는 요청에 대한 정보를 저장하는 데이터 전송 객체DTO
Client의 요청(Request)이 "createRoom"인 경우, 새로운 Room객체를 생성하고 현재 ConnectedSocket객체를 추가한 뒤, 생성된 Room을 roomList에 추가
ResponseDto를 생성하여 클라이언트에게 "createRoomSucessfully" 메시지를 보내고, refreshUsernameList() 메서드를 호출하여 유저 리스트를 갱신
refreshRoomList 메서드를 호출하여 방 리스트를 갱신, sendToAll() 메서드를 호출하여 모든 Client에게 방 리스틀 보냄

Client의 요청(Request)이 "enterRoom" 인 경우, 해당 방을 찾아서 Client를 추가, 방에 참가 요청에 대한 응답을 보내주고, 참가한 방의 유저 목록을 갱신하여 모든 Client에게 방 정보가 업데이트 되었음을 알림

Client의 요청(Request)이 "sendMessage 인 경우, Client가 속한 방에서 메시지를 전송, 해당 메시지는 모든 Client에게 알려야하므로 해당 방에 속한 모든 Client에게 메시지를 전송

Client의 요청(Request)이 "exitRoom" 인 경우, Client가 속한 방에서 나가게 됨, 방에서 나가는 요청은 방장인 경우와 일반 사용자인 경우로 나누어져 처리
방장인 경우, 해당 방에 속한 모든 Client를 방에서 나가게 하고, 방을 삭제
일반 사용자인 경우, 해당 방에서 Client를 제거하고, 방 정보를 갱신하여 모든 Client에게 방 정보가 업데이트 되었음을 알림

	private void checkUsername(String username) {
		if(username.isBlank()) {
			sendToMe(new ResponseDto<String>("usernameCheckIsBlank", "사용자 이름은 공백일 수 없습니다"));
			return;
		}
		
		for(ConnectedSocket connectedSocket : connectedSocketList) {
			if(connectedSocket.getUsername().equals(username)) {
				sendToMe(new ResponseDto<String>("usernameCheckIsDuplicate", "이미 사용중인 이름입니다."));
				return;
			}
		}
		
		this.username = username;
		connectedSocketList.add(this);
		sendToMe(new ResponseDto<String>("usernameCheckSuccessfully", null));
		sendToMe(refreshRoomList());
	}

"checkUsername" method 정의 → Client에서 전송된 Username이 유효한지 검사하고, 이미 사용 중인지 확인 (중복 유저네임 체크)
"username.isBlank()" 를 사용하여 입력된 Username이 공백인지 확인,
만약 공백이라면, "ResponseDto"를 사용하여 Client에게 "usernamecheckBlank" 리소스를 가진 응답을 전송 (이 리소스는 Client에서 Username 입력이 공백으로 제출된 경우에 대한 메시지를 포함)
공백이 아니라면, for 루프를 사용하여 연결된 모든 socket을 확인, "connectedSocketList"는 "ConnectedSocket" 객체의 리스트이며, Server에 연결된 모든 Client socket List를 포함
connectedSocket.getUsername().equals(username) 를 사용하여 현재 socket이 소유하고 있는 Username과 입력된 Username이 일치하는지 확인
일치하는 경우, "ResponseDto"를 사용하여 "usernameCheckIsDuplicate" 리소스를 가진 응답을 전송 (이 리소스는 이미 사용 중인 Username을 입력한 경우에 대한 메시지를 포함)
두 조건문에 모두 해당하지 않으면, 현재 socket 객체의 username 멤버 변수에 입력된 사용자 이름을 할당 → "connectedSocketList"에 현재 socket 객체를 추가
"ResponseDto"를 사용하여 "usernameCheckSuccessfully" 리소스를 가진 응답을 전송 ( 이 리소스는 사용자 이름이 성공적으로 확인된 경우에 대한 메시지를 포함), refreshRoomList() 메서드를 호출하여 새로운 사용자가 접속한 것을 모든 Client에게 알림

private ResponseDto<List<Map<String, String>>> refreshRoomList() {
		List<Map<String, String>> roomNameList = new ArrayList<>();
		
		for(Room room : roomList) {
			Map<String, String> roomInfo = new HashMap<>();
			roomInfo.put("roomName", room.getRoomName());
			roomInfo.put("owner", room.getOwner());
			roomNameList.add(roomInfo);
		}
		
		ResponseDto<List<Map<String, String>>> responseDto = new ResponseDto<List<Map<String, String>>>("refreshRoomList", roomNameList);
		return responseDto;
	}

이 method는 현재 존재하는 방 목록을 Client에게 보내기 위해 사용
refreshRoomListResponseDto<List<Map<String,String>>> 를 반환

  • resource : "refreshRoomList" (목록 갱신을 요청하는 리스트)
  • body : List<Map<String, String>> ( 방 목록을 담은 리스트)
    refreshRoomList 메서드 내부에서는 현재 존재하는 방 목록('roomList')을 순회하면서 각방의 이름과 방의 소유자 정보를 'Map<String, String>' 형태로 만들고, 이를 리스트에 담은후 'ResponseDto' 객체에 담아 반환
private Room findConnectedRoom(String username) {
		for(Room r : roomList) {
			for(ConnectedSocket cs : r.getUsers()) {
				if(cs.getUsername().equals(username)) {
					return r;
				}
			}
		}
		return null;
	}

이 method는 연결된 소켓들이 속한 방을 찾기 위한 method, 소켓들의 리스트를 순회하며,
소켓의 사용자 이름이 인자로 받은 사용자 이름과 같은 방을 찾아 해당 방 객체를 리턴,
소켓들이 속한 방이 없을 경우 null을 리턴

private Room findRoom(Map<String, String> roomInfo) {
		for(Room room : roomList) {
			if(room.getRoomName().equals(roomInfo.get("roomName"))
					&& room.getOwner().equals(roomInfo.get("owner"))) {
				return room;
			}
		}
		return null;
	}

roomInfo Map 객체에 저장된 방 정보와 일치하는 'Room' 객체를 'roomList' 리스트에서 찾아 반환하는 method, roomInfo Map 객체는 "roomName" key : 방이름 ,"owner" key : 방 생성자 이름이 저장
이 method는 'roomList' 리스트를 순회하면서 각 방의 정보와 'roomInfo' Map 객체의 정보가 일치하는 검사, 방 이름과 생성자 이름이 모두 일치하면 해당 방 객체를 반환하고, 모든 방을 검사한 후에도 일치하는 방을 찾지 못하면 NULL을 반환

private void refreshUsernameList(String username) {
		Room room = findConnectedRoom(username);
		List<String> usernameList = new ArrayList<>();
		usernameList.add("방제목: " + room.getRoomName());
		for(ConnectedSocket connectedSocket : room.getUsers()) {
			if(connectedSocket.getUsername().equals(room.getOwner())) {
				usernameList.add(connectedSocket.getUsername() + "(방장)");
				continue;
			}
			usernameList.add(connectedSocket.getUsername());
		}
		ResponseDto<List<String>> responseDto = new ResponseDto<List<String>>("refreshUsernameList", usernameList);
		sendToAll(responseDto, room.getUsers());
	}

이 method는 채팅방 내에서 새로운 유저가 입장하거나, 기존 유저가 채팅방을 나갔을 때, 채팅방의 참여자 목록을 업데이트하는 method
먼저 새로운 유저가 들온 채팅방을 findConnectedRoom() method를 통해 찾음
새로운 유저가 입장한 채팅방의 참여자 목록을 구성. 이때, 먼저 방 제목을 추가한 후 , 방장의 이름에 "(방장)"을 붙여서 참여자 목록에 추가
방 참여자들의 이름을 참여자 목록에 추가하고, 이를 'ResponseDto' 객체에 담아서 'sendToAll' method를 통해 모든 방 참여자들에게 보냄
❗ 'sendToAll()' method의 두 번째 인자로는 List<ConnectedSocket> 타입의 users 변수가 전달, 이 변수는 메시지를 전달할 대상이 되는 유저 목록을 담고 있음
즉, 이 method를 호출할 때는 방에 참여하는 모든 유저를 전달

private void exitRoomAll(Room room) {
		sendToAll(new ResponseDto<String>("exitRoom", null), room.getUsers());
		roomList.remove(room);
		sendToAll(refreshRoomList(), connectedSocketList);
	}

이 method는 방장이 방에서 나가는 경우에 호출. 모든 방 참여자들에게 'exitRoom'이라는 타입을 가진 ResponseDto를 보내며, 이를 수신한 클라이언트들은 자동으로 해당 방에서 나가게 됨. 그리고 방 목록에서 해당 방을 삭제하고, 방 목록을 수신한 모든 Client들에게 방 목록을 업데이트하도록 'refreshRoomList()' method를 호출

private void exitRoom(Room room) {
		room.getUsers().remove(this);
		sendToMe(new ResponseDto<String>("exitRoom", null));
		refreshUsernameList(room);
	}

이 method는 현재 접속중인 사용자를 대상으로 해당 방에서 퇴장하는 메서드
room.getUsers().remove(this) 를 통해 현재 객체인 'this'를 방에서 나간 사용자 목록에서 제거, 그리고 sendToMe(new ResponseDto<String>("exitRoom", null))를 통해 현재 접속중인 사용자에게 해당 방에서 퇴장했다는 메시지를 전송
마지막으로 refreshUsernameList(room)을 통해 해당 방의 사용자 목록을 업데이트함

private void sendToMe(ResponseDto<?> responseDto) {
		try {
			OutputStream outputStream = socket.getOutputStream();
			PrintWriter printWriter = new PrintWriter(outputStream, true);
			
			String responseJson = gson.toJson(responseDto);
			printWriter.println(responseJson);
			
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

"sendToMe" method를 정의 → Client에게 응답(response)을 보내는 역할
socket.getOutputStream()을 사용하여 Client socket으로부터 출력 스트림을 가져옴, 그리고 PrintWriter 객체를 생성하여 출력 스트림을 감싸고, true를 인수로 전달하여 auto flush mode를 활성화
이 모드는 데이터를 쓸 때마다 자동으로 출력 스트림을 flush하여, Client에게 즉시 데이터를 전송
gson.toJson()을 사용하여 "responseDto"객체를 JSON 문자열로 직렬화함
이렇게 생성된 JSON 문자열을 printWriter.println()을 사용하여 Client socket에 전송

private void sendToAll(ResponseDto<?> responseDto, List<ConnectedSocket> connectedSockets) {
		for(ConnectedSocket connectedSocket : connectedSockets) {
			try {
				OutputStream outputStream = connectedSocket.getSocket().getOutputStream();
				PrintWriter printWriter = new PrintWriter(outputStream, true);
				
				String responseJson = gson.toJson(responseDto);
				printWriter.println(responseJson);
				
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}

이 method는 List 형식의 ConnectedSocket 객체와 ResponseDto 객체를 인수로 받음
ConnectedSocket 객체는 연결된 모든 Client 소켓의 목록이며, ResponseDto 객체는 Client에게 전송할 데이터를 포함


📑 Client

  • 📑Dto
    • Request
    • Response
  • 📑Views
    • ClientApplication
    • ClientRecive

ClientRecive

public class ClientRecive extends Thread {

	private Socket socket;
	private Gson gson;
	
	public ClientRecive(Socket socket) {
		this.socket = socket;
		gson = new Gson();
	}

ClientRecive 클래스를 정의 → Client에서 Server로부터 메시지를 수신(Recive)하는 역할
ConnectedSocket 과 마찬가지로, Thread 클래스를 상속하므로, run method를 override하여 thread로 동작하도록 구현
생성자에서는 "socket" 객체를 인수로 받아 멤버 변수에 저장, "Gson" 역시 객체를 생성하여 멤버 변수에 저장

	@Override
	public void run() {
		try {
			InputStream inputStream = socket.getInputStream();
			BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
			while(true) {
				String responseJson = bufferedReader.readLine();
				responseMapping(responseJson);
			}
			
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

runmethod를 override하여 "ConnectedSocket" 클래스의 "ClientRecevie" Thread가 Server로부터 메시지를 수신하는 역할을 구현하는 코드
"socket" 객체로부터 입력 스트림을 가져오기 위해 socket.getInputStream()을 사용하고, BufferedReader객체를 생성하여 입력 스트림을 감싸고, "readLine()"method를 사용하여 서버로부터 수신된 JSON 문자열을 읽음
그리고 읽어드린 JSON 문자열을 "responseMapping()"method를 호출하여 처리

private void responseMapping(String responseJson) {
		ResponseDto<?> responseDto = gson.fromJson(responseJson, ResponseDto.class);
		switch (responseDto.getResource()) {
			case "usernameCheckIsBlank":
			case "usernameCheckIsDuplicate":
				JOptionPane.showMessageDialog(null, (String) responseDto.getBody(), "접속오류", JOptionPane.WARNING_MESSAGE);
				break;
				
			case "usernameCheckSuccessfully":
				ClientApplication.getInstance()
								.getMainCard()
								.show(ClientApplication.getInstance().getMainPanel(), "roomListPanel");
				break;
				
			case "refreshRoomList":
				refreshRoomList((List<Map<String, String>>) responseDto.getBody());
				break;
				
			case "createRoomSuccessfully": 
				ClientApplication.getInstance()
								.getMainCard()
								.show(ClientApplication.getInstance().getMainPanel(), "roomPanel");
				break;
				
			case "refreshUsernameList":
				refreshUsernameList((List<String>) responseDto.getBody());
				break;
				
			case "enterRoomSuccessfully":
				ClientApplication.getInstance()
								 .getMainCard()
								 .show(ClientApplication.getInstance().getMainPanel(), "roomPanel");
				break;
			case "reciveMessage":
				ClientApplication.getInstance().getChattingContent().append((String)responseDto.getBody() +"\n");
				break;
			case "exitRoom":
				ClientApplication.getInstance().getChattingContent().setText("");
				ClientApplication.getInstance()
								 .getMainCard()
								 .show(ClientApplication.getInstance().getMainPanel(), "roomListPanel");
				break;
		}
	}

ClientRecive Thread에서 수신된 JSON 문자열을 처리하는 "responseMapping()"method → "responseJson" 문자열을 gson.fromJson()method를 사용하여 "ResponseDto"객체로 변환
그리고 switch문을 사용하여 "responseDto" 객체가 가리키는 자원을 확인하고, 그에 따라 처리를 수행

usernameCheckIsBlank ,usernameCheckIsDuplicate : 사용자명 중복 검사에 실패하거나 입력된 사용자명이 공백일 경우 경고 메시지를 출력
usernameCheckSuccessfully : 사용자명 중복 검사에 성공하면 대기실 목록을 보여주는 화면으로 전환
refreshRoomList : 대기실 목록이 업데이트 되었을 경우 대기실 목록을 갱신
createRoomSuccessfully : 새로운 방을 생성한 경우 채팅방 화면으로 전환
refreshUsernameList : 채팅방 사용자 목록이 변경되었을 경우 채팅방 사용자 목록을 갱신
enterRoomSuccessfully : 채팅방에 입장한 경우 채팅방 화면으로 전환
reciveMessage : 채팅방에서 다른 사용자가 메시지를 보낸 경우 채팅 내용에 메시지를 추가
exitRoom : 채팅방에서 나간 경우 대기실 목록을 보여주는 화면으로 전환하고, 채팅 내용을 지움

private void refreshRoomList(List<Map<String, String>> roomList) {
		ClientApplication.getInstance().getRoomNameListModel().clear();
		ClientApplication.getInstance().setRoomInfoList(roomList);
		for(Map<String, String> roomInfo : roomList) {
			ClientApplication.getInstance().getRoomNameListModel().addElement(roomInfo.get("roomName"));
		}
		ClientApplication.getInstance().getRoomList().setSelectedIndex(0);
	}

이 method는 서버에서 받은 방 목록 정보를 클라이언트에 업데이트하는 역할을 함
'ClientApp'의 RoomNameListModel을 초기화 → 서버에서 받은 방 목록 정보 (roomList)를 'ClientApp'의 RoomInfoList에 저장 → roomList에 있는 각 방 정보를 순회하면서, RoomNameListModel에 방 이름을 추가
이렇게 하면, 클라이언트 측에서는 방 목록이 업데이트되고, 사용자가 새로운 방을 생성하거나, 기존 방에 참가하거나, 방 목록을 새로고침할 때마다 서버에서 받은 방 목록 정보로 화면이 갱신
마지막으로, 새로 업데이트 된 방 목록에서 첫 번째 방을 선택하도록 설정

private void refreshUsernameList(List<String> usernameList) {
		ClientApplication.getInstance().getUsernameListModel().clear();
		ClientApplication.getInstance().getUsernameListModel().addAll(usernameList);
		ClientApplication.getInstance().getJoinUserList().setSelectedIndex(0);
	}

이 method는 서버에서 받아온 사용자 리스트를 화면에 업데이트하는 역할
서버에서 보내온 사용자 리스트를 받아와서, 이전에 있던 사용자 리스트를 모두 지우고, 새로 받아온 사용자 리스트를 추가. 따라서, 사용자 리스트가 실시간으로 갱신되며, 사용자 목록이 변화할 때마다 해당 메서드가 호출
사용자 리스트에서 첫 번째 항목을 선택하도록 설정


ClientApplication

public class ClientApplication extends JFrame {

	private static final long serialVersionUID = -4753767777928836759L;
	
	private Gson gson;
	private Socket socket;
	
	private JPanel mainPanel;
	private CardLayout mainCard;
	
	private JTextField usernameField;
	
	private JTextField sendMessageField;

ClientApplication 👈🏻 클래스 필드 정의
"ClientApplication" 클래스는 Client App UI를 구현하는 클래스
CardLayout은 Java Swing에서 제공하는 레이아웃 매니저 중 하나로, 여러 개의 패널을 겹쳐서 화면 전환 효과를 구현할 때 사용

@Setter
	private List<Map<String, String>> roomInfoList;
	private DefaultListModel<String> roomNameListModel;
	private DefaultListModel<String> usernameListModel;
	private JList roomList;
	private JList joinUserList;
	
	private JTextArea chattingContent;

클래스 필드 선언 부

public static ClientApplication getInstance() {
		if(instance == null) {
			instance = new ClientApplication();
		}
		return instance;
	}
    
    private ClientApplication() {
    //Singleton public → private로 변경
    }

Singleton 디자인 패턴을 구현한 method로, ClientApplication 클래스의 인스턴스를 유일하게 생성하고 이를 반환하는 정적 method
getInstance() method는 ClientApplication 클래스의 인스턴스 변수 instance가 null일 경우에만 인스턴스를 생성하고, 이후에는 이미 생성된 인스턴스를 반환
이렇게 하면 애플리케이션 전역에서 하나의 인스턴스만 사용할 수 있게 되어, 메모리 관리나 데이터 일관성 여러 면에서 이점이 있음

🔥 getInstance method는 애플리케이션 전역에서 하나의 인스턴스를 공유하기 위해 사용하는 method

public static void main(String[] args) {
		EventQueue.invokeLater(new Runnable() {
			public void run() {
				try {
					ClientApplication frame = ClientApplication.getInstance();
					frame.setVisible(true);
				} catch (Exception e) {
					e.printStackTrace();
				}
			}
		});
	}

ClientApplication클래스의 "main" method, App의 시작점이며, "EventQueue"를 사용하여 이벤트 디스패치 Thread에서 UI를 생성
"Runnable" 객체를 생성하여 run method를 override하고, 이 method에서 "ClientApplication" 객체를 Singleton으로 생성하고 화면에 표시, 예외 시 예외 스택 트레이스 출력

private ClientApplication() {
addWindowListener(new WindowAdapter() {
			@Override
			public void windowClosing(WindowEvent e) {
				
				RequestDto<String> requestDto = new RequestDto<String>("exitRoom", null);
				sendRequest(requestDto);
				
			}
		});
		
		/*========<< init >>========*/
		gson = new Gson();
		try {
			socket = new Socket("127.0.0.1", 9090);
			ClientRecive clientRecive = new ClientRecive(socket);
			clientRecive.start();
			
		} catch (UnknownHostException e1) {
			e1.printStackTrace();
		} catch (ConnectException e1) {
			JOptionPane.showMessageDialog(this, "서버에 접속할 수 없습니다.", "접속오류", JOptionPane.ERROR_MESSAGE);
			System.exit(0);
		} catch (IOException e1) {
			e1.printStackTrace();
		}

addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {}});

  • 윈도우 창을 닫을 때 실행되는 Listener이며, 창이 닫힐 때 서버로 exitRoom 요청을 보내는 역할
  • WindowAdapter 클래스를 상속받은 익명 클래스를 생성,
    windowClosing method를 Override하여 윈도우가 닫힐 때 수행할 작업을 정의, 여기서는 RequestDto를 생성하고 'exitRoom'을 Resource로 지정한 후 서버로 보내는 sendRequest method를 호출

    🔥 즉, Client가 방에서 나가는것을 의미

생성자에서는 Gson 객체와 Socket 객체를 초기화하고, IP :@.@.@.@ // PORT : 9090 로 localhost에 연결하는 socket을 생성
ClientRecive clientRecive = new ClientRecive(socket) : 'ClientRecive' 객체를 생성, 생정자의 파라미터로 'socket'을 전달
'ClientRecive' 클래스는 서버에서 보내는 메시지를 받아들이는 클래스
clientRecive.start() : 'ClientRecive' 객체에서 'start()' method를 호출하여 Thread를 실행, 이 Thread는 서버에서 보내는 메시지를 계속해서 수신하며, 수신된 메시지를 처리하는 코드가 'ClientRecive'클래스 내부에 구현

loginPanel

/*========<< login panel >>========*/
		
		JButton enterButton = new JButton("접속하기");
		
		usernameField = new JTextField();
		usernameField.addKeyListener(new KeyAdapter() {
			@Override
			public void keyPressed(KeyEvent e) {
				if(e.getKeyCode() == KeyEvent.VK_ENTER) {
					RequestDto<String> usernameCheckReqDto = 
							new RequestDto<String>("usernameCheck", usernameField.getText());
					sendRequest(usernameCheckReqDto);
				}
			}
		});

사용자 로그인 정보 입력하는 화면을 구성
JTextField 클래스를 사용하여 사용자 로그인 정보를 입력할 수 있는 입력란을 만듦
이때, usernameField 라는 변수명으로 해당 입력란을 참조할 수 있음
addKeyListener() method를 호출하여, 키보드 이벤트를 처리할 수 있는 리스너를 추가
이 리스너에서는, keyPressed() method를 override하여, 사용자가 입력한 키보드 이벤트를 처리 , 이벤트가 발생할 때, 입력된 키가 엔터키(Keycode.VK_ENTER) 인 경우 RequesetDto 객체를 생성, sendRequest method를 이용하여 서버로 전송

enterButton.addMouseListener(new MouseAdapter() {
			@Override
			public void mouseClicked(MouseEvent e) {
				RequestDto<String> usernameCheckReqDto = 
						new RequestDto<String>("usernameCheck", usernameField.getText());
				sendRequest(usernameCheckReqDto);
			}
		});

enterButton에 대한 MouseAdapter를 추가하고, mouseClicked method를 override.
이 method는 마우스 클릭 이벤트가 발생했을 때 호출되며, 클릭 이벤트가 발생하면
usernameCheckReqDto 객체를 생성하고, 이를 sendRequest method로 전송
RequestDto 객체는 "usernameCheck" 리소스 이름과 usernameField에서 가져온 사용자 이름을 가지고 생성, 이 RequestDto 객체는 서버로 전송되어, 서버는 이를 처리하고 결과를 ClientRecive Thread로 보냄

roomList Panel

roomNameListModel = new DefaultListModel<String>();
		roomList = new JList(roomNameListModel);
		roomList.addMouseListener(new MouseAdapter() {
			@Override
			public void mouseClicked(MouseEvent e) {
				if(e.getClickCount() == 2) {
					int selectedIndex = roomList.getSelectedIndex();
					RequestDto<Map<String, String>> requestDto = 
							new RequestDto<Map<String,String>>("enterRoom", roomInfoList.get(selectedIndex));
					sendRequest(requestDto);
				}
			}
		});

roomNameListModel은 JList에 표시할 데이터를 담는 리스트 모델 객체이며, roomList는 이 모델을 이용하여 생선한 JList
여기에서는 입장하고 싶은 방 제목을 mouseClicked method를 통해 클릭 횟수가 2번이면 선택된 항목의 인덱스를 가져와서 그 인덱스에 해당하는 방 정보를 서버에 전송하는 RequestDto를 생성하고, sendRequest method를 이용해 서버에 전송

JButton createRoomButton = new JButton("방생성");
		createRoomButton.addMouseListener(new MouseAdapter() {
			@Override
			public void mouseClicked(MouseEvent e) {
				String roomName = null;
				while(true) {
					roomName = JOptionPane.showInputDialog(null, "생성할 방의 제목을 입력하세요", "방생성", JOptionPane.PLAIN_MESSAGE);
					if(roomName == null) {
						return;
					}
					if(!roomName.isBlank()) {
						break;
					}
					JOptionPane.showMessageDialog(null, "공백은 사용할 수 없습니다.", "방생성 오류", JOptionPane.ERROR_MESSAGE);
				}
				RequestDto<String> requestDto = new RequestDto<String>("createRoom", roomName);
				sendRequest(requestDto);
			}
		});

해당 method는 "방생성" 버튼을 생성하고, 마우스 클릭 이벤트 리스너를 추가하는 부분
클릭 이벤트가 발생하면,while문을 사용하여 사용자로부터 방이름을 입력받음 → 입력된 값이 NULL이면 method 종료, 아니면 입력된 값이 빈 문자열이인지 아닌지 검사 → 값이 빈 문자열이 아니라면, "createRoom" 명령어와 함께 입력받은 방 이름을 RequestDTo 객체에 담아 서버로 전송

🔥 즉, 새로운 방을 생성하는 기능을 구현하는 부분

roomPanel

JButton roomExitButton = new JButton("나가기");
		roomExitButton.addMouseListener(new MouseAdapter() {
			@Override
			public void mouseClicked(MouseEvent e) {
				if(JOptionPane.showConfirmDialog(null, "정말로 방을 나가시겠습니까?","방 나가기",JOptionPane.YES_NO_OPTION)==0) {
					RequestDto<String> requestDto =  new RequestDto<String>("exitRoom", null);
					sendRequest(requestDto);
				}
			}
		});

해당 코드는 "나가기" 버튼 클릭시 방에서 나가는 동작을 수행
YES / NO 대화상자를 띄워서, "정말로 방을 나가시겠습니까?" 메시지와 함께, 사용자의 선택에 따라 나가기 동작을 처리
RequestDto<String> requestDto = new RequestDto<String>("exitRoom", null) : 나가기 요청을 서버에 전송하기 위해 , RequestDto 객체를 생성, 이때 exitRoom은 요청 타입, null은 요청에 필요한 데이터가 없음을 의미
sendRequest(requestDto) : 생성된 RequestDto 객체를 서버로 전송

sendMessageField = new JTextField();
		sendMessageField.addKeyListener(new KeyAdapter() {
			@Override
			public void keyPressed(KeyEvent e) {
				if(e.getKeyCode()==KeyEvent.VK_ENTER) {
					RequestDto<String> requestDto = new RequestDto<String>("sendMessage", sendMessageField.getText());
					sendMessageField.setText("");
					sendRequest(requestDto);
				}
			}
		});

해당 method는 사용자가 해당 필드에 키를 누르면 keyPressed 이벤트가 발생하며, 이때 눌린 키가 Enter 키인지 확인, Enter 키가 눌린 경우, sendMessageField에 입력된 값을 가져와서 해당 값을 RequestDto 객체에 담아서 서버에 전송하고, sendMessageField의 값을 비움

🔥 즉, sendMessageField에 입력된 값을 Enter 키의 이벤트로 RequestDto 객체에 담아 서버로 전송하는 역할

private void sendRequest(RequestDto<?> requestDto) {
		String reqJson = gson.toJson(requestDto);
		OutputStream outputStream = null;
		PrintWriter printWriter = null;
		try {
			outputStream = socket.getOutputStream();
			printWriter = new PrintWriter(outputStream, true);
			printWriter.println(reqJson);
			System.out.println("클라이언트 -> 서버: " + reqJson);
		} catch (IOException e) {
			e.printStackTrace();
		} 
	}

해당 method는 RequestDto객체를 JSON 문자열로 변환하고, 이를 서버로 송신 하는 역할
먼저, requestDto 객체를 gson을 사용하여 JSON 문자열로 변환 후, 송신할 socket의 OutputStream 객체를 얻어 PrintWriter 객체를 생성
PrintWriter 객체는 println method를 사용하여 문자열을 출력
따라서, reqJson 문자열을 printWriter를 통해 서버로 송신


📑 Dto

  • RequestDto
import lombok.AllArgsConstructor;
import lombok.Data;

@AllArgsConstructor
@Data
public class RequestDto<T> {
	private String resource;
	private T body;
}
  • ResponseDto
import lombok.AllArgsConstructor;
import lombok.Data;

@AllArgsConstructor
@Data
public class ResponseDto<T> {
	private String resource;
	private T body;
}
profile
HW + SW = 1

0개의 댓글