정적 분석의 한계 - 해결할 수 없는 것들

2026. 1. 4. 02:34·토스 러너스하이 2기/기술

레거시 코드 분석 도구를 만들면서 마주친 정적 분석의 본질적 한계, 그리고 그 한계를 어떻게 다뤘는지 정리한다.

 


3줄 요약

  • 정적 분석은 런타임 정보를 알 수 없다는 본질적 한계가 있다
  • 다중 구현체 문제는 해결 대신 경고하는 것이 더 나은 선택이었다
  • 순환참조 오탐은 "전체 방문 기록"에서 "호출 스택"으로 개념을 바꿔 해결했다

1. 들어가며: 분석이 잘 되는데... 뭐가 문제야?

이전 글에서 호출 흐름 분석 도구를 완성했다.

UserController.selectUser()
└── UserServiceImpl.selectUser()
    └── UserDAO.selectUser()
        └── SQL: SELECT * FROM TB_USER

Controller부터 SQL까지 잘 추적한다. 잘 동작한다.

그런데 어느 날, 이런 코드를 만났다:

// UserService 인터페이스
public interface UserService {
    UserVO selectUser(String userId);
}

// 구현체가 3개?!
@Service
public class UserServiceImpl implements UserService { ... }

@Service
public class UserServiceV2 implements UserService { ... }

@Service
public class UserServiceV3 implements UserService { ... }

하나의 인터페이스에 구현체가 3개.

내 도구는 어떤 구현체를 분석할까? 실제로 실행되는 건 어떤 걸까?

 

 

 

모른다. 정적 분석으로는 알 수 없다.

이 글에서는 정적 분석의 본질적 한계를 이야기한다. 그리고 그 한계를 어떻게 다뤘는지.


2. 정적 분석이란? 날씨 예보와 비슷하다

한 줄 정의

정적 분석 = 코드를 실행하지 않고 분석하는 것

날씨 예보 비유

정적 분석은 날씨 예보와 비슷하다.

[날씨 예보]
- 대기 상태, 기압, 습도 등 데이터를 분석
- "내일 비가 올 확률 70%"
- 하지만 실제로 비가 올지는 내일이 되어봐야 안다

[정적 분석]
- 소스 코드, AST 등 데이터를 분석
- "이 메서드가 호출될 것이다"
- 하지만 실제로 호출될지는 실행해봐야 안다

날씨 예보가 100% 정확하지 않듯이, 정적 분석도 100% 정확하지 않다.

그래도 유용하다. 우산을 챙길지 말지 결정하는 데 도움이 되니까.


3. 정적 분석 vs 동적 분석: 무엇이 다른가?

두 가지 분석 방법 비교

구분 정적 분석 동적 분석
언제 실행 전 (코드만 봄) 실행 중 (런타임)
뭘 봄 소스 코드, AST 메모리, 호출 스택
예시 JavaParser, SonarQube 디버거, 프로파일러
장점 빠름, 전체 파악 가능 정확함, 실제 동작 확인
단점 런타임 정보 없음 느림, 실행 경로만 확인

 

정적 분석이 모르는 것들

// 1. 어떤 구현체가 주입될까?
@Autowired
private UserService userService;  // Impl? V2? V3? 모른다!

// 2. 조건에 따라 뭐가 실행될까?
if (type.equals("A")) {
    doSomethingA();  // A일 때만 실행
} else {
    doSomethingB();  // B일 때만 실행
}
// → 둘 다 "호출될 수 있다"고만 알 수 있다

// 3. 동적으로 결정되는 SQL ID
String sqlId = "userDAO." + methodName;  // 뭐가 들어올지 모름

핵심: 정적 분석은 "코드에 적힌 것"만 본다. "실제로 실행되는 것"은 모른다.


4. 왜 정적 분석을 선택했나?

그래도 정적 분석을 선택한 이유가 있다.

 

4.1 전체를 한 번에 볼 수 있다

[동적 분석]
API 1 실행 → 경로 A 확인
API 2 실행 → 경로 B 확인
API 3 실행 → 경로 C 확인
... 100개 API를 다 실행해야 전체 파악

