프로젝트에서 파일 URL을 응답 직전에 변환하는 기능이 AOP로 구현되어 있었다.
DTO에는 파일 스토리지의 상대 경로만 저장하고, 서비스 메소드에 @FileUrlResolved를 붙이면
Aspect가 응답 객체를 순회하면서 @TargetFileUriToResolve가 붙은 필드에 CDN 도메인을 붙여주는 구조였다.
이 기능은 팀원이 구현한 것이었고, 대부분의 API에서는 정상적으로 동작하고 있었다.
그런데 그룹 스터디 게시글 목록 조회 API에서만 프로필 이미지 URL이 변환되지 않는 문제가 발생했다.
같은 DTO를 사용하는 단건 조회, 생성 API는 정상인데 목록 조회만 실패한다는 점에서
DTO보다는 반환 타입 쪽에서 문제가 있을 것으로 판단했다.
문제 상황
문제가 발생한 메소드는 그룹 스터디 게시글 목록 조회였다.
1
2
3
4
5
@FileUrlResolved
@Transactional
public Page<ThreadSummaryResponse> getThreadList(...) {
...
}
응답 DTO인 ThreadSummaryResponse 안에는 ImageDto가 있고, 그 안의 ResizedImageDto.resizedImageUrl에는 @TargetFileUriToResolve가 붙어 있다.
1
2
3
4
5
6
7
8
9
public class ResizedImageDto {
private Long resizedImageId;
@TargetFileUriToResolve
private String resizedImageUrl;
private ImageSizeTypeDto imageSizeType;
}
즉, Aspect가 정상 동작하기만 하면 게시글 목록 안에 포함된 프로필 이미지 URL도 자동으로 절대 경로로 바뀌어야 했다.
그런데 실제 응답에서는 목록 API만 이 필드가 변환되지 않았다.
같은 DTO를 사용하는 단건 조회, 생성 응답은 정상인데 목록 조회만 실패한다는 점에서, DTO 자체보다 “반환 타입” 쪽을 먼저 의심하게 됐다.
원인 분석
후처리 로직은 응답 객체를 재귀적으로 순회하면서 필드를 읽고 있었다. 핵심은 아래와 같은 방식이었다.
1
Field[] fields = retrieved.getClass().getDeclaredFields();
getDeclaredFields()는 현재 클래스에 선언된 필드만 가져오고, 부모 클래스의 필드는 가져오지 않는다.
문제는 Spring Data의 Page가 인터페이스라는 점이었다. 실제 런타임 객체는 PageImpl인데, 목록 데이터인 content는 PageImpl의 부모 클래스인 Chunk에 들어 있다.
정리하면 당시 구조는 이랬다.
- 서비스는
Page를 반환한다. - 런타임에서는
PageImpl이 반환된다. - Aspect는
PageImpl인스턴스를 리플렉션으로 훑는다. - 하지만
getDeclaredFields()는Chunk에 있는content를 보지 못한다. - 결국
content안의ThreadSummaryResponse -> ImageDto -> ResizedImageDto까지 내려가지 못한다.
즉, 어노테이션을 못 찾은 것이 아니라, 어노테이션이 들어 있는 객체 그래프까지 아예 도달하지 못한 것이다.
이 문제를 확인하기 위해 반환 타입을 Spring Page 대신 프로젝트 내부에서 정의한 PageResponseDto로 바꿔 보았다.
1
2
3
4
5
@FileUrlResolved
@Transactional
public PageResponseDto<ThreadSummaryResponse> getThreadList(...) {
...
}
이 경우에는 content 필드가 우리 DTO에 직접 선언되어 있기 때문에, 같은 후처리 로직으로도 이미지 URL 변환이 정상 동작했다.
이걸로 문제의 본질이 DTO 변환 로직이 아니라 “리플렉션 탐색 범위”라는 점을 확정할 수 있었다.
해결 방향
해결책은 크게 두 가지였다.
1. Page 대신 DTO로 감싸서 반환
응답을 PageResponseDto로 감싸서 반환하는 방식이다.
1
2
3
4
5
6
7
8
9
public record PageResponseDto<T>(
List<T> content,
long page,
int size,
long totalElements,
int totalPages,
boolean hasNext,
boolean hasPrevious) {
}
이 방식은 다음과 같은 장점이 있다.
응답 구조를 직접 통제할 수 있다.
프레임워크 내부 구조에 의존하지 않는다.
후처리 로직이 항상 동일한 방식으로 동작한다.
현재 게시글 목록 API는 이 방식으로 정리되어 있다.
2. Page 타입에 대한 별도 처리 추가
또 다른 방법은 후처리 로직에서 Page 타입을 별도로 처리하는 것이다.
1
2
3
4
5
6
if (Page.class.isAssignableFrom(retrieved.getClass())) {
((Page<?>) retrieved)
.getContent()
.forEach((nestedField) -> extractAllAnnotatedFields(nestedField, fieldsToResolve));
return;
}
이렇게 하면 PageImpl 내부 구조를 직접 알지 않아도 인터페이스 메소드를 통해 안전하게 내부 데이터에 접근할 수 있다.
결국 중요한 점은 모든 객체를 동일한 방식으로 순회할 수 있다고 가정하면 안 된다는 것이다. 프레임워크에서 제공하는 컨테이너 타입은 별도로 고려해야 한다.
정리
이번 이슈를 통해 리플렉션 기반 후처리 로직의 한계를 확인할 수 있었다.
특히 다음 두 가지를 항상 확인해야 한다는 점을 알게 되었다.
런타임에 실제로 반환되는 객체가 무엇인지, 해당 객체를 리플렉션으로 정상적으로 순회할 수 있는지
또한 기존에 구현된 구조를 그대로 사용하는 것이 아니라, 문제가 발생했을 때 내부 동작을 분석하고 원인을 찾아 해결하는 과정이 중요하다는 점을 다시 느꼈다.
프레임워크 컨테이너 타입을 사용할 경우 별도 처리 여부를 고려하고, 응답 구조를 명확하게 통제할 수 있는 방향을 생각해야 된다는 것을 느꼈다.