Flyway 사용법 (#2)

Flyway 사용법 (#1) 에서 대략적인 Flyway 사용방법을 다뤄 봤습니다.

이번 글에서는 Flyway 사용 중 자주 발생하는 문제와 해결 방법을 정리합니다.

 

  1. SQL 파일과 Entity 의 불일치 문제
  2. Flyway SQL 파일이 무한히 늘어나는 문제

 

1. SQL 파일과 Entity 의 불일치 문제

1.1 문제 개요

Flyway는 SQL 기반으로 DB 스키마를 관리합니다.

하지만 JPA Entity는 코드 기반으로 스키마를 정의하기 때문에, 두 구조가 항상 일치한다는 보장이 없습니다.

 

1.2 ddl-auto: validate 설정

Entity 구조가 DB 스키마와 일치하는지 자동으로 확인할 수 있습니다.

spring:
  jpa:
    hibernate:
      ddl-auto: validate

이 설정을 적용하면 애플리케이션 실행 시 다음이 수행됩니다.

케이스 결과
Entity 필드가 DB에 없음 예외 발생

 

1.3 테스트 환경 구성

Member Entity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

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

	@Column(nullable = false, length = 50)
	private String nickname;

	@CreationTimestamp
	private LocalDateTime createdAt;
}

V1__init.sql

CREATE TABLE `member`
(
    `id`         BIGINT       NOT NULL AUTO_INCREMENT,
    `nickname`   VARCHAR(100) NOT NULL,
    `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`)
);

application.yml

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/demo
    username: root
    password: 1234
  jpa:
    hibernate:
      ddl-auto: validate
  flyway:
    enabled: true
    baseline-on-migrate: true

 

1.4 검증 결과

위 설정 상태로 애플리케이션을 실행하면 Entity와 SQL 스키마가 일치하므로 정상적으로 구동됩니다.

image-20251105220427388

실행결과 문제없이 잘 실행되는것을 볼수있습니다.

 

1.5 불일치 발생 시 예외 확인

만약 Member Entity 에 Email 컬럼을 아래와 같이 추가후 실행하면 오류가 나는것또한 볼수있습니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

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

	@Column(nullable = false, length = 50)
	private String nickname;

	@Column(nullable = false, length = 50)
	private String email;

	@CreationTimestamp
	private LocalDateTime createdAt;
}
스크린샷 2025-11-05 오후 10.06.09

 

1.6 한계

앞서 살펴본 것처럼 ddl-auto: validate 옵션을 사용하면

Entity → DB 방향의 불일치(즉, Entity에는 존재하지만 DB에는 없는 컬럼)는 감지할 수 있습니다.

하지만 그 반대 방향, 즉 DB → Entity 방향의 불일치는 검증할 수 없습니다.

예를 들어, 아래와 같이 Flyway를 통해 DB에만 컬럼을 추가하더라도 애플리케이션은 아무런 오류 없이 실행됩니다

 

V2__add_email_column.sql

ALTER TABLE `member`
ADD COLUMN `email` VARCHAR(255);
스크린샷 2025-11-05 오후 10.08.48

위 사진에서 보이는것 처럼 문제없이 실행이 됩니다.

 

이러한 한계를 극복하기위해 DB -> Entity 방향 검증을 위한 글을 다음에 써보겠습니다.

 

2. SQL 파일이 무한히 늘어나는 문제

Flyway는 마이그레이션을 버전 기반(V1__, V2__, …) 으로 관리합니다.

파일이 몇 개 수준일 때는 큰 문제가 없지만, 서비스가 장기 운영되면 마이그레이션 파일이 수백, 수천 개로 누적될 수 있습니다.

이럴 경우 유지보수성·빌드 시간·이전 버전 호환성 관리가 어려워집니다.

 

2.2 테스트 사전 준비

스크린샷 2025-11-05 오후 10.32.05

3개의 SQL 파일을 만들어두고 현재 Flyway는 V3 SQL까지 적용이 된 상태입니다.

Flyway는 과거 버전의 모든 SQL 파일을 이력으로 관리합니다. 따라서 이후 버전(V4__...)을 추가해도 V1부터 순차적으로 실행 및 검증 대상에 포함됩니다.

즉 Flyway 의 기본동작은 아래와 같습니다.

