Spring app을 docker-compose와 nginx로 클러스터링하기

박정호·2025년 12월 17일

이전까지는 docker에 spring app 하나를 띄웠지만 이제는 app 3개로 늘릴 것이다.

이때 트래픽 분산을 위한 Load Balancer는 nginx를 사용한다.

현재까지 구조 정리

      [업비트 WebSocket 서버]
                ↑ 지속 연결
                │
        (Spring Producer)
   OkHttp WebSocketListener
                │
                ▼
           [ Kafka Topic ]
                │
                ▼
        (Spring Consumer)
   @KafkaListenerWebSocket push
                │
                ▼
      [브라우저 WebSocket 클라이언트]

현재 웹소켓 세션은 두가지이다.

  1. 업비트 웹소켓 세션
    1. 이 웹소켓은 업비트 데이터를 실시간으로 가져와서 kafka에 produce한다
  2. Spring과 클라이언트(웹 브라우저)가 연결된 웹소켓 세션
    1. Spring이 kafka에서 consume한 데이터를 실시간으로 웹 브라우저에 반영해준다

그렇다면 이 구조에서 spring 인스턴스가 세 개로 늘어난다면 Spring과 클라이언트의 웹소켓도 두 개가 더 늘어날 것이다. 이때, kafka consumer의 구조에 따라 웹 브라우저에 보이는 정보가 달라진다.

  1. 각 spring consumer가 같은 group-id를 사용하는 경우, 각 브라우저에는 서로 다른 정보가 보인다.
    1. 같은 topic + 같은 group-id면 → 한 그룹
    2. 그룹 안에서는 같은 partition의 메시지는 딱 한 Consumer만 읽는다.
  2. 각 spring consumer에서 다른 group-id를 사용하는 경우, 각 브라우저에는 같은 정보가 보인다.
    1. group-id가 다르면 완전히 다른 그룹으로 취급.
    2. 서로 다른 그룹은 똑같은 메시지를 각각 다 받아간다.
    3. 이것이 내가 구현하려고 하는 분산 환경

이제 spring 인스턴스를 늘려보도록하자

Spring 인스턴스 늘리기

각 spring consumer에서 서로 다른 group-id를 사용해야 하기 때문에 application.yml의 수정이 필요하다

application.yml 수정

기존

server:
  port: 8080

spring:
  application:
    name: websocket-test

  jmx:
    enabled: false

  kafka:
    bootstrap-servers: 192.168.56.11:9092
    consumer:
      group-id: upbit-ticker-group
      auto-offset-reset: latest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer

app:
  kafka:
    topic: upbit-ticker

변경

server:
  port: 8080

spring:
  application:
    name: websocket-test

  jmx:
    enabled: false

  kafka:
    # docker-compose 안에서 kafka 컨테이너 이름이 'kafka'라고 가정
    bootstrap-servers: kafka:9092
    consumer:
      # 각 인스턴스마다 다른 group-id 사용 (INSTANCE_ID는 docker-compose에서 넘겨줌)
      group-id: upbit-ticker-${INSTANCE_ID:default}
      auto-offset-reset: latest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer

app:
  kafka:
    topic: upbit-ticker

TickerKafkaConsumer.java (KafkaListener 수정)

package com.websockettest.kafka;

import com.websockettest.websocket.TickerWebSocketHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class TickerKafkaConsumer {

    private final TickerWebSocketHandler tickerWebSocketHandler;

    @Value("${app.kafka.topic}")
    private String topic;

    @KafkaListener(topics = "${app.kafka.topic}", groupId = "${spring.kafka.consumer.group-id}") 
    public void listen(String message) {
        log.info("Received from Kafka: {}", message);
        // 그대로 브라우저로 푸시 (필요하면 여기서 가공)
        tickerWebSocketHandler.broadcast(message);
    }
}

카프카 리스너 수정
groupId = "upbit-ticker-group" → groupId = "${spring.kafka.consumer.group-id}"

docker-compose.yml

version: "3.8"

