앱개발에서 한글 깨짐이 생기는 이유와 해결법

인코딩-기본값 함정

앱 개발에서 텍스트 처리는 대부분 “문자열”로 시작하지만, 실제 장애는 거의 항상 “바이트” 경계에서 발생합니다. 화면에 보이는 문자열은 내부적으로 어떤 인코딩으로 저장되거나 전송되며, 그 바이트를 다른 규칙으로 해석하는 순간 글자가 깨지거나 데이터가 손상됩니다. 그래서 앱은 처음부터 끝까지 UTF-8을 기준으로 정하고, 경계 지점마다 “명시적으로” 고정하는 습관이 가장 중요합니다. 안드로이드와 iOS 모두 내부 문자열 표현은 UTF-8이 아니라는 점도 흔한 함정인데, 예를 들어 JVM 계열(Kotlin/Java)은 문자열이 UTF-16 기반이고 Swift는 유니코드 그래핌(사용자가 인식하는 글자 단위) 기준으로 동작하기 때문에, “내가 보고 있는 글자 수”와 “전송되는 바이트 수”가 달라지는 상황이 자주 나옵니다.

API-헤더 누락 문제

실무에서 가장 흔한 깨짐은 앱과 서버가 같은 데이터를 주고받는데도 특정 기기나 특정 네트워크 환경에서만 문자가 깨지는 케이스입니다. 원인은 대체로 응답 헤더의 charset 누락, 또는 서버가 UTF-8로 인코딩했는데 클라이언트가 기본값(혹은 잘못된 값)으로 해석하는 경우입니다. 특히 WebView, 오래된 프록시, 일부 CDN 설정에서 charset이 명확하지 않으면 해석이 흔들릴 수 있습니다. 해결책은 서버에서 Content-Type에 charset=utf-8을 명확히 포함하고, 앱에서도 바이트를 문자열로 바꿀 때 항상 UTF-8을 지정하는 방식으로 “양쪽에서 고정”하는 것입니다.

안드로이드에서 바이트를 문자열로 바꾸는 코드가 기본 charset에 의존하면 디바이스/OS 설정에 따라 깨질 수 있으니, 다음처럼 명시적으로 처리하는 것이 안전합니다.

import java.nio.charset.StandardCharsets

val text = String(responseBytes, StandardCharsets.UTF_8)

iOS에서도 Data를 String으로 변환할 때 utf8을 명시하고, 실패 시 원인을 기록해 두면 장애 재현이 훨씬 쉬워집니다.

let text = String(data: data, encoding: .utf8) ?? ""

JSON-이중인코딩 사고

API 연동에서 자주 발생하는 또 다른 문제는 이중 인코딩입니다. 예를 들어 서버가 이미 JSON 이스케이프된 문자열을 내려주는데, 앱에서 다시 escape/unescape를 적용하거나, 반대로 URL 인코딩이 필요한 위치에 일반 문자열을 그대로 넣어 “%”가 포함된 문자열을 다시 인코딩해버리는 식으로 데이터가 변형됩니다. 증상은 “\uXXXX가 그대로 보인다”, “공백이 +로 바뀌었다가 다시 공백이 된다”, “한글이 %EA%… 형태로 그대로 노출된다”처럼 다양합니다. 해결은 문자열이 지나가는 지점마다 그 문자열이 “문자열”인지 “인코딩된 문자열”인지 상태를 구분하는 것입니다. 특히 쿼리스트링에는 URL 인코딩을 적용하고, JSON 바디에는 JSON 직렬화 라이브러리에 맡기며, 화면 렌더링 단계에서는 원본 문자열만 다루도록 경계를 분리해야 합니다.

로컬저장소-파일깨짐 이슈