상황 Flyway 동작
DB에는 V1~V3 기록이 있음 정상
코드에서 V1, V2 파일이 사라짐 missing migration 에러
코드의 V1, V2 SQL 내용이 변경됨 checksum mismatch 에러

하지만 시간이 지나면

  • 이미 오래전에 반영된 마이그레이션 SQL을 더 이상 관리할 필요가 없고
  • 새 개발자가 프로젝트를 받아도 과거 SQL을 일일이 살펴볼 필요가 없는 상황이 됩니다.

 

2.2 ignore-migration-patterns

application.yml

spring:
  flyway:
    enabled: true
    validate-on-migrate: true
    ignore-migration-patterns: "*:missing"
상황 기본 동작 ignore-migration-patterns 적용 시
DB에는 V1, V2 기록 있음, 코드에서 V1, V2 파일 삭제 missing migration 에러 무시하고 통과
DB에는 V2 기록 있음코드의 V2 내용을 수정 checksum mismatch 에러 여전히 에러 발생

즉, ignore-migration-patterns 옵션은 파일이 “없어진(missing)” 상황만 무시하며, 내용 변경(checksum mismatch)은 절대 허용하지 않습니다.

 

따라서 ignore-migration-patterns이미 DB에는 적용되었지만, 더 이상 소스코드에 보관할 필요 없는 과거 SQL 파일들을 안전하게 삭제하고 싶을 때 사용할 수 있습니다.

 

2.2.1 ignore-migration-patterns 테스트

기존 DB에는 flyway_schema_history가 존재하므로, 과거 파일(V1, V2)을 코드에서 제거하더라도 검증이 통과하도록 해야 합니다.

스크린샷 2025-11-11 오후 8.21.43

이를 위해 아래와 같이 설정합니다.

spring:
  flyway:
    enabled: true
    validate-on-migrate: true
    ignore-migration-patterns: "*:missing"
  • ignore-missing-migrations → 히스토리에 기록은 남아있지만 파일이 없는 버전(V1, V2)을 검증에서 무시합니다. → 따라서 코드에서 과거 파일을 삭제해도 검증 단계에서 실패하지 않습니다.
스크린샷 2025-11-11 오후 8.44.48스크린샷 2025-11-11 오후 8.45.54

따라서 더 이상 관리할 필요 없는 SQL들을 삭제하고도 기존 DB를 유지해야 한다면 ignore-migration-patterns 를 사용할 수 있습니다.

 

2.3 baseLine

baseline 은 이미 DB에 스키마가 존재할 때, “이 시점을 특정 버전(Vx)의 기준점으로 삼겠다”는 의미로 사용합니다.

즉 baseline 을 통하여 현재 스키마 상태를 버전 X부터 시작한 것으로 간주하고,

이후 버전부터 Flyway로 관리하겠다는 뜻입니다.

예를 들어 현재 V1~V3 까지 수동으로(또는 기존 Flyway로) 적용된 DB가 있고,

이제 V1~V3 를 하나로 묶어 새로운 V1로 시작하고 싶다면 baseline 을 사용할 수 있습니다.

 

2.3.1 baseLine 활용

baseline 적용을 위해 flyway_schema_history 테이블을 비우거나 삭제했다고 가정합니다.

스크린샷 2025-11-15 오후 4.43.54

 

application.yml

  flyway:
    enabled: true
    baseline-on-migrate: true
    baseline-version: 1
스크린샷 2025-11-15 오후 4.46.17

이제 서버를 실행하면 위 처럼 history 테이블이 생성되고 BASELINE 이 기록됩니다.

이렇게 하면 기존 스키마 전체가 Flyway 기준의 “V1 적용 완료 상태”가 되며,

이후 컬럼 추가 등의 변경은 V2__...sql 버전부터 적용하면 됩니다.

 

V2__add_number_column.sql

ALTER TABLE member
ADD COLUMN number VARCHAR(255);
스크린샷 2025-11-15 오후 4.48.45

 

이처럼 baseline 을 기준으로 이후 버전들이 정상 적용됩니다.

 

2.4 결론

ignore-migration-patterns을 사용할 때

