0. 들어가며


Spring WebSocket에서 기본으로 제공하는 Simple Broker는 이름 그대로 “Simple”한 구현체다. 별도의 외부 브로커 없이도 STOMP 기반 메시징을 빠르게 구성할 수 있도록, 각 서버 인스턴스의 메모리 안에서만 동작한다.

WebSocket 세션은 서버 인스턴스에 직접 연결되며, SimpMessagingTemplate을 통해 전송되는 메시지는 해당 인스턴스가 관리하고 있는 세션들에게만 전달된다. 이는 성능과 단순성을 위해 의도된 설계다.

문제는 서버가 하나일 때는 전혀 드러나지 않는다. 하지만 인스턴스가 두 개 이상이 되는 순간, 각 서버는 서로의 세션 존재를 알 수 없게 되며, 아래와 같은 문제가 발생한다.

  • 첫 번째, 메시지 전파 문제
    사용자 A는 인스턴스 1에, 사용자 B는 인스턴스 2에 접속해 있다고 가정해보자. A가 메시지를 보내면 인스턴스 1의 메모리에 연결된 세션들에는 메시지가 전달되지만, 인스턴스 2에 있는 B에게는 메시지가 도달하지 않는다. 인스턴스 간에 서로 "메시지가 왔으니 니가 가진 세션들에게도 뿌려줘"라고 말해줄 중간 매개체(Message Broker)가 없기 때문이다.
  • 두 번째, DB 저장과 메시지 발행의 원자성(Automicity)
    브로커를 도입하더라도 더 큰 고민이 남는다. 채팅 내용을 DB에 저장하는 것과 메시지 브로커에 전달하는 것, 이 두 작업은 서로 다른 인프라에서 일어난다. 만약 DB 저장은 성공했는데 갑자기 네트워크 오류로 Message Broker로의 발행이 실패한다면? 혹은 그 반대의 상황이라면? 사용자는 채팅을 보냈다고 생각하지만 상대방은 영원히 메시지를 받지 못하거나, DB에는 기록이 없는 유령 메시지가 떠다니는 데이터 불일치 현상이 발생한다.

Simple Broker는 “모든 클라이언트에게 메시지를 보내는 브로커”가 아니라, “이 인스턴스에 연결된 클라이언트들에게만 메시지를 전달하는 로컬 브로커”에 가깝다. 몰론 Simple Broker 외에도 STOMP Broker Relay라는 선택지가 있지만, 이는 WebSocket 메시지 전파를 외부 브로커에 위임하여 인스턴스 스케일 아웃 시 세션 전파 문제만 해결해줄 뿐 DB 트랜잭션과 메시지 발행의 원자성은 해결하지 못한다.

따라서 멀티 인스턴스 환경에서 WebSocket 메시지를 전체 사용자에게 일관되게 전달하려면, 인스턴스 간 메시지를 중계해줄 외부 Message Broker가 반드시 필요하다. 그리고 이때 DB 저장과 메시지 발행의 원자성 보장도 함께 고려해야 한다.

결론: Message Broker와 Transactional Outbox 패턴을 통한 해결
이 문제를 해결하기 위해 외부 브로커인 RabbitMQ(CloudAMQP)를 도입하여 인스턴스 간 통신을 연결하고, Outbox 패턴을 통해 DB 저장과 메시지 발행을 하나의 트랜잭션으로 묶어 데이터의 정합성을 보장하기로 하였다.

1. 왜 RabbitMQ인가


동일한 문제를 해결하기 위해 Redis나 Kafka를 사용하는 사례도 많다. 하지만 현재 프로젝트의 요구사항과 운영 환경을 기준으로 보면, 두 기술 모두 아쉬운 지점이 있었다.

Redis Pub/Sub

Redis Pub/Sub은 구독자가 연결되어 있는 순간에만 메시지를 전달한다. 즉, 메시지를 저장하지 않는다. 인스턴스가 재시작 중이거나 네트워크 순단이 발생하면 그 사이에 발행된 메시지는 영구적으로 유실되는 문제가 있다.

Outbox 패턴으로 "발행 보장"을 구현해놓고, 브로커 단에서 메시지가 유실되면 의미가 없다. Redis Pub/Sub에는 메시지 ACK 메커니즘도 없어서, 브로커가 "전달했다"고 확인해줄 방법도 없다.

Redis Streams를 쓰면 영속성 문제는 해결되지만, Consumer Group 관리와 오프셋 추적을 직접 구현해야 하는 부담이 생긴다. 이 시점에서 이미 RabbitMQ가 제공하는 것을 재구현하는 셈이다.

Kafka

Kafka는 대용량 이벤트 스트리밍에 설계된 시스템이다. 메시지를 디스크에 순서대로 저장하고, 컨슈머가 오프셋을 관리하며, 파티션 기반으로 수평 확장한다.

Kafka에서 Fanout 브로드캐스트를 구현하려면 각 인스턴스가 서로 다른 Consumer Group을 가져야 한다. 가능은 하지만, 이 정도 규모의 채팅에 Kafka의 파티션/오프셋/리밸런싱 관리를 도입하는 것은 복잡도 대비 이점이 없다고 생각했다.

RabbitMQ

RabbitMQ는 다음 요구사항을 균형 있게 만족시켰다.

1. Fanout Exchange가 요구사항에 정확히 대응한다
"모든 인스턴스에 같은 메시지를 전달"이라는 요구사항을 Exchange 타입 하나로 해결한다. 별도의 Consumer Group 트릭이나 토픽 복제 설정 없이, Fanout Exchange에 큐를 바인딩하면 끝이다.

2. 메시지 ACK로 전달을 보장한다
Consumer가 메시지를 정상 처리하면 ACK를 보내고, 큐에서 제거된다. ACK 전에 Consumer가 죽으면 메시지는 큐에 남아서 다른 Consumer(또는 재시작된 Consumer)가 다시 받는다. Redis Pub/Sub에는 없는 메커니즘이다.

3. CloudAMQP 무료 플랜
CloudAMQP의 Little Lemur 플랜(무료)으로 managed RabbitMQ를 사용할 수 있다. 사이드 프로젝트에서 메시지 브로커 서버를 직접 운영할 필요가 없다.

4. 큐의 생명주기가 인스턴스와 일치한다
exclusive=true, autoDelete=true 큐를 사용하면, 인스턴스가 종료될 때 큐가 자동 삭제된다. 죽은 인스턴스의 큐에 메시지가 쌓이는 문제가 없다. Kafka에서는 비활성 Consumer Group의 오프셋 관리를 별도로 처리해야 한다.

 

결론적으로 메시지 유실 방지가 목표이므로 Redis Pub/Sub은 탈락, 처리량 수십 건 수준의 채팅에 Kafka는 과하므로 탈락. RabbitMQ의 Fanout Exchange + 메시지 ACK가 요구사항에 가장 정확히 부합했다.

2. 아키텍처 설계


최종적으로 설계한 메시지 흐름은 다음과 같다.

  Client (STOMP)
      │
      ▼
  WebSocketController
      │
      ▼
  WebSocketChatService (@Transactional)
      ├── ChatMessage DB 저장
      └── OutboxEvent DB 저장 (같은 트랜잭션)
              │
              ▼ (트랜잭션 커밋 후)
      TransactionalEventListener
              │
              ▼ (즉시 발행 시도)
      RabbitMQ (CloudAMQP)
        Fanout Exchange
        ┌─────┴─────┐
        ▼           ▼
    Queue(inst-1) Queue(inst-2)   ← 인스턴스별 전용 큐
        │           │
        ▼           ▼
    Consumer     Consumer
        │           │
        ▼           ▼
    STOMP 전송   STOMP 전송
        │           │
        ▼           ▼
    Client A     Client B

핵심 원칙

  1. DB 저장과 이벤트 발행을 같은 트랜잭션에서 -> 원자성 보장
  2. RabbitMQ Fanout Exchange로 모든 인스턴스에 브로드캐스트 -> 다중 인스턴스 문제 해결
  3. 즉시 발행 실패 시 스케줄러로 재시도 -> 메시지 유실 방지

