기술

배포도 안 했는데 500이 떴다고?

2026.08.177분 읽기

어느 날 아침, 물류 매입 조회 화면이 500을 뱉는다는 장애 보고가 올라왔습니다. 운영만의 문제인가 싶어 개발 환경에서 같은 화면을 열어 봤는데 똑같이 터졌습니다.

이상한 점은 하나였습니다. 그 화면 쪽은 최근 배포가 없었습니다. 코드는 수개월째 그대로였고, 어제까지 멀쩡하던 화면이 오늘 갑자기 죽은 것입니다. 배포 이력을 아무리 뒤져도 용의자가 없었습니다.

스택트레이스는 서비스 로직의 Map.get 호출을 가리켰습니다. 메시지는 「r is null」. 조회 결과 리스트를 돌며 각 행을 읽는 평범한 루프였는데, 특정 컬럼이 비어 있다는 게 아니라 행 자체가 null이라는 뜻이었습니다.


행이 통째로 사라졌다

문제의 쿼리는 품목 마스터에서 코드 컬럼과 브랜드 컬럼, 딱 두 개만 SELECT하는 단순 조회였습니다. 데이터를 확인해 보니 필수 지정된 품목 16행 중 15행이 두 컬럼 모두 NULL이었습니다. 이 품목들은 외부 공급사 직송 경로라, 다른 경로용 코드 컬럼이 비어 있는 게 원래 정상이었습니다.

여기서 MyBatis의 엣지 케이스가 발현됩니다. 선택한 모든 컬럼이 NULL인 행을 MyBatis는 빈 Map이 아니라 리스트 안의 null 원소로 돌려줍니다. returnInstanceForEmptyRow라는 설정의 기본값이 false이기 때문인데, 이 프로젝트는 그 설정을 지정한 적이 없었습니다.

List<Map<String, Object>> rows = mapper.selectRequiredItems();
// 기대: [{cd=null, brand=null}, {cd=null, brand=null}, ...]
// 실제: [null, null, ..., {cd=A0001, brand=B1}]
// → rows.get(0).get("cd") 에서 NPE

다운로드.png

16행짜리 리스트에 null이 15개 담겨 있었고, 루프는 첫 원소에서 바로 죽었습니다. 특정 값이 비는 것과 행이 사라지는 것은 전혀 다른 종류의 실패였습니다.


코드는 그대로였는데 왜 오늘 터졌나

발현 조건을 추적해 보니 답은 코드가 아니라 데이터에 있었습니다. 며칠 전 운영 담당자가 외부 공급사 직송 품목들에 처음으로 「필수」 지정을 했습니다. 그 순간 두 컬럼이 모두 NULL인 행들이 처음으로 이 쿼리의 조건에 걸려들었습니다.

코드는 처음부터 이 지뢰를 밟을 수 있는 상태였습니다. 다만 수개월 동안 그 조합의 데이터가 존재하지 않았을 뿐입니다. 장애의 트리거는 배포만이 아니었습니다. 체크박스 하나짜리 데이터 변경이 이 사건에서는 배포와 정확히 같은 역할을 했습니다.


같은 NULL의 두 얼굴

NPE만 잡고 덮을 수도 있었지만, 같은 코드 컬럼을 쓰는 쿼리를 한 번 grep해 봤습니다. 자매 쿼리 하나가 걸렸습니다. 발주 실적을 매칭하는 쿼리였는데, 외부 공급사 품목은 여기서도 코드가 NULL이라 서비스의 if (cd != null) 가드에 걸러지고 있었습니다.

이쪽의 증상은 500이 아니었습니다. 에러 한 줄 없이, 외부 공급사 품목의 매입 실적이 「발주했음」으로 집계되지 않고 있었습니다. 품목 9개가 매장 전량에서 미매칭이었습니다. 같은 뿌리의 NULL이 한쪽에서는 시끄러운 500을, 다른 쪽에서는 조용한 집계 누락을 만들고 있었습니다.

NPE는 알람입니다. 진짜 손해는 조용한 쪽이 내고 있었습니다.

다운로드 (1).png


가드가 아니라 합성키

수리 방법은 세 가지가 보였습니다. 처음에는 리스트에서 null만 건너뛰면 되지 않을까 싶었지만, 따져 보니 그건 시끄러운 증상 하나만 지우는 수리였습니다.

선택지

결과

리스트에서 null 원소 skip

NPE는 사라지지만 조용한 집계 누락은 그대로

returnInstanceForEmptyRow=true

빈 Map은 오지만 NULL 키라 매칭은 여전히 실패, 전역 설정 변경의 파급도 미지수

COALESCE 합성키

키의 NULL 가능성 자체가 사라져 두 증상을 한 번에 해결

채택한 것은 세 번째였습니다. 마침 형제 쿼리 하나가 이미 쓰고 있던 합성키 규칙이 있어서, 문제의 두 쿼리에 같은 규칙을 맞춰 적용했습니다.

-- BEFORE
SELECT cd, brand_cd FROM 품목마스터 WHERE ...

-- AFTER: 키가 NULL이 될 수 없게
SELECT COALESCE(cd, CONCAT('F', id)) AS cd, brand_cd
FROM 품목마스터 WHERE ...

키가 NULL이 될 수 없으니 전 컬럼 NULL 행 자체가 사라지고, 가드에 걸러지던 발주 실적 매칭도 살아났습니다. 수정 SQL을 직접 실행해 NULL 키 0건을 확인한 뒤 배포했고, 운영에서 200 응답과 214행 정상 조회를 확인했습니다. 매퍼에는 「COALESCE를 빼면 NPE가 나는 이유」를 주석으로 남겼습니다.

다운로드 (2).png

다만 두 가지를 별건으로 남겼습니다. 첫째, 이 수정으로 그동안 누락되던 외부 공급사 필수품목 15개가 미주문 판정에 실제로 들어가기 시작했습니다. 옳은 수정이어도 판정 모집단이 바뀐 것 자체가 동작 변경이라, 화면 수치를 지켜보며 판정 규칙 조정 여부를 정하기로 했습니다. 둘째, 합성키는 F{id} 같은 가짜 코드를 만들어냅니다. 이 값이 화면이나 외부 연동으로 새어 나가지 않는지는 계속 경계할 부분입니다.


체크리스트

부분 컬럼만 SELECT하는 쿼리는 「선택한 모든 컬럼이 NULL인 행이 존재할 수 있는가」를 확인한다 — MyBatis 기본 설정에서 그 행은 빈 객체가 아니라 null이다

코드가 안 바뀌었는데 터졌다면 「어떤 데이터가 처음 이 경로에 들어왔나」를 먼저 본다

시끄러운 버그를 잡았으면 같은 패턴을 쓰는 자매 쿼리를 grep해 조용한 쪽을 찾는다

NULL이 될 수 있는 키는 if 가드로 막지 말고 COALESCE 합성키로 NULL 가능성 자체를 없앤다

엣지 수정으로 새 모집단이 판정에 편입되면 그 파급은 별건 관찰 항목으로 남긴다


데이터도 배포다

이 사건 이후 장애를 만나면 묻는 순서가 바뀌었습니다. 「마지막 배포가 언제였나」보다 「이 경로에 처음 들어온 데이터가 무엇인가」를 먼저 봅니다. 코드 배포는 파이프라인이 이력을 남겨 주지만, 데이터 배포는 아무도 기록해 주지 않습니다.

그날 운영에서 바뀐 것은 코드 한 줄이 아니라 체크박스 하나였습니다.

#운영의기술#MyBatis#NULL#NPE#합성키#COALESCE#장애회고