들어가기 전
팟캐스트 플랫폼을 만들기 위해 어떤 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로 피드를 쉽게 불러올 수 있다.
'Server > 공부' 카테고리의 다른 글
[Spring/프로젝트] 교정 좋아요 여부 조회 최적화: N+1 문제 해결하기 (0) | 2025.07.22 |
---|---|
[AI] RAG과 임베딩의 개념 : AI 챗봇 만들기 -1 (0) | 2025.07.14 |
[서버/공부] 데이터베이스 설계 및 정규화 이론 (0) | 2024.11.05 |