앱에서 파일로 텍스트를 저장할 때도 깨짐이 자주 납니다. 로그를 파일로 남기거나, 내보내기(Export) 기능으로 CSV/텍스트를 만들 때 “쓰기”는 UTF-8인데 “읽기”가 기본 인코딩으로 동작하면 바로 문제가 됩니다. 안드로이드에서 FileWriter, BufferedWriter 같은 클래스는 기본 charset을 쓰는 경우가 있어 주의가 필요합니다. 해결책은 저장과 로드 모두에서 UTF-8을 명시하고, 외부로 내보내는 파일이라면 BOM 처리 여부까지 정해야 합니다. 특히 엑셀 호환을 기대하는 CSV는 환경에 따라 BOM이 없으면 한글이 깨져 보일 수 있으니, 제품 요구사항에 따라 BOM을 넣는 전략을 선택합니다.

데이터베이스-utf8과 utf8mb4 차이

백엔드가 MySQL/MariaDB를 쓰는 경우, “utf8로 설정했는데 이모지가 저장이 안 된다”는 문제가 실제로 매우 자주 발생합니다. MySQL의 utf8은 3바이트 UTF-8로 동작하는 경우가 많아 4바이트 문자인 이모지나 일부 확장 문자를 제대로 저장하지 못합니다. 앱에서는 정상인데 서버 저장 단계에서 물음표로 바뀌거나 INSERT가 실패하는 증상으로 나타납니다. 해결은 데이터베이스와 테이블, 컬럼을 utf8mb4로 통일하고, 커넥션 설정도 utf8mb4를 사용하도록 맞추는 것입니다. 이건 앱 단에서 우회하기보다 서버/DB 표준을 바로잡는 쪽이 장기적으로 비용이 적습니다.

길이계산-문자수 착시

앱 기능 중 닉네임, 소개글, 댓글 같은 입력 제한은 흔한 요구사항인데, 여기서 “글자 수 제한”과 “바이트 제한”을 혼동하면 운영 이슈가 터집니다. 사용자가 보기에는 한 글자처럼 보이는 이모지 조합(예: 가족 이모지, 피부톤 변형, 결합 문자)은 내부적으로 여러 코드포인트와 여러 바이트로 구성될 수 있습니다. 서버가 바이트 기준으로 제한을 걸어두면 앱에서 통과한 값이 서버에서 거절되는 불일치가 생깁니다. 해결책은 요구사항을 먼저 명확히 하는 것입니다. UX 관점의 글자 수 제한이라면 사용자 인식 단위(그래핌 클러스터)에 가깝게 제한해야 하고, 저장소 관점의 제한이라면 바이트 기준을 앱에도 동일하게 적용해야 합니다. 서버와 앱이 같은 기준을 쓰도록 합의하지 않으면, 특정 입력에서만 오류가 발생하는 “재현 어려운 버그”로 남습니다.

안드로이드에서는 UTF-16 기반 길이와 코드포인트 기반 길이가 다를 수 있으니, 이모지 대응이 필요하면 코드포인트 기준 함수를 활용합니다.

val codePoints = text.codePointCount(0, text.length)

정규화-검색불일치 문제

한글과 다국어 입력에서는 유니코드 정규화(예: NFC/NFD) 차이로 인해 “같아 보이는데 검색이 안 된다”, “정렬이 이상하다” 같은 문제가 생길 수 있습니다. 특히 외부 키보드, 일부 입력기, 혹은 서버에서 가공된 텍스트가 들어올 때 정규화 형태가 달라지면 동일 비교가 실패합니다. 해결책은 저장 직전에 정규화를 강제하거나, 검색/비교 시점에 정규화를 적용해 비교 기준을 통일하는 것입니다. 다만 정규화를 무조건 한 번에 해결하려 하면 언어별 예외와 성능 문제가 생길 수 있으므로, 실제로 문제가 발생하는 기능(검색, 중복 체크, 해시 키 생성)부터 우선 적용하는 접근이 실무적으로 안전합니다.

제어문자-줄바꿈 폭탄

