diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 0000000..2b63946 --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..71e59f6 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# Java 소켓 기반 멀티 클라이언트 채팅 프로그램 + +## 📖 프로젝트 소개 +이 프로젝트는 **Java 표준 라이브러리(`java.net`, `java.io`)만**을 사용하여 개발된 경량 텍스트 기반 채팅 애플리케이션입니다. +동아리나 수업 환경에서 **별도의 외부 라이브러리 설치 없이**, 같은 네트워크 내 여러 사용자가 동시에 대화할 수 있는 간단한 채팅 도구를 목표로 합니다. +서버-클라이언트 모델을 기반으로 하며, **스레드 풀**을 이용해 다수의 클라이언트를 효율적으로 처리합니다. + +--- + +## 주요 기능 +- **다중 클라이언트 접속**: `ExecutorService`(스레드 풀)를 활용하여 안정적인 동시 접속 처리 +- **닉네임 시스템** + - 서버 접속 시 고유한 닉네임을 설정 (`NICK <이름>`) + - 중복 또는 공백이 포함된 닉네임 거절 +- **실시간 채팅**: 메시지를 모든 클라이언트에게 브로드캐스트 +- **입장/퇴장 알림**: 사용자 입장/퇴장 시 전체 알림 +- **기본 명령어** + - `/who` : 현재 접속자 목록 출력 + - `/quit` : 정상 종료 및 연결 해제 +- **안정적인 종료 처리**: 서버가 `Ctrl+C`로 종료될 경우, 클라이언트에게 알림 후 안전 종료 + +--- + +## 사용 기술 +- **언어**: Java (JDK 11 이상 권장) +- **핵심 라이브러리** + - `java.net.Socket`, `java.net.ServerSocket` : TCP/IP 기반 소켓 통신 + - `java.io.*` : 데이터 입출력 스트림 + - `java.util.concurrent.*` : 멀티스레딩 및 동시성 제어 (`ExecutorService`, `ConcurrentHashMap`) +- **인코딩**: UTF-8 (한글 입출력 지원) + +--- + +## 빌드 및 실행 방법 + +### 1. 사전 준비 +- JDK가 설치되어 있어야 합니다. + +### 2. 컴파일 +`src` 폴더에 `ChatServer.java`, `ChatClient.java` 파일이 있다고 가정합니다. +```bash +javac -d out src/ChatServer.java src/ChatClient.java +``` + +→ 컴파일된 .class 파일은 out 디렉토리에 생성됩니다. + +### 3. 서버 실행 +```bash +# 기본 포트(5000) +java -cp out ChatServer + +# 특정 포트(예: 5001) +java -cp out ChatServer 5001 +``` + +```bash +실행 시: + +[서버] 채팅 서버를 포트 5001에서 시작합니다. +``` + +### 4. 클라이언트 실행 +```bash +# 형식 +java -cp out ChatClient <서버 IP> <포트> <닉네임> + +# 예시 1 +java -cp out ChatClient 127.0.0.1 5001 Yumi + +# 예시 2 +java -cp out ChatClient 127.0.0.1 5001 ShinSaegae +``` + +테스트 시나리오 (실행 예시) +```bash +1. 서버 실행 +[서버] 채팅 서버를 포트 5001에서 시작합니다. + +2. 클라이언트 1 (Yumi) 접속 +채팅 서버에 연결되었습니다 (127.0.0.1:5001) +OK 채팅 서버에 오신 것을 환영합니다. 'NICK <닉네임>' 형식으로 닉네임을 설정해주세요. +OK 닉네임이 'Yumi'으로 설정되었습니다. +Yumi님이 채팅에 참여했습니다. + + +서버 로그: + +[서버] /127.0.0.1:xxxxx 님이 'Yumi' 닉네임으로 접속했습니다. + +3. 클라이언트 2 (Bob) 접속 +OK 닉네임이 'Bob'으로 설정되었습니다. +Yumi님이 채팅에 참여했습니다. +Bob님이 채팅에 참여했습니다. + + +(Yumi 클라이언트 창에도 "Bob님이 채팅에 참여했습니다." 출력) + +4. 대화 및 명령어 + +(Yumi) 안녕하세요! +→ 모든 클라이언트: [Yumi] 안녕하세요! + +(Bob) /who +→ 현재 접속자: Yumi, Bob + +5. 클라이언트 종료 + +(Yumi) /quit +→ 모든 클라이언트: Yumi님이 채팅을 떠났습니다. + +6. 서버 강제 종료 (Ctrl+C) +[서버] 종료 절차를 시작합니다... +``` +```bash +남은 클라이언트: + +SYSTEM: 서버가 곧 종료됩니다. +[클라이언트] 서버와의 연결이 끊어졌습니다. (Disconnected) +``` \ No newline at end of file diff --git a/src/ChatClient.java b/src/ChatClient.java new file mode 100644 index 0000000..7e10d38 --- /dev/null +++ b/src/ChatClient.java @@ -0,0 +1,78 @@ +import java.io.*; +import java.net.Socket; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; + +public class ChatClient { + public static void main(String[] args) throws IOException{ + if (args.length != 3) { + System.out.println("사용법: java -cp out ChatClient <서버_IP> <포트> <닉네임>"); + return; + } + + String host = args[0]; + int port = Integer.parseInt(args[1]); + String nickname = args[2]; + + try (Socket socket = new Socket(host, port)) { + System.out.println("채팅 서버에 연결되었습니다 (" + host + ":" + port + ")"); + + PrintWriter out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); + + Thread readerThread = new Thread(new ServerMessageReader(socket)); + readerThread.start(); + + out.println("NICK " + nickname); + + BufferedReader consoleReader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + String userInput; + while ((userInput = consoleReader.readLine()) != null) { + out.println(userInput); + if ("/quit".equalsIgnoreCase(userInput.trim())) { + break; + } + } + readerThread.join(); + + } catch (UnknownHostException e) { + System.err.println("호스트 " + host + "를 찾을 수 없습니다."); + } catch (IOException e) { + System.err.println(host + "에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요."); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.err.println("클라이언트가 중단되었습니다."); + } finally { + System.out.println("서버와의 연결이 종료되었습니다."); + } + } + + private static class ServerMessageReader implements Runnable { + private final Socket socket; + private BufferedReader in; + + public ServerMessageReader(Socket socket) { + this.socket = socket; + } + + @Override + public void run() { + try { + in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + String serverMessage; + while ((serverMessage = in.readLine()) != null) { + System.out.println(serverMessage); + } + } catch (IOException e) { + System.out.println("서버와의 연결이 끊어졌습니다."); + } finally { + try { + if (!socket.isClosed()) { + socket.close(); + } + } catch (IOException e) { + System.out.println(e.getMessage()); + } + } + } + } +} diff --git a/src/ChatServer.java b/src/ChatServer.java new file mode 100644 index 0000000..5402921 --- /dev/null +++ b/src/ChatServer.java @@ -0,0 +1,161 @@ +import java.io.*; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + + +public class ChatServer { + private static final int PORT = 5001; + + private static final ExecutorService pool = Executors.newCachedThreadPool(); + + private static final Map clients = new ConcurrentHashMap<>(); + + public static void main(String[] args) { + System.out.println("[Server] port:" + PORT + " 에서 서버 실행"); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("\n[서버] 종료를 시작합니다..."); + broadcast("SYSTEM: 서버가 곧 종료됩니다."); + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + pool.shutdownNow(); + System.out.println("[서버] 서버 강제 종료."); + })); + + try (ServerSocket serverSocket = new ServerSocket(PORT)) { + while (!Thread.currentThread().isInterrupted()) { + try { + Socket socket = serverSocket.accept(); + pool.submit(new CLientHandler(socket)); + } catch (IOException e) { + System.out.println("[Server] client연결 오류" + e.getMessage()); + break; + } + } + } catch (IOException e) { + System.out.println("[Server] 포트" + PORT + " not listen"); + } finally { + pool.shutdown(); + } + + } + + private static void broadcast(String message) { + for(CLientHandler client : clients.values()){ + client.sendMessage(message); + } + System.out.println("[전체 메세지]" + message); + } + + private static class CLientHandler implements Runnable { + private final Socket socket; + private PrintWriter out; + private BufferedReader in; + private String nickname; + + public CLientHandler(Socket socket) { + this.socket = socket; + } + + @Override + public void run() { + try{ + in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); + + // 닉네임 설정 + setupNickname(); + if(nickname == null) return; + + String message; + while ((message = in.readLine()) != null) { + if (message.startsWith("/")) { + handleCommand(message); + } else { + broadcast("[" + nickname + "] " + message); + } + } + + } catch(IOException e){ + System.out.println(e.getMessage()); + } finally { + cleanup(); + } + } + + private void setupNickname() throws IOException{ + sendMessage("NICK <이름> "); + String nickCommand = in.readLine(); + + if (nickCommand == null || !nickCommand.toUpperCase().startsWith("NICK ")) { + sendMessage("ERR 잘못된 명령어입니다. 'NICK <닉네임>'으로 시작해야 합니다. 연결을 종료합니다."); + return; + } + + String potentialNickname = nickCommand.substring(5).trim(); + // 닉네임 유효성 검사 (공백, 중복) + if (potentialNickname.isEmpty() || potentialNickname.contains(" ")) { + sendMessage("ERR 닉네임은 비어있거나 공백을 포함할 수 없습니다. 연결을 종료합니다."); + return; + } + + synchronized (clients) { + if (clients.containsKey(potentialNickname)) { + sendMessage("ERR 이미 사용 중인 닉네임입니다. 연결을 종료합니다."); + return; + } + this.nickname = potentialNickname; + clients.put(nickname, this); + } + sendMessage("OK 닉네임이 '" + nickname + "'으로 설정되었습니다."); + broadcast(nickname + "님이 채팅에 참여했습니다."); + System.out.println("[서버] " + socket.getRemoteSocketAddress() + " 님이 '" + nickname + "' 닉네임으로 접속했습니다."); + + } + + private void handleCommand(String command) { + if ("/quit".equalsIgnoreCase(command)) { + try { + socket.close(); + } catch (IOException e) { + e.getMessage(); + } + } else if ("/who".equalsIgnoreCase(command)) { + // /who 명령어 처리 + String userList = "현재 접속자: " + String.join(", ", clients.keySet()); + sendMessage(userList); + } else { + sendMessage("ERR 알 수 없는 명령어: " + command); + } + } + + public void sendMessage(String message) { + if (out != null) { + out.println(message); + } + } + + private void cleanup() { + if (nickname != null) { + clients.remove(nickname); + broadcast(nickname + "님이 채팅을 떠났습니다."); + System.out.println("[서버] " + nickname + "님의 연결이 끊어졌습니다."); + } + try { + socket.close(); + } catch (IOException e) { + // 무시 + } + } + + } + +} diff --git a/src/Main.java b/src/Main.java deleted file mode 100644 index 8265e58..0000000 --- a/src/Main.java +++ /dev/null @@ -1,17 +0,0 @@ -// Press Shift twice to open the Search Everywhere dialog and type `show whitespaces`, -// then press Enter. You can now see whitespace characters in your code. -public class Main { - public static void main(String[] args) { - // Press Opt+Enter with your caret at the highlighted text to see how - // IntelliJ IDEA suggests fixing it. - System.out.printf("Hello and welcome!"); - - // Press Ctrl+R or click the green arrow button in the gutter to run the code. - for (int i = 1; i <= 5; i++) { - - // Press Ctrl+D to start debugging your code. We have set one breakpoint - // for you, but you can always add more by pressing Cmd+F8. - System.out.println("i = " + i); - } - } -} \ No newline at end of file