Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
54d64a1
chore : RabbitMQ 환경 설정
Sep 30, 2025
feaa21d
chore : CI 파이프라인에서 RabbitMQ 컨테이너를 띄워서 사용하도록 설정
Sep 30, 2025
b8a65c3
new : RabbitMQ 설정 클래스, dto 생성
Sep 30, 2025
feb8f39
feat : producer method 생성
Sep 30, 2025
a41beea
feat : 메세지 큐 구현 완료
Sep 30, 2025
afdfdb0
feat : 데이터 저장 요청 테스트 케이스 수정
Sep 30, 2025
f76bf0c
feat : ConsumerTest 코드 추가
Sep 30, 2025
4550585
fix : 저장 테스트 케이스 성공
Sep 30, 2025
b0c8baa
fix : 테스트 코드 수정 중
Sep 30, 2025
89acf55
fix : 테스트 케이스 성공
Oct 1, 2025
befdbd8
feat : dlq 도입
Oct 1, 2025
6e0d280
feat : 데이터 순서 보장을 위해 version 추가
Oct 1, 2025
d29ed3d
refactor : MQConfig 파일 위치 변경
Oct 1, 2025
a9d1960
chore : RabbitMQ 환경 설정
Sep 30, 2025
8158ad5
chore : CI 파이프라인에서 RabbitMQ 컨테이너를 띄워서 사용하도록 설정
Sep 30, 2025
7dae036
new : RabbitMQ 설정 클래스, dto 생성
Sep 30, 2025
307a1ca
feat : producer method 생성
Sep 30, 2025
f44cbc0
feat : 메세지 큐 구현 완료
Sep 30, 2025
15b22e5
feat : 데이터 저장 요청 테스트 케이스 수정
Sep 30, 2025
40ce2b4
feat : ConsumerTest 코드 추가
Sep 30, 2025
65a63e7
fix : 저장 테스트 케이스 성공
Sep 30, 2025
05c4f78
fix : 테스트 코드 수정 중
Sep 30, 2025
db9ef67
fix : 테스트 케이스 성공
Oct 1, 2025
a50b5b7
feat : dlq 도입
Oct 1, 2025
974473e
feat : 데이터 순서 보장을 위해 version 추가
Oct 1, 2025
fcaefd5
refactor : MQConfig 파일 위치 변경
Oct 1, 2025
5d54358
fix : 최신 사항 반영
Oct 1, 2025
e4d1679
feat : 최신 커밋 반영
Oct 1, 2025
e27ebe2
fix : copilot review 반영
Oct 1, 2025
29136fb
feat : Dashboard에서 graph 참조 방식 EAGER -> LAZY 로 변경
Oct 1, 2025
6022f40
CI 실패해서 다시 EAGER로 변경
Oct 1, 2025
2be799b
fix : 다시 LAZY로 변경
Oct 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/test-server-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ jobs:
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}


# CI 작업이 실행되는 동안 RabbitMQ 서비스 컨테이너를 함께 실행
services:
rabbitmq:
image: rabbitmq:3-management
ports:
- 5672:5672
# RabbitMQ가 완전히 준비될 때까지 기다리는 상태 확인 옵션
options: >-
--health-cmd "rabbitmq-diagnostics check_running"
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
# 1. 소스 코드 체크아웃
- name: Checkout source code
Expand Down Expand Up @@ -74,6 +88,12 @@ jobs:

# 7. Gradle 테스트 실행
- name: Test with Gradle
# 테스트 단계에서 RabbitMQ 연결을 위한 환경 변수 설정
env:
SPRING_RABBITMQ_HOST: localhost
SPRING_RABBITMQ_PORT: 5672
SPRING_RABBITMQ_USERNAME: guest
SPRING_RABBITMQ_PASSWORD: guest
run: ./gradlew test

# 8. 테스트 결과 요약 출력
Expand Down
6 changes: 5 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,12 @@ dependencies {
// Redis (Spring starter)
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// RabbitMQ
// RabbitMQ (Spring starter)
implementation 'org.springframework.boot:spring-boot-starter-amqp'
testImplementation 'org.springframework.amqp:spring-rabbit-test'

// Awaitility (비동기 테스트 지원)
testImplementation 'org.awaitility:awaitility:4.2.0'
}