“히스토리는 그대로 두고, 과거 파일만 깔끔하게 치우고 싶을 때”

  • 이미 운영 DB에는 v1~v50 까지 기록이 있음
  • 하지만 repo에서는 v1~v50 파일을 삭제해도 Flyway가 에러 내지 않게 하고 싶음
  • DB는 유지, 히스토리는 유지
  • 새 DB를 Flyway로 처음부터 풀로 재현할 필요가 없는 경우

즉, 운영과 기존 히스토리는 유지하면서 과거 SQL을 정리하고 싶은 상황에서 사용합니다.

baseline 을 사용할 때

“이미 스키마가 있는 DB의 ‘지금 상태’를 새로운 시작 버전으로 선언하고 싶을 때”

  • 운영 DB를 비울 수 없음
  • 현재 스키마를 기준으로 “여기부터 V1이다” 라고 선언해야 함
  • Flyway 도입 시점, 스쿼시 후 새로운 기준점을 잡고 싶을 때
  • history 테이블을 비우거나 유실된 뒤 기준점을 다시 만들어야 할 때

즉, ‘기존 스키마를 건드리지 않고 버전의 시작점을 다시 설정해야 할 때’ baseline 을 사용합니다.

 

 

Spring WebSocket(STOMP) 통합 테스트 정리

 

1. 통합 테스트를 결정한 이유

WebSocket의 전반적인 흐름을 실제와 동일하게 검증하기 위해 통합 테스트 방식을 선택하였습니다.

현재 STOMP 구조는 다음과 같습니다.

StompDispatcher → 각 command에 맞는 handler → SEND의 경우 @MessageMapping이 선언된 단에서 처리

이 흐름에서 최초 handshake 단계에서 사용자의 인증 및 인가 상태를 확인하며, 필요한 인가 정보는 session에 저장합니다.

이후 각 command에 해당하는 handler는 handshake 과정에서 설정된 session 값을 활용하여 비즈니스 로직을 수행합니다.

이러한 일련의 흐름은 단위 테스트로는 충분히 검증하기 어렵기 때문에, 실제 WebSocket 연결 및 메시지 송수신 과정을 포함하는 통합 테스트로 구성하였습니다.

 

2. 멀티스레드로 인한 문제

아래 코드는 SEND 커맨드를 테스트하기 위한 예시입니다.

// given
// ... 더미 데이터 저장

// when -----------------------------

BlockingQueue<String> messageQueue = new LinkedBlockingDeque<>();
BlockingQueue<String> errorQueue = new LinkedBlockingDeque<>();

// 1. Handshake + STOMP CONNECT
session = connectWithCookies(jwtCreatedBySavedMember.getAccessToken(),
    jwtCreatedBySavedMember.getRefreshToken(), errorQueue);

StompHeaders headers = buildSubscribeHeaders(chatRoom.getId());

// 2. 구독
session.subscribe(headers, new SimpleFrameHandler(messageQueue));

ChatMessageSendRequest payload = ChatMessageSendRequest.builder()
    ...
    .build();

// 3. SEND 요청
session.send("/api/pub/message", payload);

// then -----------------------------
await()
    .atMost(4, SECONDS)
    .untilAsserted(() -> {
        // DB 내 변경사항 검증
    });

// 응답 검증
String result = messageQueue.poll(3, SECONDS);
// ... 응답 검증

WebSocket 통합 테스트는 STOMP 요청을 수신하는 스레드와 메시지를 송신하는 스레드가 분리되어 동작하므로 멀티스레드 환경에서 수행됩니다.

이 때문에 DB 상태 검증 시점이 비동기적으로 지연될 수 있습니다. 이를 해결하기 위해 Awaitility 라이브러리의 await() 메서드를 사용하였습니다.

await() 메서드는 일정 간격으로 DB 쿼리를 반복 실행하여 원하는 변경사항이 발생했는지 확인합니다.

테스트에서는 타임아웃을 4초로 설정하여, 그 안에 기대한 변경사항이 감지되지 않으면 테스트가 실패하도록 하였습니다.

 

3. 트랜잭션(@Transactional) 관련 문제

기존 HTTP 테스트에서는 각 테스트 메서드에 @Transactional을 적용하였습니다.

그러나 WebSocket 통합 테스트는 멀티스레드 기반으로 실행되므로, 테스트 스레드와 STOMP 요청을 처리하는 스레드의 트랜잭션이 서로 분리됩니다.

