Sunday, March 17, 2019

스프링부트와 Gradle 환경에서 Lombok 'compileJava' 'cannot find symbol' 에러 해결

Spring Boot, Gradle, 그리고 Lombok은 현대 자바 애플리케이션 개발, 특히 백엔드 개발에서 거의 표준처럼 사용되는 조합입니다. 이 강력한 삼각편대는 개발자의 생산성을 극적으로 향상시켜 주지만, 때로는 예상치 못한 곳에서 발목을 잡기도 합니다. 그중 가장 대표적이고 많은 개발자들을 혼란에 빠뜨리는 오류가 바로 'execution failed for task ':compileJava'' 또는 'cannot find symbol' 에러입니다.

분명히 IntelliJ나 Eclipse 같은 통합 개발 환경(IDE)에서는 아무런 오류도 표시되지 않고, @Getter, @Setter, @Data 어노테이션이 생성하는 메소드들에 대한 자동 완성까지 완벽하게 동작합니다. 모든 것이 정상적으로 보이는 코드인데도 불구하고, 새로운 환경에서 프로젝트를 실행하거나 gradle build, gradle bootJar 명령어로 빌드를 시도하는 순간, 붉은색 에러 메시지가 콘솔을 가득 채우는 상황은 신입 개발자뿐만 아니라 경력 개발자에게도 상당한 당혹감을 안겨줍니다. 이 글에서는 이 미스터리한 에러가 왜 발생하는지 그 근본적인 원인을 깊이 파헤치고, 명쾌한 해결책과 더 나은 예방책까지 종합적으로 제시하고자 합니다.

에러의 재구성: IDE는 침묵하고, Gradle은 비명을 지른다

이 문제의 가장 큰 특징은 IDE와 실제 빌드 도구(Gradle) 사이의 동작 불일치입니다. 구체적인 시나리오를 통해 문제를 명확히 해보겠습니다.

  1. 개발 환경: 동료 개발자의 컴퓨터나 CI/CD 서버 등, 기존에 코드를 작성하던 곳이 아닌 새로운 환경에서 프로젝트를 클론(clone) 또는 풀(pull) 받습니다.
  2. IDE 확인: 프로젝트를 IntelliJ IDEA로 엽니다. Gradle 종속성을 동기화하고 나면 프로젝트 전체에 빨간 줄(컴파일 오류 표시)이 하나도 보이지 않습니다. Lombok 어노테이션을 사용한 클래스를 열어봐도 깨끗합니다.
  3. IDE 기능 테스트: User 클래스에 @Getter 어노테이션만 붙여놓고, 다른 서비스 클래스에서 user.getName() 메소드를 호출하면 자동 완성이 완벽하게 동작하고, 해당 메소드의 선언으로 이동(Go to declaration)하는 것까지 잘 됩니다. IDE는 getName() 메소드가 존재하는 것처럼 완벽하게 인지하고 있습니다.
  4. Gradle 빌드 시도: 이제 터미널을 열고 프로젝트를 빌드하여 실행 가능한 JAR 파일을 만들기 위해 ./gradlew build 명령어를 실행합니다.
  5. 에러 발생: 빌드 프로세스가 진행되다가 :compileJava 태스크에서 실패하며, 아래와 유사한 'cannot find symbol' 오류가 대량으로 발생합니다.

> Task :compileJava FAILED

/path/to/project/com/example/service/UserService.java:25: error: cannot find symbol
    String name = user.getName();
                      ^
  symbol:   method getName()
  location: variable user of type User
  
/path/to/project/com/example/service/UserService.java:30: error: cannot find symbol
    user.setName("New Name");
        ^
  symbol:   method setName(String)
  location: variable user of type User

... (오류 다수 발생) ...

FAILURE: Build failed with an exception.

