장쫄깃 기술블로그

[Spring Boot] Spring Boot를 이용한 GraphQL (with TDD) 본문

Spring Framework/Spring Boot

[Spring Boot] Spring Boot를 이용한 GraphQL (with TDD)

장쫄깃 2025. 1. 18. 23:40
728x90


GraphQL이란?


GraphQL은 API를 위한 쿼리 언어이자 이를 실행하기 위한 런타임으로, 클라이언트가 필요한 데이터만 명시적으로 요청하고 받을 수 있게 해 준다. REST API의 단점을 보완하고 유연한 데이터 요청 및 처리를 가능하게 한다.

 

주요 특징으로는 다음과 같다.

1. 클라이언트 주도 데이터 요청

클라이언트가 원하는 데이터의 구조를 지정하여 요청을 보내면, 서버는 정확히 그 구조에 맞는 데이터를 반환한다.

query {
    user(id: 1) {
        id
        name
        email
    }
}

 

2. 단일 엔드포인트

REST API와 달리, GraphQL은 하나의 엔드포인트로 모든 데이터를 요청할 수 있습니다. (예: /grqphql)

 

3. Overfetching과 Underfetching 문제 해결

  • Overfetching: REST API에서 불필요한 데이터까지 가져오는 문제
  • Underfetching: 필요한 데이터를 위해 여러 요청을 보내야 하는 문제. GraphQL은 필요한 데이터만 선택적으로 가져와 이러한 문제들을 해결

 

GraphQL 주요 구성 요소


1. 스키마 (Schema)

API의 데이터 구조와 쿼리 가능한 데이터 타입을 정의한다.

type Query {
    user(id: ID!): User
}

 

2. 쿼리 (Query)

데이터를 읽기 위한 요청이다.

query {
    user(id: 1) {
      name
      email
    }
}

 

3. 변형 (Mutation)

데이터를 생성, 수정, 삭제하는 요청이다.

mutation {
    createUser(name: "Alice", email: "alice@example.com") {
      id
      name
    }
}

 

 

GraphQL의 장단점


장점

  • 유연하고 효율적인 데이터 요청
  • 강력한 타입 시스템을 통한 API 문서화 및 검증
  • 단일 엔드포인트로 다양한 데이터 처리
  • 클라이언트와 서버 간 통신의 명확성 향상

단점

  • REST API보다 복잡한 초기 설정
  • 캐싱 구현의 어려움 (REST의 HTTP 캐싱에 비해)
  • 특정 쿼리의 서버 성능 저하 가능성
  • 복잡한 비즈니스 로직에서의 스키마 설계 어려움

 

GraphQL vs REST


특징 GraphQL REST
데이터 요청 방식 필요한 데이터만 명시적으로 요청 고정된 형식의 데이터 제공
엔드포인트 관리 단일 엔드포인트/graphql 여러 엔드포인트/users,/posts등
타입 시스템 명시적 스키마 기반 명시적 타입 시스템 없음
캐싱 복잡함 HTTP 캐싱 가능

 

 

GraphQL 구현


구현

build.gradle

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.4.1'
	id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.jdh'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion.set(JavaLanguageVersion.of(17))
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-graphql'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework:spring-webflux'
	testImplementation 'org.springframework.graphql:spring-graphql-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

	// querydsl (spring boot 3.x 에서의 설정)
	implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
	annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
	annotationProcessor "jakarta.annotation:jakarta.annotation-api"
	annotationProcessor "jakarta.persistence:jakarta.persistence-api"

	// h2
	runtimeOnly 'com.h2database:h2'
}

test {
	useJUnitPlatform()
}

 

application.yaml

spring:
  datasource:
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password: password
  h2:
    console:
      enabled: true
      path: /h2-console
  sql:
    init:
      mode: always
  jpa:
    properties:
      hibernate:
        show_sql: true # sql show
        format_sql: true # pretty show
    hibernate:
      ddl-auto: create # db init (create, create-drop, update, validate, none)
  graphql:
    schema:
      locations: graphql/**/
      file-extensions: .graphqls

h2, jpa 설정과 graphql 설정을 추가한다.

 

graphql.schema 하위 설정을 통해 resources/graphql 내 모든 .graphqls 파일을 인식한다. 이는 뒤에서 설명할 .graphqls를 직관적으로 관리하기 위한 설정이다.

 

QueryDslConfig.java

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }

}

JPA 동적 쿼리 사용을 위한 JPAQueryFactory bean을 등록한다.

 

