본문 바로가기
SpringBoot

@GroovyASTTransformation 컴파일타임 조작, 어노테이션 동적 적용

by 오렌지마끼야또 2025. 2. 24.
728x90
반응형

 

 

 

 

목차

build.gradle 추가한 내용

구현하고 싶은 것, 그 이유

AST 란?

AST Transformation 메인 코드보기

CompilePhase

AST Transformation 종류

Local AST Transformation

Global AST Transformation

AST 로 어노테이션, 메소드 추가하기

xxxApiTest.groovy Spock 테스트코드

AST Transformation 풀코드

 

 

 

groovy 4.0

spock 2.3

사용했습니다.

 

 


🎈 build.gradle 추가한 내용

 

plugins

id 'groovy'

 

allprojects

apply plugin: 'groovy'

 

dependencies

// groovy
testImplementation 'org.apache.groovy:groovy-all:4.0.25'
// spock
 testImplementation 'org.spockframework:spock-spring:2.3-groovy-4.0'
// WebClient
testImplementation 'org.springframework.boot:spring-boot-starter-webflux'
// EntityManager
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa'

 

test 에서 exclude 빼기

    test {
//        exclude '**/*'
        useJUnitPlatform()
    }
}

 

 

디렉토리 구조는 아래와 같이 만들었습니다.

 

 

 

 

 

🎈 구현하고 싶은 것, 그 이유

 

지금까지 API 통합테스트는 postman으로 하고 있었습니다. 새로운 프로젝트부터는 groovy, spock로 통합테스트를 진행해야해서 전환하게 되었습니다.

 

그냥 하면 되겠거니 하고 적당히 시작했는데 하다보니 욕심이 생긴 부분이 있었습니다. 상황별로 최적의 어노테이션을 적용하고싶다는 것이었습니다.

 

통합테스트를 하는 시점 2가지(push 전, 후) 입니다.

쿼리 전처리란 테스트 실행하기 전에 특정 데이터를 insert 하거나 delete 해야하는 것을 말합니다.

예를 들어 특정 데이터를 저장하는 api인 경우에는 먼저 데이터가 없어야겠죠? 이때 delete 쿼리를 수행합니다. 지금은 검증팀이 수동으로 하고 있습니다. 다행히 그런 케이스가 많진 않습니다. spock 로 전환하게 되면 쿼리도 코드화해서 딸깍 한번으로 수행하게 하려고 합니다.

 

사실 모든 상황에서 그냥 전부 @SpringBootTest 를 써도 되긴 합니다. 하지만 단점이 있습니다.

@SpringBootTest는 전체 application context 를 띄우기 때문에 테스트 실행시간이 오래걸립니다.

 

push 전에는 당연히 클라우드 개발환경에 배포를 안했기 때문에 local 에서 @SpringBootTest 를 사용하는게 맞습니다.

 

하지만 push 후에는 클라우드 개발환경에 배포도 했으니 그곳으로 호출하면 되기 때문에 굳이 application context 가 다 뜰 때까지 기다릴 필요가 없습니다. 쿼리 전처리가 있는 경우에만 JPA EntityManager 를 사용할 수 있게 하면 됩니다. @DataJpaTest 어노테이션은 application context 를 띄우긴 하지만 data access 를 위한 JPA 관련 빈만 로드하기 때문에 빠른 실행이 가능합니다.

 

@SpringBootTest : 1분 30초

@DataJpaTest : 35초

어노테이션 없음 : 17초

 

테스트할 때마다 이정도 차이면 충분히 다르게 적용할 가치가 있지 않을까요? 우리의 시간은 소중하니까요

하지만 위처럼 코드는 하나인데 상황에 따라 어노테이션이 달라지는 모양이 되버립니다.

 

그렇다고 둘 다 일단 넣고 그때그때 주석처리해서 쓰세요~ 하면.. 이건 너무 개발자스럽지 못하잖아요?

 

??? : 어차피 개발환경에 배포 후에는 @SpringBootTest 필요없는거 아닌가요??

네 맞습니다. 필요 없습니다.

근데 왜 이런 귀찮은 짓을 하냐면 통합테스트 코드는 해당 api를 맡은 개발자가 만드는게 아니고 검증팀에서 만들기 때문입니다.

 

1. 개발자 개발 시작

2. 개발자가 push 하기 전에 검증팀이 먼저 통합테스트 코드 만들고 push

3. 개발자가 개발 완료 후 push 전에 pull 받아서 로컬환경에서 통합테스트 실행. @SpringBootTest 필요

4. 개발자가 push 및 배포

5. 검증팀이 개발환경으로 호출해서 통합테스트 실시. @DataJpaTest 필요 or 어노테이션 필요 없음

 

만약 2번에서 검증팀이 @DataJpaTest 또는 어노테이션 없음 으로 통합테스트 코드를 올린다면,

개발자는 pull 받고, @DataJpaTest 주석처리하고, @SpringBootTest 적용하고, 테스트 끝나면 테스트코드 push 하면 안되고, 주석처리 원복하고.. 하는 과정을 수행해야 합니다.

이 방식을 채택한다면 매 api를 개발할 때마다 이런 불필요한 행동을 해야합니다.

'별거 없네. 개발자가 테스트할때만 잠깐 하면 되지.' 라고 할 수도 있습니다.

 

하지만 우리는 간과해선 안됩니다.

시간은 흐르고, 사람은 바뀌고, 히스토리는 잊혀진다는 것을.

나중에 분명 누군가는 저 간단한 행동을 안해서 '이거 왜 안됨?' 할거니까요

 

 

