공공부하자개발 · 영어 학습 노트
자바
실무 확장Excel · 파일 업로드 · DB 연동0/22 완료
  • 01Excel(XLSX) 구조와 순수 JDK로 읽기/쓰기
  • 02Apache POI로 Excel 업로드/다운로드
  • 03파일 업로드/다운로드 서버 (HttpServer)
  • 04JDBC 기초와 트랜잭션 (H2)
  • 05MyBatis 어노테이션 매퍼로 쿼리 연동
  • 06MyBatis XML 매퍼 · Oracle 방언 · PageHelper · Spring Boot
  • 07REST API 서버와 JSON
  • 08Vue 3 SPA 와 Java 서버 연동
  • 09@Scheduled 운영
  • 10로깅 실무: 레벨·계층, MDC 추적, 예외·성능, 마스킹, 롤링, JSON 로그
  • 11외부 API 연동
  • 12테스트 실무
  • 13암호화·개인정보 보호
  • 14인코딩·한글 실무
  • 15@Transactional 심화
  • 16긴 작업 비동기 처리와 진행률
  • 17SFTP·FTP 파일 연계
  • 18로컬 캐시와 @Cacheable
  • 19메일·알림 발송
  • 20웹 보안 체크리스트
  • 21빌드 도구와 폐쇄망 의존성 반입
  • 22성능 측정: p50·p95·p99, 측정 계층, JFR, JMH 함정, 자체 부하 테스트, 병목 순위
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 실무 확장 › 07 / 22

REST API 서버와 JSON

CRUD, 상태 코드, 에러 규격, 검증, 페이징
섹션 7진행 0 / 22
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

6. 연습 문제

문제 1

Page.of 는 오프셋 페이징입니다. MemberStore 에 findAfter(long lastId, int size) 를 추가해 키셋 페이징을 구현하세요. id 가 lastId 보다 크고 오름차순으로 정렬된 상위 size 개만 돌려주고, MemberApi 에 GET /members/seek?after=0&size=2 핸들러를 등록하세요. lastId=0 은 첫 페이지를 뜻합니다.

정답 보기
java
// 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();
}
java
// 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 커서 방식(피드, 알림 목록)에 적합하고, 관리자 화면의 페이지 번호 이동에는 오프셋 페이징이 낫습니다.

문제 2

PUT 을 version 필드 대신 If-Match 헤더로 검증하도록 바꾸세요. 헤더가 없으면 428(Precondition Required 의미로 400 사용), 헤더 값이 현재 ETag 와 다르면 409 를 응답해야 합니다. MemberApi.replace 를 수정하세요.

정답 보기
java
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 게이트웨이가 별도 파싱 없이 조건부 요청을 이해할 수 있다는 장점이 있습니다.

문제 3

GET /members 의 sort 파라미터는 지금 name, age, 그 외(id)만 받습니다. 화이트리스트에 없는 필드(sort=password,asc 등)가 오면 400 을 응답하도록 MemberApi.list 를 수정하세요.

정답 보기
java
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 이 나거나 최악의 경우 정렬 표현식 자체가 조작(인젝션)될 수 있습니다. 화이트리스트는 짧은 코드로 이 경로를 완전히 막습니다.

연습 문제
  • 문제 1
  • 문제 2
  • 문제 3
이전 섹션5 자주 하는 실수 (Tip)6 / 7다음 섹션7 정리