services:
  app1:
    image: jeongho427/upbit-ws:latest
    container_name: app1
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - INSTANCE_ID=app1
    networks:
      - app-net

  app2:
    image: jeongho427/upbit-ws:latest
    container_name: app2
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - INSTANCE_ID=app2
    networks:
      - app-net

  app3:
    image: jeongho427/upbit-ws:latest
    container_name: app3
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - INSTANCE_ID=app3
    networks:
      - app-net

  nginx:
    image: nginx:alpine
    container_name: nginx-lb
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - "8080:80"
    depends_on:
      - app1
      - app2
      - app3
    networks:
      - app-net

networks:
  app-net:
    driver: bridge
  • Spring 앱 3개: app1, app2, app3
  • 모두 app-net 네트워크 안에서만 통신
  • nginx가 app1/app2/app3로 트래픽 분산
  • 외부에서는 http://호스트IP:8080 / ws://호스트IP:8080/ws/ticker 로 접속

이후 spring 인스턴스의 로드밸런싱 관련 설정을 위해 nginx.conf 파일을 작성한다.

이때 nginx는 따로 설치할 필요가 없다. docker-compose.yml 파일을 보면 nginx 이미지를 가져와서 컨테이너로 실행하게 되문

nginx.conf

events {}

http {
    upstream spring_cluster {
        server app1:8080;
        server app2:8080;
        server app3:8080;
    }

    server {
        listen 80;

        # HTTP + WebSocket 공통
        location / {
            proxy_pass http://spring_cluster;

            # 기본 헤더
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

            # WebSocket 업그레이드
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "Upgrade";
        }
    }
}

docker-compose로 spring 인스턴스 3개 실행

Dockerfile 좀 더 운영스럽게 수정

FROM eclipse-temurin:17-jdk-jammy

WORKDIR /app

COPY build/libs/websocket-test-0.0.1-SNAPSHOT.jar app.jar

# 프로필은 Dockerfile에서 안 박고, compose에서만 제어
# ENV SPRING_PROFILES_ACTIVE=prod

EXPOSE 8080

ENTRYPOINT ["java","-jar","app.jar"]
  • ENTRYPOINT를 상대 경로로 바꾼 것 정도 + 프로필 하드코딩 제거

이렇게 하면:

  • Dockerfile은 “그냥 실행만 담당”
  • 어떤 프로필/INSTANCE_ID로 돌릴지는 전부 docker-compose.yml에서 제어 → 관리가 더 쉬워짐

docker hub에 이미지 다시 빌드 & 푸시

./gradlew clean build
docker build -t jeongho427/upbit-ws:latest .
docker push jeongho427/upbit-ws:latest

docker-compose로 전체 올리기

docker pull jeongho427/upbit-ws:latest
docker-compose up -d
docker ps

docker-compose가 신버전이라면 docker compose up -d로 가능

로그 확인

docker logs app1
docker logs app2
docker logs app3

문제 발생

브라우저에서 확인한 결과 정상적인 결과가 나오지 않고 위와 같이 나왔다.

docker logs app1

명령어로 app1의 로그를 확인해보니 아래와 같았고

bootstrap url에 문제가 있는 것 같았다.

컨테이너 안 스프링이 카프카 설정을 제대로 못 읽고 있는 문제였다.

잘 읽을 수 있게 직접 yml 파일에 명시를 해줬다

- SPRING_KAFKA_BOOTSTRAP_SERVERS=192.168.56.11:9092
- SPRING_KAFKA_CONSUMER_GROUP_ID=upbit-ticker-app1

docker-compose.yml

version: "3.8"

