본문 바로가기
이슈와해결

자동화 경험 공유 - 코드 생성 도구 JavaPoet을 이용한 클래스 복사, 커스터마이징, 컴파일, 로딩

by Renechoi 2023. 12. 13.

0. 개요

이 글에서는 자동 코드 생성을 위해 사용해 본 JavaPoet 라이브러리 활용 경험을 공유한다. 이 라이브러리를 사용하여 공통 모델 클래스를 특정 도메인에서 복제하고 자동화 한 과정을 설명한다. 자바 포엣 라이브러리의 기본적인 소개와 간단한 사용 방법에 대해서도 다룬다.

 

  • 예시 코드는 실제 코드가 아닌 컨셉 코드로 대체하였습니다.

 

1. 목차

  1. 개요
  2. 목차
  3. 배경
  4. 복사 자동화하기
    1. JavaPoet 소개
    2. 사용방법 및 예제
    3. 코드 구현
    4. 결과
  5. 컴파일 자동화하기
    1. 컴파일과 로딩 과정
    2. 효과 및 고려사항
  6. 결론

2. 배경

사내에서 개발 프로세스 개선을 위한 자동화 툴을 개발하면서 두가지 추가 요구사항이 발생했다. 첫째, 기존에 A 도메인에서 사용되던 프로세스 검증 자동화 Tool이 B 도메인에서도 활용될 수 있도록 확장해야 할 것. 문제는 공통 모델에 커스터마이징이 필요했다는 점이다. 이 확장 작업은 기존 도메인에서 사용되던 로그와 새로운 도메인에서 사용되는 로그 간의 호환성을 유지하면서 진행되어야 했다.

 

둘째, B 도메인에서 사용되는 페이로드가 A 도메인에 비해 약 8-10배 많았고, 로그량 또한 훨씬 많은 상황에서 효율적으로 개선을 해야한다는 것이다. 많은 차이로 수작업의 부담과 변경에 따른 유지보수성을 고려해 페이로드 작성은 자동화로서 구현되어야 했다.

 

이 두 가지 요구사항을 충족시키기 위해 공통 모듈에서 사용되는 클래스를 프로젝트에 맞도록 커스텀화하고 복사하는 일련의 과정을 자동화하는 솔루션을 생각해보았다.

 

3. 복사 자동화

3.1. JavaPoet 소개

JavaPoet은 Java 코드를 프로그래밍 방식으로 생성하는 라이브러리이다. 주로 코드 생성 라이브러리로 사용되며, 어노테이션 처리, 소스 코드 생성 및 컴파일 등에 활용된다.

 

3.2. 사용방법 및 예제

1. TypeSpec, MethodSpec, FieldSpec 등을 활용한 구조 생성

JavaPoet은 클래스, 메서드, 필드 등의 구조를 정의하는 데 사용되는 Spec 객체를 제공한다. 이 객체들을 사용하여 원하는 코드 구조를 생성할 수 있다.

 

2. 코드 작성

생성된 Spec 객체를 조합하여 전체 클래스 또는 파일을 구성한다.

 

3. 파일 출력

구성된 객체를 파일로 출력하거나 문자열로 변환하여 직접 컴파일과 연계할 수 있다.

 

MethodSpec mainMethod = MethodSpec.methodBuilder("main")
        .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
        .returns(void.class)
        .addParameter(String[].class, "args")
        .addStatement("$T.out.println($S)", System.class, "Hello, JavaPoet!")
        .build();

TypeSpec helloWorld = TypeSpec.classBuilder("HelloWorld")
        .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
        .addMethod(mainMethod)
        .build();

JavaFile javaFile = JavaFile.builder("com.example", helloWorld)
        .build();

javaFile.writeTo(System.out);

 

package com.example;

public final class HelloWorld {
  public static void main(String[] args) {
    System.out.println("Hello, JavaPoet!");
  }
}

 

3.3. 코드 구현

이제 JavaPoet을 이용하여 코드 복사 한 상세한 과정을 살펴보자. 원본 클래스의 구조를 분석하고, 커스터마이징 옵션을 반영하여 새로운 클래스를 생성한다. 이 과정은 다음 단계로 이루어진다.

 

1. 의존성 추가

