문자 하나 때문에 셀러가 자기 문의를 열지 못한 이야기
셀러 어드민에 문의가 하나 들어왔습니다. 내용은 이랬습니다.
"제가 작성한 글인데 조회를 할 수 없습니다."
목록에는 자기가 쓴 문의가 멀쩡히 보입니다. 그런데 클릭하면 토스트가 뜹니다. "문의글을 찾을 수 없습니다." 목록에 있는 글을 못 찾겠다니, 앞뒤가 맞지 않는 말입니다.
이 글은 그 한 줄짜리 제보에서 시작해 MySQL의 collation 규칙과 Hibernate 내부까지 내려간 기록입니다.
이 글에서
␣는 공백 한 칸을 나타냅니다. 원래 눈에 보이지 않는 문자라 일부러 기호로 표시했습니다.
로그를 따라가니 예외 지점은 금방 나왔습니다. 문의를 읽음 처리하는 서비스 코드입니다.
fun markAsRead(id: Long, …, readerId: String) {
val inquiry = getInquiry(id)
⋮
// 셀러 본인이 쓴 문의 → seller ID 수준 격리
require(inquiry.writerId == readerId) { "문의글을 찾을 수 없습니다." }
}
셀러가 남의 문의를 열지 못하게 막는, 지극히 평범한 소유권 검증입니다. 문제는 이 require가 통과하지 못했다는 것입니다.
두 값을 찍어봤습니다.
| 값 | |
|---|---|
inquiry.writerId | seller_a |
readerId | seller_a |
== | FALSE |
눈으로 봤을 때는 완전히 같은 문자열인데 false입니다.
문자열을 그대로 믿지 않고 길이를 재봤습니다.
| 출처 | 값 | 길이 |
|---|---|---|
inquiry.writer_id (테이블에 저장돼 있던 값) | seller_a␣ | 9자 · 후행 공백 |
readerId (요청으로 들어온 값) | seller_a | 8자 |
DB에 저장된 작성자 ID 끝에 공백 한 칸이 붙어 있었습니다.
원인은 찾은 것 같았습니다. 그런데 이 공백을 설명하려니 오히려 이상한 점이 두 개 생겼습니다.
공백이 어디서 들어왔는지 경로를 추적했습니다.
로그인 요청
"seller_a␣"로 클라이언트가 로그인 요청을 합니다.
seller_account 조회
"seller_a␣" 값으로 DB에서 계정 정보를 조회합니다.
DB에 저장된 "seller_a" row가 조회됩니다.
SellerAccount 엔티티 및 JWT 토큰
서버에서 Entity 객체로 인스턴스화 될 때 id 값에 "seller_a␣"가 들어갑니다.
JWT 토큰에 들어가는 sellerId 커스텀 필드에도 마찬가지로 "seller_a␣"가 들어갑니다.
문의 작성자 id
작성자 id로 SellerAccount 엔티티의 id 값을 사용하기 때문에 writer_id에 "seller_a␣"가 저장됩니다.
여기서 이해가 되지 않는 지점이 두 군데입니다.
1. 틀린 값인데 왜 매칭에 성공했을까요?
DB에 저장된 계정 ID는 seller_a인데, 클라이언트는 seller_a␣를 보냈습니다. 다른 문자열이니 조회에 실패해야 정상입니다. 그런데 로그인은 통과했습니다.
2. DB엔 공백이 없는데 왜 그대로 남았을까요?
조회가 성공했다면 DB에서 읽어온 seller_a가 토큰에 담겨야 합니다. 그런데 토큰에는 공백이 그대로 남았습니다.
테스트 서버에서 1~3 구간을 그대로 실행했습니다. 로그인 ID 끝에 공백 한 칸만 붙여서 요청을 보냈습니다.
로그인은 그대로 성공했습니다. 그리고 발급된 액세스 토큰을 디코딩해보니 이렇습니다.
{
"jti": "…",
"iss": "…",
"iat": 1786104687,
"exp": 1786111887,
"type": "ACCESS",
"sellerId": "seller-a ", // ← 후행 공백이 그대로 실려 있다
"companyCode": "SELLER_A",
"accessType": "FULL"
}
공백을 붙여도 로그인은 성공하고, 그 공백이 토큰까지 따라옵니다. 이후 이 셀러가 하는 모든 요청은 seller_a␣라는 정체성을 달고 다니게 됩니다.
먼저 첫 번째 의문에 알아볼 시간입니다. 답은 MySQL collation의 패드 속성(pad attribute) 에 있었습니다.
utf8mb4_general_ci 같은 collation은 PAD SPACE 속성을 갖습니다. 문자열을 비교할 때 뒤에 붙은 공백을 없는 것으로 취급한다는 뜻입니다.
저장값 s e l l e r _ a
요청값 s e l l e r _ a ␣
└──── 비교 ────┘ └ 무시 ┘
= TRUE → 행이 정상적으로 반환됩니다
WHERE id = 'seller_a␣' 가 seller_a 행을 멀쩡히 찾아옵니다. 로그인이 성공한 이유입니다.
여기서 중요한 것은, 이 관대함이 읽을 때만 적용된다는 점입니다.
| 동작 | |
|---|---|
| 비교할 때 · WHERE | 'seller_a' = 'seller_a␣' → TRUE — 후행 공백을 무시합니다 |
| 저장할 때 · INSERT | 'seller_a␣' → 'seller_a␣' — VARCHAR는 공백을 그대로 보존합니다 |
읽을 때는 공백을 무시하고, 쓸 때는 공백도 문자로 취급합니다. 이 비대칭이 이번 버그의 토대입니다. 공백이 붙은 값으로 조회하면 오류 없이 통과하니 아무도 이상함을 눈채채지 못합니다. 그 값이 다른 테이블에 저장되는 순간 공백은 그대로 유지됩니다.
| 패드 속성 | 해당 COLLATION |
|---|---|
| PAD SPACE (후행 공백 무시) | utf8mb4_bin, utf8mb4_unicode_ci, utf8mb4_general_ci (MySQL 8.0 이전 계열) |
| NO PAD (후행 공백도 비교) | utf8mb4_0900_ai_ci, utf8mb4_0900_bin — _0900_ 계열 (UCA 9.0.0 기반) |
문제가 된 두 컬럼(계정 테이블의 id, 문의 테이블의 writer_id)은 둘 다 PAD SPACE 쪽이었습니다.
이제 두 번째 의문에 대해 알아볼 시간입니다. DB에서 seller_a를 읽어왔는데 왜 엔티티에는 seller_a␣가 들어 있을까요?
먼저 의심한 것은 "id 컬럼을 select하지 않는 것 아닐까"였습니다. 실제 발행되는 쿼리를 확인해봤습니다.
select sa1_0.id, sa1_0.account_status, … , c1_0.code, …
from seller_account sa1_0
left join seller_company c1_0 on c1_0.code = sa1_0.company_code
where sa1_0.id = ?
-- Params: [(seller_a␣)]
id는 분명히 select 목록 맨 앞에 있습니다. 값도 정상적으로 돌아옵니다.
그런데 결과는 이렇습니다.
| 값 | |
|---|---|
| ResultSet이 돌려준 id | seller_a |
| Entity에 박힌 id | seller_a␣ — 요청 인자가 그대로 들어갑니다 |
select 목록에 있다고 해서, 그 값이 쓰인다는 뜻은 아니었습니다.
Hibernate 내부를 열어보니 이유가 나왔습니다.
findById("seller_a␣")
│
├─① 쿼리 파라미터로 ──→ WHERE id = ? ──→ row 반환 (ResultSet의 id는 조립되지 않음)
│
└─② 그대로 Entity.id 에 대입 ──→ "seller_a␣"
▲
검증 게이트 없음
①에 쓴 인자가 ResultSet을 거치지 않고 그대로 ②가 됩니다
해당 코드는 다음과 같습니다.
// EntityInitializerImpl#resolveKey — Hibernate ORM 6.6.41
if (identifierAssembler == null) { // id를 조립할 담당자가 없다
id = rowProcessingState.getEntityId(); // = findById에 넘긴 그 값
}
data.entityKey = new EntityKey(id, …); // 비교·검증 없이 그대로
단건 PK 조회에서 Hibernate는 "어차피 내가 조회한 id니까 결과의 id도 같겠지"라고 가정합니다. 대부분의 DB 환경에서는 맞는 가정입니다. 하지만 PAD SPACE collation에서는 조회에 쓴 값과 실제 행의 값이 다를 수 있습니다.
이제 처음 그 코드로 돌아가겠습니다.
require(inquiry.writerId == readerId) { "문의글을 찾을 수 없습니다." }
| 값 | 정체 | |
|---|---|---|
inquiry.writerId | seller_a␣ | 문의 작성 시점에 INSERT된 값. 그때 셀러는 공백 붙은 토큰을 들고 있었습니다. |
readerId | seller_a | 이번 요청으로 들어온 값. 이번엔 공백 없이 로그인했습니다. |
== | FALSE |
같은 사람이 같은 계정으로 두 번 로그인했을 뿐인데, 한 번은 공백이 붙었고 한 번은 붙지 않았습니다. 그 차이가 자기 글을 못 여는 결과로 이어졌습니다.
MySQL은 두 값을 같다고 보고 로그인을 통과시켰지만, JVM의 String.equals는 그럴 이유가 없었습니다. DB 계층의 동등성과 애플리케이션 계층의 동등성이 다르다는 것이 이번 버그의 핵심이었습니다.
trim()로그인 · 토큰 발급 · @PathVariable 처럼 외부 입력이 들어오는 지점에서, findById에 넘기기 전에 잘라냅니다. 공백이 시스템 안으로 들어오지 못하게 막는 것이 좋습니다.
공백이 절대 없어야 하는 식별자 컬럼이라면 NO PAD collation(utf8mb4_0900_*)을 쓰거나 애플리케이션 검증으로 유입 자체를 막습니다. NO PAD였다면 애초에 로그인 조회부터 실패했을 것이고, 버그는 훨씬 앞단에서 눈에 띄었을 것입니다.