-
Notifications
You must be signed in to change notification settings - Fork 0
이호석 2주차 java‐was 학습일지
public class Main {
private static final Logger log = LoggerFactory.getLogger(Main.class);
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080); // 8080 포트에서 서버를 엽니다.
System.out.println("Listening for connection on port 8080 ....");
while (true) { // 무한 루프를 돌며 클라이언트의 연결을 기다립니다.
try (Socket clientSocket = serverSocket.accept();
BufferedReader requestReader = new BufferedReader(
new InputStreamReader(clientSocket.getInputStream()))
) { // 클라이언트 연결을 수락합니다.
// 이렇게 되면 lines()에서
requestReader.lines()
.forEach(log::info);
log.info(request.toString());
System.out.println("Client connected");
// HTTP 응답을 생성합니다.
OutputStream clientOutput = clientSocket.getOutputStream();
clientOutput.write("HTTP/1.1 200 OK\r\n".getBytes());
clientOutput.write("Content-Type: text/html\r\n".getBytes());
clientOutput.write("\r\n".getBytes());
clientOutput.write("<h1>Hello</h1>\r\n".getBytes()); // 응답 본문으로 "Hello"를 보냅니다.
clientOutput.flush();
}
}
}
}
-
대충 위 코드가 안되는 이유는 밑도 끝도 없이 forEach로 불러와서 인가?
-
while문을 통해 isEmpty인지 검증하면서 진행하면 SocketException: Broknen Pipe가 발생하지 않음
// 동작 코드 public class Main { private static final Logger log = LoggerFactory.getLogger(Main.class); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8080); // 8080 포트에서 서버를 엽니다. System.out.println("Listening for connection on port 8080 ...."); while (true) { // 무한 루프를 돌며 클라이언트의 연결을 기다립니다. try (Socket clientSocket = serverSocket.accept(); BufferedReader requestReader = new BufferedReader( new InputStreamReader(clientSocket.getInputStream())); OutputStream clientOutput = clientSocket.getOutputStream()) { // 클라이언트 연결을 수락합니다. String httpRequest = printRequest(requestReader); log.debug(httpRequest); System.out.println("Client connected"); FileInputStream fileInputStream = new FileInputStream("src/main/resources/static/index.html"); // HTTP 응답을 생성합니다. clientOutput.write("HTTP/1.1 200 OK\r\n".getBytes()); clientOutput.write("Content-Type: text/html\r\n".getBytes()); clientOutput.write("\r\n".getBytes()); clientOutput.write(fileInputStream.readAllBytes()); // 응답 본문으로 "Hello"를 보냅니다. clientOutput.flush(); fileInputStream.close(); } } } private static String printRequest(final BufferedReader requestReader) throws IOException { StringBuilder httpRequestBuilder = new StringBuilder(); String requestLine; while (!(requestLine = requestReader.readLine()).isEmpty()) { httpRequestBuilder.append(requestLine) .append(System.lineSeparator()); } return httpRequestBuilder.toString(); } }
-
1차적으로 완성된 코드
package codesquad; import java.io.BufferedReader; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.ServerSocket; import java.net.Socket; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class Main { private static final Logger log = LoggerFactory.getLogger(Main.class); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8080); // 8080 포트에서 서버를 엽니다. System.out.println("Listening for connection on port 8080 ...."); ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 20, 1000L, TimeUnit.MICROSECONDS, new LinkedBlockingQueue<>()); while (true) { // 무한 루프를 돌며 클라이언트의 연결을 기다립니다. executor.execute(() -> { try (Socket clientSocket = serverSocket.accept(); BufferedReader requestReader = new BufferedReader( new InputStreamReader(clientSocket.getInputStream())); OutputStream clientOutput = clientSocket.getOutputStream(); FileInputStream fileInputStream = new FileInputStream("src/main/resources/static/index.html") ) { // 클라이언트 연결을 수락합니다. String httpRequest = printRequest(requestReader); log.debug(httpRequest); System.out.println("Client connected"); // HTTP 응답을 생성합니다. clientOutput.write("HTTP/1.1 200 OK\r\n".getBytes()); clientOutput.write("Content-Type: text/html\r\n".getBytes()); clientOutput.write("\r\n".getBytes()); clientOutput.write(fileInputStream.readAllBytes()); // 응답 본문으로 "Hello"를 보냅니다. clientOutput.flush(); } catch (IOException e) { throw new RuntimeException(e); } }); } } private static String printRequest(final BufferedReader requestReader) throws IOException { StringBuilder httpRequestBuilder = new StringBuilder(); String requestLine; while (!(requestLine = requestReader.readLine()).isEmpty()) { httpRequestBuilder.append(requestLine) .append(System.lineSeparator()); } return httpRequestBuilder.toString(); } }
-
결국 serverSocket.accept를 통해 클라이언트 소켓을 받아서 처리하는 것이므로, 이런 커넥션이 들어왔을때에 대한 처리를 ConnectionHandler로 분리할 수 있음
public class ConnectionHandler implements Runnable { private final Logger log = LoggerFactory.getLogger(Main.class); private final Socket clientSocket; public ConnectionHandler(final Socket clientSocket) { this.clientSocket = clientSocket; } @Override public void run() { try (BufferedReader requestReader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); OutputStream clientOutput = clientSocket.getOutputStream(); FileInputStream fileInputStream = new FileInputStream("src/main/resources/static/index.html")) { String httpRequestInformation = printRequest(requestReader); log.debug(httpRequestInformation); log.debug("Client connected"); // HTTP 응답을 생성합니다. clientOutput.write("HTTP/1.1 200 OK\r\n".getBytes()); clientOutput.write("Content-Type: text/html\r\n".getBytes()); clientOutput.write("\r\n".getBytes()); clientOutput.write(fileInputStream.readAllBytes()); // 응답 본문으로 "Hello"를 보냅니다. clientOutput.flush(); } catch (IOException e) { log.error("요청을 처리할 수 없습니다."); throw new RuntimeException("요청을 처리할 수 없습니다.", e); } finally { try { clientSocket.close(); } catch (IOException e) { log.error("클라이언트 소켓을 닫을 수 없습니다."); throw new RuntimeException(e); } } } private String printRequest(final BufferedReader requestReader) throws IOException { StringBuilder httpRequestBuilder = new StringBuilder(); String requestLine; while (!(requestLine = requestReader.readLine()).isEmpty()) { httpRequestBuilder.append(requestLine) .append(System.lineSeparator()); } return httpRequestBuilder.toString(); } } public class Main { private static final Logger log = LoggerFactory.getLogger(Main.class); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8080); // 8080 포트에서 서버를 엽니다. log.debug("Listening for connection on port 8080 ...."); ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 20, 0, TimeUnit.MICROSECONDS, new LinkedBlockingQueue<>()); while (true) { // 무한 루프를 돌며 클라이언트의 연결을 기다립니다. executor.execute(new ConnectionHandler(serverSocket.accept())); } } }
-
Java 1.0
- 기본 스레드 (java.lang.Thread 클래스, java.lang.Runnable 인터페이스 지원)
- Thread synchronization: synchronized 키워드 및 wait(), notify(), notifyAll()과 같은 메서드로 기본적 동기화 메커니즘 제공함
-
Java 1.2
- ThreadLocal: ThreadLocal 클래스가 도입되어 스레드별로 독립적인 변수를 쉽게 사용할 수 있게 됨(파라미터를 사용하지 않고 객체를 전파하기 위한 용도)
-
Java 1.4
- java.util.concurrent.atomic: 원자적 연산을 위한 클래스들 도입됨
- NIO(New Input/Output): 비동기 I/O를 지원하여 더 효율적인 스레드 관리를 가능하게 함
-
Java 1.5
- java.util.concurrent 패키지: 동시성 유틸리티가 포함된 패키지 도입됨
-
Executor
,ExecutorService
,Callable
,Future
등 - ReentranLock 클래스 및 ReadWriteLock 인터페이스
- ThreadFactory 인터페이
-
- java.util.concurrent 패키지: 동시성 유틸리티가 포함된 패키지 도입됨
-
Java 1.7
-
NIO2
등장: File을 다루는 부분이 크게 개선되었다고 함
-
-
Java 8 (2014)
- CompletableFuture: 비동기 프로그래밍을 더 쉽게 할 수 있도록 하는 기능이 추가되었습니다.
- Lambda Expressions: 람다 표현식이 도입되어 스레드 코드가 더 간결해졌습니다.
- Parallel Streams: 스트림 API가 병렬 처리를 지원하게 되어 스레드 관리가 더 쉬워졌습니다.
-
Java 9
- Reactive Streams:
java.util.concurrent.Flow
API로 반응형 스트림을 지원
- Reactive Streams:
-
Java 11
- HttpClient: 새로운 HttpClient API가 비동기 HTTP 호출을 지원하도록 개선됨
-
Java 18
- Virtual Threads(Project Loom) 정식 도입
-
Java 20
- Project Loom의 가상 스레드가 정식으로 포함되어 더 최적화되고 안정화
- 뭔가 유저 스레드와 커널 스레드가 매핑되는것에 대한 부담이 크기에, 스레드를 최대한 경량화하여 성능을 끌어올리기 위해 노력하는게 아닌가? 하는 생각이 든다.
- 또한 지속적으로 동시성 처리를 잘 할 수 있게끔 하는 라이브러리들이 추가되어왔다.
-
개요
풀링된 여러 스레드 중 하나를 사용하여 제출된 각 작업을 실행하는 ExecutorService로, 일반적으로 Executors 팩토리 메서드를 사용하여 구성
스레드 풀은 일반적으로 많은 수의 비동기 작업을 실행할 때 작업당 호출 오버헤드가 감소하여 성능이 향상되고, 작업 모음을 실행할 때 소비되는 스레드를 포함한 리소스를 제한하고 관리하는 수단을 제공한다는 점에서 두 가지 문제를 해결합니다. 각 스레드풀실행자는 완료된 작업 수와 같은 몇 가지 기본 통계도 유지합니다.
다양하게 사용할 수 있도록, 많은 매개변수와 확장성 훅을 제공하는데 일반적으로 Excutors의 팩토리 메소드를 통해(
newCachedThreadPool
(자동 스레드 회수 기능이 있는 무제한 스레드 풀),newFixedThreadPool
(고정 크기 스레드 풀),newSingleThreadExecutor
(단일 백그라운드 스레드)) 사용할 것을 권장합니다. -
수동 사용을 위해서는 다음 가이드를 참조 해야 한다.
-
core and maximum pool sizes
- 새 작업이
submit
되었고, 실행 중인 스레드가corePoolSize
보다 작다면 요청 처리를 위해 새로운 스레드를 생성합니다. -
corePoolSize
보다 많은Thread
가 수행되고 있지만,maxPoolSize
보다 적은 수의Thread
가 수행되고 있는 경우:-
Queue
가 가득 차지 않은 경우: 즉시 실행하지 않고Queue
에Runnable
을 넣는다. -
Queue
가 가득 찬 경우:maxPoolSize
까지Thread
를 더 만들어 실행한다.
- 즉 Queue를 채우는 작업을 우선시 한다.
-
- 반면에 corePoolSize와 maximumPoolSize가 동일하다면 고정 크기의 스레드 풀이 생성됩니다.
- 따라서
maximumPoolSize
를 본질적으로 무제한 값으로 설정하면 스레드 풀은 임의의 갯수의 동시 작업을 수용하도록 허용할 수 있습니다. 즉, 스레드 풀에 상주하는 스레드의 갯수의 최댓값을 고정시킬 수 있다~ - 이런 값들은 별도의
setter
를 통해 동적으로 변경할 수 있습니다.
- 새 작업이
-
On-demand construction
- 코어 스레드는 새 작업이 도착할때 생성 및 시작됩니다. 다만 prestartCoreThread나 prestartAllCoreThreads 메소드를 사용하여 동적으로 비어있지 않은 큐(스레드를 미리 생성해놓고)를 두고 시작할 수 있습니다.
-
Creating new threads
- 새로운 스레드는
ThreadFactory
를 사용하여 생성됩니다. 별도의 지정이 없으면Executors.defaultThreadFactory
메소드가 사용됩니다. - 이때 스레드는 모두 동일
ThreadGroup
에, 동일한NORM_PRIORITY
우선순위(5)와 데몬 스레드가 아닌 상태로 생성됩니다. 만약 다른ThreadFactory
를 제공하면 스레드의 이름, 스레드 그룹, 우선순위, 데몬 상태 등을 변경할 수 있습니다. - ThreadFactory가 newThread에서 null을 반환하여 스레드 생성에 실패하면 executor는 실행되어도 작업 실행을 못할 수 있습니다.
- 스레드에는
modifyThread 런타임 권한
이 있어야 합니다. 풀을 사용하는 워커 스레드나 다른 스레드가 이 권한을 가지고 있지 않다면 구성 변경 사항이 적시에 적용되지 않고 종료 풀이 완료되지 않은 상태를 그대로 가지고 종료되어 서비스 성능의 저하 우려가 있습니다.
- 새로운 스레드는
-
Keep-alive times
- 풀에 현재 코어 풀 사이즈 스레드보다 많은 스레드가 있는 경우, 초과 스레드가
keepAliveTime
보다 오래 유휴 상태라면 해당 스레드를 종료합니다. - 풀이 활발히 사용되지 않을때 리소스 소비를 줄이는 수단을 제공합니다.
- setter를 사용해 동적 변환도 가능합니다
- 기본적으로 keep-alive 정책은 코어풀 사이즈보다 많은 스레드가 있는 경우에만 적용되지만,
allCoreThreadTimeOut(boolean)
메서드를 사용하면 keepAliveTime 값이 0이 아닌 경우 코어 스레드에서도 이 시간 초과 정책을 적용할 수 있습니다.
- 풀에 현재 코어 풀 사이즈 스레드보다 많은 스레드가 있는 경우, 초과 스레드가
-
Queuing
(대기열)- 모든
BlockingQueue
는 제출된 작업전송
,보류
하는데 사용할 수 있습니다. 또한 큐의 사용은 풀 크기 조정과 상호작용하게 됩니다. - corePoolSize보다 적은 수의 스레드가 실행중이라면, executor는 항상 대기열보다 새로운 스레드를 추가하는 걸 선호합니다.
- 만약
corePoolSize
이상의 스레드가 실행중이라면,executors
는 항생 새 스레드를 추가하는 것보다 요청을 대기열에 추가하는 것을 선호합니다. - 만약 요청을 대기열에 넣을 수 없다면,
maximumPoolSize
를 초과하지 않는 한 새 스레드가 생성되고, 이 경우에는 작업이 거부됩니다. - 대기열에는 일반적인 3가지 전략이 존재합니다.
-
Direct handoffs.
이 전략을 사용할때 작업 큐에 대한 좋은 기본적인 선택은 스레드에 작업을 넘겨주는
SynchronousQueue
입니다. 만약 작업을 즉시 실행 할 수 있는 스레드가 없는 경우 작업을 큐에 넣으려는 시도가 실패하므로 새 스레드가 만들어집니다.새로 제출된 작업이 거부되는걸 방지하기 위해
unbounded maximumPoolSizes
가 필요합니다. 이는 처리 속도보다 작업이 쌓이는 양이 많을때 무제한으로 스레드가 증가할 수 있는 가능성이 있다는 의미입니다. -
Unbounded queues
(무제한 대기열)무제한 큐(미리 정의된 용량 없는
LinkedBlockingQueue
)를 사용하면 모든corePoolSize
스레드가 사용 중일 때 새 작업이 큐에서 대기하게 됩니다. 따라서corePoolSize
스레드는 더 이상 생성되지 않게 됩니다. (이때maximumPoolSize
값은 아무런 영향을 미치지 않음)웹 페이지 서버와 같이 각 작업이 서로 완전히 독립적 이어서 서로의 실행에 영향을 미칠 수 없는 경우에 적합할 수 있습니다.
일시적인 요청 폭주를 원활하게 처리하는데 유용할 수 있지만, 명령이 평균적으로 처리할 수 있는 속도보다 빠르게 도착하면 작업 큐가 무한대로 늘어날 가능성을 인정하게 됩니다.
-
Bounded queues.
한정된 큐(ArrayBlockingQueue)는 유한 최대 풀 크기와 함께 사용할 때 리소스 고갈을 방지하는데 도움이 됩니다.
하지만 이런 사이즈를 조정하거나 제어하는것이 더 어려울 수 있습니다.
-
Queue Size
,maximumuPoolSizes
는 서로 트레이드 오프 관계에 있습니다.-
big size queue and small pools
: CPU 사용량, OS 리소스 및 컨텍스트 전환 오버헤드가 최소화 됨, 하지만 처리량이 인위적으로 낮아질 수 있음작업이 자주 block 되는 경우(I/O 바인딩과 같은) 시스템에서 허용하는 것보다 더 많은 스레드 스케줄 시간이 예약될 수 있습니다.
-
small size queue and large pools
: 더 큰 풀 크기가 필요하므로 CPU가 더 바빠지지만, 허용할 수 없는 스케줄링 오버헤드가 발생해 처리량도 감소될 수 있습니다.
-
-
-
- 모든
-
-
생성자
-
corePoolSize
:allowCoreThreadTimeOut
이 설정되지 않은 경우 유휴 상태일때 스레드 풀에서 유지할 최대 스레드의 갯수를 말합니다. -
maximumPoolSize
: 풀에 허용할 최대 스레드 갯수 -
keepAliveTime
: 스레드 수가 코어보다 많은 경우, 초과 유휴 스레드가 종료되기 전에 새 작업을 대기하는 최대 시간 -
unit
: keepAliveTime의 시간 단위 -
workQueue
: 작업이 실행되기 전에 대기하는 데 사용할 큐가 된다. 큐는 execute Method가 제공한 Runnable한 작업은 보관할 수 있습니다.
-
초기에는 예외를 반환하기로 결정했었으나, RFC 7230문서를 보면 HTTP Header를 커스텀하게 사용할 수 있다고 명시되어 있기에 일단 NONE이라는 특수 값을 반환하고 반환받은곳에서는 NONE 헤더인 경우 로깅을 하도록 대처 → 후에 개선이 필요해 보입니다.
MIME타입의 경우에는 알 수 없는 파일 유형의 경우 application/octet-stream을 사용하도록 MDN 문서에서 언급되고 있기에 예외를 던지지 않도록 리팩토링 했습니다~!
- Status Line
- headers
- Message Body
- Request Line
- HTTP headers
- HTTP Message Body
-
[MIME Type MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types)
-
알 수 없는 파일 유형은 위 유형을 사용합니다.
-
Query Parameter
는form data
로 올 수 있고,URL 쿼리스트링
으로 올 수 있음URL 쿼리 스트링은 RequestLine에서 관리하고Form Data는 Message Body에서 관리하도록 하려고 함URL 쿼리스트링
과form data
의 쿼리스트링은 모두 동일한 형식이므로HttpRequest
가 공통으로 관리하도록 리팩토링 했습니다!
// 리팩토링 이전
public class ConnectionHandler implements Runnable {
private final Logger log = LoggerFactory.getLogger(getClass());
private final Socket clientSocket;
public ConnectionHandler(final Socket clientSocket) {
this.clientSocket = clientSocket;
}
@Override
public void run() {
try (InputStream clientInput = clientSocket.getInputStream();
OutputStream clientOutput = clientSocket.getOutputStream()
) {
HttpRequest httpRequest = new HttpRequest(clientInput);
log.debug("Http Request = {}", httpRequest);
log.debug("Client connected");
HttpResponse httpResponse = createResponse(StatusCodeType.OK, httpRequest);
clientOutput.write(httpResponse.getResponseBytes());
} catch (IOException e) {
log.error("요청을 처리할 수 없습니다.", e);
}
}
private HttpResponse createResponse(final StatusCodeType statusCodeType, final HttpRequest httpRequest)
throws IOException {
String requestPath = httpRequest.getRequestPath();
URL fileUrl = getClass().getClassLoader().getResource("static" + httpRequest.getRequestPath());
try (InputStream inputStream = fileUrl.openStream()) {
Headers headers = new Headers();
headers.add(HeaderType.CONTENT_TYPE, MimeType.findMimeValue(StringUtils.getFilenameExtension(requestPath)));
return new HttpResponse(
new StatusLine(httpRequest.getHttpVersion(), statusCodeType),
headers,
new ResponseMessageBody(inputStream.readAllBytes()));
}
}
}
// 리팩토링 이후
public class ConnectionHandler implements Runnable {
private final Logger log = LoggerFactory.getLogger(getClass());
private final Socket clientSocket;
private final RequestHandlerMapping requestHandlerMapping;
public ConnectionHandler(final Socket clientSocket, final RequestHandlerMapping requestHandlerMapping) {
this.clientSocket = clientSocket;
this.requestHandlerMapping = requestHandlerMapping;
}
@Override
public void run() {
try (InputStream clientInput = clientSocket.getInputStream();
OutputStream clientOutput = clientSocket.getOutputStream()
) {
log.debug("Client connected");
HttpRequest httpRequest = new HttpRequest(clientInput);
log.debug("Http Request = {}", httpRequest);
HttpResponse httpResponse = new HttpResponse(clientOutput, httpRequest.getHttpVersion());
RequestHandler requestHandler = requestHandlerMapping.read(httpRequest.getRequestPath());
requestHandler.process(httpRequest, httpResponse);
} catch (IOException e) {
log.error("요청을 처리할 수 없습니다.", e);
}
}
}
redirect
를 보내거나, 파일에 대한 포워딩 요청을 직접 보낼때 response
가 SocketOutputStream
에 대한 책임을 갖는것이 좀 더 직관적이라고 생각했기 때문에 찾아온 requestHandler
가 로직을 처리하고, HttpResonse
에게 직접 요청을 처리하도록 리팩토링을 시도했습니다.
public void forward(final String requestPath) throws IOException {
URL fileUrl = getClass().getClassLoader().getResource("static" + requestPath);
try (InputStream inputStream = fileUrl.openStream()) {
byte[] fileBytes = inputStream.readAllBytes();
headers.add(HeaderType.CONTENT_TYPE, MimeType.findMimeValue(StringUtils.getFilenameExtension(requestPath)));
headers.add(HeaderType.CONTENT_LENGTH, String.valueOf(fileBytes.length));
statusLine.setResponseStatus(StatusCodeType.OK);
sendResponse(fileBytes);
}
}
private void sendResponse(final byte[] responseBytes) throws IOException {
dataOutputStream.writeBytes(getStatusLineMessage() + CRLF + getHeaderMessage() + CRLF + CRLF);
dataOutputStream.write(responseBytes);
}
HTTP Response
에게 파일에 대한 requestPath
를 넘겨주면 해당 Path를 통해 forwarding
작업을 합니다.
단순하게 정적인 파일을 서빙하는 용도로도 forwarding
이 사용되지만, 요청 URI
가 파일이 아니고 /login
과 같은경로 매핑
이라면 해당 경로 전용 핸들러가 실행을 하고, 알맞은 파일을 forwarding
하도록 할 수 있습니다.
이때 WAS의 영역
과, WAS를 사용하는 사용자의 영역
을 나눠서 생각했는데
Request, Response를 안전하게 제공하는 영역은 WAS의 영역, 핸들러에 대한 비즈니스 로직을 만들고, 적절한 파일을 찾아서 포워딩 하는 작업을 사용자의 영역으로 두고 구분하여 작업했습니다.
리다이렉트를 수행하는 주체는 브라우저입니다. 따라서 웹 서버에서는 리다이렉트를 위해 응답으로 3xx번을 보내고 Response
의 Location
헤더에는 브라우저가 리다이렉트 할 URL
을 넘겨주면, 브라우저는 status code를 보고 Location 헤더에 기록된 위치로 리다이렉트하게 됩니다.
따라서 HttpReponse에 sendRedirect를 호출하면 즉시 dataOutputStream을 통해 리다이렉션 명령을 처리하기 위해 response정보를 write하고 보냅니다.
- 🤔 RequestMapping을 어떻게 유연하게 관리할 수 있을까?
- 🤔 단순 Forwarding 로직인데 핸들러 클래스로 일일이 분리해야 할까?
- 🤔 너무 구현에만 몰두한건 아닌가? 지금 배우는 것에 대해 어떻게 학습해야 할까..?- 자바 버전별 스레드 기술 사용 변화
- 🤔 Server Socket Client Socket 어떻게 접속되는지!!