services:
  app1:
    image: jeongho427/upbit-ws:latest
    container_name: app1
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - INSTANCE_ID=app1
      - SPRING_KAFKA_BOOTSTRAP_SERVERS=192.168.56.11:9092
      - SPRING_KAFKA_CONSUMER_GROUP_ID=upbit-ticker-app1
    networks:
      - app-net

  app2:
    image: jeongho427/upbit-ws:latest
    container_name: app2
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - INSTANCE_ID=app2
      - SPRING_KAFKA_BOOTSTRAP_SERVERS=192.168.56.11:9092
      - SPRING_KAFKA_CONSUMER_GROUP_ID=upbit-ticker-app2
    networks:
      - app-net

  app3:
    image: jeongho427/upbit-ws:latest
    container_name: app3
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - INSTANCE_ID=app3
      - SPRING_KAFKA_BOOTSTRAP_SERVERS=192.168.56.11:9092
      - SPRING_KAFKA_CONSUMER_GROUP_ID=upbit-ticker-app3
    networks:
      - app-net

  nginx:
    image: nginx:alpine
    container_name: nginx-lb
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - "8080:80"
    depends_on:
      - app1
      - app2
      - app3
    networks:
      - app-net

networks:
  app-net:
    driver: bridge

다시 실행

docker-compose down
docker-compose up -d
docker logs app1

브라우저에서 확인

결과는 성공이다.

spring 인스턴스 증가로 인한 업비트 네트워크 부하 문제

현재 docker-compose에 spring 인스턴스를 3개를 띄워서 실행 중이다. 그런데 문득 의문이 들었다.

현재 TickerKafkaConsumer에서 업비트에 웹소켓을 연결해서 데이터를 받고 있는데 중복된 데이터를 kafka에 넣고 있는 것 같았다.

public class TickerKafkaConsumer {

    private final TickerWebSocketHandler tickerWebSocketHandler;

    @Value("${app.kafka.topic}")
    private String topic;

    @KafkaListener(topics = "${app.kafka.topic}", groupId = "${spring.kafka.consumer.group-id}")
    public void listen(String message) {
        log.info("Received from Kafka: {}", message);
        // 그대로 브라우저로 푸시 (필요하면 여기서 가공)
        tickerWebSocketHandler.broadcast(message);
    }
}

위 코드를 보면 group-id다 인스턴스마다 다르게 설정되어 있어서 실제로 그런 문제는 발생하지 않는다.

즉, 업비트와 네트워크 연결이 세 번 되는 것은 맞지만 각 업비트 데이터가 서로 다른 컨슈머 그룹에 들어가고 있고 각 스프링 인스턴스는 자신만의 컨슈머 그룹에서 데이터를 읽고 있기 때문에 문제는 없다.

컨슈

하지만 업비트와 웹소켓 연결이 세 개나 되고 있는 것은 불필요한 과정인 것 같다. 결국 spring 인스턴스가 n개가 되고 이 n이 엄청나게 커진다면 불필요한 부하가 발생할 것이다.

UpbitWebSocketClient.java

Collector 전용 조건 추가

@ConditionalOnProperty(value = "app.upbit.collector-enabled", havingValue = "true")
@ConditionalOnProperty(value = "app.upbit.collector-enabled", havingValue = "true")
@Component
public class UpbitWebSocketClient { ... }

TickerKafkaConsumer.java

collector는 consume하지 않고 consumer만 consume하도록 설정

@ConditionalOnProperty(
    value = "app.kafka.consumer-enabled",
    havingValue = "true",
    matchIfMissing = true   // 기본값 true (그냥 안 적으면 켜짐)
)
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;

@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(
    value = "app.kafka.consumer-enabled",
    havingValue = "true",
    matchIfMissing = true   // 기본값 true (그냥 안 적으면 켜짐)
)
public class TickerKafkaConsumer {

    private final TickerWebSocketHandler tickerWebSocketHandler;

    @Value("${app.kafka.topic}")
    private String topic;

    @KafkaListener(topics = "${app.kafka.topic}", groupId = "${spring.kafka.consumer.group-id}")
    public void listen(String message) {
        log.info("Received from Kafka: {}", message);
        tickerWebSocketHandler.broadcast(message);
    }
}

application.yml

collector 조건 추가

app:
  kafka:
    topic: ${APP_KAFKA_TOPIC:upbit-ticker}
    consumer-enabled: true   # 기본은 "consume 한다"
  upbit:
    collector-enabled: ${APP_UPBIT_COLLECTOR_ENABLED:false}

