본문으로 건너뛰기
wooncloud

거짓 성공을 만드는 방어 코드 — 동적 UPDATE에서 컬럼을 조용히 건너뛸 때

동적 UPDATE문에서 특정 컬럼을 조용히 제외하는 방어 코드는 실패를 숨겨 데이터 무결성을 해칠 수 있습니다. 예외를 삼키는 대신 입구에서 검증하거나 실패를 명확히 응답하여 시스템의 정직함을 유지하는 설계 방법을 제안합니다

·9 min read·— views··

한 필드씩 수정하는 API가 있다고 해 봅시다. 요청은 "어떤 컬럼을(field) 어떤 값으로(value)" 형태이고, 서버는 받은 컬럼 하나만 SET 절에 넣어 동적으로 UPDATE 문을 조립합니다. 나머지 컬럼은 SQL에 아예 등장하지 않으니 부분 수정이 구조적으로 안전한, 나쁘지 않은 설계입니다.

그런데 이 API를 자동화 도구에서 대량으로 호출하려고 계약을 뜯어보다가 이상한 경로를 발견했습니다. 특정 값에서 200 OK를 받는데 DB는 그대로였습니다.

문제의 방어 코드

해당 컬럼은 varchar(1)이었습니다. 과거에 클라이언트가 매핑 실패값("-1" 같은)을 보내 value too long for type character varying(1)로 트랜잭션이 통째로 깨지는 장애가 있었고, 그걸 막으려고 이런 방어가 들어가 있었습니다.

// 길이가 안 맞으면 이 컬럼의 UPDATE를 건너뛴다
if (params.containsKey("status") && params.get("status").length() <= 1) {
    setClause.append(", status = ? ");
    values.add(params.get("status"));
}

의도 자체는 이해됩니다. "잘못된 값 하나 때문에 요청 전체가 죽는 것보다, 그 컬럼만 빼고 나머지는 살리자."

문제는 이 API가 컬럼 하나만 바꾸는 API라는 점입니다. 그 하나가 빠지면 남는 게 없습니다. SET 절이 비고, 서버는 아무 일도 안 한 채 정상 응답을 돌려줍니다.

더 나쁜 건 후처리였습니다. 이 API는 변경 사실을 알림과 이력으로 남기는데, 그 코드는 조건 분기 밖에 있었습니다. 결과적으로 DB는 그대로인데 "상태가 변경되었습니다" 알림은 나갑니다. 저장은 실패했고, 실패했다는 사실만 어디에도 남지 않았습니다.

방어 코드는 실패를 없앤 게 아니었습니다. 탐지 불가능한 실패로 바꿔놨을 뿐입니다. 예외는 시끄럽지만 정직합니다. 조용한 건너뛰기는 조용하지만 거짓말을 합니다.

단건 UI에서는 그럭저럭 굴러갑니다. 사용자가 화면을 보고 "안 바뀌었네" 하고 다시 누르기 때문입니다. 사람이 최종 검증자 역할을 대신 해주는 셈입니다. 하지만 자동화 도구가 100건을 순회하면 이야기가 완전히 달라집니다. 전부 200 OK를 받고, 몇 건이 조용히 무시됐는지 알 방법이 없습니다.

왜 이 패턴에서 자주 나오나

동적 SET 절 조립은 "조건을 만족하면 컬럼을 추가한다"는 형태입니다. 그래서 조건을 만족하지 않으면 컬럼이 빠지는 게 문법적으로 자연스럽습니다. 검증 실패와 "이번 요청에 해당 없음"이 코드상 완전히 같은 모양이 됩니다.

if (params.containsKey("start_date")) { ... }   // 해당 없음 → 정상
if (isValid(params.get("status")))    { ... }   // 검증 실패 → 정상이 아님

두 줄은 똑같이 생겼지만 의미가 정반대입니다. 위는 "이번엔 안 바꾼다", 아래는 "바꿔달라고 했는데 못 바꿨다"입니다. 후자를 전자와 같은 자리에 두는 순간 실패가 사라집니다.

리뷰에서도 잘 안 걸립니다. isValid() 체크는 그 자체로는 나무랄 데 없어 보이고, 빠졌을 때 무슨 일이 생기는지는 호출자 쪽 코드를 같이 봐야 드러나기 때문입니다.

어떻게 고치나

검증 실패는 입구에서 거절한다

방어의 목적이 "트랜잭션 전체가 깨지는 것"을 막는 거였다면, 답은 컬럼을 조용히 빼는 게 아니라 SQL에 닿기 전에 되돌리는 것입니다. 클라이언트가 잘못된 값을 보낸 것이므로 400이 정확한 의미고, 호출자는 즉시 압니다.

// 조립하기 전에 거절
if (params.containsKey("status") && params.get("status").length() > 1) {
    throw new BadRequestException("status must be a single character");
}

값 검증을 SQL 조립 로직 안이 아니라 입구에서 하는 것이 핵심입니다. 조립 단계에 섞이는 순간 "해당 없음"과 구분할 방법이 없어집니다.

다건이라면 무엇이 빠졌는지 응답에 실어라

정말로 나머지를 살려야 하는 다건 API라면 전체 거절이 답이 아닐 수 있습니다. 그럴 땐 최소한 무엇이 적용되지 않았는지 응답에 담아야 합니다.

{
  "updated": ["priority"],
  "skipped": [{ "field": "status", "reason": "INVALID_LENGTH" }]
}

호출자가 부분 성공을 처리할 수 있게 하는 것과, 부분 성공을 완전 성공처럼 보이게 하는 것은 다릅니다.

SET 절이 비면 실행하지 않는다

조립 결과가 빈 문자열이면 그건 "바꿀 게 없는 요청"이 아니라 대개 "바꾸려던 게 전부 탈락한 요청"입니다.

if (setClause.isEmpty()) {
    throw new BadRequestException("no applicable field to update");
}

그리고 알림·이력 같은 후처리는 실제 변경 여부에 묶어야지, 요청이 들어왔다는 사실에 묶으면 안 됩니다. 앞의 사례에서 가장 해로웠던 부분이 정확히 여기였습니다.

정리

  • 예외를 삼키는 방어는 실패를 제거하지 못합니다. 관측 가능한 실패를 관측 불가능한 실패로 옮길 뿐입니다. 옮겨간 곳이 대개 더 나쁩니다.
  • 방어 코드를 넣을 땐 "이 분기를 타면 호출자는 무엇을 보게 되는가"를 같이 정해야 합니다. 그 답이 "성공했다고 본다"면 그 방어는 틀렸습니다.
  • 사람이 결과를 눈으로 확인하는 UI에서는 조용한 실패가 오래 숨습니다. 자동화가 붙는 순간 드러납니다. 기존 API에 배치·연동을 붙이기 전에 이런 경로부터 찾아보는 게 좋습니다.
  • 원인 장애를 막은 것 자체는 맞습니다. 다만 가장 시끄러운 실패를 가장 조용한 실패로 맞바꾼 거래였고, 그 대가는 나중에 다른 사람이 치릅니다.

참고