3. 구현 상세: Outbox 패턴


Outbox 패턴은 마이크로서비스 아키텍처에서 자주 사용되는 패턴이다. 핵심 아이디어는 간단하다. 외부 시스템(여기서는 RabbitMQ)에 보낼 메시지를 DB 테이블에 먼저 저장하고, 비즈니스 로직과 같은 트랜잭션으로 묶는 것이다.

테이블 설계

CREATE TABLE outbox_events (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id VARCHAR(255) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    payload JSONB NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    retry_count INT NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_at TIMESTAMPTZ
);

CREATE INDEX idx_outbox_events_pending
ON outbox_events (status, created_at) WHERE status = 'PENDING';

payload를 JSONB로 저장한 이유는 브로드캐스트 대상(topic/user)과 메시지 내용을 하나의 구조화된 데이터로 담기 위해서다.

엔티티

enum class OutboxEventStatus {
    PENDING,
    PUBLISHED
}

@Entity
@Table(name = "outbox_events")
class OutboxEvent(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L,

    @Column(name = "aggregate_type", nullable = false, updatable = false)
    val aggregateType: String,

    @Column(name = "aggregate_id", nullable = false, updatable = false)
    val aggregateId: String,

    @Column(name = "event_type", nullable = false, updatable = false)
    val eventType: String,

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(nullable = false, updatable = false, columnDefinition = "jsonb")
    val payload: String,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    var status: OutboxEventStatus = OutboxEventStatus.PENDING,

    @Column(name = "retry_count", nullable = false)
    var retryCount: Int = 0,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now(),

    @Column(name = "published_at")
    var publishedAt: Instant? = null
) {
    fun markPublished() {
        status = OutboxEventStatus.PUBLISHED
        publishedAt = Instant.now()
    }
}

트랜잭션 내에서 함께 저장

WebSocketChatService에서 채팅 메시지와 Outbox 이벤트를 같은 @Transactional 안에서 저장한다.

@Transactional
fun sendMessage(userId: Long, chatRoomId: Long, content: String) {
    // 1. 채팅 메시지 DB 저장
    val chatMessage = chatRoomService.saveChatMessage(userId, chatRoomId, content)

    // 2. 브로드캐스트 메시지 구성
    val response = ChatMessageResponse(
        id = chatMessage.id,
        chatRoomId = chatRoomId,
        senderId = userId,
        content = content,
        type = MessageType.CHAT,
        timestamp = chatMessage.createdAt
    )

    val broadcasts = mutableListOf<ChatBroadcastMessage>()

    broadcasts.add(
        ChatBroadcastMessage(
            destinations = listOf(
                BroadcastDestination(
                    type = DestinationType.TOPIC,
                    path = "/topic/chat/$chatRoomId"
                )
            ),
            payload = response
        )
    )

    // 3. Outbox에 저장
    saveOutboxEvents(chatRoomId.toString(), "CHAT_MESSAGE", broadcasts)
}

private fun saveOutboxEvents(aggregateId: String, eventType: String, broadcasts: List<ChatBroadcastMessage>) {
    val payload = objectMapper.writeValueAsString(broadcasts)
    val outboxEvent = outboxEventRepository.save(
        OutboxEvent(
            aggregateType = "ChatRoom",
            aggregateId = aggregateId,
            eventType = eventType,
            payload = payload
        )
    )
    // Spring ApplicationEvent 발행 -> 트랜잭션 커밋 후 즉시 발행 시도
    applicationEventPublisher.publishEvent(OutboxPublishEvent(outboxEvent.id))
}

여기서 중요한 점은 ChatMessage와 OutboxEvent가 같은 트랜잭션에서 저장되므로, 둘 다 성공하거나 둘 다 실패한다는 것이다. 이를 통해 DB에 메시지는 있는데 이벤트가 없는 상황, 혹은 그 반대 상황을 원천적으로 방지할 수 있다.

4. 구현 상세: 즉시 발행 + Fallback 폴링


Outbox 패턴의 이벤트 발행 전략은 두 가지를 조합했다.

즉시 발행: @TransactionalEventListener

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun handleOutboxPublishEvent(event: OutboxPublishEvent) {
    try {
        val outboxEvent = outboxEventRepository.findById(event.outboxEventId).orElse(null) ?: return
        if (outboxEvent.status != OutboxEventStatus.PENDING) return

        rabbitTemplate.convertAndSend(exchangeName, "", outboxEvent.payload)
        outboxEvent.markPublished()
        outboxEventRepository.save(outboxEvent)
    } catch (e: Exception) {
        logger.warn(e) { "즉시 발행 실패 (outboxEventId=${event.outboxEventId}), 스케줄러에서 재시도 예정" }
    }
}

TransactionPhase.AFTER_COMMIT을 사용하여 트랜잭션이 커밋된 후에만 실행되도록 함으로써, DB에 데이터가 확실히 저장된 상태에서만 RabbitMQ로 발행을 시도하도록 했다.

Propagation.REQUIRES_NEW로 새 트랜잭션을 열어서 status 업데이트를 처리한다. 어차피 Fallback 폴링이 있기 때문에, 즉시 발행에 실패해도 예외를 삼키고 로그만 남기도록 했다.

Fallback 폴링: @Scheduled

@Scheduled(fixedDelayString = $$"${outbox.poll-interval-ms:5000}")
@SchedulerLock(name = "outbox-poll", lockAtMostFor = "PT25S", lockAtLeastFor = "PT4S")
@Transactional
fun pollPendingEvents() {
    val pendingEvents = outboxEventRepository.findByStatus(OutboxEventStatus.PENDING)
    for (event in pendingEvents) {
        if (event.retryCount >= maxRetryCount) {
            logger.error { "최대 재시도 횟수 초과 (outboxEventId=${event.id}, retryCount=${event.retryCount})" }
            continue
        }
        try {
            rabbitTemplate.convertAndSend(exchangeName, "", event.payload)
            event.markPublished()
        } catch (e: Exception) {
            event.retryCount++
            logger.warn(e) { "Outbox 폴링 발행 실패 (outboxEventId=${event.id}, retryCount=${event.retryCount})" }
        }
        outboxEventRepository.save(event)
    }
}

5초마다 PENDING 상태인 이벤트를 조회해서 재시도한다. 무한 재시도를 방지하기 위해 maxRetryCount(기본 5회)를 두었다.

정리(Cleanup): 오래된 이벤트 삭제

@Scheduled(fixedRate = 3600000)
@SchedulerLock(name = "outbox-cleanup", lockAtMostFor = "PT5M", lockAtLeastFor = "PT30S")
@Transactional
fun cleanupPublishedEvents() {
    val cutoff = Instant.now().minus(cleanupOlderThanHours, ChronoUnit.HOURS)
    outboxEventRepository.deleteByStatusAndPublishedAtBefore(OutboxEventStatus.PUBLISHED, cutoff)
}

PUBLISHED 상태로 1시간 이상 경과한 이벤트를 삭제하여, Outbox 테이블이 무한정 커지는 것을 방지하였다.

5. 구현 상세: RabbitMQ Fanout Exchange


왜 CloudAMQP인가

필자는 RabbitMQ를 직접 운영하는 대신 CloudAMQP(managed RabbitMQ 서비스)를 사용했다. Render를 사용하여 서버를 배포하고 있기 때문에 서버 내부에서 띄우기보다는 안정성을 위해 외부 관리형 서비스를 사용하는 방식을 채택했다.

Fanout Exchange + 인스턴스별 Queue

@Configuration
class RabbitMQConfig(
    @Value($$"${chat.exchange-name}")
    private val exchangeName: String
) {

    private val instanceId: String = UUID.randomUUID().toString()

    @Bean
    fun chatBroadcastExchange(): FanoutExchange =
        FanoutExchange(exchangeName, true, false)

    @Bean
    fun chatInstanceQueue(): Queue =
        Queue("$exchangeName.$instanceId", false, true, true)

    @Bean
    fun chatQueueBinding(chatBroadcastExchange: FanoutExchange, chatInstanceQueue: Queue): Binding =
        BindingBuilder.bind(chatInstanceQueue).to(chatBroadcastExchange)
}

