JavaParser로 Java 코드 분석하기 - 처음 배운 사람의 정리

2025. 12. 23. 10:29·토스 러너스하이 2기/기술

레거시 코드 분석 도구를 만들면서 처음 배운 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 호출 흐름 추적하기

  1. Controller에서 시작한다
  2. 메서드 안의 호출(MethodCallExpr)을 찾는다
  3. 그 호출이 어떤 Service인지 찾는다
  4. 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에 실제로 뭐가 들어오는지 알 수 없다
  • 동적으로 클래스를 불러오는 경우
    • 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. 마치며

처음 배우면서 느낀 점

  1. AST가 어려워 보이지만, 마트료시카로 생각하니 이해됐다
    • 파일 → 클래스 → 메서드 → 호출, 큰 거 안에 작은 게 있는 구조
  2. 이름이 길어서 무섭지만, 패턴이 있다
    • ~Declaration = 선언 (만드는 것)
    • ~Expr = 표현식 (사용하는 것)
  3. 실제로 써보니까 강력했다
    • 정규식으로는 불가능한 정확한 분석이 가능
  4. 정적 분석의 한계는 컨벤션으로 보완
    • 인터페이스 → 구현체: implements 키워드 분석
    • 변수명 → 클래스명: 네이밍 컨벤션 활용

9. 참고 자료

  • JavaParser 공식 GitHub
  • JavaParser 공식 문서

이 글은 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
'토스 러너스하이 2기/기술' 카테고리의 다른 글
  • 분석 결과를 어떻게 보여줄까? - 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
JavaParser로 Java 코드 분석하기 - 처음 배운 사람의 정리
상단으로
목차

    티스토리툴바