docker-compose.yml

version: "3.8"

services:
  collector:
    image: jeongho427/upbit-ws:latest
    container_name: collector
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - INSTANCE_ID=collector
      - SPRING_KAFKA_BOOTSTRAP_SERVERS=192.168.56.11:9092
      - APP_UPBIT_COLLECTOR_ENABLED=true      # ✅ 이 인스턴스만 Upbit WS에 접속
      - APP_KAFKA_TOPIC=upbit-ticker          # ✅ 토픽 하나로 통일
      - APP_KAFKA_CONSUMER_ENABLED=false      # ✅ consume 끔
    networks:
      - app-net

  app1:  # Consumer 전용
    image: jeongho427/upbit-ws:latest
    container_name: app1
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - INSTANCE_ID=app1
      - SPRING_KAFKA_BOOTSTRAP_SERVERS=192.168.56.11:9092
      - APP_UPBIT_COLLECTOR_ENABLED=false     # 기본값이 false라 사실 생략 가능
      - APP_KAFKA_TOPIC=upbit-ticker
    networks:
      - app-net

  app2:  # Consumer 전용
    image: jeongho427/upbit-ws:latest
    container_name: app2
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - INSTANCE_ID=app2
      - SPRING_KAFKA_BOOTSTRAP_SERVERS=192.168.56.11:9092
      - APP_UPBIT_COLLECTOR_ENABLED=false
      - APP_KAFKA_TOPIC=upbit-ticker
    networks:
      - app-net

  app3:  # Consumer 전용
    image: jeongho427/upbit-ws:latest
    container_name: app3
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - INSTANCE_ID=app3
      - SPRING_KAFKA_BOOTSTRAP_SERVERS=192.168.56.11:9092
      - APP_UPBIT_COLLECTOR_ENABLED=false
      - APP_KAFKA_TOPIC=upbit-ticker
    networks:
      - app-net

  nginx:
    image: nginx:alpine
    container_name: nginx-lb
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - "8080:80"
    depends_on:
      - app1
      - app2
      - app3
    networks:
      - app-net

networks:
  app-net:
    driver: bridge

collector만 수집하고 app들은 consume만 하고 있는지 확인

docker logs collector | grep -E "Connected to Upbit|Received binary message|Received text message|Upbit WebSocket"

위의 명령어로 확인했을때 collector만 로그가 있고 app1,2,3에는 로그가 없어야함

결과는 성공

consumer만 consume하고 collector는 수집만 하고 있는지 확인

docker logs collector | grep "Received from Kafka"

위의 명령어를 쳤을때 아무것도 나와서는 안됨.

docker logs app1 | grep "Received from Kafka"

위의 명령어 쳤을때 로그가 나와야함.

결과는 성공이다.

브라우저 결과 화면

장애 시에도 서비스가 유지되는지 확인

우선 모든 인스턴스가 정상인지 확인해보자

docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}"

강제로 spring 인스턴스 하나 죽이기

docker kill app2 

이후 다시

docker ps --format "table {{.Names}}\t{{.Status}}"

Collector 정상 동작 유지 여부

docker logs collector | grep "Connected to Upbit" | tail -3
docker logs collector | grep "Received binary message" | tail -3

여전히 메시지 계속 들어오고 있으면 OK.

→ 데이터 수집은 장애와 무관하게 계속 진행 중

나머지 consumer가 계속 consume 하는지

docker logs app1 | grep "Received from Kafka" | tail -5
docker logs app3 | grep "Received from Kafka" | tail -5

여기서 여전히 로그가 쭉 올라가면:

  • app2만 죽었고
  • app1, app3는 계속 토픽 전체를 구독 중 → 장애 시에도 나머지 인스턴스는 실시간 데이터 유지 ✅

로그를 확인해보면 모두 정상이다.

브라우저 확인

브라우저에서 확인해도 서비스가 유지 중임을 확인할 수 있다.

추후 추가할 것

  • 파티션 늘려보기
  • consumer scale-out
  • kafka 브로커 클러스터 구성

0개의 댓글