본문 바로가기

개발/BACK

Spring 환경에서 RabbitMQ 설정하기 예제

728x90

RabbitMQ는 오픈소스 메시지 브로커로, 애플리케이션 간 비동기 메시지 통신을 제공합니다. Spring Boot와 RabbitMQ를 통합하여 메시지 큐를 활용하는 방법을 알아보겠습니다. Exchange, Queue, Binding 개념부터 실제 구현 예제까지 단계별로 설명합니다.


1. RabbitMQ란?

RabbitMQ는 AMQP(Advanced Message Queuing Protocol)를 구현한 메시지 브로커입니다. 애플리케이션 간 비동기 메시지 통신을 제공하여 느슨한 결합(Loose Coupling)을 가능하게 합니다.

1.1 RabbitMQ 주요 개념

  • Producer: 메시지를 전송하는 애플리케이션
  • Consumer: 메시지를 수신하는 애플리케이션
  • Queue: 메시지가 저장되는 버퍼
  • Exchange: 메시지를 라우팅하는 컴포넌트
  • Binding: Exchange와 Queue를 연결하는 규칙
  • Routing Key: 메시지 라우팅을 위한 키

1.2 RabbitMQ 메시지 흐름

Producer → Exchange → Binding → Queue → Consumer

1. Producer가 메시지를 Exchange로 전송
2. Exchange가 Routing Key를 기반으로 메시지 라우팅
3. Binding 규칙에 따라 적절한 Queue로 메시지 전달
4. Consumer가 Queue에서 메시지 수신 및 처리

1.3 Exchange 타입

Exchange 타입 설명 사용 사례
Direct Routing Key가 정확히 일치하는 Queue로 전달 작업별 라우팅
Topic 패턴 매칭으로 라우팅 (예: *.error, order.*) 카테고리별 메시지
Fanout 모든 바인딩된 Queue로 브로드캐스트 알림, 로그
Headers 메시지 헤더를 기반으로 라우팅 복잡한 라우팅 조건

2. RabbitMQ 설치 및 실행

2.1 Docker로 RabbitMQ 실행 (권장)

# RabbitMQ 컨테이너 실행
docker run -d \
  --name rabbitmq \
  -p 5672:5672 \
  -p 15672:15672 \
  -e RABBITMQ_DEFAULT_USER=admin \
  -e RABBITMQ_DEFAULT_PASS=admin \
  rabbitmq:3-management

# 관리자 웹 UI 접속
# http://localhost:15672
# ID: admin, Password: admin

2.2 로컬 설치 (Windows)

# 1. Erlang 설치 (필수)
# https://www.erlang.org/downloads

# 2. RabbitMQ 설치
# https://www.rabbitmq.com/download.html

# 3. RabbitMQ 서비스 시작
rabbitmq-server start

# 4. 관리 플러그인 활성화
rabbitmq-plugins enable rabbitmq_management
💡 팁: 개발 환경에서는 Docker를 사용하는 것이 가장 간단합니다. 관리자 웹 UI를 통해 Queue, Exchange, Binding 상태를 시각적으로 확인할 수 있습니다.

3. Spring Boot 프로젝트 설정

3.1 build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.0'
    id 'io.spring.dependency-management' version '1.1.0'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