User Entity 생성

import com.jdh.graphql.api.user.domain.entity.value.UserInfo;
import com.jdh.graphql.api.user.dto.request.UserAddRequestDTO;
import jakarta.persistence.*;
import lombok.*;

@Entity
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "user_info")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Embedded
    private UserInfo userInfo;

    public void changeUserName(String newName) {
        this.userInfo = UserInfo.builder()
                .name(newName)
                .age(this.userInfo.getAge())
                .build();
    }

    public static User of(UserAddRequestDTO add) {
        UserInfo addUserInfo = UserInfo.builder()
                .name(add.getName())
                .age(add.getAge())
                .build();

        return User.builder()
                .userInfo(addUserInfo)
                .build();
    }

}
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.*;

@Getter
@Embeddable
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserInfo {

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private int age;

}

 

UserRepository 구현

import com.jdh.graphql.api.user.domain.entity.User;
import java.util.List;

public interface UserCustomRepository {

    List<User> findUserByAttributes(Long id, String name, Integer age);

}
import com.jdh.graphql.api.user.domain.entity.User;
import com.jdh.graphql.api.user.domain.repository.UserCustomRepository;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

import static com.jdh.graphql.api.user.domain.entity.QUser.user;

@RequiredArgsConstructor
@Repository
public class UserCustomRepositoryImpl implements UserCustomRepository {

    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public List<User> findUserByAttributes(Long id, String name, Integer age) {
        BooleanBuilder builder = getBooleanBuilder(id, name, age);

        return jpaQueryFactory
                .selectFrom(user)
                .where(builder)
                .fetch();
    }

    private BooleanBuilder getBooleanBuilder(Long id, String name, Integer age) {
        BooleanBuilder builder = new BooleanBuilder();

        if (id != null) {
            builder.and(user.id.eq(id));
        }
        if (name != null) {
            builder.and(user.userInfo.name.eq(name));
        }
        if (age != null) {
            builder.and(user.userInfo.age.eq(age));
        }

        return builder;
    }

}

동적 쿼리에 사용할 Custom Repository를 구현한다.

 

import com.jdh.graphql.api.user.domain.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long>, UserCustomRepository {
}

그리고 Custom Repository를 User Repository에서 상속받아 사용한다.

 

User DTO 구현

UserGetRequestDTO

@AllArgsConstructor
@Getter
@ToString
public class UserGetRequestDTO {

    private Long id;

    private String name;

    private Integer age;

}

 

UserAddRequestDTO

@AllArgsConstructor
@Getter
@ToString
public class UserAddRequestDTO {

    @NotBlank
    private String name;

    @NotNull
    @Min(1)
    private Integer age;

}

 

UserGetResponseDTO

@Builder
public record UserGetResponseDTO(Long id, String name, Integer age) {

    public static UserGetResponseDTO of(User user) {
        return UserGetResponseDTO.builder()
                .id(user.getId())
                .name(user.getUserInfo().getName())
                .age(user.getUserInfo().getAge())
                .build();
    }

}

사용자 조회, 추가에 사용할 request, response dto를 각각 구현한다.

 

User Service 구현

UserGetService

import com.jdh.graphql.api.user.dto.request.UserGetRequestDTO;
import com.jdh.graphql.api.user.dto.response.UserGetResponseDTO;

import java.util.List;

public interface UserGetService {

    List<UserGetResponseDTO> getUser(UserGetRequestDTO requestDTO);

}
import com.jdh.graphql.api.user.application.UserGetService;
import com.jdh.graphql.api.user.domain.repository.UserRepository;
import com.jdh.graphql.api.user.dto.request.UserGetRequestDTO;
import com.jdh.graphql.api.user.dto.response.UserGetResponseDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.List;

@RequiredArgsConstructor
@Slf4j
@Service
public class UserGetServiceImpl implements UserGetService {

    private final UserRepository userRepository;

    @Override
    public List<UserGetResponseDTO> getUser(UserGetRequestDTO requestDTO) {
        return userRepository.findUserByAttributes(requestDTO.getId(), requestDTO.getName(), requestDTO.getAge())
                .stream().map(UserGetResponseDTO::of)
                .toList();
    }
}

 

UserAddService

import com.jdh.graphql.api.user.dto.request.UserAddRequestDTO;
import com.jdh.graphql.api.user.dto.response.UserGetResponseDTO;

public interface UserAddService {