핵심 설계

  • Fanout Exchange: 바인딩된 모든 큐에 메시지를 복제해서 전달한다. 라우팅 키를 무시하고 무조건 브로드캐스트
  • 인스턴스별 UUID 큐: 각 서버 인스턴스가 시작될 때 고유한 큐를 생성한다. exclusive=true, autoDelete=true로 설정해서 인스턴스가 죽으면 큐도 자동으로 삭제되도록 한다.

이 구조 덕분에 인스턴스가 3개든 10개든, 모든 인스턴스가 동일한 채팅 메시지를 수신할 수 있다.

Consumer: RabbitMQ + WebSocket 클라이언트

@Component
class ChatMessageRabbitMQConsumer(
    private val messagingTemplate: SimpMessagingTemplate,
    private val objectMapper: ObjectMapper
) {

    @RabbitListener(queues = ["#{chatInstanceQueue.name}"])
    fun handleMessage(payload: String) {
        try {
            val broadcasts: List<ChatBroadcastMessage> = objectMapper.readValue(
                payload,
                object : TypeReference<List<ChatBroadcastMessage>>() {}
            )

            for (broadcast in broadcasts) {
                for (destination in broadcast.destinations) {
                    val serializedPayload = objectMapper.writeValueAsString(broadcast.payload)
                    when (destination.type) {
                        DestinationType.TOPIC -> {
                            messagingTemplate.convertAndSend(destination.path, serializedPayload)
                        }
                        DestinationType.USER -> {
                            messagingTemplate.convertAndSendToUser(
                                destination.userId!!,
                                destination.path,
                                serializedPayload
                            )
                        }
                    }
                }
            }
        } catch (e: Exception) {
            logger.error(e) { "RabbitMQ 메시지 처리 실패" }
        }
    }
}

RabbitMQ에서 메시지를 받으면, 목적지 타입(TOPIC/USER)에 따라 SimpMessagingTemplate으로 해당 인스턴스에 연결된 WebSocket 클라이언트에게 전달한다. 모든 인스턴스가 같은 메시지를 받지만, 각 인스턴스는 자신에게 연결된 클라이언트에게만 전송하므로 중복 수신은 발생하지 않는다.

6. 구현 상세: ShedLock으로 분산 스케줄링


@Scheduled 폴링은 모든 인스턴스에서 동시에 실행된다. 따라서 인스턴스가 3개면 같은 PENDING 이벤트를 3개 인스턴스가 동시에 처리하려고 시도할 수 있다. 결과적으로 같은 메시지가 3번 발행될 수도 있다.

이를 해결하기 위해 ShedLock을 사용하였다. 분산 스케줄링에서 "하나의 인스턴스만 실행"을 보장하는 방법은 여러 가지가 있겠지만, ShedLock을 선택한 데는 몇 가지 이유가 있다.

  1. Outbox 이벤트는 PostgreSQL에 저장되어 있으므로, 락도 같은 DB에 두면 "PostgreSQL만 살아있으면 폴링이 동작한다"는 단순한 의존 관계를 만들 수 있다.
  2. 추가 인프라가 필요 없고, 코드 변경을 최소화할 수 있다.
  3. ShedLock은 "여러 인스턴스에서 같은 스케줄러가 동시에 실행되지 않도록 보장"한다는 딱 하나의 문제만 풀기 때문에, 별다른 기능이 없는 대신, 그만큼 가볍고 이해하기 쉽다.
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30S")
class ShedLockConfig {

    @Bean
    fun lockProvider(dataSource: DataSource): LockProvider =
        JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(org.springframework.jdbc.core.JdbcTemplate(dataSource))
                .usingDbTime()
                .build()
        )
}

ShedLock은 DB 테이블을 이용한 분산 락 라이브러리다. shedlock 테이블에 락 정보를 기록해서, 여러 인스턴스 중 하나만 스케줄러를 실행하도록 보장한다.

CREATE TABLE shedlock (
    name VARCHAR(64) PRIMARY KEY,
    lock_until TIMESTAMPTZ NOT NULL,
    locked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    locked_by VARCHAR(255) NOT NULL
);

스케줄러에 적용한 락 설정은 다음과 같다.

@SchedulerLock(name = "outbox-poll", lockAtMostFor = "PT25S", lockAtLeastFor = "PT4S")
  • lockAtMostFor = "PT25S": 락을 최대 25초까지 유지한다. 인스턴스가 비정상 종료되어도 25초 후에는 다른 인스턴스가 락을 획득할 수 있다.
  • lockAtLeastFor = "PT4S": 락을 최소 4초 동안 유지한다. 작업이 너무 빨리 끝나도 다음 인스턴스가 바로 실행하지 않도록 방지할 수 있다.

7. 결과


달성한 것

  1. 메시지 유실 방지: Outbox 패턴으로 DB 트랜잭션과 메시지 발행의 원자성을 보장했다. 즉시 발행이 실패해도 폴링으로 재시도된다.
  2. 다중 인스턴스 지원: RabbitMQ Fanout Exchange로 모든 인스턴스에 메시지를 브로드캐스트한다.
  3. 장애 복구: 서버가 재시작되어도 PENDING 상태의 이벤트를 폴링으로 복구한다.
  4. 중복 처리 방지: ShedLock으로 스케줄러의 분산 실행을 제어한다.

트레이드오프

  • 복잡도 증가: 단순 messagingTemplate.send() 한 줄이면 될 것을 Outbox 테이블, 이벤트 리스너, 폴링 스케줄러, RabbitMQ Consumer 까지 만들어야 한다.
  • 지연: 즉시 발행이 성공하면 거의 실시간이지만, 실패 시 최대 5초(폴링 간격)까지 지연될 수 있다.
  • Outbox 테이블 관리: 정리 스케줄러를 돌리지 않으면 테이블이 계속 커진다.

추후 개선 가능한 점

  • CDC(Change Data Capture) 방식: Debezium 같은 도구로 Outbox 테이블의 변경을 감지해서 폴링 없이 이벤트를 발행할 수 있다. 폴링보다 실시간성이 좋고 DB 부하도 줄어든다.
  • Dead Letter Queue: 최대 재시도 횟수를 초과한 이벤트를 별도 큐로 이동시켜 모니터링할 수 있다.

'개발일지' 카테고리의 다른 글

jOOQ 코드 생성 프로세스 개선하기  (0) 2026.01.10

0. 들어가며


SQL을 작성하다 보면 "JOIN을 할 때, 왼쪽 테이블의 컬럼 값을 오른쪽 서브쿼리 안에서 변수처럼 쓰고 싶다"는 생각이 들때가 있다. 그리고 일반적인 JOIN에서는 불가능한 이 작업을 가능하게 해주는 것이 바로 LATERAL JOIN이다.

LATERAL JOIN은 SQL 표준(SQL:1999)에 도입된 기능으로, 서브쿼리가 자신보다 먼저 명시된 선행 테이블의 컬럼을 참조할 수 있도록 허용하는 조인 방식이다.

일반적인 서브쿼리는 독립적으로 실행되어 그 결과를 테이블에 붙이는 방식이지만, LATERAL JOIN은 마치 프로그래밍의 for-each 루프처럼 작동한다. 왼쪽 테이블의 각 행(row)에 대해 오른쪽 서브쿼리를 반복해서 실행하는 구조라고 이해하면 쉽다.

예를 들어, 왼쪽 테이블에 5명의 사용자가 있고, 오른쪽 서브쿼리에서 사용자별 최근 주문 내역을 3개씩 가져오려 한다면

  • 왼쪽(사용자 1) -> 오른쪽(주문 내역 조회 쿼리 1회 실행, 최근 주문 내역 3개를 가져옴)
  • 왼쪽(사용자 2) -> 오른쪽(주문 내역 조회 쿼리 1회 실행, 최근 주문 내역 3개를 가져옴)
  • ...
  • 왼쪽(사용자 5) -> 오른쪽(주문 내역 조회 쿼리 1회 실행, 최근 주문 내역 3개를 가져옴)

