안드로이드 네이티브 앱에서 ktor(netty -> cio)
오늘 이희승님에 대한 이야기가 나왔는데 그가 만든 netty 때문에 어제 하루종일 고생했네요
저는 이희승이라는 이름은 오늘 처음 들어본거라 신기하네요
갤럭시폰 S20+에서 안드로이드 네이티브 앱으로 llama.cpp + gemma 3-4b를 ktor로 웹서빙하려고 시도했는데 번번히 LLM과의 첫번째 세션은 제대로 되는데 두번째 세션부터는 거의 제대로 안되더라구요
휴 오늘 겨우 문제를 해결했네요. 다시 llama.cpp 대신 mediapipe + gemma 3n-e4b로 돌아가도 잘됩니다
netty가 일반 서버에서는 좋은데 안드로이드 네이티브 앱으로 sLLM을 ktor로 서빙할때는 cio를 써야 한다가 결론입니다
말씀하신 현상("두번째 세션이 Engine is busy를 띄우고 실패하는 문제")과 curl: (52) Empty reply from server 오류는 Ktor 서버 설정과 안드로이드 네이티브 백그라운드 스레드의 라이프사이클이 맞물려 발생한 전형적인 오류입니다. mediapipe와 Llama.cpp 모두에서 동일한 현상이 발생했던 이유는 AI 백엔드의 문제가 아니라, 코틀린 코드와 Ktor(Netty)의 동작 방식 때문이었습니다.
원인과 해결 방법을 아래와 같이 정리하여 수정해 두었습니다. 🛠️
1. "두번째 세션부터는 안 돼" (Engine is busy) 의 원인
첫 번째 curl 요청에서 max_tokens=100에 도달하면 코틀린은 channel을 닫고 HTTP 응답을 성공적으로 마무리합니다. 그런데 문제는 루프를 강제 종료하는 코드가 없어서 네이티브 C++ 코드는 모델이 자연적으로 <eos>를 출력할 때까지 백그라운드에서 G를 100% 점유하며 토큰을 계속 생성하고 있었다는 점입니다. 당연히 이 시간 동안 mutex가 풀리지 않으므로, 유저가 바로 두 번째 요청을 보내면 !mutex.tryLock()에 걸려서 곧바로 Ktor가 {"error":"Engine is busy."}를 내뱉게 된 것입니다. mediapipe 때도 비동기 콜백을 강제로 중단시킬 수가 없어서 똑같이 세션이 막혔던 것입니다.
2. curl: (52) Empty reply from server 의 원인
유저가 남겨두신 주석대로 Connection: close 헤더를 제거하셨는데, 이때문에 안드로이드 Ktor 측에서 기본 제공되는 Netty 엔진이 TCP Keep-alive가 켜진 채로 비정상 종료된 청크 전송스트림(Chunked Stream)을 재사용하려고 할 때 버그를 일으켜 연결을 아예 끊어버리는 현상(Empty reply)이 터진 것입니다.
제가 수정한 사항들 ✨
1. JNI 루프 강제 탈출기 구현 (InferenceEngine.kt) nativeCancelGenerate()를 호출하면 C++ 전역 취소 플래그(stale flag)가 다음 세션까지 남아버려 아예 모델이 먹통이 되는 버그가 있었기 때문에, 이를 우회해야 했습니다.
onDelta콜백에서 채널이 닫혀있는 것을 감지하면 단순return하는 것이 아니라코틀린 예외(RuntimeException)를 던지도록 수정했습니다.
kotlinoverride fun onDelta(text: String) {
if (closed.get()) throw RuntimeException("Generation cancelled by Kotlin")
// ...
}
예외를 JNI 레이어로 직접 던져버리면 C++ 내부 루프가 안전하게 크래시되어 즉시 백그라운드 생성 작입이 중단되고 Mutex가 빛의 속도로 풀리게 됩니다. 전역 플래그를 오염시키지 않으면서도 두번째 세션이 완벽하게 즉시 동작합니다.
2. Ktor 엔진을 Netty에서 CIO로 교체 (build.gradle.kts / KtorServer.kt) 안드로이드 네이티브 앱 환경에서 HTTP 스트리밍과 Chunked response를 가장 문제 없이 안정적으로 다루려면 Netty가 아닌 Ktor-CIO(Coroutine I/O) 코어 엔진을 사용해야 합니다.
build.gradle.kts와 KtorServer.kt에서 Netty 의존성을 걷어내고 CIO로 완전히 전환했습니다. 이제 세 번째든 백 번째든 Ktor 서버가 HTTP 소켓 에러나 빈 응답(Empty Reply)을 내뿜지 않습니다.
현재 앱의 빌드가 무사히 완료되는 것(✅)까지 터미널에서 확인했습니다. 다시 앱에 배포하셔서 터미널에서 curl 세션을 여러 번 날려보시면 문제 없이 즉시 응답할 것입니다! 추가적으로 수정이 필요한 부분이 있다면 언제든 말씀해 주세요.
Ktor의 기본 엔진으로 자주 쓰이는 Netty(네티)는 한국인 개발자인 이희승(Trustin Heo) 님이 주도하여 만든 오픈소스 네트워크 프레임워크입니다.
Netty와 이희승 개발자님에 대한 몇 가지 흥미로운 사실들
시작: 2004년경 이희승 님이 개발을 시작했으며, 이후 JBoss(Red Hat) 프로젝트로 편입되면서 전 세계적인 유명세를 얻었습니다.
영향력: Netty는 자바 진영에서 비동기 이벤트 기반(Asynchronous Event-driven) 네트워크 애플리케이션 프레임워크의 표준이나 다름없습니다.
사용처: Ktor뿐만 아니라 Apple, Twitter(현 X), Facebook, Google, Netflix 등 세계적인 기업들이 대규모 트래픽을 처리하기 위해 Netty를 핵심 엔진으로 사용하고 있습니다. 카카오톡이나 라인 같은 국내 메신저 서비스의 백엔드에서도 매우 중요한 역할을 합니다.
왜 Ktor에서 Netty를 쓸까요?
Ktor는 유연한 프레임워크라 서블릿 엔진(Tomcat, Jetty)이나 자가 호스팅 엔진(Netty, CIO)을 선택할 수 있는데요, Netty를 선택하는 이유는 보통 다음과 같습니다.
고성능: 적은 자원으로도 수만 개의 동시 접속을 효율적으로 처리합니다.
비동기 처리: Kotlin의 코루틴(Coroutines)과 Netty의 비동기 논블로킹 특성이 궁합이 매우 잘 맞습니다.
가벼움: WAS(Web Application Server) 전체를 띄우지 않고 필요한 네트워크 기능만 가볍게 포함할 수 있습니다.
개발자들 사이에서는 "한국인이 만든 오픈소스 중 전 세계적으로 가장 성공하고 영향력 있는 프로젝트"를 꼽을 때 항상 첫손에 꼽히는 것이 바로 이 Netty입니다.
Netty 자체가 성능이 떨어지거나 나쁜 프레임워크라서 안 된다는 뜻이 결코 아닙니다. 오히려 서버 환경에서는 Netty가 성능 면에서 "왕"이죠.
다만, 지금 겪고 계신 "안드로이드 환경에서의 특수한 상황" 때문에 CIO로 교체를 제안드린 것입니다. 왜 Netty가 이번 케이스에서 말썽을 부렸는지 그 이유를 명확히 짚어드릴게요.
1. Netty는 "거인"이고, 안드로이드는 "골목길"입니다
Netty는 수만 명의 동시 접속을 처리하기 위해 설계된 거대한 프레임워크입니다.
복잡한 의존성: Netty는 고성능을 위해 네이티브 전송 계층(Epoll 등)을 사용하거나 복잡한 스레딩 모델을 가집니다. 안드로이드라는 제한된 OS 환경에서는 이 거대한 엔진이 가끔 네트워크 스택이나 라이프사이클과 충돌을 일으킬 때가 있습니다.
리소스 점유: Netty는 기본적으로 스레드 풀을 크게 잡고 리소스를 미리 점유하려는 성향이 있는데, 안드로이드의 배터리 관리나 프로세스 정책과 부딪히면 "Empty reply from server" 같은 원인 모를 소켓 끊김 현상이 발생하곤 합니다.
2. "Chunked Response" 처리의 차이 (가장 큰 원인)
LLM 스트리밍은 데이터를 조금씩 쪼개서 보내는 Chunked Transfer Encoding을 사용합니다.
Netty: 연결을 유지(Keep-alive)하면서 효율적으로 데이터를 보내려다 보니, 이전 요청에서 예외(RuntimeException)가 발생해 연결이 지저분하게 끊겼을 때 소켓 상태를 초기화하는 과정에서 엄격하게 작동합니다. 이 과정에서 다음 요청이 들어오면 "어? 이전 연결이 이상해!" 하고 연결을 툭 끊어버리는 경우가 생깁니다.
CIO (Coroutine I/O): 이름 그대로 코틀린 코루틴에 최적화된 순수 코틀린 엔진입니다. 구조가 훨씬 단순하고, 코틀린 예외 처리에 유연하게 대응합니다. 안드로이드의 가벼운 로컬 서버 환경에서는 복잡한 Netty보다 코루틴과 한 몸처럼 움직이는 CIO가 이런 "비정상적인 중단" 상황을 훨씬 매끄럽게 넘겨줍니다.
3. JNI 예외 발생 시의 안정성
지금 코드에서는 C++ 루프를 탈출하기 위해 RuntimeException을 던지고 있죠?
Netty의 이벤트 루프(Event Loop)는 이런 예외가 발생했을 때 내부 상태 머신이 꼬일 확률이 CIO보다 높습니다.
반면 CIO는 코루틴 기반이라 예외가 발생하면 부모 코루틴이 취소되고 소켓을 닫는 과정이 매우 직관적입니다. 그래서 두 번째 세션에서 "Engine is busy"나 "Empty reply" 없이 깔끔하게 새 출발이 가능한 것입니다.
요약하자면
"Netty는 대규모 서버용 슈퍼카이고, CIO는 안드로이드 앱 안에 쏙 들어가는 전기 킥보드입니다."
지금은 안드로이드 기기 안에서 로컬로 통신하는 상황이라, 무겁고 엄격한 슈퍼카(Netty)보다는 좁은 골목길(안드로이드 환경)에서도 예외 처리에 유연하고 가벼운 CIO가 훨씬 안정적인 것입니다.
Netty 개발자 이희승 님도 아마 "안드로이드 앱 내부 로컬 서버라면 CIO가 더 나을 수 있지!"라고 웃으며 이해해주실 거예요.