dependencies {
    // ...
    annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"
    implementation 'com.squareup:javapoet:1.13.0'
    // ... 
}

2. 설정 파일 구성

AdapterProperties는 주소와 같은 공통 속성을 정의하는 enum이다. 소스 패키지와 타겟 패키지의 주소를 저장하고 관리하는 역할을 담당한다.

 


@RequiredArgsConstructor
@Getter
public enum AdapterProperties {
    SOURCE_PACKAGE("kr.co....model"),
    GENERAL_TARGET_PACKAGE("ko.co....domain.logaggregate.payload.domainB.etc"),

        // ...
 SITEHUB_PAYLOADS_PATH("src/main/java/ko/co//.../logfile/domain/logaggregate/payload/domainB");

    private final String address;
}

 

3. 메인 로직 담당 클래스 (서비스) 구현

 

핵심 로직은 ClassAdapterService 클래스에서 구현된다. 이 클래스는 클래스를 커스터마이징하여 복사하고 컴파일하며 로드하는 과정을 관리한다. 어플리케이션 초기 시작시에 구동되도록 ApplicationRunnerimplements한다.

 


@Service
@RequiredArgsConstructor
@Slf4j
public class ClassAdapterService implements ApplicationRunner {

    private final List<ClassAdapter> adapters;

    @Override
    public void run(ApplicationArguments args) {
        adaptClasses();
        compileAndLoadClasses(new File(DOMAINB_PATH.getAddress()));
    }

 

Reflections 라이브러리를 사용하여 원본 클래스를 탐색하고 분석한다. 복사할 대상 클래스와 필드, 메서드 등의 정보를 추출하는 작업이다. 이후 대상 클래스들에 대해 클래스별로 각각의 Adapters에게 위임한다.

 

    public void adaptClasses() {
        Reflections reflections = new Reflections(new ConfigurationBuilder()
                .setUrls(ClasspathHelper.forPackage(AdapterProperties.SOURCE_PACKAGE.getAddress()))
                .setScanners(new SubTypesScanner(false)));

        Set<Class<?>> classes = reflections.getSubTypesOf(Object.class);
        Set<Class<? extends Enum>> enumClasses = reflections.getSubTypesOf(Enum.class);
        classes.addAll(enumClasses);

        cacheResponseClass(classes);

        classes.forEach(sourceClass -> adapters.stream()
                    .filter(adapter -> adapter.support(sourceClass))
                    .forEach(adapter -> adapter.adapt(sourceClass)));
    }

 

4. 복사 기능을 담당하는 Adapter 구현 (전략 패턴)

 

각각의 클래스 복사에 있어서 전략 패턴을 사용하여 역할을 분배한다. ClassAdapter 인터페이스를 통해 각각의 변환 전략을 정의하고, BasicEnumAdapter, PayloadClassAdapter와 같은 구체적인 클래스에서 이를 구현한다.

 

public interface ClassAdapter {
    boolean support(Class<?> clazz);
    void adapt(Class<?> sourceClass);
}

 

 


@Component
@Slf4j
public class BasicEnumAdapter implements ClassAdapter {

    @Override
    public boolean support(Class<?> clazz) {
        return clazz.isEnum() && !Arrays.asList(clazz.getInterfaces()).contains(ActionType.class);
    }

    @Override
    public void adapt(Class<?> sourceClass) {
        if (sourceClass.getEnumConstants().length == 0) {
            log.info("enum empty {}", sourceClass);
            return;
        }

        TypeSpec.Builder targetEnum = buildEnumClass(sourceClass);
        writeJavaFile(targetEnum, sourceClass.getSimpleName());
    }

    private TypeSpec.Builder buildEnumClass(Class<?> sourceClass) {
        TypeSpec.Builder targetEnum = TypeSpec.enumBuilder(sourceClass.getSimpleName())
                .addModifiers(Modifier.PUBLIC)
                .addAnnotation(RequiredArgsConstructor.class)
                .addAnnotation(Getter.class);

        addEnumConstants(targetEnum, sourceClass);
        addEnumFields(targetEnum, sourceClass);

        return targetEnum;
    }


