From 694d24930ea8d2b496fd1386f96ddea236750dc8 Mon Sep 17 00:00:00 2001 From: fbwougr121 Date: Thu, 28 Aug 2025 18:03:17 +0900 Subject: [PATCH 1/4] 25.08.28 --- .idea/uiDesigner.xml | 124 ++++++++++++++++++++++++++++++++ src/ChatClient.java | 78 ++++++++++++++++++++ src/ChatServer.java | 166 +++++++++++++++++++++++++++++++++++++++++++ src/Main.java | 17 ----- 4 files changed, 368 insertions(+), 17 deletions(-) create mode 100644 .idea/uiDesigner.xml create mode 100644 src/ChatClient.java create mode 100644 src/ChatServer.java delete mode 100644 src/Main.java 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/src/ChatClient.java b/src/ChatClient.java new file mode 100644 index 0000000..90b64f8 --- /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 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..c0c01f3 --- /dev/null +++ b/src/ChatServer.java @@ -0,0 +1,166 @@ +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.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + + +public class ChatServer { + private static final int PORT = 5000; + + private static final ExecutorService pool = Executors.newCachedThreadPool(); + + private static final Map clients = new ConcurrentHashMap<>(); + + public static void main(String[] args) { + int port = PORT; + + 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 맵에 대한 동시 접근을 제어합니다. + 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)) { + // /quit 명령어 처리 + try { + socket.close(); // 소켓을 닫으면 readLine()에서 예외가 발생하여 finally 블록이 실행됩니다. + } catch (IOException e) { + // 무시 + } + } 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 From f58201f98f3fff79fda2bb136ce6664c65c92275 Mon Sep 17 00:00:00 2001 From: fbwougr121 Date: Thu, 28 Aug 2025 18:18:41 +0900 Subject: [PATCH 2/4] fixed commit --- src/ChatServer.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/ChatServer.java b/src/ChatServer.java index c0c01f3..ffcb52e 100644 --- a/src/ChatServer.java +++ b/src/ChatServer.java @@ -4,7 +4,6 @@ import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -110,7 +109,6 @@ private void setupNickname() throws IOException{ return; } - // synchronized 블록으로 clients 맵에 대한 동시 접근을 제어합니다. synchronized (clients) { if (clients.containsKey(potentialNickname)) { sendMessage("ERR 이미 사용 중인 닉네임입니다. 연결을 종료합니다."); @@ -127,11 +125,10 @@ private void setupNickname() throws IOException{ private void handleCommand(String command) { if ("/quit".equalsIgnoreCase(command)) { - // /quit 명령어 처리 try { - socket.close(); // 소켓을 닫으면 readLine()에서 예외가 발생하여 finally 블록이 실행됩니다. + socket.close(); } catch (IOException e) { - // 무시 + e.getMessage(); } } else if ("/who".equalsIgnoreCase(command)) { // /who 명령어 처리 From 379940cefb2f45d41bc8c6c91b092bcf740ef13b Mon Sep 17 00:00:00 2001 From: fbwougr121 Date: Fri, 29 Aug 2025 12:48:35 +0900 Subject: [PATCH 3/4] fix --- src/ChatClient.java | 2 +- src/ChatServer.java | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/ChatClient.java b/src/ChatClient.java index 90b64f8..7e10d38 100644 --- a/src/ChatClient.java +++ b/src/ChatClient.java @@ -6,7 +6,7 @@ public class ChatClient { public static void main(String[] args) throws IOException{ if (args.length != 3) { - System.out.println("사용법: java ChatClient <서버_IP> <포트> <닉네임>"); + System.out.println("사용법: java -cp out ChatClient <서버_IP> <포트> <닉네임>"); return; } diff --git a/src/ChatServer.java b/src/ChatServer.java index ffcb52e..5402921 100644 --- a/src/ChatServer.java +++ b/src/ChatServer.java @@ -9,19 +9,17 @@ public class ChatServer { - private static final int PORT = 5000; + 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) { - int port = PORT; - System.out.println("[Server] port:" + PORT + " 에서 서버 실행"); Runtime.getRuntime().addShutdownHook(new Thread(() -> { - System.out.println("\n[서버] 종료 절차를 시작합니다..."); + System.out.println("\n[서버] 종료를 시작합니다..."); broadcast("SYSTEM: 서버가 곧 종료됩니다."); try { Thread.sleep(500); @@ -32,7 +30,7 @@ public static void main(String[] args) { System.out.println("[서버] 서버 강제 종료."); })); - try (ServerSocket serverSocket = new ServerSocket(port)) { + try (ServerSocket serverSocket = new ServerSocket(PORT)) { while (!Thread.currentThread().isInterrupted()) { try { Socket socket = serverSocket.accept(); @@ -43,7 +41,7 @@ public static void main(String[] args) { } } } catch (IOException e) { - System.out.println("[Server] 포트" + port + " not listen"); + System.out.println("[Server] 포트" + PORT + " not listen"); } finally { pool.shutdown(); } From 5d14d449b75927013dc7768a0acd4908fc2399c2 Mon Sep 17 00:00:00 2001 From: fbwougr121 Date: Fri, 29 Aug 2025 13:42:55 +0900 Subject: [PATCH 4/4] =?UTF-8?q?README.md=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 README.md 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