WITH RECURSIVE로 범인 찾기: 코드 한 줄 안 고치고 끝난 장애

“삭제 버튼이 안 먹어요”

운영 시스템에서 이런 신고를 받으면 머릿속에는 대개 같은 순서로 생각이 흐른다. 최근 배포에 뭐가 들어갔더라. 저 기능을 마지막으로 만진 게 누구더라 — 설마 나인가. 신고를 다 읽기도 전에 커밋 기록부터 뒤지는 것. 코드 쓰는 사람의 오래된 반사신경이고, 대개는 그 반사신경이 맞는다. 이 글은 그 반사신경이 틀렸던 어느 사건의 기록이다. 그리고 이 시리즈에서 계속 확인하게 되는 감각 — '조회는 수사 도구다' — 이 가장 극적으로 드러난 사건이기도 하다.

내가 운영에 참여하는 시스템(농산물 생산 이력 관리, Spring Boot + JPA/Hibernate + PostgreSQL)에 신고가 들어왔다. '특정 공간(space)의 삭제가 실패한다.' 공간은 생산이 이뤄지는 장소를 담는 데이터인데, 이 사건에서 중요한 특징이 둘 있다. 하나 — 공간은 부모-자식으로 엮인 계층 구조다. 폴더 안에 폴더가 들어가듯 큰 공간 아래 작은 공간이 매달리는, 트리라고 부르는 모양이다. 둘 — 공간에는 이력 추적용 태그가 달리고, 태그에는 활성 같은 상태가 있다.

'특정'이라는 단어가 마음에 걸렸다. 전부가 아니라 어떤 공간만 안 된다는 뜻이니까. 그래도 수사는 절차대로 간다. 용의자 1호, 코드부터 불렀다.


용의자 1호: 삭제 코드의 알리바이

삭제 로직(JPA)의 설계는 명확했다. '공간에 달린 활성 태그를 먼저 정리하고, 그다음 공간을 삭제한다.' 이력 추적이 생명인 규제 산업에서 태그를 허공에 남긴 채 공간만 지울 수는 없으니, 순서 자체는 건전하다.

코드를 처음부터 끝까지 읽었다. 설계 그대로 구현돼 있었고, 수상한 곳은 없었다. 그리고 결정적인 알리바이 — 같은 코드가 다른 공간들에서는 오늘도 멀쩡히 돌고 있었다.

여기서 수사의 제1원칙이 선다. 같은 코드가 어디서는 되고 어디서는 안 된다면, 변수는 코드가 아니라 데이터다. 용의선상은 '그 공간에 매달려 있는 무언가'로 좁혀졌다.

그런데 바로 다음 걸음에서 수사가 막혔다. 매달려 있는 무언가를 보러 가는 길 자체가 문제였다.


수사를 가로막은 지형: 깊이를 모르는 트리

공간 하나만 조사하면 되는 게 아니다. 그 공간의 자식, 자식의 자식, 맨 아래 노드에 달린 태그까지 계층 전체를 훑어야 한다. 문제는 이 트리가 몇 층인지 모른다는 것.

평소 쓰는 조회 도구로 견적을 내보자. '문제 공간의 자식'은 WHERE parent_id = ? 한 줄이면 된다. 손자까지는 같은 테이블을 두 번 조인하면 어떻게든 된다. 그런데 그 아래 '전부'는? 트리가 3층인지 7층인지 모르면, 조인을 몇 번 써야 할지도 미리 정할 수 없다. SELECT와 JOIN은 깊이가 '고정'된 도구인 것이다. JPA도 대안이 아니었다. 자식 컬렉션을 타고 내려가는 재귀 순회는 노드 수만큼 쿼리를 날리는 N+1의 재림이 될 게 뻔하고, 무엇보다 조사용 코드를 새로 짜서 운영에 배포할 수는 없는 노릇이다. 수사는 조회 전용 SQL로, 지금 이 자리에서 끝내야 했다.

이럴 때 쓰라고 SQL에 준비된 장비가 있다. WITH RECURSIVE — 트리를 깊이만큼 알아서 타고 내려가는 재귀 쿼리다.


수사 장비 브리핑: WITH RECURSIVE

이름을 둘로 쪼개면 절반은 끝난다.

WITH는 쿼리 결과에 이름을 붙여 임시 표처럼 쓰게 해주는 문법이다 — 공식 명칭은 CTE(Common Table Expression). 여기에 RECURSIVE가 붙으면 그 임시 표가 자기 자신을 참조할 수 있게 된다. '방금 찾은 결과'를 재료로 다음 결과를 또 찾는 것, 이게 재귀다.

말보다 코드가 빠르다. 아래는 실전 쿼리가 아니라 구조를 익히기 위한 '개념 예제'다. 최소한의 공간 트리를 만들고, 한 공간 아래의 후손 전부를 찾아본다.

