diff --git a/docker-compose-db.yml b/docker-compose-db.yml index 6254cb6..d297c1a 100644 --- a/docker-compose-db.yml +++ b/docker-compose-db.yml @@ -4,10 +4,8 @@ services: image: redis:alpine container_name: rabbit-redis hostname: redis - ports: - - "6379:6379" - networks: - - rabbit-db + ports: [ "6379:6379" ] + networks: [ rabbit-db ] mysql_master: container_name: rabbit-mysql-master @@ -16,13 +14,11 @@ services: MYSQL_DATABASE: rabbit MYSQL_ROOT_HOST: '%' MYSQL_ROOT_PASSWORD: 1234 - ports: - - "3306:3306" + ports: [ "3306:3306" ] volumes: - ./mysql/master-data-source.cnf:/etc/mysql/conf.d/my.cnf - ./mysql/init-master.sql:/docker-entrypoint-initdb.d/01-init-master.sql - networks: - - rabbit-db + networks: [ rabbit-db ] healthcheck: test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234" ] timeout: 20s @@ -35,12 +31,10 @@ services: MYSQL_DATABASE: rabbit MYSQL_ROOT_HOST: '%' MYSQL_ROOT_PASSWORD: 1234 - ports: - - "3307:3306" + ports: [ "3307:3306" ] volumes: - ./mysql/replica-data-source.cnf:/etc/mysql/conf.d/my.cnf - networks: - - rabbit-db + networks: [ rabbit-db ] depends_on: mysql_master: condition: service_healthy @@ -50,13 +44,13 @@ services: retries: 10 mysql_replication_setup: + profiles: [ setup ] image: mysql:8.0 - container_name: mysql_replication_setup + container_name: mysql-replication-setup volumes: - - ./mysql/setup-replication.sh:/setup-replication.sh - command: [ "/bin/bash", "/setup-replication.sh" ] - networks: - - rabbit-db + - ./mysql/entrypoint.sh:/entrypoint.sh + command: [ "/bin/bash", "/entrypoint.sh" ] + networks: [ rabbit-db ] depends_on: mysql_master: condition: service_healthy diff --git a/docker-compose-elk.yml b/docker-compose-elk.yml index 0e99b3f..c94c655 100644 --- a/docker-compose-elk.yml +++ b/docker-compose-elk.yml @@ -3,6 +3,7 @@ version: "3.8" services: setup: profiles: [ setup ] + container_name: rabbit-elk-setup init: true build: context: elk/setup/ @@ -29,9 +30,7 @@ services: volumes: - ./elk/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro,Z - elasticsearch:/usr/share/elasticsearch/data - ports: - - "9200:9200" - - "9300:9300" + ports: [ "9200:9200", "9300:9300" ] environment: ES_JAVA_OPTS: "-Xmx256m -Xms256m" ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-rabbit1234} @@ -47,11 +46,7 @@ services: volumes: - ./elk/logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml:ro,Z - ./elk/logstash/pipeline:/usr/share/logstash/pipeline:ro,Z - ports: - - "5044:5044" - - "50000:50000/tcp" - - "50000:50000/udp" - - "9600:9600" + ports: [ "5044:5044", "50000:50000/tcp", "50000:50000/udp", "9600:9600" ] environment: LS_JAVA_OPTS: "-Xmx256m -Xms256m" LOGSTASH_USERNAME: ${LOGSTASH_USERNAME:-rabbit_logstash} @@ -67,8 +62,7 @@ services: ELASTIC_VERSION: ${ELASTIC_VERSION:-8.10.2} volumes: - ./elk/kibana/config/kibana.yml:/usr/share/kibana/config/kibana.yml:ro,Z - ports: - - "5601:5601" + ports: [ "5601:5601" ] environment: KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-rabbit1234} networks: [ rabbit-elk ] diff --git a/docker-compose.yml b/docker-compose.yml index f2acc1b..23e6ec1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,29 +3,22 @@ services: rabbitmq: image: rabbitmq:3-management container_name: rabbit-rabbitmq - ports: - - "5672:5672" - - "15672:15672" - - "61613:61613" + ports: [ "5672:5672", "15672:15672", "61613:61613" ] command: > /bin/bash -c "rabbitmq-plugins enable --offline rabbitmq_management rabbitmq_stomp && rabbitmq-server" environment: RABBITMQ_DEFAULT_USER: guest RABBITMQ_DEFAULT_PASS: guest - networks: - - rabbit-default + networks: [ rabbit-default ] nginx: image: nginx:latest container_name: rabbit-nginx - ports: - - "80:80" - - "443:443" + ports: [ "80:80", "443:443" ] volumes: - ./nginx/config/nginx.conf:/etc/nginx/conf.d/default.conf - ./nginx/ssl:/etc/nginx/ssl - networks: - - rabbit-default + networks: [ rabbit-default ] networks: rabbit-default: diff --git a/elk/setup/sh/tmp.sh b/elk/setup/sh/tmp.sh deleted file mode 100644 index c6a5c1e..0000000 --- a/elk/setup/sh/tmp.sh +++ /dev/null @@ -1,24 +0,0 @@ -# -curl -u pitchain_logstash:pitchain_logstash_password -X GET "localhost:9200/_security/_authenticate?pretty" - -# pitchain_logstash 유저 생성 확인 -curl -u pitchain_logstash:pitchain_logstash_password http://elasticsearch:9200/ - -# logstash_writer Role 생성 확인 -curl -u elastic:pitchain_elasticsearch_password http://localhost:9200/_security/role/logstash_writer?pretty - -# 도커 컴포즈 세팅 -docker-compose --profile setup -f docker-compose-elk.yml up --build -d - -# es 삭제 -docker-compose -f docker-compose-elk.yml down -v # ES 컨테이너와 볼륨(설정) 삭제 -rm -rf ./elk/elasticsearch/data ─╯ -rm -rf ./elk/elasticsearch/nodes -docker volume rm pitchain_elasticsearchr - -docker-compose --env-file /home/ec2-user/app/.env --profile setup -f /home/ec2-user/app/docker-compose-elk.yml up --build -d -docker-compose --profile setup -f docker-compose-elk.yml up --build -d - - - -ssh -i /Users/seungheonlee/Desktop/IntelliJ/pitchain-server-key.pem ec2-user@ec2-43-201-79-150.ap-northeast-2.compute.amazonaws.com \ No newline at end of file diff --git a/mysql/entrypoint.sh b/mysql/entrypoint.sh new file mode 100755 index 0000000..e0ac17c --- /dev/null +++ b/mysql/entrypoint.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +# MySQL Master-Replica 복제 설정 자동화 스크립트 +set -e + +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') [+] $1" +} +sublog() { + echo "$(date '+%Y-%m-%d %H:%M:%S') ⠿ $1" +} +suberr() { + echo "$(date '+%Y-%m-%d %H:%M:%S') ❌ $1" >&2 +} + +log "MySQL Master-Replica 복제 설정을 시작합니다..." + +log "Master DB 연결 대기 중..." +until mysql -h rabbit-mysql-master -u root -p1234 -e "SELECT 1" > /dev/null 2>&1; do + sublog "Master DB 연결 대기 중..." + sleep 3 +done +sublog "Master DB 연결됨" + +log "Replica DB 연결 대기 중..." +until mysql -h rabbit-mysql-replica -u root -p1234 -e "SELECT 1" > /dev/null 2>&1; do + sublog "Replica DB 연결 대기 중..." + sleep 3 +done +sublog "Replica DB 연결됨" + +log "DB 초기화 완료 대기 중..." +sleep 10 +sublog "DB 초기화 완료" + +log "Master DB에서 복제 사용자 확인 중..." +REPLICA_USER_EXISTS=$(mysql -h rabbit-mysql-master -u root -p1234 -e "SELECT COUNT(*) FROM mysql.user WHERE user='replica';" 2>/dev/null | tail -n 1) +if [ "$REPLICA_USER_EXISTS" -eq 0 ]; then + sublog "복제 사용자가 존재하지 않습니다. 생성 중..." + mysql -h rabbit-mysql-master -u root -p1234 << EOF +CREATE USER IF NOT EXISTS 'replica'@'%' IDENTIFIED WITH mysql_native_password BY '1234'; +GRANT REPLICATION SLAVE ON *.* TO 'replica'@'%'; +FLUSH PRIVILEGES; +EOF + sublog "복제 사용자가 생성되었습니다." +else + sublog "복제 사용자가 이미 존재합니다." +fi + +log "Master DB에서 바이너리 로그 상태 확인 중..." +MASTER_STATUS=$(mysql -h rabbit-mysql-master -u root -p1234 -e "SHOW MASTER STATUS\G" 2>/dev/null) +MASTER_FILE=$(echo "$MASTER_STATUS" | grep "File:" | awk '{print $2}') +MASTER_POSITION=$(echo "$MASTER_STATUS" | grep "Position:" | awk '{print $2}') +sublog "Master File: $MASTER_FILE" +sublog "Master Position: $MASTER_POSITION" + +if [ -z "$MASTER_FILE" ] || [ -z "$MASTER_POSITION" ]; then + suberr "Master 상태를 가져올 수 없습니다." + exit 1 +fi + +log "기존 복제 설정 정리 중..." +mysql -h rabbit-mysql-replica -u root -p1234 << EOF +STOP SLAVE; +RESET SLAVE ALL; +EOF +sublog "기존 복제 설정 정리 완료" + +log "Replica DB에서 Master 연결 설정 중..." +mysql -h rabbit-mysql-replica -u root -p1234 << EOF +CHANGE MASTER TO + MASTER_HOST='rabbit-mysql-master', + MASTER_USER='replica', + MASTER_PASSWORD='1234', + MASTER_LOG_FILE='$MASTER_FILE', + MASTER_LOG_POS=$MASTER_POSITION, + MASTER_CONNECT_RETRY=10, + MASTER_RETRY_COUNT=3; +START SLAVE; +EOF +sublog "Master 연결 설정 완료" + +log "복제 연결 대기 및 상태 확인 중..." +for i in {1..30}; do + SLAVE_STATUS=$(mysql -h rabbit-mysql-replica -u root -p1234 -e "SHOW SLAVE STATUS\G" 2>/dev/null) + IO_RUNNING=$(echo "$SLAVE_STATUS" | grep "Slave_IO_Running:" | awk '{print $2}') + SQL_RUNNING=$(echo "$SLAVE_STATUS" | grep "Slave_SQL_Running:" | awk '{print $2}') + + sublog "시도 $i/30 - IO Running: $IO_RUNNING, SQL Running: $SQL_RUNNING" + + if [ "$IO_RUNNING" = "Yes" ] && [ "$SQL_RUNNING" = "Yes" ]; then + sublog "✅ MySQL Master-Replica 복제 설정이 성공적으로 완료되었습니다!" + log "복제 상태 상세 정보 출력" + mysql -h rabbit-mysql-replica -u root -p1234 -e "SHOW SLAVE STATUS\G" | grep -E "(Slave_IO_Running|Slave_SQL_Running|Master_Host|Master_User|Read_Master_Log_Pos|Exec_Master_Log_Pos)" + exit 0 + elif [ "$IO_RUNNING" = "No" ]; then + suberr "IO 스레드 연결 실패. 오류 확인 중..." + LAST_IO_ERROR=$(echo "$SLAVE_STATUS" | grep "Last_IO_Error:" | cut -d':' -f2- | xargs) + if [ -n "$LAST_IO_ERROR" ]; then + suberr "IO 오류: $LAST_IO_ERROR" + fi + break + fi + sleep 2 +done + +suberr "복제 설정에 문제가 발생했습니다. 최종 상태: IO Running: $IO_RUNNING, SQL Running: $SQL_RUNNING" +log "복제 오류 정보 출력" +mysql -h rabbit-mysql-replica -u root -p1234 -e "SHOW SLAVE STATUS\G" | grep -E "(Last_IO_Error|Last_SQL_Error)" +exit 1 diff --git a/mysql/setup-replication.sh b/mysql/setup-replication.sh deleted file mode 100755 index 12bb831..0000000 --- a/mysql/setup-replication.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/bin/bash - -# MySQL Master-Replica 복제 설정 자동화 스크립트 -set -e - -echo "MySQL Master-Replica 복제 설정을 시작합니다..." - -# Master DB가 완전히 시작될 때까지 대기 -echo "Master DB 연결 대기 중..." -until mysql -h rabbit-mysql-master -u root -p1234 -e "SELECT 1" > /dev/null 2>&1; do - echo "Master DB 연결 대기 중..." - sleep 3 -done - -# Replica DB가 완전히 시작될 때까지 대기 -echo "Replica DB 연결 대기 중..." -until mysql -h rabbit-mysql-replica -u root -p1234 -e "SELECT 1" > /dev/null 2>&1; do - echo "Replica DB 연결 대기 중..." - sleep 3 -done - -# 추가 안정화 대기 시간 -echo "DB 초기화 완료 대기 중..." -sleep 10 - -# Master DB에서 복제 사용자가 생성되었는지 확인 -echo "Master DB에서 복제 사용자 확인 중..." -REPLICA_USER_EXISTS=$(mysql -h rabbit-mysql-master -u root -p1234 -e "SELECT COUNT(*) FROM mysql.user WHERE user='replica';" 2>/dev/null | tail -n 1) -if [ "$REPLICA_USER_EXISTS" -eq 0 ]; then - echo "복제 사용자가 존재하지 않습니다. 생성 중..." - mysql -h rabbit-mysql-master -u root -p1234 << EOF -CREATE USER IF NOT EXISTS 'replica'@'%' IDENTIFIED WITH mysql_native_password BY '1234'; -GRANT REPLICATION SLAVE ON *.* TO 'replica'@'%'; -FLUSH PRIVILEGES; -EOF - echo "복제 사용자가 생성되었습니다." -fi - -# Master DB에서 바이너리 로그 상태 확인 -echo "Master DB에서 바이너리 로그 상태 확인 중..." -MASTER_STATUS=$(mysql -h rabbit-mysql-master -u root -p1234 -e "SHOW MASTER STATUS\G" 2>/dev/null) -MASTER_FILE=$(echo "$MASTER_STATUS" | grep "File:" | awk '{print $2}') -MASTER_POSITION=$(echo "$MASTER_STATUS" | grep "Position:" | awk '{print $2}') - -echo "Master File: $MASTER_FILE" -echo "Master Position: $MASTER_POSITION" - -if [ -z "$MASTER_FILE" ] || [ -z "$MASTER_POSITION" ]; then - echo "❌ Master 상태를 가져올 수 없습니다." - exit 1 -fi - -# 기존 복제 설정 정리 -echo "기존 복제 설정 정리 중..." -mysql -h rabbit-mysql-replica -u root -p1234 << EOF -STOP SLAVE; -RESET SLAVE ALL; -EOF - -# Replica DB에서 Master 설정 -echo "Replica DB에서 Master 연결 설정 중..." -mysql -h rabbit-mysql-replica -u root -p1234 << EOF -CHANGE MASTER TO - MASTER_HOST='rabbit-mysql-master', - MASTER_USER='replica', - MASTER_PASSWORD='1234', - MASTER_LOG_FILE='$MASTER_FILE', - MASTER_LOG_POS=$MASTER_POSITION, - MASTER_CONNECT_RETRY=10, - MASTER_RETRY_COUNT=3; -START SLAVE; -EOF - -# 복제 연결 대기 및 상태 확인 -echo "복제 연결 대기 중..." -for i in {1..30}; do - SLAVE_STATUS=$(mysql -h rabbit-mysql-replica -u root -p1234 -e "SHOW SLAVE STATUS\G" 2>/dev/null) - IO_RUNNING=$(echo "$SLAVE_STATUS" | grep "Slave_IO_Running:" | awk '{print $2}') - SQL_RUNNING=$(echo "$SLAVE_STATUS" | grep "Slave_SQL_Running:" | awk '{print $2}') - - echo "시도 $i/30 - IO Running: $IO_RUNNING, SQL Running: $SQL_RUNNING" - - if [ "$IO_RUNNING" = "Yes" ] && [ "$SQL_RUNNING" = "Yes" ]; then - echo "✅ MySQL Master-Replica 복제 설정이 성공적으로 완료되었습니다!" - - # 복제 상태 상세 정보 출력 - echo "=== 복제 상태 상세 정보 ===" - mysql -h rabbit-mysql-replica -u root -p1234 -e "SHOW SLAVE STATUS\G" | grep -E "(Slave_IO_Running|Slave_SQL_Running|Master_Host|Master_User|Read_Master_Log_Pos|Exec_Master_Log_Pos)" - exit 0 - elif [ "$IO_RUNNING" = "No" ]; then - echo "❌ IO 스레드 연결 실패. 오류 확인 중..." - LAST_IO_ERROR=$(echo "$SLAVE_STATUS" | grep "Last_IO_Error:" | cut -d':' -f2- | xargs) - if [ -n "$LAST_IO_ERROR" ]; then - echo "IO 오류: $LAST_IO_ERROR" - fi - break - fi - - sleep 2 -done - -echo "❌ 복제 설정에 문제가 발생했습니다." -echo "최종 상태: IO Running: $IO_RUNNING, SQL Running: $SQL_RUNNING" - -# 오류 정보 출력 -echo "=== 복제 오류 정보 ===" -mysql -h rabbit-mysql-replica -u root -p1234 -e "SHOW SLAVE STATUS\G" | grep -E "(Last_IO_Error|Last_SQL_Error)" - -exit 1 diff --git a/src/main/java/com/rabbitmqprac/application/controller/ChatMessageController.java b/src/main/java/com/rabbitmqprac/application/controller/ChatMessageController.java index 868ae78..0ecbafb 100644 --- a/src/main/java/com/rabbitmqprac/application/controller/ChatMessageController.java +++ b/src/main/java/com/rabbitmqprac/application/controller/ChatMessageController.java @@ -9,7 +9,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; @@ -29,7 +28,6 @@ public class ChatMessageController implements ChatMessageApi { * Destination Queue: /pub/chat.message.{chatRoomId}를 통해 호출 후 처리 되는 로직 */ @Override - @PreAuthorize("#chatRoomAccessChecker.hasPermission(#chatRoomId, principal)") @MessageMapping("chat.room.{chatRoomId}/message") public void sendMessage(UserPrincipal principal, @DestinationVariable Long chatRoomId, diff --git a/src/main/java/com/rabbitmqprac/config/SecurityConfig.java b/src/main/java/com/rabbitmqprac/config/SecurityConfig.java index 1301cc2..b8a5734 100644 --- a/src/main/java/com/rabbitmqprac/config/SecurityConfig.java +++ b/src/main/java/com/rabbitmqprac/config/SecurityConfig.java @@ -14,6 +14,7 @@ import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -39,6 +40,7 @@ @Configuration @EnableWebSecurity @ConditionalOnDefaultWebSecurity +@EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; diff --git a/src/main/java/com/rabbitmqprac/domain/context/chatmessage/service/ChatMessageService.java b/src/main/java/com/rabbitmqprac/domain/context/chatmessage/service/ChatMessageService.java index 0c6d8e8..284e4bc 100644 --- a/src/main/java/com/rabbitmqprac/domain/context/chatmessage/service/ChatMessageService.java +++ b/src/main/java/com/rabbitmqprac/domain/context/chatmessage/service/ChatMessageService.java @@ -16,6 +16,7 @@ import com.rabbitmqprac.domain.persistence.usersession.entity.UserStatus; import com.rabbitmqprac.global.helper.RabbitPublisher; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,6 +35,7 @@ public class ChatMessageService { private final ChatMessageRepository chatMessageRepository; private final RabbitPublisher rabbitPublisher; + @PreAuthorize("@chatRoomMemberAuthorityValidator.isMember(#chatRoomId, #userId)") @Transactional public void sendMessage(Long userId, Long chatRoomId, ChatMessageReq req) { User user = entityFacade.readUser(userId); @@ -45,6 +47,7 @@ public void sendMessage(Long userId, Long chatRoomId, ChatMessageReq req) { sendMessage(chatMessage, unreadMemberCnt, chatRoom); } + @PreAuthorize("@chatRoomMemberAuthorityValidator.isMember(#chatRoomId)") @Transactional(readOnly = true) public List readChatMessagesBefore(Long chatRoomId, Long lastChatMessageId, int size) { List chatMessages = chatMessageRepository.findByChatRoomIdBefore( @@ -137,16 +140,19 @@ private void sendMessage(ChatMessage chatMessage, int unreadMemberCnt, ChatRoom rabbitPublisher.publish(chatRoom.getId(), chatMessageRes); } + @PreAuthorize("@chatRoomMemberAuthorityValidator.isMember(#chatRoomId)") @Transactional(readOnly = true) public Optional readLastChatMessage(Long chatRoomId) { return chatMessageRepository.findTopByChatRoomIdOrderByCreatedAtDesc(chatRoomId); } + @PreAuthorize("@chatRoomMemberAuthorityValidator.isMember(#chatRoomId)") @Transactional(readOnly = true) public int countUnreadMessages(Long chatRoomId, Long lastReadMessageId) { return chatMessageRepository.countByChatRoomIdAndIdGreaterThan(chatRoomId, lastReadMessageId); } + @PreAuthorize("@chatRoomMemberAuthorityValidator.isMember(#chatRoomId)") @Transactional(readOnly = true) public List readChatMessagesBetween(Long userId, Long chatRoomId, Long from, Long to) { List chatMessages = chatMessageRepository.findByChatRoomIdAndIdBetween(chatRoomId, from, to); diff --git a/src/main/java/com/rabbitmqprac/domain/context/user/exception/UserErrorCode.java b/src/main/java/com/rabbitmqprac/domain/context/user/exception/UserErrorCode.java index 1360eab..a8af4ef 100644 --- a/src/main/java/com/rabbitmqprac/domain/context/user/exception/UserErrorCode.java +++ b/src/main/java/com/rabbitmqprac/domain/context/user/exception/UserErrorCode.java @@ -9,6 +9,9 @@ @RequiredArgsConstructor public enum UserErrorCode implements BaseErrorCode { + /* 403 FORBIDDEN */ + FORBIDDEN(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "사용자에게 권한이 없습니다."), + /* 404 NOT FOUND */ NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "회원을 찾을 수 없습니다."), CONFLICT_USERNAME(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 존재하는 유저 아이디입니다"); diff --git a/src/main/java/com/rabbitmqprac/global/advice/GlobalExceptionAdvice.java b/src/main/java/com/rabbitmqprac/global/advice/GlobalExceptionAdvice.java index d230644..318fe2c 100644 --- a/src/main/java/com/rabbitmqprac/global/advice/GlobalExceptionAdvice.java +++ b/src/main/java/com/rabbitmqprac/global/advice/GlobalExceptionAdvice.java @@ -1,6 +1,7 @@ package com.rabbitmqprac.global.advice; import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.rabbitmqprac.domain.context.user.exception.UserErrorCode; import com.rabbitmqprac.global.exception.CustomValidationException; import com.rabbitmqprac.global.exception.GlobalErrorException; import com.rabbitmqprac.global.exception.payload.CausedBy; @@ -14,6 +15,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.validation.BindingResult; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -126,6 +128,19 @@ protected ErrorResponse handleAccessDeniedException(AccessDeniedException e) { return ErrorResponse.of(causedBy.getCode(), causedBy.getReason(), e.getMessage()); } + /** + * @PreAuthorize 에서 검증 실패 시 발생하는 AuthorizationDeniedException 처리 + */ + @ResponseStatus(HttpStatus.FORBIDDEN) + @ExceptionHandler(AuthorizationDeniedException.class) + protected ErrorResponse handleAuthorizationDeniedException(AuthorizationDeniedException e) { + log.warn("handleAuthorizationDeniedException : {}", e.getMessage()); + UserErrorCode errorCode = UserErrorCode.FORBIDDEN; + CausedBy causedBy = errorCode.causedBy(); + + return ErrorResponse.of(causedBy.getCode(), causedBy.getReason(), errorCode.getExplainError()); + } + /** * API 호출 시 객체 혹은 파라미터 데이터 값이 유효하지 않은 경우 * diff --git a/src/main/java/com/rabbitmqprac/infra/security/authorityvalidator/AuthorityValidator.java b/src/main/java/com/rabbitmqprac/infra/security/authorityvalidator/AuthorityValidator.java new file mode 100644 index 0000000..4681ab6 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/security/authorityvalidator/AuthorityValidator.java @@ -0,0 +1,16 @@ +package com.rabbitmqprac.infra.security.authorityvalidator; + +import com.rabbitmqprac.domain.context.user.exception.UserErrorCode; +import com.rabbitmqprac.domain.context.user.exception.UserErrorException; +import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; +import org.springframework.security.core.Authentication; + +public abstract class AuthorityValidator { + protected SecurityUserDetails isAuthenticated(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + throw new UserErrorException(UserErrorCode.FORBIDDEN); + } + + return (SecurityUserDetails) authentication.getPrincipal(); + } +} diff --git a/src/main/java/com/rabbitmqprac/infra/security/authorityvalidator/ChatRoomMemberAuthorityValidator.java b/src/main/java/com/rabbitmqprac/infra/security/authorityvalidator/ChatRoomMemberAuthorityValidator.java new file mode 100644 index 0000000..af4b7cf --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/security/authorityvalidator/ChatRoomMemberAuthorityValidator.java @@ -0,0 +1,29 @@ +package com.rabbitmqprac.infra.security.authorityvalidator; + +import com.rabbitmqprac.domain.context.chatroommember.service.ChatRoomMemberService; +import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component("chatRoomMemberAuthorityValidator") +@RequiredArgsConstructor +public class ChatRoomMemberAuthorityValidator extends AuthorityValidator { + private final ChatRoomMemberService chatRoomMemberService; + + public boolean isMember(Long chatRoomId) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + SecurityUserDetails userDetails = super.isAuthenticated(authentication); + Long currentUserId = userDetails.getUserId(); + + return chatRoomMemberService.isExists(chatRoomId, currentUserId); + } + + /** + * STOMP는 HTTP와 달리 SecurityContextHolder에서 인증 정보를 가져올 수 없으므로, userId를 직접 받아서 검증한다. + */ + public boolean isMember(Long chatRoomId, Long userId) { + return chatRoomMemberService.isExists(chatRoomId, userId); + } +} diff --git a/src/main/java/com/rabbitmqprac/infra/security/common/registry/ResourceAccessRegistry.java b/src/main/java/com/rabbitmqprac/infra/security/registry/ResourceCheckerRegistry.java similarity index 74% rename from src/main/java/com/rabbitmqprac/infra/security/common/registry/ResourceAccessRegistry.java rename to src/main/java/com/rabbitmqprac/infra/security/registry/ResourceCheckerRegistry.java index ba7903e..5ff3990 100644 --- a/src/main/java/com/rabbitmqprac/infra/security/common/registry/ResourceAccessRegistry.java +++ b/src/main/java/com/rabbitmqprac/infra/security/registry/ResourceCheckerRegistry.java @@ -1,12 +1,11 @@ -package com.rabbitmqprac.infra.security.common.registry; +package com.rabbitmqprac.infra.security.registry; -import com.rabbitmqprac.infra.stomp.exception.StompErrorCode; -import com.rabbitmqprac.infra.stomp.exception.StompErrorException; +import com.rabbitmqprac.infra.security.registry.checker.ChatRoomAccessChecker; +import com.rabbitmqprac.infra.security.registry.checker.StompAuthorityChecker; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import javax.swing.text.html.Option; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -18,8 +17,8 @@ */ @RequiredArgsConstructor @Component -public class ResourceAccessRegistry { - private final Map checkers = new HashMap<>(); +public class ResourceCheckerRegistry { + private final Map checkers = new HashMap<>(); private final ChatRoomAccessChecker chatRoomAccessChecker; @PostConstruct @@ -27,7 +26,7 @@ public void setCheckers() { registerChecker("^/exchange/chat\\.exchange/room\\.\\d+$", chatRoomAccessChecker); } - public void registerChecker(final String pathPattern, final ResourceAccessChecker checker) { + public void registerChecker(final String pathPattern, final StompAuthorityChecker checker) { checkers.put(Pattern.compile(pathPattern), checker); } @@ -38,7 +37,7 @@ public void registerChecker(final String pathPattern, final ResourceAccessChecke * @return ResourceAccessChecker : path에 대한 체커 * @throws IllegalArgumentException : 해당 경로에 대한 체커가 없는 경우 */ - public Optional getChecker(final String path) { + public Optional getChecker(final String path) { return checkers.entrySet().stream() .filter(entry -> entry.getKey().matcher(path).matches()) .map(Map.Entry::getValue) diff --git a/src/main/java/com/rabbitmqprac/infra/security/common/registry/ChatRoomAccessChecker.java b/src/main/java/com/rabbitmqprac/infra/security/registry/checker/ChatRoomAccessChecker.java similarity index 85% rename from src/main/java/com/rabbitmqprac/infra/security/common/registry/ChatRoomAccessChecker.java rename to src/main/java/com/rabbitmqprac/infra/security/registry/checker/ChatRoomAccessChecker.java index a1adaae..0937f6b 100644 --- a/src/main/java/com/rabbitmqprac/infra/security/common/registry/ChatRoomAccessChecker.java +++ b/src/main/java/com/rabbitmqprac/infra/security/registry/checker/ChatRoomAccessChecker.java @@ -1,4 +1,4 @@ -package com.rabbitmqprac.infra.security.common.registry; +package com.rabbitmqprac.infra.security.registry.checker; import com.rabbitmqprac.domain.context.chatroommember.service.ChatRoomMemberService; import lombok.RequiredArgsConstructor; @@ -10,7 +10,7 @@ @Slf4j @Component("chatRoomAccessChecker") @RequiredArgsConstructor -public class ChatRoomAccessChecker implements ResourceAccessChecker { +public class ChatRoomAccessChecker implements StompAuthorityChecker { private final ChatRoomMemberService chatRoomMemberService; @Override diff --git a/src/main/java/com/rabbitmqprac/infra/security/common/registry/ResourceAccessChecker.java b/src/main/java/com/rabbitmqprac/infra/security/registry/checker/StompAuthorityChecker.java similarity index 78% rename from src/main/java/com/rabbitmqprac/infra/security/common/registry/ResourceAccessChecker.java rename to src/main/java/com/rabbitmqprac/infra/security/registry/checker/StompAuthorityChecker.java index ced40b2..c8963bf 100644 --- a/src/main/java/com/rabbitmqprac/infra/security/common/registry/ResourceAccessChecker.java +++ b/src/main/java/com/rabbitmqprac/infra/security/registry/checker/StompAuthorityChecker.java @@ -1,11 +1,11 @@ -package com.rabbitmqprac.infra.security.common.registry; +package com.rabbitmqprac.infra.security.registry.checker; import java.security.Principal; /** * 리소스 접근 권한을 확인하는 인터페이스 */ -public interface ResourceAccessChecker { +public interface StompAuthorityChecker { /** * 리소스에 대한 접근 권한을 확인한다. * diff --git a/src/main/java/com/rabbitmqprac/infra/stomp/handler/command/subscribe/ChatRoomAuthorizeHandler.java b/src/main/java/com/rabbitmqprac/infra/stomp/handler/command/subscribe/ChatRoomAuthorizeHandler.java index 0debc03..8e5e960 100644 --- a/src/main/java/com/rabbitmqprac/infra/stomp/handler/command/subscribe/ChatRoomAuthorizeHandler.java +++ b/src/main/java/com/rabbitmqprac/infra/stomp/handler/command/subscribe/ChatRoomAuthorizeHandler.java @@ -1,7 +1,7 @@ package com.rabbitmqprac.infra.stomp.handler.command.subscribe; import com.rabbitmqprac.domain.context.usersession.service.UserSessionService; -import com.rabbitmqprac.infra.security.common.registry.ResourceAccessRegistry; +import com.rabbitmqprac.infra.security.registry.ResourceCheckerRegistry; import com.rabbitmqprac.infra.stomp.exception.StompErrorCode; import com.rabbitmqprac.infra.stomp.exception.StompErrorException; import lombok.RequiredArgsConstructor; @@ -14,7 +14,7 @@ @Component @RequiredArgsConstructor public class ChatRoomAuthorizeHandler implements SubscribeCommandHandler { - private final ResourceAccessRegistry resourceAccessRegistry; + private final ResourceCheckerRegistry resourceCheckerRegistry; private final UserSessionService userSessionService; private static final String USER_EXCHANGE_PREFIX = "/user"; @@ -29,7 +29,7 @@ public void handle(Message message, StompHeaderAccessor accessor) { } Long chatRoomId = extractChatRoomId(destination); - resourceAccessRegistry.getChecker(destination).ifPresent(checker -> { + resourceCheckerRegistry.getChecker(destination).ifPresent(checker -> { if (checker.hasPermission(chatRoomId, accessor.getUser())) { Long userId = Long.parseLong(accessor.getUser().getName()); log.info("[Exchange 권한 검사] userId={}에 대한 {} 권한 검사 통과", userId, destination); diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 4742b22..e21cc5b 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -13,12 +13,16 @@ DELETE FROM `chat_message`; DELETE FROM `oauth`; +DELETE +FROM `chat_message_status`; + -- AUTO_INCREMENT 값 초기화 ALTER TABLE `user` AUTO_INCREMENT = 1; ALTER TABLE `chat_room` AUTO_INCREMENT = 1; ALTER TABLE `chat_room_member` AUTO_INCREMENT = 1; ALTER TABLE `chat_message` AUTO_INCREMENT = 1; ALTER TABLE `oauth` AUTO_INCREMENT = 1; +ALTER TABLE `chat_message_status` AUTO_INCREMENT = 1; -- 빠른 삽입을 위해 검사 비활성화 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; @@ -39,16 +43,18 @@ LOCK TABLES `user` WRITE; /*!40000 ALTER TABLE `user` DISABLE KEYS */; INSERT INTO `user` (`user_id`, `username`, `password`, `nickname`, `role`, `created_at`, `updated_at`) -VALUES (1, 'user_1', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', 'USER_1', 'USER', NOW(), NOW()), - (2, 'user_2', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', 'USER_2', 'USER', NOW(), NOW()), - (3, 'user_3', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', 'USER_3', 'USER', NOW(), NOW()), - (4, 'user_4', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', 'USER_4', 'USER', NOW(), NOW()), - (5, 'user_5', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', 'USER_5', 'USER', NOW(), NOW()), - (6, 'user_6', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', 'USER_6', 'USER', NOW(), NOW()), - (7, 'user_7', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', 'USER_7', 'USER', NOW(), NOW()), - (8, 'user_8', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', 'USER_8', 'USER', NOW(), NOW()), - (9, 'user_9', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', 'USER_9', 'USER', NOW(), NOW()), - (10, 'user_10', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', 'USER_10', 'USER', NOW(), NOW()); +VALUES (1, 'user_1', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', '귀여운토끼', 'USER', NOW(), NOW()), + (2, 'user_2', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', '용감한호랑이', 'USER', NOW(), NOW()), + (3, 'user_3', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', '지혜로운부엉이', 'USER', NOW(), NOW()), + (4, 'user_4', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', '빠른치타', 'USER', NOW(), NOW()), + (5, 'user_5', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', '성실한개미', 'USER', NOW(), NOW()), + (6, 'user_6', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', '재치있는여우', 'USER', NOW(), NOW()), + (7, 'user_7', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', '강인한곰', 'USER', NOW(), NOW()), + (8, 'user_8', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', '우아한백조', 'USER', NOW(), NOW()), + (9, 'user_9', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', '영리한돌고래', 'USER', NOW(), NOW()), + (10, 'user_10', '$2a$10$gcUz6PGJLUrSt1TF6/rnc.FLqJPzevnkzDx2yY2Y49ehhuAxhQ4WW', '신속한매', 'USER', NOW(), NOW()), + (11, 'admin', '$2a$10$KfN5M7d9YNGfK/7oQkHzFeLeG6eFv.zbDHL7qN9fUbbdS3G6wOEqK', 'ADMIN', 'ADMIN', NOW(), NOW()), + (12, 'guest', '$2a$10$OrK25aG4HqYbXk4NHtMzoOPF9V2B3oFfjkqhv8o1cX1j8xFWXZoe2', 'GUEST', 'USER', NOW(), NOW()); /*!40000 ALTER TABLE `user` ENABLE KEYS */; UNLOCK TABLES; @@ -57,12 +63,12 @@ LOCK TABLES `chat_room` WRITE; /*!40000 ALTER TABLE `chat_room` DISABLE KEYS */; INSERT INTO `chat_room` (`chat_room_id`, `title`, `max_capacity`, `created_at`, `updated_at`) -VALUES (1, 'CHAT_ROOM_1', 10, NOW(), NOW()), - (2, 'CHAT_ROOM_2', 10, NOW(), NOW()), - (3, 'CHAT_ROOM_3', 10, NOW(), NOW()), - (4, 'CHAT_ROOM_4', 10, NOW(), NOW()), - (5, 'CHAT_ROOM_5', 10, NOW(), NOW()), - (6, 'CHAT_ROOM_6', 10, NOW(), NOW()) +VALUES (1, '노래 추천좀', 4, NOW(), NOW()), + (2, '점메추', 3, NOW(), NOW()), + (3, '재밌는 사람만', 10, NOW(), NOW()), + (4, '고민 들어줄 사람 있나요?', 2, NOW(), NOW()), + (5, '면접 준비 도와줄사람?', 3, NOW(), NOW()), + (6, '같이 노래방 갈사람 ㄱㄱ', 10, NOW(), NOW()) ; /*!40000 ALTER TABLE `chat_room` ENABLE KEYS */; @@ -73,18 +79,31 @@ LOCK TABLES `chat_room_member` WRITE; /*!40000 ALTER TABLE `chat_room_member` DISABLE KEYS */; INSERT INTO `chat_room_member` (`chat_room_member_id`, `chat_room_id`, `user_id`, `role`, `created_at`, `updated_at`) -VALUES (1, 1, 1, 'ADMIN', NOW(), NOW()), - (2, 1, 2, 'MEMBER', NOW(), NOW()), - (3, 2, 1, 'MEMBER', NOW(), NOW()), - (4, 2, 2, 'ADMIN', NOW(), NOW()), - (5, 3, 1, 'MEMBER', NOW(), NOW()), - (6, 3, 2, 'MEMBER', NOW(), NOW()), - (7, 3, 3, 'ADMIN', NOW(), NOW()), - (8, 4, 1, 'MEMBER', NOW(), NOW()), - (9, 4, 2, 'MEMBER', NOW(), NOW()), - (10, 4, 3, 'MEMBER', NOW(), NOW()), - (11, 4, 4, 'ADMIN', NOW(), NOW()) -; +VALUES +-- 1번방: 노래 추천좀 + (1, 1, 1, 'ADMIN', '2025-08-31 10:00:00', '2025-08-31 10:00:00'), + (2, 1, 4, 'MEMBER', '2025-08-31 10:01:00', '2025-08-31 10:01:00'), + (3, 1, 5, 'MEMBER', '2025-08-31 10:02:00', '2025-08-31 10:02:00'), +-- 2번방: 점메추 + (4, 2, 2, 'ADMIN', '2025-08-31 10:03:00', '2025-08-31 10:03:00'), + (5, 2, 6, 'MEMBER', '2025-08-31 10:04:00', '2025-08-31 10:04:00'), + (6, 2, 7, 'MEMBER', '2025-08-31 10:05:00', '2025-08-31 10:05:00'), +-- 3번방: 재밌는 사람만 + (7, 3, 3, 'ADMIN', '2025-08-31 10:06:00', '2025-08-31 10:06:00'), + (8, 3, 8, 'MEMBER', '2025-08-31 10:07:00', '2025-08-31 10:07:00'), + (9, 3, 9, 'MEMBER', '2025-08-31 10:08:00', '2025-08-31 10:08:00'), +-- 4번방: 고민 들어줄 사람 있나요? + (10, 4, 4, 'ADMIN', '2025-08-31 10:09:00', '2025-08-31 10:09:00'), + (11, 4, 10, 'MEMBER', '2025-08-31 10:10:00', '2025-08-31 10:10:00'), + (12, 4, 11, 'MEMBER', '2025-08-31 10:11:00', '2025-08-31 10:11:00'), +-- 5번방: 면접 준비 도와줄사람? + (13, 5, 1, 'ADMIN', '2025-08-31 10:12:00', '2025-08-31 10:12:00'), + (14, 5, 5, 'MEMBER', '2025-08-31 10:13:00', '2025-08-31 10:13:00'), + (15, 5, 6, 'MEMBER', '2025-08-31 10:14:00', '2025-08-31 10:14:00'), +-- 6번방: 같이 노래방 갈사람 ㄱㄱ + (16, 6, 2, 'ADMIN', '2025-08-31 10:15:00', '2025-08-31 10:15:00'), + (17, 6, 7, 'MEMBER', '2025-08-31 10:16:00', '2025-08-31 10:16:00'), + (18, 6, 8, 'MEMBER', '2025-08-31 10:17:00', '2025-08-31 10:17:00'); /*!40000 ALTER TABLE `chat_room_member` ENABLE KEYS */; UNLOCK TABLES; @@ -93,19 +112,43 @@ LOCK TABLES `chat_message` WRITE; /*!40000 ALTER TABLE `chat_message` DISABLE KEYS */; INSERT INTO `chat_message` (`chat_message_id`, `chat_room_id`, `user_id`, `content`, `created_at`, `updated_at`) -VALUES (1, 1, 1, '안녕하세요~', '2024-01-01 10:00:00', '2024-01-01 10:00:00'), - (2, 1, 2, '안녕하세요~', '2024-01-01 10:01:00', '2024-01-01 10:01:00'), - (3, 2, 1, '안녕하세요~', '2024-01-01 10:02:00', '2024-01-01 10:02:00'), - (4, 2, 2, '네 반갑습니다.', '2024-01-01 10:03:00', '2024-01-01 10:03:00'), - (5, 3, 1, '안녕하세요~', '2024-01-01 10:04:00', '2024-01-01 10:04:00'), - (6, 3, 2, '잘 부탁드립니다!', '2024-01-01 10:05:00', '2024-01-01 10:05:00'), - (7, 3, 3, '반갑습니다~', '2024-01-01 10:06:00', '2024-01-01 10:06:00'), - (8, 4, 1, '안녕하세요~', '2024-01-01 10:07:00', '2024-01-01 10:07:00'), - (9, 4, 2, '저는 백엔드입니다', '2024-01-01 10:08:00', '2024-01-01 10:08:00'), - (10, 4, 3, '저는 프론트입니다', '2024-01-01 10:09:00', '2024-01-01 10:09:00'), - (11, 4, 4, '저는 모바일입니다', '2024-01-01 10:10:00', '2024-01-01 10:10:00'), - (12, 4, 4, '저는 모바일입니다22', '2024-01-01 10:11:00', '2024-01-01 10:11:00') -; +VALUES +-- 1번방: 노래 추천좀 + (1, 1, 4, '요즘 듣기 좋은 노래 뭐 있어?', '2025-08-31 10:00:00', '2025-08-31 10:00:00'), + (2, 1, 5, '저는 뉴진스 노래 추천해요!', '2025-08-31 10:01:00', '2025-08-31 10:01:00'), + (3, 1, 1, '잔나비 노래도 좋아요!', '2025-08-31 10:02:00', '2025-08-31 10:02:00'), + (4, 1, 4, '혹시 발라드 추천해줄 사람?', '2025-08-31 10:03:00', '2025-08-31 10:03:00'), + (5, 1, 5, '폴킴 노래 들어보세요!', '2025-08-31 10:04:00', '2025-08-31 10:04:00'), + +-- 2번방: 점메추 + (6, 2, 6, '오늘 점심 뭐 먹지?', '2025-08-31 10:05:00', '2025-08-31 10:05:00'), + (7, 2, 7, '김치찌개 어때요?', '2025-08-31 10:06:00', '2025-08-31 10:06:00'), + (8, 2, 2, '저는 샐러드 추천!', '2025-08-31 10:07:00', '2025-08-31 10:07:00'), + +-- 3번방: 재밌는 사람만 + (9, 3, 8, '재밌는 얘기 해줄 사람?', '2025-08-31 10:08:00', '2025-08-31 10:08:00'), + (10, 3, 9, '어제 축구 봤어요? 완전 웃겼음!', '2025-08-31 10:09:00', '2025-08-31 10:09:00'), + (11, 3, 3, '밈 공유해요~', '2025-08-31 10:10:00', '2025-08-31 10:10:00'), + (12, 3, 8, '오늘 회사에서 있었던 썰 풀어봄', '2025-08-31 10:11:00', '2025-08-31 10:11:00'), + +-- 4번방: 고민 들어줄 사람 있나요? + (13, 4, 10, '요즘 진로 고민이 많아요...', '2025-08-31 10:12:00', '2025-08-31 10:12:00'), + (14, 4, 11, '무슨 고민인지 말해봐요!', '2025-08-31 10:13:00', '2025-08-31 10:13:00'), + (15, 4, 4, '저도 비슷한 고민 있었어요', '2025-08-31 10:14:00', '2025-08-31 10:14:00'), + +-- 5번방: 면접 준비 도와줄사람? + (16, 5, 5, '면접 준비 어떻게 해야 할까요?', '2025-08-31 10:15:00', '2025-08-31 10:15:00'), + (17, 5, 6, '자기소개 연습해봤어요?', '2025-08-31 10:16:00', '2025-08-31 10:16:00'), + (18, 5, 1, '기술 면접 팁 공유해요!', '2025-08-31 10:17:00', '2025-08-31 10:17:00'), + (19, 5, 5, '면접 질문 리스트 있어요?', '2025-08-31 10:18:00', '2025-08-31 10:18:00'), + +-- 6번방: 같이 노래방 갈사람 ㄱㄱ + (20, 6, 7, '노래방 언제 갈까요?', '2025-08-31 10:19:00', '2025-08-31 10:19:00'), + (21, 6, 8, '주말에 시간 되는 사람?', '2025-08-31 10:20:00', '2025-08-31 10:20:00'), + (22, 6, 2, '노래방에서 부를 곡 추천해요!', '2025-08-31 10:21:00', '2025-08-31 10:21:00'), + (23, 6, 7, '2차로 밥도 먹을래요?', '2025-08-31 10:22:00', '2025-08-31 10:22:00'), + (24, 6, 8, '노래방 예약은 누가 할까요?', '2025-08-31 10:23:00', '2025-08-31 10:23:00'), + (25, 6, 2, '다들 어떤 노래 좋아해요?', '2025-08-31 10:24:00', '2025-08-31 10:24:00'); /*!40000 ALTER TABLE `chat_message` ENABLE KEYS */; UNLOCK TABLES;