우아한테크코스 8기 precourse

JSON DTO Converter (3) - Generator & 출력 분석

hwangsoojin 2025. 11. 24. 18:00

Template · ClassGenerator · CodeFormatter · FileWriter로 이어지는 "코드 생성 파이프라인'

이 글에서는 JSON DTO Converter의 마지막 단계인 generator 계층을 집중적으로 다룬다.

 

앞선 글(Overview / CLI / JSON 분석)에서 우리는

  • CLI로부터 설정을 받아
  • JSON을 검사하고(JsonValidator)
  • JsonNode → SchemaNode → 타입 추론(TypeInferencer) → ModelGraph까지

왔다.

 

이제 남은 일은 단 하나다!

"설계도(ModelGraph)를 실제 Java 코드(.java 파일)로 바꾸는 것"

 

이 역할을 담당하는 것이 바로 generator 패키지다.


0. generator 패키지의 구성

 

 org.example.generator  에는 다음 네 개의 핵심 클래스가 있다.

  •  Template  : 문자열 템플릿 엔진 (  ${name}  치환 )
  •  ClassGenerator  : ClassSpec → Java 소스코드 문자열 생성
  •  CodeFormatter  : 줄바꿈/공백/빈 줄 정리
  •  FileWriter  : 최종 문자열을  .java  파일로 저장

이 네 클래스는 서로 결합되어 다음과 같은 파이프라인을 만든다.

[ClassSpec]
   ↓ ClassGenerator
[raw Java source String]
   ↓ CodeFormatter
[formatted Java source String]
   ↓ FileWriter
[MyDto.java 파일]
 

1. Template — 최소한의 템플릿 엔진

1-1. 왜 직접 Template을 만들었나?

외부 템플릿 엔진(예: Freemarker, Velocity 등)을 쓰는 것도 방법이지만,

  • 프리코스에서는 외부 라이브러리 의존도를 낮추는 것이 좋다고 판단했고,
  • "템플릿 엔진이 내부에서 어떻게 동작하는지" 이해하고 싶었고,
  • 필요한 기능은  ${var}  치환 정도로 충분했다.

그래서 아주 작은 범위의 템플릿 기능만 직접 구현했다.


1-2. Template의 기본 구조

Template은 내부에  pattern  문자열을 하나 들고 있는 불변 객체다.

public class Template {

    private static final char DOLLAR = '$';
    private static final char OPEN_BRACE = '{';
    private static final char CLOSE_BRACE = '}';

    private final String pattern;

    public Template(String pattern) {
        this.pattern = Objects.requireNonNull(pattern, "pattern must not be null");
    }

    public String render(Map<String, String> variables) {
        StringBuilder result = new StringBuilder();
        // pattern을 순회하며 ${name}을 찾아 variables.get("name")으로 치환
        return result.toString();
    }
}
 

 

여기서 플레이스홀더는 다음 형태만 지원한다.

${className}
${package}
${fields}
${methods}
...
 

이 정도면 코드 생성용으로는 충분하다.


1-3. 템플릿 사용 예시

예를 들어 클래스 전체 구조 템플릿은 이런 식으로 설계할 수 있다.

String pattern = """
package ${package};

${imports}

public class ${className} {

${fields}

${methods}
}
""";

Template t = new Template(pattern);
String source = t.render(Map.of(
    "package", "com.example",
    "imports", "import java.util.List;",
    "className", "User",
    "fields", "    private String name;\n    private int age;",
    "methods", "    public String getName() { return name; }\n    // ..."
));
 

이렇게 하면 Template은 내부적으로 문자열을 순회하며  ${...}  패턴을 찾고
 variables  에서 값을 꺼내 치환한 결과를 돌려준다.

 

핵심 포인트:
Template은  "문자열 조립"  이라는 책임만 가진다.
이 템플릿에 어떤 데이터를 넣을지는 ClassGenerator의 책임이다.


2. ClassGenerator — DTO 한 개의 소스 코드를 만드는 역할

2-1. ClassSpec / FieldSpec - "코드 생성 계획" 객체

generator 계층은 ModelGraph로부터 이미 클래스 설계도를 전달받는다.
이 설계도는 코드 생성에 최적화된 DTO인  ClassSpec  형태다.

개념적으로:

public final class ClassSpec {

    public static final class FieldSpec {
        private final String type;      // "String", "int", "List<Location>" ...
        private final String name;      // "name", "age" ...
        private final boolean optional; // 필요 시 nullable 표현 등에 활용 가능
    }

    private final String packageName;   // com.example.dto
    private final String className;     // WeatherApiResponse
    private final List<FieldSpec> fields;
    private final List<String> importTypes; // java.util.List 등
    private final boolean innerClass;   // 루트 내부에 중첩 클래스인지 여부
    // ...
}
 

