꿈소년의 개발 이야기

[스프링 부트 개발자 온보딩 가이드 스터디] Chapter 07 GraphQL 기반 마이크로블로그 API 서버 개발 본문

Do it 스터디!/스프링 부트 개발자 온보딩 가이드 스터디!

[스프링 부트 개발자 온보딩 가이드 스터디] Chapter 07 GraphQL 기반 마이크로블로그 API 서버 개발

fogthegreat 2026. 4. 5. 21:27
반응형

DAY 9

🔖 오늘 읽은 범위 :

🌱공부 내용: Chapter 07 GraphQL 기반 마이크로블로그 API 서버 개발
👢쪽수: p.291 - p.344


  • 목표: GraphQL 기반 Minilog API 재구현
  • 기능 요구사항
    • GraphQL Query 기능
    • GraphQL Mutation 기능
  • 구현 요구사항
    • 엔드포인트는 /graphql 을 사용한다.
    • 해당 엔드포인트를 통한 GraphQL 연산은 인증을 거친 요청에 한해 허용한다.

GraphQL 이해하기

GraphQL: API를 위한 쿼리 언어

GraphQL | A query language for your API

GraphQL

  • API 쿼리 언어
  • 클라이언트가 원하는 데이터 구조를 직접 정의할 수 있어 유연하고 효율적임.
  • 중요한 포인트는 QL(Query Language).
  • 클라이언트가 필요한 데이터 형태를 선언적으로 정의하고 서버가 구조에 맞게 정확히 일치하는 데이터를 반환한다.

GraphQL 핵심 개념

  • 선언적 데이터 요청
    • 클라이언트가 “필요한 데이터와 그 구조를 명시적으로 정의”할 수 있다.
    • 서버는 요청된 필드만 반환하기 때문에 불필요한 데이터 전송이 없다.
  • 단일 엔드포인트
    • 모든 요청이 단일 엔드포인트를 통해 처리 된다.
    • 필요한 데이터를 한번에 가져온다.
  • 타입 시스템
    • 엄격한 타입 시스템 제공.
    • API 데이터 구조를 명확하게 정의함.
  • 오버 패칭이 없다.
  • 언더 패칭은 단일 요청으로 해결 가능하다.

GraphQL 구성 요소

스키마

  • 클라이언트가 요청할 수 있는 데이터 구조 정의.

쿼리

  • 데이터를 조회하는 요청.
  • 필요한 데이터와 구조를 클라이언트가 명시적을 요청할 수 있다.

뮤테이션

  • 데이터 수정 또는 추가하는 요청.
  • 새로운 데이터 생성, 기존 데이터 업데이트도 포함.

서브스크립션

  • 실시간 데이터 업데이트 요청.
  • 웹 소켓을 활용하기 때문에 GraphQL 은 실시간 데이터 업데이트가 가능하다.
  • 웹 소켓 연결을 통해 서버와 클라이언트 간의 지속적인 연결을 유지하여, 서버에서 발생하는 이벤트를 실시간으로 클라이언트에게 전달할 수 있다. 클라이언트는 데이터 변경 사항을 즉시 수신할 수 있다.

리졸버

  • 요청에 대한 실제 데이터를 제공하는 함수.

  • 데이터를 다양한 소스에서 가져올 수 있다. 데이터베이스, REST API 엔드포인트, 다른 GraphQL 서버에서도 데이터를 가져올 수 있다.

  • 스프링 부트3에서 GraphQL 리졸버는 일반적으로 @QueryMapping, @MutationMapping 과 같은 애노테이션을 사용하여 정의한다.

  • 예시

      // 1. Database 에서 가져오기
      @RestController
      @RequestMapping("/graphql")
      public class UserResolver {
          @QueryMapping
          public User getUser(@Argument Long id) {
              return userRepository.findById(id).orElseThrow();
          }
      }
    
      // 2. 다른 Rest API 엔드포인트에서 데이터 가져오기
      @RestController
      @RequestMapping("/graphql")
      public class UserResolver {
          private final RestTemplate restTemplate;
    
          public UserResolver(RestTemplate restTemplate) {
              this.restTemplate = restTemplate;
          }
    
          @QueryMapping
          public User getUser(@Argument Long id) {
              String url = "https://api.example.com/users/" + id;
              return restTemplate.getForObject(url, User.class);
          }
      }
    
      // 3. 다른 GraphQL 서버에서 데이터 가져오기
      @RestController
      @RequestMapping("/graphql")
      public class UserResolver {
          private final GraphQLClient graphQLClient;
    
          public UserResolver(GraphQLClient graphQLClient) {
              this.graphQLClient = graphQLClient;
          }
    
          @QueryMapping
          public User getUser(@Argument Long id) {
              String query = "{ user(id: " + id + ") { name email } }";
              return graphQLClient.execute(query, User.class);
          }
      }

