Mutation 입력과 오류 경계
query가 현재 상태를 읽는다면 mutation은 생성한다, 완료한다 같은 의도를 이름으로 드러냅니다. HTTP status 하나로 성공과 실패를 나누던 REST와 달리, GraphQL은 200 response 안에 data와 errors가 함께 있을 수 있습니다. client가 문구를 parsing하지 않도록 extensions.code를 안정된 오류 계약으로 정합니다.
Mutation은 상태 전이 의도를 드러낸다
input type은 mutation이 필요로 하는 값을 하나의 계약으로 묶습니다. Java record의 validation은 형식과 범위를 거르고, service는 상태 전이와 동시성을 지킵니다. GraphQL error의 path는 어느 field resolver에서 실패했는지를, extensions.code는 어떤 종류의 실패인지를 나타냅니다.
입력 record와 예외 resolver를 작성한다
schema의 String 날짜는 Controller에서 LocalDate.parse 시점에 유효성이 확정됩니다. 잘못된 날짜, Bean Validation 실패, 없는 세션을 DataFetcherExceptionResolverAdapter에서 서로 다른 code로 바꿉니다. 알 수 없는 예외에서 null을 반환하면 다른 resolver나 기본 처리가 이어받으므로, 모든 예외를 INVALID_INPUT으로 숨기지 않습니다.
input CreateStudySessionInput {
subject: String!
topic: String!
studyDate: String!
minutes: Int!
}
type Mutation {
createStudySession(input: CreateStudySessionInput!): StudySession!
completeStudySession(id: ID!): StudySession!
}package com.andongmin.studylog.graphql;
import java.time.LocalDate;
import java.util.UUID;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.validation.annotation.Validated;
import com.andongmin.studylog.session.CreateStudySessionRequest;
import com.andongmin.studylog.session.StudySessionResponse;
import com.andongmin.studylog.session.VersionedStudySessionService;
@Controller
@Validated
public class StudySessionMutationController {
private final VersionedStudySessionService service;
public StudySessionMutationController(VersionedStudySessionService service) {
this.service = service;
}
@MutationMapping
@PreAuthorize("hasAuthority('SCOPE_study.write')")
public StudySessionResponse createStudySession(@Argument @Valid CreateInput input) {
return service.create(new CreateStudySessionRequest(
input.subject(), input.topic(), LocalDate.parse(input.studyDate()),
input.minutes()));
}
@MutationMapping
@PreAuthorize("hasAuthority('SCOPE_study.write')")
public StudySessionResponse completeStudySession(@Argument UUID id) {
return service.complete(id);
}
record CreateInput(
@NotBlank String subject,
@NotBlank String topic,
@NotBlank String studyDate,
@Min(1) @Max(1440) int minutes) {
}
}package com.andongmin.studylog.graphql;
import java.time.format.DateTimeParseException;
import java.util.Map;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import jakarta.validation.ConstraintViolationException;
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
import org.springframework.stereotype.Component;
import com.andongmin.studylog.session.SessionNotFoundException;
@Component
public class StudySessionGraphQlErrorResolver
extends DataFetcherExceptionResolverAdapter {
@Override
protected GraphQLError resolveToSingleError(
Throwable failure, DataFetchingEnvironment environment) {
if (failure instanceof ConstraintViolationException
|| failure instanceof DateTimeParseException
|| failure instanceof IllegalArgumentException) {
return error(environment, "Invalid study session input", "INVALID_INPUT");
}
if (failure instanceof SessionNotFoundException) {
return error(environment, "Study session not found", "SESSION_NOT_FOUND");
}
return null;
}
private GraphQLError error(
DataFetchingEnvironment environment, String message, String code) {
return GraphqlErrorBuilder.newError(environment)
.message(message)
.extensions(Map.of("code", code))
.build();
}
}{
"query": "mutation Create($input: CreateStudySessionInput!) { createStudySession(input: $input) { id status } }",
"variables": {
"input": {
"subject": "Spring",
"topic": "GraphQL",
"studyDate": "2026-07-13",
"minutes": 60
}
}
}{
"query": "mutation Create($input: CreateStudySessionInput!) { createStudySession(input: $input) { id } }",
"variables": {
"input": {
"subject": "Spring",
"topic": "GraphQL",
"studyDate": "not-a-date",
"minutes": 0
}
}
}성공 data와 errors 배열을 비교한다
서버는 terminal A에 유지하고 terminal B에서 두 JSON document를 순서대로 보냅니다. 정상 요청은 data.createStudySession.status=PLANNED를, 잘못된 날짜와 0분 요청은 errors[0].extensions.code=INVALID_INPUT을 가져야 합니다. read-only token으로 같은 mutation을 보내면 resolver에 도달하기 전에 @PreAuthorize가 거부해야 합니다.
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"
WRITE_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.write")"
curl -s http://localhost:8080/graphql \
-H "Authorization: Bearer $WRITE_TOKEN" \
-H "Content-Type: application/json" --data-binary @create-session.json
curl -s http://localhost:8080/graphql \
-H "Authorization: Bearer $WRITE_TOKEN" \
-H "Content-Type: application/json" --data-binary @invalid-session.jsonstudy.write scope가 있을 때 data.createStudySession.status는 PLANNED여야 합니다. 잘못된 날짜·분량은 `errors[0].extensions.code`에 `INVALID_INPUT`을 남기고, study.read만 있는 토큰은 mutation method에서 거절됩니다.오류 코드와 문구의 생명주기를 나눈다
message는 사용자 문구나 번역 정책에 따라 바뀌 수 있지만 extensions.code는 client code와 약속한 값입니다. 새 오류를 추가할 때는 HTTP status를 억지로 끄어오기보다 GraphQL field의 부분 성공 가능성과 client가 취할 조치를 기준으로 code를 정합니다.
mutation까지 연결되었지만, 목록의 각 세션에 과목 통계를 붙이면 부모 수만큼 추가 SQL이 나갈 수 있습니다. GraphQL이 필드 단위로 resolver를 실행하는 특성을 batch로 다룹니다.