이렇게 총 5번의 하위 SELECT 실행이 내부적으로 반복된다.

1. 왜 'LATERAL' 인가?


'Lateral'은 '측면의', '옆으로의'라는 뜻을 가지고 있다. 이는 서브쿼리가 자기 울타리 안에 갇혀 있지 않고, 옆에 있는(Lateral) 테이블의 데이터를 들여다보고 참조할 수 있다는 의미를 담고 있다.

일반 JOIN/서브쿼리와 LATERAL JOIN의 결정적인 차이는 '상호작용성'에 있다.

구분 일반 JOIN / 서브쿼리 LATERAL JOIN
참조 범위 서브쿼리 외부의 컬럼의 참조할 수 없음 왼쪽 테이블의 컬럼을 자유롭게 참조 가능
실행 방식 서브쿼리가 한 번 실행되어 결과셋 생성 왼쪽 행마다 서브쿼리가 반복 계산됨
주요 용도 단순 데이터 결합 행별 계산, TOP-N 추출, 복잡한 가공

따라서 LATERAL JOIN은 다음과 같은 경우에 유용하게 사용할 수 있다.

  • 행별로 상위 N개의 결과를 가져와야 할 때 (예: 각 사용자별 최신 주문 3건)
  • 계산된 결과를 즉시 재사용해야 할 때 (SELECT 절의 복잡한 연산을 반복하지 않음)
  • 집합 리턴 함수(Set-returning functions)를 사용할 때 (JSON 데이터 풀기 등)
PostgreSQL에서는 LATERAL 키워드를 명시하며, Oracle에서는 CROSS APPLY 또는 OUTER APPLY라는 이름으로 동일한 기능을 제공한다.

2. LATERAL JOIN의 문법과 작동 원리


LATERAL JOIN이 강력한 이유는 그 동작 방식에 있다. 문법 자체는 간단하지만, 데이터가 흐르는 방식을 이해하는 것이 중요하다.

2-1. 기본 문법

PostgreSQL 기준으로 문법은 다음과 같다. JOIN 키워드 뒤에 LATERAL을 붙이고 서브쿼리를 작성한다.

SELECT *
FROM table_a a
CROSS JOIN LATERAL (
    SELECT *
    FROM table_b b
    WHERE b.foreign_id = a.id -- a(왼쪽 테이블)의 컬럼을 참조!
) sub;

2-2. 동작 원리 (The For-Each Loop)

일반적인 JOIN은 두 테이블을 한꺼번에 비교하여 합치지만, LATERAL JOIN은 다음과 같은 순서로 움직인다.

  1. Step 1: 왼쪽 테이블(table_a)에서 첫 번째 행을 가져온다.
  2. Step 2: 그 행의 값을 서브쿼리의 파라미터로 전달한다.
  3. Step 3: 서브쿼리를 실행하여 결과 행들을 생성한다.
  4. Step 4: 왼쪽 테이블의 다음 행으로 넘어가서 위 과정을 반복한다.

3. 실전 예제: "각 카테고리별 최신 상품 2개씩 가져오기"


LATERAL JOIN이 가장 빛나는 순간은 "그룹별로 상위 N개의 결과만 필터링"해야 할 때이다.

3-1. 문제 상황

categories 테이블과 products 테이블이 있을 때, 각 카테고리별로 가장 비싼 상품 2개씩만 뽑아보고 싶다고 가정해보자. LATERAL JOIN이 없다면 보통 ROW_NUMBER()를 사용하여 전체 순위를 매긴 뒤, 서브쿼리로 한 번 더 감싸서 필터링해야 한다. 이는 쿼리가 다소 길고 직관적이지 않다.

SELECT category_name, product_name, price
FROM (
    SELECT 
        c.name AS category_name,
        p.name AS product_name,
        p.price,
        ROW_NUMBER() OVER (PARTITION BY p.category_id ORDER BY p.price DESC) as rank
    FROM categories c
    JOIN products p ON c.id = p.category_id
) ranked_products
WHERE rank <= 2; -- 서브쿼리 밖에서 다시 필터링 필요

3-2. LATERAL JOIN을 이용한 해결

LATERAL을 사용하면 서브쿼리 안에서 ORDER BY와 LIMIT을 자유롭게 사용할 수 있어 코드가 훨씬 간결해진다. 마치 카테고리 하나하나를 순회하며 "그 카테고리의 비싼 상품 2개만 가져와"라고 명령하는 것과 같다.

SELECT 
    c.name AS category_name,
    p.name AS product_name,
    p.price
FROM categories c
LEFT JOIN LATERAL (
    SELECT name, price
    FROM products
    WHERE category_id = c.id  -- 외부 테이블(c) 참조
    ORDER BY price DESC       -- 가격순 정렬
    LIMIT 2                   -- 카테고리당 딱 2개만!
) p ON TRUE;

왜 LATERAL JOIN을 사용하는게 더 좋을까?

  • 가독성: 윈도우 함수(ROW_NUMBER())와 서브쿼리를 중첩해서 쓰는 것보다 논리 구조가 훨씬 명확하다.
  • 유연성: 서브쿼리 내부에서 복잡한 연산이나 함수 호출을 행 단위로 수행할 수 있다.
  • 성능: 인덱스가 적절히 설정되어 있다면, 모든 데이터를 읽어서 순위를 매기는 윈도우 함수보다 각 카테고리별로 필요한 데이터만 인덱스로 찾아내는 LATERAL JOIN이 더 빠를 수 있다.

3-3. 주의사항

  • 참조의 방향성: 반드시 왼쪽(먼저 정의된) 테이블의 컬럼만 오른쪽(LATERAL)에서 참조할 수 있다. 반대 방향은 불가능하다.
  • 성능 체크: 데이터 양이 많을 경우 왼쪽 테이블의 행 수만큼 서브쿼리가 호출되므로, 서브쿼리 내부의 조인 조건에 인덱스가 걸려 있는지 반드시 확인해야 한다.

4. 마치며


지금까지 LATERAL JOIN의 정의부터 실전 예제까지 살펴보았다. 처음 접하면 다소 낯설 수 있지만, 한 번 익숙해지면 SQL의 표현력을 비약적으로 높여주는 도구이다.

핵심 요약

  • 동적 참조: 서브쿼리가 외부 테이블의 컬럼을 실시간으로 참조할 수 있게 해준다.
  • 강력한 행별 연산: TOP-N 추출, JSON 파싱, 복잡한 수식 계산 등 '행 단위' 작업에 최적화되어 있다.
  • 코드 가독성: 윈도우 함수와 중첩 서브쿼리로 복잡하게 얽힌 코드를 훨씬 직관적인 for-each 구조로 변경해 준다.

선택을 위한 가이드

모든 상황에서 LATERAL JOIN이 정답은 아니다. 상황에 맞춰 적절한 도구를 선택해야 한다.

  1. 단순 조인이라면? → 일반 JOIN이 가장 빠르고 표준적이다.
  2. 전체 순위나 누적 합이 필요하다면? → 윈도우 함수(OVER())가 효율적이다.
  3. 그룹별로 일부 행만 추출하거나, 행마다 복잡한 서브쿼리 결과를 붙여야 한다면? → 고민하지 말고 LATERAL JOIN을 선택하자.

데이터베이스의 성능과 쿼리의 가독성 사이에서 고민하는 개발자에게 LATERAL JOIN은 훌륭한 해답이 된다. 특히 PostgreSQL이나 Oracle(Apply), SQL Server 환경에서 작업하고 있다면, LATERAL JOIN을 사용했을 때 복잡했던 SQL 코드가 한결 가벼워지는 경험을 할 수 있을 것이다.

 

'TIL' 카테고리의 다른 글

