Java 레코드에는 전용 매퍼가 필요하다
출처: Dev.to
Java Records는 Java 16부터 안정화되었으며, 이제 LTS 베이스라인인 Java 21과 함께 DTO, 값 객체, 도메인 모델 등 어디서든 등장하고 있습니다. 설계상 불변이며, 간결하고 의미가 명확합니다.
하지만 아무도 언급하지 않는 격차가 있습니다: Java 생태계의 모든 객체 매퍼는 Records가 존재하기 전에 만들어졌습니다. 이들은 JavaBeans—즉, getter·setter와 인자 없는 생성자를 가진 가변 객체—를 기준으로 설계되었습니다. Records는 그런 것이 전혀 없습니다. 그래서 어떻게 될까요? 기존 라이브러리들은 사후에 부분적인 Record 지원을 억지로 끼워 넣으며, 그 결과가 눈에 띕니다.
나는 그 격차를 메우기 위해 Immuto를 만들었습니다.
Record의 정체성은 정규(cononical) 생성자에 있다
public record PersonDTO(Long id, String fullName, String email) {}
이 생성자가 PersonDTO를 만들 수 있는 유일한 방법입니다. setter는 존재하지 않으며, 직접 작성하지 않는 한 builder도 없습니다. 컴포넌트 접근자는 읽기 전용입니다.
기존 매퍼들은 이를 염두에 두고 설계되지 않았습니다. Records와 함께 사용하려면 다음 중 하나를 해야 합니다.
- 존재하지 않는 setter 호출을 생성하고(런타임에 실패)
- 우회책으로 가변 builder를 직접 작성하도록 요구하고
- 정규 생성자를 완전히 우회해 private 필드에 대한 리플렉션을 사용한다
이러한 문제는 런타임에야 비로소 드러납니다.
Immuto는 어노테이션 프로세서다
Immuto는 mvn compile 단계에서 Lombok이나 APT 기반 접근 방식과 동일하게 동작합니다. 정규 생성자를 직접 호출하는 순수 .java 소스 파일을 생성합니다. 리플렉션도, setter도, 런타임 서프라이즈도 없습니다.
@RecordMapper
public interface PersonMapper {
@Mapping(target = "fullName",
expression = "java(source.firstName() + \" \" + source.lastName())")
PersonDTO toDto(PersonEntity source);
@InheritInverseConfiguration(name = "toDto")
PersonEntity toEntity(PersonDTO source);
}
mvn compile 후, Immuto는 target/generated-sources에 PersonMapperImpl.java를 작성합니다. 손으로 직접 작성한 코드와 동일하게 보입니다.
@Generated("io.github.karunarathnad.immuto.processor.RecordMapperProcessor")
public final class PersonMapperImpl implements PersonMapper, ImmutoMapper {
@Override
public PersonDTO toDto(PersonEntity source) {
if (source == null) return null;
return new PersonDTO(
source.id(),
source.firstName() + " " + source.lastName(),
source.email()
);
}
}
정규 생성자— 언제나. 이것이 Immuto가 강제하는 계약입니다.
- 매핑되지 않은 컴포넌트 → 컴파일 단계에서 빌드 오류
- 등록된 변환기가 없는 타입 불일치 → 컴파일 단계에서 빌드 오류
- 인터페이스가 아닌 클래스에
@RecordMapper적용 → 컴파일 단계에서 빌드 오류
이것이 Records가 받아야 할 동작 방식입니다. Records는 명시적이고 안전하도록 설계되었으며, 매퍼도 그래야 합니다.
추가 기능
- 중첩 Record — 컴포넌트 이름을 매칭해 재귀적으로 매핑합니다. 비대칭 중첩은
@Mapping(expression=…)을 사용합니다. - 양방향 매핑 —
@InheritInverseConfiguration을 통해toDto만 정의하면toEntity를 자동으로 얻습니다. - @NullSafe — 호출 지점에서
Optional.ofNullable(...)로 결과를 감싸줍니다.
@NullSafe
Optional<AddressDTO> toAddressDto(AddressEntity entity);
- Sealed 클래스 지원 — Immuto는 sealed 계층 구조를 이해합니다. 기존 매퍼에서는 처리하지 못합니다.
- 생명주기 훅 —
@BeforeMapping·@AfterMapping메서드는 생성된 코드에 인라인됩니다. AOP나 프록시가 필요 없습니다. - 커스텀 타입 변환기
@Named("isoDate")
public class IsoDateConverter implements TypeConverter {
@Override
public String convert(LocalDate source, MappingContext ctx) {
return source == null ? null : source.toString();
}
}
- Fluent 런타임 API — 테스트나 APT를 사용할 수 없는 동적 환경을 위해 제공됩니다.
FluentMapper mapper = FluentMapper
.from(PersonEntity.class)
.to(PersonDTO.class)
.override("fullName", p -> p.firstName() + " " + p.lastName())
.build();
참고:
FluentMapper는 리플렉션을 사용합니다. 이는 기본 경로가 아니라 명시적인 옵트인 탈출구입니다.
Maven 의존성
<dependency>
<groupId>io.github.karunarathnad</groupId>
<artifactId>immuto-annotations</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>io.github.karunarathnad</groupId>
<artifactId>immuto-core</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>io.github.karunarathnad</groupId>
<artifactId>immuto-processor</artifactId>
<version>1.1.0</version>
<scope>provided</scope>
</dependency>
컴파일러 플러그인에 프로세서 경로를 추가합니다.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.github.karunarathnad</groupId>
<artifactId>immuto-processor</artifactId>
<version>1.1.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
그런 다음 인터페이스에 어노테이션을 붙이고 mvn compile을 실행하면 됩니다.
PersonMapper mapper = Immuto.getMapper(PersonMapper.class);
PersonDTO dto = mapper.toDto(entity);
마무리
Java 21은 현재 LTS 버전이며, Records는 실험적인 기능이 아니라 현대 Java에서 불변 데이터를 모델링하는 관용적인 방법입니다. 더 많은 코드베이스가 Records를 채택함에 따라, 이를 일급 시민으로 다루는 도구의 필요성도 커지고 있습니다.
Immuto는 Maven Central에 배포되어 있으며, Apache 2.0 라이선스를 갖고 활발히 개발 중입니다.
GitHub: https://github.com/karunarathnad/immuto
피드백, 이슈, 기여를 언제든 환영합니다.