dependencyManagement {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,18 @@ public class ApiV1DashboardController {
*/
@PutMapping("/{dashboardId}/graph")
@Operation(summary = "React-flow 데이터 저장(갱신)")
public ResponseEntity<RsData<Void>> updateGraph(
public ResponseEntity<RsData<Void>> queueGraphUpdate(
@PathVariable Integer dashboardId,
@RequestBody String requestBody,
@RequestHeader("Liveblocks-Signature") String signature
) {
dashboardService.verifyAndUpdateGraph(dashboardId, requestBody, signature);
dashboardService.queueGraphUpdate(dashboardId, requestBody, signature);

return ResponseEntity
.status(HttpStatus.OK)
.status(HttpStatus.ACCEPTED)
.body(new RsData<>(
"200",
"React-flow 데이터를 저장했습니다.",
"202",
"데이터 업데이트 요청이 성공적으로 접수되었습니다.",
null
));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.tuna.zoopzoop.backend.domain.dashboard.dto;

public record GraphUpdateMessage(
Integer dashboardId,
String requestBody
){
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.Setter;
import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity;
Expand All @@ -14,6 +15,10 @@
@Setter
@Entity
public class Graph extends BaseEntity {

@Version
private Long version;

@OneToMany(mappedBy = "graph", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Node> nodes = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.tuna.zoopzoop.backend.domain.dashboard.extraComponent;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Component;
import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow;
import org.tuna.zoopzoop.backend.domain.dashboard.dto.GraphUpdateMessage;
import org.tuna.zoopzoop.backend.domain.dashboard.service.DashboardService;

@Slf4j
@Component
@RequiredArgsConstructor
public class GraphUpdateConsumer {
private final DashboardService dashboardService;
private final ObjectMapper objectMapper;

@RabbitListener(queues = "graph.update.queue")
public void handleGraphUpdate(GraphUpdateMessage message) {
log.info("Received graph update message for dashboardId: {}", message.dashboardId());
try {
BodyForReactFlow dto = objectMapper.readValue(message.requestBody(), BodyForReactFlow.class);
dashboardService.updateGraph(message.dashboardId(), dto);
log.info("Successfully updated graph for dashboardId: {}", message.dashboardId());
} catch (ObjectOptimisticLockingFailureException e) {
// Optimistic Lock 충돌 발생!
// 내가 처리하려던 메시지는 이미 구버전 데이터에 대한 요청이었음.
// 따라서 이 메시지는 무시하고 정상 처리된 것으로 간주.
log.warn("Stale update attempt for dashboardId: {}. A newer version already exists. Discarding message.", message.dashboardId());
// 예외를 다시 던지지 않으므로, 메시지는 큐에서 정상적으로 제거(ACK)됩니다.
} catch (Exception e) {
// 실제 운영에서는 메시지를 재시도하거나, 실패 큐(Dead Letter Queue)로 보내는 등의
// 정교한 에러 처리 로직이 필요합니다.
log.error("Failed to process graph update for dashboardId: {}", message.dashboardId(), e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.NoResultException;
import lombok.RequiredArgsConstructor;
import org.apache.commons.codec.binary.Hex;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow;
import org.tuna.zoopzoop.backend.domain.dashboard.dto.GraphUpdateMessage;
import org.tuna.zoopzoop.backend.domain.dashboard.entity.Dashboard;
import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge;
import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph;
Expand All @@ -25,6 +29,7 @@ public class DashboardService {
private final MembershipService membershipService;
private final ObjectMapper objectMapper;
private final SignatureService signatureService;
private final RabbitTemplate rabbitTemplate;



Expand Down Expand Up @@ -103,6 +108,33 @@ public void verifyAccessPermission(Member member, Integer dashboardId) throws Ac
}
}

// =========================== message 관리 메서드 ===========================

/**
* Graph 업데이트 요청을 RabbitMQ 큐에 비동기적으로 발행하는 메서드
* @param dashboardId 대시보드 ID
* @param requestBody 요청 바디
* @param signatureHeader 서명 헤더
*/
public void queueGraphUpdate(Integer dashboardId, String requestBody, String signatureHeader){
// 서명 검증은 동기적으로 즉시 처리
if (!signatureService.isValidSignature(requestBody, signatureHeader)) {
throw new SecurityException("Invalid webhook signature.");
}

// 대시보드 존재 여부 확인
if (!dashboardRepository.existsById(dashboardId)) {
throw new NoResultException(dashboardId + " ID를 가진 대시보드를 찾을 수 없습니다.");
}

// 큐에 보낼 메시지 생성
GraphUpdateMessage message = new GraphUpdateMessage(dashboardId, requestBody);

// RabbitMQ에 메시지 발행
rabbitTemplate.convertAndSend("zoopzoop.exchange", "graph.update.rk", message);
}





Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ public class SignatureService {
* @return 서명이 유효하면 true, 그렇지 않으면 false
*/
public boolean isValidSignature(String requestBody, String signatureHeader) {
// [임시 코드] 로컬 테스트를 위해 무조건 true 반환
// if ("true".equals(System.getProperty("local.test.skip.signature"))) {
// return true;
// }

try {
// 1. 헤더 파싱
String[] parts = signatureHeader.split(",");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package org.tuna.zoopzoop.backend.global.config.mq;

import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitMQConfig {
private static final String EXCHANGE_NAME = "zoopzoop.exchange";
private static final String QUEUE_NAME = "graph.update.queue";
private static final String ROUTING_KEY = "graph.update.#";

private static final String DLQ_EXCHANGE_NAME = EXCHANGE_NAME + ".dlx";
private static final String DLQ_QUEUE_NAME = QUEUE_NAME + ".dlq";
private static final String DLQ_ROUTING_KEY = "graph.update.dlq";

@Bean
public TopicExchange exchange() {
return new TopicExchange(EXCHANGE_NAME);
}

@Bean
public Queue queue() {
return QueueBuilder.durable(QUEUE_NAME)
.withArgument("x-dead-letter-exchange", DLQ_EXCHANGE_NAME) // 실패 시 메시지를 보낼 Exchange
.withArgument("x-dead-letter-routing-key", DLQ_ROUTING_KEY) // 실패 시 사용할 라우팅 키
.build();
}

@Bean
public Binding binding(Queue queue, TopicExchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with(ROUTING_KEY);
}

// ================= DLQ 인프라 구성 추가 ================= //

@Bean
public TopicExchange dlqExchange() {
return new TopicExchange(DLQ_EXCHANGE_NAME);
}

@Bean
public Queue dlqQueue() {
return new Queue(DLQ_QUEUE_NAME);
}

@Bean
public Binding dlqBinding(Queue dlqQueue, TopicExchange dlqExchange) {
return BindingBuilder.bind(dlqQueue).to(dlqExchange).with(DLQ_ROUTING_KEY);
}

// ================= DLQ 인프라 구성 추가 ================= //
@Bean
public MessageConverter messageConverter() {
// 메시지를 JSON 형식으로 직렬화/역직렬화하는 컨버터
return new Jackson2JsonMessageConverter();
}

@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, MessageConverter messageConverter) {
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
rabbitTemplate.setMessageConverter(messageConverter);
return rabbitTemplate;
}
}
18 changes: 18 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ spring:
port: 5672
username: ${SPRING_RABBITMQ_USERNAME:guest}
password: ${SPRING_RABBITMQ_PASSWORD:guest}
listener:
simple:
retry:
enabled: true
initial-interval: 2000
max-attempts: 3
data: #RedisTemplate 등을 사용하기 위한 직접 연결용
redis:
host: localhost
port: 6379
timeout: 6000
cache: #Spring Cache를 사용하기 위한 Redis
type: redis
redis:
time-to-live: 300000
cache-null-values: false
key-prefix:

springdoc:
default-produces-media-type: application/json;charset=UTF-8
Expand All @@ -49,6 +66,7 @@ logging:
org.hibernate.orm.jdbc.extract: TRACE
org.springframework.transaction.interceptor: TRACE
com.back: DEBUG
org.springframework.retry: DEBUG

server:
port: 8080
Expand Down
Loading