본문 바로가기

[Server/공부] GraphQL이란? REST와 비교

@hyeon.s2025. 8. 10. 15:01

들어가기 전

팟캐스트 플랫폼을 만들기 위해 어떤 API 개발을 사용할지, 고민하던 중 GraphQL에 대해서 탐구하고자 해당 포스팅을 작성합니다.

GraphQL이란?

  • 클라이언트가 ‘원하는 데이터 필드만’ 한 번의 요청으로 가져오게 해주는 API 쿼리 언어이다.
  • API는 보통 단일 엔드포인트(/graphql)이고, 스키마로 타입과 질의/변형을 엄격히 정의한다.

왜 GraphQL을 사용하나?

1. 오버패칭 / 언더패팅 해결

  • 필요한 필드만 선택하여 → 데이터 낭비가 줄고, 모바일 트래픽/배터리가 절약된다.
  • 한 화면에서 여러 리소스(피드, 프로필 ,추천)를 1~2회 호출로 묶어준다.

2. 타입 스키마 기반 개발 경험

  • SDL (Schema.graphqls)로 클라,서버 계약이 명확하다.
  • 자동 문서화가 된다.

3. 버저닝 부담이 줄어든다.

  • 새 필드는 추가, 구 필드는 @deprecated → /v1 → /v2 같은 대규모 버전 분기 줄어듦.

GraphQL과 REST 비교 시 갖는 차이

  • GraphQL은 보통 하나의 엔드포인트를 갖는다.
  • 요청할 때 사용하는 쿼리에 따라 다른 응답을 받을 수 있다.
  • 원하는 데이터만 받을 수 있다.

하지만 GraphQL의 단점

  • 서버 복잡도가 올라간다 → N+1쿼리 문제 → DataLoader로 배치 로딩이 필요하다.
  • 고정된 요청과 응답만 필요할 때에는 query로 인해 요청의 크기가 Restful보다 커질 수 있다.
  • 캐싱이 REST보다 복잡하다.
  • 파일 업로드 구현 방법이 정해져있지 않아 직접 구현해야 한다.

어떤 상황에 REST? GraphQL?

  • GraphQL은 읽기 중심의 화면에 필요한 데이터가 여러 리소스에 흩어져 있을 때 사용하기 좋다.
    • 현재 플랫폼 기획상 프로필/둘러보기/검색 결과 합본 등이 한번에 보여지는 경우가 있기에 사용하기 적절하다고 볼 수 있다.
  • REST : 쓰기 (생성/수정/삭제), 파일 업로드(S3 presign), 웹훅, 단순 리소스 조회/캐싱의 경우 사용하기 좋다.

→ 따라서 우리 서비스에서는 복잡한 읽기 = GraphQL / 쓰기,업로드,웹훅 = REST의 방식으로 기술을 선택하고자 한다.

Spring GraphQL 작성방법

1) 의존성 (Gradle)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-graphql'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
}

2) GraphQL 스키마

(src/main/resources/graphql/schema.graphqls)

type Query {
  homeFeed(page: Int = 0, size: Int = 10): FeedPage!
}

type FeedPage {
  content: [Podcast!]!
  page: Int!
  size: Int!
  totalElements: Int!
}

type Podcast {
  id: ID!
  title: String!
  description: String
  coverUrl: String
  audioUrl: String
  likeCount: Int!
  createdAt: String!
  author: User!
}

type User {
  id: ID!
  nickname: String!
  profileImageUrl: String
}

3) JPA 엔티티 (아주 단순화)

// UserEntity.java
@Entity
@Table(name = "users")
public class UserEntity {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String nickname;
  private String profileImageUrl;

  // getters/setters
}

// PodcastEntity.java
@Entity
@Table(name = "podcasts")
public class PodcastEntity {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String title;
  private String description;
  private String coverUrl;
  private String audioUrl;
  private Integer likeCount;
  private LocalDateTime createdAt;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "author_id")
  private UserEntity author;

  // getters/setters
}


4) 리포지토리

public interface PodcastRepo extends JpaRepository<PodcastEntity, Long> {
  Page<PodcastEntity> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
public interface UserRepo extends JpaRepository<UserEntity, Long> {}

5) 응답 DTO (GraphQL 타입에 맞춰 매핑)

