was를 만들면서 socket과 입출력장치를 사용하는 데 정말 애를 먹었다. 어떻게 문제를 해결해야 할지 몰라서 gpt에게 물어보고 해결한 적도 많다.
이에, 문제가 어떤 부분에서 발생했는지 그리고 내가 그 문제를 어떻게 해결할 수 있었는지를 정리할 필요를 느꼈다.
try (InputStreamReader inputStreamReader = new InputStreamReader(in, "UTF-8");
BufferedReader socketBuffer = new BufferedReader(inputStreamReader)) {
RequestMessage requestMessage = parse(socketBuffer);
RequestLine requestLine = requestMessage.getRequestLine();
File file = getFile(requestLine);
print(requestMessage);
respond(file, requestMessage);
} catch (IOException | URISyntaxException e) {
e.printStackTrace();
}
처음에는 소켓을 사용하면서 위처럼 try with resource 코드를 사용했다. 입출력라이브러리를 만들면 알아서 close까지 해주는 것으로 알고 있었기 때문이다.
코드를 변경하면서
try {
HttpRequest httpRequest = convertToHttpRequest(in);
handler.respondTo(httpRequest, out);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
위처럼 코드를 변경했다. 메서드로 전달할 때 buffer로 전달하는 buffer에 대한 의존성을 끊어내고 싶었다.
(이건 jk에게 받은 리뷰와 관련이 있는데, 이 방법보다는 아예 string 값을 전달해주는 방식도 좋을 거 같다)
그런데, 코드를 수정하니 웹사이트가 작동하지 않는 문제가 생겼다. 디버깅을 하면 잘 작동하는데 그냥 run을 하면 웹사이트에서 다음 페이지로 넘어가질 않았다.
알고보니 socket을 쓰고서 닫아주는 작업을 하지 않았기 떄문에 생긴 문제였다.
private void closeConnection() {
if (connection != null) {
try {
connection.close();
logger.info("Connection closed.");
} catch (IOException e) {
logger.error("Failed to close the connection: ", e);
}
}
}
위 코드를 추가하니 다시 정상 작동했다.
공식문서를 보니
"losing this socket will also close the socket's InputStream and OutputStream"라는 말이 있다.
소켓을 close해줌으로써 입출력 스트림이 닫힐 수 있었던 거 같다.
자바에서는 IO를 사용하면 반드시 닫아야 한다는 말을 많이 들었다. 정확하게 어떤 이유에서 그런지 이번에 공부해봤다.
losing Java IO Streams이 아티클을 정리했다.
IO를 닫지 않으면 여러 문제가 발생한다
1)리소스 누수
IO스트림을 열면 이 IO는 시스템의 자원을 일정 부분 차지한다.스트림을 close하기 전까지는 이 자원이 해제되지 않는다.
어떤 IO스트림은 GC가 동작함에 따라 자동으로 close되기도 한다. 하지만, 여기에 의존하는 것은 위험하다. GC가 반드시 작동할 것이라는 보장이 없고, GC가 시작되기 전에 리소스가 모두 고갈될 수 있기 떄문이다.
2)데이터 Corruption
버퍼는 데이터의 임시보관을 위한 공간이다. 이 버퍼에 데이터가 쌓여서 특정한 사이즈가 되거나 flush()가 호출되면 이 데이터가 목적지에 쓰여진다.
BufferedOutputStream을 사용하여 데이터를 쓸 때, 프로그램이 데이터 쓰기를 마쳤다고 해서 모든 데이터가 실제 출력 대상으로 즉시 전송된다는 보장이 없다. close를 호출해서 모든 데이터가 목적지로 가도록 해야 한다.
우리가 FileOutputStream을 이용해서 특정 file에 데이터를 작성하고 있으면, 일부 운영체제는 그 file을 계속 holding한 상태가 된다.(어떻게 의역해야 할지 모르겠지만...잡고 있다? 정도로 이해하면 될듯하다.) 이렇게 되면, 스트림이 닫히기 전까지 다른 애플리케이션이 그 파일에 데이터를 쓰거나, 접근조차 하지 못하게 된다.
IO를 닫을 때는 try-with-resources 구문이 널리 선호된다고 한다. 이를 통해서 try statement에 리소스를 정의할 수 있다. 인풋스트림과 아웃풋스트림은 AutoClosable을 implement하고 있다. 이러한 구현체들은 try-catch 블록이 끝나고서 자동으로 클로즈된다.
*한번쯤은 IO관련 기본을 정리하고 싶었는데 이렇게 정리하니 이해가 잘 된다.
인풋스트림 리더에
InputStreamReader inputStreamReader = new InputStreamReader(in, "UTF-8");
위처럼 "UTF-8"을 넣은건 다른 코드스쿼드 멤버들의 코드를 보고서였다. 놀랍게도 저걸 넣으니 WAS 웹페이지 로딩 속도가 빨라졌다. 그래서 입출력을 빠르게 해주기 위해서 넣은건가? 싶었는데 글을 쓰면서 확인해보니 HTTP로 받은 한글입력값이 깨져서 사용하는 것이었다.
실제로 "UTF-8"을 빼고서 디버깅을 해보니 아래처럼 한글로 입력한 USER가입폼의 데이터가 모두 깨진채로 나왔다.
근데 또 다른 문제가 생겼다. 알고보니 UTF-8을 하는 것만으로는 한글데이터가 제대로 들어오지 않았다.
이유를 찾아보니
JAVA [자바] - 입력 뜯어보기 [Scanner, InputStream, BufferedReader]
이 글이 나왔다.
여기서는 "야스키 코드에 한글이 없고, 이로 인해 한글을 입력해도 엉뚱한 결과가 나온다."
private RequestBody parseBody(BufferedReader buffer, int size) throws IOException {
char[] body = new char[size];
int bytesRead = buffer.read(body, 0, size);
if (bytesRead != size) {
throw new IOException("Content length mismatch.");
}
String requestBody = new String(body);
String decodedBody = URLDecoder.decode(requestBody, StandardCharsets.UTF_8.name());
return new RequestBody(requestBody, parseUserInfo(decodedBody));
}
이런식으로 디코딩 과정을 한번 거쳐주면 한글도 정상적으로 입력이 들어온다.
아직 자바에 대한 지식이 부족해서 위 블로그의 글이 전부 이해되지는 않는다. 다만, 여기서는 내가 필요한 정보만을 기록해보겠다.
저 블로그 글에 따르면, InputStream 의 가장 큰 특징 두 가지가 있었다.
1)입력받은 데이터는 int 형으로 저장되는데 이는 10진수의 UTF-16 값으로 저장된다.
2)1 byte 만 읽는다.
InputStream 은 우리가 InputStream.read() 를 통해 입력을 받으려고 해도 1Byte 만 인식하니 한글은 입력해봤자 읽지도 못하고 엉뚱한 문자만 나온다.
*참고로 http 규약상 header와 body는 연속된 CRLF, \r\n\r\n으로 구분된다. 그래서, 버퍼가 body를 읽어주지 못하는 문제가 있어서 이를 해결할 필요도 있다.
버퍼가 아직 쉽지 않아서 버퍼에 대한 내용도 별도로 정리한다.
버퍼는 어떠한 인풋 소스로부터든 테스트를 읽고 싶을 때 쓸 수 있다. 그게 file이든 소켓이든 말이다. 버퍼는 I/O 오퍼레이션을 줄여준다는 이점이 있다. 문자들을 일정 단위로 읽고 내부 버퍼에 저장하는 방식이다. 버퍼에 데이터가 있다면, reader가 stream이 아니라 버퍼 내부에서 데이터를 읽어올 수 있다.
BufferedReader reader =
new BufferedReader(new FileReader("src/main/resources/input.txt"));
//특정 파일을 버퍼로 읽어오기
BufferedReader reader =
new BufferedReader(new InputStreamReader(System.in));
//특정 스트림에서 바로 버퍼로 읽어올 수도 있다. 이건 키보드 스트림에서 읽어오는 코드다.
*버퍼와 스캐너의 차이
1)버퍼리더는 스레드 세이프지만 스캐너는 아니다.
2)스캐너는 정규표현식을 통해서 원시타입과 스트링을 파싱할 수 있다.
3)스캐너는 고정된 버퍼사이즈를 갖지만, 버퍼리더는 내부 버퍼 사이즈 변경가능
4)버퍼리더가 스캐너보다 더 큰 디폴트 버퍼 사이즈를 갖는다.
5)스캐너는 IO예외를 감추지만, 버퍼리더는 우리가 그걸 처리하도록 한다.
6)버퍼리더는 파싱 없이 데이터를 읽어오기만 해서 스캐너보다 더 빠르다.
BufferedReader reader =
new BufferedReader(new FileReader("src/main/resources/input.txt"));
기본적으로 위처럼 버퍼를 만들면 8kb크기의 버퍼가 만들어진다.
BufferedReader reader =
new BufferedReader(new FileReader("src/main/resources/input.txt")), 16384);
버퍼사이즈를 위처럼 수정할 수도 있다.
BufferedReader reader =
Files.newBufferedReader(Paths.get("src/main/resources/input.txt"))
이렇게 하면 fileReader를 만들지 않아도 된다.
public String readAllLines(BufferedReader reader) throws IOException {
StringBuilder content = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
content.append(line);
content.append(System.lineSeparator());
}
return content.toString();
}
보통 버퍼 데이러를 읽을 떄는 위처럼 한다. 이번 was 미션에서도 이렇게 했다.
스트림을 사용하면 더 간단하게 할 수 있다고 한다.
public String readAllLinesWithStream(BufferedReader reader) {
return reader.lines()
.collect(Collectors.joining(System.lineSeparator()));
}
다만, 이번 미션에서는
private int parseHeader(BufferedReader buffer, List<String> subsequentLines) throws IOException {
String line;
int MessageSize = 0;
while ((line = buffer.readLine()) != null && !line.isEmpty()) {
if (line.contains("Content-Length")) {//size
String[] split = line.split(":");
MessageSize = Integer.parseInt(split[1].trim());
}
subsequentLines.add(line);
}
return MessageSize;
}
위처럼 Content-size값을 파싱해야 해서 저렇게 하기는 어려울듯하다.