-- 개념 예제: 부모-자식으로 엮인 공간 테이블
create table space (
    id        bigint primary key,
    parent_id bigint,        -- 부모 공간 (최상위는 NULL)
    name      varchar(50)
);
-- 개념 예제: 1번 공간 아래의 모든 후손 찾기
with recursive space_tree as (
    -- ① 앵커(anchor): 수색의 출발점. 딱 한 번 실행된다
    select id, parent_id, name, 0 as depth
    from space
    where id = 1

    union all

    -- ② 재귀 파트: '방금 찾은 행들'의 자식을 찾는다
    select s.id, s.parent_id, s.name, t.depth + 1
    from space s
    join space_tree t on s.parent_id = t.id
)
select * from space_tree order by depth;

조직도에 비유하면 앵커는 '본부장 한 명을 호출한다'이고, 재귀 파트는 '방금 호출된 사람들의 직속 부하를 전부 호출한다'이다. 1회차에 자식들이, 2회차에 손자들이 잡히고, 더 이상 부하가 없으면 호출은 알아서 멈춘다 — '새로 찾은 행이 없으면 끝'이라는 종료 규칙이 문법에 내장돼 있어서, 재귀라는 말에서 떠오르는 무한 루프 걱정은 접어도 된다. 트리가 3층이든 30층이든 쿼리는 이 한 개이고, depth는 지금 몇 층을 수색 중인지 남기는 기록용 컬럼이다.

트리 다이어그램의 꼭대기에서 닻이 밧줄을 타고 층층이 내려가고, 지나온 층마다 불이 켜지는 일러스트.
앵커에서 출발해 층마다 자식을 붙이며 내려간다 — 더 붙일 행이 없으면 알아서 멈춘다.

장비 점검은 끝났다. 이제 진짜 트리에 투입한다.


전수 수색, 그리고 검거

실전에서 쓴 쿼리를 그대로 옮길 수는 없지만, 구조는 개념 예제의 확장이다. 앵커에 문제의 공간을 놓고, 재귀로 후손 공간 전체를 펼친 다음, 펼쳐진 각 노드에 태그 테이블을 LEFT JOIN(짝이 없어도 왼쪽 행을 결과에 남기는 조인)으로 매달았다. 어떤 노드에 어떤 상태의 태그가 붙어 있는지 — 계층 전체의 전수 조사표를 만든 것이다.

같은 구조를 샘플 트리로 재현하면 이렇다. 9개 노드짜리 공간 트리에 '가정을 어긴 노드' 하나를 심어둔 재현 데모의 조사 쿼리다.

-- 재현 데모: 트리 전체를 펼치고 노드별 태그 상태를 집계한다
with recursive space_tree as (
  select id, name, parent_id, 1 as depth, name::text as path
  from space
  where parent_id is null

  union all

  select s.id, s.name, s.parent_id, st.depth + 1,
         st.path || ' > ' || s.name
  from space s
  join space_tree st on s.parent_id = st.id
)
select st.path,
       count(t.id)                                  as tag_cnt,
       count(t.id) filter (where t.status = 'ACTIVE') as active,
       count(t.id) filter (where t.status is null)     as no_status  -- ← 범인 탐지기
from space_tree st
left join tag t on t.space_id = st.id
group by st.id, st.path, st.depth
order by st.path;

LEFT JOIN이어야 하는 이유가 수사의 핵심이다. '태그가 없는 공간'도 조사표에 남아야 하기 때문이다. INNER JOIN이었다면 조용히 사라졌을 행이, LEFT JOIN 아래에서는 '없음'이라는 형태로 남는다 — 하필 그런 행이 범인이라면, INNER JOIN은 용의자를 수색 명단에서 지우고 시작하는 셈이다. 집계에서 count(*)가 아니라 count(t.id)를 세는 것도 같은 계열의 함정인데, 잔량 편에서 다룬 LEFT JOIN의 NULL 문제 그대로다.

재현용 샘플 트리에 조사 쿼리를 실행한 결과. 경로별로 펼쳐진 아홉 개 노드 중 no_status 열에 1이 찍힌 행 하나가 눈에 띈다.
재현 데모 실행 결과(PostgreSQL) — 심어둔 ‘가정을 어긴 노드’ 한 줄만 조사표에 걸려 나온다.

실전의 조사표에서도 어긋난 행은 그렇게 모습을 드러냈다. 계층 아래 어느 노드의 데이터가 삭제 로직의 전제 — '공간에는 정리할 활성 태그가 반드시 존재한다' — 를 만족하지 않고 있었다. 6년 묵은 레거시 행이었다.

탐정이 손전등으로 카드 트리를 비추는 일러스트. 말끔한 카드들 사이에서 맨 아래의 낡고 갈라진 카드 하나가 불빛에 걸려 있다.
전수 수색의 끝에서 손전등에 걸린 것은 6년 묵은 카드 한 장이었다.

