Skip to content

fix: EgovFileUtil.readFile의 문자셋 디코딩 오류와 개행 소실 수정 - #300

Open
z3rotig4r wants to merge 2 commits into
eGovFramework:mainfrom
z3rotig4r:fix/fileutil-charset-and-line-separator
Open

fix: EgovFileUtil.readFile의 문자셋 디코딩 오류와 개행 소실 수정#300
z3rotig4r wants to merge 2 commits into
eGovFramework:mainfrom
z3rotig4r:fix/fileutil-charset-and-line-separator

Conversation

@z3rotig4r

Copy link
Copy Markdown
Contributor

기존 테스트의 기대값을 바꿉니다

이 점을 먼저 확인해 주시기 바랍니다. testReadMultiLineFile은 PR #257에서 당시 동작을 그대로 고정해 둔 characterization 테스트입니다. 이번에 그 고정 대상이던 동작 자체가 결함으로 드러나 기대값을 바꾸고 이름도 testReadMultiLineFilePreservesLineSeparator로 정리했습니다. 종전 기대값이 규정된 계약이 아니라 구현 부산물이라고 판단한 근거는 다음 세 가지입니다.

  1. 개행이 사라진다고 규정한 javadoc이나 문서가 없습니다. readFile(File, String)의 javadoc은 "String 형으로 파일의 내용을 읽는다" 한 줄뿐입니다.
  2. 형제 API readTextFile(String, boolean)은 개행을 붙일지 여부를 호출자가 newline 인자로 고르게 하고 붙일 때는 System.lineSeparator()를 씁니다. 이 클래스는 개행을 말없이 버리는 설계가 아닙니다.
  3. 줄 단위로 읽어 붙이는 구현은 커밋 c3d1fc7("Apache common readLines(File) DEPRECATED 대응")에서 들어온 구현 세부입니다.

이 판단에 동의하지 않으시면 개행 부분만 따로 되돌릴 수 있도록 커밋을 나눠 두었습니다(아래 "변경 내용" 참고).

변경 이유

EgovFileUtil의 파일 읽기 경로에 결함이 두 가지 있습니다.

멀티바이트 문자가 깨집니다. readFileContent(InputStream)buf.append((char) i)로 바이트 하나를 char 하나로 캐스팅합니다. UTF-8로 저장한 행정안전부 표준프레임워크(13자)를 읽으면 37자짜리 깨진 문자열이 됩니다. public API인 readFile(File)이 이 경로를 쓰므로 한글이 든 파일은 이 메서드로 읽어서는 원문을 복원할 수 없습니다.

개행이 전부 사라집니다. readFile(File, String encoding)readTextLines()가 돌려준 줄들을 구분자 없이 이어 붙입니다. line1\nline2\nline3을 읽으면 line1line2line3이 됩니다. 읽은 내용을 다시 파일로 쓰거나 줄 단위로 파싱하면 원본과 달라집니다.

변경 내용

주제별로 커밋을 둘로 나눴습니다.

  • 2145063 인코딩readFileContentnew String(in.readAllBytes(), Charset.defaultCharset())으로 바이트가 아니라 문자 경계로 디코딩합니다. readFile(File)의 스트림 닫기도 try-with-resources로 정리했습니다.
  • 25cb547 개행readFile(File, String)이 줄 단위로 읽어 붙이는 대신 파일 전체를 읽어 지정 인코딩으로 디코딩합니다. 원본 개행이 LF든 CRLF든 그대로 남습니다. encoding이 null이면 종전처럼 플랫폼 기본 문자셋을 씁니다.

두 커밋은 서로 독립적입니다. HEAD에서 git revert 25cb547이 충돌 없이 적용되고 그 상태에서도 테스트 27건이 모두 통과하므로 개행 변경은 보류하고 인코딩 수정만 받으셔도 됩니다.

인코딩 미지정 오버로드는 JVM 기본 문자셋에 의존합니다

이 선택도 확인 부탁드립니다. readFile(File)은 이제 Charset.defaultCharset()으로 디코딩하므로 결과가 배포 JVM의 기본 문자셋에 좌우됩니다. UTF-8을 하드코딩하지 않은 이유는 짝이 되는 writeFile(File, String)(FileWriter)과 readTextFile(FileReader)이 모두 플랫폼 기본 문자셋을 쓰기 때문입니다. UTF-8로 고정하면 기본 문자셋이 EUC-KR인 JVM에서 이 클래스 자신의 쓰기·읽기 왕복이 오히려 깨집니다.

writeFile(File, String)으로 쓴 한글을 readFile(File)로 다시 읽는 왕복을 JDK 21에서 -Dfile.encoding을 바꿔가며 측정한 결과입니다(소스는 javac -encoding UTF-8로 선컴파일).

JVM 기본 문자셋 수정 전 이 PR UTF-8 하드코딩
UTF-8
EUC-KR
ISO-8859-1
windows-31j

ISO-8859-1과 windows-31j(일본어 MS932 계열)는 한글 자체를 표현하지 못해 쓰는 시점에 이미 손실됩니다. 읽기 쪽 수정으로 되돌릴 수 있는 범위 밖입니다. 어떤 환경에서도 이 PR이 수정 전보다 나쁘지 않습니다. 인코딩을 고정해야 하는 호출자는 readFile(File, String)을 쓰라는 안내를 javadoc에 적어 두었습니다.

예외 타입이 바뀌는 경우가 있습니다

