Java 두 날짜 중 최신값으로 정렬하기: nullsLast와 thenComparing 구분
생성일과 수정일이 함께 있는 목록을 ‘최신순’으로 정렬하려면 먼저 최신의 뜻을 정해야 한다. 두 날짜 중 더 늦은 시각이 기준이라면, 행마다 최신값 하나를 계산한 뒤 그 값으로 정렬한다. thenComparing()으로 날짜 두 개를 연결하는 것과는 결과가 다르다.
이 글은 두 값 중 하나가 null이면 다른 값을 사용하고, 둘 다 null이면 뒤로 보내는 정책을 적용한다. 최신값이 같으면 ID 오름차순으로 순서를 정한다. 예제 ID는 고유한 long이며 목록과 행 자체는 null이 아니라고 가정한다.
검증일은 2026-10-30이다. Ubuntu Java 런타임 OpenJDK 17.0.20, Linux amd64에서 설명용 데이터로 22개 검사를 실행했다. Java 정렬과 시간 비교를 확인한 결과이며, 실제 회사 코드·Spring Boot API·JPA·MyBatis·운영 DB를 통합 검증한 결과는 아니다. 예제는 Java 17의 record와 Stream.toList를 사용한다.
1. 두 날짜를 사용하는 세 가지 요구를 구분한다
다음 요구는 각각 다른 정렬 키가 필요하다.
- 생성일 우선, 생성일이 같을 때 수정일 비교: 첫 번째 날짜를 주 기준으로 삼는 다중 기준 정렬이다.
- 수정일이 있으면 사용하고 없으면 생성일 사용: null 대체 규칙이다. 두 값의 크기를 비교하지 않는다.
- 생성일과 수정일 중 더 늦은 시각 사용: 두 필드의 최댓값을 한 개의 키로 만드는 규칙이다. 이번 글의 대상이다.
차이를 보려면 두 행이면 충분하다. 아래 값은 모두 2026년 10월, 오전 9시인 설명용 데이터다.
- ID 1: 생성일 28일, 수정일 20일 → 최신값은 28일.
- ID 2: 생성일 27일, 수정일 29일 → 최신값은 29일.
두 값의 최신순이면 ID 2가 먼저다. 생성일을 먼저 비교하면 ID 1이 앞선다. thenComparing()은 앞 비교가 동률일 때만 다음 기준을 적용하므로 두 날짜의 최댓값을 자동으로 선택하지 않는다. Comparator 공식 API
ID 1의 날짜 역전은 차이를 드러내기 위해 만든 반례다. 실제 업무에서 수정일이 생성일보다 빠른 값을 금지한다면, 정렬로 감추지 말고 입력·이관 검증에서 오류로 처리해야 한다. 날짜 필드가 승인일·변경일처럼 서로 독립적이라면 두 값 중 최신값이 적절한지 업무 규칙부터 정한다.
업무상 우선순위가 최신 시각보다 먼저라면 PostgreSQL 요금 조회 우선순위 설계처럼 우선순위와 날짜 조건을 별도로 정의하는 편이 맞다.
2. 최신값 선택과 정렬을 분리한 실행 예제
다음 전체 코드를 LatestDateSort.java로 저장하고 JDK 17 환경에서 실행한다. DB 연결이나 외부 라이브러리는 필요 없다.
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class LatestDateSort {
record Row(long id, LocalDateTime createdAt, LocalDateTime updatedAt) {}
static LocalDateTime latest(LocalDateTime a, LocalDateTime b) {
if (a == null) return b;
if (b == null) return a;
return a.isAfter(b) ? a : b;
}
static final Comparator<LocalDateTime> DATE_DESC =
Comparator.nullsLast(Comparator.reverseOrder());
static final Comparator<Row> BY_LATEST = Comparator
.comparing((Row row) -> latest(row.createdAt(), row.updatedAt()), DATE_DESC)
.thenComparingLong(Row::id);
static LocalDateTime at(int day) {
return LocalDateTime.of(2026, 10, day, 9, 0);
}
static List<Long> ids(List<Row> rows) {
return rows.stream().map(Row::id).toList();
}
public static void main(String[] args) {
// 설명용 데이터: 날짜 역전도 포함해 정렬 정책을 비교한다.
List<Row> input = List.of(
new Row(5, null, null),
new Row(6, at(28), at(29)),
new Row(1, at(28), at(20)),
new Row(4, at(25), null),
new Row(2, at(27), at(29)),
new Row(3, null, at(26))
);
List<Row> result = new ArrayList<>(input);
result.sort(BY_LATEST);
System.out.println(ids(result));
}
}
java LatestDateSort.java
이번 실행 출력은 다음과 같다.
[2, 6, 1, 3, 4, 5]
ID 2와 6의 최신값은 29일로 같다. 보조 기준인 ID 오름차순으로 2가 먼저 왔다. 다음은 28일의 ID 1, 26일의 ID 3, 25일의 ID 4다. 날짜가 모두 없는 ID 5는 마지막이다.
실제 DTO에서는 row.createdAt()과 row.updatedAt()을 getCreatedAt(), getUpdatedAt() 같은 실제 getter로 바꾸면 된다. 비교 함수 안에서 DB를 조회하거나 현재 시각을 계속 읽지 말고, 정렬하는 동안 같은 행이 같은 키를 내도록 유지한다.
이 예제의 ‘최신’은 비어 있지 않은 값 중 가장 큰 날짜·시각이다. 미래 예약일을 자동으로 제외하지 않는다. ‘오늘까지 적용된 값 중 최신’이 필요하면 정렬 전에 기준 시각에 맞는 후보를 걸러야 한다.
3. nullsLast 뒤에 reversed를 붙이면 null도 뒤집힌다
다음 두 비교기는 같지 않다.
Comparator<LocalDateTime> latestFirstNullLast =
Comparator.nullsLast(Comparator.reverseOrder());
Comparator<LocalDateTime> nullWillComeFirst =
Comparator.nullsLast(
Comparator.<LocalDateTime>naturalOrder()
).reversed();
첫 번째는 날짜만 내림차순으로 비교하고 null은 뒤로 보낸다. 두 번째는 null 배치를 포함한 전체 순서를 뒤집는다. 이번 테스트에서도 두 번째 비교기는 null을 날짜보다 앞에 놓았다.
BY_LATEST의 끝에 다시 reversed()를 붙이면 최신순뿐 아니라 동률일 때의 ID 순서까지 바뀐다. 원하는 방향을 각 기준에 지정한 뒤 전체를 또 뒤집지 않는 편이 읽기 쉽다. nullsLast·reversed 공식 계약
또한 날짜 필드가 null인 경우와 DTO 자체가 null인 경우는 다르다. 이번 기본 비교기는 DTO가 있다고 가정한다. 목록에 null 행을 보존해야 한다는 별도 정책이 있다면 Comparator.nullsLast(BY_LATEST)로 행 비교기를 감쌀 수 있다. null 행이 데이터 오류라면 먼저 거절해야 하며, 조용히 삭제하거나 마지막으로 보내는 정책을 임의로 정하지 않는다.
4. ‘수정일이 없으면 생성일’이 정답인 경우도 있다
화면의 의미가 ‘수정일 우선’이라면 다음처럼 선택할 수 있다.
LocalDateTime key = row.updatedAt() != null
? row.updatedAt()
: row.createdAt();
이 코드는 수정일이 있는 순간 그 값을 택한다. 앞의 ID 1에서는 20일을 선택하므로, 두 날짜의 최신값인 28일과 다르다. 이번 검증에서 ID 1과 생성일만 25일인 ID 4를 비교했을 때, null 대체 방식은 [4, 1], 최댓값 방식은 [1, 4]였다.
어느 쪽이 언제나 맞는 것이 아니다. 화면이 ‘마지막 변경 기록’을 표시하는지, 독립된 두 이벤트 중 최근 발생 시각을 표시하는지에 따라 선택한다. 명세에 ‘첫 번째 날짜가 없으면 두 번째 날짜’라고 적혀 있다면 최댓값으로 바꾸는 것도 요구 변경이다.
5. 정렬 대상이 수정 가능한 목록인지 확인한다
List.sort()는 전달받은 목록의 순서를 바꾼다. 예제에서 new ArrayList<>(input)을 만든 이유는 원본을 유지하면서 정렬 가능한 목록을 얻기 위해서다. 이번 테스트에서는 정렬 후에도 원본 ID 순서 [5, 6, 1, 4, 2, 3]가 유지됐다.
List.of(...)로 만든 목록을 바로 정렬하면 UnsupportedOperationException이 발생한다. List 공식 API
Java 17의 stream().sorted(BY_LATEST).toList()는 새 정렬 결과를 만들 수 있지만, 반환 목록은 수정 불가능하다. 나중에 그 결과를 다시 정렬하거나 추가·삭제해야 한다면 new ArrayList<>(결과) 등으로 변경 가능한 목록을 명확히 준비한다. Stream.toList()는 Java 16부터 제공되므로 Java 8 코드에 그대로 복사하지 않는다. Stream.toList 공식 API
List.sort()는 동률 원소의 기존 순서를 보존하는 안정 정렬이다. 그러나 원본의 조회 순서가 요청마다 달라지면 안정 정렬만으로 화면의 동률 순서를 고정할 수는 없다. 예제처럼 고유 ID를 최종 기준으로 정하면 같은 입력 집합의 동률 순서를 명확히 할 수 있다. ID가 중복되거나 null인 모델이라면 해당 조건까지 다시 정해야 한다.
6. 서로 다른 시간대의 값은 실제 순간으로 비교한다
LocalDateTime에는 시간대나 UTC 오프셋이 없다. 이 글의 기본 예제는 모든 값이 같은 시간 기준이라는 전제다. 서로 다른 지역에서 전달된 시각의 실제 선후 관계를 비교하는 작업에는 그 전제가 성립하지 않을 수 있다. LocalDateTime 공식 설명
이번 검증에 쓴 두 값은 다음과 같다.
2026-10-30T09:00:00+09:002026-10-30T01:00:00Z
앞 값은 UTC 0시이고 뒤 값은 UTC 1시다. 오프셋을 지운 현지 시각만 비교하면 9시가 1시보다 늦게 보이지만, 실제로는 뒤 값이 더 늦다. 오프셋이 명시된 입력은 OffsetDateTime으로 읽고 toInstant()로 순간을 맞춰 비교할 수 있다. OffsetDateTime 공식 API
이미 시간대가 없는 값에 임의로 UTC나 한국 시간을 붙이지 않는다. DB 저장 규칙과 JDBC 타입부터 확인해야 한다. 이 부분은 운영 SQL 콘솔의 KST 변환·시간 의미 점검과 연결된다.
날짜 범위의 포함·제외 경계가 문제라면 이 정렬 함수로 해결할 수 없다. PostgreSQL 시작일·종료일 겹침 조건처럼 조회 대상 선정과 정렬을 분리한다.
7. 한 페이지만 가져온 뒤 정렬하면 전체 최신순이 아니다
위 예제의 원본에서 앞 두 행만 가져오면 ID 5와 6이다. 그 안에서 최신순으로 바꾸면 [6, 5]가 된다. 전체 목록을 먼저 정렬한 뒤 앞 두 행을 선택하면 [2, 6]이다. 이번 테스트에서 이 차이를 확인했다.
DB에서 이미 한 페이지를 조회한 뒤 Java에서 정렬해도, 조회되지 않은 ID 2를 새로 포함할 수는 없다. 전체 결과의 최신순 페이지가 필요하다면 DB 조회 단계에서 같은 정렬 키와 동률 규칙을 적용하고, 그다음 페이지를 잘라야 한다. PostgreSQL도 LIMIT 사용 시 결과를 고유한 순서로 제한하는 ORDER BY를 강조한다. PostgreSQL LIMIT·OFFSET 공식 문서
이번 글에서는 DB별 SQL 구현과 실행 계획을 검증하지 않았다. 목록이 크다고 무조건 전부 메모리에 읽어 정렬하라는 제안도 아니다. Java 정렬은 API 여러 결과를 합친 작은 목록처럼 전체 후보가 이미 준비된 경우에 사용할 수 있다. 운영 데이터 규모와 페이지 계약은 별도 검증 대상이다.
요구사항을 확인할 때는 최댓값인가 우선순위인가 → null 정책은 무엇인가 → 동률은 어떻게 처리하는가 → 전체 후보를 정렬하는가를 차례로 묻는다. 비교기를 짧게 쓰는 것보다 이 네 조건이 결과와 일치하는지가 먼저다.