이처럼 IDE의 '지능적인' 지원을 믿고 있던 개발자는 코드에 아무런 문제가 없다고 확신했지만, 빌드 시스템은 해당 메소드를 전혀 찾지 못하는 상황에 직면하게 됩니다. 이 불일치야말로 혼란의 근원이며, 이 불일치가 발생하는 이유를 이해하는 것이 문제 해결의 첫걸음입니다.

근본 원인 탐구: 컴파일 시점과 어노테이션 프로세싱의 동작 원리

결론부터 말하면, 이 문제는 'Gradle의 의존성 구성(Dependency Configuration)에 대한 이해 부족'에서 비롯됩니다. 특히 Lombok과 같은 '어노테이션 프로세서(Annotation Processor)'가 어떻게 작동하고, 빌드 도구와 어떻게 상호작용하는지에 대한 이해가 핵심입니다. 차근차근 그 원리를 파고들어 보겠습니다.

1. 자바 소스 코드가 클래스 파일이 되기까지: 컴파일의 마법

우리가 작성하는 .java 파일은 사람이 읽기 위한 텍스트 파일일 뿐, JVM(자바 가상 머신)이 직접 실행할 수는 없습니다. JVM이 이해할 수 있는 언어는 바이트코드(bytecode)이며, 이 바이트코드가 담긴 파일이 바로 .class 파일입니다. 이 변환 과정을 '컴파일(compile)'이라고 부르며, 자바 컴파일러(javac)가 이 역할을 수행합니다.

컴파일 과정은 단순히 문법을 검사하고 코드를 변환하는 것 이상으로, 코드의 참조 관계를 확인하는 중요한 작업을 포함합니다. 예를 들어, `UserService`에서 `user.getName()`을 호출하면, 컴파일러는 `User` 클래스에 실제로 `getName()`이라는 메소드가 정의되어 있는지 확인합니다. 만약 이 메소드를 찾지 못하면, 바로 그 유명한 'cannot find symbol' 에러를 뱉어내는 것입니다.

2. Lombok은 어떻게 코드를 '만들어'내는가? 어노테이션 프로세서(JSR 269)

그렇다면 우리는 User 클래스에 getName() 메소드를 직접 작성한 적이 없는데, 어떻게 IDE는 이 메소드를 알고 있으며, 우리는 어떻게 이 메소드를 사용할 수 있을까요? 바로 여기에 Lombok어노테이션 프로세싱의 비밀이 숨어있습니다.

Lombok은 런타임에 동적으로 코드를 변경하는 라이브러리가 아닙니다. Lombok은 **컴파일 시점(compile time)**에 개입하여 우리가 작성한 코드에 '추가적인 코드'를 생성해주는 도구입니다. 이 기능을 가능하게 하는 것이 바로 '어노테이션 프로세서'입니다.

자바 6부터 도입된 JSR 269(Pluggable Annotation Processing API)는 자바 컴파일러의 동작 과정에 '플러그인'처럼 끼어들 수 있는 공식적인 방법을 제공합니다. 어노테이션 프로세서는 이 API의 구현체로, 컴파일 과정의 특정 단계에서 실행됩니다.

Lombok의 동작 순서는 다음과 같습니다.

  1. 1단계 (컴파일 시작): 개발자가 javac 또는 Gradle의 compileJava 태스크를 실행하여 컴파일을 시작합니다.
  2. 2단계 (어노테이션 스캐닝 및 프로세싱): 자바 컴파일러는 소스 코드를 분석하기 전에, 등록된 어노테이션 프로세서들을 먼저 실행합니다. 이때 Lombok의 어노테이션 프로세서가 동작을 시작합니다.
  3. 3단계 (코드 생성): Lombok 프로세서는 소스 코드에서 @Getter, @Setter, @Data, @Builder 등의 Lombok 어노테이션을 찾아냅니다. 그리고 이 어노테이션들의 규칙에 따라 필요한 메소드(e.g., `getName()`, `setName()`)의 소스 코드를 메모리 상에서 또는 임시 파일로 생성합니다. 이 생성된 코드는 추상 구문 트리(AST, Abstract Syntax Tree)를 직접 조작하는 방식으로 이루어집니다.
  4. 4단계 (최종 컴파일): 어노테이션 프로세서의 작업이 모두 끝나면, 컴파일러는 '원본 소스 코드 + Lombok이 생성한 코드'를 모두 합친 완전한 형태의 소스 코드를 가지고 최종적인 컴파일을 진행하여 .class 파일을 생성합니다.