존재하지 않는 파일에 UTF-8이 아닌 인코딩을 지정해 readFile(File, String)을 호출하면 예외가 NoSuchFileException에서 FileNotFoundException으로 바뀝니다. 둘 다 IOException입니다. 종전에도 UTF-8 경로는 FileNotFoundException이었으므로 두 경로가 같아지는 방향입니다. 레포 안에서 이 차이에 영향받는 호출처는 없습니다.

테스트 방법

mvn -B -o -pl Foundation/org.egovframe.rte.fdl.filehandling test
  • upstream main 기준 25건 통과 → 이 브랜치에서 31건 통과입니다. 신규 6건을 추가했습니다. 갱신한 1건은 위에 적은 testReadMultiLineFile의 기대값 변경 및 이름 변경입니다.
  • 신규 테스트는 기본 문자셋 한글 읽기, 빈 파일, LF 보존, CRLF 보존, EUC-KR 지정 읽기, 잘못된 UTF-8 바이트 처리를 검증합니다. 마지막 항목은 깨진 바이트를 대체 문자로 관대하게 처리하던 종전 동작이 유지되는지 보는 가드라서 수정 전후 모두 통과합니다.
  • 개행 커밋의 소스만 되돌리면 4건이 실패합니다. 인코딩 커밋의 소스만 되돌리면 1건이 실패합니다.

영향 범위

  • 변경 파일은 EgovFileUtil.javaFilehandlingServiceTest.java 둘뿐이며 시그니처 변경은 없습니다.
  • readFileContentreadFile(File)을 호출하는 곳은 EgovFileUtil 자신 말고는 레포 전역에 없습니다.
  • 부수 효과로 읽기 속도가 빨라졌습니다. 3.10MB 한글 UTF-8 파일 기준(워밍업 3회 후 10회 평균) readFile(File)이 150.6ms에서 14.6ms로, readFile(File, "UTF-8")이 30.2ms에서 14.3ms로 줄었습니다. 반환 문자열 길이도 바이트 수가 아니라 실제 문자 수로 바로잡힙니다.

readFileContent(InputStream)이 바이트 하나를 char 하나로 캐스팅한다. Latin-1로
읽는 것과 같아 멀티바이트 인코딩이 전부 깨진다. 이 경로를 쓰는 public
readFile(File)도 같은 영향을 받는다.

  "행정안전부 표준프레임워크"(13자)
    -> "íì ìì ë¶ íì¤íë ììí¬"(37자)

스트림을 전량 읽어 Charset.defaultCharset()으로 디코딩하도록 바꿨다. UTF-8을
하드코딩하지 않은 이유는 짝이 되는 쓰기 경로가 모두 플랫폼 기본 문자셋이기
때문이다. writeFile(File, String)은 FileWriter를, readTextFile(String, boolean)은
FileReader를 쓴다. 기본 문자셋을 써야 쓰기와 읽기의 왕복이 성립하고, 기존에
기본 문자셋으로 기록해 둔 파일도 계속 읽을 수 있다.

동작 변경: 비-ASCII 파일에서 readFile(File)의 반환값이 달라진다. 종전에는 깨진
문자열이 나왔으므로 그 값에 의존하는 코드는 없다고 본다. 실행환경 안에는
readFile(File)과 readFileContent(InputStream)의 호출처가 없다.

부수 효과로 성능도 개선된다. 3.36MB 한글 텍스트를 읽을 때 5회 평균 156~189ms에서
31~33ms가 됐다. 바이트 단위 read 호출과 StringBuilder 확장이 사라진 결과이며,
반환 문자열 길이도 바이트 수(3,364,768)가 아닌 실제 문자 수(1,300,024)가 된다.
readFile(File)의 스트림 해제도 try-with-resources로 정리했다.
readFile(File, String)은 readTextLines()로 줄 목록을 얻어 구분자 없이 이어붙인다.
readTextLines()가 돌려주는 각 원소에는 개행이 들어 있지 않으므로 원본의 개행이
전부 사라진다.

  "첫째 줄\n둘째 줄\n셋째 줄\n" -> "첫째 줄둘째 줄셋째 줄"
  "a\r\nb\r\n"                  -> "ab"

같은 클래스의 다른 읽기 경로와 견주면 의도된 동작으로 보기 어렵다.
readTextFile(String, boolean)은 newline 인자로 개행을 붙일지 호출자가 고르게
하고, 붙일 때는 System.lineSeparator()를 쓴다. 파일 내용을 문자열 하나로
돌려주는 API가 개행만 말없이 버릴 이유가 없다.

파일을 전량 읽어 지정한 인코딩으로 한 번에 디코딩하도록 바꿨다. LF와 CRLF의
구분, 마지막 줄의 개행까지 원본 그대로 보존된다. new String(byte[], Charset)은
잘못된 바이트를 U+FFFD로 대체하므로 종전 readUtf8LinesLenient의 관대한 처리도
그대로 유지된다. 없는 파일에 대한 예외는 종전 UTF-8 경로와 같은
FileNotFoundException으로 맞췄다(종전 비-UTF-8 경로는 NoSuchFileException).
readTextLines()는 grep(File, String)이 계속 쓰므로 남겨두었다.

testReadMultiLineFile의 기대값을 갱신했다. 이 테스트는 #257에서 빈 파일의
NoSuchElementException을 고치면서 당시 동작을 고정해 둔 characterization
테스트이고, 이번에 그 고정 대상이던 동작 자체가 결함으로 드러났다. 이름과
주석도 개행 보존 취지로 바꿨다.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant