이전까지는 docker에 spring app 하나를 띄웠지만 이제는 app 3개로 늘릴 것이다.
이때 트래픽 분산을 위한 Load Balancer는 nginx를 사용한다.
[업비트 WebSocket 서버]
↑ 지속 연결
│
(Spring Producer)
OkHttp WebSocketListener
│
▼
[ Kafka Topic ]
│
▼
(Spring Consumer)
@KafkaListener → WebSocket push
│
▼
[브라우저 WebSocket 클라이언트]
현재 웹소켓 세션은 두가지이다.
그렇다면 이 구조에서 spring 인스턴스가 세 개로 늘어난다면 Spring과 클라이언트의 웹소켓도 두 개가 더 늘어날 것이다. 이때, kafka consumer의 구조에 따라 웹 브라우저에 보이는 정보가 달라진다.
이제 spring 인스턴스를 늘려보도록하자
각 spring consumer에서 서로 다른 group-id를 사용해야 하기 때문에 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
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}"
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
app1, app2, app3app-net 네트워크 안에서만 통신app1/app2/app3로 트래픽 분산http://호스트IP:8080 / ws://호스트IP:8080/ws/ticker 로 접속이후 spring 인스턴스의 로드밸런싱 관련 설정을 위해 nginx.conf 파일을 작성한다.
이때 nginx는 따로 설치할 필요가 없다. docker-compose.yml 파일을 보면 nginx 이미지를 가져와서 컨테이너로 실행하게 되문
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";
}
}
}
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를 상대 경로로 바꾼 것 정도 + 프로필 하드코딩 제거이렇게 하면:
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
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

결과는 성공이다.
현재 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이 엄청나게 커진다면 불필요한 부하가 발생할 것이다.
Collector 전용 조건 추가
@ConditionalOnProperty(value = "app.upbit.collector-enabled", havingValue = "true")
@ConditionalOnProperty(value = "app.upbit.collector-enabled", havingValue = "true")
@Component
public class UpbitWebSocketClient { ... }
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);
}
}
collector 조건 추가
app:
kafka:
topic: ${APP_KAFKA_TOPIC:upbit-ticker}
consumer-enabled: true # 기본은 "consume 한다"
upbit:
collector-enabled: ${APP_UPBIT_COLLECTOR_ENABLED:false}
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
docker logs collector | grep -E "Connected to Upbit|Received binary message|Received text message|Upbit WebSocket"
위의 명령어로 확인했을때 collector만 로그가 있고 app1,2,3에는 로그가 없어야함

결과는 성공
docker logs collector | grep "Received from Kafka"
위의 명령어를 쳤을때 아무것도 나와서는 안됨.

docker logs app1 | grep "Received from Kafka"
위의 명령어 쳤을때 로그가 나와야함.

결과는 성공이다.

우선 모든 인스턴스가 정상인지 확인해보자
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}"

docker kill app2
이후 다시
docker ps --format "table {{.Names}}\t{{.Status}}"

docker logs collector | grep "Connected to Upbit" | tail -3
docker logs collector | grep "Received binary message" | tail -3
여전히 메시지 계속 들어오고 있으면 OK.
→ 데이터 수집은 장애와 무관하게 계속 진행 중

docker logs app1 | grep "Received from Kafka" | tail -5
docker logs app3 | grep "Received from Kafka" | tail -5
여기서 여전히 로그가 쭉 올라가면:

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

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