그래서 결심했습니다. @SpringBootTest, @DataJpaTest, 어노테이션없음 을 동적으로 구현해보자! 뭘 써야할지 신경쓰지 않고 테스트 run 버튼 딸깍 한번으로 알아서 적용되게 해보자!

 

컨셉은 @SpockTest 라는 커스텀 어노테이션을 만들고 flag 값 true, false 만 변경하면 동적으로 적용되게 하는 것으로 생각했습니다.

 

구현 방법은 컴파일타임 또는 런타임에 코드를 조작하는 것입니다. 이게 되는거라는걸 저도 이번에 처음 알았습니다.

솔직히 방법을 알아서 시작한건 아니었습니다. 그냥 어림잡아 '되겠지~ 안되는게 어딨어~' 하는 마음으로 시작했습니다.(오래 걸릴줄 모르고..)

결론부터 말하자면 성공했고,

이 기능 하나 구현하는데 총 3주 걸렸고,

2주동안 삽질했고,

해답찾고 적용하는데 1주 걸렸습니다.

 

삽질 뭐했는지 대충 빠르게 말하자면

@Conditional, @ExtendWith, Annotation Processor, Java Agent 시도해봤습니다. 특히 Annotation Processor 는 Lombok 이 동작하는 방식이기도 하지요. 그래서 될 줄 알았는데 안돼서 포기할 뻔.. 2주동안 삽질하면서 '하지말까..' 만 한 3번 생각했습니다ㅎ

 

 -  @Conditional : 런타임에 동작. 스프링 컨텍스트가 확인하는 것이기 때문에 애초에 사용 불가
 -  @ExtendWith : 런타임에 동작 . 프로필 여러 개 만들어서 사용. 그냥 똑같은 코드 2개 만들어 @SpringBootTest,  @DataJpaTest 붙이고 프로필 다르게 적용하는 방법. 불필요한 중복코드 발생.
 -  Annotation Processor : 컴파일타임에 동작 . 자바 컴파일러가 동작시키는데 나는 그루비 컴파일러 사용. Groovy의 AST 변환처럼 기존 클래스나 메서드의 구조를 직접 수정하는 것이 아니라 새로운 파일을 만드는 것. 자바 AST 조작을 하고 싶으면 com.sun.tools.javac.tree.JCTree 등의 라이브러리를 사용해서 가능은 한데 해킹의 영역이라 기본적으로는 못쓰게 되어 있음. 활성화가 안됨. 회색글자. 각 개발자가 IDE에서 컴파일러 설정 추가하면 사용할 순 있음.
 - Java Agent : 런타임에 동작 . JVM이 바이트코드 실행 전 바이트코드 조작. 얘는 돼야할 것 같은데 왜 안됐지.
★ @GroovyASTTransformation : 컴파일타임에 동작. Abstract Syntax Tree, 추상 구문 트리 변환. 성공. Groovy AST 조작

 

 

 

 

🎈 AST란?

 

그래서 AST 가 무엇이냐!

 

Abstract Syntax Tree, 추상 구문 트리 라고 하여 컴파일 타임에 생성되는 트리입니다.

보통

컴파일타임 : .java 파일 -> .class 파일(바이트코드) 변환

런타임 : jvm이 .class 파일(바이트코드)을 읽어 프로그램 실행

로 이해하고 계실겁니다.

AST는 컴파일타임에서 .java 파일(.groovy 파일)과 .class 파일의 사이에 생성되는 중간단계입니다!  .class 로 만들기 전에 소스코드를 트리형태로 먼저 만들고 이를 분석하여 .class 파일을 만드는 것입니다.

컴파일타임 : .java 파일 -> AST -> .class 파일(바이트코드) 변환 이 되는 것이지요. (이것도 이번에 알음)

Abstract (추상) 이라고 하는 이유는 세부적인 정보는 생략하고 일반화하여 표현하기 때문입니다.

 

 

AST 가 어떻게 구성되어 있는지 한 번 볼까요? 테스트를 실행하면 들여쓰기 형식으로 출력되도록 해보았습니다. (코드는 맨아래에. 너무 길어서)

노드의 종류로는 ClassNode, MethodNode, FieldNode, AnnotationNode, ConstructorNode 등이 있습니다.

 

클래스에 적용된 어노테이션은 뭔지, 필드는 뭐가 있는지, 메소드는 뭐가 있는지 등등이 출력됩니다.

이걸 보기 쉽게 JSON형식으로 변환하고, 시각화 해주는 사이트를 찾아서 보았습니다.

 

출력된 AST 를 json 형식으로 변환한 것 (copilot이 해줌 + 손으로 조금 수정)

스크롤 주의!!!!