즉, `compileJava` 태스크의 관점에서 `user.getName()`을 호출하는 코드를 컴파일 할 때, `User.java` 파일에는 이미 Lombok에 의해 `getName()` 메소드가 '존재하는' 상태여야만 합니다. 만약 어노테이션 프로세싱 단계가 제대로 실행되지 않았다면, 컴파일러는 `getName()` 메소드를 찾을 수 없어 'cannot find symbol' 에러를 발생시키는 것입니다.

3. Gradle의 의존성 구성: `compileOnly`와 `annotationProcessor`의 결정적 차이

이제 핵심 퍼즐 조각인 Gradle의 의존성 구성(Dependency Configuration)을 살펴볼 차례입니다. Gradle은 dependencies { ... } 블록 안에 다양한 키워드를 사용하여 의존성의 '범위(scope)'를 지정합니다. 이 범위에 따라 해당 라이브러리가 언제, 어디서 사용될지가 결정됩니다.

  • implementation: 가장 흔하게 사용되는 구성입니다. 컴파일 시점과 런타임 시점 모두에 필요하며, 이 모듈을 의존하는 다른 모듈에게는 노출되지 않습니다.
  • api: implementation과 유사하지만, 이 모듈을 의존하는 다른 모듈에게도 해당 의존성이 전이(transitive)됩니다. 라이브러리 프로젝트에서 주로 사용합니다.
  • compileOnly: 이름 그대로 **'오직 컴파일 시에만 필요한'** 의존성을 의미합니다. 이 의존성은 최종적으로 만들어지는 결과물(JAR, WAR 파일)에는 포함되지 않습니다. 왜냐하면 런타임에는 필요 없기 때문입니다. Lombok이 여기에 완벽하게 부합합니다. 일단 Lombok이 getter/setter 메소드를 생성하여 .class 파일에 포함시키고 나면, 런타임 환경에서는 더 이상 @Getter 어노테이션이나 Lombok 라이브러리 자체가 필요하지 않습니다. 따라서 compileOnly로 선언하여 최종 결과물을 가볍게 유지하는 것이 모범 사례입니다.
  • annotationProcessor: 이것이 바로 오늘의 주인공입니다. 이 구성은 해당 의존성이 **'어노테이션 프로세서'**임을 Gradle에게 명시적으로 알려주는 역할을 합니다. Gradle은 이 `annotationProcessor`로 등록된 라이브러리를 컴파일 과정 중 javac의 어노테이션 프로세싱 단계에서 실행시켜 줍니다. 즉, **Lombok의 코드 생성 마법을 실제로 발동시키는 스위치**가 바로 이 구성입니다.

이제 모든 퍼즐이 맞춰졌습니다. IDE에서는 문제가 없고 Gradle 빌드에서만 실패하는 이유는 다음과 같습니다.