ASCII 제어문자는 “옛날 통신 문자”로만 보이지만, 앱에서는 여전히 이슈를 만듭니다. 대표적으로 CR/LF 차이 때문에 멀티라인 텍스트가 플랫폼마다 다르게 렌더링되거나, 로그 전송 시 줄바꿈이 깨져 파싱이 실패하는 경우가 있습니다. 또 NUL 같은 제어 문자가 문자열에 섞이면 일부 라이브러리나 백엔드 언어에서 문자열 종료로 오인해 데이터가 잘리는 사고도 발생합니다. 해결책은 입력 데이터에서 허용하지 않는 제어문자를 정제하고, 줄바꿈을 내부 규칙으로 통일한 뒤 저장하거나 전송하는 것입니다. 사용자 입력을 그대로 저장하는 기능이라면 “정제”가 오히려 데이터 훼손이 될 수 있으니, 최소한 전송/로그/프로토콜 경계에서만 안전하게 이스케이프하거나 제거하는 방식으로 경계를 나누는 것이 좋습니다.

디버깅-바이트 관찰법

문자 깨짐은 화면만 보고는 원인을 특정하기 어렵습니다. 실무에서는 문제 문자열을 “문자열”로 보지 말고 “바이트 시퀀스”로 덤프해 비교하는 것이 가장 빠릅니다. 앱에서 수신한 원본 바이트, 서버에서 보낸 바이트, 중간 게이트웨이를 통과한 바이트를 각각 기록하면 “어디서 변형됐는지”가 즉시 드러납니다. 또한 API 응답의 Content-Type, 서버 로그의 실제 인코딩, 데이터베이스 컬럼의 문자셋, 앱 내부에서 문자열을 생성하는 단계(예: Base64 디코딩, URL 디코딩, HTML 엔티티 디코딩)까지 순서대로 점검하면 재발 방지까지 연결됩니다.

테스트-다국어 시나리오

인코딩 관련 버그는 영어 데이터로는 거의 드러나지 않기 때문에, QA 단계에서 다국어와 이모지를 포함한 고정 테스트 문자열을 반드시 돌려야 합니다. 한글, 일본어, 아랍어 같은 RTL 언어, 결합 이모지, 악센트 결합 문자, 긴 문자열, 줄바꿈 포함 문자열을 동일한 흐름으로 “입력→전송→저장→조회→표시”까지 왕복시켜보면, 앱 단독 문제가 아니라 시스템 전체의 경계 문제를 조기에 잡을 수 있습니다. 실무적으로는 특정 기능에서만 문제가 발생하는 경우가 많으니, 댓글/채팅/검색/푸시/딥링크/웹뷰처럼 텍스트가 다양한 채널을 거치는 기능부터 우선순위를 두고 시나리오를 구성하는 편이 효율적입니다.

결론

앱 개발에서 텍스트 깨짐과 저장 실패는 문자열 자체의 문제가 아니라, 문자열이 바이트로 변환되고 다시 문자열로 복원되는 경계에서 인코딩 규칙이 어긋날 때 발생합니다. UTF-8을 “기본값”으로 믿고 방치하면 API 응답의 charset 누락, JSON/URL 이중 인코딩, 파일 저장 시 기본 인코딩 의존, DB의 utf8·utf8mb4 불일치, 문자 수·바이트 수 착시 같은 실무 이슈가 특정 기기·특정 입력에서만 터져 재현과 추적이 어려워집니다. 가장 확실한 대응은 전 구간에서 UTF-8을 표준으로 정하고, 서버 헤더와 앱 변환 지점에서 인코딩을 명시하며, 저장소와 전송 규칙(특히 이모지 포함)을 시스템 전체에서 통일하는 것입니다. 동시에 디버깅 단계에서는 화면에 보이는 글자가 아니라 원본 바이트를 관찰해 변형 지점을 찾는 방식으로 접근해야 재발 방지까지 연결됩니다.

FAQ

UTF-8을 쓰는데도 한글이 깨지는 이유가 뭔가요?