    UserGetResponseDTO addUser(UserAddRequestDTO requestDTO);

}
import com.jdh.graphql.api.user.application.UserAddService;
import com.jdh.graphql.api.user.domain.entity.User;
import com.jdh.graphql.api.user.domain.repository.UserRepository;
import com.jdh.graphql.api.user.dto.request.UserAddRequestDTO;
import com.jdh.graphql.api.user.dto.response.UserGetResponseDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Slf4j
@Service
@Transactional
public class UserAddServiceImpl implements UserAddService {

    private final UserRepository userRepository;

    @Override
    public UserGetResponseDTO addUser(UserAddRequestDTO requestDTO) {
        User user = User.of(requestDTO);

        userRepository.save(user);

        return UserGetResponseDTO.of(user);
    }
}

사용자 조회, 추가 Service를 각각 구현한다.

 

User Controller 구현

import com.jdh.graphql.api.user.application.UserAddService;
import com.jdh.graphql.api.user.application.UserGetService;
import com.jdh.graphql.api.user.dto.request.UserAddRequestDTO;
import com.jdh.graphql.api.user.dto.request.UserGetRequestDTO;
import com.jdh.graphql.api.user.dto.response.UserGetResponseDTO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;

import java.util.List;

@RequiredArgsConstructor
@Controller
@Slf4j
public class UserController {

    private final UserGetService userGetService;

    private final UserAddService userAddService;

    @QueryMapping
    public List<UserGetResponseDTO> getUsers(@Argument UserGetRequestDTO request) {
        log.info(request.toString());

        return userGetService.getUser(request);
    }

    @MutationMapping
    public UserGetResponseDTO addUser(@Argument @Valid UserAddRequestDTO request) {
        log.info(request.toString());

        return userAddService.addUser(request);
    }

}

 

graphqls 파일 추가

query.graphqls

type Query {
    getUsers(request: UserGetRequestDTO!): [UserGetResponseDTO]
    # ...
}

조회를 위한 query를 모아놓은 graphqls 이다.

 

mutation.graphqls

type Mutation {
    addUser(request: UserAddRequestDTO!): UserGetResponseDTO
    # ...
}

추가, 수정, 삭제 등을 위한 mutation을 모아놓은 graphqls 이다.

 

userGetRequest.graphqls

# 사용자 조회 요청에 사용할 입력 타입
input UserGetRequestDTO {
    id: ID
    name: String
    age: Int
}

사용자 조회 요청에 사용되는 input 데이터 형식을 정의한다.

 

userAddRequest.graphqls

# 사용자 등록 요청에 사용할 입력 타입
input UserAddRequestDTO {
    name: String!
    age: Int!
}

사용자 등록 요청에 사용되는 input 데이터 형식을 정의한다.

데이터 타입 다음에 느낌표(!)를 입력하면 해당 값은 필수값이 된다.

 

userGetResponse.graphqls

# 응답으로 반환할 User 타입
type UserGetResponseDTO {
    id: ID
    name: String
    age: Int
}

사용자 조회, 등록 시 응답에 사용되는 데이터 type을 정의한다.

 

위에서 설명했듯이 각 graphqls 파일을 직관적으로 관리하기 위해 폴더를 구분하였다.

파일 구조는 다음과 같다.

 

TDD

graphql을 tdd를 통해 검증해 보도록 하겠다.

 

테스트 쿼리들

테스트 쿼리들을 /test/resources/graphql-test/ 디렉토리에 저장한다.

 

getUser.graphql

query {
    getUsers(request: { name: "AAA" }) {
        id
        name
        age
    }
}

 

getUsers.graphql

query {
    getUsers(request: {}) {
        id
        name
        age
    }
}

 

addUser.graphql

mutation {
    addUser(request: {name: "AAA", age: 20}) {
        id
        name
        age
    }
}

 

파일 구조는 다음과 같다.

 

UserQueryTest

@GraphQlTest
@ComponentScan(basePackages = "com.jdh.graphql")
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@Transactional
class UserQueryTest {

    @Autowired
    private GraphQlTester graphQlTester;

    @Autowired
    private UserRepository userRepository;

    @BeforeAll
    public void addUser() {
        userRepository.deleteAll();

        final User userA = getUser(getUserInfoA());
        final User userB = getUser(getUserInfoB());

        userRepository.save(userA);
        userRepository.save(userB);
    }

    @AfterAll
    public void removeUser() {
        userRepository.deleteAll();
    }

