Skip to content

최세민 2주차 학습일지 ‐ WAS (1)

Semin Choi edited this page Jul 8, 2024 · 8 revisions

2주차 회고

  • 구조를 고민하는 시간보다는 공부하는 시간을 늘려서 같은 고민을 해도 더 나은 산출물을 만들 수 있는 힘을 기르자.
  • 모르는 API를 메소드 시그니처만 보고 어림짐작해서 사용하지 말자.
  • CS 지식이 부족하다는 것이 많이 느껴집니다.. 틈틈히 CS 공부도 잊지 말기!!

학습 내용

String과 byte[]의 호환성

WAS 미션 수행 중 정적 리소스인 favicon.ico 파일을 읽어 클라이언트에게 반환하는 요구사항이 있었습니다. 처음에 파일을 읽어 반환하는 코드는 다음과 같았습니다.

 try(BufferedReader bufferedReader = new BufferedReader(new java.io.FileReader(file))) {
     StringBuilder stringBuilder = new StringBuilder();
     String line;
     while ((line = bufferedReader.readLine()) != null) {
     stringBuilder.append(line);
     
     return stringBuilder.toString().getBytes();
}

하지만 바이트 파일을 읽어 String으로 만든 뒤 getBytes()를 이용해 byte[]로 변환하니 파일이 깨지는 것을 발견하였습니다. 이것은 문자열의 인코딩 방식과 관련이 있었습니다. 자바는 기본적으로 String의 charset으로 UTF-8을 사용합니다. 아래 사진을 보다시피 유니코드 인코딩은 바이트 데이터에 매핑되는 모든 문자열이 존재하지 않습니다. image

이미지 출처 - https://en.wikipedia.org/wiki/UTF-8

즉, 바이트 데이터를 문자열로 인코딩할 때 매칭되는 문자가 없다면 바이트 -> 문자열 -> 바이트 변환 과정을 거칠 때 데이터가 보존되지 않을 수 있다는 것을 배웠습니다.


ClientSocket 으로부터 받는 InputStream(Http Request) 처리하기

문제상황1 - while(bufferdReader.readLine() == null) 조건 무한대기

클라이언트의 요청을 처리하기 위해 while(bufferdReader.readLine() == null) 조건으로 반복문을 수행했습니다. 하지만 해당 코드에서 클라이언트가 요청을 취소할 때 까지 무한 대기한다는 문제점을 찾았습니다. BufferedReader의 readLine()은 blocking 방식으로 개행 문자가 포함된 문자열이 들어올 때 까지 대기합니다. bufferdReader.readLine() == null 조건에서는 input으로 EOF를 요청해야만 반복이 종료되는데, HttpRequest에서는 명시적으로 EOF를 전송하지 않습니다.

문제상황2 - BufferedReader.ready() 메소드가 요청을 읽지 않고 false 반환

1번 상황을 겪고 BufferedReader의 API를 찾아보던 중 boolean 값을 반환하는 ready() 메소드를 발견했습니다.

이를 사용해서 while(br.ready()) 조건으로 루프를 진행하였더니, 클라이언트의 요청을 읽지 못하고 반복문을 빠져나오는 것을 발견했습니다. 하지만 디버깅을 할 때는 해당 문제가 재현되지 않았고 의문이 들어 ready() 메소드에 대해 좀 더 알아보았습니다. ready() 메소드는 현재 버퍼에 읽을 수 있는 데이터가 있는지 확인한다는 것을 알게 되었습니다. 만약 클라이언트와 TCP 연결을 맺고, 클라이언트가 요청을 전송하기 전에 ready() 메소드를 호출하면 false를 즉시 반환해 요청을 읽지 못한다는 것을 알 수 있었습니다.

문제 해결 HttpRequest는 헤더와 바디 사이에 CRLF 문자를 가지고 있으며 엔티티헤더에 Content-Length 라는 정보를 가지고 있습니다. 이를 이용해서 br.readLine().isEmpty() 일때 까지 반복문을 돌고, 헤더를 모두 읽은 뒤에 Content-Length를 활용해서 body를 읽으면 요청을 읽고 처리할 수 있게됩니다.


Thread

자바에서 스레드를 생성할 수 있는 Runnable을 구현한 클래스 입니다. run() 메소드를 가지고 있지만 run() 메소드를 사용하면 스레드 동작을 하는 것이 아닌 메인스레드에서 메소드를 호출 할 뿐입니다. 새로운 스레드를 만들어서 Task를 수행하고자 한다면 start() 메소드를 호출해야 합니다.

start() 의 동작

  1. 스레드가 실행 가능 상태인지 확인
  2. 스레드를 JVM의 스레드 그룹에 추가
  3. native 메소드로 스레드 생성 및 Task 수행

Thread 클래스의 문제점

  • 실행 결과를 반환받을 수 없음
  • 매번 새로운 스레드를 생성하고 삭제하는 오버헤드가 발생

concurrent 패키지

Callable

함수형 인터페이스로, 작업 결과를 반환합니다. 파라미터가 없고 반환 값이 있다는 점에서 Supplier와 비슷하지만, call() 메소드는 Exception을 던질 수 있습니다.

Executor, ExecutorService, Executors

Executor와 ExecutorService는 스레드 풀을 만들어 놓고 고수준에서 멀티 스레드 작업을 수행할 수 있도록 제공하는 인터페이스 입니다.

Future

ExecutorService에서 수행하는 Task들은 메인스레드가 아니기때문에 작업결과를 얻기 전에 메인스레드의 작업이 끝나면 프로그램이 종료될 수 있습니다. Future는 Task의 작업결과를 Blocking하여 반환받을 수 있는 get() 메소드를 제공합니다.

Executor

작업을 등록하는 책임을 가지는 Functional 인터페이스 입니다. Runnable 인자를 받아 작업을 등록합니다.

ExecutorService

Executor를 상속하며 등록된 작업을 수행하는 책임까지 가지고 있습니다.

ExecutorService는 등록된 테스크를 스레드풀 내에서 수행하며 스래드 풀이 부족할 경우 Blocking Queue에서 대기하다가 FIFO로 작업을 수행합니다.

ExecutorService는 작업이 없어도 새로운 Task 등록을 대기하기 때문에 명시적으로 shutdown()해주지 않으면 프로그램이 끝나지 않고 계속 대기합니다.

  • execute: Runnable을 인자로받아 작업을 등록하고 수행합니다.

  • submit: Runnable 혹은 Callable을 인자로 받아 작업을 등록하고 수행하며 Future을 반환합니다. 만약 Runnable을 인자로 받았다면, Future<?> 를 반환하며 get() 호출시 항상 null을 반환합니다.

  • invokeAll: blocking 방식으로 작동하며 등록된 모든 Task가 수행되는 것을 기다리고, 결과 값을 List 로 반환받습니다.

  • invokeAny: blocking 방식으로 작동하며 등록된 Task 중 가장 빠르게 수행되는 Task의 결과값을 반환받습니다.

  • shutdown: 새로운 작업 제출을 중단하며, 등록된 Task를 모두 수행한 뒤 종료합니다.

  • shutdownNow: 실행중인 모든 작업을 즉시 중단 시도하고, 대기 큐에 있던 작업 목록을 반환합니다.

  • isShutdown: ExecutorService가 shutdown 되었는지 확인합니다.

  • isTerminated: shuwdown 실행 후 모든 작업이 종료되었는지 여부를 반환합니다.

isShutdown, isTerminated 의 차이

ExecutorService executorService = Executors.newSingleThreadExecutor();

Callable<Integer> callable = new Callable() {

    @Override
    public Integer call() throws Exception {
        while (true) {
            System.out.println("hi");
        }
    }
};


executorService.submit(callable);

executorService.shutdownNow();
System.out.println(executorService.isShutdown());
System.out.println(executorService.isTerminated());

위 코드에서 callable 은 스레드가 인터럽트 되었을 때 어떤 조치를 하지 않으므로 shutdownNow 를 호출해도 태스크가 계속 수행되게 됩니다. 따라서 isShutdown은 true를 반환하지만 isTerminated는 false를 반환하게 됩니다. 만약 인터럽트에 대한 조치가 필요하다면 다음과 같이 조치를 해주어야 합니다.

@Override
public Integer call() throws Exception {
    while (true) {
        if (Thread.currentThread().isInterrupted()) {
            System.out.println("Interrupted");
            break;
        System.out.println("hi");
    }
}

Executors

ThreadPoolExecutor를 생성할 수 있는 팩토리 메소드들을 제공합니다.

ConcurrentHashMap

멀티 스레드 환경에서 사용할 수 있도록 구현된 Map입니다. 멀티스레드에서 사용할 수 있는 HashTable이라는 클래스도 있지만, 메서드 전체에 syncronzied 키워드가 있어서 성능이 안좋습니다. (모든 접근에 대해 락을 겁니다.) 반면에 ConcurrentHashMap은 데이터를 Put할 때, 그 중에서도 접근하는 일부 버킷에만 동기화를 사용해 비교적 성능이 좋습니다. WAS 1주차에서 동시성을 고려해 HashMap에 syncronized 키워드를 사용했는데, 자바가 제공하는 API를 더 많이 공부해야겠다고 생각했습니다.


ClassLoader와 Jar

Jar파일로 빌드하고 나면 getResource()를 통해 읽어온 new File("파일경로") 로 파일을 읽지 못하는 문제를 겪었습니다.

인텔리제이(build tool - gradle)로 직접 프로젝트를 실행했을 때는 jar파일을 생성하지 않고 Gradle build시에 생성된 바이트코드를 직접 실행하게 되는것을 확인했습니다. 빌드할 때 프로젝트의 리소스 파일들도 build 폴더에 복사되는데, 해당 경로에서 파일을 읽고 있었습니다.

반면에 Jar파일로 빌드한 후에는 new File("파일경로")로 파일을 읽지 못해서 getResource()로 추출된 path를 로그로 확인해보았습니다.

file:/Users/(중략)/workspace/woowacamp/java-was/build/libs/java-was-1.0-SNAPSHOT.jar!/static/img/ci_chevron-left.svg

일반적인 파일시스템의 경로는 아닌 것을 알 수 있습니다. JAR는 압축 파일이므로 파일 내부의 리소스 파일에 직접 접근할 수 없습니다. 따라서 ClassLoader.getResource()는 JAR 파일 내의 리소스 URL을 반환할 때 jar: 프로토콜을 사용하여, 압축된 아카이브 파일의 리소스를 가리키는 URL을 제공합니다. JVM은 이 URL을 해석하여 InputStream으로 읽고 JAR 파일 내에서 파일 데이터에 직접 접근할 수 있습니다.

따라서 배포 환경에서도 리소스파일을 읽기 위해서는 File 클래스가 아닌 InputStream을 이용해서 파일을 읽어야한다는 것을 알 수 있었습니다.


참고 자료

👼 개인 활동을 기록합시다.

개인 활동 페이지

🧑‍🧑‍🧒‍🧒 그룹 활동을 기록합시다.

그룹 활동 페이지

🎤 미니 세미나

미니 세미나

🤔 기술 블로그 활동

기술 블로그 활동

📚 도서를 추천해주세요

추천 도서 목록

🎸 기타

기타 유용한 학습 링크

Clone this wiki locally