대부분은 “UTF-8로 인코딩된 바이트”를 앱이나 중간 장치가 다른 문자셋으로 해석해서 생깁니다. 서버가 Content-Type에 charset=utf-8을 누락하거나, 클라이언트가 기본 charset으로 바이트를 문자열로 변환하면 환경에 따라 깨짐이 발생할 수 있으니, 서버·앱 모두에서 UTF-8을 명시적으로 고정하는 것이 핵심입니다.

JSON에서 \uXXXX가 그대로 보이거나 %EA%.. 같은 값이 노출돼요. 왜 그런가요?

이스케이프나 URL 인코딩이 “필요한 지점”과 “이미 인코딩된 값”을 구분하지 못해 이중 처리될 때 흔히 나타납니다. JSON 직렬화는 라이브러리에 맡기고, 쿼리스트링처럼 URL 인코딩이 필요한 경계에서만 인코딩을 적용하며, 화면에는 원본 문자열만 전달되도록 단계별 책임을 분리하면 해결됩니다.

이모지가 DB에 저장이 안 되거나 물음표로 바뀌는데 앱 문제인가요?

앱 문제가 아니라 DB 문자셋 문제인 경우가 매우 많습니다. 특히 MySQL 계열에서 utf8을 사용하면 4바이트 문자인 이모지 저장이 실패할 수 있으니, DB/테이블/컬럼을 utf8mb4로 통일하고 커넥션 문자셋도 맞춰야 안정적으로 저장됩니다.

글자 수 제한을 걸었는데 특정 이모지나 결합 문자는 통과/실패가 제각각이에요. 왜 이런가요?

사용자가 인식하는 “글자 1개”가 내부적으로 여러 코드포인트와 여러 바이트로 구성될 수 있기 때문입니다. UX 기준의 글자 수 제한인지, 저장소/서버 기준의 바이트 제한인지 먼저 결정하고, 앱과 서버가 동일한 기준으로 검증하도록 합의해야 불일치 오류를 막을 수 있습니다.

파일로 내보낸 CSV나 로그 파일이 PC에서 열면 한글이 깨져요. 어떻게 해야 하나요?

쓰기와 읽기의 인코딩이 다르거나, 뷰어(예: 일부 스프레드시트)가 UTF-8을 자동 인식하지 못할 때 발생합니다. 앱에서 저장할 때 UTF-8을 명시하고, 외부 도구 호환이 요구되면 BOM 포함 여부를 제품 요구사항으로 정해 일관되게 적용하는 것이 안전합니다.

WebView에서만 글자가 깨지거나 특정 네트워크에서만 문제가 재현돼요. 원인이 뭔가요?

WebView는 응답 헤더의 Content-Type과 charset에 영향을 크게 받으며, 프록시/CDN 같은 중간 경로가 헤더를 변형하거나 누락시키면 해석이 흔들릴 수 있습니다. WebView 로드 대상의 서버 응답에 charset=utf-8을 명시하고, 가능한 경우 응답 본문과 헤더를 캡처해 실제로 어떤 값이 전달되는지 확인하는 방식이 효과적입니다.

줄바꿈이 iOS/안드로이드/서버 로그에서 다르게 보이거나 파싱이 실패해요. 어떻게 정리하나요?

CR/LF 차이와 제어문자 처리 방식 차이가 원인인 경우가 많습니다. 내부 저장 규칙을 하나로 정해 줄바꿈을 정규화하거나, 전송/로그 같은 프로토콜 경계에서만 안전하게 이스케이프 처리해 파싱 실패를 방지하는 방식이 실무적으로 안정적입니다.

인코딩 문제를 가장 빠르게 찾는 디버깅 방법은 뭔가요?

문자열로 보지 말고 “바이트”로 덤프해 비교하는 것이 가장 빠릅니다. 앱이 수신한 원본 바이트, 서버가 송신한 바이트, 중간 경로를 통과한 바이트를 순서대로 대조하면 변형 지점이 명확해지고, Content-Type/charset, 디코딩 단계, URL/JSON 처리 지점을 함께 점검하면 재발 방지까지 연결됩니다.

댓글 남기기