dependencies {
    // Spring Boot
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-amqp' // RabbitMQ
    
    // JSON 직렬화
    implementation 'com.fasterxml.jackson.core:jackson-databind'
    
    // Lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

3.2 application.yml 설정

spring:
  rabbitmq:
    host: localhost
    port: 5672
    username: admin
    password: admin
    virtual-host: /
    
    # Connection Pool 설정
    listener:
      simple:
        acknowledge-mode: auto          # 자동 확인
        prefetch: 10                    # 한 번에 가져올 메시지 수
        concurrency: 1                  # 최소 Consumer 수
        max-concurrency: 10             # 최대 Consumer 수
        retry:
          enabled: true                 # 재시도 활성화
          initial-interval: 1000        # 초기 재시도 간격 (ms)
          max-attempts: 3               # 최대 재시도 횟수
          multiplier: 2.0               # 재시도 간격 배수
    
    # Publisher 설정
    publisher-confirm-type: correlated  # Publisher 확인
    publisher-returns: true             # 반환 메시지 활성화
    template:
      mandatory: true                   # 라우팅 실패 시 반환

3.3 RabbitMQ 설정 클래스

package com.example.config;

import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
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 {
    
    // Queue 이름 상수
    public static final String QUEUE_NAME = "example.queue";
    public static final String EXCHANGE_NAME = "example.exchange";
    public static final String ROUTING_KEY = "example.routing.key";
    
    /**
     * Queue 생성
     */
    @Bean
    public Queue queue() {
        return QueueBuilder.durable(QUEUE_NAME).build();
    }
    
    /**
     * Direct Exchange 생성
     */
    @Bean
    public DirectExchange exchange() {
        return new DirectExchange(EXCHANGE_NAME);
    }
    
    /**
     * Binding 생성 (Exchange와 Queue 연결)
     */
    @Bean
    public Binding binding(Queue queue, DirectExchange exchange) {
        return BindingBuilder
            .bind(queue)
            .to(exchange)
            .with(ROUTING_KEY);
    }
    
    /**
     * JSON 메시지 컨버터
     */
    @Bean
    public MessageConverter messageConverter() {
        return new Jackson2JsonMessageConverter();
    }
    
    /**
     * RabbitTemplate 설정
     */
    @Bean
    public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
        RabbitTemplate template = new RabbitTemplate(connectionFactory);
        template.setMessageConverter(messageConverter());
        return template;
    }
    
    /**
     * Listener Factory 설정
     */
    @Bean
    public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
            ConnectionFactory connectionFactory) {
        SimpleRabbitListenerContainerFactory factory = 
            new SimpleRabbitListenerContainerFactory();
        factory.setConnectionFactory(connectionFactory);
        factory.setMessageConverter(messageConverter());
        return factory;
    }
}

4. 기본 Producer/Consumer 구현

4.1 메시지 DTO

package com.example.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MessageDto implements Serializable {
    
    private String id;
    private String content;
    private LocalDateTime timestamp;
    
    public MessageDto(String content) {
        this.content = content;
        this.timestamp = LocalDateTime.now();
    }
}

4.2 Producer (메시지 전송)

package com.example.producer;

import com.example.config.RabbitMQConfig;
import com.example.dto.MessageDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;

import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class MessageProducer {
    
    private final RabbitTemplate rabbitTemplate;
    
    /**
     * 메시지 전송
     */
    public void sendMessage(MessageDto message) {
        message.setId(UUID.randomUUID().toString());
        
        rabbitTemplate.convertAndSend(
            RabbitMQConfig.EXCHANGE_NAME,
            RabbitMQConfig.ROUTING_KEY,
            message
        );
        
        log.info("메시지 전송: {}", message);
    }
    
    /**
     * 간단한 문자열 메시지 전송
     */
    public void sendSimpleMessage(String message) {
        MessageDto messageDto = new MessageDto(message);
        sendMessage(messageDto);
    }
}

4.3 Consumer (메시지 수신)

package com.example.consumer;

import com.example.config.RabbitMQConfig;
import com.example.dto.MessageDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class MessageConsumer {
    
    /**
     * 메시지 수신 및 처리
     * @RabbitListener: 지정된 Queue에서 메시지를 수신
     */
    @RabbitListener(queues = RabbitMQConfig.QUEUE_NAME)
    public void receiveMessage(MessageDto message) {
        log.info("메시지 수신: {}", message);
        
        // 메시지 처리 로직
        try {
            processMessage(message);
            log.info("메시지 처리 완료: {}", message.getId());
        } catch (Exception e) {
            log.error("메시지 처리 실패: {}", message.getId(), e);
            throw e; // 예외 발생 시 재시도 또는 Dead Letter Queue로 이동
        }
    }
    
    /**
     * 메시지 처리 로직
     */
    private void processMessage(MessageDto message) {
        // 실제 비즈니스 로직 구현
        log.info("메시지 내용 처리: {}", message.getContent());
    }
}

4.4 Controller 예제

package com.example.controller;

import com.example.dto.MessageDto;
import com.example.producer.MessageProducer;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/messages")
@RequiredArgsConstructor
public class MessageController {
    
    private final MessageProducer messageProducer;
    