GraphQL 동작 구조

  • 서버 → 클라이언트 요청 처리 단계
    1. 클라이언트 요청 수신
      1. 클라이언트는 단일 엔드포인트로 쿼리 또는 뮤테이션 요청 보낸다.
    2. 스키마와 요청 매핑
      1. 서버는 요청 내용을 스키마에 정의한 타입 및 필드와 매핑한다.
    3. 리졸버 호출
      1. 매핑된 필드에 따라 리졸버를 호출하고 필요한 데이터를 조회 또는 연산을 수행한다.
    4. 응답 생성 및 반환.
      1. 리졸버 결과를 기반으로 응답을 구성한 후 클라이언트에게 반환한다.
      • 리졸버에서 반환 한 결과들을 가지고, GraphQL 서버(Spring Boot 3)에서 최종 응답을 구성하여 전달한다.

GraphQL 스키마

  • 클라이언트가 요청할 수 있는 데이터 타입과 구조 정의

타입(Type)

  • 데이터 표현 기본 단위.
  • String, Int, Boolean 같은 기본 타입
  • 사용자 정의 타입.
GraphQL Java
Int Integer
Float Double
String String
Boolean Boolean
ID String or Long

쿼리 타입

  • 데이터 조회 가능한 필드 정의.

뮤테이션 타입

  • 데이터 추가, 수정, 삭제 할 경우 사용하는 필드 정의.

필드

  • 각 타입의 속성.
  • 특정 데이터에 대한 접근 방법 정의.

쿼리

  • 데이터 조회.
  • 요청할 필드를 명시적으로 정의.
  • 필요한 데이터만 가져올 수 있게 불필요한 데이터 제외 가능.
{
    user {
        name
        age
    }
}

뮤테이션

  • 데이터 추가, 수정, 삭제 할때 사용한다.
  • REST 의 POST, PUT, DELETE 요청.
mutation {
    addMessage(content: "Hello, GraphQL") {
        id
        content
    }
}

에러 처리

  • data 필드와 errors 필드가 포함 된 응답 반환.
  • 에러 처리 흐름
    1. 클라이언트가 잘못된 쿼리를 서버에 전송한다.
    2. 서버가 스키마와 쿼리를 비교하여, 유효성을 검사하는 과정에서 오류 감지한다.
    3. 서버는 오류 메시지를 포함한 응답을 생성하여 클라이언트에게 반환한다.
{
    "errors": [
        {
            "message": "Field 'unknownField' not found in type 'Query'",
            "locations": [ { "line": 1, "column": 2 } ]
        }
    ]
}

GraphQL 에서 고려해야 할 문제

  • 복잡한 서버 설정
    • 설정이 REST API 보다 복잡하다.
    • 데이터 로더와 같은 최적화 도구가 추가로 필요할 수 있다.
  • 캐싱 문제
    • REST HTTP 캐싱(headers, status code 등)을 활용하기 어렵다.
    • 클라이언트가 별도의 캐싱 전략을 구현해야 할 수도 있다.
  • 스키마 설계의 중요성
    • 스키마가 API 설계의 중심이다. 따라서 설계 단계에서 신중하게 계획할 필요가 있다.