즉, 테스트 코드에서 더미 데이터를 저장하더라도 해당 데이터가 STOMP 처리 스레드에서는 보이지 않는 문제가 발생합니다.

따라서 STOMP 기반 WebSocket 테스트에서는 @Transactional을 사용하지 않았습니다.

대신 테스트 실행 전후로 DB를 정리(clean-up)하여 항상 초기 상태를 유지하도록 하였습니다.

 

4. 서로 다른 스프링 컨텍스트

 

4.1 HTTP 테스트를 위한 어노테이션

기존 HTTP 통합 테스트에서 사용한 커스텀 어노테이션은 다음과 같습니다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@Import(WireMockConfig.class)
public @interface ControllerIntegrationTest {
}

위 어노테이션은 @SpringBootTest의 기본 설정(WebEnvironment.MOCK)을 사용합니다.

즉, 애플리케이션 컨텍스트는 로드되지만 서블릿 환경은 Mock으로 구성되며, 실제 내장 톰캣(Tomcat)은 구동되지 않습니다.

image-20251017102700735

 

4.2 Websocket(STOMP) 를 위한 어노테이션

반면 STOMP 기반 WebSocket 테스트는 실제 서버가 존재해야 전체 프로토콜 흐름(핸드셰이크 → CONNECT→SUBSCRIBE → SEND → DISCONNECT)을 검증할 수 있습니다.

그 이유는 STOMP 통신이 양방향 비동기 구조로 동작하기 때문입니다.

따라서 테스트 환경에서도 다음 두 가지 스레드가 모두 필요합니다.

  1. 요청을 처리할 서버 스레드
    • 실제 톰캣(WebSocket 서버)이 기동되어야 클라이언트의 CONNECT, SUBSCRIBE, SEND 등의 프레임 요청을 수신하고 처리할 수 있습니다.
    • 즉, @MessageMapping이 붙은 컨트롤러 메서드나, ChannelInterceptor, StompHandler 등이 실제 네트워크 스레드에서 호출되어야 합니다.
  2. 요청을 발생시키는 클라이언트 스레드
    • 테스트 코드 내에서 StompSession을 통해 서버에 연결하고 메시지를 송수신합니다.
    • 이 스레드는 실제 네트워크 소켓을 열어 서버 스레드와 통신해야 하므로, Mock 서블릿 환경에서는 동작할 수 없습니다.

따라서 실제 서버 스레드를 띄워 요청과 응답이 오가는 환경을 재현하기 위해 @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) 설정을 사용하였습니다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles("stomp-test")
public @interface StompHandlerTest {
}
image-20251017103142495

HTTP용 어노테이션(ControllerIntegrationTest)과 STOMP(WebSocket)용 어노테이션 (StompHandlerAnnotation)의 가장 큰 차이점은 @SpringBootTestwebEnvironment 설정입니다.

  • WebEnvironment.MOCK : 실제 서버 미구동(Mock 서블릿 환경)
  • WebEnvironment.RANDOM_PORT : 실제 내장 톰캣 서버 구동

WebEnvironment.RANDOM_PORT를 사용하면 테스트 시 실제 톰캣이 랜덤 포트에서 실행되며, 이를 통해 실질적인 WebSocket 통신이 가능해집니다.

따라서 같은 패키지 내에 존재하는 테스트이더라도, HTTP 기반 테스트와 WebSocket 테스트는 서로 다른 스프링 컨텍스트를 사용합니다.

 

flyway 사용법

