본문으로 건너뛰기
wooncloud

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

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

·8 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에 배치·연동을 붙이기 전에 이런 경로부터 찾아보는 게 좋다.
  • 원인 장애를 막은 것 자체는 맞다. 다만 가장 시끄러운 실패를 가장 조용한 실패로 맞바꾼 거래였고, 그 대가는 나중에 다른 사람이 치른다.

참고