코드가 '언제나 참'이라고 믿고 짜인 데이터의 조건을 불변식(invariant)이라고 부른다. 이 삭제 로직의 불변식은 활성 태그의 존재였고, 요즘 데이터는 전부 그 약속을 지키고 있었다. 하지만 불변식은 코드에만 적혀 있고, 데이터는 그 계약서에 서명한 적이 없다 — 지금의 규칙이 자리 잡기 전에 태어난 데이터라면 더더욱. 그 행이 하필 삭제 대상이 된 날, 로직은 자기 전제가 깨진 자리에서 그대로 멈춰 섰다.

코드는 무죄다. 다만 세상이 계약서대로일 거라고 믿었을 뿐이다. 오늘 터진 장애의 범인은, 6년 전부터 그 자리에 앉아 있었다.


판결: 배포 0회

범인이 데이터라면 판결도 데이터에 내린다. 조치는 코드 수정이 아니라, 문제의 레거시 행을 현행 설계의 가정에 맞게 정리하는 데이터 레벨 SQL이었다. 그걸로 사건 종결. 애플리케이션 코드 변경 0줄, 배포 0회.

코드에 방어 분기를 넣는 선택지도 물론 있었다. 하지만 이 케이스는 앞으로 반복될 예외가 아니라 6년 전에 만들어진 역사였다. 정상 데이터에게 그 분기는 영원히 실행되지 않는 죽은 코드가 됐을 것이고, 그걸 위해 배포 리스크까지 얹을 이유가 없었다. 수사는 SELECT만으로, 조치는 최소한의 데이터 정리만으로 — 사건의 크기에 맞는 도구를 골랐다.

돌아보면 이 수사에서 가장 오래 걸린 일은 조치가 아니라 용의자를 바꾸는 것이었다. 코드만 붙잡고 있었다면 영원히 못 찾을 범인이었다 — 코드에는 정말로 아무 문제가 없었으니까. 코드는 늙지 않는다. 배포된 날의 모습 그대로 몇 년이고 돌아간다. 늙는 것은 데이터다. 연식 있는 시스템일수록 데이터는 들어온 그 시절 모습 그대로 지층처럼 쌓이고, 지금 코드의 가정이 6년 전 지층에서도 유효한지는 아무도 보증해주지 않는다.

재현 데모 전체(테이블·샘플 데이터·조사 쿼리)는 공부 노트 저장소의 demo 폴더에 있다. 덧붙여 계층형 질의는 SQLD에도 단골로 나오는데, 시험(Oracle 기준)은 같은 순회를 CONNECT BY … START WITH 문법으로 묻는다. 두 문법의 대응은 공부 노트의 계층형 질의 편에 정리해뒀다.


실무 조언: 가정을 심문하는 법

  1. 내 코드의 숨은 가정을 목록화하자. 삭제, 정산, 마감처럼 전제가 깔리는 로직일수록 '이 코드는 데이터의 무엇을 믿고 있는가'를 주석 한 줄로라도 적어두자. 리뷰 질문도 하나 늘어난다 — “이 가정을 어긴 데이터가 오면?”
  2. 데이터에도 연식이 있다. 오래된 시스템의 장애에서 '이 데이터는 언제 만들어졌는가'는 스택 트레이스만큼 중요한 단서다. 코드의 가정은 대개 특정 시점 이후의 데이터에만 참이다.
  3. 계층을 만나면 WITH RECURSIVE를 기억하자. 배포 없이, 롤백 걱정 없이, 깊이를 모르는 트리를 조회 한 장으로 전부 훑을 수 있다.

마치며: SELECT는 수사권이다

첫 글에서는 grep 몇 줄이 리팩토링 계획 하나를 멈춰 세웠고, 이번에는 SELECT 한 개가 배포 없이 장애를 끝냈다. 두 사건의 공통점은 도구가 아니라 순서다. 그럴듯한 용의자를 심문하기 전에, 먼저 세어보고 훑어볼 것. WITH RECURSIVE는 그 훑어보기를 계층이라는 지형에서도 가능하게 해주는 장비였다. 쓰기 권한도, 배포 파이프라인도 필요 없었다. SELECT는 아무것도 못 바꾸는 반쪽짜리 권한이 아니라, 시스템을 수사할 수 있는 권한이다.

다만 사건이 종결된 뒤에도 찜찜함이 하나 남았다. 조회로 수사는 끝냈다. 그런데 그 6년 묵은 행이 누구의 손으로, 언제, 왜 그렇게 됐는지는 끝내 알 수 없었다 — 그걸 말해줄 기록이 시스템 어디에도 없었기 때문이다. 당신의 시스템은 어떤가. 다음 범인을 만났을 때, 정황 증거 말고 목격자의 진술을 내밀 수 있는가.

댓글 남기기