이 시점의 ClassSpec은 이미:

  • 필드 타입 추론(TypeInferencer까지 끝난 상태)
  • 이름(NameConverter까지 반영된 상태)
  • optional 여부 판단 완료
  • inner-classes 옵션 반영 여부 결정

즉, ClassGenerator는 비즈니스 로직을 고민할 필요 없이,
"기계적으로 '코드 텍스트'를 찍어내기만 하면 되는 상태"가 된다.


2-2. ClassGenerator의 책임

ClassGenerator는 다음 책임을 갖는다.

  1. ClassSpec으로부터  import  목록 생성
  2.  fields  리스트를 바탕으로 필드 선언부 문자열 생성
  3. 생성자, getter 같은 부가 메서드를 필요에 맞게 생성
  4. Template에 채워 넣을 문자열을 준비
  5. Template.render를 호출해 최종 소스 코드 문자열 생성

의사코드로 표현하면:

String generate(ClassSpec spec) {
    String packageLine = "package " + spec.packageName() + ";";
    String imports = buildImports(spec.importTypes());
    String fields = buildFieldLines(spec.fields());
    String methods = buildMethods(spec.fields());

    Template t = new Template(CLASS_TEMPLATE);
    return t.render(Map.of(
        "package", packageLine,
        "imports", imports,
        "className", spec.className(),
        "fields", fields,
        "methods", methods
    ));
}
 

➕ 왜 Template과 ClassGenerator를 분리했나?

  • Template은 "문자열 틀"에 집중
  • ClassGenerator는 "틀에 넣을 데이터 계산"에 집중

만약 템플릿이나 출력 스타일을 바꾸고 싶다면 Template 쪽만 손대면 되고,
DTO의 스펙 자체가 바뀌면 ClassGenerator 쪽만 손대면 된다.

 

즉, 관심사의 분리(Separation of Concerns)를 지키기 위함이다.


2-3. 예시 — WeatherApiResponse를 어떻게 생성하나?

JSON 분석 계층에서 아래 설계도(ClassSpec)가 넘어왔다고 해보자.

ClassSpec: WeatherApiResponse
- package: com.team606.mrdinner.entity
- fields:
    - Location location
    - Current current
- innerClasses: true
- nested ClassSpec:
    - Location (fields: String name, String country)
    - Current (fields: double tempC, int humidity, boolean isDay)
 
 

ClassGenerator의 출력은 대략 다음과 같이 된다:

package com.team606.mrdinner.entity;

public class WeatherApiResponse {

    private Location location;
    private Current current;

    public Location getLocation() {
        return location;
    }

    public Current getCurrent() {
        return current;
    }

    public static class Location {
        private String name;
        private String country;

        public String getName() { return name; }
        public String getCountry() { return country; }
    }

    public static class Current {
        private double tempC;
        private int humidity;
        private boolean isDay;

        public double getTempC() { return tempC; }
        public int getHumidity() { return humidity; }
        public boolean isDay() { return isDay; }
    }
}
 

여기까지가 "raw Java 코드 문자열"이다.
이제 이 문자열을 조금 더 정리해서 보기 좋게 만들 차례다.


3. CodeFormatter - 줄바꿈(LF), 공백, 빈 줄 정리

ClassGenerator까지 지나면 기능적으로는 문제가 없는 Java 코드가 나온다.
하지만 코드 스타일 측면에서는 다음과 같은 문제가 있을 수 있다.

  • Windows/Unix 환경이 섞여 CRLF / LF가 뒤섞임
  • 일부 줄 끝에 불필요한 공백 존재
  • 템플릿/조립 과정에서 빈 줄이 두 세 줄씩 생김

이 문제를 해결하기 위해 별도의 CodeFormatter 클래스를 만들었다.


3-1. CodeFormatter가 수행하는 규칙

1) 개행 문자 통일 — 전부  \n  (LF)로

테스트 코드 예시:

@Test
void 개행_문자를_전부_LF로_통일한다() {
    String src = "line1\r\nline2\rline3\n";
    String result = formatter.format(src);

    assertThat(result).contains("line1\nline2\nline3\n");
    assertThat(result).doesNotContain("\r");
}
 

어떤 환경에서 생성하든 결과물은 항상 LF 기반 코드만 남도록.


2) 줄 끝 공백 제거

테스트 예시:

@Test
void 줄_끝_공백을_제거한다() {
    String src = "int x = 1;   \nint y = 2;\t\n";
    String result = formatter.format(src);

    assertThat(result).isEqualTo("int x = 1;\nint y = 2;\n");
}
 

코드 리뷰 시 "줄 끝에 공백"이 잡히는 것만큼 거슬리는 것도 없으므로
아예 생성 단계에서 제거해버린다.


