From 1f5facffc8f53684a4e1cc870602d332678c3655 Mon Sep 17 00:00:00 2001 From: LSH <104637774+lsh2613@users.noreply.github.com> Date: Fri, 22 Aug 2025 02:44:48 +0900 Subject: [PATCH 1/9] =?UTF-8?q?Chore:=20CQRS=20=EC=A0=81=EC=9A=A9=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: CQRS 적용 * fix: 테스트 환경에서 CQRS 데이터소스 설정 충돌 해결 * refactor: log level 변경 * refactor: DataSource 빈 의존성 주입 방식으로 개선 --- docker-compose.yml | 62 +++++++--- mysql/init-master.sql | 5 + mysql/master-data-source.cnf | 4 + mysql/replica-data-source.cnf | 4 + mysql/setup-replication.sh | 109 ++++++++++++++++++ .../config/DataSourceConfiguration.java | 87 ++++++++++++++ src/main/resources/application.yml | 20 +++- .../RabbitMqPracApplicationTests.java | 2 + 8 files changed, 274 insertions(+), 19 deletions(-) create mode 100644 mysql/init-master.sql create mode 100644 mysql/master-data-source.cnf create mode 100644 mysql/replica-data-source.cnf create mode 100755 mysql/setup-replication.sh create mode 100644 src/main/java/com/rabbitmqprac/config/DataSourceConfiguration.java diff --git a/docker-compose.yml b/docker-compose.yml index ca1f114..80999d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,16 +12,6 @@ services: environment: RABBITMQ_DEFAULT_USER: guest RABBITMQ_DEFAULT_PASS: guest - - mongodb: - image: mongo:latest - container_name: mongodb - environment: - MONGO_INITDB_ROOT_USERNAME: root - MONGO_INITDB_ROOT_PASSWORD: 1234 - MONGO_INITDB_DATABASE: rabbitmq - ports: - - "27017:27017" networks: - my_network @@ -34,16 +24,60 @@ services: networks: - my_network - mysql: + mysql_master: + container_name: rabbit-mysql-master image: mysql:8.0 - container_name: mysql environment: + MYSQL_DATABASE: rabbit + MYSQL_ROOT_HOST: '%' MYSQL_ROOT_PASSWORD: 1234 - MYSQL_DATABASE: rabbitmq 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: + - my_network + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234" ] + timeout: 20s + retries: 10 + + mysql_replica: + container_name: rabbit-mysql-replica + image: mysql:8.0 + environment: + MYSQL_DATABASE: rabbit + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: 1234 + ports: + - "3307:3306" + volumes: + - ./mysql/replica-data-source.cnf:/etc/mysql/conf.d/my.cnf networks: - my_network + depends_on: + mysql_master: + condition: service_healthy + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234" ] + timeout: 20s + retries: 10 + + mysql_replication_setup: + image: mysql:8.0 + container_name: mysql_replication_setup + volumes: + - ./mysql/setup-replication.sh:/setup-replication.sh + command: [ "/bin/bash", "/setup-replication.sh" ] + networks: + - my_network + depends_on: + mysql_master: + condition: service_healthy + mysql_replica: + condition: service_healthy + restart: "no" nginx: image: nginx:latest @@ -55,7 +89,7 @@ services: - ./nginx/config/nginx.conf:/etc/nginx/conf.d/default.conf - ./nginx/ssl:/etc/nginx/ssl networks: - - my_network + - my_network networks: my_network: diff --git a/mysql/init-master.sql b/mysql/init-master.sql new file mode 100644 index 0000000..fdbf3d1 --- /dev/null +++ b/mysql/init-master.sql @@ -0,0 +1,5 @@ +-- Master DB 초기화 스크립트 +-- 복제용 사용자 생성 +CREATE USER 'replica'@'%' IDENTIFIED WITH mysql_native_password BY '1234'; +GRANT REPLICATION SLAVE ON *.* TO 'replica'@'%'; +FLUSH PRIVILEGES; diff --git a/mysql/master-data-source.cnf b/mysql/master-data-source.cnf new file mode 100644 index 0000000..2239e80 --- /dev/null +++ b/mysql/master-data-source.cnf @@ -0,0 +1,4 @@ +[mysqld] +server-id=1 +log-bin=mysql-bin +binlog-do-db=rabbit diff --git a/mysql/replica-data-source.cnf b/mysql/replica-data-source.cnf new file mode 100644 index 0000000..4331422 --- /dev/null +++ b/mysql/replica-data-source.cnf @@ -0,0 +1,4 @@ +[mysqld] +server-id=2 +log-bin=mysql-bin +read_only=1 diff --git a/mysql/setup-replication.sh b/mysql/setup-replication.sh new file mode 100755 index 0000000..12bb831 --- /dev/null +++ b/mysql/setup-replication.sh @@ -0,0 +1,109 @@ +#!/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/config/DataSourceConfiguration.java b/src/main/java/com/rabbitmqprac/config/DataSourceConfiguration.java new file mode 100644 index 0000000..b382cad --- /dev/null +++ b/src/main/java/com/rabbitmqprac/config/DataSourceConfiguration.java @@ -0,0 +1,87 @@ +package com.rabbitmqprac.config; + +import com.zaxxer.hikari.HikariDataSource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import javax.sql.DataSource; +import java.util.HashMap; + +@Slf4j +@Profile("!test") +@Configuration +public class DataSourceConfiguration { + + private static final String MASTER_DATA_SOURCE = "MASTER"; + private static final String REPLICA_DATA_SOURCE = "REPLICA"; + + @Bean + @Qualifier(MASTER_DATA_SOURCE) + @ConfigurationProperties(prefix = "spring.datasource.master") + public DataSource masterDataSource() { + HikariDataSource dataSource = DataSourceBuilder + .create() + .type(HikariDataSource.class) + .build(); + dataSource.setPoolName(MASTER_DATA_SOURCE); + return dataSource; + } + + @Bean + @Qualifier(REPLICA_DATA_SOURCE) + @ConfigurationProperties(prefix = "spring.datasource.replica") + public DataSource replicaDataSource() { + HikariDataSource dataSource = DataSourceBuilder + .create() + .type(HikariDataSource.class) + .build(); + dataSource.setPoolName(REPLICA_DATA_SOURCE); + return dataSource; + } + + @Bean + public DataSource routingDataSource( + @Qualifier(MASTER_DATA_SOURCE) DataSource masterDataSource, + @Qualifier(REPLICA_DATA_SOURCE) DataSource replicaDataSource + ) { + RoutingDataSource routingDataSource = new RoutingDataSource(); + + HashMap dataSourceMap = new HashMap<>(); + dataSourceMap.put(MASTER_DATA_SOURCE, masterDataSource); + dataSourceMap.put(REPLICA_DATA_SOURCE, replicaDataSource); + + routingDataSource.setTargetDataSources(dataSourceMap); + routingDataSource.setDefaultTargetDataSource(masterDataSource); + + return routingDataSource; + } + + @Bean + @Primary + public DataSource dataSource( + @Qualifier(MASTER_DATA_SOURCE) DataSource masterDataSource, + @Qualifier(REPLICA_DATA_SOURCE) DataSource replicaDataSource + ) { + DataSource determinedDataSource = routingDataSource(masterDataSource, replicaDataSource); + return new LazyConnectionDataSourceProxy(determinedDataSource); + } + + @Slf4j + public static class RoutingDataSource extends AbstractRoutingDataSource { + @Override + protected Object determineCurrentLookupKey() { + String lookupKey = TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? REPLICA_DATA_SOURCE : MASTER_DATA_SOURCE; + log.debug("Current DataSource type: {}", lookupKey); + return lookupKey; + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index afb5e81..adce61d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,14 +3,24 @@ spring: init: mode: always datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://localhost:3306/rabbitmq?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: root - password: 1234 + master: + driver-class-name: com.mysql.cj.jdbc.Driver + jdbc-url: jdbc:mysql://localhost:3306/rabbit?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: root + password: 1234 + replica: + driver-class-name: com.mysql.cj.jdbc.Driver + jdbc-url: jdbc:mysql://localhost:3307/rabbit?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: root + password: 1234 jpa: hibernate: ddl-auto: update defer-datasource-initialization: true + database-platform: org.hibernate.dialect.MySQLDialect + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect data: mongodb: @@ -60,4 +70,4 @@ logging: org.springframework.orm: TRACE org.springframework.transaction: TRACE com.zaxxer.hikari: TRACE - com.mysql.cj.jdbc: TRACE \ No newline at end of file + com.mysql.cj.jdbc: TRACE diff --git a/src/test/java/com/rabbitmqprac/RabbitMqPracApplicationTests.java b/src/test/java/com/rabbitmqprac/RabbitMqPracApplicationTests.java index 975102b..68d1680 100644 --- a/src/test/java/com/rabbitmqprac/RabbitMqPracApplicationTests.java +++ b/src/test/java/com/rabbitmqprac/RabbitMqPracApplicationTests.java @@ -3,7 +3,9 @@ import com.rabbitmqprac.common.container.MySQLTestContainer; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") @SpringBootTest class RabbitMqPracApplicationTests extends MySQLTestContainer { From 86f90474bbf3b4cab61221146c01b46f8f4eb1a4 Mon Sep 17 00:00:00 2001 From: LSH <104637774+lsh2613@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:00:17 +0900 Subject: [PATCH 2/9] =?UTF-8?q?Feat:=20oauth=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: nickname 검증 어노테이션 적용 * refactor: 메소드 추출 * refactor: UserFixture에 nickname 추가 * feat: oauth 회원가입/로그인 기능 * test: oauth 회원가입/로그인 기능 * chore: init.sql 개선 * refactor: oauth 관련 접근 권한 endpoint 추가 * chore: checkstyle OIDC, RSA 약어 허용 추가 * chore: 변수명 네이밍 규칙 한 글자 허용되도록 수정 * fix: signIn 테스트 NPE 해결 * refactor: OauthServiceTest 코드 개선 --- build.gradle | 4 +- checkstyle/config/rules.xml | 4 +- .../controller/OauthController.java | 53 ++++++ .../dto/auth/req/AuthSignUpReq.java | 7 +- .../dto/oauth/req/OauthSignInReq.java | 9 + .../dto/oauth/req/OauthSignUpReq.java | 14 ++ .../dto/user/req/NicknameUpdateReq.java | 4 +- .../com/rabbitmqprac/config/OauthConfig.java | 16 ++ .../rabbitmqprac/config/WebClientConfig.java | 72 +++++++ .../context/auth/service/AuthService.java | 2 +- .../oauth/exception/OauthErrorCode.java | 38 ++++ .../oauth/exception/OauthErrorException.java | 14 ++ .../context/oauth/service/OauthService.java | 68 +++++++ .../context/user/dto/req/UserCreateReq.java | 6 +- .../context/user/service/UserService.java | 17 +- .../oauth/constant/OauthProvider.java | 23 +++ .../persistence/oauth/entity/Oauth.java | 50 +++++ .../oauth/repository/OauthRepository.java | 13 ++ .../domain/persistence/user/entity/User.java | 16 +- .../global/annotation/Nickname.java | 24 +++ .../global/exception/payload/DomainCode.java | 3 +- .../global/helper/OauthHelper.java | 93 +++++++++ .../global/util/OauthRequestBodyUtil.java | 26 +++ .../global/validator/NicknameValidator.java | 16 ++ .../infra/oauth/client/GoogleOidcClient.java | 41 ++++ .../infra/oauth/client/KakaoOidcClient.java | 43 +++++ .../infra/oauth/client/OauthClient.java | 8 + .../infra/oauth/client/OauthOidcClient.java | 7 + .../infra/oauth/dto/OauthTokenRes.java | 21 +++ .../infra/oauth/dto/OidcDecodePayload.java | 12 ++ .../infra/oauth/dto/OidcPublicKey.java | 11 ++ .../infra/oauth/dto/OidcPublicKeyRes.java | 12 ++ .../properties/GoogleOidcProperties.java | 17 ++ .../oauth/properties/KakaoOidcProperties.java | 17 ++ .../properties/OauthOidcClientProperties.java | 15 ++ .../oauth/provider/OauthOidcProvider.java | 26 +++ .../oauth/provider/OauthOidcProviderImpl.java | 144 ++++++++++++++ .../security/constant/WebSecurityUrls.java | 4 +- src/main/resources/application.yml | 22 +++ src/main/resources/data.sql | 12 +- .../common/fixture/UserFixture.java | 7 +- .../rabbitmqprac/service/AuthServiceTest.java | 14 +- .../service/OauthServiceTest.java | 177 ++++++++++++++++++ .../rabbitmqprac/service/UserServiceTest.java | 14 +- 44 files changed, 1177 insertions(+), 39 deletions(-) create mode 100644 src/main/java/com/rabbitmqprac/application/controller/OauthController.java create mode 100644 src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignInReq.java create mode 100644 src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignUpReq.java create mode 100644 src/main/java/com/rabbitmqprac/config/OauthConfig.java create mode 100644 src/main/java/com/rabbitmqprac/config/WebClientConfig.java create mode 100644 src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorCode.java create mode 100644 src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorException.java create mode 100644 src/main/java/com/rabbitmqprac/domain/context/oauth/service/OauthService.java create mode 100644 src/main/java/com/rabbitmqprac/domain/persistence/oauth/constant/OauthProvider.java create mode 100644 src/main/java/com/rabbitmqprac/domain/persistence/oauth/entity/Oauth.java create mode 100644 src/main/java/com/rabbitmqprac/domain/persistence/oauth/repository/OauthRepository.java create mode 100644 src/main/java/com/rabbitmqprac/global/annotation/Nickname.java create mode 100644 src/main/java/com/rabbitmqprac/global/helper/OauthHelper.java create mode 100644 src/main/java/com/rabbitmqprac/global/util/OauthRequestBodyUtil.java create mode 100644 src/main/java/com/rabbitmqprac/global/validator/NicknameValidator.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/client/GoogleOidcClient.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/client/KakaoOidcClient.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/client/OauthClient.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/client/OauthOidcClient.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/dto/OauthTokenRes.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcDecodePayload.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKey.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKeyRes.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/properties/GoogleOidcProperties.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/properties/KakaoOidcProperties.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/properties/OauthOidcClientProperties.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProvider.java create mode 100644 src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProviderImpl.java create mode 100644 src/test/java/com/rabbitmqprac/service/OauthServiceTest.java diff --git a/build.gradle b/build.gradle index 413cdb5..13ecd79 100644 --- a/build.gradle +++ b/build.gradle @@ -70,13 +70,15 @@ dependencies { // Apache Commons Lang3 implementation 'org.apache.commons:commons-lang3:3.12.0' + + // WebClient + implementation 'org.springframework.boot:spring-boot-starter-webflux' } tasks.named('test') { useJUnitPlatform() } - tasks.withType(Checkstyle){ reports { xml.required = true diff --git a/checkstyle/config/rules.xml b/checkstyle/config/rules.xml index 4f64efa..3e9622a 100644 --- a/checkstyle/config/rules.xml +++ b/checkstyle/config/rules.xml @@ -59,7 +59,7 @@ The following rules in the Naver coding convention cannot be checked by this con - + @@ -97,7 +97,7 @@ The following rules in the Naver coding convention cannot be checked by this con - + diff --git a/src/main/java/com/rabbitmqprac/application/controller/OauthController.java b/src/main/java/com/rabbitmqprac/application/controller/OauthController.java new file mode 100644 index 0000000..794a047 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/controller/OauthController.java @@ -0,0 +1,53 @@ +package com.rabbitmqprac.application.controller; + +import com.rabbitmqprac.application.dto.oauth.req.OauthSignInReq; +import com.rabbitmqprac.application.dto.oauth.req.OauthSignUpReq; +import com.rabbitmqprac.domain.context.oauth.service.OauthService; +import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider; +import com.rabbitmqprac.global.util.CookieUtil; +import com.rabbitmqprac.infra.security.jwt.Jwts; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.util.Map; + +@RequiredArgsConstructor +@RestController +public class OauthController { + private final OauthService oauthService; + + @PostMapping("/oauth/sign-in") + @PreAuthorize("isAnonymous()") + public ResponseEntity signIn(@RequestParam OauthProvider oauthProvider, @RequestBody @Validated OauthSignInReq req) { + return createAuthenticatedResponse(oauthService.signIn(oauthProvider, req)); + } + + @PostMapping("/oauth/sign-up") + @PreAuthorize("isAnonymous()") + public ResponseEntity signUp(@RequestParam OauthProvider oauthProvider, @RequestBody @Validated OauthSignUpReq req) { + return createAuthenticatedResponse(oauthService.signUp(oauthProvider, req)); + } + + private ResponseEntity createAuthenticatedResponse(Pair userInfo) { + ResponseCookie cookie = CookieUtil.createCookie( + "refreshToken", userInfo.getValue().refreshToken(), Duration.ofDays(7).toSeconds() + ); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .header(HttpHeaders.AUTHORIZATION, userInfo.getValue().accessToken()) + .body( + Map.of("userId", userInfo.getKey()) + ); + } +} diff --git a/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignUpReq.java b/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignUpReq.java index fca1145..8ae96b3 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignUpReq.java +++ b/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignUpReq.java @@ -1,13 +1,17 @@ package com.rabbitmqprac.application.dto.auth.req; +import com.rabbitmqprac.global.annotation.Nickname; import com.rabbitmqprac.global.annotation.Password; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import org.springframework.security.crypto.password.PasswordEncoder; public record AuthSignUpReq( + @NotBlank(message = "닉네임을 입력해주세요") + @Nickname + String nickname, @NotBlank(message = "아이디를 입력해주세요") - @Pattern(regexp = "^[a-z0-9-_.]{5,20}$", message = "영문 소문자, 숫자, 특수기호 (-), (_), (.) 만 사용하여, 5~20자의 아이디를 입력해 주세요") + @Pattern(regexp = "^[a-z0-9-_.]{5,20}$", message = "영문 소문자, 숫자만 사용하여, 5~20자의 아이디를 입력해 주세요") String username, @NotBlank(message = "비밀번호를 입력해주세요") @Password(message = "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") @@ -20,4 +24,3 @@ public String getEncodedPassword(PasswordEncoder bCryptPasswordEncoder) { return bCryptPasswordEncoder.encode(password); } } - diff --git a/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignInReq.java b/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignInReq.java new file mode 100644 index 0000000..c53dee6 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignInReq.java @@ -0,0 +1,9 @@ +package com.rabbitmqprac.application.dto.oauth.req; + +import jakarta.validation.constraints.NotBlank; + +public record OauthSignInReq( + @NotBlank(message = "OIDC CODE 필수 입력값입니다.") + String code +) { +} diff --git a/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignUpReq.java b/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignUpReq.java new file mode 100644 index 0000000..ce91d43 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignUpReq.java @@ -0,0 +1,14 @@ +package com.rabbitmqprac.application.dto.oauth.req; + +import com.rabbitmqprac.global.annotation.Nickname; +import jakarta.validation.constraints.NotBlank; + +public record OauthSignUpReq( + @NotBlank(message = "OIDC CODE는 필수 입력값입니다.") + String code, + @NotBlank(message = "닉네임을 입력해주세요") + @Nickname + String nickname +) { + +} diff --git a/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameUpdateReq.java b/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameUpdateReq.java index ee55f6a..d032594 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameUpdateReq.java +++ b/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameUpdateReq.java @@ -1,11 +1,11 @@ package com.rabbitmqprac.application.dto.user.req; +import com.rabbitmqprac.global.annotation.Nickname; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; public record NicknameUpdateReq( @NotBlank(message = "닉네임을 입력해주세요") - @Pattern(regexp = "^[가-힣a-zA-Z]{2,8}$", message = "한글과 영문 대, 소문자만 가능해요") + @Nickname String nickname ) { } diff --git a/src/main/java/com/rabbitmqprac/config/OauthConfig.java b/src/main/java/com/rabbitmqprac/config/OauthConfig.java new file mode 100644 index 0000000..8daf416 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/config/OauthConfig.java @@ -0,0 +1,16 @@ +package com.rabbitmqprac.config; + +import com.rabbitmqprac.infra.oauth.properties.GoogleOidcProperties; +import com.rabbitmqprac.infra.oauth.properties.KakaoOidcProperties; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({ + ServerProperties.class, + GoogleOidcProperties.class, + KakaoOidcProperties.class +}) +public class OauthConfig { +} diff --git a/src/main/java/com/rabbitmqprac/config/WebClientConfig.java b/src/main/java/com/rabbitmqprac/config/WebClientConfig.java new file mode 100644 index 0000000..ecceaa3 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/config/WebClientConfig.java @@ -0,0 +1,72 @@ +package com.rabbitmqprac.config; + +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.http.codec.LoggingCodecSupport; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; + +@Slf4j +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient() { + ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder() + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024 * 50)) + .build(); + exchangeStrategies + .messageWriters().stream() + .filter(LoggingCodecSupport.class::isInstance) + .forEach(writer -> ((LoggingCodecSupport) writer).setEnableLoggingRequestDetails(true)); + + return WebClient.builder() + .clientConnector( + new ReactorClientHttpConnector( + HttpClient + .create() + .tcpConfiguration(client -> + client.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 120_000) + .doOnConnected(conn -> conn.addHandlerLast(new ReadTimeoutHandler(180)) + .addHandlerLast(new WriteTimeoutHandler(180)) + ) + ) + ) + ) + .exchangeStrategies(exchangeStrategies) + .filter(ExchangeFilterFunction.ofRequestProcessor( + clientRequest -> { + log.debug("Request: {} {}", clientRequest.method(), clientRequest.url()); + clientRequest.headers().forEach((name, values) -> values.forEach(value -> log.debug("{} : {}", name, value))); + if (clientRequest.body() != null) { + log.debug("Request Body: {}", clientRequest.body()); + } + return Mono.just(clientRequest); + } + )) + .filter(ExchangeFilterFunction.ofResponseProcessor( + clientResponse -> { + clientResponse.headers().asHttpHeaders().forEach((name, values) -> values.forEach(value -> log.debug("{} : {}", name, value))); + return Mono.just(clientResponse); + } + )) + .defaultStatusHandler( + HttpStatusCode::isError, + clientResponse -> clientResponse.bodyToMono(String.class) + .flatMap(body -> { + log.error("WebClient error: status={}, body={}", clientResponse.statusCode(), body); + return Mono.error(new RuntimeException("WebClient error: " + body)); + }) + ) + .build(); + } +} diff --git a/src/main/java/com/rabbitmqprac/domain/context/auth/service/AuthService.java b/src/main/java/com/rabbitmqprac/domain/context/auth/service/AuthService.java index 5c69a58..241b800 100644 --- a/src/main/java/com/rabbitmqprac/domain/context/auth/service/AuthService.java +++ b/src/main/java/com/rabbitmqprac/domain/context/auth/service/AuthService.java @@ -29,7 +29,7 @@ public Pair signUp(AuthSignUpReq req) { throw new AuthErrorException(AuthErrorCode.PASSWORD_CONFIRM_MISMATCH); User user = userService.saveUserWithEncryptedPassword( - UserCreateReq.of(req.username(), req.getEncodedPassword(bCryptPasswordEncoder)) + UserCreateReq.of(req.nickname(), req.username(), req.getEncodedPassword(bCryptPasswordEncoder)) ); return Pair.of(user.getId(), jwtHelper.createToken(user)); diff --git a/src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorCode.java b/src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorCode.java new file mode 100644 index 0000000..124a152 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorCode.java @@ -0,0 +1,38 @@ +package com.rabbitmqprac.domain.context.oauth.exception; + +import com.rabbitmqprac.global.exception.payload.BaseErrorCode; +import com.rabbitmqprac.global.exception.payload.CausedBy; +import com.rabbitmqprac.global.exception.payload.DomainCode; +import com.rabbitmqprac.global.exception.payload.ReasonCode; +import com.rabbitmqprac.global.exception.payload.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum OauthErrorCode implements BaseErrorCode { + /* 400 BAD_REQUEST */ + MISSING_ISS(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "iss 값이 존재하지 않습니다."), + MISSING_AUD(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "aud 값이 존재하지 않습니다."), + MISSING_NONCE(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "nonce 값이 존재하지 않습니다."), + + INVALID_ISS(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "iss 값이 유효하지 않습니다."), + INVALID_AUD(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "aud 값이 유효하지 않습니다."), + INVALID_NONCE(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "nonce 값이 유효하지 않습니다."), + + /* 409 CONFLICT */ + CONFLICT(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 회원가입된 유저입니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + private final DomainCode domainCode = DomainCode.OAUTH; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode, domainCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorException.java b/src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorException.java new file mode 100644 index 0000000..1680dfb --- /dev/null +++ b/src/main/java/com/rabbitmqprac/domain/context/oauth/exception/OauthErrorException.java @@ -0,0 +1,14 @@ +package com.rabbitmqprac.domain.context.oauth.exception; + +import com.rabbitmqprac.global.exception.GlobalErrorException; +import lombok.Getter; + +@Getter +public class OauthErrorException extends GlobalErrorException { + private final OauthErrorCode errorCode; + + public OauthErrorException(OauthErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/rabbitmqprac/domain/context/oauth/service/OauthService.java b/src/main/java/com/rabbitmqprac/domain/context/oauth/service/OauthService.java new file mode 100644 index 0000000..c4f569d --- /dev/null +++ b/src/main/java/com/rabbitmqprac/domain/context/oauth/service/OauthService.java @@ -0,0 +1,68 @@ +package com.rabbitmqprac.domain.context.oauth.service; + +import com.rabbitmqprac.application.dto.oauth.req.OauthSignInReq; +import com.rabbitmqprac.application.dto.oauth.req.OauthSignUpReq; +import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorCode; +import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorException; +import com.rabbitmqprac.domain.context.user.service.UserService; +import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider; +import com.rabbitmqprac.domain.persistence.oauth.entity.Oauth; +import com.rabbitmqprac.domain.persistence.oauth.repository.OauthRepository; +import com.rabbitmqprac.domain.persistence.user.entity.User; +import com.rabbitmqprac.global.helper.JwtHelper; +import com.rabbitmqprac.global.helper.OauthHelper; +import com.rabbitmqprac.infra.oauth.dto.OauthTokenRes; +import com.rabbitmqprac.infra.oauth.dto.OidcDecodePayload; +import com.rabbitmqprac.infra.security.jwt.Jwts; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@RequiredArgsConstructor +@Service +public class OauthService { + private final OauthHelper oauthHelper; + private final OauthRepository oauthRepository; + private final UserService userService; + private final JwtHelper jwtHelper; + + @Transactional(readOnly = true) + public Pair signIn(OauthProvider oauthProvider, OauthSignInReq req) { + String code = URLDecoder.decode(req.code(), StandardCharsets.UTF_8); + OauthTokenRes tokenRes = oauthHelper.getIdToken(oauthProvider, code); + OidcDecodePayload payload = oauthHelper.getOidcDecodedPayload(oauthProvider, tokenRes.idToken()); + log.debug("payload : {}", payload); + + User user = oauthRepository.findBySubAndOauthProvider(payload.sub(), oauthProvider) + .map(oauth -> oauth.getUser()) + .orElse(null); + + return (user != null) + ? Pair.of(user.getId(), jwtHelper.createToken(user)) + : Pair.of(-1L, null); + } + + @Transactional + public Pair signUp(OauthProvider oauthProvider, OauthSignUpReq req) { + String code = URLDecoder.decode(req.code(), StandardCharsets.UTF_8); + OauthTokenRes tokenRes = oauthHelper.getIdToken(oauthProvider, code); + OidcDecodePayload payload = oauthHelper.getOidcDecodedPayload(oauthProvider, tokenRes.idToken()); + + if (oauthRepository.existsBySubAndOauthProvider(payload.sub(), oauthProvider)) { + throw new OauthErrorException(OauthErrorCode.CONFLICT); + } + + userService.validateNicknameDuplication(req.nickname()); + + User user = userService.create(req.nickname()); + oauthRepository.save(Oauth.of(oauthProvider, payload.sub(), user)); + + return Pair.of(user.getId(), jwtHelper.createToken(user)); + } +} diff --git a/src/main/java/com/rabbitmqprac/domain/context/user/dto/req/UserCreateReq.java b/src/main/java/com/rabbitmqprac/domain/context/user/dto/req/UserCreateReq.java index 6334196..d1b769b 100644 --- a/src/main/java/com/rabbitmqprac/domain/context/user/dto/req/UserCreateReq.java +++ b/src/main/java/com/rabbitmqprac/domain/context/user/dto/req/UserCreateReq.java @@ -4,15 +4,17 @@ import com.rabbitmqprac.domain.persistence.user.entity.User; public record UserCreateReq( + String nickname, String username, String password ) { - public static UserCreateReq of(String username, String password) { - return new UserCreateReq(username, password); + public static UserCreateReq of(String nickname, String username, String password) { + return new UserCreateReq(nickname, username, password); } public User toEntity() { return User.of( + nickname, username, password, Role.USER diff --git a/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java b/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java index 2e84251..5e0beb9 100644 --- a/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java +++ b/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java @@ -7,9 +7,9 @@ import com.rabbitmqprac.domain.context.user.dto.req.UserCreateReq; import com.rabbitmqprac.domain.context.user.exception.UserErrorCode; import com.rabbitmqprac.domain.context.user.exception.UserErrorException; +import com.rabbitmqprac.domain.persistence.user.entity.Role; import com.rabbitmqprac.domain.persistence.user.entity.User; import com.rabbitmqprac.domain.persistence.user.repository.UserRepository; -import com.rabbitmqprac.global.exception.GlobalErrorException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -70,14 +70,25 @@ public void updateNickname(Long userId, NicknameUpdateReq req) { User user = userRepository.findById(userId) .orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - if (userRepository.existsByNickname(req.nickname())) - throw new UserErrorException(UserErrorCode.CONFLICT_USERNAME); + validateNicknameDuplication(req.nickname()); user.updateNickname(req.nickname()); } + @Transactional(readOnly = true) + public void validateNicknameDuplication(String nickname) { + if (userRepository.existsByNickname(nickname)) + throw new UserErrorException(UserErrorCode.CONFLICT_USERNAME); + } + @Transactional(readOnly = true) public Boolean isDuplicatedUsername(String username) { return userRepository.existsByUsername(username); } + + @Transactional + public User create(String nickname) { + User user = User.of(nickname, Role.USER); + return userRepository.save(user); + } } diff --git a/src/main/java/com/rabbitmqprac/domain/persistence/oauth/constant/OauthProvider.java b/src/main/java/com/rabbitmqprac/domain/persistence/oauth/constant/OauthProvider.java new file mode 100644 index 0000000..3cdd1f3 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/domain/persistence/oauth/constant/OauthProvider.java @@ -0,0 +1,23 @@ +package com.rabbitmqprac.domain.persistence.oauth.constant; + +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum OauthProvider { + KAKAO, + GOOGLE, + APPLE; + + @JsonCreator + public OauthProvider fromString(String type) { + return valueOf(type.toUpperCase()); + } + + @Override + public String toString() { + return name().toLowerCase(); + } +} diff --git a/src/main/java/com/rabbitmqprac/domain/persistence/oauth/entity/Oauth.java b/src/main/java/com/rabbitmqprac/domain/persistence/oauth/entity/Oauth.java new file mode 100644 index 0000000..7452dea --- /dev/null +++ b/src/main/java/com/rabbitmqprac/domain/persistence/oauth/entity/Oauth.java @@ -0,0 +1,50 @@ +package com.rabbitmqprac.domain.persistence.oauth.entity; + +import com.rabbitmqprac.domain.persistence.common.model.DateAuditable; +import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider; +import com.rabbitmqprac.domain.persistence.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@Table(name = "oauth") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class Oauth extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private OauthProvider oauthProvider; + @Column(nullable = false) + private String sub; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + public static Oauth of(OauthProvider provider, String oauthId, User user) { + Oauth oauth = new Oauth(); + oauth.oauthProvider = provider; + oauth.sub = oauthId; + oauth.user = user; + return oauth; + } +} diff --git a/src/main/java/com/rabbitmqprac/domain/persistence/oauth/repository/OauthRepository.java b/src/main/java/com/rabbitmqprac/domain/persistence/oauth/repository/OauthRepository.java new file mode 100644 index 0000000..5929253 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/domain/persistence/oauth/repository/OauthRepository.java @@ -0,0 +1,13 @@ +package com.rabbitmqprac.domain.persistence.oauth.repository; + +import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider; +import com.rabbitmqprac.domain.persistence.oauth.entity.Oauth; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OauthRepository extends JpaRepository { + Optional findBySubAndOauthProvider(String sub, OauthProvider oauthProvider); + + boolean existsBySubAndOauthProvider(String sub, OauthProvider oauthProvider); +} diff --git a/src/main/java/com/rabbitmqprac/domain/persistence/user/entity/User.java b/src/main/java/com/rabbitmqprac/domain/persistence/user/entity/User.java index f8cc354..dbcdb01 100644 --- a/src/main/java/com/rabbitmqprac/domain/persistence/user/entity/User.java +++ b/src/main/java/com/rabbitmqprac/domain/persistence/user/entity/User.java @@ -11,6 +11,7 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -21,20 +22,27 @@ public class User extends DateAuditable { @Column(name = "user_id") private Long id; - @Column(nullable = false, unique = true) + @Column(unique = true) private String username; - @Column(nullable = false) + @ColumnDefault("NULL") private String password; @Column(nullable = false) private String nickname; @Enumerated(EnumType.STRING) private Role role; - public static User of(String username, String password, Role role) { + public static User of(String nickname, String username, String password, Role role) { User user = new User(); + user.nickname = nickname; user.username = username; user.password = password; - user.nickname = username; + user.role = role; + return user; + } + + public static User of(String nickname, Role role) { + User user = new User(); + user.nickname = nickname; user.role = role; return user; } diff --git a/src/main/java/com/rabbitmqprac/global/annotation/Nickname.java b/src/main/java/com/rabbitmqprac/global/annotation/Nickname.java new file mode 100644 index 0000000..9e8f435 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/annotation/Nickname.java @@ -0,0 +1,24 @@ +package com.rabbitmqprac.global.annotation; + +import com.rabbitmqprac.global.validator.NicknameValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Documented +@Constraint(validatedBy = {NicknameValidator.class}) +@Target({FIELD}) +@Retention(RUNTIME) +public @interface Nickname { + String message() default "한글, 영문, 숫자만 사용하여, 2~10자의 닉네임을 입력해 주세요"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/rabbitmqprac/global/exception/payload/DomainCode.java b/src/main/java/com/rabbitmqprac/global/exception/payload/DomainCode.java index b252472..ead7400 100644 --- a/src/main/java/com/rabbitmqprac/global/exception/payload/DomainCode.java +++ b/src/main/java/com/rabbitmqprac/global/exception/payload/DomainCode.java @@ -8,7 +8,8 @@ public enum DomainCode implements BaseCode { JWT(1), USER(2), USER_SESSION(3), - CHAT_ROOM(4); + CHAT_ROOM(4), + OAUTH(5); private final int code; diff --git a/src/main/java/com/rabbitmqprac/global/helper/OauthHelper.java b/src/main/java/com/rabbitmqprac/global/helper/OauthHelper.java new file mode 100644 index 0000000..2200f92 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/helper/OauthHelper.java @@ -0,0 +1,93 @@ +package com.rabbitmqprac.global.helper; + +import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider; +import com.rabbitmqprac.global.annotation.Helper; +import com.rabbitmqprac.global.util.OauthRequestBodyUtil; +import com.rabbitmqprac.infra.oauth.client.GoogleOidcClient; +import com.rabbitmqprac.infra.oauth.client.KakaoOidcClient; +import com.rabbitmqprac.infra.oauth.client.OauthOidcClient; +import com.rabbitmqprac.infra.oauth.dto.OauthTokenRes; +import com.rabbitmqprac.infra.oauth.dto.OidcDecodePayload; +import com.rabbitmqprac.infra.oauth.dto.OidcPublicKey; +import com.rabbitmqprac.infra.oauth.dto.OidcPublicKeyRes; +import com.rabbitmqprac.infra.oauth.properties.GoogleOidcProperties; +import com.rabbitmqprac.infra.oauth.properties.KakaoOidcProperties; +import com.rabbitmqprac.infra.oauth.properties.OauthOidcClientProperties; +import com.rabbitmqprac.infra.oauth.provider.OauthOidcProvider; +import org.springframework.util.MultiValueMap; + +import java.util.Map; + +@Helper +public class OauthHelper { + private final OauthOidcProvider oauthOidcProvider; + private final Map> oauthOidcClients; + + public OauthHelper( + OauthOidcProvider oauthOidcProvider, + KakaoOidcClient kakaoOauthClient, + GoogleOidcClient googleOauthClient, + KakaoOidcProperties kakaoOauthClientProperties, + GoogleOidcProperties googleOauthClientProperties + ) { + this.oauthOidcProvider = oauthOidcProvider; + oauthOidcClients = Map.of( + OauthProvider.KAKAO, Map.of(kakaoOauthClient, kakaoOauthClientProperties), + OauthProvider.GOOGLE, Map.of(googleOauthClient, googleOauthClientProperties) + ); + } + + /** + * Provider에 따라 Client와 Properties를 선택하고 Odic public key 정보를 가져와서 ID Token의 payload를 추출하는 메서드 + * + * @param oauthProvider : {@link OauthProvider} + * @param idToken : code + * @return OIDCDecodePayload : ID Token의 payload + */ + public OidcDecodePayload getOidcDecodedPayload(OauthProvider oauthProvider, String idToken) { + OauthOidcClient client = oauthOidcClients.get(oauthProvider).keySet().iterator().next(); + OauthOidcClientProperties properties = oauthOidcClients.get(oauthProvider).values().iterator().next(); + OidcPublicKeyRes response = client.getOidcPublicKey(); + + return getPayloadFromIdToken(idToken, properties.getIssuer(), properties.getClientId(), properties.getNonce(), response); + } + + /** + * ID Token의 payload를 추출하는 메서드
+ * OAuth 2.0 spec에 따라 ID Token의 유효성 검사 수행
+ * + * @param idToken : code + * @param iss : ID Token을 발급한 provider의 URL + * @param aud : ID Token이 발급된 앱의 앱 키 + * @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열 (Optional, 현재는 사용하지 않음) + * @param response : 공개키 목록 + * @return OIDCDecodePayload : ID Token의 payload + */ + private OidcDecodePayload getPayloadFromIdToken(String idToken, String iss, String aud, String nonce, OidcPublicKeyRes response) { + String kid = getKidFromUnsignedIdToken(idToken, iss, aud, nonce); + + OidcPublicKey key = response.getKeys().stream() + .filter(k -> k.kid().equals(kid)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No matching key found")); + return oauthOidcProvider.getOIDCTokenBody(idToken, key.n(), key.e()); + } + + private String getKidFromUnsignedIdToken(String token, String iss, String aud, String nonce) { + return oauthOidcProvider.getKidFromUnsignedTokenHeader(token, iss, aud, nonce); + } + + public OauthTokenRes getIdToken(OauthProvider oauthProvider, String code) { + OauthOidcClient client = oauthOidcClients.get(oauthProvider).keySet().iterator().next(); + OauthOidcClientProperties properties = oauthOidcClients.get(oauthProvider).values().iterator().next(); + + MultiValueMap body = OauthRequestBodyUtil.createIdTokenRequestBody( + code, + properties.getClientId(), + properties.getClientSecret(), + properties.getRedirectUri() + ); + + return client.getIdToken(body); + } +} diff --git a/src/main/java/com/rabbitmqprac/global/util/OauthRequestBodyUtil.java b/src/main/java/com/rabbitmqprac/global/util/OauthRequestBodyUtil.java new file mode 100644 index 0000000..0f56fbb --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/util/OauthRequestBodyUtil.java @@ -0,0 +1,26 @@ +package com.rabbitmqprac.global.util; + + +import com.rabbitmqprac.global.annotation.Util; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +@Util +public final class OauthRequestBodyUtil { + private static final String GRANT_TYPE = "authorization_code"; + + public static MultiValueMap createIdTokenRequestBody( + String code, + String clientId, + String clientSecret, + String redirectUri + ) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("grant_type", GRANT_TYPE); + formData.add("code", code); + formData.add("client_id", clientId); + formData.add("client_secret", clientSecret); + formData.add("redirect_uri", redirectUri); + return formData; + } +} diff --git a/src/main/java/com/rabbitmqprac/global/validator/NicknameValidator.java b/src/main/java/com/rabbitmqprac/global/validator/NicknameValidator.java new file mode 100644 index 0000000..727baf6 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/validator/NicknameValidator.java @@ -0,0 +1,16 @@ +package com.rabbitmqprac.global.validator; + +import com.rabbitmqprac.global.annotation.Nickname; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.regex.Pattern; + +public class NicknameValidator implements ConstraintValidator { + private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[가-힣a-zA-Z0-9]{2,10}$"); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return value != null && PASSWORD_PATTERN.matcher(value).matches(); + } +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/client/GoogleOidcClient.java b/src/main/java/com/rabbitmqprac/infra/oauth/client/GoogleOidcClient.java new file mode 100644 index 0000000..e5e9597 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/client/GoogleOidcClient.java @@ -0,0 +1,41 @@ +package com.rabbitmqprac.infra.oauth.client; + +import com.rabbitmqprac.infra.oauth.dto.OauthTokenRes; +import com.rabbitmqprac.infra.oauth.dto.OidcPublicKeyRes; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; + +@RequiredArgsConstructor +@Component +public class GoogleOidcClient implements OauthOidcClient { + private final WebClient webClient; + + @Value("${oauth2.client.provider.google.jwks-uri}") + private String jwksUri; + @Value("${oauth2.client.provider.google.token-uri}") + private String tokenUri; + + @Override + public OidcPublicKeyRes getOidcPublicKey() { + return webClient.get() + .uri(jwksUri) + .retrieve() + .bodyToMono(OidcPublicKeyRes.class) + .block(); + } + + @Override + public OauthTokenRes getIdToken(MultiValueMap body) { + return webClient.post() + .uri(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .bodyValue(body) + .retrieve() + .bodyToMono(OauthTokenRes.class) + .block(); + } +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/client/KakaoOidcClient.java b/src/main/java/com/rabbitmqprac/infra/oauth/client/KakaoOidcClient.java new file mode 100644 index 0000000..45b5bc1 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/client/KakaoOidcClient.java @@ -0,0 +1,43 @@ +package com.rabbitmqprac.infra.oauth.client; + +import com.rabbitmqprac.infra.oauth.dto.OauthTokenRes; +import com.rabbitmqprac.infra.oauth.dto.OidcPublicKeyRes; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; + + +@RequiredArgsConstructor +@Component +public class KakaoOidcClient implements OauthOidcClient { + private final WebClient webClient; + + @Value("${oauth2.client.provider.kakao.jwks-uri}") + private String jwksUri; + @Value("${oauth2.client.provider.kakao.token-uri}") + private String tokenUri; + + @Override + public OidcPublicKeyRes getOidcPublicKey() { + return webClient.get() + .uri(jwksUri) + .retrieve() + .bodyToMono(OidcPublicKeyRes.class) + .block(); + } + + @Override + public OauthTokenRes getIdToken(MultiValueMap body) { + return webClient.post() + .uri(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .bodyValue(body) + .retrieve() + .bodyToMono(OauthTokenRes.class) + .block(); + } + +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/client/OauthClient.java b/src/main/java/com/rabbitmqprac/infra/oauth/client/OauthClient.java new file mode 100644 index 0000000..3582564 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/client/OauthClient.java @@ -0,0 +1,8 @@ +package com.rabbitmqprac.infra.oauth.client; + +import com.rabbitmqprac.infra.oauth.dto.OauthTokenRes; +import org.springframework.util.MultiValueMap; + +public interface OauthClient { + OauthTokenRes getIdToken(MultiValueMap body); +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/client/OauthOidcClient.java b/src/main/java/com/rabbitmqprac/infra/oauth/client/OauthOidcClient.java new file mode 100644 index 0000000..bb30011 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/client/OauthOidcClient.java @@ -0,0 +1,7 @@ +package com.rabbitmqprac.infra.oauth.client; + +import com.rabbitmqprac.infra.oauth.dto.OidcPublicKeyRes; + +public interface OauthOidcClient extends OauthClient { + OidcPublicKeyRes getOidcPublicKey(); +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/dto/OauthTokenRes.java b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OauthTokenRes.java new file mode 100644 index 0000000..4648236 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OauthTokenRes.java @@ -0,0 +1,21 @@ +package com.rabbitmqprac.infra.oauth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record OauthTokenRes( + @JsonProperty("access_token") + String accessToken, + @JsonProperty("token_type") + String tokenType, + @JsonProperty("refresh_token") + String refreshToken, + @JsonProperty("id_token") + String idToken, + @JsonProperty("expires_in") + int expiresIn, + @JsonProperty("scope") + String scope, + @JsonProperty("refresh_token_expires_in") + int refreshTokenExpiresIn +) { +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcDecodePayload.java b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcDecodePayload.java new file mode 100644 index 0000000..69e1801 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcDecodePayload.java @@ -0,0 +1,12 @@ +package com.rabbitmqprac.infra.oauth.dto; + +public record OidcDecodePayload( + /* issuer */ + String iss, + /* client id */ + String aud, + /* aouth provider account unique id */ + String sub, + String email +) { +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKey.java b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKey.java new file mode 100644 index 0000000..d0deff0 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKey.java @@ -0,0 +1,11 @@ +package com.rabbitmqprac.infra.oauth.dto; + +public record OidcPublicKey( + String kid, + String kty, + String alg, + String use, + String n, + String e +) { +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKeyRes.java b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKeyRes.java new file mode 100644 index 0000000..330d972 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/dto/OidcPublicKeyRes.java @@ -0,0 +1,12 @@ +package com.rabbitmqprac.infra.oauth.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class OidcPublicKeyRes { + List keys; +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/properties/GoogleOidcProperties.java b/src/main/java/com/rabbitmqprac/infra/oauth/properties/GoogleOidcProperties.java new file mode 100644 index 0000000..7a88fba --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/properties/GoogleOidcProperties.java @@ -0,0 +1,17 @@ +package com.rabbitmqprac.infra.oauth.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "oauth2.client.provider.google") +public class GoogleOidcProperties implements OauthOidcClientProperties { + private final String clientId; + private final String clientSecret; + private final String issuer; + private final String jwksUri; + private final String nonce; + private final String redirectUri; +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/properties/KakaoOidcProperties.java b/src/main/java/com/rabbitmqprac/infra/oauth/properties/KakaoOidcProperties.java new file mode 100644 index 0000000..6a3225c --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/properties/KakaoOidcProperties.java @@ -0,0 +1,17 @@ +package com.rabbitmqprac.infra.oauth.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "oauth2.client.provider.kakao") +public class KakaoOidcProperties implements OauthOidcClientProperties { + private final String clientId; + private final String clientSecret; + private final String issuer; + private final String jwksUri; + private final String nonce; + private final String redirectUri; +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/properties/OauthOidcClientProperties.java b/src/main/java/com/rabbitmqprac/infra/oauth/properties/OauthOidcClientProperties.java new file mode 100644 index 0000000..fbd9068 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/properties/OauthOidcClientProperties.java @@ -0,0 +1,15 @@ +package com.rabbitmqprac.infra.oauth.properties; + +public interface OauthOidcClientProperties { + String getJwksUri(); + + String getClientId(); + + String getIssuer(); + + String getNonce(); + + String getClientSecret(); + + String getRedirectUri(); +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProvider.java b/src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProvider.java new file mode 100644 index 0000000..ae0aa90 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProvider.java @@ -0,0 +1,26 @@ +package com.rabbitmqprac.infra.oauth.provider; + +import com.rabbitmqprac.infra.oauth.dto.OidcDecodePayload; + +public interface OauthOidcProvider { + /** + * ID Token의 header에서 kid를 추출하는 메서드 + * + * @param token : code + * @param iss : ID Token을 발급한 OAuth 2.0 제공자의 URL + * @param aud : ID Token이 발급된 앱의 앱 키 + * @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열 + * @return kid : ID Token의 서명에 사용된 공개키의 ID + */ + String getKidFromUnsignedTokenHeader(String token, String iss, String aud, String nonce); + + /** + * ID Token의 payload를 추출하는 메서드 + * + * @param token : code + * @param modulus : 공개키 모듈(n) + * @param exponent : 공개키 지수(e) + * @return OIDCDecodePayload : ID Token의 payload + */ + OidcDecodePayload getOIDCTokenBody(String token, String modulus, String exponent); +} diff --git a/src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProviderImpl.java b/src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProviderImpl.java new file mode 100644 index 0000000..e7bacb6 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/oauth/provider/OauthOidcProviderImpl.java @@ -0,0 +1,144 @@ +package com.rabbitmqprac.infra.oauth.provider; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorCode; +import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorException; +import com.rabbitmqprac.global.util.JwtErrorCodeUtil; +import com.rabbitmqprac.infra.oauth.dto.OidcDecodePayload; +import com.rabbitmqprac.infra.security.exception.JwtErrorCode; +import com.rabbitmqprac.infra.security.exception.JwtErrorException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OauthOidcProviderImpl implements OauthOidcProvider { + private static final String KID = "kid"; + private static final String RSA = "RSA"; + private final ObjectMapper objectMapper; + + @Override + public String getKidFromUnsignedTokenHeader(String token, String iss, String aud, String nonce) { + return getUnsignedTokenClaims(token, iss, aud, nonce).get("header").get(KID); + } + + @Override + public OidcDecodePayload getOIDCTokenBody(String token, String modulus, String exponent) { + Claims body = getOIDCTokenJws(token, modulus, exponent).getPayload(); + String aud = body.getAudience().iterator().next(); // aud가 여러개일 경우 첫 번째 aud를 사용 + + return new OidcDecodePayload( + body.getIssuer(), + aud, + body.getSubject(), + body.get("email", String.class) + ); + } + + /** + * ID Token의 header와 body를 Base64 방식으로 디코딩하는 메서드
+ */ + @SuppressWarnings("unchecked") + private Map> getUnsignedTokenClaims(String token, String iss, String aud, String nonce) { + try { + Base64.Decoder decoder = Base64.getUrlDecoder(); + + String unsignedToken = getUnsignedToken(token); + String headerJson = new String(decoder.decode(unsignedToken.split("\\.")[0])); + String payloadJson = new String(decoder.decode(unsignedToken.split("\\.")[1])); + + Map header = objectMapper.readValue(headerJson, Map.class); + Map payload = objectMapper.readValue(payloadJson, Map.class); + + validatePayload(payload, iss, aud, nonce); + + return Map.of("header", header, "payload", payload); + } catch (JsonProcessingException e) { + log.warn("getUnsignedTokenClaims : Error - {}, {}", e.getClass(), e.getMessage()); + throw new RuntimeException(e); + } + } + + /** + * ID Token의 payload를 검증하는 메서드
+ */ + private void validatePayload(Map payload, String iss, String aud, String nonce) { + if (!payload.containsKey("iss")) { + throw new OauthErrorException(OauthErrorCode.MISSING_ISS); + } + if (!payload.containsKey("aud")) { + throw new OauthErrorException(OauthErrorCode.MISSING_AUD); + } + if (!payload.containsKey("nonce")) { + throw new OauthErrorException(OauthErrorCode.MISSING_NONCE); + } + if (!payload.get("iss").equals(iss)) { + throw new OauthErrorException(OauthErrorCode.INVALID_ISS); + } + if (!payload.get("aud").equals(aud)) { + throw new OauthErrorException(OauthErrorCode.INVALID_AUD); + } + if (!payload.get("nonce").equals(nonce)) { + throw new OauthErrorException(OauthErrorCode.INVALID_NONCE); + } + } + + /** + * Token의 signature를 제거하는 메서드 + */ + private String getUnsignedToken(String token) { + String[] splitToken = token.split("\\."); + if (splitToken.length != 3) throw new JwtErrorException(JwtErrorCode.MALFORMED_TOKEN); + return splitToken[0] + "." + splitToken[1]; + } + + /** + * 공개키로 서명을 검증하는 메서드 + */ + private Jws getOIDCTokenJws(String token, String modulus, String exponent) { + try { + return Jwts.parser() + .verifyWith(getRSAPublicKey(modulus, exponent)) + .build() + .parseSignedClaims(token); + } catch (JwtException e) { + final JwtErrorCode errorCode = JwtErrorCodeUtil.determineErrorCode(e, JwtErrorCode.FAILED_AUTHENTICATION); + + log.warn("getOIDCTokenJws : Error code : {}, Error - {}, {}", errorCode, e.getClass(), e.getMessage()); + throw new JwtErrorException(errorCode); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + log.warn("getOIDCTokenJws : Error - {}, {}", e.getClass(), e.getMessage()); + throw new JwtErrorException(JwtErrorCode.MALFORMED_TOKEN); + } + } + + /** + * n, e 조합으로 공개키를 생성하는 메서드 + */ + private PublicKey getRSAPublicKey(String modulus, String exponent) throws NoSuchAlgorithmException, InvalidKeySpecException { + KeyFactory keyFactory = KeyFactory.getInstance(RSA); + byte[] decodeN = Base64.getUrlDecoder().decode(modulus); + byte[] decodeE = Base64.getUrlDecoder().decode(exponent); + BigInteger n = new BigInteger(1, decodeN); + BigInteger e = new BigInteger(1, decodeE); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); + return keyFactory.generatePublic(publicKeySpec); + } +} diff --git a/src/main/java/com/rabbitmqprac/infra/security/constant/WebSecurityUrls.java b/src/main/java/com/rabbitmqprac/infra/security/constant/WebSecurityUrls.java index 05c4f76..725c72b 100644 --- a/src/main/java/com/rabbitmqprac/infra/security/constant/WebSecurityUrls.java +++ b/src/main/java/com/rabbitmqprac/infra/security/constant/WebSecurityUrls.java @@ -1,9 +1,9 @@ package com.rabbitmqprac.infra.security.constant; public final class WebSecurityUrls { - public static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/chat-rooms", "/users/username", "/test"}; + public static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/chat-rooms", "/users/nickname"}; public static final String[] PUBLIC_ENDPOINTS = {"/"}; - public static final String[] ANONYMOUS_ENDPOINTS = {"/auth/sign-in", "/auth/sign-up"}; + public static final String[] ANONYMOUS_ENDPOINTS = {"/auth/sign-in", "/auth/sign-up", "/oauth/sign-up", "/oauth/sign-in"}; public static final String[] AUTHENTICATED_ENDPOINTS = {"/"}; public static final String[] PUBLIC_STOMP_ENDPOINTS = {"/chat/inbox"}; public static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger"}; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index adce61d..9a26064 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,6 @@ spring: + config: + import: optional:file:.env[.properties] sql: init: mode: always @@ -31,6 +33,26 @@ spring: host: localhost port: 6379 +oauth2: + client: + provider: + kakao: + client-id: ${KAKAO_CLIENT_ID:57e328d97dc0c7b53898e2e79082f23c} + client-secret: ${KAKAO_CLIENT_SECRET:exampleSecretKeyForRabbit} + jwks-uri: ${KAKAO_JWKS_URI:https://kauth.kakao.com/.well-known/jwks.json} + token-uri: ${KAKAO_TOKEN_URI:https://kauth.kakao.com/oauth/token} + issuer: ${KAKAO_ISS:https://kauth.kakao.com} + nonce: ${KAKAO_NONCE:example-nonce} + redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:8080} + google: + client-id: ${GOOGLE_CLIENT_ID:248388975343-0oo0f79rrsqpf1k63ahpivkhd2rfu1jp.apps.googleusercontent.com} + client-secret: ${GOOGLE_CLIENT_SECRET:exampleSecretKeyForRabbit} + jwks-uri: ${GOOGLE_JWKS_URI:https://www.googleapis.com/oauth2/v3/certs} + token-uri: ${GOOGLE_TOKEN_URI:https://oauth2.googleapis.com/token} + issuer: ${GOOGLE_ISS:https://accounts.google.com} + nonce: ${GOOGLE_NONCE:example-nonce} + redirect-uri: ${GOOGLE_REDIRECT_URI:http://localhost:8080} + jwt: secret-key: access-token: ${JWT_ACCESS_SECRET_KEY:exampleSecretKeyForPennywaySystemAccessSecretKeyTestForPadding} diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index a194ad0..4742b22 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -2,7 +2,7 @@ SET FOREIGN_KEY_CHECKS = 0; --- 기존 데이터 삭제 (이미지에 있는 테이블만 해당) +-- 기존 데이터 삭제 DELETE FROM `user`; DELETE @@ -11,8 +11,14 @@ DELETE FROM `chat_room_member`; DELETE FROM `chat_message`; --- AUTO_INCREMENT 값 초기화 (이미지에 있는 테이블만 해당) +DELETE +FROM `oauth`; +-- 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; -- 빠른 삽입을 위해 검사 비활성화 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; @@ -115,4 +121,4 @@ TABLES; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; SET -FOREIGN_KEY_CHECKS = 1; \ No newline at end of file +FOREIGN_KEY_CHECKS = 1; diff --git a/src/test/java/com/rabbitmqprac/common/fixture/UserFixture.java b/src/test/java/com/rabbitmqprac/common/fixture/UserFixture.java index 77adabc..2b3fff3 100644 --- a/src/test/java/com/rabbitmqprac/common/fixture/UserFixture.java +++ b/src/test/java/com/rabbitmqprac/common/fixture/UserFixture.java @@ -8,15 +8,16 @@ @RequiredArgsConstructor @Getter public enum UserFixture { - FIRST_USER(1L, "user_1", "password_1", Role.USER), - SECOND_USER(2L, "user_2", "password_2", Role.USER); + FIRST_USER(1L, "user_1", "user_1", "password_1", Role.USER), + SECOND_USER(2L, "user_2", "user_2", "password_2", Role.USER); private final Long id; + private final String nickname; private final String username; private final String password; private final Role role; public User toEntity() { - return User.of(username, password, role); + return User.of(nickname, username, password, role); } } diff --git a/src/test/java/com/rabbitmqprac/service/AuthServiceTest.java b/src/test/java/com/rabbitmqprac/service/AuthServiceTest.java index 2b949b8..07e2585 100644 --- a/src/test/java/com/rabbitmqprac/service/AuthServiceTest.java +++ b/src/test/java/com/rabbitmqprac/service/AuthServiceTest.java @@ -53,10 +53,11 @@ class SignUpSuccessScenarios { @DisplayName("회원가입 성공") void signUp() { // given - final String username = "username"; + final String nickname = "nickname"; + final String username = "nickname"; final String password = "password_123"; final String confirmPassword = "password_123"; - AuthSignUpReq req = new AuthSignUpReq(username, password, confirmPassword); + AuthSignUpReq req = new AuthSignUpReq(nickname, username, password, confirmPassword); Jwts jwts = mock(Jwts.class); given(userService.saveUserWithEncryptedPassword(any(UserCreateReq.class))) @@ -81,10 +82,11 @@ class SignUpFailScenarios { @DisplayName("password와 confirmPassword가 다른 경우 회원 가입에 실패") void signUpWhenInvalidPassword() { // given - final String username = "username"; + final String nickname = "nickname"; + final String username = "nickname"; final String password = "password"; final String confirmPassword = "invalid_password"; - AuthSignUpReq req = new AuthSignUpReq(username, password, confirmPassword); + AuthSignUpReq req = new AuthSignUpReq(nickname, username, password, confirmPassword); // when AuthErrorException errorException = assertThrows(AuthErrorException.class, () -> authService.signUp(req)); @@ -101,7 +103,7 @@ class SignInSuccessScenarios { @DisplayName("로그인 성공") void signIn() { // given - final String username = "username"; + final String username = "nickname"; final String password = "password"; AuthSignInReq req = new AuthSignInReq(username, password); Jwts jwts = mock(Jwts.class); @@ -127,7 +129,7 @@ class SignInFailScenarios { @DisplayName("로그인 유저의 패스워드가 올바르지 않다면 로그인 실패") void signInWhenInvalidPassword() { // given - final String username = "username"; + final String username = "nickname"; final String password = "password"; AuthSignInReq req = new AuthSignInReq(username, password); given(userService.readUserByUsername(username)).willReturn(user); diff --git a/src/test/java/com/rabbitmqprac/service/OauthServiceTest.java b/src/test/java/com/rabbitmqprac/service/OauthServiceTest.java new file mode 100644 index 0000000..03f82d5 --- /dev/null +++ b/src/test/java/com/rabbitmqprac/service/OauthServiceTest.java @@ -0,0 +1,177 @@ +package com.rabbitmqprac.service; + +import com.rabbitmqprac.application.dto.oauth.req.OauthSignInReq; +import com.rabbitmqprac.application.dto.oauth.req.OauthSignUpReq; +import com.rabbitmqprac.common.fixture.UserFixture; +import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorCode; +import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorException; +import com.rabbitmqprac.domain.context.oauth.service.OauthService; +import com.rabbitmqprac.domain.context.user.service.UserService; +import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider; +import com.rabbitmqprac.domain.persistence.oauth.entity.Oauth; +import com.rabbitmqprac.domain.persistence.oauth.repository.OauthRepository; +import com.rabbitmqprac.domain.persistence.user.entity.User; +import com.rabbitmqprac.global.helper.JwtHelper; +import com.rabbitmqprac.global.helper.OauthHelper; +import com.rabbitmqprac.infra.oauth.dto.OauthTokenRes; +import com.rabbitmqprac.infra.oauth.dto.OidcDecodePayload; +import com.rabbitmqprac.infra.security.jwt.Jwts; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("OauthService 테스트") +public class OauthServiceTest { + @Mock + private OauthHelper oauthHelper; + @Mock + private OauthRepository oauthRepository; + @Mock + private UserService userService; + @Mock + private JwtHelper jwtHelper; + + @InjectMocks + private OauthService oauthService; + + private static final User user = mock(User.class); + private static final Oauth oauth = mock(Oauth.class); + + @BeforeEach + void setUp() { + when(user.getId()).thenReturn(UserFixture.FIRST_USER.toEntity().getId()); + when(oauth.getUser()).thenReturn(user); + } + + @Nested + @DisplayName("signIn 시나리오") + class SignInScenario { + private final OauthSignInReq req = mock(OauthSignInReq.class); + private final OauthProvider provider = mock(OauthProvider.class); + private final String code = "test-code"; + private final String idToken = "test-id-token"; + private final OidcDecodePayload payload = mock(OidcDecodePayload.class); + private final Jwts jwts = mock(Jwts.class); + private final OauthTokenRes oauthTokenRes = mock(OauthTokenRes.class); + + @BeforeEach + void setUp() { + when(req.code()).thenReturn(code); + when(oauthHelper.getIdToken(provider, code)).thenReturn(oauthTokenRes); + when(oauthTokenRes.idToken()).thenReturn(idToken); + when(oauthHelper.getOidcDecodedPayload(provider, idToken)).thenReturn(payload); + } + + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("존재하는 유저일 때 정상적으로 토큰 반환") + void signInSuccessWithExistingUser() { + // given + when(oauthRepository.findBySubAndOauthProvider(payload.sub(), provider)).thenReturn(Optional.of(oauth)); + when(jwtHelper.createToken(user)).thenReturn(jwts); + + // when + Pair result = oauthService.signIn(provider, req); + + // then + assertThat(result.getLeft()).isEqualTo(user.getId()); + assertThat(result.getRight()).isEqualTo(jwts); + } + + @Test + @DisplayName("존재하지 않는 유저일 때 -1L, null 반환") + void signInSuccessWittNotExistingUser() { + // given + when(oauthRepository.findBySubAndOauthProvider(payload.sub(), provider)).thenReturn(Optional.empty()); + + // when + Pair result = oauthService.signIn(provider, req); + + // then + assertThat(result.getLeft()).isEqualTo(-1L); + assertThat(result.getRight()).isNull(); + } + } + } + + @Nested + @DisplayName("signUp 시나리오") + class SignUpScenario { + private final OauthProvider provider = mock(OauthProvider.class); + private final String code = "test-code"; + private final String nickname = "test-nickname"; + private final String idToken = "test-id-token"; + private final OauthSignUpReq req = mock(OauthSignUpReq.class); + private final OauthTokenRes tokenRes = mock(OauthTokenRes.class); + private final OidcDecodePayload payload = mock(OidcDecodePayload.class); + private final Jwts jwts = mock(Jwts.class); + + @BeforeEach + void setUp() { + when(req.code()).thenReturn(code); + when(oauthHelper.getIdToken(provider, code)).thenReturn(tokenRes); + when(tokenRes.idToken()).thenReturn(idToken); + when(oauthHelper.getOidcDecodedPayload(provider, idToken)).thenReturn(payload); + when(req.nickname()).thenReturn(nickname); + } + + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("정상적으로 회원가입 및 토큰 반환") + void signUpSuccess() { + // given + when(oauthRepository.existsBySubAndOauthProvider(payload.sub(), provider)).thenReturn(false); + doNothing().when(userService).validateNicknameDuplication(nickname); + when(userService.create(nickname)).thenReturn(user); + when(jwtHelper.createToken(user)).thenReturn(jwts); + + // when + Pair result = oauthService.signUp(provider, req); + + // then + verify(userService).create(nickname); + verify(oauthRepository).save(any(Oauth.class)); + assertThat(result.getLeft()).isEqualTo(user.getId()); + assertThat(result.getRight()).isEqualTo(jwts); + } + } + + @Nested + @DisplayName("실패 시나리오") + class FailScenario { + @Test + @DisplayName("이미 가입된 sub일 때 예외 발생") + void signUpFail_conflict() { + // given + when(oauthRepository.existsBySubAndOauthProvider(payload.sub(), provider)).thenReturn(true); + + // when + OauthErrorException ex = assertThrows(OauthErrorException.class, () -> oauthService.signUp(provider, req)); + + // then + assertThat(ex.getErrorCode()).isEqualTo(OauthErrorCode.CONFLICT); + } + } + } +} diff --git a/src/test/java/com/rabbitmqprac/service/UserServiceTest.java b/src/test/java/com/rabbitmqprac/service/UserServiceTest.java index 9db3168..b0d72f8 100644 --- a/src/test/java/com/rabbitmqprac/service/UserServiceTest.java +++ b/src/test/java/com/rabbitmqprac/service/UserServiceTest.java @@ -44,7 +44,7 @@ class UserSaveSuccessScenarios { @DisplayName("유저 저장 성공") void saveUser() { // given - UserCreateReq req = new UserCreateReq(user.getUsername(), user.getPassword()); + UserCreateReq req = new UserCreateReq(user.getNickname(), user.getUsername(), user.getPassword()); given(userRepository.save(any(User.class))).willReturn(user); given(userRepository.existsByUsername(user.getUsername())).willReturn(Boolean.FALSE); @@ -61,10 +61,10 @@ void saveUser() { @DisplayName("유저 저장 실패 시나리오") class UserSaveFailScenarios { @Test - @DisplayName("username 중복") + @DisplayName("nickname 중복") void saveUserWhenExistingUserByUsername() { // given - UserCreateReq req = new UserCreateReq(user.getUsername(), user.getPassword()); + UserCreateReq req = new UserCreateReq(user.getNickname(), user.getUsername(), user.getPassword()); given(userRepository.existsByUsername(user.getUsername())).willReturn(Boolean.TRUE); // when @@ -184,7 +184,7 @@ void readUserByUsername() { @DisplayName("username으로 유저 조회 실패 시나리오") class UserReadByUsernameFailScenarios { @Test - @DisplayName("존재하지 않는 username") + @DisplayName("존재하지 않는 nickname") void readUserByUsernameWhenNotFoundedUser() { // given given(userRepository.findByUsername(user.getUsername())).willReturn(Optional.empty()); @@ -250,10 +250,10 @@ void updateNicknameFailByDuplicate() { } @Nested - @DisplayName("username 중복 체크 성공 시나리오") + @DisplayName("nickname 중복 체크 성공 시나리오") class UsernameDuplicateCheckSuccessScenarios { @Test - @DisplayName("username 중복 체크 - 중복") + @DisplayName("nickname 중복 체크 - 중복") void isDuplicatedUsernameTrue() { // given given(userRepository.existsByUsername(user.getUsername())).willReturn(Boolean.TRUE); @@ -266,7 +266,7 @@ void isDuplicatedUsernameTrue() { } @Test - @DisplayName("username 중복 체크 - 중복 아님") + @DisplayName("nickname 중복 체크 - 중복 아님") void isDuplicatedUsernameFalse() { // given given(userRepository.existsByUsername(user.getUsername())).willReturn(Boolean.FALSE); From a124f105e5dc45f31153e750433fb5a7187ef1f8 Mon Sep 17 00:00:00 2001 From: LSH <104637774+lsh2613@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:36:10 +0900 Subject: [PATCH 3/9] =?UTF-8?q?Feat:=20=EC=9C=A0=EC=A0=80=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=A4=91=EB=B3=B5=20=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 유저 닉네임 중복 체크 api * test: 유저 닉네임 중복 체크 api --- .../controller/UserController.java | 5 +++ .../dto/user/req/NicknameCheckReq.java | 9 +++++ .../context/user/service/UserService.java | 6 +++ .../rabbitmqprac/service/UserServiceTest.java | 39 +++++++++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameCheckReq.java diff --git a/src/main/java/com/rabbitmqprac/application/controller/UserController.java b/src/main/java/com/rabbitmqprac/application/controller/UserController.java index 27c825d..0504715 100644 --- a/src/main/java/com/rabbitmqprac/application/controller/UserController.java +++ b/src/main/java/com/rabbitmqprac/application/controller/UserController.java @@ -1,6 +1,7 @@ package com.rabbitmqprac.application.controller; import com.rabbitmqprac.application.dto.auth.res.UserDetailRes; +import com.rabbitmqprac.application.dto.user.req.NicknameCheckReq; import com.rabbitmqprac.application.dto.user.req.NicknameUpdateReq; import com.rabbitmqprac.domain.context.user.service.UserService; import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; @@ -46,4 +47,8 @@ public ResponseEntity patchNickname(@AuthenticationPrincipal SecurityUserD return ResponseEntity.noContent().build(); } + @GetMapping("/users/nickname") + public Map checkNicknameDuplication(@Validated NicknameCheckReq nicknameCheckReq) { + return Map.of("isDuplicated", userService.isDuplicatedNickname(nicknameCheckReq)); + } } diff --git a/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameCheckReq.java b/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameCheckReq.java new file mode 100644 index 0000000..b24e13d --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameCheckReq.java @@ -0,0 +1,9 @@ +package com.rabbitmqprac.application.dto.user.req; + +import com.rabbitmqprac.global.annotation.Nickname; + +public record NicknameCheckReq( + @Nickname + String nickname +) { +} diff --git a/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java b/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java index 5e0beb9..c591fcc 100644 --- a/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java +++ b/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java @@ -2,6 +2,7 @@ import com.rabbitmqprac.application.dto.auth.res.UserDetailRes; +import com.rabbitmqprac.application.dto.user.req.NicknameCheckReq; import com.rabbitmqprac.application.dto.user.req.NicknameUpdateReq; import com.rabbitmqprac.application.mapper.UserMapper; import com.rabbitmqprac.domain.context.user.dto.req.UserCreateReq; @@ -91,4 +92,9 @@ public User create(String nickname) { User user = User.of(nickname, Role.USER); return userRepository.save(user); } + + @Transactional(readOnly = true) + public boolean isDuplicatedNickname(NicknameCheckReq req) { + return userRepository.existsByNickname(req.nickname()); + } } diff --git a/src/test/java/com/rabbitmqprac/service/UserServiceTest.java b/src/test/java/com/rabbitmqprac/service/UserServiceTest.java index b0d72f8..cc3720c 100644 --- a/src/test/java/com/rabbitmqprac/service/UserServiceTest.java +++ b/src/test/java/com/rabbitmqprac/service/UserServiceTest.java @@ -1,6 +1,7 @@ package com.rabbitmqprac.service; import com.rabbitmqprac.application.dto.auth.res.UserDetailRes; +import com.rabbitmqprac.application.dto.user.req.NicknameCheckReq; import com.rabbitmqprac.application.dto.user.req.NicknameUpdateReq; import com.rabbitmqprac.common.fixture.UserFixture; import com.rabbitmqprac.domain.context.user.dto.req.UserCreateReq; @@ -9,6 +10,7 @@ import com.rabbitmqprac.domain.context.user.service.UserService; import com.rabbitmqprac.domain.persistence.user.entity.User; import com.rabbitmqprac.domain.persistence.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -279,4 +281,41 @@ void isDuplicatedUsernameFalse() { } } + @Nested + @DisplayName("닉네임 중복 체크 성공 시나리오") + class NicknameDuplicateCheckSuccessScenarios { + private static NicknameCheckReq req = mock(NicknameCheckReq.class); + + @BeforeEach + void setUp() { + given(req.nickname()).willReturn(user.getNickname()); + } + + @Test + @DisplayName("닉네임 중복 체크 - 중복") + void isDuplicatedNicknameTrue() { + // given + given(userRepository.existsByNickname(user.getNickname())).willReturn(Boolean.TRUE); + + // when + Boolean result = userService.isDuplicatedNickname(req); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("닉네임 중복 체크 - 중복 아님") + void isDuplicatedNicknameFalse() { + // given + given(userRepository.existsByNickname(user.getNickname())).willReturn(Boolean.FALSE); + + // when + Boolean result = userService.isDuplicatedNickname(req); + + // then + assertThat(result).isFalse(); + } + } + } From a4f2a724041000e5a92c3c35c8943359b47568f4 Mon Sep 17 00:00:00 2001 From: LSH <104637774+lsh2613@users.noreply.github.com> Date: Tue, 26 Aug 2025 22:20:48 +0900 Subject: [PATCH 4/9] =?UTF-8?q?Chore:=20swagger=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=ED=95=9C=20=EB=AC=B8=EC=84=9C=ED=99=94=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: swagger 활용한 API 및 DTO 문서화 * chore: swagger error response 자동 생성 비활성화 * chore: Swagger 에러 응답 문서 작성 반자동화 * chore: ChatRoomInfoRes -> ChatRoomSummaryRes 클래스명 변경 * chore: 응답 데이터 없는 API에 대해 NO_CONTENT Status 적용 * chore: UserDetailRes 패키지 경로 변경 * chore: ChatRoomMemberCreateReq 제거 * chore: rabbit 서버 도메인 값에 http 프로토콜 추가 * refactor: 중복 인증 응답 생성 로직을 AuthenticationResponseUtil로 분리 및 개선 * chore: Swagger 문서에 GitHub 외부 문서 링크 추가 * chore: AbbreviationAsWordInName 규칙 제거 --- build.gradle | 3 + checkstyle/config/rules.xml | 9 -- .../rabbitmqprac/application/api/AuthApi.java | 62 +++++++++++ .../application/api/ChatMessageApi.java | 95 ++++++++++++++++ .../application/api/ChatRoomApi.java | 35 ++++++ .../application/api/ChatRoomMemberApi.java | 23 ++++ .../application/api/OauthApi.java | 68 ++++++++++++ .../rabbitmqprac/application/api/UserApi.java | 44 ++++++++ .../controller/AuthController.java | 42 +++---- .../controller/ChatMessageController.java | 6 +- .../controller/ChatRoomController.java | 10 +- .../controller/ChatRoomMemberController.java | 6 +- .../controller/OauthController.java | 40 ++----- .../controller/UserController.java | 19 +++- .../dto/auth/req/AuthSignInReq.java | 4 + .../dto/auth/req/AuthSignUpReq.java | 6 + .../dto/auth/req/AuthUpdatePasswordReq.java | 10 +- .../dto/auth/res/UserDetailRes.java | 10 -- .../dto/chatmessage/req/ChatMessageReq.java | 5 +- .../chatmessage/res/ChatMessageDetailRes.java | 8 ++ .../dto/chatmessage/res/ChatMessageRes.java | 7 ++ .../res/LastChatMessageDetailRes.java | 6 + .../dto/chatroom/req/ChatRoomCreateReq.java | 4 + .../dto/chatroom/res/ChatRoomDetailRes.java | 8 ++ .../dto/chatroom/res/ChatRoomInfoRes.java | 30 ----- .../dto/chatroom/res/ChatRoomSummaryRes.java | 38 +++++++ .../req/ChatRoomMemberCreateReq.java | 8 -- .../res/ChatRoomMemberDetailRes.java | 5 + .../dto/oauth/req/OauthSignInReq.java | 5 +- .../dto/oauth/req/OauthSignUpReq.java | 6 +- .../dto/user/req/NicknameCheckReq.java | 3 + .../dto/user/req/NicknameUpdateReq.java | 3 + .../dto/user/res/UserDetailRes.java | 15 +++ .../application/dto/user/res/UserIdRes.java | 13 +++ .../application/mapper/ChatRoomMapper.java | 6 +- .../application/mapper/UserMapper.java | 2 +- .../rabbitmqprac/config/SwaggerConfig.java | 83 ++++++++++++++ .../chatroom/service/ChatRoomService.java | 4 +- .../context/user/service/UserService.java | 2 +- .../annotation/ApiExceptionExplanation.java | 39 +++++++ .../annotation/ApiExceptionExplanations.java | 36 ++++++ .../exception/payload/ErrorResponse.java | 9 ++ .../util/ApiExceptionExplainParser.java | 103 ++++++++++++++++++ .../util/AuthenticationResponseUtil.java | 27 +++++ src/main/resources/application.yml | 9 ++ .../service/ChatRoomServiceTest.java | 10 +- .../rabbitmqprac/service/UserServiceTest.java | 2 +- 47 files changed, 846 insertions(+), 142 deletions(-) create mode 100644 src/main/java/com/rabbitmqprac/application/api/AuthApi.java create mode 100644 src/main/java/com/rabbitmqprac/application/api/ChatMessageApi.java create mode 100644 src/main/java/com/rabbitmqprac/application/api/ChatRoomApi.java create mode 100644 src/main/java/com/rabbitmqprac/application/api/ChatRoomMemberApi.java create mode 100644 src/main/java/com/rabbitmqprac/application/api/OauthApi.java create mode 100644 src/main/java/com/rabbitmqprac/application/api/UserApi.java delete mode 100644 src/main/java/com/rabbitmqprac/application/dto/auth/res/UserDetailRes.java delete mode 100644 src/main/java/com/rabbitmqprac/application/dto/chatroom/res/ChatRoomInfoRes.java create mode 100644 src/main/java/com/rabbitmqprac/application/dto/chatroom/res/ChatRoomSummaryRes.java delete mode 100644 src/main/java/com/rabbitmqprac/application/dto/chatroommember/req/ChatRoomMemberCreateReq.java create mode 100644 src/main/java/com/rabbitmqprac/application/dto/user/res/UserDetailRes.java create mode 100644 src/main/java/com/rabbitmqprac/application/dto/user/res/UserIdRes.java create mode 100644 src/main/java/com/rabbitmqprac/config/SwaggerConfig.java create mode 100644 src/main/java/com/rabbitmqprac/global/annotation/ApiExceptionExplanation.java create mode 100644 src/main/java/com/rabbitmqprac/global/annotation/ApiExceptionExplanations.java create mode 100644 src/main/java/com/rabbitmqprac/global/util/ApiExceptionExplainParser.java create mode 100644 src/main/java/com/rabbitmqprac/global/util/AuthenticationResponseUtil.java diff --git a/build.gradle b/build.gradle index 13ecd79..50850dc 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,9 @@ dependencies { // WebClient implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' } tasks.named('test') { diff --git a/checkstyle/config/rules.xml b/checkstyle/config/rules.xml index 3e9622a..ac826bb 100644 --- a/checkstyle/config/rules.xml +++ b/checkstyle/config/rules.xml @@ -53,15 +53,6 @@ The following rules in the Naver coding convention cannot be checked by this con - - - - - - - - diff --git a/src/main/java/com/rabbitmqprac/application/api/AuthApi.java b/src/main/java/com/rabbitmqprac/application/api/AuthApi.java new file mode 100644 index 0000000..aeb425b --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/api/AuthApi.java @@ -0,0 +1,62 @@ +package com.rabbitmqprac.application.api; + +import com.rabbitmqprac.application.dto.auth.req.AuthSignInReq; +import com.rabbitmqprac.application.dto.auth.req.AuthSignUpReq; +import com.rabbitmqprac.application.dto.auth.req.AuthUpdatePasswordReq; +import com.rabbitmqprac.application.dto.user.res.UserIdRes; +import com.rabbitmqprac.domain.context.auth.exception.AuthErrorCode; +import com.rabbitmqprac.domain.context.user.exception.UserErrorCode; +import com.rabbitmqprac.global.annotation.ApiExceptionExplanation; +import com.rabbitmqprac.global.annotation.ApiExceptionExplanations; +import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.Map; + +@Tag(name = "[인증 API]") +public interface AuthApi { + + @Operation(summary = "일반 회원가입") + @ApiExceptionExplanations({ + @ApiExceptionExplanation( + errorCode = AuthErrorCode.class, + constants = "PASSWORD_CONFIRM_MISMATCH" + ), + @ApiExceptionExplanation( + errorCode = UserErrorCode.class, + constants = "CONFLICT_USERNAME" + ) + }) + ResponseEntity signUp(@RequestBody @Validated AuthSignUpReq authSignUpReq); + + @Operation(summary = "일반 로그인") + @ApiExceptionExplanations({ + @ApiExceptionExplanation( + errorCode = AuthErrorCode.class, + constants = "INVALID_PASSWORD" + ), + @ApiExceptionExplanation( + errorCode = UserErrorCode.class, + constants = "NOT_FOUND" + ) + }) + ResponseEntity signIn(@RequestBody @Validated AuthSignInReq authSignInReq); + + @Operation(summary = "비밀번호 변경") + @ApiExceptionExplanations({ + @ApiExceptionExplanation( + errorCode = UserErrorCode.class, + constants = "NOT_FOUND" + ), + @ApiExceptionExplanation( + errorCode = AuthErrorCode.class, + constants = "INVALID_PASSWORD" + ) + }) + void patchPassword(@AuthenticationPrincipal SecurityUserDetails user, @RequestBody @Validated AuthUpdatePasswordReq authUpdatePasswordReq); +} diff --git a/src/main/java/com/rabbitmqprac/application/api/ChatMessageApi.java b/src/main/java/com/rabbitmqprac/application/api/ChatMessageApi.java new file mode 100644 index 0000000..7bbbba0 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/api/ChatMessageApi.java @@ -0,0 +1,95 @@ +package com.rabbitmqprac.application.api; + +import com.rabbitmqprac.application.dto.chatmessage.req.ChatMessageReq; +import com.rabbitmqprac.application.dto.chatmessage.res.ChatMessageDetailRes; +import com.rabbitmqprac.domain.context.chatroom.exception.ChatRoomErrorCode; +import com.rabbitmqprac.domain.context.user.exception.UserErrorCode; +import com.rabbitmqprac.global.annotation.ApiExceptionExplanation; +import com.rabbitmqprac.global.annotation.ApiExceptionExplanations; +import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; +import com.rabbitmqprac.infra.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@Tag(name = "[채팅 메시지 API]") +public interface ChatMessageApi { + + @RequestMapping(method = RequestMethod.OPTIONS) + @Operation( + summary = "채팅 메시지 전송 (STOMP 설명용)", + description = """ + STOMP를 통해 채팅 메시지를 전송하는 방법을 설명합니다. ※ 이 API는 HTTP가 아닌 WebSocket(STOMP) 기반으로 실제로 동작하지 않습니다. + - STOMP Destination: /pub/chat.room.{chatRoomId}/message + - Payload: ChatMessageReq {"content": "메시지 내용"} + - 인증: Header에 JWT 토큰 필요 + """ + ) + @ApiExceptionExplanations({ + @ApiExceptionExplanation( + errorCode = UserErrorCode.class, + constants = "NOT_FOUND" + ), + @ApiExceptionExplanation( + errorCode = ChatRoomErrorCode.class, + constants = "NOT_FOUND" + ) + }) + void sendMessage(UserPrincipal principal, + @DestinationVariable Long chatRoomId, + @Validated ChatMessageReq message + ); + + @Operation(summary = "채팅 메시지 목록 조회 (이전)", description = "특정 채팅 메시지 ID 이전의 채팅 메시지 목록을 조회한다.") + @Parameters({ + @Parameter( + name = "lastChatMessageId", + description = """ + 조회 시작 기준이 되는 채팅 메시지 ID. 이 ID 이전의 메시지들을 조회한다. + 기본값은 0이며, 이 경우 가장 최근 메시지부터 조회를 시작한다. + """, + example = "0" + ), + @Parameter( + name = "size", + description = "한 번에 조회할 채팅 메시지의 최대 개수. 기본값은 30이다.", + example = "30" + ) + }) + List readChatMessagesBefore( + @PathVariable Long chatRoomId, + @RequestParam(value = "lastChatMessageId", defaultValue = "0") Long lastMessageId, + @RequestParam(value = "size", defaultValue = "30") int size + ); + + @Operation(summary = "채팅 메시지 목록 조회 (범위)") + @Parameters({ + @Parameter( + name = "from", + description = "조회할 채팅 메시지 ID 범위의 시작. 이 ID를 포함한다.", + example = "100", + required = true + ), + @Parameter( + name = "to", + description = "조회할 채팅 메시지 ID 범위의 끝. 이 ID를 포함한다.", + example = "200", + required = true + ) + }) + List readChatMessagesBetween(@AuthenticationPrincipal SecurityUserDetails user, + @PathVariable Long chatRoomId, + @RequestParam Long from, + @RequestParam Long to + ); +} diff --git a/src/main/java/com/rabbitmqprac/application/api/ChatRoomApi.java b/src/main/java/com/rabbitmqprac/application/api/ChatRoomApi.java new file mode 100644 index 0000000..fad6c9b --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/api/ChatRoomApi.java @@ -0,0 +1,35 @@ +package com.rabbitmqprac.application.api; + +import com.rabbitmqprac.application.dto.chatroom.req.ChatRoomCreateReq; +import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomDetailRes; +import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomSummaryRes; +import com.rabbitmqprac.domain.context.user.exception.UserErrorCode; +import com.rabbitmqprac.global.annotation.ApiExceptionExplanation; +import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +@Tag(name = "[채팅방 API]") +public interface ChatRoomApi { + + @Operation(summary = "채팅방 생성") + @ApiExceptionExplanation( + errorCode = UserErrorCode.class, + constants = "NOT_FOUND" + ) + ChatRoomDetailRes create( + @AuthenticationPrincipal SecurityUserDetails user, + @RequestBody @Validated ChatRoomCreateReq chatRoomCreateReq + ); + + @Operation(summary = "가입한 채팅방 목록 조회", description = "로그인된 유저의 채팅방 목록을 조회한다.") + List getMyChatRooms(@AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "채팅방 목록 조회") + List getChatRooms(@AuthenticationPrincipal SecurityUserDetails user); +} diff --git a/src/main/java/com/rabbitmqprac/application/api/ChatRoomMemberApi.java b/src/main/java/com/rabbitmqprac/application/api/ChatRoomMemberApi.java new file mode 100644 index 0000000..54513ce --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/api/ChatRoomMemberApi.java @@ -0,0 +1,23 @@ +package com.rabbitmqprac.application.api; + +import com.rabbitmqprac.application.dto.chatroommember.res.ChatRoomMemberDetailRes; +import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.List; + +@Tag(name = "[채팅방 멤버 API]") +public interface ChatRoomMemberApi { + + @Operation(summary = "채팅방 참여") + void joinChatRoom( + @AuthenticationPrincipal SecurityUserDetails user, + @PathVariable Long chatRoomId + ); + + @Operation(summary = "채팅방 멤버 목록 조회") + List getChatRoomMembers(@PathVariable Long chatRoomId); +} diff --git a/src/main/java/com/rabbitmqprac/application/api/OauthApi.java b/src/main/java/com/rabbitmqprac/application/api/OauthApi.java new file mode 100644 index 0000000..3958e75 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/api/OauthApi.java @@ -0,0 +1,68 @@ +package com.rabbitmqprac.application.api; + +import com.rabbitmqprac.application.dto.oauth.req.OauthSignInReq; +import com.rabbitmqprac.application.dto.oauth.req.OauthSignUpReq; +import com.rabbitmqprac.application.dto.user.res.UserIdRes; +import com.rabbitmqprac.domain.context.oauth.exception.OauthErrorCode; +import com.rabbitmqprac.domain.context.user.exception.UserErrorCode; +import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider; +import com.rabbitmqprac.global.annotation.ApiExceptionExplanation; +import com.rabbitmqprac.global.annotation.ApiExceptionExplanations; +import com.rabbitmqprac.infra.security.exception.JwtErrorCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "[소셜 인증 API]") +public interface OauthApi { + + @Operation( + summary = "소셜 로그인", + description = """ + code는 각 소셜 플랫폼에서 발급받은 인가 코드로 KAKAO, GOOGLE 등의 OauthProvider를 통해 발급받은 인가 코드를 의미합니다. + - [KAKAO CODE 발급 URL](https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=57e328d97dc0c7b53898e2e79082f23c&redirect_uri=http://localhost:8080&scope=openid%20profile_nickname&nonce=example-nonce) + - [GOOGLE CODE 발급 URL](https://accounts.google.com/o/oauth2/v2/auth?client_id=248388975343-0oo0f79rrsqpf1k63ahpivkhd2rfu1jp.apps.googleusercontent.com&redirect_uri=http://localhost:8080&response_type=code&scope=openid%20email%20profile&nonce=example-nonce) + """ + ) + @ApiExceptionExplanations({ + @ApiExceptionExplanation( + errorCode = OauthErrorCode.class, + constants = {"MISSING_ISS", "INVALID_ISS", "MISSING_NONCE", "INVALID_ISS", "INVALID_AUD", "INVALID_NONCE"} + ), + @ApiExceptionExplanation( + errorCode = JwtErrorCode.class, + constants = "MALFORMED_TOKEN" + ) + }) + ResponseEntity signIn( + @RequestParam OauthProvider oauthProvider, + @RequestBody @Validated OauthSignInReq req + ); + + + @Operation( + summary = "소셜 회원가입", + description = """ + code는 각 소셜 플랫폼에서 발급받은 인가 코드로 KAKAO, GOOGLE 등의 OauthProvider를 통해 발급받은 인가 코드를 의미합니다. + - [KAKAO CODE 발급 URL](https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=57e328d97dc0c7b53898e2e79082f23c&redirect_uri=http://localhost:8080&scope=openid%20profile_nickname&nonce=example-nonce) + - [GOOGLE CODE 발급 URL](https://accounts.google.com/o/oauth2/v2/auth?client_id=248388975343-0oo0f79rrsqpf1k63ahpivkhd2rfu1jp.apps.googleusercontent.com&redirect_uri=http://localhost:8080&response_type=code&scope=openid%20email%20profile&nonce=example-nonce) + """ + ) + @ApiExceptionExplanations({ + @ApiExceptionExplanation( + errorCode = OauthErrorCode.class, + constants = {"CONFLICT", "MISSING_ISS", "INVALID_ISS", "MISSING_NONCE", "INVALID_ISS", "INVALID_AUD", "INVALID_NONCE"} + ), + @ApiExceptionExplanation( + errorCode = UserErrorCode.class, + constants = "CONFLICT_USERNAME" + ) + }) + ResponseEntity signUp( + @RequestParam OauthProvider oauthProvider, + @RequestBody @Validated OauthSignUpReq req + ); +} diff --git a/src/main/java/com/rabbitmqprac/application/api/UserApi.java b/src/main/java/com/rabbitmqprac/application/api/UserApi.java new file mode 100644 index 0000000..776025b --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/api/UserApi.java @@ -0,0 +1,44 @@ +package com.rabbitmqprac.application.api; + +import com.rabbitmqprac.application.dto.user.res.UserDetailRes; +import com.rabbitmqprac.application.dto.user.req.NicknameCheckReq; +import com.rabbitmqprac.application.dto.user.req.NicknameUpdateReq; +import com.rabbitmqprac.domain.context.user.exception.UserErrorCode; +import com.rabbitmqprac.global.annotation.ApiExceptionExplanation; +import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; +import java.util.Map; + +@Tag(name = "[유저 API]") +public interface UserApi { + + @Operation(summary = "내 정보 조회") + UserDetailRes getMember(@AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "전체 회원 조회") + List getMembers(); + + @Operation(summary = "유저 아이디 중복 확인") + Map isDuplicatedUsername(@RequestParam @Validated String username); + + @Operation(summary = "닉네임 변경") + @ApiExceptionExplanation( + errorCode = UserErrorCode.class, + constants = "CONFLICT_USERNAME" + ) + void patchNickname( + @AuthenticationPrincipal SecurityUserDetails user, + @RequestBody NicknameUpdateReq nicknameUpdateReq + + ); + + @Operation(summary = "닉네임 중복 확인") + Map checkNicknameDuplication(@Validated NicknameCheckReq nicknameCheckReq); +} diff --git a/src/main/java/com/rabbitmqprac/application/controller/AuthController.java b/src/main/java/com/rabbitmqprac/application/controller/AuthController.java index f77a0ac..94b5eba 100644 --- a/src/main/java/com/rabbitmqprac/application/controller/AuthController.java +++ b/src/main/java/com/rabbitmqprac/application/controller/AuthController.java @@ -1,17 +1,16 @@ package com.rabbitmqprac.application.controller; +import com.rabbitmqprac.application.api.AuthApi; import com.rabbitmqprac.application.dto.auth.req.AuthSignInReq; import com.rabbitmqprac.application.dto.auth.req.AuthSignUpReq; import com.rabbitmqprac.application.dto.auth.req.AuthUpdatePasswordReq; +import com.rabbitmqprac.application.dto.user.res.UserIdRes; import com.rabbitmqprac.domain.context.auth.service.AuthService; -import com.rabbitmqprac.global.util.CookieUtil; +import com.rabbitmqprac.global.util.AuthenticationResponseUtil; import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; -import com.rabbitmqprac.infra.security.jwt.Jwts; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -19,45 +18,34 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -import java.time.Duration; -import java.util.Map; - @Slf4j @RequiredArgsConstructor @RequestMapping("/auth") @RestController -public class AuthController { +public class AuthController implements AuthApi { private final AuthService authService; + @Override @PostMapping("/sign-up") - public ResponseEntity signUp(@RequestBody @Validated AuthSignUpReq authSignUpReq) { - return createAuthenticatedResponse(authService.signUp(authSignUpReq)); + public ResponseEntity signUp(@RequestBody @Validated AuthSignUpReq authSignUpReq) { + return AuthenticationResponseUtil.createAuthenticatedResponse(authService.signUp(authSignUpReq)); } + @Override @PostMapping("/sign-in") - public ResponseEntity signIn(@RequestBody @Validated AuthSignInReq authSignInReq) { - return createAuthenticatedResponse(authService.signIn(authSignInReq)); + public ResponseEntity signIn(@RequestBody @Validated AuthSignInReq authSignInReq) { + return AuthenticationResponseUtil.createAuthenticatedResponse(authService.signIn(authSignInReq)); } + @ResponseStatus(HttpStatus.NO_CONTENT) + @Override @PatchMapping("/password") - public ResponseEntity patchPassword(@AuthenticationPrincipal SecurityUserDetails user, @RequestBody @Validated AuthUpdatePasswordReq authUpdatePasswordReq) { + public void patchPassword(@AuthenticationPrincipal SecurityUserDetails user, @RequestBody @Validated AuthUpdatePasswordReq authUpdatePasswordReq) { authService.updatePassword(user.getUserId(), authUpdatePasswordReq); - return ResponseEntity.noContent().build(); } - private ResponseEntity createAuthenticatedResponse(Pair userInfo) { - ResponseCookie cookie = CookieUtil.createCookie( - "refreshToken", userInfo.getValue().refreshToken(), Duration.ofDays(7).toSeconds() - ); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .header(HttpHeaders.AUTHORIZATION, userInfo.getValue().accessToken()) - .body( - Map.of("userId", userInfo.getKey()) - ); - } } diff --git a/src/main/java/com/rabbitmqprac/application/controller/ChatMessageController.java b/src/main/java/com/rabbitmqprac/application/controller/ChatMessageController.java index 59cdbb0..868ae78 100644 --- a/src/main/java/com/rabbitmqprac/application/controller/ChatMessageController.java +++ b/src/main/java/com/rabbitmqprac/application/controller/ChatMessageController.java @@ -1,5 +1,6 @@ package com.rabbitmqprac.application.controller; +import com.rabbitmqprac.application.api.ChatMessageApi; import com.rabbitmqprac.application.dto.chatmessage.req.ChatMessageReq; import com.rabbitmqprac.application.dto.chatmessage.res.ChatMessageDetailRes; import com.rabbitmqprac.domain.context.chatmessage.service.ChatMessageService; @@ -20,13 +21,14 @@ @RestController @RequiredArgsConstructor -public class ChatMessageController { +public class ChatMessageController implements ChatMessageApi { private final ChatMessageService chatMessageService; /** * Destination Queue: /pub/chat.message.{chatRoomId}를 통해 호출 후 처리 되는 로직 */ + @Override @PreAuthorize("#chatRoomAccessChecker.hasPermission(#chatRoomId, principal)") @MessageMapping("chat.room.{chatRoomId}/message") public void sendMessage(UserPrincipal principal, @@ -35,6 +37,7 @@ public void sendMessage(UserPrincipal principal, chatMessageService.sendMessage(principal.getUserId(), chatRoomId, message); } + @Override @GetMapping("/chat-rooms/{chatRoomId}/messages/before") public List readChatMessagesBefore(@PathVariable Long chatRoomId, @RequestParam(value = "lastChatMessageId", defaultValue = "0") Long lastMessageId, @@ -43,6 +46,7 @@ public List readChatMessagesBefore(@PathVariable Long chat return chatMessageService.readChatMessagesBefore(chatRoomId, lastMessageId, size); } + @Override @GetMapping("/chat-rooms/{chatRoomId}/messages/between") public List readChatMessagesBetween(@AuthenticationPrincipal SecurityUserDetails user, @PathVariable Long chatRoomId, diff --git a/src/main/java/com/rabbitmqprac/application/controller/ChatRoomController.java b/src/main/java/com/rabbitmqprac/application/controller/ChatRoomController.java index 20a4b29..312066a 100644 --- a/src/main/java/com/rabbitmqprac/application/controller/ChatRoomController.java +++ b/src/main/java/com/rabbitmqprac/application/controller/ChatRoomController.java @@ -1,8 +1,9 @@ package com.rabbitmqprac.application.controller; +import com.rabbitmqprac.application.api.ChatRoomApi; import com.rabbitmqprac.application.dto.chatroom.req.ChatRoomCreateReq; import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomDetailRes; -import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomInfoRes; +import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomSummaryRes; import com.rabbitmqprac.domain.context.chatroom.service.ChatRoomService; import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; import lombok.RequiredArgsConstructor; @@ -21,23 +22,26 @@ @RestController @RequiredArgsConstructor @Slf4j -public class ChatRoomController { +public class ChatRoomController implements ChatRoomApi { private final ChatRoomService chatRoomService; + @Override @PostMapping("/chat-rooms") public ChatRoomDetailRes create(@AuthenticationPrincipal SecurityUserDetails user, @RequestBody @Validated ChatRoomCreateReq chatRoomCreateReq) { return chatRoomService.create(user.getUserId(), chatRoomCreateReq); } + @Override @GetMapping("/chat-rooms/me") public List getMyChatRooms(@AuthenticationPrincipal SecurityUserDetails user) { return chatRoomService.getMyChatRooms(user.getUserId()); } + @Override @GetMapping("/chat-rooms") - public List getChatRooms(@AuthenticationPrincipal SecurityUserDetails user) { + public List getChatRooms(@AuthenticationPrincipal SecurityUserDetails user) { return chatRoomService.getChatRooms(Optional.ofNullable(Objects.isNull(user) ? null : user.getUserId()) ); } diff --git a/src/main/java/com/rabbitmqprac/application/controller/ChatRoomMemberController.java b/src/main/java/com/rabbitmqprac/application/controller/ChatRoomMemberController.java index 1769997..fe4e371 100644 --- a/src/main/java/com/rabbitmqprac/application/controller/ChatRoomMemberController.java +++ b/src/main/java/com/rabbitmqprac/application/controller/ChatRoomMemberController.java @@ -1,23 +1,27 @@ package com.rabbitmqprac.application.controller; +import com.rabbitmqprac.application.api.ChatRoomMemberApi; import com.rabbitmqprac.application.dto.chatroommember.res.ChatRoomMemberDetailRes; import com.rabbitmqprac.domain.context.chatroommember.service.ChatRoomMemberService; import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import java.util.List; @RequiredArgsConstructor @RestController -public class ChatRoomMemberController { +public class ChatRoomMemberController implements ChatRoomMemberApi { private final ChatRoomMemberService chatRoomMemberService; + @ResponseStatus(HttpStatus.NO_CONTENT) @PostMapping("/chat-rooms/{chatRoomId}/members") public void joinChatRoom(@AuthenticationPrincipal SecurityUserDetails user, @PathVariable Long chatRoomId) { diff --git a/src/main/java/com/rabbitmqprac/application/controller/OauthController.java b/src/main/java/com/rabbitmqprac/application/controller/OauthController.java index 794a047..17ea1f7 100644 --- a/src/main/java/com/rabbitmqprac/application/controller/OauthController.java +++ b/src/main/java/com/rabbitmqprac/application/controller/OauthController.java @@ -1,15 +1,13 @@ package com.rabbitmqprac.application.controller; +import com.rabbitmqprac.application.api.OauthApi; import com.rabbitmqprac.application.dto.oauth.req.OauthSignInReq; import com.rabbitmqprac.application.dto.oauth.req.OauthSignUpReq; +import com.rabbitmqprac.application.dto.user.res.UserIdRes; import com.rabbitmqprac.domain.context.oauth.service.OauthService; import com.rabbitmqprac.domain.persistence.oauth.constant.OauthProvider; -import com.rabbitmqprac.global.util.CookieUtil; -import com.rabbitmqprac.infra.security.jwt.Jwts; +import com.rabbitmqprac.global.util.AuthenticationResponseUtil; import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.tuple.Pair; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; @@ -18,36 +16,22 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.time.Duration; -import java.util.Map; - @RequiredArgsConstructor @RestController -public class OauthController { +public class OauthController implements OauthApi { private final OauthService oauthService; - @PostMapping("/oauth/sign-in") + @Override @PreAuthorize("isAnonymous()") - public ResponseEntity signIn(@RequestParam OauthProvider oauthProvider, @RequestBody @Validated OauthSignInReq req) { - return createAuthenticatedResponse(oauthService.signIn(oauthProvider, req)); + @PostMapping("/oauth/sign-in") + public ResponseEntity signIn(@RequestParam OauthProvider oauthProvider, @RequestBody @Validated OauthSignInReq req) { + return AuthenticationResponseUtil.createAuthenticatedResponse(oauthService.signIn(oauthProvider, req)); } - @PostMapping("/oauth/sign-up") + @Override @PreAuthorize("isAnonymous()") - public ResponseEntity signUp(@RequestParam OauthProvider oauthProvider, @RequestBody @Validated OauthSignUpReq req) { - return createAuthenticatedResponse(oauthService.signUp(oauthProvider, req)); - } - - private ResponseEntity createAuthenticatedResponse(Pair userInfo) { - ResponseCookie cookie = CookieUtil.createCookie( - "refreshToken", userInfo.getValue().refreshToken(), Duration.ofDays(7).toSeconds() - ); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .header(HttpHeaders.AUTHORIZATION, userInfo.getValue().accessToken()) - .body( - Map.of("userId", userInfo.getKey()) - ); + @PostMapping("/oauth/sign-up") + public ResponseEntity signUp(@RequestParam OauthProvider oauthProvider, @RequestBody @Validated OauthSignUpReq req) { + return AuthenticationResponseUtil.createAuthenticatedResponse(oauthService.signUp(oauthProvider, req)); } } diff --git a/src/main/java/com/rabbitmqprac/application/controller/UserController.java b/src/main/java/com/rabbitmqprac/application/controller/UserController.java index 0504715..24e84c5 100644 --- a/src/main/java/com/rabbitmqprac/application/controller/UserController.java +++ b/src/main/java/com/rabbitmqprac/application/controller/UserController.java @@ -1,19 +1,21 @@ package com.rabbitmqprac.application.controller; -import com.rabbitmqprac.application.dto.auth.res.UserDetailRes; +import com.rabbitmqprac.application.api.UserApi; +import com.rabbitmqprac.application.dto.user.res.UserDetailRes; import com.rabbitmqprac.application.dto.user.req.NicknameCheckReq; import com.rabbitmqprac.application.dto.user.req.NicknameUpdateReq; import com.rabbitmqprac.domain.context.user.service.UserService; import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -22,31 +24,36 @@ @Slf4j @RestController @RequiredArgsConstructor -public class UserController { +public class UserController implements UserApi { private final UserService userService; + @Override @GetMapping("/users/me") public UserDetailRes getMember(@AuthenticationPrincipal SecurityUserDetails user) { return userService.getUserDetail(user.getUserId()); } + @Override @GetMapping("/users") public List getMembers() { return userService.getUserDetails(); } + @Override @GetMapping("/users/username") public Map isDuplicatedUsername(@RequestParam @Validated String username) { return Map.of("isDuplicated", userService.isDuplicatedUsername(username)); } + @ResponseStatus(HttpStatus.NO_CONTENT) + @Override @PatchMapping("/users/nickname") - public ResponseEntity patchNickname(@AuthenticationPrincipal SecurityUserDetails user, - @RequestBody NicknameUpdateReq nicknameUpdateReq) { + public void patchNickname(@AuthenticationPrincipal SecurityUserDetails user, + @RequestBody NicknameUpdateReq nicknameUpdateReq) { userService.updateNickname(user.getUserId(), nicknameUpdateReq); - return ResponseEntity.noContent().build(); } + @Override @GetMapping("/users/nickname") public Map checkNicknameDuplication(@Validated NicknameCheckReq nicknameCheckReq) { return Map.of("isDuplicated", userService.isDuplicatedNickname(nicknameCheckReq)); diff --git a/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignInReq.java b/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignInReq.java index 66ad85c..f73f5ca 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignInReq.java +++ b/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignInReq.java @@ -1,13 +1,17 @@ package com.rabbitmqprac.application.dto.auth.req; import com.rabbitmqprac.global.annotation.Password; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; +@Schema(name = "authSignInReq", title = "일반 로그인 요청 DTO") public record AuthSignInReq( + @Schema(title = "아이디", example = "rabbit") @NotBlank(message = "아이디를 입력해주세요") @Pattern(regexp = "^[a-z0-9-_.]{5,20}$", message = "영문 소문자, 숫자, 특수기호 (-), (_), (.) 만 사용하여, 5~20자의 아이디를 입력해 주세요") String username, + @Schema(title = "비밀번호", example = "rabbit1234") @NotBlank(message = "비밀번호를 입력해주세요") @Password(message = "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") String password diff --git a/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignUpReq.java b/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignUpReq.java index 8ae96b3..e79614f 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignUpReq.java +++ b/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthSignUpReq.java @@ -2,20 +2,26 @@ import com.rabbitmqprac.global.annotation.Nickname; import com.rabbitmqprac.global.annotation.Password; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import org.springframework.security.crypto.password.PasswordEncoder; +@Schema(name = "authSignUpReq", title = "일반 회원가입 요청 DTO") public record AuthSignUpReq( + @Schema(title = "닉네임", example = "스프링") @NotBlank(message = "닉네임을 입력해주세요") @Nickname String nickname, + @Schema(title = "아이디", example = "rabbit") @NotBlank(message = "아이디를 입력해주세요") @Pattern(regexp = "^[a-z0-9-_.]{5,20}$", message = "영문 소문자, 숫자만 사용하여, 5~20자의 아이디를 입력해 주세요") String username, + @Schema(title = "비밀번호", example = "rabbit1234") @NotBlank(message = "비밀번호를 입력해주세요") @Password(message = "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") String password, + @Schema(title = "확인 비밀번호", example = "rabbit1234") @NotBlank(message = "확인 비밀번호를 입력해주세요") @Password(message = "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") String confirmPassword diff --git a/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthUpdatePasswordReq.java b/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthUpdatePasswordReq.java index 413b495..30b2366 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthUpdatePasswordReq.java +++ b/src/main/java/com/rabbitmqprac/application/dto/auth/req/AuthUpdatePasswordReq.java @@ -1,17 +1,21 @@ package com.rabbitmqprac.application.dto.auth.req; import com.rabbitmqprac.global.annotation.Password; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import org.springframework.security.crypto.password.PasswordEncoder; +@Schema(name = "authUpdatePasswordReq", title = "비밀번호 변경 요청 DTO") public record AuthUpdatePasswordReq( + @Schema(title = "기존 비밀번호", example = "oldPassword123!") @NotBlank(message = "비밀번호를 입력해주세요") String oldPassword, + @Schema(title = "새로운 비밀번호", example = "NewPassword123!") @NotBlank(message = "새로운 비밀번호를 입력해주세요") @Password(message = "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") String newPassword ) { - public String newPassword(PasswordEncoder bCryptPasswordEncoder) { - return bCryptPasswordEncoder.encode(newPassword); - } + public String newPassword(PasswordEncoder bCryptPasswordEncoder) { + return bCryptPasswordEncoder.encode(newPassword); + } } diff --git a/src/main/java/com/rabbitmqprac/application/dto/auth/res/UserDetailRes.java b/src/main/java/com/rabbitmqprac/application/dto/auth/res/UserDetailRes.java deleted file mode 100644 index 8cd8f00..0000000 --- a/src/main/java/com/rabbitmqprac/application/dto/auth/res/UserDetailRes.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.rabbitmqprac.application.dto.auth.res; - -public record UserDetailRes( - Long userId, - String nickname -) { - public static UserDetailRes of(Long memberId, String nickname) { - return new UserDetailRes(memberId, nickname); - } -} diff --git a/src/main/java/com/rabbitmqprac/application/dto/chatmessage/req/ChatMessageReq.java b/src/main/java/com/rabbitmqprac/application/dto/chatmessage/req/ChatMessageReq.java index 2e7bf21..1d531b9 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/chatmessage/req/ChatMessageReq.java +++ b/src/main/java/com/rabbitmqprac/application/dto/chatmessage/req/ChatMessageReq.java @@ -3,11 +3,14 @@ import com.rabbitmqprac.domain.persistence.chatmessage.entity.ChatMessage; import com.rabbitmqprac.domain.persistence.chatroom.entity.ChatRoom; import com.rabbitmqprac.domain.persistence.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +@Schema(name = "chatMessageReq", title = "채팅 메시지 발행 DTO") public record ChatMessageReq( - @NotNull(message = "메시지 내용은 Null을 허용하지 않습니다.") + @Schema(title = "메시지 내용", example = "안녕하세요~") + @NotNull(message = "메시지 내용은 필수입니다.") @Size(min = 1, max = 1000, message = "메시지 내용은 1자 이상 1000자 이하로 입력해주세요.") String content ) { diff --git a/src/main/java/com/rabbitmqprac/application/dto/chatmessage/res/ChatMessageDetailRes.java b/src/main/java/com/rabbitmqprac/application/dto/chatmessage/res/ChatMessageDetailRes.java index 2354c43..a24e147 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/chatmessage/res/ChatMessageDetailRes.java +++ b/src/main/java/com/rabbitmqprac/application/dto/chatmessage/res/ChatMessageDetailRes.java @@ -3,19 +3,27 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; +@Schema(name = "ChatMessageDetailRes", title = "채팅 메시지 상세 응답 DTO") public record ChatMessageDetailRes( + @Schema(title = "유저 ID", example = "1") Long userId, + @Schema(title = "닉네임", example = "RabbitMaster") String nickname, + @Schema(title = "채팅 메시지 ID", example = "1001") Long chatMessageId, + @Schema(title = "메시지 내용", example = "안녕하세요") String content, + @Schema(title = "메시지 생성 시간", example = "2023-10-05 14:30:00") @JsonSerialize(using = LocalDateTimeSerializer.class) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, + @Schema(title = "읽지 않은 멤버 수", example = "3") int unreadMemberCnt ) { public static ChatMessageDetailRes of(Long userId, diff --git a/src/main/java/com/rabbitmqprac/application/dto/chatmessage/res/ChatMessageRes.java b/src/main/java/com/rabbitmqprac/application/dto/chatmessage/res/ChatMessageRes.java index c443543..7f82ee8 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/chatmessage/res/ChatMessageRes.java +++ b/src/main/java/com/rabbitmqprac/application/dto/chatmessage/res/ChatMessageRes.java @@ -4,16 +4,23 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import com.rabbitmqprac.domain.context.chatmessage.constant.MessageType; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; +@Schema(name = "ChatMessageRes", title = "채팅 메시지 전송 DTO") public record ChatMessageRes( + @Schema(title = "메시지 타입", example = "CHAT_MESSAGE") MessageType messageType, + @Schema(title = "유저 ID", example = "1") Long userId, + @Schema(title = "닉네임", example = "RabbitMaster") String nickname, + @Schema(title = "메시지 내용", example = "안녕하세요") String content, + @Schema(title = "메시지 생성 시간", example = "2023-10-05 14:30:00") @JsonSerialize(using = LocalDateTimeSerializer.class) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, diff --git a/src/main/java/com/rabbitmqprac/application/dto/chatmessage/res/LastChatMessageDetailRes.java b/src/main/java/com/rabbitmqprac/application/dto/chatmessage/res/LastChatMessageDetailRes.java index 8f25927..88219ae 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/chatmessage/res/LastChatMessageDetailRes.java +++ b/src/main/java/com/rabbitmqprac/application/dto/chatmessage/res/LastChatMessageDetailRes.java @@ -3,14 +3,20 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; +@Schema(name = "LastChatMessageDetailRes", title = "마지막 채팅 메시지 상세 DTO") public record LastChatMessageDetailRes( + @Schema(title = "유저 ID", example = "1") Long userId, + @Schema(title = "채팅 메시지 ID", example = "1001") Long chatMessageId, + @Schema(title = "메시지 내용", example = "안녕하세요") String content, + @Schema(title = "메시지 생성 시간", example = "2023-10-05 14:30:00") @JsonSerialize(using = LocalDateTimeSerializer.class) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt diff --git a/src/main/java/com/rabbitmqprac/application/dto/chatroom/req/ChatRoomCreateReq.java b/src/main/java/com/rabbitmqprac/application/dto/chatroom/req/ChatRoomCreateReq.java index a3d529b..947e292 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/chatroom/req/ChatRoomCreateReq.java +++ b/src/main/java/com/rabbitmqprac/application/dto/chatroom/req/ChatRoomCreateReq.java @@ -1,13 +1,17 @@ package com.rabbitmqprac.application.dto.chatroom.req; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +@Schema(name = "chatRoomCreateReq", title = "채팅방 생성 요청 DTO") public record ChatRoomCreateReq( + @Schema(description = "채팅방 제목", example = "스프링 스터디") @NotBlank @Size(min = 1, max = 20) String title, + @Schema(description = "채팅방 최대 인원", example = "5") @NotNull @Size(min = 2, max = 10) Integer maxCapacity diff --git a/src/main/java/com/rabbitmqprac/application/dto/chatroom/res/ChatRoomDetailRes.java b/src/main/java/com/rabbitmqprac/application/dto/chatroom/res/ChatRoomDetailRes.java index 4d506c2..bfd360b 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/chatroom/res/ChatRoomDetailRes.java +++ b/src/main/java/com/rabbitmqprac/application/dto/chatroom/res/ChatRoomDetailRes.java @@ -4,12 +4,17 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import com.rabbitmqprac.application.dto.chatmessage.res.LastChatMessageDetailRes; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; +@Schema(name = "ChatRoomDetailRes", title = "채팅방 상세 DTO") public record ChatRoomDetailRes( + @Schema(title = "채팅방 ID", example = "1") Long chatRoomId, + @Schema(title = "채팅방 제목", example = "스터디 그룹") String title, + @Schema(title = "최대 인원", example = "10") Integer maxCapacity, @JsonSerialize(using = LocalDateTimeSerializer.class) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @@ -17,10 +22,13 @@ public record ChatRoomDetailRes( LastChatMessageDetailRes lastMessage, + @Schema(title = "현재 인원", example = "5") int currentCapacity, + @Schema(title = "마지막으로 읽은 메시지 ID", example = "1005") Long lastReadMessageId, + @Schema(title = "읽지 않은 메시지 수", example = "3") int unreadMessageCount ) { public static ChatRoomDetailRes of(Long chatRoomId, diff --git a/src/main/java/com/rabbitmqprac/application/dto/chatroom/res/ChatRoomInfoRes.java b/src/main/java/com/rabbitmqprac/application/dto/chatroom/res/ChatRoomInfoRes.java deleted file mode 100644 index 29c7699..0000000 --- a/src/main/java/com/rabbitmqprac/application/dto/chatroom/res/ChatRoomInfoRes.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.rabbitmqprac.application.dto.chatroom.res; - -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; - -import java.time.LocalDateTime; - -public record ChatRoomInfoRes( - Long chatRoomId, - String title, - Integer maxCapacity, - @JsonSerialize(using = LocalDateTimeSerializer.class) - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - LocalDateTime createdAt, - - int currentCapacity, - - Boolean isJoined -) { - public static ChatRoomInfoRes of(Long chatRoomId, - String title, - Integer maxCapacity, - LocalDateTime createdAt, - int currentCapacity, - Boolean isJoined - ) { - return new ChatRoomInfoRes(chatRoomId, title, maxCapacity, createdAt, currentCapacity, isJoined); - } -} diff --git a/src/main/java/com/rabbitmqprac/application/dto/chatroom/res/ChatRoomSummaryRes.java b/src/main/java/com/rabbitmqprac/application/dto/chatroom/res/ChatRoomSummaryRes.java new file mode 100644 index 0000000..fe00b41 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/dto/chatroom/res/ChatRoomSummaryRes.java @@ -0,0 +1,38 @@ +package com.rabbitmqprac.application.dto.chatroom.res; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(name = "ChatRoomInfoRes", title = "채팅방 정보 DTO") +public record ChatRoomSummaryRes( + @Schema(title = "채팅방 ID", example = "1") + Long chatRoomId, + @Schema(title = "채팅방 제목", example = "스프링 스터디") + String title, + @Schema(title = "최대 인원", example = "10") + Integer maxCapacity, + @Schema(title = "채팅방 생성 시간", example = "2023-10-05 14:30:00") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + + @Schema(title = "현재 인원", example = "5") + int currentCapacity, + + @Schema(title = "참여 여부", example = "true") + Boolean isJoined +) { + public static ChatRoomSummaryRes of(Long chatRoomId, + String title, + Integer maxCapacity, + LocalDateTime createdAt, + int currentCapacity, + Boolean isJoined + ) { + return new ChatRoomSummaryRes(chatRoomId, title, maxCapacity, createdAt, currentCapacity, isJoined); + } +} diff --git a/src/main/java/com/rabbitmqprac/application/dto/chatroommember/req/ChatRoomMemberCreateReq.java b/src/main/java/com/rabbitmqprac/application/dto/chatroommember/req/ChatRoomMemberCreateReq.java deleted file mode 100644 index 77f0051..0000000 --- a/src/main/java/com/rabbitmqprac/application/dto/chatroommember/req/ChatRoomMemberCreateReq.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.rabbitmqprac.application.dto.chatroommember.req; - -import lombok.Getter; - -@Getter -public class ChatRoomMemberCreateReq { - private Long userId; -} diff --git a/src/main/java/com/rabbitmqprac/application/dto/chatroommember/res/ChatRoomMemberDetailRes.java b/src/main/java/com/rabbitmqprac/application/dto/chatroommember/res/ChatRoomMemberDetailRes.java index 4b011a2..53be1df 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/chatroommember/res/ChatRoomMemberDetailRes.java +++ b/src/main/java/com/rabbitmqprac/application/dto/chatroommember/res/ChatRoomMemberDetailRes.java @@ -1,7 +1,12 @@ package com.rabbitmqprac.application.dto.chatroommember.res; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "ChatRoomMemberDetailRes", title = "채팅방 멤버 상세 DTO") public record ChatRoomMemberDetailRes( + @Schema(title = "유저 ID", example = "1") Long userId, + @Schema(title = "닉네임", example = "홍길동") String nickname ) { diff --git a/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignInReq.java b/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignInReq.java index c53dee6..5730a29 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignInReq.java +++ b/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignInReq.java @@ -1,9 +1,12 @@ package com.rabbitmqprac.application.dto.oauth.req; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +@Schema(name = "oauthSignInReq", title = "소셜 로그인 요청 DTO") public record OauthSignInReq( - @NotBlank(message = "OIDC CODE 필수 입력값입니다.") + @Schema(title = "OAUTH CODE", example = "4/P7q7W91a-oMsCeLvIaQm6bTrgtp7") + @NotBlank(message = "OAUTH CODE는 필수 입력값입니다.") String code ) { } diff --git a/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignUpReq.java b/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignUpReq.java index ce91d43..b6b9788 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignUpReq.java +++ b/src/main/java/com/rabbitmqprac/application/dto/oauth/req/OauthSignUpReq.java @@ -1,11 +1,15 @@ package com.rabbitmqprac.application.dto.oauth.req; import com.rabbitmqprac.global.annotation.Nickname; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +@Schema(name = "oauthSignUpReq", title = "소셜 회원가입 요청 DTO") public record OauthSignUpReq( - @NotBlank(message = "OIDC CODE는 필수 입력값입니다.") + @Schema(title = "OAUTH CODE", example = "4/P7q7W91a-oMsCeLvIaQm6bTrgtp7") + @NotBlank(message = "OAUTH CODE는 필수 입력값입니다.") String code, + @Schema(title = "닉네임", example = "RabbitMaster") @NotBlank(message = "닉네임을 입력해주세요") @Nickname String nickname diff --git a/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameCheckReq.java b/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameCheckReq.java index b24e13d..0aad597 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameCheckReq.java +++ b/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameCheckReq.java @@ -1,8 +1,11 @@ package com.rabbitmqprac.application.dto.user.req; import com.rabbitmqprac.global.annotation.Nickname; +import io.swagger.v3.oas.annotations.media.Schema; +@Schema(name = "nicknameCheckReq", title = "닉네임 중복 확인 요청 DTO") public record NicknameCheckReq( + @Schema(description = "닉네임", example = "RabbitMaster") @Nickname String nickname ) { diff --git a/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameUpdateReq.java b/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameUpdateReq.java index d032594..2254d93 100644 --- a/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameUpdateReq.java +++ b/src/main/java/com/rabbitmqprac/application/dto/user/req/NicknameUpdateReq.java @@ -1,9 +1,12 @@ package com.rabbitmqprac.application.dto.user.req; import com.rabbitmqprac.global.annotation.Nickname; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +@Schema(name = "nicknameUpdateReq", title = "닉네임 변경 요청 DTO") public record NicknameUpdateReq( + @Schema(description = "닉네임", example = "RabbitMaster") @NotBlank(message = "닉네임을 입력해주세요") @Nickname String nickname diff --git a/src/main/java/com/rabbitmqprac/application/dto/user/res/UserDetailRes.java b/src/main/java/com/rabbitmqprac/application/dto/user/res/UserDetailRes.java new file mode 100644 index 0000000..c561d37 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/dto/user/res/UserDetailRes.java @@ -0,0 +1,15 @@ +package com.rabbitmqprac.application.dto.user.res; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "userDetailRes", title = "회원 상세정보 응답 DTO") +public record UserDetailRes( + @Schema(title = "유저 ID", example = "1") + Long userId, + @Schema(title = "닉네임", example = "RabbitMaster") + String nickname +) { + public static UserDetailRes of(Long memberId, String nickname) { + return new UserDetailRes(memberId, nickname); + } +} diff --git a/src/main/java/com/rabbitmqprac/application/dto/user/res/UserIdRes.java b/src/main/java/com/rabbitmqprac/application/dto/user/res/UserIdRes.java new file mode 100644 index 0000000..069d202 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/application/dto/user/res/UserIdRes.java @@ -0,0 +1,13 @@ +package com.rabbitmqprac.application.dto.user.res; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "userIdRes", title = "회원 ID 응답 DTO") +public record UserIdRes( + @Schema(title = "유저 ID", example = "1") + Long userId +) { + public static UserIdRes of(Long userId) { + return new UserIdRes(userId); + } +} diff --git a/src/main/java/com/rabbitmqprac/application/mapper/ChatRoomMapper.java b/src/main/java/com/rabbitmqprac/application/mapper/ChatRoomMapper.java index de1acfb..ffb1514 100644 --- a/src/main/java/com/rabbitmqprac/application/mapper/ChatRoomMapper.java +++ b/src/main/java/com/rabbitmqprac/application/mapper/ChatRoomMapper.java @@ -2,7 +2,7 @@ import com.rabbitmqprac.application.dto.chatmessage.res.LastChatMessageDetailRes; import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomDetailRes; -import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomInfoRes; +import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomSummaryRes; import com.rabbitmqprac.domain.persistence.chatroom.entity.ChatRoom; import com.rabbitmqprac.global.annotation.Mapper; @@ -39,8 +39,8 @@ public static ChatRoomDetailRes toDetailRes(ChatRoom chatRoom, ); } - public static ChatRoomInfoRes toInfoRes(ChatRoom chatRoom, int currentCapacity, Boolean isJoined) { - return ChatRoomInfoRes.of( + public static ChatRoomSummaryRes toInfoRes(ChatRoom chatRoom, int currentCapacity, Boolean isJoined) { + return ChatRoomSummaryRes.of( chatRoom.getId(), chatRoom.getTitle(), chatRoom.getMaxCapacity(), diff --git a/src/main/java/com/rabbitmqprac/application/mapper/UserMapper.java b/src/main/java/com/rabbitmqprac/application/mapper/UserMapper.java index 6b75b17..bc0ce63 100644 --- a/src/main/java/com/rabbitmqprac/application/mapper/UserMapper.java +++ b/src/main/java/com/rabbitmqprac/application/mapper/UserMapper.java @@ -1,6 +1,6 @@ package com.rabbitmqprac.application.mapper; -import com.rabbitmqprac.application.dto.auth.res.UserDetailRes; +import com.rabbitmqprac.application.dto.user.res.UserDetailRes; import com.rabbitmqprac.domain.persistence.user.entity.User; import com.rabbitmqprac.global.annotation.Mapper; diff --git a/src/main/java/com/rabbitmqprac/config/SwaggerConfig.java b/src/main/java/com/rabbitmqprac/config/SwaggerConfig.java new file mode 100644 index 0000000..7371999 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/config/SwaggerConfig.java @@ -0,0 +1,83 @@ +package com.rabbitmqprac.config; + +import com.rabbitmqprac.global.util.ApiExceptionExplainParser; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.servers.Server; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.ExternalDocumentation; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.web.filter.ForwardedHeaderFilter; +import org.springframework.web.method.HandlerMethod; + +import static org.springframework.security.config.Elements.JWT; + +@OpenAPIDefinition( + servers = { + @Server(url = "${rabbit.server.domain.local}", description = "Local Server"), + @Server(url = "${rabbit.server.domain.dev}", description = "Develop Server") + } +) +@Configuration +@RequiredArgsConstructor +public class SwaggerConfig { + private final Environment environment; + + @Bean + public OpenAPI openAPI() { + String[] profiles = environment.getActiveProfiles(); + String activeProfile = (profiles.length > 0) ? profiles[0] : "default"; + + SecurityRequirement securityRequirement = new SecurityRequirement().addList(JWT); + + return new OpenAPI() + .info(apiInfo(activeProfile)) + .addSecurityItem(securityRequirement) + .components(securitySchemes()) + .externalDocs(new ExternalDocumentation() + .description("Rabbit API GitHub") + .url("https://github.com/lsh2613/RabbitChat") + ); + } + + @Bean + public ForwardedHeaderFilter forwardedHeaderFilter() { + return new ForwardedHeaderFilter(); + } + + @Bean + public OperationCustomizer customizer() { + return (Operation operation, HandlerMethod handlerMethod) -> { + ApiExceptionExplainParser.parse(operation, handlerMethod); + return operation; + }; + } + + private Info apiInfo(String activeProfile) { + return new Info() + .title("Rabbit API (" + activeProfile + ")") + .description("채팅 플랫폼 Rabbit API 명세서") + .version("v1.0.0"); + } + + private Components securitySchemes() { + final var securitySchemeAccessToken = new SecurityScheme() + .name(JWT) + .type(SecurityScheme.Type.HTTP) + .scheme("Bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization"); + + return new Components() + .addSecuritySchemes(JWT, securitySchemeAccessToken); + } +} diff --git a/src/main/java/com/rabbitmqprac/domain/context/chatroom/service/ChatRoomService.java b/src/main/java/com/rabbitmqprac/domain/context/chatroom/service/ChatRoomService.java index af0573d..1c9d20b 100644 --- a/src/main/java/com/rabbitmqprac/domain/context/chatroom/service/ChatRoomService.java +++ b/src/main/java/com/rabbitmqprac/domain/context/chatroom/service/ChatRoomService.java @@ -3,7 +3,7 @@ import com.rabbitmqprac.application.dto.chatroom.req.ChatRoomCreateReq; import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomDetailRes; -import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomInfoRes; +import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomSummaryRes; import com.rabbitmqprac.application.mapper.ChatMessageMapper; import com.rabbitmqprac.application.mapper.ChatRoomMapper; import com.rabbitmqprac.domain.context.chatmessage.service.ChatMessageService; @@ -73,7 +73,7 @@ public List getMyChatRooms(Long userId) { } @Transactional(readOnly = true) - public List getChatRooms(Optional userId) { + public List getChatRooms(Optional userId) { List chatRooms = chatRoomRepository.findAll(); return chatRooms.stream() diff --git a/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java b/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java index c591fcc..a79e022 100644 --- a/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java +++ b/src/main/java/com/rabbitmqprac/domain/context/user/service/UserService.java @@ -1,7 +1,7 @@ package com.rabbitmqprac.domain.context.user.service; -import com.rabbitmqprac.application.dto.auth.res.UserDetailRes; +import com.rabbitmqprac.application.dto.user.res.UserDetailRes; import com.rabbitmqprac.application.dto.user.req.NicknameCheckReq; import com.rabbitmqprac.application.dto.user.req.NicknameUpdateReq; import com.rabbitmqprac.application.mapper.UserMapper; diff --git a/src/main/java/com/rabbitmqprac/global/annotation/ApiExceptionExplanation.java b/src/main/java/com/rabbitmqprac/global/annotation/ApiExceptionExplanation.java new file mode 100644 index 0000000..1dbedfc --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/annotation/ApiExceptionExplanation.java @@ -0,0 +1,39 @@ +package com.rabbitmqprac.global.annotation; + +import com.rabbitmqprac.global.exception.payload.BaseErrorCode; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Swagger 문서화에 사용되는 API 예외 응답 데이터를 담기 위한 어노테이션입니다. + *
+ *
+ * - errorCodes: BaseErrorCode를 구현한 Enum 클래스 타입을 지정합니다.
+ * - constants: Enum 클래스의 상수명을 콤마(,)로 구분하여 여러 개 지정할 수 있습니다.
+ *   예시: "INVALID_INPUT,RESOURCE_NOT_FOUND"
+ * - mediaType: 응답 미디어 타입을 지정합니다. 기본값은 "application/json"입니다.
+ * 
+ * + *

+ * {@code
+ * @ApiExceptionExplanation(
+ *     errorCodes = OauthErrorCode.class,
+ *     constants = "MISSING_ISS, INVALID_ISS"
+ * )
+ * }
+ * 
+ * + * 여러 ErrorCode Enum을 지정해야 하는 경우 {@link ApiExceptionExplanations} 어노테이션을 사용합니다. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiExceptionExplanation { + Class errorCode(); + + String[] constants(); + + String mediaType() default "application/json"; +} diff --git a/src/main/java/com/rabbitmqprac/global/annotation/ApiExceptionExplanations.java b/src/main/java/com/rabbitmqprac/global/annotation/ApiExceptionExplanations.java new file mode 100644 index 0000000..a799a4a --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/annotation/ApiExceptionExplanations.java @@ -0,0 +1,36 @@ +package com.rabbitmqprac.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 여러 예외 응답 예시를 한 번에 문서화할 때 사용하는 어노테이션입니다. + *
+ *
+ * - value: {@link ApiExceptionExplanation} 어노테이션 배열을 받아 여러 예외 상황을 한 번에 기술할 수 있습니다.
+ * 
+ * + *

+ * {@code
+ * @ApiResponseExplanations({
+ *     @ApiExceptionExplanation(
+ *         errorCodes = OauthErrorCode.class,
+ *         constants = "MISSING_ISS, INVALID_ISS"
+ *     ),
+ *     @ApiExceptionExplanation(
+ *         errorCodes = OauthErrorCode.class,
+ *         constants = "EXPIRED_TOKEN"
+ *     )
+ * })
+ * }
+ * 
+ *

+ * 단일 예외만 문서화할 경우 {@link ApiExceptionExplanation}만 사용해도 됩니다. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiExceptionExplanations { + ApiExceptionExplanation[] value() default {}; +} diff --git a/src/main/java/com/rabbitmqprac/global/exception/payload/ErrorResponse.java b/src/main/java/com/rabbitmqprac/global/exception/payload/ErrorResponse.java index 6e9cb2a..65727d2 100644 --- a/src/main/java/com/rabbitmqprac/global/exception/payload/ErrorResponse.java +++ b/src/main/java/com/rabbitmqprac/global/exception/payload/ErrorResponse.java @@ -1,6 +1,7 @@ package com.rabbitmqprac.global.exception.payload; import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; import org.springframework.validation.BindingResult; @@ -9,11 +10,19 @@ import java.util.HashMap; import java.util.Map; +@Schema(title = "API 응답 - 실패 및 에러") @Builder @Getter public class ErrorResponse { + @Schema( + title = "에러 코드", + description = "정의된 에러의 4~6자리 정수형 문자열로 상태코드(3)+이유코드(1)+도메인코드(1)+필드코드(1)로 구성됩니다.", + example = "40401" + ) private String code; + @Schema(title = "에러 이유", description = "에러 코드의 이유코드에 해당하며, 에러 원인의 디테일한 상태값을 제공", example = "REQUESTED_RESOURCE_NOT_FOUND") private String reason; + @Schema(title = "에러 메시지", description = "에러 메시지", example = "회원을 찾을 수 없습니다.") private String message; @JsonInclude(JsonInclude.Include.NON_NULL) private Object fieldErrors; diff --git a/src/main/java/com/rabbitmqprac/global/util/ApiExceptionExplainParser.java b/src/main/java/com/rabbitmqprac/global/util/ApiExceptionExplainParser.java new file mode 100644 index 0000000..32c0289 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/util/ApiExceptionExplainParser.java @@ -0,0 +1,103 @@ +package com.rabbitmqprac.global.util; + +import com.rabbitmqprac.global.annotation.ApiExceptionExplanation; +import com.rabbitmqprac.global.annotation.ApiExceptionExplanations; +import com.rabbitmqprac.global.exception.payload.BaseErrorCode; +import com.rabbitmqprac.global.exception.payload.CausedBy; +import com.rabbitmqprac.global.exception.payload.ErrorResponse; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import lombok.AccessLevel; +import lombok.Builder; +import org.springframework.util.StringUtils; +import org.springframework.web.method.HandlerMethod; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public final class ApiExceptionExplainParser { + public static void parse(Operation operation, HandlerMethod handlerMethod) { + ApiExceptionExplanations explanations = handlerMethod.getMethodAnnotation(ApiExceptionExplanations.class); + if (explanations != null) { + generateExceptionResponseDocs(operation, explanations.value()); + return; + } + ApiExceptionExplanation explanation = handlerMethod.getMethodAnnotation(ApiExceptionExplanation.class); + if (explanation != null) { + generateExceptionResponseDocs(operation, new ApiExceptionExplanation[]{explanation}); + } + } + + private static void generateExceptionResponseDocs(Operation operation, ApiExceptionExplanation[] exceptions) { + ApiResponses responses = operation.getResponses(); + + Map> holders = Arrays.stream(exceptions) + .flatMap(ApiExceptionExplainParser::expandExampleHolders) + .collect(Collectors.groupingBy(ExampleHolder::httpStatus)); + + addExamplesToResponses(responses, holders); + } + + private static Stream expandExampleHolders(ApiExceptionExplanation annotation) { + String[] constantsArr = annotation.constants(); + if (constantsArr == null || constantsArr.length == 0) { + return Stream.of(ExampleHolder.from(annotation, null)); + } + return Arrays.stream(constantsArr) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(constant -> ExampleHolder.from(annotation, constant)); + } + + private static void addExamplesToResponses(ApiResponses responses, Map> holders) { + holders.forEach((httpStatus, exampleHolders) -> { + Content content = new Content(); + MediaType mediaType = new MediaType(); + ApiResponse response = new ApiResponse(); + + exampleHolders.forEach(holder -> mediaType.addExamples(holder.name(), holder.holder())); + content.addMediaType("application/json", mediaType); + response.setContent(content); + + responses.addApiResponse(String.valueOf(httpStatus), response); + }); + } + + @Builder(access = AccessLevel.PRIVATE) + private record ExampleHolder(int httpStatus, String name, String mediaType, String description, Example holder) { + static ExampleHolder from(ApiExceptionExplanation annotation, String constantOverride) { + BaseErrorCode errorCode = getErrorCode(annotation, constantOverride); + + return ExampleHolder.builder() + .httpStatus(errorCode.causedBy().statusCode().getCode()) + .name(errorCode.getExplainError()) + .mediaType(annotation.mediaType()) + .holder(createExample(errorCode)) + .build(); + } + + @SuppressWarnings("unchecked") + public static & BaseErrorCode> E getErrorCode(ApiExceptionExplanation annotation, String constantOverride) { + Class enumClass = (Class) annotation.errorCode(); + String constant = constantOverride != null ? constantOverride : (annotation.constants().length > 0 ? annotation.constants()[0] : null); + return Enum.valueOf(enumClass, constant); + } + + private static Example createExample(BaseErrorCode errorCode) { + CausedBy causedBy = errorCode.causedBy(); + ErrorResponse response = ErrorResponse.of(causedBy.getCode(), causedBy.getReason(), errorCode.getExplainError()); + + Example example = new Example(); + example.setValue(response); + + return example; + } + } +} diff --git a/src/main/java/com/rabbitmqprac/global/util/AuthenticationResponseUtil.java b/src/main/java/com/rabbitmqprac/global/util/AuthenticationResponseUtil.java new file mode 100644 index 0000000..c028e8e --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/util/AuthenticationResponseUtil.java @@ -0,0 +1,27 @@ +package com.rabbitmqprac.global.util; + +import com.rabbitmqprac.application.dto.user.res.UserIdRes; +import com.rabbitmqprac.global.annotation.Util; +import com.rabbitmqprac.infra.security.jwt.Jwts; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; + +import java.time.Duration; + +@Util +public final class AuthenticationResponseUtil { + public static ResponseEntity createAuthenticatedResponse(Pair userInfo) { + ResponseCookie cookie = CookieUtil.createCookie( + "refreshToken", userInfo.getValue().refreshToken(), Duration.ofDays(7).toSeconds() + ); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .header(HttpHeaders.AUTHORIZATION, userInfo.getValue().accessToken()) + .body( + UserIdRes.of(userInfo.getKey()) + ); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9a26064..83e2b6f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,8 @@ +rabbit: + server: + domain: + local: ${RABBIT_SERVER_DOMAIN_LOCAL:http://localhost:8080} + dev: ${RABBIT_SERVER_DOMAIN_LOCAL:http://localhost:8080} spring: config: import: optional:file:.env[.properties] @@ -81,6 +86,10 @@ rabbitmq: routing: key: "*.room." +# swagger에서 자동으로 error response를 생성하지 않도록 설정 +springdoc: + override-with-generic-response: false + logging: level: ROOT: INFO diff --git a/src/test/java/com/rabbitmqprac/service/ChatRoomServiceTest.java b/src/test/java/com/rabbitmqprac/service/ChatRoomServiceTest.java index 0c66c73..86956e9 100644 --- a/src/test/java/com/rabbitmqprac/service/ChatRoomServiceTest.java +++ b/src/test/java/com/rabbitmqprac/service/ChatRoomServiceTest.java @@ -2,7 +2,7 @@ import com.rabbitmqprac.application.dto.chatroom.req.ChatRoomCreateReq; import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomDetailRes; -import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomInfoRes; +import com.rabbitmqprac.application.dto.chatroom.res.ChatRoomSummaryRes; import com.rabbitmqprac.common.fixture.ChatRoomFixture; import com.rabbitmqprac.common.fixture.UserFixture; import com.rabbitmqprac.domain.context.chatmessage.service.ChatMessageService; @@ -142,13 +142,13 @@ void getChatRoomsWhenNotLoggedIn() { given(chatRoomRepository.findAll()).willReturn(List.of(chatRoom)); // when - List result = chatRoomService.getChatRooms(Optional.ofNullable(null)); + List result = chatRoomService.getChatRooms(Optional.ofNullable(null)); // then assertThat(result).isNotNull(); assertThat(result.size()).isEqualTo(1); - ChatRoomInfoRes res = result.getFirst(); + ChatRoomSummaryRes res = result.getFirst(); assertThat(res.isJoined()).isFalse(); } @@ -160,13 +160,13 @@ void getChatRoomsWhenLoggedIn() { given(chatRoomMemberService.isExists(chatRoom.getId(), user.getId())).willReturn(true); // when - List result = chatRoomService.getChatRooms(Optional.of(user.getId())); + List result = chatRoomService.getChatRooms(Optional.of(user.getId())); // then assertThat(result).isNotNull(); assertThat(result.size()).isEqualTo(1); - ChatRoomInfoRes res = result.getFirst(); + ChatRoomSummaryRes res = result.getFirst(); assertThat(res.isJoined()).isTrue(); } } diff --git a/src/test/java/com/rabbitmqprac/service/UserServiceTest.java b/src/test/java/com/rabbitmqprac/service/UserServiceTest.java index cc3720c..f928f74 100644 --- a/src/test/java/com/rabbitmqprac/service/UserServiceTest.java +++ b/src/test/java/com/rabbitmqprac/service/UserServiceTest.java @@ -1,6 +1,6 @@ package com.rabbitmqprac.service; -import com.rabbitmqprac.application.dto.auth.res.UserDetailRes; +import com.rabbitmqprac.application.dto.user.res.UserDetailRes; import com.rabbitmqprac.application.dto.user.req.NicknameCheckReq; import com.rabbitmqprac.application.dto.user.req.NicknameUpdateReq; import com.rabbitmqprac.common.fixture.UserFixture; From 3ee846a3ff63947d2f9cf384311485a4fdc53fe5 Mon Sep 17 00:00:00 2001 From: LSH <104637774+lsh2613@users.noreply.github.com> Date: Wed, 27 Aug 2025 14:37:59 +0900 Subject: [PATCH 5/9] =?UTF-8?q?Refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rabbitmqprac/service/AuthServiceTest.java | 250 ++++----- .../service/ChatMessageServiceTest.java | 67 ++- .../service/ChatRoomMemberServiceTest.java | 103 ++-- .../service/ChatRoomServiceTest.java | 216 ++++---- .../rabbitmqprac/service/UserServiceTest.java | 494 +++++++++--------- 5 files changed, 587 insertions(+), 543 deletions(-) diff --git a/src/test/java/com/rabbitmqprac/service/AuthServiceTest.java b/src/test/java/com/rabbitmqprac/service/AuthServiceTest.java index 07e2585..e96c96c 100644 --- a/src/test/java/com/rabbitmqprac/service/AuthServiceTest.java +++ b/src/test/java/com/rabbitmqprac/service/AuthServiceTest.java @@ -25,7 +25,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertThrows; import static org.mockito.BDDMockito.any; -import static org.mockito.BDDMockito.anyLong; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.mock; import static org.mockito.BDDMockito.never; @@ -44,145 +43,148 @@ public class AuthServiceTest { @InjectMocks private AuthService authService; - private static User user = UserFixture.FIRST_USER.toEntity(); + private static final User USER = UserFixture.FIRST_USER.toEntity(); + private static final Jwts JWTS = mock(Jwts.class); @Nested - @DisplayName("회원가입 성공 시나리오") - class SignUpSuccessScenarios { - @Test - @DisplayName("회원가입 성공") - void signUp() { - // given - final String nickname = "nickname"; - final String username = "nickname"; - final String password = "password_123"; - final String confirmPassword = "password_123"; - AuthSignUpReq req = new AuthSignUpReq(nickname, username, password, confirmPassword); - Jwts jwts = mock(Jwts.class); - - given(userService.saveUserWithEncryptedPassword(any(UserCreateReq.class))) - .willReturn(user); - given(jwtHelper.createToken(user)).willReturn(jwts); - - // when - Pair result = authService.signUp(req); - - // then - assertThat(result.getLeft()).isEqualTo(user.getId()); - assertThat(result.getRight()).isEqualTo(jwts); - verify(userService).saveUserWithEncryptedPassword(any(UserCreateReq.class)); - verify(jwtHelper).createToken(user); + @DisplayName("회원가입 시나리오") + class SignUpScenario { + private final String nickname = "nickname"; + private final String username = "nickname"; + private final String password = "password_123"; + private final String confirmPassword = "password_123"; + private final AuthSignUpReq req = new AuthSignUpReq(nickname, username, password, confirmPassword); + + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("회원가입 성공") + void signUpSuccess() { + // given + given(userService.saveUserWithEncryptedPassword(any(UserCreateReq.class))).willReturn(USER); + given(jwtHelper.createToken(USER)).willReturn(JWTS); + + // when + Pair result = authService.signUp(req); + + // then + assertThat(result.getLeft()).isEqualTo(USER.getId()); + assertThat(result.getRight()).isEqualTo(JWTS); + verify(userService).saveUserWithEncryptedPassword(any(UserCreateReq.class)); + verify(jwtHelper).createToken(USER); + } } - } - @Nested - @DisplayName("회원가입 실패 시나리오") - class SignUpFailScenarios { - @Test - @DisplayName("password와 confirmPassword가 다른 경우 회원 가입에 실패") - void signUpWhenInvalidPassword() { - // given - final String nickname = "nickname"; - final String username = "nickname"; - final String password = "password"; - final String confirmPassword = "invalid_password"; - AuthSignUpReq req = new AuthSignUpReq(nickname, username, password, confirmPassword); - - // when - AuthErrorException errorException = assertThrows(AuthErrorException.class, () -> authService.signUp(req)); - - // then - assertThat(errorException.getErrorCode()).isEqualTo(AuthErrorCode.PASSWORD_CONFIRM_MISMATCH); + @Nested + @DisplayName("실패 시나리오") + class FailScenario { + @Test + @DisplayName("password와 confirmPassword가 다르면 회원가입 실패") + void signUpFailWhenPasswordMismatch() { + // given + AuthSignUpReq invalidReq = new AuthSignUpReq(nickname, username, password, "wrong"); + + // when + AuthErrorException ex = assertThrows(AuthErrorException.class, () -> authService.signUp(invalidReq)); + + // then + assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.PASSWORD_CONFIRM_MISMATCH); + } } } @Nested - @DisplayName("로그인 성공 시나리오") - class SignInSuccessScenarios { - @Test - @DisplayName("로그인 성공") - void signIn() { - // given - final String username = "nickname"; - final String password = "password"; - AuthSignInReq req = new AuthSignInReq(username, password); - Jwts jwts = mock(Jwts.class); - - given(userService.readUserByUsername(username)).willReturn(user); - given(jwtHelper.createToken(user)).willReturn(jwts); - given(bCryptPasswordEncoder.matches(password, user.getPassword())).willReturn(Boolean.TRUE); - - // when - Pair result = authService.signIn(req); - - // then - assertThat(result.getLeft()).isEqualTo(user.getId()); - assertThat(result.getRight()).isEqualTo(jwts); - verify(jwtHelper).createToken(user); + @DisplayName("로그인 시나리오") + class SignInScenario { + private final String username = "nickname"; + private final String password = "password"; + private final AuthSignInReq req = new AuthSignInReq(username, password); + + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("로그인 성공") + void signInSuccess() { + // given + given(userService.readUserByUsername(username)).willReturn(USER); + given(jwtHelper.createToken(USER)).willReturn(JWTS); + given(bCryptPasswordEncoder.matches(password, USER.getPassword())).willReturn(true); + + // when + Pair result = authService.signIn(req); + + // then + assertThat(result.getLeft()).isEqualTo(USER.getId()); + assertThat(result.getRight()).isEqualTo(JWTS); + verify(jwtHelper).createToken(USER); + } } - } - @Nested - @DisplayName("로그인 실패 시나리오") - class SignInFailScenarios { - @Test - @DisplayName("로그인 유저의 패스워드가 올바르지 않다면 로그인 실패") - void signInWhenInvalidPassword() { - // given - final String username = "nickname"; - final String password = "password"; - AuthSignInReq req = new AuthSignInReq(username, password); - given(userService.readUserByUsername(username)).willReturn(user); - given(bCryptPasswordEncoder.matches(password, user.getPassword())).willReturn(Boolean.FALSE); - - // when - AuthErrorException errorException = assertThrows(AuthErrorException.class, () -> authService.signIn(req)); - - // then - assertThat(errorException.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_PASSWORD); - verify(jwtHelper, never()).createToken(user); + @Nested + @DisplayName("실패 시나리오") + class FailScenario { + @Test + @DisplayName("패스워드가 올바르지 않으면 로그인 실패") + void signInFailWhenInvalidPassword() { + // given + given(userService.readUserByUsername(username)).willReturn(USER); + given(bCryptPasswordEncoder.matches(password, USER.getPassword())).willReturn(false); + + // when + AuthErrorException ex = assertThrows(AuthErrorException.class, () -> authService.signIn(req)); + + // then + assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_PASSWORD); + verify(jwtHelper, never()).createToken(USER); + } } } @Nested - @DisplayName("패스워드 변경 성공 시나리오") - class UpdatePasswordScenarios { - @Test - @DisplayName("패스워드 변경 성공") - void updatePasswordSuccess() { - // given - final Long userId = anyLong(); - final String oldPassword = "oldPassword"; - final String newPassword = "newPassword"; - AuthUpdatePasswordReq req = new AuthUpdatePasswordReq(oldPassword, newPassword); - - given(userService.readUser(userId)).willReturn(user); - given(bCryptPasswordEncoder.matches(req.oldPassword(), user.getPassword())).willReturn(true); - given(req.newPassword(bCryptPasswordEncoder)).willReturn(newPassword); - - // when - authService.updatePassword(userId, req); - - // then - assertThat(user.getPassword()).isEqualTo(newPassword); + @DisplayName("패스워드 변경 시나리오") + class UpdatePasswordScenario { + private final Long userId = 1L; + private final String oldPassword = "oldPassword"; + private final String newPassword = "newPassword"; + private final AuthUpdatePasswordReq req = new AuthUpdatePasswordReq(oldPassword, newPassword); + + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("패스워드 변경 성공") + void updatePasswordSuccess() { + // given + given(userService.readUser(userId)).willReturn(USER); + given(bCryptPasswordEncoder.matches(oldPassword, USER.getPassword())).willReturn(true); + given(req.newPassword(bCryptPasswordEncoder)).willReturn(newPassword); + + // when + authService.updatePassword(userId, req); + + // then + assertThat(USER.getPassword()).isEqualTo(newPassword); + } } - @Test - @DisplayName("기존 패스워드가 틀리면 변경 실패") - void updatePasswordFailWhenInvalidOldPassword() { - // given - final Long userId = anyLong(); - final String oldPassword = "oldPassword"; - final String newPassword = "newPassword"; - AuthUpdatePasswordReq req = new AuthUpdatePasswordReq(oldPassword, newPassword); - given(userService.readUser(userId)).willReturn(user); - given(bCryptPasswordEncoder.matches(oldPassword, user.getPassword())).willReturn(false); - - // when - AuthErrorException errorException = assertThrows(AuthErrorException.class, () -> authService.updatePassword(userId, req)); - - // then - assertThat(errorException.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_PASSWORD); + @Nested + @DisplayName("실패 시나리오") + class FailScenario { + @Test + @DisplayName("기존 패스워드가 틀리면 변경 실패") + void updatePasswordFailWhenInvalidOldPassword() { + // given + given(userService.readUser(userId)).willReturn(USER); + given(bCryptPasswordEncoder.matches(oldPassword, USER.getPassword())).willReturn(false); + + // when + AuthErrorException ex = assertThrows(AuthErrorException.class, () -> authService.updatePassword(userId, req)); + + // then + assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_PASSWORD); + } } } } diff --git a/src/test/java/com/rabbitmqprac/service/ChatMessageServiceTest.java b/src/test/java/com/rabbitmqprac/service/ChatMessageServiceTest.java index a7a924b..713bf3a 100644 --- a/src/test/java/com/rabbitmqprac/service/ChatMessageServiceTest.java +++ b/src/test/java/com/rabbitmqprac/service/ChatMessageServiceTest.java @@ -55,37 +55,37 @@ public class ChatMessageServiceTest { private static final ChatRoomFixture CHAT_ROOM_FIXTURE = ChatRoomFixture.FIRST_CHAT_ROOM; private static final ChatMessageFixture CHAT_MESSAGE_FIXTURE = ChatMessageFixture.FIRST_CHAT_MESSAGE; - private User user = mock(User.class); - private ChatRoom chatRoom = mock(ChatRoom.class); - private ChatMessage chatMessage = mock(ChatMessage.class); + private static final User USER = mock(User.class); + private static final ChatRoom CHAT_ROOM = mock(ChatRoom.class); + private static final ChatMessage CHAT_MESSAGE = mock(ChatMessage.class); @BeforeEach void setUp() { - given(user.getId()).willReturn(USER_FIXTURE.getId()); - given(chatRoom.getId()).willReturn(CHAT_ROOM_FIXTURE.getId()); - - given(chatMessage.getId()).willReturn(CHAT_MESSAGE_FIXTURE.getId()); - given(chatMessage.getUser()).willReturn(user); - given(chatMessage.getChatRoom()).willReturn(chatRoom); + given(USER.getId()).willReturn(USER_FIXTURE.getId()); + given(CHAT_ROOM.getId()).willReturn(CHAT_ROOM_FIXTURE.getId()); + given(CHAT_MESSAGE.getId()).willReturn(CHAT_MESSAGE_FIXTURE.getId()); + given(CHAT_MESSAGE.getUser()).willReturn(USER); + given(CHAT_MESSAGE.getChatRoom()).willReturn(CHAT_ROOM); } @Nested @DisplayName("채팅 메시지 전송 시나리오") class SendMessageScenario { + private final ChatMessageReq req = new ChatMessageReq("content"); + @Nested - @DisplayName("채팅 메시지 전송 성공 시나리오") - class SendMessageSuccessScenario { + @DisplayName("성공 시나리오") + class SuccessScenario { @Test - @DisplayName("성공") + @DisplayName("채팅 메시지 전송 성공") void sendMessageSuccess() { // given - ChatMessageReq req = new ChatMessageReq("content"); - given(entityFacade.readUser(user.getId())).willReturn(user); - given(entityFacade.readChatRoom(chatRoom.getId())).willReturn(chatRoom); - given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(chatMessage); + given(entityFacade.readUser(USER.getId())).willReturn(USER); + given(entityFacade.readChatRoom(CHAT_ROOM.getId())).willReturn(CHAT_ROOM); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(CHAT_MESSAGE); // when - chatMessageService.sendMessage(user.getId(), chatRoom.getId(), req); + chatMessageService.sendMessage(USER.getId(), CHAT_ROOM.getId(), req); // then verify(rabbitPublisher).publish(any(Long.class), any(ChatMessageRes.class)); @@ -96,24 +96,24 @@ void sendMessageSuccess() { @Nested @DisplayName("채팅 메시지 범위 조회 시나리오") class ReadMessageBetweenScenario { + private final Long from = 2L; + private final Long to = 10L; + @Nested - @DisplayName("채팅 메시지 범위 조회 성공 시나리오") - class ReadMessageBetweenSuccessScenario { + @DisplayName("성공 시나리오") + class SuccessScenario { @Test @DisplayName("안 읽은 메시지가 존재하는 경우") void readMessageBetweenSuccessWhenExistsUnReadMessage() { // given - final Long from = 2L; - final Long to = 10L; - given(chatMessageRepository.findByChatRoomIdAndIdBetween(user.getId(), from, to)) - .willReturn(List.of(chatMessage)); - - long lastMessageId = chatRoom.getId() - 1; // chatRoomId보다 작은 값 - given(chatMessageStatusService.readLastReadMessageId(user.getId(), chatRoom.getId())) + given(chatMessageRepository.findByChatRoomIdAndIdBetween(CHAT_ROOM.getId(), from, to)) + .willReturn(List.of(CHAT_MESSAGE)); + long lastMessageId = CHAT_ROOM.getId() - 1; + given(chatMessageStatusService.readLastReadMessageId(USER.getId(), CHAT_ROOM.getId())) .willReturn(lastMessageId); // when - chatMessageService.readChatMessagesBetween(user.getId(), chatRoom.getId(), from, to); + chatMessageService.readChatMessagesBetween(USER.getId(), CHAT_ROOM.getId(), from, to); // then verify(chatMessageStatusService).saveLastReadMessageId(any(Long.class), any(Long.class), any(Long.class)); @@ -123,17 +123,14 @@ void readMessageBetweenSuccessWhenExistsUnReadMessage() { @DisplayName("안 읽은 메시지가 존재하지 않는 경우") void readMessageBetweenSuccessWhenNotExistsUnReadMessage() { // given - final Long from = 2L; - final Long to = 10L; - given(chatMessageRepository.findByChatRoomIdAndIdBetween(user.getId(), from, to)) - .willReturn(List.of(chatMessage)); - - long lastMessageId = chatRoom.getId() + 1; // chatRoomId보다 큰 값 - given(chatMessageStatusService.readLastReadMessageId(user.getId(), chatRoom.getId())) + given(chatMessageRepository.findByChatRoomIdAndIdBetween(CHAT_ROOM.getId(), from, to)) + .willReturn(List.of(CHAT_MESSAGE)); + long lastMessageId = CHAT_ROOM.getId() + 1; + given(chatMessageStatusService.readLastReadMessageId(USER.getId(), CHAT_ROOM.getId())) .willReturn(lastMessageId); // when - chatMessageService.readChatMessagesBetween(user.getId(), chatRoom.getId(), from, to); + chatMessageService.readChatMessagesBetween(USER.getId(), CHAT_ROOM.getId(), from, to); // then verify(chatMessageStatusService, never()).saveLastReadMessageId(any(Long.class), any(Long.class), any(Long.class)); diff --git a/src/test/java/com/rabbitmqprac/service/ChatRoomMemberServiceTest.java b/src/test/java/com/rabbitmqprac/service/ChatRoomMemberServiceTest.java index 34ba020..08fa11e 100644 --- a/src/test/java/com/rabbitmqprac/service/ChatRoomMemberServiceTest.java +++ b/src/test/java/com/rabbitmqprac/service/ChatRoomMemberServiceTest.java @@ -38,41 +38,46 @@ class ChatRoomMemberServiceTest { @InjectMocks private ChatRoomMemberService chatRoomMemberService; - private static final User user = UserFixture.FIRST_USER.toEntity(); - private static final ChatRoom chatRoom = ChatRoomFixture.FIRST_CHAT_ROOM.toEntity(); - private static final ChatRoomMember chatRoomAdmin = ChatRoomMemberFixture.ADMIN.toEntity(chatRoom, user); - private static final ChatRoomMember chatRoomMember = ChatRoomMemberFixture.MEMBER.toEntity(chatRoom, user); + private static final UserFixture USER_FIXTURE = UserFixture.FIRST_USER; + private static final ChatRoomFixture CHAT_ROOM_FIXTURE = ChatRoomFixture.FIRST_CHAT_ROOM; + private static final ChatRoomMemberFixture ADMIN_FIXTURE = ChatRoomMemberFixture.ADMIN; + private static final ChatRoomMemberFixture MEMBER_FIXTURE = ChatRoomMemberFixture.MEMBER; + + private static final User USER = USER_FIXTURE.toEntity(); + private static final ChatRoom CHAT_ROOM = CHAT_ROOM_FIXTURE.toEntity(); + private static final ChatRoomMember CHAT_ROOM_ADMIN = ADMIN_FIXTURE.toEntity(CHAT_ROOM, USER); + private static final ChatRoomMember CHAT_ROOM_MEMBER = MEMBER_FIXTURE.toEntity(CHAT_ROOM, USER); @Nested @DisplayName("채팅방 어드민 생성 시나리오") - class CreateAdminScenarios { + class CreateAdminScenario { @Nested - @DisplayName("채팅방 어드민 생성 성공 시나리오") - class CreateAdminSuccessScenarios { + @DisplayName("성공 시나리오") + class SuccessScenario { @Test - @DisplayName("성공") + @DisplayName("어드민 생성 성공") void createAdminSuccess() { // given - given(chatRoomMemberRepository.existsByChatRoomAndUser(chatRoom, user)).willReturn(Boolean.FALSE); + given(chatRoomMemberRepository.existsByChatRoomAndUser(CHAT_ROOM, USER)).willReturn(false); // when - chatRoomMemberService.createAdmin(user, chatRoom); + chatRoomMemberService.createAdmin(USER, CHAT_ROOM); // then verify(chatRoomMemberRepository).save(any(ChatRoomMember.class)); } } @Nested - @DisplayName("채팅방 어드민 생성 실패 시나리오") - class CreateAdminFailScenarios { + @DisplayName("실패 시나리오") + class FailScenario { @Test - @DisplayName("이미 가입한 유저가 가입 요청 시 CONFLICT 예외") + @DisplayName("이미 가입한 유저가 어드민 생성 요청 시 CONFLICT 예외") void createAdminFailByAlreadyJoined() { // given - given(chatRoomMemberRepository.existsByChatRoomAndUser(chatRoom, user)).willReturn(true); + given(chatRoomMemberRepository.existsByChatRoomAndUser(CHAT_ROOM, USER)).willReturn(true); // when - ChatRoomErrorException ex = assertThrows(ChatRoomErrorException.class, () -> chatRoomMemberService.createAdmin(user, chatRoom)); + ChatRoomErrorException ex = assertThrows(ChatRoomErrorException.class, () -> chatRoomMemberService.createAdmin(USER, CHAT_ROOM)); // then assertThat(ex.getErrorCode()).isEqualTo(ChatRoomErrorCode.CONFLICT); @@ -82,39 +87,39 @@ void createAdminFailByAlreadyJoined() { @Nested @DisplayName("채팅방 가입 시나리오") - class JoinChatRoomScenarios { + class JoinChatRoomScenario { @Nested - @DisplayName("채팅방 가입 성공 시나리오") - class JoinChatRoomSuccessScenarios { + @DisplayName("성공 시나리오") + class SuccessScenario { @Test - @DisplayName("성공") + @DisplayName("채팅방 가입 성공") void joinChatRoomSuccess() { // given - given(entityFacade.readUser(user.getId())).willReturn(user); - given(entityFacade.readChatRoom(chatRoom.getId())).willReturn(chatRoom); - given(chatRoomMemberRepository.existsByChatRoomAndUser(chatRoom, user)).willReturn(false); - given(chatRoomMemberRepository.save(any(ChatRoomMember.class))).willReturn(chatRoomMember); + given(entityFacade.readUser(USER.getId())).willReturn(USER); + given(entityFacade.readChatRoom(CHAT_ROOM.getId())).willReturn(CHAT_ROOM); + given(chatRoomMemberRepository.existsByChatRoomAndUser(CHAT_ROOM, USER)).willReturn(false); + given(chatRoomMemberRepository.save(any(ChatRoomMember.class))).willReturn(CHAT_ROOM_MEMBER); // when - chatRoomMemberService.joinChatRoom(user.getId(), chatRoom.getId()); + chatRoomMemberService.joinChatRoom(USER.getId(), CHAT_ROOM.getId()); // then verify(chatRoomMemberRepository).save(any(ChatRoomMember.class)); } } @Nested - @DisplayName("채팅방 가입 실패 시나리오") - class JoinChatRoomFailScenarios { + @DisplayName("실패 시나리오") + class FailScenario { @Test @DisplayName("이미 가입한 유저가 채팅방 가입 시도 시 CONFLICT 예외") void joinChatRoomFailByAlreadyJoined() { // given - given(entityFacade.readUser(user.getId())).willReturn(user); - given(entityFacade.readChatRoom(chatRoom.getId())).willReturn(chatRoom); - given(chatRoomMemberRepository.existsByChatRoomAndUser(chatRoom, user)).willReturn(true); + given(entityFacade.readUser(USER.getId())).willReturn(USER); + given(entityFacade.readChatRoom(CHAT_ROOM.getId())).willReturn(CHAT_ROOM); + given(chatRoomMemberRepository.existsByChatRoomAndUser(CHAT_ROOM, USER)).willReturn(true); // when - ChatRoomErrorException ex = assertThrows(ChatRoomErrorException.class, () -> chatRoomMemberService.joinChatRoom(user.getId(), chatRoom.getId())); + ChatRoomErrorException ex = assertThrows(ChatRoomErrorException.class, () -> chatRoomMemberService.joinChatRoom(USER.getId(), CHAT_ROOM.getId())); // then assertThat(ex.getErrorCode()).isEqualTo(ChatRoomErrorCode.CONFLICT); @@ -124,19 +129,19 @@ void joinChatRoomFailByAlreadyJoined() { @Nested @DisplayName("채팅방 멤버 조회 시나리오") - class GetChatRoomMembersScenarios { + class GetChatRoomMembersScenario { @Nested - @DisplayName("채팅방 멤버 조회 성공 시나리오") - class GetChatRoomMembersSuccessScenarios { + @DisplayName("성공 시나리오") + class SuccessScenario { @Test - @DisplayName("성공") + @DisplayName("채팅방 멤버 조회 성공") void getChatRoomMembersSuccess() { // given - given(entityFacade.readChatRoom(chatRoom.getId())).willReturn(chatRoom); - given(chatRoomMemberRepository.findAllWithUserByChatRoomId(chatRoom.getId())).willReturn(List.of(chatRoomAdmin, chatRoomMember)); + given(entityFacade.readChatRoom(CHAT_ROOM.getId())).willReturn(CHAT_ROOM); + given(chatRoomMemberRepository.findAllWithUserByChatRoomId(CHAT_ROOM.getId())).willReturn(List.of(CHAT_ROOM_ADMIN, CHAT_ROOM_MEMBER)); // when - var result = chatRoomMemberService.getChatRoomMembers(chatRoom.getId()); + var result = chatRoomMemberService.getChatRoomMembers(CHAT_ROOM.getId()); // then assertThat(result).isNotNull(); @@ -147,18 +152,18 @@ void getChatRoomMembersSuccess() { @Nested @DisplayName("User ID로 채팅방 멤버 조회 시나리오") - class ReadChatRoomMembersByUserIdScenarios { + class ReadChatRoomMembersByUserIdScenario { @Nested - @DisplayName("User ID로 채팅방 멤버 조회 성공 시나리오") - class ReadChatRoomMembersByUserIdSuccessScenarios { + @DisplayName("성공 시나리오") + class SuccessScenario { @Test - @DisplayName("성공") + @DisplayName("User ID로 채팅방 멤버 조회 성공") void readChatRoomMembersByUserIdSuccess() { // given - given(chatRoomMemberRepository.findAllByUserId(user.getId())).willReturn(List.of(chatRoomAdmin, chatRoomMember)); + given(chatRoomMemberRepository.findAllByUserId(USER.getId())).willReturn(List.of(CHAT_ROOM_ADMIN, CHAT_ROOM_MEMBER)); // when - List result = chatRoomMemberService.readChatRoomMembersByUserId(user.getId()); + List result = chatRoomMemberService.readChatRoomMembersByUserId(USER.getId()); // then assertThat(result).hasSize(2); @@ -168,18 +173,18 @@ void readChatRoomMembersByUserIdSuccess() { @Nested @DisplayName("채팅방 인원 조회 시나리오") - class CountChatRoomMembersScenarios { + class CountChatRoomMembersScenario { @Nested - @DisplayName("채팅방 인원 조회 성공 시나리오") - class CountChatRoomMembersSuccessScenarios { + @DisplayName("성공 시나리오") + class SuccessScenario { @Test - @DisplayName("성공") + @DisplayName("채팅방 인원 조회 성공") void countChatRoomMembersSuccess() { // given - given(chatRoomMemberRepository.countByChatRoomId(chatRoom.getId())).willReturn(2); + given(chatRoomMemberRepository.countByChatRoomId(CHAT_ROOM.getId())).willReturn(2); // when - int count = chatRoomMemberService.countChatRoomMembers(chatRoom.getId()); + int count = chatRoomMemberService.countChatRoomMembers(CHAT_ROOM.getId()); // then assertThat(count).isEqualTo(2); diff --git a/src/test/java/com/rabbitmqprac/service/ChatRoomServiceTest.java b/src/test/java/com/rabbitmqprac/service/ChatRoomServiceTest.java index 86956e9..5b7a3a1 100644 --- a/src/test/java/com/rabbitmqprac/service/ChatRoomServiceTest.java +++ b/src/test/java/com/rabbitmqprac/service/ChatRoomServiceTest.java @@ -6,6 +6,7 @@ import com.rabbitmqprac.common.fixture.ChatRoomFixture; import com.rabbitmqprac.common.fixture.UserFixture; import com.rabbitmqprac.domain.context.chatmessage.service.ChatMessageService; +import com.rabbitmqprac.domain.context.chatmessagestatus.service.ChatMessageStatusService; import com.rabbitmqprac.domain.context.chatroom.exception.ChatRoomErrorCode; import com.rabbitmqprac.domain.context.chatroom.exception.ChatRoomErrorException; import com.rabbitmqprac.domain.context.chatroom.service.ChatRoomService; @@ -42,6 +43,8 @@ class ChatRoomServiceTest { @Mock private ChatRoomMemberService chatRoomMemberService; @Mock + private ChatMessageStatusService chatMessageStatusService; + @Mock private ChatRoomRepository chatRoomRepository; @InjectMocks @@ -49,125 +52,136 @@ class ChatRoomServiceTest { private static final UserFixture USER_FIXTURE = UserFixture.FIRST_USER; private static final ChatRoomFixture CHAT_ROOM_FIXTURE = ChatRoomFixture.FIRST_CHAT_ROOM; - - private User user = mock(User.class); - private ChatRoom chatRoom = mock(ChatRoom.class); + private static final User USER = mock(User.class); + private static final ChatRoom CHAT_ROOM = mock(ChatRoom.class); @BeforeEach void setUp() { - given(user.getId()).willReturn(USER_FIXTURE.getId()); - given(chatRoom.getId()).willReturn(CHAT_ROOM_FIXTURE.getId()); + given(USER.getId()).willReturn(USER_FIXTURE.getId()); + given(CHAT_ROOM.getId()).willReturn(CHAT_ROOM_FIXTURE.getId()); + given(CHAT_ROOM.getTitle()).willReturn(CHAT_ROOM_FIXTURE.getTitle()); + given(CHAT_ROOM.getMaxCapacity()).willReturn(CHAT_ROOM_FIXTURE.getMaxCapacity()); } @Nested - @DisplayName("채팅방 생성 성공 시나리오") - class CreateChatRoomSuccessScenarios { - @Test - @DisplayName("채팅방 생성 성공") - void createChatRoom() { - // given - ChatRoomCreateReq req = new ChatRoomCreateReq(chatRoom.getTitle(), chatRoom.getMaxCapacity()); - given(entityFacade.readUser(user.getId())).willReturn(user); - given(chatRoomRepository.save(any(ChatRoom.class))).willReturn(chatRoom); - - // when - ChatRoomDetailRes result = chatRoomService.create(user.getId(), req); - - // then - assertThat(result.title()).isEqualTo(chatRoom.getTitle()); - assertThat(result.maxCapacity()).isEqualTo(chatRoom.getMaxCapacity()); + @DisplayName("채팅방 생성 시나리오") + class CreateChatRoomScenario { + private final ChatRoomCreateReq req = new ChatRoomCreateReq(CHAT_ROOM.getTitle(), CHAT_ROOM.getMaxCapacity()); + + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("채팅방 생성 성공") + void createChatRoomSuccess() { + // given + given(entityFacade.readUser(USER.getId())).willReturn(USER); + given(chatRoomRepository.save(any(ChatRoom.class))).willReturn(CHAT_ROOM); + + // when + ChatRoomDetailRes result = chatRoomService.create(USER.getId(), req); + + // then + assertThat(result.title()).isEqualTo(CHAT_ROOM.getTitle()); + assertThat(result.maxCapacity()).isEqualTo(CHAT_ROOM.getMaxCapacity()); + } } - } - @Nested - @DisplayName("채팅방 생성 실패 시나리오") - class CreateChatRoomFailScenarios { - @Test - @DisplayName("존재하지 않는 유저로 채팅방 생성") - void createChatRoomWhenUserNotFound() { - // given - ChatRoomCreateReq req = new ChatRoomCreateReq(chatRoom.getTitle(), chatRoom.getMaxCapacity()); - given(entityFacade.readUser(user.getId())).willThrow(new ChatRoomErrorException(ChatRoomErrorCode.NOT_FOUND)); - - // when - ChatRoomErrorException ex = assertThrows(ChatRoomErrorException.class, () -> chatRoomService.create(user.getId(), req)); - - // then - assertThat(ex.getErrorCode()).isEqualTo(ChatRoomErrorCode.NOT_FOUND); + @Nested + @DisplayName("실패 시나리오") + class FailScenario { + @Test + @DisplayName("존재하지 않는 유저로 채팅방 생성 시 실패") + void createChatRoomFailWhenUserNotFound() { + // given + given(entityFacade.readUser(USER.getId())).willThrow(new ChatRoomErrorException(ChatRoomErrorCode.NOT_FOUND)); + + // when + ChatRoomErrorException ex = assertThrows(ChatRoomErrorException.class, () -> chatRoomService.create(USER.getId(), req)); + + // then + assertThat(ex.getErrorCode()).isEqualTo(ChatRoomErrorCode.NOT_FOUND); + } } } @Nested - @DisplayName("내 채팅방 목록 조회 성공 시나리오") - class GetMyChatRoomsSuccessScenarios { - @Test - @DisplayName("내 채팅방 목록 조회 성공") - void getMyChatRooms() { - // given - given(entityFacade.readUser(user.getId())).willReturn(user); - given(chatRoomMemberService.readChatRoomMembersByUserId(user.getId())).willReturn(List.of()); - - // when - List result = chatRoomService.getMyChatRooms(user.getId()); - - // then - assertThat(result).isNotNull(); + @DisplayName("내 채팅방 목록 조회 시나리오") + class GetMyChatRoomsScenario { + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("내 채팅방 목록 조회 성공") + void getMyChatRoomsSuccess() { + // given + given(entityFacade.readUser(USER.getId())).willReturn(USER); + given(chatRoomMemberService.readChatRoomMembersByUserId(USER.getId())).willReturn(List.of()); + + // when + List result = chatRoomService.getMyChatRooms(USER.getId()); + + // then + assertThat(result).isNotNull(); + } } - } - @Nested - @DisplayName("내 채팅방 목록 조회 실패 시나리오") - class GetMyChatRoomsFailScenarios { - @Test - @DisplayName("존재하지 않는 유저의 채팅방 목록 조회") - void getMyChatRoomsWhenUserNotFound() { - // given - given(entityFacade.readUser(user.getId())).willThrow(new ChatRoomErrorException(ChatRoomErrorCode.NOT_FOUND)); - - // when - ChatRoomErrorException ex = assertThrows(ChatRoomErrorException.class, () -> chatRoomService.getMyChatRooms(user.getId())); - - // then - assertThat(ex.getErrorCode()).isEqualTo(ChatRoomErrorCode.NOT_FOUND); + @Nested + @DisplayName("실패 시나리오") + class FailScenario { + @Test + @DisplayName("존재하지 않는 유저의 채팅방 목록 조회 시 실패") + void getMyChatRoomsFailWhenUserNotFound() { + // given + given(entityFacade.readUser(USER.getId())).willThrow(new ChatRoomErrorException(ChatRoomErrorCode.NOT_FOUND)); + + // when + ChatRoomErrorException ex = assertThrows(ChatRoomErrorException.class, () -> chatRoomService.getMyChatRooms(USER.getId())); + + // then + assertThat(ex.getErrorCode()).isEqualTo(ChatRoomErrorCode.NOT_FOUND); + } } } @Nested - @DisplayName("전체 채팅방 목록 조회 성공 시나리오") - class GetChatRoomsSuccessScenarios { - @Test - @DisplayName("비로그인 유저의 전체 채팅방 목록 조회 성공") - void getChatRoomsWhenNotLoggedIn() { - // given - given(chatRoomRepository.findAll()).willReturn(List.of(chatRoom)); - - // when - List result = chatRoomService.getChatRooms(Optional.ofNullable(null)); - - // then - assertThat(result).isNotNull(); - assertThat(result.size()).isEqualTo(1); - - ChatRoomSummaryRes res = result.getFirst(); - assertThat(res.isJoined()).isFalse(); - } - - @Test - @DisplayName("로그인 유저의 전체 채팅방 목록 조회 성공") - void getChatRoomsWhenLoggedIn() { - // given - given(chatRoomRepository.findAll()).willReturn(List.of(chatRoom)); - given(chatRoomMemberService.isExists(chatRoom.getId(), user.getId())).willReturn(true); - - // when - List result = chatRoomService.getChatRooms(Optional.of(user.getId())); - - // then - assertThat(result).isNotNull(); - assertThat(result.size()).isEqualTo(1); - - ChatRoomSummaryRes res = result.getFirst(); - assertThat(res.isJoined()).isTrue(); + @DisplayName("전체 채팅방 목록 조회 시나리오") + class GetChatRoomsScenario { + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("비로그인 유저의 전체 채팅방 목록 조회 성공") + void getChatRoomsSuccessWhenNotLoggedIn() { + // given + given(chatRoomRepository.findAll()).willReturn(List.of(CHAT_ROOM)); + + // when + List result = chatRoomService.getChatRooms(Optional.ofNullable(null)); + + // then + assertThat(result).isNotNull(); + assertThat(result.size()).isEqualTo(1); + ChatRoomSummaryRes res = result.getFirst(); + assertThat(res.isJoined()).isFalse(); + } + + @Test + @DisplayName("로그인 유저의 전체 채팅방 목록 조회 성공") + void getChatRoomsSuccessWhenLoggedIn() { + // given + given(chatRoomRepository.findAll()).willReturn(List.of(CHAT_ROOM)); + given(chatRoomMemberService.isExists(CHAT_ROOM.getId(), USER.getId())).willReturn(true); + + // when + List result = chatRoomService.getChatRooms(Optional.of(USER.getId())); + + // then + assertThat(result).isNotNull(); + assertThat(result.size()).isEqualTo(1); + ChatRoomSummaryRes res = result.getFirst(); + assertThat(res.isJoined()).isTrue(); + } } } } diff --git a/src/test/java/com/rabbitmqprac/service/UserServiceTest.java b/src/test/java/com/rabbitmqprac/service/UserServiceTest.java index f928f74..00a7eee 100644 --- a/src/test/java/com/rabbitmqprac/service/UserServiceTest.java +++ b/src/test/java/com/rabbitmqprac/service/UserServiceTest.java @@ -37,285 +37,311 @@ class UserServiceTest { @InjectMocks private UserService userService; - private static User user = UserFixture.FIRST_USER.toEntity(); + private static final User USER = UserFixture.FIRST_USER.toEntity(); @Nested - @DisplayName("유저 저장 성공 시나리오") - class UserSaveSuccessScenarios { - @Test - @DisplayName("유저 저장 성공") - void saveUser() { - // given - UserCreateReq req = new UserCreateReq(user.getNickname(), user.getUsername(), user.getPassword()); - given(userRepository.save(any(User.class))).willReturn(user); - given(userRepository.existsByUsername(user.getUsername())).willReturn(Boolean.FALSE); - - // when - User savedUser = userService.saveUserWithEncryptedPassword(req); - - // then - assertThat(savedUser.getUsername()).isEqualTo(user.getUsername()); - assertThat(savedUser.getPassword()).isEqualTo(user.getPassword()); + @DisplayName("유저 저장 시나리오") + class SaveUserScenario { + private final UserCreateReq req = new UserCreateReq(USER.getNickname(), USER.getUsername(), USER.getPassword()); + + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("유저 저장 성공") + void saveUserSuccess() { + // given + given(userRepository.existsByUsername(USER.getUsername())).willReturn(false); + given(userRepository.save(any(User.class))).willReturn(USER); + + // when + User saved = userService.saveUserWithEncryptedPassword(req); + + // then + assertThat(saved.getUsername()).isEqualTo(USER.getUsername()); + assertThat(saved.getPassword()).isEqualTo(USER.getPassword()); + } } - } - @Nested - @DisplayName("유저 저장 실패 시나리오") - class UserSaveFailScenarios { - @Test - @DisplayName("nickname 중복") - void saveUserWhenExistingUserByUsername() { - // given - UserCreateReq req = new UserCreateReq(user.getNickname(), user.getUsername(), user.getPassword()); - given(userRepository.existsByUsername(user.getUsername())).willReturn(Boolean.TRUE); - - // when - UserErrorException ex = assertThrows(UserErrorException.class, () -> userService.saveUserWithEncryptedPassword(req)); - - // then - assertThat(ex.getErrorCode()).isEqualTo(UserErrorCode.CONFLICT_USERNAME); + @Nested + @DisplayName("실패 시나리오") + class FailScenario { + @Test + @DisplayName("username 중복") + void saveUserFailByDuplicateUsername() { + // given + given(userRepository.existsByUsername(USER.getUsername())).willReturn(true); + + // when + UserErrorException ex = assertThrows(UserErrorException.class, () -> userService.saveUserWithEncryptedPassword(req)); + + // then + assertThat(ex.getErrorCode()).isEqualTo(UserErrorCode.CONFLICT_USERNAME); + } } } @Nested - @DisplayName("유저 조회 성공 시나리오") - class UserReadSuccessScenarios { - @Test - @DisplayName("유저 조회 성공") - void readUser() { - // given - given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); - - // when - User foundUser = userService.readUser(user.getId()); - - // then - assertThat(foundUser.getUsername()).isEqualTo(user.getUsername()); + @DisplayName("유저 조회 시나리오") + class ReadUserScenario { + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("유저 조회 성공") + void readUserSuccess() { + // given + given(userRepository.findById(USER.getId())).willReturn(Optional.of(USER)); + + // when + User found = userService.readUser(USER.getId()); + + // then + assertThat(found.getUsername()).isEqualTo(USER.getUsername()); + } } - } - @Nested - @DisplayName("유저 조회 실패 시나리오") - class UserReadFailScenarios { - @Test - @DisplayName("존재하지 않는 userId") - void readUserWhenNotFoundedUserId() { - // given - given(userRepository.findById(user.getId())).willReturn(Optional.empty()); - - // when - UserErrorException ex = assertThrows(UserErrorException.class, () -> userService.readUser(user.getId())); - - // then - assertThat(ex.getErrorCode()).isEqualTo(UserErrorCode.NOT_FOUND); + @Nested + @DisplayName("실패 시나리오") + class FailScenario { + @Test + @DisplayName("존재하지 않는 userId") + void readUserFailByNotFound() { + // given + given(userRepository.findById(USER.getId())).willReturn(Optional.empty()); + // when + UserErrorException ex = assertThrows(UserErrorException.class, () -> userService.readUser(USER.getId())); + // then + assertThat(ex.getErrorCode()).isEqualTo(UserErrorCode.NOT_FOUND); + } } } - @Nested - @DisplayName("유저 리스트 조회 성공 시나리오") - class UserListScenarios { - @Test - @DisplayName("유저 리스트 조회 성공") - void getUserDetails() { - // given - given(userRepository.findAll()).willReturn(List.of(user)); - - // when - List details = userService.getUserDetails(); - - // then - assertThat(details).hasSize(1); - assertThat(details.get(0).nickname()).isEqualTo(user.getUsername()); + @DisplayName("유저 리스트 조회 시나리오") + class GetUserDetailsScenario { + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("유저 리스트 조회 성공") + void getUserDetailsSuccess() { + // given + given(userRepository.findAll()).willReturn(List.of(USER)); + + // when + List details = userService.getUserDetails(); + + // then + assertThat(details).hasSize(1); + assertThat(details.get(0).nickname()).isEqualTo(USER.getUsername()); + } } } - @Nested - @DisplayName("단일 유저 상세 조회 성공 시나리오") - class UserDetailSuccessScenarios { - @Test - @DisplayName("유저 상세 조회 성공") - void getUserDetail() { - // given - given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); - - // when - UserDetailRes detail = userService.getUserDetail(user.getId()); - - // then - assertThat(detail.nickname()).isEqualTo(user.getUsername()); + @DisplayName("단일 유저 상세 조회 시나리오") + class GetUserDetailScenario { + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("유저 상세 조회 성공") + void getUserDetailSuccess() { + // given + given(userRepository.findById(USER.getId())).willReturn(Optional.of(USER)); + + // when + UserDetailRes detail = userService.getUserDetail(USER.getId()); + + // then + assertThat(detail.nickname()).isEqualTo(USER.getUsername()); + } } - } - @Nested - @DisplayName("단일 유저 상세 조회 실패 시나리오") - class UserDetailFailScenarios { - @Test - @DisplayName("존재하지 않는 userId") - void getUserDetailWhenNotFoundedUserId() { - // given - given(userRepository.findById(user.getId())).willReturn(Optional.empty()); - - // when - UserErrorException ex = assertThrows(UserErrorException.class, () -> userService.getUserDetail(user.getId())); - - // then - assertThat(ex.getErrorCode()).isEqualTo(UserErrorCode.NOT_FOUND); + @Nested + @DisplayName("실패 시나리오") + class FailScenario { + @Test + @DisplayName("존재하지 않는 userId") + void getUserDetailFailByNotFound() { + // given + given(userRepository.findById(USER.getId())).willReturn(Optional.empty()); + + // when + UserErrorException ex = assertThrows(UserErrorException.class, () -> userService.getUserDetail(USER.getId())); + + // then + assertThat(ex.getErrorCode()).isEqualTo(UserErrorCode.NOT_FOUND); + } } } @Nested - @DisplayName("username으로 유저 조회 성공 시나리오") - class UserReadByUsernameSuccessScenarios { - @Test - @DisplayName("username으로 유저 조회 성공") - void readUserByUsername() { - // given - given(userRepository.findByUsername(user.getUsername())).willReturn(java.util.Optional.of(user)); - - // when - User foundUser = userService.readUserByUsername(user.getUsername()); - - // then - assertThat(foundUser.getUsername()).isEqualTo(user.getUsername()); + @DisplayName("username으로 유저 조회 시나리오") + class ReadUserByUsernameScenario { + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("username으로 유저 조회 성공") + void readUserByUsernameSuccess() { + // given + given(userRepository.findByUsername(USER.getUsername())).willReturn(Optional.of(USER)); + + // when + User found = userService.readUserByUsername(USER.getUsername()); + + // then + assertThat(found.getUsername()).isEqualTo(USER.getUsername()); + } } - } - @Nested - @DisplayName("username으로 유저 조회 실패 시나리오") - class UserReadByUsernameFailScenarios { - @Test - @DisplayName("존재하지 않는 nickname") - void readUserByUsernameWhenNotFoundedUser() { - // given - given(userRepository.findByUsername(user.getUsername())).willReturn(Optional.empty()); - - // when - UserErrorException ex = assertThrows(UserErrorException.class, () -> userService.readUserByUsername(user.getUsername())); - - // then - assertThat(ex.getErrorCode()).isEqualTo(UserErrorCode.NOT_FOUND); + @Nested + @DisplayName("실패 시나리오") + class FailScenario { + @Test + @DisplayName("존재하지 않는 username") + void readUserByUsernameFailByNotFound() { + // given + given(userRepository.findByUsername(USER.getUsername())).willReturn(Optional.empty()); + + // when + UserErrorException ex = assertThrows(UserErrorException.class, () -> userService.readUserByUsername(USER.getUsername())); + + // then + assertThat(ex.getErrorCode()).isEqualTo(UserErrorCode.NOT_FOUND); + } } } @Nested - @DisplayName("닉네임 변경 성공 시나리오") - class UpdateNicknameSuccessScenarios { - @Test - @DisplayName("닉네임 변경 성공") - void updateNicknameSuccess() { - // given - NicknameUpdateReq req = new NicknameUpdateReq("newNickname"); - given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); - given(userRepository.existsByNickname(req.nickname())).willReturn(Boolean.FALSE); - - // when - userService.updateNickname(user.getId(), req); - - // then - assertThat(user.getNickname()).isEqualTo(req.nickname()); + @DisplayName("닉네임 변경 시나리오") + class UpdateNicknameScenario { + private final NicknameUpdateReq req = new NicknameUpdateReq("newNickname"); + + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("닉네임 변경 성공") + void updateNicknameSuccess() { + // given + given(userRepository.findById(USER.getId())).willReturn(Optional.of(USER)); + given(userRepository.existsByNickname(req.nickname())).willReturn(false); + + // when + userService.updateNickname(USER.getId(), req); + + // then + assertThat(USER.getNickname()).isEqualTo(req.nickname()); + } } - } - @Nested - @DisplayName("닉네임 변경 실패 시나리오") - class UpdateNicknameFailScenarios { - @Test - @DisplayName("존재하지 않은 유저") - void updateNicknameFailWhenNotFoundedUser() { - // given - NicknameUpdateReq req = mock(NicknameUpdateReq.class); - given(userRepository.findById(user.getId())).willReturn(Optional.empty()); - - // when - UserErrorException ex = assertThrows(UserErrorException.class, () -> userService.updateNickname(user.getId(), req)); - - // then - assertThat(ex.getErrorCode()).isEqualTo(UserErrorCode.NOT_FOUND); - } - - @Test - @DisplayName("닉네임 중복") - void updateNicknameFailByDuplicate() { - // given - NicknameUpdateReq req = new NicknameUpdateReq("newNickname"); - given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); - given(userRepository.existsByNickname(req.nickname())).willReturn(Boolean.TRUE); - - // when - UserErrorException ex = assertThrows(UserErrorException.class, () -> userService.updateNickname(user.getId(), req)); - - // then - assertThat(ex.getErrorCode()).isEqualTo(UserErrorCode.CONFLICT_USERNAME); + @Nested + @DisplayName("실패 시나리오") + class FailScenario { + @Test + @DisplayName("존재하지 않은 유저") + void updateNicknameFailByNotFound() { + // given + given(userRepository.findById(USER.getId())).willReturn(Optional.empty()); + + // when + UserErrorException ex = assertThrows(UserErrorException.class, () -> userService.updateNickname(USER.getId(), req)); + + // then + assertThat(ex.getErrorCode()).isEqualTo(UserErrorCode.NOT_FOUND); + } + + @Test + @DisplayName("닉네임 중복") + void updateNicknameFailByDuplicate() { + // given + given(userRepository.findById(USER.getId())).willReturn(Optional.of(USER)); + given(userRepository.existsByNickname(req.nickname())).willReturn(true); + + // when + UserErrorException ex = assertThrows(UserErrorException.class, () -> userService.updateNickname(USER.getId(), req)); + + // then + assertThat(ex.getErrorCode()).isEqualTo(UserErrorCode.CONFLICT_USERNAME); + } } } @Nested - @DisplayName("nickname 중복 체크 성공 시나리오") - class UsernameDuplicateCheckSuccessScenarios { - @Test - @DisplayName("nickname 중복 체크 - 중복") - void isDuplicatedUsernameTrue() { - // given - given(userRepository.existsByUsername(user.getUsername())).willReturn(Boolean.TRUE); - - // when - Boolean result = userService.isDuplicatedUsername(user.getUsername()); - - // then - assertThat(result).isTrue(); - } - - @Test - @DisplayName("nickname 중복 체크 - 중복 아님") - void isDuplicatedUsernameFalse() { - // given - given(userRepository.existsByUsername(user.getUsername())).willReturn(Boolean.FALSE); - - // when - Boolean result = userService.isDuplicatedUsername(user.getUsername()); - - // then - assertThat(result).isFalse(); + @DisplayName("username 중복 체크 시나리오") + class UsernameDuplicateCheckScenario { + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("username 중복 체크 - 중복") + void isDuplicatedUsernameTrue() { + // given + given(userRepository.existsByUsername(USER.getUsername())).willReturn(true); + + // when + Boolean result = userService.isDuplicatedUsername(USER.getUsername()); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("username 중복 체크 - 중복 아님") + void isDuplicatedUsernameFalse() { + // given + given(userRepository.existsByUsername(USER.getUsername())).willReturn(false); + + // when + Boolean result = userService.isDuplicatedUsername(USER.getUsername()); + + // then + assertThat(result).isFalse(); + } } } @Nested - @DisplayName("닉네임 중복 체크 성공 시나리오") - class NicknameDuplicateCheckSuccessScenarios { - private static NicknameCheckReq req = mock(NicknameCheckReq.class); + @DisplayName("닉네임 중복 체크 시나리오") + class NicknameDuplicateCheckScenario { + private final NicknameCheckReq req = mock(NicknameCheckReq.class); @BeforeEach void setUp() { - given(req.nickname()).willReturn(user.getNickname()); + given(req.nickname()).willReturn(USER.getNickname()); } - @Test - @DisplayName("닉네임 중복 체크 - 중복") - void isDuplicatedNicknameTrue() { - // given - given(userRepository.existsByNickname(user.getNickname())).willReturn(Boolean.TRUE); - - // when - Boolean result = userService.isDuplicatedNickname(req); - - // then - assertThat(result).isTrue(); - } - - @Test - @DisplayName("닉네임 중복 체크 - 중복 아님") - void isDuplicatedNicknameFalse() { - // given - given(userRepository.existsByNickname(user.getNickname())).willReturn(Boolean.FALSE); - - // when - Boolean result = userService.isDuplicatedNickname(req); - - // then - assertThat(result).isFalse(); + @Nested + @DisplayName("성공 시나리오") + class SuccessScenario { + @Test + @DisplayName("닉네임 중복 체크 - 중복") + void isDuplicatedNicknameTrue() { + // given + given(userRepository.existsByNickname(USER.getNickname())).willReturn(true); + + // when + Boolean result = userService.isDuplicatedNickname(req); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("닉네임 중복 체크 - 중복 아님") + void isDuplicatedNicknameFalse() { + // given + given(userRepository.existsByNickname(USER.getNickname())).willReturn(false); + + // when + Boolean result = userService.isDuplicatedNickname(req); + + // then + assertThat(result).isFalse(); + } } } - } From c9c9c9cb58945217361b32d6ba0cfa59c20b85af Mon Sep 17 00:00:00 2001 From: LSH <104637774+lsh2613@users.noreply.github.com> Date: Wed, 27 Aug 2025 15:06:21 +0900 Subject: [PATCH 6/9] =?UTF-8?q?Chore:=20=EC=84=9C=EB=B2=84=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=EB=AA=85=EC=9D=84=20rabbit.com=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nginx/config/nginx.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nginx/config/nginx.conf b/nginx/config/nginx.conf index 55c5bee..9680fef 100644 --- a/nginx/config/nginx.conf +++ b/nginx/config/nginx.conf @@ -11,14 +11,14 @@ map $http_origin $allowed_origin { # HTTP → HTTPS 리다이렉트 server { listen 80; - server_name rabbit; # 실제 공인 도메인이 아니기 때문에 로컬 /etc/hosts 파일의 "127.0.0.1 rabbit"을 추가하여 NXDOMAIN 오류를 방지 + server_name rabbit.com; # 실제 공인 도메인이 아니기 때문에 로컬 /etc/hosts 파일의 "127.0.0.1 rabbit.com"을 추가하여 NXDOMAIN 오류를 방지 return 301 https://$host$request_uri; } # HTTPS 설정 server { listen 443 ssl; - server_name rabbit; # 실제 공인 도메인이 아니기 때문에 로컬 /etc/hosts 파일의 "127.0.0.1 rabbit"을 추가하여 NXDOMAIN 오류를 방지 + server_name rabbit.com; # 실제 공인 도메인이 아니기 때문에 로컬 /etc/hosts 파일의 "127.0.0.1 rabbit.com"을 추가하여 NXDOMAIN 오류를 방지 ssl_certificate /etc/nginx/ssl/rabbit.cert.pem; ssl_certificate_key /etc/nginx/ssl/rabbit.key.pem; From 8236ac076b30cdf666984c5c19cfd2b674d1e799 Mon Sep 17 00:00:00 2001 From: LSH <104637774+lsh2613@users.noreply.github.com> Date: Sat, 30 Aug 2025 04:36:30 +0900 Subject: [PATCH 7/9] Chore/apply metrix monitoring (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: docker-compose 서비스 분리 * chore: prometheus, grafana 적용 --- build.gradle | 8 +- docker-compose-db.yml | 68 +++++++++++++++++ docker-compose-monitoring.yml | 30 ++++++++ docker-compose.yml | 74 ++----------------- monitoring/prometheus/Dockerfile | 19 +++++ .../prometheus/config/prometheus-env.yml | 15 ++++ .../rabbitmqprac/config/SecurityConfig.java | 54 +++++++++++++- .../auth/service/UserDetailServiceImpl.java | 4 +- .../filter/JwtAuthenticationFilter.java | 10 ++- src/main/resources/application.yml | 25 ++++++- 10 files changed, 230 insertions(+), 77 deletions(-) create mode 100644 docker-compose-db.yml create mode 100644 docker-compose-monitoring.yml create mode 100644 monitoring/prometheus/Dockerfile create mode 100644 monitoring/prometheus/config/prometheus-env.yml diff --git a/build.gradle b/build.gradle index 50850dc..865948b 100644 --- a/build.gradle +++ b/build.gradle @@ -76,13 +76,17 @@ dependencies { // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + // monoitoring + implementation("org.springframework.boot:spring-boot-starter-actuator") + runtimeOnly("io.micrometer:micrometer-registry-prometheus") } tasks.named('test') { useJUnitPlatform() } -tasks.withType(Checkstyle){ +tasks.withType(Checkstyle) { reports { xml.required = true html.required = true @@ -91,7 +95,7 @@ tasks.withType(Checkstyle){ checkstyle { configFile = file("checkstyle/config/rules.xml") - configProperties = ["suppressionFile" : "checkstyle/config/suppressions.xml"] + configProperties = ["suppressionFile": "checkstyle/config/suppressions.xml"] maxWarnings = 0 } diff --git a/docker-compose-db.yml b/docker-compose-db.yml new file mode 100644 index 0000000..6254cb6 --- /dev/null +++ b/docker-compose-db.yml @@ -0,0 +1,68 @@ +version: '3.8' +services: + redis: + image: redis:alpine + container_name: rabbit-redis + hostname: redis + ports: + - "6379:6379" + networks: + - rabbit-db + + mysql_master: + container_name: rabbit-mysql-master + image: mysql:8.0 + environment: + MYSQL_DATABASE: rabbit + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: 1234 + 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 + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234" ] + timeout: 20s + retries: 10 + + mysql_replica: + container_name: rabbit-mysql-replica + image: mysql:8.0 + environment: + MYSQL_DATABASE: rabbit + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: 1234 + ports: + - "3307:3306" + volumes: + - ./mysql/replica-data-source.cnf:/etc/mysql/conf.d/my.cnf + networks: + - rabbit-db + depends_on: + mysql_master: + condition: service_healthy + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234" ] + timeout: 20s + retries: 10 + + mysql_replication_setup: + image: mysql:8.0 + container_name: mysql_replication_setup + volumes: + - ./mysql/setup-replication.sh:/setup-replication.sh + command: [ "/bin/bash", "/setup-replication.sh" ] + networks: + - rabbit-db + depends_on: + mysql_master: + condition: service_healthy + mysql_replica: + condition: service_healthy + restart: "no" + +networks: + rabbit-db: diff --git a/docker-compose-monitoring.yml b/docker-compose-monitoring.yml new file mode 100644 index 0000000..5af2c2c --- /dev/null +++ b/docker-compose-monitoring.yml @@ -0,0 +1,30 @@ +version: '3.8' +services: + prometheus: + build: ./monitoring/prometheus + container_name: rabbit-prometheus + ports: + - 9090:9090 + environment: + ACTUATOR_METRICS_PATH: ${ACTUATOR_METRICS_PATH:-/actuator/prometheus} + ACTUATOR_USERNAME: ${ACTUATOR_USERNAME:-rabbit} + ACTUATOR_PASSWORD: ${ACTUATOR_PASSWORD:-rabbit1234} + restart: always + networks: + - rabbit-monitoring + + grafana: + image: grafana/grafana + container_name: rabbit-grafana + ports: + - 3000:3000 + volumes: + - ./monitoring/grafana/volume:/var/lib/grafana + restart: always + networks: + - rabbit-monitoring + depends_on: + - prometheus + +networks: + rabbit-monitoring: diff --git a/docker-compose.yml b/docker-compose.yml index 80999d4..f2acc1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: rabbitmq: image: rabbitmq:3-management - container_name: rabbitmq + container_name: rabbit-rabbitmq ports: - "5672:5672" - "15672:15672" @@ -13,75 +13,11 @@ services: RABBITMQ_DEFAULT_USER: guest RABBITMQ_DEFAULT_PASS: guest networks: - - my_network - - redis: - image: redis:alpine - container_name: redis - hostname: redis - ports: - - "6379:6379" - networks: - - my_network - - mysql_master: - container_name: rabbit-mysql-master - image: mysql:8.0 - environment: - MYSQL_DATABASE: rabbit - MYSQL_ROOT_HOST: '%' - MYSQL_ROOT_PASSWORD: 1234 - 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: - - my_network - healthcheck: - test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234" ] - timeout: 20s - retries: 10 - - mysql_replica: - container_name: rabbit-mysql-replica - image: mysql:8.0 - environment: - MYSQL_DATABASE: rabbit - MYSQL_ROOT_HOST: '%' - MYSQL_ROOT_PASSWORD: 1234 - ports: - - "3307:3306" - volumes: - - ./mysql/replica-data-source.cnf:/etc/mysql/conf.d/my.cnf - networks: - - my_network - depends_on: - mysql_master: - condition: service_healthy - healthcheck: - test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234" ] - timeout: 20s - retries: 10 - - mysql_replication_setup: - image: mysql:8.0 - container_name: mysql_replication_setup - volumes: - - ./mysql/setup-replication.sh:/setup-replication.sh - command: [ "/bin/bash", "/setup-replication.sh" ] - networks: - - my_network - depends_on: - mysql_master: - condition: service_healthy - mysql_replica: - condition: service_healthy - restart: "no" + - rabbit-default nginx: image: nginx:latest - container_name: nginx + container_name: rabbit-nginx ports: - "80:80" - "443:443" @@ -89,7 +25,7 @@ services: - ./nginx/config/nginx.conf:/etc/nginx/conf.d/default.conf - ./nginx/ssl:/etc/nginx/ssl networks: - - my_network + - rabbit-default networks: - my_network: + rabbit-default: diff --git a/monitoring/prometheus/Dockerfile b/monitoring/prometheus/Dockerfile new file mode 100644 index 0000000..34f3ee1 --- /dev/null +++ b/monitoring/prometheus/Dockerfile @@ -0,0 +1,19 @@ +FROM alpine:latest + +# 2025.08.30 기준 최신 LTS 버전 +ENV PROMETHEUS_VERSION=3.5.0 + +# Prometheus 설치 +RUN wget https://github.com/prometheus/prometheus/releases/download/v${PROMETHEUS_VERSION}/prometheus-${PROMETHEUS_VERSION}.linux-amd64.tar.gz \ + && tar xvf prometheus-${PROMETHEUS_VERSION}.linux-amd64.tar.gz \ + && mv prometheus-${PROMETHEUS_VERSION}.linux-amd64/prometheus /bin/prometheus \ + && mv prometheus-${PROMETHEUS_VERSION}.linux-amd64/promtool /bin/promtool \ + && rm -rf prometheus-${PROMETHEUS_VERSION}.linux-amd64* + +# envsubst 설치 +RUN apk add --no-cache gettext + +# 설정 파일 복사 +COPY config/prometheus-env.yml /etc/prometheus/prometheus-env.yml + +ENTRYPOINT ["/bin/sh", "-c", "envsubst < /etc/prometheus/prometheus-env.yml > /etc/prometheus/prometheus.yml && exec prometheus --web.enable-lifecycle --enable-feature=expand-external-labels --config.file=/etc/prometheus/prometheus.yml"] diff --git a/monitoring/prometheus/config/prometheus-env.yml b/monitoring/prometheus/config/prometheus-env.yml new file mode 100644 index 0000000..d19c343 --- /dev/null +++ b/monitoring/prometheus/config/prometheus-env.yml @@ -0,0 +1,15 @@ +global: + scrape_interval: 15s # scrap target의 기본 interval을 15초로 변경 / default = 1m + scrape_timeout: 15s # scrap request 가 timeout wait/ default = 10s + + external_labels: + monitor: 'rabbit-prometheus' # 기본적으로 붙여줄 라벨 + +scrape_configs: + - job_name: prometheus + metrics_path: ${ACTUATOR_METRICS_PATH} + static_configs: + - targets: [ 'host.docker.internal:8080' ] + basic_auth: + username: ${ACTUATOR_USERNAME} + password: ${ACTUATOR_PASSWORD} diff --git a/src/main/java/com/rabbitmqprac/config/SecurityConfig.java b/src/main/java/com/rabbitmqprac/config/SecurityConfig.java index dde5309..1301cc2 100644 --- a/src/main/java/com/rabbitmqprac/config/SecurityConfig.java +++ b/src/main/java/com/rabbitmqprac/config/SecurityConfig.java @@ -4,19 +4,27 @@ import com.rabbitmqprac.infra.security.filter.JwtAuthenticationFilter; import com.rabbitmqprac.infra.security.filter.JwtExceptionFilter; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity; -import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; 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; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; @@ -27,6 +35,7 @@ import java.util.List; +@Slf4j @Configuration @EnableWebSecurity @ConditionalOnDefaultWebSecurity @@ -37,16 +46,55 @@ public class SecurityConfig { private final CorsConfigurationSource corsConfigurationSource; private final AccessDeniedHandler accessDeniedHandler; private final AuthenticationEntryPoint authenticationEntryPoint; + private final PasswordEncoder bCryptPasswordEncoder; + + @Value("${management.actuator.username}") + private String actuatorUsername; + @Value("${management.actuator.password}") + private String actuatorPassword; + @Value("${management.actuator.role}") + private String actuatorRole; + @Value("${management.endpoints.web.exposure.base-path}") + private String metricsPath; + + @Bean(name = "actuatorUserDetailsService") + public UserDetailsService actuatorUserDetailsService() { + String encodedPassword = bCryptPasswordEncoder.encode(actuatorPassword); + UserDetails actuatorUser = User.builder() + .username(actuatorUsername) + .password(encodedPassword) + .roles(actuatorRole) + .build(); + return new InMemoryUserDetailsManager(actuatorUser); + } + + @Bean + @Order(1) + public SecurityFilterChain actuatorFilterChain( + HttpSecurity http, + @Qualifier("actuatorUserDetailsService") UserDetailsService actuatorUserDetailsService + ) throws Exception { + http + .securityMatcher(metricsPath) + .authorizeHttpRequests(auth -> auth + .anyRequest().hasRole(actuatorRole) + ) + .userDetailsService(actuatorUserDetailsService) + .httpBasic(Customizer.withDefaults()) + .csrf(csrf -> csrf.disable()); + return http.build(); + } @Bean - @Order(SecurityProperties.BASIC_AUTH_ORDER) - public SecurityFilterChain filterChainDev(HttpSecurity http) throws Exception { + @Order(2) + public SecurityFilterChain jwtFilterChain(HttpSecurity http) throws Exception { return defaultSecurity(http) .cors((cors) -> cors.configurationSource(corsConfigurationSource())) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class) .authorizeHttpRequests( auth -> defaultAuthorizeHttpRequests(auth) + .requestMatchers(metricsPath).permitAll() .requestMatchers(WebSecurityUrls.SWAGGER_ENDPOINTS).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/rabbitmqprac/domain/context/auth/service/UserDetailServiceImpl.java b/src/main/java/com/rabbitmqprac/domain/context/auth/service/UserDetailServiceImpl.java index 974c67a..a29b2f1 100644 --- a/src/main/java/com/rabbitmqprac/domain/context/auth/service/UserDetailServiceImpl.java +++ b/src/main/java/com/rabbitmqprac/domain/context/auth/service/UserDetailServiceImpl.java @@ -1,15 +1,17 @@ package com.rabbitmqprac.domain.context.auth.service; -import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; import com.rabbitmqprac.domain.context.user.service.UserService; import com.rabbitmqprac.domain.persistence.user.entity.User; +import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Primary; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service +@Primary @RequiredArgsConstructor public class UserDetailServiceImpl implements UserDetailsService { private final UserService userService; diff --git a/src/main/java/com/rabbitmqprac/infra/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/rabbitmqprac/infra/security/filter/JwtAuthenticationFilter.java index 91568e4..bd70f64 100644 --- a/src/main/java/com/rabbitmqprac/infra/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/rabbitmqprac/infra/security/filter/JwtAuthenticationFilter.java @@ -11,6 +11,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -31,9 +32,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final UserDetailsService userDetailService; private final JwtProvider accessTokenProvider; + @Value("${management.endpoints.web.exposure.base-path}") + private String metricsPath; + @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { - if (isAnonymousRequest(request)) { + if (isMetricRequest(request) || isAnonymousRequest(request)) { filterChain.doFilter(request, response); return; } @@ -45,6 +49,10 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht filterChain.doFilter(request, response); } + private boolean isMetricRequest(HttpServletRequest request) { + return request.getRequestURI().startsWith(metricsPath); + } + /** * AccessToken과 RefreshToken이 모두 없는 경우, 익명 사용자로 간주한다. */ diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 83e2b6f..161b22e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,6 +3,30 @@ rabbit: domain: local: ${RABBIT_SERVER_DOMAIN_LOCAL:http://localhost:8080} dev: ${RABBIT_SERVER_DOMAIN_LOCAL:http://localhost:8080} + +management: + endpoints: + web: + exposure: + include: "*" # 실제 운영 시 필요 엔드포인트만 명시하여 보안 강화 필요 + base-path: ${ACTUATOR_METRICS_PATH:/actuator/prometheus} + endpoint: + health: + show-details: always + prometheus: + metrics: + export: + enabled: true + actuator: + username: ${ACTUATOR_USERNAME:rabbit} + password: ${ACTUATOR_PASSWORD:rabbit1234} + role: ${ACTUATOR_ROLE:ACTUATOR} + +server: + tomcat: + mbeanregistry: # 톰캣 메트릭을 모두 사용하려면 다음 옵션을 켜야하며, 옵션을 켜지 않으면 tomcat.session. 관련정보만 노출 + enabled: true + spring: config: import: optional:file:.env[.properties] @@ -28,7 +52,6 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect - data: mongodb: uri: mongodb://root:1234@localhost:27017/rabbitmq?authSource=admin&authMechanism=SCRAM-SHA-1 From 8914d7bfb3c37022a70de36cef0ffa39f738468a Mon Sep 17 00:00:00 2001 From: LSH <104637774+lsh2613@users.noreply.github.com> Date: Sat, 30 Aug 2025 19:09:05 +0900 Subject: [PATCH 8/9] =?UTF-8?q?Chore:=20grafana=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EA=B3=84=EC=A0=95=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose-monitoring.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose-monitoring.yml b/docker-compose-monitoring.yml index 5af2c2c..a89d739 100644 --- a/docker-compose-monitoring.yml +++ b/docker-compose-monitoring.yml @@ -20,6 +20,9 @@ services: - 3000:3000 volumes: - ./monitoring/grafana/volume:/var/lib/grafana + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_USER:-rabbit} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-rabbit1234} restart: always networks: - rabbit-monitoring From ecae5984921d90d461539bc01af580a87c29b9ff Mon Sep 17 00:00:00 2001 From: LSH <104637774+lsh2613@users.noreply.github.com> Date: Sat, 30 Aug 2025 23:09:40 +0900 Subject: [PATCH 9/9] =?UTF-8?q?Feat:=20Logging=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=EA=B5=AC=EC=B6=95=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: docker-compose를 통한 ELK 스택 서비스 구성 * feat: prod 환경에서 요청 및 예외 로깅 기능 추가 * chore: logback 설정 파일 추가 및 prod 환경 파일 로깅 구성 * chore: gitignore 추가 --- .gitignore | 3 + build.gradle | 3 + docker-compose-elk.yml | 94 +++++++ elk/elasticsearch/Dockerfile | 4 + elk/elasticsearch/config/elasticsearch.yml | 12 + elk/filebeat/Dockerfile | 10 + elk/filebeat/config/filebeat.yml | 14 + elk/kibana/Dockerfile | 7 + elk/kibana/config/kibana.yml | 9 + elk/logstash/Dockerfile | 8 + elk/logstash/config/logstash.yml | 6 + elk/logstash/pipeline/logstash.conf | 52 ++++ elk/setup/Dockerfile | 6 + elk/setup/roles/logstash_writer.json | 34 +++ elk/setup/sh/entrypoint.sh | 105 ++++++++ elk/setup/sh/lib.sh | 240 ++++++++++++++++++ elk/setup/sh/tmp.sh | 24 ++ .../com/rabbitmqprac/config/WebConfig.java | 21 ++ .../aspect/GlobalExceptionLoggingAspect.java | 50 ++++ .../UndefinedExceptionLoggingAspect.java | 72 ++++++ .../global/consant/SessionType.java | 6 + .../interceptor/RequestInterceptor.java | 75 ++++++ .../rabbitmqprac/global/util/LoggingUtil.java | 54 ++++ .../global/util/RequestInfoExtractor.java | 71 ++++++ src/main/resources/application.yml | 13 - src/main/resources/logback.xml | 105 ++++++++ 26 files changed, 1085 insertions(+), 13 deletions(-) create mode 100644 docker-compose-elk.yml create mode 100644 elk/elasticsearch/Dockerfile create mode 100644 elk/elasticsearch/config/elasticsearch.yml create mode 100644 elk/filebeat/Dockerfile create mode 100644 elk/filebeat/config/filebeat.yml create mode 100644 elk/kibana/Dockerfile create mode 100644 elk/kibana/config/kibana.yml create mode 100644 elk/logstash/Dockerfile create mode 100644 elk/logstash/config/logstash.yml create mode 100644 elk/logstash/pipeline/logstash.conf create mode 100644 elk/setup/Dockerfile create mode 100644 elk/setup/roles/logstash_writer.json create mode 100755 elk/setup/sh/entrypoint.sh create mode 100644 elk/setup/sh/lib.sh create mode 100644 elk/setup/sh/tmp.sh create mode 100644 src/main/java/com/rabbitmqprac/config/WebConfig.java create mode 100644 src/main/java/com/rabbitmqprac/global/aspect/GlobalExceptionLoggingAspect.java create mode 100644 src/main/java/com/rabbitmqprac/global/aspect/UndefinedExceptionLoggingAspect.java create mode 100644 src/main/java/com/rabbitmqprac/global/consant/SessionType.java create mode 100644 src/main/java/com/rabbitmqprac/global/interceptor/RequestInterceptor.java create mode 100644 src/main/java/com/rabbitmqprac/global/util/LoggingUtil.java create mode 100644 src/main/java/com/rabbitmqprac/global/util/RequestInfoExtractor.java create mode 100644 src/main/resources/logback.xml diff --git a/.gitignore b/.gitignore index c2065bc..750a320 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ +/logs/** +/.github/git-commit-instructions.md +/monitoring/grafana/volume/** diff --git a/build.gradle b/build.gradle index 865948b..1ddcddc 100644 --- a/build.gradle +++ b/build.gradle @@ -80,6 +80,9 @@ dependencies { // monoitoring implementation("org.springframework.boot:spring-boot-starter-actuator") runtimeOnly("io.micrometer:micrometer-registry-prometheus") + + // logging + implementation 'net.logstash.logback:logstash-logback-encoder:8.1' } tasks.named('test') { diff --git a/docker-compose-elk.yml b/docker-compose-elk.yml new file mode 100644 index 0000000..0e99b3f --- /dev/null +++ b/docker-compose-elk.yml @@ -0,0 +1,94 @@ +version: "3.8" + +services: + setup: + profiles: [ setup ] + init: true + build: + context: elk/setup/ + args: + ELASTIC_VERSION: ${ELASTIC_VERSION:-8.10.2} + volumes: + - ./elk/setup/sh/entrypoint.sh:/entrypoint.sh:ro,Z + - ./elk/setup/sh/lib.sh:/lib.sh:ro,Z + - ./elk/setup/roles:/roles:ro,Z + environment: + ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-rabbit1234} + KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-rabbit1234} + LOGSTASH_USERNAME: ${LOGSTASH_USERNAME:-rabbit_logstash} + LOGSTASH_PASSWORD: ${LOGSTASH_PASSWORD:-rabbit1234} + networks: [ rabbit-elk ] + depends_on: [ elasticsearch ] + + elasticsearch: + container_name: rabbit-elasticsearch + build: + context: ./elk/elasticsearch + args: + ELASTIC_VERSION: ${ELASTIC_VERSION:-8.10.2} + volumes: + - ./elk/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro,Z + - elasticsearch:/usr/share/elasticsearch/data + ports: + - "9200:9200" + - "9300:9300" + environment: + ES_JAVA_OPTS: "-Xmx256m -Xms256m" + ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-rabbit1234} + discovery.type: single-node + networks: [ rabbit-elk ] + + logstash: + container_name: rabbit-logstash + build: + context: ./elk/logstash/ + args: + ELASTIC_VERSION: ${ELASTIC_VERSION:-8.10.2} + 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" + environment: + LS_JAVA_OPTS: "-Xmx256m -Xms256m" + LOGSTASH_USERNAME: ${LOGSTASH_USERNAME:-rabbit_logstash} + LOGSTASH_PASSWORD: ${LOGSTASH_PASSWORD:-rabbit1234} + networks: [ rabbit-elk ] + depends_on: [ elasticsearch ] + + kibana: + container_name: rabbit-kibana + build: + context: ./elk/kibana/ + args: + ELASTIC_VERSION: ${ELASTIC_VERSION:-8.10.2} + volumes: + - ./elk/kibana/config/kibana.yml:/usr/share/kibana/config/kibana.yml:ro,Z + ports: + - "5601:5601" + environment: + KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-rabbit1234} + networks: [ rabbit-elk ] + depends_on: [ elasticsearch ] + + filebeat: + container_name: rabbit-filebeat + user: root + build: + context: ./elk/filebeat/ + args: + ELASTIC_VERSION: ${ELASTIC_VERSION:-8.10.2} + volumes: + - ./logs:/logs + - ./elk/filebeat/config/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro,Z + networks: [ rabbit-elk ] + depends_on: [ logstash ] + +volumes: + elasticsearch: + +networks: + rabbit-elk: diff --git a/elk/elasticsearch/Dockerfile b/elk/elasticsearch/Dockerfile new file mode 100644 index 0000000..dacfbd8 --- /dev/null +++ b/elk/elasticsearch/Dockerfile @@ -0,0 +1,4 @@ +ARG ELASTIC_VERSION + +# https://www.docker.elastic.co/ +FROM docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION} diff --git a/elk/elasticsearch/config/elasticsearch.yml b/elk/elasticsearch/config/elasticsearch.yml new file mode 100644 index 0000000..784362a --- /dev/null +++ b/elk/elasticsearch/config/elasticsearch.yml @@ -0,0 +1,12 @@ +--- +## Default Elasticsearch configuration from Elasticsearch base image. +## https://github.com/elastic/elasticsearch/blob/master/distribution/docker/src/docker/config/elasticsearch.yml +# +cluster.name: docker-cluster +network.host: 0.0.0.0 + +xpack.license.self_generated.type: trial +xpack.security.enabled: true + +# 추후 확장 시 제거 +discovery.type: single-node diff --git a/elk/filebeat/Dockerfile b/elk/filebeat/Dockerfile new file mode 100644 index 0000000..40afca0 --- /dev/null +++ b/elk/filebeat/Dockerfile @@ -0,0 +1,10 @@ +ARG ELASTIC_VERSION + +FROM docker.elastic.co/beats/filebeat:${ELASTIC_VERSION} + +COPY config/filebeat.yml /usr/share/filebeat/filebeat.yml +#USER root +# +#RUN mkdir /var/logs +# +#RUN chown -R root /usr/share/filebeat diff --git a/elk/filebeat/config/filebeat.yml b/elk/filebeat/config/filebeat.yml new file mode 100644 index 0000000..cf432bd --- /dev/null +++ b/elk/filebeat/config/filebeat.yml @@ -0,0 +1,14 @@ +filebeat.inputs: + - type: log + enabled: true + paths: + - /logs/*.log + json.keys_under_root: true + json.add_error_key: true + json.overwrite_keys: true + +output.logstash: + hosts: ["logstash:5044"] + +setup.kibana: + host: "http://kibana:5601" diff --git a/elk/kibana/Dockerfile b/elk/kibana/Dockerfile new file mode 100644 index 0000000..9a075be --- /dev/null +++ b/elk/kibana/Dockerfile @@ -0,0 +1,7 @@ +ARG ELASTIC_VERSION + +# https://www.docker.elastic.co/ +FROM docker.elastic.co/kibana/kibana:${ELASTIC_VERSION} + +# Add your kibana plugins setup here +# Example: RUN kibana-plugin install diff --git a/elk/kibana/config/kibana.yml b/elk/kibana/config/kibana.yml new file mode 100644 index 0000000..b1cd109 --- /dev/null +++ b/elk/kibana/config/kibana.yml @@ -0,0 +1,9 @@ +server.name: kibana +server.host: 0.0.0.0 + +elasticsearch.hosts: [ "http://elasticsearch:9200" ] +elasticsearch.username: kibana_system +elasticsearch.password: ${KIBANA_SYSTEM_PASSWORD} + +monitoring.ui.container.elasticsearch.enabled: true +monitoring.ui.container.logstash.enabled: true diff --git a/elk/logstash/Dockerfile b/elk/logstash/Dockerfile new file mode 100644 index 0000000..d21770b --- /dev/null +++ b/elk/logstash/Dockerfile @@ -0,0 +1,8 @@ +ARG ELASTIC_VERSION + +# https://www.docker.elastic.co/ +FROM docker.elastic.co/logstash/logstash:${ELASTIC_VERSION} + +# Add your logstash plugins setup here +# Example: RUN logstash-plugin install logstash-filter-json +RUN logstash-plugin install logstash-filter-prune \ No newline at end of file diff --git a/elk/logstash/config/logstash.yml b/elk/logstash/config/logstash.yml new file mode 100644 index 0000000..3386cbf --- /dev/null +++ b/elk/logstash/config/logstash.yml @@ -0,0 +1,6 @@ +http.host: "0.0.0.0" + +xpack.monitoring.enabled: true +xpack.monitoring.elasticsearch.hosts: [ "http://elasticsearch:9200" ] +xpack.monitoring.elasticsearch.username: ${LOGSTASH_USERNAME} +xpack.monitoring.elasticsearch.password: ${LOGSTASH_PASSWORD} diff --git a/elk/logstash/pipeline/logstash.conf b/elk/logstash/pipeline/logstash.conf new file mode 100644 index 0000000..6a6c766 --- /dev/null +++ b/elk/logstash/pipeline/logstash.conf @@ -0,0 +1,52 @@ +input { + beats { + port => 5044 + } +} + +filter { + mutate { + add_field => { "[@metadata][korea_time]" => "%{+YYYY.MM.dd}" } + } + ruby { + code => " + event.set('[@metadata][korea_time]', LogStash::Timestamp.at(event.get('@timestamp').to_i + 9 * 60 * 60).time.strftime('%Y.%m.%d')) + " + } + prune { + blacklist_names => [ + "^ecs\\.", + "^host\\.", + "^log\\.file", + "^log\\.offset$", + "^event\\.original", + ".*\\.keyword$" + ] + } +} + +output { + if [level] == "ERROR" { + elasticsearch { + hosts => ["http://elasticsearch:9200"] + index => "logstash-error-%{[@metadata][korea_time]}" + user => "${LOGSTASH_USERNAME}" + password => "${LOGSTASH_PASSWORD}" + } + } else if [level] == "WARN" { + elasticsearch { + hosts => ["http://elasticsearch:9200"] + index => "logstash-warn-%{[@metadata][korea_time]}" + user => "${LOGSTASH_USERNAME}" + password => "${LOGSTASH_PASSWORD}" + } + } else { + elasticsearch { + hosts => ["http://elasticsearch:9200"] + index => "logstash-info-%{[@metadata][korea_time]}" + user => "${LOGSTASH_USERNAME}" + password => "${LOGSTASH_PASSWORD}" + } + } +} + diff --git a/elk/setup/Dockerfile b/elk/setup/Dockerfile new file mode 100644 index 0000000..8c76dcb --- /dev/null +++ b/elk/setup/Dockerfile @@ -0,0 +1,6 @@ +ARG ELASTIC_VERSION + +# https://www.docker.elastic.co/ +FROM docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION:-9.0.2} + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/elk/setup/roles/logstash_writer.json b/elk/setup/roles/logstash_writer.json new file mode 100644 index 0000000..a423685 --- /dev/null +++ b/elk/setup/roles/logstash_writer.json @@ -0,0 +1,34 @@ +{ + "cluster": [ + "manage_index_templates", + "monitor", + "manage_ilm", + "manage" + ], + "indices": [ + { + "names": [ + "logs-generic-default", + "logstash-*", + "ecs-logstash-*" + ], + "privileges": [ + "write", + "create", + "create_index", + "manage", + "manage_ilm" + ] + }, + { + "names": [ + "logstash", + "ecs-logstash" + ], + "privileges": [ + "write", + "manage" + ] + } + ] +} \ No newline at end of file diff --git a/elk/setup/sh/entrypoint.sh b/elk/setup/sh/entrypoint.sh new file mode 100755 index 0000000..98e41fa --- /dev/null +++ b/elk/setup/sh/entrypoint.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +set -eu +set -o pipefail + +source "${BASH_SOURCE[0]%/*}"/lib.sh + + +# -------------------------------------------------------- +# Users declarations + +declare -A users_passwords +users_passwords=( + [${LOGSTASH_USERNAME}]="${LOGSTASH_PASSWORD}" + [kibana_system]="${KIBANA_SYSTEM_PASSWORD:-}" +) + +declare -A users_roles +users_roles=( + [${LOGSTASH_USERNAME}]='logstash_writer' +) + +# -------------------------------------------------------- +# Roles declarations + +declare -A roles_files +roles_files=( + [logstash_writer]='logstash_writer.json' +) +# -------------------------------------------------------- + +log 'Waiting for availability of Elasticsearch. This can take several minutes.' + +declare -i exit_code=0 +wait_for_elasticsearch || exit_code=$? + +if ((exit_code)); then + case $exit_code in + 6) + suberr 'Could not resolve host. Is Elasticsearch running?' + ;; + 7) + suberr 'Failed to connect to host. Is Elasticsearch healthy?' + ;; + 28) + suberr 'Timeout connecting to host. Is Elasticsearch healthy?' + ;; + *) + suberr "Connection to Elasticsearch failed. Exit code: ${exit_code}" + ;; + esac + + exit $exit_code +fi + +sublog 'Elasticsearch is running' + +log 'Waiting for initialization of built-in users' + +wait_for_builtin_users || exit_code=$? + +if ((exit_code)); then + suberr 'Timed out waiting for condition' + exit $exit_code +fi + +sublog 'Built-in users were initialized' + +for role in "${!roles_files[@]}"; do + log "Role '$role'" + + declare body_file + body_file="${BASH_SOURCE[0]%/*}/roles/${roles_files[$role]:-}" + if [[ ! -f "${body_file:-}" ]]; then + sublog "No role body found at '${body_file}', skipping" + continue + fi + + sublog 'Creating/updating' + ensure_role "$role" "$(<"${body_file}")" +done + +for user in "${!users_passwords[@]}"; do + log "User '$user'" + if [[ -z "${users_passwords[$user]:-}" ]]; then + sublog 'No password defined, skipping' + continue + fi + + declare -i user_exists=0 + user_exists="$(check_user_exists "$user")" + + if ((user_exists)); then + sublog 'User exists, setting password' + set_user_password "$user" "${users_passwords[$user]}" + else + if [[ -z "${users_roles[$user]:-}" ]]; then + suberr ' No role defined, skipping creation' + continue + fi + + sublog 'User does not exist, creating' + create_user "$user" "${users_passwords[$user]}" "${users_roles[$user]}" + fi +done \ No newline at end of file diff --git a/elk/setup/sh/lib.sh b/elk/setup/sh/lib.sh new file mode 100644 index 0000000..f13a8b3 --- /dev/null +++ b/elk/setup/sh/lib.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash + +# Log a message. +function log { + echo "[+] $1" +} + +# Log a message at a sub-level. +function sublog { + echo " ⠿ $1" +} + +# Log an error. +function err { + echo "[x] $1" >&2 +} + +# Log an error at a sub-level. +function suberr { + echo " ⠍ $1" >&2 +} + +# Poll the 'elasticsearch' service until it responds with HTTP code 200. +function wait_for_elasticsearch { + local elasticsearch_host="${ELASTICSEARCH_HOST:-elasticsearch}" + + local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}' "http://${elasticsearch_host}:9200/" ) + + if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then + args+=( '-u' "elastic:${ELASTIC_PASSWORD}" ) + fi + + local -i result=1 + local output + + # retry for max 300s (60*5s) + for _ in $(seq 1 60); do + local -i exit_code=0 + output="$(curl "${args[@]}")" || exit_code=$? + + if ((exit_code)); then + result=$exit_code + fi + + if [[ "${output: -3}" -eq 200 ]]; then + result=0 + break + fi + + sleep 5 + done + + if ((result)) && [[ "${output: -3}" -ne 000 ]]; then + echo -e "\n${output::-3}" + fi + + return $result +} + +# Poll the Elasticsearch users API until it returns users. +function wait_for_builtin_users { + local elasticsearch_host="${ELASTICSEARCH_HOST:-elasticsearch}" + + local -a args=( '-s' '-D-' '-m15' "http://${elasticsearch_host}:9200/_security/user?pretty" ) + + if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then + args+=( '-u' "elastic:${ELASTIC_PASSWORD}" ) + fi + + local -i result=1 + + local line + local -i exit_code + local -i num_users + + # retry for max 30s (30*1s) + for _ in $(seq 1 30); do + num_users=0 + + # read exits with a non-zero code if the last read input doesn't end + # with a newline character. The printf without newline that follows the + # curl command ensures that the final input not only contains curl's + # exit code, but causes read to fail so we can capture the return value. + # Ref. https://unix.stackexchange.com/a/176703/152409 + while IFS= read -r line || ! exit_code="$line"; do + if [[ "$line" =~ _reserved.+true ]]; then + (( num_users++ )) + fi + done < <(curl "${args[@]}"; printf '%s' "$?") + + if ((exit_code)); then + result=$exit_code + fi + + # we expect more than just the 'elastic' user in the result + if (( num_users > 1 )); then + result=0 + break + fi + + sleep 1 + done + + return $result +} + +# Verify that the given Elasticsearch user exists. +function check_user_exists { + local username=$1 + + local elasticsearch_host="${ELASTICSEARCH_HOST:-elasticsearch}" + + local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}' + "http://${elasticsearch_host}:9200/_security/user/${username}" + ) + + if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then + args+=( '-u' "elastic:${ELASTIC_PASSWORD}" ) + fi + + local -i result=1 + local -i exists=0 + local output + + output="$(curl "${args[@]}")" + if [[ "${output: -3}" -eq 200 || "${output: -3}" -eq 404 ]]; then + result=0 + fi + if [[ "${output: -3}" -eq 200 ]]; then + exists=1 + fi + + if ((result)); then + echo -e "\n${output::-3}" + else + echo "$exists" + fi + + return $result +} + +# Set password of a given Elasticsearch user. +function set_user_password { + local username=$1 + local password=$2 + + local elasticsearch_host="${ELASTICSEARCH_HOST:-elasticsearch}" + + local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}' + "http://${elasticsearch_host}:9200/_security/user/${username}/_password" + '-X' 'POST' + '-H' 'Content-Type: application/json' + '-d' "{\"password\" : \"${password}\"}" + ) + + if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then + args+=( '-u' "elastic:${ELASTIC_PASSWORD}" ) + fi + + local -i result=1 + local output + + output="$(curl "${args[@]}")" + if [[ "${output: -3}" -eq 200 ]]; then + result=0 + fi + + if ((result)); then + echo -e "\n${output::-3}\n" + fi + + return $result +} + +# Create the given Elasticsearch user. +function create_user { + local username=$1 + local password=$2 + local role=$3 + + local elasticsearch_host="${ELASTICSEARCH_HOST:-elasticsearch}" + + local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}' + "http://${elasticsearch_host}:9200/_security/user/${username}" + '-X' 'POST' + '-H' 'Content-Type: application/json' + '-d' "{\"password\":\"${password}\",\"roles\":[\"${role}\"]}" + ) + + if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then + args+=( '-u' "elastic:${ELASTIC_PASSWORD}" ) + fi + + local -i result=1 + local output + + output="$(curl "${args[@]}")" + if [[ "${output: -3}" -eq 200 ]]; then + result=0 + fi + + if ((result)); then + echo -e "\n${output::-3}\n" + fi + + return $result +} + +# Ensure that the given Elasticsearch role is up-to-date, create it if required. +function ensure_role { + local name=$1 + local body=$2 + + local elasticsearch_host="${ELASTICSEARCH_HOST:-elasticsearch}" + + local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}' + "http://${elasticsearch_host}:9200/_security/role/${name}" + '-X' 'POST' + '-H' 'Content-Type: application/json' + '-d' "$body" + ) + + if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then + args+=( '-u' "elastic:${ELASTIC_PASSWORD}" ) + fi + + local -i result=1 + local output + + output="$(curl "${args[@]}")" + if [[ "${output: -3}" -eq 200 ]]; then + result=0 + fi + + if ((result)); then + echo -e "\n${output::-3}\n" + fi + + return $result +} \ No newline at end of file diff --git a/elk/setup/sh/tmp.sh b/elk/setup/sh/tmp.sh new file mode 100644 index 0000000..c6a5c1e --- /dev/null +++ b/elk/setup/sh/tmp.sh @@ -0,0 +1,24 @@ +# +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/src/main/java/com/rabbitmqprac/config/WebConfig.java b/src/main/java/com/rabbitmqprac/config/WebConfig.java new file mode 100644 index 0000000..9ab3bc9 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/config/WebConfig.java @@ -0,0 +1,21 @@ +package com.rabbitmqprac.config; + +import com.rabbitmqprac.global.interceptor.RequestInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Profile("prod") +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + private final RequestInterceptor requestInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(requestInterceptor); + } + +} diff --git a/src/main/java/com/rabbitmqprac/global/aspect/GlobalExceptionLoggingAspect.java b/src/main/java/com/rabbitmqprac/global/aspect/GlobalExceptionLoggingAspect.java new file mode 100644 index 0000000..b42009a --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/aspect/GlobalExceptionLoggingAspect.java @@ -0,0 +1,50 @@ +package com.rabbitmqprac.global.aspect; + +import com.rabbitmqprac.global.exception.GlobalErrorException; +import com.rabbitmqprac.global.exception.payload.BaseErrorCode; +import com.rabbitmqprac.global.exception.payload.CausedBy; +import com.rabbitmqprac.global.util.LoggingUtil; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.MDC; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Profile("prod") +@Slf4j +@Aspect +@Component +public class GlobalExceptionLoggingAspect { + + @Pointcut("execution(public * com.rabbitmqprac.application.controller..*.*(..))") + private void logPointcut() { + } + + @AfterThrowing(value = "logPointcut()", throwing = "ex") + public void logAfterThrowing(JoinPoint joinPoint, GlobalErrorException ex) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String className = signature.getDeclaringType().getSimpleName(); + + List arguments = LoggingUtil.getArguments(joinPoint); + String parameterMessage = LoggingUtil.getParameterMessage(arguments); + + BaseErrorCode errorCode = ex.getErrorCode(); + CausedBy causedBy = errorCode.causedBy(); + + MDC.put("httpStatus", String.valueOf(causedBy.statusCode().getCode())); + + log.error("[ERROR] POINT : {} || ERROR CODE : {} || ARGUMENTS : {}", + className, causedBy.getCode(), parameterMessage + ); + log.error("[ERROR] FINAL POINT : {}", ex.getStackTrace()[0]); + log.error("[ERROR] MESSAGE : {}", ex.getMessage()); + + MDC.remove("httpStatus"); + } +} diff --git a/src/main/java/com/rabbitmqprac/global/aspect/UndefinedExceptionLoggingAspect.java b/src/main/java/com/rabbitmqprac/global/aspect/UndefinedExceptionLoggingAspect.java new file mode 100644 index 0000000..1b7ebd1 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/aspect/UndefinedExceptionLoggingAspect.java @@ -0,0 +1,72 @@ +package com.rabbitmqprac.global.aspect; + +import com.rabbitmqprac.global.exception.GlobalErrorException; +import com.rabbitmqprac.global.util.LoggingUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.MDC; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.List; + +@Profile("prod") +@Slf4j +@Aspect +@RequiredArgsConstructor +@Component +public class UndefinedExceptionLoggingAspect { + + @Pointcut("execution(public * com.rabbitmqprac.application.controller..*.*(..))") + private void logPointcut() { + } + + @AfterThrowing(value = "logPointcut()", throwing = "exception") + public void logAfterThrowing(JoinPoint joinPoint, Exception exception) { + if (exception instanceof GlobalErrorException) return; + + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String className = signature.getDeclaringType().getSimpleName(); + + String parameterMessage = getParameterMessage(joinPoint); + + MDC.put("httpStatus", "500"); + + Throwable cause = exception.getCause(); + String stackTrace = getStackTrace(exception); + + log.error("[SERVER ERROR] POINT : {} || ARGUMENTS : {}", className, parameterMessage); + log.error("[SERVER ERROR] MESSAGE : {}", exception.getMessage()); + log.error("[SERVER ERROR] CAUSE : {}", cause != null ? cause.toString() : "No cause available" + ); + log.error("[SERVER ERROR] FINAL POINT : {}", + (stackTrace != null && stackTrace.length() > 0) ? getFirstLine(stackTrace) : "No stack trace available" + ); + + MDC.remove("httpStatus"); + } + + private static String getParameterMessage(JoinPoint joinPoint) { + List arguments = LoggingUtil.getArguments(joinPoint); + return LoggingUtil.getParameterMessage(arguments); + } + + private static String getFirstLine(String stackTrace) { + return stackTrace.split("\n")[0]; + } + + private static String getStackTrace(Exception exception) { + StringWriter stackTraceWriter = new StringWriter(); + exception.printStackTrace(new PrintWriter(stackTraceWriter)); + String stackTrace = stackTraceWriter.toString(); + return stackTrace; + } +} + diff --git a/src/main/java/com/rabbitmqprac/global/consant/SessionType.java b/src/main/java/com/rabbitmqprac/global/consant/SessionType.java new file mode 100644 index 0000000..e44e46b --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/consant/SessionType.java @@ -0,0 +1,6 @@ +package com.rabbitmqprac.global.consant; + +public final class SessionType { + public static final String START_TIME = "startTime"; + public static final String REQUEST_ID = "requestId"; +} diff --git a/src/main/java/com/rabbitmqprac/global/interceptor/RequestInterceptor.java b/src/main/java/com/rabbitmqprac/global/interceptor/RequestInterceptor.java new file mode 100644 index 0000000..0c9fe9b --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/interceptor/RequestInterceptor.java @@ -0,0 +1,75 @@ +package com.rabbitmqprac.global.interceptor; + +import com.rabbitmqprac.global.consant.SessionType; +import com.rabbitmqprac.global.util.RequestInfoExtractor; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.context.annotation.Profile; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Objects; +import java.util.UUID; + +@Profile("prod") +@Slf4j +@RequiredArgsConstructor +@Component +public class RequestInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) { + String requestId = UUID.randomUUID().toString(); + request.setAttribute(SessionType.REQUEST_ID, requestId); + request.setAttribute(SessionType.START_TIME, System.currentTimeMillis()); + logPreRequest(requestId, request.getRequestURI(), request.getMethod()); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + long turnaroundTimeSec = getTurnaroundTimeSec(request); + + completeRequest(response.getStatus(), turnaroundTimeSec, ex); + } + + private void logPreRequest(String requestId, String requestURI, String requestMethod) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String userId = (authentication == null || Objects.equals(authentication.getName(), "anonymousUser")) ? "anonymous" : authentication.getName(); + String clientIp = RequestInfoExtractor.getClientIpAddressIfServletRequestExist(); + + MDC.put("requestId", requestId); + MDC.put("userId", userId); + MDC.put("clientIp", clientIp); + MDC.put("requestURI", requestURI); + MDC.put("requestMethod", requestMethod); + + log.info("HTTP request started"); + } + + private void completeRequest(int status, long turnaroundTime, Exception ex) { + MDC.put("httpStatus", String.valueOf(status)); + MDC.put("turnaroundTime(sec)", String.valueOf(turnaroundTime)); + + if (ex == null) { + log.info("HTTP request completed"); + } else { + log.error("HTTP request failed", ex); + } + + MDC.clear(); + } + + private static long getTurnaroundTimeSec(HttpServletRequest request) { + long startTime = (Long) request.getAttribute(SessionType.START_TIME); + long turnaroundTimeSec = (System.currentTimeMillis() - startTime) / 1000; + return turnaroundTimeSec; + } + +} diff --git a/src/main/java/com/rabbitmqprac/global/util/LoggingUtil.java b/src/main/java/com/rabbitmqprac/global/util/LoggingUtil.java new file mode 100644 index 0000000..0e19ba6 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/util/LoggingUtil.java @@ -0,0 +1,54 @@ +package com.rabbitmqprac.global.util; + +import com.rabbitmqprac.global.annotation.Util; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +@Slf4j +@Util +public final class LoggingUtil { + + public static List getArguments(JoinPoint joinPoint) { + return Arrays.stream(joinPoint.getArgs()) + .map(LoggingUtil::getObjectFields) + .toList(); + } + + private static String getObjectFields(Object obj) { + if (Objects.isNull(obj)) { + return "null"; + } + + StringBuilder result = new StringBuilder(); + Class objClass = obj.getClass(); + result.append(objClass.getSimpleName()).append(" {"); + + Field[] fields = objClass.getDeclaredFields(); + for (int i = 0; i < fields.length; i++) { + fields[i].setAccessible(true); + try { + result.append(fields[i].getName()).append(" = ") + .append(fields[i].get(obj)); + } catch (IllegalAccessException e) { + result.append(fields[i].getName()).append("=ACCESS_DENIED"); + } + if (i < fields.length - 1) { + result.append(", "); + } + } + result.append("}"); + return result.toString(); + } + + public static String getParameterMessage(List arguments) { + if (arguments == null) + return ""; + + return String.join(" | ", arguments); + } +} diff --git a/src/main/java/com/rabbitmqprac/global/util/RequestInfoExtractor.java b/src/main/java/com/rabbitmqprac/global/util/RequestInfoExtractor.java new file mode 100644 index 0000000..6d7dee0 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/global/util/RequestInfoExtractor.java @@ -0,0 +1,71 @@ +package com.rabbitmqprac.global.util; + +import com.rabbitmqprac.global.annotation.Util; +import com.rabbitmqprac.global.consant.SessionType; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.logging.log4j.util.Strings; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Util +public final class RequestInfoExtractor { + private static final String[] IP_HEADER_CANDIDATES = { + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_X_FORWARDED_FOR", + "HTTP_X_FORWARDED", + "HTTP_X_CLUSTER_CLIENT_IP", + "HTTP_CLIENT_IP", + "HTTP_FORWARDED_FOR", + "HTTP_FORWARDED", + "HTTP_VIA", + "REMOTE_ADDR" + }; + + public static String getClientIpAddressIfServletRequestExist() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes == null) { + return "0.0.0.0"; + } + HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest(); + for (String header : IP_HEADER_CANDIDATES) { + String ipList = request.getHeader(header); + if (ipList != null && !ipList.isEmpty() && !"unknown".equalsIgnoreCase(ipList)) { + return ipList.split(",")[0]; + } + } + return request.getRemoteAddr(); + } + + public static String getFullPath() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes == null) { + return ""; + } + HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest(); + return extractFullPath(request); + } + + private static String extractFullPath(HttpServletRequest request) { + String path = request.getMethod() + " " + request.getRequestURL(); + + String queryString = request.getQueryString(); + if (queryString != null) { + path += "?" + queryString; + } + + return path; + } + + public static String getRequestIdInSession() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes == null) { + return Strings.EMPTY; + } + + HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest(); + return (String) request.getAttribute(SessionType.REQUEST_ID); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 161b22e..563e9d0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -112,16 +112,3 @@ rabbitmq: # swagger에서 자동으로 error response를 생성하지 않도록 설정 springdoc: override-with-generic-response: false - -logging: - level: - ROOT: INFO - org.hibernate: DEBUG - org.hibernate.type.descriptor.sql.BasicBinder: TRACE - org.hibernate.sql: debug - org.hibernate.type: trace - com.zaxxer.hikari.HikariConfig: DEBUG - org.springframework.orm: TRACE - org.springframework.transaction: TRACE - com.zaxxer.hikari: TRACE - com.mysql.cj.jdbc: TRACE diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..e7fa277 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + %clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr([%t]){magenta} %clr(%-40.40logger{39}){cyan} : %msg%n + + + + + + + + + + + + + ${LOG_FILE_INFO} + + ${LOG_PATH}/info.%d{yyyy-MM-dd}.%i.log + 100KB + 30 + + + INFO + ACCEPT + DENY + + + + + + + + + + + + + + + ${LOG_FILE_WARN} + + ${LOG_PATH}/warn.%d{yyyy-MM-dd}.%i.log + 1MB + 30 + + + WARN + ACCEPT + DENY + + + + + + + + + + + + + + + ${LOG_FILE_ERROR} + + ${LOG_PATH}/error.%d{yyyy-MM-dd}.%i.log + 1MB + 30 + + + ERROR + + + + + + + + + + + + + + + + + + + + + + +