0. 개요
이 글에서는 자동 코드 생성을 위해 사용해 본 JavaPoet
라이브러리 활용 경험을 공유한다. 이 라이브러리를 사용하여 공통 모델 클래스를 특정 도메인에서 복제하고 자동화 한 과정을 설명한다. 자바 포엣 라이브러리의 기본적인 소개와 간단한 사용 방법에 대해서도 다룬다.
- 예시 코드는 실제 코드가 아닌 컨셉 코드로 대체하였습니다.
1. 목차
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
클래스에서 구현된다. 이 클래스는 클래스를 커스터마이징하여 복사하고 컴파일하며 로드하는 과정을 관리한다. 어플리케이션 초기 시작시에 구동되도록 ApplicationRunner
를 implements
한다.
@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. 컴파일과 로딩 과정
컴파일 자동화는 다음의 단계로 이루어진다
- 초기 주소를 받아서 디렉토리 내부 탐색: 재귀적 방법을 사용하여 디렉토리 내부를 탐색하며 컴파일할 클래스를 찾아낸다.
- 컴파일:
JavaCompiler
를 통해 해당 클래스 파일을 컴파일한다. - 클래스 로딩:
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
을 사용했다. 컴파일 및 로딩 자동화를 위해서는 JavaCompiler
와 URLClassLoader
를 사용했다.
참고 자료
https://www.baeldung.com/java-poet
https://github.com/ronmamo/reflections
'이슈와해결' 카테고리의 다른 글
리팩토링 회고 - QueryDsl 검색 로직을 좀 더 클린하게 만들어보기 (0) | 2023.12.13 |
---|---|
마이크로서비스 아키텍처에서 하나의 도메인 서비스에 다른 도메인이 필요하다면 ? (0) | 2023.12.13 |
리팩토링 회고 - 검증이라는 관심사의 분리와 복잡성 해결을 위한 노력 (0) | 2023.12.12 |
리팩토링 회고 - 복잡한 코드, 중복 코드, 비효율적 코드 개선 경험 (1) | 2023.12.12 |
고도화 회고 - 유효성 검증 로직에서 최소한의 변화로 리턴 타입 변경하기 (0) | 2023.12.12 |