더보기
{
  "클래스": [
    {
      "클래스명": ["com.company.abc.api.ApiTest"],
      "extends": ["com.company.abc.spock.Specification"],
      "어노테이션": [
        "com.company.abc.annotation.SpockTest",
        "org.spockframework.runtime.model.SpecMetadata"
      ]
    },
    {
      "필드": [
        {
          "필드명": ["entityManager"],
          "타입": ["javax.persistence.EntityManager"],
          "어노테이션": [
            "javax.persistence.PersistenceContext",
            "org.spockframework.runtime.model.FieldMetadata"
          ]
        },
        {
          "필드명": ["webClient"],
          "타입": ["org.springframework.web.reactive.function.client.WebClient"],
          "어노테이션": [
            "org.spockframework.runtime.model.FieldMetadata"
          ]
        },
        {
          "필드명": ["cloudUrl"],
          "타입": ["java.lang.Object"],
          "어노테이션": [
            "org.spockframework.runtime.model.FieldMetadata"
          ]
        },
        {
          "필드명": ["baseUrl"],
          "타입": ["java.lang.Object"],
          "어노테이션": [
            "org.spockframework.runtime.model.FieldMetadata"
          ]
        },
        {
          "필드명": ["port"],
          "타입": ["java.lang.Object"],
          "어노테이션": [
            "org.springframework.boot.web.server.LocalServerPort",
            "org.spockframework.runtime.model.FieldMetadata"
          ]
        },
        {
          "필드명": ["apiPath"],
          "타입": ["java.lang.Object"],
          "어노테이션": [
            "org.spockframework.runtime.model.FieldMetadata"
          ]
        },
        {
          "필드명": ["$staticClassInfo"],
          "타입": ["org.codehaus.groovy.reflection.ClassInfo"]
        },
        {
          "필드명": ["__$stMC"],
          "타입": ["boolean"]
        }
      ]
    },
    {
      "메서드": [
        {
          "메서드명": ["setupSpec"],
          "리턴타입": ["java.lang.Object"],
          "코드 블록": [
            {
              "메서드 호출": "printAddedAnnotation()"
            }
          ]
        },
        {
          "메서드명": ["setup"],
          "리턴타입": ["java.lang.Object"],
          "코드 블록": [
            {
              "할당": "localUrl = http://localhost:$port"
            },
            {
              "할당": "baseUrl = com.company.abc.api.ApiTest.getBaseUrl(this.getClass(), cloudUrl, localUrl)"
            },
            {
              "할당": "webClient = org.springframework.web.reactive.function.client.WebClient.builder().baseUrl(baseUrl).build()"
            }
          ]
        },
        {
          "메서드명": ["$getStaticMetaClass"],
          "리턴타입": ["groovy.lang.MetaClass"]
        },
        {
          "메서드명": ["$spock_initializeFields"],
          "리턴타입": ["java.lang.Object"],
          "코드 블록": [
            {
              "할당": "this.cloudUrl = com.company.abc.api.ApiTest.getCloudUrl(this.getClass())"
            },
            {
              "할당": "this.apiPath = /v1/getUserInfo"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_0"],
          "파라미터": [
            "NO",
            "API",
            "PARAM"
          ],
          "리턴타입": ["void"],
          "어노테이션": [
            "spock.lang.Unroll",
            "org.spockframework.runtime.model.FeatureMetadata"
          ],
          "코드 블록": [
            {
              "할당": "$spock_errorCollector = org.spockframework.runtime.ErrorRethrower.INSTANCE"
            },
            {
              "할당": "$spock_valueRecorder = new org.spockframework.runtime.ValueRecorder()"
            },
            {
              "할당": "sql = SELECT * FROM schema.table LIMIT :limit"
            },
            {
              "할당": "queryResponse = entityManager.createNativeQuery(sql).setParameter(limit, 1).getSingleResult()"
            },
            {
              "메서드 호출": "println()"
            },
            {
              "메서드 호출": "println()"
            },
            {
              "할당": "cloudUri = org.springframework.web.util.UriComponentsBuilder.fromPath(/v1).queryParam(CMD, API).queryParam(PARAM, PARAM).build(false).toUriString()"
            },
            {
              "할당": "response = org.springframework.web.reactive.function.client.WebClient.builder().baseUrl(cloudUrl).build().get().uri(cloudUri).accept(org.springframework.http.MediaType.TEXT_PLAIN).retrieve().toEntity(java.lang.String).block()"
            },
            {
              "예상 외 타입": "org.codehaus.groovy.ast.stmt.TryCatchStatement"
            },
            {
              "메서드 호출": "leaveScope()"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_0prov0"],
          "리턴타입": ["java.lang.Object"],
          "어노테이션": [
            "org.spockframework.runtime.model.DataProviderMetadata"
          ],
          "코드 블록": [
            {
              "return": "[1, 2]"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_0prov1"],
          "파라미터": [
            "$spock_p_NO"
          ],
          "리턴타입": ["java.lang.Object"],
          "어노테이션": [
            "org.spockframework.runtime.model.DataProviderMetadata"
          ],
          "코드 블록": [
            {
              "return": "[getBuyInfo, getSellInfo]"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_0prov2"],
          "파라미터": [
            "$spock_p_NO",
            "$spock_p_API"
          ],
          "리턴타입": ["java.lang.Object"],
          "어노테이션": [
            "org.spockframework.runtime.model.DataProviderMetadata"
          ],
          "코드 블록": [
            {
              "return": "[userId=kkk123, userId=kkk123]"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_0proc"],
          "파라미터": [
            "$spock_p0",
            "$spock_p1",
            "$spock_p2"
          ],
          "리턴타입": ["java.lang.Object"],
          "어노테이션": [
            "org.spockframework.runtime.model.DataProcessorMetadata"
          ],
          "코드 블록": [
            {
              "할당": "NO = (java.lang.Object) $spock_p0"
            },
            {
              "할당": "API = (java.lang.Object) $spock_p1"
            },
            {
              "할당": "PARAM = (java.lang.Object) $spock_p2"
            },
            {
              "return": "[{NO, API, PARAM}]"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_1"],
          "파라미터": [
            "NO",
            "DESC",
            "userId",
            "email"
          ],
          "리턴타입": ["void"],
          "어노테이션": [
            "org.spockframework.runtime.model.FeatureMetadata"
          ],
          "코드 블록": [
            {
              "할당": "uri = org.springframework.web.util.UriComponentsBuilder.fromPath(apiPath).queryParam(userId, userId).queryParam(email, email).toUriString()"
            },
            {
              "메서드 호출": "println()"
            },
            {
              "할당": "response = webClient.get().uri(uri).accept(MediaType.APPLICATION_JSON).retrieve().toEntity(Object.class).block()"
            },
            {
              "메서드 호출": "println()"
            },
            {
              "메서드 호출": "verifyAll()"
            },
            {
              "메서드 호출": "leaveScope()"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_1prov0"],
          "리턴타입": ["java.lang.Object"],
          "어노테이션": [
            "org.spockframework.runtime.model.DataProviderMetadata"
          ],
          "코드 블록": [
            {
              "return": "[1, 2, 3]"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_1prov1"],
          "파라미터": [
            "$spock_p_NO"
          ],
          "리턴타입": ["java.lang.Object"],
          "어노테이션": [
            "org.spockframework.runtime.model.DataProviderMetadata"
          ],
          "코드 블록": [
            {
              "return": "[사용자 정보 제공, 사용자 정보 제공, 사용자 정보 제공]"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_1prov2"],
          "파라미터": [
            "$spock_p_NO",
            "$spock_p_DESC"
          ],
          "리턴타입": ["java.lang.Object"],
          "어노테이션": [
            "org.spockframework.runtime.model.DataProviderMetadata"
          ],
          "코드 블록": [
            {
              "return": "[aaa111, bbb222, ccc333]"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_1prov3"],
          "파라미터": [
            "$spock_p_NO",
            "$spock_p_DESC",
            "$spock_p_userId"
          ],
          "리턴타입": ["java.lang.Object"],
          "어노테이션": [
            "org.spockframework.runtime.model.DataProviderMetadata"
          ],
          "코드 블록": [
            {
              "return": "[aaa111@naver.com, bbb222@gmail.com, ccc333@naver.com]"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_1proc"],
          "파라미터": [
            "$spock_p0",
            "$spock_p1",
            "$spock_p2",
            "$spock_p3"
          ],
          "리턴타입": ["java.lang.Object"],
          "어노테이션": [
            "org.spockframework.runtime.model.DataProcessorMetadata"
          ],
          "코드 블록": [
            {
              "할당": "NO = (java.lang.Object) $spock_p0"
            },
            {
              "할당": "DESC = (java.lang.Object) $spock_p1"
            },
            {
              "할당": "userId = (java.lang.Object) $spock_p2"
            },
            {
              "할당": "email = (java.lang.Object) $spock_p3"
            },
            {
              "return": "[{NO, DESC, userId, email}]"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_2"],
          "리턴타입": ["void"],
          "어노테이션": [
            "spock.lang.Unroll",
            "org.spockframework.runtime.model.FeatureMetadata"
          ],
          "코드 블록": [
            {
              "할당": "$spock_errorCollector = org.spockframework.runtime.ErrorRethrower.INSTANCE"
            },
            {
              "할당": "$spock_valueRecorder = new org.spockframework.runtime.ValueRecorder()"
            },
            {
              "할당": "sql = SELECT * FROM schema.table LIMIT :limit"
            },
            {
              "할당": "queryResponse = entityManager.createNativeQuery(sql).setParameter(limit, 1).getResultList()"
            },
            {
              "예상 외 타입": "org.codehaus.groovy.ast.stmt.TryCatchStatement"
            },
            {
              "메서드 호출": "leaveScope()"
            }
          ]
        },
        {
          "메서드명": ["$spock_feature_1_3"],
          "리턴타입": ["void"],
          "어노테이션": [
            "org.spockframework.runtime.model.FeatureMetadata"
          ],
          "코드 블록": [
            {
              "할당": "$spock_errorCollector = org.spockframework.runtime.ErrorRethrower.INSTANCE"
            },
            {
              "할당": "$spock_valueRecorder = new org.spockframework.runtime.ValueRecorder()"
            },
            {
              "할당": "clazz = this.class"
            },
            {
              "할당": "annotations = clazz.getAnnotations()"
            },
            {
              "예상 외 타입": "org.codehaus.groovy.ast.stmt.TryCatchStatement"
            },
            {
              "예상 외 타입": "org.codehaus.groovy.ast.stmt.TryCatchStatement"
            },
            {
              "메서드 호출": "leaveScope()"
            }
          ]
        }
      ]
    }
  ]
}

 

json을 tree로 그려주는 사이트

https://jsonviewer.tools/editor

https://omute.net/editor

 

가로, 세로 원하는 방향으로 볼 수 있습니다.

 

AST가 대략 이렇게 생겼구나를 알 수 있겠죠?

 

 

 

 

🎈 AST Transformation 메인 코드 보기

 

이제 본론으로 들어가서 AST 코드를 볼까요? (풀코드는 밑에)

 

먼저 봐야하는 것은 @GroovyASTTransformation 어노테이션입니다.

 

@GroovyASTTransformation 어노테이션을 사용하여 컴파일러에게 이 파일이 ASTTransformation 에 사용된다는 것을 인식시켜줍니다. 여기서 중요한 것은 phase 입니다. 어느 단계에서 ASTTransformation 을 수행할건지 정하는건데요. 컴파일러가 동작하는 총 9가지 시점 중에 선택할 수 있습니다.

 

 

🎈 CompilePhase

 

각 단계를 간단하게 살펴보면

 

1. INITIALIZATION

    -  컴파일러가 초기화되는 단계

    -  컴파일러 설정, 클래스 경로 설정, 자원 준비 등

    -  아직 소스 코드가 파싱되지 않음

    -  아직 AST 가 없으므로 변환 불가


2. PARSING

    -  소스 코드를 AST로 변환하는 단계

    -  어휘 분석 (Lexical Analysis) : 코드를 토큰 단위로 분리

    -  구문 분석(Syntax Analysis) : 토큰들을 문법 규칙에 따라 AST 형태로 구성

    -  아직 의미 분석(Semantic Analysis)이 되지 않음

    -  전체 AST가 완성되기 전, 과정 중간중간에 노드가 생성되는 즉시 해당 노드에 관련된 transformation이 실행됨

    -  Groovy의 기본 문법을 확장하여 새로운 키워드나 문법을 정의하고 싶을 때 유용함.

    -  이 단계에서는 아직 AST가 완전히 형성되지 않아서 새로운 노드를 추가하는 것이 어렵거나 불가능할 수 있음.

 

3. CONVERSION

    -  AST를 내부 표현으로 변환하는 단계

    -  Groovy 언어 특성 (예: 클로저, 동적 타이핑)에 대한 처리

    -  Closure → MethodNode / for 루프 → while 루프 등

    -  아직 불안정한 상태이므로 AST 변환 시 예상치 못한 에러가 발생할 수 있음


4. SEMANTIC_ANALYSIS

    -  AST의 의미 분석(Semantic Analysis)을 수행하는 단계

    -  변수, 메서드, 클래스의 타입 체크 수행

    -  잘못된 타입 사용 감지 (int 변수에 String을 할당하는 등의 오류 검출)

    -  AST 변환은 SEMANTIC_ANALYSIS 단계가 시작되기 전에 동작함

    -  의미 분석 및 타입 체크 전에 AST를 변환

    -  추가한 변수, 메서드 등도 의미 분석, 타입 체크를 수행한다는 의미

 

5. CANONICALIZATION

    -  AST를 정규화하는 단계
    -  AST 구조 최적화. 중복된 정보 제거, 불필요한 AST 노드 제거

    -  AST 변환 가능하지만, 최적화 단계이므로 SEMANTIC_ANALYSIS 단계에서 적용하는 것이 더 일반적

    -  AST 변환은 CANONICALIZATION 단계가 시작되기 전에 동작함

    -  의미 분석이 끝난 상태이므로 타입 체크를 거치지 않음

    -  컴파일은 완료가 되지만, 추가한 코드가 잘못된 경우 런타임에 에러가 발생할 수 있음


6. INSTRUCTION_SELECTION

    -  AST를 바이트코드로 변환할 준비를 하는 단계

    -  AST 노드들을 JVM 명령어에 매핑. 코드 실행 순서 결정

    -  AST 변환 불가


7. CLASS_GENERATION

    -  바이트코드를 생성하는 단계

    -  실제 .class 파일이 만들어짐
    -  AST 변환 불가


8. OUTPUT

    -  바이트코드를 디스크에 기록하는 단계

    -  .class 파일을 파일 시스템에 저장

    -  최종 컴파일 아티팩트(산출물) 생성
    -  AST 변환 불가


9. FINALIZATION

    -  컴파일 과정이 종료되는 단계

    -  정리 작업이 수행됨. 자원 해제

    -  AST 변환 불가

 

 

그래서 4. SEMANTIC_ANALYSIS 로 설정했습니다. 5. CANONICALIZATION 로 해도 추가한 코드에 문제가 없다면 잘 동작하긴 합니다.

 

 

 

 

🎈 AST Transformation 종류

 

AST Transformation 은 두종류가 있습니다.

 

Local AST Transformation 과 Global AST Transformation

두가지는 사용방식과 동작원리가 조금씩 다릅니다.

제가 구현하려는건 Local AST Transformation 입니다.

차이점을 먼저 볼까요?

  Local AST Transformation Global AST Transformation
트리거 방식 특정 어노테이션 감지 시 실행 조건 없이 모든 소스코드에서 실행
등록 방식 어노테이션에다 @GroovyASTTransformationClass("MyTransformation") 적용 META-INF/services/org.codehaus.groovy.transform.ASTTransformation 파일 만들고 MyTransformation 경로 작성
실행 횟수 해당하는 어노테이션 개수만큼 소스코드당 1번

 

 

 

🎈 Local AST Transformation

 

특정 애노테이션 기반으로 트리거 됩니다.

작성한 ASTTransformation 코드를 원하는 어노테이션에 적용합니다.

 

SpockTest 라는 커스텀 어노테이션을 만들고 @GroovyASTTransformationClass 어노테이션으로 작성한 ASTTransformation 코드를 명시해줍니다. @SpockTest 가 있는 코드가 실행되면 AST 변환을 하겠다는 의미입니다.

xxxApiTest.groovy 통합테스트코드를 실행하면 등록한 AST 변환이 이루어집니다.

 

 

 

다음으로 봐야할 것은 visit() 메소드인데요

 

ASTTransformation 인터페이스를 implements 받아 구현해야하는 메소드입니다.

visit() 메소드는 컴파일러가 AST를 순회하면서 특정 annotationNode (여기선 @SpockTest) 를 만날 때마다 호출됩니다.

 

sourceUnit 은 현재 처리 중인 소스 파일에 대한 정보들을 담고 있는 객체입니다.

 

 

변환 과정

1. 어노테이션을 Class나 Method나 Field 에 적용
2. Groovy 컴파일러가 컴파일 과정에서 해당 어노테이션노드를 감지
3. 어노테이션에 연결된 ASTTransformation 클래스를 실행
4. visit(ASTNode[] astNodes, SourceUnit sourceUnit) 메소드 호출
5. AST 변환 수행

 

ASTNode[] astNodes 배열의 길이는 2입니다. 첫 번째 요소는 AnnotationNode로, 트랜스포메이션을 트리거하는 어노테이션을 나타냅니다. 두 번째 요소는 어노테이션이 적용된 노드(ClassNode, MethodNode, FieldNode 등) 를 나타냅니다.

 

백문이 불여일견, 예제코드를 보시죠.

@SpringBootTest
@SpockTest
class ApiTest {

    @LocalServerPort
    def port

    def baseUrl

    @Unroll("전처리")
    def "pre process"() {}

    def printMethod1() {}

    def printMethod2() {}
}

 

위와 같은 코드가 있을 때 ASTNode[] astNodes 쌍으로 들어올 수 있는 경우의 수는 다음과 같습니다.

 

@SpringBootTest 어노테이션에 대한 ASTTransformation 인 경우

AnnotationNode(@SpringBootTest) 에서 트리거됨
 - astNodes[0] = AnnotationNode(@SpringBootTest)
 - astNodes[1] = ClassNode(ApiTest)

 

@SpockTest 어노테이션에 대한 ASTTransformation 인 경우

AnnotationNode(@SpockTest) 에서 트리거됨
 - astNodes[0] = AnnotationNode(@SpockTest)
 - astNodes[1] = ClassNode(ApiTest)

@LocalServerPort 어노테이션에 대한 ASTTransformation 인 경우

AnnotationNode(@LocalServerPort) 에서 트리거됨
 - astNodes[0] = AnnotationNode(@LocalServerPort)
 - astNodes[1] = FieldNode(port)

@Unroll 어노테이션에 대한 ASTTransformation 인 경우
AnnotationNode(@Unroll) 에서 트리거됨
 - astNodes[0] = AnnotationNode(@Unroll)
 - astNodes[1] = MethodNode(pre process)

 

baseUrl, printMethod1(), printMethod2() 는 어노테이션이 없으므로 AST 변환이 적용되지 않습니다.

 

 

저의 경우는 @SpockTest 어노테이션에 대한 ASTTransformation 인 경우이고, 클래스에 한개밖에 없으니 visit() 메소드가 한번만 호출되겠죠? 진짜 한번만 호출되는지 궁금해서 출력해보았습니다. (println 된다는데 왜 난 안돼..)

테스트를 실행해보니 build 탭에 노란글씨로 AnnotationNode, ClassNode 가 한번만 잘 출력됩니다!

 

그래서 @SpockTest 어노테이션에 대해 특정되어 동작하기 때문에 다음과 같은 예외처리는 굳이 필요 없음니다만 추가해봤습니다.

 

 

 

 

 

🎈 Global AST Transformation

 

컴파일되는 파일의 개수만큼 실행됩니다.

 

src/main/resources/META-INF/services/org.codehaus.groovy.transform.ASTTransformation 파일을 만들어서 등록

FQCN (Full Qualified Class Name, 패키지를 포함한 클래스명) 작성

 

com.example.GlobalTransformation

 

Groovy 컴파일러가 META-INF/services/ 디렉토리를 확인하고 GlobalTransformation을 자동으로 실행합니다.

 

실제 코드

ASTTransformationVisitor 파일

org.apache.groovy:groovy-all:4.0.25 라이브러리에 있음

 

클래스 파일 안에 특정 어노테이션의 개수만큼 visit() 메소드가 호출되는 Local AST Transformation 과는 다르게 컴파일되는 소스파일에 대해서 한번만 호출됩니다.

 

변환 과정
1. META-INF/services/~ 에 transformation 코드 등록
2. Groovy 컴파일러가 컴파일
3. 컴파일되는 파일마다 ASTTransformation 클래스를 실행
4. visit(ASTNode[] astNodes, SourceUnit sourceUnit) 메소드 호출
5. AST 변환 수행

 

Global AST Transformation 에서 visit() 메소드의 ASTNode[] astNodes 는 빈값입니다. AnnotationNode 일때마다 동작하는게 아니라 파일 단위로 동작하기 때문입니다. AST 정보는 sourceUnit에서 가져올 수 있습니다.
ModuleNode ast = sourceUnit.AST 와 같이 사용하여 모든 노드를 탐색할 수 있습니다.



 

 

 

settings - build, excution, deployment - build tools - gradle 에 들어가서 Intellij IDEA로 설정하면 테스트 실행할 때 gradle이 아니라 junit test runner 로 실행됩니다.

 

junit test runner 로 테스트 실행시에는 Global AST Transformation 동작 하지 않습니다.

Global AST Transformation을 사용할 때는 JAR 파일이 있어야 하고, JAR 파일 내에 META-INF/services/org.codehaus.groovy.transform.ASTTransformation 파일이 포함되어 있어야 합니다.

하지만 junit test runner 는 소스 코드를 컴파일한 후, 메모리에서 직접 실행하기 때문에 별도의 JAR 파일이 필요하지 않아 생성되지 않습니다.

(이것도 왜안되지 하고 삽질 하다가 알게되었습니다ㅡㅡ)

 

 

 

 

🎈 AST 로 어노테이션, 메소드 추가하기

 

다시 Local AST Transformation 으로 돌아와서!

 

astNodes[1] 가 ClassNode 라고 했죠? 여기에 AnnotationNode와 MethodNode를 추가해서 새로운 것을 추가할 수 있습니다.

 

 

어노테이션 추가하기

 

메소드 추가하기

문자열을 출력하는 메소드를 추가해보았습니다.

 

테스트코드에는 메소드가 정의되어 있지 않아 회색처리 되지만 실행해보면 아주 잘 동작합니다.

 

 

@SpockSpringBootTest 와 @SpockDataJpaTest 는 제가 만든 커스텀 어노테이션입니다.

 

@SpockSpringBootTest 는 @SpringBootTest에 random port 가 설정된 어노테이션이고

 

@SpockDataJpaTest 는 3가지가 적용된 어노테이션입니다.

저희 프로젝트 자체적으로 설정한 DataAccess관련 config를 import 해주었고,

@DataJpaTest 랑

@AutoConfigureTestDatabase 라는 애플리케이션에 정의된 DB 대신 h2 같은 임시 DB를 사용하게 해주는 어노테이션인데 replace = AutoConfigureTestDatabase.Replace.NONE 를 설정해서 기존 설정된 DB로 그대로 가게 해줍니다.

통합테스트라 실제 DB의 데이터가 필요하지, 임시 DB 가 필요한게 아니니까요!

 

사실 각각의 설정 다 AST Transformation 코드에서 가능하긴 한데

비슷한 코드 여러번 써야하고 번잡해보여서 그냥 깔끔하게 커스텀 어노테이션으로 만들어놓고 classNode.addAnnotation() 한번만 해주었습니다.

 

 

 

 

 

🎈 xxxApiTest.groovy Spock 테스트코드

스크롤 주의!!!

더보기
@SpockTest(cloudTest = true, query = true)
class xxxApiTest extends Specification {

    @PersistenceContext
    EntityManager entityManager

    WebClient webClient

    def cloudUrl = getCloudUrl(this.getClass())

    def baseUrl

    @LocalServerPort
    def port

    def apiPath = '/v1/getUserInfo'


    def setupSpec() {
        printAddedAnnotation()
    }

    def setup() {
        def localUrl = "http://localhost:$port"
        baseUrl = getBaseUrl(this.getClass(), cloudUrl, localUrl)
        webClient = WebClient.builder().baseUrl(baseUrl).build()
    }


    @Unroll("전처리")
    def "pre process"() {

        given:
        // 직접 쿼리 전처리
        def sql = """
        SELECT * 
        FROM schema.table 
        LIMIT :limit
    """

        def queryResponse = entityManager.createNativeQuery(sql).setParameter("limit", 1).getSingleResult()
        println "쿼리 전처리 = ${queryResponse}"
        println "queryResponse[1] = ${queryResponse[1]}"

        def cloudUri = UriComponentsBuilder
                .fromPath("/v1")
                .queryParam("CMD", API)
                .queryParam("PARAM", PARAM)
                .build(false)
                .toUriString()


        when:
        def response = WebClient.builder().baseUrl(cloudUrl).build().get()
                .uri(cloudUri)
                .accept(MediaType.TEXT_PLAIN)
                .retrieve()
                .toEntity(String.class)
                .block()


        then:
        println "$API 전처리 = $response"


        where:
        NO   | API                 | PARAM
        "1"  | "getBuyInfo"        | "userId=kkk123"
        "2"  | "getSellInfo"       | "userId=kkk123"

    }


    def "#NO #DESC"() {

        given:
        def uri = UriComponentsBuilder
                .fromPath(apiPath)
                .queryParam("userId", userId)
                .queryParam("email", email)
                .toUriString()

        println "url = $baseUrl$uri"


        when:
        def response = webClient.get()
                .uri(uri)
                .accept(MediaType.APPLICATION_JSON)
                .retrieve()
                .toEntity(Object.class)
                .block()

        println "response = $response"

        then:
        verifyAll(response) {
            def body = response.getBody()
            "(1) 정상 응답 여부 확인"
            assert body["code"] == "0"
            "(2) message 빈값 확인"
            assert body["message"] == "success"
        }


        where:
        NO   | DESC              | userId     | email
        "1"  | "사용자 정보 제공"   | "aaa111"   | "aaa111@naver.com"
        "2"  | "사용자 정보 제공"   | "bbb222"   | "bbb222@gmail.com"
        "3"  | "사용자 정보 제공"   | "ccc333"   | "ccc333@naver.com"

    }


    @Unroll("후처리")
    def "post process"() {

        given:
        def sql = """
        SELECT * 
        FROM schema.table 
        LIMIT :limit
    """

        when:
        def queryResponse = entityManager.createNativeQuery(sql).setParameter("limit", 1).getResultList()

        then:
        println "쿼리 후처리 = ${queryResponse}"

    }

	// 적용된 어노테이션 출력하여 확인
    def "annotation list"() {

        given:
        def clazz = this.class

        when:
        def annotations = clazz.getAnnotations()

        then:
        annotations.toString().contains("SpockSpringBootTest") || annotations.toString().contains("SpockDataJpaTest")
        annotations.each { annotation ->
            println annotation
        }
    }

}

 

 

 

 

 

🎈 AST Transformation 풀코드

스크롤 주의!!!

더보기
import org.codehaus.groovy.ast.*
import org.codehaus.groovy.ast.expr.*
import org.codehaus.groovy.ast.stmt.BlockStatement
import org.codehaus.groovy.ast.stmt.ExpressionStatement
import org.codehaus.groovy.ast.stmt.ReturnStatement
import org.codehaus.groovy.ast.stmt.Statement
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.transform.ASTTransformation
import org.codehaus.groovy.transform.GroovyASTTransformation

import java.lang.reflect.Modifier

@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class ConditionalClassAnnotationASTTransformation implements ASTTransformation {

    def static ast = ""

    @Override
    void visit(ASTNode[] astNodes, SourceUnit sourceUnit) {
    
    
            // ASTNode[] print
        def nodeTypePrint = ".\n" +
                "astNodes size : ${astNodes.size()}\n" +
                "astNodes[0] : ${astNodes[0].class.simpleName} / ${astNodes[0].text}\n" +
                "astNodes[1] : ${astNodes[1].class.simpleName} / ${astNodes[1].text}\n"
        sourceUnit.getErrorCollector().addWarning(new WarningMessage(WarningMessage.NONE, nodeTypePrint, null, sourceUnit))


        if (astNodes.length != 2 || !(astNodes[0] instanceof AnnotationNode) || !(astNodes[1] instanceof ClassNode)) {
            throw new IllegalArgumentException("Not AnnotationNode and ClassNode")
        }


        def annotationNode = astNodes[0] as AnnotationNode
        def classNode = astNodes[1] as ClassNode


        sourceUnit.AST.classes.each { node ->
            ast += "AST 구조:\n"
            printClassNode(node, 0)
        }


        def cloudTest = annotationNode.getMember("cloudTest").getText()
        def query = annotationNode.getMember("query").getText()
        if (cloudTest == "false") {
            classNode.addAnnotation(new AnnotationNode(ClassHelper.make(SpockSpringBootTest.class)))
            printAddedAnnotation(classNode, "${ast}\n\n@SpockSpringBootTest added. Local Environment")
        } else if (cloudTest == "true" && query == "true") {
            classNode.addAnnotation(new AnnotationNode(ClassHelper.make(SpockDataJpaTest.class)))
            printAddedAnnotation(classNode, "${ast}\n\n@SpockDataJpaTest added. Dev Environment with Query")
        } else if (cloudTest == "true" && query == "false") {
            printAddedAnnotation(classNode, "${ast}\n\nAnnotation not added. Dev Environment with No Query")
            // 아무 어노테이션도 필요 없음
        }

    }


    // 클래스에 메서드를 동적으로 추가하는 메서드
    private static void printAddedAnnotation(ClassNode classNode, String message) {
        // 메서드의 이름과 반환형을 설정
        def printMethod = new MethodNode(
                'printAddedAnnotation', // 메서드 이름
                Modifier.PUBLIC, // 접근 제어자 (public)
                ClassHelper.VOID_TYPE, // 리턴 타입
                [] as Parameter[], // 매개변수 없음
                [] as ClassNode[], // 예외 없음
                new BlockStatement([new ExpressionStatement(
                        new MethodCallExpression(
                                new VariableExpression('this'),
                                'println',
                                new ArgumentListExpression(
                                        new ConstantExpression("\n"+message+"\n")
                                )
                        )
                )], // 메서드 본문
                new VariableScope()) // 메서드 구현
        )

        // 클래스에 메서드 추가
        classNode.addMethod(printMethod)
    }



    // 트리 구조 출력 함수
    void printClassNode(ClassNode classNode, int level) {
        printIndented(level, "클래스: ${classNode.name} (extends: ${classNode.superClass?.name})")

        // 어노테이션 출력
        classNode.annotations.each { annotation ->
            printIndented(level + 1, "어노테이션: ${annotation.classNode.name}")
        }

        // 필드 출력
        classNode.fields.each { field ->
            printIndented(level + 1, "필드: ${field.name} (${field.type.name})")
            field.annotations.each { annotation ->
                printIndented(level + 2, "어노테이션: ${annotation.classNode.name}")
            }
        }

        // 생성자 출력
        classNode.declaredConstructors.each { constructor ->
            printIndented(level + 1, "생성자: (${constructor.parameters*.name.join(', ')})")
            constructor.annotations.each { annotation ->
                printIndented(level + 2, "어노테이션: ${annotation.classNode.name}")
            }
        }

        // 메서드 출력
        classNode.methods.each { method ->
            printIndented(level + 1, "메서드: ${method.name} (${method.parameters*.name.join(', ')}) -> ${method.returnType.name}")
            method.annotations.each { annotation ->
                printIndented(level + 2, "어노테이션: ${annotation.classNode.name}")
            }

            // 메서드 내부의 코드 블록(Statement) 탐색
            if (method.code instanceof BlockStatement) {
                printIndented(level + 2, "코드 블록:")
                BlockStatement block = (BlockStatement) method.code
                block.statements.each { stmt ->
                    printStatement(stmt, level + 3)
                }
            }
        }

        // 내부 클래스 출력
        classNode.innerClasses.each { innerClass ->
            printClassNode(innerClass, level + 1)
        }
    }

    // 들여쓰기 출력 함수
    static void printIndented(int level, String message) {
        ast += "${'   ' * level}$message\n"
    }

    // Statement(문장) 탐색 함수
    static void printStatement(Statement stmt, int level) {
        if (stmt instanceof ExpressionStatement) {
            Expression expr = stmt.expression
            if (expr instanceof MethodCallExpression) {
                printIndented(level, "메서드 호출: ${expr.methodAsString}()")
            } else if (expr instanceof BinaryExpression) {
                printIndented(level, "할당: ${expr.leftExpression.text} = ${expr.rightExpression.text}")
            }
        } else if (stmt instanceof ReturnStatement) {
            printIndented(level, "return: ${stmt.expression.text}")
        } else {
            printIndented(level, "예상 외 타입: ${stmt.getClass().name}")
        }
    }

}

 

 

 

 

참고

 

https://docs.groovy-lang.org/docs/groovy-2.4.9/html/gapi/org/codehaus/groovy/transform/ASTTransformation.html


https://docs.groovy-lang.org/docs/latest/html/documentation/core-metaprogramming.html

 

 

728x90
반응형

댓글