    private void addEnumConstants(TypeSpec.Builder targetEnum, Class<?> sourceClass) {
        for (Object constant : sourceClass.getEnumConstants()) {
            String constantValues = buildConstantValues(sourceClass, constant);
            TypeSpec constantSpec = TypeSpec.anonymousClassBuilder(constantValues).build();
            targetEnum.addEnumConstant(constant.toString(), constantSpec);
        }
    }
    // ... 
    }

 

 


@Component
public class PayloadClassAdapter implements ClassAdapter {

    // ... 

    @Override
    public void adapt(Class<?> sourceClass) {
        String className = sourceClass.getSimpleName().replace("V1", "");
        String domainName = getDomainName(sourceClass);
        boolean isRequest = className.contains("Request");

        if (isRequest) {
            TypeSpec.Builder mainClass = buildMainClass(sourceClass, domainName.replace(String.valueOf(domainName.charAt(0)), String.valueOf(domainName.charAt(0)).toUpperCase())+className.replace("Request", ""));
            Class<?> responseClass = findResponseClass(className);
            if (responseClass != null) {
                TypeSpec responseSpec = buildResponseClass(responseClass).build();
                mainClass.addType(responseSpec);
            }
            writeJavaFile(domainName, mainClass);
        }
    }
    // ... 
}

 

복사 과정에서 다음과 같은 이식 규칙에 따라 커스터마이징 옵션을 설정하였다.

 

- `enum`은 `일반(General) enum`과 `액션(ActionType) enum`으로 구분. 현재 구현에서는 필요성 여부에 따라 `액션 enum`은 제외하고 `일반 enum`만을 이식.
-   인터페이스 및 추상 클래스는 제외.
-   `Reqeuest`와 `Response` 이름이 붙은 `Payload`는 한 쌍으로 인식. 이때 `Request` 페이로드가 메인 클래스이며, 해당 클래스 내부의 `static class`로서 `Response`를 작성함.
-   메인 클래스 이름에서 `Reqeuest`는 제외.
-   메인 클래스 이름 앞에는 도메인B에서 작성한 세부 도메인 이름이 붙음(`e.g. 'Gate-'`).
-   `@Notnull` 애노테이션은 본 프로젝트에서 `@Required`로 변경하여 이식함.
-   기타 다른 애노테이션은 제외.
-   클래스 애노테이션으로 `@Component, @Builder, @NoArgsConstructor, @Getter, @ToString, @AllArgsConstructor`를 추가하며, 필드가 없는 경우 `@AllArgsConstructor`은 제외함.
-   `@builder` 애노테이션 때문에 자동으로 `'-Builder' 클래스`가 디렉토리에 생성(해당 애노테이션을 쓰려면 불가피한 것으로 결론).
-   디렉토리 구조는 `sitehub` 하위 `constant, etc, payloads`로 생성. `enum`은 `constant`, `Request 및 Response`는 `payloads`, 나머지는 `etc`에 위치함. `etc` 내부의 클래스들은 도메인 B 페이로드에서는 클래스 필드에 해당하는 클래스들이 대다수임.  

 

3.4. 결과

source

@Builder
@EqualsAndHashCode(callSuper = true)
@Data
public class SamplePayloadV1 extends DomainBPayload {

    @NotNull
    private String something1;
    private Boolean something2;
    private Boolean something3;
    @NotNull
    private SomeInformationV1 someInformation;
}

@Builder
@EqualsAndHashCode(callSuper = true)
@Data
public class SampleResponsePayloadV1 extends DomainBPayload {

    @NotNull
    private String something1;
    @NotNull
    private boolean something2;
    @NotNull
    private boolean something3;
    // ...
}

target


@Component
@Builder
@NoArgsConstructor
@Getter
@ToString
@AllArgsConstructor
public class CopiedPayload extends Payload {
  @Required
  private String something1;

  private Boolean something2;

  private Boolean something3;

  @Required
  private SampleInformationV1 sampleInformation;

  @Component
  @Builder
  @NoArgsConstructor
  @Getter
  @ToString
  @AllArgsConstructor
  public static class Response extends Payload {
    @Required
    private String something1;

    @Required
    private boolean something2;

    @Required
    private boolean something3;