Flyway 사용법 (#1)

지금까진 ERDCloud로 전체적인 DB 구조와 컬럼을 관리하고, 변경사항이 생기면 세 가지 단계를 거쳤습니다. 먼저 ERDCloud를 수정하고, 그다음 Spring Boot 코드(Entity)를 수정한 뒤, 마지막으로 DB에 직접 쿼리를 실행해 반영하는 방식이었습니다.

 

하지만 이 과정에는 몇 가지 문제가 있었습니다. 가장 큰 문제는 DB에 직접 쿼리를 날리는 시점과 실제 서비스 배포 시점이 정확히 맞아야 한다는 점이었습니다. 시점이 어긋나면 서비스가 정상적으로 동작하지 않을 위험이 있었습니다. 또한, Entity와 ERDCloud 간 불일치 문제가 자주 발생했습니다. 원래는 ERDCloud에서 DDL을 추출한 뒤, 그중에서 수정되거나 추가된 부분만 직접 쿼리로 DB에 적용하는 방식을 썼는데, 테이블 수가 많아지고 변경사항이 누적되다 보니, Entity와 ERDCloud 간 동기화가 맞지 않으면 언제든 예기치 못한 문제가 생길 수 있었습니다.

 

이런 상황에서 최소한 Entity와 실제 DB 테이블 간에는 불일치가 없어야 한다는 점이 중요했고, 동시에 배포 타이밍에 맞춰 직접 쿼리를 적용해야 하는 리스크를 줄일 필요가 있었습니다. 따라서 이러한 문제들을 해결하기 위해Flyway를 도입하게 되었습니다.

 

1. Spring Boot 적용 방법

실행환경: gradle, springboot 3.5.6

 

1.1 의존성 추가

먼저 프로젝트에 Flyway를 적용하기 위해 빌드 도구에 의존성을 추가해야 합니다.

build.gradle 파일에 flyway-mysql 의존성을 추가합니다.

//flyway
implementation 'org.flywaydb:flyway-mysql'

 

1.2 설정 (application.yml)

그다음 application.yml 혹은 application.properties 파일에서 데이터베이스 연결 정보와 함께 Flyway 설정을 추가합니다.

spring:
  flyway:
    enabled: true       # flyway 활성화
    baseline-on-migrate: true   # DB가 비어있지 않아도 baseline 잡고 시작
    locations: classpath:db  # migration 파일 위치

enabled: true → 실행 시점에 자동으로 DB 마이그레이션 반영 됩니다.

locations → 기본값이 classpath:db/migration이라 커스텀 경로를 사용하지 않는 이상 따로 설정하지 않아도 됩니다.

baseline-on-migrate: true기존 DB가 이미 초기화되어 있더라도, Flyway가 그 상태를 baseline으로 잡고 이후 스크립트부터 적용 해줍니다. 만약 해당 설정값이 false 라면 이미 테이블이 있을때 Flyway는 "스키마가 손상된 상태"라고 판단하고 오류 발생시킵니다. 즉, 기존 DB → Flyway 도입이 불가능해 집니다.

 

1.3 마이그레이션 스크립트 작성 규칙

마이그레이션 스크립트는 정해진 규칙에 맞춰 파일명을 작성해야 합니다.

일반적으로 V숫자__설명.sql 형태를 사용합니다.

예를 들어, 초기 스키마를 정의할 때는 V1__init.sql, 이후 사용자 테이블을 추가한다면 V2__add_user_table.sql 같은 식입니다.

작성한 스크립트 파일은 src/main/resources/db/migration 디렉터리에 두면, 애플리케이션이 실행될 때 자동으로 해당 SQL들이 순서대로 실행되어 DB에 반영됩니다.

 

1.4 실제 구현 예시

image-20250925094409701

v1__init.sql

CREATE TABLE `member`
(
    `id`         BIGINT       NOT NULL AUTO_INCREMENT,
    `nickname`   VARCHAR(100) NOT NULL,
    `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`)
);

application.yml

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/test
    username: root
    password: 1234
  flyway:
    enabled: true       # flyway 활성화
    baseline-on-migrate: true   # DB가 비어있지 않아도 baseline 잡고 시작
    locations: classpath:db  # migration 파일 위치

우리의 예상대로라면, 위, 설정들에 의하여 Spring 실행시 db 에 member 테이블이 생겨야 합니다.

 

스크린샷 2025-09-25 오전 9.45.48

서버를 실행하면 Flyway가 db/migration에 있는 마이그레이션 스크립트를 읽어서 순서대로 실행합니다. 그 결과 DB에는 우리가 작성한 member 테이블이 생성되고, 동시에 Flyway가 자체적으로 관리하는 flyway_schema_history 테이블도 추가됩니다.

 

flyway_schema_historyDB 마이그레이션 이력이 저장되는 버전 관리 테이블이고, 이를 통해 Flyway는 중복 실행을 방지하면서 다음 버전부터 이어서 실행할 수 있게 됩니다.

 

2. 문제점

해당 방법을 사용한다면, 제가 가장 큰 문제라 생각했던, "DB에 직접 쿼리를 날리는 시점과 실제 서비스 배포 시점이 정확히 맞아야 한다는 점 " 해당 문제점이 해결됩니다.