데이터베이스의 Git, Flyway 도입기  (0) 2026.01.19
왜 jOOQ인가  (0) 2026.01.09

0. 들어가며


JPA(Hibernate)를 처음 접하면 ddl-auto 옵션에 감탄하게 된다. Entity 클래스에 필드 하나만 추가하고 서버를 재시작하면 DB 테이블이 알아서 척척 바뀌기 때문이다.

하지만 서비스 규모가 커지고 실제 운영 환경에 가까워질수록, 우리는 이 편리한 기능과 작별해야 한다. 운영 DB에서 ddl-auto를 사용하는 것은 매우 위험하기 때문이다.

  • 정교한 제어의 불가능: JPA는 단순한 컬럼 추가는 잘 하지만, 기존 데이터의 제약 조건을 변경하거나 인덱스를 효율적으로 생성하는 세밀한 작업에는 서툴다. 잘못된 쿼리가 실행되어 수천만 건의 데이터가 담긴 테이블에 Lock이 걸리면 서비스는 그대로 중단된다.
  • 데이터 마이그레이션: 단순히 구조만 바꾸는 게 아니라, "A 컬럼의 데이터를 가공해서 B 컬럼으로 옮겨야 하는" 상황에서 JPA는 아무것도 해줄 수 없다.
  • 히스토리 파악 불가: "이 테이블의 구조가 1년 전과 지금 어떻게 달라졌는가?"에 대해 JPA는 답을 주지 않는다. 오직 '현재 상태'만 보여줄 뿐이다.

이러한 문제를 해결하기 위해 등장한 것이 바로 데이터베이스의 Git, Flyway이다.

1. Flyway란 무엇인가


Flyway는 오픈소스 데이터베이스 마이그레이션 도구이다.

여기서 마이그레이션이란 '이주하다'라는 뜻으로, 데이터베이스 마이그레이션이라고 하면 MySQL에서 PostgreSQL로 데이터베이스를 옮긴다고 생각할 수도 있다.

하지만 Flyway는 그런 의미가 아닌 데이터베이스 스키마 변경을 관리하는 도구이다.

https://documentation.red-gate.com/fd/migrations -271585107.html

마이그레이션은 개발 데이터베이스에 가해진 점진적인 변경 사항을 기록합니다. 이는 스키마 및 데이터 변경 사항(예: CREATE, ALTER, INSERT, UPDATE 등)을 담은 SQL 스크립트 입니다. 이러한 스크립트는 버전 관리 시스템에서 관리되어야 합니다. 해당 스크립트들은 다른 환경으로 배포할 때 사용되어, 일관된 방식의 배포를 가능하게 합니다. 모든 환경에서 동일한 스크립트가 동일한 순서로 실행되므로, 반복 가능하고 신뢰할 수 있는 배포 프로세스를 제공합니다.

Flyway 공식 홈페이지에서 언급이 되어 있으며, 쉽게 말하자면 Git을 생각하면 된다. 소스 코드를 Git으로 버전 관리하듯이, 데이터베이스의 스키마 변경 사항을 스크립트 파일로 작성하여 버전별로 관리할 수 있게 해준다.

핵심 원리: flyway_schema_history

Flyway의 작동 원리는 단순하지만 강력하다.

  1. DB 내에 flyway_schema_history라는 메타데이터 테이블을 생성한다.
  2. 적용해야 할 SQL 파일들(V1, V2...)의 버전과 체크섬(파일 내용의 해시값)을 기록한다.
  3. 애플리케이션이 뜰 때마다 이 테이블을 검사하여, 아직 실행되지 않은 높은 버전의 스크립트만 순서대로 실행한다.

2. flyway_schema_history


Flyway의 모든 동작은 flyway_schema_history라는 단 하나의 테이블을 중심으로 돌아간다. Flyway를 처음 실행하면 이 테이블이 자동으로 생성된다.

SELECT * FROM flyway_schema_history;
| installed_rank | version | description           | type | script                       | checksum    | installed_by | installed_on               | execution_time | success |
| -------------- | ------- | --------------------- | ---- | -----------------------------| ----------- | ------------ | -------------------------- | -------------- | ------- |
| 1              | 1       |  create member table  | SQL  | V1__create_member_table.sql  | 1795424239  | postgres     | 2026-01-18 03:45:02.609075 | 416            | true    |
| 2              | 2       |  add email column     | SQL  | V2__add_email_column.sql     | 489194189   | postgres     | 2026-01-18 03:47:37.501219 | 74             | true    |

각 컬럼의 의미는 다음과 같다.

  • installed_rank: 실행 순서
  • version: 마이그레이션 버전
  • description: 파일명에서 추출한 설명
  • script: 실행된 스크립트 파일명
  • checksum: 파일 내용의 해시값
  • installed_on: 실행 시각
  • success: 성공 여부 (1: 성공, 0: 실패)

Flyway의 실행 흐름

애플리케이션이 시작될 때마다 Flyway는 다음 과정을 거친다.

1. flyway_schema_history 테이블 확인
   └── 없으면 생성

2. db/migration 디렉토리의 스크립트 스캔
   └── V1, V2, V3... 파일 목록 수집

3. 히스토리 테이블과 비교
   └── 이미 실행된 버전: checksum 일치 여부 검증
   └── 새로운 버전: 실행 대상으로 표시

4. 미실행 스크립트를 버전 순서대로 실행

5. 실행 결과를 히스토리 테이블에 기록

checksum이 중요한 이유

checksum은 스크립트 파일 내용을 해시한 값이다. Flyway는 이 값을 통해 이미 적용된 스크립트가 변경되었는지 감지한다. 만약 V1__create_member_table.sql을 이미 실행한 후에 파일 내용을 수정하면 어떻게 될까?

Flyway는 V1의 checksum 변경을 감지하여 이미 적용된 스크립트가 달라진 것을 확인하고 애플리케이션 실행을 거부한다.

이 정책이 존재하는 이유는 명확하다. 개발 서버에서는 수정된 V1이 적용되고, 운영 서버에서는 예전 V1이 적용된 상태라면 두 환경의 스키마가 달라지기 때문이다.

따라서 Flyway를 사용할 때의 황금률은 다음과 같다.

한번 커밋된 마이그레이션 스크립트는 절대 수정하지 않는다. 변경이 필요하면 새로운 버전의 스크립트를 추가한다.

3. Spring Boot에서 Flyway 설정하기


Spring Boot는 Flyway를 공식적으로 지원하기 때문에, 설정이 매우 간단하다.

3-1. 의존성 추가

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-flyway")
    implementation("org.flywaydb:flyway-database-postgresql")
}

Spring Boot 3.x 기준으로, spring-boot-starter-flyway만 추가하면 자동 설정이 활성화된다. 단, PostgreSQL이나 MySQL을 사용한다면 관련 의존성을 반드시 추가해야 한다. 누락하면 애플리케이션 시작 시 오류가 발생한다.

3-2. application.yml 설정

spring:
  flyway:
    enabled: true
    baseline-on-migrate: true
    baseline-version: 0
    locations: classpath:db/migration

주요 설정 옵션을 살펴보면 다음과 같다.

  • enabled: Flyway 활성화 여부, 기본값은 true
  • locations: 마이그레이션 스크립트 경로, 기본값은 classpath:db/migration
  • baseline-on-migrate: flyway_schema_history 테이블이 없을 때 자동 생성 여부
  • baseline-version: baseline 시작 버전, 이 버전 이하의 스크립트는 무시됨

3-3 마이그레이션 스크립트 작성

스크립트는 src/main/resources/db/migration 디렉토리에 위치해야 한다.

src/
└── main/
    └── resources/
        └── db/
            └── migration/
                ├── V1__create_member_table.sql
                ├── V2__add_email_column.sql
                └── V3__create_order_table.sql

파일명은 반드시 정해진 규칙을 따라야 한다.

V{버전}__{설명}.sql

  • V1__create_member_table.sql
  • V2__add_email_column.sql
  • V3__create_order_table.sql