3) 연속된 빈 줄 하나로 축소

@Test
void 연속된_빈줄을_하나로_축소한다() {
    String src = "class A {\n\n\n    int x;\n}\n";
    // ...
}
 

 format  호출 후 결과:

class A {

    int x;
}
 

처럼 중간에 불필요하게 2줄 이상 비어 있는 구간을 하나로 줄인다.


3-2. 왜 CodeFormatter를 별도 클래스로 뺐을까?

SRP 관점

  • ClassGenerator는 "논리적 코드 내용"만 고민해야 한다.
    • 어떤 필드를 가질지
    • 어떤 메서드를 생성할지
    • 어떤 import가 필요한지 등
  • CodeFormatter는 "코드의 외형/스타일"만 고민해야 한다.
    • 줄바꿈 / 공백 / 빈 줄

두 가지를 섞어버리면:

  • ClassGenerator가 지나치게 비대해지고
  • 포맷팅 룰을 바꿀 때마다 코드 생성 로직까지 건드려야 한다.

따라서 유지보수성과 변경 용이성을 위해 완전히 분리했다.


4. FileWriter — 최종 결과를 디스크에 내리는 단계

마지막으로,  FileWriter  가  String  으로 된 소스를  .java  파일로 저장한다.


4-1. FileWriter의 책임

public class FileWriter {

    public void write(Path outDir, String className, String content) {
        Path file = outDir.resolve(className + ".java");
        try {
            Files.writeString(file, content, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new UserException(
                "[ERROR] Java 파일을 저장하는 중 오류가 발생했습니다: " + file, e);
        }
    }
}
 

책임은 딱 두 가지다.

  1.  "className.java"  파일 경로 조합
  2. UTF-8로 파일 쓰기 + 예외를 UserException으로 래핑

4-2. 왜 UserException으로 래핑했나?

파일 저장 실패는 대부분 사용자가 조정할 수 있는 문제다.

  • 잘못된 out 경로 지정
  • 권한 없는 디렉터리
  • 디스크 가득 참

이런 문제에 대해 스택 트레이스를 그대로 보여주면,
사용자는 혼란만 느낄 수 있다.

 

그래서:

  • 내부적으로는 IOException을 받되
  • 사용자에게는 깔끔한 메시지로 전달
    [ERROR] Java 파일을 저장하는 중 오류가 발생했습니다: ...  )

하는 구조를 택했다.


5. 전체 Generator 파이프라인 다시 한 번 정리

Generator 계층은 한 마디로 말하면:

"ModelGraph가 만든 ClassSpec(설계도)를
Java 소스코드 문자열로 만들고,
스타일을 정리한 뒤, 실제 파일로 저장하는 역할"

 

을 수행한다.

 

흐름을 코드로 표현하면 대략 이런 느낌이다.

for (ClassSpec spec : modelGraph.getAllClasses()) {
    String rawSource = classGenerator.generate(spec);
    String formatted = codeFormatter.format(rawSource);
    fileWriter.write(outDir, spec.className(), formatted);
}
 
  1.  ClassGenerator  → 논리적으로 올바른 Java 코드 생성
  2.  CodeFormatter  → 보기 좋은 형태로 정리
  3.  FileWriter  → 실제  .java  파일로 저장

이 세 부분이 역할을 나누어 맡는다.


6. Generator 계층 설계가 의미하는 것

이 구조는 단순히 DTO를 찍어내기 위한 것이 아니다.
조금 관점을 바꿔 보면:

  • Template : 출력 형식에 대한 추상
  • ClassGenerator : 도메인 모델(ClassSpec)을 코드로 매핑하는 컴파일러 프런트엔드
  • CodeFormatter : pretty printer
  • FileWriter : 백엔드(코드 → 파일)

즉, 아주 작은 스케일이지만 "코드 생성기(Code generator)"를 설계해 본 셈이다.


7. 마무리 — Generator가 만들어낸 최종 산출물

최종적으로 사용자는:

  • CLI 옵션만 적절히 넘겨주면
  • JSON 구조를 이해하지 않고도
  • Java 소스코드(DTO)를 바로 받아서 프로젝트에 포함시킬 수 있다.

Generator 계층 덕분에
이 도구는 “콘솔 로그만 찍고 끝나는 프로그램”이 아니라,

실제 프로젝트에서 바로 가져다 쓸 수 있는   .java  파일을 생산하는
작은 빌드 도구

 

의 역할까지 수행할 수 있게 되었다.

 

마지막으로, 분석된 스키마를 실제 Java 클래스로 변환하는

ClassGenerator·Template·CodeFormatter 구현 과정을 소개한다

👉 JSON DTO Converter (4) - Exception 분석