    /**
     * 메시지 전송
     */
    @PostMapping("/send")
    public ResponseEntity<String> sendMessage(@RequestBody MessageDto message) {
        messageProducer.sendMessage(message);
        return ResponseEntity.ok("메시지 전송 완료");
    }
    
    /**
     * 간단한 메시지 전송
     */
    @PostMapping("/send-simple")
    public ResponseEntity<String> sendSimpleMessage(@RequestParam String message) {
        messageProducer.sendSimpleMessage(message);
        return ResponseEntity.ok("메시지 전송 완료");
    }
}

5. Exchange 타입별 구현

5.1 Direct Exchange

package com.example.config;

import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DirectExchangeConfig {
    
    public static final String DIRECT_QUEUE = "direct.queue";
    public static final String DIRECT_EXCHANGE = "direct.exchange";
    public static final String DIRECT_ROUTING_KEY = "direct.key";
    
    @Bean
    public Queue directQueue() {
        return QueueBuilder.durable(DIRECT_QUEUE).build();
    }
    
    @Bean
    public DirectExchange directExchange() {
        return new DirectExchange(DIRECT_EXCHANGE);
    }
    
    @Bean
    public Binding directBinding(Queue directQueue, DirectExchange directExchange) {
        return BindingBuilder
            .bind(directQueue)
            .to(directExchange)
            .with(DIRECT_ROUTING_KEY);
    }
}

5.2 Topic Exchange

package com.example.config;

import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TopicExchangeConfig {
    
    public static final String TOPIC_QUEUE_1 = "topic.queue.1";
    public static final String TOPIC_QUEUE_2 = "topic.queue.2";
    public static final String TOPIC_EXCHANGE = "topic.exchange";
    
    // Routing Key 패턴
    public static final String ROUTING_KEY_ERROR = "*.error";
    public static final String ROUTING_KEY_ORDER = "order.*";
    
    @Bean
    public Queue topicQueue1() {
        return QueueBuilder.durable(TOPIC_QUEUE_1).build();
    }
    
    @Bean
    public Queue topicQueue2() {
        return QueueBuilder.durable(TOPIC_QUEUE_2).build();
    }
    
    @Bean
    public TopicExchange topicExchange() {
        return new TopicExchange(TOPIC_EXCHANGE);
    }
    
    // 모든 error로 끝나는 메시지
    @Bean
    public Binding topicBinding1(Queue topicQueue1, TopicExchange topicExchange) {
        return BindingBuilder
            .bind(topicQueue1)
            .to(topicExchange)
            .with(ROUTING_KEY_ERROR);
    }
    
    // order로 시작하는 모든 메시지
    @Bean
    public Binding topicBinding2(Queue topicQueue2, TopicExchange topicExchange) {
        return BindingBuilder
            .bind(topicQueue2)
            .to(topicExchange)
            .with(ROUTING_KEY_ORDER);
    }
}

5.3 Fanout Exchange

package com.example.config;

import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FanoutExchangeConfig {
    
    public static final String FANOUT_QUEUE_1 = "fanout.queue.1";
    public static final String FANOUT_QUEUE_2 = "fanout.queue.2";
    public static final String FANOUT_EXCHANGE = "fanout.exchange";
    
    @Bean
    public Queue fanoutQueue1() {
        return QueueBuilder.durable(FANOUT_QUEUE_1).build();
    }
    
    @Bean
    public Queue fanoutQueue2() {
        return QueueBuilder.durable(FANOUT_QUEUE_2).build();
    }
    
    @Bean
    public FanoutExchange fanoutExchange() {
        return new FanoutExchange(FANOUT_EXCHANGE);
    }
    
    // Fanout은 Routing Key 무시, 모든 Queue로 브로드캐스트
    @Bean
    public Binding fanoutBinding1(Queue fanoutQueue1, FanoutExchange fanoutExchange) {
        return BindingBuilder
            .bind(fanoutQueue1)
            .to(fanoutExchange);
    }
    
    @Bean
    public Binding fanoutBinding2(Queue fanoutQueue2, FanoutExchange fanoutExchange) {
        return BindingBuilder
            .bind(fanoutQueue2)
            .to(fanoutExchange);
    }
}

5.4 Topic Exchange Producer/Consumer 예제

