일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- http
- proxy pattern
- RestControllerAdvice
- OOP
- response
- network
- java
- Spring Security
- 스프링
- Filter
- SQL
- 인터셉터
- git
- 객체지향프로그래밍
- 자바
- aspect
- request
- 디자인패턴
- 관점지향프로그래밍
- exception
- aop
- Spring
- Redis
- Interceptor
- mybatis
- 트랜잭션
- 스프링 시큐리티
- 스프링부트
- MYSQL
- spring boot
- Today
- Total
장쫄깃 기술블로그
[Spring Boot] Spring Boot를 이용한 GraphQL (with TDD) 본문
[Spring Boot] Spring Boot를 이용한 GraphQL (with TDD)
장쫄깃 2025. 1. 18. 23:40
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
'Spring Framework > Spring Boot' 카테고리의 다른 글
[Spring Boot] Redis Sorted Set, Spring Batch, STOMP를 이용하여 대기열 시스템 만들어보기 (4) | 2024.12.15 |
---|---|
[Spring Boot] AOP, Redis Transaction을 이용한 분산락(Distributed Lock)으로 동시성 해결하기 (0) | 2024.10.18 |
[Spring Boot] AOP, Redis를 이용한 멱등성 보장 구현하기 (2) | 2024.09.18 |
[Spring Boot] log4j2 로그 레벨별 색 지정 (0) | 2024.07.16 |
[Spring Boot] logback 로그 레벨별 색 지정 (4) | 2024.07.16 |