[정적 분석]
코드 스캔 → 100개 API 경로 한 번에 파악

 

4.2 실행 환경이 필요 없다

  • DB 연결 없이 분석 가능
  • 서버 구동 없이 분석 가능
  • 폐쇄망에서도 동작 ← 이게 핵심이었다

 

4.3 빠르다

100개 Java 파일 + 50개 XML 파일
→ 정적 분석: 2-3초
→ 동적 분석: 모든 경로 실행하면 수십 분

레거시 시스템에서 모든 경로를 실행해보는 건 현실적으로 불가능하다.


5. 문제 1: 다중 구현체 - 어떤 걸 분석해야 하지?

5.1 문제 상황

public interface UserService {
    UserVO selectUser(String userId);
}

@Service
public class UserServiceImpl implements UserService { ... }

@Service
public class UserServiceV2 implements UserService { ... }

@Service
public class UserServiceV3 implements UserService { ... }

Controller에서 UserService를 호출한다. 어떤 구현체가 실행될까?

정적 분석은 모른다. 이유:

  • @Primary가 붙었나? → 없으면 모름
  • @Profile로 분기하나? → 런타임에 결정
  • XML 설정으로 주입하나? → 설정 파일 파싱 필요
  • 조건부 빈(@Conditional)인가? → 런타임에 결정

 

5.2 해결하려고 뭘 시도했나?

시도 1: Spring 설정 XML 파싱

<bean id="userService" class="com.example.UserServiceImpl" />

→ 실패: Java Config, @Profile, @Conditional 등 설정 방식이 너무 다양

 

시도 2: @Primary, @Qualifier 분석

@Primary
@Service
public class UserServiceImpl implements UserService { ... }

→ 실패: 어노테이션이 없는 경우가 더 많음

 

시도 3: 이름 컨벤션 추정

UserService → UserServiceImpl (Impl 접미사)

→ 실패: V2, V3 같은 버전 구현체는 Impl이 아님

결론: 정적 분석으로는 확실한 답을 낼 수 없다.

 

5.3 해결 대신 경고: 틀린 답보다 "모른다"가 낫다

완벽한 해결이 불가능하면, 경고하고 사용자가 확인하게 하자.

// FlowAnalyzer.java

// 다중 구현체 경고 저장
private final Map<String, List<String>> multipleImplWarnings = new HashMap<>();

private void buildInterfaceMapping(List<ParsedClass> parsedClasses) {
    // 1. 인터페이스별 모든 구현체 수집
    Map<String, List<String>> interfaceToAllImpls = new HashMap<>();

    for (ParsedClass clazz : parsedClasses) {
        for (String interfaceName : clazz.getImplementedInterfaces()) {
            interfaceToAllImpls
                .computeIfAbsent(interfaceName, k -> new ArrayList<>())
                .add(clazz.getClassName());
        }
    }

    // 2. 2개 이상이면 경고 목록에 추가
    for (var entry : interfaceToAllImpls.entrySet()) {
        List<String> impls = entry.getValue();

        // 첫 번째 구현체를 분석에 사용
        interfaceToImpl.put(entry.getKey(), impls.get(0));

        // 2개 이상이면 경고!
        if (impls.size() > 1) {
            multipleImplWarnings.put(entry.getKey(), impls);
        }
    }
}

 

5.4 경고는 어떻게 표시했나?

콘솔 출력:

[GET] /api/user/detail.do
└── [Controller] UserController.selectUser()
    └── [Service] UserServiceImpl.selectUser() (외 V2, V3)  ← 노란색 경고!
        └── [DAO] UserDAO.selectUser()

엑셀 출력:

  • 해당 행을 연한 살구색으로 강조
  • "비고" 칼럼에 외 UserServiceV2, UserServiceV3 표시
  • 요약 시트에 경고 설명 섹션 추가

6. 문제 2: 순환참조 오탐 - 순환이 아닌데 순환이라고?

6.1 문제 상황