package com.example.producer;

import com.example.config.TopicExchangeConfig;
import com.example.dto.MessageDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class TopicProducer {
    
    private final RabbitTemplate rabbitTemplate;
    
    /**
     * Error 메시지 전송 (routing key: "payment.error")
     */
    public void sendError(String service, String errorMessage) {
        MessageDto message = new MessageDto(errorMessage);
        String routingKey = service + ".error";
        
        rabbitTemplate.convertAndSend(
            TopicExchangeConfig.TOPIC_EXCHANGE,
            routingKey,
            message
        );
        
        log.info("Error 메시지 전송: routingKey={}, message={}", routingKey, message);
    }
    
    /**
     * Order 메시지 전송 (routing key: "order.created")
     */
    public void sendOrder(String orderAction, MessageDto message) {
        String routingKey = "order." + orderAction;
        
        rabbitTemplate.convertAndSend(
            TopicExchangeConfig.TOPIC_EXCHANGE,
            routingKey,
            message
        );
        
        log.info("Order 메시지 전송: routingKey={}, message={}", routingKey, message);
    }
}
package com.example.consumer;

import com.example.config.TopicExchangeConfig;
import com.example.dto.MessageDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class TopicConsumer {
    
    /**
     * 모든 error 메시지 수신 (*.error)
     */
    @RabbitListener(queues = TopicExchangeConfig.TOPIC_QUEUE_1)
    public void receiveError(MessageDto message) {
        log.info("Error 메시지 수신: {}", message);
        // 에러 처리 로직
    }
    
    /**
     * 모든 order 메시지 수신 (order.*)
     */
    @RabbitListener(queues = TopicExchangeConfig.TOPIC_QUEUE_2)
    public void receiveOrder(MessageDto message) {
        log.info("Order 메시지 수신: {}", message);
        // 주문 처리 로직
    }
}

6. 메시지 직렬화/역직렬화

Spring AMQP는 기본적으로 Java 직렬화를 사용하지만, JSON을 사용하는 것이 더 효율적입니다.

6.1 커스텀 MessageConverter

package com.example.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
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 MessageConverterConfig {
    
    @Bean
    public MessageConverter messageConverter() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule()); // LocalDateTime 지원
        
        Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(objectMapper);
        return converter;
    }
}

7. 에러 처리 및 재시도

7.1 Dead Letter Queue 설정

package com.example.config;

import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DeadLetterQueueConfig {
    
    public static final String MAIN_QUEUE = "main.queue";
    public static final String DLQ = "dlq.queue";
    public static final String DLX = "dlx.exchange";
    
    /**
     * Dead Letter Exchange
     */
    @Bean
    public DirectExchange deadLetterExchange() {
        return new DirectExchange(DLX);
    }
    
    /**
     * Dead Letter Queue
     */
    @Bean
    public Queue deadLetterQueue() {
        return QueueBuilder.durable(DLQ).build();
    }
    
    /**
     * DLX와 DLQ 바인딩
     */
    @Bean
    public Binding deadLetterBinding(Queue deadLetterQueue, DirectExchange deadLetterExchange) {
        return BindingBuilder
            .bind(deadLetterQueue)
            .to(deadLetterExchange)
            .with("dlq.routing.key");
    }
    
    /**
     * 메인 Queue (DLQ 설정 포함)
     */
    @Bean
    public Queue mainQueue() {
        return QueueBuilder.durable(MAIN_QUEUE)
            .withArgument("x-dead-letter-exchange", DLX)
            .withArgument("x-dead-letter-routing-key", "dlq.routing.key")
            .withArgument("x-message-ttl", 60000) // 60초 TTL
            .build();
    }
}

7.2 에러 처리 Consumer

package com.example.consumer;

