bluetape4k-dependencies 1.3.0 활용기 Part 4: 입력 경계와 자원 안전성

작은 파일, 짧은 문자열, 정상적인 JSON, 개발자가 직접 만든 테스트 데이터만으로는 입력 경계의 문제가 드러나지 않을 수 있습니다.
그러다 운영에서 이런 입력이 들어옵니다.
- 80MB TIFF 파일
- 회전 정보가 이상한 이미지
- OCR 적용 가능 여부가 불분명한 스캔본
- 30만 자짜리 검색어
- 금칙어 우회를 노린 문자열
- 인코딩이 손상된 텍스트
이러한 입력은 타임아웃, 메모리 압박, 400·413 응답, 로그 마스킹 누락과 같은 문제를 일으킵니다. 따라서 입력 수신, 검증, 디코딩, 처리, 실패 응답의 경계를 명시적으로 설계해야 합니다.
dependencies 1.3.0에서 image 0.3.0과 text 0.2.1 변화는 이 입력 경계를 다룰 때 특히 의미가 있습니다.
이미지: OCR 전에 파일의 메모리 적재 지점을 확인한다
섹션 제목: “이미지: OCR 전에 파일의 메모리 적재 지점을 확인한다”OCR 인식률은 중요한 품질 지표입니다. 그러나 서버에서는 그보다 먼저 자원 경계를 확인해야 합니다.
이 파일 전체가 메모리에 적재되는 시점은 언제인가?
작은 예제에서는 이런 코드가 편합니다.
fun extractText(file: MultipartFile): OcrResult { val bytes = file.bytes return ocr.read(bytes, language = "kor+eng")}샘플 이미지가 300KB라면 아무 문제도 없습니다. 테스트도 잘 통과합니다. 그런데 운영에서 80MB짜리 TIFF가 들어오면 상황이 달라집니다. 동시 요청이 몇 개만 겹쳐도 메모리 압박이 급격히 커집니다.
원하는 방향은 파일을 너무 빨리 ByteArray로 바꾸지 않는 것입니다. image 0.3.0 라인에서는 OCR 예제와 Okio 기반
대용량 파일 I/O 경로를 함께 확인해야 합니다.
fun extractText(path: Path): OcrResult { return FileSystem.SYSTEM.source(path).buffer().use { source -> imageReader .read(source) .autoOrient() .resize(maxWidth = 2_000) .grayscale() .sharpenForOcr() .runOcr(language = "kor+eng") }}이 코드는 실제 API 이름을 그대로 보여주기 위한 예제가 아니라, 처리 순서를 보여주기 위한 의사코드입니다. 중요한 흐름은 다음과 같습니다.
upload stream -> temporary file -> Okio source -> image decode -> orientation/resize/preprocess -> OCR -> structured result이 순서에서 각 단계마다 제한을 걸 수 있습니다.

