레거시 코드 분석 도구를 만들면서 마주친 정적 분석의 본질적 한계, 그리고 그 한계를 어떻게 다뤘는지 정리한다.
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. 마치며
정리
- 정적 분석은 런타임을 모른다 - 이건 한계가 아니라 본질이다
- 다중 구현체는 해결할 수 없다 - 대신 경고하고 사용자가 확인하게 했다
- 순환참조 오탐은 개념 오류였다 - "방문 기록"을 "호출 스택"으로 바꿨다
이 글을 쓰며 배운 것
기술적으로:
- 정적 분석 vs 동적 분석의 트레이드오프
- 재귀에서 백트래킹(
remove())의 중요성 - 경고 기능 설계 (위치, 색상, 메시지)
개발자로서:
- 모든 문제를 해결하려 하지 말 것 - 한계를 인정하는 것도 설계다
- 문제의 본질을 파악할 것 - "방문 기록"과 "호출 스택"을 혼동하면 잘못된 해결책이 나온다
- 사용자 관점에서 생각할 것 - 틀린 답보다 "확인 필요"가 낫다
다음 글 예고
이제 분석 엔진은 완성됐다. 다음은 사용자 인터페이스다.
- Swing으로 GUI를 어떻게 만들었나?
- 왜 Web UI 대신 Swing을 선택했나?
- FlatLaf로 다크 테마를 어떻게 적용했나?
참고 자료
이 글은 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 |