// GqlModels.java
public record UserGql(Long id, String nickname, String profileImageUrl) {
  public static UserGql from(UserEntity e) {
    return new UserGql(e.getId(), e.getNickname(), e.getProfileImageUrl());
  }
}

public record PodcastGql(
    Long id, String title, String description,
    String coverUrl, String audioUrl, int likeCount,
    String createdAt, UserGql author
) {
  public static PodcastGql from(PodcastEntity e) {
    return new PodcastGql(
        e.getId(),
        e.getTitle(),
        e.getDescription(),
        e.getCoverUrl(),
        e.getAudioUrl(),
        e.getLikeCount() == null ? 0 : e.getLikeCount(),
        e.getCreatedAt().toString(),                  // ISO 문자열
        UserGql.from(e.getAuthor())
    );
  }
}

public record FeedPageGql(
    List<PodcastGql> content, int page, int size, int totalElements
) {}


6) GraphQL 쿼리 핸들러 (Resolver)

// FeedQueryController.java
@Controller
@RequiredArgsConstructor
public class FeedQueryController {

  private final PodcastRepo podcastRepo;

  @QueryMapping
  public FeedPageGql homeFeed(@Argument int page, @Argument int size) {
    var pg = podcastRepo.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size));

    // LAZY author N+1이 걱정되면 여기서 미리 fetch join 쿼리를 써도 됩니다.
    var list = pg.getContent().stream()
        .map(PodcastGql::from)
        .toList();

    return new FeedPageGql(list, page, size, (int) pg.getTotalElements());
  }
}

팁: N+1 최적화가 필요하면

① @EntityGraph(attributePaths = "author") 를 리포지토리에 붙이거나,

② JPQL에 fetch join 사용,

③ DataLoader 사용(한 번에 author들 배치 조회) 중 하나를 쓰면 된다.


7) 간단 초기데이터(선택)

@Component
@RequiredArgsConstructor
class DataInit implements CommandLineRunner {
  private final UserRepo userRepo;
  private final PodcastRepo podcastRepo;

  @Override public void run(String... args) {
    var u = new UserEntity();
    u.setNickname("summer"); u.setProfileImageUrl(null);
    userRepo.save(u);

    for (int i = 1; i <= 3; i++) {
      var p = new PodcastEntity();
      p.setTitle("에피소드 " + i);
      p.setDescription("설명 " + i);
      p.setLikeCount(10 * i);
      p.setCreatedAt(LocalDateTime.now().minusDays(i));
      p.setAuthor(u);
      podcastRepo.save(p);
    }
  }


8) 실행 & 쿼리 예

서버 띄운 뒤 /graphiql 또는 /playground(설정에 따라 다름)에서 아래 쿼리 실행:

query {
  homeFeed(page: 0, size: 10) {
    content {
      id
      title
      likeCount
      createdAt
      author { id nickname }
    }
    totalElements
  }
}

응답은 content에 최신순 팟캐스트 목록이 담겨옵니다.

 

  • DTO 매핑
    GraphQL의 type(스키마)에 맞춘 DTO(또는 record)를 만들어 응답 모양을 통제
    → 엔티티를 그대로 노출하지 않고, 날짜 포맷·필드명·중첩 구조를 클라 친화적으로 매핑.
  • @QueryMapping 역할
    스키마의 Query 필드(예: homeFeed, profile)에 대응하는 리졸버 메서드
    클라이언트가 쿼리를 보내면 이 메서드가 호출되어 요청한 필드들만 담아 반환.
  • “한 번에 가져온다”의 의미
    GraphQL은 하나의 HTTP 요청에서처럼 여러 루트 필드중첩 필드를 선택할 수 있다.
    내부에선 각 필드 리졸버가 실행되고, 관계 필드(author 등)는 DataLoader/페치 조인으로 N+1을 막아 효율적으로 묶어준다.

한 줄 요약

  • schema.graphqls로 타입/쿼리 정의 →
  • JPA로 데이터 조회 → DTO로 간단 매핑 →
  • @QueryMapping으로 반환.
  • 이렇게만 하면 Spring GraphQL로 피드를 쉽게 불러올 수 있다.

 

 

hyeon.s
@hyeon.s :: 개발로그
목차