프로젝트

// application.properties
# GraphiQL을 사용하는 경우, 엔드포인트 설정
spring.graphql.graphiql.enabled=true // GraphQL 오퍼레이션 실행 여부. 상용 배포에는 false 해야 함.
spring.graphql.graphiql.path=graphiql

스키마 정의

  • 지정 경로

      <Project 경로>/src/main/resources/graphql/schema.graphqls
  • 스칼라 타입 확장

    • Long, DateTime 같은 것을 정의하여 다양한 데이터 유형 지원.
  • 쿼리, 뮤테이션 오퍼레이션 정의

  • Input Type 활용

    • 뮤테이션 인 경우, Input Type 을 활용해 코드 확장성과 가독성 향상.
  • 응답 타입 정의

스칼라 정의

  • Scalar : 기본 데이터 유형

  • 지원되지 않는 데이터 유형은 확장 시켜서 스칼라를 직접 정의한다.

      scalar Long
      scalar DateTime @specifiedBy(url:"https://tools.ietf.org/html/rfc3339")
  • 실제 매핑 처리 로직은 GraphQLConfig 에서 구현한다.

쿼리 정의

type Query {
    getArticles(userId: Long!): [ArticleResponse]
    getArticle(articleId: Long!): ArticleResponse
    getFeedList(followerId: Long!): [ArticleResponse]
    getFollowList(followerId: Long!): [FollowResponse]
    getUsers: [UserResponse]
    getUserById(userId: Long!): UserResponse
}

뮤테이션 정의

type Mutation {
    createArticle(input: CreateArticleInput!): ArticleResponse
    updateArticle(input: UpdateArticleInput!): ArticleResponse
    deleteArticle(articleId: Long!): Boolean
    follow(followeeId: Long!): FollowResponse
    unfollow(followeeId: Long!): Boolean
    createUser(input: CreateUserInput!): UserResponse
    updateUser(input: UpdateUserInput!): UserResponse
    deleteUser(userId: Long!): Boolean
}

뮤테이션 Input Type 정의

input CreateArticleInput {
    content: String!
}

응답 타입 정의

type ArticleResponse {
    articleId: Long
    content: String
    authorId: Long
    authorName: String
    createdAt: DateTime
}

GraphQLConfig - 스칼라 타입 매핑

// @Configuration 을 통해서 스프링 부트가 애플리케이션 실행 시, 이 설정 클래스를 자동 인식한다.
// 여기서 RuntimeWiringConfigurer 빈이 자동 등록되고, GraphQL 실행 시점에 해당 설정이 자동 적용된다.
@Configuration
public class GraphQLConfig {

    // RuntimeWiringConfigurer: graphql 실행 환경(Runtime Wiring)을 구성하는 인터페이스.
    // GraphQL 엔진에 등록할 수 있다.
  @Bean
  public RuntimeWiringConfigurer runtimeWiringConfigurer() {
    return wiringBuilder ->
        wiringBuilder
        .scalar(ExtendedScalars.DateTime) // DateTime 타입을 RFC3339 표준 형식으로 처리 지원.
        .scalar(ExtendedScalars.GraphQLLong); // 64bit 정수 처리.
  }
}

전역 GraphQL 예외 처리

@Component
public class MinilogGraphQLExceptionResolver extends DataFetcherExceptionResolverAdapter {
  private static final Logger logger =
      LoggerFactory.getLogger(MinilogGraphQLExceptionResolver.class);

