Schema와 Query Controller
REST가 자원별 endpoint와 응답 모양을 서버가 고정한다면, GraphQL은 schema가 가능한 타입과 연산을 정하고 client가 필요한 field를 선택합니다. 새 저장소를 만들지 않고 5장의 VersionedStudySessionService를 그대로 사용해, 같은 데이터와 method security 위에 다른 전송 계약을 올립니다.
GraphQL 계약은 schema에서 시작한다
schema-first라는 말은 Java class를 지우고 schema만 작성한다는 뜻이 아닙니다. client가 볼 수 있는 field·null 가능성·연산 이름을 먼저 고정하고, Controller가 그 계약을 기존 유스케이스에 연결한다는 뜻입니다. StudySessionResponse와 schema StudySession의 이름은 다르지만 field accessor가 맞으면 기본 property data fetcher가 값을 읽습니다.
Query와 DTO를 Controller에 연결한다
spring-boot-starter-graphql은 src/main/resources/graphql/**의 schema를 합쳐 실행 schema를 만듭니다. schema에 정의한 field와 Controller mapping이 엇갈리면 startup inspector와 실행 오류로 드러납니다. @QueryMapping은 Query.studySessions와 Query.studySession에 연결되고, @PreAuthorize 때문에 유효한 JWT라도 study.read가 없으면 data fetcher를 실행하지 못합니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>type StudySession {
id: ID!
subject: String!
topic: String!
studyDate: String!
minutes: Int!
status: SessionStatus!
version: String!
}
enum SessionStatus {
PLANNED
COMPLETED
CANCELLED
}
type Query {
studySessions: [StudySession!]!
studySession(id: ID!): StudySession
}package com.andongmin.studylog.graphql;
import java.util.List;
import java.util.UUID;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import com.andongmin.studylog.session.StudySessionResponse;
import com.andongmin.studylog.session.VersionedStudySessionService;
@Controller
public class StudySessionGraphQlController {
private final VersionedStudySessionService service;
public StudySessionGraphQlController(VersionedStudySessionService service) {
this.service = service;
}
@QueryMapping
@PreAuthorize("hasAuthority('SCOPE_study.read')")
public List<StudySessionResponse> studySessions() {
return service.findAll();
}
@QueryMapping
@PreAuthorize("hasAuthority('SCOPE_study.read')")
public StudySessionResponse studySession(@Argument UUID id) {
return service.findOne(id);
}
}document 실행 결과의 data 경로를 확인한다
terminal A에 local 서버를 두고 terminal B에서 read token으로 document를 POST합니다. REST와 달리 모든 성공 결과는 data 아래에 연산 이름으로 담깁니다. document에 id subject minutes만 적었다면 topic·status·version은 응답에 없어야 합니다. write-only token으로 같은 query를 보내 errors로 거부되는지도 비교하면 method security 경계까지 확인할 수 있습니다.
export LOCAL_JWT_SECRET="study-log-local-development-key-2026"
SPRING_PROFILES_ACTIVE=local ./mvnw -q spring-boot:runexport LOCAL_JWT_SECRET="study-log-local-development-key-2026"
READ_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.read")"
curl -s http://localhost:8080/graphql \
-H "Authorization: Bearer $READ_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"query\":\"{ studySessions { id subject minutes } }\"}"ch4-4의 단일 filter chain이 JWT를 인증하고 `@PreAuthorize`가 study.read scope를 확인한 뒤, 응답 data.studySessions에는 요청한 id, subject, minutes 필드만 있어야 합니다.null 계약은 실패 범위를 결정한다
[StudySession!]!은 목록 자체도 null이 아니고 요소도 null이 아니라는 약속입니다. non-null field resolver가 실패하면 GraphQL은 가까운 nullable 부모까지 null을 올리는 null bubbling을 수행합니다. 편의상 모든 field에 !를 붙이지 말고, 정말 반환할 수 없는 값만 non-null로 정합니다.
조회는 연결됐습니다. 생성과 완료는 입력 검증과 업무 실패를 errors 계약으로 표현해야 하므로 query보다 한 단계 더 많은 경계가 필요합니다.