    // ...

  }
}

 

4. 컴파일 자동화

복사와 커스터마이징 작업이 완료된 클래스는 컴파일 및 로딩 과정을 거쳐야 실제로 사용될 수 있다. 처음에는 이 작업을 수동으로 수행하려 했으나, 초기에 애플리케이션을 시작하고 종료한 다음 두 번째 실행에서야 정상적으로 사용할 수 있게 되는 문제가 발생했다. 이러한 번거로움을 제거하고 처음 실행부터 바로 사용할 수 있게 하기 위해 컴파일 과정도 자동화하기로 결정했다.

 

컴파일 자동화는 서비스 클래스에서 담당하며, 클래스 복사가 완료된 후에 실행된다.

    @Override
    public void run(ApplicationArguments args) {
        adaptClasses();
        compileAndLoadClasses(new File(DOMAIN_PATH.getAddress()));
    }

 

4.1. 컴파일과 로딩 과정

컴파일 자동화는 다음의 단계로 이루어진다

  1. 초기 주소를 받아서 디렉토리 내부 탐색: 재귀적 방법을 사용하여 디렉토리 내부를 탐색하며 컴파일할 클래스를 찾아낸다.
  2. 컴파일: JavaCompiler를 통해 해당 클래스 파일을 컴파일한다.
  3. 클래스 로딩: ClassLoader를 통해 컴파일된 클래스를 로드하여 런타임 시점에 업데이트된 클래스를 사용할 수 있게 한다.

 

    private void compileAndLoadClasses(File directory) {
        File[] files = directory.listFiles();
        if (files == null) {
            return;
        }

        for (File file : files) {
            if (file.isDirectory()) {
                compileAndLoadClasses(file);
            } else if (file.isFile() && file.getName().endsWith(".java")) {
                compileJavaFile(file.getAbsolutePath());
                loadCompiledClass(file.getAbsolutePath().replace(".java", "").replace(File.separator, "."));
            }
        }
    }

    private void compileJavaFile(String javaFilePath) {
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
        Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjects(javaFilePath);

        List<String> optionList = Arrays.asList("-d", "out/production/classes");
        JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, null, optionList, null, compilationUnits);

        if (!task.call()) {
            log.error("Compilation failed for: " + javaFilePath);
        }

        try {
            fileManager.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void loadCompiledClass(String fullPath) {
        try {
            String className = fullPath.substring(fullPath.indexOf(".ko")+1);

            URL[] urls = new URL[]{new File("out/production/classes").toURI().toURL()};
            URLClassLoader classLoader = new URLClassLoader(urls);
            Class<?> clazz = classLoader.loadClass(className);
            log.info("newly created classes compile and load: {}", clazz.getSimpleName());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

 

클래스를 동적으로 컴파일하고 로드하는 작업을 수행하면, 해당 변경사항을 Spring Boot DevTools가 감지해 자동으로 애플리케이션을 다시 시작한다. 결과적으로 무한 재시작 루프에 빠져버린다. 설정 파일을 다음과 같이 변경해 이 문제를 해결할 수 있다.

 

spring:
  devtools:
    restart:
      exclude: ko/co/...logfile/domain/logaggregate/payload/domainname/**

 

4.2. 효과 및 고려사항

 

이러한 자동화 과정을 통해 개발 및 테스트 과정에서의 편의성이 크게 향상되었다. 다만 이 과정에서 초기 시작 시간 증가라는 트레이드 오프가 있다. 클래스가 복사되면 해당 클래스는 프로젝트 디렉토리와 빌드 디렉토리에 모두 존재하므로 실제적으로는 1회만 필요하지만, 현재로서는 매번 프로그램을 껐다가 켤 때마다 이 과정이 진행되며, 약 10초 가량의 추가 소요 시간을 발생시킨다. 실제 운영 환경에서 필요에 따라 조절할 수 있도록 설정 파일을 통해 자동 컴파일 기능을 on/off 하는 방식을 추가했다.

 

5. 결론

도메인 간의 호환성을 위해 공통 모듈의 페이로드를 이식하는 작업을 위와 같이 수행했다. 클래스 탐색을 위해 Reflections 라이브러리를 사용했고 코드 복사 자동화를 위해 JavaPoet을 사용했다. 컴파일 및 로딩 자동화를 위해서는 JavaCompilerURLClassLoader를 사용했다.

 


 

참고 자료
https://www.baeldung.com/java-poet
https://github.com/ronmamo/reflections

반응형