여기서 주의할 점이 몇 가지 있다.

  • V는 대문자여야 한다
  • 버전과 설명 사이에 언더스코어가 두 개(__)다. 실수하기 쉬운 부분이다.
  • 버전은 정수 또는 소수점 형태 모두 가능하다 (V1, V1.1, V1_1 등)
  • 한번 적용된 스크립트는 절대 수정하면 안 된다. Flyway는 체크섬으로 파일 변경을 감지하고, 변경이 감지되면 애플리케이션 시작을 거부한다.

3-4. 첫 번째 마이그레이션 작성 예시

-- V1__create_member_table.sql
CREATE TABLE member (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- V2__add_email_column.sql
ALTER TABLE member
ADD COLUMN email VARCHAR(255) NULL;

이제 애플리케이션을 시작하면 Flyway가 자동으로 스크립트를 순서대로 실행하고, flyway_schema_history 테이블에 실행 이력을 기록한다.

3-5 JPA의 ddl-auto와 함께 사용하기

Flyway를 도입했다면, JPA의 자동 스키마 생성 기능은 꺼두는 것이 원칙이다.

spring:
  jpa:
    hibernate:
      ddl-auto: validate  # 또는 none

 

  • validate: Entity와 실제 테이블 구조가 일치하는지 검증만 한다. 불일치 시 애플리케이션 시작 실패
  • none: 아무것도 하지 않는다

validate 옵션을 추천한다. Flyway로 스키마를 관리하면서, Entity 클래스와의 불일치를 조기에 발견할 수 있기 때문이다.

4. 이미 운영 중인 프로젝트에 Flyway 도입하기


새 프로젝트라면 V1부터 차근차근 시작하면 된다. 하지만 현실은 다르다. 대부분은 이미 테이블이 존재하고, 데이터도 쌓여있는 상태에서 Flyway를 도입하게 된다.

이때 필요한 개념이 바로 baseline이다.

4-1. baseline이란

baseline은 "이 시점의 스키마 상태를 버전 X로 간주하겠다"라고 선언하는 것이다.

예를 들어 현재 운영 DB에 member, order, product 테이블이 이미 존재한다고 가정하자. 이 상태를 V1으로 잡고 싶다면, Flyway에게 "현재 상태가 V1이니까, V2부터 실행해"라고 알려줘야 한다.

spring:
  flyway:
    baseline-on-migrate: true
    baseline-version: 1

이렇게 설정하면 Flyway는 첫 실행 시 다음과 같이 동작한다.

1. flyway_schema_history 테이블 생성
2. "현재 상태를 V1로 기록" (baseline 엔트리 추가)
3. V1 이하의 스크립트는 무시
4. V2부터 실행

히스토리 테이블을 조회해보면 baseline 기록을 확인할 수 있다.

installed_rank version description type script
1 1 << Flyway Baseline >> BASELINE << Flyway Baseline >>
2 2 add phone column SQL V2__add_phone_column.sql

4-2. 실무 도입 시나리오

실제로 운영 중인 프로젝트에 Flyway를 도입하는 과정을 단계별로 살펴보자.

Step 1: 현재 스키마를 V1으로 덤프해둔다

-- V1__baseline_schema.sql
-- 이 파일은 실행되지 않지만, 기록용으로 보관한다

CREATE TABLE member (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    created_at DATETIME NOT NULL
);

CREATE TABLE order (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    member_id BIGINT NOT NULL,
    amount DECIMAL(10, 2) NOT NULL
);

-- ... 현재 존재하는 모든 테이블

이 파일은 실제로 실행되지 않는다. baseline-version을 1로 설정하면 V1은 건너뛰기 때문이다. 하지만 "프로젝트 시작 시점의 스키마가 어땠는지" 기록하는 용도로 반드시 남겨두자. 나중에 신규 개발 환경을 세팅하거나, 스키마 히스토리를 파악할 때 유용하다.

Step 2: Flyway 설정 추가

spring:
  flyway:
    enabled: true
    baseline-on-migrate: true
    baseline-version: 1

Step 3: 새로운 변경사항은 V2부터 작성

-- V2__add_phone_column.sql
ALTER TABLE member
ADD COLUMN phone VARCHAR(20) NULL;

Step 4: 배포

운영 서버에 배포하면 Flyway가 자동으로 baseline을 설정하고, V2 스크립트를 실행한다.

4-3. 주의사항: 환경별 적용 순서

Flyway 도입 시 가장 흔한 실수는 환경별 적용 순서를 고려하지 않는 것이다.

[잘못된 순서]
1. 개발 환경에 Flyway 적용 (baseline V1 설정)
2. V2, V3 스크립트 작성 및 개발 환경에 적용
3. 운영 환경에 Flyway 적용 (baseline V1 설정)
4. 운영 환경에 V2, V3 자동 적용 → 💥 이미 수동으로 적용한 변경사항과 충돌!

운영 환경에서 이미 수동으로 스키마를 변경했다면, baseline 버전을 그에 맞게 조정해야 한다.

[올바른 순서]
1. 모든 환경의 현재 스키마 상태 확인
2. 가장 최신 상태를 기준으로 baseline 버전 결정
3. 모든 환경에 동시에 Flyway 적용
4. 이후 변경사항부터 Flyway로 관리

4-4. baseline-on-migrate vs baseline 명령어

baseline을 설정하는 방법은 두 가지가 있다.

방법 1: baseline-on-migrate 옵션 (권장)

spring:
  flyway:
    baseline-on-migrate: true
    baseline-version: 1

애플리케이션 시작 시 자동으로 baseline이 설정된다. 별도 작업이 필요 없어 편리하다.

방법 2: CLI 명령어로 직접 실행

flyway -url=jdbc:mysql://localhost:3306/mydb \
       -user=root \
       -password=password \
       -baselineVersion=1 \
       baseline

CI/CD 파이프라인에서 명시적으로 baseline을 설정하고 싶을 때 사용한다. 더 통제된 방식을 선호하는 팀에 적합하다.

5. 롤백 전략


"실수로 잘못된 마이그레이션을 운영에 배포했다."

언젠가 반드시 마주칠 상황이다. 이때 어떻게 대처할지 미리 알아두자.

5-1. Flyway의 철학: 롤백을 지원하지 않는다

Flyway는 기본적으로 롤백 기능을 제공하지 않는다. 정확히 말하면 유료 버전(Teams/Enterprise)에서만 undo 기능을 지원한다.

Flyway Community (무료): 롤백 ❌
Flyway Teams (유료): 롤백 ✅ (U1__undo_something.sql)

무료 버전에서 롤백이 없는 이유는 Flyway의 철학 때문이다.

"마이그레이션은 전진만 한다. 문제가 생기면 새로운 마이그레이션으로 수정한다."

이 철학이 합리적인 이유가 있다. 롤백은 생각보다 복잡하다. 단순한 스키마 변경도 데이터가 쌓이기 시작하면 롤백이 "원상복구"가 아니게 된다.

5-2. 가능한 롤백 전략

롤백 기능이 없더라도 대처 방법은 있다.

전략 1: 새로운 마이그레이션으로 수정 (Forward Fix)

가장 정석적인 방법이다.

-- V5__add_nickname_column.sql (실수: 길이가 너무 짧음)
ALTER TABLE member ADD COLUMN nickname VARCHAR(10);

-- V6__fix_nickname_length.sql (수정)
ALTER TABLE member MODIFY COLUMN nickname VARCHAR(50);

장점은 히스토리가 명확하게 남고, 모든 환경에 일관되게 적용된다는 것이다. 단점은 빠른 롤백이 필요한 긴급 상황에는 부적합하다.

전략 2: 수동 롤백 후 히스토리 조작

긴급 상황에서 사용하는 방법이다. 권장하지는 않지만 알아둬야 한다.

-- 1. 수동으로 스키마 롤백
ALTER TABLE member DROP COLUMN nickname;

-- 2. flyway_schema_history에서 해당 레코드 삭제
DELETE FROM flyway_schema_history 
WHERE version = '5';

-- 3. 마이그레이션 파일 수정 또는 삭제

이 방법은 최후의 수단이다. 환경 간 히스토리 불일치가 발생할 수 있어 매우 위험하다.

전략 3: repair 명령어 활용

마이그레이션 실행 중 실패한 경우에 사용한다.

# 실패한 마이그레이션 기록 정리
flyway repair

repair는 다음 작업을 수행한다.

  • 실패한 마이그레이션 레코드 삭제 (success=0)
  • checksum 불일치 수정

단, 성공한 마이그레이션(success=1)은 repair로 되돌릴 수 없다.

5-3. 롤백 가능한 구조 만들기

따라서 롤백 기능에 의존하기보다는, 롤백이 쉬운 구조로 마이그레이션을 작성하는 게 더 중요하다.

패턴 1: 확장은 쉽고, 축소는 어렵다

-- 좋음: NULL 허용 컬럼 추가 (롤백 시 DROP만 하면 됨)
ALTER TABLE member ADD COLUMN nickname VARCHAR(50) NULL;

-- 위험: NOT NULL 컬럼 추가 (기존 데이터 처리 필요)
ALTER TABLE member ADD COLUMN nickname VARCHAR(50) NOT NULL;

새 컬럼은 항상 NULL을 허용하고, 데이터 안정화 후에 NOT NULL 제약을 추가하는 게 안전하다.

패턴 2: 삭제는 미루고, 추가부터

컬럼 이름을 변경하고 싶다면 한 번에 하지 말고 단계를 나눈다.

-- V5: 새 컬럼 추가
ALTER TABLE member ADD COLUMN user_nickname VARCHAR(50);

-- V6: 데이터 마이그레이션 (별도 스크립트 또는 배치)
UPDATE member SET user_nickname = nickname WHERE user_nickname IS NULL;

-- V7: 코드에서 새 컬럼 사용하도록 배포 (마이그레이션 아님)

-- V8: 기존 컬럼 삭제 (충분한 검증 후)
ALTER TABLE member DROP COLUMN nickname;

V5~V6까지만 적용된 상태에서 문제가 생기면 새 컬럼만 DROP하면 된다. 기존 nickname 데이터는 그대로 살아있다.

패턴 3: 피처 플래그와 함께 사용

스키마 변경과 코드 변경을 분리하면 롤백이 유연해진다.

1. 마이그레이션 배포 (새 컬럼 추가)
2. 코드 배포 (피처 플래그 OFF 상태)
3. 피처 플래그 ON으로 점진적 활성화
4. 문제 발생 시 피처 플래그 OFF (스키마는 그대로)
5. 원인 파악 후 수정하여 재배포

5-4. 결국 예방이 최선이다

롤백 전략을 아무리 잘 세워도, 운영에서 롤백하는 상황 자체가 고통스럽다. 예방이 최선이다.

6. 마치며


처음으로 돌아가보자. JPA의 ddl-auto가 나쁜 게 아니다. 다만 적재적소에 사용해야 한다.

ddl-auto의 편리함에서 Flyway의 체계적인 관리로 넘어가는 건 분명 번거롭다. 매번 SQL 파일을 작성해야 하고, 버전 관리도 신경 써야 한다. 하지만 그 번거로움을 감수할 만한 가치는 충분하다고 생각한다.

소스 코드에 Git이 있듯이, 데이터베이스에는 Flyway가 있다.

'TIL' 카테고리의 다른 글

LATERAL JOIN  (0) 2026.01.22
왜 jOOQ인가  (0) 2026.01.09

0. 들어가며


예외 처리는 애플리케이션의 안정성과 유지보수성을 결정짓는 핵심 요소다. 잘 설계된 예외 처리 구조는 디버깅을 쉽게 만들고, 클라이언트에게 일관된 에러 응답을 제공하며, 새로운 예외 상황이 생겨도 쉽게 확장할 수 있다.

이 글에서는 내가 실제 프로젝트에서 적용한 예외 처리 설계를 공유한다. 비즈니스 예외와 인프라 예외를 분리하고, ErrorCode를 인터페이스로 추상화하며, RFC 7807 표준을 활용한 응답 구조를 살펴본다.

1. 설계 원칙


1. 예외 계층 분리: 비즈니스 vs 인프라

예외를 크게 두 가지로 분류했다.

  • BusinessException: 비즈니스 로직 위반으로 발생하는 예외. 잘못된 인증 코드 입력, 존재하지 않는 리소스 조회 등 클라이언트의 요청이 원인인 경우다.
  • InfraException: 외부 API 호출 실패, DB 연결 오류 등 인프라 레벨에서 발생하는 예외. 클라이언트 잘못이 아닌 서버 측 문제다.

이렇게 분리한 이유는 명확하다. 두 예외는 처리 방식이 다르기 때문이다.

구분 BusinessException InfraException
원인 클라이언트 요청 오류 서버/외부 시스템 오류
클라이언트 응답 구체적인 에러 정보 제공 일반적인 서버 오류 메시지
로깅 상황에 따라 warn 또는 error 항상 error
후속 조치 대부분 불필요 알림, 재시도 로직 등 필요

2. 로그 메시지와 클라이언트 메시지 분리

예외에는 두 가지 메시지가 필요하다.

  • 로그 메시지: 개발자가 문제를 파악하기 위한 상세 정보
  • 클라이언트 메시지: 사용자에게 보여줄 간결하고 이해하기 쉬운 메시지

예를 들어 리소스를 찾지 못한 경우, 로그에는 "존재하지 않는 리소스에 대한 요청 발생: 12345"처럼 ID를 포함한 상세 정보를 남기고, 클라이언트에게는 "요청한 리소스를 찾을 수 없습니다"라는 일반적인 메시지를 전달한다.

3. ErrorCode 인터페이스화

에러 코드를 인터페이스로 정의하면 도메인별로 enum을 분리하면서도 일관된 처리가 가능하다. 새로운 도메인이 추가되어도 해당 도메인의 ErrorCode enum만 만들면 된다.

2. 구현


추상 예외 클래스

abstract class BusinessException(
    val errorCode: ErrorCode,
    logMessage: String,
    cause: Throwable? = null
) : RuntimeException(logMessage, cause)
abstract class InfraException(
    val errorCode: ErrorCode,
    logMessage: String,
    cause: Throwable? = null
) : RuntimeException(logMessage, cause)

두 추상 클래스 모두 ErrorCode를 가지고 있어 일관된 에러 응답 생성이 가능하다. RuntimeException의 message로는 로그용 메시지를 전달하고, 클라이언트 응답에는 errorCode.message를 사용한다.

ErrorCode 인터페이스

interface ErrorCode {
    val code: String
    val message: String
    val status: HttpStatus
}

code는 클라이언트가 에러를 식별하는 데 사용하는 문자열이다. 숫자 코드 대신 INVALID_VERIFICATION_CODE처럼 의미를 담은 문자열을 사용하면 클라이언트 개발자가 문서 없이도 에러의 의미를 파악할 수 있다.

도메인별 ErrorCode 구현

enum class AuthErrorCode(
    override val code: String,
    override val message: String,
    override val status: HttpStatus
) : ErrorCode {
    INVALID_VERIFICATION_CODE("INVALID_VERIFICATION_CODE", "인증코드가 일치하지 않습니다", HttpStatus.BAD_REQUEST),
    VERIFICATION_CODE_EXPIRED("VERIFICATION_CODE_EXPIRED", "인증코드가 만료 되었습니다", HttpStatus.GONE),
    AUTHENTICATION_FAILED("AUTHENTICATION_FAILED", "인증에 실패했습니다", HttpStatus.UNAUTHORIZED)
}
enum class CommonErrorCode(
    override val code: String,
    override val message: String,
    override val status: HttpStatus
) : ErrorCode {
    VALIDATION_FAILED("VALIDATION_FAILED", "유효성 검증에 실패했습니다", HttpStatus.BAD_REQUEST),
    INTERNAL_SERVER_ERROR("INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다", HttpStatus.INTERNAL_SERVER_ERROR),
    EXTERNAL_API_ERROR("EXTERNAL_API_ERROR", "외부 API 호출에 실패했습니다", HttpStatus.BAD_GATEWAY)
}

도메인이 늘어나면 UserErrorCode, OrderErrorCode 등을 추가하면 된다. 모두 ErrorCode 인터페이스를 구현하므로 GlobalExceptionHandler에서 동일한 방식으로 처리할 수 있다.

구체 예외 클래스

비즈니스 예외 예시:

class AuthenticationException(
    errorCode: AuthErrorCode = AuthErrorCode.AUTHENTICATION_FAILED,
    logMessage: String
) : BusinessException(
    errorCode = errorCode,
    logMessage = logMessage
)
class ResourceNotFoundException(
    resourceId: Long,
    errorCode: ErrorCode,
) : BusinessException(
    errorCode = errorCode,
    logMessage = "존재하지 않는 리소스에 대한 요청 발생: $resourceId"
)

인프라 예외 예시:

class ExternalApiException(
    externalApi: ExternalApi,
    requestUrl: String? = null,
    val statusCode: Int? = null,
    body: String? = null,
    cause: Throwable? = null,
) : InfraException(
    logMessage = "${externalApi.apiName} API 호출 실패: url: $requestUrl, status: $statusCode, body: $body",
    errorCode = CommonErrorCode.EXTERNAL_API_ERROR,
    cause = cause
) {
    enum class ExternalApi(val apiName: String) {
        COOLSMS("CoolSMS"),
        AMAZON_S3("Amazon S3")
    }
}

ExternalApiException은 외부 API별로 enum을 정의해 로그 메시지에 어떤 API에서 문제가 발생했는지 명확히 남긴다. URL, 상태 코드, 응답 본문까지 로그에 포함되어 디버깅이 용이하다.

여기서 핵심은 커스텀 예외 클래스를 만들거면 명분이 있어야 한다는 것이다. ErrorCode를 강제하거나, 로그 메시지를 강제하거나, 해당 예외 클래스에 특화된 필드를 받아서 처리할 수도 있다.

GlobalExceptionHandler

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(AuthenticationException::class)
    fun handleAuthenticationException(e: AuthenticationException): ProblemDetail {
        logger.warn(e) { e.message }
        return ProblemDetail.forStatusAndDetail(e.errorCode.status, e.errorCode.message).apply {
            setProperty("code", e.errorCode.code)
        }
    }

    @ExceptionHandler(BusinessException::class)
    fun handleBusinessException(e: BusinessException): ProblemDetail {
        logger.error(e) { "${e.errorCode.code}: ${e.message}" }
        return ProblemDetail.forStatusAndDetail(e.errorCode.status, e.errorCode.message).apply {
            setProperty("code", e.errorCode.code)
        }
    }

    @ExceptionHandler(InfraException::class)
    fun handleInfraException(e: InfraException): ProblemDetail {
        logger.error(e) { "${e.errorCode.code}: ${e.message}" }
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다").apply {
            setProperty("code", CommonErrorCode.INTERNAL_SERVER_ERROR.code)
        }
    }

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ProblemDetail {
        val errors = e.bindingResult.fieldErrors.map {
            mapOf("field" to it.field, "message" to (it.defaultMessage ?: "유효하지 않은 값입니다"))
        }
        logger.warn(e) { "입력값 검증 실패: ${errors.map { it["field"] }}" }
        return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "입력값 검증 실패").apply {
            setProperty("code", CommonErrorCode.VALIDATION_FAILED.code)
            setProperty("errors", errors)
        }
    }

    @ExceptionHandler(Exception::class)
    fun handleException(e: Exception): ProblemDetail {
        logger.error(e) { "Unhandled exception" }
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다").apply {
            setProperty("code", CommonErrorCode.INTERNAL_SERVER_ERROR.code)
        }
    }
}