다만, flyway 를 사용하다보니 다음과 같은 2가지 문제점이 존재했습니다.

  1. db/migration 안에 있는 sql 파일이 우리의 entity 와 완벽히 일치한다는 보장이 있는가?
  2. db/migration 안에 있는 sql 파일이 무한정 늘어나면 관리는 어떻게 할것인가?

 

다음글은 해당 문제를 어떻게 해결했는지에 대해 작성해보겠습니다.

JPQL 실행 시 Flush와 영속성 컨텍스트 동작 확인

JPQL 실행 시 Flush와 영속성 컨텍스트 동작 확인

  1. 이 테스트는 두 가지 주요 목적을 가지고 있습니다:

    1. JPQL 실행 전 flush가 실행되는지 확인합니다.
    2. 영속성 컨텍스트에 동일한 ID를 가진 엔티티가 존재할 경우, JPQL로 가져온 값이 영속성 컨텍스트의 값으로 대체되지 않는지 확인합니다.

 

@Test
@DisplayName("닉네임 변경 요청시 해당 회원의 닉네임을 변경한다.")
void updateMemberWithNickNameRequest() throws Exception {
  //given
  Member member = saveMemberAndMemberImage();
  Long memberId = member.getId();

  Jwt jwtCreatedBySavedMember = generateTokenWithMemberId(memberId);

  MemberUpdateRequest memberUpdateRequest = MemberUpdateRequest.builder()
    .nickname(CHANGED_NICK_NAME)
    .build();

  String jsonRequest = objectMapper.writeValueAsString(memberUpdateRequest);

  //when & then
  mockMvc.perform(
      MockMvcRequestBuilders.patch("/api/members/{memberId}", memberId)
        .header(AUTHORIZATION_STRING, JWT_TOKEN_PREFIX + jwtCreatedBySavedMember.getAccessToken())
        .contentType(MediaType.APPLICATION_JSON)
        .content(jsonRequest))
    .andExpect(status().isOk());

  Member updatedMember = memberProvider.findById(memberId);
  assertThat(updatedMember.getNickname()).isEqualTo(CHANGED_NICK_NAME);
}

 

테스트 설명

  1. Member 저장

    Member member = saveMemberAndMemberImage();
    Long memberId = member.getId();
    

    이 메서드에 의해 memberimage가 저장됩니다. @GeneratedValue(strategy = GenerationType.IDENTITY)로 설정되어 있기 때문에 member가 저장될 때 바로 저장 쿼리가 실행됩니다. 이 상태에서 영속성 컨텍스트와 데이터베이스 모두에 member가 존재합니다. 이때 Member 엔티티의 isWithdrawal 필드는 데이터베이스에 의해 기본값으로 초기화되지만, 해당필드의 초기화 상태가 영속성 컨텍스트에는 반영되지 않습니다.

 

  1. MockMvc 요청

    mockMvc.perform(
            MockMvcRequestBuilders.patch("/api/members/{memberId}", memberId)
                .header(AUTHORIZATION_STRING, JWT_TOKEN_PREFIX + jwtCreatedBySavedMember.getAccessToken())
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonRequest))
        .andExpect(status().isOk());
    

    이 요청은 member의 닉네임을 변경합니다. updateMember 메서드는 JPQL을 통해 member를 조회합니다. 이때 JPQL memberQueryService.findById(memberId) 이 실행되기 전에 flush가 자동으로 실행됩니다.

    @Transactional
    public void updateMember(Long memberIdFromJwt, Long memberId, MemberUpdateRequest memberUpdateRequest) {
        validateMemberId(memberIdFromJwt, memberId);
        Member member = memberQueryService.findById(memberId); // JPQL 실행 전 flush() 호출
    
        Optional.ofNullable(memberUpdateRequest.getAlert())
            .ifPresent(member::updateAlert);
    
        Optional.ofNullable(memberUpdateRequest.getNickname())
            .ifPresent(member::updateNickname);
    }
    

    entityManager.clear()를 호출하지 않았으므로, entityManager에는 여전히 saveMemberAndMemberImage() 메서드에 의해 저장된 member가 존재합니다. 이 상태에서 JPQL로 가져온 member는 영속성 컨텍스트에 이미 존재하는 member로 대체되지 않습니다.

    (JPQL 을 통해 값을 가져오더라도, 영속성 컨텍스트안에 같은 ID 를 가진 객체가 있다면 JPQL 을 통해 가져온 값을 버리고 영속성 컨텍스트 안에 값을 유지하기 때문에 DB에 의해 기본값으로 초기화된 엔티티가 현재 영속성 컨텍스트 안의 엔티티로 대체되지 않습니다.)

 

  1. Update 와 Flush

    Member updatedMember = memberProvider.findById(memberId);
    assertThat(updatedMember.getNickname()).isEqualTo(CHANGED_NICK_NAME);
    

    이 코드에서 memberProvider.findById(memberId)는 JPQL 쿼리를 실행하며, 쿼리 실행 전에 flush가 호출됩니다. flush 가 호출될때, mockMvc.perform() 에 의해 실행된 Member 엔티티 업데이트 쿼리가 나갈것입니다. 트랜잭션의 전파에 의해 test 코드에 걸어놓은 트랜잭션이 끝나지 않았으므로 mockMvc.perform 에 의해 실행된 update 쿼리는 mockMvc.perform 이 실행이 완료된 후에도 아직 실행되지 않기 때문입니다.

 

