본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
12장 : 고급 설계와 최신 Spring

육각형 경계와 AOT 준비

지금까지 만든 서비스는 Spring Data와 annotation을 직접 사용해도 동작합니다. 하지만 업무 규칙이 저장 기술과 같은 타입에 묶이면 JPA 매핑을 바꾸는 일이 도메인 코드 변경으로 번지고, 프레임워크 없이 규칙만 실행하기도 어려워집니다.

육각형 구조는 패키지 모양을 위한 규칙이 아니라 의존 방향을 고정합니다. 도메인은 “세션을 저장하고 찾는 능력”을 포트로 요구하고, JPA 어댑터가 그 능력을 구현합니다. AOT는 이 경계 바깥의 reflection과 proxy 사용을 빌드 시점에 더 엄격하게 드러냅니다.


프레임워크 경계는 도메인 바깥에 둔다

도메인 패키지는 Spring을 import하지 않고 Java 타입과 업무 규칙만 가집니다. SessionStore는 저장 방법을 말하지 않은 채 필요한 동작만 선언하고, persistence 패키지가 Spring Data repository와 mapper로 그 계약을 채웁니다.


포트·어댑터·구성 클래스로 조립한다

아래 예제는 기존 JPA entity를 도메인 record와 분리한 최소 형태입니다. mapper가 양쪽 변환을 한곳에 모으므로 JPA annotation과 접근 제약이 도메인 타입으로 새지 않습니다.

src/main/java/com/andongmin/studylog/domain/StudySession.java
package com.andongmin.studylog.domain;

import java.time.LocalDate;
import java.util.UUID;

public record StudySession(UUID id, String subject, String topic,
        LocalDate studyDate, int minutes, String status, long version) {
}
src/main/java/com/andongmin/studylog/domain/SessionStore.java
package com.andongmin.studylog.domain;

import java.util.Optional;
import java.util.UUID;

public interface SessionStore {
    StudySession save(StudySession session);
    Optional<StudySession> findById(UUID id);
}
src/main/java/com/andongmin/studylog/persistence/SessionEntity.java
package com.andongmin.studylog.persistence;

import java.time.LocalDate;
import java.util.UUID;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;

@Entity
@Table(name = "study_session")
public class SessionEntity {
    @Id UUID id;
    String subject;
    String topic;
    LocalDate studyDate;
    int minutes;
    String status;
    @Version long version;

    protected SessionEntity() {
    }

    SessionEntity(UUID id, String subject, String topic, LocalDate studyDate,
            int minutes, String status, long version) {
        this.id = id;
        this.subject = subject;
        this.topic = topic;
        this.studyDate = studyDate;
        this.minutes = minutes;
        this.status = status;
        this.version = version;
    }
}
src/main/java/com/andongmin/studylog/persistence/SpringDataSessionRepository.java
package com.andongmin.studylog.persistence;

import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;

public interface SpringDataSessionRepository
        extends JpaRepository<SessionEntity, UUID> {
}
src/main/java/com/andongmin/studylog/persistence/SessionMapper.java
package com.andongmin.studylog.persistence;

import org.springframework.stereotype.Component;
import com.andongmin.studylog.domain.StudySession;

@Component
public class SessionMapper {
    SessionEntity toEntity(StudySession session) {
        return new SessionEntity(session.id(), session.subject(), session.topic(),
                session.studyDate(), session.minutes(), session.status(), session.version());
    }

    StudySession toDomain(SessionEntity entity) {
        return new StudySession(entity.id, entity.subject, entity.topic,
                entity.studyDate, entity.minutes, entity.status, entity.version);
    }
}
src/main/java/com/andongmin/studylog/persistence/JpaSessionStore.java
package com.andongmin.studylog.persistence;

import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Repository;
import com.andongmin.studylog.domain.SessionStore;
import com.andongmin.studylog.domain.StudySession;

@Repository
public class JpaSessionStore implements SessionStore {
    private final SpringDataSessionRepository repository;
    private final SessionMapper mapper;

    public JpaSessionStore(SpringDataSessionRepository repository, SessionMapper mapper) {
        this.repository = repository;
        this.mapper = mapper;
    }

    @Override
    public StudySession save(StudySession session) {
        return mapper.toDomain(repository.save(mapper.toEntity(session)));
    }

    @Override
    public Optional<StudySession> findById(UUID id) {
        return repository.findById(id).map(mapper::toDomain);
    }
}
src/main/java/com/andongmin/studylog/aot/StudyLogRuntimeHints.java
package com.andongmin.studylog.aot;

import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import com.andongmin.studylog.report.client.ReportClient.ReportResponse;

public class StudyLogRuntimeHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        hints.reflection().registerType(ReportResponse.class,
                MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                MemberCategory.INVOKE_PUBLIC_METHODS);
    }
}
src/main/java/com/andongmin/studylog/aot/AotConfiguration.java
package com.andongmin.studylog.aot;

import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportRuntimeHints;

@Configuration(proxyBeanMethods = false)
@ImportRuntimeHints(StudyLogRuntimeHints.class)
public class AotConfiguration {
}

이 mapper는 매 저장마다 새 SessionEntity를 만들기 때문에, UUID가 이미 할당된 새 객체를 Spring Data가 update로 오인하지 않는지 확인해야 합니다. 실제 구현에서는 생성과 갱신을 분리하거나 Persistable.isNew() 정책을 명시하고, 낙관적 lock의 version을 덮어쓰지 않는 통합 테스트를 둡니다.

StudyLogRuntimeHintsReportResponse는 7장에서 만든 HTTP interface DTO입니다. AOT 분석이 해당 타입을 자동으로 발견한다면 수동 hint는 필요 없으며, 실제 native 실행에서 누락이 확인된 reflection 경계만 최소 범위로 등록합니다.


AOT 처리와 native metadata 경고를 확인한다

process-aot은 일반 JDK만으로 생성 코드와 hint 분석을 수행하므로 먼저 실행할 수 있습니다. native 실행 파일 생성은 GraalVM과 플랫폼 빌드 도구가 추가로 필요하므로 준비되지 않은 환경에서 정상 검증 명령처럼 실행하지 않습니다.

./mvnw -q spring-boot:process-aot
test -d target/spring-aot/main

# GraalVM과 native-image 도구가 설치된 환경에서만 실행한다.
# ./mvnw -q -Pnative native:compile
Adapter 확인 결과
도메인 패키지는 Spring annotation 없이 컴파일되고 `target/spring-aot/main`에 AOT 생성물이 만들어져야 합니다. native 실행 성공은 별도 환경에서 원격 DTO binding, JPA proxy, resource hint를 실제 경로로 호출해 확인합니다.

AOT 경고는 어댑터 경계를 다시 보여 준다

reflection 설정이 도메인 타입마다 퍼진다면 경계가 잘못된 것입니다. serializer, HTTP client, JPA처럼 런타임 동적 기능을 쓰는 어댑터 가까이에 hint를 두면 native 요구가 바뀌어도 순수 업무 규칙은 영향을 받지 않습니다.

이제 앞 장들의 선택을 한 프로젝트에 다시 쌓습니다. 마지막 장에서는 새 예제를 흩어 놓지 않고 프로젝트 골격, DB, REST, 보안, ETag, 배포 파일을 순서대로 누적합니다.