Page.of 는 오프셋 페이징입니다. MemberStore 에 findAfter(long lastId, int size) 를 추가해 키셋 페이징을 구현하세요. id 가 lastId 보다 크고 오름차순으로 정렬된 상위 size 개만 돌려주고, MemberApi 에 GET /members/seek?after=0&size=2 핸들러를 등록하세요. lastId=0 은 첫 페이지를 뜻합니다.
// MemberStore.java 에 추가
public List<Member> findAfter(long lastId, int size) {
return data.values().stream()
.filter(m -> m.id() > lastId)
.sorted(Comparator.comparingLong(Member::id))
.limit(size)
.toList();
}// MemberApi.java: register() 에 등록 추가
router.add("GET", "/members/seek", this::seek);
private void seek(HttpExchange ex, Map<String, String> vars) throws IOException {
Map<String, String> q = query(ex);
long after = Long.parseLong(q.getOrDefault("after", "0"));
int size = Math.min(Math.max(intParam(q, "size", 10), 1), 100);
List<Member> items = store.findAfter(after, size);
Map<String, Object> body = new LinkedHashMap<>();
body.put("size", items.size());
body.put("nextAfter", items.isEmpty() ? after : items.get(items.size() - 1).id());
body.put("content", items.stream().map(Member::toJson).toList());
Router.writeJson(ex, 200, body);
}
// GET /members/seek?after=0&size=2 → nextAfter 를 다음 요청의 after 로 그대로 넘기면 이어서 조회된다키셋 페이징은 OFFSET 이 없어 몇 페이지째든 조회 속도가 일정하지만, "3페이지로 바로 이동" 같은 임의 점프는 못 합니다. 무한 스크롤·API 커서 방식(피드, 알림 목록)에 적합하고, 관리자 화면의 페이지 번호 이동에는 오프셋 페이징이 낫습니다.
PUT 을 version 필드 대신 If-Match 헤더로 검증하도록 바꾸세요. 헤더가 없으면 428(Precondition Required 의미로 400 사용), 헤더 값이 현재 ETag 와 다르면 409 를 응답해야 합니다. MemberApi.replace 를 수정하세요.
private void replace(HttpExchange ex, Map<String, String> vars) throws IOException {
long id = Long.parseLong(vars.get("id"));
var found = store.find(id);
if (found.isEmpty()) { ApiError.send(ex, 404, "NOT_FOUND", "회원 없음: id=" + id, null); return; }
String ifMatch = ex.getRequestHeaders().getFirst("If-Match");
if (ifMatch == null) { ApiError.send(ex, 400, "VALIDATION_ERROR", "If-Match 헤더가 필요합니다", null); return; }
Map<String, Object> body = readJson(ex);
var errors = Validation.validateCreate(body);
if (!errors.isEmpty()) { ApiError.send(ex, 400, "VALIDATION_ERROR", "입력 값을 확인하세요", errors); return; }
int expected;
try { expected = Integer.parseInt(ifMatch.replace("\"", "")); }
catch (NumberFormatException e) { ApiError.send(ex, 400, "VALIDATION_ERROR", "If-Match 형식이 올바르지 않습니다", null); return; }
try {
Member updated = store.update(id, expected, cur -> new Member(
cur.id(), (String) body.get("name"), (String) body.get("email"), toInteger(body.get("age")), cur.version(), cur.createdAt()));
ex.getResponseHeaders().set("ETag", "\"" + updated.version() + "\"");
Router.writeJson(ex, 200, updated.toJson());
} catch (MemberStore.VersionConflict c) {
ApiError.send(ex, 409, "CONFLICT", "다른 요청이 먼저 수정했습니다. 최신 version=" + c.currentVersion, null);
}
}
// 호출: PUT /members/1 header If-Match: "2" body {"name":...,"email":...,"age":...} (version 필드 불필요)version 필드는 요청 본문(업무 데이터)에 메타데이터가 섞이는 방식이고, If-Match 는 HTTP 헤더(전송 계층 메타데이터)로 분리하는 방식입니다. 표준 헤더를 쓰면 캐시 서버나 API 게이트웨이가 별도 파싱 없이 조건부 요청을 이해할 수 있다는 장점이 있습니다.
GET /members 의 sort 파라미터는 지금 name, age, 그 외(id)만 받습니다. 화이트리스트에 없는 필드(sort=password,asc 등)가 오면 400 을 응답하도록 MemberApi.list 를 수정하세요.
private static final java.util.Set<String> SORTABLE = java.util.Set.of("id", "name", "age");
private void list(HttpExchange ex, Map<String, String> vars) throws IOException {
Map<String, String> q = query(ex);
int page = intParam(q, "page", 0);
int size = Math.min(Math.max(intParam(q, "size", 10), 1), 100);
String[] sort = q.getOrDefault("sort", "id,asc").split(",");
if (!SORTABLE.contains(sort[0])) {
ApiError.send(ex, 400, "VALIDATION_ERROR", "정렬할 수 없는 필드입니다",
List.of(new Validation.FieldError("sort", "id, name, age 중 하나여야 합니다")));
return;
}
List<Member> all = new ArrayList<>(store.findAll());
Comparator<Member> cmp = switch (sort[0]) {
case "name" -> Comparator.comparing(Member::name);
case "age" -> Comparator.comparing(m -> m.age() == null ? -1 : m.age());
default -> Comparator.comparingLong(Member::id);
};
if (sort.length > 1 && sort[1].equalsIgnoreCase("desc")) cmp = cmp.reversed();
all.sort(cmp);
// ... 이하 Page.of 로 응답 (기존과 동일)
}정렬 필드를 문자열 그대로 받아 리플렉션이나 동적 쿼리 빌더에 흘리면, 존재하지 않는 필드로 500 이 나거나 최악의 경우 정렬 표현식 자체가 조작(인젝션)될 수 있습니다. 화이트리스트는 짧은 코드로 이 경로를 완전히 막습니다.