    @Test
    @Order(1)
    @DisplayName("User 사용자 이름으로 단건 조회 테스트")
    void getUser_name_단건_조회_쿼리_테스트() {
        final UserGetRequestDTO request = new UserGetRequestDTO(null, "AAA", null);

        graphQlTester.documentName("getUser")
                .variable("request", request)
                .execute()
                .path("getUsers[0].name")
                .entity(String.class)
                .isEqualTo("AAA")
                .path("getUsers[0].age")
                .entity(Integer.class)
                .isEqualTo(20);
    }

    @Test
    @Order(2)
    @DisplayName("User 목록 조회 테스트")
    void getUser_목록_조회_쿼리_테스트() {
        graphQlTester.documentName("getUsers")
                .execute()
                .path("getUsers[*].name")
                .entityList(String.class)
                .containsExactly("AAA", "BBB")
                .path("getUsers[*].age")
                .entityList(Integer.class)
                .containsExactly(20, 30);
    }

    @Test
    @Order(3)
    @DisplayName("User 등록 테스트")
    void addUser_등록_테스트() {
        final UserAddRequestDTO request = new UserAddRequestDTO("AAA", 20);

        graphQlTester.documentName("addUser")
                .variable("request", request)
                .execute()
                .path("addUser.name")
                .entity(String.class)
                .isEqualTo("AAA")
                .path("addUser.age")
                .entity(Integer.class)
                .isEqualTo(20);
    }

    /**
     * 테스트 사용자 Entity 생성
     */
    private User getUser(UserInfo userInfo) {
        return User.builder()
                .userInfo(userInfo)
                .build();
    }

    /**
     * AAA 테스트 사용자 정보 생성
     */
    private UserInfo getUserInfoA() {
        return UserInfo.builder()
                .name("AAA")
                .age(20)
                .build();
    }

    /**
     * BBB 테스트 사용자 정보 생성
     */
    private UserInfo getUserInfoB() {
        return UserInfo.builder()
                .name("BBB")
                .age(30)
                .build();
    }

}

테스트 코드를 작성한다.

 

먼저 GraphQL 테스트에 필요한 @GraphQlTest 어노테이션을 적용한다. 해당 어노테이션을 사용하면 GraphQL 테스트에 사용할 GraphQlTester를 사용할 수 있다.

 

그리고 테스트에 사용할 Service, Repository, JPA 등 Bean을 사용하기 위해 @ComponentScan, @AutoConfigureDataJpa, @AutoConfigureTestDatabase, @AutoConfigureTestEntityManager를 적용한다.

 

테스트 데이터 초기화를 위한 @BeforeAll, @AfterAll을 적용하기 위해서는 static 메소드를 사용해야 하는데, 그럴 경우 의존성을 주입받는 매개변수를 사용하기에 불편하다. 이를 해결하기 위해 @TestInstance(TestInstance.Lifecycle.PER_CLASS)를 적용하여 비정적 메소드를 @BeforeAll, @AfterAll에서 사용할 수 있도록 한다.

 

목록 데이터 검증 시 목록의 모든 데이터를 검증하려면 [*] 를 사용한다. 또, containsExactly()는 목록 데이터와 순서를 일치시켜 검증한다.

 

테스트

postman을 통해 테스트를 진행해 보겠다.

 

mutation을 실행하여 사용자 데이터를 등록하고 데이터를 반환받는다.

 

그 후, query를 실행하여 사용자를 조회한다.

 

원하는 응답 데이터 구조를 지정하면 서버는 그 구조에 맞는 데이터를 반환한다.

 

뿐만 아니라 여러 query를 동시에 요청할 수 있다.

 

 

정리하며


이커머스 서비스를 개발하면서 하나의 화면에서 다수의 API를 요청해야 했던 경험이 있다. 이런 상황에서 GraphQL을 도입했다면, 서비스 품질을 더욱 향상시킬 수 있었을 것이다. 그때 GraphQL을 알았다면, 해당 기술의 도입을 적극적으로 제안했을 것이다.

 

 

자세한 코드는 깃허브를 참고하기 바란다.

 

링크 : https://github.com/JangDaeHyeok/SpringBoot-GraphQL-Practice

 

GitHub - JangDaeHyeok/SpringBoot-GraphQL-Practice: Spring Boot Project for GraphQL Practice

Spring Boot Project for GraphQL Practice. Contribute to JangDaeHyeok/SpringBoot-GraphQL-Practice development by creating an account on GitHub.

github.com

 

728x90