[GET] /api/webtoons
└── [Controller] ContentApiController.getMainWebtoons()
    ├── [Service] WebtoonService.getFeaturedContent()
    │   └── [Repository] ContentRepository.findTop5()
    ├── [Service] WebtoonService.getPopularContent()
    │   └── [Repository] ContentRepository.findTop5() [순환참조]  ← 뭐?!
    └── [Service] WebtoonService.getTodayContent()
        └── [Repository] ContentRepository.findByDay()

같은 Repository 메서드를 다른 Service에서 호출했을 뿐인데, [순환참조]로 표시됐다.

이건 진짜 순환참조(A→B→A)가 아니다!

 

6.2 왜 이런 문제가 생겼나?

기존 로직의 문제:

private Set<String> visitedMethods = new HashSet<>();  // 전체 분석에서 공유!

private FlowNode buildFlowTree(...) {
    String signature = className + "." + methodName;

    if (visitedMethods.contains(signature)) {
        // 이미 방문함 → 순환참조로 판단 ← 여기가 문제!
        return new FlowNode("[순환참조]");
    }

    visitedMethods.add(signature);
    // 자식 탐색...
}

 

동작 과정:

경로 A: getFeaturedContent → findTop5  → Set에 추가
경로 B: getPopularContent → findTop5  → 이미 Set에 있음 → 순환참조?!

visitedMethods가 전체 분석에서 공유되기 때문에, 다른 경로에서 같은 메서드를 호출하면 순환으로 오탐한다.

 

6.3 "방문 기록"과 "호출 스택"은 다르다

여기서 개념을 잘못 잡은 거였다.

[방문 기록] - 한 번 방문하면 영원히 기록
A, B, C를 방문함 → {A, B, C}
다른 경로에서 B 방문 → "이미 방문함!"

[호출 스택] - 현재 경로만 기록
A → B → C 진행 중 → {A, B, C}
C 완료 → {A, B}  ← C 제거!
다른 경로에서 C 방문 → 가능!

진짜 순환참조란?

A → B → C → A  (A가 호출 스택에 다시 나옴 = 순환!)

현재 경로에서 같은 메서드가 다시 나와야 순환이다.

 

6.4 어떻게 해결했나?

핵심은 remove()로 백트래킹하는 것이다.

private FlowNode buildFlowTree(...) {
    String signature = className + "." + methodName;

    // 현재 호출 스택에 있으면 = 진짜 순환
    if (visitedMethods.contains(signature)) {
        return new FlowNode(...);  // 무한 루프만 방지
    }

    visitedMethods.add(signature);     // 스택에 추가

    // 자식 노드 탐색
    for (MethodCall call : method.getMethodCalls()) {
        FlowNode child = buildFlowTree(...);
        node.addChild(child);
    }

    visitedMethods.remove(signature);  // 핵심! 탐색 완료 → 스택에서 제거

    return node;
}

 

6.5 수정 후 동작 과정

시작: visitedMethods = {}

getFeaturedContent 진입 → {getFeaturedContent}
  findTop5 진입 → {getFeaturedContent, findTop5}
  findTop5 완료 → {getFeaturedContent}  ← remove!
getFeaturedContent 완료 → {}

getPopularContent 진입 → {getPopularContent}
  findTop5 진입 → {getPopularContent, findTop5}  ← 다시 방문 가능!
  ...

수정 결과:

├── [Service] WebtoonService.getFeaturedContent()
│   └── [Repository] ContentRepository.findTop5()
├── [Service] WebtoonService.getPopularContent()
│   └── [Repository] ContentRepository.findTop5()  ← 정상!
└── [Service] WebtoonService.getTodayContent()
    └── [Repository] ContentRepository.findByDay()

7. 정적 분석의 한계 정리: 무엇을 해결할 수 없나?

7.1 해결할 수 없는 것들

문제 이유 대응
다중 구현체 런타임에 결정됨 경고 표시
조건 분기 실행해봐야 알 수 있음 모든 경로 표시
동적 SQL ID 문자열 조합은 분석 불가 리터럴만 추출
리플렉션 호출 코드에 안 보임 분석 범위 외

 

7.2 그래도 정적 분석이 가치 있는 이유

