설정 화면에 텍스트 입력 칸을 하나 만들었습니다. 값은 DataStore에 저장하고, 화면은 그 값을 collectAsState()로 받아 씁니다. 교과서적인 단방향 데이터 흐름입니다.
영어로 hello를 칩니다. 잘 됩니다. 지우고 다시 칩니다. 잘 됩니다. 배포합니다.
그리고 며칠 뒤 한글로 "가나"를 쳐 봅니다.
입력한 것 : 가나
칸에 남은 것 : ㄱㅏㄴㅏ
자음과 모음이 합쳐지지 않고 낱개로 확정됩니다. 백스페이스도 자모 단위로 지워집니다. 저는 이 상태로 앱을 스토어에 내보냈고, 그 입력 칸은 앱의 핵심 기능이었습니다.
원인 — 한 글자마다 디스크를 한 바퀴 돈다
문제의 코드는 이렇게 생겼습니다.
// ❌ 값의 주인이 DataStore 다
val settings by settingsVm.settings.collectAsState()
OutlinedTextField(
value = settings.customText,
onValueChange = { settingsVm.setCustomText(it) },
)
한 글자를 칠 때마다 이 경로를 돕니다.
키 입력 → onValueChange → ViewModel → DataStore 쓰기(디스크)
→ Flow 방출 → collectAsState → 리컴포지션 → value 갱신
영어에서는 이 왕복이 늦어도 티가 안 납니다. h는 그냥 h이고, 늦게 돌아와도 h입니다.
한글은 다릅니다. 한글 입력은 "조합 중"이라는 중간 상태를 거칩니다. ㄱ → 가 → 간처럼 이미 화면에 있는 글자가 다음 입력으로 바뀌는 과정이고, 이 조합 상태는 IME가 입력 필드와 연결된 채로 들고 있습니다.
그런데 value가 비동기 저장소에서 돌아오는 값이면, 한 글자마다 필드의 값이 외부에서 통째로 교체됩니다. IME 입장에서는 자기가 조합하던 글자가 매번 발밑에서 사라지는 셈이라 조합 상태가 끊깁니다. 끊긴 조합은 ㄱ인 채로 확정되고, 다음 ㅏ도 따로 확정됩니다. 그래서 ㄱㅏㄴㅏ가 됩니다.
조합 과정이 없는 문자에서는 왕복 지연이 그냥 "약간 늦게 반영됨"으로만 보입니다. 한글·일본어·중국어에서만 터집니다. 그래서 영어 문서와 영어 샘플만 보고 만든 코드에는 이 함정이 그대로 남아 있습니다.
해결 — 입력값은 화면이 들고, 저장은 따로 내보낸다
고치는 원칙은 한 줄입니다. 입력 중인 값의 주인은 화면이어야 합니다. 저장은 그대로 나가되, 필드가 그 결과를 기다리지 않게 만듭니다.
// ✅ 입력값은 화면이 직접 들고 있는다
var customInput by rememberSaveable { mutableStateOf<String?>(null) }
OutlinedTextField(
value = customInput ?: settings.customText,
onValueChange = {
customInput = it // 화면이 즉시 반영 — IME 조합이 안 끊긴다
settingsVm.setCustomText(it) // 저장은 따로 나간다
},
singleLine = true,
)
String?과 null이 하는 일
null은 "아직 사용자가 아무것도 안 쳤다"는 뜻입니다.
| 상태 | value | 의미 |
|---|---|---|
customInput == null | settings.customText | 저장된 값을 보여준다 (첫 진입, 로드 완료 시점 반영) |
customInput != null | customInput | 사용자가 한 글자라도 쳤다 — 로컬이 주인 |
빈 문자열("")로 초기화하면 안 되는 이유가 여기 있습니다. DataStore가 값을 늦게 내주는 첫 프레임에서 저장돼 있던 글자가 빈 칸으로 덮여 보입니다. 사용자에게는 설정이 날아간 것처럼 보입니다.
remember가 아니라 rememberSaveable
remember만 쓰면 화면 회전이나 시스템의 프로세스 정리에서 입력 중이던 값이 사라집니다. rememberSaveable은 Bundle에 실려 살아남습니다. 입력 칸에는 기본으로 이쪽을 씁니다.
같은 함정이 있는 자리들
DataStore만의 문제가 아닙니다. value가 비동기 왕복을 거쳐 돌아오면 전부 같습니다.
- Room — 입력할 때마다 DB에 쓰고
Flow로 다시 받는 구조 - 서버 저장 — 자동 저장 필드. 네트워크 지연이라 증상이 가장 심합니다
- ViewModel
StateFlow— 저장소가 없어도debounce·distinctUntilChanged·stateIn같은 연산자가 중간에 끼면 같은 일이 벌어집니다 - 부모에서 내려주는 상태 호이스팅 — 값이 여러 단계를 거쳐 오면서 프레임이 밀리는 경우
"
onValueChange에서 넣은 값이 같은 프레임에 그대로 value로 돌아오는가?"중간에 비동기가 한 번이라도 끼면 조합형 문자가 깨질 자리입니다.
확인하는 법
도구가 필요 없습니다. 앱을 켜고 텍스트 칸에 한글을 치면 끝입니다. 문제는 그걸 안 해 본다는 것입니다.
체크리스트로 두면 좋습니다.
- 입력 칸마다 "가나다"를 실제로 쳐 본다 — 붙여넣기 말고 자판으로. 붙여넣기는 조합 과정이 없어 통과합니다
- 중간 글자를 백스페이스로 지웠다가 다시 친다 — 조합 상태가 가장 잘 깨지는 지점입니다
- 빠르게 연타해 본다 — 저장이 느릴수록 증상이 선명합니다
- 화면을 회전시킨다 —
rememberSaveable여부가 여기서 드러납니다
UI 테스트로 잡으려는 시도는 권하지 않습니다. Compose 테스트의 텍스트 입력은 조합 과정을 거치지 않고 값을 밀어 넣는 방식이라, 사람이 손으로 치는 것과 경로가 다릅니다. 초록불이 나와도 손으로 치면 깨집니다.
정리
- Compose
TextField의value를 비동기 저장소 왕복에 맡기면 한글 자모가 흩어집니다. - 원인은 성능이 아니라 IME 조합 상태가 매 글자 끊기는 것입니다.
- 입력값은
rememberSaveable로 화면이 들고, 저장은 따로 내보냅니다. null초기값으로 "아직 안 침"과 "빈 값"을 구분합니다.- 텍스트 입력 칸이 있으면 반드시 한글로 쳐 보세요. 영어 테스트는 이 버그를 절대 못 잡습니다.
자주 묻는 것
매 글자 저장하는 게 문제 아닌가요? debounce를 걸면 되지 않나요?
debounce는 저장 횟수를 줄일 뿐, value의 주인이 여전히 저장소면 조합은 똑같이 끊깁니다. 오히려 지연이 들쭉날쭉해져 증상이 불규칙해집니다. 두 가지는 별개의 개선이고, 먼저 고칠 것은 값의 주인입니다. 주인을 화면으로 옮긴 뒤에 저장 쪽에 debounce를 거는 것은 좋습니다.
TextFieldValue를 쓰면 해결되나요?
TextFieldValue는 커서 위치·선택 영역까지 같이 다루는 타입이라 관리할 상태가 늘어납니다. 원인이 "값이 외부에서 교체된다"는 것이므로, 타입을 바꾸는 게 아니라 주인을 바꿔야 풀립니다.
단방향 데이터 흐름 원칙에 어긋나는 것 아닌가요?
저장의 진실 공급원은 여전히 저장소 하나입니다. 달라진 것은 입력 중인 임시 상태를 화면이 들고 있다는 점뿐이고, 이건 IME처럼 프레임 단위로 이어져야 하는 상태에 대한 정상적인 처리입니다. 아키텍처를 지키다 글자가 깨지면 지킬 이유가 없습니다.