'잘못된 설정'의 시나리오: build.gradlecompileOnly 'org.projectlombok:lombok'만 있고, annotationProcessor 'org.projectlombok:lombok'가 없는 경우.

  • IDE의 동작: IntelliJ와 같은 최신 IDE는 자체적으로 강력한 어노테이션 프로세싱 기능을 내장하고 있습니다. 프로젝트 설정에서 'Enable annotation processing' 옵션이 켜져 있으면, Gradle의 설정과는 별개로 IDE가 직접 Lombok을 어노테이션 프로세서로 인지하고 실행합니다. 따라서 IDE 내부적으로는 getter/setter가 생성된 것처럼 코드를 분석하고 자동 완성 및 탐색 기능을 제공합니다. 개발자는 모든 것이 정상이라고 느끼게 됩니다.
  • Gradle의 동작: 그러나 터미널에서 gradle build를 실행하면, 이 과정은 IDE의 지능적인 기능과 무관하게 오직 build.gradle 파일의 설정에만 의존합니다. Gradle은 compileOnly 설정을 보고 "아, 개발 중에 Lombok 어노테이션을 사용하겠구나. 문법 에러는 내지 말아야지."라고 생각합니다. 하지만 annotationProcessor 설정이 없기 때문에, **"Lombok을 어노테이션 프로세서로 실행하라는 명령은 없었어."** 라고 판단하고 코드 생성 단계를 건너뛰게 됩니다. 결국 Lombok의 마법이 발동되지 않은 상태에서 순수한 원본 코드만으로 컴파일을 시도하게 되고, 당연히 존재하지 않는 `getName()`, `setName()` 등을 찾지 못해 'cannot find symbol' 에러를 뿜어내는 것입니다.

문제 해결: `build.gradle`을 올바르게 수정하기

원인을 파악했으니 해결은 간단합니다. Gradle 빌드 스크립트에 Lombok을 어노테이션 프로세서로 사용하라고 명시해주기만 하면 됩니다.

1. 올바른 의존성 설정 추가

사용하고 있는 Gradle DSL(Domain Specific Language)에 맞춰 아래와 같이 수정합니다.

Groovy DSL (build.gradle 파일)

가장 일반적인 `build.gradle` 파일의 경우입니다. dependencies 블록 안에 annotationProcessor 구성을 추가해야 합니다.

수정 전 (잘못된 설정):


dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // annotationProcessor가 누락됨
    compileOnly 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

수정 후 (올바른 설정):


dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    
    // 컴파일 시에만 Lombok 라이브러리가 필요함을 명시
    compileOnly 'org.projectlombok:lombok'
    // 컴파일 과정에서 Lombok 어노테이션 프로세서를 실행하도록 명시
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Kotlin DSL (build.gradle.kts 파일)

코틀린 DSL을 사용하는 프로젝트의 경우, 문법이 약간 다릅니다.

수정 전 (잘못된 설정):


dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    // annotationProcessor가 누락됨
    compileOnly("org.projectlombok:lombok")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

수정 후 (올바른 설정):


dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")

    // 컴파일 시에만 Lombok 라이브러리가 필요함을 명시
    compileOnly("org.projectlombok:lombok")
    // 컴파일 과정에서 Lombok 어노테이션 프로세서를 실행하도록 명시
    annotationProcessor("org.projectlombok:lombok")
    
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

2. 테스트 코드도 잊지 말자!

만약 src/test/java 경로의 테스트 코드에서도 Lombok을 사용하고 있다면, 테스트 코드 컴파일을 위한 설정도 추가해주어야 합니다. 그렇지 않으면 메인 코드는 잘 빌드되다가 테스트 코드 컴파일(compileTestJava) 단계에서 똑같은 오류를 만나게 됩니다.

Groovy DSL (build.gradle 파일)


dependencies {
    // ... (기존 설정)
    
    // 테스트 코드에서도 Lombok을 사용하기 위한 설정
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
}

Kotlin DSL (build.gradle.kts 파일)


dependencies {
    // ... (기존 설정)
    
    // 테스트 코드에서도 Lombok을 사용하기 위한 설정
    testCompileOnly("org.projectlombok:lombok")
    testAnnotationProcessor("org.projectlombok:lombok")
}

3. 변경 후 Gradle 프로젝트 새로고침 및 캐시 정리

build.gradle 파일을 수정한 후에는 IDE에서 'Reload All Gradle Projects' 버튼을 눌러 변경사항을 프로젝트에 적용해야 합니다. 또한, 만약을 위해 이전의 잘못된 빌드 결과가 캐시에 남아 문제를 일으키는 것을 방지하기 위해 터미널에서 `clean` 작업을 수행하는 것이 좋습니다.


./gradlew clean build

