
Spring Boot 4 기반으로 파일 업로드 기능을 구현하던 중, 파일 업로드 자체는 정상적으로 동작했지만 목록 화면에서 500 오류가 발생하는 문제가 있었습니다.
처음에는 업로드 로직, DB 조회, JSP 설정, JSTL 문제 중 하나라고 생각했습니다. 하지만 로그를 확인해보니 실제 원인은 전혀 다른 곳에 있었습니다.
문제는 JSP EL이 Java record 객체의 boolean 메서드를 해석하는 방식이었습니다.
이번 글에서는 PageResult를 Java record로 만들었을 때 JSP에서 ${page.first} 표현식이 왜 isFirst()가 아니라 first()를 찾으려고 하는지, 그리고 이 문제를 어떻게 해결했는지 정리해보겠습니다.
파일 업로드는 정상적으로 완료되었습니다.
파일도 저장되었고, DB에도 메타데이터가 들어갔습니다.
그런데 파일 목록 화면 / 으로 이동하면 다음과 같은 500 오류가 발생했습니다.
jakarta.el.PropertyNotFoundException: Property [first] not found on type [PageResult]
조금 더 안쪽 예외를 보면 다음 메시지가 있었습니다.
Caused by: java.lang.NoSuchMethodException: PageResult.first()
오류가 발생한 위치는 JSP의 페이징 영역이었습니다.
<c:when test="${page.first}">
<span class="disabled">이전</span>
</c:when>
<c:when test="${page.last}">
<span class="disabled">다음</span>
</c:when>
코드만 보면 크게 이상해 보이지 않습니다.
Spring Data의 Page 객체를 JSP나 Thymeleaf에서 사용해본 경험이 있다면 아래와 같은 표현은 익숙합니다.
${page.first}
${page.last}
${page.totalPages}
${page.content}
하지만 이번 프로젝트에서 사용한 page 객체는 Spring Data의 Page가 아니라 직접 만든 PageResult였습니다.
그리고 이 PageResult는 일반 class가 아니라 Java record였습니다.
프로젝트에서는 공통 페이징 응답을 표현하기 위해 다음과 같은 record를 사용했습니다.
public record PageResult<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages
) {
public boolean isFirst() {
return page == 0;
}
public boolean isLast() {
return totalPages == 0 || page >= totalPages - 1;
}
}
PageResult의 역할은 단순합니다.
| 필드 | 의미 |
|---|---|
content | 현재 페이지 데이터 |
page | 현재 페이지 번호 |
size | 페이지 크기 |
totalElements | 전체 데이터 수 |
totalPages | 전체 페이지 수 |
그리고 편의를 위해 다음 메서드를 추가했습니다.
public boolean isFirst() {
return page == 0;
}
public boolean isLast() {
return totalPages == 0 || page >= totalPages - 1;
}
일반 JavaBean 스타일의 class였다면 isFirst()는 first 프로퍼티처럼 인식될 수 있습니다.
즉, 일반 class에서는 아래 JSP 표현이 자연스럽게 동작할 가능성이 높습니다.
${page.first}
하지만 Java record는 일반적인 JavaBean과 accessor 구조가 다릅니다.
Java record는 필드에 대해 getXxx() 형태의 getter를 만들지 않습니다.
예를 들어 다음과 같은 record가 있다고 하겠습니다.
public record FileView(
Long id,
String originalFilename
) {
}
이 record의 accessor는 다음과 같습니다.
fileView.id();
fileView.originalFilename();
일반 class처럼 아래 메서드가 자동으로 생기지 않습니다.
fileView.getId();
fileView.getOriginalFilename();
즉, record의 accessor 이름은 record 컴포넌트 이름과 같습니다.
PageResult도 마찬가지입니다.
pageResult.content();
pageResult.page();
pageResult.size();
pageResult.totalElements();
pageResult.totalPages();
여기서 중요한 점은 first()와 last()라는 record 컴포넌트는 존재하지 않는다는 점입니다.
PageResult의 record 컴포넌트는 다음 다섯 개뿐입니다.
content
page
size
totalElements
totalPages
따라서 record accessor도 다음 다섯 개만 기본으로 생성됩니다.
content()
page()
size()
totalElements()
totalPages()
반면 isFirst()와 isLast()는 record 컴포넌트 accessor가 아니라 개발자가 추가한 일반 메서드입니다.
${page.first}를 해석하는 방식문제가 된 JSP 코드는 다음과 같습니다.
<c:when test="${page.first}">
개발자 입장에서는 이렇게 생각하기 쉽습니다.
PageResult에 isFirst()가 있으니까
${page.first}로 접근하면 되겠지?
일반 JavaBean 관점에서는 이 생각이 틀리지 않습니다.
하지만 JSP EL이 record 객체를 처리할 때는 상황이 달라질 수 있습니다.
Jakarta EL 환경에서는 record 객체에 대해 record 컴포넌트 accessor를 기준으로 property를 해석합니다.
즉, ${page.first}라는 표현을 만나면 EL은 first라는 property를 찾고, record 객체에서는 first()라는 accessor를 찾으려고 합니다.
문제는 PageResult에 first()라는 메서드가 없다는 것입니다.
실제로 존재하는 메서드는 이것입니다.
isFirst()
하지만 EL이 찾으려고 한 것은 이것입니다.
first()
그래서 다음 예외가 발생합니다.
java.lang.NoSuchMethodException: PageResult.first()
결과적으로 JSP 렌더링이 실패하고 500 오류가 발생했습니다.
page.totalPages는 정상 동작했을까?여기서 한 가지 의문이 생길 수 있습니다.
page.first는 터졌는데, 왜 아래 표현은 정상 동작했을까요?
${page.totalPages}
${page.content}
${page.size}
이유는 간단합니다.
totalPages, content, size는 실제 record 컴포넌트이기 때문입니다.
public record PageResult<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages
)
record 컴포넌트로 선언되어 있기 때문에 다음 accessor가 존재합니다.
content()
page()
size()
totalElements()
totalPages()
따라서 JSP EL에서도 다음 표현은 안전하게 읽을 수 있습니다.
${page.content}
${page.page}
${page.size}
${page.totalElements}
${page.totalPages}
하지만 first와 last는 record 컴포넌트가 아닙니다.
public boolean isFirst()
public boolean isLast()
이 메서드가 있다고 해서 record 컴포넌트 accessor인 first()와 last()가 생기는 것은 아닙니다.
결국 문제의 핵심은 다음과 같습니다.
${page.first}
→ record 컴포넌트 first를 찾음
→ PageResult에는 first()가 없음
→ NoSuchMethodException
→ PropertyNotFoundException
→ JSP 500 오류
이번 프로젝트에서는 JSP에서 record의 boolean 편의 메서드를 직접 사용하지 않는 방식으로 해결했습니다.
기존 코드는 다음과 같았습니다.
<c:when test="${page.first}">
<span class="disabled">이전</span>
</c:when>
<c:when test="${page.last}">
<span class="disabled">다음</span>
</c:when>
수정 후에는 컨트롤러에서 넘긴 currentPage 값을 기준으로 판단하도록 변경했습니다.
<c:when test="${currentPage == 0}">
<span class="disabled">이전</span>
</c:when>
<c:when test="${page.totalPages == 0 || currentPage >= page.totalPages - 1}">
<span class="disabled">다음</span>
</c:when>
컨트롤러에서는 이미 현재 페이지 번호를 모델에 담고 있었습니다.
model.addAttribute("page", pageResult);
model.addAttribute("currentPage", page);
model.addAttribute("size", size);
model.addAttribute("keyword", keyword);
따라서 JSP에서는 page.isFirst()나 page.isLast()에 의존하지 않고, 명시적으로 페이지 번호를 비교하도록 수정했습니다.
최종적으로 페이징 영역은 다음과 같이 정리할 수 있습니다.
<div class="pagination">
<c:choose>
<c:when test="${currentPage == 0}">
<span class="disabled">이전</span>
</c:when>
<c:otherwise>
<a href="/?page=${currentPage - 1}&size=${page.size}&keyword=${keyword}">
이전
</a>
</c:otherwise>
</c:choose>
<span class="page-info">
${currentPage + 1} / ${page.totalPages}
</span>
<c:choose>
<c:when test="${page.totalPages == 0 || currentPage >= page.totalPages - 1}">
<span class="disabled">다음</span>
</c:when>
<c:otherwise>
<a href="/?page=${currentPage + 1}&size=${page.size}&keyword=${keyword}">
다음
</a>
</c:otherwise>
</c:choose>
</div>
이 방식의 장점은 명확합니다.
JSP가 PageResult의 편의 메서드에 의존하지 않습니다.
JSP는 다음 값만 사용합니다.
${currentPage}
${page.size}
${page.totalPages}
${keyword}
page.size와 page.totalPages는 실제 record 컴포넌트이기 때문에 안전하게 접근할 수 있습니다.
이 문제를 해결하는 방법은 여러 가지가 있습니다.
PageResult를 일반 class로 바꾸면 JavaBean getter 규칙을 사용할 수 있습니다.
public class PageResult<T> {
private final List<T> content;
private final int page;
private final int size;
private final long totalElements;
private final int totalPages;
public PageResult(
List<T> content,
int page,
int size,
long totalElements,
int totalPages
) {
this.content = content;
this.page = page;
this.size = size;
this.totalElements = totalElements;
this.totalPages = totalPages;
}
public List<T> getContent() {
return content;
}
public int getPage() {
return page;
}
public int getSize() {
return size;
}
public long getTotalElements() {
return totalElements;
}
public int getTotalPages() {
return totalPages;
}
public boolean isFirst() {
return page == 0;
}
public boolean isLast() {
return totalPages == 0 || page >= totalPages - 1;
}
}
이렇게 하면 JSP에서 다음 표현이 동작할 가능성이 높습니다.
${page.first}
${page.last}
하지만 PageResult는 값 전달용 객체입니다.
생성 후 변경할 필요가 없고, 단순히 페이징 결과를 담는 목적입니다.
이런 구조에는 record가 잘 맞습니다.
JSP EL 하나 때문에 record를 일반 class로 바꾸는 것은 좋은 선택이 아니라고 판단했습니다.
first, last 컴포넌트를 추가하는 방법record 자체에 first, last 값을 넣는 방법도 있습니다.
public record PageResult<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages,
boolean first,
boolean last
) {
}
그러면 JSP에서 다음 표현이 정상 동작합니다.
${page.first}
${page.last}
하지만 이 방식은 first, last 값을 매번 생성 시점에 계산해서 넣어야 합니다.
또한 page, totalPages로 계산 가능한 값을 중복해서 들고 있게 됩니다.
작은 프로젝트에서는 괜찮아 보일 수 있지만, 모델이 불필요하게 무거워질 수 있습니다.
JSP 전용 View DTO를 따로 만들 수도 있습니다.
public record PageView<T>(
List<T> content,
int currentPage,
int size,
long totalElements,
int totalPages,
boolean first,
boolean last
) {
}
이 방식은 가장 명확합니다.
뷰에서 필요한 값을 DTO에 명시적으로 담기 때문입니다.
하지만 이번 프로젝트에서는 PageResult와 거의 동일한 구조의 DTO가 하나 더 생기게 됩니다.
현재 규모에서는 보일러플레이트가 늘어나는 단점이 더 크다고 판단했습니다.
따라서 이번에는 View DTO를 추가하지 않고, 컨트롤러에서 currentPage를 넘겨 JSP에서 직접 비교하는 방식으로 해결했습니다.
최종적으로 선택한 방식은 다음과 같습니다.
PageResult는 record로 유지한다.
JSP에서는 record의 boolean 편의 메서드를 직접 사용하지 않는다.
컨트롤러가 넘긴 currentPage와 page.totalPages를 기준으로 페이징 상태를 판단한다.
정리하면 다음과 같습니다.
| 방법 | 장점 | 단점 | 선택 |
|---|---|---|---|
${currentPage == 0} 사용 | 단순하고 안전함 | JSP에 조건식이 들어감 | 채택 |
| record를 일반 class로 변경 | JSP EL과 호환이 쉬움 | record 장점 포기 | 미채택 |
record에 first, last 추가 | JSP 표현이 간단함 | 계산 가능한 값 중복 | 미채택 |
| View DTO 추가 | 뷰 모델이 명확함 | 코드 증가 | 현재는 미채택 |
${page.isFirst} 사용 | 수정 범위가 작음 | EL 구현 방식에 의존 | 미채택 |
이번 경우에는 가장 단순한 해결이 가장 적절했습니다.
JSP에서는 명확한 값만 사용하고, record의 메서드 해석 방식에 기대지 않는 것이 안전했습니다.
이 문제는 JSON API에서는 잘 드러나지 않을 수 있습니다.
예를 들어 PageResult를 REST API 응답으로 반환하면 Jackson은 record 컴포넌트를 기준으로 JSON을 만들 수 있습니다.
{
"content": [],
"page": 0,
"size": 10,
"totalElements": 0,
"totalPages": 0
}
프론트엔드에서는 다음처럼 직접 계산하면 됩니다.
const first = page.page === 0;
const last = page.totalPages === 0 || page.page >= page.totalPages - 1;
하지만 JSP는 서버에서 Java 객체를 직접 읽어 HTML을 렌더링합니다.
이때 EL이 Java 객체의 property를 해석해야 하고, record 객체에서는 JavaBean 방식과 다른 해석이 발생할 수 있습니다.
즉, 이번 문제는 데이터 자체의 문제가 아닙니다.
DB 문제 아님
업로드 문제 아님
S3 문제 아님
PageResult 데이터 문제 아님
JSP EL과 Java record 접근 방식 문제
이렇게 원인을 좁히는 것이 중요했습니다.
같은 시점에 다른 로그들도 함께 보였습니다.
예를 들어 브라우저가 자동으로 요청하는 리소스들이 있었습니다.
| 요청 | 의미 |
|---|---|
/favicon.ico | 브라우저의 파비콘 자동 요청 |
/.well-known/appspecific/com.chrome.devtools.json | Chrome DevTools 관련 요청 |
정적 리소스 NoResourceFoundException | 존재하지 않는 static resource 요청 |
이런 로그가 JSP 500 오류와 섞이면 문제를 더 크게 오해할 수 있습니다.
실제 핵심 오류는 이것이었습니다.
Property [first] not found on type [PageResult]
NoSuchMethodException: PageResult.first()
반면 favicon이나 .well-known 요청은 본질적인 장애 원인이 아니었습니다.
따라서 정적 리소스 404는 운영 로그에서 과하게 ERROR로 다루지 않도록 분리하는 것이 좋습니다.
예를 들어 다음처럼 처리할 수 있습니다.
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<Void> handleNoResourceFound(NoResourceFoundException e) {
log.debug("Static resource not found: {}", e.getResourcePath());
return ResponseEntity.notFound().build();
}
중요한 것은 실제 500 오류와 404 노이즈를 구분하는 것입니다.
운영 로그에서 모든 오류가 같은 무게로 보이면 원인 분석이 느려집니다.
이번 문제를 정리하면 디버깅 흐름은 다음과 같습니다.
먼저 JSP 렌더링 중 500 오류가 발생했습니다.
jakarta.el.PropertyNotFoundException: Property [first] not found on type [PageResult]
그다음 원인 예외에서 EL이 어떤 메서드를 찾고 있었는지 확인했습니다.
java.lang.NoSuchMethodException: PageResult.first()
여기서 중요한 단서는 first()였습니다.
PageResult에는 isFirst()는 있었지만 first()는 없었습니다.
이후 PageResult가 Java record라는 점을 확인했습니다.
public record PageResult<T>(...)
그리고 JSP에서 다음 표현을 사용하고 있음을 확인했습니다.
${page.first}
${page.last}
최종적으로 이 표현을 제거하고, 컨트롤러에서 넘긴 currentPage와 record 컴포넌트인 totalPages를 사용하도록 변경했습니다.
${currentPage == 0}
${page.totalPages == 0 || currentPage >= page.totalPages - 1}
수정 후 PageResult는 그대로 유지했습니다.
public record PageResult<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages
) {
public boolean isFirst() {
return page == 0;
}
public boolean isLast() {
return totalPages == 0 || page >= totalPages - 1;
}
}
JSP에서는 isFirst(), isLast()를 직접 사용하지 않도록 변경했습니다.
<c:choose>
<c:when test="${currentPage == 0}">
<span class="disabled">이전</span>
</c:when>
<c:otherwise>
<a href="/?page=${currentPage - 1}&size=${page.size}&keyword=${keyword}">
이전
</a>
</c:otherwise>
</c:choose>
다음 버튼도 동일하게 처리했습니다.
<c:choose>
<c:when test="${page.totalPages == 0 || currentPage >= page.totalPages - 1}">
<span class="disabled">다음</span>
</c:when>
<c:otherwise>
<a href="/?page=${currentPage + 1}&size=${page.size}&keyword=${keyword}">
다음
</a>
</c:otherwise>
</c:choose>
이렇게 수정한 뒤 목록 화면은 정상적으로 렌더링되었습니다.
이번 문제는 겉으로 보면 단순한 JSP 오류처럼 보입니다.
하지만 실제 원인은 Java record와 JSP EL의 property 해석 방식 차이에 있었습니다.
일반 JavaBean에서는 isFirst()가 first 프로퍼티처럼 자연스럽게 인식될 수 있습니다.
하지만 record 객체에서는 ${page.first}가 isFirst()가 아니라 first() accessor를 찾는 흐름으로 동작할 수 있습니다.
따라서 record를 JSP에 넘길 때는 다음 원칙을 지키는 것이 안전합니다.
JSP에서는 record의 boolean 편의 메서드에 직접 의존하지 않는다.
record 컴포넌트로 선언된 값만 사용한다.
필요한 계산값은 controller에서 scalar로 넘기거나 View DTO로 분리한다.
이번 프로젝트에서는 PageResult를 record로 유지하면서, JSP에서는 currentPage와 totalPages를 기준으로 페이징 상태를 판단하도록 수정했습니다.
이 방식은 구조를 크게 바꾸지 않으면서도 JSP EL과 record 사이의 충돌을 피할 수 있는 현실적인 해결책이었습니다.
Java record는 DTO나 값 객체를 표현할 때 매우 편리합니다.
하지만 JSP처럼 Java 객체의 property를 EL로 해석하는 기술과 함께 사용할 때는 주의가 필요합니다.
특히 아래와 같은 표현은 조심해야 합니다.
${page.first}
${page.last}
isFirst()와 isLast()가 있다고 해서 항상 안전하게 동작하는 것은 아닙니다.
record 환경에서는 EL이 first()와 last()를 찾으려고 할 수 있습니다.
따라서 JSP 페이징에서는 아래처럼 명시적인 값 비교를 사용하는 것이 더 안전합니다.
${currentPage == 0}
${currentPage >= page.totalPages - 1}
결론은 간단합니다.
Java record의 accessor 방식과 JavaBean getter 관례는 다릅니다.
JSP EL에서 record를 사용할 때는isXxx()편의 메서드에 기대지 말고, record 컴포넌트나 명시적인 모델 값을 사용하는 것이 안전합니다.