몇 가지 포인트를 짚어보자.

로깅 레벨 차등 적용: AuthenticationException은 warn 레벨로, 나머지 BusinessException은 error 레벨로 로깅한다. 인증 실패는 비정상적인 상황이 아니라 일상적으로 발생할 수 있는 상황이기 때문이다. 로그인 시도 실패가 모두 error로 쌓이면 정작 중요한 에러를 놓치기 쉽다.

InfraException 응답 처리: 인프라 예외는 내부 상세 정보를 클라이언트에게 노출하지 않는다. 로그에는 "CoolSMS API 호출 실패: url: ..., status: 500, body: ..."처럼 상세 정보가 남지만, 클라이언트에게는 "서버 내부 오류가 발생했습니다"라는 일반적인 메시지만 전달한다. 보안과 사용자 경험 양쪽을 고려한 설계다.

ProblemDetail 활용: Spring 6부터 지원하는 ProblemDetail은 RFC 7807 표준을 따르는 에러 응답을 쉽게 만들 수 있게 해준다. setProperty로 커스텀 필드를 추가할 수 있어 code나 errors 같은 필드를 자유롭게 확장할 수 있다.

3. 실제 사용 예시


서비스 계층에서 예외를 던지는 코드:

@Service
class VerificationService(
    private val verificationCodeRepository: VerificationCodeRepository
) {
    fun verify(phoneNumber: String, code: String) {
        val verification = verificationCodeRepository.findByPhoneNumber(phoneNumber)
            ?: throw AuthenticationException(
                errorCode = AuthErrorCode.INVALID_VERIFICATION_CODE,
                logMessage = "인증 정보 없음: $phoneNumber"
            )
        
        if (verification.isExpired()) {
            throw AuthenticationException(
                errorCode = AuthErrorCode.VERIFICATION_CODE_EXPIRED,
                logMessage = "만료된 인증코드: $phoneNumber, 만료시간: ${verification.expiredAt}"
            )
        }
        
        if (verification.code != code) {
            throw AuthenticationException(
                errorCode = AuthErrorCode.INVALID_VERIFICATION_CODE,
                logMessage = "인증코드 불일치: $phoneNumber"
            )
        }
    }
}

