레거시 코드 분석 도구를 만들면서 처음 배운 JavaParser, 그 과정을 정리한다.
3줄 요약
- JavaParser는 Java 코드를 구조(AST)로 바꿔주는 라이브러리다
- 마트료시카처럼 파일 → 클래스 → 메서드 → 호출 순으로 중첩된 구조다
- 정적 분석의 한계(인터페이스, 변수명)는
implements분석과 네이밍 컨벤션으로 보완했다
1. 들어가며
왜 JavaParser를 쓰게 됐을까?
레거시 코드를 분석해야 할 일이 많았다. 주로 공공 SI/SM 프로젝트에서 일해왔고, 오래된 코드를 자주 마주했다.
레거시 프로젝트에 투입되면 항상 이런 고민이 있었다.
"이 API가 어디서 시작해서 어디까지 가는 거지?"
"Controller → Service → DAO → SQL...
어려운건 아닌데.. 계속 오가면서 추적하기 너무 번거롭다"

그래서 호출 흐름을 자동으로 분석해주는 도구를 만들어보기로 했다.
[목표]
/api/user/list 요청이 오면
→ UserController.getList()
→ UserService.findAll()
→ UserDAO.selectList()
→ SQL: SELECT * FROM TB_USER
이 흐름을 자동으로 추적하고 싶다!
이걸 하려면 Java 코드를 프로그램으로 읽고 분석할 수 있어야 했다.
그래서 찾은 게 JavaParser다.
이 글에서 다루는 것
- JavaParser가 뭔지, 왜 필요한지
- AST(추상 구문 트리)를 쉽게 이해하는 방법
- 실제 코드로 따라해보기
- 내 프로젝트에서 어떻게 활용했는지
이 글의 대상
- 나처럼 JavaParser를 처음 접하는 사람
- Java 코드를 분석하는 도구를 만들어보고 싶은 사람
- AST가 뭔지 궁금했던 사람
2. JavaParser란?
한 줄 정의
Java 코드를 읽어서 "구조"로 바꿔주는 라이브러리
왜 필요한가?
Java 코드에서 "메서드 이름"을 찾고 싶다고 해보자.
방법 1: 정규식으로 메서드 이름 추출하기
String code = "public void getList() { userService.findAll(); }";
// 메서드 이름 "getList"를 어떻게 찾지?
// "public" 다음에 타입 다음에 나오는 단어?
// 근데 주석 안에 "public void getList"가 있으면?
// 문자열 안에 있으면?
정규식으로 하면 엄청 복잡하고, 예외 케이스가 끝도 없이 나온다.
방법 2: JavaParser 라이브러리 사용
// JavaParser가 알아서 코드를 구조로 바꿔줌
CompilationUnit cu = javaParser.parse(code).getResult().get();
// 메서드를 찾아서 이름을 가져옴
MethodDeclaration method = cu.findFirst(MethodDeclaration.class).get();
String methodName = method.getNameAsString(); // "getList" ← 바로 찾음!
JavaParser가 복잡한 파싱을 대신 해주고, 우리는 결과만 사용하면 된다.
쉬운 비유
사람이 이 코드를 보면:
@Controller
public class UserController {
public void getList() {
userService.findAll();
}
}
머릿속으로 이렇게 이해한다.
- "아, Controller 클래스네"
- "getList라는 메서드가 있고"
- "그 안에서 userService.findAll()을 호출하는구나"
JavaParser는 컴퓨터가 이런 이해를 할 수 있게 해주는 도구다.
3. 핵심 개념: 마트료시카처럼 이해하기
AST란?
AST = Abstract Syntax Tree = 추상 구문 트리
이름이 어렵지만, 그냥 "코드의 구조를 트리로 표현한 것"이다.
러시아 인형 (마트료시카) 비유
큰 인형을 열면 작은 인형이 나오고, 또 열면 더 작은 인형이 나오는 마트료시카처럼 생각하면 이해하기 한결 쉽다.
Java 코드도 똑같다!
파일 (가장 큰 인형)
└── 클래스 (두 번째 인형)
└── 메서드 (세 번째 인형)
└── 메서드 호출 (가장 작은 인형)
실제 코드로 보면
// UserController.java 파일
@Controller // ← 어노테이션
@RequestMapping("/user")
public class UserController { // ← 클래스
@GetMapping("/list.do")
public String selectUserList() { // ← 메서드
userService.selectUserList(); // ← 메서드 호출
return "user/list";
}
}
이걸 JavaParser가 분석하면:
CompilationUnit (파일 전체)
│
└── ClassOrInterfaceDeclaration (클래스)
│
├── annotations: [@Controller, @RequestMapping("/user")]
├── name: "UserController"
│
└── methods:
└── MethodDeclaration (메서드)
│
├── annotations: [@GetMapping("/list.do")]
├── name: "selectUserList"
│
└── body:
└── MethodCallExpr (메서드 호출)
├── scope: "userService"
└── name: "selectUserList"
JavaParser 용어 정리
JavaParser가 Java 코드의 각 부분에 붙여놓은 이름이다.
| Java 코드 요소 | JavaParser 이름 | 쉬운 설명 |
|---|---|---|
| 파일 전체 | CompilationUnit |
가장 큰 인형 (파일 1개) |
| 클래스/인터페이스 | ClassOrInterfaceDeclaration |
두 번째 인형 |
| 메서드 정의 | MethodDeclaration |
메서드를 만든 것 |
| 메서드 호출 | MethodCallExpr |
메서드를 부른 것 |
| 어노테이션 | AnnotationExpr |
@로 시작하는 것 |
Declaration = 선언 (만드는 것)
Expr = Expression = 표현식 (사용하는 것)
// MethodDeclaration = 메서드를 "만드는" 것
public void hello() { ... }
// MethodCallExpr = 메서드를 "부르는" 것
obj.hello();
4. 실제 코드로 배워보기
4.1 환경 설정
build.gradle에 추가한다.
dependencies {
implementation 'com.github.javaparser:javaparser-core:3.25.5'
}
4.2 첫 번째 예제 - 클래스 이름 가져오기
import com.github.javaparser.JavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
public class Example1 {
public static void main(String[] args) {
JavaParser javaParser = new JavaParser();
// 분석할 코드 (문자열로도 가능하다!)
String code = """
public class Hello {
public void sayHi() {
System.out.println("Hi");
}
}
""";
// 1. 파싱 (코드 → 구조)
CompilationUnit cu = javaParser.parse(code).getResult().get();
// 2. 클래스 찾기
ClassOrInterfaceDeclaration clazz =
cu.findFirst(ClassOrInterfaceDeclaration.class).get();
// 3. 클래스 이름 출력
System.out.println("클래스명: " + clazz.getNameAsString());
// 출력: 클래스명: Hello
}
}
핵심 흐름은 이렇다:
코드 문자열
↓ javaParser.parse()
CompilationUnit (파일 전체)
↓ .findFirst(Class~.class)
ClassOrInterfaceDeclaration (클래스)
↓ .getNameAsString()
"Hello"
4.3 findFirst vs findAll
| 메서드 | 의미 | 반환 타입 | 언제 쓰나 |
|---|---|---|---|
findFirst |
첫 번째 하나만 | Optional<T> |
클래스 찾을 때 (보통 1개) |
findAll |
전부 다 | List<T> |
메서드 호출 찾을 때 (여러 개) |
// 파일에 클래스는 보통 1개 → findFirst
Optional<ClassOrInterfaceDeclaration> clazz =
cu.findFirst(ClassOrInterfaceDeclaration.class);
// 메서드 안에 호출은 여러 개 → findAll
List<MethodCallExpr> calls =
method.findAll(MethodCallExpr.class);
4.4 메서드 안의 호출 찾기
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.expr.MethodCallExpr;
// ... (파싱 코드 생략)
// 클래스의 모든 메서드 순회
for (MethodDeclaration method : clazz.getMethods()) {
System.out.println("메서드: " + method.getNameAsString());
// 이 메서드 안에서 호출하는 것들 찾기
List<MethodCallExpr> calls = method.findAll(MethodCallExpr.class);
for (MethodCallExpr call : calls) {
String scope = call.getScope()
.map(Object::toString)
.orElse("(없음)");
String name = call.getNameAsString();
System.out.println(" → " + scope + "." + name + "()");
}
}
실행 결과
메서드: selectUserList
→ userService.selectUserList()
→ model.addAttribute()
5. 프로젝트에서 활용한 방법
5.1 Controller/Service/DAO 구분하기
어노테이션을 확인해서 클래스 타입을 구분했다.
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.expr.AnnotationExpr;
private ClassType determineClassType(ClassOrInterfaceDeclaration clazz) {
for (AnnotationExpr annotation : clazz.getAnnotations()) {
String name = annotation.getNameAsString();
if (name.equals("Controller") || name.equals("RestController")) {
return ClassType.CONTROLLER;
}
if (name.equals("Service")) {
return ClassType.SERVICE;
}
if (name.equals("Repository")) {
return ClassType.DAO;
}
}
// 어노테이션 없으면 클래스명으로 추정
String className = clazz.getNameAsString();
if (className.endsWith("Controller")) return ClassType.CONTROLLER;
if (className.endsWith("Service")) return ClassType.SERVICE;
if (className.endsWith("DAO")) return ClassType.DAO;
return ClassType.OTHER;
}
5.2 호출 흐름 추적하기
- Controller에서 시작한다
- 메서드 안의 호출(MethodCallExpr)을 찾는다
- 그 호출이 어떤 Service인지 찾는다
- Service에서 또 호출을 찾는다... (반복)
이렇게 해서 최종 결과를 얻었다.
[GET /user/list.do] UserController.selectUserList()
└── UserServiceImpl.selectUserList()
└── UserDAO.selectUserList()
└── SQL: userDAO.selectUserList
5.3 @RequestMapping URL 추출하기
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
import com.github.javaparser.ast.expr.MemberValuePair;
private String extractUrlFromAnnotation(AnnotationExpr annotation) {
// @GetMapping("/list.do") 형태
if (annotation instanceof SingleMemberAnnotationExpr) {
SingleMemberAnnotationExpr single = (SingleMemberAnnotationExpr) annotation;
return single.getMemberValue().toString().replace("\"", "");
}
// @RequestMapping(value = "/list.do") 형태
if (annotation instanceof NormalAnnotationExpr) {
NormalAnnotationExpr normal = (NormalAnnotationExpr) annotation;
for (MemberValuePair pair : normal.getPairs()) {
if (pair.getNameAsString().equals("value")) {
return pair.getValue().toString().replace("\"", "");
}
}
}
return "";
}
6. 정적 분석의 한계와 해결
코드만 읽어서 분석하는 정적 분석이다 보니 한계가 있다.
해결하지 못한 한계
- 프로그램 실행 중에 결정되는 건 알 수 없다
- Spring의
@Autowired는 프로그램이 실행될 때 어떤 클래스를 넣을지 결정된다 - 코드만 봐서는
userService에 실제로 뭐가 들어오는지 알 수 없다
- Spring의
- 동적으로 클래스를 불러오는 경우
Class.forName("com.example.MyClass")같은 코드는 문자열로 클래스를 불러오기 때문에 추적이 안 된다
인터페이스 추적 문제
호출 흐름을 추적하다 보면 이런 상황이 생긴다.
UserController.selectUserList()
→ userService.selectUserList()
→ ??? ← 여기서 끊김!
왜 끊기나?
userService의 타입을 보면 인터페이스다.
// UserController.java
private UserService userService; // ← 타입이 인터페이스!
인터페이스는 "이런 메서드가 있어야 해"라는 약속만 정의하고, 실제 코드는 없다.
// UserService.java (인터페이스)
public interface UserService {
List<UserVO> selectUserList(); // ← 선언만 있고, 코드가 없다!
}
실제 코드는 구현 클래스에 있다.
// UserServiceImpl.java (구현 클래스)
public class UserServiceImpl implements UserService {
public List<UserVO> selectUserList() {
return userDAO.selectUserList(); // ← 진짜 코드는 여기!
}
}
JavaParser는 코드에 적힌 대로 UserService만 알려줄 뿐, 실제로 UserServiceImpl이 들어온다는 건 모른다.
첫 번째 시도 - 이름 규칙으로 찾기
처음에는 이름으로 추측했다. 관례적으로 인터페이스 이름 + Impl을 붙이니까.
UserService → UserServiceImpl 이겠지!
한계 발견
근데 실제로는 이름을 마음대로 지을 수 있다.
class UserServiceV2 implements UserService { } // Impl 아님
class DefaultUserService implements UserService { } // Impl 아님
이름 규칙만으로는 못 찾는 경우가 있었다.
임시 해결 - implements 키워드 분석
이름에 의존하지 말고, 코드 자체를 분석하기로 했다.
public class UserServiceV2 implements UserService { }
// ↑ 이 부분을 JavaParser로 읽는다!
JavaParser로 클래스를 파싱하면 ClassOrInterfaceDeclaration 객체가 나온다. 이 객체에서 필요한 정보를 꺼낼 수 있다.
// clazz = ClassOrInterfaceDeclaration 객체 (파싱 결과)
// 1. 클래스 이름 꺼내기
String className = clazz.getNameAsString();
// → "UserServiceV2"
// 2. implements 뒤에 있는 인터페이스 꺼내기
for (ClassOrInterfaceType type : clazz.getImplementedTypes()) {
String interfaceName = type.getNameAsString();
// → "UserService"
}
쉽게 비유하면 이렇다.
Java 코드: public class UserServiceV2 implements UserService { }
↓ ↓
JavaParser: getNameAsString() getImplementedTypes()
↓ ↓
결과: "UserServiceV2" "UserService"
프로젝트의 모든 클래스를 스캔해서 연결 관계를 저장해둔다.
Map<String, String> connectionMap = new HashMap<>();
for (ClassOrInterfaceDeclaration clazz : allClasses) {
String className = clazz.getNameAsString();
for (ClassOrInterfaceType type : clazz.getImplementedTypes()) {
String interfaceName = type.getNameAsString();
connectionMap.put(interfaceName, className);
}
}
[연결 관계]
UserService → UserServiceV2
OrderService → OrderServiceImpl
호출 추적할 때 이 연결 관계를 참고해서 이어갈 수 있다.
userService.selectUserList() 발견
↓
타입이 "UserService"네?
↓
연결 관계 확인 → UserServiceV2
↓
UserServiceV2.selectUserList()로 이동!
↓
그 안에서 userDAO.selectUserList() 발견
↓
DAO까지 추적 성공!
implements 분석을 우선 적용하고, 정보가 없는 경우에만 이름 규칙으로 fallback 처리했다.
구현체가 여러 개면?
한 가지 의문이 생길 수 있다. 구현체가 여러 개면 어떻게 하지?
public class UserServiceImpl implements UserService { }
public class UserServiceV2 implements UserService { }
public class DefaultUserService implements UserService { }
이 경우 일단 먼저 발견한 구현체를 사용했다.
// 이미 연결된 게 있으면 스킵 (먼저 발견한 것 우선)
if (!connectionMap.containsKey(interfaceName)) {
connectionMap.put(interfaceName, className);
}
솔직히 이건 완벽한 해결책이 아니다. 어떤 구현체가 실제로 사용되는지는 런타임에 결정되기 때문이다.
일단 진행
- 레거시 프로젝트에서는 인터페이스당 구현체가 1개인 경우가 대부분이다
- 여러 개여도
UserServiceMock,UserServiceStub같은 테스트용인 경우가 많다 - 이 도구의 목적은 완벽한 분석이 아니라 대략적인 흐름 파악이다
정적 분석의 한계이며
100% 정확하지 않아도, 레거시 코드를 빠르게 이해하는 데는 충분히 도움이 될거라 생각했다.
하지만 아직 해결할 문제가 하나 더 있었다. 변수명에서 실제 클래스를 찾는 것이다.
7. 변수명에서 클래스 찾기
또 다른 문제: scope에서 클래스를 어떻게 찾지?
인터페이스 → 구현체 문제를 해결했다고 생각했는데, 또 다른 문제가 있었다.
userService.selectUser(userId);
// ↑ ↑
// scope methodName
- scope: 메서드를 호출하는 객체의 변수명 (
userService) - methodName: 호출하는 메서드 이름 (
selectUser)
문제는 userService는 변수명이고, 우리가 필요한 건 클래스명이라는 것:
UserServiceImpl // 실제 클래스
변수명에서 클래스명으로 어떻게 연결하지?
해결: 네이밍 컨벤션 활용
다행히 대부분의 프로젝트에서 변수명은 규칙이 있다:
| 변수명 (camelCase) | 클래스명 (PascalCase) |
|---|---|
| userService | UserService → UserServiceImpl |
| userDAO | UserDAO |
| orderController | OrderController |
첫 글자만 대문자로 바꾸면 클래스명이 된다!
미리 준비해둔 매핑 테이블
코드를 파싱할 때 미리 두 개의 Map을 만들어둔다:
// 1. scopeToClassName: 변수명 → 클래스명
// 클래스의 필드를 파싱할 때 자동으로 생성
Map<String, String> scopeToClassName = {
"userService" → "UserServiceImpl",
"userDAO" → "UserDAO",
"orderService" → "OrderServiceImpl"
};
// 2. interfaceToImpl: 인터페이스 → 구현체
// 앞에서 implements 키워드를 보고 생성한 것
Map<String, String> interfaceToImpl = {
"UserService" → "UserServiceImpl",
"OrderService" → "OrderServiceImpl"
};
이 매핑들은 프로젝트 파싱할 때 한 번 만들어두고, 분석할 때 계속 사용한다.
resolveClassName 함수
이 모든 것을 조합한 함수:
private String resolveClassName(String scope) {
// 1. 미리 만들어둔 매핑에서 먼저 찾기
if (scopeToClassName.containsKey(scope)) {
String className = scopeToClassName.get(scope);
return resolveToImplementation(className); // 인터페이스면 구현체로
}
// 2. 첫 글자 대문자로 바꿔서 찾기 (userService → UserService)
String pascalCase = toUpperCamelCase(scope);
if (classIndex.containsKey(pascalCase)) {
return resolveToImplementation(pascalCase);
}
// 3. Impl 붙여서 찾기 (UserService → UserServiceImpl)
if (classIndex.containsKey(pascalCase + "Impl")) {
return pascalCase + "Impl";
}
return null; // 못 찾음
}
// 인터페이스명 → 구현체명 변환
private String resolveToImplementation(String className) {
if (interfaceToImpl.containsKey(className)) {
return interfaceToImpl.get(className); // UserService → UserServiceImpl
}
return className; // 구현체면 그대로 반환
}
세 가지 전략을 순서대로 시도:
| 순서 | 전략 | 예시 |
|---|---|---|
| 1 | 직접 매핑 조회 | scopeToClassName.get("userService") → "UserServiceImpl" |
| 2 | 대문자 변환 후 조회 | "userService" → "UserService" → 구현체로 변환 |
| 3 | Impl 접미사 추가 | "UserService" + "Impl" → "UserServiceImpl" |
왜 이렇게 여러 단계가 필요한가?
실제 레거시 코드에서는 다양한 케이스가 있기 때문이다:
// 케이스 1: 필드에 구현체 타입이 직접 선언된 경우
private UserServiceImpl userService; // → scopeToClassName에서 바로 찾음
// 케이스 2: 필드에 인터페이스 타입이 선언된 경우
private UserService userService; // → 찾은 후 interfaceToImpl로 구현체 변환
// 케이스 3: 타입 정보 없이 변수명만 있는 경우 (JSP 등)
userService.findAll(); // → 네이밍 컨벤션으로 추론
정적 분석의 한계를 네이밍 컨벤션으로 보완하는 전략이다.
8. 마치며
처음 배우면서 느낀 점
- AST가 어려워 보이지만, 마트료시카로 생각하니 이해됐다
- 파일 → 클래스 → 메서드 → 호출, 큰 거 안에 작은 게 있는 구조
- 이름이 길어서 무섭지만, 패턴이 있다
~Declaration= 선언 (만드는 것)~Expr= 표현식 (사용하는 것)
- 실제로 써보니까 강력했다
- 정규식으로는 불가능한 정확한 분석이 가능
- 정적 분석의 한계는 컨벤션으로 보완
- 인터페이스 → 구현체:
implements키워드 분석 - 변수명 → 클래스명: 네이밍 컨벤션 활용
- 인터페이스 → 구현체:
9. 참고 자료
이 글은 Code Flow Tracer 프로젝트를 만들면서 배운 내용을 정리한 것입니다.
'토스 러너스하이 2기 > 기술' 카테고리의 다른 글
| 분석 결과를 Excel로 정리하기 - Apache POI 활용기 (0) | 2026.01.10 |
|---|---|
| 분석 결과를 어떻게 보여줄까? - CLI 출력 구현기 (1) | 2026.01.07 |
| 정적 분석의 한계 - 해결할 수 없는 것들 (0) | 2026.01.04 |
| DAO에서 SQL까지 - XML 파싱으로 연결하기 (0) | 2025.12.29 |
| 호출 흐름을 어떻게 따라가나? - 재귀와 백트래킹 (0) | 2025.12.26 |