| 단계 | 걸어야 할 제한 |
|---|---|
| 업로드 | 요청 크기, 콘텐츠 유형, 타임아웃 |
| 임시 파일 | 디스크 할당량, 정리 정책 |
| 디코딩 | 지원 형식, 최대 픽셀 수 |
| 전처리 | 크기 조정 한계, 네이티브 런타임 실패 처리 |
| OCR | 언어, 타임아웃, 빈 결과 처리 |
이미지 API에서 결함이 자주 발생하는 지점은 OCR 옵션이 아니라 입력 제한입니다. 예상에 의존하지 말고 허용할 요청 크기와 디코딩 한계를 명시해야 합니다.
API 응답은 실패를 숨기지 않아야 한다
섹션 제목: “API 응답은 실패를 숨기지 않아야 한다”OCR API는 실패 종류도 나눠야 합니다.
post("/ocr") { val file = call.receiveMultipartFile("file")
when { file.size > 20.megabytes -> call.respond(HttpStatusCode.PayloadTooLarge) !file.contentType.isSupportedImage() -> call.respond(HttpStatusCode.UnsupportedMediaType) else -> { val result = ocrService.extract(file) call.respond(result) } }}모든 실패를 500 응답으로 처리하면 운영자가 원인을 분류해야 합니다.
| 실패 | HTTP 상태 코드 | 이유 |
|---|---|---|
| 파일이 너무 큼 | 413 | 서버 문제가 아니라 요청 크기 문제 |
| 지원하지 않는 형식 | 415 | 클라이언트가 입력을 바꿔야 함 |
| OCR 타임아웃 | 504 또는 재시도 가능 실패 | 처리 시간이 한계를 넘음 |
| 네이티브 런타임 오류 | 500 | 서버 환경 또는 라이브러리 런타임 확인 필요 |
실패를 구분하면 클라이언트는 입력을 수정할 수 있고, 운영자는 원인을 좁힐 수 있습니다. 반대로 실패 응답을 단일 형태로 처리하면 입력 오류와 서버 오류를 로그에서 다시 분류해야 합니다.
텍스트: 토크나이저 앞에는 길이 제한이 있어야 한다
섹션 제목: “텍스트: 토크나이저 앞에는 길이 제한이 있어야 한다”텍스트 API도 비슷합니다. 토크나이저는 편리하지만, 외부 입력을 그대로 넣으면 안 됩니다.
post("/tokenize") { val request = call.receive<TokenizeRequest>() val tokens = koreanTokenizer.tokenize(request.text) call.respond(tokens)}이 코드는 문서 예제처럼 보이지만, 실제 API로는 부족합니다. 최소한 길이 제한과 실패 응답을 먼저 정해야 합니다.
post("/tokenize") { val request = call.receive<TokenizeRequest>()
if (request.text.length > 10_000) { call.respond( HttpStatusCode.PayloadTooLarge, ErrorResponse(code = "TEXT_TOO_LONG", message = "text is too long") ) return@post }
val tokens = koreanTokenizer.tokenize(request.text) call.respond(TokenizeResponse(tokens))}여기서 주의할 점은 오류 응답에 원문을 그대로 넣지 않는 것입니다.
// 원문을 오류 응답에 포함하지 않는다.ErrorResponse( code = "INVALID_TEXT", message = "invalid text: ${request.text}")검색어, 이름, 주소, 메시지 본문이 로그나 오류 응답에 그대로 남으면 개인정보 문제가 될 수 있습니다. 토크나이저와 금칙어 API는 입력 문자열을 다루므로 로그 마스킹과 오류 응답을 함께 설계해야 합니다.
금칙어: 검사는 통과와 실패만으로 끝나지 않는다
섹션 제목: “금칙어: 검사는 통과와 실패만으로 끝나지 않는다”금칙어 검사는 언뜻 단순합니다.
if (blockwordMatcher.contains(request.title)) { throw BadRequestException("blocked word")}하지만 실제 서비스에서는 더 많은 질문이 따라옵니다.
- 어떤 필드를 검사할 것인가?
- 원문을 로그에 남길 것인가?
- 어느 단어가 걸렸는지 사용자에게 알려줄 것인가?
- 우회 문자열을 정규화할 것인가?
- 관리자가 사전을 어떻게 갱신할 것인가?
이 질문에 대한 답이 없으면 금칙어 검사는 나중에 정책 논쟁이 됩니다. 기술 문제처럼 시작했는데, 운영 정책과 고객 응대가 함께 포함됩니다.
의사코드로는 이런 경계를 잡아두는 편이 낫습니다.
fun validatePost(request: CreatePostRequest) { request.title.requireLength(max = 200) request.body.requireLength(max = 10_000)
val normalizedTitle = textNormalizer.normalize(request.title) val normalizedBody = textNormalizer.normalize(request.body)
val hit = blockwordMatcher.findFirst(normalizedTitle, normalizedBody)
if (hit != null) { throw InvalidInputException( code = "BLOCKED_WORD", publicMessage = "입력값에 사용할 수 없는 표현이 포함되어 있습니다.", logContext = mapOf( "field" to hit.field, "ruleId" to hit.ruleId, ), ) }}publicMessage에는 원문을 넣지 않습니다. 로그에도 원문 대신 필드, 규칙 ID, 요청 ID처럼 추적 가능한 값만 남기는
편이 안전합니다. 운영자가 원인을 확인할 수는 있어야 하지만, 그 과정에서 민감한 입력을 더 넓게 퍼뜨리면 안 됩니다.
입력 경계 점검표
섹션 제목: “입력 경계 점검표”image와 text 쪽 API를 만들 때는 다음 항목을 먼저 정해두면 좋습니다.
| 경계 | 확인할 질문 |
|---|---|
| 요청 크기 | 이 API 경로가 받을 수 있는 최대 크기는 얼마인가? |
| 디코딩·토큰화 타임아웃 | 처리 시간이 길어질 때 어디서 중단할 것인가? |
| 메모리 | 입력을 ByteArray나 String으로 통째로 복사하는 지점은 어디인가? |
| 상태 코드 | 사용자 입력 문제와 서버 문제를 어떻게 구분할 것인가? |
| 오류 응답 | 원문 입력이 응답이나 로그에 노출되지 않는가? |
| 정규화 | OCR·텍스트·금칙어 처리 전에 정규화가 필요한가? |
| 관측성 | 실패 횟수, 타임아웃, 요청 크기 초과를 메트릭으로 확인할 수 있는가? |
이 점검표는 완전한 보안 체계를 대체하지 않습니다. 입력 자원과 실패 응답의 기본 경계를 일관되게 검증하기 위한 최소 기준입니다.
마무리
섹션 제목: “마무리”dependencies 1.3.0에서 image와 text 변화는 입력 경계를 다시 검토하게 합니다. OCR을 적용하면 파일 크기와 메모리를
확인해야 하고, 토크나이저와 금칙어 처리를 적용하면 문자열 길이, 실패 응답, 로그 마스킹을 확인해야 합니다.
새 기능은 보통 제공하는 동작을 중심으로 소개됩니다. 그러나 서비스에서는 허용할 입력 범위를 함께 정의해야 합니다. 입력 경계를 정하지 않으면 비정상적이거나 과도한 입력이 운영 장애로 이어질 수 있습니다.
기능을 추가할 때 입력 한계와 실패 계약까지 함께 정의해야 운영 장애를 줄일 수 있습니다.
시리즈 글
섹션 제목: “시리즈 글”처음 bluetape4k-dependencies를 적용하는 방법부터 확인하려면
사용 가이드를 먼저 읽으면 됩니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.