clean 태스크는 이전 빌드에서 생성된 모든 파일(build 디렉토리)을 삭제하여, 완전히 새로운 상태에서 빌드를 시작하도록 보장합니다.

더 나은 방법: Gradle Lombok 플러그인으로 간소화하기

매번 compileOnlyannotationProcessor를 쌍으로 관리하는 것은 번거롭고 실수의 여지를 남깁니다. 다행히도 이 과정을 자동화해주는 매우 유용한 Gradle 플러그인이 있습니다. 바로 io.freefair.lombok 플러그인입니다.

이 플러그인을 사용하면 build.gradle 설정이 훨씬 깔끔해지고, 앞서 언급한 문제들을 근본적으로 예방할 수 있습니다.

Lombok 플러그인 적용 방법

Groovy DSL (build.gradle 파일)

plugins 블록에 플러그인 ID를 추가하고, dependencies 블록에서는 lombok 이라는 새로운 구성을 사용하여 의존성을 한 번만 선언하면 됩니다. 이 플러그인이 알아서 compileOnly, annotationProcessor, testCompileOnly, testAnnotationProcessor 등을 자동으로 설정해줍니다.


plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.5'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
    id 'io.freefair.lombok' version '6.5.1' // Lombok 플러그인 추가
}

// ... (생략)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // 'lombok' 구성으로 한 번만 선언하면 플러그인이 나머지를 처리해 줌
    lombok 'org.projectlombok:lombok'
    
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Kotlin DSL (build.gradle.kts 파일)


plugins {
    java
    id("org.springframework.boot") version "2.7.5"
    id("io.spring.dependency-management") version "1.0.15.RELEASE"
    id("io.freefair.lombok") version "6.5.1" // Lombok 플러그인 추가
}

// ... (생략)

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    
    // 'lombok' 구성으로 한 번만 선언하면 플러그인이 나머지를 처리해 줌
    lombok("org.projectlombok:lombok")
    
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

새로운 프로젝트를 시작하거나 기존 프로젝트를 리팩토링할 기회가 있다면, 이 플러그인을 도입하여 빌드 스크립트의 안정성과 가독성을 높이는 것을 강력히 추천합니다.

결론 및 최종 정리

Spring Boot와 Gradle 환경에서 발생하는 Lombok의 'cannot find symbol' 에러는 코드 자체의 결함이 아니라, 빌드 도구와 어노테이션 프로세서의 상호작용에 대한 오해에서 비롯된 전형적인 문제입니다.

핵심을 다시 한번 요약하면 다음과 같습니다.

  • IDE는 믿지 마라: IDE의 내장 기능은 실제 빌드 환경과 다를 수 있습니다. 빌드의 유일한 진실은 build.gradle 파일과 커맨드라인입니다.
  • compileOnly는 선언일 뿐이다: 이 구성은 "이 라이브러리를 컴파일 중에 참조할게"라고 알려주는 역할만 합니다.
  • annotationProcessor가 실행의 열쇠다: 이 구성은 "이 라이브러리는 그냥 라이브러리가 아니라 컴파일 과정에 개입하는 도구이니, 실행해줘!"라고 명령하는 핵심 스위치입니다.
  • 플러그인을 사용하라: 가능하다면 io.freefair.lombok 플러그인을 사용하여 설정의 복잡성을 줄이고 실수를 예방하는 것이 현명한 선택입니다.

이러한 빌드 시스템의 동작 원리를 정확히 이해한다면, Lombok 관련 에러뿐만 아니라 QueryDSL(Q-Type 생성) 등 다른 어노테이션 프로세서 기반 라이브러리에서 발생하는 유사한 문제들도 손쉽게 해결할 수 있는 능력을 갖추게 될 것입니다. 당혹스러운 컴파일 에러 앞에서 더 이상 헤매지 않고, 문제의 근원을 파악하여 자신감 있게 대처하는 개발자로 성장하기를 바랍니다.


0 개의 댓글:

Post a Comment