❗️이때 중요한것은 우린 아직 entityManager.clear() 를 한번도 호출하지 않았다는 점입니다. Member 엔티티 안에는 DB의 기본값에 의해 초기화되는 isWithdrawal 이라는 필드가 있는데, 아직 영속성 컨텍스트 안에 있는 entityclear() 가 된 적이 없고, 따라서 DB에 의해 초기화된 필드는 영속성 컨텍스트 안에 적용이 되지 않았을 것입니다.

updatedMembergetisWithdrawalnull

이를 검증하기 위해 아래와 같이 최종적으로 찾은 updatedMemberisWithdrawal 필드를 가져와보면 null 인것을 확인할수 있으며

 

만약 isWithdrawal 이 초기화된 상태를 보고싶다면 movkMvc.perform() 메서드 호출 이전 또는 이후에 entityManagerclear 메서드를 실행해주면 잘초기화된 isWithdrawal 값을 볼수있습니다.

entityManagerFlushAfterMvcPerform

entityManagerClearAfterMvcPerform

참고로 mockMvc.perform 이후에 clear() 를 호출하는 로직에서 entityManger.flush() 가 추가된 이유는 만약 entityManger.clear() 만 호출시 mockMvc.perform() 에 의해 업데이트된 member 엔티티의 변경사항이 모두 detach 되면서 업데이트 쿼리가 나가지 않습니다. 따라서 mockMvc.perform 이후에는 entityManger.flush() 를 추가해 member 엔티티의 update 정보가 사라지지 않도록 합니다.

 

결론

  1. JPQL이 실행되기 전에 flush가 실행되는지 확인:

    • mockMvc.perform() 이후, JPQL로 인해 update 쿼리가 실행되는 것을 확인했습니다. 이는 JPQL이 실행되기 전에 flush가 자동으로 호출된다는 것을 의미합니다.
  2. 영속성 컨텍스트에 동일한 ID의 엔티티가 존재할 때 JPQL로 가져온 값이 대체되지 않는지 확인:

    • entityManager.clear()를 호출하지 않으면, JPQL로 가져온 값이 영속성 컨텍스트에 이미 존재하는 값으로 대체되지 않는다는 것을 확인했습니다. 예를 들어, member.getIsWithdrawal()null인 것을 확인했습니다.

       

사실, 엔티티 클래스 안에 초기화 로직을 넣거나 다른 방법으로 영속성 컨텍스트와 데이터베이스 간의 불일치를 줄이는 것이 가장 좋은 방법이지만, 이번 테스트는 이 불일치를 확인하기 위해 수행되었습니다.

API 통신시 값이 null 일때 해당 값을 응답 또는 요청에 포함시켜야하나?

API 통신 시 null 값 처리: 포함 vs. 미포함의 장단점

JSON 기반의 API 통신에서 값이 null일 경우 이를 요청/응답에 포함시켜야 하는지는 자주 논의되는 주제입니다. 이 글에서는 StackOverflow 토론 내용을 기반으로 정리해 보았습니다.

 

대형 플랫폼의 사례

isitworthtoexcludenullfields