날씨 예보가 100% 정확하지 않아도 유용하듯이:

  • 전체 파악: 수백 개 API를 한 번에 분석
  • 빠름: 실행 없이 몇 초 만에 완료
  • 재현 가능: 같은 코드는 같은 결과
  • 자동화 가능: CI/CD 파이프라인에 통합 가능

8. 정적 분석 도구를 만들 때 기억할 것

이번 경험에서 배운 원칙들:

8.1 한계를 인정하라

❌ "모든 경우를 정확히 분석합니다"
✅ "첫 번째 구현체 기준으로 분석하며, 다중 구현체는 경고합니다"

 

8.2 틀린 답보다 "모른다"가 낫다

❌ 아무 구현체나 골라서 "이게 정답입니다"
✅ "여러 구현체가 있으니 확인해주세요"

 

8.3 사용자 판단에 맡겨라

❌ 도구가 모든 걸 결정
✅ 도구는 정보 제공, 판단은 사용자가

 

8.4 80%가 맞으면 20%는 경고로 처리

완벽을 추구하면 아무것도 못 만든다.


9. 마치며

정리

  1. 정적 분석은 런타임을 모른다 - 이건 한계가 아니라 본질이다
  2. 다중 구현체는 해결할 수 없다 - 대신 경고하고 사용자가 확인하게 했다
  3. 순환참조 오탐은 개념 오류였다 - "방문 기록"을 "호출 스택"으로 바꿨다

 

이 글을 쓰며 배운 것

기술적으로:

  • 정적 분석 vs 동적 분석의 트레이드오프
  • 재귀에서 백트래킹(remove())의 중요성
  • 경고 기능 설계 (위치, 색상, 메시지)

개발자로서:

  • 모든 문제를 해결하려 하지 말 것 - 한계를 인정하는 것도 설계다
  • 문제의 본질을 파악할 것 - "방문 기록"과 "호출 스택"을 혼동하면 잘못된 해결책이 나온다
  • 사용자 관점에서 생각할 것 - 틀린 답보다 "확인 필요"가 낫다

 

다음 글 예고

이제 분석 엔진은 완성됐다. 다음은 사용자 인터페이스다.

  • Swing으로 GUI를 어떻게 만들었나?
  • 왜 Web UI 대신 Swing을 선택했나?
  • FlatLaf로 다크 테마를 어떻게 적용했나?

참고 자료

  • 이전 글: DAO에서 SQL까지, XML 파싱
  • GitHub: Code Flow Tracer
  • 정적 분석 vs 동적 분석 - Wikipedia

이 글은 Code Flow Tracer 프로젝트를 만들면서 배운 내용입니다.

'토스 러너스하이 2기 > 기술' 카테고리의 다른 글

분석 결과를 Excel로 정리하기 - Apache POI 활용기  (0) 2026.01.10
분석 결과를 어떻게 보여줄까? - CLI 출력 구현기  (1) 2026.01.07
DAO에서 SQL까지 - XML 파싱으로 연결하기  (0) 2025.12.29
호출 흐름을 어떻게 따라가나? - 재귀와 백트래킹  (0) 2025.12.26
JavaParser로 Java 코드 분석하기 - 처음 배운 사람의 정리  (0) 2025.12.23
'토스 러너스하이 2기/기술' 카테고리의 다른 글
  • 분석 결과를 Excel로 정리하기 - Apache POI 활용기
  • 분석 결과를 어떻게 보여줄까? - CLI 출력 구현기
  • DAO에서 SQL까지 - XML 파싱으로 연결하기
  • 호출 흐름을 어떻게 따라가나? - 재귀와 백트래킹
KBroJ9210
KBroJ9210
  • KBroJ9210
    개발일기
    KBroJ9210
  • 전체
    오늘
    어제
    • 분류 전체보기 (25)
      • 토스 러너스하이 2기 (11)
        • 회고 (1)
        • 기술 (10)
      • Loopers (9)
        • 테크니컬 라이팅 (6)
        • WIL(What I Learned) (3)
      • 두리두리넋두리 (5)
        • 개발일기 (5)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
KBroJ9210
정적 분석의 한계 - 해결할 수 없는 것들
상단으로
목차

    티스토리툴바