Home > AI > Backend > depth < 10 방어 코드가 버그를 감추고 있었다

depth < 10 방어 코드가 버그를 감추고 있었다
AI Claude Code Java 리팩터링

문제의 코드

계층형 조직 트리를 다루는 서비스에 조상 조직 ID를 전부 모아오는 메서드가 있다. 원래 구현의 핵심은 이 한 줄이었다.

while (currentId != null && depth < 10) {
    ancestorIds.add(0, currentId);
    OrganizationDto parent = organizationDao.selectOrganizationById(currentId);
    if (parent == null) break;
    currentId = parent.getParentOrgId();
    depth++;
}

부모를 따라 위로 올라가는 평범한 루프다. depth < 10은 순환 참조가 생겼을 때 무한루프에 빠지지 않게 하는 방어 코드로 들어가 있었다.

의도는 좋다. 문제는 이게 방어가 아니라 은폐라는 것이다.

왜 은폐인가

depth < 10이 실제로 하는 일을 정확히 서술하면 이렇다.

조상을 10개까지 모은 뒤, 아무 말 없이 멈춘다.

조건에 걸려서 루프가 끝났는지, 루트에 도달해서 정상적으로 끝났는지를 호출자가 구분할 방법이 없다. 반환 타입은 그냥 List<Long>이다. 예외도, 경고 로그도, 플래그도 없다.

이게 두 가지 서로 다른 상황을 하나로 뭉갠다.

상황 실제로 일어난 일 반환값
조직 깊이가 12단계 조상 2개가 조용히 누락 불완전한 목록
A → B → A 순환 참조 같은 ID를 반복 수집하다 10에서 절단 중복이 섞인 쓰레기 목록
깊이 5, 정상 정상 종료 정상 목록

세 경우 모두 정상적인 리스트처럼 생긴 값이 나온다. 이 목록은 권한 범위 계산이나 상위 조직 조회 같은 곳에 쓰인다. 조상이 하나 빠지면 보여야 할 데이터가 안 보이거나, 없어야 할 권한이 생긴다. 그런데 로그에는 아무것도 안 남는다.

무한루프는 최소한 시끄럽다. CPU가 튀고 요청이 안 끝나니 누군가 알아챈다. 조용히 틀린 답을 주는 것은 알아채기까지 훨씬 오래 걸린다.

두 개의 문제가 하나의 숫자에 뭉개져 있었다

10이라는 숫자 하나가 두 가지 다른 질문에 동시에 답하려 하고 있었다.

  1. 순환 참조가 있는가? → 이건 예외 상황이다. 데이터가 망가졌다는 뜻이고, 반드시 기록돼야 한다.
  2. 조직 계층이 얼마나 깊을 수 있는가? → 이건 정상 범위의 도메인 제약이다. 조직이 12단계인 건 버그가 아니다.

이 둘은 대응 방식이 다르다. 1번은 “고쳐야 할 데이터”고, 2번은 “허용할 범위”다. 그런데 같은 조건문에 섞여 있으니 실제로 어느 쪽이 발생했는지 알 수가 없다.

어떻게 고쳤나

커밋 642845d에서 둘을 분리했다.

Long currentId = org.getParentOrgId();
Set<Long> visited = new HashSet<>();
while (currentId != null) {
    if (!visited.add(currentId)) {
        log.warn("[조직 서비스] getAncestorOrgIds 순환 참조 감지 - orgId={}, cycleAt={}",
                 orgId, currentId);
        break;
    }
    if (visited.size() > MAX_ORG_DEPTH) {
        log.warn("[조직 서비스] getAncestorOrgIds 최대 깊이({}) 초과 - orgId={}",
                 MAX_ORG_DEPTH, orgId);
        break;
    }
    ancestorIds.add(0, currentId);
    OrganizationDto parent = organizationDao.selectOrganizationById(currentId);
    if (parent == null) break;
    currentId = parent.getParentOrgId();
}

바뀐 점은 셋이다.

  • Set<Long> visited로 순환을 실제로 검출한다. add()false를 반환하면 이미 방문한 노드로 되돌아온 것이다. 깊이를 세는 게 아니라 순환 그 자체를 잡는다.
  • 경고 로그를 남긴다. 순환일 때와 깊이 초과일 때 메시지가 다르다. 사후에 로그만 봐도 어느 쪽인지 알 수 있고, cycleAt으로 어느 노드에서 돌았는지까지 나온다.
  • MAX_ORG_DEPTH = 50을 별도 상수로 뺐다. 순환 검출이 visited로 확실히 되니, 깊이 한도는 순환 방어 역할에서 해방됐다. 이제 순수하게 “이 정도 깊이는 정상이 아니다”라는 도메인 판단만 표현한다. 그래서 10보다 훨씬 넉넉한 50으로 잡아도 안전하다.

근본 원인 쪽도 함께 손봤다

같은 커밋에 마이그레이션 V169__fix_null_org_paths.sql이 들어 있다. 이 메서드는 조직이 org_path("/1/5/12" 형태의 경로 문자열)를 가지고 있으면 그걸 파싱해서 바로 조상을 얻고, org_path가 없을 때만 부모를 따라 올라가는 루프로 폴백한다.

즉 위험한 루프에 진입하는 조건 자체가 “org_path가 NULL인 데이터가 존재한다”였다. V169는 그 NULL 경로를 실제로 채워 넣는 데이터 교정 마이그레이션이다.

방어 코드를 고치는 것과 방어할 일이 없게 만드는 것을 같은 커밋에서 함께 한 셈이다. 폴백은 남겨뒀다 — 데이터를 한 번 고쳤다고 앞으로 영원히 NULL이 안 생긴다는 보장은 없으니까.

남는 교훈

루프 한도 상수를 보면 그게 무엇을 막고 있는지 물어야 한다. 대개 두 가지 중 하나다. “비정상 상태를 감지하는 것”이거나 “정상 범위의 상한”이거나. 하나의 숫자가 둘 다를 하고 있으면 둘 다 제대로 못 하고 있는 것이다.

그리고 조용히 break 하는 방어 코드는 방어가 아니다. 예상 못 한 경로로 루프를 빠져나갔다면 그건 정보다. 로그든 예외든 메트릭이든, 어떤 형태로든 밖으로 나가야 한다. 그러지 않으면 “가끔 상위 조직이 안 보인다”는 제보를 받고 몇 시간을 헤매게 된다.

depth < 10은 코드 리뷰에서 지적받기 어려운 종류의 코드다. 무한루프를 막고 있고, 짧고, 의도가 명백해 보인다. 그런데 그 명백해 보이는 의도가 실제 동작과 달랐다.