StackOverflow에서 소개된 사례에 따르면, Twitter 같은 대규모 플랫폼에서는 "someGenericProperty": null 같은 26바이트의 단순 필드 제거만으로도 하루 300GB 이상의 트래픽 절감 효과를 얻을 수 있었다고 합니다.

-> 하루 수십억 건의 API 요청이 발생하는 환경에서는 이런 미세한 최적화도 큰 비용 절감으로 이어집니다.

 

하지만 때로는, 응답에서 null 필드를 무조건 제외하는 것이 좋은 것은 아닙니다.

excludenullfieldsAnswer

 

위 글을 기반으로 대략적으로 정리를 해보자면 내용은 아래와 같습니다.

API 사용자 입장에서는 일관된 구조가 더 중요할 수 있기 때문입니다.

  • 응답에 항상 필드가 존재한다면, 데이터 부재 상황을 쉽게 파악할 수 있습니다.
  • 반대로, 필드 자체가 누락되면 데이터가 없는 것인지 / 서버 오류인지 구분하기 어려울 수 있습니다.

 

장단점 정리

 

null 필드를 포함하는 경우

  • 장점:

    • 일관된 스키마 유지 → API 사용자가 예측 가능한 구조로 개발 가능
    • 디버깅 용이 → 누락 원인 추적이 쉬움
  • 단점:

    • 응답 크기 증가 (네트워크/트래픽 비용)

 

null 필드를 제외하는 경우

  • 장점:

    • 대역폭 절감 → 대규모 트래픽 환경에서 비용 최적화 가능
    • 희소 데이터 구조에서 불필요한 데이터 제거 효과
  • 단점:

    • 일관성 부족 → 클라이언트 개발자가 조건 분기 처리 필요
    • 디버깅 시 혼란 발생 가능

 

예외적으로 제외가 유리한 경우

  • 극도로 제한된 네트워크 환경 (예: IoT, 위성 통신 등)
  • 응답 대부분이 null인 희소 데이터 구조

 

결론

  • 일반적인 서비스: null 필드를 포함해 일관성 유지 → 사용성 & 신뢰성 강화
  • 특수한 경우: 트래픽 절감이 최우선이라면 null 필드 제외 고려

즉, “대부분은 포함, 필요할 때만 제외”가 합리적인 선택입니다.

JPA에서 단방향 및 양방향 일대일 관계의 외래키 처리와 지연 로딩 문제

JPA에서 단방향 및 양방향 일대일 관계의 외래키 처리와 지연 로딩 문제

❗️일대일 대상 테이블에 외래키 단방향 관계는 JPA에서 지원하지 않으며, 양방향 관계만 지원합니다.

 

[인프런 김영한님 자료]

memberAndLockerImage

대상 테이블에 외래키가 존재하는 경우, 프록시 기능의 한계로 인해 지연 로딩으로 설정해도 항상 즉시 로딩됩니다.

예를 들어, Member 객체를 가져왔을 때 JPA는 가져온 Member 객체에 Locker가 있는지 여부를 알아야 합니다.

 

1. 외래키가 Locker 에 있을 때

외래키가 Locker에 있다면, Member 엔티티를 가져올 때 Member 클래스에 정의된 Locker가 존재하는지 확인해야 합니다. 이때 Member 테이블에는 외래키가 존재하지 않기 때문에, 지연 로딩 설정 여부와 상관없이 Locker 테이블을 조회하여 Member와 연결된 Locker가 있는지 확인하는 추가 쿼리가 발생합니다.

 

2. 외래키가 Member에 있을 때

외래키가 Member에 있다면, Member 테이블의 조회만으로도 Member와 연결된 Locker가 있는지 여부를 판단할 수 있습니다. 따라서 이 경우에는 지연 로딩(lazy loading) 또는 즉시 로딩(eager loading) 설정이 가능합니다.

 

요약

  • 외래키가 Locker에 있는 경우: Member를 조회할 때 항상 Locker 테이블을 추가로 조회해야 하므로, 지연 로딩 설정이 무시되고 즉시 로딩이 이루어집니다.
  • 외래키가 Member에 있는 경우: Member 테이블만으로 Locker와의 연결 여부를 확인할 수 있으므로, 지연 로딩과 즉시 로딩 설정이 가능합니다.

+ Recent posts