import com.example.config.DeadLetterQueueConfig;
import com.example.dto.MessageDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class ErrorHandlingConsumer {
    
    /**
     * 메인 Queue에서 메시지 수신
     * 예외 발생 시 자동으로 DLQ로 이동
     */
    @RabbitListener(queues = DeadLetterQueueConfig.MAIN_QUEUE)
    public void processMessage(MessageDto message) {
        log.info("메시지 처리 시작: {}", message);
        
        try {
            // 비즈니스 로직
            if (message.getContent().contains("error")) {
                throw new RuntimeException("처리 실패");
            }
            
            log.info("메시지 처리 완료: {}", message.getId());
        } catch (Exception e) {
            log.error("메시지 처리 실패, DLQ로 이동: {}", message.getId(), e);
            throw e; // 예외를 다시 던져서 DLQ로 이동
        }
    }
    
    /**
     * Dead Letter Queue에서 메시지 수신
     */
    @RabbitListener(queues = DeadLetterQueueConfig.DLQ)
    public void processDeadLetter(MessageDto message) {
        log.error("DLQ에서 메시지 수신: {}", message);
        // 실패한 메시지에 대한 추가 처리 (알림, 로깅 등)
    }
}

7.3 수동 확인 (Manual Acknowledgment)

package com.example.config;

import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.listener.ConditionalRejectingErrorHandler;
import org.springframework.amqp.rabbit.listener.FatalExceptionStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ManualAckConfig {
    
    @Bean
    public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
            ConnectionFactory connectionFactory) {
        SimpleRabbitListenerContainerFactory factory = 
            new SimpleRabbitListenerContainerFactory();
        factory.setConnectionFactory(connectionFactory);
        
        // 수동 확인 모드
        factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
        
        // 에러 핸들러
        factory.setErrorHandler(new ConditionalRejectingErrorHandler(
            new FatalExceptionStrategy()
        ));
        
        return factory;
    }
}
package com.example.consumer;

import com.example.dto.MessageDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class ManualAckConsumer {
    
    private final RabbitTemplate rabbitTemplate;
    
    public ManualAckConsumer(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }
    
    @RabbitListener(queues = "manual.queue")
    public void processMessage(MessageDto messageDto, Message message) {
        try {
            log.info("메시지 처리: {}", messageDto);
            
            // 비즈니스 로직
            processBusinessLogic(messageDto);
            
            // 성공 시 수동 확인
            rabbitTemplate.getConnectionFactory()
                .createConnection()
                .createChannel(false)
                .basicAck(message.getMessageProperties().getDeliveryTag(), false);
            
        } catch (Exception e) {
            log.error("메시지 처리 실패", e);
            // 실패 시 NACK (재시도 또는 DLQ로 이동)
            // basicNack(deliveryTag, multiple, requeue)
        }
    }
    
    private void processBusinessLogic(MessageDto messageDto) {
        // 실제 비즈니스 로직
    }
}

8. Best Practice

8.1 Queue 설계 원칙

  • Durable Queue: 서버 재시작 후에도 유지되도록 설정
  • DLQ 설정: 실패한 메시지를 처리할 Dead Letter Queue 필수
  • TTL 설정: 필요시 메시지 만료 시간 설정
  • 우선순위: 중요한 메시지는 우선순위 Queue 사용

8.2 메시지 처리 원칙

  • 멱등성: 같은 메시지를 여러 번 처리해도 안전하도록 구현
  • 에러 처리: 모든 예외를 적절히 처리하고 로깅
  • 재시도: 일시적 오류는 재시도, 영구적 오류는 DLQ로 이동
  • 로깅: 메시지 처리 시작/완료/실패를 모두 로깅

8.3 성능 최적화

  • Prefetch: Consumer가 한 번에 가져올 메시지 수 조정
  • Concurrency: 동시 처리할 Consumer 수 설정
  • Connection Pool: 적절한 Connection Pool 크기 설정
  • 메시지 크기: 대용량 메시지는 파일 저장 후 경로만 전송
✅ 핵심 요약:
  • RabbitMQ는 Producer → Exchange → Queue → Consumer 구조
  • Exchange 타입: Direct, Topic, Fanout, Headers
  • Spring Boot는 spring-boot-starter-amqp로 간편하게 통합
  • JSON 메시지 컨버터 사용 권장
  • Dead Letter Queue로 실패한 메시지 처리
  • 에러 처리와 재시도 로직 필수
  • 멱등성 보장으로 안전한 메시지 처리

 

카카오톡 오픈채팅 링크

https://open.kakao.com/o/seCteX7h

 

 

추가로 궁금한 점이 있으시면 댓글로 남겨주세요! 🚀


참고 자료

 

728x90