이 코드가 AuthenticationException을 던지면 클라이언트는 다음과 같은 응답을 받는다. 여기서 핵심은 클라이언트에게는 일반적인 메시지만 전달한다는 것이다.

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "인증코드가 일치하지 않습니다",
  "instance": "/api/v1/verification",
  "code": "INVALID_VERIFICATION_CODE"
}

validation 에러의 경우:

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "입력값 검증 실패",
  "instance": "/api/v1/users",
  "code": "VALIDATION_FAILED",
  "errors": [
    { "field": "email", "message": "이메일 형식이 올바르지 않습니다" },
    { "field": "password", "message": "비밀번호는 8자 이상이어야 합니다" }
  ]
}

4. 마치며


이 구조의 장점을 정리하면 다음과 같다.

  • 확장성: 새로운 도메인 예외가 필요하면 ErrorCode enum과 Exception 클래스만 추가하면 된다. GlobalExceptionHandler는 수정할 필요가 없다.
  • 일관성: 모든 에러 응답이 동일한 포맷을 따르므로 클라이언트에서 에러 처리 로직을 단순화할 수 있다.
  • 디버깅 용이성: 로그 메시지와 클라이언트 메시지가 분리되어 있어 상세한 디버깅 정보를 로그에 남기면서도 사용자에게는 적절한 메시지를 보여줄 수 있다.
  • 보안: Exception의 상세 정보가 클라이언트에 노출되지 않아 내부 시스템 구조가 외부에 드러나지 않는다.

물론 프로젝트 상황에 따라 더 단순하거나 복잡한 구조가 필요할 수 있다. 중요한 것은 일관된 원칙을 세우고 그 원칙에 따라 예외를 처리하는 것이다.

'개발고민' 카테고리의 다른 글

좋은 예외 처리란 무엇인가 #2  (1) 2026.01.07
좋은 예외 처리란 무엇인가 #1  (0) 2026.01.06

+ Recent posts