  @Override
  protected @Nullable GraphQLError resolveToSingleError(
      Throwable exception, DataFetchingEnvironment env) {
    logger.error(
        "Error occurred while fetching data at {}: {}",
        env.getExecutionStepInfo().getPath(),
        exception.getMessage(),
        exception);

    GraphQLError error;
    var handlerParameterPath = env.getExecutionStepInfo().getPath();

    if (exception instanceof ArticleNotFoundException
        || exception instanceof UserNotFoundException) {
      error =
          GraphqlErrorBuilder.newError()
              .message(exception.getMessage())
              .errorType(ErrorType.NOT_FOUND)
              .path(handlerParameterPath)
              .build();
    } else if (exception instanceof NotAuthorizedException) {
      error =
          GraphqlErrorBuilder.newError()
              .message(exception.getMessage())
              .errorType(ErrorType.UNAUTHORIZED)
              .path(handlerParameterPath)
              .build();
    } else if (exception instanceof IllegalArgumentException) {
      error =
          GraphqlErrorBuilder.newError()
              .message(exception.getMessage())
              .errorType(ErrorType.BAD_REQUEST)
              .path(handlerParameterPath)
              .build();
    } else {
      error =
          GraphqlErrorBuilder.newError()
              .message("Internal Server Error")
              .errorType(ErrorType.INTERNAL_ERROR)
              .path(handlerParameterPath)
              .build();
    }

    return error;
  }
}
  • GraphQL 표준 오류 타입은 아래와 같다. 보통 이 기준으로 분류가 된다.

      public enum ErrorType implements ErrorClassification {
    
          /**
           * {@link graphql.schema.DataFetcher} cannot or will not fetch the data value due to
           * something that is perceived to be a client error.
           */
          BAD_REQUEST,
    
          /**
           * {@link graphql.schema.DataFetcher} did not fetch the data value due to a lack of
           * valid authentication credentials.
           */
          UNAUTHORIZED,
    
          /**
           * {@link graphql.schema.DataFetcher} refuses to authorize the fetching of the data
           * value.
           */
          FORBIDDEN,
    
          /**
           * {@link graphql.schema.DataFetcher} did not find a data value or is not willing to
           * disclose that one exists.
           */
          NOT_FOUND,
    
          /**
           * {@link graphql.schema.DataFetcher} encountered an unexpected condition that
           * prevented it from fetching the data value.
           */
          INTERNAL_ERROR
    
      }

GraphQL 적용 된 API 구조

graph LR
  GraphQLResolver(GraphQL Resolver) --> Service(Service)
  Service --> Repository(Repository)
  Repository --> Database[(Database)]

API 테스트

GraphQL 도입은 언제?

  • 도입 기준은?
    • 클라이언트가 각각 다른 화면이나 컴포넌트가 다양한 데이터 조합을 필요로 할 때.
    • 이때 클라이언트는 하나가 아니라 여러 곳이다. 모바일, 웹, 외부 오픈 API 등등.
    • 여러 서비스 특히 마이크로 서비스나 여러 DB 등일 경우에 데이터를 조합해서 한꺼번에 내려줘야 할 때.
  • REST API 의 경우,
    • API 구조가 단순한 경우.
    • 각 화면에 필요한 데이터가 정해져 있는 경우.
    • GraphQL 에 대한 경험이 적거나 없어서 학습이 아직 필요한 경우.
  • REST 한계점.
    • 오버 패칭(Over-Fetching; 불필요한 데이터까지 가져오는 문제)
    • 언더 패칭(Under-Fetching; 원하는 데이터를 한번에 못 받아서 여러 번 호출해야 하는 문제)
  • GraphQL 한계점.
    • 설계와 운영의 복잡성
    • N+1 쿼리 문제
    • 권한/인증 체크의 어려움. ⇒ 특정한 테이블 접근 권한을 제어할 수 있나?
    • 단일 엔드포인트로 인한 요청 패턴과 데이터 조합의 다양성으로 인해 모니터링과 트러블 슈팅의 어려움.
    • 스키마의 작은 변화에도 민감한 서비스 영향도와 그로 인한 배포 및 운영 관리의 복잡성 문제.
  • 일반적으로 REST 로 시작하고, 서비스와 데이터 구조가 복잡해질 때, 일부 API 를 GraphQL 로 점진적 전환 방식을 채택한다.
  • GraphQL API 를 경험한 적이 있는데, 실제 작업해보니 그 당시 백엔드 개발자들의 고민을 느껴보게 된다.
반응형